diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt index 22b2faa5c..ea0ff0d85 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt @@ -59,7 +59,7 @@ import com.getcode.ui.utils.heightOrZero import com.getcode.ui.utils.widthOrZero data class NavigationBarState( - val notificationUnreadCount: Int = 0, + val contactDmUnreadCount: Int = 0, val showToast: Boolean = false, val toastText: String? = null, val isPaused: Boolean = false, @@ -144,7 +144,7 @@ fun NavigationBar( NavBarButton.Send -> BottomBarAction( modifier = buttonModifier, label = stringResource(R.string.action_send), - badgeCount = state.notificationUnreadCount, + badgeCount = state.contactDmUnreadCount, painter = painterResource(R.drawable.ic_send_outlined), onClick = { onButtonClick(NavBarButton.Send) } ) @@ -301,7 +301,7 @@ private fun BottomBarAction( @Composable private fun NavigationBarPreview() { NavigationBar( - state = NavigationBarState(notificationUnreadCount = 100), + state = NavigationBarState(contactDmUnreadCount = 100), ) } @Preview diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListBuilder.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListBuilder.kt index a7ab694fc..2129ce76e 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListBuilder.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListBuilder.kt @@ -9,6 +9,7 @@ import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatSummary +import com.flipcash.shared.chat.ui.ConversationReference import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Token import com.getcode.solana.keys.Mint @@ -109,7 +110,7 @@ internal class ContactListBuilder @Inject constructor( contact = contact, isOnFlipcash = true, lastActivity = summary.metadata.lastActivity, - conversation = Conversation( + conversation = ConversationReference( chatId = chatId, lastMessagePreview = formatPreview(summary, selfId, tokensByMint), unreadCount = summary.unreadCount, diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListItem.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListItem.kt index 388457f8e..8ecfcbd95 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListItem.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListItem.kt @@ -2,6 +2,7 @@ package com.flipcash.app.directsend.internal import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ui.ConversationReference import kotlin.time.Instant internal sealed interface ContactListItem { @@ -22,14 +23,6 @@ internal sealed interface ContactListItem { val contact: DeviceContact, val isOnFlipcash: Boolean, val lastActivity: Instant? = null, - val conversation: Conversation? = null, + val conversation: ConversationReference? = null, ) : ContactListItem } - -/** Presentation state derived from an existing DM with a contact. */ -internal data class Conversation( - val chatId: ChatId, - val lastMessagePreview: String? = null, - val unreadCount: Int = 0, - val isTyping: Boolean = false, -) diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt index 264178324..2928b94bf 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt @@ -14,6 +14,7 @@ import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.permissions.PickedContact import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.features.directsend.R +import com.flipcash.services.models.chat.ChatType import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatCoordinator import com.getcode.manager.BottomBarManager @@ -92,7 +93,7 @@ internal class SendFlowViewModel @Inject constructor( featureFlags.observe(FeatureFlag.PhoneNumberSend), featureFlags.observe(FeatureFlag.ContactPickerMode), contactCoordinator.state, - chatCoordinator.feed, + chatCoordinator.feed(ChatType.CONTACT_DM), ) { userState, phoneNumberSendFlag, contactPickerMode, contactState, chats -> val hasLinkedPhone = userState.userProfile?.verifiedPhoneNumber != null val phoneNumberSendEnabled = phoneNumberSendFlag || @@ -119,7 +120,7 @@ internal class SendFlowViewModel @Inject constructor( .map { it.searchState } .distinctUntilChanged() .flatMapLatest { snapshotFlow { it.text } }, - chatCoordinator.feed, + chatCoordinator.feed(ChatType.CONTACT_DM), tokenCoordinator.tokens, ) { contactState, searchText, chatFeed, tokens -> contactListBuilder.build( diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/components/ContactList.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/components/ContactList.kt index 17fb3bb9c..747ca65d3 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/components/ContactList.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/components/ContactList.kt @@ -24,8 +24,8 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Search @@ -36,39 +36,33 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import com.flipcash.app.contacts.ui.ContactAvatar -import com.flipcash.app.core.android.extensions.launchAppSettings import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.directsend.internal.ContactListItem -import com.flipcash.app.directsend.internal.Conversation import com.flipcash.app.permissions.ContactAccessHandle import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.features.directsend.R import com.flipcash.services.models.chat.ChatId -import com.flipcash.shared.chat.ui.AnimatedConversationPaymentsPreview +import com.flipcash.shared.chat.ui.ConversationReference import com.getcode.theme.CodeTheme import com.getcode.theme.White10 -import com.getcode.theme.extraLarge import com.getcode.theme.extraSmall import com.getcode.ui.core.verticalScrollStateGradient -import com.getcode.ui.theme.CodeButton import com.getcode.util.formatLocalized -import com.getcode.util.permissions.PermissionResult import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import kotlin.time.Clock @@ -576,7 +570,7 @@ private fun ContactListPreview() { contact = flipcashContacts[0].contact, isOnFlipcash = true, lastActivity = now - 5.minutes, - conversation = Conversation( + conversation = ConversationReference( chatId = ChatId(byteArrayOf(1)), lastMessagePreview = "Sent you $10.00", unreadCount = 2, @@ -587,7 +581,7 @@ private fun ContactListPreview() { contact = flipcashContacts[1].contact, isOnFlipcash = true, lastActivity = now - 1.hours, - conversation = Conversation( + conversation = ConversationReference( chatId = ChatId(byteArrayOf(2)), lastMessagePreview = "See you soon", isTyping = true, @@ -598,7 +592,7 @@ private fun ContactListPreview() { contact = flipcashContacts[2].contact, isOnFlipcash = true, lastActivity = now - 26.hours, - conversation = Conversation( + conversation = ConversationReference( chatId = ChatId(byteArrayOf(3)), lastMessagePreview = "You: Thanks!", ), diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/ScannerNavigationBar.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/ScannerNavigationBar.kt index 0fa687779..a84ca8972 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/ScannerNavigationBar.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/ScannerNavigationBar.kt @@ -40,7 +40,7 @@ internal fun ScannerNavigationBar( modifier = modifier, config = effectiveConfig, state = NavigationBarState( - notificationUnreadCount = state.notificationUnreadCount, + contactDmUnreadCount = state.contactDmUnreadCount, showToast = billState.showToast && billState.toast != null, toastText = billState.toast?.formattedAmount, isPaused = isPaused, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt new file mode 100644 index 000000000..e1ee984d7 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt @@ -0,0 +1,11 @@ +package com.flipcash.shared.chat.ui + +import com.flipcash.services.models.chat.ChatId + +/** Presentation state derived from an existing DM with a contact. */ +data class ConversationReference( + val chatId: ChatId, + val lastMessagePreview: String? = null, + val unreadCount: Int = 0, + val isTyping: Boolean = false, +) \ No newline at end of file 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 0660397ef..dcbb35201 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 @@ -8,6 +8,7 @@ import com.flipcash.app.core.contacts.DeviceContact 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.ChatType import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.ReactionSummary @@ -21,11 +22,11 @@ import kotlinx.coroutines.flow.StateFlow * Implemented by [com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate]. */ interface FeedOperations { - /** Reactive list of all DM conversations, sorted by last activity. */ - val feed: Flow> + /** Reactive list of [chatType] conversations, sorted by last activity. */ + fun feed(chatType: ChatType): Flow> - /** Emits the number of conversations that have unread messages. */ - fun observeUnreadConversations(): Flow + /** Emits the number of [chatType] conversations that have unread messages. */ + fun observeUnreadConversations(chatType: ChatType): Flow /** Triggers a server-side feed sync. Safe to call redundantly. */ fun refreshFeed() diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt index 0e01828cf..47d806de4 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt @@ -72,37 +72,44 @@ class FeedSyncDelegate @Inject constructor( // region FeedOperations - override val feed: Flow> - get() = stateHolder.state.map { state -> + override fun feed(chatType: ChatType): Flow> = + stateHolder.state.map { state -> val selfId = userManager.accountId val selfPhone = userManager.profile?.verifiedPhoneNumber val isSelf = { member: ChatMember -> member.userId == selfId || (selfPhone != null && member.userProfile.verifiedPhoneNumber == selfPhone) } - state.feed.mapNotNull { metadata -> - val otherMember = metadata.members.firstOrNull { !isSelf(it) } - ?: return@mapNotNull null - val profile = otherMember.userProfile - val hasIdentity = !profile.displayName.isNullOrBlank() || - !profile.verifiedPhoneNumber.isNullOrBlank() - if (!hasIdentity) return@mapNotNull null - - val readPointer = metadata.members - .firstOrNull { it.userId == selfId } - ?.pointers - ?.firstOrNull { it.type == PointerType.READ } - ?.value ?: 0L - - val unreadCount = metadata.lastMessage?.let { lastMsg -> - if (lastMsg.messageId > readPointer && lastMsg.senderId != selfId) 1 else 0 - } ?: 0 - - ChatSummary(metadata = metadata, unreadCount = unreadCount) - } + state.feed + .filter { it.type == chatType } + .mapNotNull { metadata -> + val otherMember = metadata.members.firstOrNull { !isSelf(it) } + ?: return@mapNotNull null + + // Contact DMs require a resolvable identity (phone / display name). Tip DMs are + // identified by user id and have no phone by design, so they are never dropped. + if (chatType == ChatType.CONTACT_DM) { + val profile = otherMember.userProfile + val hasIdentity = !profile.displayName.isNullOrBlank() || + !profile.verifiedPhoneNumber.isNullOrBlank() + if (!hasIdentity) return@mapNotNull null + } + + val readPointer = metadata.members + .firstOrNull { it.userId == selfId } + ?.pointers + ?.firstOrNull { it.type == PointerType.READ } + ?.value ?: 0L + + val unreadCount = metadata.lastMessage?.let { lastMsg -> + if (lastMsg.messageId > readPointer && lastMsg.senderId != selfId) 1 else 0 + } ?: 0 + + ChatSummary(metadata = metadata, unreadCount = unreadCount) + } } - override fun observeUnreadConversations(): Flow { - return feed.map { summaries -> summaries.count { it.unreadCount > 0 } } + override fun observeUnreadConversations(chatType: ChatType): Flow { + return feed(chatType).map { summaries -> summaries.count { it.unreadCount > 0 } } } override fun refreshFeed() { @@ -155,13 +162,25 @@ class FeedSyncDelegate @Inject constructor( } } + /** + * Fetches the CONTACT_DM and TIP_DM feeds and returns their merged chats. The contact feed is + * required (its failure fails the whole sync, preserving prior behaviour); a TIP_DM failure is + * tolerated so tips never break the main DM list. Each chat carries its own [ChatType]. + */ + internal suspend fun fetchCombinedFeed(): Result> { + val contact = chatController.getDmChatFeed(ChatType.CONTACT_DM) + .getOrElse { return Result.failure(it) } + val tip = chatController.getDmChatFeed(ChatType.TIP_DM).getOrNull() + return Result.success(contact.chats + (tip?.chats ?: emptyList())) + } + private suspend fun performFeedSync() { stateHolder.update { it.copy(feedSyncState = FeedSyncState.Syncing) } - chatController.getDmChatFeed(ChatType.CONTACT_DM) - .onSuccess { page -> - metadataDataSource.upsert(page.chats) + fetchCombinedFeed() + .onSuccess { chats -> + metadataDataSource.upsert(chats) - for (chat in page.chats) { + for (chat in chats) { memberDataSource.upsert(chat.chatId, chat.members) chat.lastMessage?.let { msg -> messageDataSource.upsert(chat.chatId, listOf(msg)) @@ -169,9 +188,9 @@ class FeedSyncDelegate @Inject constructor( } stateHolder.update { it.copy(feedSyncState = FeedSyncState.Synced) } - trace(tag = TAG, message = "Feed synced: ${page.chats.size} chats", type = TraceType.Process) + trace(tag = TAG, message = "Feed synced: ${chats.size} chats", type = TraceType.Process) - for (chat in page.chats) { + for (chat in chats) { if (chat.latestEventSequence > 0) { val localSeq = metadataDataSource.getLatestEventSequence(chat.chatId) if (localSeq > 0 && localSeq < chat.latestEventSequence) { diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedSyncCombinedFeedTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedSyncCombinedFeedTest.kt new file mode 100644 index 000000000..1604b294a --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedSyncCombinedFeedTest.kt @@ -0,0 +1,64 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.chat.ChatFeedPage +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant +import org.junit.Assert.assertEquals +import org.junit.Test + +class FeedSyncCombinedFeedTest { + + private fun metadata(hex: String, type: ChatType) = ChatMetadata( + chatId = ChatId(hex), + type = type, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(1000), + ) + + private fun delegateWith(chatController: ChatController) = FeedSyncDelegate( + chatController = chatController, + metadataDataSource = mockk(relaxed = true), + messageDataSource = mockk(relaxed = true), + memberDataSource = mockk(relaxed = true), + stateHolder = mockk(relaxed = true), + userManager = mockk(relaxed = true), + ) + + @Test + fun `fetches both contact and tip feeds and merges chats`() = runTest { + val chatController = mockk(relaxed = true) + coEvery { chatController.getDmChatFeed(ChatType.CONTACT_DM, any()) } returns + Result.success(ChatFeedPage(listOf(metadata("aa", ChatType.CONTACT_DM)), null, false)) + coEvery { chatController.getDmChatFeed(ChatType.TIP_DM, any()) } returns + Result.success(ChatFeedPage(listOf(metadata("bb", ChatType.TIP_DM)), null, false)) + + val merged = delegateWith(chatController).fetchCombinedFeed().getOrThrow() + + assertEquals(2, merged.size) + coVerify { chatController.getDmChatFeed(ChatType.CONTACT_DM, any()) } + coVerify { chatController.getDmChatFeed(ChatType.TIP_DM, any()) } + } + + @Test + fun `contact feed still returned when tip feed fails`() = runTest { + val chatController = mockk(relaxed = true) + coEvery { chatController.getDmChatFeed(ChatType.CONTACT_DM, any()) } returns + Result.success(ChatFeedPage(listOf(metadata("aa", ChatType.CONTACT_DM)), null, false)) + coEvery { chatController.getDmChatFeed(ChatType.TIP_DM, any()) } returns + Result.failure(RuntimeException("tip feed unavailable")) + + val merged = delegateWith(chatController).fetchCombinedFeed().getOrThrow() + + assertEquals(1, merged.size) + assertEquals(ChatType.CONTACT_DM, merged.first().type) + } +} 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 669878ad9..747e5c214 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 @@ -59,7 +59,7 @@ data class SessionState( val billResult: BillDeterminationResult = BillDeterminationResult.None, val restrictionType: RestrictionType? = null, val isRemoteSendLoading: Boolean = false, - val notificationUnreadCount: Int = 0, + val contactDmUnreadCount: Int = 0, val tokens: List = emptyList(), val isPhoneNumberSendEnabled: Boolean = false, val addMoneyUx: Boolean = false, 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 839d50a10..2382decea 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 @@ -6,6 +6,7 @@ import com.flipcash.app.appsettings.AppSettingValue import com.flipcash.app.appsettings.AppSettingsCoordinator import com.flipcash.app.billing.BillingClient import com.flipcash.app.contacts.ContactCoordinator +import com.flipcash.services.models.chat.ChatType import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.app.core.internal.bill.BillController import com.flipcash.app.core.internal.updater.ProfileUpdater @@ -194,9 +195,9 @@ class RealSessionController @Inject constructor( .map { it.authState } .filter { it.isAtLeastRegistered } .distinctUntilChanged() - .flatMapLatest { chatCoordinator.observeUnreadConversations() } + .flatMapLatest { chatCoordinator.observeUnreadConversations(ChatType.CONTACT_DM) } .distinctUntilChanged() - .onEach { count -> stateHolder.update { it.copy(notificationUnreadCount = count) } } + .onEach { count -> stateHolder.update { it.copy(contactDmUnreadCount = count) } } .launchIn(scope) appSettingsCoordinator