Implement functional replies

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-09 19:02:42 +03:00
Unverified
parent 74e1126bc4
commit 206acd712a
10 changed files with 275 additions and 61 deletions
@@ -116,6 +116,7 @@
<string name="message_corrupted">Это сообщение не удалось показать.</string>
<string name="message_edited_suffix">(изменено)</string>
<string name="message_replying_to">Ответ %1$s</string>
<string name="message_reply_jump_cd">Перейти к цитируемому сообщению</string>
<string name="message_corrupted_short">Сообщение не показывается</string>
<string name="attachment_image_load_failed">Не удалось загрузить</string>
<string name="attachment_upload_failed">Не удалось отправить файл</string>
@@ -128,6 +128,7 @@
<string name="message_corrupted">This message could not be shown.</string>
<string name="message_edited_suffix">(edited)</string>
<string name="message_replying_to">Reply to %1$s</string>
<string name="message_reply_jump_cd">Jump to quoted message</string>
<string name="message_corrupted_short">Cant show this message</string>
<string name="attachment_image_load_failed">Failed to load</string>
<string name="attachment_upload_failed">Couldn\'t send file</string>
@@ -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<Message>): List<Message> {
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"
@@ -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,
)
}
@@ -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 })
@@ -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<Int?>(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)
}
@@ -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<LayoutCoordinates?>(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,13 +167,52 @@ 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)
Box(modifier = modifier.fillMaxWidth()) {
if (highlightAlpha > 0f) {
Box(
Modifier
.matchParentSize()
.background(
MaterialTheme.colorScheme.primaryContainer.copy(
alpha = 0.4f * highlightAlpha,
),
),
)
}
Row(
modifier = modifier
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom
verticalAlignment = Alignment.Bottom,
) {
if (!isAuthor && showUsername) {
Box(
@@ -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,7 +510,6 @@ fun MessageItem(
Column(
Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
) {
if (showUsername) {
Text(
text = replyName,
style = MaterialTheme.typography.labelSmall,
@@ -443,7 +517,6 @@ fun MessageItem(
color = MaterialTheme.colorScheme.primary,
fontSize = 11.sp
)
}
Text(
text = replyToMsg.content.take(50) + if (replyToMsg.content.length > 50) "..." else "",
style = MaterialTheme.typography.bodySmall,
@@ -687,5 +760,6 @@ fun MessageItem(
}
}
}
}
}
@@ -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<Pair<Int, String>>()
val messages = response.messages.map { envelope ->
val outcome = decryptDmEnvelopeForUi(envelope)
@@ -260,7 +261,13 @@ class DmPanel(
msg.copy(reply_to = replyToMap[envelope.replyToId])
} else msg
}
addMessages(messagesWithReplies)
val mergedForUi = preserveReplyToFromExisting(
priorMessages + optimisticSnapshot,
messagesWithReplies,
)
batchStateUpdates {
clearMessages()
addMessages(mergedForUi)
restorePendingOptimisticMessages(optimisticSnapshot)
updateState { state ->
val cleaned = dedupeMessagesByClientId(
@@ -269,6 +276,7 @@ class DmPanel(
if (cleaned == state.messages) state else state.copy(messages = cleaned)
}
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,
)
}
@@ -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,12 +166,21 @@ class PublicChatPanel(
if (cached.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)) }
}
setLoading(false)
} else {
clearMessages()
addMessages(cached)
setLoading(false)
}
}
}
}
private fun applyPublicChatProfile(profile: PublicChatProfile) {
updateState { s ->
@@ -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)
}
@@ -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<Message>,
incoming: List<Message>,
): List<Message> {
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
}
}