fix(ui): prevent animation flicker when backfilling message history

- Remove AnimatedVisibility wrapper from navigation bar and replace with conditional if statement
- Add comment explaining why animations are deliberately avoided on screen swaps to prevent layout thrashing
- Update MessageArrivalTracker.arrivals() to filter out backfilled messages from arrival detection
- Add lastKnownIndex tracking to distinguish between backfilled history and new messages
- Update filter logic to only report messages newer than the last known message as arrivals
- Add comprehensive test coverage for backfill scenarios: messages loaded behind latest and new arrivals alongside backfill
- Prevents transcript flicker on short conversations by not animating backfilled history rows
This commit is contained in:
SammyZi 2026-09-11 12:15:42 +05:30
parent b9f87eba9e
commit 357e7f65ee
3 changed files with 42 additions and 11 deletions

View File

@ -1,11 +1,6 @@
package com.bitchat.android.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -105,11 +100,11 @@ fun MainScaffold(viewModel: ChatViewModel, modifier: Modifier = Modifier) {
// so the Scaffold must not pad the top. The bottom bar still reports its own height.
contentWindowInsets = WindowInsets(0),
bottomBar = {
AnimatedVisibility(
visible = !inConversation,
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut()
) {
// Shown or hidden in the same frame the screen swaps, deliberately not animated. A
// slide/fade AnimatedVisibility keeps its full layout slot for the whole exit and only
// then collapses to zero, so the conversation opened above a blank strip and lurched
// down by the bar's height a few hundred milliseconds later.
if (!inConversation) {
BitchatNavigationBar(
viewModel = viewModel,
selected = tab,

View File

@ -183,8 +183,18 @@ internal fun MessageArrivalTracker.arrivals(messages: List<BitchatMessage>): Set
val isWholesaleReplacement =
messages.isNotEmpty() && known.isNotEmpty() && messages.none { it.id in known }
// History backfill inserts messages *older* than ones already on screen: opening a DM shows its
// compacted summary (the latest message only), then loads the stored history in behind it.
// Those rows are not arrivals - only messages after the newest already-known one are. Without
// this, a short conversation's whole history (anything under the burst cap) slid in on every
// open, which read as the transcript flickering.
val lastKnownIndex = messages.indexOfLast { it.id in known }
// `HashSet.add` reports whether the id was new, so this both diffs and updates in one pass.
val added = messages.filter { known.add(it.id) }
// The add runs first so backfilled ids are still recorded as known.
val added = messages.filterIndexed { index, message ->
known.add(message.id) && index > lastKnownIndex
}
if (known.size > messages.size) {
// Messages disappeared (/clear, channel switch). Drop the stale ids so the set cannot

View File

@ -171,6 +171,32 @@ class MessageArrivalTrackerTest {
assertEquals(setOf("y"), tracker.arrivals(switched))
}
@Test
fun `history backfilled behind the latest message is adopted silently`() {
// Opening a DM shows its compacted summary (just the latest message), then the stored
// history loads in behind it. Those older rows are not arrivals; animating them made the
// whole transcript flicker on every open of a short conversation.
val tracker = MessageArrivalTracker()
val latest = msg("latest")
tracker.arrivals(listOf(latest))
val backfilled = listOf(msg("h1"), msg("h2"), msg("h3"), latest)
assertTrue(tracker.arrivals(backfilled).isEmpty())
assertEquals(setOf("h1", "h2", "h3", "latest"), tracker.known)
}
@Test
fun `a new message arriving alongside a backfill still animates`() {
val tracker = MessageArrivalTracker()
val latest = msg("latest")
tracker.arrivals(listOf(latest))
assertEquals(
setOf("new"),
tracker.arrivals(listOf(msg("h1"), latest, msg("new")))
)
}
@Test
fun `the known set never outgrows the conversation`() {
val tracker = MessageArrivalTracker()