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