From efeb9b54b5597005fb36924df8ffb304a41dc944 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:51:16 +0200 Subject: [PATCH] Fix Wear chat auto-scroll timing --- .../java/com/bitchat/watch/ui/ChatScaffold.kt | 27 ++++++++-- .../bitchat/watch/ui/ChatAutoScrollTest.kt | 50 +++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 wear/src/test/java/com/bitchat/watch/ui/ChatAutoScrollTest.kt diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt index 5f740f12..3404881b 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt @@ -42,6 +42,8 @@ import com.bitchat.android.model.BitchatMessage import com.bitchat.watch.ui.theme.BitchatMotion import com.bitchat.watch.ui.theme.ChatVisualTokens import com.bitchat.watch.ui.theme.LocalBitchatPalette +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first /** * The shared chat body for global chat and DM threads, following the classic messenger @@ -106,11 +108,19 @@ fun ChatScaffold( } // Stick to bottom: follow new messages while resting at the newest. + // Capture this when the message count changes, before the new layout can temporarily make + // canScrollForward true and report that the user is browsing history. + val followNewest = remember(messages.size) { atNewest } LaunchedEffect(columnState, messages.size) { - if (messages.isNotEmpty() && atNewest) { - // scrollBy to the end of the range: animateScrollToItem stops as soon as the - // item is partially visible, which left the last message cropped. - columnState.scroll { scrollBy(Float.MAX_VALUE) } + if (messages.isNotEmpty() && followNewest) { + scrollToNewestAfterItemsMeasured( + expectedItemCount = messages.size, + measuredItemCounts = snapshotFlow { columnState.layoutInfo.totalItemsCount } + ) { + // scrollBy to the end of the range: animateScrollToItem stops as soon as the + // item is partially visible, which left the last message cropped. + columnState.scroll { scrollBy(Float.MAX_VALUE) } + } } } @@ -128,6 +138,15 @@ fun ChatScaffold( ) } +internal suspend fun scrollToNewestAfterItemsMeasured( + expectedItemCount: Int, + measuredItemCounts: Flow, + scrollToEnd: suspend () -> Unit +) { + measuredItemCounts.first { it >= expectedItemCount } + scrollToEnd() +} + @Composable private fun ChatBody( messages: List, diff --git a/wear/src/test/java/com/bitchat/watch/ui/ChatAutoScrollTest.kt b/wear/src/test/java/com/bitchat/watch/ui/ChatAutoScrollTest.kt new file mode 100644 index 00000000..2262bc75 --- /dev/null +++ b/wear/src/test/java/com/bitchat/watch/ui/ChatAutoScrollTest.kt @@ -0,0 +1,50 @@ +package com.bitchat.watch.ui + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class ChatAutoScrollTest { + + @Test + fun `newest scroll waits until appended message is measured`() = runTest { + val measuredItemCount = MutableStateFlow(3) + var scrollCount = 0 + + val scrollJob = launch(start = CoroutineStart.UNDISPATCHED) { + scrollToNewestAfterItemsMeasured( + expectedItemCount = 4, + measuredItemCounts = measuredItemCount + ) { + scrollCount += 1 + } + } + + assertFalse(scrollJob.isCompleted) + assertEquals(0, scrollCount) + + measuredItemCount.value = 4 + scrollJob.join() + + assertEquals(1, scrollCount) + } + + @Test + fun `newest scroll runs immediately when messages are already measured`() = runTest { + val measuredItemCount = MutableStateFlow(4) + var scrollCount = 0 + + scrollToNewestAfterItemsMeasured( + expectedItemCount = 4, + measuredItemCounts = measuredItemCount + ) { + scrollCount += 1 + } + + assertEquals(1, scrollCount) + } +}