diff --git a/app/src/main/java/com/bitchat/android/ui/MainScaffold.kt b/app/src/main/java/com/bitchat/android/ui/MainScaffold.kt index 9667409c..ae55647a 100644 --- a/app/src/main/java/com/bitchat/android/ui/MainScaffold.kt +++ b/app/src/main/java/com/bitchat/android/ui/MainScaffold.kt @@ -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, diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 7aba917d..7aec7769 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -183,8 +183,18 @@ internal fun MessageArrivalTracker.arrivals(messages: List): 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 diff --git a/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt b/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt index fb534e69..ca0a9fd5 100644 --- a/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt @@ -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()