From 74e1126bc44888339c0cf9038b5069ce42bd4e47 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 9 Jul 2026 00:40:04 +0300 Subject: [PATCH] Fix deleted account issues Signed-off-by: denis0001-dev --- .../fromchat/api/FcmRegistration.android.kt | 42 ++-- .../ui/calls/CallMediaLayer.android.kt | 23 +- .../composeResources/values-ru/strings.xml | 8 + .../composeResources/values/strings.xml | 8 + .../kotlin/ru/fromchat/api/ApiClient.kt | 24 +- .../kotlin/ru/fromchat/api/ChatListSync.kt | 96 +++++++ .../kotlin/ru/fromchat/api/FcmRegistration.kt | 5 +- .../api/instance/SessionInstanceBootstrap.kt | 2 + .../fromchat/api/local/db/LocalCacheWipe.kt | 2 + .../api/local/db/store/MessageCacheStore.kt | 40 ++- .../api/local/db/store/ProfileCache.kt | 46 ++-- .../api/local/messages/MessageTimestamps.kt | 17 ++ .../ru/fromchat/api/schema/user/User.kt | 1 + .../schema/user/profile/VerificationStatus.kt | 3 + .../commonMain/kotlin/ru/fromchat/ui/App.kt | 1 - .../ru/fromchat/ui/calls/CallOverlay.kt | 4 +- .../ru/fromchat/ui/chat/AttachmentPreview.kt | 9 +- .../kotlin/ru/fromchat/ui/chat/Avatar.kt | 33 ++- .../kotlin/ru/fromchat/ui/chat/ChatPanel.kt | 22 +- .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 57 +++++ .../kotlin/ru/fromchat/ui/chat/ChatTopBar.kt | 16 ++ .../ru/fromchat/ui/chat/MessageDisplayName.kt | 49 ++-- .../kotlin/ru/fromchat/ui/chat/MessageItem.kt | 18 +- .../ru/fromchat/ui/chat/TypingIndicator.kt | 16 +- .../ru/fromchat/ui/chat/panels/dm/DmPanel.kt | 71 +++--- .../fromchat/ui/main/chats/ChatListShared.kt | 74 ++++-- .../ru/fromchat/ui/main/chats/ChatsTab.kt | 51 ++-- .../fromchat/ui/main/settings/LogsScreen.kt | 236 ++++++++++++++++-- .../ui/main/settings/NotificationsScreen.kt | 37 +-- .../ui/main/settings/account/AccountScreen.kt | 2 - .../fromchat/ui/profile/EditProfileScreen.kt | 2 +- .../ru/fromchat/ui/profile/ProfileScreen.kt | 137 +++++++--- .../ru/fromchat/ui/profile/StatusBadge.kt | 9 + .../fromchat/ui/profile/UserProfileDisplay.kt | 67 +++++ .../ru/fromchat/utils/LastSeenFormat.kt | 7 +- .../ru/fromchat/api/FcmRegistration.ios.kt | 2 + 36 files changed, 987 insertions(+), 250 deletions(-) create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/ChatListSync.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/UserProfileDisplay.kt diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/FcmRegistration.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/FcmRegistration.android.kt index 1c33819..a192856 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/api/FcmRegistration.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/FcmRegistration.android.kt @@ -3,16 +3,10 @@ package ru.fromchat.api import com.google.android.gms.tasks.Task import com.google.firebase.messaging.FirebaseMessaging import com.pr0gramm3r101.utils.settings.settings -import io.ktor.client.call.body -import io.ktor.client.request.header -import io.ktor.client.request.post -import io.ktor.client.request.setBody import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import ru.fromchat.Logger -import ru.fromchat.api.schema.core.SimpleStatusResponse -import ru.fromchat.config.ServerConfig import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -34,14 +28,8 @@ private suspend fun fetchCurrentFcmToken(): String? = suspendCancellableCoroutin private suspend fun postFcmToken(token: String): Boolean { return runCatching { - val suffix = token.takeLast(8) - ApiClient.http - .post("${ServerConfig.apiBaseUrl}/push/register") { - header("Content-Type", "application/json") - setBody(ApiClient.json.encodeToString(mapOf("token" to token))) - } - .body() - Logger.i("FcmReg", "Uploaded FCM token to server: ...$suffix") + ApiClient.registerFcmToken(token) + Logger.i("FcmReg", "Uploaded FCM token to server: ...${token.takeLast(8)}") true }.getOrElse { e -> Logger.e("FcmReg", "Failed to upload FCM token: ${e.message}", e) @@ -53,8 +41,11 @@ actual suspend fun uploadPendingFcmTokenIfAvailable() = withContext(Dispatchers. try { val pending = settings.getString(PENDING_FCM_TOKEN_KEY, "") - if (ApiClient.token.isNullOrEmpty() || pending.isBlank()) { - Logger.d("FcmReg", "Auth token missing or no FCM token; deferring FCM token upload") + if (ApiClient.token.isNullOrEmpty()) { + Logger.d("FcmReg", "Auth token missing; deferring FCM token upload") + return@withContext + } + if (pending.isBlank()) { return@withContext } @@ -102,22 +93,21 @@ actual suspend fun unregisterFcmTokenFromServer(): Boolean = withContext(Dispatc return@withContext false } - val token = settings.getString(CURRENT_FCM_TOKEN_KEY, "").trim() + val storedToken = settings.getString(CURRENT_FCM_TOKEN_KEY, "").trim() + val token = storedToken.ifBlank { + runCatching { fetchCurrentFcmToken()?.trim().orEmpty() }.getOrDefault("") + } Logger.i("FcmReg", "unregisterFcmTokenFromServer requested with token=...${token.takeLast(8)}") return@withContext runCatching { - ApiClient.http.post("${ServerConfig.apiBaseUrl}/push/unregister") { - header("Content-Type", "application/json") - if (token.isNotEmpty()) { - setBody(ApiClient.json.encodeToString(mapOf("token" to token))) - } - } + ApiClient.unregisterFcmToken(token.takeIf { it.isNotBlank() }) settings.remove(PENDING_FCM_TOKEN_KEY) - if (token.isNotBlank()) { - settings.remove(CURRENT_FCM_TOKEN_KEY) - } + settings.remove(CURRENT_FCM_TOKEN_KEY) true }.getOrElse { e -> Logger.e("FcmReg", "Failed to unregister FCM token: ${e.message}") false } } + +actual suspend fun isFcmPushRegisteredLocally(): Boolean = + settings.getString(CURRENT_FCM_TOKEN_KEY, "").isNotBlank() diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.android.kt index a0b055b..1a63b78 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.android.kt @@ -142,6 +142,7 @@ import ru.fromchat.notif_call_ongoing_title import ru.fromchat.notif_screenshare_text import ru.fromchat.notif_screenshare_title import ru.fromchat.ui.chat.Avatar +import ru.fromchat.ui.profile.avatarLabelForInitials import kotlin.math.roundToInt import kotlin.math.sqrt @@ -731,7 +732,8 @@ private fun SoloCallParticipantVideos( val lcRef = localCamRefs.firstOrNull() val self = ApiClient.user val selfPic = self?.id?.let { ProfileCache.get(it)?.profilePicture } ?: self?.profile_picture - val selfName = self?.displayName?.takeIf { !it.isNullOrBlank() } ?: self?.username.orEmpty() + val selfAvatarLabel = self?.displayName?.trim().orEmpty() + val peerAvatarLabel = ProfileCache.get(session.peerUserId)?.avatarLabelForInitials(self?.id).orEmpty() val peerPic = ProfileCache.get(session.peerUserId)?.profilePicture Box( Modifier @@ -753,7 +755,7 @@ private fun SoloCallParticipantVideos( } lcRef != null && !localCamOn -> { RemoteVideoOffPlaceholder( - displayName = selfName, + displayName = selfAvatarLabel, profilePictureUrl = selfPic, audioLevel = localLevel, modifier = Modifier.fillMaxSize(), @@ -761,7 +763,7 @@ private fun SoloCallParticipantVideos( } else -> { RemoteVideoOffPlaceholder( - displayName = session.peerDisplayName, + displayName = peerAvatarLabel, profilePictureUrl = peerPic, audioLevel = 0f, modifier = Modifier.fillMaxSize(), @@ -884,7 +886,8 @@ private fun DuoCallParticipantVideos( val selfId = self?.id val selfPic = selfId?.let { ProfileCache.get(it)?.profilePicture } ?: self?.profile_picture - val selfName = self?.displayName?.takeIf { !it.isNullOrBlank() } ?: self?.username.orEmpty() + val selfAvatarLabel = self?.displayName?.trim().orEmpty() + val peerAvatarLabel = ProfileCache.get(session.peerUserId)?.avatarLabelForInitials(selfId).orEmpty() val youLabel = stringResource(Res.string.message_sender_you) val controlsReserve = if (showInCallControls) 168.dp else 0.dp val screenShareMainBottomPad = controlsReserve @@ -893,7 +896,7 @@ private fun DuoCallParticipantVideos( VideoSlot.RemoteScreen, VideoSlot.RemoteCam -> CallOwnerUi( name = session.peerDisplayName, - avatarName = session.peerDisplayName, + avatarName = peerAvatarLabel, pictureUrl = peerPic, level = remoteLevel, isSelf = false, @@ -901,7 +904,7 @@ private fun DuoCallParticipantVideos( VideoSlot.LocalCam, VideoSlot.LocalScreen -> CallOwnerUi( name = youLabel, - avatarName = selfName, + avatarName = selfAvatarLabel, pictureUrl = selfPic, level = localLevel, isSelf = true, @@ -950,7 +953,11 @@ private fun DuoCallParticipantVideos( } slot != VideoSlot.None && mainRef != null && !isScreen(slot) && !camEnabled(slot) -> { RemoteVideoOffPlaceholder( - displayName = session.peerDisplayName, + displayName = if (slot == VideoSlot.RemoteCam) { + peerAvatarLabel + } else { + selfAvatarLabel + }, profilePictureUrl = if (slot == VideoSlot.RemoteCam) peerPic else selfPic, audioLevel = if (slot == VideoSlot.RemoteCam) remoteLevel else localLevel, modifier = baseModifier, @@ -958,7 +965,7 @@ private fun DuoCallParticipantVideos( } else -> { RemoteVideoOffPlaceholder( - displayName = session.peerDisplayName, + displayName = peerAvatarLabel, profilePictureUrl = peerPic, audioLevel = remoteLevel, modifier = Modifier.fillMaxSize(), diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index 144d610..beb4cfc 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -110,6 +110,9 @@ Загрузка продолжается в фоне Вы Человек %1$d + Удалённый аккаунт + Аккаунт заблокирован + Удалить чат Это сообщение не удалось показать. (изменено) Ответ %1$s @@ -178,9 +181,11 @@ Сделать официальным Снять официальный статус Официальный аккаунт + Аккаунт заблокирован Похож на официальный аккаунт В сети Недавно заходил + был(а) давно Сегодня в %1$s Вчера в %1$s %1$s в %2$s @@ -383,6 +388,8 @@ %1$d КБ %1$s МБ Прокрутить к последним записям + Поиск + Поиск по записям журнала Аккаунт Выйти? @@ -413,6 +420,7 @@ Не удалось подключиться. Проверьте интернет. Что-то пошло не так. Попробуйте ещё раз. %1$s печатает… + печатает… %1$s и %2$s печатают… %1$s, %2$s и ещё %3$d печатают… Ещё diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index c8291d4..f481184 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -122,6 +122,9 @@ You Person %1$d + Deleted account + Account suspended + Delete chat This message could not be shown. (edited) Reply to %1$s @@ -200,11 +203,13 @@ Verified account + Account blocked May be a verified account Online Active recently + last seen a long time ago Today at %1$s Yesterday at %1$s %1$s at %2$s @@ -409,6 +414,8 @@ %1$d KB %1$s MB Scroll to latest logs + Search + Search log entries Account Log out? @@ -443,6 +450,7 @@ %1$s is typing… + typing… %1$s and %2$s are typing… %1$s, %2$s and %3$d more are typing… diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt index 304c188..d0bc0a3 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -232,7 +232,7 @@ object ApiClient { if (response.status.value == 401) { val path = response.call.request.url.encodedPath val isCredentialCheck = path.endsWith("/login") || path.endsWith("/register") - if (!isCredentialCheck) { + if (!isCredentialCheck && !logoutInProgress) { MainScope().launch { runCatching { WebSocketManager.disconnect() } runCatching { clearLocalSession() } @@ -408,6 +408,9 @@ object ApiClient { @Volatile var user: User? = null + @Volatile + private var logoutInProgress = false + var onAuthError: (() -> Unit)? = null private fun getSuspensionReasonFromForbiddenResponse(response: HttpResponse): String? = @@ -1406,13 +1409,20 @@ object ApiClient { } suspend fun logout() { - runCatching { - http.get("${ServerConfig.apiBaseUrl}/logout") - }.onFailure { e -> - ru.fromchat.Logger.e("ApiClient", "Server logout failed", e) + if (logoutInProgress) return + logoutInProgress = true + try { + runCatching { WebSocketManager.disconnect() } + runCatching { unregisterFcmTokenFromServer() } + runCatching { + http.get("${ServerConfig.apiBaseUrl}/logout") + }.onFailure { e -> + ru.fromchat.Logger.e("ApiClient", "Server logout failed", e) + } + clearLocalSession() + } finally { + logoutInProgress = false } - runCatching { unregisterFcmTokenFromServer() } - clearLocalSession() } fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated") diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ChatListSync.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ChatListSync.kt new file mode 100644 index 0000000..0081168 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ChatListSync.kt @@ -0,0 +1,96 @@ +package ru.fromchat.api + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonElement +import ru.fromchat.api.local.WebSocketManager +import ru.fromchat.api.local.cache.CacheContext +import ru.fromchat.api.local.db.store.MessageCacheStore +import ru.fromchat.api.local.db.store.MessageRepository +import ru.fromchat.api.local.db.store.ProfileCache +import ru.fromchat.api.local.messages.parseMessageTimestampMillis +import ru.fromchat.api.schema.messages.Message +import ru.fromchat.api.schema.websocket.WebSocketMessage +import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData + +/** + * Keeps the chats tab list in sync: DM conversations from the server and the latest public-chat + * message for list previews (without opening each chat first). + */ +object ChatListSync { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var started = false + + fun ensureStarted() { + if (started) return + started = true + + WebSocketManager.addSessionReadyHandler { + scope.launch { syncFromNetwork() } + } + + WebSocketManager.addGlobalMessageHandler(::handleWebSocketMessage) + } + + fun resetOnLogout() { + started = false + } + + suspend fun syncFromNetwork() { + if (!canSync()) return + syncDmConversations() + syncPublicChatPreview() + } + + private fun canSync(): Boolean { + if (ApiClient.token.isNullOrEmpty()) return false + if (CacheContext.activeInstanceId.value.trim().isEmpty()) return false + return true + } + + private suspend fun syncDmConversations() { + val previewStrings = MessageCacheStore.listPreviewStrings ?: return + runCatching { + val conversations = ApiClient.getDmConversations() + conversations.forEach { ProfileCache.mergeFromDmUser(it.user) } + MessageRepository.replaceDmConversations(conversations, previewStrings) + } + } + + private suspend fun syncPublicChatPreview() { + runCatching { + val response = ApiClient.getMessages(limit = 1) + val latest = response.messages.maxByOrNull { message -> + parseMessageTimestampMillis(message.timestamp) ?: Long.MIN_VALUE + } ?: return@runCatching + MessageRepository.upsertPublicMessage(latest) + } + } + + private fun handleWebSocketMessage(message: WebSocketMessage) { + when (message.type) { + "updates" -> { + val data = message.data ?: return + val updates = runCatching { + ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data) + }.getOrNull() ?: return + updates.updates.forEach { update -> + handleWebSocketMessage(WebSocketMessage(type = update.type, data = update.data)) + } + } + "newMessage" -> message.data?.let { element -> + scope.launch { ingestPublicMessage(element) } + } + } + } + + private suspend fun ingestPublicMessage(element: JsonElement) { + val newMsg = runCatching { + ApiClient.json.decodeFromJsonElement(Message.serializer(), element) + }.getOrNull() ?: return + ProfileCache.mergePreviewFromPublicMessage(newMsg) + MessageRepository.upsertPublicMessage(newMsg) + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/FcmRegistration.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/FcmRegistration.kt index 0a3b91e..61fb07e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/FcmRegistration.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/FcmRegistration.kt @@ -12,4 +12,7 @@ expect suspend fun ensureFcmTokenRegistered(): Boolean * Unregisters the local FCM token from the server for this user. * Returns true when the unregister request succeeds. */ -expect suspend fun unregisterFcmTokenFromServer(): Boolean \ No newline at end of file +expect suspend fun unregisterFcmTokenFromServer(): Boolean + +/** Whether this device has an FCM token registered with the server for the current session. */ +expect suspend fun isFcmPushRegisteredLocally(): Boolean \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/SessionInstanceBootstrap.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/SessionInstanceBootstrap.kt index b43e389..0acad13 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/SessionInstanceBootstrap.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/SessionInstanceBootstrap.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import ru.fromchat.api.ApiClient +import ru.fromchat.api.ChatListSync import ru.fromchat.api.PublicChatProfileSync import ru.fromchat.api.local.db.store.InstanceRegistryStore import ru.fromchat.api.local.db.store.MessageRepository @@ -38,6 +39,7 @@ private suspend fun activateInstance(instanceId: String) { CacheContext.setActiveInstance(instanceId, ApiClient.user?.id) runCatching { PublicChatProfileCache.hydrateFromDiskImmediate(instanceId) } PublicChatProfileSync.ensureStarted() + ChatListSync.ensureStarted() scheduleOutboxProcessing(instanceId) scheduleAttachmentResumeAfterSession() ApiClient.user?.id?.let { userId -> diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/LocalCacheWipe.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/LocalCacheWipe.kt index f466bd5..5b0bf60 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/LocalCacheWipe.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/LocalCacheWipe.kt @@ -3,6 +3,7 @@ package ru.fromchat.api.local.db import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import ru.fromchat.api.ApiClient +import ru.fromchat.api.ChatListSync import ru.fromchat.api.PublicChatProfileSync import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.DecryptedFileCache @@ -48,6 +49,7 @@ suspend fun wipeLocalCacheOnDisk() { suspend fun clearAccountCacheOnLogout(instanceId: String) { val id = instanceId.trim() PublicChatProfileSync.resetOnLogout() + ChatListSync.resetOnLogout() if (id.isNotEmpty()) { cancelOutboxProcessing(id) runCatching { MessageRepository.purgeAllPendingForInstance() } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt index f5e3eee..f49f339 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt @@ -99,11 +99,11 @@ object MessageCacheStore { fun loadRecentPublicMessagesImmediate(instanceId: String, limit: Long = 128): List { if (instanceId.isBlank()) return emptyList() val convId = conversationIdForPublic() - val raw = db.messageDatabaseQueries - .selectRecentMessagesByConversation(instanceId, convId, limit) - .executeAsList() - .map { row: DbMessage -> row.toAppMessage() } - .reversed() + val raw = hydrateReplyReferences( + db.messageDatabaseQueries + .selectRecentMessagesByConversation(instanceId, convId, limit) + .executeAsList(), + ).reversed() val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id) return ProfileCache.enrichPublicMessagesForDisplay( sortMessagesForChatDisplay( @@ -345,8 +345,11 @@ object MessageCacheStore { withContext(Dispatchers.Default) { val upserts = conversations.map { conv -> val conversationId = conversationIdForDm(conv.user.id) - val displayLabel = conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() } - ?: conv.user.username.trim() + val displayLabel = when { + conv.user.deleted == true -> "" + else -> conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() } + ?: conv.user.username.trim() + } val localUnread = db.messageDatabaseQueries .countUnreadInboundDmMessages(iid, conversationId, conv.user.id.toLong()) .executeAsOne() @@ -388,6 +391,7 @@ object MessageCacheStore { pruneEmptyConversationsLocked(iid) } } + DmConversationListNotifier.notifyChanged() } private data class UpsertDmConversationRow( @@ -853,10 +857,10 @@ object MessageCacheStore { val iid = instanceId() OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance(iid) return withContext(Dispatchers.Default) { - val raw = db.messageDatabaseQueries + val rows = db.messageDatabaseQueries .selectMessagesByConversation(iid, conversationId) .executeAsList() - .map { row: DbMessage -> row.toAppMessage() } + val raw = rows.map { it.toAppMessage() } val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id) purgeSupersededPendingRows(iid, conversationId, raw, withoutSuperseded) sortMessagesForChatDisplay( @@ -873,11 +877,10 @@ object MessageCacheStore { private suspend fun loadRecentMessages(conversationId: String, limit: Long): List { val iid = instanceId() return withContext(Dispatchers.Default) { - db.messageDatabaseQueries + val rows = db.messageDatabaseQueries .selectRecentMessagesByConversation(iid, conversationId, limit) .executeAsList() - .map { row: DbMessage -> row.toAppMessage() } - .reversed() + rows.map { it.toAppMessage() }.reversed() } } @@ -983,6 +986,19 @@ object MessageCacheStore { } } + private fun hydrateReplyReferences(rows: List): List { + val messages = rows.map { it.toAppMessage() } + val byId = messages.associateBy { it.id } + return rows.zip(messages).map { (row, message) -> + val replyId = row.replyToId?.toInt() + if (replyId != null) { + message.copy(reply_to = byId[replyId]) + } else { + message + } + } + } + private fun DbMessage.toAppMessage(): Message { val uid = userId.toInt() val self = ApiClient.user diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt index 3372d0b..2a4ce83 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt @@ -19,7 +19,10 @@ import kotlin.concurrent.Volatile * to the UI (for suspended or deleted users), except for the current user. */ fun UserProfile.shouldHideUsername(currentUserId: Int? = null): Boolean = - id != currentUserId && (deleted == true || suspended == true) + id != currentUserId && (deleted == true || isDeletedPlaceholderUsername(username)) + +private fun isDeletedPlaceholderUsername(username: String?): Boolean = + username?.startsWith("#deleted") == true fun UserProfile.visibleUsername(currentUserId: Int? = null): String? = if (shouldHideUsername(currentUserId)) { @@ -71,18 +74,23 @@ object ProfileCache { val incomingUsername = username?.trim()?.takeIf { it.isNotEmpty() } ?: existing?.username?.trim()?.takeIf { it.isNotEmpty() } - val incomingDisplayName = displayName?.trim()?.takeIf { it.isNotEmpty() } - ?: existing?.displayName?.takeIf { it.isNotBlank() } - ?: incomingUsername + val isDeleted = existing?.deleted == true || isDeletedPlaceholderUsername(incomingUsername) + val incomingDisplayName = if (isDeleted) { + null + } else { + displayName?.trim()?.takeIf { it.isNotEmpty() } + ?: existing?.displayName?.takeIf { it.isNotBlank() } + ?: incomingUsername + } - if (incomingUsername.isNullOrEmpty() && incomingDisplayName.isNullOrBlank()) return + if (!isDeleted && incomingUsername.isNullOrEmpty() && incomingDisplayName.isNullOrBlank()) return put( UserProfile( id = id, username = incomingUsername.orEmpty(), displayName = incomingDisplayName, - profilePicture = profilePicture?.takeIf { it.isNotBlank() } + profilePicture = if (isDeleted) null else profilePicture?.takeIf { it.isNotBlank() } ?: existing?.profilePicture, bio = existing?.bio, online = existing?.online ?: false, @@ -92,7 +100,7 @@ object ProfileCache { verificationStatus = verificationStatus ?: existing?.verificationStatus, suspended = existing?.suspended, suspensionReason = existing?.suspensionReason, - deleted = existing?.deleted, + deleted = isDeleted, isClientPreviewOnly = true, ), ) @@ -152,16 +160,20 @@ object ProfileCache { val incomingUsername = user.username.trim() if (incomingUsername.isEmpty()) return - val incomingDisplayName = + val isDeleted = user.deleted == true || isDeletedPlaceholderUsername(incomingUsername) + val incomingDisplayName = if (isDeleted) { + null + } else { user.displayName?.trim()?.takeIf { it.isNotEmpty() } ?: incomingUsername + } put( UserProfile( id = user.id, username = incomingUsername, - displayName = existing?.displayName?.takeIf { it.isNotBlank() } + displayName = if (isDeleted) null else existing?.displayName?.takeIf { it.isNotBlank() } ?: incomingDisplayName, - profilePicture = user.profile_picture?.takeIf { it.isNotBlank() } + profilePicture = if (isDeleted) null else user.profile_picture?.takeIf { it.isNotBlank() } ?: existing?.profilePicture, bio = existing?.bio, online = user.online, @@ -169,9 +181,9 @@ object ProfileCache { createdAt = user.created_at.takeIf { it.isNotBlank() } ?: existing?.createdAt, verified = user.verified ?: existing?.verified, verificationStatus = user.verificationStatus ?: existing?.verificationStatus, - suspended = existing?.suspended, - suspensionReason = existing?.suspensionReason, - deleted = existing?.deleted, + suspended = user.suspended ?: existing?.suspended, + suspensionReason = user.suspensionReason ?: existing?.suspensionReason, + deleted = isDeleted, isClientPreviewOnly = true, ), ) @@ -185,8 +197,10 @@ object ProfileCache { val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() } if (uname.isBlank()) return - val display = existing?.displayName?.takeIf { it.isNotBlank() } ?: uname - val pic = message.profile_picture?.takeIf { it.isNotBlank() } ?: existing?.profilePicture + val isDeleted = isDeletedPlaceholderUsername(uname) || existing?.deleted == true + val display = if (isDeleted) null else existing?.displayName?.takeIf { it.isNotBlank() } ?: uname + val pic = if (isDeleted) null else message.profile_picture?.takeIf { it.isNotBlank() } + ?: existing?.profilePicture put( UserProfile( @@ -202,7 +216,7 @@ object ProfileCache { verificationStatus = message.verificationStatus ?: existing?.verificationStatus, suspended = existing?.suspended, suspensionReason = existing?.suspensionReason, - deleted = existing?.deleted, + deleted = isDeleted, isClientPreviewOnly = true, ), ) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/MessageTimestamps.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/MessageTimestamps.kt index a160718..0bc1bf9 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/MessageTimestamps.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/MessageTimestamps.kt @@ -30,6 +30,23 @@ internal fun parseMessageInstant(timestamp: String): Instant? { internal fun parseMessageTimestampMillis(timestamp: String): Long? = parseMessageInstant(timestamp)?.toEpochMilliseconds() +/** HH:mm today; date + time when the message is from another day (bubble footer). */ +internal fun formatMessageBubbleTimeLocal(timestamp: String): String { + val local = parseMessageInstant(timestamp)?.toDeviceLocal() ?: return "" + val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) + val hour = local.hour.toString().padStart(2, '0') + val minute = local.minute.toString().padStart(2, '0') + val time = "$hour:$minute" + if (local.date == now.date) return time + val day = local.day.toString().padStart(2, '0') + val month = local.month.number.toString().padStart(2, '0') + return if (local.year == now.year) { + "$day.$month $time" + } else { + "$day.$month.${local.year} $time" + } +} + /** HH:mm in the device time zone (bubble footer). */ internal fun formatMessageTimeLocal(timestamp: String): String { val local = parseMessageInstant(timestamp)?.toDeviceLocal() ?: return "" diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/User.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/User.kt index 93225c9..fce3d0f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/User.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/User.kt @@ -19,5 +19,6 @@ data class User( @SerialName("verification_status") val verificationStatus: VerificationStatus? = null, val suspended: Boolean? = null, @SerialName("suspension_reason") val suspensionReason: String? = null, + val deleted: Boolean? = null, ) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/VerificationStatus.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/VerificationStatus.kt index 6e8bd53..0081289 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/VerificationStatus.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/VerificationStatus.kt @@ -13,6 +13,9 @@ enum class VerificationStatus { @SerialName("none") None, + + @SerialName("blocked") + Blocked, } fun VerificationStatus?.orFromLegacyVerified(verified: Boolean?): VerificationStatus = diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index a32f4a7..d1bd21b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -165,7 +165,6 @@ private fun handleAccountLifecycleEvent(message: WebSocketMessage) { MainScope().launch { ApiClient.logout() } - WebSocketManager.disconnect() } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/CallOverlay.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/CallOverlay.kt index 40e3cf5..88a2f01 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/CallOverlay.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/CallOverlay.kt @@ -32,7 +32,7 @@ import ru.fromchat.api.ApiClient import ru.fromchat.api.calls.CallStore import ru.fromchat.api.calls.CallUiState import ru.fromchat.api.local.db.store.ProfileCache -import ru.fromchat.api.local.db.store.visibleDisplayName +import ru.fromchat.ui.profile.displayNameForUi import ru.fromchat.ui.profile.DisplayName import ru.fromchat.ui.profile.effectiveVerificationStatus import ru.fromchat.ui.profile.resolveVerificationStatus @@ -94,7 +94,7 @@ fun CallOverlay(modifier: Modifier = Modifier) { val me = ApiClient.user?.id val cached = ProfileCache.get(s.fromUserId) val title = - cached?.visibleDisplayName(me)?.takeIf { it.isNotBlank() } + cached?.displayNameForUi(me)?.takeIf { it.isNotBlank() } ?: cached?.username?.takeIf { it.isNotBlank() } ?: stringResource(Res.string.user_fallback, s.fromUserId) Box( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt index 95f17b3..64b33b1 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn @@ -108,6 +109,7 @@ import ru.fromchat.ui.components.Text import com.pr0gramm3r101.utils.scaleOnPress private val IMAGE_SIZE = 160.dp +private val IMAGE_MAX_HEIGHT = 240.dp private const val BLUR_FADE_MS = 450 internal fun isImageFilename(name: String): Boolean = @@ -210,11 +212,12 @@ fun AttachmentPreview( fileAspectRatio != null && fileAspectRatio > 0f, `if` = { Modifier - .aspectRatio(fileAspectRatio!!) - .sizeIn(maxWidth = IMAGE_SIZE, maxHeight = IMAGE_SIZE) + .heightIn(max = IMAGE_MAX_HEIGHT) + .widthIn(max = IMAGE_SIZE) + .aspectRatio(fileAspectRatio!!, matchHeightConstraintsFirst = true) }, `else` = { - Modifier.size(IMAGE_SIZE) + Modifier.sizeIn(maxWidth = IMAGE_SIZE, maxHeight = IMAGE_MAX_HEIGHT) } ) .clip(attachmentImageCornerShape(isAuthor)) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/Avatar.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/Avatar.kt index 96d4e24..52f8443 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/Avatar.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/Avatar.kt @@ -4,6 +4,9 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.PersonOff +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -29,8 +32,35 @@ import ru.fromchat.ui.chat.components.getInitials fun Avatar( profilePictureUrl: String?, displayName: String, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + isDeletedUser: Boolean = false, + userId: Int? = null, ) { + if (isDeletedUser) { + val gradientSeed = userId?.toString() ?: displayName + val gradient = remember(gradientSeed) { generateGradientFromName(gradientSeed) } + Box( + modifier = modifier.clip(CircleShape), + contentAlignment = Alignment.Center, + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + val radius = size.minDimension / 2f + drawCircle( + brush = gradient, + radius = radius, + center = center, + ) + } + Icon( + imageVector = Icons.Outlined.PersonOff, + contentDescription = displayName, + modifier = Modifier.fillMaxSize(0.55f), + tint = Color.White, + ) + } + return + } + var imageLoadFailed by remember { mutableStateOf(false) } val gradient = remember(displayName) { generateGradientFromName(displayName) } @@ -99,4 +129,3 @@ fun Avatar( } } } - diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt index b8964d3..848ee4e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt @@ -321,25 +321,35 @@ abstract class ChatPanel( pending?.first?.cancel() updateState { currentState -> - val withoutDupReal = if (confirmedMessage.id > 0) { - currentState.messages.filter { it.id != confirmedMessage.id } + val optimistic = currentState.messages.find { it.client_message_id == tempId } + val resolvedConfirmed = if (confirmedMessage.reply_to == null) { + val reply = optimistic?.reply_to + if (reply != null) confirmedMessage.copy(reply_to = reply) else confirmedMessage + } else { + confirmedMessage + } + val withoutDupReal = if (resolvedConfirmed.id > 0) { + currentState.messages.filter { it.id != resolvedConfirmed.id } } else { currentState.messages } val hadTemp = withoutDupReal.any { it.client_message_id == tempId } val mapped = withoutDupReal.map { msg -> - if (msg.client_message_id == tempId) confirmedMessage else msg + if (msg.client_message_id == tempId) resolvedConfirmed else msg } val messages = when { hadTemp -> mapped - confirmedMessage.id > 0 && mapped.none { it.id == confirmedMessage.id } -> - mapped + confirmedMessage + resolvedConfirmed.id > 0 && mapped.none { it.id == resolvedConfirmed.id } -> + mapped + resolvedConfirmed else -> mapped } currentState.copy(messages = sortMessagesForChatDisplay(messages)) } scope.launch(Dispatchers.Default) { - runCatching { onOptimisticMessageConfirmed(tempId, confirmedMessage) } + val resolved = _state.messages.find { it.client_message_id == tempId } + ?: _state.messages.find { it.id == confirmedMessage.id } + val toPersist = resolved ?: confirmedMessage + runCatching { onOptimisticMessageConfirmed(tempId, toPersist) } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index 3b1dbd0..08e72df 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -20,6 +20,9 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -69,6 +72,7 @@ import ru.fromchat.api.calls.CallStore import ru.fromchat.api.local.WebSocketManager import ru.fromchat.api.local.db.store.ConnectionStateStore import ru.fromchat.api.local.db.store.ConnectionStatus +import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.UserStatusStore import ru.fromchat.api.local.download.SavableMessageImage @@ -94,6 +98,8 @@ import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.websocket.WebSocketMessage import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData import ru.fromchat.back +import ru.fromchat.action_delete_chat +import ru.fromchat.ui.profile.peerIsDeleted import ru.fromchat.cd_call import ru.fromchat.chat_group_label import ru.fromchat.status_connecting @@ -104,6 +110,7 @@ import ru.fromchat.ui.chat.utils.getImageAspectRatio import ru.fromchat.ui.chat.utils.getImageDimensions import ru.fromchat.ui.chat.utils.imageAttachmentKey import ru.fromchat.ui.chat.utils.visibleMessageIdsInChatList +import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.SuspendedAccountSupportSheet import ru.fromchat.utils.NetworkConnectivity import ru.fromchat.utils.formatLastSeen @@ -167,6 +174,24 @@ fun ChatScreen( val online by NetworkConnectivity.isOnline.collectAsState(initial = true) val suspensionState by ApiClient.suspensionState.collectAsState() val isReadOnly = suspensionState.isSuspended + val dmRecipientId = panel.getRecipientId() + var peerDeleted by remember(dmRecipientId) { mutableStateOf(false) } + val deleteChatLabel = stringResource(Res.string.action_delete_chat) + LaunchedEffect(dmRecipientId) { + val userId = dmRecipientId ?: return@LaunchedEffect + peerDeleted = peerIsDeleted(userId = userId, currentUserId = currentUserId) + if (!peerDeleted) { + runCatching { ApiClient.getProfileById(userId) }.onSuccess { profile -> + ProfileCache.put(profile) + peerDeleted = peerIsDeleted( + userId = userId, + currentUserId = currentUserId, + deleted = profile.deleted, + username = profile.username, + ) + } + } + } val lastSeenFormat = rememberLastSeenFormatStrings() var showSuspendedSupportSheet by remember { mutableStateOf(false) } val statusConnecting = stringResource(Res.string.status_connecting) @@ -507,6 +532,36 @@ fun ChatScreen( ) } ) { + if (peerDeleted && dmRecipientId != null) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 8.dp), + ) { + Button( + onClick = { + scope.launch { + val messages = runCatching { + MessageRepository.loadDmMessages(dmRecipientId) + }.getOrDefault(emptyList()).filter { it.id > 0 } + messages.forEach { msg -> + runCatching { ApiClient.deleteDm(msg.id, dmRecipientId) } + } + runCatching { MessageRepository.deleteDmConversation(dmRecipientId) } + navController.popBackStack() + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(deleteChatLabel) + } + } + } else { ChatInput( text = inputText, onTextChange = { inputText = it }, @@ -632,6 +687,7 @@ fun ChatScreen( } } ) + } } } ) { innerPadding -> @@ -775,6 +831,7 @@ fun ChatScreen( sharedAvatarKey = sharedAvatarKey, subtitleKey = subtitleKey, currentTypingUsers = currentTypingUsers, + typingShowsUsernames = panel.usesPublicGroupSubtitle, statusConnecting = statusConnecting, statusUpdating = statusUpdating, chatGroupLabel = chatGroupLabel, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt index 7146abd..0a5a932 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt @@ -65,7 +65,10 @@ import ru.fromchat.ui.chat.utils.TypingUser import ru.fromchat.ui.components.ConnectingEllipsis import ru.fromchat.ui.components.Text import ru.fromchat.ui.profile.StatusBadge +import ru.fromchat.ui.profile.peerIsDeleted import ru.fromchat.ui.profile.resolveVerificationStatus +import ru.fromchat.api.local.db.store.ProfileCache +import ru.fromchat.api.ApiClient import com.pr0gramm3r101.utils.scaleOnPress import kotlin.math.PI import kotlin.math.tan @@ -85,11 +88,19 @@ fun ChatTopBarInner( sharedAvatarKey: Any?, subtitleKey: String, currentTypingUsers: List, + typingShowsUsernames: Boolean = true, statusConnecting: String, statusUpdating: String, chatGroupLabel: String, modifier: Modifier = Modifier, ) { + val isDeletedPeer = profileUserId?.let { userId -> + peerIsDeleted( + userId = userId, + currentUserId = ApiClient.user?.id, + username = titleAvatar?.displayName ?: title, + ) + } == true Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, @@ -118,6 +129,8 @@ fun ChatTopBarInner( animatedVisibilityScope = animatedVisibilityScope, ) .size(40.dp), + isDeletedUser = isDeletedPeer, + userId = profileUserId, ) } @@ -130,6 +143,8 @@ fun ChatTopBarInner( profilePictureUrl = avatar.profilePictureUrl, displayName = avatar.displayName, modifier = Modifier.size(40.dp), + isDeletedUser = isDeletedPeer, + userId = profileUserId, ) Spacer(modifier = Modifier.width(6.dp)) @@ -244,6 +259,7 @@ fun ChatTopBarInner( key == "typing" -> { TypingIndicator( typingUsers = currentTypingUsers.map { it.username }, + showUsernames = typingShowsUsernames, modifier = Modifier.padding(top = 2.dp), ) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt index ac6acc4..83f292a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt @@ -6,37 +6,41 @@ import ru.fromchat.Res import ru.fromchat.api.ApiClient import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.schema.messages.Message -import ru.fromchat.api.local.db.store.visibleUsername import ru.fromchat.api.local.db.store.visibleDisplayName +import ru.fromchat.ui.profile.avatarLabelForInitials import ru.fromchat.message_sender_you -import ru.fromchat.user_fallback +import ru.fromchat.ui.profile.deletedUserDisplayNameForUi +import ru.fromchat.ui.profile.isDeletedAccount +import ru.fromchat.ui.profile.isDeletedAccountUsername +import ru.fromchat.ui.profile.peerIsDeleted private val userIdUsernamePattern = Regex("^User (\\d+)$") /** - * Resolves [Message.username] for display: localized «Вы», «Пользователь N», or server-provided name. + * Resolves [Message.username] for display: localized «Вы», deleted user label, or server-provided name. */ @Composable fun messageDisplayUsername(message: Message, currentUserId: Int?): String { if (currentUserId != null && message.user_id == currentUserId) { return stringResource(Res.string.message_sender_you) } - val cachedProfile = ProfileCache.get(message.user_id) - val isCachedUserHidden = cachedProfile?.let { - it.id != currentUserId && (it.deleted == true || it.suspended == true) - } == true - if (isCachedUserHidden) { - return stringResource(Res.string.user_fallback, message.user_id) + ProfileCache.get(message.user_id)?.let { profile -> + if (profile.isDeletedAccount(currentUserId) || isDeletedAccountUsername(profile.username)) { + return deletedUserDisplayNameForUi() + } } - val cachedUsername = cachedProfile?.visibleUsername(currentUserId) + if (isDeletedAccountUsername(message.username)) { + return deletedUserDisplayNameForUi() + } + val cachedUsername = ProfileCache.get(message.user_id)?.visibleDisplayName(currentUserId) if (cachedUsername != null) return cachedUsername if (message.username.equals("deleted", ignoreCase = true)) { - return stringResource(Res.string.user_fallback, message.user_id) + return deletedUserDisplayNameForUi() } val m = userIdUsernamePattern.matchEntire(message.username) if (m != null) { val id = m.groupValues[1].toIntOrNull() - if (id != null) return stringResource(Res.string.user_fallback, id) + if (id != null) return deletedUserDisplayNameForUi() } return message.username } @@ -45,6 +49,9 @@ fun messageSenderProfilePicture( message: Message, currentUserId: Int? = ApiClient.user?.id, ): String? { + if (ProfileCache.get(message.user_id)?.isDeletedAccount(currentUserId) == true) { + return null + } if (currentUserId != null && message.user_id == currentUserId) { return message.profile_picture?.takeIf { it.isNotBlank() } ?: ApiClient.user?.profile_picture?.takeIf { it.isNotBlank() } @@ -53,14 +60,24 @@ fun messageSenderProfilePicture( ?: ProfileCache.get(message.user_id)?.profilePicture?.takeIf { it.isNotBlank() } } +fun messageSenderIsDeleted(message: Message, currentUserId: Int? = ApiClient.user?.id): Boolean = + peerIsDeleted( + userId = message.user_id, + currentUserId = currentUserId, + username = message.username, + ) + fun messageSenderAvatarLabel( message: Message, currentUserId: Int? = ApiClient.user?.id, ): String { + if (messageSenderIsDeleted(message, currentUserId)) return "" + ProfileCache.get(message.user_id) + ?.avatarLabelForInitials(currentUserId) + ?.takeIf { it.isNotBlank() } + ?.let { return it } if (currentUserId != null && message.user_id == currentUserId) { - return ApiClient.user?.username?.takeIf { it.isNotBlank() }.orEmpty() - } - return message.username.trim().ifBlank { - ProfileCache.get(message.user_id)?.visibleDisplayName(currentUserId).orEmpty() + return ApiClient.user?.displayName?.trim()?.takeIf { it.isNotBlank() }.orEmpty() } + return message.username.trim() } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt index 5ef52bc..f14c309 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -57,8 +56,8 @@ import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res import ru.fromchat.api.local.cache.DecryptedImageCache import ru.fromchat.api.local.db.store.ProfileCache -import ru.fromchat.api.local.db.store.visibleUsername -import ru.fromchat.api.local.messages.formatMessageTimeLocal +import ru.fromchat.ui.chat.messageSenderAvatarLabel +import ru.fromchat.api.local.messages.formatMessageBubbleTimeLocal import ru.fromchat.api.local.messages.isQueuedOutbound import ru.fromchat.api.schema.messages.Message import ru.fromchat.message_corrupted @@ -69,6 +68,7 @@ import ru.fromchat.ui.chat.components.getReplyMessageGradient import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage import ru.fromchat.ui.chat.utils.imageAttachmentKey import ru.fromchat.ui.components.Text +import ru.fromchat.ui.isAppInDarkTheme import ru.fromchat.ui.profile.StatusBadge import ru.fromchat.ui.profile.resolveVerificationStatus @@ -119,7 +119,7 @@ fun MessageItem( isMessageCorrupted(message) } val formattedTime = remember(message.timestamp) { - formatMessageTimeLocal(message.timestamp) + formatMessageBubbleTimeLocal(message.timestamp) } val corruptedBody = stringResource(Res.string.message_corrupted) val editedSuffix = stringResource(Res.string.message_edited_suffix) @@ -128,9 +128,9 @@ fun MessageItem( val senderProfile = ProfileCache.get(message.user_id) val avatarPictureUrl = senderProfile?.profilePicture?.takeIf { it.isNotBlank() } ?: message.profile_picture - val avatarDisplayName = senderProfile?.visibleUsername(currentUserId)?.takeIf { it.isNotBlank() } - ?: message.username + val avatarDisplayName = messageSenderAvatarLabel(message, currentUserId) val senderVerificationStatus = resolveVerificationStatus(message.user_id, message) + val isDeletedSender = messageSenderIsDeleted(message, currentUserId) val replyRef = message.reply_to // No AnimatedVisibility here: visible=true still ran enter transitions for every item on first @@ -193,7 +193,9 @@ fun MessageItem( Avatar( profilePictureUrl = avatarPictureUrl, displayName = avatarDisplayName, - modifier = Modifier.size(32.dp) + modifier = Modifier.size(32.dp), + isDeletedUser = isDeletedSender, + userId = message.user_id, ) } @@ -210,7 +212,7 @@ fun MessageItem( horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start ) { // Message bubble - val isDark = isSystemInDarkTheme() + val isDark = isAppInDarkTheme() val pendingIsImage = when { message.pendingFilename?.isNotBlank() == true -> isImageFilename(message.pendingFilename) message.pendingFileUri != null -> isImageFilename( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingIndicator.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingIndicator.kt index e386e01..b972e62 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingIndicator.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingIndicator.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res +import ru.fromchat.typing_alone import ru.fromchat.typing_many import ru.fromchat.typing_single import ru.fromchat.typing_two @@ -29,6 +30,7 @@ import ru.fromchat.typing_two @Composable fun TypingIndicator( typingUsers: List, + showUsernames: Boolean = true, modifier: Modifier = Modifier ) { if (typingUsers.isEmpty()) return @@ -40,7 +42,7 @@ fun TypingIndicator( TypingDots() Spacer(modifier = Modifier.width(8.dp)) Text( - text = formatTypingText(typingUsers), + text = formatTypingText(typingUsers, showUsernames), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary ) @@ -48,11 +50,13 @@ fun TypingIndicator( } @Composable -private fun formatTypingText(typingUsers: List): String { - return when (typingUsers.size) { - 0 -> "" - 1 -> stringResource(Res.string.typing_single, typingUsers[0]) - 2 -> stringResource(Res.string.typing_two, typingUsers[0], typingUsers[1]) +private fun formatTypingText(typingUsers: List, showUsernames: Boolean): String { + return when { + typingUsers.isEmpty() -> "" + !showUsernames && typingUsers.size == 1 -> + stringResource(Res.string.typing_alone) + typingUsers.size == 1 -> stringResource(Res.string.typing_single, typingUsers[0]) + typingUsers.size == 2 -> stringResource(Res.string.typing_two, typingUsers[0], typingUsers[1]) else -> stringResource( Res.string.typing_many, typingUsers[0], diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt index 98087ee..cfb691c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt @@ -28,7 +28,8 @@ import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.dm.DmEnvelope import ru.fromchat.api.schema.websocket.WebSocketMessage import ru.fromchat.api.schema.websocket.types.DmDeletedData -import ru.fromchat.api.local.db.store.visibleDisplayName +import ru.fromchat.ui.profile.displayNameText +import ru.fromchat.ui.profile.isDeletedAccount import ru.fromchat.Logger import ru.fromchat.config.ServerConfig import ru.fromchat.api.crypto.CorruptedDmMessagePlaceholder @@ -106,42 +107,55 @@ class DmPanel( if (_state.title.isBlank()) { loadPeerTitleFromConversationCache() } - runCatching { - ApiClient.getProfileById(otherUserId) - }.onSuccess { profile -> - if (profile.username.isBlank() && profile.displayName.isNullOrBlank()) { + try { + val profile = ApiClient.getProfileById(otherUserId) + if ( + !profile.isDeletedAccount(ApiClient.user?.id) && + profile.username.isBlank() && + profile.displayName.isNullOrBlank() + ) { ProfileCache.evictUnusableClientPreview(otherUserId) if (_state.title.isBlank()) { + withContext(Dispatchers.Main) { + updateState { + it.copy(title = "", titleAvatar = null, profileUserId = otherUserId) + } + } + } + return@launch + } + ProfileCache.put(profile) + val displayName = profile.displayNameText(ApiClient.user?.id) + if (displayName.isNotBlank()) { + withContext(Dispatchers.Main) { + applyPeerTitle(displayName, profile.profilePicture) + } + } + } catch (_: Throwable) { + ProfileCache.evictUnusableClientPreview(otherUserId) + if (_state.title.isBlank()) { + withContext(Dispatchers.Main) { updateState { it.copy(title = "", titleAvatar = null, profileUserId = otherUserId) } } - return@onSuccess - } - ProfileCache.put(profile) - val displayName = profile.visibleDisplayName(ApiClient.user?.id).orEmpty() - if (displayName.isNotBlank()) { - applyPeerTitle(displayName, profile.profilePicture) - } - }.onFailure { - ProfileCache.evictUnusableClientPreview(otherUserId) - if (_state.title.isBlank()) { - updateState { - it.copy(title = "", titleAvatar = null, profileUserId = otherUserId) - } } } } } private fun applyCachedPeerProfileOrReset() { - val cached = ProfileCache.get(otherUserId) - val displayName = cached?.visibleDisplayName(ApiClient.user?.id).orEmpty() - if (displayName.isNotBlank()) { - applyPeerTitle(displayName, cached?.profilePicture) - } else { - updateState { - it.copy(title = "", titleAvatar = null, profileUserId = otherUserId) + scope.launch(Dispatchers.Default) { + val cached = ProfileCache.get(otherUserId) + val displayName = cached?.displayNameText(ApiClient.user?.id).orEmpty() + withContext(Dispatchers.Main) { + if (displayName.isNotBlank()) { + applyPeerTitle(displayName, cached?.profilePicture) + } else { + updateState { + it.copy(title = "", titleAvatar = null, profileUserId = otherUserId) + } + } } } } @@ -423,6 +437,9 @@ class DmPanel( pendingFileAspectRatio = aspect, fileAspectRatios = confirmed.fileAspectRatios ?: aspect?.let { listOf(it) }, fileDimensions = confirmed.fileDimensions ?: stateSourceBeforeMerge?.fileDimensions, + reply_to = envelope.replyToId?.let { replyId -> + _state.messages.find { it.id == replyId } + } ?: stateSourceBeforeMerge?.reply_to, ) val mergedForPersistence = merged.copy(pendingFilename = null) AttachmentMediaLog.persist( @@ -461,10 +478,6 @@ class DmPanel( val deduped = dedupeMessagesByClientId(newMessages) currentState.copy(messages = deduped) } - if (envelope.replyToId != null) { - val replyTo = _state.messages.find { it.id == envelope.replyToId } - updateMessage(envelope.id) { it.copy(reply_to = replyTo) } - } } if (cid.isNotEmpty()) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt index 00c0f07..40f0969 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt @@ -87,6 +87,9 @@ import ru.fromchat.ui.chat.TypingIndicator import ru.fromchat.ui.components.Text import ru.fromchat.unread_count_badge import ru.fromchat.unread_count_overflow +import ru.fromchat.ui.profile.deletedUserDisplayNameForUi +import ru.fromchat.ui.profile.displayNameForUi +import ru.fromchat.ui.profile.peerIsDeleted import ru.fromchat.user_fallback internal object ChatListLayout { @@ -394,12 +397,33 @@ internal fun SearchConversationsList( resultIndex++ item { val cached = ProfileCache.get(user.id) - val avatarUrl = cached?.profilePicture ?: user.profile_picture - val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() } - ?: user.displayName?.takeIf { it.isNotBlank() } - ?: cached?.visibleUsername(ApiClient.user?.id) - ?: user.username - val username = cached?.visibleUsername(ApiClient.user?.id) ?: user.username + val isPeerDeleted = peerIsDeleted( + userId = user.id, + currentUserId = ApiClient.user?.id, + deleted = user.deleted ?: cached?.deleted, + username = user.username, + ) + val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture ?: user.profile_picture + val peerTitle = if (isPeerDeleted) { + deletedUserDisplayNameForUi() + } else { + cached?.displayName?.takeIf { it.isNotBlank() } + ?: user.displayName?.takeIf { it.isNotBlank() } + ?: cached?.visibleUsername(ApiClient.user?.id) + ?: user.username + } + val avatarInitialsLabel = if (isPeerDeleted) { + deletedUserDisplayNameForUi() + } else { + cached?.displayName?.takeIf { it.isNotBlank() } + ?: user.displayName?.takeIf { it.isNotBlank() } + ?: "" + } + val username = if (isPeerDeleted) { + deletedUserDisplayNameForUi() + } else { + cached?.visibleUsername(ApiClient.user?.id) ?: user.username + } ChatRowScaleContainer( listItemPosition = position, @@ -418,11 +442,13 @@ internal fun SearchConversationsList( leadingContent = { ChatRowAvatar( profilePictureUrl = avatarUrl, - displayNameForInitials = peerTitle, + displayNameForInitials = avatarInitialsLabel, enabled = false, onPressStart = {}, onPressEnd = {}, onLongPress = {}, + isDeletedUser = isPeerDeleted, + userId = user.id, ) }, ) @@ -503,6 +529,8 @@ internal fun ChatRowAvatar( modifier: Modifier = Modifier, showOnlineIndicator: Boolean = false, onlineIndicatorBorderColor: Color = MaterialTheme.colorScheme.surfaceContainerLow, + isDeletedUser: Boolean = false, + userId: Int? = null, ) { Box( modifier @@ -526,6 +554,8 @@ internal fun ChatRowAvatar( profilePictureUrl = profilePictureUrl, displayName = displayNameForInitials, modifier = Modifier.fillMaxSize(), + isDeletedUser = isDeletedUser, + userId = userId, ) if (showOnlineIndicator) { Box( @@ -830,13 +860,27 @@ internal fun DmConversationRowContent( onBodyLongPress: () -> Unit, modifier: Modifier = Modifier, ) { + val currentUserId = ApiClient.user?.id val cached = ProfileCache.get(conversation.otherUserId) - val avatarUrl = cached?.profilePicture - val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() } - ?: cached?.visibleUsername(ApiClient.user?.id) - ?: conversation.displayName.ifBlank { - stringResource(Res.string.user_fallback, conversation.otherUserId) - } + val isPeerDeleted = peerIsDeleted( + userId = conversation.otherUserId, + currentUserId = currentUserId, + deleted = cached?.deleted, + username = cached?.username ?: conversation.displayName.takeIf { it.isNotBlank() }, + ) + val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture + val peerTitle = when { + isPeerDeleted -> deletedUserDisplayNameForUi() + !cached?.displayName.isNullOrBlank() -> cached.displayName!!.trim() + conversation.displayName.isNotBlank() -> conversation.displayName + else -> stringResource(Res.string.user_fallback, conversation.otherUserId) + } + val avatarInitialsLabel = when { + isPeerDeleted -> deletedUserDisplayNameForUi() + !cached?.displayName.isNullOrBlank() -> cached.displayName!!.trim() + conversation.displayName.isNotBlank() -> conversation.displayName + else -> "" + } val preview = conversation.lastMessagePreview?.trim().orEmpty().ifEmpty { defaultLastMessage } val status = statusMap[conversation.otherUserId] val typingUsers = status?.typingUsernames.orEmpty() @@ -869,13 +913,15 @@ internal fun DmConversationRowContent( ) ChatRowAvatar( profilePictureUrl = avatarUrl, - displayNameForInitials = peerTitle, + displayNameForInitials = avatarInitialsLabel, enabled = avatarEnabled, onPressStart = onAvatarPressStart, onPressEnd = onAvatarPressEnd, onLongPress = onAvatarLongPress, showOnlineIndicator = isOnline, onlineIndicatorBorderColor = listSurfaceColor, + isDeletedUser = isPeerDeleted, + userId = conversation.otherUserId, ) } }, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt index 41a38c5..1332648 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt @@ -30,6 +30,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.rounded.Block +import com.pr0gramm3r101.components.ListItem +import com.pr0gramm3r101.components.ListItemPosition import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.rounded.Delete import androidx.compose.material3.AlertDialog @@ -112,8 +115,7 @@ import ru.fromchat.public_chat import ru.fromchat.search_title import ru.fromchat.status_connecting import ru.fromchat.status_updating -import ru.fromchat.suspend_chat_banner_message -import ru.fromchat.suspended_default_reason +import ru.fromchat.account_suspended import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.chat.panels.dm.DmNav import ru.fromchat.ui.components.BackHandler @@ -122,8 +124,7 @@ import ru.fromchat.ui.components.ConnectingEllipsis import ru.fromchat.ui.components.PredictiveBackHandler import ru.fromchat.ui.components.SearchBar import ru.fromchat.ui.components.SearchBarSharedElement -import ru.fromchat.ui.components.SuspendedAccountBannerStyle -import ru.fromchat.ui.components.SuspendedAccountNoticeHost +import ru.fromchat.ui.components.SuspendedAccountSupportSheet import ru.fromchat.ui.components.Text import ru.fromchat.utils.NetworkConnectivity import ru.fromchat.utils.haptic.HapticFeedbackEvent @@ -250,7 +251,7 @@ private fun ChatsSelectionTopBar( Box { IconButton(onClick = { overflowOpen = true }) { Icon( - imageVector = Icons.Default.MoreVert, + imageVector = Icons.Filled.MoreVert, contentDescription = moreActionsCd, ) } @@ -330,6 +331,7 @@ fun ChatsTab( var subscribedDmUserIds by remember { mutableStateOf>(emptySet()) } val statusSubscriptionScope = rememberCoroutineScope() val suspensionState by ApiClient.suspensionState.collectAsState() + var showSuspendedSupportSheet by remember { mutableStateOf(false) } val defaultLastMessage = stringResource(Res.string.chat_last_mesaage) LaunchedEffect(previewStrings.imageOnly, previewStrings.attachmentOnly) { @@ -519,7 +521,7 @@ fun ChatsTab( } LaunchedEffect(serverConfig, activeInstanceId, connectionStatus) { - if (activeInstanceId.isBlank()) return@LaunchedEffect + if (activeInstanceId.isBlank() || connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect runCatching { ApiClient.getDmConversations() @@ -546,8 +548,7 @@ fun ChatsTab( val updatingTitle = stringResource(Res.string.status_updating) val selectedCount = selectedOtherUserIds.size + if (publicChatSelected) 1 else 0 val selectedCountTitle = stringResource(Res.string.chats_selected_count, selectedCount) - val suspendBannerTitle = stringResource(Res.string.suspend_chat_banner_message) - val suspendDefaultReason = stringResource(Res.string.suspended_default_reason) + val accountSuspendedTitle = stringResource(Res.string.account_suspended) val publicChatFallbackTitle = stringResource(Res.string.public_chat) val publicChatTitle = publicChatProfile?.title?.takeIf { it.isNotBlank() } ?: publicChatFallbackTitle.takeIf { activeInstanceId.isNotBlank() } @@ -696,15 +697,25 @@ fun ChatsTab( } } - SuspendedAccountNoticeHost( - isSuspended = suspensionState.isSuspended, - reason = suspensionState.reason, - fallbackReason = suspendDefaultReason, - bannerTitle = suspendBannerTitle, - style = SuspendedAccountBannerStyle.Tabs, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - ) - + if (suspensionState.isSuspended) { + ListItem( + headline = accountSuspendedTitle, + position = ListItemPosition.START, + groupItemCount = 1, + divider = false, + leadingContent = { + Icon( + imageVector = Icons.Rounded.Block, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + onClick = { showSuspendedSupportSheet = true }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + ) + } else { ChatConversationsList( listState = tabListState, listFilter = ChatListFilter.Active, @@ -797,6 +808,7 @@ fun ChatsTab( } }, ) + } } } @@ -903,6 +915,11 @@ fun ChatsTab( } } } + + SuspendedAccountSupportSheet( + isVisible = showSuspendedSupportSheet, + onDismissRequest = { showSuspendedSupportSheet = false }, + ) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogsScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogsScreen.kt index 63ee86e..1230fdc 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogsScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogsScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.size @@ -37,6 +38,9 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.selection.DisableSelection import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -54,6 +58,7 @@ import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Report +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.automirrored.filled.Subject import androidx.compose.material.icons.filled.Sync @@ -98,7 +103,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollConnection @@ -108,12 +116,17 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import com.pr0gramm3r101.components.Category +import com.pr0gramm3r101.utils.ToggleNavScrimEffect import com.pr0gramm3r101.components.ListItem +import com.pr0gramm3r101.utils.resetFocus import com.pr0gramm3r101.utils.supportClipboardManagerImpl import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -158,6 +171,8 @@ import ru.fromchat.logs_rotate import ru.fromchat.logs_rotate_confirm_body import ru.fromchat.logs_rotate_confirm_title import ru.fromchat.logs_scroll_to_bottom_cd +import ru.fromchat.logs_search +import ru.fromchat.logs_search_hint import ru.fromchat.logs_selected_count import ru.fromchat.logs_share import ru.fromchat.logs_share_compressed @@ -167,6 +182,7 @@ import ru.fromchat.logs_share_uncompressed import ru.fromchat.logs_share_uncompressed_desc import ru.fromchat.logs_title import ru.fromchat.more +import ru.fromchat.search_not_found import ru.fromchat.logging.AppLogEntry import ru.fromchat.logging.AppLogLevel import ru.fromchat.logging.AppLogStore @@ -218,6 +234,8 @@ fun LogsScreen() { val clipboard = supportClipboardManagerImpl val haptic = rememberHapticFeedback() val density = LocalDensity.current + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current val topAppBarState = rememberTopAppBarState() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topAppBarState) val listState = rememberLazyListState() @@ -246,7 +264,21 @@ fun LogsScreen() { var listMode by remember { mutableStateOf(LogsListMode.Normal) } var selectedEntryIds by remember { mutableStateOf>(emptySet()) } + var searchMode by remember { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf("") } + val listEntries = remember(displayEntries, searchMode, searchQuery) { + if (!searchMode || searchQuery.isBlank()) { + displayEntries + } else { + val needle = searchQuery.lowercase() + displayEntries.filter { entry -> + entry.displayText().lowercase().contains(needle) + } + } + } val selectionTransitionProgress = remember { Animatable(0f) } + val searchTransitionProgress = remember { Animatable(0f) } + val searchFocusRequester = remember { FocusRequester() } val gestureState = rememberLogsListGestureState() var dragAnchorIndex by remember { mutableIntStateOf(-1) } var dragLastY by remember { mutableFloatStateOf(0f) } @@ -273,13 +305,22 @@ fun LogsScreen() { val shareTitle = stringResource(Res.string.logs_title) val selectionMode = listMode == LogsListMode.Selecting val selectionProgress = selectionTransitionProgress.value - val showBrowseFab = AppLogStore.hasFilesBesidesCurrent() && !selectionMode + val searchProgress = searchTransitionProgress.value + val showBrowseFab = AppLogStore.hasFilesBesidesCurrent() && !selectionMode && !searchMode val showScrollToBottomFab = isViewingCurrent && !selectionMode && + !searchMode && displayEntries.isNotEmpty() && !isAtBottom && !isProgrammaticScroll val scrollToBottomCd = stringResource(Res.string.logs_scroll_to_bottom_cd) + val logsSearchLabel = stringResource(Res.string.logs_search) + val logsSearchHint = stringResource(Res.string.logs_search_hint) + val searchNotFoundLabel = stringResource(Res.string.search_not_found) + + val hideIme: () -> Unit = { + resetFocus(keyboardController, focusManager) + } val selectedCountTitle = stringResource(Res.string.logs_selected_count, selectedEntryIds.size) val closeSelectionCd = stringResource(Res.string.cd_close_selection) @@ -287,6 +328,20 @@ fun LogsScreen() { val shareLabel = stringResource(Res.string.logs_share) val deleteLabel = stringResource(Res.string.action_delete) + fun exitSearchMode() { + searchQuery = "" + searchMode = false + hideIme() + scope.launch { searchTransitionProgress.snapTo(0f) } + } + + fun requestExitSearchMode() { + scope.launch { + searchTransitionProgress.animateTo(0f, ChatSelectionTransitionSpring) + exitSearchMode() + } + } + fun scrollToLatestLogs() { if (displayEntries.isEmpty()) return scope.launch { @@ -339,6 +394,14 @@ fun LogsScreen() { } } + fun enterSearchMode() { + if (selectionMode) { + exitEntrySelection() + } + scope.launch { searchTransitionProgress.snapTo(0f) } + searchMode = true + } + fun performShare(compression: LogShareCompression) { val request = pendingShareRequest ?: return scope.launch { @@ -361,19 +424,20 @@ fun LogsScreen() { } fun applyDragSelectionRange(toIndex: Int) { + if (searchMode) return val anchor = dragAnchorIndex if (anchor < 0 || toIndex < 0) return val start = minOf(anchor, toIndex) val end = maxOf(anchor, toIndex) - selectedEntryIds = displayEntries.subList(start, end + 1).map { it.id }.toSet() + selectedEntryIds = listEntries.subList(start, end + 1).map { it.id }.toSet() } fun beginDragSelection(index: Int) { - if (index !in displayEntries.indices) return + if (searchMode || index !in listEntries.indices) return gestureState.onDragSelectionStart() dragAnchorIndex = index if (!selectionMode) { - enterEntrySelection(displayEntries[index].id) + enterEntrySelection(listEntries[index].id) } else { applyDragSelectionRange(index) } @@ -414,11 +478,12 @@ fun LogsScreen() { } } - LaunchedEffect(displayEntries.size, isViewingCurrent, followLatest, selectionMode) { + LaunchedEffect(displayEntries.size, isViewingCurrent, followLatest, selectionMode, searchMode) { if ( isViewingCurrent && displayEntries.isNotEmpty() && !selectionMode && + !searchMode && followLatest ) { isProgrammaticScroll = true @@ -462,8 +527,8 @@ fun LogsScreen() { } } - LaunchedEffect(selectionMode) { - if (selectionMode) { + LaunchedEffect(selectionMode, searchMode) { + if (selectionMode || searchMode) { followLatest = false } else if (isAtBottom) { followLatest = true @@ -476,6 +541,13 @@ fun LogsScreen() { } } + LaunchedEffect(searchMode) { + if (searchMode) { + searchTransitionProgress.animateTo(1f, ChatSelectionTransitionSpring) + searchFocusRequester.requestFocus() + } + } + LaunchedEffect(selectedEntryIds, listMode) { if (listMode == LogsListMode.Selecting && selectedEntryIds.isEmpty()) { requestExitEntrySelection() @@ -483,7 +555,10 @@ fun LogsScreen() { } DisposableEffect(Unit) { - onDispose { exitEntrySelection() } + onDispose { + exitEntrySelection() + exitSearchMode() + } } if (showDecompressDialog) { @@ -606,6 +681,7 @@ fun LogsScreen() { } BackHandler(enabled = selectionMode) { requestExitEntrySelection() } + BackHandler(enabled = searchMode && !selectionMode) { requestExitSearchMode() } PredictiveBackHandler( enabled = selectionMode, onProgress = { backProgress -> @@ -622,7 +698,26 @@ fun LogsScreen() { } }, ) + PredictiveBackHandler( + enabled = searchMode && !selectionMode, + onProgress = { backProgress -> + scope.launch { + searchTransitionProgress.snapTo((1f - backProgress).coerceIn(0f, 1f)) + } + }, + onCommit = { requestExitSearchMode() }, + onCancel = { + if (searchMode) { + scope.launch { + searchTransitionProgress.animateTo(1f, ChatSelectionTransitionSpring) + } + } + }, + ) + if (searchMode) { + ToggleNavScrimEffect() + } Scaffold( modifier = Modifier @@ -632,7 +727,7 @@ fun LogsScreen() { contentWindowInsets = WindowInsets.navigationBars, floatingActionButtonPosition = FabPosition.End, floatingActionButton = { - val fabReveal = (1f - selectionProgress).coerceIn(0f, 1f) + val fabReveal = ((1f - selectionProgress) * (1f - searchProgress)).coerceIn(0f, 1f) Column( horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(12.dp), @@ -657,11 +752,13 @@ fun LogsScreen() { topBar = { Box { TopAppBar( - modifier = Modifier.graphicsLayer { alpha = 1f - selectionProgress }, + modifier = Modifier.graphicsLayer { + alpha = (1f - selectionProgress) * (1f - searchProgress) + }, navigationIcon = { IconButton( onClick = { navController.navigateUp() }, - enabled = selectionProgress < 1f, + enabled = selectionProgress < 1f && searchProgress < 1f, ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, @@ -682,7 +779,7 @@ fun LogsScreen() { } }, actions = { - if (isViewingCurrent && !selectionMode) { + if (isViewingCurrent && !selectionMode && searchProgress < 1f) { IconButton( onClick = { pendingShareRequest = LogsShareRequest( @@ -699,7 +796,10 @@ fun LogsScreen() { } } Box { - IconButton(onClick = { menuExpanded = true }) { + IconButton( + onClick = { menuExpanded = true }, + enabled = searchProgress < 1f, + ) { Icon( imageVector = Icons.Default.MoreVert, contentDescription = stringResource(Res.string.more), @@ -709,6 +809,16 @@ fun LogsScreen() { expanded = menuExpanded, onDismissRequest = { menuExpanded = false }, ) { + DropdownMenuItem( + text = { Text(logsSearchLabel) }, + leadingIcon = { + Icon(Icons.Default.Search, contentDescription = null) + }, + onClick = { + menuExpanded = false + enterSearchMode() + }, + ) if (isViewingCurrent) { DropdownMenuItem( text = { Text(stringResource(Res.string.logs_rotate)) }, @@ -750,6 +860,66 @@ fun LogsScreen() { }, scrollBehavior = scrollBehavior, ) + if (searchMode || searchProgress > 0f) { + TopAppBar( + modifier = Modifier.graphicsLayer { alpha = searchProgress }, + colors = logsTransparentTopAppBarColors(), + navigationIcon = { + IconButton( + onClick = { requestExitSearchMode() }, + enabled = searchProgress > 0f, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.back), + ) + } + }, + title = { + BasicTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + singleLine = true, + textStyle = MaterialTheme.typography.titleLarge.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { hideIme() }), + modifier = Modifier + .fillMaxWidth() + .focusRequester(searchFocusRequester), + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.CenterStart) { + if (searchQuery.isEmpty()) { + Text( + text = logsSearchHint, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + innerTextField() + } + }, + ) + }, + actions = { + if (searchQuery.isNotBlank()) { + IconButton( + onClick = { searchQuery = "" }, + enabled = searchProgress > 0f, + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(Res.string.cancel), + ) + } + } + }, + ) + } if (selectionMode || selectionProgress > 0f) { TopAppBar( modifier = Modifier.graphicsLayer { alpha = selectionProgress }, @@ -806,11 +976,13 @@ fun LogsScreen() { } }, ) { innerPadding -> - if (displayEntries.isEmpty()) { + when { + displayEntries.isEmpty() -> { Column( modifier = Modifier .fillMaxSize() - .padding(innerPadding), + .padding(innerPadding) + .then(if (searchMode) Modifier.imePadding() else Modifier), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -820,7 +992,26 @@ fun LogsScreen() { color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - } else { + } + + searchMode && searchQuery.isNotBlank() && listEntries.isEmpty() -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .imePadding(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = searchNotFoundLabel, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + else -> { val listContent: @Composable () -> Unit = { LazyColumn( state = listState, @@ -837,7 +1028,7 @@ fun LogsScreen() { verticalArrangement = Arrangement.spacedBy(8.dp), ) { itemsIndexed( - items = displayEntries, + items = listEntries, key = { _, entry -> entry.id }, ) { index, entry -> LogEntryRow( @@ -861,14 +1052,23 @@ fun LogsScreen() { Column( modifier = Modifier .fillMaxSize() - .padding(innerPadding), + .padding(innerPadding) + .then(if (searchMode) Modifier.imePadding() else Modifier), ) { listContent() } + } } } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun logsTransparentTopAppBarColors() = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent, +) + @Composable internal fun LogsAnimatedFab( visible: Boolean, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt index ed1268f..59ae0bc 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -33,6 +34,7 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res import ru.fromchat.api.ensureFcmTokenRegistered +import ru.fromchat.api.isFcmPushRegisteredLocally import ru.fromchat.api.unregisterFcmTokenFromServer import ru.fromchat.back import ru.fromchat.error_unexpected @@ -51,7 +53,10 @@ fun NotificationsScreen(onBack: () -> Unit) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) val coroutineScope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } - var notificationsEnabled by remember { mutableStateOf(areAppNotificationsEnabled()) } + var notificationsEnabled by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + notificationsEnabled = areAppNotificationsEnabled() && isFcmPushRegisteredLocally() + } val notificationsPermissionText = stringResource(Res.string.settings_notifications_permission_required) val unexpectedErrorText = stringResource(Res.string.error_unexpected) @@ -86,23 +91,27 @@ fun NotificationsScreen(onBack: () -> Unit) { Icon(Icons.Filled.Notifications, null) }, checked = notificationsEnabled, - onCheckedChange = { + onCheckedChange = { enabled -> coroutineScope.launch { - if (!areAppNotificationsEnabled()) { - if (!openAppNotificationSettings()) { - snackbarHostState.showSnackbar(message = unexpectedErrorText) - } else { - snackbarHostState.showSnackbar(message = notificationsPermissionText) + if (enabled) { + if (!areAppNotificationsEnabled()) { + if (!openAppNotificationSettings()) { + snackbarHostState.showSnackbar(message = unexpectedErrorText) + } else { + snackbarHostState.showSnackbar(message = notificationsPermissionText) + } + return@launch } - return@launch - } - if (notificationsEnabled) { - unregisterFcmTokenFromServer() - notificationsEnabled = !notificationsEnabled + val registered = ensureFcmTokenRegistered() + if (registered) { + notificationsEnabled = true + } else { + snackbarHostState.showSnackbar(message = unexpectedErrorText) + } } else { - ensureFcmTokenRegistered() - snackbarHostState.showSnackbar(message = unexpectedErrorText) + unregisterFcmTokenFromServer() + notificationsEnabled = false } } }, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt index bcbfea7..38d3fbc 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt @@ -39,7 +39,6 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res import ru.fromchat.api.ApiClient -import ru.fromchat.api.local.WebSocketManager import ru.fromchat.back import ru.fromchat.cancel import ru.fromchat.logout @@ -139,7 +138,6 @@ fun AccountScreen( showLogoutConfirm = false scope.launch { runCatching { ApiClient.logout() } - WebSocketManager.disconnect() onLogout() } }, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/EditProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/EditProfileScreen.kt index 30aa455..72b1a68 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/EditProfileScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/EditProfileScreen.kt @@ -217,7 +217,7 @@ fun EditProfileScreen( val canSave = loaded && hasChanges && !hasValidationErrors && !busy - val avatarDisplayName = trimmedDisplayName.ifBlank { trimmedUsername }.ifBlank { "?" } + val avatarDisplayName = trimmedDisplayName.ifBlank { "?" } fun showSnack(text: String) { scope.launch { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt index a2f0cf8..c1ef900 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt @@ -150,7 +150,12 @@ import ru.fromchat.profile_load_failed import ru.fromchat.profile_not_found import ru.fromchat.profile_verified_support import ru.fromchat.profile_verify_prompt_support +import ru.fromchat.ui.profile.deletedUserDisplayNameForUi +import ru.fromchat.ui.profile.avatarLabelForInitials +import ru.fromchat.ui.profile.displayNameForUi import ru.fromchat.ui.profile.effectiveVerificationStatus +import ru.fromchat.ui.profile.isDeletedAccount +import ru.fromchat.ui.profile.peerIsDeleted import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.chat.Avatar import ru.fromchat.ui.chat.TypingIndicator @@ -196,6 +201,7 @@ private fun hasDisplayableProfile( currentUserId: Int?, ) = !initialDisplayName.isNullOrBlank() || (profile != null && profile.id > 0 && ( + profile.isDeletedAccount(currentUserId) || !profile.visibleDisplayName(currentUserId).isNullOrBlank() || profile.username.isNotBlank() )) @@ -461,12 +467,27 @@ fun ProfileScreen( val showBodySkeleton = resolvedProfile == null val showAvatarSkeleton = showBodySkeleton && !hasDisplayable val currentProfileUserId = targetUserId ?: ownUserId ?: profile?.id - val displayName = - profile?.visibleDisplayName(currentProfileUserId) + val viewerUserId = ownUserId + val resolvedUserId = resolvedProfile?.id ?: targetUserId + val isDeletedProfile = resolvedUserId != null && peerIsDeleted( + userId = resolvedUserId, + currentUserId = viewerUserId, + deleted = resolvedProfile?.deleted, + username = resolvedProfile?.username, + ) + val displayName = when { + isDeletedProfile -> deletedUserDisplayNameForUi() + else -> profile?.displayNameForUi(viewerUserId)?.takeIf { it.isNotBlank() } ?: initialDisplayName?.takeIf { it.isNotBlank() } - ?: profile?.username?.takeIf { it.isNotBlank() } ?: "" - val usernameForLinks = profile?.visibleUsername(currentProfileUserId) + } + val avatarLabel = when { + isDeletedProfile -> displayName + else -> profile?.avatarLabelForInitials(viewerUserId)?.takeIf { it.isNotBlank() } + ?: initialDisplayName?.takeIf { it.isNotBlank() } + ?: "" + } + val usernameForLinks = if (isDeletedProfile) null else profile?.visibleUsername(viewerUserId) val profileLink = resolvedProfile?.let { usernameForLinks?.let { name -> "https://fromchat.ru/@$name" } ?: "https://fromchat.ru/?u=${it.id}" @@ -507,6 +528,28 @@ fun ProfileScreen( onClick = onOpenSettings, ), ) + } else if (p.isDeletedAccount(viewerUserId)) { + listOf( + ProfileAction( + label = labelChat, + icon = Icons.AutoMirrored.Filled.Chat, + holdsExpansionOnNavigate = true, + onClick = { onChat(p.id) }, + ), + ProfileAction( + label = labelSearch, + icon = Icons.Filled.Search, + onClick = { + scope.launch { + snackbarHostState.showReplacingSnackbar( + message = notImplementedMessage, + withDismissAction = false, + duration = SnackbarDuration.Short, + ) + } + }, + ), + ) } else { buildList { add( @@ -554,8 +597,10 @@ fun ProfileScreen( val showDetailsUsername = usernameForLinks != null val showDetailsMemberSince = !resolvedProfile?.createdAt.isNullOrBlank() val showDetailsBio = !resolvedProfile?.bio.isNullOrBlank() - val showDetailsVerify = resolvedProfile?.verified == true || ApiClient.user?.id == 1 - val showDetailsSection = resolvedProfile != null && ( + val showDetailsVerify = !isDeletedProfile && ( + resolvedProfile?.verified == true || ApiClient.user?.id == 1 + ) + val showDetailsSection = resolvedProfile != null && !isDeletedProfile && ( showDetailsUsername || showDetailsMemberSince || showDetailsBio || showDetailsVerify ) @@ -596,14 +641,16 @@ fun ProfileScreen( with(sharedTransitionScope) { Avatar( profilePictureUrl = profile?.profilePicture, - displayName = displayName, + displayName = avatarLabel, modifier = Modifier .padding(top = profileAvatarTop) .sharedElement( rememberSharedContentState(key = sharedAvatarKey), animatedVisibilityScope = animatedVisibilityScope ) - .size(104.dp) + .size(104.dp), + isDeletedUser = isDeletedProfile, + userId = resolvedUserId, ) } } @@ -614,10 +661,12 @@ fun ProfileScreen( item { Avatar( profilePictureUrl = profile?.profilePicture, - displayName = displayName, + displayName = avatarLabel, modifier = Modifier .padding(top = profileAvatarTop) - .size(104.dp) + .size(104.dp), + isDeletedUser = isDeletedProfile, + userId = resolvedUserId, ) } item { Spacer(Modifier.height(12.dp)) } @@ -665,6 +714,7 @@ fun ProfileScreen( resolvedProfile = resolvedProfile!!, displayName = displayName, isOwnProfile = isOwnProfile, + isDeletedProfile = isDeletedProfile, typingUsers = typingUsers, statusState = statusState, statusText = statusText, @@ -1145,6 +1195,7 @@ private fun ProfileLoadedBody( resolvedProfile: UserProfile, displayName: String, isOwnProfile: Boolean, + isDeletedProfile: Boolean, typingUsers: List, statusState: UserStatus?, statusText: String, @@ -1204,40 +1255,46 @@ private fun ProfileLoadedBody( color = MaterialTheme.colorScheme.onSurface, ) } - StatusBadge( - verificationStatus = resolvedProfile.effectiveVerificationStatus(), - ) - } - - Spacer(Modifier.height(4.dp)) - - AnimatedContent( - targetState = when { - typingUsers.isNotEmpty() -> "typing:${typingUsers.joinToString("|")}" - statusState?.online == true -> "online" - else -> "offline" - }, - transitionSpec = { - (slideInVertically { it / 2 } + fadeIn()) togetherWith - (slideOutVertically { -it / 2 } + fadeOut()) - }, - label = "profile_status_${resolvedProfile.id}", - ) { animatedState -> - if (animatedState.startsWith("typing:")) { - TypingIndicator(typingUsers = typingUsers) - } else { - Text( - text = statusText, - style = MaterialTheme.typography.bodyMedium, - color = if (animatedState == "online") { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + if (!isDeletedProfile) { + StatusBadge( + verificationStatus = resolvedProfile.effectiveVerificationStatus(), ) } } + if (!isDeletedProfile) { + Spacer(Modifier.height(4.dp)) + + AnimatedContent( + targetState = when { + typingUsers.isNotEmpty() -> "typing:${typingUsers.joinToString("|")}" + statusState?.online == true -> "online" + else -> "offline" + }, + transitionSpec = { + (slideInVertically { it / 2 } + fadeIn()) togetherWith + (slideOutVertically { -it / 2 } + fadeOut()) + }, + label = "profile_status_${resolvedProfile.id}", + ) { animatedState -> + if (animatedState.startsWith("typing:")) { + TypingIndicator(typingUsers = typingUsers) + } else { + Text( + text = statusText, + style = MaterialTheme.typography.bodyMedium, + color = if (animatedState == "online") { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } else { + Spacer(Modifier.height(4.dp)) + } + Spacer(Modifier.height(24.dp)) ProfileActionButtonRow( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/StatusBadge.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/StatusBadge.kt index 7d1b357..360e07c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/StatusBadge.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/StatusBadge.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Verified import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.rounded.Block import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -16,6 +17,7 @@ import ru.fromchat.Res import ru.fromchat.api.schema.user.profile.VerificationStatus import ru.fromchat.cd_similar_verified import ru.fromchat.cd_verified_account +import ru.fromchat.cd_account_blocked @Composable fun StatusBadge( @@ -38,6 +40,13 @@ fun StatusBadge( tint = Color(0xFFFFA000), ) + VerificationStatus.Blocked -> Icon( + imageVector = Icons.Rounded.Block, + contentDescription = stringResource(Res.string.cd_account_blocked), + modifier = modifier.size(size), + tint = MaterialTheme.colorScheme.error, + ) + VerificationStatus.None, null -> Unit } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/UserProfileDisplay.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/UserProfileDisplay.kt new file mode 100644 index 0000000..2392aff --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/UserProfileDisplay.kt @@ -0,0 +1,67 @@ +package ru.fromchat.ui.profile + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Res +import ru.fromchat.api.local.db.store.ProfileCache +import ru.fromchat.api.local.db.store.shouldHideUsername +import ru.fromchat.api.local.db.store.visibleDisplayName +import ru.fromchat.api.schema.user.profile.UserProfile +import ru.fromchat.deleted_account + +fun UserProfile.isDeletedAccount(currentUserId: Int? = null): Boolean = + deleted == true && id != currentUserId + +fun UserProfile.isSuspendedAccount(currentUserId: Int? = null): Boolean = + suspended == true && deleted != true && id != currentUserId + +fun isDeletedAccountUsername(username: String?): Boolean = + username?.startsWith("#deleted") == true + +/** True when [userId] should be shown as a deleted account to [currentUserId]. */ +fun peerIsDeleted( + userId: Int, + currentUserId: Int? = null, + deleted: Boolean? = null, + username: String? = null, +): Boolean { + if (userId <= 0 || userId == currentUserId) return false + if (deleted == true) return true + if (isDeletedAccountUsername(username)) return true + ProfileCache.get(userId)?.let { profile -> + if (profile.isDeletedAccount(currentUserId)) return true + if (isDeletedAccountUsername(profile.username)) return true + } + return false +} + +suspend fun deletedUserDisplayName(): String = + getString(Res.string.deleted_account) + +@Composable +fun deletedUserDisplayNameForUi(): String = + stringResource(Res.string.deleted_account) + +suspend fun UserProfile.displayNameText(currentUserId: Int? = null): String { + if (isDeletedAccount(currentUserId)) { + return deletedUserDisplayName() + } + return visibleDisplayName(currentUserId).orEmpty() +} + +@Composable +fun UserProfile.displayNameForUi(currentUserId: Int? = null): String = + if (isDeletedAccount(currentUserId)) { + deletedUserDisplayNameForUi() + } else { + visibleDisplayName(currentUserId).orEmpty() + } + +/** Display name for avatar initials/gradient only; never falls back to username. */ +fun UserProfile.avatarLabelForInitials(currentUserId: Int? = null): String = + if (isDeletedAccount(currentUserId)) { + "" + } else { + displayName?.trim().orEmpty() + } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/utils/LastSeenFormat.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/utils/LastSeenFormat.kt index 4cc8d41..e9f083e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/utils/LastSeenFormat.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/utils/LastSeenFormat.kt @@ -25,6 +25,7 @@ import ru.fromchat.month_sep import ru.fromchat.presence_date_full import ru.fromchat.presence_date_this_year import ru.fromchat.presence_online +import ru.fromchat.presence_long_ago import ru.fromchat.presence_recently import ru.fromchat.presence_today_at import ru.fromchat.presence_weekday_at @@ -53,6 +54,7 @@ private fun formatFromXmlTemplate(template: String, vararg args: Any): String { data class LastSeenFormatStrings( val online: String, val recently: String, + val longAgo: String, val todayAt: String, val yesterdayAt: String, val weekdayAt: String, @@ -66,6 +68,7 @@ data class LastSeenFormatStrings( fun rememberLastSeenFormatStrings(): LastSeenFormatStrings { val online = stringResource(Res.string.presence_online) val recently = stringResource(Res.string.presence_recently) + val longAgo = stringResource(Res.string.presence_long_ago) val todayAt = stringResource(Res.string.presence_today_at) val yesterdayAt = stringResource(Res.string.presence_yesterday_at) val weekdayAt = stringResource(Res.string.presence_weekday_at) @@ -91,7 +94,7 @@ fun rememberLastSeenFormatStrings(): LastSeenFormatStrings { val mNov = stringResource(Res.string.month_nov) val mDec = stringResource(Res.string.month_dec) return remember( - online, recently, todayAt, yesterdayAt, weekdayAt, dateThisYear, dateFull, + online, recently, longAgo, todayAt, yesterdayAt, weekdayAt, dateThisYear, dateFull, mon, tue, wed, thu, fri, sat, sun, mJan, mFeb, mMar, mApr, mMay, mJun, mJul, mAug, mSep, mOct, mNov, mDec ) { @@ -99,6 +102,7 @@ fun rememberLastSeenFormatStrings(): LastSeenFormatStrings { LastSeenFormatStrings( online = online, recently = recently, + longAgo = longAgo, todayAt = todayAt, yesterdayAt = yesterdayAt, weekdayAt = weekdayAt, @@ -128,6 +132,7 @@ fun formatLastSeen(online: Boolean, lastSeenIso: String?, s: LastSeenFormatStrin if (online) return s.online val iso = lastSeenIso ?: return "" val instant = runCatching { Instant.parse(iso) }.getOrNull() ?: return s.recently + if (instant.toEpochMilliseconds() <= 0L) return s.longAgo val timeZone = TimeZone.currentSystemDefault() val lastLocal = instant.toLocalDateTime(timeZone) diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/FcmRegistration.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/FcmRegistration.ios.kt index 6b8db8a..931119f 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/api/FcmRegistration.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/FcmRegistration.ios.kt @@ -13,3 +13,5 @@ actual suspend fun unregisterFcmTokenFromServer(): Boolean { // iOS does not use FCM token management in this app build. return false } + +actual suspend fun isFcmPushRegisteredLocally(): Boolean = false