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_member_since">Дата регистрации</string>
<string name="profile_headline_bio">О себе</string> <string name="profile_headline_bio">О себе</string>
<string name="profile_headline_verification">Официальный аккаунт</string> <string name="profile_headline_verification">Официальный аккаунт</string>
<string name="profile_verified_support">Это официальный аккаунт</string> <string name="profile_verified_support">Этот аккаунт - официальное лицо FromChat.</string>
<string name="profile_verify_prompt_support">Нажмите, чтобы сделать аккаунт официальным (только админы)</string> <string name="profile_admin_actions_category">Действия администратора</string>
<string name="verify">Сделать официальным</string> <string name="profile_admin_verify">Подтвердить</string>
<string name="unverify">Снять официальный статус</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_verified_account">Официальный аккаунт</string>
<string name="cd_account_blocked">Аккаунт заблокирован</string> <string name="cd_account_blocked">Аккаунт заблокирован</string>
<string name="cd_similar_verified">Похож на официальный аккаунт</string> <string name="cd_similar_verified">Похож на официальный аккаунт</string>
@@ -198,8 +198,28 @@
<string name="profile_headline_member_since">Joined</string> <string name="profile_headline_member_since">Joined</string>
<string name="profile_headline_bio">About</string> <string name="profile_headline_bio">About</string>
<string name="profile_headline_verification">Verified account</string> <string name="profile_headline_verification">Verified account</string>
<string name="profile_verified_support">This account is verified</string> <string name="profile_verified_support">This account is an official FromChat representative.</string>
<string name="profile_verify_prompt_support">Tap to verify (admins only)</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) --> <!-- Verify (admin) -->
<string name="verify">Verify</string> <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.BackupBlobRequest
import ru.fromchat.api.schema.user.keys.BackupBlobResponse import ru.fromchat.api.schema.user.keys.BackupBlobResponse
import ru.fromchat.api.schema.user.keys.PublicKeyResponse 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.UpdateProfileRequest
import ru.fromchat.api.schema.user.profile.UpdateProfileResponse import ru.fromchat.api.schema.user.profile.UpdateProfileResponse
import ru.fromchat.api.schema.user.profile.UserProfile import ru.fromchat.api.schema.user.profile.UserProfile
@@ -637,6 +638,34 @@ object ApiClient {
.body<VerifyResponse>() .body<VerifyResponse>()
}.getOrNull() }.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> = suspend fun getDmConversations(): List<DmConversation> =
http http
.get("${ServerConfig.apiBaseUrl}/dm/conversations") { .get("${ServerConfig.apiBaseUrl}/dm/conversations") {
@@ -1589,9 +1618,7 @@ object ApiClient {
suspend fun deleteMessage(messageId: Int) { suspend fun deleteMessage(messageId: Int) {
if (_suspensionState.value.isSuspended) return if (_suspensionState.value.isSuspended) return
runCatching { // WS-only (like Web): HTTP-first would hard-delete before WS and skip messageDeleted broadcast.
http.delete("${ServerConfig.apiBaseUrl}/delete_message/$messageId")
}
WebSocketManager.send( WebSocketManager.send(
WebSocketMessage( WebSocketMessage(
type = "deleteMessage", type = "deleteMessage",
@@ -297,6 +297,10 @@ object MessageCacheStore {
syncDmConversationPreviewFromCache(otherUserId) syncDmConversationPreviewFromCache(otherUserId)
} }
suspend fun deletePublicMessageById(messageId: Int) {
deleteMessageById(conversationIdForPublic(), messageId)
}
suspend fun deleteMessageByClientMessageId(conversationId: String, clientMessageId: String) { suspend fun deleteMessageByClientMessageId(conversationId: String, clientMessageId: String) {
deleteByClientMessageId(conversationId, clientMessageId) deleteByClientMessageId(conversationId, clientMessageId)
} }
@@ -73,6 +73,9 @@ object MessageRepository {
suspend fun markPublicMessageDeleted(messageId: Int) = suspend fun markPublicMessageDeleted(messageId: Int) =
markMessageDeleted(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID), messageId) markMessageDeleted(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID), messageId)
suspend fun deletePublicMessageById(messageId: Int) =
MessageCacheStore.deletePublicMessageById(messageId)
suspend fun loadDmMessages(otherUserId: Int): List<Message> = suspend fun loadDmMessages(otherUserId: Int): List<Message> =
MessageCacheStore.loadDmMessages(otherUserId) MessageCacheStore.loadDmMessages(otherUserId)
@@ -26,7 +26,11 @@ import kotlin.concurrent.Volatile
* to the UI (for suspended or deleted users), except for the current user. * to the UI (for suspended or deleted users), except for the current user.
*/ */
fun UserProfile.shouldHideUsername(currentUserId: Int? = null): Boolean = 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 = private fun isDeletedPlaceholderUsername(username: String?): Boolean =
username?.startsWith("#deleted") == true username?.startsWith("#deleted") == true
@@ -34,12 +34,11 @@ object UserStatusStore {
} }
} }
fun removeTyping(userId: Int, username: String) { fun removeTyping(userId: Int, username: String = "") {
val trimmed = username.trim().takeIf { it.isNotBlank() } ?: return
_status.update { current -> _status.update { current ->
val existing = current[userId] ?: return@update current val existing = current[userId] ?: return@update current
val typing = existing.typingUsernames.filterNot { it.equals(trimmed, ignoreCase = true) } // Clear by userId: after suspend/delete the stop event may use #deleted{id}.
current + (userId to existing.copy(typingUsernames = typing)) 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.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp 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 kotlinx.datetime.LocalDate
import ru.fromchat.api.local.messages.formatChatDateSeparator import ru.fromchat.api.local.messages.formatChatDateSeparator
import ru.fromchat.chat_date_today import ru.fromchat.chat_date_today
@@ -200,49 +195,9 @@ fun ChatScreen(
LazyListState(0, 0) LazyListState(0, 0)
} }
val fakeScrollOffset = remember(panelId) { Animatable(0f) } 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) { val isNearBottom by remember(panelId) {
derivedStateOf { listState.isChatNearBottom() } 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 density = LocalDensity.current
val fallbackMessageHeightPx = remember(density) { with(density) { 80.dp.roundToPx() } } val fallbackMessageHeightPx = remember(density) { with(density) { 80.dp.roundToPx() } }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -485,6 +440,7 @@ fun ChatScreen(
userId = userId, userId = userId,
currentUserId = currentUserId, currentUserId = currentUserId,
deleted = profile.deleted, deleted = profile.deleted,
suspended = profile.suspended,
username = profile.username, username = profile.username,
) )
} }
@@ -1361,23 +1317,6 @@ fun ChatScreen(
val showScrollToBottomFab = !isNearBottom && val showScrollToBottomFab = !isNearBottom &&
panelState.messages.isNotEmpty() && panelState.messages.isNotEmpty() &&
!contextMenuState.isOpen !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( AnimatedVisibility(
visible = showScrollToBottomFab, visible = showScrollToBottomFab,
enter = fadeIn( enter = fadeIn(
@@ -1415,19 +1354,6 @@ fun ChatScreen(
ChatScrollToBottomButton( ChatScrollToBottomButton(
onClick = { onClick = {
scope.launch { 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. // Snap list to bottom first so content is correct.
listState.scrollChatToBottom() listState.scrollChatToBottom()
@@ -1648,16 +1574,6 @@ private fun ChatScrollToBottomButton(
.size(ChatScrollToBottomButtonSize) .size(ChatScrollToBottomButtonSize)
.clip(CircleShape) .clip(CircleShape)
.clickable { .clickable {
// #region agent log
agentDebugLog(
hypothesisId = "H3",
location = "ChatScreen.kt:1519",
message = "scroll_button_clicked",
data = mapOf(
"contentDescription" to contentDescription,
),
)
// #endregion
onClick() onClick()
}, },
contentAlignment = Alignment.Center, 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( private suspend fun LazyListState.scrollChatMessageToCenter(
lazyIndex: Int, lazyIndex: Int,
topClearancePx: Int, topClearancePx: Int,
@@ -10,8 +10,8 @@ import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.ui.profile.avatarLabelForInitials import ru.fromchat.ui.profile.avatarLabelForInitials
import ru.fromchat.message_sender_you import ru.fromchat.message_sender_you
import ru.fromchat.ui.profile.deletedUserDisplayNameForUi import ru.fromchat.ui.profile.deletedUserDisplayNameForUi
import ru.fromchat.ui.profile.isDeletedAccount
import ru.fromchat.ui.profile.isDeletedAccountUsername import ru.fromchat.ui.profile.isDeletedAccountUsername
import ru.fromchat.ui.profile.isRedactedPeerAccount
import ru.fromchat.ui.profile.peerIsDeleted import ru.fromchat.ui.profile.peerIsDeleted
/** /**
@@ -23,7 +23,7 @@ fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
return stringResource(Res.string.message_sender_you) return stringResource(Res.string.message_sender_you)
} }
ProfileCache.get(message.user_id)?.let { profile -> ProfileCache.get(message.user_id)?.let { profile ->
if (profile.isDeletedAccount(currentUserId) || isDeletedAccountUsername(profile.username)) { if (profile.isRedactedPeerAccount(currentUserId)) {
return deletedUserDisplayNameForUi() return deletedUserDisplayNameForUi()
} }
} }
@@ -42,7 +42,10 @@ fun messageSenderProfilePicture(
message: Message, message: Message,
currentUserId: Int? = ApiClient.user?.id, currentUserId: Int? = ApiClient.user?.id,
): String? { ): 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 return null
} }
if (currentUserId != null && message.user_id == currentUserId) { 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.WebSocketMessage
import ru.fromchat.api.schema.websocket.types.DmDeletedData import ru.fromchat.api.schema.websocket.types.DmDeletedData
import ru.fromchat.ui.profile.displayNameText 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.Logger
import ru.fromchat.config.ServerConfig import ru.fromchat.config.ServerConfig
import ru.fromchat.api.crypto.CorruptedDmMessagePlaceholder import ru.fromchat.api.crypto.CorruptedDmMessagePlaceholder
@@ -120,7 +120,7 @@ class DmPanel(
try { try {
val profile = ApiClient.getProfileById(otherUserId) val profile = ApiClient.getProfileById(otherUserId)
if ( if (
!profile.isDeletedAccount(ApiClient.user?.id) && !profile.isRedactedPeerAccount(ApiClient.user?.id) &&
profile.username.isBlank() && profile.username.isBlank() &&
profile.displayName.isNullOrBlank() profile.displayName.isNullOrBlank()
) { ) {
@@ -274,7 +274,12 @@ class PublicChatPanel(
mergeMessageUiFields(net, local) 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 -> val inFlight = shown.filter { msg ->
msg.id < 0 && ( msg.id < 0 && (
!msg.client_message_id.isNullOrBlank() || !msg.client_message_id.isNullOrBlank() ||
@@ -282,8 +287,8 @@ class PublicChatPanel(
!msg.uploadJobId.isNullOrBlank() !msg.uploadJobId.isNullOrBlank()
) )
} }
if (ahead.isEmpty() && inFlight.isEmpty()) return merged if (ahead.isEmpty() && older.isEmpty() && inFlight.isEmpty()) return merged
val combined = merged + ahead + inFlight val combined = merged + ahead + older + inFlight
return ru.fromchat.api.local.messages.sortMessagesForChatDisplay( return ru.fromchat.api.local.messages.sortMessagesForChatDisplay(
ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(combined), ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(combined),
) )
@@ -493,8 +498,7 @@ class PublicChatPanel(
removeMessage(deletedData.message_id) removeMessage(deletedData.message_id)
clearReplyReferencesTo(deletedData.message_id) clearReplyReferencesTo(deletedData.message_id)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageRepository.markPublicMessageDeleted(deletedData.message_id) MessageRepository.deletePublicMessageById(deletedData.message_id)
MessageCacheStore.replacePublicMessages(_state.messages)
} }
} }
"reactionUpdate" -> { "reactionUpdate" -> {
@@ -572,6 +576,9 @@ class PublicChatPanel(
return return
} }
beginMessageDissolve(message) beginMessageDissolve(message)
withContext(Dispatchers.Default) {
MessageRepository.deletePublicMessageById(messageId)
}
ApiClient.deleteMessage(messageId) ApiClient.deleteMessage(messageId)
} }
@@ -459,6 +459,7 @@ internal fun SearchConversationsList(
userId = user.id, userId = user.id,
currentUserId = ApiClient.user?.id, currentUserId = ApiClient.user?.id,
deleted = user.deleted ?: cached?.deleted, deleted = user.deleted ?: cached?.deleted,
suspended = user.suspended ?: cached?.suspended,
username = user.username, username = user.username,
) )
val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture ?: user.profile_picture val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture ?: user.profile_picture
@@ -926,6 +927,7 @@ internal fun DmConversationRowContent(
userId = conversation.otherUserId, userId = conversation.otherUserId,
currentUserId = currentUserId, currentUserId = currentUserId,
deleted = cached?.deleted, deleted = cached?.deleted,
suspended = cached?.suspended,
username = cached?.username ?: conversation.displayName.takeIf { it.isNotBlank() }, username = cached?.username ?: conversation.displayName.takeIf { it.isNotBlank() },
) )
val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture 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.Icons
import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.AlternateEmail 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.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.Edit
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Link 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.Call
import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState 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.visibleDisplayName
import ru.fromchat.api.local.db.store.visibleUsername import ru.fromchat.api.local.db.store.visibleUsername
import ru.fromchat.api.schema.user.profile.UserProfile 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.config.ServerConfig
import ru.fromchat.feature_not_implemented import ru.fromchat.feature_not_implemented
import ru.fromchat.presence_online 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_link
import ru.fromchat.profile_action_search import ru.fromchat.profile_action_search
import ru.fromchat.profile_action_settings 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_bio
import ru.fromchat.profile_headline_member_since import ru.fromchat.profile_headline_member_since
import ru.fromchat.profile_headline_username 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_load_failed
import ru.fromchat.profile_not_found import ru.fromchat.profile_not_found
import ru.fromchat.profile_verified_support import ru.fromchat.profile_verified_support
import ru.fromchat.profile_verify_prompt_support
import ru.fromchat.ui.profile.deletedUserDisplayNameForUi import ru.fromchat.ui.profile.deletedUserDisplayNameForUi
import ru.fromchat.ui.profile.avatarLabelForInitials import ru.fromchat.ui.profile.avatarLabelForInitials
import ru.fromchat.ui.profile.displayNameForUi import ru.fromchat.ui.profile.displayNameForUi
@@ -501,7 +527,12 @@ fun ProfileScreen(
val headlineBio = stringResource(Res.string.profile_headline_bio) val headlineBio = stringResource(Res.string.profile_headline_bio)
val headlineVerification = stringResource(Res.string.profile_headline_verification) val headlineVerification = stringResource(Res.string.profile_headline_verification)
val verifiedSupport = stringResource(Res.string.profile_verified_support) 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 val profile = liveProfile
?: state.profile ?: state.profile
@@ -531,6 +562,7 @@ fun ProfileScreen(
userId = resolvedUserId, userId = resolvedUserId,
currentUserId = viewerUserId, currentUserId = viewerUserId,
deleted = resolvedProfile?.deleted, deleted = resolvedProfile?.deleted,
suspended = resolvedProfile?.suspended,
username = resolvedProfile?.username, username = resolvedProfile?.username,
) )
val displayName = when { val displayName = when {
@@ -655,9 +687,11 @@ fun ProfileScreen(
val showDetailsUsername = usernameForLinks != null val showDetailsUsername = usernameForLinks != null
val showDetailsMemberSince = !resolvedProfile?.createdAt.isNullOrBlank() val showDetailsMemberSince = !resolvedProfile?.createdAt.isNullOrBlank()
val showDetailsBio = !resolvedProfile?.bio.isNullOrBlank() val showDetailsBio = !resolvedProfile?.bio.isNullOrBlank()
val showDetailsVerify = !isDeletedProfile && ( val showDetailsVerify = !isDeletedProfile && resolvedProfile?.verified == true
resolvedProfile?.verified == true || ApiClient.user?.id == 1 val showAdminActions = !isOwnProfile &&
) !isDeletedProfile &&
resolvedProfile != null &&
ApiClient.user?.id == 1
val showDetailsSection = resolvedProfile != null && !isDeletedProfile && ( val showDetailsSection = resolvedProfile != null && !isDeletedProfile && (
showDetailsUsername || showDetailsMemberSince || showDetailsBio || showDetailsVerify showDetailsUsername || showDetailsMemberSince || showDetailsBio || showDetailsVerify
) )
@@ -779,13 +813,19 @@ fun ProfileScreen(
showDetailsMemberSince = showDetailsMemberSince, showDetailsMemberSince = showDetailsMemberSince,
showDetailsBio = showDetailsBio, showDetailsBio = showDetailsBio,
showDetailsVerify = showDetailsVerify, showDetailsVerify = showDetailsVerify,
showAdminActions = showAdminActions,
headlineUsername = headlineUsername, headlineUsername = headlineUsername,
headlineMemberSince = headlineMemberSince, headlineMemberSince = headlineMemberSince,
headlineBio = headlineBio, headlineBio = headlineBio,
headlineVerification = headlineVerification, headlineVerification = headlineVerification,
usernameForLinks = usernameForLinks, usernameForLinks = usernameForLinks,
verifiedSupport = verifiedSupport, verifiedSupport = verifiedSupport,
verifyPromptSupport = verifyPromptSupport, adminActionsCategory = adminActionsCategory,
adminVerifyLabel = adminVerifyLabel,
adminUnverifyLabel = adminUnverifyLabel,
adminSuspendLabel = adminSuspendLabel,
adminUnsuspendLabel = adminUnsuspendLabel,
adminDeleteLabel = adminDeleteLabel,
registrationDateStrings = registrationDateStrings, registrationDateStrings = registrationDateStrings,
listItemIconTint = listItemIconTint, listItemIconTint = listItemIconTint,
labelCopy = labelCopy, labelCopy = labelCopy,
@@ -796,6 +836,7 @@ fun ProfileScreen(
navController = navController, navController = navController,
scope = scope, scope = scope,
openContextMenuHaptic = openContextMenuHaptic, openContextMenuHaptic = openContextMenuHaptic,
onBack = onBack,
onProfileUpdated = { updated -> onProfileUpdated = { updated ->
state = state.copy(profile = updated) state = state.copy(profile = updated)
ProfileCache.put(updated) ProfileCache.put(updated)
@@ -1261,13 +1302,19 @@ private fun ProfileLoadedBody(
showDetailsMemberSince: Boolean, showDetailsMemberSince: Boolean,
showDetailsBio: Boolean, showDetailsBio: Boolean,
showDetailsVerify: Boolean, showDetailsVerify: Boolean,
showAdminActions: Boolean,
headlineUsername: String, headlineUsername: String,
headlineMemberSince: String, headlineMemberSince: String,
headlineBio: String, headlineBio: String,
headlineVerification: String, headlineVerification: String,
usernameForLinks: String?, usernameForLinks: String?,
verifiedSupport: String, verifiedSupport: String,
verifyPromptSupport: String, adminActionsCategory: String,
adminVerifyLabel: String,
adminUnverifyLabel: String,
adminSuspendLabel: String,
adminUnsuspendLabel: String,
adminDeleteLabel: String,
registrationDateStrings: RegistrationDateFormatStrings, registrationDateStrings: RegistrationDateFormatStrings,
listItemIconTint: Color, listItemIconTint: Color,
labelCopy: String, labelCopy: String,
@@ -1278,9 +1325,17 @@ private fun ProfileLoadedBody(
navController: NavController, navController: NavController,
scope: CoroutineScope, scope: CoroutineScope,
openContextMenuHaptic: () -> Unit, openContextMenuHaptic: () -> Unit,
onBack: () -> Unit,
onProfileUpdated: (UserProfile) -> Unit, onProfileUpdated: (UserProfile) -> Unit,
modifier: Modifier = Modifier, 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( Column(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -1486,11 +1541,7 @@ private fun ProfileLoadedBody(
detailIndex++ detailIndex++
ListItem( ListItem(
headline = headlineVerification, headline = headlineVerification,
supportingText = if (resolvedProfile.verified == true) { supportingText = verifiedSupport,
verifiedSupport
} else {
verifyPromptSupport
},
divider = true, divider = true,
position = position, position = position,
groupItemCount = detailCount, groupItemCount = detailCount,
@@ -1501,36 +1552,312 @@ private fun ProfileLoadedBody(
tint = listItemIconTint, 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 { scope.launch {
val result = withContext(Dispatchers.Default) { val result = withContext(Dispatchers.Default) {
runCatching { runCatching { ApiClient.verifyUser(resolvedProfile.id) }.getOrNull()
ApiClient.verifyUser(resolvedProfile.id)
}.getOrNull()
} }
adminActionInProgress = false
showVerifyConfirm = false
result?.let { response -> result?.let { response ->
onProfileUpdated( onProfileUpdated(
resolvedProfile.copy( resolvedProfile.copy(
verified = response.verified, verified = response.verified,
verificationStatus = response.verificationStatus verificationStatus = response.verificationStatus
?: response.verified.let { verified -> ?: if (response.verified) {
if (verified) { VerificationStatus.Verified
ru.fromchat.api.schema.user.profile.VerificationStatus.Verified
} else { } 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 = fun UserProfile.isSuspendedAccount(currentUserId: Int? = null): Boolean =
suspended == true && deleted != true && id != currentUserId 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 = fun isDeletedAccountUsername(username: String?): Boolean =
username?.startsWith("#deleted") == true username?.startsWith("#deleted") == true
@@ -25,13 +33,15 @@ fun peerIsDeleted(
currentUserId: Int? = null, currentUserId: Int? = null,
deleted: Boolean? = null, deleted: Boolean? = null,
username: String? = null, username: String? = null,
suspended: Boolean? = null,
): Boolean { ): Boolean {
if (userId <= 0 || userId == currentUserId) return false if (userId <= 0 || userId == currentUserId) return false
if (deleted == true) return true if (deleted == true) return true
if (isDeletedAccountUsername(username)) 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 -> ProfileCache.get(userId)?.let { profile ->
if (profile.isDeletedAccount(currentUserId)) return true if (profile.isRedactedPeerAccount(currentUserId)) return true
if (isDeletedAccountUsername(profile.username)) return true
} }
return false return false
} }
@@ -44,7 +54,7 @@ fun deletedUserDisplayNameForUi(): String =
stringResource(Res.string.deleted_account) stringResource(Res.string.deleted_account)
suspend fun UserProfile.displayNameText(currentUserId: Int? = null): String { suspend fun UserProfile.displayNameText(currentUserId: Int? = null): String {
if (isDeletedAccount(currentUserId)) { if (isRedactedPeerAccount(currentUserId)) {
return deletedUserDisplayName() return deletedUserDisplayName()
} }
return visibleDisplayName(currentUserId).orEmpty() return visibleDisplayName(currentUserId).orEmpty()
@@ -52,7 +62,7 @@ suspend fun UserProfile.displayNameText(currentUserId: Int? = null): String {
@Composable @Composable
fun UserProfile.displayNameForUi(currentUserId: Int? = null): String = fun UserProfile.displayNameForUi(currentUserId: Int? = null): String =
if (isDeletedAccount(currentUserId)) { if (isRedactedPeerAccount(currentUserId)) {
deletedUserDisplayNameForUi() deletedUserDisplayNameForUi()
} else { } else {
visibleDisplayName(currentUserId).orEmpty() 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. */ /** Display name for avatar initials/gradient only; never falls back to username. */
fun UserProfile.avatarLabelForInitials(currentUserId: Int? = null): String = fun UserProfile.avatarLabelForInitials(currentUserId: Int? = null): String =
if (isDeletedAccount(currentUserId)) { if (isRedactedPeerAccount(currentUserId)) {
"" ""
} else { } else {
displayName?.trim().orEmpty() displayName?.trim().orEmpty()