feat(ui): add BotsScreen and BotsViewModel with active presence strip (#977)
This commit is contained in:
8 files changed
+699
No files matched your search
@@ -21,6 +21,8 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable data object ProfilesScreen : NavKey
|
||||
|
||||
@Serializable data object BotsScreen : NavKey
|
||||
|
||||
@Serializable data object ToolsetsScreen : NavKey
|
||||
|
||||
@Serializable data class ToolsetDetailKey(
|
||||
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.Psychology
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material.icons.filled.Sensors
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.SmartToy
|
||||
import androidx.compose.material.icons.filled.ViewKanban
|
||||
import androidx.compose.material.icons.filled.Webhook
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -34,6 +35,7 @@ import com.m57.hermescontrol.ui.common.NeurologyIcon
|
||||
import com.m57.hermescontrol.ui.achievements.AchievementsScreen as AchievementsScreenContent
|
||||
import com.m57.hermescontrol.ui.analytics.AnalyticsScreen as AnalyticsScreenContent
|
||||
import com.m57.hermescontrol.ui.billing.BillingScreen as BillingScreenContent
|
||||
import com.m57.hermescontrol.ui.bots.BotsScreen as BotsScreenContent
|
||||
import com.m57.hermescontrol.ui.channels.ChannelsScreen as ChannelsScreenContent
|
||||
import com.m57.hermescontrol.ui.chat.ChatScreen as ChatScreenContent
|
||||
import com.m57.hermescontrol.ui.config.ConfigScreen as ConfigScreenContent
|
||||
@@ -96,6 +98,12 @@ object ScreenRegistry {
|
||||
Icons.Filled.AccountCircle,
|
||||
DrawerSection.CONVERSE,
|
||||
) { sessionId, openDrawer -> ProfilesScreenContent(onOpenDrawer = openDrawer) },
|
||||
ScreenDefinition(
|
||||
BotsScreen,
|
||||
R.string.screen_bots,
|
||||
Icons.Filled.SmartToy,
|
||||
DrawerSection.CONVERSE,
|
||||
) { sessionId, openDrawer -> BotsScreenContent(onOpenDrawer = openDrawer) },
|
||||
ScreenDefinition(
|
||||
CronJobsScreen,
|
||||
R.string.screen_cron,
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
package com.m57.hermescontrol.ui.bots
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.NavigationController
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.ProfileInfo
|
||||
import com.m57.hermescontrol.ui.common.BotAvatar
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||
import com.m57.hermescontrol.ui.common.NavIcon
|
||||
import com.m57.hermescontrol.ui.common.SkeletonListState
|
||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BotsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onOpenDrawer: (() -> Unit)? = null,
|
||||
viewModel: BotsViewModel = viewModel { BotsViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var isSearchActive by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
ToastEffect(
|
||||
toastMessage = state.toastMessage,
|
||||
onClearToast = { viewModel.clearToast() },
|
||||
)
|
||||
|
||||
HermesScaffold(
|
||||
modifier = modifier,
|
||||
title = {
|
||||
if (isSearchActive) {
|
||||
OutlinedTextField(
|
||||
value = state.searchQuery,
|
||||
onValueChange = { viewModel.setSearchQuery(it) },
|
||||
placeholder = { Text(stringResource(R.string.bots_search_placeholder)) },
|
||||
singleLine = true,
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (state.searchQuery.isNotEmpty()) {
|
||||
viewModel.setSearchQuery("")
|
||||
} else {
|
||||
isSearchActive = false
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = stringResource(R.string.action_cancel),
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("bots_search_field"),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(R.string.screen_bots),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
actions = {
|
||||
if (!isSearchActive) {
|
||||
IconButton(
|
||||
onClick = { isSearchActive = true },
|
||||
modifier = Modifier.testTag("bots_action_search"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
contentDescription = stringResource(R.string.bots_search_placeholder),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.hasHiddenBots) {
|
||||
IconButton(
|
||||
onClick = { viewModel.toggleShowHidden() },
|
||||
modifier = Modifier.testTag("bots_action_toggle_hidden"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (state.showHidden) {
|
||||
Icons.Filled.VisibilityOff
|
||||
} else {
|
||||
Icons.Filled.Visibility
|
||||
},
|
||||
contentDescription =
|
||||
if (state.showHidden) {
|
||||
stringResource(R.string.bots_hide_hidden)
|
||||
} else {
|
||||
stringResource(R.string.bots_show_hidden)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
isRefreshing = state.isRefreshing,
|
||||
onRefresh = { viewModel.loadBots(isRefresh = true) },
|
||||
) {
|
||||
when {
|
||||
state.isLoading && state.profiles.isEmpty() -> {
|
||||
SkeletonListState()
|
||||
}
|
||||
|
||||
state.errorMessage != null && state.profiles.isEmpty() -> {
|
||||
ErrorState(
|
||||
message = state.errorMessage ?: "",
|
||||
onRetry = { viewModel.loadBots() },
|
||||
)
|
||||
}
|
||||
|
||||
state.displayProfiles.isEmpty() -> {
|
||||
EmptyState(
|
||||
title = stringResource(R.string.bots_empty_title),
|
||||
subtitle = stringResource(R.string.bots_empty_description),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (state.activeNowBots.isNotEmpty() && !isSearchActive) {
|
||||
item(key = "active_now_strip") {
|
||||
ActiveNowStrip(
|
||||
activeBots = state.activeNowBots,
|
||||
onSelectBot = { bot ->
|
||||
scope.launch {
|
||||
viewModel.selectBot(bot)
|
||||
val canonicalId =
|
||||
bot.canonical_session?.resolved_id
|
||||
?: bot.canonical_session?.id
|
||||
if (!canonicalId.isNullOrBlank()) {
|
||||
NavigationController.openChatSession(canonicalId)
|
||||
} else {
|
||||
NavigationController.navigateTo(com.m57.hermescontrol.ChatScreen)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(
|
||||
items = state.displayProfiles,
|
||||
key = { it.name },
|
||||
) { profile ->
|
||||
BotCard(
|
||||
profile = profile,
|
||||
isActiveProfile = profile.name == state.activeProfileName,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
viewModel.selectBot(profile)
|
||||
val canonicalId =
|
||||
profile.canonical_session?.resolved_id
|
||||
?: profile.canonical_session?.id
|
||||
if (!canonicalId.isNullOrBlank()) {
|
||||
NavigationController.openChatSession(canonicalId)
|
||||
} else {
|
||||
NavigationController.navigateTo(com.m57.hermescontrol.ChatScreen)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveNowStrip(
|
||||
activeBots: List<ProfileInfo>,
|
||||
onSelectBot: (ProfileInfo) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(R.string.bots_active_now),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(bottom = 6.dp),
|
||||
)
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
activeBots.forEach { bot ->
|
||||
Surface(
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.clickable { onSelectBot(bot) }
|
||||
.testTag("bot_active_chip_${bot.name}"),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
BotAvatar(
|
||||
name = bot.name,
|
||||
avatar = bot.botMeta()?.avatar,
|
||||
size = 24.dp,
|
||||
isActive = true,
|
||||
showPresence = true,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = bot.effectiveTitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BotCard(
|
||||
profile: ProfileInfo,
|
||||
isActiveProfile: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val botMeta = profile.botMeta()
|
||||
val isHidden = profile.isHidden
|
||||
val hasWorker = profile.worker_session != null
|
||||
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (isActiveProfile) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
},
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.testTag("bot_card_${profile.name}"),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
BotAvatar(
|
||||
name = profile.name,
|
||||
avatar = botMeta?.avatar,
|
||||
size = 44.dp,
|
||||
isActive = isActiveProfile || hasWorker,
|
||||
showPresence = true,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = profile.effectiveTitle,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (isHidden) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.bots_hidden_badge),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (profile.effectiveTitle != profile.name) {
|
||||
Text(
|
||||
text = "@${profile.name}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (profile.effectiveDescription.isNotBlank()) {
|
||||
Text(
|
||||
text = profile.effectiveDescription,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
val preview = profile.canonical_session?.preview ?: profile.last_session?.preview
|
||||
if (!preview.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = preview,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.m57.hermescontrol.ui.bots
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.model.ProfileInfo
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class BotsUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val isRefreshing: Boolean = false,
|
||||
val profiles: List<ProfileInfo> = emptyList(),
|
||||
val activeProfileName: String? = null,
|
||||
val searchQuery: String = "",
|
||||
val showHidden: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val toastMessage: String? = null,
|
||||
) {
|
||||
val hasHiddenBots: Boolean
|
||||
get() = profiles.any { it.isHidden }
|
||||
|
||||
val activeNowBots: List<ProfileInfo>
|
||||
get() {
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
return profiles.filter { profile ->
|
||||
profile.worker_session != null ||
|
||||
profile.name == activeProfileName ||
|
||||
((profile.canonical_session?.last_active ?: 0L) > nowSeconds - 90) ||
|
||||
((profile.last_session?.last_active ?: 0L) > nowSeconds - 90)
|
||||
}
|
||||
}
|
||||
|
||||
val displayProfiles: List<ProfileInfo>
|
||||
get() {
|
||||
val query = searchQuery.trim().lowercase()
|
||||
return profiles
|
||||
.filter { profile ->
|
||||
if (!showHidden && profile.isHidden) return@filter false
|
||||
if (query.isBlank()) return@filter true
|
||||
profile.name.lowercase().contains(query) ||
|
||||
profile.effectiveTitle.lowercase().contains(query) ||
|
||||
profile.effectiveDescription.lowercase().contains(query)
|
||||
}
|
||||
.sortedWith(
|
||||
compareByDescending<ProfileInfo> { it.name == activeProfileName }
|
||||
.thenByDescending {
|
||||
it.canonical_session?.last_active
|
||||
?: it.last_session?.last_active
|
||||
?: 0L
|
||||
}
|
||||
.thenBy { it.name },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class BotsViewModel(
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
autoLoad: Boolean = true,
|
||||
) : ViewModel(),
|
||||
ToastHost {
|
||||
private val _uiState = MutableStateFlow(BotsUiState())
|
||||
val uiState: StateFlow<BotsUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
if (autoLoad) {
|
||||
loadBots()
|
||||
}
|
||||
}
|
||||
|
||||
fun loadBots(isRefresh: Boolean = false) {
|
||||
_uiState.update {
|
||||
if (isRefresh) {
|
||||
it.copy(isRefreshing = true, errorMessage = null)
|
||||
} else {
|
||||
it.copy(isLoading = true, errorMessage = null)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
val profilesResult = safeApiCall { ApiClient.hermesApi.getProfiles() }
|
||||
val activeResult = safeApiCall { ApiClient.hermesApi.getActiveProfile() }
|
||||
|
||||
if (profilesResult is NetworkResult.Success) {
|
||||
val activeName = (activeResult as? NetworkResult.Success)?.data?.active
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
profiles = profilesResult.data.profiles.orEmpty(),
|
||||
activeProfileName = activeName ?: it.activeProfileName,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val err =
|
||||
(profilesResult as? NetworkResult.Failure)?.error?.message
|
||||
?: "Failed to load bots"
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
errorMessage = err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setSearchQuery(query: String) {
|
||||
_uiState.update { it.copy(searchQuery = query) }
|
||||
}
|
||||
|
||||
fun toggleShowHidden() {
|
||||
_uiState.update { it.copy(showHidden = !it.showHidden) }
|
||||
}
|
||||
|
||||
suspend fun selectBot(bot: ProfileInfo): Boolean {
|
||||
val result = ProfileSwitchCoordinator.switchProfile(bot.name)
|
||||
return result is NetworkResult.Success
|
||||
}
|
||||
|
||||
fun showToast(message: String) {
|
||||
_uiState.update { it.copy(toastMessage = message) }
|
||||
}
|
||||
|
||||
override fun clearToast() {
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user