Redesign the chat interface

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-10 11:17:45 +03:00
Unverified
parent 206acd712a
commit ff2f6e4e45
15 changed files with 1695 additions and 651 deletions
@@ -117,6 +117,9 @@
<string name="message_edited_suffix">(изменено)</string>
<string name="message_replying_to">Ответ %1$s</string>
<string name="message_reply_jump_cd">Перейти к цитируемому сообщению</string>
<string name="message_reply_photo">Фото</string>
<string name="chat_date_today">Сегодня</string>
<string name="chat_date_yesterday">Вчера</string>
<string name="message_corrupted_short">Сообщение не показывается</string>
<string name="attachment_image_load_failed">Не удалось загрузить</string>
<string name="attachment_upload_failed">Не удалось отправить файл</string>
@@ -129,6 +129,9 @@
<string name="message_edited_suffix">(edited)</string>
<string name="message_replying_to">Reply to %1$s</string>
<string name="message_reply_jump_cd">Jump to quoted message</string>
<string name="message_reply_photo">Photo</string>
<string name="chat_date_today">Today</string>
<string name="chat_date_yesterday">Yesterday</string>
<string name="message_corrupted_short">Cant show this message</string>
<string name="attachment_image_load_failed">Failed to load</string>
<string name="attachment_upload_failed">Couldn\'t send file</string>
@@ -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)
@@ -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)
@@ -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) }
}
}
}
}
@@ -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<String>())
}
var hiddenDefaultTimestampKeys by rememberSaveable(panelId) {
mutableStateOf(setOf<String>())
}
var lastAnimatedMessageKeys by rememberSaveable(panelId) {
mutableStateOf(setOf<String>())
}
val enterCoordinator = remember(panelId) { MessageEnterCoordinator(scope) }
val activeEnterAnimation by enterCoordinator.currentItem.collectAsState()
val pendingNewMessageKeys by enterCoordinator.pendingNewMessageKeys.collectAsState()
val queuedEnter by enterCoordinator.queuedEnter.collectAsState()
var previousNewestFingerprint by rememberSaveable(panelId) { mutableStateOf("") }
var previousEnterMessageCount by rememberSaveable(panelId) { mutableIntStateOf(0) }
var enterAnimationsSeeded by rememberSaveable(panelId) { mutableStateOf(false) }
LaunchedEffect(panelState.messages) {
val messages = panelState.messages
val 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,
@@ -0,0 +1,69 @@
package ru.fromchat.ui.chat
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
internal val BUBBLE_RADIUS_LARGE = 20.dp
internal val BUBBLE_RADIUS_SMALL = 4.dp
@Composable
internal fun rememberAnimatedBubbleShape(
isAuthor: Boolean,
group: MessageGroupInfo,
): RoundedCornerShape {
val large = BUBBLE_RADIUS_LARGE
val small = BUBBLE_RADIUS_SMALL
// Spec (outgoing / bubble-local): start always large; end depends on neighbors.
// Incoming: mirror so the tail sits on the screen-edge (start) side.
val topStartTarget: Dp
val topEndTarget: Dp
val bottomStartTarget: Dp
val bottomEndTarget: Dp
if (isAuthor) {
topStartTarget = large
bottomStartTarget = large
topEndTarget = if (group.hasSameAuthorAbove) small else large
bottomEndTarget = if (group.hasSameAuthorBelow) small else large
} else {
topEndTarget = large
bottomEndTarget = large
topStartTarget = if (group.hasSameAuthorAbove) small else large
bottomStartTarget = if (group.hasSameAuthorBelow) small else large
}
val springSpec = spring<Dp>(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium,
)
val topStart by animateDpAsState(topStartTarget, springSpec, label = "bubbleTopStart")
val topEnd by animateDpAsState(topEndTarget, springSpec, label = "bubbleTopEnd")
val bottomStart by animateDpAsState(bottomStartTarget, springSpec, label = "bubbleBottomStart")
val bottomEnd by animateDpAsState(bottomEndTarget, springSpec, label = "bubbleBottomEnd")
return RoundedCornerShape(
topStart = topStart,
topEnd = topEnd,
bottomStart = bottomStart,
bottomEnd = bottomEnd,
)
}
internal fun bubbleTopRadii(
isAuthor: Boolean,
group: MessageGroupInfo,
): Pair<Dp, Dp> {
val large = BUBBLE_RADIUS_LARGE
val small = BUBBLE_RADIUS_SMALL
return if (isAuthor) {
large to if (group.hasSameAuthorAbove) small else large
} else {
(if (group.hasSameAuthorAbove) small else large) to large
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,296 @@
package ru.fromchat.ui.chat
import androidx.compose.runtime.Immutable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import ru.fromchat.Logger
import ru.fromchat.api.local.messages.parseMessageInstant
import ru.fromchat.api.schema.messages.Message
import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
@Immutable
data class MessageGroupInfo(
val hasSameAuthorAbove: Boolean,
val hasSameAuthorBelow: Boolean,
) {
val isFirstInGroup: Boolean get() = !hasSameAuthorAbove
val isLastInGroup: Boolean get() = !hasSameAuthorBelow
}
sealed interface ChatListItem {
data class DateSeparator(
val label: String,
val epochDay: Long,
) : ChatListItem
data class MessageRow(
val message: Message,
val group: MessageGroupInfo,
/** Spacing above this row in the visual (chronological) stack. */
val spacingAbove: Dp,
) : ChatListItem
}
enum class EnterMode {
ExtendGroup,
NewGroup,
NewDay,
FirstMessage,
None,
}
enum class EnterAnimationRole {
None,
PreviousLast,
NewMessage,
NewDateSeparator,
}
data class ActiveEnterAnimation(
val newMessageKey: String,
val previousMessageKey: String?,
val mode: EnterMode,
val newDateSeparatorEpochDay: Long? = null,
)
data class PendingEnter(
val newMessageKey: String,
val previousMessageKey: String?,
val mode: EnterMode,
val newDateSeparatorEpochDay: Long? = null,
)
/** Shared 500ms pacing for outbound network sends (not UI display). */
object MessageRateLimiter {
private const val MIN_INTERVAL_MS = 500L
private var nextSlotAtMs: Long = 0L
private val mutex = Mutex()
/**
* Reserves the next send slot and waits if needed.
* Does **not** hold [mutex] while delaying, so callers don't stack waits.
*/
suspend fun awaitSlot() {
val wait = mutex.withLock {
val now = Clock.System.now().toEpochMilliseconds()
val waitMs = (nextSlotAtMs - now).coerceAtLeast(0L)
nextSlotAtMs = now + waitMs + MIN_INTERVAL_MS
waitMs
}
if (wait > 0L) delay(wait)
}
}
/**
* Publishes enter roles immediately. Hold window is visual only never rate-limits.
*/
class MessageEnterCoordinator(
private val scope: CoroutineScope,
) {
private val _currentItem = MutableStateFlow<ActiveEnterAnimation?>(null)
val currentItem: StateFlow<ActiveEnterAnimation?> = _currentItem.asStateFlow()
private val _pendingNewMessageKeys = MutableStateFlow(setOf<String>())
val pendingNewMessageKeys: StateFlow<Set<String>> = _pendingNewMessageKeys.asStateFlow()
private val _queuedEnter = MutableStateFlow<PendingEnter?>(null)
val queuedEnter: StateFlow<PendingEnter?> = _queuedEnter.asStateFlow()
private var activeJob: Job? = null
private var activeGeneration = 0
fun enqueue(entry: PendingEnter) {
val generation = ++activeGeneration
val previousKey = _queuedEnter.value?.newMessageKey
val cancelledPrev = activeJob?.isActive == true
Logger.d(
"EnterAnim",
"enqueue gen=$generation newKey=${entry.newMessageKey.take(12)} " +
"prevKey=${entry.previousMessageKey?.take(12)} mode=${entry.mode} " +
"cancelledPrev=$cancelledPrev prevQueued=${previousKey?.take(12)} " +
"pendingCount=${_pendingNewMessageKeys.value.size}",
)
_pendingNewMessageKeys.value =
_pendingNewMessageKeys.value - (previousKey ?: "") + entry.newMessageKey
_queuedEnter.value = entry
val active = ActiveEnterAnimation(
newMessageKey = entry.newMessageKey,
previousMessageKey = entry.previousMessageKey,
mode = entry.mode,
newDateSeparatorEpochDay = entry.newDateSeparatorEpochDay,
)
_currentItem.value = active
activeJob?.cancel()
activeJob = scope.launch {
try {
delay(450.milliseconds)
} finally {
val superseded = generation != activeGeneration
Logger.d(
"EnterAnim",
"hold_end gen=$generation newKey=${entry.newMessageKey.take(12)} " +
"superseded=$superseded",
)
_pendingNewMessageKeys.value =
_pendingNewMessageKeys.value - entry.newMessageKey
if (superseded) return@launch
if (_currentItem.value == active) {
_currentItem.value = null
}
if (_queuedEnter.value?.newMessageKey == entry.newMessageKey) {
_queuedEnter.value = null
}
}
}
}
}
internal fun messageListKey(message: Message): String {
val cid = message.client_message_id?.trim().orEmpty()
return if (cid.isNotEmpty()) "c:$cid" else "i:${message.id}:${message.timestamp}"
}
internal fun timestampGroupKey(message: Message): String {
val cid = message.client_message_id?.trim().orEmpty()
return if (cid.isNotEmpty()) "c:$cid" else "i:${message.id}"
}
internal fun messageLocalDate(message: Message): LocalDate? =
parseMessageInstant(message.timestamp)
?.toLocalDateTime(TimeZone.currentSystemDefault())
?.date
/**
* Walks [messages] oldestnewest, inserts date separators, computes grouping,
* then returns items newestoldest for `LazyColumn(reverseLayout = true)`.
*/
fun buildChatListItems(
messages: List<Message>,
dateLabel: (LocalDate) -> String,
): List<ChatListItem> {
if (messages.isEmpty()) return emptyList()
val chronological = buildList {
var previousDate: LocalDate? = null
var previousUserId: Int? = null
messages.forEachIndexed { index, message ->
val date = messageLocalDate(message)
val dateChanged = date != null && date != previousDate
if (dateChanged) {
add(
ChatListItem.DateSeparator(
label = dateLabel(date),
epochDay = date.toEpochDays(),
),
)
}
val next = messages.getOrNull(index + 1)
val nextDate = next?.let { messageLocalDate(it) }
val hasSameAuthorAbove =
previousUserId == message.user_id &&
previousDate != null &&
date != null &&
previousDate == date &&
!dateChanged
val hasSameAuthorBelow =
next != null &&
next.user_id == message.user_id &&
date != null &&
nextDate != null &&
date == nextDate
val spacingAbove = when {
index == 0 && !dateChanged -> 0.dp
dateChanged -> 0.dp
hasSameAuthorAbove -> 1.dp
else -> 10.dp
}
add(
ChatListItem.MessageRow(
message = message,
group = MessageGroupInfo(
hasSameAuthorAbove = hasSameAuthorAbove,
hasSameAuthorBelow = hasSameAuthorBelow,
),
spacingAbove = spacingAbove,
),
)
previousDate = date ?: previousDate
previousUserId = message.user_id
}
}
return chronological.asReversed()
}
/** LazyColumn index for a message (index 0 = bottom spacer). */
fun lazyIndexForMessageId(listItems: List<ChatListItem>, messageId: Int): Int? {
val itemIndex = listItems.indexOfFirst {
it is ChatListItem.MessageRow && it.message.id == messageId
}
return if (itemIndex == -1) null else 1 + itemIndex
}
fun classifyEnterMode(
previous: Message?,
newest: Message,
): EnterMode {
if (previous == null) return EnterMode.FirstMessage
val prevDate = messageLocalDate(previous)
val newDate = messageLocalDate(newest)
if (prevDate == null || newDate == null || prevDate != newDate) return EnterMode.NewDay
return if (previous.user_id == newest.user_id) EnterMode.ExtendGroup else EnterMode.NewGroup
}
fun resolveMessageEnterRole(
messageKey: String,
active: ActiveEnterAnimation?,
pendingNewMessageKeys: Set<String> = emptySet(),
queuedEnter: PendingEnter? = null,
): EnterAnimationRole {
// New message: pending or active/queued.
if (
messageKey in pendingNewMessageKeys ||
messageKey == active?.newMessageKey ||
messageKey == queuedEnter?.newMessageKey
) {
return EnterAnimationRole.NewMessage
}
// PreviousLast only from the coordinator (active/queued), never composition-only,
// so the timestamp fade starts together with the new-bubble spring.
val previousKey = active?.previousMessageKey ?: queuedEnter?.previousMessageKey
val mode = active?.mode ?: queuedEnter?.mode
if (mode == EnterMode.ExtendGroup && messageKey == previousKey) {
return EnterAnimationRole.PreviousLast
}
return EnterAnimationRole.None
}
fun resolveDateSeparatorEnterRole(
epochDay: Long,
active: ActiveEnterAnimation?,
): EnterAnimationRole {
if (active == null) return EnterAnimationRole.None
return if (
active.mode == EnterMode.NewDay &&
active.newDateSeparatorEpochDay == epochDay
) {
EnterAnimationRole.NewDateSeparator
} else {
EnterAnimationRole.None
}
}
@@ -438,6 +438,7 @@ class DmPanel(
)
val merged = confirmed.copy(
client_message_id = cid.ifEmpty { confirmed.client_message_id },
uploadJobId = null,
uploadProgress = null,
pendingFileUri = if (isImageAttachment) localPreviewUri ?: localUri else null,
@@ -4,9 +4,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.unit.dp
import 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,
)
@@ -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<Message>,
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
@@ -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 {
@@ -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()
@@ -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 }