mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Implement functional replies
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -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">Can’t show this message</string>
|
||||
<string name="attachment_image_load_failed">Failed to load</string>
|
||||
<string name="attachment_upload_failed">Couldn\'t send file</string>
|
||||
|
||||
+34
-10
@@ -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,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(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+23
-7
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user