From 243d41385d1b7724348a46554f045984ecd8c765 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 10 Jul 2026 14:10:18 +0300 Subject: [PATCH] Fix cache bugs, light theme and others Signed-off-by: denis0001-dev --- .../notifications/NotificationHelper.kt | 24 ++-- .../kotlin/ru/fromchat/ui/Theme.android.kt | 31 +++++- .../composeResources/values-ru/strings.xml | 1 - .../composeResources/values/strings.xml | 1 - .../kotlin/ru/fromchat/api/ApiClient.kt | 17 +++ .../kotlin/ru/fromchat/api/ChatListSync.kt | 6 +- .../ru/fromchat/api/DeferredStartupNetwork.kt | 4 +- .../kotlin/ru/fromchat/api/calls/CallStore.kt | 2 +- .../api/local/db/DmStoredMessageContent.kt | 40 +++++++ .../api/local/db/store/MessageCacheStore.kt | 78 +++++++++---- .../api/local/db/store/MessageRepository.kt | 2 +- .../api/local/db/store/ProfileCache.kt | 40 ++++++- .../messages/DmInboundMessageProcessor.kt | 67 ++++++++---- .../local/send/OutgoingMessageCoordinator.kt | 7 +- .../fromchat/api/schema/messages/Message.kt | 4 +- .../ru/fromchat/legal/DocumentScreen.kt | 5 +- .../commonMain/kotlin/ru/fromchat/ui/App.kt | 7 +- .../commonMain/kotlin/ru/fromchat/ui/Theme.kt | 6 + .../ru/fromchat/ui/calls/CallOverlay.kt | 4 +- .../ru/fromchat/ui/chat/AttachmentPreview.kt | 12 +- .../kotlin/ru/fromchat/ui/chat/ChatPanel.kt | 33 ++++-- .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 4 +- .../ru/fromchat/ui/chat/MessageDisplayName.kt | 7 -- .../kotlin/ru/fromchat/ui/chat/MessageItem.kt | 103 +++++++++--------- .../chat/components/ChatFileAttachmentTile.kt | 4 +- .../components/FileAttachmentLeadingSlot.kt | 9 +- .../ru/fromchat/ui/chat/panels/dm/DmPanel.kt | 44 ++++---- .../chat/panels/publicchat/PublicChatPanel.kt | 40 ++++--- .../fromchat/ui/chat/utils/MessageUiMerge.kt | 39 +++++++ .../fromchat/ui/components/ScreenSurface.kt | 28 +++++ .../fromchat/ui/main/chats/ChatListShared.kt | 3 +- .../ui/main/chats/ChatsSearchScreen.kt | 15 ++- .../ru/fromchat/ui/main/chats/ChatsTab.kt | 10 +- .../ui/main/settings/LogFilesScreen.kt | 11 +- .../fromchat/ui/main/settings/LogsScreen.kt | 22 +++- .../fromchat/ui/profile/EditProfileScreen.kt | 3 + .../ru/fromchat/ui/profile/ProfileScreen.kt | 64 ++++++----- .../ru/fromchat/db/MessageDatabase.sq | 2 +- .../kotlin/ru/fromchat/ui/Theme.ios.kt | 6 +- 39 files changed, 572 insertions(+), 233 deletions(-) create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ScreenSurface.kt diff --git a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt index 7fcdff8..b2e2298 100644 --- a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt +++ b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt @@ -27,6 +27,8 @@ import ru.fromchat.MainActivity import ru.fromchat.Logger import ru.fromchat.R import ru.fromchat.api.ApiClient +import ru.fromchat.api.local.db.store.ProfileCache +import ru.fromchat.api.local.db.store.visibleDisplayName import ru.fromchat.api.local.messages.ChatListPreviewStrings import ru.fromchat.api.local.messages.buildChatListPreview import ru.fromchat.api.local.messages.buildChatListPreviewFromEnvelope @@ -301,15 +303,13 @@ object NotificationHelper { } } - val senderName = if ( - envelopeId == dmMessageId && !dmSenderName.isNullOrBlank() - ) { - dmSenderName - } else if (!envelope.senderUsername.isNullOrBlank()) { - envelope.senderUsername - } else { - "User ${envelope.senderId}" - } + val senderName = when { + envelopeId == dmMessageId && !dmSenderName.isNullOrBlank() -> dmSenderName + !envelope.senderUsername.isNullOrBlank() -> envelope.senderUsername + else -> ProfileCache.get(envelope.senderId) + ?.visibleDisplayName(currentUserId) + ?.takeIf { it.isNotBlank() } + }.orEmpty() val dmConversationUserId = envelope.senderId val notificationBody = buildChatListPreviewFromEnvelope( envelope = envelope, @@ -319,7 +319,11 @@ object NotificationHelper { showFallbackPushNotification( context = context, - title = "Direct message from $senderName", + title = if (senderName.isNotBlank()) { + "Direct message from $senderName" + } else { + "Direct message" + }, body = notificationBody, sender = senderName, messageId = envelopeId, diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/Theme.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/Theme.android.kt index 271f626..902c376 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/Theme.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/Theme.android.kt @@ -1,12 +1,19 @@ package ru.fromchat.ui +import android.app.Activity +import android.graphics.Color as AndroidColor import android.os.Build import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat @Composable actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) = @@ -16,4 +23,26 @@ actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) = } else { if (darkTheme) darkColorScheme() else lightColorScheme() - } \ No newline at end of file + } + +@Composable +actual fun ApplySystemBarTheme(darkTheme: Boolean, surfaceColor: Color) { + val view = LocalView.current + if (view.isInEditMode) return + + SideEffect { + val window = (view.context as? Activity)?.window ?: return@SideEffect + WindowCompat.getInsetsController(window, view).apply { + isAppearanceLightStatusBars = !darkTheme + isAppearanceLightNavigationBars = !darkTheme + } + @Suppress("DEPRECATION") + window.statusBarColor = AndroidColor.TRANSPARENT + @Suppress("DEPRECATION") + window.navigationBarColor = AndroidColor.TRANSPARENT + window.decorView.setBackgroundColor(surfaceColor.toArgb()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + } + } +} diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index 68175ba..379a8a0 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -109,7 +109,6 @@ Отправка вложения Загрузка продолжается в фоне Вы - Человек %1$d Удалённый аккаунт Аккаунт заблокирован Удалить чат diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index c9e2f4c..db7ae70 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -121,7 +121,6 @@ You - Person %1$d Deleted account Account suspended Delete chat 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 d0bc0a3..a85d981 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -166,6 +166,23 @@ object ApiClient { ) } + suspend fun applyOwnProfile(profile: UserProfile) { + syncSuspensionStateFromProfile(profile) + val currentUser = user ?: return + user = currentUser.copy( + username = profile.username, + displayName = profile.displayName, + bio = profile.bio, + profile_picture = profile.profilePicture, + verified = profile.verified, + verificationStatus = profile.verificationStatus, + deleted = profile.deleted, + online = profile.online, + last_seen = profile.lastSeen, + ) + persistCurrentUser() + } + fun clearSuspensionState() { _suspensionState.value = SuspensionState() user = user?.copy( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ChatListSync.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ChatListSync.kt index 0081168..24ba949 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ChatListSync.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ChatListSync.kt @@ -51,11 +51,13 @@ object ChatListSync { } private suspend fun syncDmConversations() { - val previewStrings = MessageCacheStore.listPreviewStrings ?: return runCatching { val conversations = ApiClient.getDmConversations() conversations.forEach { ProfileCache.mergeFromDmUser(it.user) } - MessageRepository.replaceDmConversations(conversations, previewStrings) + MessageRepository.replaceDmConversations( + conversations, + MessageCacheStore.listPreviewStrings, + ) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/DeferredStartupNetwork.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/DeferredStartupNetwork.kt index ad28756..671f439 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/DeferredStartupNetwork.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/DeferredStartupNetwork.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import ru.fromchat.api.local.db.store.ProfileCache /** * Network work that must not block cold start or the first frame. @@ -20,7 +21,8 @@ object DeferredStartupNetwork { if (ApiClient.token.isNullOrEmpty()) return@launch runCatching { val profile = ApiClient.getOwnProfile() - ApiClient.syncSuspensionStateFromProfile(profile) + ApiClient.applyOwnProfile(profile) + ProfileCache.put(profile) } runCatching { syncPushTokenAfterStartup() } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/CallStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/CallStore.kt index cf2d4c3..7e33fd7 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/CallStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/CallStore.kt @@ -54,7 +54,7 @@ object CallStore { val me = ApiClient.user?.id val p = ProfileCache.get(peerUserId) val label = p?.visibleDisplayName(me)?.orEmpty()?.ifBlank { null } - return label ?: p?.username?.takeIf { it.isNotBlank() } ?: "User $peerUserId" + return label ?: p?.username?.takeIf { it.isNotBlank() }.orEmpty() } fun onWebSocketMessage(message: WebSocketMessage) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/DmStoredMessageContent.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/DmStoredMessageContent.kt index e039190..78473a2 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/DmStoredMessageContent.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/DmStoredMessageContent.kt @@ -16,6 +16,18 @@ import ru.fromchat.api.local.cache.DecryptedImageCache private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } +@Serializable +private data class DmOutboundTextEnvelope( + val type: String = "text", + val data: DmOutboundTextData, +) + +@Serializable +private data class DmOutboundTextData( + val content: String, + @SerialName("reply_to_id") val replyToId: Int? = null, +) + @Serializable private data class PersistedOptimisticOutboundPayload( @SerialName("text") val text: String, @@ -40,6 +52,8 @@ private data class PersistedDmMessagePayload( data class ParsedDmMessageContent( val text: String, + /** Reply target from encrypted JSON payload (`reply_to_id`), when present. */ + val replyToId: Int? = null, val envelope: DmEnvelope? = null, val fileThumbnails: List? = null, val fileAspectRatios: List? = null, @@ -52,6 +66,21 @@ data class ParsedDmMessageContent( val uploadJobId: String? = null, ) +/** + * Plaintext for outbound DM encryption. Embeds [replyToId] in the Web-compatible JSON envelope + * so recipients can resolve reply previews without relying on API envelope metadata. + */ +fun buildDmOutboundPlaintext(content: String, replyToId: Int?): String { + val trimmed = content.trim() + val replyId = replyToId?.takeIf { it > 0 } + if (replyId == null) return trimmed + return json.encodeToString( + DmOutboundTextEnvelope( + data = DmOutboundTextData(content = trimmed, replyToId = replyId), + ), + ) +} + /** Persists in-flight attachment fields so SQLDelight reload keeps the file row UI. */ fun encodeOptimisticOutboundMessage(message: Message): String { val pendingUri = message.pendingFileUri?.trim().orEmpty() @@ -133,6 +162,17 @@ fun parseDmMessageContent(plaintext: String): ParsedDmMessageContent { val trimmed = plaintext.trim() if (trimmed.startsWith("{")) { val root = runCatching { json.parseToJsonElement(trimmed).jsonObject }.getOrNull() + if (root?.containsKey("type") == true && root["type"]?.jsonPrimitive?.content == "text") { + return runCatching { + val payload = json.decodeFromString(trimmed) + ParsedDmMessageContent( + text = payload.data.content, + replyToId = payload.data.replyToId?.takeIf { it > 0 }, + ) + }.getOrElse { + ParsedDmMessageContent(text = plaintext) + } + } if (root?.containsKey("pendingFileUri") == true) { return runCatching { val payload = json.decodeFromString(trimmed) 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 beddfcd..88cae8e 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 @@ -41,8 +41,10 @@ import ru.fromchat.api.crypto.decryptEnvelope import ru.fromchat.api.local.cache.DecryptedFileCache import ru.fromchat.api.local.cache.DecryptedImageCache import ru.fromchat.api.local.download.DownloadedFileRegistry +import ru.fromchat.ui.chat.utils.attachPublicReplyReferences import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId import ru.fromchat.ui.chat.utils.dropSupersededOptimisticMessages +import kotlin.concurrent.Volatile import ru.fromchat.db.Message as DbMessage data class CachedConversation( @@ -78,7 +80,7 @@ object MessageCacheStore { .asFlow() .mapToList(Dispatchers.Default) .map { rows -> - val raw = hydrateReplyReferences(rows) + val raw = hydrateReplyReferencesFromRows(rows) val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id) sortMessagesForChatDisplay( validatedOrEmpty( @@ -99,11 +101,10 @@ object MessageCacheStore { fun loadRecentPublicMessagesImmediate(instanceId: String, limit: Long = 128): List { if (instanceId.isBlank()) return emptyList() val convId = conversationIdForPublic() - val raw = hydrateReplyReferences( - db.messageDatabaseQueries - .selectRecentMessagesByConversation(instanceId, convId, limit) - .executeAsList(), - ).reversed() + val rows = db.messageDatabaseQueries + .selectRecentMessagesByConversation(instanceId, convId, limit) + .executeAsList() + val raw = hydrateReplyReferencesFromRows(rows).reversed() val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id) return ProfileCache.enrichPublicMessagesForDisplay( sortMessagesForChatDisplay( @@ -338,9 +339,9 @@ object MessageCacheStore { suspend fun replaceDmConversations( conversations: List, - previewStrings: ChatListPreviewStrings, + previewStrings: ChatListPreviewStrings? = listPreviewStrings, ) { - listPreviewStrings = previewStrings + previewStrings?.let { listPreviewStrings = it } val iid = instanceId() val currentUserId = ApiClient.user?.id withContext(Dispatchers.Default) { @@ -408,10 +409,14 @@ object MessageCacheStore { private suspend fun buildDmListPreview( envelope: DmEnvelope, currentUserId: Int?, - previewStrings: ChatListPreviewStrings, + previewStrings: ChatListPreviewStrings?, ): String? { val decrypted = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() - val previewSource = buildChatListPreviewFromEnvelope(envelope, decrypted, previewStrings) + val previewSource = if (previewStrings != null) { + buildChatListPreviewFromEnvelope(envelope, decrypted, previewStrings) + } else { + decrypted?.trim()?.takeIf { it.isNotEmpty() } + } return previewSource?.let { truncateDmListPreview(it) }?.takeIf { it.isNotEmpty() } } @@ -448,11 +453,25 @@ object MessageCacheStore { val existing = db.messageDatabaseQueries .selectConversationById(iid, convId) .executeAsOneOrNull() - if (existing != null) return@withContext - val label = displayName?.trim()?.takeIf { it.isNotEmpty() } - ?: ProfileCache.get(otherUserId)?.displayName?.trim()?.takeIf { it.isNotEmpty() } - ?: ProfileCache.get(otherUserId)?.username?.trim()?.takeIf { it.isNotEmpty() } - ?: "" + val label = resolveDmConversationDisplayLabel(otherUserId, displayName) + if (existing != null) { + if (label.isNotEmpty() && existing.displayName.isNullOrBlank()) { + db.messageDatabaseQueries.upsertConversation( + instanceId = iid, + id = existing.id, + type = existing.type, + otherUserId = existing.otherUserId, + displayName = label, + lastMessageId = existing.lastMessageId, + lastMessagePreview = existing.lastMessagePreview, + unreadCount = existing.unreadCount, + updatedAt = existing.updatedAt, + archived = existing.archived, + ) + DmConversationListNotifier.notifyChanged() + } + return@withContext + } db.messageDatabaseQueries.upsertConversation( instanceId = iid, id = convId, @@ -468,6 +487,11 @@ object MessageCacheStore { } } + private fun resolveDmConversationDisplayLabel(otherUserId: Int, displayName: String?): String = + displayName?.trim()?.takeIf { it.isNotEmpty() } + ?: ProfileCache.get(otherUserId)?.displayName?.trim()?.takeIf { it.isNotEmpty() } + ?: ProfileCache.get(otherUserId)?.visibleUsername(ApiClient.user?.id).orEmpty() + suspend fun markDmConversationReadLocally(otherUserId: Int, upToEnvelopeId: Int? = null) { val iid = instanceId() val convId = conversationIdForDm(otherUserId) @@ -835,6 +859,11 @@ object MessageCacheStore { val storedContent = encodePersistedDmMessage(confirmed) withContext(Dispatchers.Default) { db.messageDatabaseQueries.transaction { + val existingReplyToId = db.messageDatabaseQueries + .selectMessagesByConversation(iid, conversationId) + .executeAsList() + .firstOrNull { it.clientMessageId == clientMessageId } + ?.replyToId db.messageDatabaseQueries.deleteMessageByClientMessageId(iid, conversationId, clientMessageId) db.messageDatabaseQueries.upsertMessage( instanceId = iid, @@ -845,7 +874,7 @@ object MessageCacheStore { timestamp = confirmed.timestamp, isRead = if (confirmed.is_read) 1L else 0L, isEdited = if (confirmed.is_edited) 1L else 0L, - replyToId = resolveReplyToIdForPersistence(confirmed), + replyToId = resolveReplyToIdForPersistence(confirmed, existingReplyToId), clientMessageId = confirmed.client_message_id, deletedFlag = 0L, sendStatus = "sent" @@ -865,7 +894,7 @@ object MessageCacheStore { val rows = db.messageDatabaseQueries .selectMessagesByConversation(iid, conversationId) .executeAsList() - val raw = hydrateReplyReferences(rows) + val raw = hydrateReplyReferencesFromRows(rows) val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id) purgeSupersededPendingRows(iid, conversationId, raw, withoutSuperseded) sortMessagesForChatDisplay( @@ -885,7 +914,7 @@ object MessageCacheStore { val rows = db.messageDatabaseQueries .selectRecentMessagesByConversation(iid, conversationId, limit) .executeAsList() - hydrateReplyReferences(rows).reversed() + hydrateReplyReferencesFromRows(rows).reversed() } } @@ -991,13 +1020,23 @@ object MessageCacheStore { } } + private fun hydrateReplyReferencesFromRows(rows: List): List { + val replyIds = rows.mapNotNull { row -> + row.replyToId?.toInt()?.takeIf { it > 0 }?.let { row.id.toInt() to it } + }.toMap() + return attachPublicReplyReferences(hydrateReplyReferences(rows), replyIds) + } + 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() ?: message.dmEnvelope?.replyToId if (replyId != null) { - message.copy(reply_to = byId[replyId]) + message.copy( + replyToId = replyId, + reply_to = byId[replyId] ?: message.reply_to, + ) } else { message } @@ -1015,6 +1054,7 @@ object MessageCacheStore { private fun resolveReplyToIdForPersistence(msg: Message, existingReplyToId: Long? = null): Long? { return msg.reply_to?.id?.toLong() + ?: msg.replyToId?.toLong() ?: msg.dmEnvelope?.replyToId?.toLong() ?: existingReplyToId?.takeIf { it > 0L } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt index 172ae51..8c7c19f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt @@ -93,7 +93,7 @@ object MessageRepository { suspend fun replaceDmConversations( conversations: List, - previewStrings: ChatListPreviewStrings, + previewStrings: ChatListPreviewStrings? = null, ) = MessageCacheStore.replaceDmConversations(conversations, previewStrings) suspend fun loadCachedDmConversations(): List = 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 ef2371a..3df9faf 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 @@ -3,6 +3,9 @@ package ru.fromchat.api.local.db.store import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -45,9 +48,16 @@ object ProfileCache { @Volatile private var loadedInstanceId: String = "" + private val _revision = MutableStateFlow(0) + val revision: StateFlow = _revision.asStateFlow() + private val persistMutex = Mutex() private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private fun bumpRevision() { + _revision.value++ + } + fun get(userId: Int): UserProfile? = profiles[userId] fun findByUsername(username: String): UserProfile? = @@ -125,6 +135,7 @@ object ProfileCache { } val cur = profiles profiles = cur + (profile.id to profile) + bumpRevision() val instanceId = loadedInstanceId if (instanceId.isNotEmpty()) { ioScope.launch { @@ -137,6 +148,7 @@ object ProfileCache { val cur = profiles if (userId !in cur) return profiles = cur - userId + bumpRevision() val instanceId = loadedInstanceId if (instanceId.isNotEmpty()) { ioScope.launch { @@ -154,8 +166,7 @@ object ProfileCache { } fun mergeFromDmUser(user: User) { - val existing = get(user.id) - if (existing != null && !existing.isClientPreviewOnly) return + if (user.id <= 0) return val incomingUsername = user.username.trim() if (incomingUsername.isEmpty()) return @@ -167,6 +178,28 @@ object ProfileCache { user.displayName?.trim()?.takeIf { it.isNotEmpty() } ?: incomingUsername } + val existing = get(user.id) + if (existing != null && !existing.isClientPreviewOnly) { + val patched = existing.copy( + username = incomingUsername, + displayName = if (isDeleted) null else incomingDisplayName ?: existing.displayName, + profilePicture = if (isDeleted) { + null + } else { + user.profile_picture?.takeIf { it.isNotBlank() } ?: existing.profilePicture + }, + online = user.online, + lastSeen = user.last_seen?.takeIf { it.isNotBlank() } ?: existing.lastSeen, + verified = user.verified ?: existing.verified, + verificationStatus = user.verificationStatus ?: existing.verificationStatus, + suspended = user.suspended ?: existing.suspended, + suspensionReason = user.suspensionReason ?: existing.suspensionReason, + deleted = isDeleted, + ) + if (patched != existing) put(patched) + return + } + put( UserProfile( id = user.id, @@ -272,6 +305,7 @@ object ProfileCache { emptyMap() } pruneUnusableClientPreviewsLocked() + bumpRevision() } } } @@ -286,6 +320,7 @@ object ProfileCache { emptyMap() } pruneUnusableClientPreviewsLocked() + bumpRevision() } } @@ -308,6 +343,7 @@ object ProfileCache { persistMutex.withLock { profiles = emptyMap() loadedInstanceId = "" + bumpRevision() } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/DmInboundMessageProcessor.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/DmInboundMessageProcessor.kt index 7a71b6b..c80de99 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/DmInboundMessageProcessor.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/DmInboundMessageProcessor.kt @@ -8,10 +8,13 @@ import ru.fromchat.api.crypto.decryptEnvelope import ru.fromchat.api.local.db.parseDmMessageContent import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.db.store.ProfileCache +import ru.fromchat.api.local.db.store.visibleDisplayName import ru.fromchat.api.local.messages.ActiveDmChatTracker import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.dm.DmEnvelope import ru.fromchat.api.schema.websocket.types.DmDeletedData +import ru.fromchat.ui.chat.utils.attachDmReplyReferences +import ru.fromchat.ui.chat.utils.resolveDmReplyToId object DmInboundMessageProcessor { suspend fun processNew(element: JsonElement) { @@ -32,18 +35,28 @@ object DmInboundMessageProcessor { val outcome = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() val plaintext = outcome ?: "" val isCorrupted = outcome == null + val dec = parseDmMessageContent(plaintext) val message = buildMessage(envelope, plaintext, isCorrupted, currentUserId, otherUserId) + val existing = runCatching { MessageRepository.loadDmMessages(otherUserId) } + .getOrDefault(emptyList()) + val replyId = resolveDmReplyToId(envelope, dec.replyToId) + ?: existing.firstOrNull { it.client_message_id == envelope.clientMessageId?.trim() } + ?.reply_to?.id?.takeIf { it > 0 } + val hydrated = attachDmReplyReferences( + existing.filter { it.id != message.id } + message, + replyId?.let { mapOf(message.id to it) } ?: emptyMap(), + ).last() if (envelope.senderId == currentUserId) { val clientId = envelope.clientMessageId?.trim().orEmpty() if (clientId.isNotEmpty()) { - MessageRepository.confirmDmMessage(otherUserId, clientId, message) + MessageRepository.confirmDmMessage(otherUserId, clientId, hydrated) } else { - MessageRepository.upsertDmMessage(otherUserId, message) + MessageRepository.upsertDmMessage(otherUserId, hydrated) } } else { val isRead = ActiveDmChatTracker.isActive(otherUserId) - val inbound = message.copy(is_read = isRead) + val inbound = hydrated.copy(is_read = isRead) MessageRepository.upsertDmMessage(otherUserId, inbound) } } @@ -82,26 +95,32 @@ object DmInboundMessageProcessor { } withContext(Dispatchers.Default) { - val existing = runCatching { MessageRepository.loadDmMessages(otherUserId) } + val existingMessages = runCatching { MessageRepository.loadDmMessages(otherUserId) } .getOrDefault(emptyList()) - .find { it.id == envelope.id } + val existing = existingMessages.find { it.id == envelope.id } val outcome = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() val plaintext = outcome ?: "" val isCorrupted = outcome == null val dec = parseDmMessageContent(plaintext) - val updated = (existing ?: buildMessage(envelope, plaintext, isCorrupted, currentUserId, otherUserId)) - .copy( - content = dec.text, - is_edited = true, - files = envelope.files, - dmEnvelope = envelope, - fileThumbnails = dec.fileThumbnails ?: existing?.fileThumbnails, - fileAspectRatios = dec.fileAspectRatios ?: existing?.fileAspectRatios, - fileSizes = dec.fileSizes ?: existing?.fileSizes, - fileDimensions = dec.fileDimensions ?: existing?.fileDimensions, - isContentCorrupted = isCorrupted, - ) - MessageRepository.upsertDmMessage(otherUserId, updated) + val base = existing ?: buildMessage(envelope, plaintext, isCorrupted, currentUserId, otherUserId) + val updated = base.copy( + content = dec.text, + is_edited = true, + files = envelope.files, + dmEnvelope = envelope, + fileThumbnails = dec.fileThumbnails ?: existing?.fileThumbnails, + fileAspectRatios = dec.fileAspectRatios ?: existing?.fileAspectRatios, + fileSizes = dec.fileSizes ?: existing?.fileSizes, + fileDimensions = dec.fileDimensions ?: existing?.fileDimensions, + isContentCorrupted = isCorrupted, + ) + val replyId = resolveDmReplyToId(envelope, dec.replyToId) + ?: existing?.reply_to?.id?.takeIf { it > 0 } + val hydrated = attachDmReplyReferences( + existingMessages.filter { it.id != updated.id } + updated, + replyId?.let { mapOf(updated.id to it) } ?: emptyMap(), + ).last() + MessageRepository.upsertDmMessage(otherUserId, hydrated) } } @@ -113,14 +132,18 @@ object DmInboundMessageProcessor { otherUserId: Int, ): Message { val dec = parseDmMessageContent(plaintext) - val cached = ProfileCache.get(otherUserId) + if (envelope.senderId != currentUserId) { + envelope.senderUsername?.trim()?.takeIf { it.isNotEmpty() }?.let { senderName -> + ProfileCache.mergePreview(id = envelope.senderId, username = senderName) + } + } + val senderProfile = ProfileCache.get(envelope.senderId) val username = if (envelope.senderId == currentUserId) { "You" } else { - cached?.displayName?.takeIf { it.isNotBlank() } - ?: cached?.username?.takeIf { it.isNotBlank() } + senderProfile?.visibleDisplayName(currentUserId)?.takeIf { it.isNotBlank() } ?: envelope.senderUsername?.takeIf { it.isNotBlank() } - ?: "User $otherUserId" + ?: "" } return Message( id = envelope.id, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt index ddea26b..a31285b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt @@ -26,6 +26,7 @@ import ru.fromchat.api.local.send.outboundFailureErrorKey import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.dm.SendDmFile import ru.fromchat.api.local.cache.CacheContext +import ru.fromchat.api.local.db.buildDmOutboundPlaintext import ru.fromchat.api.local.cache.clearUploadArtifacts import ru.fromchat.api.local.cache.clearUploadSecretsOnly import ru.fromchat.api.local.AttachmentMediaLog @@ -179,10 +180,11 @@ object OutgoingMessageCoordinator { val conversationId = conversationIdForDm(recipientId) withContext(Dispatchers.Default) { MessageRepository.upsertDmMessage(recipientId, optimisticMessage) + val outboundPlaintext = buildDmOutboundPlaintext(plaintext, replyToId) val payload = json.encodeToString( DmOutboxPayload( recipientId = recipientId, - plaintext = plaintext, + plaintext = outboundPlaintext, clientMessageId = clientMessageId, replyToId = replyToId, transportFiles = transportFiles, @@ -226,10 +228,11 @@ object OutgoingMessageCoordinator { ) withContext(Dispatchers.Default) { MessageRepository.upsertDmMessage(recipientId, optimisticMessage) + val outboundPlaintext = buildDmOutboundPlaintext(plaintext, replyToId) val payload = json.encodeToString( DmAttachmentOutboxPayload( recipientId = recipientId, - plaintext = plaintext, + plaintext = outboundPlaintext, clientMessageId = clientMessageId, replyToId = replyToId, fileUri = fileUri, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/Message.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/Message.kt index a5a4e8a..8dfb9ad 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/Message.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/Message.kt @@ -47,5 +47,7 @@ data class Message( /** Image dimensions (width, height) for image files (by index); from decrypted message JSON. */ @Transient val fileDimensions: List>? = null, /** True when DM plaintext could not be decrypted and [content] shows the corrupted placeholder. */ - @Transient val isContentCorrupted: Boolean = false + @Transient val isContentCorrupted: Boolean = false, + /** Reply target id from local DB when nested [reply_to] is not hydrated yet. */ + @Transient val replyToId: Int? = null, ) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentScreen.kt index 4a59dc2..6a623bc 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentScreen.kt @@ -67,6 +67,7 @@ import ru.fromchat.legal_document_cached_banner import ru.fromchat.legal_document_load_error import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle import ru.fromchat.ui.components.ActionButton +import ru.fromchat.ui.components.ScreenSurface import ru.fromchat.ui.components.Text private data class PendingDocument( @@ -108,6 +109,7 @@ fun DocumentScreen( ToggleNavScrimEffect() + ScreenSurface { Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), containerColor = Color.Transparent, @@ -273,6 +275,7 @@ fun DocumentScreen( ) } } + } } } } @@ -358,6 +361,6 @@ private fun DocumentContent( } } } - } + } } } 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 d1bd21b..949d9d1 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -102,6 +102,7 @@ import ru.fromchat.ui.profile.EditProfileFocusField import ru.fromchat.ui.profile.EditProfileScreen import ru.fromchat.ui.profile.ProfileRoutes import ru.fromchat.ui.profile.ProfileScreen +import ru.fromchat.ui.components.ScreenSurface import ru.fromchat.utils.NetworkConnectivity val LocalNavController = compositionLocalOf { error("NavController not provided") } @@ -401,8 +402,9 @@ fun App( LocalSystemBarsVisibility provides rememberSystemBarsController() ) { if (startDestination != null) { - Box(Modifier.fillMaxSize()) { - NavHost( + ScreenSurface { + Box(Modifier.fillMaxSize()) { + NavHost( navController = navController, startDestination = startDestination!!, enterTransition = { rootNavEnterTransition() }, @@ -691,6 +693,7 @@ fun App( } CallOverlay(Modifier.fillMaxSize()) + } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/Theme.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/Theme.kt index 05d1a2f..7126f81 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/Theme.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/Theme.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color import ru.fromchat.config.Settings import ru.fromchat.ui.components.googleSansMaterialTypography @@ -28,6 +29,9 @@ var theme by mutableStateOf( @Composable expect fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean): ColorScheme +@Composable +expect fun ApplySystemBarTheme(darkTheme: Boolean, surfaceColor: Color) + @Composable fun FromChatTheme( darkTheme: Boolean = when (theme) { @@ -76,6 +80,8 @@ fun FromChatTheme( val surfaceContainerHigh by animateColorAsState(colorScheme.surfaceContainerHigh) val surfaceContainerHighest by animateColorAsState(colorScheme.surfaceContainerHighest) + ApplySystemBarTheme(darkTheme = darkTheme, surfaceColor = surface) + colorScheme = colorScheme.copy( primary = primary, onPrimary = onPrimary, 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 88a2f01..62b5bc6 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 @@ -42,7 +42,6 @@ import ru.fromchat.call_dismiss import ru.fromchat.call_failed_title import ru.fromchat.call_incoming_subtitle import ru.fromchat.ui.components.Text -import ru.fromchat.user_fallback @Composable fun CallOverlay(modifier: Modifier = Modifier) { @@ -96,7 +95,8 @@ fun CallOverlay(modifier: Modifier = Modifier) { val title = cached?.displayNameForUi(me)?.takeIf { it.isNotBlank() } ?: cached?.username?.takeIf { it.isNotBlank() } - ?: stringResource(Res.string.user_fallback, s.fromUserId) + ?: s.fromUsername.takeIf { it.isNotBlank() } + ?: "" Box( modifier = modifier .fillMaxSize() 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 5c1a7f4..4376e7f 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 @@ -1090,7 +1090,7 @@ private fun AttachmentUploadFailedOverlay( } val retryText = stringResource(Res.string.attachment_retry) val retryCd = stringResource(Res.string.cd_attachment_upload_retry) - val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface + val headlineColor = messageBubbleContentColor(isAuthor) Box( modifier = modifier .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f)), @@ -1125,7 +1125,7 @@ private fun AttachmentImageLoadFailedOverlay( val failedText = stringResource(Res.string.attachment_image_load_failed) val retryText = stringResource(Res.string.attachment_retry) val retryCd = stringResource(Res.string.cd_attachment_retry) - val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface + val headlineColor = messageBubbleContentColor(isAuthor) Box( modifier = modifier .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f)), @@ -1194,12 +1194,8 @@ internal fun ExpressiveFileAttachmentRow( onCancelProgress: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { - val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface - val supportingColor = if (isAuthor) { - Color.White.copy(alpha = 0.78f) - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } + val headlineColor = messageBubbleContentColor(isAuthor) + val supportingColor = messageBubbleSupportingContentColor(isAuthor) val leadingSize = 48.dp Row( modifier = modifier 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 a556fc0..2c1a355 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 @@ -22,6 +22,7 @@ import ru.fromchat.ui.chat.utils.TypingHandler import ru.fromchat.ui.chat.utils.TypingUser import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId import ru.fromchat.ui.chat.utils.dropSupersededOptimisticMessages +import ru.fromchat.ui.chat.utils.attachPublicReplyReferences import ru.fromchat.ui.chat.utils.mergeDatabaseMessagesWithPanelState import kotlin.time.ExperimentalTime @@ -83,11 +84,14 @@ abstract class ChatPanel( /** Merge SQLDelight rows with in-memory optimistic attachment UI (pending preview, thumbnails). */ suspend fun syncMessagesFromDatabase(messages: List) { - batchStateUpdates { - updateState { current -> - val merged = mergeDatabaseMessagesWithPanelState(current.messages, messages) - if (current.messages == merged) current - else current.copy(messages = merged) + addMessageMutex.withLock { + batchStateUpdates { + updateState { current -> + val merged = mergeDatabaseMessagesWithPanelState(current.messages, messages) + val withReplies = attachPublicReplyReferences(merged) + if (current.messages == withReplies) current + else current.copy(messages = withReplies) + } } } } @@ -360,7 +364,9 @@ abstract class ChatPanel( mapped + resolvedConfirmed else -> mapped } - currentState.copy(messages = sortMessagesForChatDisplay(messages)) + currentState.copy( + messages = sortMessagesForChatDisplay(attachPublicReplyReferences(messages)), + ) } scope.launch(Dispatchers.Default) { val resolved = _state.messages.find { it.client_message_id == tempId } @@ -435,7 +441,11 @@ abstract class ChatPanel( * Send message with immediate display (optimistic update) */ @OptIn(ExperimentalTime::class) - suspend fun sendMessageWithImmediateDisplay(content: String, replyToId: Int?) { + suspend fun sendMessageWithImmediateDisplay( + content: String, + replyToId: Int?, + replyTo: Message? = null, + ) { if (content.isBlank()) return val sendT0 = kotlin.time.Clock.System.now().toEpochMilliseconds() @@ -446,6 +456,10 @@ abstract class ChatPanel( // Show the bubble immediately; pace only the network send below. val tempId = generateClientMessageId() + val resolvedReply = replyTo?.takeIf { it.id > 0 } + ?: replyToId?.takeIf { it > 0 }?.let { replyId -> + _state.messages.find { it.id == replyId } + } val tempMessage = Message( id = -1, // Temporary negative ID user_id = currentUserId ?: -1, @@ -455,9 +469,8 @@ abstract class ChatPanel( is_edited = false, username = "You", client_message_id = tempId, - reply_to = replyToId?.let { replyId -> - _state.messages.find { it.id == replyId } - } + reply_to = resolvedReply, + replyToId = resolvedReply?.id ?: replyToId?.takeIf { it > 0 }, ) // Unique negative id avoids duplicate LazyColumn keys and bad merge logic. 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 0cd1eaa..f7a779d 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 @@ -754,7 +754,7 @@ fun ChatScreen( } } else { scope.launch { - val replyToId = replyTo?.id + val replyToId = replyTo?.id?.takeIf { it > 0 } val recipientId = panel.getRecipientId() if (attachments.isNotEmpty() && recipientId != null) { val plaintext = text.ifBlank { "" } @@ -842,7 +842,7 @@ fun ChatScreen( } } } else if (text.isNotBlank()) { - panel.sendMessageWithImmediateDisplay(text, replyToId) + panel.sendMessageWithImmediateDisplay(text, replyToId, replyTo) } replyTo = null haptic(HapticFeedbackEvent.MessageSent) 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 83f292a..60941fd 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 @@ -14,8 +14,6 @@ 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 «Вы», deleted user label, or server-provided name. */ @@ -37,11 +35,6 @@ fun messageDisplayUsername(message: Message, currentUserId: Int?): String { if (message.username.equals("deleted", ignoreCase = true)) { return deletedUserDisplayNameForUi() } - val m = userIdUsernamePattern.matchEntire(message.username) - if (m != null) { - val id = m.groupValues[1].toIntOrNull() - if (id != null) return deletedUserDisplayNameForUi() - } return message.username } 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 1504630..b23a7c4 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 @@ -180,8 +180,7 @@ fun MessageItem( var isPressed by remember { mutableStateOf(false) } var avatarPressed by remember(message.id) { mutableStateOf(false) } var replyPressed by remember(message.id) { mutableStateOf(false) } - var bubbleBodyPositionInRoot by remember { mutableStateOf(Offset.Zero) } - var slackRowLayoutCoords by remember(message.id) { mutableStateOf(null) } + var rowLayoutCoords by remember(message.id) { mutableStateOf(null) } val scaleTarget = if (isPressed && !isContextMenuForThisMessage && !isContextMenuOpen) 0.96f else 1f val avatarScaleTarget = if (avatarPressed && !isContextMenuOpen) 0.96f else 1f val replyScaleTarget = if (replyPressed && !isContextMenuOpen) 0.96f else 1f @@ -375,9 +374,33 @@ fun MessageItem( ) } + val rowLongPress = + if (isContextMenuOpen) Modifier + else Modifier.pointerInput(isContextMenuOpen, message.id) { + detectTapGestures( + onPress = { + isPressed = true + try { + awaitRelease() + } finally { + isPressed = false + } + }, + onLongPress = { localOffset -> + val coords = rowLayoutCoords + if (coords != null && coords.isAttached) { + onTapPosition(coords.localToRoot(localOffset)) + } + onLongPress() + }, + ) + } + Row( modifier = Modifier .fillMaxWidth() + .onGloballyPositioned { rowLayoutCoords = it } + .then(rowLongPress) .padding(horizontal = 8.dp) .enterLayoutHeight( scale = enterScale.value, @@ -467,13 +490,9 @@ fun MessageItem( message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true ) - val bubblePressAndLongPress = - if (isContextMenuOpen) Modifier - else Modifier.pointerInput( - isContextMenuOpen, - message.id, - onBubbleTap, - ) { + val bubbleTap = + if (isContextMenuOpen || onBubbleTap == null) Modifier + else Modifier.pointerInput(message.id, onBubbleTap) { detectTapGestures( onPress = { isPressed = true @@ -483,48 +502,11 @@ fun MessageItem( isPressed = false } }, - onTap = { onBubbleTap?.invoke() }, - onLongPress = { localOffset -> - onTapPosition(bubbleBodyPositionInRoot + localOffset) - onLongPress() - } + onTap = { onBubbleTap.invoke() }, ) } - val slackRowPressAndLongPress = - if (isContextMenuOpen) Modifier - else Modifier.pointerInput( - isContextMenuOpen, - message.id, - onBubbleTap, - ) { - detectTapGestures( - onPress = { - isPressed = true - try { - awaitRelease() - } finally { - isPressed = false - } - }, - onTap = { onBubbleTap?.invoke() }, - onLongPress = { localOffset -> - val coords = slackRowLayoutCoords - if (coords != null && coords.isAttached) { - onTapPosition(coords.localToRoot(localOffset)) - } else { - onTapPosition(bubbleBodyPositionInRoot + localOffset) - } - onLongPress() - } - ) - } - - Box( - modifier = Modifier - .onGloballyPositioned { slackRowLayoutCoords = it } - .then(slackRowPressAndLongPress) - ) { + Box { Column( horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start, @@ -618,13 +600,10 @@ fun MessageItem( } } - val gestureWidthModifier = Modifier.fillMaxWidth() Box( - modifier = gestureWidthModifier - .onGloballyPositioned { coordinates -> - bubbleBodyPositionInRoot = coordinates.positionInRoot() - } - .then(bubblePressAndLongPress) + modifier = Modifier + .fillMaxWidth() + .then(bubbleTap) ) { Column { replyRef?.let { replyToMsg -> @@ -1069,3 +1048,19 @@ private fun MessageReplyQuote( } } } + +@Composable +internal fun messageBubbleContentColor(isAuthor: Boolean) = + if (isAuthor) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurface + } + +@Composable +internal fun messageBubbleSupportingContentColor(isAuthor: Boolean) = + if (isAuthor) { + MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.78f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/ChatFileAttachmentTile.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/ChatFileAttachmentTile.kt index d1b1f64..0a46bde 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/ChatFileAttachmentTile.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/ChatFileAttachmentTile.kt @@ -18,7 +18,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp @@ -41,6 +40,7 @@ import ru.fromchat.attachment_upload_failed import ru.fromchat.attachment_upload_failed_too_large import ru.fromchat.cd_attachment_upload_retry import ru.fromchat.ui.chat.ExpressiveFileAttachmentRow +import ru.fromchat.ui.chat.messageBubbleContentColor import ru.fromchat.ui.chat.utils.showAttachmentOpenFailed import ru.fromchat.ui.components.Text @@ -228,7 +228,7 @@ fun ChatFileAttachmentTile( } val retryText = stringResource(Res.string.attachment_retry) val retryCd = stringResource(Res.string.cd_attachment_upload_retry) - val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface + val headlineColor = messageBubbleContentColor(isAuthor) Box(modifier = modifier.widthIn(max = 280.dp)) { ExpressiveFileAttachmentRow( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/FileAttachmentLeadingSlot.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/FileAttachmentLeadingSlot.kt index 1f6ef3f..ed870a6 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/FileAttachmentLeadingSlot.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/FileAttachmentLeadingSlot.kt @@ -59,13 +59,14 @@ internal fun FileAttachmentLeadingSlot( isDownloaded -> FileLeadingVisual.File else -> FileLeadingVisual.Download } + val onPrimary = MaterialTheme.colorScheme.onPrimary val containerColor = if (isAuthor) { - Color.White.copy(alpha = 0.22f) + onPrimary.copy(alpha = 0.22f) } else { MaterialTheme.colorScheme.secondaryContainer } val iconOnContainer = if (isAuthor) { - Color.White + onPrimary } else { MaterialTheme.colorScheme.onSecondaryContainer } @@ -83,9 +84,9 @@ internal fun FileAttachmentLeadingSlot( onCancel = onCancelProgress, showCloseScrim = false, modifier = Modifier.fillMaxSize(), - indicatorColor = if (isAuthor) Color.White else null, + indicatorColor = if (isAuthor) onPrimary else null, trackColorOverride = if (isAuthor) { - Color.White.copy(alpha = 0.28f) + onPrimary.copy(alpha = 0.28f) } else { null }, 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 ca400e6..a275336 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 @@ -43,10 +43,12 @@ import ru.fromchat.api.local.cache.DecryptedImageCache import ru.fromchat.ui.chat.utils.DmTypingHandler import ru.fromchat.api.local.download.DownloadedFileRegistry import ru.fromchat.ui.chat.utils.TypingHandler +import ru.fromchat.ui.chat.utils.attachDmReplyReferences import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId import ru.fromchat.ui.chat.utils.dropSupersededOptimisticMessages import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage import ru.fromchat.ui.chat.utils.preserveReplyToFromExisting +import ru.fromchat.ui.chat.utils.resolveDmReplyToId import ru.fromchat.ui.chat.isImageFilename import ru.fromchat.api.local.send.seedOutboundFileAsDownloaded @@ -246,21 +248,18 @@ class DmPanel( val priorMessages = _state.messages val optimisticSnapshot = snapshotPendingOptimisticMessages() val decryptedForLog = mutableListOf>() + val parsedReplyIds = mutableMapOf() val messages = response.messages.map { envelope -> val outcome = decryptDmEnvelopeForUi(envelope) decryptedForLog.add(envelope.id to outcome.plaintext) + val dec = parseDmMessageContent(outcome.plaintext) + resolveDmReplyToId(envelope, dec.replyToId)?.let { parsedReplyIds[envelope.id] = it } createMessage(envelope, outcome.plaintext, outcome.isCorrupted) } decryptedForLog.takeLast(5).forEachIndexed { i, (id, json) -> Logger.d("DmPanel", "Decrypted message #${i + 1} (id=$id): $json") } - val replyToMap = messages.associateBy { it.id } - val messagesWithReplies = messages.map { msg -> - val envelope = response.messages.find { it.id == msg.id } - if (envelope?.replyToId != null) { - msg.copy(reply_to = replyToMap[envelope.replyToId]) - } else msg - } + val messagesWithReplies = attachDmReplyReferences(messages, parsedReplyIds) val mergedForUi = preserveReplyToFromExisting( priorMessages + optimisticSnapshot, messagesWithReplies, @@ -368,11 +367,12 @@ class DmPanel( if (envelope.senderId == currentUserId) { mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted) } else { + val dec = parseDmMessageContent(outcome.plaintext) val incoming = createMessage(envelope, outcome.plaintext, outcome.isCorrupted) - val replyTo = envelope.replyToId?.let { replyId -> - _state.messages.find { it.id == replyId } - } - val withReply = if (replyTo != null) incoming.copy(reply_to = replyTo) else incoming + val replyId = resolveDmReplyToId(envelope, dec.replyToId) + val context = _state.messages + incoming + val withReply = attachDmReplyReferences(context, replyId?.let { mapOf(incoming.id to it) } ?: emptyMap()) + .last() if (ActiveDmChatTracker.isActive(otherUserId)) { withContext(Dispatchers.Default) { MessageCacheStore.upsertDmMessage(otherUserId, withReply) @@ -446,16 +446,20 @@ 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) + val dec = parseDmMessageContent(plaintext) + val replyId = resolveDmReplyToId(envelope, dec.replyToId) + ?: stateSourceBeforeMerge?.reply_to?.id?.takeIf { it > 0 } + val mergedWithReply = attachDmReplyReferences( + _state.messages.filter { it.id != envelope.id && it.client_message_id != cid } + merged, + replyId?.let { mapOf(merged.id to it) } ?: emptyMap(), + ).last() + val mergedForPersistence = mergedWithReply.copy(pendingFilename = null) AttachmentMediaLog.persist( "merge_confirmed", "msgId" to envelope.id, "clientId" to cid, - "localPreview" to (merged.pendingFileUri?.take(64) ?: "null"), + "localPreview" to (mergedWithReply.pendingFileUri?.take(64) ?: "null"), "aspect" to aspect, ) @@ -471,7 +475,7 @@ class DmPanel( optimisticIndex >= 0 -> { currentState.messages.mapIndexedNotNull { index, message -> when { - index == optimisticIndex -> merged + index == optimisticIndex -> mergedWithReply message.id == envelope.id -> null else -> message } @@ -479,10 +483,10 @@ class DmPanel( } existingRealIndex >= 0 -> { currentState.messages.mapIndexed { index, message -> - if (index == existingRealIndex) merged else message + if (index == existingRealIndex) mergedWithReply else message } } - else -> currentState.messages + merged + else -> currentState.messages + mergedWithReply } val deduped = dedupeMessagesByClientId(newMessages) currentState.copy(messages = deduped) @@ -540,7 +544,7 @@ class DmPanel( val username = if (envelope.senderId == currentUserId) { "You" } else { - otherDisplayName.ifBlank { "User $otherUserId" } + otherDisplayName } return Message( id = envelope.id, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt index 2931b02..3455c0d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt @@ -31,6 +31,8 @@ import ru.fromchat.ui.chat.AvatarInfo import ru.fromchat.ui.chat.ChatPanel import ru.fromchat.ui.chat.utils.PublicChatTypingHandler import ru.fromchat.ui.chat.utils.TypingHandler +import ru.fromchat.ui.chat.utils.attachPublicReplyReferences +import ru.fromchat.ui.chat.utils.mergeDatabaseMessagesWithPanelState import ru.fromchat.ui.chat.utils.preserveReplyToFromExisting class PublicChatPanel( @@ -160,23 +162,31 @@ class PublicChatPanel( private suspend fun hydrateMessagesFromLocalCache() { val cached = withContext(Dispatchers.Default) { - runCatching { MessageRepository.loadRecentPublicMessagesImmediate(limit = 128) } + runCatching { MessageRepository.loadPublicMessages() } .getOrDefault(emptyList()) } - if (cached.isEmpty()) return + if (cached.isEmpty() && _state.messages.isEmpty()) return withContext(Dispatchers.Main) { batchStateUpdates { val shown = _state.messages - if (shown.isNotEmpty()) { - val withReplies = preserveReplyToFromExisting(shown, cached) - if (withReplies != shown) { - updateState { it.copy(messages = sortMessagesForChatDisplay(withReplies)) } + when { + shown.isEmpty() -> { + if (cached.isNotEmpty()) { + clearMessages() + addMessages(cached) + } + setLoading(false) + } + cached.isEmpty() -> setLoading(false) + else -> { + // Never replace the in-memory list with the DB snapshot alone — that + // dropped paginated / ahead-of-network rows when reopening (e.g. profile). + val merged = mergeDatabaseMessagesWithPanelState(shown, cached) + if (merged != shown) { + updateState { it.copy(messages = sortMessagesForChatDisplay(merged)) } + } + setLoading(false) } - setLoading(false) - } else { - clearMessages() - addMessages(cached) - setLoading(false) } } } @@ -219,10 +229,11 @@ class PublicChatPanel( private suspend fun ingestIncomingPublicMessage(newMsg: Message) { ProfileCache.mergePreviewFromPublicMessage(newMsg) - val displayMessage = ProfileCache.enrichPublicMessageForDisplay(newMsg) + val withReply = attachPublicReplyReferences(_state.messages + newMsg).last() + val displayMessage = ProfileCache.enrichPublicMessageForDisplay(withReply) addMessage(displayMessage) withContext(Dispatchers.Default) { - MessageCacheStore.upsertPublicMessage(newMsg) + MessageCacheStore.upsertPublicMessage(withReply) } } @@ -365,6 +376,9 @@ class PublicChatPanel( messages = older + currentState.messages ) } + withContext(Dispatchers.Default) { + MessageCacheStore.replacePublicMessages(_state.messages) + } } setHasMoreMessages(false) // TODO: Implement has_more from API } catch (_: Exception) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageUiMerge.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageUiMerge.kt index a2b6300..8bc04b4 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageUiMerge.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageUiMerge.kt @@ -5,6 +5,44 @@ import ru.fromchat.api.local.cache.DecryptedImageCache import ru.fromchat.api.local.db.aspectRatioFromDimensionPair import ru.fromchat.api.local.messages.sortMessagesForChatDisplay import ru.fromchat.api.schema.messages.Message +import ru.fromchat.api.schema.messages.dm.DmEnvelope + +internal fun resolveDmReplyToId( + envelope: DmEnvelope?, + parsedReplyToId: Int?, +): Int? = envelope?.replyToId?.takeIf { it > 0 } + ?: parsedReplyToId?.takeIf { it > 0 } + +internal fun resolvePublicReplyToId(message: Message): Int? = + message.replyToId?.takeIf { it > 0 } + ?: message.reply_to?.id?.takeIf { it > 0 } + +/** Resolves [Message.reply_to] from in-chat siblings when the payload is missing or stub-only. */ +internal fun attachPublicReplyReferences( + messages: List, + parsedReplyIds: Map = emptyMap(), +): List { + val byId = messages.associateBy { it.id } + return messages.map { msg -> + val replyId = parsedReplyIds[msg.id] ?: resolvePublicReplyToId(msg) ?: return@map msg + val nested = msg.reply_to + if (nested != null && nested.content.isNotBlank()) return@map msg + byId[replyId]?.let { msg.copy(reply_to = it, replyToId = replyId) } ?: msg + } +} + +/** Resolves [Message.reply_to] from envelope metadata and/or parsed reply ids. */ +internal fun attachDmReplyReferences( + messages: List, + parsedReplyIds: Map = emptyMap(), +): List { + val byId = messages.associateBy { it.id } + return messages.map { msg -> + if (msg.reply_to != null) return@map msg + val replyId = resolveDmReplyToId(msg.dmEnvelope, parsedReplyIds[msg.id]) ?: return@map msg + byId[replyId]?.let { msg.copy(reply_to = it) } ?: msg + } +} /** * SQLDelight rows omit optimistic attachment fields; merge DB snapshot with in-memory UI state. @@ -70,6 +108,7 @@ internal fun mergeMessageUiFields(db: Message, panel: Message?): Message { fileDimensions = db.fileDimensions ?: panel.fileDimensions, content = db.content.ifBlank { panel.content }, isContentCorrupted = panel.isContentCorrupted || db.isContentCorrupted, + replyToId = db.replyToId ?: panel.replyToId, reply_to = db.reply_to ?: panel.reply_to, ) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ScreenSurface.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ScreenSurface.kt new file mode 100644 index 0000000..02237cd --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ScreenSurface.kt @@ -0,0 +1,28 @@ +package ru.fromchat.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +/** + * Full-bleed themed background for screens behind edge-to-edge system bars. + * Paint this at the root; apply [WindowInsets] padding on inner content, not on this layer. + */ +@Composable +fun ScreenSurface( + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.background, + content: @Composable BoxScope.() -> Unit, +) { + Box( + modifier = modifier + .fillMaxSize() + .background(color), + content = content, + ) +} 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 40f0969..d54a810 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 @@ -90,7 +90,6 @@ 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 { private const val CATEGORY_TOP_SPACER = 0 @@ -873,7 +872,7 @@ internal fun DmConversationRowContent( isPeerDeleted -> deletedUserDisplayNameForUi() !cached?.displayName.isNullOrBlank() -> cached.displayName!!.trim() conversation.displayName.isNotBlank() -> conversation.displayName - else -> stringResource(Res.string.user_fallback, conversation.otherUserId) + else -> cached?.visibleUsername(currentUserId).orEmpty() } val avatarInitialsLabel = when { isPeerDeleted -> deletedUserDisplayNameForUi() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt index e2a38d4..12da162 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.pr0gramm3r101.utils.resetFocus @@ -76,6 +77,7 @@ import ru.fromchat.search_not_found_message import ru.fromchat.search_title import ru.fromchat.ui.components.SearchBar import ru.fromchat.ui.components.SearchBarSharedElement +import ru.fromchat.ui.components.ScreenSurface import ru.fromchat.ui.components.Text @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -229,12 +231,12 @@ fun ChatsSearchScreen( } } - Scaffold( - modifier = Modifier - .imePadding() - .windowInsetsPadding(WindowInsets.safeDrawing), - contentWindowInsets = WindowInsets.safeDrawing, - topBar = { + ScreenSurface { + Scaffold( + modifier = Modifier.imePadding(), + containerColor = Color.Transparent, + contentWindowInsets = WindowInsets.safeDrawing, + topBar = { SearchBar( query = searchText, onQueryChange = { searchText = it }, @@ -325,6 +327,7 @@ fun ChatsSearchScreen( } } } + } } private fun matchesSearchConversations(conv: CachedConversation, normalizedQuery: String) = 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 1332648..736dca0 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 @@ -51,6 +51,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -86,6 +87,7 @@ import ru.fromchat.Res import ru.fromchat.action_delete import ru.fromchat.action_mark_read import ru.fromchat.api.ApiClient +import ru.fromchat.api.ChatListSync import ru.fromchat.api.calls.CallStore import ru.fromchat.api.local.WebSocketManager import ru.fromchat.api.local.cache.CacheContext @@ -289,6 +291,7 @@ fun ChatsTab( val connectionStatus by ConnectionStateStore.status.collectAsState() val online by NetworkConnectivity.isOnline.collectAsState(initial = true) val activeInstanceId by CacheContext.activeInstanceId.collectAsState() + val profileCacheRevision by ProfileCache.revision.collectAsState() var dmConversations by remember(activeInstanceId) { val cached = if (activeInstanceId.isBlank()) { emptyList() @@ -334,8 +337,11 @@ fun ChatsTab( var showSuspendedSupportSheet by remember { mutableStateOf(false) } val defaultLastMessage = stringResource(Res.string.chat_last_mesaage) - LaunchedEffect(previewStrings.imageOnly, previewStrings.attachmentOnly) { + LaunchedEffect(previewStrings.imageOnly, previewStrings.attachmentOnly, activeInstanceId) { MessageCacheStore.listPreviewStrings = previewStrings + if (activeInstanceId.isNotBlank()) { + runCatching { ChatListSync.syncFromNetwork() } + } } SideEffect { @@ -716,6 +722,7 @@ fun ChatsTab( .padding(horizontal = 12.dp, vertical = 8.dp), ) } else { + key(profileCacheRevision) { ChatConversationsList( listState = tabListState, listFilter = ChatListFilter.Active, @@ -809,6 +816,7 @@ fun ChatsTab( }, ) } + } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogFilesScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogFilesScreen.kt index 16497ab..2d281f2 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogFilesScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogFilesScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -108,6 +109,7 @@ import ru.fromchat.logging.LogShareCompression import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.components.BackHandler import ru.fromchat.ui.components.PredictiveBackHandler +import ru.fromchat.ui.components.ScreenSurface import ru.fromchat.ui.components.Text import ru.fromchat.ui.main.chats.ChatSelectionTransitionSpring import ru.fromchat.ui.main.chats.SelectionCheckmarkSlot @@ -424,6 +426,7 @@ fun LogFilesScreen( val listBottomInset = if (selectionBarVisible) 88.dp else 8.dp val fileCategoryColor = MaterialTheme.colorScheme.surfaceContainer + ScreenSurface { Scaffold( modifier = Modifier.fillMaxSize(), containerColor = Color.Transparent, @@ -442,7 +445,9 @@ fun LogFilesScreen( topBar = { Box { TopAppBar( - modifier = Modifier.graphicsLayer { alpha = 1f - selectionProgress }, + modifier = Modifier + .graphicsLayer { alpha = 1f - selectionProgress } + .background(MaterialTheme.colorScheme.surfaceContainer), navigationIcon = { IconButton( onClick = { navController.navigateUp() }, @@ -493,7 +498,8 @@ fun LogFilesScreen( Column( modifier = Modifier .fillMaxSize() - .padding(innerPadding), + .padding(innerPadding) + .background(MaterialTheme.colorScheme.background), ) { DisableSelection { LazyColumn( @@ -626,6 +632,7 @@ fun LogFilesScreen( } } } + } } @Composable 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 1230fdc..f340833 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 @@ -15,6 +15,7 @@ import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.animation.animateContentSize import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.interaction.MutableInteractionSource @@ -197,6 +198,7 @@ import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.components.BackHandler import ru.fromchat.ui.components.ExpressiveIconFrame import ru.fromchat.ui.components.PredictiveBackHandler +import ru.fromchat.ui.components.ScreenSurface import ru.fromchat.ui.components.Text import ru.fromchat.ui.main.chats.ChatSelectionTransitionSpring import ru.fromchat.ui.main.chats.SelectionCheckmarkSlot @@ -719,6 +721,7 @@ fun LogsScreen() { ToggleNavScrimEffect() } + ScreenSurface { Scaffold( modifier = Modifier .fillMaxSize() @@ -752,9 +755,11 @@ fun LogsScreen() { topBar = { Box { TopAppBar( - modifier = Modifier.graphicsLayer { - alpha = (1f - selectionProgress) * (1f - searchProgress) - }, + modifier = Modifier + .graphicsLayer { + alpha = (1f - selectionProgress) * (1f - searchProgress) + } + .background(MaterialTheme.colorScheme.surfaceContainer), navigationIcon = { IconButton( onClick = { navController.navigateUp() }, @@ -976,12 +981,17 @@ fun LogsScreen() { } }, ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .background(MaterialTheme.colorScheme.background), + ) { when { displayEntries.isEmpty() -> { Column( modifier = Modifier .fillMaxSize() - .padding(innerPadding) .then(if (searchMode) Modifier.imePadding() else Modifier), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, @@ -998,7 +1008,6 @@ fun LogsScreen() { Column( modifier = Modifier .fillMaxSize() - .padding(innerPadding) .imePadding(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, @@ -1052,13 +1061,14 @@ fun LogsScreen() { Column( modifier = Modifier .fillMaxSize() - .padding(innerPadding) .then(if (searchMode) Modifier.imePadding() else Modifier), ) { listContent() } } } + } + } } } 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 72b1a68..5c2f89a 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 @@ -82,6 +82,7 @@ import ru.fromchat.ui.components.DisabledBringIntoViewSpec import ru.fromchat.ui.components.FromChatSnackbarHost import ru.fromchat.ui.components.HazeActionButton import ru.fromchat.ui.components.LazyListFocusScrollEffect +import ru.fromchat.ui.components.ScreenSurface import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.expressiveStepFieldColors import ru.fromchat.ui.components.rememberLazyListFocusScrollState @@ -320,6 +321,7 @@ fun EditProfileScreen( } } + ScreenSurface { Scaffold( modifier = Modifier.fillMaxSize(), contentWindowInsets = WindowInsets.navigationBars, @@ -499,4 +501,5 @@ fun EditProfileScreen( } } } + } } 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 c1ef900..6b5067c 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 @@ -160,6 +160,7 @@ import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.chat.Avatar import ru.fromchat.ui.chat.TypingIndicator import ru.fromchat.ui.components.FromChatSnackbarHost +import ru.fromchat.ui.components.ScreenSurface import ru.fromchat.ui.components.ShimmerBox import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.showReplacingSnackbar @@ -296,6 +297,17 @@ fun ProfileScreen( } val latestUi by rememberUpdatedState(state) + val profileCacheRevision by ProfileCache.revision.collectAsState() + val isOwnProfileLookup = targetUserId == null && targetUsername == null + + LaunchedEffect(profileCacheRevision) { + if (!isOwnProfileLookup) return@LaunchedEffect + ownUserId?.let { ProfileCache.get(it) }?.let { cached -> + if (hasDisplayableProfile(cached, initialDisplayName, ownUserId)) { + state = latestUi.copy(profile = cached, isLoading = false, error = null) + } + } + } val backStackEntry = navController.currentBackStackEntry LaunchedEffect(backStackEntry, lookupKey) { @@ -307,6 +319,7 @@ fun ProfileScreen( try { val refreshed = ApiClient.getOwnProfile() ProfileCache.put(refreshed) + ApiClient.applyOwnProfile(refreshed) state = latestUi.copy(profile = refreshed, error = null) } catch (_: Exception) { ownUserId?.let { ProfileCache.get(it) }?.let { cached -> @@ -367,6 +380,9 @@ fun ProfileScreen( ) ProfileCache.put(profile) + if (targetUserId == null && targetUsername == null) { + ApiClient.applyOwnProfile(profile) + } state = latestUi.copy(profile = profile, isLoading = false, error = null) loadedSuccessfully = true true @@ -604,21 +620,18 @@ fun ProfileScreen( showDetailsUsername || showDetailsMemberSince || showDetailsBio || showDetailsVerify ) - Box( - modifier = modifier - .fillMaxSize() - .windowInsetsPadding(WindowInsets.navigationBars), - ) { + ScreenSurface(modifier = modifier) { val useSharedAvatar = sharedTransitionScope != null && animatedVisibilityScope != null && sharedAvatarKey != null - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .hazeSource(hazeState), - ) { + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .hazeSource(hazeState), + ) { LazyColumn( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, @@ -764,10 +777,12 @@ fun ProfileScreen( hostState = snackbarHostState, modifier = Modifier .align(Alignment.BottomCenter) + .windowInsetsPadding(WindowInsets.navigationBars) .padding(horizontal = 16.dp) .padding(bottom = 16.dp) .fillMaxWidth(), ) + } } } @OptIn(ExperimentalMaterial3Api::class) @@ -890,21 +905,18 @@ fun PublicChatProfileScreen( animatedVisibilityScope != null && sharedAvatarKey != null - Box( - modifier = modifier - .fillMaxSize() - .windowInsetsPadding(WindowInsets.navigationBars), - ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .hazeSource(hazeState), - ) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, + ScreenSurface(modifier = modifier) { + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .hazeSource(hazeState), ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { when { useSharedAvatar && displayName.isNotBlank() -> { item { @@ -974,10 +986,12 @@ fun PublicChatProfileScreen( hostState = snackbarHostState, modifier = Modifier .align(Alignment.BottomCenter) + .windowInsetsPadding(WindowInsets.navigationBars) .padding(horizontal = 16.dp) .padding(bottom = 16.dp) .fillMaxWidth(), ) + } } } diff --git a/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq b/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq index 49c570e..28a7258 100644 --- a/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq +++ b/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq @@ -284,7 +284,7 @@ WHERE instanceId = ? AND id = ?; deleteEmptyDmConversations: DELETE FROM conversation -WHERE instanceId = ? AND type = 'dm' AND id NOT IN ( +WHERE instanceId = ? AND type = 'dm' AND lastMessageId IS NULL AND id NOT IN ( SELECT DISTINCT conversationId FROM message WHERE instanceId = ? AND deletedFlag = 0 ); diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/Theme.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/Theme.ios.kt index 4a59b98..9da7155 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/Theme.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/Theme.ios.kt @@ -3,7 +3,11 @@ package ru.fromchat.ui import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color @Composable actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) = - if (darkTheme) darkColorScheme() else lightColorScheme() \ No newline at end of file + if (darkTheme) darkColorScheme() else lightColorScheme() + +@Composable +actual fun ApplySystemBarTheme(darkTheme: Boolean, surfaceColor: Color) = Unit