diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
index 111c4a7..fb6e188 100644
--- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
@@ -180,10 +180,28 @@
Дата регистрации
О себе
Официальный аккаунт
- Это официальный аккаунт
- Нажмите, чтобы сделать аккаунт официальным (только админы)
- Сделать официальным
- Снять официальный статус
+ Этот аккаунт - официальное лицо FromChat.
+ Действия администратора
+ Подтвердить
+ Отменить подтверждение
+ Заблокировать аккаунт
+ Разблокировать аккаунт
+ Удалить аккаунт
+ Подтвердить аккаунт?
+ Отметить этот аккаунт как официальное лицо FromChat?
+ Снять подтверждение?
+ Убрать официальное подтверждение с этого аккаунта?
+ Заблокировать аккаунт
+ Укажите причину блокировки этого аккаунта:
+ Причина
+ Заблокировать
+ Разблокировать аккаунт?
+ Вернуть этому аккаунту возможность отправлять сообщения?
+ Удалить аккаунт
+ Данные пользователя будут удалены навсегда, но сообщения и переписки сохранятся. Если пользователь в сети, он будет сразу разлогинен. Это действие нельзя отменить.
+ Удалить
+ Подтвердить
+ Отменить подтверждение
Официальный аккаунт
Аккаунт заблокирован
Похож на официальный аккаунт
diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml
index f1f7ab7..cb8d879 100644
--- a/app/shared/src/commonMain/composeResources/values/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values/strings.xml
@@ -198,8 +198,28 @@
Joined
About
Verified account
- This account is verified
- Tap to verify (admins only)
+ This account is an official FromChat representative.
+
+
+ Admin actions
+ Verify
+ Remove verification
+ Suspend account
+ Unsuspend account
+ Delete account
+ Verify account?
+ Mark this account as an official FromChat representative?
+ Remove verification?
+ Remove official verification from this account?
+ Suspend account
+ Enter the reason for suspending this account:
+ Reason
+ Suspend
+ Unsuspend account?
+ Restore this account’s ability to send messages?
+ Delete account
+ This will permanently delete user data but preserve messages and conversations. If the user is online, they will be immediately logged out. This action cannot be undone.
+ Delete
Verify
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
index 91d71b4..51cc3b8 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
@@ -108,6 +108,7 @@ import ru.fromchat.api.schema.user.devices.DevicesListResponse
import ru.fromchat.api.schema.user.keys.BackupBlobRequest
import ru.fromchat.api.schema.user.keys.BackupBlobResponse
import ru.fromchat.api.schema.user.keys.PublicKeyResponse
+import ru.fromchat.api.schema.user.profile.SuspendUserRequest
import ru.fromchat.api.schema.user.profile.UpdateProfileRequest
import ru.fromchat.api.schema.user.profile.UpdateProfileResponse
import ru.fromchat.api.schema.user.profile.UserProfile
@@ -637,6 +638,34 @@ object ApiClient {
.body()
}.getOrNull()
+ suspend fun suspendUser(userId: Int, reason: String): SimpleStatusResponse? =
+ runCatching {
+ http
+ .post("${ServerConfig.apiBaseUrl}/user/$userId/suspend") {
+ contentType(ContentType.Application.Json)
+ setBody(SuspendUserRequest(reason = reason))
+ }
+ .body()
+ }.getOrNull()
+
+ suspend fun unsuspendUser(userId: Int): SimpleStatusResponse? =
+ runCatching {
+ http
+ .post("${ServerConfig.apiBaseUrl}/user/$userId/unsuspend") {
+ contentType(ContentType.Application.Json)
+ }
+ .body()
+ }.getOrNull()
+
+ suspend fun adminDeleteUser(userId: Int): SimpleStatusResponse? =
+ runCatching {
+ http
+ .post("${ServerConfig.apiBaseUrl}/user/$userId/delete") {
+ contentType(ContentType.Application.Json)
+ }
+ .body()
+ }.getOrNull()
+
suspend fun getDmConversations(): List =
http
.get("${ServerConfig.apiBaseUrl}/dm/conversations") {
@@ -1589,9 +1618,7 @@ object ApiClient {
suspend fun deleteMessage(messageId: Int) {
if (_suspensionState.value.isSuspended) return
- runCatching {
- http.delete("${ServerConfig.apiBaseUrl}/delete_message/$messageId")
- }
+ // WS-only (like Web): HTTP-first would hard-delete before WS and skip messageDeleted broadcast.
WebSocketManager.send(
WebSocketMessage(
type = "deleteMessage",
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt
index 023103e..3ac83ec 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt
@@ -297,6 +297,10 @@ object MessageCacheStore {
syncDmConversationPreviewFromCache(otherUserId)
}
+ suspend fun deletePublicMessageById(messageId: Int) {
+ deleteMessageById(conversationIdForPublic(), messageId)
+ }
+
suspend fun deleteMessageByClientMessageId(conversationId: String, clientMessageId: String) {
deleteByClientMessageId(conversationId, clientMessageId)
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt
index 93f0aa7..854ab75 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt
@@ -73,6 +73,9 @@ object MessageRepository {
suspend fun markPublicMessageDeleted(messageId: Int) =
markMessageDeleted(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID), messageId)
+ suspend fun deletePublicMessageById(messageId: Int) =
+ MessageCacheStore.deletePublicMessageById(messageId)
+
suspend fun loadDmMessages(otherUserId: Int): List =
MessageCacheStore.loadDmMessages(otherUserId)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt
index c2c8817..32a9c65 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt
@@ -26,7 +26,11 @@ import kotlin.concurrent.Volatile
* to the UI (for suspended or deleted users), except for the current user.
*/
fun UserProfile.shouldHideUsername(currentUserId: Int? = null): Boolean =
- id != currentUserId && (deleted == true || isDeletedPlaceholderUsername(username))
+ id != currentUserId && (
+ deleted == true ||
+ isDeletedPlaceholderUsername(username) ||
+ (suspended == true && currentUserId != 1)
+ )
private fun isDeletedPlaceholderUsername(username: String?): Boolean =
username?.startsWith("#deleted") == true
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/UserStatusStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/UserStatusStore.kt
index b853ea6..71210bf 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/UserStatusStore.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/UserStatusStore.kt
@@ -34,12 +34,11 @@ object UserStatusStore {
}
}
- fun removeTyping(userId: Int, username: String) {
- val trimmed = username.trim().takeIf { it.isNotBlank() } ?: return
+ fun removeTyping(userId: Int, username: String = "") {
_status.update { current ->
val existing = current[userId] ?: return@update current
- val typing = existing.typingUsernames.filterNot { it.equals(trimmed, ignoreCase = true) }
- current + (userId to existing.copy(typingUsernames = typing))
+ // Clear by userId: after suspend/delete the stop event may use #deleted{id}.
+ current + (userId to existing.copy(typingUsernames = emptyList()))
}
}
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/SuspendUserRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/SuspendUserRequest.kt
new file mode 100644
index 0000000..3b462fa
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/SuspendUserRequest.kt
@@ -0,0 +1,8 @@
+package ru.fromchat.api.schema.user.profile
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class SuspendUserRequest(
+ val reason: String,
+)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt
index 78b7f00..d36a890 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt
@@ -71,11 +71,6 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
-import io.ktor.client.request.header
-import io.ktor.client.request.post
-import io.ktor.client.request.setBody
-import io.ktor.http.ContentType
-import io.ktor.http.contentType
import kotlinx.datetime.LocalDate
import ru.fromchat.api.local.messages.formatChatDateSeparator
import ru.fromchat.chat_date_today
@@ -200,49 +195,9 @@ fun ChatScreen(
LazyListState(0, 0)
}
val fakeScrollOffset = remember(panelId) { Animatable(0f) }
- LaunchedEffect(panelId) {
- // #region agent log
- agentDebugLog(
- hypothesisId = "H4",
- location = "ChatScreen.kt:188",
- message = "chat_screen_opened",
- data = mapOf(
- "panelId" to panelId,
- "listStateHash" to listState.hashCode(),
- ),
- )
- // #endregion
- }
val isNearBottom by remember(panelId) {
derivedStateOf { listState.isChatNearBottom() }
}
- LaunchedEffect(listState, panelId) {
- snapshotFlow {
- val info = listState.layoutInfo
- Triple(
- info.totalItemsCount,
- listState.firstVisibleItemIndex,
- listState.firstVisibleItemScrollOffset,
- )
- }
- .distinctUntilChanged()
- .collect { (totalItems, firstIndex, firstOffset) ->
- // #region agent log
- agentDebugLog(
- hypothesisId = "H1",
- location = "ChatScreen.kt:196",
- message = "scroll_state_changed",
- data = mapOf(
- "panelId" to panelId,
- "totalItems" to totalItems,
- "firstVisibleItemIndex" to firstIndex,
- "firstVisibleItemScrollOffset" to firstOffset,
- "isNearBottom" to listState.isChatNearBottom(),
- ),
- )
- // #endregion
- }
- }
val density = LocalDensity.current
val fallbackMessageHeightPx = remember(density) { with(density) { 80.dp.roundToPx() } }
val scope = rememberCoroutineScope()
@@ -485,6 +440,7 @@ fun ChatScreen(
userId = userId,
currentUserId = currentUserId,
deleted = profile.deleted,
+ suspended = profile.suspended,
username = profile.username,
)
}
@@ -1361,23 +1317,6 @@ fun ChatScreen(
val showScrollToBottomFab = !isNearBottom &&
panelState.messages.isNotEmpty() &&
!contextMenuState.isOpen
- LaunchedEffect(showScrollToBottomFab) {
- // #region agent log
- agentDebugLog(
- hypothesisId = "H2",
- location = "ChatScreen.kt:1338",
- message = "show_scroll_button_changed",
- data = mapOf(
- "panelId" to panelId,
- "showScrollToBottomFab" to showScrollToBottomFab,
- "isNearBottom" to isNearBottom,
- "messageCount" to panelState.messages.size,
- "contextMenuOpen" to contextMenuState.isOpen,
- "usesPublicGroupSubtitle" to panel.usesPublicGroupSubtitle,
- ),
- )
- // #endregion
- }
AnimatedVisibility(
visible = showScrollToBottomFab,
enter = fadeIn(
@@ -1415,19 +1354,6 @@ fun ChatScreen(
ChatScrollToBottomButton(
onClick = {
scope.launch {
- // #region agent log
- agentDebugLog(
- hypothesisId = "H5",
- location = "ChatScreen.kt:1402",
- message = "scroll_button_jump_to_bottom",
- data = mapOf(
- "panelId" to panelId,
- "fromIndex" to listState.firstVisibleItemIndex,
- "fromOffset" to listState.firstVisibleItemScrollOffset,
- ),
- )
- // #endregion
-
// Snap list to bottom first so content is correct.
listState.scrollChatToBottom()
@@ -1648,16 +1574,6 @@ private fun ChatScrollToBottomButton(
.size(ChatScrollToBottomButtonSize)
.clip(CircleShape)
.clickable {
- // #region agent log
- agentDebugLog(
- hypothesisId = "H3",
- location = "ChatScreen.kt:1519",
- message = "scroll_button_clicked",
- data = mapOf(
- "contentDescription" to contentDescription,
- ),
- )
- // #endregion
onClick()
},
contentAlignment = Alignment.Center,
@@ -1677,70 +1593,6 @@ private fun ChatScrollToBottomButton(
}
}
-private fun agentDebugLog(
- hypothesisId: String,
- location: String,
- message: String,
- data: Map = emptyMap(),
- runId: String = "initial",
-) {
- val timestamp = kotlin.time.Clock.System.now().toEpochMilliseconds()
- fun escape(value: String): String {
- return value
- .replace("\\", "\\\\")
- .replace("\"", "\\\"")
- }
-
- val dataJson = buildString {
- append("{")
- data.entries.joinToString(",") { (key, rawValue) ->
- val value = rawValue?.toString() ?: "null"
- "\"${escape(key)}\":\"${escape(value)}\""
- }.let { append(it) }
- append("}")
- }
-
- val json = buildString {
- append("{")
- append("\"sessionId\":\"a042f7\",")
- append("\"id\":\"log_")
- append(timestamp)
- append("_")
- append(escape(hypothesisId))
- append("\",")
- append("\"timestamp\":")
- append(timestamp)
- append(",")
- append("\"location\":\"")
- append(escape(location))
- append("\",")
- append("\"message\":\"")
- append(escape(message))
- append("\",")
- append("\"runId\":\"")
- append(escape(runId))
- append("\",")
- append("\"hypothesisId\":\"")
- append(escape(hypothesisId))
- append("\",")
- append("\"data\":")
- append(dataJson)
- append("}")
- }
-
- Logger.d("AgentDebug", json)
-
- kotlinx.coroutines.GlobalScope.launch {
- runCatching {
- ApiClient.http.post("http://192.168.1.6:7629/ingest/edf4b1c4-ae73-4d46-9110-6235fc587a70") {
- contentType(ContentType.Application.Json)
- header("X-Debug-Session-Id", "a042f7")
- setBody(json)
- }
- }
- }
-}
-
private suspend fun LazyListState.scrollChatMessageToCenter(
lazyIndex: Int,
topClearancePx: Int,
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt
index 60941fd..ebdaf25 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt
@@ -10,8 +10,8 @@ import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.ui.profile.avatarLabelForInitials
import ru.fromchat.message_sender_you
import ru.fromchat.ui.profile.deletedUserDisplayNameForUi
-import ru.fromchat.ui.profile.isDeletedAccount
import ru.fromchat.ui.profile.isDeletedAccountUsername
+import ru.fromchat.ui.profile.isRedactedPeerAccount
import ru.fromchat.ui.profile.peerIsDeleted
/**
@@ -23,7 +23,7 @@ fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
return stringResource(Res.string.message_sender_you)
}
ProfileCache.get(message.user_id)?.let { profile ->
- if (profile.isDeletedAccount(currentUserId) || isDeletedAccountUsername(profile.username)) {
+ if (profile.isRedactedPeerAccount(currentUserId)) {
return deletedUserDisplayNameForUi()
}
}
@@ -42,7 +42,10 @@ fun messageSenderProfilePicture(
message: Message,
currentUserId: Int? = ApiClient.user?.id,
): String? {
- if (ProfileCache.get(message.user_id)?.isDeletedAccount(currentUserId) == true) {
+ if (ProfileCache.get(message.user_id)?.isRedactedPeerAccount(currentUserId) == true) {
+ return null
+ }
+ if (isDeletedAccountUsername(message.username)) {
return null
}
if (currentUserId != null && message.user_id == currentUserId) {
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt
index 3e4e13c..e43457c 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt
@@ -30,7 +30,7 @@ import ru.fromchat.api.schema.messages.dm.DmEnvelope
import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.api.schema.websocket.types.DmDeletedData
import ru.fromchat.ui.profile.displayNameText
-import ru.fromchat.ui.profile.isDeletedAccount
+import ru.fromchat.ui.profile.isRedactedPeerAccount
import ru.fromchat.Logger
import ru.fromchat.config.ServerConfig
import ru.fromchat.api.crypto.CorruptedDmMessagePlaceholder
@@ -120,7 +120,7 @@ class DmPanel(
try {
val profile = ApiClient.getProfileById(otherUserId)
if (
- !profile.isDeletedAccount(ApiClient.user?.id) &&
+ !profile.isRedactedPeerAccount(ApiClient.user?.id) &&
profile.username.isBlank() &&
profile.displayName.isNullOrBlank()
) {
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt
index 25c2c2e..ebcda37 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt
@@ -274,7 +274,12 @@ class PublicChatPanel(
mergeMessageUiFields(net, local)
}
}
- val ahead = shown.filter { it.id > 0 && it.id !in networkIds }
+ val minNetworkId = fromNetwork.minOfOrNull { it.id } ?: Int.MAX_VALUE
+ val maxNetworkId = fromNetwork.maxOfOrNull { it.id } ?: Int.MIN_VALUE
+ // Keep only messages strictly newer/older than the network page — not in-window gaps
+ // (those are server-side deletes that must not be resurrected from cache).
+ val ahead = shown.filter { it.id > 0 && it.id !in networkIds && it.id > maxNetworkId }
+ val older = shown.filter { it.id > 0 && it.id !in networkIds && it.id < minNetworkId }
val inFlight = shown.filter { msg ->
msg.id < 0 && (
!msg.client_message_id.isNullOrBlank() ||
@@ -282,8 +287,8 @@ class PublicChatPanel(
!msg.uploadJobId.isNullOrBlank()
)
}
- if (ahead.isEmpty() && inFlight.isEmpty()) return merged
- val combined = merged + ahead + inFlight
+ if (ahead.isEmpty() && older.isEmpty() && inFlight.isEmpty()) return merged
+ val combined = merged + ahead + older + inFlight
return ru.fromchat.api.local.messages.sortMessagesForChatDisplay(
ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(combined),
)
@@ -493,8 +498,7 @@ class PublicChatPanel(
removeMessage(deletedData.message_id)
clearReplyReferencesTo(deletedData.message_id)
withContext(Dispatchers.Default) {
- MessageRepository.markPublicMessageDeleted(deletedData.message_id)
- MessageCacheStore.replacePublicMessages(_state.messages)
+ MessageRepository.deletePublicMessageById(deletedData.message_id)
}
}
"reactionUpdate" -> {
@@ -572,6 +576,9 @@ class PublicChatPanel(
return
}
beginMessageDissolve(message)
+ withContext(Dispatchers.Default) {
+ MessageRepository.deletePublicMessageById(messageId)
+ }
ApiClient.deleteMessage(messageId)
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt
index 545d416..3427718 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt
@@ -459,6 +459,7 @@ internal fun SearchConversationsList(
userId = user.id,
currentUserId = ApiClient.user?.id,
deleted = user.deleted ?: cached?.deleted,
+ suspended = user.suspended ?: cached?.suspended,
username = user.username,
)
val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture ?: user.profile_picture
@@ -926,6 +927,7 @@ internal fun DmConversationRowContent(
userId = conversation.otherUserId,
currentUserId = currentUserId,
deleted = cached?.deleted,
+ suspended = cached?.suspended,
username = cached?.username ?: conversation.displayName.takeIf { it.isNotBlank() },
)
val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt
index 1fd8ebf..68f7a3e 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt
@@ -69,7 +69,10 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.AlternateEmail
+import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.CalendarMonth
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Link
@@ -79,11 +82,14 @@ import androidx.compose.material.icons.filled.Verified
import androidx.compose.material.icons.rounded.Call
import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Edit
+import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -137,6 +143,8 @@ import ru.fromchat.api.local.db.store.UserStatusStore
import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.api.local.db.store.visibleUsername
import ru.fromchat.api.schema.user.profile.UserProfile
+import ru.fromchat.api.schema.user.profile.VerificationStatus
+import ru.fromchat.cancel
import ru.fromchat.config.ServerConfig
import ru.fromchat.feature_not_implemented
import ru.fromchat.presence_online
@@ -146,6 +154,25 @@ import ru.fromchat.profile_action_chat
import ru.fromchat.profile_action_link
import ru.fromchat.profile_action_search
import ru.fromchat.profile_action_settings
+import ru.fromchat.profile_admin_actions_category
+import ru.fromchat.profile_admin_delete
+import ru.fromchat.profile_admin_delete_confirm
+import ru.fromchat.profile_admin_delete_confirm_body
+import ru.fromchat.profile_admin_delete_confirm_title
+import ru.fromchat.profile_admin_suspend
+import ru.fromchat.profile_admin_suspend_confirm
+import ru.fromchat.profile_admin_suspend_confirm_body
+import ru.fromchat.profile_admin_suspend_confirm_title
+import ru.fromchat.profile_admin_suspend_reason_label
+import ru.fromchat.profile_admin_unsuspend
+import ru.fromchat.profile_admin_unsuspend_confirm_body
+import ru.fromchat.profile_admin_unsuspend_confirm_title
+import ru.fromchat.profile_admin_unverify
+import ru.fromchat.profile_admin_unverify_confirm_body
+import ru.fromchat.profile_admin_unverify_confirm_title
+import ru.fromchat.profile_admin_verify
+import ru.fromchat.profile_admin_verify_confirm_body
+import ru.fromchat.profile_admin_verify_confirm_title
import ru.fromchat.profile_headline_bio
import ru.fromchat.profile_headline_member_since
import ru.fromchat.profile_headline_username
@@ -153,7 +180,6 @@ import ru.fromchat.profile_headline_verification
import ru.fromchat.profile_load_failed
import ru.fromchat.profile_not_found
import ru.fromchat.profile_verified_support
-import ru.fromchat.profile_verify_prompt_support
import ru.fromchat.ui.profile.deletedUserDisplayNameForUi
import ru.fromchat.ui.profile.avatarLabelForInitials
import ru.fromchat.ui.profile.displayNameForUi
@@ -501,7 +527,12 @@ fun ProfileScreen(
val headlineBio = stringResource(Res.string.profile_headline_bio)
val headlineVerification = stringResource(Res.string.profile_headline_verification)
val verifiedSupport = stringResource(Res.string.profile_verified_support)
- val verifyPromptSupport = stringResource(Res.string.profile_verify_prompt_support)
+ val adminActionsCategory = stringResource(Res.string.profile_admin_actions_category)
+ val adminVerifyLabel = stringResource(Res.string.profile_admin_verify)
+ val adminUnverifyLabel = stringResource(Res.string.profile_admin_unverify)
+ val adminSuspendLabel = stringResource(Res.string.profile_admin_suspend)
+ val adminUnsuspendLabel = stringResource(Res.string.profile_admin_unsuspend)
+ val adminDeleteLabel = stringResource(Res.string.profile_admin_delete)
val profile = liveProfile
?: state.profile
@@ -531,6 +562,7 @@ fun ProfileScreen(
userId = resolvedUserId,
currentUserId = viewerUserId,
deleted = resolvedProfile?.deleted,
+ suspended = resolvedProfile?.suspended,
username = resolvedProfile?.username,
)
val displayName = when {
@@ -655,9 +687,11 @@ fun ProfileScreen(
val showDetailsUsername = usernameForLinks != null
val showDetailsMemberSince = !resolvedProfile?.createdAt.isNullOrBlank()
val showDetailsBio = !resolvedProfile?.bio.isNullOrBlank()
- val showDetailsVerify = !isDeletedProfile && (
- resolvedProfile?.verified == true || ApiClient.user?.id == 1
- )
+ val showDetailsVerify = !isDeletedProfile && resolvedProfile?.verified == true
+ val showAdminActions = !isOwnProfile &&
+ !isDeletedProfile &&
+ resolvedProfile != null &&
+ ApiClient.user?.id == 1
val showDetailsSection = resolvedProfile != null && !isDeletedProfile && (
showDetailsUsername || showDetailsMemberSince || showDetailsBio || showDetailsVerify
)
@@ -779,13 +813,19 @@ fun ProfileScreen(
showDetailsMemberSince = showDetailsMemberSince,
showDetailsBio = showDetailsBio,
showDetailsVerify = showDetailsVerify,
+ showAdminActions = showAdminActions,
headlineUsername = headlineUsername,
headlineMemberSince = headlineMemberSince,
headlineBio = headlineBio,
headlineVerification = headlineVerification,
usernameForLinks = usernameForLinks,
verifiedSupport = verifiedSupport,
- verifyPromptSupport = verifyPromptSupport,
+ adminActionsCategory = adminActionsCategory,
+ adminVerifyLabel = adminVerifyLabel,
+ adminUnverifyLabel = adminUnverifyLabel,
+ adminSuspendLabel = adminSuspendLabel,
+ adminUnsuspendLabel = adminUnsuspendLabel,
+ adminDeleteLabel = adminDeleteLabel,
registrationDateStrings = registrationDateStrings,
listItemIconTint = listItemIconTint,
labelCopy = labelCopy,
@@ -796,6 +836,7 @@ fun ProfileScreen(
navController = navController,
scope = scope,
openContextMenuHaptic = openContextMenuHaptic,
+ onBack = onBack,
onProfileUpdated = { updated ->
state = state.copy(profile = updated)
ProfileCache.put(updated)
@@ -1261,13 +1302,19 @@ private fun ProfileLoadedBody(
showDetailsMemberSince: Boolean,
showDetailsBio: Boolean,
showDetailsVerify: Boolean,
+ showAdminActions: Boolean,
headlineUsername: String,
headlineMemberSince: String,
headlineBio: String,
headlineVerification: String,
usernameForLinks: String?,
verifiedSupport: String,
- verifyPromptSupport: String,
+ adminActionsCategory: String,
+ adminVerifyLabel: String,
+ adminUnverifyLabel: String,
+ adminSuspendLabel: String,
+ adminUnsuspendLabel: String,
+ adminDeleteLabel: String,
registrationDateStrings: RegistrationDateFormatStrings,
listItemIconTint: Color,
labelCopy: String,
@@ -1278,9 +1325,17 @@ private fun ProfileLoadedBody(
navController: NavController,
scope: CoroutineScope,
openContextMenuHaptic: () -> Unit,
+ onBack: () -> Unit,
onProfileUpdated: (UserProfile) -> Unit,
modifier: Modifier = Modifier,
) {
+ var showVerifyConfirm by remember { mutableStateOf(false) }
+ var showUnverifyConfirm by remember { mutableStateOf(false) }
+ var showSuspendConfirm by remember { mutableStateOf(false) }
+ var showUnsuspendConfirm by remember { mutableStateOf(false) }
+ var showDeleteConfirm by remember { mutableStateOf(false) }
+ var suspendReason by remember { mutableStateOf("") }
+ var adminActionInProgress by remember { mutableStateOf(false) }
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -1485,52 +1540,324 @@ private fun ProfileLoadedBody(
val position = listItemPositionInGroup(detailIndex, detailCount)
detailIndex++
ListItem(
- headline = headlineVerification,
- supportingText = if (resolvedProfile.verified == true) {
- verifiedSupport
- } else {
- verifyPromptSupport
- },
- divider = true,
- position = position,
- groupItemCount = detailCount,
- leadingContent = {
- Icon(
- imageVector = Icons.Filled.Verified,
- contentDescription = null,
- tint = listItemIconTint,
- )
- },
- onClick = if (ApiClient.user?.id == 1) {
- {
- scope.launch {
- val result = withContext(Dispatchers.Default) {
- runCatching {
- ApiClient.verifyUser(resolvedProfile.id)
- }.getOrNull()
- }
- result?.let { response ->
- onProfileUpdated(
- resolvedProfile.copy(
- verified = response.verified,
- verificationStatus = response.verificationStatus
- ?: response.verified.let { verified ->
- if (verified) {
- ru.fromchat.api.schema.user.profile.VerificationStatus.Verified
- } else {
- ru.fromchat.api.schema.user.profile.VerificationStatus.None
- }
- },
- ),
- )
- }
- }
- }
- } else null,
- )
+ headline = headlineVerification,
+ supportingText = verifiedSupport,
+ divider = true,
+ position = position,
+ groupItemCount = detailCount,
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Filled.Verified,
+ contentDescription = null,
+ tint = listItemIconTint,
+ )
+ },
+ )
}
}
}
+
+ if (showAdminActions) {
+ val isVerified = resolvedProfile.verified == true
+ val isSuspended = resolvedProfile.suspended == true
+ Category(
+ title = adminActionsCategory,
+ margin = PaddingValues(
+ start = 16.dp,
+ end = 16.dp,
+ top = if (showDetailsSection) 8.dp else 28.dp,
+ bottom = 20.dp,
+ ),
+ roundedCorners = false,
+ ) {
+ ListItem(
+ headline = if (isVerified) adminUnverifyLabel else adminVerifyLabel,
+ divider = true,
+ position = ListItemPosition.START,
+ groupItemCount = 3,
+ enabled = !adminActionInProgress,
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Filled.Verified,
+ contentDescription = null,
+ tint = listItemIconTint,
+ )
+ },
+ onClick = {
+ if (isVerified) showUnverifyConfirm = true else showVerifyConfirm = true
+ },
+ )
+ ListItem(
+ headline = if (isSuspended) adminUnsuspendLabel else adminSuspendLabel,
+ divider = true,
+ position = ListItemPosition.MIDDLE,
+ groupItemCount = 3,
+ enabled = !adminActionInProgress,
+ leadingContent = {
+ Icon(
+ imageVector = if (isSuspended) {
+ Icons.Filled.CheckCircle
+ } else {
+ Icons.Filled.Block
+ },
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error,
+ )
+ },
+ onClick = {
+ if (isSuspended) {
+ showUnsuspendConfirm = true
+ } else {
+ suspendReason = ""
+ showSuspendConfirm = true
+ }
+ },
+ )
+ ListItem(
+ headline = adminDeleteLabel,
+ divider = true,
+ position = ListItemPosition.END,
+ groupItemCount = 3,
+ enabled = !adminActionInProgress,
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Filled.DeleteForever,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error,
+ )
+ },
+ onClick = { showDeleteConfirm = true },
+ )
+ }
+ }
+ }
+
+ if (showVerifyConfirm) {
+ AlertDialog(
+ onDismissRequest = { if (!adminActionInProgress) showVerifyConfirm = false },
+ title = { Text(stringResource(Res.string.profile_admin_verify_confirm_title)) },
+ text = { Text(stringResource(Res.string.profile_admin_verify_confirm_body)) },
+ confirmButton = {
+ TextButton(
+ enabled = !adminActionInProgress,
+ onClick = {
+ adminActionInProgress = true
+ scope.launch {
+ val result = withContext(Dispatchers.Default) {
+ runCatching { ApiClient.verifyUser(resolvedProfile.id) }.getOrNull()
+ }
+ adminActionInProgress = false
+ showVerifyConfirm = false
+ result?.let { response ->
+ onProfileUpdated(
+ resolvedProfile.copy(
+ verified = response.verified,
+ verificationStatus = response.verificationStatus
+ ?: if (response.verified) {
+ VerificationStatus.Verified
+ } else {
+ VerificationStatus.None
+ },
+ ),
+ )
+ }
+ }
+ },
+ ) {
+ Text(stringResource(Res.string.profile_admin_verify))
+ }
+ },
+ dismissButton = {
+ TextButton(
+ enabled = !adminActionInProgress,
+ onClick = { showVerifyConfirm = false },
+ ) {
+ Text(stringResource(Res.string.cancel))
+ }
+ },
+ )
+ }
+
+ if (showUnverifyConfirm) {
+ AlertDialog(
+ onDismissRequest = { if (!adminActionInProgress) showUnverifyConfirm = false },
+ title = { Text(stringResource(Res.string.profile_admin_unverify_confirm_title)) },
+ text = { Text(stringResource(Res.string.profile_admin_unverify_confirm_body)) },
+ confirmButton = {
+ TextButton(
+ enabled = !adminActionInProgress,
+ onClick = {
+ adminActionInProgress = true
+ scope.launch {
+ val result = withContext(Dispatchers.Default) {
+ runCatching { ApiClient.verifyUser(resolvedProfile.id) }.getOrNull()
+ }
+ adminActionInProgress = false
+ showUnverifyConfirm = false
+ result?.let { response ->
+ onProfileUpdated(
+ resolvedProfile.copy(
+ verified = response.verified,
+ verificationStatus = response.verificationStatus
+ ?: if (response.verified) {
+ VerificationStatus.Verified
+ } else {
+ VerificationStatus.None
+ },
+ ),
+ )
+ }
+ }
+ },
+ ) {
+ Text(stringResource(Res.string.profile_admin_unverify))
+ }
+ },
+ dismissButton = {
+ TextButton(
+ enabled = !adminActionInProgress,
+ onClick = { showUnverifyConfirm = false },
+ ) {
+ Text(stringResource(Res.string.cancel))
+ }
+ },
+ )
+ }
+
+ if (showSuspendConfirm) {
+ AlertDialog(
+ onDismissRequest = { if (!adminActionInProgress) showSuspendConfirm = false },
+ title = { Text(stringResource(Res.string.profile_admin_suspend_confirm_title)) },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Text(stringResource(Res.string.profile_admin_suspend_confirm_body))
+ OutlinedTextField(
+ value = suspendReason,
+ onValueChange = { suspendReason = it },
+ label = { Text(stringResource(Res.string.profile_admin_suspend_reason_label)) },
+ enabled = !adminActionInProgress,
+ singleLine = false,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(
+ enabled = !adminActionInProgress && suspendReason.trim().isNotEmpty(),
+ onClick = {
+ val reason = suspendReason.trim()
+ adminActionInProgress = true
+ scope.launch {
+ val result = withContext(Dispatchers.Default) {
+ runCatching {
+ ApiClient.suspendUser(resolvedProfile.id, reason)
+ }.getOrNull()
+ }
+ adminActionInProgress = false
+ showSuspendConfirm = false
+ if (result != null) {
+ onProfileUpdated(
+ resolvedProfile.copy(
+ suspended = true,
+ suspensionReason = reason,
+ ),
+ )
+ }
+ }
+ },
+ ) {
+ Text(stringResource(Res.string.profile_admin_suspend_confirm))
+ }
+ },
+ dismissButton = {
+ TextButton(
+ enabled = !adminActionInProgress,
+ onClick = { showSuspendConfirm = false },
+ ) {
+ Text(stringResource(Res.string.cancel))
+ }
+ },
+ )
+ }
+
+ if (showUnsuspendConfirm) {
+ AlertDialog(
+ onDismissRequest = { if (!adminActionInProgress) showUnsuspendConfirm = false },
+ title = { Text(stringResource(Res.string.profile_admin_unsuspend_confirm_title)) },
+ text = { Text(stringResource(Res.string.profile_admin_unsuspend_confirm_body)) },
+ confirmButton = {
+ TextButton(
+ enabled = !adminActionInProgress,
+ onClick = {
+ adminActionInProgress = true
+ scope.launch {
+ val result = withContext(Dispatchers.Default) {
+ runCatching { ApiClient.unsuspendUser(resolvedProfile.id) }.getOrNull()
+ }
+ adminActionInProgress = false
+ showUnsuspendConfirm = false
+ if (result != null) {
+ onProfileUpdated(
+ resolvedProfile.copy(
+ suspended = false,
+ suspensionReason = null,
+ ),
+ )
+ }
+ }
+ },
+ ) {
+ Text(stringResource(Res.string.profile_admin_unsuspend))
+ }
+ },
+ dismissButton = {
+ TextButton(
+ enabled = !adminActionInProgress,
+ onClick = { showUnsuspendConfirm = false },
+ ) {
+ Text(stringResource(Res.string.cancel))
+ }
+ },
+ )
+ }
+
+ if (showDeleteConfirm) {
+ AlertDialog(
+ onDismissRequest = { if (!adminActionInProgress) showDeleteConfirm = false },
+ title = { Text(stringResource(Res.string.profile_admin_delete_confirm_title)) },
+ text = { Text(stringResource(Res.string.profile_admin_delete_confirm_body)) },
+ confirmButton = {
+ TextButton(
+ enabled = !adminActionInProgress,
+ onClick = {
+ adminActionInProgress = true
+ scope.launch {
+ val result = withContext(Dispatchers.Default) {
+ runCatching { ApiClient.adminDeleteUser(resolvedProfile.id) }.getOrNull()
+ }
+ adminActionInProgress = false
+ showDeleteConfirm = false
+ if (result != null) {
+ onProfileUpdated(
+ resolvedProfile.copy(deleted = true),
+ )
+ onBack()
+ }
+ }
+ },
+ ) {
+ Text(stringResource(Res.string.profile_admin_delete_confirm))
+ }
+ },
+ dismissButton = {
+ TextButton(
+ enabled = !adminActionInProgress,
+ onClick = { showDeleteConfirm = false },
+ ) {
+ Text(stringResource(Res.string.cancel))
+ }
+ },
+ )
}
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/UserProfileDisplay.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/UserProfileDisplay.kt
index 2392aff..51011cb 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/UserProfileDisplay.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/UserProfileDisplay.kt
@@ -16,6 +16,14 @@ fun UserProfile.isDeletedAccount(currentUserId: Int? = null): Boolean =
fun UserProfile.isSuspendedAccount(currentUserId: Int? = null): Boolean =
suspended == true && deleted != true && id != currentUserId
+/** True when peers should see this profile as a deleted/hidden account. */
+fun UserProfile.isRedactedPeerAccount(currentUserId: Int? = null): Boolean =
+ id != currentUserId && (
+ deleted == true ||
+ isDeletedAccountUsername(username) ||
+ (suspended == true && currentUserId != 1)
+ )
+
fun isDeletedAccountUsername(username: String?): Boolean =
username?.startsWith("#deleted") == true
@@ -25,13 +33,15 @@ fun peerIsDeleted(
currentUserId: Int? = null,
deleted: Boolean? = null,
username: String? = null,
+ suspended: Boolean? = null,
): Boolean {
if (userId <= 0 || userId == currentUserId) return false
if (deleted == true) return true
if (isDeletedAccountUsername(username)) return true
+ // Suspended peers look deleted to everyone except admin (id 1).
+ if (suspended == true && currentUserId != 1) return true
ProfileCache.get(userId)?.let { profile ->
- if (profile.isDeletedAccount(currentUserId)) return true
- if (isDeletedAccountUsername(profile.username)) return true
+ if (profile.isRedactedPeerAccount(currentUserId)) return true
}
return false
}
@@ -44,7 +54,7 @@ fun deletedUserDisplayNameForUi(): String =
stringResource(Res.string.deleted_account)
suspend fun UserProfile.displayNameText(currentUserId: Int? = null): String {
- if (isDeletedAccount(currentUserId)) {
+ if (isRedactedPeerAccount(currentUserId)) {
return deletedUserDisplayName()
}
return visibleDisplayName(currentUserId).orEmpty()
@@ -52,7 +62,7 @@ suspend fun UserProfile.displayNameText(currentUserId: Int? = null): String {
@Composable
fun UserProfile.displayNameForUi(currentUserId: Int? = null): String =
- if (isDeletedAccount(currentUserId)) {
+ if (isRedactedPeerAccount(currentUserId)) {
deletedUserDisplayNameForUi()
} else {
visibleDisplayName(currentUserId).orEmpty()
@@ -60,7 +70,7 @@ fun UserProfile.displayNameForUi(currentUserId: Int? = null): String =
/** Display name for avatar initials/gradient only; never falls back to username. */
fun UserProfile.avatarLabelForInitials(currentUserId: Int? = null): String =
- if (isDeletedAccount(currentUserId)) {
+ if (isRedactedPeerAccount(currentUserId)) {
""
} else {
displayName?.trim().orEmpty()