mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Redesign the chat interface
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -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">Can’t show this message</string>
|
||||
<string name="attachment_image_load_failed">Failed to load</string>
|
||||
<string name="attachment_upload_failed">Couldn\'t send file</string>
|
||||
|
||||
+42
-5
@@ -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,11 +484,18 @@ abstract class ChatPanel(
|
||||
runCatching { persistOptimisticMessage(optimistic) }
|
||||
}
|
||||
|
||||
// Actually send the message
|
||||
// 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)
|
||||
// Message sent successfully - will be updated when WebSocket confirms
|
||||
} catch (error: Exception) {
|
||||
} catch (_: Exception) {
|
||||
removeMessageByClientMessageId(tempId)
|
||||
pendingMessages.remove(tempId)
|
||||
timeoutJob.cancel()
|
||||
@@ -478,6 +504,7 @@ abstract class ChatPanel(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun uniqueOptimisticMessageId(): Int = addMessageMutex.withLock {
|
||||
var id: Int
|
||||
|
||||
@@ -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,25 +912,137 @@ fun ChatScreen(
|
||||
.hazeSource(hazeState),
|
||||
userScrollEnabled = !contextMenuState.isOpen,
|
||||
reverseLayout = true,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) }
|
||||
item {
|
||||
Spacer(
|
||||
Modifier.height(
|
||||
innerPadding.calculateBottomPadding() + 12.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
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}"
|
||||
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
|
||||
}
|
||||
) { message ->
|
||||
var tapPositionInRoot by remember { mutableStateOf(IntOffset(0, 0)) }
|
||||
|
||||
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
|
||||
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
|
||||
@@ -793,7 +1062,8 @@ fun ChatScreen(
|
||||
)
|
||||
},
|
||||
onTapPosition = { offset ->
|
||||
tapPositionInRoot = IntOffset(offset.x.toInt(), offset.y.toInt())
|
||||
tapPositionInRoot =
|
||||
IntOffset(offset.x.toInt(), offset.y.toInt())
|
||||
},
|
||||
onUsernameClick =
|
||||
if (panel.supportsNavigateToSenderProfile &&
|
||||
@@ -801,8 +1071,12 @@ fun ChatScreen(
|
||||
message.user_id > 0
|
||||
) {
|
||||
{
|
||||
ProfileCache.mergePreviewFromPublicMessage(message)
|
||||
navController.navigate("profile/${message.user_id}")
|
||||
ProfileCache.mergePreviewFromPublicMessage(
|
||||
message,
|
||||
)
|
||||
navController.navigate(
|
||||
"profile/${message.user_id}",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
@@ -825,10 +1099,13 @@ fun ChatScreen(
|
||||
val cid = msg.client_message_id?.trim().orEmpty()
|
||||
if (cid.isNotEmpty()) {
|
||||
panel.updateMessageByClientMessageId(cid) {
|
||||
it.copy(uploadError = null, uploadProgress = 0)
|
||||
it.copy(
|
||||
uploadError = null,
|
||||
uploadProgress = 0,
|
||||
)
|
||||
}
|
||||
|
||||
OutgoingMessageCoordinator.retryDmAttachmentUpload(cid)
|
||||
OutgoingMessageCoordinator
|
||||
.retryDmAttachmentUpload(cid)
|
||||
}
|
||||
},
|
||||
onReplyClick = scrollToChatMessage,
|
||||
@@ -836,8 +1113,10 @@ fun ChatScreen(
|
||||
highlightFading = highlightFading,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(floatingHeaderClearance)) }
|
||||
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] oldest→newest, inserts date separators, computes grouping,
|
||||
* then returns items newest→oldest 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,
|
||||
|
||||
+12
-7
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user