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 }