mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Fix deleted account issues
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -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 account’s 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()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -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()
|
||||||
) {
|
) {
|
||||||
|
|||||||
+12
-5
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user