From 964ac7d09c72fee94aa4635b557ab860c09d1c82 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 16:29:27 -0400 Subject: [PATCH] fix(chat): count unread groups in the Chats tab badge The Chats list shows tip DMs and groups, but the tab badge counted unread tip DMs only. When the only unread chats were groups, their rows showed a count and the tab showed no badge. Both now read through one definition in :shared:chat (chatListFeed, currentChatListFeed, observeUnreadChatListCount), so the badge counts the same chats as the list. SessionState.tipsUnreadCount is renamed to chatListUnreadCount, and the nav bar's tipUnreadCount to match; the Chats tab badge is their only reader. --- .../app/internal/ui/AppNavigationBar.kt | 10 +- .../com/flipcash/app/core/ui/NavigationBar.kt | 16 +- .../messenger/internal/MuteChatViewModel.kt | 4 +- .../app/tipping/internal/ChatsViewModel.kt | 7 +- .../com/flipcash/shared/chat/ChatList.kt | 21 +++ .../shared/chat/ChatListUnreadCountTest.kt | 155 ++++++++++++++++++ .../flipcash/app/session/SessionController.kt | 2 +- .../session/internal/RealSessionController.kt | 5 +- 8 files changed, 199 insertions(+), 21 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatList.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatListUnreadCountTest.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt index b51a91279b..0312ed898c 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt @@ -78,10 +78,10 @@ internal fun AppNavigationBar( session?.billState?.map { it.bill != null } ?: flowOf(false) }.collectAsStateWithLifecycle(initialValue = false) - // Unread tip-DM count, badged onto the Chat tab, so the badge appears, updates and clears as - // conversations are read. - val tipUnreadCount by remember(session) { - session?.state?.map { it.tipsUnreadCount } ?: flowOf(0) + // Unread chats in the Chats list (tip DMs and groups), badged onto the Chats tab, so the badge + // appears, updates and clears as conversations are read. + val chatListUnreadCount by remember(session) { + session?.state?.map { it.chatListUnreadCount } ?: flowOf(0) }.collectAsStateWithLifecycle(initialValue = 0) val avatar = rememberProfileAvatar() @@ -119,7 +119,7 @@ internal fun AppNavigationBar( if (!fadedOut) { val state = rememberNavigationBarState( selectedTab = selectedTab ?: NavBarButton.Wallet, - tipUnreadCount = tipUnreadCount, + chatListUnreadCount = chatListUnreadCount, ) NavigationBar( modifier = Modifier diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt index d9a1aa7ec0..2d604e7e41 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt @@ -57,11 +57,11 @@ data class NavigationBarState( // Route-driven: the caller derives this from the current backstack tab so the highlighted tab // is correct on launch and persists while a sheet/modal is open (not tap-managed). val selectedTab: NavBarButton = NavBarButton.Wallet, - val tipUnreadCount: Int = 0, + val chatListUnreadCount: Int = 0, ) { - /** Unread count to badge [button] with, or 0 for none. Only the tip-DM inbox badges. */ + /** Unread count to badge [button] with, or 0 for none. Only the Chats tab badges. */ fun badgeCount(button: NavBarButton): Int = when (button) { - NavBarButton.Chats -> tipUnreadCount + NavBarButton.Chats -> chatListUnreadCount else -> 0 } } @@ -69,18 +69,18 @@ data class NavigationBarState( @Composable fun rememberNavigationBarState( selectedTab: NavBarButton = NavBarButton.Wallet, - tipUnreadCount: Int = 0, + chatListUnreadCount: Int = 0, ): NavigationBarState { return produceState( initialValue = NavigationBarState( selectedTab = selectedTab, - tipUnreadCount = tipUnreadCount, + chatListUnreadCount = chatListUnreadCount, ), - selectedTab, tipUnreadCount, + selectedTab, chatListUnreadCount, ) { value = NavigationBarState( selectedTab = selectedTab, - tipUnreadCount = tipUnreadCount, + chatListUnreadCount = chatListUnreadCount, ) }.value } @@ -291,7 +291,7 @@ private fun NavBarButton.icon(selected: Boolean): Painter = when (this) { @Composable private fun NavigationBarPreview() { NavigationBar( - state = rememberNavigationBarState(tipUnreadCount = 100), + state = rememberNavigationBarState(chatListUnreadCount = 100), onButtonClick = { } ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt index 980a898d1a..584ef9f359 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt @@ -4,10 +4,10 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.ChatId -import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.ViewerState import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.currentChatListFeed import com.getcode.manager.BottomBarManager import com.getcode.util.resources.ResourceHelper import com.getcode.utils.trace @@ -40,7 +40,7 @@ internal class MuteChatViewModel @Inject constructor( * from `null` would open it without the row and then grow it by one as the store answered. */ fun currentViewerState(chatId: ChatId): ViewerState? = - chatCoordinator.currentFeed(ChatType.TIP_DM, ChatType.GROUP) + chatCoordinator.currentChatListFeed() ?.firstOrNull { it.metadata.chatId == chatId } ?.metadata?.viewerState diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt index 1daad4dcdf..cc693732b1 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt @@ -5,10 +5,11 @@ import dagger.hilt.android.lifecycle.HiltViewModel import com.flipcash.app.core.data.Loadable import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.app.tokens.TokenCoordinator -import com.flipcash.services.models.chat.ChatType import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatSummary +import com.flipcash.shared.chat.chatListFeed +import com.flipcash.shared.chat.currentChatListFeed import com.flipcash.shared.chat.ui.ConversationReference import com.flipcash.shared.chat.ui.toConversationReference import com.getcode.opencode.model.financial.Token @@ -52,14 +53,14 @@ internal class ChatsViewModel @Inject constructor( // On a cold launch the feed is usually built before this screen is, so draw it on the first // frame rather than waiting for the collector below to get a turn on the main thread. - chatCoordinator.currentFeed(ChatType.TIP_DM, ChatType.GROUP)?.let { summaries -> + chatCoordinator.currentChatListFeed()?.let { summaries -> dispatchEvent( Event.ChatsUpdated(Loadable.Loaded(conversations(summaries, tokenCoordinator.cachedTokens()))) ) } combine( - chatCoordinator.feed(ChatType.TIP_DM, ChatType.GROUP), + chatCoordinator.chatListFeed(), tokenCoordinator.tokens, ::conversations, ) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatList.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatList.kt new file mode 100644 index 0000000000..3402837b98 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatList.kt @@ -0,0 +1,21 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatType +import kotlinx.coroutines.flow.Flow + +/** + * The chat types the Chats list shows: tip DMs and groups. Contact DMs have their own inbox. + * + * The list and the Chats tab badge both read through the functions below, so the badge cannot + * count a different set of chats than the rows it sits above. + */ +private val chatListTypes = arrayOf(ChatType.TIP_DM, ChatType.GROUP) + +/** [FeedOperations.feed] for the chats the Chats list shows. */ +fun FeedOperations.chatListFeed(): Flow> = feed(*chatListTypes) + +/** [FeedOperations.currentFeed] for the chats the Chats list shows. */ +fun FeedOperations.currentChatListFeed(): List? = currentFeed(*chatListTypes) + +/** How many of the chats the Chats list shows have unread messages; the Chats tab badge. */ +fun FeedOperations.observeUnreadChatListCount(): Flow = observeUnreadConversations(*chatListTypes) diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatListUnreadCountTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatListUnreadCountTest.kt new file mode 100644 index 0000000000..c3cc20f7d0 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatListUnreadCountTest.kt @@ -0,0 +1,155 @@ +package com.flipcash.shared.chat + +import com.flipcash.app.persistence.entities.ChatMetadataEntity +import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.VerifiableContactMethod +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.MessagePointer +import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.RosterSummary +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.internal.ChatStateHolder +import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * The Chats tab badge counts the chats the Chats list shows: tip DMs and groups. It used to count + * tip DMs only, so a list whose only unread rows were groups sat under a tab with no badge. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ChatListUnreadCountTest { + + private val selfId = listOf(1, 2, 3) + private val otherId = listOf(4, 5, 6) + + private val tipHex = "aabbccdd" + private val contactHex = "55667788" + private val groupHex = "11223344" + + private fun entity(chatIdHex: String, type: ChatType) = ChatMetadataEntity( + chatIdHex = chatIdHex, + chatType = type.name, + lastActivityEpochMs = 2_000, + lastMessageId = 2, + isMember = true, + ) + + private fun message(messageId: Long) = ChatMessage( + messageId = messageId, + senderId = otherId, + content = listOf(MessageContent.Text("hi")), + timestamp = Instant.fromEpochSeconds(messageId), + unreadSeq = messageId, + ) + + /** Read up to message 1 of 2, so each chat below has one unread message. */ + private val selfUnread = ChatMember( + userId = selfId, + userProfile = UserProfile.Empty, + pointers = listOf( + MessagePointer( + type = PointerType.READ, + userId = selfId, + value = 1, + timestamp = Instant.fromEpochSeconds(1_000), + ) + ), + ) + + private val addressableOther = ChatMember( + userId = otherId, + userProfile = UserProfile.Empty.copy( + displayName = "Ada", + phoneNumber = VerifiableContactMethod("+15551234567", verified = true), + ), + pointers = emptyList(), + ) + + private suspend fun TestScope.unreadChatListCount(vararg unread: Pair): Int { + val entities = unread.map { (hex, type) -> entity(hex, type) } + val members = unread.associate { (hex, type) -> + hex to if (type == ChatType.GROUP) listOf(selfUnread) else listOf(selfUnread, addressableOther) + } + + val messageDataSource = mockk(relaxed = true).also { + coEvery { it.getLatestVisibleByChat() } returns entities.associate { e -> e.chatIdHex to message(2) } + coEvery { it.getUnreadSeq(any(), any()) } answers { secondArg() } + } + val metadataDataSource = mockk(relaxed = true).also { source -> + every { source.observeAll() } returns flowOf(entities) + every { source.toMetadata(any(), any(), any()) } answers { + val row = firstArg() + ChatMetadata( + chatId = ChatId(row.chatIdHex), + type = ChatType.valueOf(row.chatType), + members = secondArg(), + lastMessage = thirdArg(), + lastActivity = Instant.fromEpochMilliseconds(row.lastActivityEpochMs), + title = row.title, + rosterSummary = RosterSummary(memberCount = row.memberCount, version = row.rosterVersion), + ) + } + } + val memberDataSource = mockk(relaxed = true).also { source -> + every { source.observeAll() } returns flowOf(members) + } + + val delegate = FeedSyncDelegate( + messagingController = mockk(relaxed = true), + chatController = mockk(relaxed = true), + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + stateHolder = ChatStateHolder().apply { update { it.copy(feedSyncState = FeedSyncState.Synced) } }, + userManager = mockk(relaxed = true).also { + every { it.accountId } returns selfId + every { it.profile } returns null + }, + ) + delegate.initialize(backgroundScope) + delegate.observeFeedFromDb() + runCurrent() + return delegate.observeUnreadChatListCount().first() + } + + @Test + fun `an unread group counts toward the badge`() = runTest { + assertEquals(1, unreadChatListCount(groupHex to ChatType.GROUP)) + } + + @Test + fun `an unread contact DM does not count toward the badge`() = runTest { + assertEquals(0, unreadChatListCount(contactHex to ChatType.CONTACT_DM)) + } + + @Test + fun `the badge counts unread tip DMs and groups but not contact DMs`() = runTest { + val count = unreadChatListCount( + tipHex to ChatType.TIP_DM, + groupHex to ChatType.GROUP, + contactHex to ChatType.CONTACT_DM, + ) + + assertEquals(2, count) + } +} diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt index 5d94152b89..8f13be37cf 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt @@ -181,7 +181,7 @@ data class SessionState( val restrictionType: RestrictionType? = null, val isRemoteSendLoading: Boolean = false, val contactDmUnreadCount: Int = 0, - val tipsUnreadCount: Int = 0, + val chatListUnreadCount: Int = 0, val tokens: List = emptyList(), ) diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt index e2fffff0ee..71b37dad93 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt @@ -11,6 +11,7 @@ import com.flipcash.app.blob.BlobStorageCoordinator import com.flipcash.app.core.media.MediaUrlResolver import com.flipcash.services.models.chat.ChatType import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.observeUnreadChatListCount import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.internal.bill.BillController import com.flipcash.app.core.internal.updater.ProfileUpdater @@ -244,9 +245,9 @@ class RealSessionController @Inject constructor( .map { it.authState } .filter { it.isAtLeastRegistered } .distinctUntilChanged() - .flatMapLatest { chatCoordinator.observeUnreadConversations(ChatType.TIP_DM) } + .flatMapLatest { chatCoordinator.observeUnreadChatListCount() } .distinctUntilChanged() - .onEach { count -> stateHolder.update { it.copy(tipsUnreadCount = count) } } + .onEach { count -> stateHolder.update { it.copy(chatListUnreadCount = count) } } .launchIn(scope) // Preload the blob upload policy once registered so profile-photo selection can filter and