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 1/2] 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) + } +} From 44b2b8cb79c62d728945c1a357ef9f201b47ae28 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:32:20 +0200 Subject: [PATCH 2/2] Fix first Wear message auto-scroll --- .../java/com/bitchat/watch/ui/ChatScaffold.kt | 28 +++++++++++-- .../bitchat/watch/ui/ChatAutoScrollTest.kt | 42 ++++++++++++++++--- 2 files changed, 62 insertions(+), 8 deletions(-) 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 3404881b..0bcdfed3 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt @@ -113,9 +113,21 @@ fun ChatScaffold( val followNewest = remember(messages.size) { atNewest } LaunchedEffect(columnState, messages.size) { if (messages.isNotEmpty() && followNewest) { + val expectedSingleMessageKey = messages.singleOrNull()?.id scrollToNewestAfterItemsMeasured( expectedItemCount = messages.size, - measuredItemCounts = snapshotFlow { columnState.layoutInfo.totalItemsCount } + expectedSingleMessageKey = expectedSingleMessageKey, + measuredLayouts = snapshotFlow { + val layoutInfo = columnState.layoutInfo + MeasuredChatLayout( + itemCount = layoutInfo.totalItemsCount, + singleVisibleItemKey = if (expectedSingleMessageKey != null) { + layoutInfo.visibleItems.singleOrNull()?.key + } else { + null + } + ) + } ) { // scrollBy to the end of the range: animateScrollToItem stops as soon as the // item is partially visible, which left the last message cropped. @@ -138,12 +150,22 @@ fun ChatScaffold( ) } +internal data class MeasuredChatLayout( + val itemCount: Int, + val singleVisibleItemKey: Any? +) + internal suspend fun scrollToNewestAfterItemsMeasured( expectedItemCount: Int, - measuredItemCounts: Flow, + expectedSingleMessageKey: Any?, + measuredLayouts: Flow, scrollToEnd: suspend () -> Unit ) { - measuredItemCounts.first { it >= expectedItemCount } + measuredLayouts.first { layout -> + layout.itemCount >= expectedItemCount && + (expectedSingleMessageKey == null || + layout.singleVisibleItemKey == expectedSingleMessageKey) + } scrollToEnd() } diff --git a/wear/src/test/java/com/bitchat/watch/ui/ChatAutoScrollTest.kt b/wear/src/test/java/com/bitchat/watch/ui/ChatAutoScrollTest.kt index 2262bc75..0b3f85b5 100644 --- a/wear/src/test/java/com/bitchat/watch/ui/ChatAutoScrollTest.kt +++ b/wear/src/test/java/com/bitchat/watch/ui/ChatAutoScrollTest.kt @@ -12,13 +12,14 @@ class ChatAutoScrollTest { @Test fun `newest scroll waits until appended message is measured`() = runTest { - val measuredItemCount = MutableStateFlow(3) + val measuredLayouts = MutableStateFlow(MeasuredChatLayout(3, null)) var scrollCount = 0 val scrollJob = launch(start = CoroutineStart.UNDISPATCHED) { scrollToNewestAfterItemsMeasured( expectedItemCount = 4, - measuredItemCounts = measuredItemCount + expectedSingleMessageKey = null, + measuredLayouts = measuredLayouts ) { scrollCount += 1 } @@ -27,7 +28,7 @@ class ChatAutoScrollTest { assertFalse(scrollJob.isCompleted) assertEquals(0, scrollCount) - measuredItemCount.value = 4 + measuredLayouts.value = MeasuredChatLayout(4, null) scrollJob.join() assertEquals(1, scrollCount) @@ -35,16 +36,47 @@ class ChatAutoScrollTest { @Test fun `newest scroll runs immediately when messages are already measured`() = runTest { - val measuredItemCount = MutableStateFlow(4) + val measuredLayouts = MutableStateFlow(MeasuredChatLayout(4, null)) var scrollCount = 0 scrollToNewestAfterItemsMeasured( expectedItemCount = 4, - measuredItemCounts = measuredItemCount + expectedSingleMessageKey = null, + measuredLayouts = measuredLayouts ) { scrollCount += 1 } assertEquals(1, scrollCount) } + + @Test + fun `first message waits past stale empty placeholder layout`() = runTest { + val messageKey = "first-message" + val measuredLayouts = MutableStateFlow( + MeasuredChatLayout(itemCount = 1, singleVisibleItemKey = "empty-placeholder") + ) + var scrollCount = 0 + + val scrollJob = launch(start = CoroutineStart.UNDISPATCHED) { + scrollToNewestAfterItemsMeasured( + expectedItemCount = 1, + expectedSingleMessageKey = messageKey, + measuredLayouts = measuredLayouts + ) { + scrollCount += 1 + } + } + + assertFalse(scrollJob.isCompleted) + assertEquals(0, scrollCount) + + measuredLayouts.value = MeasuredChatLayout( + itemCount = 1, + singleVisibleItemKey = messageKey + ) + scrollJob.join() + + assertEquals(1, scrollCount) + } }