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_corrupted">Это сообщение не удалось показать.</string>
|
||||||
<string name="message_edited_suffix">(изменено)</string>
|
<string name="message_edited_suffix">(изменено)</string>
|
||||||
<string name="message_replying_to">Ответ %1$s</string>
|
<string name="message_replying_to">Ответ %1$s</string>
|
||||||
|
<string name="message_reply_jump_cd">Перейти к цитируемому сообщению</string>
|
||||||
<string name="message_corrupted_short">Сообщение не показывается</string>
|
<string name="message_corrupted_short">Сообщение не показывается</string>
|
||||||
<string name="attachment_image_load_failed">Не удалось загрузить</string>
|
<string name="attachment_image_load_failed">Не удалось загрузить</string>
|
||||||
<string name="attachment_upload_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_corrupted">This message could not be shown.</string>
|
||||||
<string name="message_edited_suffix">(edited)</string>
|
<string name="message_edited_suffix">(edited)</string>
|
||||||
<string name="message_replying_to">Reply to %1$s</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="message_corrupted_short">Can’t show this message</string>
|
||||||
<string name="attachment_image_load_failed">Failed to load</string>
|
<string name="attachment_image_load_failed">Failed to load</string>
|
||||||
<string name="attachment_upload_failed">Couldn\'t send file</string>
|
<string name="attachment_upload_failed">Couldn\'t send file</string>
|
||||||
|
|||||||
+34
-10
@@ -78,7 +78,7 @@ object MessageCacheStore {
|
|||||||
.asFlow()
|
.asFlow()
|
||||||
.mapToList(Dispatchers.Default)
|
.mapToList(Dispatchers.Default)
|
||||||
.map { rows ->
|
.map { rows ->
|
||||||
val raw = rows.map { it.toAppMessage() }
|
val raw = hydrateReplyReferences(rows)
|
||||||
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
|
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
|
||||||
sortMessagesForChatDisplay(
|
sortMessagesForChatDisplay(
|
||||||
validatedOrEmpty(
|
validatedOrEmpty(
|
||||||
@@ -187,11 +187,12 @@ object MessageCacheStore {
|
|||||||
val merged = dedupeMessagesByClientId(
|
val merged = dedupeMessagesByClientId(
|
||||||
dropSupersededOptimisticMessages(before, ApiClient.user?.id),
|
dropSupersededOptimisticMessages(before, ApiClient.user?.id),
|
||||||
).let { sortMessagesForChatDisplay(it) }
|
).let { sortMessagesForChatDisplay(it) }
|
||||||
|
val hydrated = hydrateReplyToInMemory(merged)
|
||||||
val iid = instanceId()
|
val iid = instanceId()
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
purgeSupersededPendingRows(iid, convId, before, merged)
|
purgeSupersededPendingRows(iid, convId, before, hydrated)
|
||||||
}
|
}
|
||||||
replaceMessages(convId, merged)
|
replaceMessages(convId, hydrated)
|
||||||
pruneEmptyConversations()
|
pruneEmptyConversations()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +317,7 @@ object MessageCacheStore {
|
|||||||
timestamp = msg.timestamp,
|
timestamp = msg.timestamp,
|
||||||
isRead = if (msg.is_read) 1L else 0L,
|
isRead = if (msg.is_read) 1L else 0L,
|
||||||
isEdited = if (msg.is_edited) 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,
|
clientMessageId = msg.client_message_id,
|
||||||
deletedFlag = 0L,
|
deletedFlag = 0L,
|
||||||
sendStatus = "sent",
|
sendStatus = "sent",
|
||||||
@@ -808,6 +809,10 @@ object MessageCacheStore {
|
|||||||
private suspend fun upsertSingle(conversationId: String, msg: Message) {
|
private suspend fun upsertSingle(conversationId: String, msg: Message) {
|
||||||
val iid = instanceId()
|
val iid = instanceId()
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
|
val existingReplyToId = db.messageDatabaseQueries
|
||||||
|
.selectMessageById(iid, conversationId, msg.id.toLong())
|
||||||
|
.executeAsOneOrNull()
|
||||||
|
?.replyToId
|
||||||
db.messageDatabaseQueries.upsertMessage(
|
db.messageDatabaseQueries.upsertMessage(
|
||||||
instanceId = iid,
|
instanceId = iid,
|
||||||
id = msg.id.toLong(),
|
id = msg.id.toLong(),
|
||||||
@@ -817,7 +822,7 @@ object MessageCacheStore {
|
|||||||
timestamp = msg.timestamp,
|
timestamp = msg.timestamp,
|
||||||
isRead = if (msg.is_read) 1L else 0L,
|
isRead = if (msg.is_read) 1L else 0L,
|
||||||
isEdited = if (msg.is_edited) 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,
|
clientMessageId = msg.client_message_id,
|
||||||
deletedFlag = 0L,
|
deletedFlag = 0L,
|
||||||
sendStatus = if (msg.id < 0) "pending" else "sent"
|
sendStatus = if (msg.id < 0) "pending" else "sent"
|
||||||
@@ -840,7 +845,7 @@ object MessageCacheStore {
|
|||||||
timestamp = confirmed.timestamp,
|
timestamp = confirmed.timestamp,
|
||||||
isRead = if (confirmed.is_read) 1L else 0L,
|
isRead = if (confirmed.is_read) 1L else 0L,
|
||||||
isEdited = if (confirmed.is_edited) 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,
|
clientMessageId = confirmed.client_message_id,
|
||||||
deletedFlag = 0L,
|
deletedFlag = 0L,
|
||||||
sendStatus = "sent"
|
sendStatus = "sent"
|
||||||
@@ -860,7 +865,7 @@ object MessageCacheStore {
|
|||||||
val rows = db.messageDatabaseQueries
|
val rows = db.messageDatabaseQueries
|
||||||
.selectMessagesByConversation(iid, conversationId)
|
.selectMessagesByConversation(iid, conversationId)
|
||||||
.executeAsList()
|
.executeAsList()
|
||||||
val raw = rows.map { it.toAppMessage() }
|
val raw = hydrateReplyReferences(rows)
|
||||||
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
|
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
|
||||||
purgeSupersededPendingRows(iid, conversationId, raw, withoutSuperseded)
|
purgeSupersededPendingRows(iid, conversationId, raw, withoutSuperseded)
|
||||||
sortMessagesForChatDisplay(
|
sortMessagesForChatDisplay(
|
||||||
@@ -880,7 +885,7 @@ object MessageCacheStore {
|
|||||||
val rows = db.messageDatabaseQueries
|
val rows = db.messageDatabaseQueries
|
||||||
.selectRecentMessagesByConversation(iid, conversationId, limit)
|
.selectRecentMessagesByConversation(iid, conversationId, limit)
|
||||||
.executeAsList()
|
.executeAsList()
|
||||||
rows.map { it.toAppMessage() }.reversed()
|
hydrateReplyReferences(rows).reversed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -990,7 +995,7 @@ object MessageCacheStore {
|
|||||||
val messages = rows.map { it.toAppMessage() }
|
val messages = rows.map { it.toAppMessage() }
|
||||||
val byId = messages.associateBy { it.id }
|
val byId = messages.associateBy { it.id }
|
||||||
return rows.zip(messages).map { (row, message) ->
|
return rows.zip(messages).map { (row, message) ->
|
||||||
val replyId = row.replyToId?.toInt()
|
val replyId = row.replyToId?.toInt() ?: message.dmEnvelope?.replyToId
|
||||||
if (replyId != null) {
|
if (replyId != null) {
|
||||||
message.copy(reply_to = byId[replyId])
|
message.copy(reply_to = byId[replyId])
|
||||||
} else {
|
} 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 {
|
private fun DbMessage.toAppMessage(): Message {
|
||||||
val uid = userId.toInt()
|
val uid = userId.toInt()
|
||||||
val self = ApiClient.user
|
val self = ApiClient.user
|
||||||
@@ -1080,6 +1100,10 @@ object MessageCacheStore {
|
|||||||
val validated = CacheValidator.filterMessages(conversationId, messages, self)
|
val validated = CacheValidator.filterMessages(conversationId, messages, self)
|
||||||
val iid = instanceId()
|
val iid = instanceId()
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
|
val existingReplyToIds = db.messageDatabaseQueries
|
||||||
|
.selectMessagesByConversation(iid, conversationId)
|
||||||
|
.executeAsList()
|
||||||
|
.associate { it.id.toInt() to it.replyToId }
|
||||||
db.messageDatabaseQueries.transaction {
|
db.messageDatabaseQueries.transaction {
|
||||||
db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId)
|
db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId)
|
||||||
validated.forEach { msg: Message ->
|
validated.forEach { msg: Message ->
|
||||||
@@ -1092,7 +1116,7 @@ object MessageCacheStore {
|
|||||||
timestamp = msg.timestamp,
|
timestamp = msg.timestamp,
|
||||||
isRead = if (msg.is_read) 1L else 0L,
|
isRead = if (msg.is_read) 1L else 0L,
|
||||||
isEdited = if (msg.is_edited) 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,
|
clientMessageId = msg.client_message_id,
|
||||||
deletedFlag = 0L,
|
deletedFlag = 0L,
|
||||||
sendStatus = if (msg.id < 0) "pending" else "sent"
|
sendStatus = if (msg.id < 0) "pending" else "sent"
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ object ProfileCache {
|
|||||||
message: Message,
|
message: Message,
|
||||||
currentUserId: Int? = ApiClient.user?.id,
|
currentUserId: Int? = ApiClient.user?.id,
|
||||||
): Message {
|
): Message {
|
||||||
|
val enrichedReply = message.reply_to?.let { enrichPublicMessageForDisplay(it, currentUserId) }
|
||||||
val self = currentUserId
|
val self = currentUserId
|
||||||
if (self != null && message.user_id == self) {
|
if (self != null && message.user_id == self) {
|
||||||
val user = ApiClient.user
|
val user = ApiClient.user
|
||||||
@@ -240,6 +241,7 @@ object ProfileCache {
|
|||||||
username = message.username.trim().ifBlank { user?.username.orEmpty() },
|
username = message.username.trim().ifBlank { user?.username.orEmpty() },
|
||||||
profile_picture = message.profile_picture?.takeIf { it.isNotBlank() }
|
profile_picture = message.profile_picture?.takeIf { it.isNotBlank() }
|
||||||
?: user?.profile_picture,
|
?: user?.profile_picture,
|
||||||
|
reply_to = enrichedReply,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val profile = get(message.user_id)
|
val profile = get(message.user_id)
|
||||||
@@ -251,6 +253,7 @@ object ProfileCache {
|
|||||||
?: profile?.profilePicture,
|
?: profile?.profilePicture,
|
||||||
verified = message.verified ?: profile?.verified,
|
verified = message.verified ?: profile?.verified,
|
||||||
verificationStatus = message.verificationStatus ?: profile?.verificationStatus,
|
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 }) }
|
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) {
|
protected fun removeMessageByClientMessageId(clientMessageId: String) {
|
||||||
updateState {
|
updateState {
|
||||||
it.copy(messages = it.messages.filter { msg -> msg.client_message_id != clientMessageId })
|
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.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.withFrameNanos
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.ExperimentalHazeMaterialsApi
|
||||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
import dev.chrisbanes.haze.materials.HazeMaterials
|
||||||
import dev.chrisbanes.haze.rememberHazeState
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
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.haptic.rememberHapticFeedback
|
||||||
import ru.fromchat.utils.rememberLastSeenFormatStrings
|
import ru.fromchat.utils.rememberLastSeenFormatStrings
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -156,7 +159,40 @@ fun ChatScreen(
|
|||||||
val listState = rememberSaveable(panelId, saver = LazyListState.Saver) {
|
val listState = rememberSaveable(panelId, saver = LazyListState.Saver) {
|
||||||
LazyListState(0, 0)
|
LazyListState(0, 0)
|
||||||
}
|
}
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val fallbackMessageHeightPx = remember(density) { with(density) { 80.dp.roundToPx() } }
|
||||||
val scope = rememberCoroutineScope()
|
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 saveMessageImage = rememberSaveMessageImage { /* best-effort */ }
|
||||||
val saveMessageFile = rememberSaveMessageFile { /* best-effort */ }
|
val saveMessageFile = rememberSaveMessageFile { /* best-effort */ }
|
||||||
val haptic = rememberHapticFeedback()
|
val haptic = rememberHapticFeedback()
|
||||||
@@ -250,17 +286,7 @@ fun ChatScreen(
|
|||||||
|
|
||||||
// Scroll to specific message when requested (e.g., from notification click)
|
// Scroll to specific message when requested (e.g., from notification click)
|
||||||
LaunchedEffect(scrollToMessageId, panelState.messages) {
|
LaunchedEffect(scrollToMessageId, panelState.messages) {
|
||||||
scrollToMessageId?.let { messageId ->
|
scrollToMessageId?.let(scrollToChatMessage)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
@@ -695,6 +721,11 @@ fun ChatScreen(
|
|||||||
val statusBarTopDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
val statusBarTopDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
||||||
val floatingHeaderClearance =
|
val floatingHeaderClearance =
|
||||||
statusBarTopDp + 64.dp + 12.dp + ChatFloatingHeaderBottomArcRadius
|
statusBarTopDp + 64.dp + 12.dp + ChatFloatingHeaderBottomArcRadius
|
||||||
|
SideEffect {
|
||||||
|
chatScrollClearancePx.value = with(density) {
|
||||||
|
floatingHeaderClearance.roundToPx() to innerPadding.calculateBottomPadding().roundToPx()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -800,6 +831,9 @@ fun ChatScreen(
|
|||||||
OutgoingMessageCoordinator.retryDmAttachmentUpload(cid)
|
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.Spring
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.spring
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.LocalIndication
|
import androidx.compose.foundation.LocalIndication
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
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.api.schema.messages.Message
|
||||||
import ru.fromchat.message_corrupted
|
import ru.fromchat.message_corrupted
|
||||||
import ru.fromchat.message_edited_suffix
|
import ru.fromchat.message_edited_suffix
|
||||||
|
import ru.fromchat.message_reply_jump_cd
|
||||||
import ru.fromchat.message_send_failed
|
import ru.fromchat.message_send_failed
|
||||||
import ru.fromchat.ui.chat.components.getMessageGradient
|
import ru.fromchat.ui.chat.components.getMessageGradient
|
||||||
import ru.fromchat.ui.chat.components.getReplyMessageGradient
|
import ru.fromchat.ui.chat.components.getReplyMessageGradient
|
||||||
@@ -113,6 +115,9 @@ fun MessageItem(
|
|||||||
isContextMenuForThisMessage: Boolean = false,
|
isContextMenuForThisMessage: Boolean = false,
|
||||||
onCancelOutboundAttachment: ((Message) -> Unit)? = null,
|
onCancelOutboundAttachment: ((Message) -> Unit)? = null,
|
||||||
onRetryOutboundAttachment: ((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.
|
// Cache derived values per message to avoid recomputing in every recomposition.
|
||||||
val isCorrupted = remember(message.files, message.fileThumbnails, message.dmEnvelope) {
|
val isCorrupted = remember(message.files, message.fileThumbnails, message.dmEnvelope) {
|
||||||
@@ -138,10 +143,12 @@ fun MessageItem(
|
|||||||
|
|
||||||
var isPressed by remember { mutableStateOf(false) }
|
var isPressed by remember { mutableStateOf(false) }
|
||||||
var avatarPressed by remember(message.id) { 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 bubbleBodyPositionInRoot by remember { mutableStateOf(Offset.Zero) }
|
||||||
var slackRowLayoutCoords by remember(message.id) { mutableStateOf<LayoutCoordinates?>(null) }
|
var slackRowLayoutCoords by remember(message.id) { mutableStateOf<LayoutCoordinates?>(null) }
|
||||||
val scaleTarget = if (isPressed && !isContextMenuForThisMessage && !isContextMenuOpen) 0.96f else 1f
|
val scaleTarget = if (isPressed && !isContextMenuForThisMessage && !isContextMenuOpen) 0.96f else 1f
|
||||||
val avatarScaleTarget = if (avatarPressed && !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(
|
val scale by animateFloatAsState(
|
||||||
targetValue = scaleTarget,
|
targetValue = scaleTarget,
|
||||||
animationSpec = spring(
|
animationSpec = spring(
|
||||||
@@ -160,14 +167,53 @@ fun MessageItem(
|
|||||||
visibilityThreshold = 0.001f,
|
visibilityThreshold = 0.001f,
|
||||||
label = "messageAvatarScale"
|
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(
|
Box(modifier = modifier.fillMaxWidth()) {
|
||||||
modifier = modifier
|
if (highlightAlpha > 0f) {
|
||||||
.fillMaxWidth()
|
Box(
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
Modifier
|
||||||
horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start,
|
.matchParentSize()
|
||||||
verticalAlignment = Alignment.Bottom
|
.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) {
|
if (!isAuthor && showUsername) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -407,8 +453,37 @@ fun MessageItem(
|
|||||||
// Reply preview
|
// Reply preview
|
||||||
replyRef?.let { replyToMsg ->
|
replyRef?.let { replyToMsg ->
|
||||||
val replyName = messageDisplayUsername(replyToMsg, currentUserId)
|
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(
|
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(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -435,15 +510,13 @@ fun MessageItem(
|
|||||||
Column(
|
Column(
|
||||||
Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
|
Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||||
) {
|
) {
|
||||||
if (showUsername) {
|
Text(
|
||||||
Text(
|
text = replyName,
|
||||||
text = replyName,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
fontWeight = FontWeight.SemiBold,
|
||||||
fontWeight = FontWeight.SemiBold,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
fontSize = 11.sp
|
||||||
fontSize = 11.sp
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
Text(
|
||||||
text = replyToMsg.content.take(50) + if (replyToMsg.content.length > 50) "..." else "",
|
text = replyToMsg.content.take(50) + if (replyToMsg.content.length > 50) "..." else "",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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.dedupeMessagesByClientId
|
||||||
import ru.fromchat.ui.chat.utils.dropSupersededOptimisticMessages
|
import ru.fromchat.ui.chat.utils.dropSupersededOptimisticMessages
|
||||||
import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage
|
import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage
|
||||||
|
import ru.fromchat.ui.chat.utils.preserveReplyToFromExisting
|
||||||
import ru.fromchat.ui.chat.isImageFilename
|
import ru.fromchat.ui.chat.isImageFilename
|
||||||
import ru.fromchat.api.local.send.seedOutboundFileAsDownloaded
|
import ru.fromchat.api.local.send.seedOutboundFileAsDownloaded
|
||||||
|
|
||||||
@@ -242,8 +243,8 @@ class DmPanel(
|
|||||||
val historyResult = runCatching { ApiClient.getDmHistory(otherUserId) }
|
val historyResult = runCatching { ApiClient.getDmHistory(otherUserId) }
|
||||||
if (historyResult.isSuccess) {
|
if (historyResult.isSuccess) {
|
||||||
val response = historyResult.getOrNull() ?: return
|
val response = historyResult.getOrNull() ?: return
|
||||||
|
val priorMessages = _state.messages
|
||||||
val optimisticSnapshot = snapshotPendingOptimisticMessages()
|
val optimisticSnapshot = snapshotPendingOptimisticMessages()
|
||||||
clearMessages()
|
|
||||||
val decryptedForLog = mutableListOf<Pair<Int, String>>()
|
val decryptedForLog = mutableListOf<Pair<Int, String>>()
|
||||||
val messages = response.messages.map { envelope ->
|
val messages = response.messages.map { envelope ->
|
||||||
val outcome = decryptDmEnvelopeForUi(envelope)
|
val outcome = decryptDmEnvelopeForUi(envelope)
|
||||||
@@ -260,15 +261,22 @@ class DmPanel(
|
|||||||
msg.copy(reply_to = replyToMap[envelope.replyToId])
|
msg.copy(reply_to = replyToMap[envelope.replyToId])
|
||||||
} else msg
|
} else msg
|
||||||
}
|
}
|
||||||
addMessages(messagesWithReplies)
|
val mergedForUi = preserveReplyToFromExisting(
|
||||||
restorePendingOptimisticMessages(optimisticSnapshot)
|
priorMessages + optimisticSnapshot,
|
||||||
updateState { state ->
|
messagesWithReplies,
|
||||||
val cleaned = dedupeMessagesByClientId(
|
)
|
||||||
dropSupersededOptimisticMessages(state.messages, currentUserId),
|
batchStateUpdates {
|
||||||
)
|
clearMessages()
|
||||||
if (cleaned == state.messages) state else state.copy(messages = cleaned)
|
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.
|
// Persist the most recent DM messages for offline use.
|
||||||
val mergedForCache = _state.messages
|
val mergedForCache = _state.messages
|
||||||
@@ -361,16 +369,16 @@ class DmPanel(
|
|||||||
mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted)
|
mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted)
|
||||||
} else {
|
} else {
|
||||||
val incoming = createMessage(envelope, outcome.plaintext, outcome.isCorrupted)
|
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)) {
|
if (ActiveDmChatTracker.isActive(otherUserId)) {
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
MessageCacheStore.upsertDmMessage(otherUserId, incoming)
|
MessageCacheStore.upsertDmMessage(otherUserId, withReply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
addMessage(incoming)
|
addMessage(withReply)
|
||||||
if (envelope.replyToId != null) {
|
|
||||||
val replyTo = _state.messages.find { it.id == envelope.replyToId }
|
|
||||||
updateMessage(envelope.id) { it.copy(reply_to = replyTo) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -515,7 +523,9 @@ class DmPanel(
|
|||||||
fileAspectRatios = dec.fileAspectRatios ?: it.fileAspectRatios,
|
fileAspectRatios = dec.fileAspectRatios ?: it.fileAspectRatios,
|
||||||
fileSizes = dec.fileSizes ?: it.fileSizes,
|
fileSizes = dec.fileSizes ?: it.fileSizes,
|
||||||
fileDimensions = dec.fileDimensions ?: it.fileDimensions,
|
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.ChatPanel
|
||||||
import ru.fromchat.ui.chat.utils.PublicChatTypingHandler
|
import ru.fromchat.ui.chat.utils.PublicChatTypingHandler
|
||||||
import ru.fromchat.ui.chat.utils.TypingHandler
|
import ru.fromchat.ui.chat.utils.TypingHandler
|
||||||
|
import ru.fromchat.ui.chat.utils.preserveReplyToFromExisting
|
||||||
|
|
||||||
class PublicChatPanel(
|
class PublicChatPanel(
|
||||||
/** Stable cache / panel id (not localized; hardcoded in [ru.fromchat.ui.chat.utils.PublicChatPanelCache]). */
|
/** 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,
|
profile_picture = fresh.profile_picture,
|
||||||
verified = fresh.verified,
|
verified = fresh.verified,
|
||||||
verificationStatus = fresh.verificationStatus,
|
verificationStatus = fresh.verificationStatus,
|
||||||
|
reply_to = fresh.reply_to ?: message.reply_to,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -164,9 +166,18 @@ class PublicChatPanel(
|
|||||||
if (cached.isEmpty()) return
|
if (cached.isEmpty()) return
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
batchStateUpdates {
|
batchStateUpdates {
|
||||||
clearMessages()
|
val shown = _state.messages
|
||||||
addMessages(cached)
|
if (shown.isNotEmpty()) {
|
||||||
setLoading(false)
|
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)
|
if (_state.isLoading) setLoading(false)
|
||||||
} else {
|
} else {
|
||||||
batchStateUpdates {
|
batchStateUpdates {
|
||||||
val merged = mergeNetworkHistoryWithShown(shown, response.messages)
|
val merged = preserveReplyToFromExisting(
|
||||||
|
shown,
|
||||||
|
mergeNetworkHistoryWithShown(shown, response.messages),
|
||||||
|
)
|
||||||
clearMessages()
|
clearMessages()
|
||||||
addMessages(
|
addMessages(
|
||||||
ProfileCache.enrichPublicMessagesForDisplay(merged),
|
ProfileCache.enrichPublicMessagesForDisplay(merged),
|
||||||
@@ -384,7 +398,9 @@ class PublicChatPanel(
|
|||||||
val data = updateMessage.data ?: return
|
val data = updateMessage.data ?: return
|
||||||
val editedMsg = json.decodeFromJsonElement(Message.serializer(), data)
|
val editedMsg = json.decodeFromJsonElement(Message.serializer(), data)
|
||||||
DecryptedImageCache.invalidateForMessage(editedMsg.id)
|
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) {
|
withContext(Dispatchers.Default) {
|
||||||
MessageCacheStore.replacePublicMessages(_state.messages)
|
MessageCacheStore.replacePublicMessages(_state.messages)
|
||||||
}
|
}
|
||||||
@@ -394,6 +410,7 @@ class PublicChatPanel(
|
|||||||
val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data)
|
val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data)
|
||||||
DecryptedImageCache.invalidateForMessage(deletedData.message_id)
|
DecryptedImageCache.invalidateForMessage(deletedData.message_id)
|
||||||
removeMessage(deletedData.message_id)
|
removeMessage(deletedData.message_id)
|
||||||
|
clearReplyReferencesTo(deletedData.message_id)
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
MessageRepository.markPublicMessageDeleted(deletedData.message_id)
|
MessageRepository.markPublicMessageDeleted(deletedData.message_id)
|
||||||
MessageCacheStore.replacePublicMessages(_state.messages)
|
MessageCacheStore.replacePublicMessages(_state.messages)
|
||||||
@@ -468,10 +485,9 @@ class PublicChatPanel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun handleDeleteMessage(messageId: Int) {
|
override suspend fun handleDeleteMessage(messageId: Int) {
|
||||||
// Remove immediately from UI
|
|
||||||
deleteMessageImmediately(messageId)
|
deleteMessageImmediately(messageId)
|
||||||
|
clearReplyReferencesTo(messageId)
|
||||||
|
|
||||||
// Send delete request
|
|
||||||
ApiClient.deleteMessage(messageId)
|
ApiClient.deleteMessage(messageId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,5 +69,18 @@ internal fun mergeMessageUiFields(db: Message, panel: Message?): Message {
|
|||||||
fileDimensions = db.fileDimensions ?: panel.fileDimensions,
|
fileDimensions = db.fileDimensions ?: panel.fileDimensions,
|
||||||
content = db.content.ifBlank { panel.content },
|
content = db.content.ifBlank { panel.content },
|
||||||
isContentCorrupted = panel.isContentCorrupted || db.isContentCorrupted,
|
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