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..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 @@ -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 @@ -21,9 +22,12 @@ 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 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 +96,35 @@ 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. 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) { - Box( - modifier = modifier - .size(CodeTheme.dimens.grid.x2) - .background( - color = CodeTheme.colors.indicator, - shape = CircleShape, - ), +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, + color = CodeTheme.colors.indicator, + contentColor = CodeTheme.colors.background, + // 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, + 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..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,12 +42,11 @@ 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( - 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) { 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..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 @@ -10,6 +10,14 @@ 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(), + // 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, @@ -19,7 +27,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..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 @@ -26,6 +28,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 @@ -44,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 @@ -69,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 { @@ -112,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 @@ -141,12 +148,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.readStampAt(metadata.chatId, 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 +190,13 @@ 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) + ?: stateHolder.state.value.fetchedReadStamps[metadata.chatId to it] + } + val count = unreadCount(metadata, selfId) { id -> readStamp.takeIf { id == readPointer } } + return ChatSummary(metadata = metadata, unreadCount = count) } override fun refreshFeed() { @@ -211,8 +225,9 @@ 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) } + fetchMissingReadStamps(feed) }.launchIn(scope) } @@ -236,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) + } + } } /** @@ -267,20 +330,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 } /** @@ -360,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 6bfa17f346..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 @@ -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 -> @@ -121,6 +123,7 @@ class FeedGroupProjectionTest { } val delegate = FeedSyncDelegate( + messagingController = mockk(relaxed = true), chatController = mockk(relaxed = true), metadataDataSource = metadataDataSource, messageDataSource = messageDataSource, @@ -182,7 +185,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 +195,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..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 @@ -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 @@ -114,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, 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 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, + ) +}