Fix deleted account issues

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-17 00:47:24 +03:00
Unverified
parent a715b7796f
commit a5bf9acd9d
15 changed files with 512 additions and 228 deletions
@@ -180,10 +180,28 @@
<string name="profile_headline_member_since">Дата регистрации</string>
<string name="profile_headline_bio">О себе</string>
<string name="profile_headline_verification">Официальный аккаунт</string>
<string name="profile_verified_support">Это официальный аккаунт</string>
<string name="profile_verify_prompt_support">Нажмите, чтобы сделать аккаунт официальным (только админы)</string>
<string name="verify">Сделать официальным</string>
<string name="unverify">Снять официальный статус</string>
<string name="profile_verified_support">Этот аккаунт - официальное лицо FromChat.</string>
<string name="profile_admin_actions_category">Действия администратора</string>
<string name="profile_admin_verify">Подтвердить</string>
<string name="profile_admin_unverify">Отменить подтверждение</string>
<string name="profile_admin_suspend">Заблокировать аккаунт</string>
<string name="profile_admin_unsuspend">Разблокировать аккаунт</string>
<string name="profile_admin_delete">Удалить аккаунт</string>
<string name="profile_admin_verify_confirm_title">Подтвердить аккаунт?</string>
<string name="profile_admin_verify_confirm_body">Отметить этот аккаунт как официальное лицо FromChat?</string>
<string name="profile_admin_unverify_confirm_title">Снять подтверждение?</string>
<string name="profile_admin_unverify_confirm_body">Убрать официальное подтверждение с этого аккаунта?</string>
<string name="profile_admin_suspend_confirm_title">Заблокировать аккаунт</string>
<string name="profile_admin_suspend_confirm_body">Укажите причину блокировки этого аккаунта:</string>
<string name="profile_admin_suspend_reason_label">Причина</string>
<string name="profile_admin_suspend_confirm">Заблокировать</string>
<string name="profile_admin_unsuspend_confirm_title">Разблокировать аккаунт?</string>
<string name="profile_admin_unsuspend_confirm_body">Вернуть этому аккаунту возможность отправлять сообщения?</string>
<string name="profile_admin_delete_confirm_title">Удалить аккаунт</string>
<string name="profile_admin_delete_confirm_body">Данные пользователя будут удалены навсегда, но сообщения и переписки сохранятся. Если пользователь в сети, он будет сразу разлогинен. Это действие нельзя отменить.</string>
<string name="profile_admin_delete_confirm">Удалить</string>
<string name="verify">Подтвердить</string>
<string name="unverify">Отменить подтверждение</string>
<string name="cd_verified_account">Официальный аккаунт</string>
<string name="cd_account_blocked">Аккаунт заблокирован</string>
<string name="cd_similar_verified">Похож на официальный аккаунт</string>
@@ -198,8 +198,28 @@
<string name="profile_headline_member_since">Joined</string>
<string name="profile_headline_bio">About</string>
<string name="profile_headline_verification">Verified account</string>
<string name="profile_verified_support">This account is verified</string>
<string name="profile_verify_prompt_support">Tap to verify (admins only)</string>
<string name="profile_verified_support">This account is an official FromChat representative.</string>
<!-- Admin actions (owner / user id 1) -->
<string name="profile_admin_actions_category">Admin actions</string>
<string name="profile_admin_verify">Verify</string>
<string name="profile_admin_unverify">Remove verification</string>
<string name="profile_admin_suspend">Suspend account</string>
<string name="profile_admin_unsuspend">Unsuspend account</string>
<string name="profile_admin_delete">Delete account</string>
<string name="profile_admin_verify_confirm_title">Verify account?</string>
<string name="profile_admin_verify_confirm_body">Mark this account as an official FromChat representative?</string>
<string name="profile_admin_unverify_confirm_title">Remove verification?</string>
<string name="profile_admin_unverify_confirm_body">Remove official verification from this account?</string>
<string name="profile_admin_suspend_confirm_title">Suspend account</string>
<string name="profile_admin_suspend_confirm_body">Enter the reason for suspending this account:</string>
<string name="profile_admin_suspend_reason_label">Reason</string>
<string name="profile_admin_suspend_confirm">Suspend</string>
<string name="profile_admin_unsuspend_confirm_title">Unsuspend account?</string>
<string name="profile_admin_unsuspend_confirm_body">Restore this accounts ability to send messages?</string>
<string name="profile_admin_delete_confirm_title">Delete account</string>
<string name="profile_admin_delete_confirm_body">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.</string>
<string name="profile_admin_delete_confirm">Delete</string>
<!-- Verify (admin) -->
<string name="verify">Verify</string>
@@ -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<VerifyResponse>()
}.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<SimpleStatusResponse>()
}.getOrNull()
suspend fun unsuspendUser(userId: Int): SimpleStatusResponse? =
runCatching {
http
.post("${ServerConfig.apiBaseUrl}/user/$userId/unsuspend") {
contentType(ContentType.Application.Json)
}
.body<SimpleStatusResponse>()
}.getOrNull()
suspend fun adminDeleteUser(userId: Int): SimpleStatusResponse? =
runCatching {
http
.post("${ServerConfig.apiBaseUrl}/user/$userId/delete") {
contentType(ContentType.Application.Json)
}
.body<SimpleStatusResponse>()
}.getOrNull()
suspend fun getDmConversations(): List<DmConversation> =
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",
@@ -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)
}
@@ -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<Message> =
MessageCacheStore.loadDmMessages(otherUserId)
@@ -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
@@ -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()))
}
}
}
@@ -0,0 +1,8 @@
package ru.fromchat.api.schema.user.profile
import kotlinx.serialization.Serializable
@Serializable
data class SuspendUserRequest(
val reason: String,
)
@@ -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<String, Any?> = 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,
@@ -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) {
@@ -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()
) {
@@ -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)
}
@@ -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
@@ -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,
@@ -1486,11 +1541,7 @@ private fun ProfileLoadedBody(
detailIndex++
ListItem(
headline = headlineVerification,
supportingText = if (resolvedProfile.verified == true) {
verifiedSupport
} else {
verifyPromptSupport
},
supportingText = verifiedSupport,
divider = true,
position = position,
groupItemCount = detailCount,
@@ -1501,36 +1552,312 @@ private fun ProfileLoadedBody(
tint = listItemIconTint,
)
},
onClick = if (ApiClient.user?.id == 1) {
{
)
}
}
}
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()
runCatching { ApiClient.verifyUser(resolvedProfile.id) }.getOrNull()
}
adminActionInProgress = false
showVerifyConfirm = false
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
?: if (response.verified) {
VerificationStatus.Verified
} else {
ru.fromchat.api.schema.user.profile.VerificationStatus.None
}
VerificationStatus.None
},
),
)
}
}
},
) {
Text(stringResource(Res.string.profile_admin_verify))
}
} else null,
},
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))
}
},
)
}
}
@@ -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()