diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml
index 37db268648..b42d5b4321 100644
--- a/apps/flipcash/core/src/main/res/values/strings.xml
+++ b/apps/flipcash/core/src/main/res/values/strings.xml
@@ -889,6 +889,10 @@
Message Not Sent
Your message could not be delivered. Would you like to try again?
Today
+
+ - %1$s Unread Message
+ - %1$s Unread Messages
+
Send Money To Your Friends
Send money to friends as easily as a text. Connect your phone number to get started
diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts
index d14d74190e..459b903778 100644
--- a/apps/flipcash/features/messenger/build.gradle.kts
+++ b/apps/flipcash/features/messenger/build.gradle.kts
@@ -37,4 +37,5 @@ dependencies {
testImplementation(libs.bundles.unit.testing)
testImplementation(libs.mockito.kotlin)
testImplementation(libs.robolectric)
+ testImplementation(libs.androidx.paging.testing)
}
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 c281ba160e..00470df4c4 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
@@ -13,7 +13,6 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.flatMap
-import androidx.paging.insertSeparators
import com.flipcash.app.analytics.Analytics
import com.flipcash.app.analytics.FlipcashAnalyticsService
import com.flipcash.app.contacts.ContactCoordinator
@@ -28,6 +27,7 @@ import com.flipcash.app.core.ui.ConfirmationStyle
import com.flipcash.app.core.util.Linkify
import com.flipcash.app.funding.PurchaseMethodController
import com.flipcash.app.messenger.internal.link.CashCardTap
+import com.flipcash.shared.chat.UnreadBoundary
import com.flipcash.app.messenger.internal.link.ClaimReplyTargets
import com.flipcash.app.messenger.internal.link.LinkCardClassifier
import com.flipcash.app.messenger.internal.link.LinkCardResolver
@@ -118,6 +118,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
@@ -264,6 +265,14 @@ internal class ChatViewModel @Inject constructor(
* it. Set alongside [jumpTarget] and cleared with it.
*/
val jumpBudget: Int? = null,
+ /**
+ * Where the "N Unread Messages" divider sits, read once per visit from the viewer's stored
+ * READ pointer. Never re-read: the list advances that pointer as the reader scrolls, and a
+ * boundary that followed it would slide the divider away from what was new at open.
+ */
+ 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,
/**
* True while the delete confirmation is up.
*
@@ -576,6 +585,9 @@ internal class ChatViewModel @Inject constructor(
/** The same request, once the walk's bound is known. */
data class JumpResolved(val messageId: Long, val budget: Int) : Event
+
+ /** The chat's unread boundary, read once when its id became known. */
+ data class UnreadBoundaryResolved(val boundary: UnreadBoundary, val walkBudget: Int) : Event
data object JumpConsumed : Event
}
@@ -664,8 +676,12 @@ internal class ChatViewModel @Inject constructor(
*/
private val claimReplyTargets = ClaimReplyTargets()
+ /**
+ * The transcript's rows before separators. Cached so a change to the unread boundary or the
+ * separator config re-runs only [messages]' separator pass, not the per-message lookups here.
+ */
@OptIn(ExperimentalCoroutinesApi::class)
- val messages: Flow> =
+ private val mappedMessages: Flow> =
combine(
messageStream,
pendingMutations,
@@ -771,13 +787,26 @@ internal class ChatViewModel @Inject constructor(
.splitAroundLinkCard()
.asReversed()
}
- }.insertSeparators { before: ChatListItem.ContentBubble?, after: ChatListItem.ContentBubble? ->
- if (before == null || after == null) return@insertSeparators null
- if (stateFlow.value.separatorConfig.shouldSeparate(before.timestamp, after.timestamp)) {
- ChatListItem.DateSeparator(before.timestamp)
- } else null
}
- }
+ }.cachedIn(viewModelScope)
+
+ /**
+ * The transcript as the list draws it: [mappedMessages] with date separators and the unread
+ * divider. The boundary and config are inputs rather than reads of the current state, so a
+ * page never takes its separators from one config and its neighbour's from another.
+ *
+ * Nothing is emitted while the boundary is still resolving. The list decides where to open from
+ * the first page it lays out, and a page drawn before the boundary landed has no divider to
+ * open at. Both wait on the same chat id, and the resolution is two local queries.
+ */
+ val messages: Flow> =
+ combine(
+ mappedMessages,
+ stateFlow.map { it.unreadBoundary }
+ .filterNot { it is UnreadBoundary.Resolving }
+ .distinctUntilChanged(),
+ stateFlow.map { it.separatorConfig }.distinctUntilChanged(),
+ ) { paging, boundary, config -> paging.withSeparators(boundary, config) }
/**
* The citation shown for [this] message.
@@ -1741,6 +1770,19 @@ internal class ChatViewModel @Inject constructor(
dispatchEvent(Event.JumpResolved(event.messageId, distance))
}
.launchIn(viewModelScope)
+
+ // Read once per chat id. The list holds read reporting until this lands, so the pointer
+ // it reads is the one the viewer arrived with.
+ stateFlow.mapNotNull { it.chatId }
+ .distinctUntilChanged()
+ .onEach { chatId ->
+ val boundary = chatCoordinator.resolveUnreadBoundary(chatId)
+ val budget = (boundary as? UnreadBoundary.At)
+ ?.let { chatCoordinator.countMessagesAfter(chatId, it.readThrough) }
+ ?: 0
+ dispatchEvent(Event.UnreadBoundaryResolved(boundary, budget))
+ }
+ .launchIn(viewModelScope)
}
/** Leaves edit mode, restoring the draft the edit interrupted. */
@@ -2162,6 +2204,11 @@ internal class ChatViewModel @Inject constructor(
private val DRAFT_WRITE_DEBOUNCE = 300.milliseconds
val updateStateForEvent: (Event) -> ((State) -> State) = { event ->
+ stateReducer(event, UNREAD_DIVIDER_LIFETIME)
+ }
+
+ /** [updateStateForEvent] under an explicit [lifetime], so tests can cover both policies. */
+ internal fun stateReducer(event: Event, lifetime: UnreadDividerLifetime): (State) -> State =
when (event) {
is Event.OnChatOpened -> { state ->
when (val id = event.identifier) {
@@ -2274,7 +2321,11 @@ internal class ChatViewModel @Inject constructor(
is Event.ResolveFailed -> { state ->
state.copy(resolveState = ResolveState.Failed)
}
- is Event.SendMessage -> { state -> state }
+ is Event.SendMessage -> { state ->
+ val clears = lifetime == UnreadDividerLifetime.UntilSend &&
+ state.unreadBoundary is UnreadBoundary.At
+ if (clears) state.copy(unreadBoundary = UnreadBoundary.None) else state
+ }
is Event.RetryMessage -> { state -> state }
Event.NavigateToAmountEntry -> { state -> state.copy(sendProgress = LoadingSuccessState()) }
Event.NavigateToInitPayment -> { state -> state.copy(sendProgress = LoadingSuccessState()) }
@@ -2385,7 +2436,9 @@ internal class ChatViewModel @Inject constructor(
state.copy(jumpTarget = event.messageId, jumpBudget = event.budget)
}
Event.JumpConsumed -> { state -> state.copy(jumpTarget = null, jumpBudget = null) }
+ is Event.UnreadBoundaryResolved -> { state ->
+ state.copy(unreadBoundary = event.boundary, unreadWalkBudget = event.walkBudget)
+ }
}
- }
}
}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadDivider.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadDivider.kt
new file mode 100644
index 0000000000..723daac54a
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadDivider.kt
@@ -0,0 +1,67 @@
+package com.flipcash.app.messenger.internal
+
+import androidx.paging.PagingData
+import androidx.paging.insertSeparators
+import com.flipcash.shared.chat.UnreadBoundary
+import com.flipcash.shared.chat.models.ChatListItem
+import com.flipcash.shared.chat.models.SeparatorConfig
+
+/** How long the unread divider stays once a chat is open. iOS carries the same switch. */
+internal enum class UnreadDividerLifetime {
+ /** The whole visit. */
+ UntilClose,
+
+ /** Until the viewer sends a message. */
+ UntilSend,
+}
+
+internal val UNREAD_DIVIDER_LIFETIME = UnreadDividerLifetime.UntilClose
+
+/**
+ * Whether the unread divider goes in the gap between [newer] and [older]. [older] is `null` at the
+ * end of pagination, when every stored message is unread.
+ *
+ * Compares ranges rather than matching an id, so a read-through message that was deleted or never
+ * stored does not matter. The rows of one message share a `messageId`, so the divider never lands
+ * inside a message.
+ */
+internal fun unreadDividerBetween(
+ newer: ChatListItem.ContentBubble,
+ older: ChatListItem.ContentBubble?,
+ boundary: UnreadBoundary,
+): Boolean {
+ if (boundary !is UnreadBoundary.At || newer.isFromSelf) return false
+ val olderId = older?.messageId ?: Long.MIN_VALUE
+ return olderId <= boundary.readThrough && boundary.readThrough < newer.messageId
+}
+
+/** The one item that goes in the gap between [newer] and [older] in the newest-first list, if any. */
+internal fun separatorBetween(
+ newer: ChatListItem.ContentBubble?,
+ older: ChatListItem.ContentBubble?,
+ boundary: UnreadBoundary,
+ config: SeparatorConfig,
+): ChatListItem? {
+ newer ?: return null
+ if (boundary is UnreadBoundary.At && unreadDividerBetween(newer, older, boundary)) {
+ // The oldest stored message has no separator of its own; the list draws its date as a
+ // trailing header. The divider sits there instead, so it carries that date.
+ val dayChanges = older == null || config.shouldSeparate(newer.timestamp, older.timestamp)
+ return ChatListItem.UnreadDivider(boundary.count, date = newer.timestamp.takeIf { dayChanges })
+ }
+ older ?: return null
+ return if (config.shouldSeparate(newer.timestamp, older.timestamp)) {
+ ChatListItem.DateSeparator(newer.timestamp)
+ } else null
+}
+
+internal fun PagingData.withSeparators(
+ boundary: UnreadBoundary,
+ config: SeparatorConfig,
+): PagingData =
+ insertSeparators { newer: ChatListItem.ContentBubble?, older: ChatListItem.ContentBubble? ->
+ separatorBetween(newer, older, boundary, config)
+ }
+
+/** The count the divider shows: past 99 it reads "99+". */
+internal fun unreadCountLabel(count: Int): String = if (count > 99) "99+" else count.toString()
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 d46d99f8dd..a8ecbbd21f 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
@@ -41,6 +41,7 @@ import com.flipcash.app.messenger.internal.screens.ChatAnimations
import com.flipcash.features.messenger.R
import com.flipcash.services.models.chat.ChatType
import com.flipcash.services.models.chat.MessagePointer
+import com.flipcash.shared.chat.UnreadBoundary
import com.flipcash.shared.chat.models.ChatAction
import com.flipcash.shared.chat.models.ChatActionHandler
import com.flipcash.shared.chat.models.ChatListItem
@@ -88,7 +89,12 @@ internal fun MessageList(
LocalChatActionHandler provides onAction,
LocalLinkCardResolution provides linkCardResolution,
) {
- HandleMessageReads(listState, messages)
+ // Whether the list has settled where this visit opens it: at the unread divider, or at the
+ // bottom when there is none. Saveable for the same reason refreshSettled is — the screen is
+ // rebuilt under the amount-entry step, and this visit's opening must not replay on the way
+ // back.
+ var hasPositioned by rememberSaveable { mutableStateOf(false) }
+ HandleMessageReads(listState, messages, enabled = hasPositioned)
// Haptic feedback when a new incoming message arrives
// Track the newest message key — only fire when it changes to an incoming message
@@ -167,32 +173,7 @@ internal fun MessageList(
val budget = (state.jumpBudget?.plus(JUMP_PAGE_SIZE) ?: MAX_JUMP_ITEMS)
.coerceAtMost(MAX_JUMP_ITEMS)
- var index = indexOf(messages, target)
- while (index == null && messages.loadedCount <= budget) {
- // Touching the last loaded index is what emits the ViewportHint that drives one
- // more append. One page at a time: with placeholders on, itemCount is the whole
- // chat, so hinting at the end would append at the far side of history instead.
- val hint = messages.appendHintIndex
- if (hint < 0) break
-
- val before = messages.loadedCount
- messages[hint]
-
- // Wait for that append to land rather than for a frame: one frame is not a
- // guarantee, and a walk that races the pager gives up and scrolls nowhere.
- // loadedCount grows either way — placeholders on, placeholdersAfter shrinks; off,
- // itemCount grows.
- val progressed = withTimeoutOrNull(JUMP_STEP_TIMEOUT_MS) {
- snapshotFlow { messages.loadedCount to messages.loadState.append }
- .first { (count, append) -> count > before || append.endOfPaginationReached }
- .first > before
- }
- if (progressed != true) break
-
- index = indexOf(messages, target)
- }
-
- index?.let {
+ messages.walkUntil(budget) { indexOf(messages, target) }?.let {
listState.centerItem(it)
attentionId = target
attentionRequest++
@@ -203,6 +184,32 @@ internal fun MessageList(
onJumpConsumed()
}
+ // Opens the chat at the first unread message. The first layout is at the bottom, like any
+ // other chat; if the divider is already on screen there, that is where it stays. Otherwise
+ // the list walks back to it the way a jump does and puts it just under the top bar, with
+ // the unread messages running down from it. Reads are held until this settles, so the
+ // bottom of the transcript is not marked read by the frame that passes through it.
+ LaunchedEffect(state.unreadBoundary) {
+ when (val boundary = state.unreadBoundary) {
+ UnreadBoundary.Resolving -> return@LaunchedEffect
+ UnreadBoundary.None -> hasPositioned = true
+ is UnreadBoundary.At -> {
+ if (hasPositioned) return@LaunchedEffect
+ snapshotFlow { hasLoaded && listState.layoutInfo.visibleItemsInfo.isNotEmpty() }
+ .first { it }
+ if (!listState.layoutInfo.showsUnreadDivider(messages)) {
+ val budget = (state.unreadWalkBudget + JUMP_PAGE_SIZE)
+ .coerceAtMost(MAX_JUMP_ITEMS)
+ // Not reached in budget: the chat stays at the bottom, as it would have
+ // opened without a divider.
+ messages.walkUntil(budget) { unreadDividerIndex(messages) }
+ ?.let { listState.alignBelowTopBar(it) }
+ }
+ hasPositioned = true
+ }
+ }
+ }
+
// Holds the focused message at the position it was long-pressed at, against the keyboard
// shortening the list from below — whether the keyboard came up for the edit or because the
// composer was tapped with the selection bar still showing. See FocusPin.
@@ -355,6 +362,7 @@ internal fun MessageList(
val oldestTimestamp = when (oldest) {
is ChatListItem.ContentBubble -> oldest.timestamp
is ChatListItem.DateSeparator -> null // already a separator
+ is ChatListItem.UnreadDivider -> null // carries the oldest message's date
null -> null
}
if (oldestTimestamp != null) {
@@ -469,6 +477,69 @@ private val LazyPagingItems.loadedCount: Int
private val LazyPagingItems.appendHintIndex: Int
get() = (itemSnapshotList.placeholdersBefore + loadedCount).coerceAtMost(itemCount - 1)
+/**
+ * Appends one page at a time until [find] answers or [budget] items are loaded, and returns what
+ * [find] last answered.
+ */
+private suspend fun LazyPagingItems.walkUntil(budget: Int, find: () -> Int?): Int? {
+ var index = find()
+ while (index == null && loadedCount <= budget) {
+ // Touching the last loaded index is what emits the ViewportHint that drives one more
+ // append. One page at a time: with placeholders on, itemCount is the whole chat, so hinting
+ // at the end would append at the far side of history instead.
+ val hint = appendHintIndex
+ if (hint < 0) break
+
+ val before = loadedCount
+ this[hint]
+
+ // Wait for that append to land rather than for a frame: one frame is not a guarantee, and a
+ // walk that races the pager gives up and scrolls nowhere. loadedCount grows either way —
+ // placeholders on, placeholdersAfter shrinks; off, itemCount grows.
+ val progressed = withTimeoutOrNull(JUMP_STEP_TIMEOUT_MS) {
+ snapshotFlow { loadedCount to loadState.append }
+ .first { (count, append) -> count > before || append.endOfPaginationReached }
+ .first > before
+ }
+ if (progressed != true) break
+
+ index = find()
+ }
+ return index
+}
+
+/** The presented index of the unread divider, or `null` while it is still unloaded. */
+private fun unreadDividerIndex(messages: LazyPagingItems): Int? =
+ (0 until messages.itemCount).firstOrNull { messages.peek(it) is ChatListItem.UnreadDivider }
+
+/**
+ * Whether the unread divider is laid out whole below the top bar. A divider under the bar's fade
+ * counts as off screen: the viewer can't read it there.
+ */
+private fun LazyListLayoutInfo.showsUnreadDivider(messages: LazyPagingItems): Boolean {
+ val band = centeringBand()
+ return visibleItemsInfo.any { info ->
+ info.index < messages.itemCount &&
+ messages.peek(info.index) is ChatListItem.UnreadDivider &&
+ info.offset >= band.first && info.offset + info.size <= band.last
+ }
+}
+
+/**
+ * Puts [index] directly under the top bar, with no animation: the chat is opening there, not
+ * moving there.
+ *
+ * The item's upper edge goes at the top of the centering band rather than its lower edge — the top
+ * of the band is the bar's lower edge, so anything above it would sit under the bar's fade.
+ */
+private suspend fun LazyListState.alignBelowTopBar(index: Int) {
+ val band = layoutInfo.centeringBand()
+ val height = measuredHeight(index) ?: 0
+ // In this reverseLayout list the item's leading edge is its bottom, placed at -offset from the
+ // band's start (its bottom edge), so the room it leaves over goes below it.
+ scrollToItem(index, scrollOffset = -(band.last - band.first - height).coerceAtLeast(0))
+}
+
/** The presented index of [messageId], or `null` while it is still unloaded. */
private fun indexOf(messages: LazyPagingItems, messageId: Long): Int? =
(0 until messages.itemCount).firstOrNull { i ->
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageReadReporter.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageReadReporter.kt
index 7d6323cadc..deb5ddb389 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageReadReporter.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageReadReporter.kt
@@ -19,16 +19,21 @@ import kotlinx.coroutines.flow.filterNotNull
/**
* Reports the newest incoming message the viewer has actually had on screen, so the read pointer
* follows the scroll rather than the arrival.
+ *
+ * Nothing is reported until [enabled]: a chat opening at its unread divider lays out at the bottom
+ * first, and reporting that frame would mark read everything the divider is there to point at.
*/
@Composable
internal fun HandleMessageReads(
listState: LazyListState,
messages: LazyPagingItems,
+ enabled: Boolean,
) {
val actionHandler = LocalChatActionHandler.current
var lastAdvanced by remember { mutableLongStateOf(0L) }
- LaunchedEffect(listState, messages) {
+ LaunchedEffect(listState, messages, enabled) {
+ if (!enabled) return@LaunchedEffect
snapshotFlow {
val layout = listState.layoutInfo
val count = messages.itemCount
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt
index c4a57e140d..9a9fdb797c 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt
@@ -213,6 +213,10 @@ internal fun MessageRow(
DateSeparatorRow(item.timestamp)
}
+ is ChatListItem.UnreadDivider -> Box(insertionModifier) {
+ UnreadDividerRow(count = item.count, date = item.date)
+ }
+
is ChatListItem.ContentBubble -> {
val effectiveStatus = effectiveReceiptStatus(item, otherReadPointer)
// Track whether this item was ever seen as SENDING so we
@@ -456,24 +460,21 @@ internal enum class RowGap { Tight, Normal, Wide }
* group: two members' messages are both incoming, so a whole conversation between them ran at the
* tight same-sender gap.
*
- * Pure so it can be tested without a `PagingData`, like [startsSenderRun]: the messenger module has
- * no `paging-testing` dependency, and the peek belongs to the caller.
+ * Pure so it can be tested without a `PagingData`, like [startsSenderRun]: the peek belongs to the
+ * caller.
*/
internal fun rowGapBelow(item: ChatListItem, below: ChatListItem?, config: SeparatorConfig): RowGap {
below ?: return RowGap.Tight
- // Separator adjacent → normal gap
- if (item is ChatListItem.DateSeparator || below is ChatListItem.DateSeparator) {
+ // Separator or unread divider adjacent → normal gap
+ if (item !is ChatListItem.ContentBubble || below !is ChatListItem.ContentBubble) {
return RowGap.Normal
}
- val current = item as? ChatListItem.ContentBubble ?: return RowGap.Tight
- val newer = below as? ChatListItem.ContentBubble ?: return RowGap.Tight
-
return when {
- !current.isSameAuthorAs(newer) -> RowGap.Wide
+ !item.isSameAuthorAs(below) -> RowGap.Wide
// Same sender, outside grouping window → normal
- !config.isGrouped(current.timestamp, newer.timestamp) -> RowGap.Normal
+ !config.isGrouped(item.timestamp, below.timestamp) -> RowGap.Normal
// Same sender, close together → tight
else -> RowGap.Tight
}
@@ -490,8 +491,7 @@ internal fun rowGapBelow(item: ChatListItem, below: ChatListItem?, config: Separ
* first frame: a group's profile map arrives after the first page, and until it does every member's
* bubble has a null `sender`.
*
- * Pure so it can be tested without a `PagingData`: the messenger module has no `paging-testing`
- * dependency, and the peek belongs to the caller anyway.
+ * Pure so it can be tested without a `PagingData`: the peek belongs to the caller.
*/
internal fun startsSenderRun(current: ChatListItem.ContentBubble, older: ChatListItem?): Boolean {
val author = current.authorId ?: return false
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/UnreadDividerRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/UnreadDividerRow.kt
new file mode 100644
index 0000000000..f35c34c17d
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/UnreadDividerRow.kt
@@ -0,0 +1,70 @@
+package com.flipcash.app.messenger.internal.screens.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewWrapper
+import com.flipcash.app.messenger.internal.unreadCountLabel
+import com.flipcash.app.theme.FlipcashThemeWrapper
+import com.flipcash.features.messenger.R
+import com.getcode.theme.CodeTheme
+import kotlin.time.Clock
+import kotlin.time.Instant
+
+/**
+ * "N Unread Messages", a caption between two hairlines above the first message that arrived since
+ * the viewer last read the chat. When a day also changes at that gap, [date] is drawn above it, so the
+ * reader sees the day, then the divider, then the messages.
+ */
+@Composable
+internal fun UnreadDividerRow(
+ count: Int,
+ date: Instant?,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier = modifier.fillMaxWidth()) {
+ if (date != null) DateSeparatorRow(date)
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(
+ horizontal = CodeTheme.dimens.inset,
+ vertical = CodeTheme.dimens.grid.x2,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
+ ) {
+ HorizontalDivider(modifier = Modifier.weight(1f), color = CodeTheme.colors.divider)
+ Text(
+ text = pluralStringResource(R.plurals.title_chatUnreadDivider, count, unreadCountLabel(count)),
+ style = CodeTheme.typography.caption,
+ color = CodeTheme.colors.textSecondary,
+ )
+ HorizontalDivider(modifier = Modifier.weight(1f), color = CodeTheme.colors.divider)
+ }
+ }
+}
+
+// region Previews
+
+@Preview
+@PreviewWrapper(FlipcashThemeWrapper::class)
+@Composable
+private fun Preview_UnreadDivider() {
+ Column {
+ UnreadDividerRow(count = 1, date = null)
+ UnreadDividerRow(count = 12, date = Clock.System.now())
+ UnreadDividerRow(count = 250, date = null)
+ }
+}
+
+// endregion
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupTranscriptScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupTranscriptScreenshotTest.kt
index 317993da17..b532297a90 100644
--- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupTranscriptScreenshotTest.kt
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupTranscriptScreenshotTest.kt
@@ -105,6 +105,40 @@ class GroupTranscriptScreenshotTest {
capture("group_transcript.png")
}
+ /**
+ * The unread divider between read and unread messages, first on its own and then sharing its
+ * gap with a day change, where the date sits above it.
+ */
+ @Test
+ fun rendersUnreadDivider() {
+ val alone = listOf(
+ bubble(5, "Watch the tape", chloe, secondsIn = 40),
+ bubble(4, "What are you talking about. Did you see him last night?", chloe, secondsIn = 30),
+ ChatListItem.UnreadDivider(count = 2, date = null),
+ bubble(3, "Fine, Saturday then", sender = null, isFromSelf = true, secondsIn = 20),
+ bubble(2, "I disagree. He isn't the best at all", noah, secondsIn = 10),
+ ChatListItem.DateSeparator(start),
+ )
+ val withDate = listOf(
+ bubble(5, "Watch the tape", chloe, secondsIn = 90_000),
+ ChatListItem.UnreadDivider(count = 1, date = start + 90_000.seconds),
+ bubble(3, "Fine, Saturday then", sender = null, isFromSelf = true, secondsIn = 20),
+ ChatListItem.DateSeparator(start),
+ )
+
+ composeRule.setContent {
+ FlipcashThemeWrapper().Wrap {
+ Column(verticalArrangement = Arrangement.spacedBy(24.dp)) {
+ Transcript(flowOf(PagingData.from(alone)).collectAsLazyPagingItems())
+ Transcript(flowOf(PagingData.from(withDate)).collectAsLazyPagingItems())
+ }
+ }
+ }
+ repeat(20) { composeRule.mainClock.advanceTimeByFrame() }
+
+ capture("unread_divider.png")
+ }
+
/**
* The same transcript seen from outside the group, once per access: an eligible viewer reads it
* sharp over the Join gate, while a blocked one and one whose balance has not arrived yet see it
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/RowGapTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/RowGapTest.kt
index 897e2fb9cb..7e8fba524e 100644
--- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/RowGapTest.kt
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/RowGapTest.kt
@@ -40,6 +40,13 @@ class RowGapTest {
sender = sender,
)
+ @Test
+ fun `the unread divider sits a normal gap from either neighbour`() {
+ val divider = ChatListItem.UnreadDivider(count = 3)
+ assertEquals(RowGap.Normal, rowGapBelow(bubble(alice), divider, config))
+ assertEquals(RowGap.Normal, rowGapBelow(divider, bubble(alice), config))
+ }
+
@Test
fun `two members of a group are a run apart`() {
assertEquals(RowGap.Wide, rowGapBelow(bubble(alice), bubble(bob), config))
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadBoundaryReducerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadBoundaryReducerTest.kt
new file mode 100644
index 0000000000..508345583b
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadBoundaryReducerTest.kt
@@ -0,0 +1,72 @@
+package com.flipcash.app.messenger.internal
+
+import com.flipcash.shared.chat.UnreadBoundary
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/**
+ * The boundary is resolved once per visit and then only the lifetime policy may clear it. Reading
+ * the chat must not move it: the list advances the READ pointer as the reader scrolls, and a
+ * boundary that followed would slide the divider away from what was new at open.
+ */
+class UnreadBoundaryReducerTest {
+
+ private val at = UnreadBoundary.At(readThrough = 10, count = 30)
+
+ private fun reduce(
+ state: ChatViewModel.State,
+ event: ChatViewModel.Event,
+ lifetime: UnreadDividerLifetime = UnreadDividerLifetime.UntilClose,
+ ): ChatViewModel.State = ChatViewModel.stateReducer(event, lifetime)(state)
+
+ @Test
+ fun `a chat opens resolving`() = assertEquals(UnreadBoundary.Resolving, ChatViewModel.State().unreadBoundary)
+
+ @Test
+ fun `resolution sets the boundary and the walk budget`() {
+ val state = reduce(ChatViewModel.State(), ChatViewModel.Event.UnreadBoundaryResolved(at, walkBudget = 30))
+
+ assertEquals(at, state.unreadBoundary)
+ assertEquals(30, state.unreadWalkBudget)
+ }
+
+ @Test
+ fun `reading does not move the boundary`() {
+ val state = reduce(ChatViewModel.State(unreadBoundary = at), ChatViewModel.Event.AdvanceReadPointer(40))
+
+ assertEquals(at, state.unreadBoundary)
+ }
+
+ @Test
+ fun `until close keeps the divider through a send`() {
+ val state = reduce(ChatViewModel.State(unreadBoundary = at), ChatViewModel.Event.SendMessage)
+
+ assertEquals(at, state.unreadBoundary)
+ }
+
+ @Test
+ fun `until send clears the divider on a send`() {
+ val state = reduce(
+ ChatViewModel.State(unreadBoundary = at),
+ ChatViewModel.Event.SendMessage,
+ UnreadDividerLifetime.UntilSend,
+ )
+
+ assertEquals(UnreadBoundary.None, state.unreadBoundary)
+ }
+
+ @Test
+ fun `until send leaves a boundary that is still resolving`() {
+ val state = reduce(
+ ChatViewModel.State(),
+ ChatViewModel.Event.SendMessage,
+ UnreadDividerLifetime.UntilSend,
+ )
+
+ assertEquals(UnreadBoundary.Resolving, state.unreadBoundary)
+ }
+
+ @Test
+ fun `the shipped lifetime is until close`() =
+ assertEquals(UnreadDividerLifetime.UntilClose, UNREAD_DIVIDER_LIFETIME)
+}
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadDividerPlacementTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadDividerPlacementTest.kt
new file mode 100644
index 0000000000..ca0f1ea4ed
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadDividerPlacementTest.kt
@@ -0,0 +1,108 @@
+package com.flipcash.app.messenger.internal
+
+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.MessagePart
+import com.flipcash.shared.chat.models.SeparatorConfig
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.time.Instant
+
+/**
+ * The shared placement table, which iOS encodes too. The boundary each case passes is the one
+ * [com.flipcash.shared.chat.MessagingOperations.resolveUnreadBoundary] resolves for it; the count
+ * side of the table is covered against Room in `ChatMessageDaoTest`.
+ */
+class UnreadDividerPlacementTest {
+
+ private val noon = Instant.fromEpochMilliseconds(1_700_000_000_000)
+
+ private fun row(id: Long, self: Boolean = false, part: MessagePart? = null) = ChatListItem.ContentBubble(
+ messageId = id,
+ contentIndex = 0,
+ content = MessageContent.Text("m$id"),
+ isFromSelf = self,
+ timestamp = noon,
+ part = part,
+ )
+
+ /** Oldest first, as the table reads; returns the id the divider sits above, or null. */
+ private fun dividerAbove(boundary: UnreadBoundary, vararg oldestFirst: ChatListItem.ContentBubble): Long? {
+ val newestFirst = oldestFirst.reversed()
+ return newestFirst.indices.firstNotNullOfOrNull { i ->
+ val newer = newestFirst[i]
+ val older = newestFirst.getOrNull(i + 1)
+ newer.messageId.takeIf { unreadDividerBetween(newer, older, boundary) }
+ }
+ }
+
+ @Test
+ fun plain() = assertEquals(
+ 3L,
+ dividerAbove(UnreadBoundary.At(2, 2), row(1), row(2, self = true), row(3), row(4)),
+ )
+
+ @Test
+ fun `nothing unread`() = assertEquals(null, dividerAbove(UnreadBoundary.None, row(1), row(2)))
+
+ @Test
+ fun `only own after pointer`() =
+ assertEquals(null, dividerAbove(UnreadBoundary.None, row(1), row(2, self = true), row(3, self = true)))
+
+ @Test
+ fun `read-through deleted`() = assertEquals(3L, dividerAbove(UnreadBoundary.At(2, 1), row(1), row(3)))
+
+ @Test
+ fun `unread tombstone`() = assertEquals(2L, dividerAbove(UnreadBoundary.At(1, 1), row(1), row(2), row(3)))
+
+ @Test
+ fun `no self row`() = assertEquals(null, dividerAbove(UnreadBoundary.None, row(1), row(2)))
+
+ @Test
+ fun `own message first after pointer`() =
+ assertEquals(3L, dividerAbove(UnreadBoundary.At(2, 1), row(1), row(2, self = true), row(3)))
+
+ @Test
+ fun `resolving draws nothing`() = assertEquals(null, dividerAbove(UnreadBoundary.Resolving, row(1), row(2)))
+
+ @Test
+ fun `nothing stored at or below read-through`() =
+ assertEquals(null, dividerAbove(UnreadBoundary.None, row(3), row(4)))
+
+ @Test
+ fun `rows of one split message never straddle the divider`() {
+ val boundary = UnreadBoundary.At(1, 1)
+ val leading = row(2, part = MessagePart.Leading)
+ val card = row(2, part = MessagePart.Card)
+ // Newest first within the message: the card sits under the leading text in reverse layout.
+ assertEquals(false, unreadDividerBetween(newer = card, older = leading, boundary = boundary))
+ assertEquals(true, unreadDividerBetween(newer = leading, older = row(1), boundary = boundary))
+ }
+
+ @Test
+ fun `a date on the divider's gap rides on the divider`() {
+ val older = row(1).copy(timestamp = noon)
+ val newer = row(2).copy(timestamp = Instant.fromEpochMilliseconds(noon.toEpochMilliseconds() + 3 * 86_400_000L))
+
+ assertEquals(
+ ChatListItem.UnreadDivider(count = 1, date = newer.timestamp),
+ separatorBetween(newer, older, UnreadBoundary.At(1, 1), SeparatorConfig.DayOnly),
+ )
+ assertEquals(
+ ChatListItem.UnreadDivider(count = 1, date = null),
+ separatorBetween(newer.copy(timestamp = noon), older, UnreadBoundary.At(1, 1), SeparatorConfig.DayOnly),
+ )
+ assertEquals(
+ ChatListItem.DateSeparator(newer.timestamp),
+ separatorBetween(newer, older, UnreadBoundary.None, SeparatorConfig.DayOnly),
+ )
+ }
+
+ @Test
+ fun `counts over 99 read 99+`() {
+ assertEquals("1", unreadCountLabel(1))
+ assertEquals("99", unreadCountLabel(99))
+ assertEquals("99+", unreadCountLabel(100))
+ }
+}
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadSeparatorsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadSeparatorsTest.kt
new file mode 100644
index 0000000000..2ba38a1506
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadSeparatorsTest.kt
@@ -0,0 +1,72 @@
+package com.flipcash.app.messenger.internal
+
+import androidx.paging.PagingData
+import androidx.paging.testing.asSnapshot
+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.SeparatorConfig
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.time.Duration.Companion.days
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Instant
+
+/** What the light pipeline stage emits for a fixed page: one item per gap, the divider carrying a date. */
+class UnreadSeparatorsTest {
+
+ private val day1 = Instant.fromEpochMilliseconds(1_700_000_000_000)
+ private val day2 = day1 + 1.days
+
+ private fun row(id: Long, at: Instant) = ChatListItem.ContentBubble(
+ messageId = id,
+ contentIndex = 0,
+ content = MessageContent.Text("m$id"),
+ isFromSelf = false,
+ timestamp = at,
+ )
+
+ private suspend fun items(boundary: UnreadBoundary, vararg newestFirst: ChatListItem.ContentBubble) =
+ flowOf(PagingData.from(newestFirst.toList()).withSeparators(boundary, SeparatorConfig.DayOnly))
+ .asSnapshot()
+
+ private val r3 = row(3, day2 + 2.minutes)
+ private val r2 = row(2, day2)
+ private val r1 = row(1, day1)
+
+ @Test
+ fun `divider alone inside a day`() = runTest {
+ assertEquals(
+ listOf(r3, ChatListItem.UnreadDivider(1, date = null), r2, r1),
+ items(UnreadBoundary.At(2, 1), r3, r2, r1).dropDates(),
+ )
+ }
+
+ @Test
+ fun `date alone when there is no boundary`() = runTest {
+ assertEquals(
+ listOf(r3, r2, ChatListItem.DateSeparator(r2.timestamp), r1),
+ items(UnreadBoundary.None, r3, r2, r1),
+ )
+ }
+
+ @Test
+ fun `date and divider on one gap are one item`() = runTest {
+ assertEquals(
+ listOf(r3, r2, ChatListItem.UnreadDivider(2, date = r2.timestamp), r1),
+ items(UnreadBoundary.At(1, 2), r3, r2, r1),
+ )
+ }
+
+ @Test
+ fun `divider above the oldest carries that message's date`() = runTest {
+ assertEquals(
+ listOf(r3, r2, ChatListItem.DateSeparator(r2.timestamp), r1, ChatListItem.UnreadDivider(3, date = r1.timestamp)),
+ items(UnreadBoundary.At(0, 3), r3, r2, r1),
+ )
+ }
+
+ private fun List.dropDates() = filterNot { it is ChatListItem.DateSeparator }
+}
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/UnreadDividerOpenTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/UnreadDividerOpenTest.kt
new file mode 100644
index 0000000000..b880e20622
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/UnreadDividerOpenTest.kt
@@ -0,0 +1,128 @@
+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.getUnclippedBoundsInRoot
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.onRoot
+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.ChatAction
+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.assertTrue
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Instant
+
+/**
+ * Where a chat opens and what it reports read on the way there: 40 inbound messages with the
+ * viewer's pointer at 10, so the divider sits above message 11, well off the bottom of the screen.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34], qualifiers = "w400dp-h800dp-xhdpi")
+class UnreadDividerOpenTest {
+
+ @get:Rule
+ val composeRule = createAndroidComposeRule()
+
+ private val start = Instant.fromEpochMilliseconds(1_700_000_000_000)
+ private val advanced = mutableListOf()
+
+ private fun open(boundary: UnreadBoundary) {
+ val newestFirst = (40L downTo 1L).map { id ->
+ ChatListItem.ContentBubble(
+ messageId = id,
+ contentIndex = 0,
+ content = MessageContent.Text("m$id"),
+ isFromSelf = false,
+ timestamp = start + id.minutes,
+ )
+ }
+ // Built once: a flow made inside composition is a new one each pass, and a new
+ // LazyPagingItems with it.
+ // With its load states stated: without them the page arrives but refresh reads Loading
+ // forever, and the list never counts its first load as done.
+ val loaded = LoadState.NotLoading(endOfPaginationReached = true)
+ val page = PagingData.from(newestFirst, LoadStates(loaded, loaded, loaded))
+ val paged = flowOf(page.withSeparators(boundary, SeparatorConfig.DayOnly))
+ composeRule.setContent {
+ FlipcashThemeWrapper().Wrap {
+ MessageList(
+ contentPadding = PaddingValues(),
+ state = ChatViewModel.State(
+ unreadBoundary = boundary,
+ unreadWalkBudget = (boundary as? UnreadBoundary.At)?.count ?: 0,
+ ),
+ messages = paged.collectAsLazyPagingItems(),
+ separatorConfig = SeparatorConfig.DayOnly,
+ onAction = { action ->
+ if (action is ChatAction.AdvanceReadPointer) advanced += action.messageId
+ },
+ linkCardResolution = Unresolved,
+ canViewProfile = false,
+ )
+ }
+ }
+ // The page arrives through the pager's own dispatch, which idling the clock does not wait
+ // on.
+ composeRule.waitUntil(5_000) {
+ (1..40).any { composeRule.onAllNodesWithText("m$it").fetchSemanticsNodes().isNotEmpty() }
+ }
+ composeRule.waitForIdle()
+ }
+
+ @Test
+ fun `opens with the divider at the top and reports nothing below the screen`() {
+ open(UnreadBoundary.At(readThrough = 10, count = 30))
+
+ val divider = composeRule.onNodeWithText("30 Unread Messages").assertIsDisplayed()
+ val rootHeight = composeRule.onRoot().getUnclippedBoundsInRoot().bottom
+ assertTrue(divider.getUnclippedBoundsInRoot().top < rootHeight / 4, "divider is not near the top")
+ composeRule.onNodeWithText("m40").assertDoesNotExist()
+
+ assertTrue(advanced.isNotEmpty(), "reads never enabled")
+ composeRule.onNodeWithText("m${advanced.max()}").assertIsDisplayed()
+ assertTrue(advanced.max() < 40L, "reported a read past the screen: ${advanced.max()}")
+ }
+
+ @Test
+ fun `opens at the bottom with no boundary`() {
+ open(UnreadBoundary.None)
+
+ composeRule.onNodeWithText("m40").assertIsDisplayed()
+ assertTrue(40L in advanced)
+ }
+
+ @Test
+ fun `reports nothing while the boundary is resolving`() {
+ open(UnreadBoundary.Resolving)
+
+ assertTrue(advanced.isEmpty(), "reported $advanced before the boundary resolved")
+ }
+
+ 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-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt
index 94a48db35a..dc8bef481d 100644
--- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt
+++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt
@@ -16,6 +16,18 @@ sealed interface ChatListItem {
override val itemContentType: Any = "date-separator"
}
+ /**
+ * "N Unread Messages", above the first message that arrived after the viewer last read the
+ * chat. [date] is set when a day also changes at that gap: the list takes one item per gap, so
+ * the date is drawn above the divider rather than as a separator of its own.
+ *
+ * At most one per transcript, so its key is fixed.
+ */
+ data class UnreadDivider(val count: Int, val date: Instant? = null) : ChatListItem {
+ override val itemKey: Any = "unread-divider"
+ override val itemContentType: Any = "unread-divider"
+ }
+
data class ContentBubble(
val messageId: Long,
val contentIndex: Int,
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 8b6ba954ba..e56f7a696d 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
@@ -190,6 +190,16 @@ interface MessagingOperations {
*/
suspend fun distanceFromNewest(chatId: ChatId, messageId: Long): Int?
+ /**
+ * Where the "N unread" divider sits in [chatId], read from the viewer's stored READ pointer.
+ * [UnreadBoundary.None] when the viewer's own member row is not stored or nothing inbound is
+ * unread. Read once per visit: later pointer writes must not move it.
+ */
+ suspend fun resolveUnreadBoundary(chatId: ChatId): UnreadBoundary
+
+ /** How many stored messages in [chatId] sit past [messageId]. Bounds the walk to the unread divider. */
+ suspend fun countMessagesAfter(chatId: ChatId, messageId: Long): Int
+
/** Observes the member list for [chatId]. */
fun observeMembers(chatId: ChatId): Flow>
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/UnreadBoundary.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/UnreadBoundary.kt
new file mode 100644
index 0000000000..e9efb47299
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/UnreadBoundary.kt
@@ -0,0 +1,21 @@
+package com.flipcash.shared.chat
+
+/**
+ * Where the "N unread" divider sits in a chat transcript, resolved once per visit from the viewer's
+ * own READ pointer as stored when the chat opened.
+ */
+sealed interface UnreadBoundary {
+ /** Not read yet; the transcript holds read reporting and initial positioning until it resolves. */
+ data object Resolving : UnreadBoundary
+
+ /** No divider: nothing inbound is unread, or the viewer's own member row is not stored. */
+ data object None : UnreadBoundary
+
+ /**
+ * The divider sits in the gap straddling [readThrough]: the viewer's READ pointer, moved past any
+ * of their own messages that directly follow it, so the divider lands above the first message
+ * someone else sent. It need not be a stored message's id. [count] is the inbound, non-deleted
+ * messages past the pointer.
+ */
+ data class At(val readThrough: Long, val count: Int) : UnreadBoundary
+}
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 4e690fc6db..19978ddc2e 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
@@ -33,6 +33,7 @@ import com.flipcash.shared.chat.internal.SenderResolver
import com.flipcash.shared.chat.ChatMembership
import com.flipcash.shared.chat.MessagingOperations
import com.flipcash.shared.chat.PendingMutation
+import com.flipcash.shared.chat.UnreadBoundary
import com.flipcash.shared.chat.internal.ChatStateHolder
import com.flipcash.shared.chat.replacingText
import com.flipcash.services.user.UserManager
@@ -179,6 +180,26 @@ class MessagingDelegate @Inject constructor(
override suspend fun distanceFromNewest(chatId: ChatId, messageId: Long): Int? =
messageDataSource.distanceFromNewest(chatId, messageId)
+ override suspend fun resolveUnreadBoundary(chatId: ChatId): UnreadBoundary {
+ val selfId = userManager.accountId ?: return UnreadBoundary.None
+ val readThrough = memberDataSource.getSelfReadPointerOrNull(chatId, selfId)
+ ?: return UnreadBoundary.None
+ val count = messageDataSource.countInboundAfter(chatId, selfId, readThrough)
+ if (count <= 0) return UnreadBoundary.None
+ // The list places the divider by comparing neighbours, so it cannot see past the viewer's
+ // own messages right after the pointer. Resolving the boundary past them puts the divider
+ // above the first message someone else sent.
+ val firstNotOwn = messageDataSource.firstNotSentByAfter(chatId, selfId, readThrough)
+ val boundary = firstNotOwn?.minus(1) ?: readThrough
+ // With nothing stored at or below the boundary, older unread messages may never have been
+ // fetched, so the count could come out short. No divider beats a wrong one.
+ if (!messageDataSource.hasAtOrBelow(chatId, boundary)) return UnreadBoundary.None
+ return UnreadBoundary.At(readThrough = boundary, count = count)
+ }
+
+ override suspend fun countMessagesAfter(chatId: ChatId, messageId: Long): Int =
+ messageDataSource.countAfter(chatId, messageId)
+
override fun observeMembers(chatId: ChatId): Flow> {
return memberDataSource.observeMembers(chatId)
}
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
new file mode 100644
index 0000000000..098e052739
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBoundaryResolutionTest.kt
@@ -0,0 +1,115 @@
+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.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import kotlin.test.assertEquals
+
+/**
+ * Where the unread divider sits is read once, from the stored self member row. What matters here is
+ * the missing row: a group's roster is paged, so the viewer's own row may not be stored, and that
+ * has to read as "nothing known" rather than as a pointer of zero, which would put the divider above
+ * the whole transcript.
+ */
+class UnreadBoundaryResolutionTest {
+
+ private val chatId = ChatId("aabbccdd")
+ private val selfId = listOf(1, 2, 3)
+
+ private val memberDataSource = mockk(relaxed = true)
+ private val messageDataSource = mockk(relaxed = true)
+ private val userManager = mockk(relaxed = true).also {
+ every { it.accountId } returns selfId
+ }
+
+ init {
+ coEvery { messageDataSource.hasAtOrBelow(chatId, any()) } returns true
+ }
+
+ private val delegate = MessagingDelegate(
+ chatController = mockk(relaxed = true),
+ messagingController = mockk(relaxed = true),
+ metadataDataSource = mockk(relaxed = true),
+ messageDataSource = messageDataSource,
+ memberDataSource = memberDataSource,
+ notificationManager = mockk(relaxed = true),
+ userManager = userManager,
+ stateHolder = mockk(relaxed = true),
+ analytics = mockk(relaxed = true),
+ senderResolver = mockk(relaxed = true),
+ )
+
+ @Test
+ fun `no self row is no divider`() = runTest {
+ coEvery { memberDataSource.getSelfReadPointerOrNull(chatId, selfId) } returns null
+
+ assertEquals(UnreadBoundary.None, delegate.resolveUnreadBoundary(chatId))
+ coVerify(exactly = 0) { messageDataSource.countInboundAfter(any(), any(), any()) }
+ }
+
+ @Test
+ fun `nothing unread past the pointer is no divider`() = runTest {
+ coEvery { memberDataSource.getSelfReadPointerOrNull(chatId, selfId) } returns 2
+ coEvery { messageDataSource.countInboundAfter(chatId, selfId, 2) } returns 0
+
+ assertEquals(UnreadBoundary.None, delegate.resolveUnreadBoundary(chatId))
+ }
+
+ @Test
+ fun `unread past the pointer places the divider there`() = runTest {
+ coEvery { memberDataSource.getSelfReadPointerOrNull(chatId, selfId) } returns 2
+ coEvery { messageDataSource.countInboundAfter(chatId, selfId, 2) } returns 3
+ coEvery { messageDataSource.firstNotSentByAfter(chatId, selfId, 2) } returns 3
+
+ assertEquals(UnreadBoundary.At(readThrough = 2, count = 3), delegate.resolveUnreadBoundary(chatId))
+ }
+
+ /**
+ * 1:other 2:self 3:other with the pointer at 1. The divider belongs above 3, and the list places
+ * it by comparing neighbours, so the boundary has to sit past the viewer's own message.
+ */
+ @Test
+ fun `the boundary steps over the viewer's own messages after the pointer`() = runTest {
+ coEvery { memberDataSource.getSelfReadPointerOrNull(chatId, selfId) } returns 1
+ coEvery { messageDataSource.countInboundAfter(chatId, selfId, 1) } returns 1
+ coEvery { messageDataSource.firstNotSentByAfter(chatId, selfId, 1) } returns 3
+
+ assertEquals(UnreadBoundary.At(readThrough = 2, count = 1), delegate.resolveUnreadBoundary(chatId))
+ }
+
+ /**
+ * 3:other 4:other with the pointer at 1. Nothing at or below the boundary is stored, so older
+ * unread messages may never have been fetched and the count could come out short.
+ */
+ @Test
+ fun `nothing stored at or below the boundary is no divider`() = runTest {
+ coEvery { memberDataSource.getSelfReadPointerOrNull(chatId, selfId) } returns 1
+ coEvery { messageDataSource.countInboundAfter(chatId, selfId, 1) } returns 2
+ coEvery { messageDataSource.firstNotSentByAfter(chatId, selfId, 1) } returns 3
+ coEvery { messageDataSource.hasAtOrBelow(chatId, 2) } returns false
+
+ 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
+
+ assertEquals(30, delegate.countMessagesAfter(chatId, 10))
+ }
+}
diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/UnreadTableTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/UnreadTableTest.kt
new file mode 100644
index 0000000000..7d7546d8ac
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/UnreadTableTest.kt
@@ -0,0 +1,90 @@
+package com.flipcash.shared.chat.internal
+
+import com.flipcash.services.models.UserProfile
+import com.flipcash.services.models.chat.ChatId
+import com.flipcash.services.models.chat.ChatMember
+import com.flipcash.services.models.chat.ChatMessage
+import com.flipcash.services.models.chat.ChatMetadata
+import com.flipcash.services.models.chat.ChatType
+import com.flipcash.services.models.chat.MessageContent
+import com.flipcash.services.models.chat.MessagePointer
+import com.flipcash.services.models.chat.PointerType
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.time.Instant
+
+/**
+ * The shared "does this chat show as unread" table, which iOS encodes too. Unread means the newest
+ * visible message is past the viewer's READ pointer and someone else sent it. A group with no self
+ * row is not unread, since its roster is paged; a DM with no self row reads its pointer as zero.
+ */
+class UnreadTableTest {
+
+ private val selfId = listOf(1, 2, 3)
+ private val otherId = listOf(4, 5, 6)
+
+ private fun member(id: List, readPointer: Long? = null) = ChatMember(
+ userId = id,
+ userProfile = UserProfile.Empty,
+ pointers = listOfNotNull(
+ readPointer?.let {
+ MessagePointer(
+ type = PointerType.READ,
+ userId = id,
+ value = it,
+ timestamp = Instant.fromEpochSeconds(1_000),
+ )
+ },
+ ),
+ )
+
+ private fun unread(
+ newest: Long?,
+ fromSelf: Boolean = false,
+ selfPointer: Long?,
+ type: ChatType,
+ ): Boolean {
+ val members = listOfNotNull(member(otherId), selfPointer?.let { member(selfId, it) })
+ val lastMessage = newest?.let {
+ ChatMessage(
+ messageId = it,
+ senderId = if (fromSelf) selfId else otherId,
+ content = listOf(MessageContent.Text("m$it")),
+ timestamp = Instant.fromEpochSeconds(it),
+ unreadSeq = it,
+ )
+ }
+ val metadata = ChatMetadata(
+ chatId = ChatId("aabbccdd"),
+ type = type,
+ members = members,
+ lastMessage = lastMessage,
+ lastActivity = Instant.fromEpochSeconds(2_000),
+ )
+ return unreadCount(metadata, selfId) > 0
+ }
+
+ @Test
+ fun `newest from someone else past the pointer`() =
+ assertEquals(true, unread(newest = 5, selfPointer = 4, type = ChatType.CONTACT_DM))
+
+ @Test
+ fun `newest is your own`() =
+ assertEquals(false, unread(newest = 5, fromSelf = true, selfPointer = 4, type = ChatType.CONTACT_DM))
+
+ @Test
+ fun `pointer at the newest`() =
+ assertEquals(false, unread(newest = 5, selfPointer = 5, type = ChatType.GROUP))
+
+ @Test
+ fun `group with no self row`() =
+ assertEquals(false, unread(newest = 5, selfPointer = null, type = ChatType.GROUP))
+
+ @Test
+ fun `DM with no self row reads the pointer as zero`() =
+ assertEquals(true, unread(newest = 5, selfPointer = null, type = ChatType.CONTACT_DM))
+
+ @Test
+ fun `no messages`() =
+ assertEquals(false, unread(newest = null, selfPointer = 4, type = ChatType.CONTACT_DM))
+}
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 b99fcd25e5..ccf717ae4d 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
@@ -76,6 +76,45 @@ interface ChatMessageDao {
throughId: Long,
): List
+ /**
+ * How many messages in [chatIdHex] someone else sent after [afterId] that still have content —
+ * the count the unread divider shows. A range rather than a lookup of [afterId], so the
+ * read-through message itself need not be stored.
+ */
+ @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"
+ )
+ suspend fun countInboundAfter(chatIdHex: String, selfIdHex: String, afterId: Long): Int
+
+ /**
+ * 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
+ * sits above it: the viewer's own messages right after their pointer are not unread.
+ */
+ @Query(
+ "SELECT MIN(message_id) FROM chat_messages " +
+ "WHERE chat_id_hex = :chatIdHex AND message_id > :afterId " +
+ "AND (sender_id_hex IS NULL OR sender_id_hex != :selfIdHex)"
+ )
+ suspend fun firstNotSentByAfter(chatIdHex: String, selfIdHex: String, afterId: Long): Long?
+
+ /**
+ * Whether any message in [chatIdHex] at or below [id] is stored, tombstones included. Without one,
+ * older unread messages may never have been fetched, so a count past [id] can come out short.
+ */
+ @Query("SELECT EXISTS(SELECT 1 FROM chat_messages WHERE chat_id_hex = :chatIdHex AND message_id <= :id)")
+ suspend fun hasAtOrBelow(chatIdHex: String, id: Long): Boolean
+
+ /**
+ * How many stored messages in [chatIdHex] come after [afterId], of any sender and including
+ * tombstones — every row the transcript draws between the newest message and that id.
+ */
+ @Query("SELECT COUNT(*) FROM chat_messages WHERE chat_id_hex = :chatIdHex AND message_id > :afterId")
+ suspend fun countAfter(chatIdHex: String, afterId: Long): Int
+
@Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND pending_client_id_hex = :clientIdHex LIMIT 1")
suspend fun getByClientId(chatIdHex: String, clientIdHex: String): ChatMessageEntity?
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 a3a05ebc41..10eb6c0373 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
@@ -211,6 +211,119 @@ class ChatMessageDaoTest {
assertEquals(2, dao.countNewerThan(CHAT_HEX, 3 * 1_000))
}
+ /**
+ * The unread divider's count, against the cases iOS encodes too: someone else's messages past
+ * the viewer's READ pointer, without tombstones. The read-through message itself need not be
+ * stored for the range to count.
+ */
+ private fun from(sender: String, messageId: Long) = text(messageId, "m$messageId").copy(senderIdHex = sender)
+
+ private suspend fun unread(pointer: Long) = dao.countInboundAfter(CHAT_HEX, SELF_HEX, pointer)
+
+ @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)))
+
+ assertEquals(2, unread(pointer = 2))
+ }
+
+ @Test
+ fun `countInboundAfter is zero when nothing is past the pointer`() = runTest {
+ dao.upsert(listOf(from(SENDER_HEX, 1), from(SENDER_HEX, 2)))
+
+ assertEquals(0, unread(pointer = 2))
+ }
+
+ @Test
+ fun `countInboundAfter ignores the viewer's own messages`() = runTest {
+ dao.upsert(listOf(from(SENDER_HEX, 1), from(SELF_HEX, 2), from(SELF_HEX, 3)))
+
+ assertEquals(0, unread(pointer = 1))
+ }
+
+ @Test
+ fun `countInboundAfter counts past a read-through message that is gone`() = runTest {
+ dao.upsert(listOf(from(SENDER_HEX, 1), from(SENDER_HEX, 3)))
+
+ assertEquals(1, unread(pointer = 2))
+ }
+
+ @Test
+ fun `countInboundAfter skips an unread tombstone`() = runTest {
+ dao.upsert(listOf(from(SENDER_HEX, 1), from(SENDER_HEX, 2), from(SENDER_HEX, 3)))
+ dao.upsert(tombstone(2))
+
+ assertEquals(1, unread(pointer = 1))
+ }
+
+ @Test
+ fun `countInboundAfter counts past the viewer's own first message`() = runTest {
+ dao.upsert(listOf(from(SENDER_HEX, 1), from(SELF_HEX, 2), from(SENDER_HEX, 3)))
+
+ assertEquals(1, unread(pointer = 1))
+ }
+
+ @Test
+ fun `countInboundAfter ignores senderless rows and other chats`() = runTest {
+ dao.upsert(listOf(from(SENDER_HEX, 1), text(2, "system").copy(senderIdHex = null)))
+ dao.upsert(from(SENDER_HEX, 3).copy(chatIdHex = OTHER_HEX))
+
+ assertEquals(0, unread(pointer = 1))
+ }
+
+ private suspend fun firstNotOwn(pointer: Long) = dao.firstNotSentByAfter(CHAT_HEX, SELF_HEX, pointer)
+
+ @Test
+ fun `firstNotSentByAfter is the first message past the pointer`() = runTest {
+ dao.upsert(listOf(from(SENDER_HEX, 1), from(SELF_HEX, 2), from(SENDER_HEX, 3), from(SENDER_HEX, 4)))
+
+ assertEquals(3L, firstNotOwn(pointer = 2))
+ }
+
+ @Test
+ fun `firstNotSentByAfter steps over the viewer's own messages after the pointer`() = runTest {
+ dao.upsert(listOf(from(SENDER_HEX, 1), from(SELF_HEX, 2), from(SELF_HEX, 3), from(SENDER_HEX, 4)))
+
+ assertEquals(4L, firstNotOwn(pointer = 1))
+ }
+
+ @Test
+ fun `firstNotSentByAfter lands on an unread tombstone`() = runTest {
+ dao.upsert(listOf(from(SENDER_HEX, 1), from(SENDER_HEX, 2), from(SENDER_HEX, 3)))
+ dao.upsert(tombstone(2))
+
+ assertEquals(2L, firstNotOwn(pointer = 1))
+ }
+
+ @Test
+ fun `firstNotSentByAfter is null when only the viewer's messages follow`() = runTest {
+ dao.upsert(listOf(from(SENDER_HEX, 1), from(SELF_HEX, 2), from(SELF_HEX, 3)))
+ dao.upsert(from(SENDER_HEX, 9).copy(chatIdHex = OTHER_HEX))
+
+ assertEquals(null, firstNotOwn(pointer = 1))
+ }
+
+ @Test
+ fun `hasAtOrBelow sees a stored row at or below the id, tombstones included`() = runTest {
+ dao.upsert(listOf(from(SENDER_HEX, 3), from(SENDER_HEX, 4)))
+ dao.upsert(from(SENDER_HEX, 1).copy(chatIdHex = OTHER_HEX))
+
+ assertEquals(false, dao.hasAtOrBelow(CHAT_HEX, 2))
+ assertEquals(true, dao.hasAtOrBelow(CHAT_HEX, 3))
+
+ dao.upsert(tombstone(3))
+ assertEquals(true, dao.hasAtOrBelow(CHAT_HEX, 3))
+ }
+
+ @Test
+ fun `countAfter counts every stored row past the id`() = runTest {
+ dao.upsert(listOf(from(SENDER_HEX, 1), from(SELF_HEX, 2), from(SENDER_HEX, 3), from(SENDER_HEX, 4)))
+ dao.upsert(tombstone(3))
+ dao.upsert(from(SENDER_HEX, 9).copy(chatIdHex = OTHER_HEX))
+
+ assertEquals(3, dao.countAfter(CHAT_HEX, 1))
+ }
+
/**
* Killing the app mid-send leaves the row `SENDING` with nothing alive to move it: failure is
* only ever written from the `onFailure` of the coroutine that issued the send. The sweep is
@@ -256,6 +369,7 @@ class ChatMessageDaoTest {
const val CHAT_HEX = "aabb"
const val OTHER_HEX = "ccdd"
const val SENDER_HEX = "1122"
+ const val SELF_HEX = "3344"
const val CLIENT_HEX = "eeff"
}
}
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 a3e753e2eb..4881470076 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
@@ -50,12 +50,17 @@ class ChatMemberDataSource @Inject constructor(
/** The READ pointer [selfId] has already advanced to in [chatId], or 0 if none is cached. */
suspend fun getSelfReadPointer(chatId: ChatId, selfId: ID): Long =
+ getSelfReadPointerOrNull(chatId, selfId) ?: 0L
+
+ /**
+ * The READ pointer [selfId] has already advanced to in [chatId]: 0 when the self row carries
+ * none, `null` when there is no self row. A group's roster is paged, so a missing row means
+ * nothing is known, not that nothing has been read.
+ */
+ suspend fun getSelfReadPointerOrNull(chatId: ChatId, selfId: ID): Long? =
getMembersForChat(chatId)
.firstOrNull { it.userId == selfId }
- ?.pointers
- ?.firstOrNull { it.type == PointerType.READ }
- ?.value
- ?: 0L
+ ?.let { self -> self.pointers.firstOrNull { it.type == PointerType.READ }?.value ?: 0L }
suspend fun upsert(chatId: ChatId, members: List) {
val database = db ?: return
diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt
index 09597c3b39..9341ed9a62 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
@@ -147,6 +147,30 @@ class ChatMessageDataSource @Inject constructor(
return dao.countNewerThan(hex, stored.timestampEpochMs)
}
+ /** Stored, non-deleted messages in [chatId] past [afterId] that someone other than [selfId] sent. */
+ suspend fun countInboundAfter(chatId: ChatId, selfId: ID, afterId: Long): Int =
+ db?.chatMessageDao()?.countInboundAfter(
+ chatIdHex = mapper.chatIdHex(chatId),
+ selfIdHex = mapper.userIdHex(selfId),
+ afterId = afterId,
+ ) ?: 0
+
+ /** 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(
+ chatIdHex = mapper.chatIdHex(chatId),
+ selfIdHex = mapper.userIdHex(selfId),
+ afterId = afterId,
+ )
+
+ /** Whether any message in [chatId] at or below [id] is stored, deleted or not. */
+ suspend fun hasAtOrBelow(chatId: ChatId, id: Long): Boolean =
+ db?.chatMessageDao()?.hasAtOrBelow(mapper.chatIdHex(chatId), id) ?: false
+
+ /** Every stored message in [chatId] past [afterId], deleted or not. */
+ suspend fun countAfter(chatId: ChatId, afterId: Long): Int =
+ db?.chatMessageDao()?.countAfter(mapper.chatIdHex(chatId), afterId) ?: 0
+
suspend fun getInboundMessagesInRange(
chatId: ChatId,
selfId: ID,