Redesign the chat interface

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-10 11:17:45 +03:00
Unverified
parent 206acd712a
commit ff2f6e4e45
15 changed files with 1695 additions and 651 deletions
@@ -117,6 +117,9 @@
<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_reply_jump_cd">Перейти к цитируемому сообщению</string>
<string name="message_reply_photo">Фото</string>
<string name="chat_date_today">Сегодня</string>
<string name="chat_date_yesterday">Вчера</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>
@@ -129,6 +129,9 @@
<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_reply_jump_cd">Jump to quoted message</string>
<string name="message_reply_photo">Photo</string>
<string name="chat_date_today">Today</string>
<string name="chat_date_yesterday">Yesterday</string>
<string name="message_corrupted_short">Cant show this 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_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>
@@ -1,9 +1,13 @@
package ru.fromchat.api.local.messages package ru.fromchat.api.local.messages
import kotlin.time.Instant import kotlin.time.Instant
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import kotlinx.datetime.number import kotlinx.datetime.number
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock import kotlin.time.Clock
@@ -14,17 +18,22 @@ fun nowMessageTimestampIso(): String = Clock.System.now().toString()
/** /**
* Parse message timestamps from server or client. * Parse message timestamps from server or client.
* Zone-less ISO strings are treated as UTC, then shown in the device zone. *
* - Strings with `Z` / an offset (optimistic client stamps, proper UTC) are true instants.
* - Zone-less ISO from the API is naive server wall time (`datetime.now().isoformat()`),
* interpreted in the device zone so HH:mm matches the user's clock.
*/ */
internal fun parseMessageInstant(timestamp: String): Instant? { internal fun parseMessageInstant(timestamp: String): Instant? {
val raw = timestamp.trim() val raw = timestamp.trim()
if (raw.isEmpty()) return null if (raw.isEmpty()) return null
val normalized = raw.replace(' ', 'T') val normalized = raw.replace(' ', 'T')
parseInstantOrNull(normalized)?.let { return it } if (hasExplicitOffset(normalized)) {
if (!hasExplicitOffset(normalized)) { return parseInstantOrNull(normalized)
parseInstantOrNull("${normalized}Z")?.let { return it }
} }
return null val local = parseLocalDateTimeOrNull(normalized) ?: return null
return runCatching {
local.toInstant(TimeZone.currentSystemDefault())
}.getOrNull()
} }
internal fun parseMessageTimestampMillis(timestamp: String): Long? = internal fun parseMessageTimestampMillis(timestamp: String): Long? =
@@ -63,9 +72,37 @@ internal fun formatMessageDateTimeLocal(timestamp: String): String {
return "$month/$day/${local.year} $hour:$minute" return "$month/$day/${local.year} $hour:$minute"
} }
/**
* Chat date separator label: Today / Yesterday / "d MMMM" / "d MMMM yyyy".
*/
internal fun formatChatDateSeparator(
date: LocalDate,
todayLabel: String,
yesterdayLabel: String,
monthName: (Int) -> String,
): String {
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val yesterday = today.minus(1, DateTimeUnit.DAY)
return when (date) {
today -> todayLabel
yesterday -> yesterdayLabel
else -> {
val month = monthName(date.month.number)
if (date.year == today.year) {
"${date.day} $month"
} else {
"${date.day} $month ${date.year}"
}
}
}
}
private fun parseInstantOrNull(value: String): Instant? = private fun parseInstantOrNull(value: String): Instant? =
runCatching { Instant.parse(value) }.getOrNull() runCatching { Instant.parse(value) }.getOrNull()
private fun parseLocalDateTimeOrNull(value: String): LocalDateTime? =
runCatching { LocalDateTime.parse(value) }.getOrNull()
private fun hasExplicitOffset(value: String): Boolean = private fun hasExplicitOffset(value: String): Boolean =
value.endsWith('Z', ignoreCase = true) || OFFSET_SUFFIX.containsMatchIn(value) value.endsWith('Z', ignoreCase = true) || OFFSET_SUFFIX.containsMatchIn(value)
@@ -107,6 +107,7 @@ import ru.fromchat.ui.chat.utils.coalesceDecodeTarget
import ru.fromchat.ui.chat.utils.decodeSizeChangedMeaningfully import ru.fromchat.ui.chat.utils.decodeSizeChangedMeaningfully
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import com.pr0gramm3r101.utils.scaleOnPress import com.pr0gramm3r101.utils.scaleOnPress
import ru.fromchat.ui.chat.MessageGroupInfo
private val IMAGE_SIZE = 160.dp private val IMAGE_SIZE = 160.dp
private val IMAGE_MAX_HEIGHT = 240.dp private val IMAGE_MAX_HEIGHT = 240.dp
@@ -145,6 +146,10 @@ fun AttachmentPreview(
/** Message text shown in attachment download/upload logs. */ /** Message text shown in attachment download/upload logs. */
messageLabel: String? = null, messageLabel: String? = null,
onCancelUpload: (() -> Unit)? = null, onCancelUpload: (() -> Unit)? = null,
messageGroup: MessageGroupInfo = MessageGroupInfo(
hasSameAuthorAbove = false,
hasSameAuthorBelow = false,
),
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val isImage = when { val isImage = when {
@@ -220,7 +225,7 @@ fun AttachmentPreview(
Modifier.sizeIn(maxWidth = IMAGE_SIZE, maxHeight = IMAGE_MAX_HEIGHT) Modifier.sizeIn(maxWidth = IMAGE_SIZE, maxHeight = IMAGE_MAX_HEIGHT)
} }
) )
.clip(attachmentImageCornerShape(isAuthor)) .clip(attachmentImageCornerShape(isAuthor, messageGroup))
.then( .then(
if (onImageBounds != null && showImageTile) { if (onImageBounds != null && showImageTile) {
Modifier.onGloballyPositioned { coords -> Modifier.onGloballyPositioned { coords ->
@@ -269,6 +274,7 @@ fun AttachmentPreview(
messageLabel = messageLabel, messageLabel = messageLabel,
onCancelUpload = onCancelUpload, onCancelUpload = onCancelUpload,
onFullyLoaded = { if (it) isFullyLoaded = true }, onFullyLoaded = { if (it) isFullyLoaded = true },
messageGroup = messageGroup,
) )
} }
} }
@@ -299,9 +305,13 @@ private fun ChatImageTileContent(
messageLabel: String? = null, messageLabel: String? = null,
onCancelUpload: (() -> Unit)? = null, onCancelUpload: (() -> Unit)? = null,
onFullyLoaded: (Boolean) -> Unit = {}, onFullyLoaded: (Boolean) -> Unit = {},
messageGroup: MessageGroupInfo = MessageGroupInfo(
hasSameAuthorAbove = false,
hasSameAuthorBelow = false,
),
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val clipShape = attachmentImageCornerShape(isAuthor) val clipShape = attachmentImageCornerShape(isAuthor, messageGroup)
val cacheClientId = clientMessageId?.trim()?.takeIf { it.isNotEmpty() } val cacheClientId = clientMessageId?.trim()?.takeIf { it.isNotEmpty() }
val layoutAspect = aspectRatio?.takeIf { it.isFinite() && it > 0f } val layoutAspect = aspectRatio?.takeIf { it.isFinite() && it > 0f }
val fallbackDecodeSize = rememberChatPreviewDecodeSize(IMAGE_SIZE, layoutAspect) val fallbackDecodeSize = rememberChatPreviewDecodeSize(IMAGE_SIZE, layoutAspect)
@@ -333,12 +333,18 @@ abstract class ChatPanel(
updateState { currentState -> updateState { currentState ->
val optimistic = currentState.messages.find { it.client_message_id == tempId } val optimistic = currentState.messages.find { it.client_message_id == tempId }
val resolvedConfirmed = if (confirmedMessage.reply_to == null) { // Keep client_message_id so LazyColumn keys / enter animation state stay stable.
val reply = optimistic?.reply_to val withClientId = if (confirmedMessage.client_message_id.isNullOrBlank()) {
if (reply != null) confirmedMessage.copy(reply_to = reply) else confirmedMessage confirmedMessage.copy(client_message_id = tempId)
} else { } else {
confirmedMessage confirmedMessage
} }
val resolvedConfirmed = if (withClientId.reply_to == null) {
val reply = optimistic?.reply_to
if (reply != null) withClientId.copy(reply_to = reply) else withClientId
} else {
withClientId
}
val withoutDupReal = if (resolvedConfirmed.id > 0) { val withoutDupReal = if (resolvedConfirmed.id > 0) {
currentState.messages.filter { it.id != resolvedConfirmed.id } currentState.messages.filter { it.id != resolvedConfirmed.id }
} else { } else {
@@ -432,7 +438,13 @@ abstract class ChatPanel(
suspend fun sendMessageWithImmediateDisplay(content: String, replyToId: Int?) { suspend fun sendMessageWithImmediateDisplay(content: String, replyToId: Int?) {
if (content.isBlank()) return if (content.isBlank()) return
// Create temporary message for immediate display val sendT0 = kotlin.time.Clock.System.now().toEpochMilliseconds()
Logger.d(
"EnterAnim",
"send_start contentLen=${content.trim().length} msgCount=${_state.messages.size}",
)
// Show the bubble immediately; pace only the network send below.
val tempId = generateClientMessageId() val tempId = generateClientMessageId()
val tempMessage = Message( val tempMessage = Message(
id = -1, // Temporary negative ID id = -1, // Temporary negative ID
@@ -452,6 +464,13 @@ abstract class ChatPanel(
val optimistic = tempMessage.copy(id = uniqueOptimisticMessageId()) val optimistic = tempMessage.copy(id = uniqueOptimisticMessageId())
addMessage(optimistic) addMessage(optimistic)
Logger.d(
"EnterAnim",
"after_addMessage tempId=${tempId.take(8)} " +
"elapsedMs=${kotlin.time.Clock.System.now().toEpochMilliseconds() - sendT0} " +
"msgCount=${_state.messages.size}",
)
// Set up timeout for failure // Set up timeout for failure
val timeoutJob = scope.launch { val timeoutJob = scope.launch {
delay(10000) // 10 seconds timeout delay(10000) // 10 seconds timeout
@@ -465,16 +484,24 @@ abstract class ChatPanel(
runCatching { persistOptimisticMessage(optimistic) } runCatching { persistOptimisticMessage(optimistic) }
} }
// Actually send the message // Network send is paced separately so UI enter never waits on the rate limiter.
try { scope.launch {
sendMessage(content, replyToId, tempId) try {
// Message sent successfully - will be updated when WebSocket confirms val rateT0 = kotlin.time.Clock.System.now().toEpochMilliseconds()
} catch (error: Exception) { MessageRateLimiter.awaitSlot()
removeMessageByClientMessageId(tempId) Logger.d(
pendingMessages.remove(tempId) "EnterAnim",
timeoutJob.cancel() "after_rate_limit tempId=${tempId.take(8)} " +
scope.launch(Dispatchers.Default) { "waitedMs=${kotlin.time.Clock.System.now().toEpochMilliseconds() - rateT0}",
runCatching { removeOptimisticFromCache(optimistic) } )
sendMessage(content, replyToId, tempId)
} catch (_: Exception) {
removeMessageByClientMessageId(tempId)
pendingMessages.remove(tempId)
timeoutJob.cancel()
scope.launch(Dispatchers.Default) {
runCatching { removeOptimisticFromCache(optimistic) }
}
} }
} }
} }
@@ -4,7 +4,6 @@ import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -15,12 +14,18 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.core.tween
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -31,6 +36,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -49,6 +55,11 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.datetime.LocalDate
import ru.fromchat.api.local.messages.formatChatDateSeparator
import ru.fromchat.chat_date_today
import ru.fromchat.chat_date_yesterday
import ru.fromchat.utils.rememberRegistrationDateFormatStrings
import com.pr0gramm3r101.utils.resetFocus import com.pr0gramm3r101.utils.resetFocus
import com.pr0gramm3r101.utils.supportClipboardManagerImpl import com.pr0gramm3r101.utils.supportClipboardManagerImpl
import dev.chrisbanes.haze.HazeProgressive import dev.chrisbanes.haze.HazeProgressive
@@ -176,12 +187,158 @@ fun ChatScreen(
} }
} }
val chatScrollClearancePx = remember { mutableStateOf(0 to 0) } val chatScrollClearancePx = remember { mutableStateOf(0 to 0) }
val scrollToChatMessage: (Int) -> Unit = { messageId -> val dateToday = stringResource(Res.string.chat_date_today)
val dateYesterday = stringResource(Res.string.chat_date_yesterday)
val registrationDateStrings = rememberRegistrationDateFormatStrings()
val listItems = remember(
panelState.messages,
dateToday,
dateYesterday,
registrationDateStrings,
) {
buildChatListItems(panelState.messages) { date: LocalDate ->
formatChatDateSeparator(
date = date,
todayLabel = dateToday,
yesterdayLabel = dateYesterday,
monthName = registrationDateStrings.monthName,
)
}
}
var revealedTimestampKeys by rememberSaveable(panelId) {
mutableStateOf(setOf<String>())
}
var hiddenDefaultTimestampKeys by rememberSaveable(panelId) {
mutableStateOf(setOf<String>())
}
var lastAnimatedMessageKeys by rememberSaveable(panelId) {
mutableStateOf(setOf<String>())
}
val enterCoordinator = remember(panelId) { MessageEnterCoordinator(scope) }
val activeEnterAnimation by enterCoordinator.currentItem.collectAsState()
val pendingNewMessageKeys by enterCoordinator.pendingNewMessageKeys.collectAsState()
val queuedEnter by enterCoordinator.queuedEnter.collectAsState()
var previousNewestFingerprint by rememberSaveable(panelId) { mutableStateOf("") }
var previousEnterMessageCount by rememberSaveable(panelId) { mutableIntStateOf(0) }
var enterAnimationsSeeded by rememberSaveable(panelId) { mutableStateOf(false) }
LaunchedEffect(panelState.messages) {
val messages = panelState.messages val messages = panelState.messages
val messageIndex = messages.indexOfFirst { it.id == messageId } val newest = messages.lastOrNull()
if (messageIndex != -1) { if (newest == null) {
if (!enterAnimationsSeeded) {
previousNewestFingerprint = ""
previousEnterMessageCount = 0
}
return@LaunchedEffect
}
val newestKey = messageListKey(newest)
val fingerprint = "$newestKey|${messages.size}"
if (fingerprint == previousNewestFingerprint) return@LaunchedEffect
val previousFingerprint = previousNewestFingerprint
val previousCount = previousEnterMessageCount
val sizeDelta = messages.size - previousCount
// First non-empty load (or reopen before seed): never animate existing history.
if (!enterAnimationsSeeded || previousFingerprint.isEmpty()) {
lastAnimatedMessageKeys = messages.map { messageListKey(it) }.toSet()
previousNewestFingerprint = fingerprint
previousEnterMessageCount = messages.size
enterAnimationsSeeded = true
return@LaunchedEffect
}
previousNewestFingerprint = fingerprint
previousEnterMessageCount = messages.size
val previousNewestKey = previousFingerprint.substringBefore('|')
// History prepend / cache hydration: newest unchanged, older rows appeared.
if (newestKey == previousNewestKey) {
Logger.d(
"EnterAnim",
"skip_newest_unchanged newestKey=${newestKey.take(12)} " +
"sizeDelta=$sizeDelta count=${messages.size}",
)
lastAnimatedMessageKeys =
lastAnimatedMessageKeys + messages.map { messageListKey(it) }
return@LaunchedEffect
}
// Transient shrink (optimistic briefly missing from a DB sync): do not seed
// lastAnimated. Drop keys for rows that left so a restore can re-enqueue enter.
if (sizeDelta < 0) {
val presentKeys = messages.mapTo(mutableSetOf()) { messageListKey(it) }
lastAnimatedMessageKeys = lastAnimatedMessageKeys.intersect(presentKeys)
Logger.d(
"EnterAnim",
"skip_shrink newestKey=${newestKey.take(12)} sizeDelta=$sizeDelta " +
"count=${messages.size} newestId=${newest.id}",
)
return@LaunchedEffect
}
// Bulk replace / multi-message sync — seed, don't animate.
if (sizeDelta > 1) {
Logger.d(
"EnterAnim",
"skip_bulk_delta newestKey=${newestKey.take(12)} sizeDelta=$sizeDelta " +
"count=${messages.size} newestId=${newest.id}",
)
lastAnimatedMessageKeys =
lastAnimatedMessageKeys + messages.map { messageListKey(it) }
return@LaunchedEffect
}
if (newestKey in lastAnimatedMessageKeys) return@LaunchedEffect
// Confirm may briefly change key shape; don't re-animate the same send.
val newestCid = newest.client_message_id?.trim().orEmpty()
if (newestCid.isNotEmpty() && "c:$newestCid" in lastAnimatedMessageKeys) {
Logger.d(
"EnterAnim",
"skip_cid_already_animated newestKey=${newestKey.take(12)} " +
"cid=${newestCid.take(8)}",
)
lastAnimatedMessageKeys = lastAnimatedMessageKeys + newestKey
return@LaunchedEffect
}
if (newest.id > 0 && lastAnimatedMessageKeys.any { it.startsWith("i:${newest.id}:") }) {
Logger.d(
"EnterAnim",
"skip_id_already_animated newestKey=${newestKey.take(12)} id=${newest.id}",
)
lastAnimatedMessageKeys = lastAnimatedMessageKeys + newestKey
return@LaunchedEffect
}
val previous = messages.getOrNull(messages.lastIndex - 1)
val mode = classifyEnterMode(previous, newest)
if (mode == EnterMode.None) {
lastAnimatedMessageKeys = lastAnimatedMessageKeys + newestKey
return@LaunchedEffect
}
Logger.d(
"EnterAnim",
"will_enqueue newestKey=${newestKey.take(12)} " +
"prevKey=${previous?.let { messageListKey(it).take(12) }} mode=$mode " +
"sizeDelta=$sizeDelta newestId=${newest.id}",
)
lastAnimatedMessageKeys = lastAnimatedMessageKeys + newestKey
enterCoordinator.enqueue(
PendingEnter(
newMessageKey = newestKey,
previousMessageKey = previous?.let { messageListKey(it) },
mode = mode,
newDateSeparatorEpochDay = if (mode == EnterMode.NewDay) {
messageLocalDate(newest)?.toEpochDays()
} else {
null
},
),
)
}
val scrollToChatMessage: (Int) -> Unit = { messageId ->
val lazyIndex = lazyIndexForMessageId(listItems, messageId)
if (lazyIndex != null) {
scope.launch { scope.launch {
val lazyIndex = 1 + (messages.size - 1 - messageIndex)
val (topClearancePx, bottomClearancePx) = chatScrollClearancePx.value val (topClearancePx, bottomClearancePx) = chatScrollClearancePx.value
listState.scrollChatMessageToCenter( listState.scrollChatMessageToCenter(
lazyIndex, lazyIndex,
@@ -755,89 +912,211 @@ fun ChatScreen(
.hazeSource(hazeState), .hazeSource(hazeState),
userScrollEnabled = !contextMenuState.isOpen, userScrollEnabled = !contextMenuState.isOpen,
reverseLayout = true, reverseLayout = true,
verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) } item {
Spacer(
items( Modifier.height(
items = panelState.messages.asReversed(), innerPadding.calculateBottomPadding() + 12.dp,
key = { msg -> ),
val cid = msg.client_message_id?.trim().orEmpty()
if (cid.isNotEmpty()) "c:$cid" else "i:${msg.id}:${msg.timestamp}"
}
) { message ->
var tapPositionInRoot by remember { mutableStateOf(IntOffset(0, 0)) }
MessageItem(
message = message,
isAuthor = message.user_id == currentUserId,
isContextMenuOpen = contextMenuState.isOpen,
isContextMenuForThisMessage = contextMenuState.isOpen && run {
val menu = contextMenuState.message ?: return@run false
val cid = menu.client_message_id?.trim().orEmpty()
if (cid.isNotEmpty()) {
message.client_message_id?.trim() == cid
} else {
menu.id == message.id
}
},
onLongPress = {
if (isReadOnly) {
return@MessageItem
}
haptic(HapticFeedbackEvent.ContextMenuOpened)
contextMenuState = ContextMenuState(
isOpen = true,
message = message,
position = tapPositionInRoot
)
},
onTapPosition = { offset ->
tapPositionInRoot = IntOffset(offset.x.toInt(), offset.y.toInt())
},
onUsernameClick =
if (panel.supportsNavigateToSenderProfile &&
message.user_id != currentUserId &&
message.user_id > 0
) {
{
ProfileCache.mergePreviewFromPublicMessage(message)
navController.navigate("profile/${message.user_id}")
}
} else {
null
},
onImageClick = { msg, idx ->
resetFocus(keyboardController, focusManager)
expandedImage = msg to idx
},
onImageBounds = { key, rect ->
imageThumbBounds[key] = rect
},
expandedImageKey = expandedImageKey,
isImageClosing = isImageClosing,
showUsername = panel.showUsernamesInMessages,
currentUserId = currentUserId,
onCancelOutboundAttachment = { msg ->
scope.launch { panel.cancelQueuedMessage(msg) }
},
onRetryOutboundAttachment = { msg ->
val cid = msg.client_message_id?.trim().orEmpty()
if (cid.isNotEmpty()) {
panel.updateMessageByClientMessageId(cid) {
it.copy(uploadError = null, uploadProgress = 0)
}
OutgoingMessageCoordinator.retryDmAttachmentUpload(cid)
}
},
onReplyClick = scrollToChatMessage,
highlightMessageId = highlightMessageId,
highlightFading = highlightFading,
) )
} }
item { Spacer(Modifier.height(floatingHeaderClearance)) } items(
items = listItems,
key = { item ->
when (item) {
is ChatListItem.DateSeparator -> "d:${item.epochDay}"
is ChatListItem.MessageRow -> messageListKey(item.message)
}
}
) { item ->
when (item) {
is ChatListItem.DateSeparator -> {
ChatDateSeparator(
label = item.label,
enterAnimationRole = resolveDateSeparatorEnterRole(
item.epochDay,
activeEnterAnimation,
),
modifier = Modifier.padding(vertical = 12.dp),
)
}
is ChatListItem.MessageRow -> {
val message = item.message
var tapPositionInRoot by remember {
mutableStateOf(IntOffset(0, 0))
}
val messageKey = timestampGroupKey(message)
val listKey = messageListKey(message)
// Keep the newest bubble as NewMessage before enqueue runs.
val newest = panelState.messages.lastOrNull()
val newestListKey = newest?.let { messageListKey(it) }
val compositionPendingNewest =
enterAnimationsSeeded &&
newestListKey != null &&
newestListKey !in lastAnimatedMessageKeys &&
newestListKey !in pendingNewMessageKeys &&
activeEnterAnimation?.newMessageKey != newestListKey
val pendingKeysForRole =
if (compositionPendingNewest) {
pendingNewMessageKeys + newestListKey
} else {
pendingNewMessageKeys
}
val enterRole = resolveMessageEnterRole(
listKey,
activeEnterAnimation,
pendingKeysForRole,
queuedEnter,
)
// Grouping flips isLastInGroup as soon as the new row exists.
// Hold the previous bubble's timestamp until PreviousLast is
// applied so fade runs in parallel with the enter spring —
// not before it (that looked like "fade, wait, then animate").
val holdTimestampForEnter = run {
if (item.group.isLastInGroup) return@run false
if (enterRole == EnterAnimationRole.PreviousLast) {
return@run true
}
val enter = queuedEnter ?: activeEnterAnimation?.let {
PendingEnter(
newMessageKey = it.newMessageKey,
previousMessageKey = it.previousMessageKey,
mode = it.mode,
newDateSeparatorEpochDay =
it.newDateSeparatorEpochDay,
)
}
if (
enter != null &&
enter.mode == EnterMode.ExtendGroup &&
enter.previousMessageKey == listKey
) {
return@run true
}
if (!compositionPendingNewest) return@run false
val previous = panelState.messages
.getOrNull(panelState.messages.lastIndex - 1)
previous != null &&
messageListKey(previous) == listKey &&
classifyEnterMode(previous, newest!!) ==
EnterMode.ExtendGroup
}
val showTimestamp = when {
messageKey in revealedTimestampKeys -> true
messageKey in hiddenDefaultTimestampKeys -> false
item.group.isLastInGroup || holdTimestampForEnter -> true
else -> false
}
MessageItem(
message = message,
isAuthor = message.user_id == currentUserId,
group = item.group,
showTimestamp = showTimestamp,
onBubbleTap = {
if (item.group.isLastInGroup &&
messageKey !in revealedTimestampKeys
) {
// Default-visible last bubble: tap hides.
hiddenDefaultTimestampKeys =
if (messageKey in hiddenDefaultTimestampKeys) {
hiddenDefaultTimestampKeys - messageKey
} else {
hiddenDefaultTimestampKeys + messageKey
}
} else {
revealedTimestampKeys =
if (messageKey in revealedTimestampKeys) {
revealedTimestampKeys - messageKey
} else {
revealedTimestampKeys + messageKey
}
hiddenDefaultTimestampKeys =
hiddenDefaultTimestampKeys - messageKey
}
},
enterAnimationRole = enterRole,
modifier = Modifier.padding(top = item.spacingAbove),
isContextMenuOpen = contextMenuState.isOpen,
isContextMenuForThisMessage =
contextMenuState.isOpen && run {
val menu = contextMenuState.message
?: return@run false
val cid = menu.client_message_id?.trim().orEmpty()
if (cid.isNotEmpty()) {
message.client_message_id?.trim() == cid
} else {
menu.id == message.id
}
},
onLongPress = {
if (isReadOnly) {
return@MessageItem
}
haptic(HapticFeedbackEvent.ContextMenuOpened)
contextMenuState = ContextMenuState(
isOpen = true,
message = message,
position = tapPositionInRoot
)
},
onTapPosition = { offset ->
tapPositionInRoot =
IntOffset(offset.x.toInt(), offset.y.toInt())
},
onUsernameClick =
if (panel.supportsNavigateToSenderProfile &&
message.user_id != currentUserId &&
message.user_id > 0
) {
{
ProfileCache.mergePreviewFromPublicMessage(
message,
)
navController.navigate(
"profile/${message.user_id}",
)
}
} else {
null
},
onImageClick = { msg, idx ->
resetFocus(keyboardController, focusManager)
expandedImage = msg to idx
},
onImageBounds = { key, rect ->
imageThumbBounds[key] = rect
},
expandedImageKey = expandedImageKey,
isImageClosing = isImageClosing,
showUsername = panel.showUsernamesInMessages,
currentUserId = currentUserId,
onCancelOutboundAttachment = { msg ->
scope.launch { panel.cancelQueuedMessage(msg) }
},
onRetryOutboundAttachment = { msg ->
val cid = msg.client_message_id?.trim().orEmpty()
if (cid.isNotEmpty()) {
panel.updateMessageByClientMessageId(cid) {
it.copy(
uploadError = null,
uploadProgress = 0,
)
}
OutgoingMessageCoordinator
.retryDmAttachmentUpload(cid)
}
},
onReplyClick = scrollToChatMessage,
highlightMessageId = highlightMessageId,
highlightFading = highlightFading,
)
}
}
}
item { Spacer(modifier.height(floatingHeaderClearance)) }
} }
ChatTopBar( ChatTopBar(
@@ -986,6 +1265,42 @@ fun ChatScreen(
} }
@Composable
private fun ChatDateSeparator(
label: String,
enterAnimationRole: EnterAnimationRole,
modifier: Modifier = Modifier,
) {
val animateEnter = enterAnimationRole == EnterAnimationRole.NewDateSeparator
AnimatedVisibility(
visible = true,
enter = if (animateEnter) {
fadeIn(tween(150)) + expandVertically(expandFrom = Alignment.Bottom)
} else {
fadeIn(tween(0))
},
exit = fadeOut(tween(0)),
modifier = modifier.fillMaxWidth(),
) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.background(
MaterialTheme.colorScheme.surfaceContainerHigh,
RoundedCornerShape(12.dp),
)
.padding(horizontal = 12.dp, vertical = 4.dp),
)
}
}
}
private suspend fun LazyListState.scrollChatMessageToCenter( private suspend fun LazyListState.scrollChatMessageToCenter(
lazyIndex: Int, lazyIndex: Int,
topClearancePx: Int, topClearancePx: Int,
@@ -0,0 +1,69 @@
package ru.fromchat.ui.chat
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
internal val BUBBLE_RADIUS_LARGE = 20.dp
internal val BUBBLE_RADIUS_SMALL = 4.dp
@Composable
internal fun rememberAnimatedBubbleShape(
isAuthor: Boolean,
group: MessageGroupInfo,
): RoundedCornerShape {
val large = BUBBLE_RADIUS_LARGE
val small = BUBBLE_RADIUS_SMALL
// Spec (outgoing / bubble-local): start always large; end depends on neighbors.
// Incoming: mirror so the tail sits on the screen-edge (start) side.
val topStartTarget: Dp
val topEndTarget: Dp
val bottomStartTarget: Dp
val bottomEndTarget: Dp
if (isAuthor) {
topStartTarget = large
bottomStartTarget = large
topEndTarget = if (group.hasSameAuthorAbove) small else large
bottomEndTarget = if (group.hasSameAuthorBelow) small else large
} else {
topEndTarget = large
bottomEndTarget = large
topStartTarget = if (group.hasSameAuthorAbove) small else large
bottomStartTarget = if (group.hasSameAuthorBelow) small else large
}
val springSpec = spring<Dp>(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium,
)
val topStart by animateDpAsState(topStartTarget, springSpec, label = "bubbleTopStart")
val topEnd by animateDpAsState(topEndTarget, springSpec, label = "bubbleTopEnd")
val bottomStart by animateDpAsState(bottomStartTarget, springSpec, label = "bubbleBottomStart")
val bottomEnd by animateDpAsState(bottomEndTarget, springSpec, label = "bubbleBottomEnd")
return RoundedCornerShape(
topStart = topStart,
topEnd = topEnd,
bottomStart = bottomStart,
bottomEnd = bottomEnd,
)
}
internal fun bubbleTopRadii(
isAuthor: Boolean,
group: MessageGroupInfo,
): Pair<Dp, Dp> {
val large = BUBBLE_RADIUS_LARGE
val small = BUBBLE_RADIUS_SMALL
return if (isAuthor) {
large to if (group.hasSameAuthorAbove) small else large
} else {
(if (group.hasSameAuthorAbove) small else large) to large
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,296 @@
package ru.fromchat.ui.chat
import androidx.compose.runtime.Immutable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import ru.fromchat.Logger
import ru.fromchat.api.local.messages.parseMessageInstant
import ru.fromchat.api.schema.messages.Message
import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
@Immutable
data class MessageGroupInfo(
val hasSameAuthorAbove: Boolean,
val hasSameAuthorBelow: Boolean,
) {
val isFirstInGroup: Boolean get() = !hasSameAuthorAbove
val isLastInGroup: Boolean get() = !hasSameAuthorBelow
}
sealed interface ChatListItem {
data class DateSeparator(
val label: String,
val epochDay: Long,
) : ChatListItem
data class MessageRow(
val message: Message,
val group: MessageGroupInfo,
/** Spacing above this row in the visual (chronological) stack. */
val spacingAbove: Dp,
) : ChatListItem
}
enum class EnterMode {
ExtendGroup,
NewGroup,
NewDay,
FirstMessage,
None,
}
enum class EnterAnimationRole {
None,
PreviousLast,
NewMessage,
NewDateSeparator,
}
data class ActiveEnterAnimation(
val newMessageKey: String,
val previousMessageKey: String?,
val mode: EnterMode,
val newDateSeparatorEpochDay: Long? = null,
)
data class PendingEnter(
val newMessageKey: String,
val previousMessageKey: String?,
val mode: EnterMode,
val newDateSeparatorEpochDay: Long? = null,
)
/** Shared 500ms pacing for outbound network sends (not UI display). */
object MessageRateLimiter {
private const val MIN_INTERVAL_MS = 500L
private var nextSlotAtMs: Long = 0L
private val mutex = Mutex()
/**
* Reserves the next send slot and waits if needed.
* Does **not** hold [mutex] while delaying, so callers don't stack waits.
*/
suspend fun awaitSlot() {
val wait = mutex.withLock {
val now = Clock.System.now().toEpochMilliseconds()
val waitMs = (nextSlotAtMs - now).coerceAtLeast(0L)
nextSlotAtMs = now + waitMs + MIN_INTERVAL_MS
waitMs
}
if (wait > 0L) delay(wait)
}
}
/**
* Publishes enter roles immediately. Hold window is visual only never rate-limits.
*/
class MessageEnterCoordinator(
private val scope: CoroutineScope,
) {
private val _currentItem = MutableStateFlow<ActiveEnterAnimation?>(null)
val currentItem: StateFlow<ActiveEnterAnimation?> = _currentItem.asStateFlow()
private val _pendingNewMessageKeys = MutableStateFlow(setOf<String>())
val pendingNewMessageKeys: StateFlow<Set<String>> = _pendingNewMessageKeys.asStateFlow()
private val _queuedEnter = MutableStateFlow<PendingEnter?>(null)
val queuedEnter: StateFlow<PendingEnter?> = _queuedEnter.asStateFlow()
private var activeJob: Job? = null
private var activeGeneration = 0
fun enqueue(entry: PendingEnter) {
val generation = ++activeGeneration
val previousKey = _queuedEnter.value?.newMessageKey
val cancelledPrev = activeJob?.isActive == true
Logger.d(
"EnterAnim",
"enqueue gen=$generation newKey=${entry.newMessageKey.take(12)} " +
"prevKey=${entry.previousMessageKey?.take(12)} mode=${entry.mode} " +
"cancelledPrev=$cancelledPrev prevQueued=${previousKey?.take(12)} " +
"pendingCount=${_pendingNewMessageKeys.value.size}",
)
_pendingNewMessageKeys.value =
_pendingNewMessageKeys.value - (previousKey ?: "") + entry.newMessageKey
_queuedEnter.value = entry
val active = ActiveEnterAnimation(
newMessageKey = entry.newMessageKey,
previousMessageKey = entry.previousMessageKey,
mode = entry.mode,
newDateSeparatorEpochDay = entry.newDateSeparatorEpochDay,
)
_currentItem.value = active
activeJob?.cancel()
activeJob = scope.launch {
try {
delay(450.milliseconds)
} finally {
val superseded = generation != activeGeneration
Logger.d(
"EnterAnim",
"hold_end gen=$generation newKey=${entry.newMessageKey.take(12)} " +
"superseded=$superseded",
)
_pendingNewMessageKeys.value =
_pendingNewMessageKeys.value - entry.newMessageKey
if (superseded) return@launch
if (_currentItem.value == active) {
_currentItem.value = null
}
if (_queuedEnter.value?.newMessageKey == entry.newMessageKey) {
_queuedEnter.value = null
}
}
}
}
}
internal fun messageListKey(message: Message): String {
val cid = message.client_message_id?.trim().orEmpty()
return if (cid.isNotEmpty()) "c:$cid" else "i:${message.id}:${message.timestamp}"
}
internal fun timestampGroupKey(message: Message): String {
val cid = message.client_message_id?.trim().orEmpty()
return if (cid.isNotEmpty()) "c:$cid" else "i:${message.id}"
}
internal fun messageLocalDate(message: Message): LocalDate? =
parseMessageInstant(message.timestamp)
?.toLocalDateTime(TimeZone.currentSystemDefault())
?.date
/**
* Walks [messages] oldestnewest, inserts date separators, computes grouping,
* then returns items newestoldest for `LazyColumn(reverseLayout = true)`.
*/
fun buildChatListItems(
messages: List<Message>,
dateLabel: (LocalDate) -> String,
): List<ChatListItem> {
if (messages.isEmpty()) return emptyList()
val chronological = buildList {
var previousDate: LocalDate? = null
var previousUserId: Int? = null
messages.forEachIndexed { index, message ->
val date = messageLocalDate(message)
val dateChanged = date != null && date != previousDate
if (dateChanged) {
add(
ChatListItem.DateSeparator(
label = dateLabel(date),
epochDay = date.toEpochDays(),
),
)
}
val next = messages.getOrNull(index + 1)
val nextDate = next?.let { messageLocalDate(it) }
val hasSameAuthorAbove =
previousUserId == message.user_id &&
previousDate != null &&
date != null &&
previousDate == date &&
!dateChanged
val hasSameAuthorBelow =
next != null &&
next.user_id == message.user_id &&
date != null &&
nextDate != null &&
date == nextDate
val spacingAbove = when {
index == 0 && !dateChanged -> 0.dp
dateChanged -> 0.dp
hasSameAuthorAbove -> 1.dp
else -> 10.dp
}
add(
ChatListItem.MessageRow(
message = message,
group = MessageGroupInfo(
hasSameAuthorAbove = hasSameAuthorAbove,
hasSameAuthorBelow = hasSameAuthorBelow,
),
spacingAbove = spacingAbove,
),
)
previousDate = date ?: previousDate
previousUserId = message.user_id
}
}
return chronological.asReversed()
}
/** LazyColumn index for a message (index 0 = bottom spacer). */
fun lazyIndexForMessageId(listItems: List<ChatListItem>, messageId: Int): Int? {
val itemIndex = listItems.indexOfFirst {
it is ChatListItem.MessageRow && it.message.id == messageId
}
return if (itemIndex == -1) null else 1 + itemIndex
}
fun classifyEnterMode(
previous: Message?,
newest: Message,
): EnterMode {
if (previous == null) return EnterMode.FirstMessage
val prevDate = messageLocalDate(previous)
val newDate = messageLocalDate(newest)
if (prevDate == null || newDate == null || prevDate != newDate) return EnterMode.NewDay
return if (previous.user_id == newest.user_id) EnterMode.ExtendGroup else EnterMode.NewGroup
}
fun resolveMessageEnterRole(
messageKey: String,
active: ActiveEnterAnimation?,
pendingNewMessageKeys: Set<String> = emptySet(),
queuedEnter: PendingEnter? = null,
): EnterAnimationRole {
// New message: pending or active/queued.
if (
messageKey in pendingNewMessageKeys ||
messageKey == active?.newMessageKey ||
messageKey == queuedEnter?.newMessageKey
) {
return EnterAnimationRole.NewMessage
}
// PreviousLast only from the coordinator (active/queued), never composition-only,
// so the timestamp fade starts together with the new-bubble spring.
val previousKey = active?.previousMessageKey ?: queuedEnter?.previousMessageKey
val mode = active?.mode ?: queuedEnter?.mode
if (mode == EnterMode.ExtendGroup && messageKey == previousKey) {
return EnterAnimationRole.PreviousLast
}
return EnterAnimationRole.None
}
fun resolveDateSeparatorEnterRole(
epochDay: Long,
active: ActiveEnterAnimation?,
): EnterAnimationRole {
if (active == null) return EnterAnimationRole.None
return if (
active.mode == EnterMode.NewDay &&
active.newDateSeparatorEpochDay == epochDay
) {
EnterAnimationRole.NewDateSeparator
} else {
EnterAnimationRole.None
}
}
@@ -438,6 +438,7 @@ class DmPanel(
) )
val merged = confirmed.copy( val merged = confirmed.copy(
client_message_id = cid.ifEmpty { confirmed.client_message_id },
uploadJobId = null, uploadJobId = null,
uploadProgress = null, uploadProgress = null,
pendingFileUri = if (isImageAttachment) localPreviewUri ?: localUri else null, pendingFileUri = if (isImageAttachment) localPreviewUri ?: localUri else null,
@@ -4,9 +4,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ru.fromchat.api.local.db.aspectRatioFromDimensionPair import ru.fromchat.api.local.db.aspectRatioFromDimensionPair
import ru.fromchat.api.local.download.ChatPreviewDecodeSize import ru.fromchat.api.local.download.ChatPreviewDecodeSize
import ru.fromchat.ui.chat.MessageGroupInfo
/** Bubble top radius (must match [ru.fromchat.ui.chat.MessageItem] bubble shape). */ import ru.fromchat.ui.chat.bubbleTopRadii
private val BUBBLE_TOP = 20.dp
/** Padding between bubble edge and attachment image (must match MessageItem image padding). */ /** Padding between bubble edge and attachment image (must match MessageItem image padding). */
internal val ATTACHMENT_IMAGE_INSET = 2.dp internal val ATTACHMENT_IMAGE_INSET = 2.dp
@@ -15,12 +14,18 @@ internal val ATTACHMENT_IMAGE_INSET = 2.dp
private val IMAGE_BOTTOM_CORNER = 4.dp private val IMAGE_BOTTOM_CORNER = 4.dp
/** Inner clip: top corners follow bubble minus inset; bottom corners lightly rounded. */ /** Inner clip: top corners follow bubble minus inset; bottom corners lightly rounded. */
@Suppress("UNUSED_PARAMETER") internal fun attachmentImageCornerShape(
internal fun attachmentImageCornerShape(isAuthor: Boolean): RoundedCornerShape { isAuthor: Boolean,
group: MessageGroupInfo = MessageGroupInfo(
hasSameAuthorAbove = false,
hasSameAuthorBelow = false,
),
): RoundedCornerShape {
val inset = ATTACHMENT_IMAGE_INSET val inset = ATTACHMENT_IMAGE_INSET
val (topStart, topEnd) = bubbleTopRadii(isAuthor, group)
return RoundedCornerShape( return RoundedCornerShape(
topStart = (BUBBLE_TOP - inset).coerceAtLeast(0.dp), topStart = (topStart - inset).coerceAtLeast(0.dp),
topEnd = (BUBBLE_TOP - inset).coerceAtLeast(0.dp), topEnd = (topEnd - inset).coerceAtLeast(0.dp),
bottomStart = IMAGE_BOTTOM_CORNER, bottomStart = IMAGE_BOTTOM_CORNER,
bottomEnd = IMAGE_BOTTOM_CORNER, bottomEnd = IMAGE_BOTTOM_CORNER,
) )
@@ -33,7 +33,13 @@ internal fun messageDedupeKey(msg: Message): String {
return if (cid.isNotEmpty()) "c:$cid" else "i:${msg.id}" return if (cid.isNotEmpty()) "c:$cid" else "i:${msg.id}"
} }
/** Drops optimistic rows already represented by a confirmed message (same client id or recent own attachment). */ /**
* Drops optimistic rows already represented by a confirmed message (same client id),
* or legacy near-duplicate own rows that have no client id.
*
* In-flight sends with a [Message.client_message_id] that is not yet confirmed must be kept
* time-based heuristics must not remove them (that aborted enter animations mid-spring).
*/
internal fun dropSupersededOptimisticMessages( internal fun dropSupersededOptimisticMessages(
messages: List<Message>, messages: List<Message>,
currentUserId: Int?, currentUserId: Int?,
@@ -46,6 +52,8 @@ internal fun dropSupersededOptimisticMessages(
if (msg.id >= 0) return@filter true if (msg.id >= 0) return@filter true
val cid = msg.client_message_id?.trim().orEmpty() val cid = msg.client_message_id?.trim().orEmpty()
if (cid.isNotEmpty() && cid in confirmedClientIds) return@filter false if (cid.isNotEmpty() && cid in confirmedClientIds) return@filter false
// Stable client id still in flight — never drop via time heuristics.
if (cid.isNotEmpty()) return@filter true
// In-flight uploads (file or image): keep until a confirmed row shares the same client id. // In-flight uploads (file or image): keep until a confirmed row shares the same client id.
if (msg.pendingFileUri != null || !msg.uploadJobId.isNullOrBlank()) return@filter true if (msg.pendingFileUri != null || !msg.uploadJobId.isNullOrBlank()) return@filter true
if (self == null || msg.user_id != self) return@filter true if (self == null || msg.user_id != self) return@filter true
@@ -26,6 +26,7 @@ internal fun mergeDatabaseMessagesWithPanelState(
val mergedClientIds = mergedDb.mapNotNull { it.client_message_id?.trim()?.takeIf { id -> id.isNotEmpty() } }.toSet() val mergedClientIds = mergedDb.mapNotNull { it.client_message_id?.trim()?.takeIf { id -> id.isNotEmpty() } }.toSet()
val mergedIds = mergedDb.map { it.id }.toSet() val mergedIds = mergedDb.map { it.id }.toSet()
// Keep in-flight panel optimistics even when the DB Flow emission already stripped them.
val extraPanel = panelMessages.filter { panel -> val extraPanel = panelMessages.filter { panel ->
val cid = panel.client_message_id?.trim()?.takeIf { it.isNotEmpty() } val cid = panel.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
when { when {
@@ -10,6 +10,7 @@ import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.local.messages.parseMessageInstant
import ru.fromchat.month_apr import ru.fromchat.month_apr
import ru.fromchat.month_aug import ru.fromchat.month_aug
import ru.fromchat.month_dec import ru.fromchat.month_dec
@@ -131,7 +132,7 @@ fun rememberLastSeenFormatStrings(): LastSeenFormatStrings {
fun formatLastSeen(online: Boolean, lastSeenIso: String?, s: LastSeenFormatStrings): String { fun formatLastSeen(online: Boolean, lastSeenIso: String?, s: LastSeenFormatStrings): String {
if (online) return s.online if (online) return s.online
val iso = lastSeenIso ?: return "" val iso = lastSeenIso ?: return ""
val instant = runCatching { Instant.parse(iso) }.getOrNull() ?: return s.recently val instant = parseMessageInstant(iso) ?: return s.recently
if (instant.toEpochMilliseconds() <= 0L) return s.longAgo if (instant.toEpochMilliseconds() <= 0L) return s.longAgo
val timeZone = TimeZone.currentSystemDefault() val timeZone = TimeZone.currentSystemDefault()
@@ -9,6 +9,7 @@ import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.local.messages.parseMessageInstant
import ru.fromchat.month_name_apr import ru.fromchat.month_name_apr
import ru.fromchat.month_name_aug import ru.fromchat.month_name_aug
import ru.fromchat.month_name_dec import ru.fromchat.month_name_dec
@@ -23,7 +24,6 @@ import ru.fromchat.month_name_oct
import ru.fromchat.month_name_sep import ru.fromchat.month_name_sep
import ru.fromchat.profile_registration_date import ru.fromchat.profile_registration_date
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
import kotlin.time.Instant
private fun formatFromXmlTemplate(template: String, vararg args: Any): String { private fun formatFromXmlTemplate(template: String, vararg args: Any): String {
var result = template var result = template
@@ -69,7 +69,7 @@ fun rememberRegistrationDateFormatStrings(): RegistrationDateFormatStrings {
@OptIn(ExperimentalTime::class) @OptIn(ExperimentalTime::class)
private fun parseRegistrationLocalDate(iso: String): LocalDate? { private fun parseRegistrationLocalDate(iso: String): LocalDate? {
runCatching { Instant.parse(iso) }.getOrNull()?.let { parseMessageInstant(iso)?.let {
return it.toLocalDateTime(TimeZone.currentSystemDefault()).date return it.toLocalDateTime(TimeZone.currentSystemDefault()).date
} }
runCatching { LocalDateTime.parse(iso).date }.getOrNull()?.let { return it } runCatching { LocalDateTime.parse(iso).date }.getOrNull()?.let { return it }