Fix animation sometimes running on chat open

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-10 11:28:21 +03:00
Unverified
parent ff2f6e4e45
commit 7b04d31fd3
2 changed files with 58 additions and 49 deletions
@@ -211,41 +211,55 @@ fun ChatScreen(
var hiddenDefaultTimestampKeys by rememberSaveable(panelId) { var hiddenDefaultTimestampKeys by rememberSaveable(panelId) {
mutableStateOf(setOf<String>()) mutableStateOf(setOf<String>())
} }
var lastAnimatedMessageKeys by rememberSaveable(panelId) { // Visit-scoped (not saveable): reopen must re-seed history, never replay enter.
mutableStateOf(setOf<String>()) var lastAnimatedMessageKeys by remember(panelId) { mutableStateOf(setOf<String>()) }
} var knownEnterIdentities by remember(panelId) { mutableStateOf(setOf<String>()) }
val enterCoordinator = remember(panelId) { MessageEnterCoordinator(scope) } val enterCoordinator = remember(panelId) { MessageEnterCoordinator(scope) }
val activeEnterAnimation by enterCoordinator.currentItem.collectAsState() val activeEnterAnimation by enterCoordinator.currentItem.collectAsState()
val pendingNewMessageKeys by enterCoordinator.pendingNewMessageKeys.collectAsState() val pendingNewMessageKeys by enterCoordinator.pendingNewMessageKeys.collectAsState()
val queuedEnter by enterCoordinator.queuedEnter.collectAsState() val queuedEnter by enterCoordinator.queuedEnter.collectAsState()
var previousNewestFingerprint by rememberSaveable(panelId) { mutableStateOf("") } var previousNewestFingerprint by remember(panelId) { mutableStateOf("") }
var previousEnterMessageCount by rememberSaveable(panelId) { mutableIntStateOf(0) } var previousEnterMessageCount by remember(panelId) { mutableIntStateOf(0) }
var enterAnimationsSeeded by rememberSaveable(panelId) { mutableStateOf(false) } var enterAnimationsSeeded by remember(panelId) { mutableStateOf(false) }
LaunchedEffect(panelState.messages) { LaunchedEffect(panelState.messages) {
val messages = panelState.messages val messages = panelState.messages
val newest = messages.lastOrNull() val newest = messages.lastOrNull()
if (newest == null) { if (newest == null) {
if (!enterAnimationsSeeded) { // Always reset so the next hydrate is treated as a fresh seed, not a +N append.
previousNewestFingerprint = "" previousNewestFingerprint = ""
previousEnterMessageCount = 0 previousEnterMessageCount = 0
}
return@LaunchedEffect return@LaunchedEffect
} }
val newestKey = messageListKey(newest) val newestKey = messageListKey(newest)
val newestIdentity = messageEnterIdentity(newest)
val fingerprint = "$newestKey|${messages.size}" val fingerprint = "$newestKey|${messages.size}"
if (fingerprint == previousNewestFingerprint) return@LaunchedEffect if (fingerprint == previousNewestFingerprint) return@LaunchedEffect
val previousFingerprint = previousNewestFingerprint val previousFingerprint = previousNewestFingerprint
val previousCount = previousEnterMessageCount val previousCount = previousEnterMessageCount
val sizeDelta = messages.size - previousCount val sizeDelta = messages.size - previousCount
val presentKeys = messages.mapTo(mutableSetOf()) { messageListKey(it) }
val presentIdentities = messages.mapTo(mutableSetOf()) { messageEnterIdentity(it) }
val newIdentities = presentIdentities - knownEnterIdentities
// First non-empty load (or reopen before seed): never animate existing history. fun seedWithoutAnimating(reason: String) {
if (!enterAnimationsSeeded || previousFingerprint.isEmpty()) { Logger.d(
lastAnimatedMessageKeys = messages.map { messageListKey(it) }.toSet() "EnterAnim",
"$reason newestKey=${newestKey.take(12)} sizeDelta=$sizeDelta " +
"count=${messages.size} newIdentities=${newIdentities.size} " +
"newestId=${newest.id}",
)
lastAnimatedMessageKeys = lastAnimatedMessageKeys + presentKeys
knownEnterIdentities = knownEnterIdentities + presentIdentities
previousNewestFingerprint = fingerprint previousNewestFingerprint = fingerprint
previousEnterMessageCount = messages.size previousEnterMessageCount = messages.size
enterAnimationsSeeded = true enterAnimationsSeeded = true
}
// First non-empty load for this visit: never animate existing history.
if (!enterAnimationsSeeded || previousFingerprint.isEmpty()) {
seedWithoutAnimating("seed_first_load")
return@LaunchedEffect return@LaunchedEffect
} }
@@ -255,20 +269,19 @@ fun ChatScreen(
val previousNewestKey = previousFingerprint.substringBefore('|') val previousNewestKey = previousFingerprint.substringBefore('|')
// History prepend / cache hydration: newest unchanged, older rows appeared. // History prepend / cache hydration: newest unchanged, older rows appeared.
if (newestKey == previousNewestKey) { if (newestKey == previousNewestKey) {
Logger.d( seedWithoutAnimating("skip_newest_unchanged")
"EnterAnim",
"skip_newest_unchanged newestKey=${newestKey.take(12)} " +
"sizeDelta=$sizeDelta count=${messages.size}",
)
lastAnimatedMessageKeys =
lastAnimatedMessageKeys + messages.map { messageListKey(it) }
return@LaunchedEffect return@LaunchedEffect
} }
// Transient shrink (optimistic briefly missing from a DB sync): do not seed // In-place remap (timestamp / client id attach) — same count, new list key.
// lastAnimated. Drop keys for rows that left so a restore can re-enqueue enter. if (sizeDelta == 0) {
seedWithoutAnimating("skip_remap")
return@LaunchedEffect
}
// Transient shrink: keep identities; drop list keys that left so a restore
// can re-enqueue only if the identity is truly new (it won't be).
if (sizeDelta < 0) { if (sizeDelta < 0) {
val presentKeys = messages.mapTo(mutableSetOf()) { messageListKey(it) }
lastAnimatedMessageKeys = lastAnimatedMessageKeys.intersect(presentKeys) lastAnimatedMessageKeys = lastAnimatedMessageKeys.intersect(presentKeys)
knownEnterIdentities = knownEnterIdentities.intersect(presentIdentities)
Logger.d( Logger.d(
"EnterAnim", "EnterAnim",
"skip_shrink newestKey=${newestKey.take(12)} sizeDelta=$sizeDelta " + "skip_shrink newestKey=${newestKey.take(12)} sizeDelta=$sizeDelta " +
@@ -276,42 +289,25 @@ fun ChatScreen(
) )
return@LaunchedEffect return@LaunchedEffect
} }
// Bulk replace / multi-message sync — seed, don't animate. // Bulk replace / multi-message sync / hydrate jump — seed, don't animate.
if (sizeDelta > 1) { if (sizeDelta > 1 || newIdentities.size != 1) {
Logger.d( seedWithoutAnimating("skip_bulk_or_multi_new")
"EnterAnim",
"skip_bulk_delta newestKey=${newestKey.take(12)} sizeDelta=$sizeDelta " +
"count=${messages.size} newestId=${newest.id}",
)
lastAnimatedMessageKeys =
lastAnimatedMessageKeys + messages.map { messageListKey(it) }
return@LaunchedEffect return@LaunchedEffect
} }
if (newestKey in lastAnimatedMessageKeys) return@LaunchedEffect // Exactly one new identity, but it isn't the newest row (insert/reorder).
// Confirm may briefly change key shape; don't re-animate the same send. if (newestIdentity !in newIdentities) {
val newestCid = newest.client_message_id?.trim().orEmpty() seedWithoutAnimating("skip_new_not_newest")
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 return@LaunchedEffect
} }
if (newest.id > 0 && lastAnimatedMessageKeys.any { it.startsWith("i:${newest.id}:") }) { if (newestKey in lastAnimatedMessageKeys || newestIdentity in knownEnterIdentities) {
Logger.d( seedWithoutAnimating("skip_already_known")
"EnterAnim",
"skip_id_already_animated newestKey=${newestKey.take(12)} id=${newest.id}",
)
lastAnimatedMessageKeys = lastAnimatedMessageKeys + newestKey
return@LaunchedEffect return@LaunchedEffect
} }
val previous = messages.getOrNull(messages.lastIndex - 1) val previous = messages.getOrNull(messages.lastIndex - 1)
val mode = classifyEnterMode(previous, newest) val mode = classifyEnterMode(previous, newest)
if (mode == EnterMode.None) { if (mode == EnterMode.None) {
lastAnimatedMessageKeys = lastAnimatedMessageKeys + newestKey seedWithoutAnimating("skip_mode_none")
return@LaunchedEffect return@LaunchedEffect
} }
Logger.d( Logger.d(
@@ -321,6 +317,7 @@ fun ChatScreen(
"sizeDelta=$sizeDelta newestId=${newest.id}", "sizeDelta=$sizeDelta newestId=${newest.id}",
) )
lastAnimatedMessageKeys = lastAnimatedMessageKeys + newestKey lastAnimatedMessageKeys = lastAnimatedMessageKeys + newestKey
knownEnterIdentities = knownEnterIdentities + newestIdentity
enterCoordinator.enqueue( enterCoordinator.enqueue(
PendingEnter( PendingEnter(
newMessageKey = newestKey, newMessageKey = newestKey,
@@ -160,6 +160,18 @@ internal fun messageListKey(message: Message): String {
return if (cid.isNotEmpty()) "c:$cid" else "i:${message.id}:${message.timestamp}" return if (cid.isNotEmpty()) "c:$cid" else "i:${message.id}:${message.timestamp}"
} }
/**
* Stable identity for enter-animation dedupe. Survives list-key remaps
* (timestamp tweaks, optimistic → confirmed) so reopen/hydration never
* re-animates an already-seen message.
*/
internal fun messageEnterIdentity(message: Message): String {
val cid = message.client_message_id?.trim().orEmpty()
if (cid.isNotEmpty()) return "c:$cid"
if (message.id > 0) return "i:${message.id}"
return "t:${message.id}:${message.timestamp}"
}
internal fun timestampGroupKey(message: Message): String { internal fun timestampGroupKey(message: Message): String {
val cid = message.client_message_id?.trim().orEmpty() val cid = message.client_message_id?.trim().orEmpty()
return if (cid.isNotEmpty()) "c:$cid" else "i:${message.id}" return if (cid.isNotEmpty()) "c:$cid" else "i:${message.id}"