From 206acd712a45bd01d7eb5edb8dc1d53d958e780c Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 9 Jul 2026 19:02:42 +0300 Subject: [PATCH] Implement functional replies Signed-off-by: denis0001-dev --- .../composeResources/values-ru/strings.xml | 1 + .../composeResources/values/strings.xml | 1 + .../api/local/db/store/MessageCacheStore.kt | 44 +++++-- .../api/local/db/store/ProfileCache.kt | 3 + .../kotlin/ru/fromchat/ui/chat/ChatPanel.kt | 11 ++ .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 83 ++++++++++++-- .../kotlin/ru/fromchat/ui/chat/MessageItem.kt | 108 +++++++++++++++--- .../ru/fromchat/ui/chat/panels/dm/DmPanel.kt | 42 ++++--- .../chat/panels/publicchat/PublicChatPanel.kt | 30 +++-- .../fromchat/ui/chat/utils/MessageUiMerge.kt | 13 +++ 10 files changed, 275 insertions(+), 61 deletions(-) diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index beb4cfc..cdc47bc 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -116,6 +116,7 @@ Это сообщение не удалось показать. (изменено) Ответ %1$s + Перейти к цитируемому сообщению Сообщение не показывается Не удалось загрузить Не удалось отправить файл diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index f481184..b801b7e 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -128,6 +128,7 @@ This message could not be shown. (edited) Reply to %1$s + Jump to quoted message Can’t show this message Failed to load Couldn\'t send file 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 f49f339..beddfcd 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 @@ -78,7 +78,7 @@ object MessageCacheStore { .asFlow() .mapToList(Dispatchers.Default) .map { rows -> - val raw = rows.map { it.toAppMessage() } + val raw = hydrateReplyReferences(rows) val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id) sortMessagesForChatDisplay( validatedOrEmpty( @@ -187,11 +187,12 @@ object MessageCacheStore { val merged = dedupeMessagesByClientId( dropSupersededOptimisticMessages(before, ApiClient.user?.id), ).let { sortMessagesForChatDisplay(it) } + val hydrated = hydrateReplyToInMemory(merged) val iid = instanceId() withContext(Dispatchers.Default) { - purgeSupersededPendingRows(iid, convId, before, merged) + purgeSupersededPendingRows(iid, convId, before, hydrated) } - replaceMessages(convId, merged) + replaceMessages(convId, hydrated) pruneEmptyConversations() } @@ -316,7 +317,7 @@ object MessageCacheStore { timestamp = msg.timestamp, isRead = if (msg.is_read) 1L else 0L, isEdited = if (msg.is_edited) 1L else 0L, - replyToId = msg.reply_to?.id?.toLong(), + replyToId = resolveReplyToIdForPersistence(msg, row.replyToId), clientMessageId = msg.client_message_id, deletedFlag = 0L, sendStatus = "sent", @@ -808,6 +809,10 @@ object MessageCacheStore { private suspend fun upsertSingle(conversationId: String, msg: Message) { val iid = instanceId() withContext(Dispatchers.Default) { + val existingReplyToId = db.messageDatabaseQueries + .selectMessageById(iid, conversationId, msg.id.toLong()) + .executeAsOneOrNull() + ?.replyToId db.messageDatabaseQueries.upsertMessage( instanceId = iid, id = msg.id.toLong(), @@ -817,7 +822,7 @@ object MessageCacheStore { timestamp = msg.timestamp, isRead = if (msg.is_read) 1L else 0L, isEdited = if (msg.is_edited) 1L else 0L, - replyToId = msg.reply_to?.id?.toLong(), + replyToId = resolveReplyToIdForPersistence(msg, existingReplyToId), clientMessageId = msg.client_message_id, deletedFlag = 0L, sendStatus = if (msg.id < 0) "pending" else "sent" @@ -840,7 +845,7 @@ object MessageCacheStore { timestamp = confirmed.timestamp, isRead = if (confirmed.is_read) 1L else 0L, isEdited = if (confirmed.is_edited) 1L else 0L, - replyToId = confirmed.reply_to?.id?.toLong(), + replyToId = resolveReplyToIdForPersistence(confirmed), clientMessageId = confirmed.client_message_id, deletedFlag = 0L, sendStatus = "sent" @@ -860,7 +865,7 @@ object MessageCacheStore { val rows = db.messageDatabaseQueries .selectMessagesByConversation(iid, conversationId) .executeAsList() - val raw = rows.map { it.toAppMessage() } + val raw = hydrateReplyReferences(rows) val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id) purgeSupersededPendingRows(iid, conversationId, raw, withoutSuperseded) sortMessagesForChatDisplay( @@ -880,7 +885,7 @@ object MessageCacheStore { val rows = db.messageDatabaseQueries .selectRecentMessagesByConversation(iid, conversationId, limit) .executeAsList() - rows.map { it.toAppMessage() }.reversed() + hydrateReplyReferences(rows).reversed() } } @@ -990,7 +995,7 @@ object MessageCacheStore { val messages = rows.map { it.toAppMessage() } val byId = messages.associateBy { it.id } return rows.zip(messages).map { (row, message) -> - val replyId = row.replyToId?.toInt() + val replyId = row.replyToId?.toInt() ?: message.dmEnvelope?.replyToId if (replyId != null) { message.copy(reply_to = byId[replyId]) } else { @@ -999,6 +1004,21 @@ object MessageCacheStore { } } + private fun hydrateReplyToInMemory(messages: List): List { + val byId = messages.associateBy { it.id } + return messages.map { msg -> + if (msg.reply_to != null) return@map msg + val replyId = msg.dmEnvelope?.replyToId ?: return@map msg + byId[replyId]?.let { msg.copy(reply_to = it) } ?: msg + } + } + + private fun resolveReplyToIdForPersistence(msg: Message, existingReplyToId: Long? = null): Long? { + return msg.reply_to?.id?.toLong() + ?: msg.dmEnvelope?.replyToId?.toLong() + ?: existingReplyToId?.takeIf { it > 0L } + } + private fun DbMessage.toAppMessage(): Message { val uid = userId.toInt() val self = ApiClient.user @@ -1080,6 +1100,10 @@ object MessageCacheStore { val validated = CacheValidator.filterMessages(conversationId, messages, self) val iid = instanceId() withContext(Dispatchers.Default) { + val existingReplyToIds = db.messageDatabaseQueries + .selectMessagesByConversation(iid, conversationId) + .executeAsList() + .associate { it.id.toInt() to it.replyToId } db.messageDatabaseQueries.transaction { db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId) validated.forEach { msg: Message -> @@ -1092,7 +1116,7 @@ object MessageCacheStore { timestamp = msg.timestamp, isRead = if (msg.is_read) 1L else 0L, isEdited = if (msg.is_edited) 1L else 0L, - replyToId = msg.reply_to?.id?.toLong(), + replyToId = resolveReplyToIdForPersistence(msg, existingReplyToIds[msg.id]), clientMessageId = msg.client_message_id, deletedFlag = 0L, sendStatus = if (msg.id < 0) "pending" else "sent" 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 2a4ce83..ef2371a 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 @@ -233,6 +233,7 @@ object ProfileCache { message: Message, currentUserId: Int? = ApiClient.user?.id, ): Message { + val enrichedReply = message.reply_to?.let { enrichPublicMessageForDisplay(it, currentUserId) } val self = currentUserId if (self != null && message.user_id == self) { val user = ApiClient.user @@ -240,6 +241,7 @@ object ProfileCache { username = message.username.trim().ifBlank { user?.username.orEmpty() }, profile_picture = message.profile_picture?.takeIf { it.isNotBlank() } ?: user?.profile_picture, + reply_to = enrichedReply, ) } val profile = get(message.user_id) @@ -251,6 +253,7 @@ object ProfileCache { ?: profile?.profilePicture, verified = message.verified ?: profile?.verified, verificationStatus = message.verificationStatus ?: profile?.verificationStatus, + reply_to = enrichedReply, ) } 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 848ee4e..30462d9 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 @@ -262,6 +262,17 @@ abstract class ChatPanel( updateState { it.copy(messages = it.messages.filter { msg -> msg.id != messageId }) } } + /** Drop reply previews that point at a message removed from the chat. */ + protected fun clearReplyReferencesTo(deletedMessageId: Int) { + updateState { currentState -> + val updated = currentState.messages.map { msg -> + if (msg.reply_to?.id == deletedMessageId) msg.copy(reply_to = null) else msg + } + if (updated == currentState.messages) currentState + else currentState.copy(messages = updated) + } + } + protected fun removeMessageByClientMessageId(clientMessageId: String) { updateState { it.copy(messages = it.messages.filter { msg -> msg.client_message_id != clientMessageId }) 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 08e72df..86b4ca9 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 @@ -38,6 +38,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -56,6 +57,7 @@ import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.rememberHazeState +import kotlin.math.roundToInt import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged @@ -118,6 +120,7 @@ import ru.fromchat.utils.haptic.HapticFeedbackEvent import ru.fromchat.utils.haptic.rememberHapticFeedback import ru.fromchat.utils.rememberLastSeenFormatStrings import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @Composable @@ -156,7 +159,40 @@ fun ChatScreen( val listState = rememberSaveable(panelId, saver = LazyListState.Saver) { LazyListState(0, 0) } + val density = LocalDensity.current + val fallbackMessageHeightPx = remember(density) { with(density) { 80.dp.roundToPx() } } val scope = rememberCoroutineScope() + var highlightMessageId by remember { mutableStateOf(null) } + var highlightFading by remember { mutableStateOf(false) } + LaunchedEffect(highlightMessageId) { + val messageId = highlightMessageId ?: return@LaunchedEffect + highlightFading = false + delay(1.seconds) + highlightFading = true + delay(300.milliseconds) + if (highlightMessageId == messageId) { + highlightMessageId = null + highlightFading = false + } + } + val chatScrollClearancePx = remember { mutableStateOf(0 to 0) } + val scrollToChatMessage: (Int) -> Unit = { messageId -> + val messages = panelState.messages + val messageIndex = messages.indexOfFirst { it.id == messageId } + if (messageIndex != -1) { + scope.launch { + val lazyIndex = 1 + (messages.size - 1 - messageIndex) + val (topClearancePx, bottomClearancePx) = chatScrollClearancePx.value + listState.scrollChatMessageToCenter( + lazyIndex, + topClearancePx, + bottomClearancePx, + fallbackMessageHeightPx, + ) + highlightMessageId = messageId + } + } + } val saveMessageImage = rememberSaveMessageImage { /* best-effort */ } val saveMessageFile = rememberSaveMessageFile { /* best-effort */ } val haptic = rememberHapticFeedback() @@ -250,17 +286,7 @@ fun ChatScreen( // Scroll to specific message when requested (e.g., from notification click) LaunchedEffect(scrollToMessageId, panelState.messages) { - scrollToMessageId?.let { messageId -> - val messages = panelState.messages - val messageIndex = messages.indexOfFirst { it.id == messageId } - if (messageIndex != -1) { - scope.launch { - // reverseLayout list: index 0 = bottom spacer, 1..n = newest..oldest - val lazyIndex = 1 + (messages.size - 1 - messageIndex) - listState.animateScrollToItem(index = lazyIndex, scrollOffset = 0) - } - } - } + scrollToMessageId?.let(scrollToChatMessage) } // UI state @@ -695,6 +721,11 @@ fun ChatScreen( val statusBarTopDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() } val floatingHeaderClearance = statusBarTopDp + 64.dp + 12.dp + ChatFloatingHeaderBottomArcRadius + SideEffect { + chatScrollClearancePx.value = with(density) { + floatingHeaderClearance.roundToPx() to innerPadding.calculateBottomPadding().roundToPx() + } + } Box( modifier = Modifier @@ -800,6 +831,9 @@ fun ChatScreen( OutgoingMessageCoordinator.retryDmAttachmentUpload(cid) } }, + onReplyClick = scrollToChatMessage, + highlightMessageId = highlightMessageId, + highlightFading = highlightFading, ) } @@ -951,3 +985,30 @@ fun ChatScreen( } } + +private suspend fun LazyListState.scrollChatMessageToCenter( + lazyIndex: Int, + topClearancePx: Int, + bottomClearancePx: Int, + fallbackItemHeightPx: Int, +) { + val info = layoutInfo + val viewportHeight = ( + info.viewportEndOffset - info.afterContentPadding - info.viewportStartOffset + ).coerceAtLeast(0) + val itemHeight = info.visibleItemsInfo.firstOrNull { it.index == lazyIndex }?.size + ?: info.visibleItemsInfo + .filter { it.index > 0 } + .map { it.size } + .takeIf { it.isNotEmpty() } + ?.average() + ?.toInt() + ?: fallbackItemHeightPx + // reverseLayout: scrollOffset 0 pins the item to the visual bottom (under the input bar). + // Negative scrollOffset moves it upward into the viewport; positive would push it further off-screen. + val visibleTop = topClearancePx + val visibleBottom = (viewportHeight - bottomClearancePx).coerceAtLeast(visibleTop) + val targetCenterY = (visibleTop + visibleBottom) / 2f + val scrollOffset = (targetCenterY + itemHeight / 2f - viewportHeight).roundToInt() + animateScrollToItem(lazyIndex, scrollOffset) +} 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 f14c309..d561c92 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 @@ -3,6 +3,7 @@ package ru.fromchat.ui.chat import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -62,6 +63,7 @@ import ru.fromchat.api.local.messages.isQueuedOutbound import ru.fromchat.api.schema.messages.Message import ru.fromchat.message_corrupted import ru.fromchat.message_edited_suffix +import ru.fromchat.message_reply_jump_cd import ru.fromchat.message_send_failed import ru.fromchat.ui.chat.components.getMessageGradient import ru.fromchat.ui.chat.components.getReplyMessageGradient @@ -113,6 +115,9 @@ fun MessageItem( isContextMenuForThisMessage: Boolean = false, onCancelOutboundAttachment: ((Message) -> Unit)? = null, onRetryOutboundAttachment: ((Message) -> Unit)? = null, + onReplyClick: ((Int) -> Unit)? = null, + highlightMessageId: Int? = null, + highlightFading: Boolean = false, ) { // Cache derived values per message to avoid recomputing in every recomposition. val isCorrupted = remember(message.files, message.fileThumbnails, message.dmEnvelope) { @@ -138,10 +143,12 @@ 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) } 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 val scale by animateFloatAsState( targetValue = scaleTarget, animationSpec = spring( @@ -160,14 +167,53 @@ fun MessageItem( visibilityThreshold = 0.001f, label = "messageAvatarScale" ) + val replyScale by animateFloatAsState( + targetValue = replyScaleTarget, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow + ), + visibilityThreshold = 0.001f, + label = "messageReplyScale" + ) + val isHighlightTarget = highlightMessageId == message.id && message.id > 0 + val highlightAlpha by animateFloatAsState( + targetValue = when { + !isHighlightTarget -> 0f + highlightFading -> 0f + else -> 1f + }, + animationSpec = tween( + durationMillis = when { + !isHighlightTarget -> 0 + highlightFading -> 300 + else -> 250 + }, + ), + label = "replyJumpHighlight", + ) + val replyJumpCd = stringResource(Res.string.message_reply_jump_cd) - Row( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start, - verticalAlignment = Alignment.Bottom - ) { + Box(modifier = modifier.fillMaxWidth()) { + if (highlightAlpha > 0f) { + Box( + Modifier + .matchParentSize() + .background( + MaterialTheme.colorScheme.primaryContainer.copy( + alpha = 0.4f * highlightAlpha, + ), + ), + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start, + verticalAlignment = Alignment.Bottom, + ) { if (!isAuthor && showUsername) { Box( modifier = Modifier @@ -407,8 +453,37 @@ fun MessageItem( // Reply preview replyRef?.let { replyToMsg -> val replyName = messageDisplayUsername(replyToMsg, currentUserId) + val replyTapEnabled = onReplyClick != null && replyToMsg.id > 0 + val replyPressModifier = + if (replyTapEnabled && !isContextMenuOpen) { + Modifier.pointerInput(replyToMsg.id, isContextMenuOpen) { + detectTapGestures( + onPress = { + replyPressed = true + try { + awaitRelease() + } finally { + replyPressed = false + } + }, + onTap = { onReplyClick.invoke(replyToMsg.id) }, + ) + } + } else { + Modifier + } Box( - Modifier.padding(bottom = 4.dp, start = 6.dp, end = 6.dp) + Modifier + .padding(bottom = 4.dp, start = 6.dp, end = 6.dp) + .graphicsLayer( + scaleX = replyScale, + scaleY = replyScale, + transformOrigin = TransformOrigin.Center, + ) + .then(replyPressModifier) + .semantics { + if (replyTapEnabled) contentDescription = replyJumpCd + }, ) { Row( modifier = Modifier @@ -435,15 +510,13 @@ fun MessageItem( Column( Modifier.padding(horizontal = 8.dp, vertical = 6.dp) ) { - if (showUsername) { - Text( - text = replyName, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - fontSize = 11.sp - ) - } + Text( + text = replyName, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + fontSize = 11.sp + ) Text( text = replyToMsg.content.take(50) + if (replyToMsg.content.length > 50) "..." else "", style = MaterialTheme.typography.bodySmall, @@ -686,6 +759,7 @@ fun MessageItem( } } } + } } } 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 cfb691c..f21321c 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 @@ -46,6 +46,7 @@ import ru.fromchat.ui.chat.utils.TypingHandler 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.isImageFilename import ru.fromchat.api.local.send.seedOutboundFileAsDownloaded @@ -242,8 +243,8 @@ class DmPanel( val historyResult = runCatching { ApiClient.getDmHistory(otherUserId) } if (historyResult.isSuccess) { val response = historyResult.getOrNull() ?: return + val priorMessages = _state.messages val optimisticSnapshot = snapshotPendingOptimisticMessages() - clearMessages() val decryptedForLog = mutableListOf>() val messages = response.messages.map { envelope -> val outcome = decryptDmEnvelopeForUi(envelope) @@ -260,15 +261,22 @@ class DmPanel( msg.copy(reply_to = replyToMap[envelope.replyToId]) } else msg } - addMessages(messagesWithReplies) - restorePendingOptimisticMessages(optimisticSnapshot) - updateState { state -> - val cleaned = dedupeMessagesByClientId( - dropSupersededOptimisticMessages(state.messages, currentUserId), - ) - if (cleaned == state.messages) state else state.copy(messages = cleaned) + val mergedForUi = preserveReplyToFromExisting( + priorMessages + optimisticSnapshot, + messagesWithReplies, + ) + batchStateUpdates { + clearMessages() + addMessages(mergedForUi) + restorePendingOptimisticMessages(optimisticSnapshot) + updateState { state -> + val cleaned = dedupeMessagesByClientId( + dropSupersededOptimisticMessages(state.messages, currentUserId), + ) + if (cleaned == state.messages) state else state.copy(messages = cleaned) + } + setHasMoreMessages(false) } - setHasMoreMessages(false) // Persist the most recent DM messages for offline use. val mergedForCache = _state.messages @@ -361,16 +369,16 @@ class DmPanel( mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted) } else { 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 if (ActiveDmChatTracker.isActive(otherUserId)) { withContext(Dispatchers.Default) { - MessageCacheStore.upsertDmMessage(otherUserId, incoming) + MessageCacheStore.upsertDmMessage(otherUserId, withReply) } } - addMessage(incoming) - if (envelope.replyToId != null) { - val replyTo = _state.messages.find { it.id == envelope.replyToId } - updateMessage(envelope.id) { it.copy(reply_to = replyTo) } - } + addMessage(withReply) } } } @@ -515,7 +523,9 @@ class DmPanel( fileAspectRatios = dec.fileAspectRatios ?: it.fileAspectRatios, fileSizes = dec.fileSizes ?: it.fileSizes, fileDimensions = dec.fileDimensions ?: it.fileDimensions, - isContentCorrupted = outcome.isCorrupted + isContentCorrupted = outcome.isCorrupted, + dmEnvelope = envelope, + reply_to = it.reply_to, ) } 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 9982461..2931b02 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,7 @@ 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.preserveReplyToFromExisting class PublicChatPanel( /** Stable cache / panel id (not localized; hardcoded in [ru.fromchat.ui.chat.utils.PublicChatPanelCache]). */ @@ -78,6 +79,7 @@ class PublicChatPanel( profile_picture = fresh.profile_picture, verified = fresh.verified, verificationStatus = fresh.verificationStatus, + reply_to = fresh.reply_to ?: message.reply_to, ), ) } @@ -164,9 +166,18 @@ class PublicChatPanel( if (cached.isEmpty()) return withContext(Dispatchers.Main) { batchStateUpdates { - clearMessages() - addMessages(cached) - setLoading(false) + val shown = _state.messages + if (shown.isNotEmpty()) { + val withReplies = preserveReplyToFromExisting(shown, cached) + if (withReplies != shown) { + updateState { it.copy(messages = sortMessagesForChatDisplay(withReplies)) } + } + setLoading(false) + } else { + clearMessages() + addMessages(cached) + setLoading(false) + } } } } @@ -287,7 +298,10 @@ class PublicChatPanel( if (_state.isLoading) setLoading(false) } else { batchStateUpdates { - val merged = mergeNetworkHistoryWithShown(shown, response.messages) + val merged = preserveReplyToFromExisting( + shown, + mergeNetworkHistoryWithShown(shown, response.messages), + ) clearMessages() addMessages( ProfileCache.enrichPublicMessagesForDisplay(merged), @@ -384,7 +398,9 @@ class PublicChatPanel( val data = updateMessage.data ?: return val editedMsg = json.decodeFromJsonElement(Message.serializer(), data) DecryptedImageCache.invalidateForMessage(editedMsg.id) - updateMessage(editedMsg.id) { editedMsg } + updateMessage(editedMsg.id) { existing -> + editedMsg.copy(reply_to = editedMsg.reply_to ?: existing.reply_to) + } withContext(Dispatchers.Default) { MessageCacheStore.replacePublicMessages(_state.messages) } @@ -394,6 +410,7 @@ class PublicChatPanel( val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data) DecryptedImageCache.invalidateForMessage(deletedData.message_id) removeMessage(deletedData.message_id) + clearReplyReferencesTo(deletedData.message_id) withContext(Dispatchers.Default) { MessageRepository.markPublicMessageDeleted(deletedData.message_id) MessageCacheStore.replacePublicMessages(_state.messages) @@ -468,10 +485,9 @@ class PublicChatPanel( } override suspend fun handleDeleteMessage(messageId: Int) { - // Remove immediately from UI deleteMessageImmediately(messageId) + clearReplyReferencesTo(messageId) - // Send delete request ApiClient.deleteMessage(messageId) } 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 a4abb9b..5ca9d4d 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 @@ -69,5 +69,18 @@ internal fun mergeMessageUiFields(db: Message, panel: Message?): Message { fileDimensions = db.fileDimensions ?: panel.fileDimensions, content = db.content.ifBlank { panel.content }, isContentCorrupted = panel.isContentCorrupted || db.isContentCorrupted, + reply_to = db.reply_to ?: panel.reply_to, ) } + +/** Keeps hydrated [Message.reply_to] when a network/DB refresh omits nested reply payloads. */ +internal fun preserveReplyToFromExisting( + existing: List, + incoming: List, +): List { + val existingById = existing.associateBy { it.id } + return incoming.map { msg -> + if (msg.reply_to != null) msg + else existingById[msg.id]?.reply_to?.let { msg.copy(reply_to = it) } ?: msg + } +}