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..0bcdfed3 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,31 @@ 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) { + val expectedSingleMessageKey = messages.singleOrNull()?.id + scrollToNewestAfterItemsMeasured( + expectedItemCount = messages.size, + 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. + columnState.scroll { scrollBy(Float.MAX_VALUE) } + } } } @@ -128,6 +150,25 @@ fun ChatScaffold( ) } +internal data class MeasuredChatLayout( + val itemCount: Int, + val singleVisibleItemKey: Any? +) + +internal suspend fun scrollToNewestAfterItemsMeasured( + expectedItemCount: Int, + expectedSingleMessageKey: Any?, + measuredLayouts: Flow, + scrollToEnd: suspend () -> Unit +) { + measuredLayouts.first { layout -> + layout.itemCount >= expectedItemCount && + (expectedSingleMessageKey == null || + layout.singleVisibleItemKey == expectedSingleMessageKey) + } + 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..0b3f85b5 --- /dev/null +++ b/wear/src/test/java/com/bitchat/watch/ui/ChatAutoScrollTest.kt @@ -0,0 +1,82 @@ +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 measuredLayouts = MutableStateFlow(MeasuredChatLayout(3, null)) + var scrollCount = 0 + + val scrollJob = launch(start = CoroutineStart.UNDISPATCHED) { + scrollToNewestAfterItemsMeasured( + expectedItemCount = 4, + expectedSingleMessageKey = null, + measuredLayouts = measuredLayouts + ) { + scrollCount += 1 + } + } + + assertFalse(scrollJob.isCompleted) + assertEquals(0, scrollCount) + + measuredLayouts.value = MeasuredChatLayout(4, null) + scrollJob.join() + + assertEquals(1, scrollCount) + } + + @Test + fun `newest scroll runs immediately when messages are already measured`() = runTest { + val measuredLayouts = MutableStateFlow(MeasuredChatLayout(4, null)) + var scrollCount = 0 + + scrollToNewestAfterItemsMeasured( + expectedItemCount = 4, + 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) + } +}