From a65e17f267de8693983c1605b5b99cfaadba7cf5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 13:22:44 -0400 Subject: [PATCH 1/4] feat(chat): show the unread count in the chat row badge The row's unread badge was an 8dp dot that ignored its count. It now renders the shared Badge in the indicator colour with the count in the background colour, 18dp tall, as in node 10329:8245. The badge's start padding is gone so the gap to the timestamp is the title row's 4dp. --- .../tipping/internal/components/TipChatRow.kt | 47 ++++++++++++++++++- .../internal/TipChatRowScreenshotTest.kt | 9 ++++ .../flipcash/shared/chat/ui/ChatListRow.kt | 29 ++++++++---- .../flipcash/shared/chat/ui/ChatRowContent.kt | 7 ++- 4 files changed, 78 insertions(+), 14 deletions(-) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt index 75244cf27d..b0764547fd 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt @@ -2,6 +2,7 @@ package com.flipcash.app.tipping.internal.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.shape.CircleShape @@ -10,6 +11,10 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.chat.ui.ChatListRow import com.flipcash.shared.chat.ui.ChatRowSubtitle import com.flipcash.shared.chat.ui.ChatRowTrailing @@ -18,6 +23,7 @@ import com.flipcash.shared.chat.ui.MutedIndicator import com.flipcash.shared.chat.ui.SubtitleText import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.theme.CodeTheme +import kotlin.time.Clock @Composable @@ -85,4 +91,43 @@ internal fun TipChatRow( showDivider = showDivider, onClick = onClick, ) -} \ No newline at end of file +} + +@Composable +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +private fun PreviewTipChatRowUnread() { + val now = Clock.System.now() + Column { + TipChatRow( + chat = ConversationReference( + chatId = ChatId(byteArrayOf(1)), + displayName = "Sally The Streamer", + lastMessagePreview = "Thanks!", + lastActivity = now, + ), + onClick = {}, + ) + TipChatRow( + chat = ConversationReference( + chatId = ChatId(byteArrayOf(2)), + displayName = "Grace Hopper", + lastMessagePreview = "Sent you $1.00 in Dollars", + lastActivity = now, + unreadCount = 2, + ), + onClick = {}, + ) + TipChatRow( + chat = ConversationReference( + chatId = ChatId(byteArrayOf(3)), + displayName = "Moony", + lastMessagePreview = "Chloe Anderson: Agreed!", + lastActivity = now, + unreadCount = 15, + ), + showDivider = false, + onClick = {}, + ) + } +} diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt index 71734dcac7..cbca38523a 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/TipChatRowScreenshotTest.kt @@ -64,6 +64,15 @@ class TipChatRowScreenshotTest { lastMessagePreview = "Sent you $0.25 in Dollars", lastActivity = at, ), + // A two-digit unread count widens the pill rather than overflowing the circle. + ConversationReference( + chatId = ChatId(byteArrayOf(4)), + displayName = "Moony", + handle = null, + lastMessagePreview = "Chloe Anderson: Agreed!", + lastActivity = at, + unreadCount = 15, + ), ) composeRule.mainClock.autoAdvance = false diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListRow.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListRow.kt index 51ad50a2ce..c961e078c3 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListRow.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListRow.kt @@ -12,8 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredWidth -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -21,9 +19,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp import com.flipcash.core.R import com.getcode.theme.CodeTheme +import com.getcode.ui.components.Badge import com.getcode.util.formatLocalized import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @@ -92,15 +94,24 @@ fun ChatListRow( } } +/** + * The chat's unread count in a pill beside the timestamp (node 10329:8245): dark text on the + * indicator colour, 18dp tall, a circle for one digit and widening for more. + */ @Composable fun UnreadBadge(count: Int, modifier: Modifier = Modifier) { - Box( - modifier = modifier - .size(CodeTheme.dimens.grid.x2) - .background( - color = CodeTheme.colors.indicator, - shape = CircleShape, - ), + Badge( + modifier = modifier, + count = count, + color = CodeTheme.colors.indicator, + contentColor = CodeTheme.colors.background, + // The default caption line height (18sp) plus the badge's vertical padding would make the + // pill 24dp; the design holds it to 18dp with the digits tracked in slightly. + textStyle = CodeTheme.typography.caption.copy( + fontWeight = FontWeight.SemiBold, + lineHeight = 12.sp, + letterSpacing = (-0.06).em, + ), ) } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt index 272525f1d9..eb9a197dcd 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt @@ -42,11 +42,10 @@ fun RowScope.ChatRowTrailing( } if (unreadCount > 0) { + // No start padding: the title row's spacing already puts the design's 4dp between the + // timestamp and the pill. UnreadBadge( - modifier = Modifier.padding( - start = CodeTheme.dimens.grid.x1, - end = CodeTheme.dimens.grid.x1, - ), + modifier = Modifier.padding(end = CodeTheme.dimens.grid.x1), count = unreadCount, ) } else if (canOpen) { From cfa7d63492849c735510008d56e199b0e7429396 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 13:31:09 -0400 Subject: [PATCH 2/4] fix(chat): match the unread badge's padding to the design The pill drew 5dp of horizontal padding on a Pixel 10, where grid.x1 scales with width and staticGrid.x1 is 5dp, and came out 20dp tall because Avenir's 12sp line is about 16dp and doesn't shrink for a smaller line height. Badge takes contentPadding and an optional height; UnreadBadge passes 4dp horizontal and a fixed 18dp. Defaults are unchanged for the tab bar and ChatNode. --- .../flipcash/shared/chat/ui/ChatListRow.kt | 10 +++--- .../kotlin/com/getcode/ui/components/Badge.kt | 32 ++++++++++++++++--- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListRow.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListRow.kt index c961e078c3..7f8cdc7e80 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListRow.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListRow.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth @@ -22,7 +23,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em -import androidx.compose.ui.unit.sp import com.flipcash.core.R import com.getcode.theme.CodeTheme import com.getcode.ui.components.Badge @@ -105,11 +105,13 @@ fun UnreadBadge(count: Int, modifier: Modifier = Modifier) { count = count, color = CodeTheme.colors.indicator, contentColor = CodeTheme.colors.background, - // The default caption line height (18sp) plus the badge's vertical padding would make the - // pill 24dp; the design holds it to 18dp with the digits tracked in slightly. + // Literal 4dp: `staticGrid.x1` is 5dp, and `grid.x1` scales with window width. + contentPadding = PaddingValues(horizontal = 4.dp), + // Fixed rather than padded: Avenir's 12sp line is ~16dp and doesn't shrink for a smaller + // line height, so vertical padding lands at 20dp instead of the design's 18. + height = 18.dp, textStyle = CodeTheme.typography.caption.copy( fontWeight = FontWeight.SemiBold, - lineHeight = 12.sp, letterSpacing = (-0.06).em, ), ) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/Badge.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/Badge.kt index 155807605c..97d92049e4 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/Badge.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/Badge.kt @@ -10,6 +10,10 @@ import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text @@ -19,9 +23,12 @@ 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.platform.LocalLayoutDirection import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified import com.getcode.theme.CodeTheme @@ -45,6 +52,11 @@ fun Badge( contentColor: Color = Color.White, textStyle: TextStyle = CodeTheme.typography.caption.copy(fontWeight = FontWeight.SemiBold), scale: Float = 1f, + contentPadding: PaddingValues = PaddingValues( + horizontal = CodeTheme.dimens.grid.x1, + vertical = 3.dp, + ), + height: Dp = Dp.Unspecified, enterTransition: EnterTransition = scaleIn(tween(durationMillis = 300)) + fadeIn(), exitTransition: ExitTransition = fadeOut() + scaleOut(tween(durationMillis = 300)) ) { @@ -67,12 +79,10 @@ fun Badge( Box( modifier = Modifier .squareMinSize() + .then(if (height.isSpecified) Modifier.height(height * scale) else Modifier) .clip(CircleShape) .background(color) - .padding( - horizontal = CodeTheme.dimens.grid.x1 * scale, - vertical = 3.dp * scale, - ), + .padding(contentPadding.scaled(scale)), contentAlignment = Alignment.Center ) { Text( @@ -82,4 +92,16 @@ fun Badge( ) } } -} \ No newline at end of file +} + +@Composable +private fun PaddingValues.scaled(scale: Float): PaddingValues { + if (scale == 1f) return this + val direction = LocalLayoutDirection.current + return PaddingValues( + start = calculateStartPadding(direction) * scale, + top = calculateTopPadding() * scale, + end = calculateEndPadding(direction) * scale, + bottom = calculateBottomPadding() * scale, + ) +} From bee92dc50ad50ca79c6a84b46abc1558d7c640c8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 13:40:20 -0400 Subject: [PATCH 3/4] fix(chat): count unread messages from unreadSeq The row badge showed "1" on every unread chat on a device: unreadCount returned 0 or 1, and only the previews passed it anything larger. Each message carries the server's unreadSeq, a running count of unread-eligible messages, so the count is the newest message's stamp less the stamp on the message the READ pointer names. The feed reads that stamp from the message table when it builds, and the paged feed per row. With no pointer, the newest stamp is the count. The count is null, drawn as the old plain dot, when it can't be known: the pointer's message isn't stored (after a database rebuild), or the newest stamp is at or below the pointer's. The tab's unread total still counts those chats. This matches iOS's Conversation.unreadCount. --- .../flipcash/shared/chat/ui/ChatListRow.kt | 15 +++++- .../flipcash/shared/chat/ui/ChatRowContent.kt | 9 ++-- .../shared/chat/ui/ConversationReference.kt | 3 +- .../com/flipcash/shared/chat/ChatState.kt | 7 ++- .../shared/chat/internal/FeedProjection.kt | 46 ++++++++++++++----- .../internal/delegates/FeedSyncDelegate.kt | 29 ++++++++---- .../shared/chat/FeedGroupProjectionTest.kt | 6 ++- .../com/flipcash/shared/chat/FeedPagedTest.kt | 2 + .../shared/chat/internal/UnreadTableTest.kt | 43 ++++++++++++++--- .../app/persistence/dao/ChatMessageDao.kt | 3 ++ .../sources/ChatMessageDataSource.kt | 7 +++ 11 files changed, 134 insertions(+), 36 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListRow.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListRow.kt index 7f8cdc7e80..c2dd30094f 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListRow.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatListRow.kt @@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -96,10 +98,19 @@ fun ChatListRow( /** * The chat's unread count in a pill beside the timestamp (node 10329:8245): dark text on the - * indicator colour, 18dp tall, a circle for one digit and widening for more. + * indicator colour, 18dp tall, a circle for one digit and widening for more. A null [count] is + * unread by a number that can't be known, and draws a plain dot rather than a guess. */ @Composable -fun UnreadBadge(count: Int, modifier: Modifier = Modifier) { +fun UnreadBadge(count: Int?, modifier: Modifier = Modifier) { + if (count == null) { + Box( + modifier = modifier + .size(CodeTheme.dimens.grid.x2) + .background(color = CodeTheme.colors.indicator, shape = CircleShape), + ) + return + } Badge( modifier = modifier, count = count, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt index eb9a197dcd..7470a23541 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatRowContent.kt @@ -20,19 +20,20 @@ import kotlin.time.Instant /** * Trailing cluster of a chat row's title line — reusable by any conversation-style row * (contacts, tip chats, …): the last-activity timestamp, then an unread badge, or an - * open-chevron when [canOpen] and there's nothing unread. + * open-chevron when [canOpen] and there's nothing unread. A null [unreadCount] is unread by a + * number that can't be known, and draws the badge without one. * * Relies on [UnreadBadge] and [formatLastActivity] from `ChatListRow`. */ @Composable fun RowScope.ChatRowTrailing( lastActivity: Instant?, - unreadCount: Int, + unreadCount: Int?, canOpen: Boolean, ) { if (lastActivity != null) { val activityTextColor by animateColorAsState( - if (unreadCount > 0) CodeTheme.colors.indicator else CodeTheme.colors.textSecondary + if (unreadCount != 0) CodeTheme.colors.indicator else CodeTheme.colors.textSecondary ) Text( text = formatLastActivity(lastActivity), @@ -41,7 +42,7 @@ fun RowScope.ChatRowTrailing( ) } - if (unreadCount > 0) { + if (unreadCount != 0) { // No start padding: the title row's spacing already puts the design's 4dp between the // timestamp and the pill. UnreadBadge( diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt index 7414a4261c..8ee3c862d7 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt @@ -41,7 +41,8 @@ data class ConversationReference( val hasMessages: Boolean = true, /** The chat's last-activity timestamp; drives recency sorting and the row's trailing timestamp. */ val lastActivity: Instant? = null, - val unreadCount: Int = 0, + /** Messages past the viewer's READ pointer: 0 when read, null when unread by an unknown count. */ + val unreadCount: Int? = 0, val isTyping: Boolean = false, /** * The viewer's own state on this chat, which is where the row's mute comes from. diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt index 1d7e1da6fc..f88dfe4d0c 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt @@ -10,6 +10,10 @@ data class ChatState( // Null until the feed has been read from the database once, so an empty list means "no // chats" and never "not read yet". val feed: List? = null, + // The unread stamp on the message each chat's READ pointer names, keyed by chat and that + // message's id, read alongside [feed] so a row can count its unread messages. A chat whose + // pointed-at message isn't stored is absent. + val readStamps: Map, Long> = emptyMap(), val typingIndicators: Map> = emptyMap(), val reactionOverlays: Map> = emptyMap(), val feedSyncState: FeedSyncState = FeedSyncState.Idle, @@ -19,7 +23,8 @@ data class ChatState( data class ChatSummary( val metadata: ChatMetadata, - val unreadCount: Int, + /** Messages past the viewer's READ pointer: 0 when read, null when unread by an unknown count. */ + val unreadCount: Int?, ) data class ActiveTypist( diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/FeedProjection.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/FeedProjection.kt index 9f3b6c1c5e..a7a8eac9f8 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/FeedProjection.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/FeedProjection.kt @@ -34,21 +34,43 @@ internal fun isRenderable(metadata: ChatMetadata, selfId: ID?, selfPhone: String return isDmAddressable(metadata.type, otherMember.userProfile) } +/** The signed-in user's READ pointer in [metadata], or null when there is none. */ +internal fun selfReadPointer(metadata: ChatMetadata, selfId: ID?): Long? = + metadata.members + .firstOrNull { it.userId == selfId } + ?.pointers + ?.firstOrNull { it.type == PointerType.READ } + ?.value + /** - * 1 when the newest visible message is past the signed-in user's READ pointer and someone else - * sent it, 0 otherwise. The feed shows a splat rather than a number, so counting past one buys - * nothing. + * How many messages past the signed-in user's READ pointer the chat holds: 0 when it's read, null + * when it's unread but the number can't be known. + * + * The server stamps each message with a running count of unread-eligible messages (`unreadSeq`), + * so the count is the newest message's stamp less the stamp on the message the pointer names, + * looked up through [unreadSeqAt]. With no pointer nothing has been read and the newest stamp is + * the count. A pointer whose message isn't stored (after a database rebuild, say) leaves the count + * unknown, as does a newest stamp at or below the pointer's: then the newest message isn't + * unread-eligible, and a guess would be wrong either way. * - * A group's roster is paged: the members the device holds are a slice, and the signed-in user's - * own row may not be in it. Falling back to a pointer of zero there would splat every group until - * the slice caught up, so an absent self row means "not known yet" for a group. On a DM both - * members always arrive together, so absence keeps its old meaning and the count is unchanged. + * The chat is read when the newest visible message is the user's own or isn't past the pointer. A + * group's roster is paged: the members the device holds are a slice, and the signed-in user's own + * row may not be in it. Falling back to no pointer there would mark every group unread until the + * slice caught up, so an absent self row means "not known yet" for a group. On a DM both members + * always arrive together, so absence means nothing has been read. */ -internal fun unreadCount(metadata: ChatMetadata, selfId: ID?): Int { - val self = metadata.members.firstOrNull { it.userId == selfId } - if (self == null && metadata.type == ChatType.GROUP) return 0 +internal fun unreadCount( + metadata: ChatMetadata, + selfId: ID?, + unreadSeqAt: (messageId: Long) -> Long?, +): Int? { + if (metadata.type == ChatType.GROUP && metadata.members.none { it.userId == selfId }) return 0 - val readPointer = self?.pointers?.firstOrNull { it.type == PointerType.READ }?.value ?: 0L val lastMessage = metadata.lastMessage ?: return 0 - return if (lastMessage.messageId > readPointer && lastMessage.senderId != selfId) 1 else 0 + if (lastMessage.senderId == selfId) return 0 + val readPointer = selfReadPointer(metadata, selfId) + if (lastMessage.messageId <= (readPointer ?: 0L)) return 0 + + val readStamp = if (readPointer == null) 0L else unreadSeqAt(readPointer) ?: return null + return (lastMessage.unreadSeq - readStamp).takeIf { it > 0 }?.toInt() } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt index 31cbab3f33..fa2d131dda 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt @@ -26,6 +26,7 @@ import com.flipcash.shared.chat.FeedSyncState import com.flipcash.shared.chat.ChatState import com.flipcash.shared.chat.internal.ChatStateHolder import com.flipcash.shared.chat.internal.isRenderable +import com.flipcash.shared.chat.internal.selfReadPointer import com.flipcash.shared.chat.internal.unreadCount import com.flipcash.services.user.UserManager import com.getcode.opencode.model.core.ID @@ -141,12 +142,13 @@ class FeedSyncDelegate @Inject constructor( .filter { it.type in requested } .filter { isRenderable(it, selfId, selfPhone) } .map { metadata -> - ChatSummary(metadata = metadata, unreadCount = unreadCount(metadata, selfId)) + val count = unreadCount(metadata, selfId) { id -> state.readStamps[metadata.chatId to id] } + ChatSummary(metadata = metadata, unreadCount = count) } } override fun observeUnreadConversations(vararg chatTypes: ChatType): Flow { - return feed(*chatTypes).map { summaries -> summaries.count { it.unreadCount > 0 } } + return feed(*chatTypes).map { summaries -> summaries.count { it.unreadCount != 0 } } } override fun feedPaged(vararg chatTypes: ChatType): Flow> { @@ -182,7 +184,10 @@ class FeedSyncDelegate @Inject constructor( val members = memberDataSource.getMembersForChat(entity.chatIdHex) val lastMessage = entity.lastMessageId?.let { messageDataSource.getLatestVisible(entity.chatIdHex) } val metadata = metadataDataSource.toMetadata(entity, members, lastMessage) - return ChatSummary(metadata = metadata, unreadCount = unreadCount(metadata, selfId)) + val readPointer = selfReadPointer(metadata, selfId) + val readStamp = readPointer?.let { messageDataSource.getUnreadSeq(entity.chatIdHex, it) } + val count = unreadCount(metadata, selfId) { id -> readStamp.takeIf { id == readPointer } } + return ChatSummary(metadata = metadata, unreadCount = count) } override fun refreshFeed() { @@ -211,8 +216,8 @@ class FeedSyncDelegate @Inject constructor( memberDataSource.observeAll(), ) { metadataEntities, membersByChat -> buildFeedFromDb(metadataEntities, membersByChat) - }.onEach { feed -> - stateHolder.update { it.copy(feed = feed) } + }.onEach { (feed, readStamps) -> + stateHolder.update { it.copy(feed = feed, readStamps = readStamps) } }.launchIn(scope) } @@ -267,20 +272,28 @@ class FeedSyncDelegate @Inject constructor( private suspend fun buildFeedFromDb( metadataEntities: List, membersByChat: Map>, - ): List { + ): Pair, Map, Long>> { // `is_member` is a column rather than a field on ChatMetadata: a chat you have left is // still a chat you can be shown (Plan C's gate reads the same row), so the flag is dropped // here, at the edge of the list, rather than carried through the domain model. val latestVisible = messageDataSource.getLatestVisibleByChat() - return metadataEntities.filter { it.isMember }.map { entity -> + val selfId = userManager.accountId + val readStamps = mutableMapOf, Long>() + val feed = metadataEntities.filter { it.isMember }.map { entity -> val members = membersByChat[entity.chatIdHex] ?: emptyList() // Deliberately the newest *visible* message, not the newest row: deleting the newest // message drops the feed back to the one before it, so the preview reads that message // instead of "Message deleted" and its unread splat clears with it (the fallback sits // at or below the read pointer whenever the deleted message was the only unread one). val lastMessage = entity.lastMessageId?.let { latestVisible[entity.chatIdHex] } - metadataDataSource.toMetadata(entity, members, lastMessage) + metadataDataSource.toMetadata(entity, members, lastMessage).also { metadata -> + // Only the stamp a row can ask for: the one on the message the READ pointer names. + val readPointer = selfReadPointer(metadata, selfId) ?: return@also + messageDataSource.getUnreadSeq(entity.chatIdHex, readPointer) + ?.let { readStamps[metadata.chatId to readPointer] = it } + } } + return feed to readStamps } /** diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedGroupProjectionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedGroupProjectionTest.kt index 6bfa17f346..a0b9201f5e 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedGroupProjectionTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedGroupProjectionTest.kt @@ -92,6 +92,8 @@ class FeedGroupProjectionTest { ) { private val messageDataSource = mockk(relaxed = true).also { coEvery { it.getLatestVisibleByChat() } returns entities.associate { e -> e.chatIdHex to message(2) } + // Every stored message is stamped with its own id, as [message] builds them. + coEvery { it.getUnreadSeq(any(), any()) } answers { secondArg() } } private val metadataDataSource = mockk(relaxed = true).also { source -> @@ -182,7 +184,7 @@ class FeedGroupProjectionTest { assertEquals(1, feed.single().unreadCount) } - /** DM behaviour is unchanged: an absent self row still reads as a pointer of zero. */ + /** A DM with no self row has read nothing, so both messages count. */ @Test fun `a DM whose roster does not include you still counts unread`() = runTest { val harness = Harness( @@ -192,7 +194,7 @@ class FeedGroupProjectionTest { val feed = harness.feed(this, ChatType.CONTACT_DM) - assertEquals(1, feed.single().unreadCount) + assertEquals(2, feed.single().unreadCount) } @Test diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedPagedTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedPagedTest.kt index 22bcb2ee45..3efa1673df 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedPagedTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedPagedTest.kt @@ -104,6 +104,8 @@ class FeedPagedTest { val messageDataSource = mockk(relaxed = true).also { coEvery { it.getLatestVisible(any()) } returns lastMessage + // The pointer's message (id 1) carries stamp 1, one below the newest. + coEvery { it.getUnreadSeq(any(), 1) } returns 1 } // Explicit rather than relaxed: `Result` is a value class, so a relaxed mock hands the 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 index 7d7546d8ac..aa9c94d057 100644 --- 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 @@ -14,9 +14,10 @@ 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. + * The shared 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 has read nothing. The count is the newest message's + * unreadSeq less the stamp on the pointer's message, and null when that stamp can't be known. */ class UnreadTableTest { @@ -43,7 +44,17 @@ class UnreadTableTest { fromSelf: Boolean = false, selfPointer: Long?, type: ChatType, - ): Boolean { + ): Boolean = count(newest, fromSelf, selfPointer, type) != 0 + + /** [newestSeq] and [stamps] default to each message's stamp equalling its id. */ + private fun count( + newest: Long?, + fromSelf: Boolean = false, + selfPointer: Long?, + type: ChatType, + newestSeq: Long? = newest, + stamps: (Long) -> Long? = { it }, + ): Int? { val members = listOfNotNull(member(otherId), selfPointer?.let { member(selfId, it) }) val lastMessage = newest?.let { ChatMessage( @@ -51,7 +62,7 @@ class UnreadTableTest { senderId = if (fromSelf) selfId else otherId, content = listOf(MessageContent.Text("m$it")), timestamp = Instant.fromEpochSeconds(it), - unreadSeq = it, + unreadSeq = newestSeq ?: it, ) } val metadata = ChatMetadata( @@ -61,7 +72,7 @@ class UnreadTableTest { lastMessage = lastMessage, lastActivity = Instant.fromEpochSeconds(2_000), ) - return unreadCount(metadata, selfId) > 0 + return unreadCount(metadata, selfId, stamps) } @Test @@ -87,4 +98,24 @@ class UnreadTableTest { @Test fun `no messages`() = assertEquals(false, unread(newest = null, selfPointer = 4, type = ChatType.CONTACT_DM)) + + @Test + fun `counts the messages between the pointer and the newest`() = + assertEquals(3, count(newest = 9, selfPointer = 4, newestSeq = 7, stamps = { 4 }, type = ChatType.GROUP)) + + @Test + fun `no pointer counts every eligible message`() = + assertEquals(5, count(newest = 8, selfPointer = null, newestSeq = 5, type = ChatType.CONTACT_DM)) + + @Test + fun `pointer's message not stored is unread by an unknown count`() = + assertEquals(null, count(newest = 9, selfPointer = 4, stamps = { null }, type = ChatType.CONTACT_DM)) + + @Test + fun `newest not unread-eligible is unread by an unknown count`() = + assertEquals(null, count(newest = 9, selfPointer = 4, newestSeq = 4, stamps = { 4 }, type = ChatType.CONTACT_DM)) + + @Test + fun `read chats count zero whatever the stamps say`() = + assertEquals(0, count(newest = 5, selfPointer = 5, stamps = { null }, 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 ccf717ae4d..a4ae030d26 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 @@ -140,6 +140,9 @@ interface ChatMessageDao { @Query("SELECT event_sequence FROM chat_messages WHERE chat_id_hex = :chatIdHex AND message_id = :messageId") suspend fun getEventSequence(chatIdHex: String, messageId: Long): Long? + @Query("SELECT unread_seq FROM chat_messages WHERE chat_id_hex = :chatIdHex AND message_id = :messageId") + suspend fun getUnreadSeq(chatIdHex: String, messageId: Long): Long? + @Transaction suspend fun upsert(entity: ChatMessageEntity) { // Event-sequence guard: skip if the stored sequence is strictly newer (last-writer-wins). 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 9341ed9a62..9aed3ba0e8 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 @@ -129,6 +129,13 @@ class ChatMessageDataSource @Inject constructor( ?.associate { it.chatIdHex to toChatMessage(it) } .orEmpty() + /** + * The server's unread stamp on one stored message, or null when that message isn't stored — + * the running count of unread-eligible messages, so two stamps subtract to the count between. + */ + suspend fun getUnreadSeq(chatIdHex: String, messageId: Long): Long? = + db?.chatMessageDao()?.getUnreadSeq(chatIdHex, messageId) + suspend fun hasMessages(chatId: ChatId): Boolean = db?.chatMessageDao()?.getLatest(mapper.chatIdHex(chatId)) != null From 87e081ec7503d0044e40b6571b57e1f239773ea1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 13:48:57 -0400 Subject: [PATCH 4/4] fix(chat): fetch the read pointer's message when it isn't stored When the message a chat's READ pointer names isn't in the message table, as after a database rebuild, the row showed a dot with no count. The feed now fetches that message with GetMessage (view mode FULL) and counts from its unreadSeq, so every unread row gets an exact number. Fetched stamps live in ChatState for the session and are never written to chat_messages: a lone message there reads as part of the transcript. A stored stamp wins over a fetched one. Only unread chats with a READ pointer fetch, one request per chat and message at a time. NOT_FOUND isn't asked for again this session and keeps the dot; any other failure is retried on the next feed build. DENIED and NOT_FOUND log at info. This matches iOS, where the fetch runs per visible row. Here it runs from the feed build, for every chat that needs one. --- .../com/flipcash/shared/chat/ChatState.kt | 4 + .../internal/delegates/FeedSyncDelegate.kt | 66 ++++++- .../shared/chat/ActiveChatHandoffTest.kt | 1 + .../chat/ChatCoordinatorEagerBalanceTest.kt | 1 + .../shared/chat/ChatCoordinatorEventsTest.kt | 1 + .../ChatCoordinatorLifecycleStreamTest.kt | 1 + .../chat/ChatCoordinatorTeardownTest.kt | 1 + .../shared/chat/ChatHistoryHydrationTest.kt | 1 + .../shared/chat/ChatMetadataEditStreamTest.kt | 1 + .../shared/chat/FeedDbHydrationTest.kt | 1 + .../shared/chat/FeedDeletedMessageTest.kt | 1 + .../shared/chat/FeedGroupProjectionTest.kt | 1 + .../com/flipcash/shared/chat/FeedPagedTest.kt | 1 + .../shared/chat/FeedReadStampFetchTest.kt | 174 ++++++++++++++++++ .../shared/chat/FeedSyncBackfillTest.kt | 1 + .../shared/chat/FeedSyncCatchUpTest.kt | 1 + .../shared/chat/FeedSyncCombinedFeedTest.kt | 1 + .../chat/FeedSyncReadPointerReportTest.kt | 1 + .../shared/chat/GroupChatRoutingTest.kt | 1 + .../chat/InterruptedSendRecoveryTest.kt | 1 + .../shared/chat/ReceivedCounterTest.kt | 1 + .../flipcash/shared/chat/ReceivedEventTest.kt | 1 + .../shared/chat/ViewerStateStreamTest.kt | 1 + 23 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedReadStampFetchTest.kt diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt index f88dfe4d0c..ce6188dc9c 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt @@ -14,6 +14,10 @@ data class ChatState( // message's id, read alongside [feed] so a row can count its unread messages. A chat whose // pointed-at message isn't stored is absent. val readStamps: Map, Long> = emptyMap(), + // The same stamps fetched from the server for pointed-at messages the device doesn't store, + // held for the session and never written to the message table: a lone message there would read + // as part of the transcript. [readStamps] wins where both hold a key. + val fetchedReadStamps: Map, Long> = emptyMap(), val typingIndicators: Map> = emptyMap(), val reactionOverlays: Map> = emptyMap(), val feedSyncState: FeedSyncState = FeedSyncState.Idle, diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt index fa2d131dda..5b499eaef7 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt @@ -14,6 +14,8 @@ import com.flipcash.app.persistence.sources.ChatMessageDataSource import com.flipcash.app.persistence.sources.ChatMetadataDataSource import com.flipcash.app.persistence.sources.mediator.ChatFeedRemoteMediator import com.flipcash.services.controllers.ChatController +import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.services.models.GetMessageError import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMetadata @@ -45,6 +47,7 @@ import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton @@ -70,6 +73,7 @@ class FeedSyncDelegate @Inject constructor( private val memberDataSource: ChatMemberDataSource, private val stateHolder: ChatStateHolder, private val userManager: UserManager, + private val messagingController: ChatMessagingController, ) : FeedOperations { companion object { @@ -113,6 +117,8 @@ class FeedSyncDelegate @Inject constructor( val events: Flow = _events.receiveAsFlow() private var scope: CoroutineScope? = null + private val readStampsInFlight = ConcurrentHashMap.newKeySet>() + private val readStampsNotFound = ConcurrentHashMap.newKeySet>() private var syncJob: Job? = null private var feedObserverJob: Job? = null @@ -142,7 +148,7 @@ class FeedSyncDelegate @Inject constructor( .filter { it.type in requested } .filter { isRenderable(it, selfId, selfPhone) } .map { metadata -> - val count = unreadCount(metadata, selfId) { id -> state.readStamps[metadata.chatId to id] } + val count = unreadCount(metadata, selfId) { id -> state.readStampAt(metadata.chatId, id) } ChatSummary(metadata = metadata, unreadCount = count) } } @@ -185,7 +191,10 @@ class FeedSyncDelegate @Inject constructor( val lastMessage = entity.lastMessageId?.let { messageDataSource.getLatestVisible(entity.chatIdHex) } val metadata = metadataDataSource.toMetadata(entity, members, lastMessage) val readPointer = selfReadPointer(metadata, selfId) - val readStamp = readPointer?.let { messageDataSource.getUnreadSeq(entity.chatIdHex, it) } + val readStamp = readPointer?.let { + messageDataSource.getUnreadSeq(entity.chatIdHex, it) + ?: stateHolder.state.value.fetchedReadStamps[metadata.chatId to it] + } val count = unreadCount(metadata, selfId) { id -> readStamp.takeIf { id == readPointer } } return ChatSummary(metadata = metadata, unreadCount = count) } @@ -218,6 +227,7 @@ class FeedSyncDelegate @Inject constructor( buildFeedFromDb(metadataEntities, membersByChat) }.onEach { (feed, readStamps) -> stateHolder.update { it.copy(feed = feed, readStamps = readStamps) } + fetchMissingReadStamps(feed) }.launchIn(scope) } @@ -241,6 +251,54 @@ class FeedSyncDelegate @Inject constructor( syncJob?.cancel() feedObserverJob?.cancel() feedObserverJob = null + readStampsInFlight.clear() + readStampsNotFound.clear() + } + + /** + * Fetches the unread stamp on the message each unread chat's READ pointer names, where the + * device doesn't store that message, so the row can show an exact count rather than a dot. + * + * One request per key at a time. A NOT_FOUND key isn't asked for again this session and keeps + * the dot; any other failure leaves the key unresolved, so the next feed build retries it. + */ + private fun fetchMissingReadStamps(feed: List) { + val scope = scope ?: return + val state = stateHolder.state.value + val selfId = userManager.accountId + val selfPhone = userManager.profile?.verifiedPhoneNumber + for (metadata in feed) { + if (!isRenderable(metadata, selfId, selfPhone)) continue + val readPointer = selfReadPointer(metadata, selfId) ?: continue + val key = metadata.chatId to readPointer + if (state.readStampAt(metadata.chatId, readPointer) != null) continue + if (key in readStampsNotFound) continue + // With no stamp to look up, only a chat that is unread by its ids counts as unknown; + // a read chat needs no stamp. + if (unreadCount(metadata, selfId) { null } != null) continue + if (!readStampsInFlight.add(key)) continue + + scope.launch { + messagingController.getMessage(metadata.chatId, readPointer) + .onSuccess { message -> + stateHolder.update { + it.copy(fetchedReadStamps = it.fetchedReadStamps + (key to message.unreadSeq)) + } + } + .onFailure { cause -> + if (cause is GetMessageError.NotFound) readStampsNotFound.add(key) + val expected = cause is GetMessageError.NotFound || cause is GetMessageError.Denied + trace( + tag = TAG, + message = "Fetching the read pointer's message $readPointer failed", + error = cause, + // Error routes through ErrorUtils, which drops transport failures. + type = if (expected) TraceType.Log else TraceType.Error, + ) + } + readStampsInFlight.remove(key) + } + } } /** @@ -373,3 +431,7 @@ class FeedSyncDelegate @Inject constructor( // endregion } + +/** The unread stamp on [messageId] in [chatId]: the stored one, else one fetched this session. */ +private fun ChatState.readStampAt(chatId: ChatId, messageId: Long): Long? = + readStamps[chatId to messageId] ?: fetchedReadStamps[chatId to messageId] diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ActiveChatHandoffTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ActiveChatHandoffTest.kt index 7b27df07ad..5647d6b0c3 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ActiveChatHandoffTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ActiveChatHandoffTest.kt @@ -78,6 +78,7 @@ class ActiveChatHandoffTest { coordinator = RealChatCoordinator( feedDelegate = FeedSyncDelegate( + messagingController = mockk(relaxed = true), chatController = chatController, metadataDataSource = metadataDataSource, messageDataSource = messageDataSource, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt index 86978115cd..bd82907572 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt @@ -82,6 +82,7 @@ class ChatCoordinatorEagerBalanceTest { val messageDataSource = mockk(relaxed = true) val feedDelegate = FeedSyncDelegate( + messagingController = mockk(relaxed = true), chatController = chatController, metadataDataSource = metadataDataSource, messageDataSource = messageDataSource, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt index 6033adb1a0..5716d1025f 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt @@ -85,6 +85,7 @@ class ChatCoordinatorEventsTest { val stateHolder = ChatStateHolder() val feedDelegate = FeedSyncDelegate( + messagingController = mockk(relaxed = true), chatController = chatController, metadataDataSource = metadataDataSource, messageDataSource = messageDataSource, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorLifecycleStreamTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorLifecycleStreamTest.kt index b6effe4c13..1e5be5a24f 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorLifecycleStreamTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorLifecycleStreamTest.kt @@ -105,6 +105,7 @@ class ChatCoordinatorLifecycleStreamTest { coordinator = RealChatCoordinator( feedDelegate = FeedSyncDelegate( + messagingController = mockk(relaxed = true), chatController = chatController, metadataDataSource = metadataDataSource, messageDataSource = messageDataSource, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt index 1c2a05e9d2..e648ff7a99 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt @@ -74,6 +74,7 @@ class ChatCoordinatorTeardownTest { coordinator = RealChatCoordinator( feedDelegate = FeedSyncDelegate( + messagingController = mockk(relaxed = true), chatController = chatController, metadataDataSource = metadataDataSource, messageDataSource = messageDataSource, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt index 67c95a5e48..1c90761e4e 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt @@ -104,6 +104,7 @@ class ChatHistoryHydrationTest { return RealChatCoordinator( feedDelegate = FeedSyncDelegate( + messagingController = mockk(relaxed = true), chatController = chatController, metadataDataSource = metadataDataSource, messageDataSource = messageDataSource, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatMetadataEditStreamTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatMetadataEditStreamTest.kt index a8e7718bee..cebb67b544 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatMetadataEditStreamTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatMetadataEditStreamTest.kt @@ -80,6 +80,7 @@ class ChatMetadataEditStreamTest { val messageDataSource = mockk(relaxed = true) val feedDelegate = FeedSyncDelegate( + messagingController = mockk(relaxed = true), chatController = chatController, metadataDataSource = metadataDataSource, messageDataSource = messageDataSource, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedDbHydrationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedDbHydrationTest.kt index 3a3520663d..6503459356 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedDbHydrationTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedDbHydrationTest.kt @@ -38,6 +38,7 @@ class FeedDbHydrationTest { private val stateHolder = ChatStateHolder() private val delegate = FeedSyncDelegate( + messagingController = mockk(relaxed = true), chatController = mockk(relaxed = true), metadataDataSource = mockk(relaxed = true).also { every { it.observeAll() } returns rows diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedDeletedMessageTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedDeletedMessageTest.kt index 2636253362..83dc72149a 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedDeletedMessageTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedDeletedMessageTest.kt @@ -111,6 +111,7 @@ class FeedDeletedMessageTest { } val delegate = FeedSyncDelegate( + messagingController = mockk(relaxed = true), chatController = mockk(relaxed = true), metadataDataSource = metadataDataSource, messageDataSource = messageDataSource, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedGroupProjectionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedGroupProjectionTest.kt index a0b9201f5e..04b12b9d44 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedGroupProjectionTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedGroupProjectionTest.kt @@ -123,6 +123,7 @@ class FeedGroupProjectionTest { } val delegate = FeedSyncDelegate( + messagingController = mockk(relaxed = true), chatController = mockk(relaxed = true), metadataDataSource = metadataDataSource, messageDataSource = messageDataSource, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedPagedTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedPagedTest.kt index 3efa1673df..fd802d985d 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedPagedTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedPagedTest.kt @@ -116,6 +116,7 @@ class FeedPagedTest { } return FeedSyncDelegate( + messagingController = mockk(relaxed = true), chatController = chatController, metadataDataSource = metadataDataSource, messageDataSource = messageDataSource, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedReadStampFetchTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedReadStampFetchTest.kt new file mode 100644 index 0000000000..cf8888cb2b --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedReadStampFetchTest.kt @@ -0,0 +1,174 @@ +package com.flipcash.shared.chat + +import com.flipcash.app.persistence.entities.ChatMetadataEntity +import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.services.models.GetMessageError +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 com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.internal.ChatStateHolder +import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +/** + * A chat's unread count subtracts the stamp on the message its READ pointer names. When the device + * doesn't store that message, the feed fetches it once and counts exactly, matching iOS. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class FeedReadStampFetchTest { + + private val selfId = listOf(1, 2, 3) + private val otherId = listOf(4, 5, 6) + private val chatHex = "11223344" + private val readPointer = 5L + + private val entity = ChatMetadataEntity( + chatIdHex = chatHex, + chatType = ChatType.GROUP.name, + lastActivityEpochMs = 2_000, + lastMessageId = 9, + isMember = true, + ) + + // Stamped with its own id, so the newest (9) is four past the pointer's message (5). + private fun message(messageId: Long) = ChatMessage( + messageId = messageId, + senderId = otherId, + content = listOf(MessageContent.Text("hi")), + timestamp = Instant.fromEpochSeconds(messageId), + unreadSeq = messageId, + ) + + private val members = listOf( + ChatMember( + userId = selfId, + userProfile = UserProfile.Empty, + pointers = listOf( + MessagePointer( + type = PointerType.READ, + userId = selfId, + value = readPointer, + timestamp = Instant.fromEpochSeconds(1_000), + ), + ), + ), + ChatMember(userId = otherId, userProfile = UserProfile.Empty, pointers = emptyList()), + ) + + private inner class Harness(storedStamp: Long?, var fetch: () -> Result) { + private val entities = MutableSharedFlow>(replay = 1) + .apply { tryEmit(listOf(entity)) } + + val messagingController = mockk().also { + coEvery { it.getMessage(any(), any(), any()) } answers { fetch() } + } + + private val delegate = FeedSyncDelegate( + messagingController = messagingController, + chatController = mockk(relaxed = true), + metadataDataSource = mockk(relaxed = true).also { source -> + every { source.observeAll() } returns entities + every { source.toMetadata(any(), any(), any()) } answers { + val row = firstArg() + ChatMetadata( + chatId = ChatId(row.chatIdHex), + type = ChatType.valueOf(row.chatType), + members = secondArg(), + lastMessage = thirdArg(), + lastActivity = Instant.fromEpochMilliseconds(row.lastActivityEpochMs), + ) + } + }, + messageDataSource = mockk(relaxed = true).also { + coEvery { it.getLatestVisibleByChat() } returns mapOf(chatHex to message(9)) + coEvery { it.getUnreadSeq(chatHex, readPointer) } returns storedStamp + }, + memberDataSource = mockk(relaxed = true).also { + every { it.observeAll() } returns flowOf(mapOf(chatHex to members)) + }, + stateHolder = ChatStateHolder().apply { + update { it.copy(feedSyncState = FeedSyncState.Synced) } + }, + userManager = mockk(relaxed = true).also { + every { it.accountId } returns selfId + every { it.profile } returns null + }, + ) + + suspend fun count(scope: TestScope): Int? { + delegate.initialize(scope.backgroundScope) + delegate.observeFeedFromDb() + scope.runCurrent() + return delegate.feed(ChatType.GROUP).first().single().unreadCount + } + + /** Rebuilds the feed from the database, as any write to the chat's row would. */ + suspend fun rebuild(scope: TestScope): Int? { + entities.emit(listOf(entity)) + scope.runCurrent() + return delegate.feed(ChatType.GROUP).first().single().unreadCount + } + } + + @Test + fun `a stored pointer message is counted without a request`() = runTest { + val harness = Harness(storedStamp = 5, fetch = { error("not fetched") }) + + assertEquals(4, harness.count(this)) + coVerify(exactly = 0) { harness.messagingController.getMessage(any(), any(), any()) } + } + + @Test + fun `an unstored pointer message is fetched once and counted exactly`() = runTest { + val harness = Harness(storedStamp = null, fetch = { Result.success(message(5)) }) + + assertEquals(4, harness.count(this)) + assertEquals(4, harness.rebuild(this)) + coVerify(exactly = 1) { harness.messagingController.getMessage(ChatId(chatHex), readPointer, any()) } + } + + @Test + fun `a pointer message the server can't find stays unknown and isn't asked for again`() = runTest { + val harness = Harness(storedStamp = null, fetch = { Result.failure(GetMessageError.NotFound()) }) + + assertNull(harness.count(this)) + assertNull(harness.rebuild(this)) + coVerify(exactly = 1) { harness.messagingController.getMessage(any(), any(), any()) } + } + + @Test + fun `a failed fetch stays unknown and is retried on the next build`() = runTest { + val harness = Harness(storedStamp = null, fetch = { Result.failure(GetMessageError.Other()) }) + + assertNull(harness.count(this)) + + harness.fetch = { Result.success(message(5)) } + assertEquals(4, harness.rebuild(this)) + coVerify(exactly = 2) { harness.messagingController.getMessage(any(), any(), any()) } + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedSyncBackfillTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedSyncBackfillTest.kt index bd172b9771..0164fd6a1e 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedSyncBackfillTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedSyncBackfillTest.kt @@ -64,6 +64,7 @@ class FeedSyncBackfillTest { val messageDataSource = mockk(relaxed = true) val delegate = FeedSyncDelegate( + messagingController = mockk(relaxed = true), chatController = chatController, metadataDataSource = metadataDataSource, messageDataSource = messageDataSource, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedSyncCatchUpTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedSyncCatchUpTest.kt index 194c7340c0..5223733c15 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedSyncCatchUpTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedSyncCatchUpTest.kt @@ -96,6 +96,7 @@ class FeedSyncCatchUpTest { } val delegate = FeedSyncDelegate( + messagingController = mockk(relaxed = true), chatController = chatController, metadataDataSource = metadataDataSource, messageDataSource = messageDataSource, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedSyncCombinedFeedTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedSyncCombinedFeedTest.kt index 1604b294ac..3924897d62 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedSyncCombinedFeedTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedSyncCombinedFeedTest.kt @@ -25,6 +25,7 @@ class FeedSyncCombinedFeedTest { ) private fun delegateWith(chatController: ChatController) = FeedSyncDelegate( + messagingController = mockk(relaxed = true), chatController = chatController, metadataDataSource = mockk(relaxed = true), messageDataSource = mockk(relaxed = true), diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedSyncReadPointerReportTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedSyncReadPointerReportTest.kt index 7a87628675..869af888c1 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedSyncReadPointerReportTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedSyncReadPointerReportTest.kt @@ -92,6 +92,7 @@ class FeedSyncReadPointerReportTest { } val delegate = FeedSyncDelegate( + messagingController = mockk(relaxed = true), chatController = chatController, metadataDataSource = mockk(relaxed = true), messageDataSource = mockk(relaxed = true), diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupChatRoutingTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupChatRoutingTest.kt index 744c003067..cf6ece956d 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupChatRoutingTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupChatRoutingTest.kt @@ -86,6 +86,7 @@ class GroupChatRoutingTest { return RealChatCoordinator( feedDelegate = FeedSyncDelegate( + messagingController = mockk(relaxed = true), chatController = chatController, metadataDataSource = metadataDataSource, messageDataSource = messageDataSource, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/InterruptedSendRecoveryTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/InterruptedSendRecoveryTest.kt index fc369a3483..c3eb10e547 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/InterruptedSendRecoveryTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/InterruptedSendRecoveryTest.kt @@ -95,6 +95,7 @@ class InterruptedSendRecoveryTest { } val feedDelegate = FeedSyncDelegate( + messagingController = mockk(relaxed = true), chatController = chatController, metadataDataSource = metadataDataSource, messageDataSource = messageDataSource, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt index f89a2c6ba0..1c2797515a 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt @@ -103,6 +103,7 @@ class ReceivedCounterTest { } val feedDelegate = FeedSyncDelegate( + messagingController = mockk(relaxed = true), chatController = chatController, metadataDataSource = metadataDataSource, messageDataSource = messageDataSource, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt index fbfccf06c6..0c53ed5ae4 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt @@ -106,6 +106,7 @@ class ReceivedEventTest { coEvery { metadataDataSource.getChatType(chatId) } returns ChatType.TIP_DM val feedDelegate = FeedSyncDelegate( + messagingController = mockk(relaxed = true), chatController = chatController, metadataDataSource = metadataDataSource, messageDataSource = messageDataSource, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ViewerStateStreamTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ViewerStateStreamTest.kt index 9e622c34d5..f7d535cf7e 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ViewerStateStreamTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ViewerStateStreamTest.kt @@ -83,6 +83,7 @@ class ViewerStateStreamTest { val messageDataSource = mockk(relaxed = true) val feedDelegate = FeedSyncDelegate( + messagingController = mockk(relaxed = true), chatController = chatController, metadataDataSource = metadataDataSource, messageDataSource = messageDataSource,