From ff2f6e4e45ad4faab2ae391341a28ef87baaa521 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 10 Jul 2026 11:17:45 +0300 Subject: [PATCH] Redesign the chat interface Signed-off-by: denis0001-dev --- .../composeResources/values-ru/strings.xml | 3 + .../composeResources/values/strings.xml | 3 + .../api/local/messages/MessageTimestamps.kt | 47 +- .../ru/fromchat/ui/chat/AttachmentPreview.kt | 14 +- .../kotlin/ru/fromchat/ui/chat/ChatPanel.kt | 55 +- .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 485 ++++-- .../ru/fromchat/ui/chat/MessageBubbleShape.kt | 69 + .../kotlin/ru/fromchat/ui/chat/MessageItem.kt | 1336 ++++++++++------- .../ru/fromchat/ui/chat/MessageListUi.kt | 296 ++++ .../ru/fromchat/ui/chat/panels/dm/DmPanel.kt | 1 + .../ui/chat/utils/AttachmentImageGeometry.kt | 19 +- .../ui/chat/utils/MessageListDedup.kt | 10 +- .../fromchat/ui/chat/utils/MessageUiMerge.kt | 1 + .../ru/fromchat/utils/LastSeenFormat.kt | 3 +- .../utils/ProfileRegistrationDateFormat.kt | 4 +- 15 files changed, 1695 insertions(+), 651 deletions(-) create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageBubbleShape.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageListUi.kt diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index cdc47bc..68175ba 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -117,6 +117,9 @@ (изменено) Ответ %1$s Перейти к цитируемому сообщению + Фото + Сегодня + Вчера Сообщение не показывается Не удалось загрузить Не удалось отправить файл diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index b801b7e..c9e2f4c 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -129,6 +129,9 @@ (edited) Reply to %1$s Jump to quoted message + Photo + Today + Yesterday Can’t show this message Failed to load Couldn\'t send file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/MessageTimestamps.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/MessageTimestamps.kt index 0bc1bf9..4d9c905 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/MessageTimestamps.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/MessageTimestamps.kt @@ -1,9 +1,13 @@ package ru.fromchat.api.local.messages import kotlin.time.Instant +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone +import kotlinx.datetime.minus import kotlinx.datetime.number +import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import kotlin.time.Clock @@ -14,17 +18,22 @@ fun nowMessageTimestampIso(): String = Clock.System.now().toString() /** * 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? { val raw = timestamp.trim() if (raw.isEmpty()) return null val normalized = raw.replace(' ', 'T') - parseInstantOrNull(normalized)?.let { return it } - if (!hasExplicitOffset(normalized)) { - parseInstantOrNull("${normalized}Z")?.let { return it } + if (hasExplicitOffset(normalized)) { + return parseInstantOrNull(normalized) } - return null + val local = parseLocalDateTimeOrNull(normalized) ?: return null + return runCatching { + local.toInstant(TimeZone.currentSystemDefault()) + }.getOrNull() } internal fun parseMessageTimestampMillis(timestamp: String): Long? = @@ -63,9 +72,37 @@ internal fun formatMessageDateTimeLocal(timestamp: String): String { 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? = runCatching { Instant.parse(value) }.getOrNull() +private fun parseLocalDateTimeOrNull(value: String): LocalDateTime? = + runCatching { LocalDateTime.parse(value) }.getOrNull() + private fun hasExplicitOffset(value: String): Boolean = value.endsWith('Z', ignoreCase = true) || OFFSET_SUFFIX.containsMatchIn(value) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt index 64b33b1..5c1a7f4 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt @@ -107,6 +107,7 @@ import ru.fromchat.ui.chat.utils.coalesceDecodeTarget import ru.fromchat.ui.chat.utils.decodeSizeChangedMeaningfully import ru.fromchat.ui.components.Text import com.pr0gramm3r101.utils.scaleOnPress +import ru.fromchat.ui.chat.MessageGroupInfo private val IMAGE_SIZE = 160.dp private val IMAGE_MAX_HEIGHT = 240.dp @@ -145,6 +146,10 @@ fun AttachmentPreview( /** Message text shown in attachment download/upload logs. */ messageLabel: String? = null, onCancelUpload: (() -> Unit)? = null, + messageGroup: MessageGroupInfo = MessageGroupInfo( + hasSameAuthorAbove = false, + hasSameAuthorBelow = false, + ), modifier: Modifier = Modifier, ) { val isImage = when { @@ -220,7 +225,7 @@ fun AttachmentPreview( Modifier.sizeIn(maxWidth = IMAGE_SIZE, maxHeight = IMAGE_MAX_HEIGHT) } ) - .clip(attachmentImageCornerShape(isAuthor)) + .clip(attachmentImageCornerShape(isAuthor, messageGroup)) .then( if (onImageBounds != null && showImageTile) { Modifier.onGloballyPositioned { coords -> @@ -269,6 +274,7 @@ fun AttachmentPreview( messageLabel = messageLabel, onCancelUpload = onCancelUpload, onFullyLoaded = { if (it) isFullyLoaded = true }, + messageGroup = messageGroup, ) } } @@ -299,9 +305,13 @@ private fun ChatImageTileContent( messageLabel: String? = null, onCancelUpload: (() -> Unit)? = null, onFullyLoaded: (Boolean) -> Unit = {}, + messageGroup: MessageGroupInfo = MessageGroupInfo( + hasSameAuthorAbove = false, + hasSameAuthorBelow = false, + ), ) { val scope = rememberCoroutineScope() - val clipShape = attachmentImageCornerShape(isAuthor) + val clipShape = attachmentImageCornerShape(isAuthor, messageGroup) val cacheClientId = clientMessageId?.trim()?.takeIf { it.isNotEmpty() } val layoutAspect = aspectRatio?.takeIf { it.isFinite() && it > 0f } val fallbackDecodeSize = rememberChatPreviewDecodeSize(IMAGE_SIZE, layoutAspect) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt index 30462d9..a556fc0 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt @@ -333,12 +333,18 @@ abstract class ChatPanel( updateState { currentState -> val optimistic = currentState.messages.find { it.client_message_id == tempId } - val resolvedConfirmed = if (confirmedMessage.reply_to == null) { - val reply = optimistic?.reply_to - if (reply != null) confirmedMessage.copy(reply_to = reply) else confirmedMessage + // Keep client_message_id so LazyColumn keys / enter animation state stay stable. + val withClientId = if (confirmedMessage.client_message_id.isNullOrBlank()) { + confirmedMessage.copy(client_message_id = tempId) } else { 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) { currentState.messages.filter { it.id != resolvedConfirmed.id } } else { @@ -432,7 +438,13 @@ abstract class ChatPanel( suspend fun sendMessageWithImmediateDisplay(content: String, replyToId: Int?) { 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 tempMessage = Message( id = -1, // Temporary negative ID @@ -452,6 +464,13 @@ abstract class ChatPanel( val optimistic = tempMessage.copy(id = uniqueOptimisticMessageId()) 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 val timeoutJob = scope.launch { delay(10000) // 10 seconds timeout @@ -465,16 +484,24 @@ abstract class ChatPanel( runCatching { persistOptimisticMessage(optimistic) } } - // Actually send the message - try { - sendMessage(content, replyToId, tempId) - // Message sent successfully - will be updated when WebSocket confirms - } catch (error: Exception) { - removeMessageByClientMessageId(tempId) - pendingMessages.remove(tempId) - timeoutJob.cancel() - scope.launch(Dispatchers.Default) { - runCatching { removeOptimisticFromCache(optimistic) } + // Network send is paced separately so UI enter never waits on the rate limiter. + scope.launch { + try { + val rateT0 = kotlin.time.Clock.System.now().toEpochMilliseconds() + MessageRateLimiter.awaitSlot() + Logger.d( + "EnterAnim", + "after_rate_limit tempId=${tempId.take(8)} " + + "waitedMs=${kotlin.time.Clock.System.now().toEpochMilliseconds() - rateT0}", + ) + sendMessage(content, replyToId, tempId) + } catch (_: Exception) { + removeMessageByClientMessageId(tempId) + pendingMessages.remove(tempId) + timeoutJob.cancel() + scope.launch(Dispatchers.Default) { + runCatching { removeOptimisticFromCache(optimistic) } + } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index 86b4ca9..0e32bf5 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -4,7 +4,6 @@ import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints 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.ime import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState 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.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator @@ -31,6 +36,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf 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.unit.IntOffset 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.supportClipboardManagerImpl import dev.chrisbanes.haze.HazeProgressive @@ -176,12 +187,158 @@ fun ChatScreen( } } 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()) + } + var hiddenDefaultTimestampKeys by rememberSaveable(panelId) { + mutableStateOf(setOf()) + } + var lastAnimatedMessageKeys by rememberSaveable(panelId) { + mutableStateOf(setOf()) + } + 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 messageIndex = messages.indexOfFirst { it.id == messageId } - if (messageIndex != -1) { + val newest = messages.lastOrNull() + 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 { - val lazyIndex = 1 + (messages.size - 1 - messageIndex) val (topClearancePx, bottomClearancePx) = chatScrollClearancePx.value listState.scrollChatMessageToCenter( lazyIndex, @@ -755,89 +912,211 @@ fun ChatScreen( .hazeSource(hazeState), userScrollEnabled = !contextMenuState.isOpen, reverseLayout = true, - verticalArrangement = Arrangement.spacedBy(4.dp) ) { - item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) } - - items( - items = panelState.messages.asReversed(), - 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( + innerPadding.calculateBottomPadding() + 12.dp, + ), ) } - 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( @@ -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( lazyIndex: Int, topClearancePx: Int, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageBubbleShape.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageBubbleShape.kt new file mode 100644 index 0000000..12ccde7 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageBubbleShape.kt @@ -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( + 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 { + 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 + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt index d561c92..cb134a0 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt @@ -1,9 +1,15 @@ package ru.fromchat.ui.chat +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable 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.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -23,13 +29,15 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ErrorOutline -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material.icons.rounded.Schedule import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -37,40 +45,39 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.pr0gramm3r101.utils.conditional +import kotlin.math.roundToInt import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Logger import ru.fromchat.Res import ru.fromchat.api.local.cache.DecryptedImageCache import ru.fromchat.api.local.db.store.ProfileCache -import ru.fromchat.ui.chat.messageSenderAvatarLabel -import ru.fromchat.api.local.messages.formatMessageBubbleTimeLocal +import ru.fromchat.api.local.messages.formatMessageTimeLocal 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_reply_photo import ru.fromchat.message_send_failed -import ru.fromchat.ui.chat.components.getMessageGradient -import ru.fromchat.ui.chat.components.getReplyMessageGradient import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage import ru.fromchat.ui.chat.utils.imageAttachmentKey import ru.fromchat.ui.components.Text -import ru.fromchat.ui.isAppInDarkTheme import ru.fromchat.ui.profile.StatusBadge import ru.fromchat.ui.profile.resolveVerificationStatus @@ -97,6 +104,30 @@ private fun isMessageCorrupted(message: Message): Boolean { } } +/** + * Scales layout height with the enter animation so the list pushes older + * messages up in real time. Visual scale is applied separately via graphicsLayer + * on the bubble (with a correct transform origin). + */ +private fun Modifier.enterLayoutHeight( + scale: Float, + minHeightPx: Int, + active: Boolean, +): Modifier { + if (!active) return this + return layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val layoutScale = scale.coerceIn(0f, 1f) + val h = (placeable.height * layoutScale).roundToInt().coerceAtLeast( + if (layoutScale > 0f) minHeightPx else 0, + ) + // Anchor to the bottom of the allocated slot (reverseLayout chat). + layout(placeable.width, h) { + placeable.placeRelative(0, h - placeable.height) + } + } +} + @Composable fun MessageItem( message: Message, @@ -118,17 +149,24 @@ fun MessageItem( onReplyClick: ((Int) -> Unit)? = null, highlightMessageId: Int? = null, highlightFading: Boolean = false, + group: MessageGroupInfo = MessageGroupInfo( + hasSameAuthorAbove = false, + hasSameAuthorBelow = false, + ), + showTimestamp: Boolean = true, + onBubbleTap: (() -> Unit)? = null, + enterAnimationRole: EnterAnimationRole = EnterAnimationRole.None, ) { - // Cache derived values per message to avoid recomputing in every recomposition. val isCorrupted = remember(message.files, message.fileThumbnails, message.dmEnvelope) { isMessageCorrupted(message) } val formattedTime = remember(message.timestamp) { - formatMessageBubbleTimeLocal(message.timestamp) + formatMessageTimeLocal(message.timestamp) } val corruptedBody = stringResource(Res.string.message_corrupted) val editedSuffix = stringResource(Res.string.message_edited_suffix) val sendFailedLabel = stringResource(Res.string.message_send_failed) + val replyPhotoLabel = stringResource(Res.string.message_reply_photo) val displayUsername = messageDisplayUsername(message, currentUserId) val senderProfile = ProfileCache.get(message.user_id) val avatarPictureUrl = senderProfile?.profilePicture?.takeIf { it.isNotBlank() } @@ -138,9 +176,6 @@ fun MessageItem( val isDeletedSender = messageSenderIsDeleted(message, currentUserId) val replyRef = message.reply_to - // No AnimatedVisibility here: visible=true still ran enter transitions for every item on first - // composition (N messages ⇒ N concurrent animations + huge JIT), causing main-thread jank. - var isPressed by remember { mutableStateOf(false) } var avatarPressed by remember(message.id) { mutableStateOf(false) } var replyPressed by remember(message.id) { mutableStateOf(false) } @@ -194,6 +229,111 @@ fun MessageItem( ) val replyJumpCd = stringResource(Res.string.message_reply_jump_cd) + // One-shot enter owned by this composition. Keyed off the message identity so + // LazyColumn reuse never carries a finished enter into a brand-new bubble. + val enterIdentity = message.client_message_id?.trim().orEmpty().ifEmpty { + "i:${message.id}:${message.timestamp}" + } + val startsAsNew = enterAnimationRole == EnterAnimationRole.NewMessage + var enterStarted by remember(enterIdentity) { mutableStateOf(startsAsNew) } + var enterFinished by remember(enterIdentity) { mutableStateOf(!startsAsNew) } + val enterScale = remember(enterIdentity) { + Animatable(if (startsAsNew) 0f else 1f) + } + val timestampForceAlpha = remember(enterIdentity) { Animatable(1f) } + val isNewEnterRole = enterAnimationRole == EnterAnimationRole.NewMessage + LaunchedEffect(enterIdentity, enterAnimationRole, showTimestamp) { + Logger.d( + "EnterAnim", + "role_or_ts id=${message.id} identity=${enterIdentity.take(12)} " + + "role=${enterAnimationRole.name} showTs=$showTimestamp " + + "groupLast=${group.isLastInGroup} enterStarted=$enterStarted " + + "enterFinished=$enterFinished scale=${enterScale.value}", + ) + } + LaunchedEffect(enterIdentity, isNewEnterRole) { + if (isNewEnterRole && !enterStarted) { + enterStarted = true + enterFinished = false + enterScale.snapTo(0f) + } + } + val runEnterAnimation = enterStarted && !enterFinished + // Single effect: start the spring as soon as this bubble is marked for enter. + LaunchedEffect(enterIdentity, runEnterAnimation) { + if (!runEnterAnimation) return@LaunchedEffect + Logger.d( + "EnterAnim", + "spring_start identity=${enterIdentity.take(12)} " + + "scaleBefore=${enterScale.value} role=${enterAnimationRole.name}", + ) + if (enterScale.value > 0.001f) enterScale.snapTo(0f) + enterScale.animateTo( + 1f, + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + ) + enterFinished = true + Logger.d( + "EnterAnim", + "spring_end identity=${enterIdentity.take(12)} role=${enterAnimationRole.name}", + ) + } + LaunchedEffect(enterIdentity, enterAnimationRole) { + when (enterAnimationRole) { + EnterAnimationRole.PreviousLast -> { + // Fade in parallel with the new bubble spring (same frame as enqueue). + if (timestampForceAlpha.value > 0.01f) { + timestampForceAlpha.animateTo(0f, tween(80)) + } + } + EnterAnimationRole.NewMessage -> Unit + else -> { + if (!runEnterAnimation && enterScale.value != 1f) { + enterScale.snapTo(1f) + } + timestampForceAlpha.snapTo(1f) + } + } + } + + val bubbleShape = rememberAnimatedBubbleShape(isAuthor, group) + val onPrimary = MaterialTheme.colorScheme.onPrimary + val onSurface = MaterialTheme.colorScheme.onSurface + val contentColor = if (isAuthor) onPrimary else onSurface + val density = LocalDensity.current + val minEnterHeightPx = remember(density) { with(density) { 4.dp.roundToPx() } } + val showAvatarSlot = !isAuthor && showUsername + val showAvatar = showAvatarSlot && group.isLastInGroup + val showUsernameInBubble = showUsername && !isAuthor && group.isFirstInGroup + + val isPendingOutbound = message.id < 0 && message.files.isNullOrEmpty() + val sendFailed = isPendingOutbound && !message.uploadError.isNullOrBlank() + val showScheduleIcon = + runEnterAnimation && + isAuthor && + isPendingOutbound && + !sendFailed + // Group membership drives timestamp space; PreviousLast fades alpha while + // showTimestamp is still held true by ChatScreen until this role arrives. + val timestampVisible = when { + enterAnimationRole == EnterAnimationRole.PreviousLast -> false + showScheduleIcon || sendFailed -> true + else -> showTimestamp + } + val timestampAlpha = + if (enterAnimationRole == EnterAnimationRole.PreviousLast) { + timestampForceAlpha.value + } else { + 1f + } + val timestampTakesSpace = timestampVisible + val metaColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + val enterTransformOrigin = + if (isAuthor) TransformOrigin(1f, 1f) else TransformOrigin(0f, 1f) + Box(modifier = modifier.fillMaxWidth()) { if (highlightAlpha > 0f) { Box( @@ -210,556 +350,684 @@ fun MessageItem( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), + .padding(horizontal = 8.dp) + .enterLayoutHeight( + scale = enterScale.value, + minHeightPx = minEnterHeightPx, + active = runEnterAnimation, + ), horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start, verticalAlignment = Alignment.Bottom, ) { - if (!isAuthor && showUsername) { - Box( - modifier = Modifier - .graphicsLayer( - scaleX = avatarScale, - scaleY = avatarScale, - transformOrigin = TransformOrigin.Center - ) - .pointerInput(onUsernameClick, isContextMenuOpen) { - detectTapGestures( - onPress = { - if (!isContextMenuOpen) avatarPressed = true - try { - awaitRelease() - } finally { - if (!isContextMenuOpen) avatarPressed = false - } - }, - onTap = { onUsernameClick?.invoke() } + if (showAvatarSlot) { + if (showAvatar) { + Box( + modifier = Modifier + .graphicsLayer( + scaleX = avatarScale, + scaleY = avatarScale, + transformOrigin = TransformOrigin.Center + ) + .pointerInput(onUsernameClick, isContextMenuOpen) { + detectTapGestures( + onPress = { + if (!isContextMenuOpen) avatarPressed = true + try { + awaitRelease() + } finally { + if (!isContextMenuOpen) avatarPressed = false + } + }, + onTap = { onUsernameClick?.invoke() } + ) + } + ) { + Avatar( + profilePictureUrl = avatarPictureUrl, + displayName = avatarDisplayName, + modifier = Modifier.size(32.dp), + isDeletedUser = isDeletedSender, + userId = message.user_id, ) } - ) { - Avatar( - profilePictureUrl = avatarPictureUrl, - displayName = avatarDisplayName, - modifier = Modifier.size(32.dp), - isDeletedUser = isDeletedSender, - userId = message.user_id, - ) + } else { + Spacer(modifier = Modifier.size(32.dp)) + } + Spacer(modifier = Modifier.width(8.dp)) } - Spacer(modifier = Modifier.width(8.dp)) - } - - BoxWithConstraints( - modifier = Modifier.weight(1f, fill = false) - ) { - // Allow bubbles to grow up to 70% of the available row width - val maxBubbleWidth = maxWidth * 0.7f - - Column( - horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start + BoxWithConstraints( + modifier = Modifier.weight(1f, fill = false) ) { - // Message bubble - val isDark = isAppInDarkTheme() - val pendingIsImage = when { - message.pendingFilename?.isNotBlank() == true -> isImageFilename(message.pendingFilename) - message.pendingFileUri != null -> isImageFilename( - message.pendingFileUri.substringAfterLast('/').substringBefore('?') - ) - else -> false - } - val pendingHasOutboundFile = message.pendingFileUri != null && - message.files.isNullOrEmpty() && - !pendingIsImage - val uploadFailed = !message.uploadError.isNullOrBlank() - val canCancelUpload = message.isQueuedOutbound() && isAuthor && - !uploadFailed && - (pendingIsImage || pendingHasOutboundFile) && - (message.uploadProgress != null || message.pendingFileUri != null) - val onCancelUpload: (() -> Unit)? = if (canCancelUpload && onCancelOutboundAttachment != null) { - { onCancelOutboundAttachment.invoke(message) } - } else { - null - } - val onRetryUpload: (() -> Unit)? = if (uploadFailed && onRetryOutboundAttachment != null) { - { onRetryOutboundAttachment.invoke(message) } - } else { - null - } - val firstContentIsImage = ( - !showUsername || isAuthor - ) && message.reply_to == null && ( - pendingIsImage || message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true - ) + val maxBubbleWidth = maxWidth * 0.7f - val bubbleShape = RoundedCornerShape( - topStart = 20.dp, - topEnd = 20.dp, - bottomStart = if (isAuthor) 20.dp else 8.dp, - bottomEnd = if (isAuthor) 8.dp else 20.dp - ) - - val bubblePressAndLongPress = - if (isContextMenuOpen) Modifier - else Modifier.pointerInput(isContextMenuOpen, message.id) { - detectTapGestures( - onPress = { - isPressed = true - try { - awaitRelease() - } finally { - isPressed = false - } - }, - onLongPress = { localOffset -> - onTapPosition(bubbleBodyPositionInRoot + localOffset) - onLongPress() - } - ) - } - - val slackRowPressAndLongPress = - if (isContextMenuOpen) Modifier - else Modifier.pointerInput(isContextMenuOpen, message.id) { - detectTapGestures( - onPress = { - isPressed = true - try { - awaitRelease() - } finally { - isPressed = false - } - }, - onLongPress = { localOffset -> - val coords = slackRowLayoutCoords - if (coords != null && coords.isAttached) { - onTapPosition(coords.localToRoot(localOffset)) - } else { - onTapPosition(bubbleBodyPositionInRoot + localOffset) - } - onLongPress() - } - ) - } - - // Full-width hit target so pressing empty row space still scales the bubble & long-press menu. - Box( - modifier = Modifier - .fillMaxWidth() - .onGloballyPositioned { slackRowLayoutCoords = it } - .then(slackRowPressAndLongPress) + Column( + horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start ) { - // graphicsLayer must wrap clip/shadow/background so the whole bubble scales on press; - // placing it only after background scaled the children but left the bubble chrome unscaled. - Box( - modifier = Modifier - .align(if (isAuthor) Alignment.BottomEnd else Alignment.BottomStart) - .widthIn(max = maxBubbleWidth) - .graphicsLayer( - scaleX = scale, - scaleY = scale, - transformOrigin = TransformOrigin.Center + val pendingIsImage = when { + message.pendingFilename?.isNotBlank() == true -> + isImageFilename(message.pendingFilename) + message.pendingFileUri != null -> isImageFilename( + message.pendingFileUri.substringAfterLast('/').substringBefore('?') ) - .clip(bubbleShape) - .conditional( - isAuthor, - `if` = { - it - .shadow( - elevation = 8.dp, - shape = RoundedCornerShape( - topStart = 20.dp, - topEnd = 20.dp, - bottomStart = 20.dp, - bottomEnd = 8.dp - ), - spotColor = if (isDark) Color(0x66000000) else Color(0x33000000) - ) - .background(getMessageGradient(isDark)) - }, - `else` = { - background(MaterialTheme.colorScheme.surfaceContainerHighest) - } - ) - .padding(top = if (firstContentIsImage) 0.dp else 6.dp) - ) { - val bubbleColumnModifier = - if (showUsername && !isAuthor) Modifier.width(IntrinsicSize.Max) - else Modifier - Column(modifier = bubbleColumnModifier) { - if (showUsername && !isAuthor) { - val usernameShape = RoundedCornerShape(6.dp) - val usernameOutset = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 4.dp) - val usernameInset = Modifier.padding(horizontal = 4.dp, vertical = 2.dp) - if (onUsernameClick != null) { - val usernameInteraction = remember(message.id) { MutableInteractionSource() } - Box( - modifier = usernameOutset - .clip(usernameShape) - .clickable( - interactionSource = usernameInteraction, - indication = LocalIndication.current, - onClick = onUsernameClick - ) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = usernameInset, - ) { - Text( - text = displayUsername, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - ) - StatusBadge( - verificationStatus = senderVerificationStatus, - size = 14.dp, - ) - } - } - } else { - Box(modifier = usernameOutset) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = usernameInset, - ) { - Text( - text = displayUsername, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - ) - StatusBadge( - verificationStatus = senderVerificationStatus, - size = 14.dp, - ) - } - } - } - } - - val gestureWidthModifier = - if (showUsername && !isAuthor) Modifier.fillMaxWidth() - else Modifier - Box( - modifier = gestureWidthModifier - .onGloballyPositioned { coordinates -> - bubbleBodyPositionInRoot = coordinates.positionInRoot() - } - .then(bubblePressAndLongPress) - ) { - Column { - // 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) - .graphicsLayer( - scaleX = replyScale, - scaleY = replyScale, - transformOrigin = TransformOrigin.Center, - ) - .then(replyPressModifier) - .semantics { - if (replyTapEnabled) contentDescription = replyJumpCd - }, - ) { - Row( - modifier = Modifier - .clip(RoundedCornerShape(12.dp)) - .fillMaxWidth() - .height(IntrinsicSize.Min) - .conditional( - isAuthor, - `if` = { - background(getReplyMessageGradient(isDark)) - }, - `else` = { - background(MaterialTheme.colorScheme.surfaceVariant) - } - ) - ) { - Box( - Modifier - .background(MaterialTheme.colorScheme.primary) - .width(3.dp) - .fillMaxHeight() - ) - - Column( - Modifier.padding(horizontal = 8.dp, vertical = 6.dp) - ) { - 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, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 12.sp, - maxLines = 1 - ) - } - } - } - } - - // Attachments (images/files) or corrupted message - if (isCorrupted) { - Text( - text = corruptedBody, - style = MaterialTheme.typography.bodyMedium, - color = if (isAuthor) { - Color.White.copy(alpha = 0.8f) - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) - }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) - ) + else -> false + } + val pendingHasOutboundFile = message.pendingFileUri != null && + message.files.isNullOrEmpty() && + !pendingIsImage + val uploadFailed = !message.uploadError.isNullOrBlank() + val canCancelUpload = message.isQueuedOutbound() && isAuthor && + !uploadFailed && + (pendingIsImage || pendingHasOutboundFile) && + (message.uploadProgress != null || message.pendingFileUri != null) + val onCancelUpload: (() -> Unit)? = + if (canCancelUpload && onCancelOutboundAttachment != null) { + { onCancelOutboundAttachment.invoke(message) } } else { - val primaryFile = message.files?.firstOrNull() - val primaryIsImage = primaryFile != null && isImageFilename(primaryFile.name) - val showPrimaryImageSlot = pendingIsImage || primaryIsImage - val showPrimaryFileSlot = pendingHasOutboundFile || - (primaryFile != null && !primaryIsImage) - if (showPrimaryImageSlot) { - val imageKey = imageAttachmentKey(message, 0) - val awaitingServer = message.id < 0 && message.files.isNullOrEmpty() - val isOutboundPendingImage = awaitingServer && pendingIsImage - val awaitingServerAck = isOutboundPendingImage && - !uploadFailed && - message.uploadProgress == null - AttachmentPreview( - file = primaryFile, - dmEnvelope = message.dmEnvelope, - currentUserId = currentUserId, - pendingFileUri = message.pendingFileUri, - pendingFilename = message.pendingFilename, - isUploading = isOutboundPendingImage && !uploadFailed, - awaitingServerAck = awaitingServerAck, - uploadProgress = message.uploadProgress, - uploadError = message.uploadError, - onRetryUpload = onRetryUpload, - fileThumbnail = message.fileThumbnails?.firstOrNull()?.takeIf { it.isNotBlank() }, - fileAspectRatio = imageAspectRatioForMessage( - fileAspectRatios = message.fileAspectRatios, - fileDimensions = message.fileDimensions, - pendingFileAspectRatio = message.pendingFileAspectRatio, - fileIndex = 0, - confirmed = message.id > 0, - hasLocalPreview = DecryptedImageCache.isDecryptedImageCacheUri( - message.pendingFileUri, - ), - ), - fileSizeBytes = message.fileSizes?.firstOrNull(), - messageId = message.id, - fileIndex = 0, - clientMessageId = message.client_message_id, - onImageClick = { onImageClick?.invoke(message, 0) }, - onImageBounds = if (onImageBounds != null) { - { rect -> onImageBounds.invoke(imageKey, rect) } - } else { - null - }, - isExpanded = expandedImageKey != null && - expandedImageKey == imageKey && - !isImageClosing, - isAuthor = isAuthor, - messageLabel = message.content, - onCancelUpload = onCancelUpload, - modifier = if (firstContentIsImage) { - Modifier.padding(all = 2.dp) - } else { - Modifier.padding(horizontal = 2.dp, vertical = 4.dp) - } - ) - } - if (showPrimaryFileSlot) { - val awaitingServer = message.id < 0 && message.files.isNullOrEmpty() - val isOutboundPendingFile = awaitingServer && pendingHasOutboundFile - val awaitingServerAck = isOutboundPendingFile && - !uploadFailed && - message.uploadProgress == null - AttachmentPreview( - file = primaryFile, - dmEnvelope = message.dmEnvelope, - currentUserId = currentUserId, - pendingFileUri = message.pendingFileUri, - pendingFilename = message.pendingFilename, - isUploading = isOutboundPendingFile && !uploadFailed, - awaitingServerAck = awaitingServerAck, - uploadProgress = message.uploadProgress, - uploadError = message.uploadError, - onRetryUpload = onRetryUpload, - fileSizeBytes = message.fileSizes?.firstOrNull(), - messageId = message.id, - fileIndex = 0, - clientMessageId = message.client_message_id, - isAuthor = isAuthor, - messageLabel = message.content, - onCancelUpload = onCancelUpload, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp), - ) - } - message.files?.forEachIndexed { index, file -> - if (index == 0 && showPrimaryImageSlot && isImageFilename(file.name)) { - return@forEachIndexed - } - if (index == 0 && showPrimaryFileSlot && !isImageFilename(file.name)) { - return@forEachIndexed - } - val isImage = isImageFilename(file.name) - val imageKey = if (isImage) imageAttachmentKey(message, index) else null - val isFirstImage = index == 0 && isImage - AttachmentPreview( - file = file, - dmEnvelope = message.dmEnvelope, - currentUserId = currentUserId, - pendingFileUri = if (index == 0) message.pendingFileUri else null, - pendingFilename = if (index == 0) message.pendingFilename else null, - isUploading = false, - fileThumbnail = message.fileThumbnails?.getOrNull(index)?.takeIf { it.isNotBlank() }, - fileAspectRatio = imageAspectRatioForMessage( - fileAspectRatios = message.fileAspectRatios, - fileDimensions = message.fileDimensions, - pendingFileAspectRatio = message.pendingFileAspectRatio, - fileIndex = index, - confirmed = message.id > 0, - hasLocalPreview = index == 0 && - DecryptedImageCache.isDecryptedImageCacheUri(message.pendingFileUri), - ), - fileSizeBytes = message.fileSizes?.getOrNull(index), - messageId = message.id, - fileIndex = index, - clientMessageId = message.client_message_id, - onImageClick = if (isImage) { { onImageClick?.invoke(message, index) } } else null, - onImageBounds = if (isImage && imageKey != null && onImageBounds != null) { - { rect -> onImageBounds.invoke(imageKey, rect) } - } else null, - isExpanded = isImage && expandedImageKey != null && expandedImageKey == imageKey && !isImageClosing, - isAuthor = isAuthor, - messageLabel = message.content, - onCancelUpload = onCancelUpload, - modifier = if (isFirstImage && firstContentIsImage && isImage) { - Modifier.padding(all = 2.dp) - } else { - Modifier.padding( - horizontal = if (isImage) 2.dp else 4.dp, - vertical = if (isImage) 2.dp else 4.dp - ) - } - ) - } + null } - if ( - message.content.isNotBlank() && - !isCorrupted && - !isFilenameOnlyMessageCaption(message) + val onRetryUpload: (() -> Unit)? = + if (uploadFailed && onRetryOutboundAttachment != null) { + { onRetryOutboundAttachment.invoke(message) } + } else { + null + } + val firstContentIsImage = ( + !showUsername || isAuthor + ) && message.reply_to == null && ( + pendingIsImage || + message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true + ) + + val bubblePressAndLongPress = + if (isContextMenuOpen) Modifier + else Modifier.pointerInput( + isContextMenuOpen, + message.id, + onBubbleTap, ) { - Text( - text = message.content, - style = MaterialTheme.typography.bodyMedium, - color = if (isAuthor) { - Color.White - } else { - MaterialTheme.colorScheme.onSurface + detectTapGestures( + onPress = { + isPressed = true + try { + awaitRelease() + } finally { + isPressed = false + } }, - modifier = Modifier.padding(horizontal = 12.dp) + onTap = { onBubbleTap?.invoke() }, + onLongPress = { localOffset -> + onTapPosition(bubbleBodyPositionInRoot + localOffset) + onLongPress() + } ) } - // Timestamp, sending indicator, and edited indicator - val isPendingOutbound = message.id < 0 && message.files.isNullOrEmpty() - val sendFailed = isPendingOutbound && !message.uploadError.isNullOrBlank() + val slackRowPressAndLongPress = + if (isContextMenuOpen) Modifier + else Modifier.pointerInput( + isContextMenuOpen, + message.id, + onBubbleTap, + ) { + detectTapGestures( + onPress = { + isPressed = true + try { + awaitRelease() + } finally { + isPressed = false + } + }, + onTap = { onBubbleTap?.invoke() }, + onLongPress = { localOffset -> + val coords = slackRowLayoutCoords + if (coords != null && coords.isAttached) { + onTapPosition(coords.localToRoot(localOffset)) + } else { + onTapPosition(bubbleBodyPositionInRoot + localOffset) + } + onLongPress() + } + ) + } + + Box( + modifier = Modifier + .onGloballyPositioned { slackRowLayoutCoords = it } + .then(slackRowPressAndLongPress) + ) { + Column( + horizontalAlignment = + if (isAuthor) Alignment.End else Alignment.Start, + modifier = Modifier.graphicsLayer { + if (runEnterAnimation) { + val s = enterScale.value + scaleX = s + scaleY = s + transformOrigin = enterTransformOrigin + alpha = if (s <= 0.001f) 0f else 1f + } + }, + ) { + Box( + modifier = Modifier + .widthIn(max = maxBubbleWidth) + .wrapContentWidth() + .graphicsLayer( + scaleX = scale, + scaleY = scale, + transformOrigin = TransformOrigin.Center + ) + .clip(bubbleShape) + .background( + if (isAuthor) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + ) + .padding( + top = if (firstContentIsImage) 0.dp else 8.dp, + bottom = 8.dp, + ) + ) { + Column(modifier = Modifier.width(IntrinsicSize.Max)) { + if (showUsernameInBubble) { + val usernameShape = RoundedCornerShape(6.dp) + val usernameOutset = + Modifier.padding(start = 8.dp, end = 8.dp, bottom = 4.dp) + val usernameInset = + Modifier.padding(horizontal = 4.dp, vertical = 2.dp) + if (onUsernameClick != null) { + val usernameInteraction = + remember(message.id) { MutableInteractionSource() } + Box( + modifier = usernameOutset + .clip(usernameShape) + .clickable( + interactionSource = usernameInteraction, + indication = LocalIndication.current, + onClick = onUsernameClick + ) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = usernameInset, + ) { + Text( + text = displayUsername, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + StatusBadge( + verificationStatus = senderVerificationStatus, + size = 14.dp, + ) + } + } + } else { + Box(modifier = usernameOutset) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = usernameInset, + ) { + Text( + text = displayUsername, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + StatusBadge( + verificationStatus = senderVerificationStatus, + size = 14.dp, + ) + } + } + } + } + + val gestureWidthModifier = Modifier.fillMaxWidth() + Box( + modifier = gestureWidthModifier + .onGloballyPositioned { coordinates -> + bubbleBodyPositionInRoot = coordinates.positionInRoot() + } + .then(bubblePressAndLongPress) + ) { + Column { + replyRef?.let { replyToMsg -> + MessageReplyQuote( + replyTo = replyToMsg, + isAuthor = isAuthor, + currentUserId = currentUserId, + replyScale = replyScale, + replyJumpCd = replyJumpCd, + photoLabel = replyPhotoLabel, + isContextMenuOpen = isContextMenuOpen, + onReplyClick = onReplyClick, + onReplyPressedChange = { replyPressed = it }, + ) + } + + if (isCorrupted) { + Text( + text = corruptedBody, + style = MaterialTheme.typography.bodyMedium, + color = contentColor.copy(alpha = 0.8f), + modifier = Modifier.padding( + horizontal = 12.dp, + vertical = 8.dp, + ) + ) + } else { + val primaryFile = message.files?.firstOrNull() + val primaryIsImage = + primaryFile != null && isImageFilename(primaryFile.name) + val showPrimaryImageSlot = pendingIsImage || primaryIsImage + val showPrimaryFileSlot = pendingHasOutboundFile || + (primaryFile != null && !primaryIsImage) + if (showPrimaryImageSlot) { + val imageKey = imageAttachmentKey(message, 0) + val awaitingServer = + message.id < 0 && message.files.isNullOrEmpty() + val isOutboundPendingImage = + awaitingServer && pendingIsImage + val awaitingServerAck = isOutboundPendingImage && + !uploadFailed && + message.uploadProgress == null + AttachmentPreview( + file = primaryFile, + dmEnvelope = message.dmEnvelope, + currentUserId = currentUserId, + pendingFileUri = message.pendingFileUri, + pendingFilename = message.pendingFilename, + isUploading = + isOutboundPendingImage && !uploadFailed, + awaitingServerAck = awaitingServerAck, + uploadProgress = message.uploadProgress, + uploadError = message.uploadError, + onRetryUpload = onRetryUpload, + fileThumbnail = message.fileThumbnails + ?.firstOrNull() + ?.takeIf { it.isNotBlank() }, + fileAspectRatio = imageAspectRatioForMessage( + fileAspectRatios = message.fileAspectRatios, + fileDimensions = message.fileDimensions, + pendingFileAspectRatio = + message.pendingFileAspectRatio, + fileIndex = 0, + confirmed = message.id > 0, + hasLocalPreview = + DecryptedImageCache + .isDecryptedImageCacheUri( + message.pendingFileUri, + ), + ), + fileSizeBytes = + message.fileSizes?.firstOrNull(), + messageId = message.id, + fileIndex = 0, + clientMessageId = message.client_message_id, + onImageClick = { + onImageClick?.invoke(message, 0) + }, + onImageBounds = if (onImageBounds != null) { + { rect -> + onImageBounds.invoke(imageKey, rect) + } + } else { + null + }, + isExpanded = expandedImageKey != null && + expandedImageKey == imageKey && + !isImageClosing, + isAuthor = isAuthor, + messageLabel = message.content, + onCancelUpload = onCancelUpload, + messageGroup = group, + modifier = if (firstContentIsImage) { + Modifier.padding(all = 2.dp) + } else { + Modifier.padding( + horizontal = 2.dp, + vertical = 4.dp, + ) + } + ) + } + if (showPrimaryFileSlot) { + val awaitingServer = + message.id < 0 && message.files.isNullOrEmpty() + val isOutboundPendingFile = + awaitingServer && pendingHasOutboundFile + val awaitingServerAck = isOutboundPendingFile && + !uploadFailed && + message.uploadProgress == null + AttachmentPreview( + file = primaryFile, + dmEnvelope = message.dmEnvelope, + currentUserId = currentUserId, + pendingFileUri = message.pendingFileUri, + pendingFilename = message.pendingFilename, + isUploading = + isOutboundPendingFile && !uploadFailed, + awaitingServerAck = awaitingServerAck, + uploadProgress = message.uploadProgress, + uploadError = message.uploadError, + onRetryUpload = onRetryUpload, + fileSizeBytes = + message.fileSizes?.firstOrNull(), + messageId = message.id, + fileIndex = 0, + clientMessageId = message.client_message_id, + isAuthor = isAuthor, + messageLabel = message.content, + onCancelUpload = onCancelUpload, + messageGroup = group, + modifier = Modifier.padding( + horizontal = 4.dp, + vertical = 4.dp, + ), + ) + } + message.files?.forEachIndexed { index, file -> + if ( + index == 0 && + showPrimaryImageSlot && + isImageFilename(file.name) + ) { + return@forEachIndexed + } + if ( + index == 0 && + showPrimaryFileSlot && + !isImageFilename(file.name) + ) { + return@forEachIndexed + } + val isImage = isImageFilename(file.name) + val imageKey = + if (isImage) { + imageAttachmentKey(message, index) + } else { + null + } + val isFirstImage = index == 0 && isImage + AttachmentPreview( + file = file, + dmEnvelope = message.dmEnvelope, + currentUserId = currentUserId, + pendingFileUri = + if (index == 0) message.pendingFileUri + else null, + pendingFilename = + if (index == 0) message.pendingFilename + else null, + isUploading = false, + fileThumbnail = message.fileThumbnails + ?.getOrNull(index) + ?.takeIf { it.isNotBlank() }, + fileAspectRatio = imageAspectRatioForMessage( + fileAspectRatios = message.fileAspectRatios, + fileDimensions = message.fileDimensions, + pendingFileAspectRatio = + message.pendingFileAspectRatio, + fileIndex = index, + confirmed = message.id > 0, + hasLocalPreview = index == 0 && + DecryptedImageCache + .isDecryptedImageCacheUri( + message.pendingFileUri, + ), + ), + fileSizeBytes = + message.fileSizes?.getOrNull(index), + messageId = message.id, + fileIndex = index, + clientMessageId = message.client_message_id, + onImageClick = if (isImage) { + { onImageClick?.invoke(message, index) } + } else { + null + }, + onImageBounds = + if ( + isImage && + imageKey != null && + onImageBounds != null + ) { + { rect -> + onImageBounds.invoke(imageKey, rect) + } + } else { + null + }, + isExpanded = isImage && + expandedImageKey != null && + expandedImageKey == imageKey && + !isImageClosing, + isAuthor = isAuthor, + messageLabel = message.content, + onCancelUpload = onCancelUpload, + messageGroup = group, + modifier = if ( + isFirstImage && + firstContentIsImage && + isImage + ) { + Modifier.padding(all = 2.dp) + } else { + Modifier.padding( + horizontal = + if (isImage) 2.dp else 4.dp, + vertical = + if (isImage) 2.dp else 4.dp, + ) + } + ) + } + } + if ( + message.content.isNotBlank() && + !isCorrupted && + !isFilenameOnlyMessageCaption(message) + ) { + Text( + text = message.content, + style = MaterialTheme.typography.bodyLarge, + color = contentColor, + modifier = Modifier.padding(horizontal = 14.dp) + ) + } + } + } + } + } + Row( modifier = Modifier - .padding(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 8.dp), + .graphicsLayer { alpha = timestampAlpha } + .padding( + top = 2.dp, + start = if (isAuthor) 0.dp else 4.dp, + end = if (isAuthor) 4.dp else 0.dp, + ), horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { - if (isPendingOutbound && !sendFailed) { - CircularProgressIndicator( - modifier = Modifier.size(12.dp), - strokeWidth = 1.5.dp, - color = if (isAuthor) { - Color.White.copy(alpha = 0.7f) - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + AnimatedVisibility( + visible = timestampTakesSpace, + enter = fadeIn(tween(200)) + + expandVertically(expandFrom = Alignment.Top), + exit = fadeOut(tween(180)) + + shrinkVertically(shrinkTowards = Alignment.Top), + ) { + // Fixed-height meta row so schedule ↔ time never shifts layout. + Box( + modifier = Modifier.height(16.dp), + contentAlignment = Alignment.CenterEnd, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + if (showScheduleIcon) { + Icon( + imageVector = Icons.Rounded.Schedule, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = metaColor, + ) + } else if (sendFailed) { + Icon( + imageVector = Icons.Rounded.ErrorOutline, + contentDescription = sendFailedLabel, + modifier = Modifier + .size(14.dp) + .semantics { + contentDescription = sendFailedLabel + }, + tint = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = formattedTime, + style = MaterialTheme.typography.labelSmall, + fontSize = 12.sp, + color = metaColor, + ) + } else { + Text( + text = formattedTime, + style = MaterialTheme.typography.labelSmall, + fontSize = 12.sp, + color = metaColor, + ) + } + if (message.is_edited && !showScheduleIcon) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = editedSuffix, + style = MaterialTheme.typography.labelSmall, + fontSize = 12.sp, + color = metaColor, + ) + } } - ) - Spacer(modifier = Modifier.width(6.dp)) - } else if (sendFailed) { - Icon( - imageVector = Icons.Rounded.ErrorOutline, - contentDescription = sendFailedLabel, - modifier = Modifier - .size(14.dp) - .semantics { contentDescription = sendFailedLabel }, - tint = if (isAuthor) { - Color.White.copy(alpha = 0.85f) - } else { - MaterialTheme.colorScheme.error - }, - ) - Spacer(modifier = Modifier.width(6.dp)) - } - Text( - text = formattedTime, - style = MaterialTheme.typography.labelSmall, - fontSize = 11.sp, - color = if (isAuthor) { - Color.White.copy(alpha = 0.7f) - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) } - ) - if (message.is_edited) { - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = editedSuffix, - style = MaterialTheme.typography.labelSmall, - fontSize = 11.sp, - color = if (isAuthor) { - Color.White.copy(alpha = 0.7f) - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) - } - ) } } - } } } } - } } } - } } } +@Composable +private fun MessageReplyQuote( + replyTo: Message, + isAuthor: Boolean, + currentUserId: Int?, + replyScale: Float, + replyJumpCd: String, + photoLabel: String, + isContextMenuOpen: Boolean, + onReplyClick: ((Int) -> Unit)?, + onReplyPressedChange: (Boolean) -> Unit, +) { + val replyName = messageDisplayUsername(replyTo, currentUserId) + val replyTapEnabled = onReplyClick != null && replyTo.id > 0 + val hasImage = replyTo.files?.firstOrNull()?.let { isImageFilename(it.name) } == true || + replyTo.fileThumbnails?.firstOrNull()?.isNotBlank() == true + val previewText = when { + replyTo.content.isNotBlank() -> + replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "" + hasImage -> photoLabel + else -> replyTo.content + } + val onPrimary = MaterialTheme.colorScheme.onPrimary + val accent = if (isAuthor) onPrimary.copy(alpha = 0.7f) else MaterialTheme.colorScheme.primary + val nameColor = if (isAuthor) onPrimary else MaterialTheme.colorScheme.primary + val previewColor = + if (isAuthor) onPrimary.copy(alpha = 0.75f) else MaterialTheme.colorScheme.onSurfaceVariant + val quoteBg = + if (isAuthor) { + onPrimary.copy(alpha = 0.18f) + } else { + MaterialTheme.colorScheme.surfaceVariant + } + + val replyPressModifier = + if (replyTapEnabled && !isContextMenuOpen) { + Modifier.pointerInput(replyTo.id, isContextMenuOpen) { + detectTapGestures( + onPress = { + onReplyPressedChange(true) + try { + awaitRelease() + } finally { + onReplyPressedChange(false) + } + }, + onTap = { onReplyClick.invoke(replyTo.id) }, + ) + } + } else { + Modifier + } + + Box( + 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 + .clip(RoundedCornerShape(12.dp)) + .background(quoteBg) + .height(IntrinsicSize.Min), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier + .background(accent) + .width(3.dp) + .fillMaxHeight() + ) + Column( + Modifier.padding(horizontal = 8.dp, vertical = 6.dp) + ) { + Text( + text = replyName, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = nameColor, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = previewText, + style = MaterialTheme.typography.bodySmall, + color = previewColor, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageListUi.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageListUi.kt new file mode 100644 index 0000000..a7d75e2 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageListUi.kt @@ -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(null) + val currentItem: StateFlow = _currentItem.asStateFlow() + private val _pendingNewMessageKeys = MutableStateFlow(setOf()) + val pendingNewMessageKeys: StateFlow> = _pendingNewMessageKeys.asStateFlow() + private val _queuedEnter = MutableStateFlow(null) + val queuedEnter: StateFlow = _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] oldest→newest, inserts date separators, computes grouping, + * then returns items newest→oldest for `LazyColumn(reverseLayout = true)`. + */ +fun buildChatListItems( + messages: List, + dateLabel: (LocalDate) -> String, +): List { + 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, 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 = 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 + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt index f21321c..ca400e6 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt @@ -438,6 +438,7 @@ class DmPanel( ) val merged = confirmed.copy( + client_message_id = cid.ifEmpty { confirmed.client_message_id }, uploadJobId = null, uploadProgress = null, pendingFileUri = if (isImageAttachment) localPreviewUri ?: localUri else null, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentImageGeometry.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentImageGeometry.kt index e9cf37c..ba91f9c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentImageGeometry.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentImageGeometry.kt @@ -4,9 +4,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.unit.dp import ru.fromchat.api.local.db.aspectRatioFromDimensionPair import ru.fromchat.api.local.download.ChatPreviewDecodeSize - -/** Bubble top radius (must match [ru.fromchat.ui.chat.MessageItem] bubble shape). */ -private val BUBBLE_TOP = 20.dp +import ru.fromchat.ui.chat.MessageGroupInfo +import ru.fromchat.ui.chat.bubbleTopRadii /** Padding between bubble edge and attachment image (must match MessageItem image padding). */ 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 /** Inner clip: top corners follow bubble minus inset; bottom corners lightly rounded. */ -@Suppress("UNUSED_PARAMETER") -internal fun attachmentImageCornerShape(isAuthor: Boolean): RoundedCornerShape { +internal fun attachmentImageCornerShape( + isAuthor: Boolean, + group: MessageGroupInfo = MessageGroupInfo( + hasSameAuthorAbove = false, + hasSameAuthorBelow = false, + ), +): RoundedCornerShape { val inset = ATTACHMENT_IMAGE_INSET + val (topStart, topEnd) = bubbleTopRadii(isAuthor, group) return RoundedCornerShape( - topStart = (BUBBLE_TOP - inset).coerceAtLeast(0.dp), - topEnd = (BUBBLE_TOP - inset).coerceAtLeast(0.dp), + topStart = (topStart - inset).coerceAtLeast(0.dp), + topEnd = (topEnd - inset).coerceAtLeast(0.dp), bottomStart = IMAGE_BOTTOM_CORNER, bottomEnd = IMAGE_BOTTOM_CORNER, ) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageListDedup.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageListDedup.kt index 2ab5651..6b12bc1 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageListDedup.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageListDedup.kt @@ -33,7 +33,13 @@ internal fun messageDedupeKey(msg: Message): String { 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( messages: List, currentUserId: Int?, @@ -46,6 +52,8 @@ internal fun dropSupersededOptimisticMessages( if (msg.id >= 0) return@filter true val cid = msg.client_message_id?.trim().orEmpty() 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. if (msg.pendingFileUri != null || !msg.uploadJobId.isNullOrBlank()) return@filter true if (self == null || msg.user_id != self) return@filter true diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageUiMerge.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageUiMerge.kt index 5ca9d4d..a2b6300 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageUiMerge.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageUiMerge.kt @@ -26,6 +26,7 @@ internal fun mergeDatabaseMessagesWithPanelState( val mergedClientIds = mergedDb.mapNotNull { it.client_message_id?.trim()?.takeIf { id -> id.isNotEmpty() } }.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 cid = panel.client_message_id?.trim()?.takeIf { it.isNotEmpty() } when { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/utils/LastSeenFormat.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/utils/LastSeenFormat.kt index e9f083e..3b1e525 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/utils/LastSeenFormat.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/utils/LastSeenFormat.kt @@ -10,6 +10,7 @@ import kotlinx.datetime.number import kotlinx.datetime.toLocalDateTime import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res +import ru.fromchat.api.local.messages.parseMessageInstant import ru.fromchat.month_apr import ru.fromchat.month_aug import ru.fromchat.month_dec @@ -131,7 +132,7 @@ fun rememberLastSeenFormatStrings(): LastSeenFormatStrings { fun formatLastSeen(online: Boolean, lastSeenIso: String?, s: LastSeenFormatStrings): String { if (online) return s.online 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 val timeZone = TimeZone.currentSystemDefault() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/utils/ProfileRegistrationDateFormat.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/utils/ProfileRegistrationDateFormat.kt index 24bbe0e..9e6811c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/utils/ProfileRegistrationDateFormat.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/utils/ProfileRegistrationDateFormat.kt @@ -9,6 +9,7 @@ import kotlinx.datetime.number import kotlinx.datetime.toLocalDateTime import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res +import ru.fromchat.api.local.messages.parseMessageInstant import ru.fromchat.month_name_apr import ru.fromchat.month_name_aug 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.profile_registration_date import kotlin.time.ExperimentalTime -import kotlin.time.Instant private fun formatFromXmlTemplate(template: String, vararg args: Any): String { var result = template @@ -69,7 +69,7 @@ fun rememberRegistrationDateFormatStrings(): RegistrationDateFormatStrings { @OptIn(ExperimentalTime::class) private fun parseRegistrationLocalDate(iso: String): LocalDate? { - runCatching { Instant.parse(iso) }.getOrNull()?.let { + parseMessageInstant(iso)?.let { return it.toLocalDateTime(TimeZone.currentSystemDefault()).date } runCatching { LocalDateTime.parse(iso).date }.getOrNull()?.let { return it }