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()