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
+ }
+}