Merge pull request #844 from permissionlesstech/codex/fix-wear-chat-auto-scroll

Fix Wear chat auto-scroll timing
This commit is contained in:
callebtc 2026-08-01 01:34:05 +02:00 committed by GitHub
commit e07a38f634
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 127 additions and 4 deletions

View File

@ -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<MeasuredChatLayout>,
scrollToEnd: suspend () -> Unit
) {
measuredLayouts.first { layout ->
layout.itemCount >= expectedItemCount &&
(expectedSingleMessageKey == null ||
layout.singleVisibleItemKey == expectedSingleMessageKey)
}
scrollToEnd()
}
@Composable
private fun ChatBody(
messages: List<BitchatMessage>,

View File

@ -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)
}
}