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