diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml
index 046bb46b4..7d3613b46 100644
--- a/apps/flipcash/core/src/main/res/values/strings.xml
+++ b/apps/flipcash/core/src/main/res/values/strings.xml
@@ -1299,6 +1299,11 @@
No chats yet
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/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt
index 0793cd452..eeb9b3219 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
@@ -424,6 +425,25 @@ 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 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,
/**
* True while the delete confirmation is up.
*
@@ -996,6 +1016,18 @@ internal class ChatViewModel @AssistedInject constructor(
/** The chat's unread boundary, read once when its id became known. */
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
+
+ /** The server confirmed a message the viewer sent; the read pointer has moved past everything. */
+ data object OwnMessageSent : Event
data object JumpConsumed : Event
}
@@ -1047,6 +1079,23 @@ internal class ChatViewModel @AssistedInject constructor(
.flatMapLatest { chatCoordinator.observeOtherReadPointer(it) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
+ /**
+ * 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 ->
+ unreadBadgeCounts(
+ state = stateFlow,
+ live = chatCoordinator.observeUnreadBelow(chatId),
+ arrivalsAfter = { afterId -> chatCoordinator.observeInboundAfter(chatId, afterId) },
+ )
+ }
+ .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.
@@ -1763,7 +1812,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}") }
}
}
@@ -1791,6 +1841,23 @@ 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 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".
+ */
+ 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,
@@ -1859,6 +1926,13 @@ internal class ChatViewModel @AssistedInject constructor(
featuredGroups.peek(chatId)
?.takeIf { chatCoordinator.observeMetadata(chatId).first() == null }
?.let { dispatchEvent(Event.OnGroupResolved(ChatMembership(metadata = it, isMember = null))) }
+ // 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.
@@ -3056,6 +3130,7 @@ internal class ChatViewModel @AssistedInject constructor(
}
sent
.onSuccess {
+ dispatchEvent(Event.OwnMessageSent)
trace("message sent successfully")
analytics.track(ChatEvents.sentMessage(chatType.analytics, null))
}
@@ -3084,11 +3159,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}") }
}
}
@@ -3701,7 +3782,15 @@ 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,
+ 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
new file mode 100644
index 000000000..8b5b4c6c3
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadBadge.kt
@@ -0,0 +1,67 @@
+package com.flipcash.app.messenger.internal
+
+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 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.
+ *
+ * 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,
+ openCount: Int,
+ arrivalsSinceOpen: Int,
+ live: Int,
+ outsideGroup: Boolean,
+): Int = when {
+ outsideGroup -> 0
+ frozen -> openCount + 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.unreadBadgeOpenCount, it.isOutsideGroup) }
+ .distinctUntilChanged()
+ 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/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 2dcf2624c..cc699102b 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,9 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
webLinkPreviewsEnabled = webLinkPreviewsEnabled,
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/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt
index fbfada687..069f7ee9c 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
@@ -105,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)
@@ -197,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) }
@@ -281,8 +284,8 @@ 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 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.
@@ -412,7 +415,7 @@ internal fun UserControlBottomBar(
Row(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = sideInset),
+ .composerSidePadding(composerInsets, hasComposer),
) {
if (canType) {
val chips = remember(state.composerPhotos, state.uploadStates) {
@@ -649,6 +652,84 @@ 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. 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 {
+ val compactInset = CodeTheme.dimens.staticGrid.x6.coerceAtLeast(CodeTheme.dimens.inset)
+ 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/ChatGlass.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatGlass.kt
new file mode 100644
index 000000000..8b03b40c0
--- /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 dfd58a3f8..9cf96d486 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
new file mode 100644
index 000000000..becc2843c
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt
@@ -0,0 +1,149 @@
+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.height
+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.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+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.graphics.Color
+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
+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.hazeBlur
+
+/** Past this many rows from the newest, a tap jumps rather than animates (from the old Flipchat). */
+internal const val JumpToBottomAnimateMaxRows = 100
+
+internal val JumpToBottomButtonSize = 48.dp
+private val BadgeOutline = 2.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)
+}
+
+/**
+ * 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
+ 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 }
+ // 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,
+ 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(JumpToBottomButtonSize)
+ .clip(CircleShape)
+ .then(
+ if (hazeState != null) {
+ Modifier
+ .hazeBlur(HazeInput.Sources(hazeState), glass)
+ .background(ChatInputDefaults.ContainerColor)
+ } else {
+ Modifier.background(Color.White.copy(alpha = 0.1f))
+ }
+ )
+ .border(CodeTheme.dimens.border, ChatInputDefaults.RimBrush, CircleShape)
+ .clickable(onClick = onClick),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.KeyboardArrowDown,
+ 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),
+ )
+ }
+ AnimatedVisibility(
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ // 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)),
+ ) {
+ // 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 = shownCount,
+ 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 c68cbc400..c4680007a 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
@@ -20,6 +21,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 +68,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
@@ -88,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
@@ -96,6 +100,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
@@ -115,6 +120,9 @@ internal fun MessageList(
webLinkPreviewsEnabled: Boolean = true,
onJumpConsumed: () -> Unit = {},
topBarBottom: Dp = 0.dp,
+ unreadBelowCount: Int = 0,
+ onUserScrolled: () -> Unit = {},
+ hazeState: HazeState? = null,
) {
val keyboard = rememberKeyboardController()
val listState = rememberLazyListState()
@@ -143,7 +151,19 @@ 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)
+ // 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
@@ -440,240 +460,272 @@ 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
- }
+ .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
- // 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) }
+ // 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 }
},
- model = typingRow,
- presence = { dotsPresence.value },
- // Dots carried under a newer message keep their place.
- enters = Snapshot.withoutReadObservation { dotsPresence.value == 0f },
+ ) {
+ 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,
)
- }
- }
- }
- // 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() }))
+ }
}
}
+ // 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.
+ // 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 }
+ }
+ // 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 composerInsets = rememberComposerInsets()
+ 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(bottom = contentPadding.calculateBottomPadding() + 20.dp)
+ .alignedToDollarColumn(
+ insets = composerInsets,
+ dollarColumn = dollarColumn,
+ columnWidth = ChatInputDefaults.OutsideSize,
+ plainInset = CodeTheme.dimens.inset,
+ ),
+ )
}
// Scroll to bottom when the newest message changes (sent or received)
@@ -685,66 +737,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
}
}
}
@@ -885,7 +949,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/ChatOpenTranscriptTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt
index 91ae4f79b..66074f0e2 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)
+ }
}
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 000000000..587621cd5
--- /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/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 000000000..38af7d08b
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadBadgeTest.kt
@@ -0,0 +1,221 @@
+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
+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))
+ dispatch(ChatViewModel.Event.ArrivalCutoffRecorded(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 `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 {
+ 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())
+ }
+
+ @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)
+ }
+}
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 000000000..dce9ac66c
--- /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))
+ }
+}
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 000000000..e2ac66e24
--- /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
+ }
+}
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 000000000..fe546ab8c
--- /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
+ }
+}
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 a5424d74f..99ca1bb6e 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,34 @@ 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
+
+ /**
+ * 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?
+
+ /**
+ * 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.
+ */
+ 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
@@ -401,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 06223cac5..8d67ab2b6 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,10 +54,14 @@ 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
+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
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@@ -204,8 +208,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)
@@ -221,6 +236,43 @@ class MessagingDelegate @Inject constructor(
return UnreadBoundary.At(readThrough = boundary, count = count)
}
+ @OptIn(ExperimentalCoroutinesApi::class)
+ 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 newestInboundId(chatId: ChatId): Long? {
+ val selfId = awaitReady()
+ 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 }
+ .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)
@@ -262,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 ->
@@ -277,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
new file mode 100644
index 000000000..23f52e898
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBelowObservationTest.kt
@@ -0,0 +1,173 @@
+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 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]
+ * 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 messagingController = mockk(relaxed = true)
+ 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 } answers { account.value.accountId }
+ every { it.state } returns account
+ }
+
+ init {
+ coEvery { messageDataSource.hasAtOrBelow(chatId, any()) } returns true
+ }
+
+ private val delegate = MessagingDelegate(
+ chatController = mockk(relaxed = true),
+ messagingController = messagingController,
+ 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())
+ }
+
+ @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())
+ }
+ }
+
+ @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)
+ 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)
+ 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/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 098e05273..7e5411d0f 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
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 a2ca50471..dc00aea3f 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 017d28d3b..cbab3c6ac 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,30 @@ 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 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/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 57397b557..94c520e0b 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)))
@@ -279,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
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 000000000..7fce28e15
--- /dev/null
+++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/UnreadBelowBadgeTest.kt
@@ -0,0 +1,164 @@
+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 app.cash.turbine.test
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+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)
+ val dividerCount = messages.countInboundAfter(CHAT_HEX, SELF_HEX, 2)
+
+ badge().test {
+ assertEquals(dividerCount, awaitItem())
+ assertEquals(2, dividerCount)
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ 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)
+
+ badge().test {
+ assertEquals(3, awaitItem())
+
+ 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 emits n then n plus 1`() = runTest {
+ messages.upsert(listOf(message(OTHER_HEX, 1), message(OTHER_HEX, 2)))
+ open(readThrough = 2)
+
+ 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 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)
+
+ 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 emits 0`() = runTest {
+ messages.upsert(listOf(message(OTHER_HEX, 1)))
+
+ badge().test {
+ assertEquals(0, awaitItem())
+ expectNoEvents()
+ }
+ }
+
+ 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 ec926802d..e11f9c44c 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,8 +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
@@ -64,6 +68,24 @@ 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. 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 =
+ 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
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 57cac1de0..ac5a77788 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,33 @@ 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(
+ 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
+ * 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 =
+ 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? =
db?.chatMessageDao()?.firstNotSentByAfter(
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 000000000..931396eff
--- /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())
+ }
+ }
+}
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 92c644b9f..af9731560 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 7fbd83e06..5276f7edf 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 a7845e549..64b1cdbed 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,