From e8f25cc8fca311fdcb64f220ca9a1a8da96b2c92 Mon Sep 17 00:00:00 2001 From: agent-team Date: Tue, 13 Oct 2026 22:29:37 -0400 Subject: [PATCH 01/23] feat(messenger): jump-to-bottom button with unread badge Floating glass button shown while the transcript is off the newest row. Its badge counts inbound messages past the viewer's stored READ pointer, the pointer the read reporter advances, using the unread divider's query as a Flow so the two start equal and the badge falls as rows are read. Adds a badgeFocus theme color (#0A84FF). --- .../core/src/main/res/values/strings.xml | 1 + .../app/messenger/internal/ChatViewModel.kt | 17 + .../internal/screens/MessengerScreen.kt | 3 + .../screens/components/JumpToBottom.kt | 203 ++++++++ .../screens/components/MessageList.kt | 448 +++++++++--------- .../messenger/internal/JumpToBottomTest.kt | 27 ++ .../flipcash/shared/chat/ChatCoordinator.kt | 8 + .../internal/delegates/MessagingDelegate.kt | 12 + .../app/persistence/dao/ChatMemberDao.kt | 4 + .../app/persistence/dao/ChatMessageDao.kt | 12 + .../persistence/dao/UnreadBelowBadgeTest.kt | 142 ++++++ .../sources/ChatMemberDataSource.kt | 15 + .../sources/ChatMessageDataSource.kt | 8 + .../theme/internal/FlipcashDesignSystem.kt | 2 + .../main/kotlin/com/getcode/theme/Color.kt | 1 + .../main/kotlin/com/getcode/theme/Theme.kt | 7 + 16 files changed, 698 insertions(+), 212 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/JumpToBottomTest.kt create mode 100644 apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/UnreadBelowBadgeTest.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 046bb46b46..fc8cc923fd 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1299,6 +1299,7 @@ No chats yet Start a chat in Flipcash, then share again. %1$d attached + Jump to latest message Add a message\u2026 Receive Tips From Everyone Add your name to receive tips diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 0793cd4522..f30a53a944 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -1047,6 +1047,23 @@ internal class ChatViewModel @AssistedInject constructor( .flatMapLatest { chatCoordinator.observeOtherReadPointer(it) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + /** + * How many inbound messages sit past the viewer's READ pointer, for the jump-to-bottom badge. + * The pointer is the one the read reporter advances, so the badge falls as rows are read and + * cannot disagree with it. An outside viewer's pointer never moves, so it counts nothing. + */ + @OptIn(ExperimentalCoroutinesApi::class) + val unreadBelowCount = stateFlow.mapNotNull { it.chatId } + .distinctUntilChanged() + .flatMapLatest { chatId -> + combine( + chatCoordinator.observeUnreadBelow(chatId), + stateFlow.map { it.isOutsideGroup }.distinctUntilChanged(), + ) { count, outside -> if (outside) 0 else count } + } + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) + /** * Re-runs the transcript mapping when the windows change, so a message resolved under the * defaults (both open, before the flags arrive) narrows as soon as the server's answer lands. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 2dcf2624cc..ad0d1575b5 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -96,6 +96,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { val mediaProgress = viewModel.mediaSendProgress.collectAsStateWithLifecycle() val mediaProgressOf = remember(mediaProgress) { { mediaProgress.value } } val otherReadPointer by viewModel.otherReadPointer.collectAsStateWithLifecycle(null) + val unreadBelowCount by viewModel.unreadBelowCount.collectAsStateWithLifecycle() val navigator = LocalCodeNavigator.current val uriHandler = LocalUriHandler.current val context = LocalContext.current @@ -515,6 +516,8 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { webLinkPreviewsEnabled = webLinkPreviewsEnabled, onJumpConsumed = { viewModel.dispatchEvent(ChatViewModel.Event.JumpConsumed) }, topBarBottom = barHeight, + unreadBelowCount = unreadBelowCount, + hazeState = hazeState, ) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt new file mode 100644 index 0000000000..391350bd46 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt @@ -0,0 +1,203 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.flipcash.features.messenger.R +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.Badge +import dev.chrisbanes.haze.HazeInput +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.blur.HazeBlurStyle +import dev.chrisbanes.haze.blur.HazeColorEffect +import dev.chrisbanes.haze.blur.hazeBlur +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow + +/** Past this many rows from the newest, a tap jumps rather than animates (from the old Flipchat). */ +internal const val JumpToBottomAnimateMaxRows = 100 + +/** + * How long the transcript has to stay off the newest row before the button appears. The insertion + * push and the typing row move the list through a transient non-zero offset on the way back to + * rest; hiding is immediate, showing is not, so those frames don't flash the button. + */ +private const val ShowDelayMillis = 120L + +private val ButtonSize = 48.dp +private val BadgeOutline = 2.dp +private val HazeHeight = 112.dp + +/** Whether a tap at [firstVisibleIndex] rows from the newest jumps instead of animating. */ +internal fun jumpsToBottom(firstVisibleIndex: Int): Boolean = firstVisibleIndex > JumpToBottomAnimateMaxRows + +/** Scrolls [this] to the newest row: a jump from far away, an animation from near. */ +internal suspend fun LazyListState.scrollToNewest() { + if (jumpsToBottom(firstVisibleItemIndex)) scrollToItem(0) else animateScrollToItem(0) +} + +/** + * Whether the jump-to-bottom button should be up: the list has settled where this visit opens it, + * and is not resting on the newest row. Reads [restsOnNewest] as it stands, so it is true for the + * frames a spring leaves the offset non-zero; those are held back by [ShowDelayMillis]. + */ +@Composable +internal fun rememberJumpToBottomVisible(listState: LazyListState, hasPositioned: () -> Boolean): Boolean { + var visible by remember { mutableStateOf(false) } + LaunchedEffect(listState) { + snapshotFlow { hasPositioned() && !listState.restsOnNewest() }.collectLatest { away -> + if (away) { + delay(ShowDelayMillis) + visible = true + } else { + visible = false + } + } + } + return visible +} + +/** + * The haze behind the button: the chat background fading in toward the composer, so the button + * and the rows it floats over read apart. Sits beside the transcript, never inside its haze + * source, and takes no touches. + */ +@Composable +internal fun JumpToBottomHaze(visible: Boolean, modifier: Modifier = Modifier) { + val background = CodeTheme.colors.background + AnimatedVisibility( + modifier = modifier, + visible = visible, + enter = fadeIn(tween(200)), + exit = fadeOut(tween(200)), + ) { + Box( + Modifier + .fillMaxWidth() + .height(HazeHeight) + .background( + Brush.verticalGradient( + 0f to background.copy(alpha = 0f), + 0.5f to background.copy(alpha = 0.75f), + 1f to background.copy(alpha = 0.95f), + ) + ), + ) + } +} + +/** + * A 48dp glass circle with a down chevron, and the unread count at its top-trailing corner. + * + * Hidden by [AnimatedVisibility] rather than alpha, so it is not tappable while it is away. + * [hazeState] must belong to a `hazeSource` that does not contain this button. + */ +@Composable +internal fun JumpToBottomButton( + visible: Boolean, + unreadCount: Int, + hazeState: HazeState?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val background = CodeTheme.colors.background + // The HazeBlurStyle builder is not a @Composable scope, so theme reads are hoisted above it. + val glass = HazeBlurStyle { + blurRadius(20.dp) + backgroundColor(background) + colorEffects(listOf(HazeColorEffect.tint(Color.White.copy(alpha = 0.12f)))) + } + // Held so the pill doesn't read "0" while it scales out. + var lastCount by remember { mutableStateOf(unreadCount) } + if (unreadCount > 0) lastCount = unreadCount + + AnimatedVisibility( + modifier = modifier, + visible = visible, + enter = fadeIn(tween(200)) + scaleIn(tween(200), initialScale = 0.8f), + exit = fadeOut(tween(150)) + scaleOut(tween(150), targetScale = 0.8f), + ) { + Box { + Box( + modifier = Modifier + .size(ButtonSize) + .shadow(elevation = 8.dp, shape = CircleShape, clip = false) + .clip(CircleShape) + .then( + if (hazeState != null) Modifier.hazeBlur(HazeInput.Sources(hazeState), glass) + else Modifier.background(Color.White.copy(alpha = 0.12f).compositeOver(background)) + ) + .border(0.5.dp, CodeTheme.colors.divider, CircleShape) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.KeyboardArrowDown, + contentDescription = stringResource(R.string.action_jumpToBottom), + tint = Color.White, + modifier = Modifier.size(28.dp), + ) + } + AnimatedVisibility( + modifier = Modifier + .align(Alignment.TopEnd) + // The pill's centre sits on the circle's top-trailing corner. + .offset(x = 4.dp, y = (-4).dp), + visible = unreadCount > 0, + enter = fadeIn(tween(200)) + scaleIn(tween(200)), + exit = fadeOut(tween(150)) + scaleOut(tween(150)), + ) { + // The outline is the chat background, so the pill reads as cut out of the button. + Box( + Modifier + .background(background, CircleShape) + .padding(BadgeOutline), + ) { + Badge( + count = lastCount, + color = CodeTheme.colors.badgeFocus, + textStyle = CodeTheme.typography.caption.copy( + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + ), + ) + } + } + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index c68cbc400c..5a8f9b42a7 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding @@ -66,6 +67,7 @@ import androidx.paging.compose.itemKey import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.features.messenger.R +import dev.chrisbanes.haze.HazeState import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessagePointer import com.flipcash.shared.chat.UnreadBoundary @@ -115,6 +117,8 @@ internal fun MessageList( webLinkPreviewsEnabled: Boolean = true, onJumpConsumed: () -> Unit = {}, topBarBottom: Dp = 0.dp, + unreadBelowCount: Int = 0, + hazeState: HazeState? = null, ) { val keyboard = rememberKeyboardController() val listState = rememberLazyListState() @@ -440,240 +444,260 @@ internal fun MessageList( } } - LazyColumn( - // NB: no sheetResignmentBehavior, unlike every other scrolling list in the app. The - // conversation is a full-screen destination, not a sheet, so there is no dismiss drag - // for the guard to hand the gesture back to — back is the only way out. It would also - // read this list wrong: the guard expects a top-anchored list, where index0/offset0 is - // the edge a downward drag overscrolls past, and this one is reverseLayout, where - // index0/offset0 is the resting position and a downward drag scrolls into history. - modifier = modifier - // The backdrop is modal. While a message is selected or being edited the rest of - // the transcript sits behind it, so a tap there dismisses what is up rather than - // reaching the message it landed on — the exit iOS gives its held blur. - .pointerInput(state.selection != null, state.editing != null) { - detectTapGestures { - when { - state.editing != null -> onAction(ChatAction.CancelEdit) - state.selection != null -> onAction(ChatAction.ClearSelection) - else -> keyboard.hide() + Box(Modifier.fillMaxSize()) { + LazyColumn( + // NB: no sheetResignmentBehavior, unlike every other scrolling list in the app. The + // conversation is a full-screen destination, not a sheet, so there is no dismiss drag + // for the guard to hand the gesture back to — back is the only way out. It would also + // read this list wrong: the guard expects a top-anchored list, where index0/offset0 is + // the edge a downward drag overscrolls past, and this one is reverseLayout, where + // index0/offset0 is the resting position and a downward drag scrolls into history. + modifier = modifier + // The backdrop is modal. While a message is selected or being edited the rest of + // the transcript sits behind it, so a tap there dismisses what is up rather than + // reaching the message it landed on — the exit iOS gives its held blur. + .pointerInput(state.selection != null, state.editing != null) { + detectTapGestures { + when { + state.editing != null -> onAction(ChatAction.CancelEdit) + state.selection != null -> onAction(ChatAction.ClearSelection) + else -> keyboard.hide() + } } } - } - .holdFocusedMessageInPlace(focusPin, imeInsets, listBottomPadPx), - state = listState, - reverseLayout = true, - // The transcript can't be dragged for as long as the backdrop is up — selection and - // edit alike. It is behind the same scrim the taps above dismiss, so a drag there has - // no more business reaching it than a tap does. Arriving messages still move it — see - // the auto-scroll effect below, which caps how far. - userScrollEnabled = !selecting, - contentPadding = PaddingValues( - top = CodeTheme.dimens.inset + contentPadding.calculateTopPadding(), - bottom = listBottomPad, - start = CodeTheme.dimens.inset, - end = CodeTheme.dimens.inset, - ), - verticalArrangement = Arrangement.Top, - ) { - items( - count = messages.itemCount, - key = messages.itemKey { it.itemKey } - ) { index -> - val item = messages[index] ?: return@items - - // Cross-item bookkeeping, so it stays with the list rather than the row: a message - // animates in once, the first time it is laid out after the initial page. - // The newest row opening from under the typing dots: see TypingRowModel. - // Decided here too: the list can lay this row out before the composition above - // has seen it arrive. See TypingRowModel.arrive. - val grow = if (index == 0) { - arriveUnderDots() - if (typingRow.takeAnchor()) SideEffect { listState.requestScrollToItem(0, 0) } - typingRow.growFor(item.itemKey) - } else { - null - } - val animateInsertion = index == 0 && hasLoaded && item.itemKey !in animatedKeys && - // A message growing out of the dots doesn't rise in as well. - grow?.handoff == null - if (animateInsertion) animatedKeys.add(item.itemKey) - - val bubble = item as? ChatListItem.ContentBubble - // Selecting or editing a message pushes the rest of the transcript behind a blur, - // leaving the one message sharp — the same treatment iOS holds from its context - // menu through the edit that can follow it. - val focused = when { - // The delete confirmation is modal, so nothing behind it is the focus: the - // selected message drops back with the rest rather than sitting sharp and - // half-clipped where the sheet cuts across it. - state.confirmingDelete -> false - state.editing != null -> bubble?.messageId == state.editing.messageId - // Every row of the message stays sharp, so a split message is focused whole. - state.selection != null -> bubble?.messageKey == state.selection.messageKey - else -> true - } - - // Every row of a message split around its card is focused, but only the pressed - // one hosts the quick strip — one strip per message, as iOS gives one per context - // menu — and it spans the rows the others measure. - val inSelection = focused && bubble != null && - bubble.messageKey == state.selection?.messageKey - if (inSelection) { - DisposableEffect(selectedRowBounds, item.itemKey) { - onDispose { selectedRowBounds.remove(item.itemKey) } + .holdFocusedMessageInPlace(focusPin, imeInsets, listBottomPadPx), + state = listState, + reverseLayout = true, + // The transcript can't be dragged for as long as the backdrop is up — selection and + // edit alike. It is behind the same scrim the taps above dismiss, so a drag there has + // no more business reaching it than a tap does. Arriving messages still move it — see + // the auto-scroll effect below, which caps how far. + userScrollEnabled = !selecting, + contentPadding = PaddingValues( + top = CodeTheme.dimens.inset + contentPadding.calculateTopPadding(), + bottom = listBottomPad, + start = CodeTheme.dimens.inset, + end = CodeTheme.dimens.inset, + ), + verticalArrangement = Arrangement.Top, + ) { + items( + count = messages.itemCount, + key = messages.itemKey { it.itemKey } + ) { index -> + val item = messages[index] ?: return@items + + // Cross-item bookkeeping, so it stays with the list rather than the row: a message + // animates in once, the first time it is laid out after the initial page. + // The newest row opening from under the typing dots: see TypingRowModel. + // Decided here too: the list can lay this row out before the composition above + // has seen it arrive. See TypingRowModel.arrive. + val grow = if (index == 0) { + arriveUnderDots() + if (typingRow.takeAnchor()) SideEffect { listState.requestScrollToItem(0, 0) } + typingRow.growFor(item.itemKey) + } else { + null } - } - val hostsStrip = inSelection && item.itemKey == state.selection?.itemKey - - var growing by remember { mutableStateOf(grow != null) } - val growProgress = remember { Animatable(if (grow != null) 0f else 1f) } - val contentAlpha = remember { Animatable(if (grow?.handoff != null) 0f else 1f) } - val dotsAlpha = remember { Animatable(if (grow?.handoff != null) 1f else 0f) } - LaunchedEffect(grow) { - grow ?: return@LaunchedEffect - coroutineScope { - val spec = if (grow.handoff != null) { - ChatAnimations.fromTyping - } else { - ChatAnimations.insertionPush + val animateInsertion = index == 0 && hasLoaded && item.itemKey !in animatedKeys && + // A message growing out of the dots doesn't rise in as well. + grow?.handoff == null + if (animateInsertion) animatedKeys.add(item.itemKey) + + val bubble = item as? ChatListItem.ContentBubble + // Selecting or editing a message pushes the rest of the transcript behind a blur, + // leaving the one message sharp — the same treatment iOS holds from its context + // menu through the edit that can follow it. + val focused = when { + // The delete confirmation is modal, so nothing behind it is the focus: the + // selected message drops back with the rest rather than sitting sharp and + // half-clipped where the sheet cuts across it. + state.confirmingDelete -> false + state.editing != null -> bubble?.messageId == state.editing.messageId + // Every row of the message stays sharp, so a split message is focused whole. + state.selection != null -> bubble?.messageKey == state.selection.messageKey + else -> true + } + + // Every row of a message split around its card is focused, but only the pressed + // one hosts the quick strip — one strip per message, as iOS gives one per context + // menu — and it spans the rows the others measure. + val inSelection = focused && bubble != null && + bubble.messageKey == state.selection?.messageKey + if (inSelection) { + DisposableEffect(selectedRowBounds, item.itemKey) { + onDispose { selectedRowBounds.remove(item.itemKey) } } - launch { growProgress.animateTo(1f, spec) } - if (grow.handoff != null) { - launch { - contentAlpha.animateTo( - 1f, - tween( - durationMillis = ChatAnimations.fromTypingTextFadeMs, - delayMillis = ChatAnimations.fromTypingTextDelayMs, - ), - ) + } + val hostsStrip = inSelection && item.itemKey == state.selection?.itemKey + + var growing by remember { mutableStateOf(grow != null) } + val growProgress = remember { Animatable(if (grow != null) 0f else 1f) } + val contentAlpha = remember { Animatable(if (grow?.handoff != null) 0f else 1f) } + val dotsAlpha = remember { Animatable(if (grow?.handoff != null) 1f else 0f) } + LaunchedEffect(grow) { + grow ?: return@LaunchedEffect + coroutineScope { + val spec = if (grow.handoff != null) { + ChatAnimations.fromTyping + } else { + ChatAnimations.insertionPush + } + launch { growProgress.animateTo(1f, spec) } + if (grow.handoff != null) { + launch { + contentAlpha.animateTo( + 1f, + tween( + durationMillis = ChatAnimations.fromTypingTextFadeMs, + delayMillis = ChatAnimations.fromTypingTextDelayMs, + ), + ) + } + launch { dotsAlpha.animateTo(0f, tween(ChatAnimations.fromTypingDotsFadeMs)) } } - launch { dotsAlpha.animateTo(0f, tween(ChatAnimations.fromTypingDotsFadeMs)) } } + growing = false + typingRow.finished(grow.key) } - growing = false - typingRow.finished(grow.key) - } - val handoff = grow?.handoff?.takeIf { growing }?.let { from -> - remember(from) { - TypingHandoff( - from = from, - progress = { growProgress.value }, - contentAlpha = { contentAlpha.value }, - dotsAlpha = { dotsAlpha.value }, - ) + val handoff = grow?.handoff?.takeIf { growing }?.let { from -> + remember(from) { + TypingHandoff( + from = from, + progress = { growProgress.value }, + contentAlpha = { contentAlpha.value }, + dotsAlpha = { dotsAlpha.value }, + ) + } } - } - // Every item is wrapped alike, so a row keeps its state as it moves off index 0. - Column( - modifier = Modifier.addIf(grow != null && growing) { - Modifier.openFromTypingRow(grow?.fromPx ?: 0) { growProgress.value } - }, - ) { - MessageRow( - index = index, - item = item, - messages = messages, - separatorConfig = separatorConfig, - otherReadPointer = otherReadPointer, - settlingKey = settlingKey, - selecting = selecting, - focused = focused, - // The pressed row is also the only one that lifts, as iOS lifts only the held bubble. - raised = hostsStrip, - animateInsertion = animateInsertion, - showsSenderGutter = isGroup, - topBarBottom = topBarBottom, - quickReactionStrip = if (hostsStrip) state.quickReactionStrip else emptyList(), - stripAnchor = if (hostsStrip) { - QuickReactionStripPlacement.messageBounds(selectedRowBounds.values) - } else { - null - }, - onBubbleBounds = if (inSelection) { - { bounds -> selectedRowBounds[item.itemKey] = bounds } - } else { - null - }, - attention = if (bubble != null && bubble.messageId == attentionId) { - readAttention - } else { - NoAttention - }, - typingHandoff = handoff, - ) - - // Under the newest row, not in place of it. A message growing out of the dots - // draws what is left of them itself. - if (index == 0 && (showDots || dotsVisible) && !typingFrame.handedOff && handoff == null) { - TypingRow( - avatars = dotsAvatars, - // The newest row already keeps the tight gap under it; the dots add the rest. - gapAbove = when (gapAboveTypingRow(item, typingFrame.lastTypists)) { - RowGap.Tight -> 0.dp - RowGap.Normal -> CodeTheme.dimens.grid.x2 - CodeTheme.dimens.grid.x1 - RowGap.Wide -> CodeTheme.dimens.grid.x3 - CodeTheme.dimens.grid.x1 + // Every item is wrapped alike, so a row keeps its state as it moves off index 0. + Column( + modifier = Modifier.addIf(grow != null && growing) { + Modifier.openFromTypingRow(grow?.fromPx ?: 0) { growProgress.value } + }, + ) { + MessageRow( + index = index, + item = item, + messages = messages, + separatorConfig = separatorConfig, + otherReadPointer = otherReadPointer, + settlingKey = settlingKey, + selecting = selecting, + focused = focused, + // The pressed row is also the only one that lifts, as iOS lifts only the held bubble. + raised = hostsStrip, + animateInsertion = animateInsertion, + showsSenderGutter = isGroup, + topBarBottom = topBarBottom, + quickReactionStrip = if (hostsStrip) state.quickReactionStrip else emptyList(), + stripAnchor = if (hostsStrip) { + QuickReactionStripPlacement.messageBounds(selectedRowBounds.values) + } else { + null + }, + onBubbleBounds = if (inSelection) { + { bounds -> selectedRowBounds[item.itemKey] = bounds } + } else { + null + }, + attention = if (bubble != null && bubble.messageId == attentionId) { + readAttention + } else { + NoAttention }, - model = typingRow, - presence = { dotsPresence.value }, - // Dots carried under a newer message keep their place. - enters = Snapshot.withoutReadObservation { dotsPresence.value == 0f }, + typingHandoff = handoff, ) - } - } - } - // Show trailing separator and contact info once messages are available - // (from Room cache) or after the refresh settles (for empty conversations). - // This prevents these items from being the only content before messages - // load, which would cause the list to start at the wrong scroll position. - if (messages.itemCount > 0 || refreshSettled) { - // Node 10127:117171. Emitted before the info card, and so drawn below it: this list - // is reverseLayout, where the last item is the topmost one. The blur over this list - // is what makes it a preview; see GatedTranscriptPlaceholder for why it is drawn - // rather than fetched. - if (messages.itemCount == 0 && state.obscuresTranscript) { - item(key = "gated-transcript-placeholder") { - GatedTranscriptPlaceholder( - modifier = Modifier.fillParentMaxWidth().then(behindBackdrop), + // Under the newest row, not in place of it. A message growing out of the dots + // draws what is left of them itself. + if (index == 0 && (showDots || dotsVisible) && !typingFrame.handedOff && handoff == null) { + TypingRow( + avatars = dotsAvatars, + // The newest row already keeps the tight gap under it; the dots add the rest. + gapAbove = when (gapAboveTypingRow(item, typingFrame.lastTypists)) { + RowGap.Tight -> 0.dp + RowGap.Normal -> CodeTheme.dimens.grid.x2 - CodeTheme.dimens.grid.x1 + RowGap.Wide -> CodeTheme.dimens.grid.x3 - CodeTheme.dimens.grid.x1 + }, + model = typingRow, + presence = { dotsPresence.value }, + // Dots carried under a newer message keep their place. + enters = Snapshot.withoutReadObservation { dotsPresence.value == 0f }, ) } + } } - // Trailing date separator for the oldest loaded message. - // This replaces the `after == null` boundary from insertSeparators - // which Paging 3 defers until endOfPaginationReached, causing a - // visible frame delay where the message appears without its separator. - if (messages.itemCount > 0) { - val oldest = messages.peek(messages.itemCount - 1) - val oldestTimestamp = when (oldest) { - is ChatListItem.TranscriptMessage -> oldest.timestamp - is ChatListItem.DateSeparator -> null // already a separator - is ChatListItem.UnreadDivider -> null // carries the oldest message's date - is ChatListItem.EncryptedMarker -> null // carries it too, as `above` - null -> null + // Show trailing separator and contact info once messages are available + // (from Room cache) or after the refresh settles (for empty conversations). + // This prevents these items from being the only content before messages + // load, which would cause the list to start at the wrong scroll position. + if (messages.itemCount > 0 || refreshSettled) { + // Node 10127:117171. Emitted before the info card, and so drawn below it: this list + // is reverseLayout, where the last item is the topmost one. The blur over this list + // is what makes it a preview; see GatedTranscriptPlaceholder for why it is drawn + // rather than fetched. + if (messages.itemCount == 0 && state.obscuresTranscript) { + item(key = "gated-transcript-placeholder") { + GatedTranscriptPlaceholder( + modifier = Modifier.fillParentMaxWidth().then(behindBackdrop), + ) + } } - if (oldestTimestamp != null) { - item(key = "trailing-date-${oldestTimestamp.epochSeconds}") { - Box( - modifier = Modifier - .padding(bottom = CodeTheme.dimens.grid.x2) - .then(behindBackdrop), - ) { - DateSeparatorRow(oldestTimestamp) + + // Trailing date separator for the oldest loaded message. + // This replaces the `after == null` boundary from insertSeparators + // which Paging 3 defers until endOfPaginationReached, causing a + // visible frame delay where the message appears without its separator. + if (messages.itemCount > 0) { + val oldest = messages.peek(messages.itemCount - 1) + val oldestTimestamp = when (oldest) { + is ChatListItem.TranscriptMessage -> oldest.timestamp + is ChatListItem.DateSeparator -> null // already a separator + is ChatListItem.UnreadDivider -> null // carries the oldest message's date + is ChatListItem.EncryptedMarker -> null // carries it too, as `above` + null -> null + } + if (oldestTimestamp != null) { + item(key = "trailing-date-${oldestTimestamp.epochSeconds}") { + Box( + modifier = Modifier + .padding(bottom = CodeTheme.dimens.grid.x2) + .then(behindBackdrop), + ) { + DateSeparatorRow(oldestTimestamp) + } } } } - } - // Room at the start of history for the quick reaction strip, made only while a - // message near the start needs it: the strip sits above the message it acts on. - item(key = "history-start") { - Spacer(Modifier.height(with(LocalDensity.current) { historyStartRoom.toDp() })) + // Room at the start of history for the quick reaction strip, made only while a + // message near the start needs it: the strip sits above the message it acts on. + item(key = "history-start") { + Spacer(Modifier.height(with(LocalDensity.current) { historyStartRoom.toDp() })) + } } } + val jumpVisible = rememberJumpToBottomVisible(listState) { hasPositioned } + // Beside the list, not inside it: the list carries the haze source the glass samples. + JumpToBottomHaze( + visible = jumpVisible, + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = contentPadding.calculateBottomPadding()), + ) + val jumpScope = rememberCoroutineScope() + JumpToBottomButton( + visible = jumpVisible, + // No count until the list has settled: reads are gated on it, and so is the badge. + unreadCount = if (hasPositioned) unreadBelowCount else 0, + hazeState = hazeState, + onClick = { jumpScope.launch { listState.scrollToNewest() } }, + modifier = Modifier + .align(Alignment.BottomEnd) + // The scaffold already rides the keyboard, so the bar-height padding does too. + .padding(end = CodeTheme.dimens.inset, bottom = contentPadding.calculateBottomPadding() + 20.dp), + ) } // Scroll to bottom when the newest message changes (sent or received) @@ -885,7 +909,7 @@ private suspend fun LazyListState.alignBelowTopBar(index: Int) { } /** Whether the transcript rests on its newest row, with nothing of it scrolled out of view. */ -private fun LazyListState.restsOnNewest(): Boolean = +internal fun LazyListState.restsOnNewest(): Boolean = firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset == 0 /** diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/JumpToBottomTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/JumpToBottomTest.kt new file mode 100644 index 0000000000..587621cd59 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/JumpToBottomTest.kt @@ -0,0 +1,27 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.app.messenger.internal.screens.components.jumpsToBottom +import com.getcode.ui.components.unreadCountLabel +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** What the jump-to-bottom button says and how a tap travels. */ +class JumpToBottomTest { + + @Test + fun `badge caps at 99+`() { + assertEquals("99+", unreadCountLabel(150)) + assertEquals("99+", unreadCountLabel(100)) + assertEquals("99", unreadCountLabel(99)) + assertEquals("1", unreadCountLabel(1)) + } + + @Test + fun `a tap animates up to 100 rows away and jumps beyond`() { + assertFalse(jumpsToBottom(1)) + assertFalse(jumpsToBottom(100)) + assertTrue(jumpsToBottom(101)) + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index a5424d74fe..400997c41c 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -334,6 +334,14 @@ interface MessagingOperations { */ suspend fun resolveUnreadBoundary(chatId: ChatId): UnreadBoundary + /** + * How many inbound messages in [chatId] sit past the viewer's stored READ pointer, live — the + * jump-to-bottom badge. Read from the same pointer the read reporter advances and counted with + * the divider's own query, so the two cannot disagree. 0 when the divider would resolve to + * [UnreadBoundary.None] for want of a pointer, which includes a viewer outside the group. + */ + fun observeUnreadBelow(chatId: ChatId): Flow + /** How many stored messages in [chatId] sit past [messageId]. Bounds the walk to the unread divider. */ suspend fun countMessagesAfter(chatId: ChatId, messageId: Long): Int diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index 06223cac54..51fb1beb0e 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -58,6 +58,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import javax.inject.Inject @@ -221,6 +222,17 @@ class MessagingDelegate @Inject constructor( return UnreadBoundary.At(readThrough = boundary, count = count) } + @OptIn(ExperimentalCoroutinesApi::class) + override fun observeUnreadBelow(chatId: ChatId): Flow { + val selfId = userManager.accountId ?: return flowOf(0) + return memberDataSource.observeSelfReadPointer(chatId, selfId) + .flatMapLatest { readThrough -> + if (readThrough == null) flowOf(0) + else messageDataSource.observeInboundCountAfter(chatId, selfId, readThrough) + } + .distinctUntilChanged() + } + override suspend fun countMessagesAfter(chatId: ChatId, messageId: Long): Int = messageDataSource.countAfter(chatId, messageId) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt index a2ca504711..dc00aea3fe 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt @@ -56,6 +56,10 @@ interface ChatMemberDao { @Query("SELECT * FROM chat_members WHERE chat_id_hex = :chatIdHex AND user_id_hex = :userIdHex LIMIT 1") suspend fun getMember(chatIdHex: String, userIdHex: String): ChatMemberEntity? + /** [getMember] as a stream: one row, so a large group's roster is not loaded to watch the viewer's pointer. */ + @Query("SELECT * FROM chat_members WHERE chat_id_hex = :chatIdHex AND user_id_hex = :userIdHex LIMIT 1") + fun observeMember(chatIdHex: String, userIdHex: String): Flow + /** * Resolves the chat id of a DM that [userIdHex] is a member of, of the given [chatType] * (e.g. `"TIP_DM"`). Reuses the already-synced `chat_members` ↔ `chat_metadata` data — no diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt index 017d28d3b0..68a4bf3213 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt @@ -111,6 +111,18 @@ interface ChatMessageDao { ) suspend fun countInboundAfter(chatIdHex: String, selfIdHex: String, afterId: Long): Int + /** + * [countInboundAfter] as a stream, for the jump-to-bottom badge. The WHERE clause is the same + * on purpose: the badge opens at the divider's count and must never drift from it. + */ + @Query( + "SELECT COUNT(*) FROM chat_messages " + + "WHERE chat_id_hex = :chatIdHex " + + "AND sender_id_hex IS NOT NULL AND sender_id_hex != :selfIdHex " + + "AND is_deleted = 0 AND message_id > :afterId" + ) + fun observeInboundCountAfter(chatIdHex: String, selfIdHex: String, afterId: Long): Flow + /** * The first stored message in [chatIdHex] after [afterId] that [selfIdHex] did not send, * tombstones included, or `null` when only the viewer's own messages follow. The unread divider diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/UnreadBelowBadgeTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/UnreadBelowBadgeTest.kt new file mode 100644 index 0000000000..c7d00e0b45 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/UnreadBelowBadgeTest.kt @@ -0,0 +1,142 @@ +package com.flipcash.app.persistence.dao + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.converters.MessageContentSerialized +import com.flipcash.app.persistence.converters.MessagePointerSerialized +import com.flipcash.app.persistence.entities.ChatMemberEntity +import com.flipcash.app.persistence.entities.ChatMessageEntity +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals + +/** + * The jump-to-bottom badge: the viewer's stored READ pointer feeding the divider's own inbound + * count, live. The pointer is the one the read reporter advances, so the badge can't disagree + * with it, and the count is the divider's query, so it opens at the divider's number. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class UnreadBelowBadgeTest { + + private lateinit var db: FlipcashDatabase + private lateinit var messages: ChatMessageDao + private lateinit var members: ChatMemberDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, FlipcashDatabase::class.java) + .allowMainThreadQueries() + .build() + messages = db.chatMessageDao() + members = db.chatMemberDao() + } + + @After + fun tearDown() { + db.close() + } + + /** The same composition MessagingDelegate.observeUnreadBelow runs over the data sources. */ + private fun badge(): Flow = + members.observeMember(CHAT_HEX, SELF_HEX) + .map { self -> + self?.let { it.pointersJson.orEmpty().firstOrNull { p -> p.type == "READ" }?.value ?: 0L } + } + .distinctUntilChanged() + .flatMapLatest { readThrough -> + if (readThrough == null) flowOf(0) + else messages.observeInboundCountAfter(CHAT_HEX, SELF_HEX, readThrough) + } + .distinctUntilChanged() + + private fun message(sender: String, id: Long) = ChatMessageEntity( + chatIdHex = CHAT_HEX, + messageId = id, + senderIdHex = sender, + contentJson = listOf(MessageContentSerialized.Text("m$id")), + timestampEpochMs = id * 1_000, + unreadSeq = id, + ) + + private fun pointer(value: Long) = MessagePointerSerialized(type = "READ", userIdHex = SELF_HEX, value = value) + + private suspend fun open(readThrough: Long) { + members.upsert(ChatMemberEntity(chatIdHex = CHAT_HEX, userIdHex = SELF_HEX, pointersJson = listOf(pointer(readThrough)))) + } + + @Test + fun `opens at the unread divider's count`() = runTest { + messages.upsert( + listOf(message(OTHER_HEX, 1), message(OTHER_HEX, 2), message(SELF_HEX, 3), message(OTHER_HEX, 4), message(OTHER_HEX, 5)), + ) + open(readThrough = 2) + + assertEquals(messages.countInboundAfter(CHAT_HEX, SELF_HEX, 2), badge().first()) + assertEquals(2, badge().first()) + } + + @Test + fun `advancing the read pointer decrements`() = runTest { + messages.upsert(listOf(message(OTHER_HEX, 1), message(OTHER_HEX, 2), message(OTHER_HEX, 3), message(OTHER_HEX, 4))) + open(readThrough = 1) + assertEquals(3, badge().first()) + + members.advancePointer(CHAT_HEX, SELF_HEX, pointer(3)) + assertEquals(1, badge().first()) + + // The pointer keeps its max: a late, lower advance doesn't bring the count back. + members.advancePointer(CHAT_HEX, SELF_HEX, pointer(2)) + assertEquals(1, badge().first()) + } + + @Test + fun `an inbound arrival below increments`() = runTest { + messages.upsert(listOf(message(OTHER_HEX, 1), message(OTHER_HEX, 2))) + open(readThrough = 2) + assertEquals(0, badge().first()) + + messages.upsert(listOf(message(OTHER_HEX, 3))) + assertEquals(1, badge().first()) + } + + @Test + fun `own send clears once the pointer reaches it, and own messages are never counted`() = runTest { + messages.upsert(listOf(message(OTHER_HEX, 1), message(OTHER_HEX, 2), message(OTHER_HEX, 3))) + open(readThrough = 0) + assertEquals(3, badge().first()) + + // MessagingDelegate.send advances the pointer to the id the server gives the sent message. + messages.upsert(listOf(message(SELF_HEX, 4))) + assertEquals(3, badge().first()) + members.advancePointer(CHAT_HEX, SELF_HEX, pointer(4)) + assertEquals(0, badge().first()) + } + + @Test + fun `no self row counts nothing`() = runTest { + messages.upsert(listOf(message(OTHER_HEX, 1))) + + assertEquals(0, badge().first()) + } + + private companion object { + const val CHAT_HEX = "c1" + const val SELF_HEX = "aa" + const val OTHER_HEX = "bb" + } +} diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt index ec926802dc..1485df73aa 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt @@ -10,6 +10,7 @@ import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.PointerType import com.getcode.opencode.model.core.ID import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -64,6 +65,20 @@ class ChatMemberDataSource @Inject constructor( ?.getMember(mapper.chatIdHex(chatId), mapper.userIdHex(selfId)) ?.let { self -> mapper.pointers(self).firstOrNull { it.type == PointerType.READ }?.value ?: 0L } + /** + * [getSelfReadPointerOrNull] as a stream, so the pointer the read reporter advances is the one + * observers see. Pointer values only: a profile or roster write touches the self row without + * moving the pointer, and must not wake anyone downstream. + */ + fun observeSelfReadPointer(chatId: ChatId, selfId: ID): Flow = + db?.chatMemberDao() + ?.observeMember(mapper.chatIdHex(chatId), mapper.userIdHex(selfId)) + ?.map { self -> + self?.let { mapper.pointers(it).firstOrNull { p -> p.type == PointerType.READ }?.value ?: 0L } + } + ?.distinctUntilChanged() + ?: emptyFlow() + suspend fun upsert(chatId: ChatId, members: List) { val database = db ?: return val hex = mapper.chatIdHex(chatId) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt index 57cac1de08..ed46cb56bc 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt @@ -191,6 +191,14 @@ class ChatMessageDataSource @Inject constructor( afterId = afterId, ) ?: 0 + /** [countInboundAfter] as a stream, so arrivals and pointer moves both update it. */ + fun observeInboundCountAfter(chatId: ChatId, selfId: ID, afterId: Long): Flow = + db?.chatMessageDao()?.observeInboundCountAfter( + chatIdHex = mapper.chatIdHex(chatId), + selfIdHex = mapper.userIdHex(selfId), + afterId = afterId, + ) ?: emptyFlow() + /** The first stored message in [chatId] past [afterId] that [selfId] did not send, or `null`. */ suspend fun firstNotSentByAfter(chatId: ChatId, selfId: ID, afterId: Long): Long? = db?.chatMessageDao()?.firstNotSentByAfter( diff --git a/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt b/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt index 92c644b9f6..af97315601 100644 --- a/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt +++ b/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt @@ -3,6 +3,7 @@ package com.flipcash.app.theme.internal import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import com.getcode.theme.BadgeFocus import com.getcode.theme.BetaIndicator import com.getcode.theme.Black40 import com.getcode.theme.BrandAccent @@ -95,6 +96,7 @@ private val colors = with(Flipcash2ColorSpec) { secondary = secondary, tertiary = BrandAccent, indicator = notification, + badgeFocus = BadgeFocus, action = Gray50, onAction = White, background = primary, diff --git a/ui/theme/src/main/kotlin/com/getcode/theme/Color.kt b/ui/theme/src/main/kotlin/com/getcode/theme/Color.kt index 7fbd83e061..5276f7edfe 100644 --- a/ui/theme/src/main/kotlin/com/getcode/theme/Color.kt +++ b/ui/theme/src/main/kotlin/com/getcode/theme/Color.kt @@ -15,6 +15,7 @@ val BrandAccent = Color(0xFF443091) val BrandIndicator = Color(0xFF31BB00) // iOS unreadIndicator: the fill of a notification dot. val UnreadDot = Color(0xFF0A84FF) +val BadgeFocus = Color(0xFF0A84FF) val BrandSlideToConfirm = Color(0xFF11142A) val BrandToggleUncheckedTrackColor = Color(0xFF201D2F) diff --git a/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt b/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt index a7845e5495..64b1cdbede 100644 --- a/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt +++ b/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt @@ -29,6 +29,7 @@ internal val CodeDefaultColorScheme = ColorScheme( secondary = BrandAccent, tertiary = BrandAccent, indicator = BrandIndicator, + badgeFocus = BadgeFocus, action = Gray50, onAction = White, background = Brand, @@ -132,6 +133,8 @@ class ColorScheme( secondary: Color, tertiary: Color, indicator: Color, + /** Fill of a count badge that asks for attention, e.g. the unread count on jump-to-bottom (Figma badge/focus). */ + badgeFocus: Color, action: Color, onAction: Color, background: Color, @@ -218,6 +221,8 @@ class ColorScheme( private set var indicator by mutableStateOf(indicator) private set + var badgeFocus by mutableStateOf(badgeFocus) + private set var action by mutableStateOf(action) private set var onAction by mutableStateOf(onAction) @@ -286,6 +291,7 @@ class ColorScheme( secondary = other.secondary tertiary = other.tertiary indicator = other.indicator + badgeFocus = other.badgeFocus action = other.action onAction = other.onAction border = other.border @@ -333,6 +339,7 @@ class ColorScheme( secondary = secondary, tertiary = tertiary, indicator = indicator, + badgeFocus = badgeFocus, action = action, onAction = onAction, border = border, From c3b19da2a3b6a249b4edab0ebe1c6e5e1b5dc703 Mon Sep 17 00:00:00 2001 From: agent-team Date: Tue, 13 Oct 2026 22:35:06 -0400 Subject: [PATCH 02/23] fix(messenger): hide jump-to-bottom while a message is raised --- .../app/messenger/internal/screens/components/MessageList.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 5a8f9b42a7..6fa1f6e6c7 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -680,7 +680,9 @@ internal fun MessageList( } } } - val jumpVisible = rememberJumpToBottomVisible(listState) { hasPositioned } + // Not while a message is raised: the backdrop is modal and the list can't be dragged, so + // the button must not scroll it either. + val jumpVisible = rememberJumpToBottomVisible(listState) { hasPositioned } && !selecting // Beside the list, not inside it: the list carries the haze source the glass samples. JumpToBottomHaze( visible = jumpVisible, From 19e6cae02949de73c3172e9c93574e300c4b0f4c Mon Sep 17 00:00:00 2001 From: agent-team Date: Tue, 13 Oct 2026 22:48:36 -0400 Subject: [PATCH 03/23] fix(messenger): keep the jump-to-bottom button down while the list auto-scrolls --- .../screens/components/JumpToBottom.kt | 32 ----- .../screens/components/MessageList.kt | 130 ++++++++++-------- 2 files changed, 73 insertions(+), 89 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt index 391350bd46..fdb6c66c7a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt @@ -44,21 +44,10 @@ import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.HazeBlurStyle import dev.chrisbanes.haze.blur.HazeColorEffect import dev.chrisbanes.haze.blur.hazeBlur -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.snapshotFlow /** Past this many rows from the newest, a tap jumps rather than animates (from the old Flipchat). */ internal const val JumpToBottomAnimateMaxRows = 100 -/** - * How long the transcript has to stay off the newest row before the button appears. The insertion - * push and the typing row move the list through a transient non-zero offset on the way back to - * rest; hiding is immediate, showing is not, so those frames don't flash the button. - */ -private const val ShowDelayMillis = 120L - private val ButtonSize = 48.dp private val BadgeOutline = 2.dp private val HazeHeight = 112.dp @@ -71,27 +60,6 @@ internal suspend fun LazyListState.scrollToNewest() { if (jumpsToBottom(firstVisibleItemIndex)) scrollToItem(0) else animateScrollToItem(0) } -/** - * Whether the jump-to-bottom button should be up: the list has settled where this visit opens it, - * and is not resting on the newest row. Reads [restsOnNewest] as it stands, so it is true for the - * frames a spring leaves the offset non-zero; those are held back by [ShowDelayMillis]. - */ -@Composable -internal fun rememberJumpToBottomVisible(listState: LazyListState, hasPositioned: () -> Boolean): Boolean { - var visible by remember { mutableStateOf(false) } - LaunchedEffect(listState) { - snapshotFlow { hasPositioned() && !listState.restsOnNewest() }.collectLatest { away -> - if (away) { - delay(ShowDelayMillis) - visible = true - } else { - visible = false - } - } - } - return visible -} - /** * The haze behind the button: the chat background fading in toward the composer, so the button * and the rows it floats over read apart. Sits beside the transcript, never inside its haze diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 6fa1f6e6c7..7bddecddcc 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -147,6 +147,8 @@ internal fun MessageList( // rebuilt under the amount-entry step, and this visit's opening must not replay on the way // back. var hasPositioned by rememberSaveable { mutableStateOf(false) } + // True while the list is being carried to a new newest row, so the jump button stays down. + var autoScrolling by remember { mutableStateOf(false) } HandleMessageReads(listState, messages, enabled = hasPositioned) // Haptic feedback when a new incoming message arrives @@ -682,7 +684,9 @@ internal fun MessageList( } // Not while a message is raised: the backdrop is modal and the list can't be dragged, so // the button must not scroll it either. - val jumpVisible = rememberJumpToBottomVisible(listState) { hasPositioned } && !selecting + val jumpVisible by remember { + derivedStateOf { hasPositioned && !listState.restsOnNewest() && !selecting && !autoScrolling } + } // Beside the list, not inside it: the list carries the haze source the glass samples. JumpToBottomHaze( visible = jumpVisible, @@ -711,66 +715,78 @@ internal fun MessageList( .filterNotNull() .distinctUntilChanged() .collectLatest { newestKey -> - // The paged snapshot changes before the list lays it out, so wait for the next - // layout pass; until then the scroll position describes the old list. - withTimeoutOrNull(NextLayoutTimeoutMs) { - snapshotFlow { listState.layoutInfo }.drop(1).first() - } - // Opening from under the typing dots already moves the transcript. Checked after - // the layout, by which point the composition that decided it has run. - if (typingRow.grows(newestKey)) return@collectLatest - // The list holds its position by key, so a message arriving at index 0 leaves - // the one that was newest (now index 1) where it sat. Sitting at offset 0 means - // the transcript was resting on the newest message. - val atBottom = listState.firstVisibleItemIndex == 1 && - listState.firstVisibleItemScrollOffset == 0 - // Always scroll for own messages; only near-bottom for incoming - val nearBottom = listState.firstVisibleItemIndex <= 5 - val newest = messages.peek(0) as? ChatListItem.TranscriptMessage - val editing = editingMessageId - if (editing != null) { - // A message arriving mid-edit still moves the transcript, or it would land - // off the bottom edge with nothing to say it had arrived. What it can't do - // is carry the row being edited away: the scroll stops at the point that - // row would pass under the top bar, and the pin holds it there. - if (newest?.isFromSelf == true || nearBottom) { - val room = listState.layoutInfo.headroomAbove(messages, editing) - // Negative is toward index 0. The list settles on the newest message by - // itself if that comes before the cap. - if (room > 0) listState.animateScrollBy(-room.toFloat()) + // The state still describes the pre-insert layout here. A list resting on the + // newest row, or near it, or a send, is about to be carried to the new row; the + // button stays down for that instead of flashing for the insertion push. Scrolled + // far up nothing moves, so the button must stay. + val carried = listState.restsOnNewest() || + listState.firstVisibleItemIndex <= 4 || + (messages.peek(0) as? ChatListItem.TranscriptMessage)?.isFromSelf == true + if (carried) autoScrolling = true + try { + // The paged snapshot changes before the list lays it out, so wait for the next + // layout pass; until then the scroll position describes the old list. + withTimeoutOrNull(NextLayoutTimeoutMs) { + snapshotFlow { listState.layoutInfo }.drop(1).first() } - return@collectLatest - } - // Resting on the newest message, the arrival pushes the transcript up as iOS - // does: every row rides up by the new row's height on the insertion spring, - // while the new row rises into its slot. Placement animations can't do this — - // the move is a scroll, and a jump in scroll position resets them. - if (atBottom && (newest?.isFromSelf == true || nearBottom)) { - val height = listState.measuredHeight(0) - if (height != null) { - // Negative is toward index 0. - listState.animateScrollBy(-height.toFloat(), ChatAnimations.insertionPush) - // The row above can resize under the scroll (its receipt moving on), so - // settle exactly on the newest message rather than a few pixels short. - if (listState.firstVisibleItemIndex != 0 || - listState.firstVisibleItemScrollOffset != 0 - ) { - listState.requestScrollToItem(0, 0) + // Opening from under the typing dots already moves the transcript. Checked after + // the layout, by which point the composition that decided it has run. + if (typingRow.grows(newestKey)) return@collectLatest + // The list holds its position by key, so a message arriving at index 0 leaves + // the one that was newest (now index 1) where it sat. Sitting at offset 0 means + // the transcript was resting on the newest message. + val atBottom = listState.firstVisibleItemIndex == 1 && + listState.firstVisibleItemScrollOffset == 0 + // Always scroll for own messages; only near-bottom for incoming + val nearBottom = listState.firstVisibleItemIndex <= 5 + val newest = messages.peek(0) as? ChatListItem.TranscriptMessage + val editing = editingMessageId + if (editing != null) { + // A message arriving mid-edit still moves the transcript, or it would land + // off the bottom edge with nothing to say it had arrived. What it can't do + // is carry the row being edited away: the scroll stops at the point that + // row would pass under the top bar, and the pin holds it there. + if (newest?.isFromSelf == true || nearBottom) { + val room = listState.layoutInfo.headroomAbove(messages, editing) + // Negative is toward index 0. The list settles on the newest message by + // itself if that comes before the cap. + if (room > 0) listState.animateScrollBy(-room.toFloat()) } return@collectLatest } - } - when { - // Own message: anchor the new bubble during the next measure pass - // rather than animating to it. An animated scroll resolves its target - // offset up front, so the previous message's receipt collapsing out - // from under it moves the target mid-flight and the list overshoots - // and corrects — read as the bubble bouncing. iOS never hand-rolls - // this scroll at all: ChatLayout's keepContentOffsetAtBottomOnBatchUpdates - // pins the bottom edge across the one batch that both inserts the new - // cell and reconfigures the old one's receipt away. - newest?.isFromSelf == true -> listState.requestScrollToItem(0, 0) - nearBottom -> listState.animateScrollToItem(0) + // Resting on the newest message, the arrival pushes the transcript up as iOS + // does: every row rides up by the new row's height on the insertion spring, + // while the new row rises into its slot. Placement animations can't do this — + // the move is a scroll, and a jump in scroll position resets them. + if (atBottom && (newest?.isFromSelf == true || nearBottom)) { + val height = listState.measuredHeight(0) + if (height != null) { + // Negative is toward index 0. + listState.animateScrollBy(-height.toFloat(), ChatAnimations.insertionPush) + // The row above can resize under the scroll (its receipt moving on), so + // settle exactly on the newest message rather than a few pixels short. + if (listState.firstVisibleItemIndex != 0 || + listState.firstVisibleItemScrollOffset != 0 + ) { + listState.requestScrollToItem(0, 0) + } + return@collectLatest + } + } + when { + // Own message: anchor the new bubble during the next measure pass + // rather than animating to it. An animated scroll resolves its target + // offset up front, so the previous message's receipt collapsing out + // from under it moves the target mid-flight and the list overshoots + // and corrects — read as the bubble bouncing. iOS never hand-rolls + // this scroll at all: ChatLayout's keepContentOffsetAtBottomOnBatchUpdates + // pins the bottom edge across the one batch that both inserts the new + // cell and reconfigures the old one's receipt away. + newest?.isFromSelf == true -> listState.requestScrollToItem(0, 0) + nearBottom -> listState.animateScrollToItem(0) + } + } finally { + autoScrolling = false } } } From 8f23275da48e95669a0fbbd077d2bea43b9c8cf0 Mon Sep 17 00:00:00 2001 From: agent-team Date: Tue, 13 Oct 2026 22:49:18 -0400 Subject: [PATCH 04/23] test(chat): assert the unread badge as a live emission sequence --- .../shared/chat/UnreadBelowObservationTest.kt | 70 +++++++++++++++++++ .../persistence/dao/UnreadBelowBadgeTest.kt | 68 ++++++++++++------ 2 files changed, 115 insertions(+), 23 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBelowObservationTest.kt diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBelowObservationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBelowObservationTest.kt new file mode 100644 index 0000000000..ab8e080033 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBelowObservationTest.kt @@ -0,0 +1,70 @@ +package com.flipcash.shared.chat + +import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.internal.delegates.MessagingDelegate +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals + +/** + * The jump-to-bottom badge opens at the unread divider's number: [MessagingDelegate.observeUnreadBelow] + * and [MessagingDelegate.resolveUnreadBoundary] read the same self READ pointer and the same + * inbound count, so seeded identically they agree. + */ +class UnreadBelowObservationTest { + + private val chatId = ChatId("aabbccdd") + private val selfId = listOf(1, 2, 3) + + private val memberDataSource = mockk(relaxed = true) + private val messageDataSource = mockk(relaxed = true) + private val userManager = mockk(relaxed = true).also { + every { it.accountId } returns selfId + } + + init { + coEvery { messageDataSource.hasAtOrBelow(chatId, any()) } returns true + } + + private val delegate = MessagingDelegate( + chatController = mockk(relaxed = true), + messagingController = mockk(relaxed = true), + metadataDataSource = mockk(relaxed = true), + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + notificationManager = mockk(relaxed = true), + userManager = userManager, + stateHolder = mockk(relaxed = true), + analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), + ) + + @Test + fun `the badge opens at the divider's count`() = runTest { + coEvery { memberDataSource.getSelfReadPointerOrNull(chatId, selfId) } returns 2 + every { memberDataSource.observeSelfReadPointer(chatId, selfId) } returns flowOf(2L) + coEvery { messageDataSource.countInboundAfter(chatId, selfId, 2) } returns 3 + every { messageDataSource.observeInboundCountAfter(chatId, selfId, 2) } returns flowOf(3) + coEvery { messageDataSource.firstNotSentByAfter(chatId, selfId, 2) } returns 3 + + val boundary = delegate.resolveUnreadBoundary(chatId) as UnreadBoundary.At + + assertEquals(3, boundary.count) + assertEquals(boundary.count, delegate.observeUnreadBelow(chatId).first()) + } + + @Test + fun `no self row counts nothing`() = runTest { + every { memberDataSource.observeSelfReadPointer(chatId, selfId) } returns flowOf(null) + + assertEquals(0, delegate.observeUnreadBelow(chatId).first()) + } +} diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/UnreadBelowBadgeTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/UnreadBelowBadgeTest.kt index c7d00e0b45..7fce28e151 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/UnreadBelowBadgeTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/UnreadBelowBadgeTest.kt @@ -8,10 +8,10 @@ import com.flipcash.app.persistence.converters.MessageContentSerialized import com.flipcash.app.persistence.converters.MessagePointerSerialized import com.flipcash.app.persistence.entities.ChatMemberEntity import com.flipcash.app.persistence.entities.ChatMessageEntity +import app.cash.turbine.test import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map @@ -85,53 +85,75 @@ class UnreadBelowBadgeTest { listOf(message(OTHER_HEX, 1), message(OTHER_HEX, 2), message(SELF_HEX, 3), message(OTHER_HEX, 4), message(OTHER_HEX, 5)), ) open(readThrough = 2) + val dividerCount = messages.countInboundAfter(CHAT_HEX, SELF_HEX, 2) - assertEquals(messages.countInboundAfter(CHAT_HEX, SELF_HEX, 2), badge().first()) - assertEquals(2, badge().first()) + badge().test { + assertEquals(dividerCount, awaitItem()) + assertEquals(2, dividerCount) + expectNoEvents() + } } @Test - fun `advancing the read pointer decrements`() = runTest { + fun `advancing the read pointer emits 3 then 1 and ignores a late lower advance`() = runTest { messages.upsert(listOf(message(OTHER_HEX, 1), message(OTHER_HEX, 2), message(OTHER_HEX, 3), message(OTHER_HEX, 4))) open(readThrough = 1) - assertEquals(3, badge().first()) - members.advancePointer(CHAT_HEX, SELF_HEX, pointer(3)) - assertEquals(1, badge().first()) + badge().test { + assertEquals(3, awaitItem()) - // The pointer keeps its max: a late, lower advance doesn't bring the count back. - members.advancePointer(CHAT_HEX, SELF_HEX, pointer(2)) - assertEquals(1, badge().first()) + members.advancePointer(CHAT_HEX, SELF_HEX, pointer(3)) + assertEquals(1, awaitItem()) + + // The pointer keeps its max: a late, lower advance doesn't bring the count back. + members.advancePointer(CHAT_HEX, SELF_HEX, pointer(2)) + expectNoEvents() + } } @Test - fun `an inbound arrival below increments`() = runTest { + fun `an inbound arrival below emits n then n plus 1`() = runTest { messages.upsert(listOf(message(OTHER_HEX, 1), message(OTHER_HEX, 2))) open(readThrough = 2) - assertEquals(0, badge().first()) - messages.upsert(listOf(message(OTHER_HEX, 3))) - assertEquals(1, badge().first()) + badge().test { + assertEquals(0, awaitItem()) + + messages.upsert(listOf(message(OTHER_HEX, 3))) + assertEquals(1, awaitItem()) + + messages.upsert(listOf(message(OTHER_HEX, 4))) + assertEquals(2, awaitItem()) + } } @Test - fun `own send clears once the pointer reaches it, and own messages are never counted`() = runTest { + fun `own messages are never counted and the pointer reaching an own send clears the badge`() = runTest { messages.upsert(listOf(message(OTHER_HEX, 1), message(OTHER_HEX, 2), message(OTHER_HEX, 3))) open(readThrough = 0) - assertEquals(3, badge().first()) - // MessagingDelegate.send advances the pointer to the id the server gives the sent message. - messages.upsert(listOf(message(SELF_HEX, 4))) - assertEquals(3, badge().first()) - members.advancePointer(CHAT_HEX, SELF_HEX, pointer(4)) - assertEquals(0, badge().first()) + badge().test { + assertEquals(3, awaitItem()) + + // An own row changes nothing. + messages.upsert(listOf(message(SELF_HEX, 4))) + expectNoEvents() + + // MessagingDelegate.send advances the pointer to the id the server gives the sent + // message on success, which is above every inbound id; that is what clears the badge. + members.advancePointer(CHAT_HEX, SELF_HEX, pointer(4)) + assertEquals(0, awaitItem()) + } } @Test - fun `no self row counts nothing`() = runTest { + fun `no self row emits 0`() = runTest { messages.upsert(listOf(message(OTHER_HEX, 1))) - assertEquals(0, badge().first()) + badge().test { + assertEquals(0, awaitItem()) + expectNoEvents() + } } private companion object { From 14de3b4f5737a55176ce18299bb0c0d5017dde88 Mon Sep 17 00:00:00 2001 From: agent-team Date: Tue, 13 Oct 2026 22:50:54 -0400 Subject: [PATCH 05/23] fix(messenger): centre the unread pill on the button corner and announce the count --- .../core/src/main/res/values/strings.xml | 4 ++++ .../screens/components/JumpToBottom.kt | 19 +++++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index fc8cc923fd..7d3613b460 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1300,6 +1300,10 @@ Start a chat in Flipcash, then share again. %1$d attached Jump to latest message + + Jump to latest message, %1$d unread + Jump to latest message, %1$d unread + Add a message\u2026 Receive Tips From Everyone Add your name to receive tips diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt index fdb6c66c7a..44bad19d3c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListState @@ -32,8 +31,11 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.layout.layout +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.flipcash.features.messenger.R @@ -136,7 +138,11 @@ internal fun JumpToBottomButton( ) { Icon( imageVector = Icons.Rounded.KeyboardArrowDown, - contentDescription = stringResource(R.string.action_jumpToBottom), + contentDescription = if (unreadCount > 0) { + pluralStringResource(R.plurals.action_jumpToBottomUnread, unreadCount, unreadCount) + } else { + stringResource(R.string.action_jumpToBottom) + }, tint = Color.White, modifier = Modifier.size(28.dp), ) @@ -144,8 +150,13 @@ internal fun JumpToBottomButton( AnimatedVisibility( modifier = Modifier .align(Alignment.TopEnd) - // The pill's centre sits on the circle's top-trailing corner. - .offset(x = 4.dp, y = (-4).dp), + // The pill, outline included, is centred on the top-trailing corner of the button's + // bounds: placed half its own size out, without changing the size it reports. + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val dx = if (layoutDirection == LayoutDirection.Rtl) -placeable.width / 2 else placeable.width / 2 + layout(placeable.width, placeable.height) { placeable.place(dx, -placeable.height / 2) } + }, visible = unreadCount > 0, enter = fadeIn(tween(200)) + scaleIn(tween(200)), exit = fadeOut(tween(150)) + scaleOut(tween(150)), From b1fbdac97d199e4fa6293a9a237b3289da6292d0 Mon Sep 17 00:00:00 2001 From: agent-team Date: Wed, 14 Oct 2026 06:22:06 -0400 Subject: [PATCH 06/23] fix(messenger): let the unread badge wait for the account and database The count stream read the account id once and fell back to an empty flow when the per-user database was missing, so a screen that subscribed before login finished stayed at 0 for the session. Both inputs are now resolved reactively, as hasEverTipped does, and resubscribe when either changes. --- .../internal/delegates/MessagingDelegate.kt | 20 ++-- .../shared/chat/UnreadBelowObservationTest.kt | 26 +++++- .../sources/ChatMemberDataSource.kt | 23 +++-- .../sources/ChatMessageDataSource.kt | 19 ++-- .../sources/UnreadStreamReadinessTest.kt | 91 +++++++++++++++++++ 5 files changed, 157 insertions(+), 22 deletions(-) create mode 100644 apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/UnreadStreamReadinessTest.kt diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index 51fb1beb0e..ef21d8e0c3 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -223,15 +223,21 @@ class MessagingDelegate @Inject constructor( } @OptIn(ExperimentalCoroutinesApi::class) - override fun observeUnreadBelow(chatId: ChatId): Flow { - val selfId = userManager.accountId ?: return flowOf(0) - return memberDataSource.observeSelfReadPointer(chatId, selfId) - .flatMapLatest { readThrough -> - if (readThrough == null) flowOf(0) - else messageDataSource.observeInboundCountAfter(chatId, selfId, readThrough) + override fun observeUnreadBelow(chatId: ChatId): Flow = + // The account id is set during login, and the per-user DB is made then too (the data + // sources wait on it): a screen that subscribes before either is ready counts 0 until they + // are, and starts over if the account changes. + userManager.state.map { it.accountId } + .distinctUntilChanged() + .flatMapLatest { selfId -> + if (selfId == null) flowOf(0) + else memberDataSource.observeSelfReadPointer(chatId, selfId) + .flatMapLatest { readThrough -> + if (readThrough == null) flowOf(0) + else messageDataSource.observeInboundCountAfter(chatId, selfId, readThrough) + } } .distinctUntilChanged() - } override suspend fun countMessagesAfter(chatId: ChatId, messageId: Long): Int = messageDataSource.countAfter(chatId, messageId) diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBelowObservationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBelowObservationTest.kt index ab8e080033..d0b269bff2 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBelowObservationTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBelowObservationTest.kt @@ -8,6 +8,8 @@ import com.flipcash.shared.chat.internal.delegates.MessagingDelegate import io.mockk.coEvery import io.mockk.every import io.mockk.mockk +import app.cash.turbine.test +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -26,8 +28,10 @@ class UnreadBelowObservationTest { private val memberDataSource = mockk(relaxed = true) private val messageDataSource = mockk(relaxed = true) + private val account = MutableStateFlow(UserManager.State(accountId = selfId)) private val userManager = mockk(relaxed = true).also { - every { it.accountId } returns selfId + every { it.accountId } answers { account.value.accountId } + every { it.state } returns account } init { @@ -67,4 +71,24 @@ class UnreadBelowObservationTest { assertEquals(0, delegate.observeUnreadBelow(chatId).first()) } + + @Test + fun `subscribing before the account is ready counts 0, then the count once it is`() = runTest { + account.value = UserManager.State(accountId = null) + every { memberDataSource.observeSelfReadPointer(chatId, selfId) } returns flowOf(2L) + every { messageDataSource.observeInboundCountAfter(chatId, selfId, 2) } returns flowOf(3) + + delegate.observeUnreadBelow(chatId).test { + assertEquals(0, awaitItem()) + + account.value = UserManager.State(accountId = selfId) + assertEquals(3, awaitItem()) + + // Ready -> not ready -> ready resubscribes rather than keeping the old stream. + account.value = UserManager.State(accountId = null) + assertEquals(0, awaitItem()) + account.value = UserManager.State(accountId = selfId) + assertEquals(3, awaitItem()) + } + } } diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt index 1485df73aa..e11f9c44c3 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt @@ -9,9 +9,12 @@ import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.PointerType import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton @@ -68,16 +71,20 @@ class ChatMemberDataSource @Inject constructor( /** * [getSelfReadPointerOrNull] as a stream, so the pointer the read reporter advances is the one * observers see. Pointer values only: a profile or roster write touches the self row without - * moving the pointer, and must not wake anyone downstream. + * moving the pointer, and must not wake anyone downstream. Resolved through + * [FlipcashDatabase.observeInstance], so a subscriber that starts before the per-user DB exists + * sees `null` until it does, and follows an account switch onto the new DB. */ + @OptIn(ExperimentalCoroutinesApi::class) fun observeSelfReadPointer(chatId: ChatId, selfId: ID): Flow = - db?.chatMemberDao() - ?.observeMember(mapper.chatIdHex(chatId), mapper.userIdHex(selfId)) - ?.map { self -> - self?.let { mapper.pointers(it).firstOrNull { p -> p.type == PointerType.READ }?.value ?: 0L } - } - ?.distinctUntilChanged() - ?: emptyFlow() + FlipcashDatabase.observeInstance().flatMapLatest { database -> + database?.chatMemberDao() + ?.observeMember(mapper.chatIdHex(chatId), mapper.userIdHex(selfId)) + ?.map { self -> + self?.let { mapper.pointers(it).firstOrNull { p -> p.type == PointerType.READ }?.value ?: 0L } + } + ?: flowOf(null) + }.distinctUntilChanged() suspend fun upsert(chatId: ChatId, members: List) { val database = db ?: return diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt index ed46cb56bc..e8ee170a2c 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt @@ -191,13 +191,20 @@ class ChatMessageDataSource @Inject constructor( afterId = afterId, ) ?: 0 - /** [countInboundAfter] as a stream, so arrivals and pointer moves both update it. */ + /** + * [countInboundAfter] as a stream, so arrivals and pointer moves both update it. Resolved through + * [FlipcashDatabase.observeInstance] like [hasEverTipped]: a subscriber that starts before the + * per-user DB exists counts 0 until it does, rather than completing empty for good. + */ + @OptIn(ExperimentalCoroutinesApi::class) fun observeInboundCountAfter(chatId: ChatId, selfId: ID, afterId: Long): Flow = - db?.chatMessageDao()?.observeInboundCountAfter( - chatIdHex = mapper.chatIdHex(chatId), - selfIdHex = mapper.userIdHex(selfId), - afterId = afterId, - ) ?: emptyFlow() + FlipcashDatabase.observeInstance().flatMapLatest { database -> + database?.chatMessageDao()?.observeInboundCountAfter( + chatIdHex = mapper.chatIdHex(chatId), + selfIdHex = mapper.userIdHex(selfId), + afterId = afterId, + ) ?: flowOf(0) + }.distinctUntilChanged() /** The first stored message in [chatId] past [afterId] that [selfId] did not send, or `null`. */ suspend fun firstNotSentByAfter(chatId: ChatId, selfId: ID, afterId: Long): Long? = diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/UnreadStreamReadinessTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/UnreadStreamReadinessTest.kt new file mode 100644 index 0000000000..931396eff4 --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/UnreadStreamReadinessTest.kt @@ -0,0 +1,91 @@ +package com.flipcash.app.persistence.sources + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import app.cash.turbine.test +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.converters.MessageContentSerialized +import com.flipcash.app.persistence.converters.MessagePointerSerialized +import com.flipcash.app.persistence.entities.ChatMemberEntity +import com.flipcash.app.persistence.entities.ChatMessageEntity +import com.flipcash.app.persistence.sources.mapper.chat.ChatEntityMapper +import com.flipcash.services.models.chat.ChatId +import com.getcode.utils.hexEncodedString +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals + +/** + * The per-user database is created at login, so the jump-to-bottom badge's streams can be + * subscribed before it exists. They must wait for it, not complete empty for good. + */ +@RunWith(RobolectricTestRunner::class) +class UnreadStreamReadinessTest { + + private val mapper = ChatEntityMapper() + private val members = ChatMemberDataSource(mapper) + private val messages = ChatMessageDataSource(mapper, mockk(relaxed = true), mockk(relaxed = true)) + + private val chatId = ChatId(listOf(0xAB.toByte())) + private val selfId = listOf(1, 2, 3) + private val otherId = listOf(4, 5, 6) + + @After + fun tearDown() { + FlipcashDatabase.closeDb() + } + + private fun openDatabase() { + FlipcashDatabase.init(ApplicationProvider.getApplicationContext(), "entropy-a") + } + + @Test + fun `the read pointer is null until the database exists and then follows it`() = runTest { + FlipcashDatabase.closeDb() + + members.observeSelfReadPointer(chatId, selfId).test { + assertEquals(null, awaitItem()) + + openDatabase() + val db = FlipcashDatabase.requireInstance() + db.chatMemberDao().upsert( + ChatMemberEntity( + chatIdHex = mapper.chatIdHex(chatId), + userIdHex = selfId.hexEncodedString(), + pointersJson = listOf( + MessagePointerSerialized(type = "READ", userIdHex = selfId.hexEncodedString(), value = 2), + ), + ), + ) + assertEquals(2L, awaitItem()) + } + } + + @Test + fun `the inbound count is 0 until the database exists and then counts`() = runTest { + FlipcashDatabase.closeDb() + + messages.observeInboundCountAfter(chatId, selfId, 1).test { + assertEquals(0, awaitItem()) + + openDatabase() + FlipcashDatabase.requireInstance().chatMessageDao().upsert( + listOf(2L, 3L).map { id -> + ChatMessageEntity( + chatIdHex = mapper.chatIdHex(chatId), + messageId = id, + senderIdHex = otherId.hexEncodedString(), + contentJson = listOf(MessageContentSerialized.Text("m$id")), + timestampEpochMs = id * 1_000, + unreadSeq = id, + ) + }, + ) + assertEquals(2, awaitItem()) + } + } +} From df0f31943939ee5e0b07b30aa61ea9fcc282f863 Mon Sep 17 00:00:00 2001 From: agent-team Date: Wed, 14 Oct 2026 06:24:13 -0400 Subject: [PATCH 07/23] fix(messenger): hide jump-to-bottom while a message is selected The visibility derivation was remembered without a key, but selecting is a plain Boolean, so it kept the value from its first read and the button stayed up behind the selection backdrop. --- .../screens/components/MessageList.kt | 4 +- .../components/JumpToBottomVisibilityTest.kt | 106 ++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottomVisibilityTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 7bddecddcc..42b4c0e7a1 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -684,7 +684,9 @@ internal fun MessageList( } // Not while a message is raised: the backdrop is modal and the list can't be dragged, so // the button must not scroll it either. - val jumpVisible by remember { + // Keyed on selecting: it is a plain Boolean, not state, so the derivation would keep the + // value it first read. + val jumpVisible by remember(selecting) { derivedStateOf { hasPositioned && !listState.restsOnNewest() && !selecting && !autoScrolling } } // Beside the list, not inside it: the list carries the haze source the glass samples. diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottomVisibilityTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottomVisibilityTest.kt new file mode 100644 index 0000000000..c9d8261772 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottomVisibilityTest.kt @@ -0,0 +1,106 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.paging.LoadState +import androidx.paging.LoadStates +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.withSeparators +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.UnreadBoundary +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.LinkCard +import com.flipcash.shared.chat.models.LinkCardResolution +import com.flipcash.shared.chat.models.SeparatorConfig +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOf +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +/** + * The jump-to-bottom button is down while a message is raised: the backdrop is modal and the list + * can't be dragged, so the button must not scroll it either. Opened at an unread divider well off + * the bottom, where the button is up. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "w400dp-h800dp-xhdpi") +class JumpToBottomVisibilityTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private val start = Instant.fromEpochMilliseconds(1_700_000_000_000) + private val button = "Jump to latest message" + + private fun bubble(id: Long) = ChatListItem.ContentBubble( + messageId = id, + contentIndex = 0, + content = MessageContent.Text("m$id"), + isFromSelf = false, + timestamp = start + id.minutes, + ) + + @Test + fun `the button goes away while a message is selected and returns after`() { + val boundary = UnreadBoundary.At(readThrough = 10, count = 30) + val newestFirst = (40L downTo 1L).map(::bubble) + val loaded = LoadState.NotLoading(endOfPaginationReached = true) + val page = PagingData.from(newestFirst, LoadStates(loaded, loaded, loaded)) + val paged = flowOf(page.withSeparators(boundary, SeparatorConfig.DayOnly)) + var selected by mutableStateOf(null) + + composeRule.setContent { + FlipcashThemeWrapper().Wrap { + MessageList( + contentPadding = PaddingValues(), + state = ChatViewModel.State( + unreadBoundary = boundary, + unreadWalkBudget = 30, + selection = selected, + ), + messages = paged.collectAsLazyPagingItems(), + separatorConfig = SeparatorConfig.DayOnly, + onAction = {}, + linkCardResolution = Unresolved, + ) + } + } + composeRule.waitUntil(5_000) { + (1..40).any { composeRule.onAllNodesWithText("m$it").fetchSemanticsNodes().isNotEmpty() } + } + composeRule.waitForIdle() + composeRule.onNodeWithContentDescription(button).assertIsDisplayed() + + selected = newestFirst[20] + composeRule.waitForIdle() + composeRule.mainClock.advanceTimeBy(500) + composeRule.onNodeWithContentDescription(button).assertDoesNotExist() + + selected = null + composeRule.waitForIdle() + composeRule.mainClock.advanceTimeBy(500) + composeRule.onNodeWithContentDescription(button).assertIsDisplayed() + } + + private object Unresolved : LinkCardResolution { + override val revision: StateFlow = MutableStateFlow(0) + override fun peek(card: LinkCard): LinkCard = card + override suspend fun resolve(card: LinkCard): LinkCard = card + } +} From 028d4b2ffb51497a326dcfe49f80eaac45cc1f5a Mon Sep 17 00:00:00 2001 From: agent-team Date: Wed, 14 Oct 2026 06:27:54 -0400 Subject: [PATCH 08/23] feat(messenger): hold the jump-to-bottom badge at the divider's count until the viewer scrolls The badge opens at the unread divider's own count instead of subtracting rows already read on screen, and rises by one for each inbound message that arrives after the open. It follows the live count after the first drag or fling on the settled transcript, or once the viewer's own message is confirmed. Scrolls the list makes itself do not release it. --- .../app/messenger/internal/ChatViewModel.kt | 66 +++++++++++++++---- .../app/messenger/internal/UnreadBadge.kt | 58 ++++++++++++++++ .../internal/screens/MessengerScreen.kt | 1 + .../screens/components/MessageList.kt | 13 ++++ .../flipcash/shared/chat/ChatCoordinator.kt | 12 ++++ .../internal/delegates/MessagingDelegate.kt | 15 +++++ .../app/persistence/dao/ChatMessageDao.kt | 12 ++++ .../sources/ChatMessageDataSource.kt | 7 ++ 8 files changed, 171 insertions(+), 13 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadBadge.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index f30a53a944..d7b163de55 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -424,6 +424,19 @@ internal class ChatViewModel @AssistedInject constructor( val unreadBoundary: UnreadBoundary = UnreadBoundary.Resolving, /** How many stored messages sit past the boundary, which bounds the walk that opens at it. */ val unreadWalkBudget: Int = 0, + /** + * Whether the jump-to-bottom badge is still holding the number the chat opened with (see + * [unreadBadgeCount]). Starts true; released by the first drag or fling the viewer makes + * on the transcript after it has settled ([Event.UserScrolledTranscript]), or by their own + * message being confirmed ([Event.OwnMessageSent]). Programmatic scrolls never release it. + */ + val unreadBadgeFrozen: Boolean = true, + /** + * The newest inbound message id stored when the chat opened, or [NO_INBOUND_AT_OPEN]; null + * until the open has resolved. Inbound messages past it are the arrivals the frozen badge + * adds to the divider's count. + */ + val newestInboundIdAtOpen: Long? = null, /** * True while the delete confirmation is up. * @@ -995,7 +1008,17 @@ internal class ChatViewModel @AssistedInject constructor( data class JumpResolved(val messageId: Long, val budget: Int) : Event /** The chat's unread boundary, read once when its id became known. */ - data class UnreadBoundaryResolved(val boundary: UnreadBoundary, val walkBudget: Int) : Event + data class UnreadBoundaryResolved( + val boundary: UnreadBoundary, + val walkBudget: Int, + val newestInboundId: Long = NO_INBOUND_AT_OPEN, + ) : Event + + /** The viewer dragged or flung the settled transcript; releases the badge's freeze. */ + data object UserScrolledTranscript : Event + + /** The server confirmed a message the viewer sent; the read pointer has moved past everything. */ + data object OwnMessageSent : Event data object JumpConsumed : Event } @@ -1048,20 +1071,20 @@ internal class ChatViewModel @AssistedInject constructor( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) /** - * How many inbound messages sit past the viewer's READ pointer, for the jump-to-bottom badge. - * The pointer is the one the read reporter advances, so the badge falls as rows are read and - * cannot disagree with it. An outside viewer's pointer never moves, so it counts nothing. + * The jump-to-bottom badge: see [unreadBadgeCount]. Its live term counts inbound messages past + * the viewer's READ pointer, the one the read reporter advances, so once released the badge + * falls as rows are read and cannot disagree with it. */ @OptIn(ExperimentalCoroutinesApi::class) val unreadBelowCount = stateFlow.mapNotNull { it.chatId } .distinctUntilChanged() .flatMapLatest { chatId -> - combine( - chatCoordinator.observeUnreadBelow(chatId), - stateFlow.map { it.isOutsideGroup }.distinctUntilChanged(), - ) { count, outside -> if (outside) 0 else count } + unreadBadgeCounts( + state = stateFlow, + live = chatCoordinator.observeUnreadBelow(chatId), + arrivalsAfter = { afterId -> chatCoordinator.observeInboundAfter(chatId, afterId) }, + ) } - .distinctUntilChanged() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) /** @@ -2886,11 +2909,14 @@ internal class ChatViewModel @AssistedInject constructor( stateFlow.mapNotNull { it.chatId } .distinctUntilChanged() .onEach { chatId -> + // Before the boundary: an arrival between the two is counted twice rather than + // missed. + val newestInbound = chatCoordinator.newestInboundId(chatId) ?: NO_INBOUND_AT_OPEN val boundary = chatCoordinator.resolveUnreadBoundary(chatId) val budget = (boundary as? UnreadBoundary.At) ?.let { chatCoordinator.countMessagesAfter(chatId, it.readThrough) } ?: 0 - dispatchEvent(Event.UnreadBoundaryResolved(boundary, budget)) + dispatchEvent(Event.UnreadBoundaryResolved(boundary, budget, newestInbound)) } // The list waits on this, and on main it queued behind the transcript's first frame. .flowOn(dispatchers.IO) @@ -3073,6 +3099,7 @@ internal class ChatViewModel @AssistedInject constructor( } sent .onSuccess { + dispatchEvent(Event.OwnMessageSent) trace("message sent successfully") analytics.track(ChatEvents.sentMessage(chatType.analytics, null)) } @@ -3101,11 +3128,17 @@ internal class ChatViewModel @AssistedInject constructor( viewModelScope.launch { if (content.photoBody() != null) { chatCoordinator.retryMedia(chatId, pendingId) - .onSuccess { trace("retry photo sent successfully") } + .onSuccess { + dispatchEvent(Event.OwnMessageSent) + trace("retry photo sent successfully") + } .onFailure { trace("retry photo failed - ${it.localizedMessage}") } } else { chatCoordinator.retryMessage(chatId, pendingId, listOf(content)) - .onSuccess { trace("retry message sent successfully") } + .onSuccess { + dispatchEvent(Event.OwnMessageSent) + trace("retry message sent successfully") + } .onFailure { trace("retry message failed - ${it.localizedMessage}") } } } @@ -3718,7 +3751,14 @@ internal class ChatViewModel @AssistedInject constructor( } Event.JumpConsumed -> { state -> state.copy(jumpTarget = null, jumpBudget = null) } is Event.UnreadBoundaryResolved -> { state -> - state.copy(unreadBoundary = event.boundary, unreadWalkBudget = event.walkBudget) + state.copy( + unreadBoundary = event.boundary, + unreadWalkBudget = event.walkBudget, + newestInboundIdAtOpen = event.newestInboundId, + ) + } + Event.UserScrolledTranscript, Event.OwnMessageSent -> { state -> + state.copy(unreadBadgeFrozen = false) } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadBadge.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadBadge.kt new file mode 100644 index 0000000000..d68c58fbae --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadBadge.kt @@ -0,0 +1,58 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.shared.chat.UnreadBoundary +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +/** + * [ChatViewModel.State.newestInboundIdAtOpen] for a chat with no inbound message stored at open: + * below any real message id, so every inbound message that comes later counts as an arrival. + */ +internal const val NO_INBOUND_AT_OPEN = -1L + +/** + * What the jump-to-bottom badge shows. + * + * While [frozen] it opens at the unread divider's own count and holds there, whatever the read + * pointer does underneath, rising by one for each inbound message that arrives after the open. + * Once released it follows [live], the inbound messages past the viewer's read pointer. A viewer + * outside the group has no read pointer to follow and sees nothing. + */ +internal fun unreadBadgeCount( + frozen: Boolean, + boundary: UnreadBoundary, + arrivalsSinceOpen: Int, + live: Int, + outsideGroup: Boolean, +): Int = when { + outsideGroup -> 0 + frozen -> ((boundary as? UnreadBoundary.At)?.count ?: 0) + arrivalsSinceOpen + else -> live +} + +/** + * The badge over a chat's [state]. [live] is the inbound count past the read pointer; + * [arrivalsAfter] counts inbound messages past an id, and is asked for the id that was newest at + * open. + */ +@OptIn(ExperimentalCoroutinesApi::class) +internal fun unreadBadgeCounts( + state: Flow, + live: Flow, + arrivalsAfter: (afterId: Long) -> Flow, +): Flow { + val arrivals = state.map { it.newestInboundIdAtOpen } + .distinctUntilChanged() + .flatMapLatest { openedAt -> if (openedAt == null) flowOf(0) else arrivalsAfter(openedAt) } + val inputs = state + .map { Triple(it.unreadBadgeFrozen, it.unreadBoundary, it.isOutsideGroup) } + .distinctUntilChanged() + return combine(inputs, arrivals, live) { (frozen, boundary, outside), arrived, liveCount -> + unreadBadgeCount(frozen, boundary, arrived, liveCount, outside) + }.distinctUntilChanged() +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index ad0d1575b5..cc699102b3 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -517,6 +517,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { onJumpConsumed = { viewModel.dispatchEvent(ChatViewModel.Event.JumpConsumed) }, topBarBottom = barHeight, unreadBelowCount = unreadBelowCount, + onUserScrolled = { viewModel.dispatchEvent(ChatViewModel.Event.UserScrolledTranscript) }, hazeState = hazeState, ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 42b4c0e7a1..fb0a760ee3 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -13,6 +13,7 @@ import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.DragInteraction import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -98,6 +99,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.withTimeoutOrNull @@ -118,6 +120,7 @@ internal fun MessageList( onJumpConsumed: () -> Unit = {}, topBarBottom: Dp = 0.dp, unreadBelowCount: Int = 0, + onUserScrolled: () -> Unit = {}, hazeState: HazeState? = null, ) { val keyboard = rememberKeyboardController() @@ -150,6 +153,16 @@ internal fun MessageList( // True while the list is being carried to a new newest row, so the jump button stays down. var autoScrolling by remember { mutableStateOf(false) } HandleMessageReads(listState, messages, enabled = hasPositioned) + // The first drag or fling after the list has settled. Not isScrollInProgress, which is + // also true through the animated and instant scrolls the list makes itself (opening, + // auto-scroll, the jump button): only a finger starts a DragInteraction, and a fling + // starts from one. + val currentOnUserScrolled by rememberUpdatedState(onUserScrolled) + LaunchedEffect(listState, hasPositioned) { + if (!hasPositioned) return@LaunchedEffect + listState.interactionSource.interactions.filterIsInstance().first() + currentOnUserScrolled() + } // Haptic feedback when a new incoming message arrives // Track the newest message key — only fire when it changes to an incoming message diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index 400997c41c..85d872192b 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -342,6 +342,18 @@ interface MessagingOperations { */ fun observeUnreadBelow(chatId: ChatId): Flow + /** + * The newest inbound, non-deleted message id stored in [chatId], or `null` when there is none. + * Read when a chat opens: inbound messages past it are the ones that arrived after the open. + */ + suspend fun newestInboundId(chatId: ChatId): Long? + + /** + * How many inbound, non-deleted messages in [chatId] sit past [afterId], live, whatever the + * read pointer says. [afterId] below every stored id counts them all. + */ + fun observeInboundAfter(chatId: ChatId, afterId: Long): Flow + /** How many stored messages in [chatId] sit past [messageId]. Bounds the walk to the unread divider. */ suspend fun countMessagesAfter(chatId: ChatId, messageId: Long): Int diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index ef21d8e0c3..a546dac24a 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -239,6 +239,21 @@ class MessagingDelegate @Inject constructor( } .distinctUntilChanged() + override suspend fun newestInboundId(chatId: ChatId): Long? { + val selfId = userManager.accountId ?: return null + return messageDataSource.newestInboundId(chatId, selfId) + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun observeInboundAfter(chatId: ChatId, afterId: Long): Flow = + userManager.state.map { it.accountId } + .distinctUntilChanged() + .flatMapLatest { selfId -> + if (selfId == null) flowOf(0) + else messageDataSource.observeInboundCountAfter(chatId, selfId, afterId) + } + .distinctUntilChanged() + override suspend fun countMessagesAfter(chatId: ChatId, messageId: Long): Int = messageDataSource.countAfter(chatId, messageId) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt index 68a4bf3213..cbab3c6ac1 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt @@ -123,6 +123,18 @@ interface ChatMessageDao { ) fun observeInboundCountAfter(chatIdHex: String, selfIdHex: String, afterId: Long): Flow + /** + * The newest inbound, non-deleted message id in [chatIdHex], or `null` when there is none. Same + * WHERE clause as [countInboundAfter]: "an arrival" and "unread" must mean the same rows. + */ + @Query( + "SELECT MAX(message_id) FROM chat_messages " + + "WHERE chat_id_hex = :chatIdHex " + + "AND sender_id_hex IS NOT NULL AND sender_id_hex != :selfIdHex " + + "AND is_deleted = 0" + ) + suspend fun newestInboundId(chatIdHex: String, selfIdHex: String): Long? + /** * The first stored message in [chatIdHex] after [afterId] that [selfIdHex] did not send, * tombstones included, or `null` when only the viewer's own messages follow. The unread divider diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt index e8ee170a2c..a79dceaf9e 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt @@ -191,6 +191,13 @@ class ChatMessageDataSource @Inject constructor( afterId = afterId, ) ?: 0 + /** The newest stored, non-deleted message in [chatId] that someone other than [selfId] sent, or `null`. */ + suspend fun newestInboundId(chatId: ChatId, selfId: ID): Long? = + db?.chatMessageDao()?.newestInboundId( + chatIdHex = mapper.chatIdHex(chatId), + selfIdHex = mapper.userIdHex(selfId), + ) + /** * [countInboundAfter] as a stream, so arrivals and pointer moves both update it. Resolved through * [FlipcashDatabase.observeInstance] like [hasEverTipped]: a subscriber that starts before the From aadcaf0384062c2978ed961dd9c8507a9b1fa702 Mon Sep 17 00:00:00 2001 From: agent-team Date: Wed, 14 Oct 2026 06:27:54 -0400 Subject: [PATCH 09/23] test(chat): cover the badge freeze and what releases it --- .../app/messenger/internal/UnreadBadgeTest.kt | 148 ++++++++++++++++++ .../components/UserScrollReleaseTest.kt | 105 +++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadBadgeTest.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/UserScrollReleaseTest.kt diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadBadgeTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadBadgeTest.kt new file mode 100644 index 0000000000..878b8bc180 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadBadgeTest.kt @@ -0,0 +1,148 @@ +package com.flipcash.app.messenger.internal + +import app.cash.turbine.test +import com.flipcash.shared.chat.UnreadBoundary +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals + +/** + * The jump-to-bottom badge opens at the unread divider's count, holds there until the viewer + * drags the transcript, and follows the live count from then on. + */ +class UnreadBadgeTest { + + private val divider = UnreadBoundary.At(readThrough = 10, count = 5) + + private val state = MutableStateFlow(ChatViewModel.State()) + private val live = MutableStateFlow(5) + private val arrivals = MutableStateFlow(0) + private val askedAfter = mutableListOf() + + private fun badge() = unreadBadgeCounts( + state = state, + live = live, + arrivalsAfter = { afterId -> askedAfter += afterId; arrivals }, + ) + + private fun dispatch(event: ChatViewModel.Event) = + state.update { ChatViewModel.stateReducer(event, UnreadDividerLifetime.UntilClose)(it) } + + private fun open(boundary: UnreadBoundary, newestInbound: Long = 15) = + dispatch(ChatViewModel.Event.UnreadBoundaryResolved(boundary, walkBudget = 5, newestInboundId = newestInbound)) + + @Test + fun `opens at the divider's count even with unread rows already read on screen`() = runTest { + open(divider) + // Three of the five unread rows were on screen at open and the read pointer has moved past them. + live.value = 2 + + badge().test { + assertEquals(5, awaitItem()) + expectNoEvents() + } + } + + @Test + fun `after a user drag the badge follows the live count`() = runTest { + open(divider) + live.value = 2 + + badge().test { + assertEquals(5, awaitItem()) + + dispatch(ChatViewModel.Event.UserScrolledTranscript) + assertEquals(2, awaitItem()) + + live.value = 1 + assertEquals(1, awaitItem()) + } + } + + @Test + fun `reading rows does not move a frozen badge`() = runTest { + open(divider) + + badge().test { + assertEquals(5, awaitItem()) + + live.value = 3 + live.value = 0 + expectNoEvents() + } + } + + @Test + fun `no divider means no badge`() = runTest { + open(UnreadBoundary.None, newestInbound = NO_INBOUND_AT_OPEN) + live.value = 0 + + badge().test { + assertEquals(0, awaitItem()) + expectNoEvents() + } + } + + @Test + fun `an inbound arrival while frozen adds to the divider's count`() = runTest { + open(divider) + + badge().test { + assertEquals(5, awaitItem()) + + arrivals.value = 1 + live.value = 6 + assertEquals(6, awaitItem()) + + arrivals.value = 2 + live.value = 7 + assertEquals(7, awaitItem()) + } + assertEquals(listOf(15L), askedAfter.distinct()) + } + + @Test + fun `with no inbound at open every later inbound counts as an arrival`() = runTest { + open(UnreadBoundary.None, newestInbound = NO_INBOUND_AT_OPEN) + live.value = 0 + + badge().test { + assertEquals(0, awaitItem()) + + arrivals.value = 1 + live.value = 1 + assertEquals(1, awaitItem()) + } + // Asked for everything past an id below any real one. + assertEquals(listOf(NO_INBOUND_AT_OPEN), askedAfter.distinct()) + } + + @Test + fun `an own send that the server confirmed clears the badge`() = runTest { + open(divider) + + badge().test { + assertEquals(5, awaitItem()) + + // The delegate advances the read pointer to the sent message before it reports success. + live.value = 0 + dispatch(ChatViewModel.Event.OwnMessageSent) + assertEquals(0, awaitItem()) + } + } + + @Test + fun `the id newest at open is read once, from the resolved open`() = runTest { + // Before the open resolves there is nothing to count arrivals past. + badge().test { + assertEquals(0, awaitItem()) + assertEquals(emptyList(), askedAfter) + + open(divider, newestInbound = 15) + assertEquals(5, awaitItem()) + } + assertEquals(listOf(15L), askedAfter.distinct()) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/UserScrollReleaseTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/UserScrollReleaseTest.kt new file mode 100644 index 0000000000..d1cd3aae05 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/UserScrollReleaseTest.kt @@ -0,0 +1,105 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeDown +import androidx.paging.LoadState +import androidx.paging.LoadStates +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.withSeparators +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.UnreadBoundary +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.LinkCard +import com.flipcash.shared.chat.models.LinkCardResolution +import com.flipcash.shared.chat.models.SeparatorConfig +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOf +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +/** + * What releases the jump-to-bottom badge's freeze: a finger on the transcript after it has + * settled, and nothing the list does by itself. isScrollInProgress can't tell them apart, so the + * list listens for the drag instead. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "w400dp-h800dp-xhdpi") +class UserScrollReleaseTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private val start = Instant.fromEpochMilliseconds(1_700_000_000_000) + private val button = "Jump to latest message" + + private fun bubble(id: Long) = ChatListItem.ContentBubble( + messageId = id, + contentIndex = 0, + content = MessageContent.Text("m$id"), + isFromSelf = false, + timestamp = start + id.minutes, + ) + + @Test + fun `the jump button's own scroll does not release the badge, a drag does`() { + val boundary = UnreadBoundary.At(readThrough = 10, count = 30) + val newestFirst = (40L downTo 1L).map(::bubble) + val loaded = LoadState.NotLoading(endOfPaginationReached = true) + val page = PagingData.from(newestFirst, LoadStates(loaded, loaded, loaded)) + val paged = flowOf(page.withSeparators(boundary, SeparatorConfig.DayOnly)) + var released = 0 + + composeRule.setContent { + FlipcashThemeWrapper().Wrap { + MessageList( + contentPadding = PaddingValues(), + state = ChatViewModel.State(unreadBoundary = boundary, unreadWalkBudget = 30), + messages = paged.collectAsLazyPagingItems(), + separatorConfig = SeparatorConfig.DayOnly, + onAction = {}, + linkCardResolution = Unresolved, + onUserScrolled = { released++ }, + ) + } + } + composeRule.waitUntil(5_000) { + (1..40).any { composeRule.onAllNodesWithText("m$it").fetchSemanticsNodes().isNotEmpty() } + } + composeRule.waitForIdle() + // The opening walk to the divider was a programmatic scroll. + assertEquals(0, released, "the opening walk released the badge") + + composeRule.onNodeWithContentDescription(button).performClick() + composeRule.waitForIdle() + composeRule.onNodeWithText("m40").assertIsDisplayed() + assertEquals(0, released, "the jump button's animated scroll released the badge") + + composeRule.onNodeWithText("m40").performTouchInput { swipeDown() } + composeRule.waitForIdle() + assertEquals(1, released) + } + + private object Unresolved : LinkCardResolution { + override val revision: StateFlow = MutableStateFlow(0) + override fun peek(card: LinkCard): LinkCard = card + override suspend fun resolve(card: LinkCard): LinkCard = card + } +} From 44053f338eaf72cb2a843fd7e4761cb9d7502a44 Mon Sep 17 00:00:00 2001 From: agent-team Date: Wed, 14 Oct 2026 06:48:28 -0400 Subject: [PATCH 10/23] test(persistence): cover the newest-inbound id query --- .../app/persistence/dao/ChatMessageDaoTest.kt | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt index 57397b5574..c9dc458699 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt @@ -228,6 +228,28 @@ class ChatMessageDaoTest { private suspend fun unread(pointer: Long) = dao.countInboundAfter(CHAT_HEX, SELF_HEX, pointer) + @Test + fun `newestInboundId is the highest id among others' messages`() = runTest { + dao.upsert(listOf(from(SENDER_HEX, 3), from(SENDER_HEX, 15), from(SENDER_HEX, 7))) + + assertEquals(15L, dao.newestInboundId(CHAT_HEX, SELF_HEX)) + } + + @Test + fun `newestInboundId ignores the viewer's own and deleted rows`() = runTest { + dao.upsert(listOf(from(SENDER_HEX, 3), from(SELF_HEX, 20), tombstone(30).copy(senderIdHex = SENDER_HEX))) + + assertEquals(3L, dao.newestInboundId(CHAT_HEX, SELF_HEX)) + } + + @Test + fun `newestInboundId is null for an empty chat`() = runTest { + dao.upsert(listOf(from(SELF_HEX, 1))) + + assertNull(dao.newestInboundId(CHAT_HEX, SELF_HEX)) + assertNull(dao.newestInboundId(OTHER_HEX, SELF_HEX)) + } + @Test fun `countInboundAfter counts only others' messages past the pointer`() = runTest { dao.upsert(listOf(from(SENDER_HEX, 1), from(SELF_HEX, 2), from(SENDER_HEX, 3), from(SENDER_HEX, 4))) From a8b57219a1d1f7e08732ded501d13e34db2187db Mon Sep 17 00:00:00 2001 From: agent-team Date: Wed, 14 Oct 2026 06:48:37 -0400 Subject: [PATCH 11/23] fix(messenger): wait for the account and database before the open snapshot newestInboundId and resolveUnreadBoundary returned null/None while the account id or the per-user database was missing. The view model mapped null to -1, so once both came up every inbound row in the chat counted as an arrival. Both now suspend until ready, so -1 only means no inbound at open. --- .../app/messenger/internal/ChatViewModel.kt | 5 ++- .../flipcash/shared/chat/ChatCoordinator.kt | 1 + .../internal/delegates/MessagingDelegate.kt | 17 ++++++- .../shared/chat/UnreadBelowObservationTest.kt | 45 +++++++++++++++++++ .../sources/ChatMessageDataSource.kt | 7 +++ 5 files changed, 71 insertions(+), 4 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index d7b163de55..c0c5d04e72 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -2909,8 +2909,9 @@ internal class ChatViewModel @AssistedInject constructor( stateFlow.mapNotNull { it.chatId } .distinctUntilChanged() .onEach { chatId -> - // Before the boundary: an arrival between the two is counted twice rather than - // missed. + // Both calls wait for the account and database, so -1 only ever means "ready, and + // no inbound stored". Before the boundary: an arrival between the two is counted + // twice rather than missed. val newestInbound = chatCoordinator.newestInboundId(chatId) ?: NO_INBOUND_AT_OPEN val boundary = chatCoordinator.resolveUnreadBoundary(chatId) val budget = (boundary as? UnreadBoundary.At) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index 85d872192b..bac52b0470 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -345,6 +345,7 @@ interface MessagingOperations { /** * The newest inbound, non-deleted message id stored in [chatId], or `null` when there is none. * Read when a chat opens: inbound messages past it are the ones that arrived after the open. + * Suspends until the account and the database are ready, so `null` only ever means "ready, and none". */ suspend fun newestInboundId(chatId: ChatId): Long? diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index a546dac24a..f62db65634 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -57,6 +57,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map @@ -205,8 +207,19 @@ class MessagingDelegate @Inject constructor( override suspend fun distanceFromNewest(chatId: ChatId, messageId: Long): Int? = messageDataSource.distanceFromNewest(chatId, messageId) + /** + * Suspends until there is an account and a per-user database. The open snapshot reads once, so + * "not ready yet" must not look like "nothing stored": a null here would become "no inbound + * messages at open" and every stored row would later count as an arrival. + */ + private suspend fun awaitReady(): ID { + val selfId = userManager.state.map { it.accountId }.filterNotNull().first() + messageDataSource.awaitDatabase() + return selfId + } + override suspend fun resolveUnreadBoundary(chatId: ChatId): UnreadBoundary { - val selfId = userManager.accountId ?: return UnreadBoundary.None + val selfId = awaitReady() val readThrough = memberDataSource.getSelfReadPointerOrNull(chatId, selfId) ?: return UnreadBoundary.None val count = messageDataSource.countInboundAfter(chatId, selfId, readThrough) @@ -240,7 +253,7 @@ class MessagingDelegate @Inject constructor( .distinctUntilChanged() override suspend fun newestInboundId(chatId: ChatId): Long? { - val selfId = userManager.accountId ?: return null + val selfId = awaitReady() return messageDataSource.newestInboundId(chatId, selfId) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBelowObservationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBelowObservationTest.kt index d0b269bff2..8107060565 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBelowObservationTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBelowObservationTest.kt @@ -9,12 +9,17 @@ import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import app.cash.turbine.test +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue /** * The jump-to-bottom badge opens at the unread divider's number: [MessagingDelegate.observeUnreadBelow] @@ -91,4 +96,44 @@ class UnreadBelowObservationTest { assertEquals(3, awaitItem()) } } + + @Test + fun `the open snapshot waits for the account and the database instead of reporting no inbound`() = runTest { + account.value = UserManager.State(accountId = null) + val databaseUp = CompletableDeferred() + coEvery { messageDataSource.awaitDatabase() } coAnswers { databaseUp.await() } + coEvery { messageDataSource.newestInboundId(chatId, selfId) } returns 15 + + val newest = async { delegate.newestInboundId(chatId) } + advanceUntilIdle() + assertFalse(newest.isCompleted, "no account yet") + + account.value = UserManager.State(accountId = selfId) + advanceUntilIdle() + assertFalse(newest.isCompleted, "no database yet") + + databaseUp.complete(Unit) + advanceUntilIdle() + assertTrue(newest.isCompleted) + assertEquals(15L, newest.await()) + } + + @Test + fun `the unread boundary waits for the account and the database`() = runTest { + account.value = UserManager.State(accountId = null) + val databaseUp = CompletableDeferred() + coEvery { messageDataSource.awaitDatabase() } coAnswers { databaseUp.await() } + coEvery { memberDataSource.getSelfReadPointerOrNull(chatId, selfId) } returns 2 + coEvery { messageDataSource.countInboundAfter(chatId, selfId, 2) } returns 3 + coEvery { messageDataSource.firstNotSentByAfter(chatId, selfId, 2) } returns 3 + + val boundary = async { delegate.resolveUnreadBoundary(chatId) } + advanceUntilIdle() + assertFalse(boundary.isCompleted) + + account.value = UserManager.State(accountId = selfId) + databaseUp.complete(Unit) + advanceUntilIdle() + assertEquals(3, (boundary.await() as UnreadBoundary.At).count) + } } diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt index a79dceaf9e..ac5a777880 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt @@ -24,6 +24,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf @@ -191,6 +193,11 @@ class ChatMessageDataSource @Inject constructor( afterId = afterId, ) ?: 0 + /** Returns once the per-user database exists; reads that must not mistake "not open yet" for "empty" call this first. */ + suspend fun awaitDatabase() { + FlipcashDatabase.observeInstance().filterNotNull().first() + } + /** The newest stored, non-deleted message in [chatId] that someone other than [selfId] sent, or `null`. */ suspend fun newestInboundId(chatId: ChatId, selfId: ID): Long? = db?.chatMessageDao()?.newestInboundId( From 49b4af00114605744b3dedbe288620e63fe0f362 Mon Sep 17 00:00:00 2001 From: agent-team Date: Wed, 14 Oct 2026 06:48:37 -0400 Subject: [PATCH 12/23] fix(messenger): count a thank-you reply as an own message sent --- .../com/flipcash/app/messenger/internal/ChatViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index c0c5d04e72..7d25c72706 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -1803,7 +1803,8 @@ internal class ChatViewModel @AssistedInject constructor( chatId, resources.getString(R.string.message_cash_link_thanks), messageId, - ).onFailure { trace("claim reply failed to send - ${it.localizedMessage}") } + ).onSuccess { dispatchEvent(Event.OwnMessageSent) } + .onFailure { trace("claim reply failed to send - ${it.localizedMessage}") } } } From a13bba51aa5e4b2a8c460dcaed1d609ee34492c7 Mon Sep 17 00:00:00 2001 From: agent-team Date: Wed, 14 Oct 2026 06:57:01 -0400 Subject: [PATCH 13/23] test(chat): update boundary resolution for the readiness wait --- .../flipcash/shared/chat/UnreadBoundaryResolutionTest.kt | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBoundaryResolutionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBoundaryResolutionTest.kt index 098e052739..7e5411d0fa 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBoundaryResolutionTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBoundaryResolutionTest.kt @@ -9,6 +9,7 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.test.assertEquals @@ -28,6 +29,7 @@ class UnreadBoundaryResolutionTest { private val messageDataSource = mockk(relaxed = true) private val userManager = mockk(relaxed = true).also { every { it.accountId } returns selfId + every { it.state } returns MutableStateFlow(UserManager.State(accountId = selfId)) } init { @@ -99,13 +101,6 @@ class UnreadBoundaryResolutionTest { assertEquals(UnreadBoundary.None, delegate.resolveUnreadBoundary(chatId)) } - @Test - fun `signed out is no divider`() = runTest { - every { userManager.accountId } returns null - - assertEquals(UnreadBoundary.None, delegate.resolveUnreadBoundary(chatId)) - } - @Test fun `the walk budget counts every stored row past the id`() = runTest { coEvery { messageDataSource.countAfter(chatId, 10) } returns 30 From 9955853f0e6c13037f958d9a88827ce1c6e5d60c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 07:16:30 -0400 Subject: [PATCH 14/23] fix(messenger): cut off badge arrivals at the read pointer as well as the newest inbound The frozen badge counted as an arrival every inbound message past the newest inbound id stored at open. With an empty cache that id is absent, so history fetched after the open counted as arrivals and inflated the badge. The cutoff is now the later of the viewer's read pointer and the newest inbound id, both read after the account and database are ready. History at or below the pointer is already read, so it can load late without counting. The test that asserted every later inbound counts with no inbound at open encoded the bug. It now covers a read pointer with an empty cache, and the no-pointer, no-inbound case keeps counting everything. --- .../app/messenger/internal/ChatViewModel.kt | 19 ++++++----- .../app/messenger/internal/UnreadBadge.kt | 14 ++++++-- .../app/messenger/internal/UnreadBadgeTest.kt | 32 +++++++++++++++++-- .../flipcash/shared/chat/ChatCoordinator.kt | 7 ++++ .../internal/delegates/MessagingDelegate.kt | 5 +++ .../shared/chat/UnreadBelowObservationTest.kt | 20 ++++++++++++ 6 files changed, 85 insertions(+), 12 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 7d25c72706..c0885ec2dc 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -432,9 +432,9 @@ internal class ChatViewModel @AssistedInject constructor( */ val unreadBadgeFrozen: Boolean = true, /** - * The newest inbound message id stored when the chat opened, or [NO_INBOUND_AT_OPEN]; null - * until the open has resolved. Inbound messages past it are the arrivals the frozen badge - * adds to the divider's count. + * The [arrivalCutoff] recorded once the open's reads have landed: the later of the read + * pointer and the newest inbound id stored, or [NO_INBOUND_AT_OPEN]; null until then. + * Inbound messages past it are the arrivals the frozen badge adds to the divider's count. */ val newestInboundIdAtOpen: Long? = null, /** @@ -2910,15 +2910,18 @@ internal class ChatViewModel @AssistedInject constructor( stateFlow.mapNotNull { it.chatId } .distinctUntilChanged() .onEach { chatId -> - // Both calls wait for the account and database, so -1 only ever means "ready, and - // no inbound stored". Before the boundary: an arrival between the two is counted - // twice rather than missed. - val newestInbound = chatCoordinator.newestInboundId(chatId) ?: NO_INBOUND_AT_OPEN + // These reads wait for the account and database, so -1 only ever means "ready, with + // no read pointer and no inbound stored". They come before the boundary: an arrival + // between the two is counted twice rather than missed. + val cutoff = arrivalCutoff( + readPointer = chatCoordinator.selfReadPointer(chatId), + newestInbound = chatCoordinator.newestInboundId(chatId), + ) val boundary = chatCoordinator.resolveUnreadBoundary(chatId) val budget = (boundary as? UnreadBoundary.At) ?.let { chatCoordinator.countMessagesAfter(chatId, it.readThrough) } ?: 0 - dispatchEvent(Event.UnreadBoundaryResolved(boundary, budget, newestInbound)) + dispatchEvent(Event.UnreadBoundaryResolved(boundary, budget, cutoff)) } // The list waits on this, and on main it queued behind the transcript's first frame. .flowOn(dispatchers.IO) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadBadge.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadBadge.kt index d68c58fbae..69e098cfcd 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadBadge.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadBadge.kt @@ -10,11 +10,21 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map /** - * [ChatViewModel.State.newestInboundIdAtOpen] for a chat with no inbound message stored at open: - * below any real message id, so every inbound message that comes later counts as an arrival. + * [ChatViewModel.State.newestInboundIdAtOpen] for a chat with neither an inbound message nor a read + * pointer at open: below any real message id, so every inbound message that comes later counts as + * an arrival. */ internal const val NO_INBOUND_AT_OPEN = -1L +/** + * The id past which an inbound message counts as an arrival after the open: the later of the + * viewer's [readPointer] and the [newestInbound] id stored, each null when absent. The pointer + * matters when the cache is empty at open: history that loads afterwards sits at or below it and + * is not new, where [newestInbound] alone would count all of it. + */ +internal fun arrivalCutoff(readPointer: Long?, newestInbound: Long?): Long = + maxOf(readPointer ?: NO_INBOUND_AT_OPEN, newestInbound ?: NO_INBOUND_AT_OPEN) + /** * What the jump-to-bottom badge shows. * diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadBadgeTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadBadgeTest.kt index 878b8bc180..ff9d6672b7 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadBadgeTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadBadgeTest.kt @@ -104,8 +104,36 @@ class UnreadBadgeTest { } @Test - fun `with no inbound at open every later inbound counts as an arrival`() = runTest { - open(UnreadBoundary.None, newestInbound = NO_INBOUND_AT_OPEN) + fun `history loaded after open below the read pointer is not an arrival`() = runTest { + // Empty cache at open: no inbound stored, but the member row says the viewer read through 40. + open(UnreadBoundary.None, newestInbound = arrivalCutoff(readPointer = 40, newestInbound = null)) + live.value = 0 + + badge().test { + assertEquals(0, awaitItem()) + + // The history fetch lands rows at or below 40; the DB counts only ids past the cutoff. + arrivals.value = 0 + expectNoEvents() + + arrivals.value = 1 + live.value = 1 + assertEquals(1, awaitItem()) + } + assertEquals(listOf(40L), askedAfter.distinct()) + } + + @Test + fun `the cutoff is the later of the read pointer and the newest inbound`() { + assertEquals(40L, arrivalCutoff(readPointer = 40, newestInbound = null)) + assertEquals(40L, arrivalCutoff(readPointer = 40, newestInbound = 25)) + assertEquals(55L, arrivalCutoff(readPointer = 40, newestInbound = 55)) + assertEquals(55L, arrivalCutoff(readPointer = null, newestInbound = 55)) + } + + @Test + fun `with neither a pointer nor inbound at open every later inbound counts as an arrival`() = runTest { + open(UnreadBoundary.None, newestInbound = arrivalCutoff(readPointer = null, newestInbound = null)) live.value = 0 badge().test { diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index bac52b0470..4306a0ddad 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -349,6 +349,13 @@ interface MessagingOperations { */ suspend fun newestInboundId(chatId: ChatId): Long? + /** + * The viewer's stored READ pointer in [chatId], or `null` when their member row is not stored. + * Read with [newestInboundId] when a chat opens: history older than the pointer is not an + * arrival even when it loads after the open. Suspends until the account and database are ready. + */ + suspend fun selfReadPointer(chatId: ChatId): Long? + /** * How many inbound, non-deleted messages in [chatId] sit past [afterId], live, whatever the * read pointer says. [afterId] below every stored id counts them all. diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index f62db65634..201acd2cdb 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -257,6 +257,11 @@ class MessagingDelegate @Inject constructor( return messageDataSource.newestInboundId(chatId, selfId) } + override suspend fun selfReadPointer(chatId: ChatId): Long? { + val selfId = awaitReady() + return memberDataSource.getSelfReadPointerOrNull(chatId, selfId) + } + @OptIn(ExperimentalCoroutinesApi::class) override fun observeInboundAfter(chatId: ChatId, afterId: Long): Flow = userManager.state.map { it.accountId } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBelowObservationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBelowObservationTest.kt index 8107060565..80f9b6b8c1 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBelowObservationTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBelowObservationTest.kt @@ -97,6 +97,26 @@ class UnreadBelowObservationTest { } } + @Test + fun `the read pointer for the open waits for the account and the database`() = runTest { + account.value = UserManager.State(accountId = null) + val databaseUp = CompletableDeferred() + coEvery { messageDataSource.awaitDatabase() } coAnswers { databaseUp.await() } + coEvery { memberDataSource.getSelfReadPointerOrNull(chatId, selfId) } returns 40 + + val pointer = async { delegate.selfReadPointer(chatId) } + advanceUntilIdle() + assertFalse(pointer.isCompleted, "no account yet") + + account.value = UserManager.State(accountId = selfId) + advanceUntilIdle() + assertFalse(pointer.isCompleted, "no database yet") + + databaseUp.complete(Unit) + advanceUntilIdle() + assertEquals(40L, pointer.await()) + } + @Test fun `the open snapshot waits for the account and the database instead of reporting no inbound`() = runTest { account.value = UserManager.State(accountId = null) From 30915d58e99f53eb4182daaf2cbeeb8312d3f867 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 07:51:18 -0400 Subject: [PATCH 15/23] fix(messenger): update the badge's held count from an effect, not composition --- .../internal/screens/components/JumpToBottom.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt index 44bad19d3c..824df44d42 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt @@ -20,8 +20,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.KeyboardArrowDown import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -113,8 +114,10 @@ internal fun JumpToBottomButton( colorEffects(listOf(HazeColorEffect.tint(Color.White.copy(alpha = 0.12f)))) } // Held so the pill doesn't read "0" while it scales out. - var lastCount by remember { mutableStateOf(unreadCount) } - if (unreadCount > 0) lastCount = unreadCount + var lastCount by remember { mutableIntStateOf(unreadCount) } + LaunchedEffect(unreadCount) { if (unreadCount > 0) lastCount = unreadCount } + // The effect runs a frame late; show the new count straight away rather than the old one. + val shownCount = if (unreadCount > 0) unreadCount else lastCount AnimatedVisibility( modifier = modifier, @@ -168,7 +171,7 @@ internal fun JumpToBottomButton( .padding(BadgeOutline), ) { Badge( - count = lastCount, + count = shownCount, color = CodeTheme.colors.badgeFocus, textStyle = CodeTheme.typography.caption.copy( fontSize = 11.sp, From 9358e11c26110e2280cc3eb55ecd5f5c1d607b02 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 07:51:20 -0400 Subject: [PATCH 16/23] fix(messenger): record the badge arrival cutoff after the opening load The cutoff was read before the opening sync, so with an empty cache the history that sync stored counted as arrivals. It is now read after openTranscript returns, and nothing counts as an arrival until then. A message that arrives during the load is not counted. The frozen badge also read its divider term from unreadBoundary, which the UntilSend lifetime clears when a message is sent, before the send succeeds. The opening count is now snapshotted into state at open, so a failed send leaves the badge where it was. --- .../app/messenger/internal/ChatViewModel.kt | 57 +++++++++++++------ .../app/messenger/internal/UnreadBadge.kt | 11 ++-- .../app/messenger/internal/UnreadBadgeTest.kt | 49 +++++++++++++++- 3 files changed, 91 insertions(+), 26 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index c0885ec2dc..d1ce99a11e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -432,9 +432,15 @@ internal class ChatViewModel @AssistedInject constructor( */ val unreadBadgeFrozen: Boolean = true, /** - * The [arrivalCutoff] recorded once the open's reads have landed: the later of the read - * pointer and the newest inbound id stored, or [NO_INBOUND_AT_OPEN]; null until then. - * Inbound messages past it are the arrivals the frozen badge adds to the divider's count. + * The divider's count as it stood at open, kept apart from [unreadBoundary] so the badge + * holds it when the boundary is later cleared (sending under [UnreadDividerLifetime.UntilSend]). + */ + val unreadBadgeOpenCount: Int = 0, + /** + * The [arrivalCutoff] recorded once the opening load has landed: the later of the read + * pointer and the newest inbound id stored, or [NO_INBOUND_AT_OPEN]; null until then, while + * nothing counts as an arrival. Inbound messages past it are the arrivals the frozen badge + * adds to the divider's count. */ val newestInboundIdAtOpen: Long? = null, /** @@ -1008,11 +1014,13 @@ internal class ChatViewModel @AssistedInject constructor( data class JumpResolved(val messageId: Long, val budget: Int) : Event /** The chat's unread boundary, read once when its id became known. */ - data class UnreadBoundaryResolved( - val boundary: UnreadBoundary, - val walkBudget: Int, - val newestInboundId: Long = NO_INBOUND_AT_OPEN, - ) : Event + data class UnreadBoundaryResolved(val boundary: UnreadBoundary, val walkBudget: Int) : Event + + /** + * The opening load has landed and the arrival cutoff ([arrivalCutoff]) was read after it. + * Inbound messages past it are the arrivals the frozen badge adds. + */ + data class ArrivalCutoffRecorded(val cutoff: Long) : Event /** The viewer dragged or flung the settled transcript; releases the badge's freeze. */ data object UserScrolledTranscript : Event @@ -1832,6 +1840,22 @@ internal class ChatViewModel @AssistedInject constructor( * the gate says so, off [State.readsFromOutside], rather than here, where the balance that * decides it has not arrived yet. */ + /** + * Reads the badge's arrival cutoff once the opening load has landed, so history that load + * stores is never an arrival. A message that arrives during the load is not counted either. + * The reads wait for the account and database, so -1 only ever means "ready, with no read + * pointer and no inbound stored". + */ + private suspend fun recordArrivalCutoff(chatId: ChatId) { + val cutoff = withContext(dispatchers.IO) { + arrivalCutoff( + readPointer = chatCoordinator.selfReadPointer(chatId), + newestInbound = chatCoordinator.newestInboundId(chatId), + ) + } + dispatchEvent(Event.ArrivalCutoffRecorded(cutoff)) + } + private suspend fun openTranscript(chatId: ChatId) { when (val hydration = chatCoordinator.hydrateChat(chatId)) { ChatHydration.Stored, @@ -1900,7 +1924,10 @@ internal class ChatViewModel @AssistedInject constructor( featuredGroups.peek(chatId) ?.takeIf { chatCoordinator.observeMetadata(chatId).first() == null } ?.let { dispatchEvent(Event.OnGroupResolved(ChatMembership(metadata = it, isMember = null))) } - viewModelScope.launch { openTranscript(chatId) } + viewModelScope.launch { + openTranscript(chatId) + recordArrivalCutoff(chatId) + } // A feed row has no cover, so fetch it now rather than when the group's // profile opens: the profile would otherwise draw an empty cover through GetChat. viewModelScope.launch { @@ -2910,18 +2937,11 @@ internal class ChatViewModel @AssistedInject constructor( stateFlow.mapNotNull { it.chatId } .distinctUntilChanged() .onEach { chatId -> - // These reads wait for the account and database, so -1 only ever means "ready, with - // no read pointer and no inbound stored". They come before the boundary: an arrival - // between the two is counted twice rather than missed. - val cutoff = arrivalCutoff( - readPointer = chatCoordinator.selfReadPointer(chatId), - newestInbound = chatCoordinator.newestInboundId(chatId), - ) val boundary = chatCoordinator.resolveUnreadBoundary(chatId) val budget = (boundary as? UnreadBoundary.At) ?.let { chatCoordinator.countMessagesAfter(chatId, it.readThrough) } ?: 0 - dispatchEvent(Event.UnreadBoundaryResolved(boundary, budget, cutoff)) + dispatchEvent(Event.UnreadBoundaryResolved(boundary, budget)) } // The list waits on this, and on main it queued behind the transcript's first frame. .flowOn(dispatchers.IO) @@ -3759,9 +3779,10 @@ internal class ChatViewModel @AssistedInject constructor( state.copy( unreadBoundary = event.boundary, unreadWalkBudget = event.walkBudget, - newestInboundIdAtOpen = event.newestInboundId, + unreadBadgeOpenCount = (event.boundary as? UnreadBoundary.At)?.count ?: 0, ) } + is Event.ArrivalCutoffRecorded -> { state -> state.copy(newestInboundIdAtOpen = event.cutoff) } Event.UserScrolledTranscript, Event.OwnMessageSent -> { state -> state.copy(unreadBadgeFrozen = false) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadBadge.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadBadge.kt index 69e098cfcd..8b5b4c6c38 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadBadge.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadBadge.kt @@ -1,6 +1,5 @@ package com.flipcash.app.messenger.internal -import com.flipcash.shared.chat.UnreadBoundary import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -35,13 +34,13 @@ internal fun arrivalCutoff(readPointer: Long?, newestInbound: Long?): Long = */ internal fun unreadBadgeCount( frozen: Boolean, - boundary: UnreadBoundary, + openCount: Int, arrivalsSinceOpen: Int, live: Int, outsideGroup: Boolean, ): Int = when { outsideGroup -> 0 - frozen -> ((boundary as? UnreadBoundary.At)?.count ?: 0) + arrivalsSinceOpen + frozen -> openCount + arrivalsSinceOpen else -> live } @@ -60,9 +59,9 @@ internal fun unreadBadgeCounts( .distinctUntilChanged() .flatMapLatest { openedAt -> if (openedAt == null) flowOf(0) else arrivalsAfter(openedAt) } val inputs = state - .map { Triple(it.unreadBadgeFrozen, it.unreadBoundary, it.isOutsideGroup) } + .map { Triple(it.unreadBadgeFrozen, it.unreadBadgeOpenCount, it.isOutsideGroup) } .distinctUntilChanged() - return combine(inputs, arrivals, live) { (frozen, boundary, outside), arrived, liveCount -> - unreadBadgeCount(frozen, boundary, arrived, liveCount, outside) + return combine(inputs, arrivals, live) { (frozen, openCount, outside), arrived, liveCount -> + unreadBadgeCount(frozen, openCount, arrived, liveCount, outside) }.distinctUntilChanged() } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadBadgeTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadBadgeTest.kt index ff9d6672b7..38af7d08bd 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadBadgeTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadBadgeTest.kt @@ -3,6 +3,7 @@ package com.flipcash.app.messenger.internal import app.cash.turbine.test import com.flipcash.shared.chat.UnreadBoundary import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.runTest import org.junit.Test @@ -30,8 +31,10 @@ class UnreadBadgeTest { private fun dispatch(event: ChatViewModel.Event) = state.update { ChatViewModel.stateReducer(event, UnreadDividerLifetime.UntilClose)(it) } - private fun open(boundary: UnreadBoundary, newestInbound: Long = 15) = - dispatch(ChatViewModel.Event.UnreadBoundaryResolved(boundary, walkBudget = 5, newestInboundId = newestInbound)) + private fun open(boundary: UnreadBoundary, newestInbound: Long = 15) { + dispatch(ChatViewModel.Event.UnreadBoundaryResolved(boundary, walkBudget = 5)) + dispatch(ChatViewModel.Event.ArrivalCutoffRecorded(newestInbound)) + } @Test fun `opens at the divider's count even with unread rows already read on screen`() = runTest { @@ -173,4 +176,46 @@ class UnreadBadgeTest { } assertEquals(listOf(15L), askedAfter.distinct()) } + + @Test + fun `history the opening load stores is not an arrival, whatever it brings`() = runTest { + // Empty cache and no read pointer: the opening sync is what fills the chat. + val stored = MutableStateFlow(emptyList()) + val countingBadge = unreadBadgeCounts( + state = state, + live = live, + arrivalsAfter = { afterId -> askedAfter += afterId; stored.map { ids -> ids.count { it > afterId } } }, + ) + dispatch(ChatViewModel.Event.UnreadBoundaryResolved(UnreadBoundary.None, walkBudget = 0)) + live.value = 0 + + countingBadge.test { + assertEquals(0, awaitItem()) + + // The sync lands 12 inbound rows before the cutoff is read; none counts. + stored.value = (1L..12L).toList() + expectNoEvents() + assertEquals(emptyList(), askedAfter) + + dispatch(ChatViewModel.Event.ArrivalCutoffRecorded(arrivalCutoff(readPointer = null, newestInbound = 12))) + expectNoEvents() + + stored.value = stored.value + 13L + live.value = 1 + assertEquals(1, awaitItem()) + } + } + + @Test + fun `a send that clears the boundary does not shrink the frozen badge`() { + val opened = ChatViewModel.stateReducer( + ChatViewModel.Event.UnreadBoundaryResolved(divider, walkBudget = 5), + UnreadDividerLifetime.UntilSend, + )(ChatViewModel.State()) + val sent = ChatViewModel.stateReducer(ChatViewModel.Event.SendMessage, UnreadDividerLifetime.UntilSend)(opened) + + assertEquals(UnreadBoundary.None, sent.unreadBoundary) + assertEquals(5, sent.unreadBadgeOpenCount) + assertEquals(true, sent.unreadBadgeFrozen) + } } From a8452499056943cdff62e0227ca1ce7ea6027fa3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 08:48:51 -0400 Subject: [PATCH 17/23] fix(messenger): wait for the first successful newest-page load before recording the badge cutoff The cutoff was read after openTranscript returned, which also happens when the load fails or is skipped (group feed not synced, outside readers load elsewhere), so history stored later counted as arrivals. ChatCoordinator.newestPageLoads() now emits when a newest-page load is stored, from any path; the view model records the cutoff on the first emission for its chat. --- .../app/messenger/internal/ChatViewModel.kt | 14 ++++++++++---- .../com/flipcash/shared/chat/ChatCoordinator.kt | 8 ++++++++ .../chat/internal/delegates/MessagingDelegate.kt | 6 ++++++ .../shared/chat/UnreadBelowObservationTest.kt | 16 +++++++++++++++- 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index d1ce99a11e..eeb9b3219b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -185,6 +185,7 @@ import kotlin.time.Instant import java.util.Optional import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -1841,8 +1842,9 @@ internal class ChatViewModel @AssistedInject constructor( * decides it has not arrived yet. */ /** - * Reads the badge's arrival cutoff once the opening load has landed, so history that load - * stores is never an arrival. A message that arrives during the load is not counted either. + * Reads the badge's arrival cutoff once the first successful load of the newest page has + * landed, so history that load stores is never an arrival. A message that arrives during the + * load is not counted either. * The reads wait for the account and database, so -1 only ever means "ready, with no read * pointer and no inbound stored". */ @@ -1924,10 +1926,14 @@ internal class ChatViewModel @AssistedInject constructor( featuredGroups.peek(chatId) ?.takeIf { chatCoordinator.observeMetadata(chatId).first() == null } ?.let { dispatchEvent(Event.OnGroupResolved(ChatMembership(metadata = it, isMember = null))) } - viewModelScope.launch { - openTranscript(chatId) + // Subscribed before any load can start, so the first one to land is not missed. + // Whichever path loads the newest page (this open, the feed sync, an outside + // reader's load) ends the wait; a load that fails or is skipped does not. + viewModelScope.launch(start = CoroutineStart.UNDISPATCHED) { + chatCoordinator.newestPageLoads().first { it == chatId } recordArrivalCutoff(chatId) } + viewModelScope.launch { openTranscript(chatId) } // A feed row has no cover, so fetch it now rather than when the group's // profile opens: the profile would otherwise draw an empty cover through GetChat. viewModelScope.launch { diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index 4306a0ddad..99ca1bb6eb 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -429,6 +429,14 @@ interface MessagingOperations { /** Fetches the full message history for [chatId] from the server and persists locally. */ suspend fun loadMessages(chatId: ChatId) + /** + * Emits a chat's id each time a load of its newest page succeeds and has been stored, whichever + * path performed it (the open, the feed sync, a push, an outside reader). Never replays and + * emits nothing for a failed load, so a collector started before the load sees only the loads + * that happen after it. + */ + fun newestPageLoads(): Flow + /** * Persists a message the server delivered inside a push payload, without an RPC. * diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index 201acd2cdb..8d67ab2b6d 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -54,6 +54,7 @@ import com.getcode.utils.TraceType import com.getcode.utils.trace import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -313,6 +314,10 @@ class MessagingDelegate @Inject constructor( .distinctUntilChanged() } + private val newestPageLoaded = MutableSharedFlow(extraBufferCapacity = 16) + + override fun newestPageLoads(): Flow = newestPageLoaded + override suspend fun loadMessages(chatId: ChatId) { messagingController.getMessages(chatId) .onSuccess { messages -> @@ -328,6 +333,7 @@ class MessagingDelegate @Inject constructor( // what the feed already stored: the list rebuilds only for a real change. val head = messages.maxOfOrNull { it.eventSequence } ?: 0L metadataDataSource.applyCatchUp(chatId, head, messages.maxByOrNull { it.messageId }) + newestPageLoaded.tryEmit(chatId) } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBelowObservationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBelowObservationTest.kt index 80f9b6b8c1..23f52e898a 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBelowObservationTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBelowObservationTest.kt @@ -31,6 +31,7 @@ class UnreadBelowObservationTest { private val chatId = ChatId("aabbccdd") private val selfId = listOf(1, 2, 3) + private val messagingController = mockk(relaxed = true) private val memberDataSource = mockk(relaxed = true) private val messageDataSource = mockk(relaxed = true) private val account = MutableStateFlow(UserManager.State(accountId = selfId)) @@ -45,7 +46,7 @@ class UnreadBelowObservationTest { private val delegate = MessagingDelegate( chatController = mockk(relaxed = true), - messagingController = mockk(relaxed = true), + messagingController = messagingController, metadataDataSource = mockk(relaxed = true), messageDataSource = messageDataSource, memberDataSource = memberDataSource, @@ -97,6 +98,19 @@ class UnreadBelowObservationTest { } } + @Test + fun `a failed load of the newest page does not announce a landing, a later success does`() = runTest { + delegate.newestPageLoads().test { + coEvery { messagingController.getMessages(chatId) } returns Result.failure(IllegalStateException("offline")) + delegate.loadMessages(chatId) + expectNoEvents() + + coEvery { messagingController.getMessages(chatId) } returns Result.success(emptyList()) + delegate.loadMessages(chatId) + assertEquals(chatId, awaitItem()) + } + } + @Test fun `the read pointer for the open waits for the account and the database`() = runTest { account.value = UserManager.State(accountId = null) From cf3959426fe66cad69a7271b7ac6fa2f081b2f31 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 08:52:49 -0400 Subject: [PATCH 18/23] test(chat): cover the badge cutoff waiting for the first newest-page load Drives the real ChatViewModel open path: no cutoff on open, none after a load for another chat, recorded once this chat's newest page lands (history stored by then is behind it), and not moved by later loads. --- .../internal/ChatOpenTranscriptTest.kt | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt index 91ae4f79b0..66074f0e27 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt @@ -30,6 +30,7 @@ import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -62,6 +63,8 @@ class ChatOpenTranscriptTest { private val chatCoordinator = mockk(relaxed = true) { every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() + // No load ever lands unless a test emits one. + every { newestPageLoads() } returns MutableSharedFlow() } private val tipPaymentDelegate = mockk(relaxed = true) private val transactionController = mockk(relaxed = true) @@ -267,4 +270,37 @@ class ChatOpenTranscriptTest { assertNull(viewModel.stateFlow.value.subject) } + @Test + fun `the badge cutoff is recorded after the first successful newest-page load, not on open`() = runTest { + val loads = MutableSharedFlow(extraBufferCapacity = 8) + var newestInbound: Long? = null + every { chatCoordinator.newestPageLoads() } returns loads + coEvery { chatCoordinator.hydrateChat(chatId) } returns ChatHydration.Stored + coEvery { chatCoordinator.selfReadPointer(chatId) } returns null + coEvery { chatCoordinator.newestInboundId(chatId) } coAnswers { newestInbound } + + val viewModel = createViewModel() + viewModel.dispatchEvent(openChat) + advanceUntilIdle() + // Opened, nothing loaded yet: no cutoff, so nothing counts as an arrival. + assertNull(viewModel.stateFlow.value.newestInboundIdAtOpen) + + // A backfill page stores history, and a load for another chat lands. Neither is the + // newest page of this chat, so the cutoff still waits. + newestInbound = 12 + loads.emit(ChatId(UUID.randomUUID().bytes)) + advanceUntilIdle() + assertNull(viewModel.stateFlow.value.newestInboundIdAtOpen) + + // The newest page lands: the history it stored is behind the cutoff. + loads.emit(chatId) + advanceUntilIdle() + assertEquals(12L, viewModel.stateFlow.value.newestInboundIdAtOpen) + + // Later loads do not move it. + newestInbound = 15 + loads.emit(chatId) + advanceUntilIdle() + assertEquals(12L, viewModel.stateFlow.value.newestInboundIdAtOpen) + } } From 355122820ee87abf6079dc131e331fec1899ee78 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 10:44:39 -0400 Subject: [PATCH 19/23] fix(chat): drop the gradient behind the jump-to-bottom button The painted fade covered the newest message rows, and the button's glass blur already separates it. --- .../screens/components/JumpToBottom.kt | 32 ------------------- .../screens/components/MessageList.kt | 5 --- 2 files changed, 37 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt index 824df44d42..33d230717c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -29,7 +28,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.layout.layout @@ -53,7 +51,6 @@ internal const val JumpToBottomAnimateMaxRows = 100 private val ButtonSize = 48.dp private val BadgeOutline = 2.dp -private val HazeHeight = 112.dp /** Whether a tap at [firstVisibleIndex] rows from the newest jumps instead of animating. */ internal fun jumpsToBottom(firstVisibleIndex: Int): Boolean = firstVisibleIndex > JumpToBottomAnimateMaxRows @@ -63,35 +60,6 @@ internal suspend fun LazyListState.scrollToNewest() { if (jumpsToBottom(firstVisibleItemIndex)) scrollToItem(0) else animateScrollToItem(0) } -/** - * The haze behind the button: the chat background fading in toward the composer, so the button - * and the rows it floats over read apart. Sits beside the transcript, never inside its haze - * source, and takes no touches. - */ -@Composable -internal fun JumpToBottomHaze(visible: Boolean, modifier: Modifier = Modifier) { - val background = CodeTheme.colors.background - AnimatedVisibility( - modifier = modifier, - visible = visible, - enter = fadeIn(tween(200)), - exit = fadeOut(tween(200)), - ) { - Box( - Modifier - .fillMaxWidth() - .height(HazeHeight) - .background( - Brush.verticalGradient( - 0f to background.copy(alpha = 0f), - 0.5f to background.copy(alpha = 0.75f), - 1f to background.copy(alpha = 0.95f), - ) - ), - ) - } -} - /** * A 48dp glass circle with a down chevron, and the unread count at its top-trailing corner. * diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index fb0a760ee3..8e72e8856e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -702,11 +702,6 @@ internal fun MessageList( val jumpVisible by remember(selecting) { derivedStateOf { hasPositioned && !listState.restsOnNewest() && !selecting && !autoScrolling } } - // Beside the list, not inside it: the list carries the haze source the glass samples. - JumpToBottomHaze( - visible = jumpVisible, - modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = contentPadding.calculateBottomPadding()), - ) val jumpScope = rememberCoroutineScope() JumpToBottomButton( visible = jumpVisible, From f2feda261ff5a1a6bb71d3113fe4ed95a2e3cccc Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 10:48:42 -0400 Subject: [PATCH 20/23] fix(chat): line the jump-to-bottom button up with the $ button The jump button used the plain inset as its end padding while the $ button sits at the bar's side inset and is wider, so their centres differed. Its end padding is now the composer's side inset plus half the width difference. --- .../internal/screens/components/ChatBottomBar.kt | 14 ++++++++++++-- .../internal/screens/components/JumpToBottom.kt | 4 ++-- .../internal/screens/components/MessageList.kt | 11 ++++++++++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index fbfada6872..019e97c813 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -281,8 +281,7 @@ internal fun UserControlBottomBar( // doesn't move anything, since nothing here animates on its own. val keyboardOpen = keyboardOpenFraction() val hasComposer = state.typingConstraints.enabled - val compactInset = CodeTheme.dimens.staticGrid.x6.coerceAtLeast(CodeTheme.dimens.inset) - val sideInset = if (hasComposer) lerp(compactInset, CodeTheme.dimens.inset, keyboardOpen) else CodeTheme.dimens.inset + val sideInset = composerSideInset(hasComposer, keyboardOpen) val restingDrop = if (hasComposer) lerp(CodeTheme.dimens.staticGrid.x2, 0.dp, keyboardOpen) else 0.dp // The part of the navigation bar's inset the keyboard isn't covering, so the bar only sinks // into it once the keyboard has cleared it. @@ -649,6 +648,17 @@ private fun DeactivatedChatBottomBar() { } } +/** + * The margin either side of the bar. Compact at rest once there is a composer, opening out to the + * normal inset as the keyboard ([keyboardOpen], 0 to 1) comes up. The `$` button's trailing edge sits + * this far from the screen edge, and the jump-to-bottom button aligns to it. + */ +@Composable +internal fun composerSideInset(hasComposer: Boolean, keyboardOpen: Float): Dp { + val compactInset = CodeTheme.dimens.staticGrid.x6.coerceAtLeast(CodeTheme.dimens.inset) + return if (hasComposer) lerp(compactInset, CodeTheme.dimens.inset, keyboardOpen) else CodeTheme.dimens.inset +} + /** * How far open the keyboard is, from 0 (down) to 1 (fully up), following its show and hide * animations frame by frame. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt index 33d230717c..446e07b304 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt @@ -49,7 +49,7 @@ import dev.chrisbanes.haze.blur.hazeBlur /** Past this many rows from the newest, a tap jumps rather than animates (from the old Flipchat). */ internal const val JumpToBottomAnimateMaxRows = 100 -private val ButtonSize = 48.dp +internal val JumpToBottomButtonSize = 48.dp private val BadgeOutline = 2.dp /** Whether a tap at [firstVisibleIndex] rows from the newest jumps instead of animating. */ @@ -96,7 +96,7 @@ internal fun JumpToBottomButton( Box { Box( modifier = Modifier - .size(ButtonSize) + .size(JumpToBottomButtonSize) .shadow(elevation = 8.dp, shape = CircleShape, clip = false) .clip(CircleShape) .then( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 8e72e8856e..9afed04503 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -91,6 +91,7 @@ import com.flipcash.shared.chat.ui.GroupInviteLinkCard import com.flipcash.shared.chat.ui.QuickReactionStripPlacement import com.flipcash.shared.chat.ui.transcriptBubbleMaxWidth import com.getcode.theme.CodeTheme +import com.getcode.ui.components.chat.ChatInputDefaults import com.getcode.ui.utils.rememberKeyboardController import com.getcode.util.vibration.LocalVibrator import kotlinx.coroutines.delay @@ -702,6 +703,14 @@ internal fun MessageList( val jumpVisible by remember(selecting) { derivedStateOf { hasPositioned && !listState.restsOnNewest() && !selecting && !autoScrolling } } + // Centred on the `$` button's column: its trailing edge is the bar's side inset from the + // screen edge and it is OutsideSize wide, so the jump button's end padding is the inset plus + // half the difference in widths. With no `$` beside a composer, the plain inset. + val dollarColumn = state.typingConstraints.enabled && state.editing == null + val jumpEndPadding = if (dollarColumn) { + composerSideInset(hasComposer = true, keyboardOpen = keyboardOpenFraction()) + + (ChatInputDefaults.OutsideSize - JumpToBottomButtonSize) / 2 + } else CodeTheme.dimens.inset val jumpScope = rememberCoroutineScope() JumpToBottomButton( visible = jumpVisible, @@ -712,7 +721,7 @@ internal fun MessageList( modifier = Modifier .align(Alignment.BottomEnd) // The scaffold already rides the keyboard, so the bar-height padding does too. - .padding(end = CodeTheme.dimens.inset, bottom = contentPadding.calculateBottomPadding() + 20.dp), + .padding(end = jumpEndPadding, bottom = contentPadding.calculateBottomPadding() + 20.dp), ) } From 3f609119a6cdd69a5823ac096b9fac4abd287f95 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 13:13:53 -0400 Subject: [PATCH 21/23] test(chat): assert a system notice leaves the badge, cutoff and divider queries unchanged Adds a ChatMessageDaoTest case that inserts a senderless System row and checks the inbound count, newest inbound id, first-unread id and own-send range are the same as before. --- .../components/JumpToBottomVisibilityTest.kt | 2 +- .../components/UserScrollReleaseTest.kt | 2 +- .../app/persistence/dao/ChatMessageDaoTest.kt | 29 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottomVisibilityTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottomVisibilityTest.kt index c9d8261772..e2ac66e246 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottomVisibilityTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottomVisibilityTest.kt @@ -61,7 +61,7 @@ class JumpToBottomVisibilityTest { val boundary = UnreadBoundary.At(readThrough = 10, count = 30) val newestFirst = (40L downTo 1L).map(::bubble) val loaded = LoadState.NotLoading(endOfPaginationReached = true) - val page = PagingData.from(newestFirst, LoadStates(loaded, loaded, loaded)) + val page = PagingData.from(newestFirst, LoadStates(loaded, loaded, loaded)) val paged = flowOf(page.withSeparators(boundary, SeparatorConfig.DayOnly)) var selected by mutableStateOf(null) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/UserScrollReleaseTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/UserScrollReleaseTest.kt index d1cd3aae05..fe546ab8c4 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/UserScrollReleaseTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/UserScrollReleaseTest.kt @@ -63,7 +63,7 @@ class UserScrollReleaseTest { val boundary = UnreadBoundary.At(readThrough = 10, count = 30) val newestFirst = (40L downTo 1L).map(::bubble) val loaded = LoadState.NotLoading(endOfPaginationReached = true) - val page = PagingData.from(newestFirst, LoadStates(loaded, loaded, loaded)) + val page = PagingData.from(newestFirst, LoadStates(loaded, loaded, loaded)) val paged = flowOf(page.withSeparators(boundary, SeparatorConfig.DayOnly)) var released = 0 diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt index c9dc458699..94c520e0b5 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt @@ -301,6 +301,35 @@ class ChatMessageDaoTest { assertEquals(0, unread(pointer = 1)) } + @Test + fun `a system notice leaves the badge count, arrival cutoff, divider and own-send range unchanged`() = runTest { + dao.upsert(listOf(from(SENDER_HEX, 1), from(SENDER_HEX, 2))) + val before = listOf( + unread(pointer = 1), + dao.observeInboundCountAfter(CHAT_HEX, SELF_HEX, 1).first(), + dao.newestInboundId(CHAT_HEX, SELF_HEX), + dao.firstNotSentByAfter(CHAT_HEX, SELF_HEX, 1), + dao.getInboundMessagesInRange(CHAT_HEX, SELF_HEX, 0, 10).map { it.messageId }, + ) + + dao.upsert( + text(5, "").copy( + senderIdHex = null, + contentJson = listOf(MessageContentSerialized.System("Alex joined the group")), + ), + ) + + val after = listOf( + unread(pointer = 1), + dao.observeInboundCountAfter(CHAT_HEX, SELF_HEX, 1).first(), + dao.newestInboundId(CHAT_HEX, SELF_HEX), + dao.firstNotSentByAfter(CHAT_HEX, SELF_HEX, 1), + dao.getInboundMessagesInRange(CHAT_HEX, SELF_HEX, 0, 10).map { it.messageId }, + ) + assertEquals(before, after) + assertEquals(listOf(1, 1, 2L, 2L, listOf(1L, 2L)), after) + } + private suspend fun firstNotOwn(pointer: Long) = dao.firstNotSentByAfter(CHAT_HEX, SELF_HEX, pointer) @Test From eead7a819e02f9fd5eb9722789a26e9a2fd22cb3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 15:09:15 -0400 Subject: [PATCH 22/23] fix(chat): place the jump-to-bottom button and the composer row from one layout-phase inset Both read the margin through ComposerInsets while laying out, so a keyboard animation frame moves the jump button and the $ button together. The old composition-time end padding in MessageList is gone. --- .../screens/components/ChatBottomBar.kt | 76 ++++++++++++++++++- .../screens/components/MessageList.kt | 19 +++-- .../screens/components/ComposerInsetsTest.kt | 50 ++++++++++++ 3 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerInsetsTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index 019e97c813..efee25747c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -79,6 +79,10 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.offset +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.Density +import androidx.compose.runtime.Stable import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import androidx.compose.ui.util.lerp @@ -282,6 +286,7 @@ internal fun UserControlBottomBar( val keyboardOpen = keyboardOpenFraction() val hasComposer = state.typingConstraints.enabled val sideInset = composerSideInset(hasComposer, keyboardOpen) + val composerInsets = rememberComposerInsets() val restingDrop = if (hasComposer) lerp(CodeTheme.dimens.staticGrid.x2, 0.dp, keyboardOpen) else 0.dp // The part of the navigation bar's inset the keyboard isn't covering, so the bar only sinks // into it once the keyboard has cleared it. @@ -411,7 +416,7 @@ internal fun UserControlBottomBar( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = sideInset), + .composerSidePadding(composerInsets, hasComposer), ) { if (canType) { val chips = remember(state.composerPhotos, state.uploadStates) { @@ -651,7 +656,8 @@ private fun DeactivatedChatBottomBar() { /** * The margin either side of the bar. Compact at rest once there is a composer, opening out to the * normal inset as the keyboard ([keyboardOpen], 0 to 1) comes up. The `$` button's trailing edge sits - * this far from the screen edge, and the jump-to-bottom button aligns to it. + * this far from the screen edge. Composition-phase: the accessory cards use it; the input row and the + * jump-to-bottom button use [ComposerInsets] instead, which is read while laying out. */ @Composable internal fun composerSideInset(hasComposer: Boolean, keyboardOpen: Float): Dp { @@ -659,6 +665,72 @@ internal fun composerSideInset(hasComposer: Boolean, keyboardOpen: Float): Dp { return if (hasComposer) lerp(compactInset, CodeTheme.dimens.inset, keyboardOpen) else CodeTheme.dimens.inset } +/** + * The composer's side margin, readable in the layout phase. The bar's input row and the + * jump-to-bottom button both place from this one value, so a keyboard animation frame moves them + * together instead of each picking it up through its own recomposition. + */ +@Stable +internal class ComposerInsets( + private val ime: WindowInsets, + private val imeSource: WindowInsets, + private val imeTarget: WindowInsets, + private val compact: Dp, + private val normal: Dp, +) { + /** The margin in px, with the keyboard's animation state read at the call site's phase. */ + fun Density.sidePx(hasComposer: Boolean): Int { + if (!hasComposer) return normal.roundToPx() + val now = ime.getBottom(this) + val full = maxOf(imeSource.getBottom(this), imeTarget.getBottom(this), now) + val open = if (full <= 0) 0f else now.toFloat() / full + return lerp(compact, normal, open).roundToPx() + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun rememberComposerInsets(): ComposerInsets { + val ime = WindowInsets.ime + val source = WindowInsets.imeAnimationSource + val target = WindowInsets.imeAnimationTarget + val compact = CodeTheme.dimens.staticGrid.x6.coerceAtLeast(CodeTheme.dimens.inset) + val normal = CodeTheme.dimens.inset + return remember(ime, source, target, compact, normal) { ComposerInsets(ime, source, target, compact, normal) } +} + +/** Horizontal padding of [ComposerInsets.sidePx], resolved while laying out. */ +internal fun Modifier.composerSidePadding(insets: ComposerInsets, hasComposer: Boolean): Modifier = + layout { measurable, constraints -> + val side = with(insets) { sidePx(hasComposer) } + val placeable = measurable.measure(constraints.offset(horizontal = -2 * side)) + layout((placeable.width + 2 * side).coerceIn(constraints.minWidth, constraints.maxWidth), placeable.height) { + placeable.place(side, 0) + } + } + +/** + * Moves a bottom-end aligned child so it is centred over the `$` button's column: the bar's margin + * plus half the difference between the column and the child, or just + * [plainInset] when no `$` sits beside a composer. + */ +internal fun Modifier.alignedToDollarColumn( + insets: ComposerInsets, + dollarColumn: Boolean, + columnWidth: Dp, + plainInset: Dp, +): Modifier = layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val end = if (dollarColumn) { + with(insets) { sidePx(hasComposer = true) } + (columnWidth.roundToPx() - placeable.width) / 2 + } else { + plainInset.roundToPx() + } + layout(placeable.width, placeable.height) { + placeable.place(if (layoutDirection == LayoutDirection.Rtl) end else -end, 0) + } +} + /** * How far open the keyboard is, from 0 (down) to 1 (fully up), following its show and hide * animations frame by frame. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 9afed04503..c4680007a9 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -703,14 +703,11 @@ internal fun MessageList( val jumpVisible by remember(selecting) { derivedStateOf { hasPositioned && !listState.restsOnNewest() && !selecting && !autoScrolling } } - // Centred on the `$` button's column: its trailing edge is the bar's side inset from the - // screen edge and it is OutsideSize wide, so the jump button's end padding is the inset plus - // half the difference in widths. With no `$` beside a composer, the plain inset. + // Centred on the `$` button's column, placed from the same inset value the bar's input row + // uses and in the same layout pass (see ComposerInsets). With no `$` beside a composer, + // the plain inset. val dollarColumn = state.typingConstraints.enabled && state.editing == null - val jumpEndPadding = if (dollarColumn) { - composerSideInset(hasComposer = true, keyboardOpen = keyboardOpenFraction()) + - (ChatInputDefaults.OutsideSize - JumpToBottomButtonSize) / 2 - } else CodeTheme.dimens.inset + val composerInsets = rememberComposerInsets() val jumpScope = rememberCoroutineScope() JumpToBottomButton( visible = jumpVisible, @@ -721,7 +718,13 @@ internal fun MessageList( modifier = Modifier .align(Alignment.BottomEnd) // The scaffold already rides the keyboard, so the bar-height padding does too. - .padding(end = jumpEndPadding, bottom = contentPadding.calculateBottomPadding() + 20.dp), + .padding(bottom = contentPadding.calculateBottomPadding() + 20.dp) + .alignedToDollarColumn( + insets = composerInsets, + dollarColumn = dollarColumn, + columnWidth = ChatInputDefaults.OutsideSize, + plainInset = CodeTheme.dimens.inset, + ), ) } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerInsetsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerInsetsTest.kt new file mode 100644 index 0000000000..dce9ac66c8 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerInsetsTest.kt @@ -0,0 +1,50 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The bar's input row and the jump-to-bottom button both place from [ComposerInsets.sidePx], so the + * value is what keeps the jump button over the `$` button while the keyboard moves. + */ +class ComposerInsetsTest { + + private val density = Density(1f) + + private class FakeInsets(var bottom: Int) : WindowInsets { + override fun getLeft(density: Density, layoutDirection: LayoutDirection) = 0 + override fun getTop(density: Density) = 0 + override fun getRight(density: Density, layoutDirection: LayoutDirection) = 0 + override fun getBottom(density: Density) = bottom + } + + private fun insets(now: Int, source: Int = now, target: Int = now, compact: Dp = 24.dp, normal: Dp = 16.dp) = + ComposerInsets(FakeInsets(now), FakeInsets(source), FakeInsets(target), compact, normal) + + private fun ComposerInsets.px(hasComposer: Boolean) = with(density) { with(this@px) { sidePx(hasComposer) } } + + @Test + fun `keyboard down leaves the compact margin`() { + assertEquals(24, insets(now = 0, source = 0, target = 800).px(hasComposer = true)) + } + + @Test + fun `keyboard fully up opens out to the normal margin`() { + assertEquals(16, insets(now = 800, source = 0, target = 800).px(hasComposer = true)) + } + + @Test + fun `halfway through the animation is halfway between the margins`() { + assertEquals(20, insets(now = 400, source = 0, target = 800).px(hasComposer = true)) + } + + @Test + fun `no composer is always the normal margin`() { + assertEquals(16, insets(now = 0, source = 0, target = 800).px(hasComposer = false)) + } +} From ac98eeb1eed52258cbb00a88ceb3f06be6fc283d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 20:26:16 -0400 Subject: [PATCH 23/23] fix(chat): give the jump-to-bottom button the composer's glass material The button built its own blur, skipped the composer's container fill and rim gradient, and carried a shadow the other surfaces don't have. It now uses a shared chatGlassMaterial() with the message bar, the $ button and the join-requests card. --- .../screens/components/ChatBottomBar.kt | 3 +-- .../internal/screens/components/ChatGlass.kt | 15 ++++++++++++ .../screens/components/JoinRequestsAnchor.kt | 6 +---- .../screens/components/JumpToBottom.kt | 24 ++++++++----------- 4 files changed, 27 insertions(+), 21 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatGlass.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index efee25747c..069f7ee9c9 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -109,7 +109,6 @@ import com.getcode.ui.utils.rememberKeyboardController import dev.chrisbanes.haze.HazeInput import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.hazeBlur -import dev.chrisbanes.haze.blur.materials.HazeMaterials /** The ramp's shape: clear for most of its height, then a late rise into the background. */ private val ComposerFadeCurve = CubicBezierEasing(0.42f, 0.18f, 0.17f, 1.04f) @@ -201,7 +200,7 @@ internal fun UserControlBottomBar( val focusRequester = remember { FocusRequester() } var buttonHeight by remember { mutableStateOf(0.dp) } var mentionListHeight by remember { mutableStateOf(0.dp) } - val material = HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background) + val material = chatGlassMaterial() // The attach menu and the camera are one surface over the keyboard, which stays up behind it. var attachPhase by remember { mutableStateOf(AttachPhase.Collapsed) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatGlass.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatGlass.kt new file mode 100644 index 0000000000..8b03b40c0c --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatGlass.kt @@ -0,0 +1,15 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.runtime.Composable +import com.getcode.theme.CodeTheme +import dev.chrisbanes.haze.blur.HazeBlurStyle +import dev.chrisbanes.haze.blur.materials.HazeMaterials + +/** + * The one glass material for the chat's floating surfaces: the composer's message bar, the `$` button, + * the join-requests card and the jump-to-bottom button. Layer `ChatInputDefaults.ContainerColor` and + * `ChatInputDefaults.RimBrush` over it as the composer does. + */ +@Composable +internal fun chatGlassMaterial(): HazeBlurStyle = + HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchor.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchor.kt index dfd58a3f8c..9cf96d486c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchor.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchor.kt @@ -41,7 +41,6 @@ import com.getcode.ui.theme.CodeButton import dev.chrisbanes.haze.HazeInput import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.hazeBlur -import dev.chrisbanes.haze.blur.materials.HazeMaterials import kotlinx.coroutines.delay import kotlin.time.Clock import kotlin.time.Duration.Companion.minutes @@ -65,10 +64,7 @@ internal fun JoinRequestsAnchor( val shape = CodeTheme.shapes.medium val ground = if (hazeState != null) { Modifier - .hazeBlur( - HazeInput.Sources(hazeState), - HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background), - ) + .hazeBlur(HazeInput.Sources(hazeState), chatGlassMaterial()) .background(ChatInputDefaults.ContainerColor) } else { Modifier.background(Color.White.copy(alpha = 0.1f)) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt index 446e07b304..becc2843c8 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt @@ -27,9 +27,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.layout.layout import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -40,10 +38,9 @@ import androidx.compose.ui.unit.sp import com.flipcash.features.messenger.R import com.getcode.theme.CodeTheme import com.getcode.ui.components.Badge +import com.getcode.ui.components.chat.ChatInputDefaults import dev.chrisbanes.haze.HazeInput import dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.blur.HazeBlurStyle -import dev.chrisbanes.haze.blur.HazeColorEffect import dev.chrisbanes.haze.blur.hazeBlur /** Past this many rows from the newest, a tap jumps rather than animates (from the old Flipchat). */ @@ -75,12 +72,7 @@ internal fun JumpToBottomButton( modifier: Modifier = Modifier, ) { val background = CodeTheme.colors.background - // The HazeBlurStyle builder is not a @Composable scope, so theme reads are hoisted above it. - val glass = HazeBlurStyle { - blurRadius(20.dp) - backgroundColor(background) - colorEffects(listOf(HazeColorEffect.tint(Color.White.copy(alpha = 0.12f)))) - } + val glass = chatGlassMaterial() // Held so the pill doesn't read "0" while it scales out. var lastCount by remember { mutableIntStateOf(unreadCount) } LaunchedEffect(unreadCount) { if (unreadCount > 0) lastCount = unreadCount } @@ -97,13 +89,17 @@ internal fun JumpToBottomButton( Box( modifier = Modifier .size(JumpToBottomButtonSize) - .shadow(elevation = 8.dp, shape = CircleShape, clip = false) .clip(CircleShape) .then( - if (hazeState != null) Modifier.hazeBlur(HazeInput.Sources(hazeState), glass) - else Modifier.background(Color.White.copy(alpha = 0.12f).compositeOver(background)) + if (hazeState != null) { + Modifier + .hazeBlur(HazeInput.Sources(hazeState), glass) + .background(ChatInputDefaults.ContainerColor) + } else { + Modifier.background(Color.White.copy(alpha = 0.1f)) + } ) - .border(0.5.dp, CodeTheme.colors.divider, CircleShape) + .border(CodeTheme.dimens.border, ChatInputDefaults.RimBrush, CircleShape) .clickable(onClick = onClick), contentAlignment = Alignment.Center, ) {