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