From 1a52e19a4359f09e4e3e24cd6d5a027dc6a5f37b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 23 Jul 2026 10:54:00 -0400 Subject: [PATCH 1/3] feat(chat): derive canonical DM chat IDs matching server Add ChatIdGenerator, which deterministically derives a DM's ChatId from the two participants' user IDs: SHA-256 over a per-type domain ("flipcash:chat:dm" for CONTACT_DM, "flipcash:chat:dm:2" for TIP_DM) and the unsigned-sorted, self-pair-collapsed member set. Mirrors the server's MustDeriveDmChatID byte-for-byte so either side reaches the same id without a prior lookup. Tests pin the wire-contract vectors (matching the iOS TipDmChatIDTests). Co-Authored-By: Claude Opus 4.8 --- .../shared/chat/internal/ChatIdGenerator.kt | 66 +++++++++++++ .../chat/internal/ChatIdGeneratorTest.kt | 92 +++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/ChatIdGenerator.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/ChatIdGeneratorTest.kt diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/ChatIdGenerator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/ChatIdGenerator.kt new file mode 100644 index 000000000..a34332111 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/ChatIdGenerator.kt @@ -0,0 +1,66 @@ +package com.flipcash.shared.chat.internal + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatType +import com.getcode.opencode.model.core.ID +import java.security.MessageDigest +import javax.inject.Inject + +class ChatIdGenerator @Inject constructor() { + + /** + * Derives the canonical DM chat id for [chatType] between users [a] and [b], + * mirroring the server's `MustDeriveDmChatID(chatType, a, b)`. Stable and + * order-independent, so either user derives the same id without a lookup. + */ + fun generate(chatType: ChatType, a: ID, b: ID): ChatId = + compute(chatType.dmDomain(), a.toByteArray(), b.toByteArray()) + + /** + * Domain separator for [this] DM chat type. Contact DMs hash under the bare + * legacy prefix (their ids predate typed derivation and must not change); + * every other DM type appends its `ChatType` enum value. Mirrors + * flipcash2-server `chat/model.go`. + */ + private fun ChatType.dmDomain(): String = when (this) { + ChatType.CONTACT_DM -> DM_DOMAIN + ChatType.TIP_DM -> TIP_DM_DOMAIN + ChatType.UNKNOWN -> error("cannot derive a DM chat id for chat type $this") + } + + /** + * Mirrors the server's derivation byte-for-byte: SHA-256 over the [domain] + * followed by the unsigned-lexicographically sorted member set, where a + * self-pair (a == b) collapses to a single member. The server rejects any + * intent whose chat id doesn't match, so this is a wire contract and must + * not diverge. + */ + private fun compute(domain: String, a: ByteArray, b: ByteArray): ChatId { + val (first, second) = if (a.compareUnsigned(b) <= 0) a to b else b to a + val digest = MessageDigest.getInstance("SHA-256").run { + update(domain.toByteArray(Charsets.UTF_8)) + update(first) + if (!first.contentEquals(second)) update(second) + digest() + } + return ChatId(digest) + } + + /** Lexicographic comparison treating bytes as unsigned, like Solana key ordering. */ + private fun ByteArray.compareUnsigned(other: ByteArray): Int { + val shared = minOf(size, other.size) + for (i in 0 until shared) { + val diff = (this[i].toInt() and 0xFF) - (other[i].toInt() and 0xFF) + if (diff != 0) return diff + } + return size - other.size + } + + private companion object { + /** Bare legacy domain: contact DM ids predate typed derivation. */ + const val DM_DOMAIN = "flipcash:chat:dm" + + /** TIP_DM appends its ChatType enum value (2) to the base domain. */ + const val TIP_DM_DOMAIN = "$DM_DOMAIN:2" + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/ChatIdGeneratorTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/ChatIdGeneratorTest.kt new file mode 100644 index 000000000..5fde56bda --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/ChatIdGeneratorTest.kt @@ -0,0 +1,92 @@ +package com.flipcash.shared.chat.internal + +import com.flipcash.services.models.chat.ChatType +import com.getcode.opencode.model.core.ID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +/** + * Vectors mirror the server's `MustDeriveDmChatID(chatType, a, b)` + * (flipcash2-server `chat/model.go`): SHA-256 over a per-type domain followed by + * the unsigned-lexicographically sorted set of the two 16-byte user IDs, with a + * self-pair collapsed to a single member. CONTACT_DM hashes under the bare + * legacy domain `"flipcash:chat:dm"`; TIP_DM appends its enum value + * (`"flipcash:chat:dm:2"`). The server rejects any intent whose chat id doesn't + * match, so these bytes are a cross-platform wire contract. The TIP_DM vectors + * are the exact iOS `TipDmChatIDTests` vectors. + */ +class ChatIdGeneratorTest { + + private val generator = ChatIdGenerator() + + // UUID 11111111-2222-3333-4444-555555555555 + private val userA: ID = "11111111222233334444555555555555".hexToId() + // UUID aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee + private val userB: ID = "aaaaaaaabbbbccccddddeeeeeeeeeeee".hexToId() + + @Test + fun `TIP_DM derives the server vector for a sorted pair`() { + val id = generator.generate(ChatType.TIP_DM, userA, userB) + assertEquals( + "8b2f0e5da9fa050dc0040fb23dc0aa0028a0c5d9fca36b50cd3c163be3cb09e7", + id.bytes.toHex(), + ) + } + + @Test + fun `TIP_DM self-pair collapses to a single member`() { + val id = generator.generate(ChatType.TIP_DM, userA, userA) + assertEquals( + "525de420ef8a70e1cf1483090f937d7563a7bbe7501558ab5f6aae59780c3af2", + id.bytes.toHex(), + ) + } + + @Test + fun `CONTACT_DM derives the server vector for a sorted pair`() { + val id = generator.generate(ChatType.CONTACT_DM, userA, userB) + assertEquals( + "542acadd83bb3ae2c341b5f058bbbdcf001258707634f2e2281811e52e3c6263", + id.bytes.toHex(), + ) + } + + @Test + fun `CONTACT_DM self-pair collapses to a single member`() { + val id = generator.generate(ChatType.CONTACT_DM, userA, userA) + assertEquals( + "4c15eb24c70f19b8266fa9ac7d32f63aaf6500b4346a3567d45a2533da72d139", + id.bytes.toHex(), + ) + } + + @Test + fun `argument order does not change the id`() { + assertEquals( + generator.generate(ChatType.CONTACT_DM, userA, userB), + generator.generate(ChatType.CONTACT_DM, userB, userA), + ) + assertEquals( + generator.generate(ChatType.TIP_DM, userA, userB), + generator.generate(ChatType.TIP_DM, userB, userA), + ) + } + + @Test + fun `domain separation makes CONTACT_DM and TIP_DM ids differ for the same pair`() { + assertNotEquals( + generator.generate(ChatType.CONTACT_DM, userA, userB), + generator.generate(ChatType.TIP_DM, userA, userB), + ) + } + + @Test + fun `produces the 32-byte ChatId length`() { + assertEquals(32, generator.generate(ChatType.CONTACT_DM, userA, userB).bytes.size) + } + + private fun String.hexToId(): ID = chunked(2).map { it.toInt(16).toByte() } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } +} From be72dc898a0a61a844c8737f1436c5c2b6e712c8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 23 Jul 2026 10:54:20 -0400 Subject: [PATCH 2/3] refactor(chat): extract DmChatResolver and add member accessors Split DM-identity resolution out of MessagingOperations into a dedicated DmChatResolver interface (generateChatId / getChatId), implemented by a new DmChatResolverDelegate and composed into RealChatCoordinator via `by` delegation. MessagingOperations is left focused on operating on an existing ChatId. Add MessagingOperations.getOtherMember(chatId), returning the full ChatMember (profile incl. avatar) with a local-cache-then-network fallback; getOtherMemberE164 now delegates to it. Back tip DM chat-id resolution with ChatMemberDao.getChatIdForMember / ChatMemberDataSource.getChatIdForUser. Co-Authored-By: Claude Opus 4.8 --- .../flipcash/shared/chat/ChatCoordinator.kt | 52 +++++++++++++++-- .../chat/internal/RealChatCoordinator.kt | 5 ++ .../delegates/DmChatResolverDelegate.kt | 56 +++++++++++++++++++ .../internal/delegates/MessagingDelegate.kt | 25 ++------- .../chat/ChatCoordinatorEagerBalanceTest.kt | 11 +++- .../shared/chat/ChatCoordinatorEventsTest.kt | 11 +++- .../app/persistence/dao/ChatMemberDao.kt | 16 ++++++ .../sources/ChatMemberDataSource.kt | 10 ++++ 8 files changed, 159 insertions(+), 27 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/DmChatResolverDelegate.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 dcbb35201..4d36849bc 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 @@ -13,6 +13,7 @@ import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.ReactionSummary import com.flipcash.services.models.chat.TypingState +import com.getcode.opencode.model.core.ID import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -49,16 +50,54 @@ interface EventStreamOperations { } /** - * Per-chat messaging operations: sending, receiving, read receipts, and identity. + * Resolves the [ChatId] of a DM from its participants, independent of any single + * conversation. Two ways to arrive at an id: * - * All methods target a single conversation identified by [ChatId]. + * - **Derive** ([generateChatId]) — compute the canonical DM id from the + * participants alone. Deterministic and order-independent, so either user + * reaches the same id without a prior lookup, matching the server's + * `MustDeriveDmChatID`. Works even before a chat has been initialized. + * - **Look up** ([getChatId]) — return the id of an *already-initialized* DM + * from local persistence, failing with [NoDmChatInitializedException] if none + * exists yet. * - * Implemented by [com.flipcash.shared.chat.internal.delegates.MessagingDelegate]. + * Only tip DMs are derivable client-side: derivation needs the counterparty's + * `UserId`, which the client has for a tip target but not for a phone [contact] + * (`FlipcashContact` carries no user id). Contact DMs don't need it anyway — the + * server pre-derives their id and delivers it via `GetFlipcashContacts`, so a + * contact's id is always resolved through [getChatId], never derived. + * + * Implemented by [com.flipcash.shared.chat.internal.delegates.DmChatResolverDelegate]. */ -interface MessagingOperations { +interface DmChatResolver { + /** Derives the canonical TIP_DM [ChatId] between the current user and [userId]. */ + suspend fun generateChatId(userId: ID): Result + /** Resolves the [ChatId] for an existing DM with [contact]. */ suspend fun getChatId(contact: DeviceContact): Result + /** Resolves the [ChatId] for an existing DM with [userId] (tip chat). */ + suspend fun getChatId(userId: ID): Result +} + +/** + * Per-chat messaging operations: sending, receiving, and read receipts. + * + * All methods target a single conversation identified by [ChatId]. Resolving + * *which* [ChatId] to operate on is [DmChatResolver]'s job. + * + * Implemented by [com.flipcash.shared.chat.internal.delegates.MessagingDelegate]. + */ +interface MessagingOperations { + /** + * Returns the other member of a DM (fetching from the server and persisting + * if not cached locally), or `null` if it can't be resolved. Chat-type + * agnostic — the returned [ChatMember] carries the counterparty's + * [com.flipcash.services.models.UserProfile] (display name, avatar) for + * rendering a sender without a phone contact. + */ + suspend fun getOtherMember(chatId: ChatId): ChatMember? + /** Returns the E.164 phone number of the other member in a DM, or `null` if unknown. */ suspend fun getOtherMemberE164(chatId: ChatId): String? @@ -104,7 +143,7 @@ interface MessagingOperations { /** * Unified facade for the chat subsystem, composing [FeedOperations], - * [EventStreamOperations], and [MessagingOperations]. + * [EventStreamOperations], [DmChatResolver], and [MessagingOperations]. * * The concrete implementation is * [RealChatCoordinator][com.flipcash.shared.chat.internal.RealChatCoordinator], @@ -113,7 +152,7 @@ interface MessagingOperations { * * @see com.flipcash.shared.chat.internal.RealChatCoordinator */ -interface ChatCoordinator : FeedOperations, EventStreamOperations, MessagingOperations { +interface ChatCoordinator : FeedOperations, EventStreamOperations, DmChatResolver, MessagingOperations { /** Full observable snapshot of chat state (feed, typing, reactions, active chat). */ val state: StateFlow @@ -122,3 +161,4 @@ interface ChatCoordinator : FeedOperations, EventStreamOperations, MessagingOper } class NoDmChatInitializedException(e164: String) : Exception("No DM chat for $e164") +class FailedToGenerateChatIdException(identifier: String?) : Exception("Failed to generate chat ID for $identifier") diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt index 10b58e24e..e0453af62 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt @@ -12,11 +12,13 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatState +import com.flipcash.shared.chat.DmChatResolver import com.flipcash.shared.chat.EventStreamOperations import com.flipcash.shared.chat.FeedOperations import com.flipcash.shared.chat.MessagingOperations import com.flipcash.shared.chat.internal.delegates.EventStreamDelegate import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate +import com.flipcash.shared.chat.internal.delegates.DmChatResolverDelegate import com.flipcash.shared.chat.internal.delegates.MessagingDelegate import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.providers.SessionListener @@ -52,6 +54,7 @@ import kotlin.time.Duration.Companion.seconds * |----------|-----------|----------------| * | [FeedSyncDelegate] | [FeedOperations] | Feed sync, DB observation, unread counts | * | [EventStreamDelegate] | [EventStreamOperations] | Event stream, real-time updates, gap-aware sequencing, reactions, typing | + * | [DmChatResolverDelegate] | [DmChatResolver] | Resolve a DM's [ChatId] from its participants (derive or look up) | * | [MessagingDelegate] | [MessagingOperations] | Per-chat send/receive, read pointers, paging, notifications | * * **What lives here (and why):** @@ -72,6 +75,7 @@ import kotlin.time.Duration.Companion.seconds class RealChatCoordinator @Inject constructor( private val feedDelegate: FeedSyncDelegate, private val eventStreamDelegate: EventStreamDelegate, + private val dmChatResolverDelegate: DmChatResolverDelegate, private val messagingDelegate: MessagingDelegate, private val stateHolder: ChatStateHolder, private val userManager: UserManager, @@ -83,6 +87,7 @@ class RealChatCoordinator @Inject constructor( DefaultLifecycleObserver, FeedOperations by feedDelegate, EventStreamOperations by eventStreamDelegate, + DmChatResolver by dmChatResolverDelegate, MessagingOperations by messagingDelegate { companion object { diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/DmChatResolverDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/DmChatResolverDelegate.kt new file mode 100644 index 000000000..0e2bba069 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/DmChatResolverDelegate.kt @@ -0,0 +1,56 @@ +package com.flipcash.shared.chat.internal.delegates + +import com.flipcash.app.core.contacts.DeviceContact +import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ContactDataSource +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.DmChatResolver +import com.flipcash.shared.chat.FailedToGenerateChatIdException +import com.flipcash.shared.chat.NoDmChatInitializedException +import com.flipcash.shared.chat.internal.ChatIdGenerator +import com.getcode.opencode.model.core.ID +import com.getcode.utils.decodeBase58 +import com.getcode.utils.hexEncodedString +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Resolves the [ChatId] of a DM from its participants — either by deterministic + * derivation ([generateChatId]) or by looking up an already-initialized chat in + * local persistence ([getChatId]). + * + * This is pure identity resolution: it does not send, receive, or mutate any + * conversation. Operating on a resolved [ChatId] is + * [MessagingDelegate][com.flipcash.shared.chat.internal.delegates.MessagingDelegate]'s job. + * + * @see com.flipcash.shared.chat.internal.RealChatCoordinator + */ +@Singleton +class DmChatResolverDelegate @Inject constructor( + private val chatIdGenerator: ChatIdGenerator, + private val userManager: UserManager, + private val contactDataSource: ContactDataSource, + private val memberDataSource: ChatMemberDataSource, +) : DmChatResolver { + + override suspend fun generateChatId(userId: ID): Result { + val self = userManager.accountId ?: return Result.failure(FailedToGenerateChatIdException(null)) + return Result.success(chatIdGenerator.generate(ChatType.TIP_DM, self, userId)) + } + + override suspend fun getChatId(contact: DeviceContact): Result { + val raw = contactDataSource.getDmChatId(contact.e164) + if (raw.isNullOrEmpty()) { + return Result.failure(NoDmChatInitializedException(contact.e164)) + } + return runCatching { ChatId(raw.decodeBase58()) } + } + + override suspend fun getChatId(userId: ID): Result { + val chatId = memberDataSource.getChatIdForUser(userId, ChatType.TIP_DM) + ?: return Result.failure(NoDmChatInitializedException(userId.hexEncodedString())) + return Result.success(chatId) + } +} 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 84678139d..551b38c1b 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 @@ -8,11 +8,9 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.map -import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.persistence.sources.ChatMemberDataSource import com.flipcash.app.persistence.sources.ChatMessageDataSource import com.flipcash.app.persistence.sources.ChatMetadataDataSource -import com.flipcash.app.persistence.sources.ContactDataSource import com.flipcash.app.persistence.sources.mediator.ChatMessageRemoteMediator import com.flipcash.services.controllers.ChatController import com.flipcash.services.controllers.ChatMessagingController @@ -24,10 +22,8 @@ import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.PointerType import com.flipcash.services.models.chat.TypingState import com.flipcash.shared.chat.MessagingOperations -import com.flipcash.shared.chat.NoDmChatInitializedException import com.flipcash.shared.chat.internal.ChatStateHolder import com.flipcash.services.user.UserManager -import com.getcode.utils.decodeBase58 import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -58,7 +54,6 @@ class MessagingDelegate @Inject constructor( private val metadataDataSource: ChatMetadataDataSource, private val messageDataSource: ChatMessageDataSource, private val memberDataSource: ChatMemberDataSource, - private val contactDataSource: ContactDataSource, private val notificationManager: NotificationManagerCompat, private val userManager: UserManager, private val stateHolder: ChatStateHolder, @@ -66,27 +61,19 @@ class MessagingDelegate @Inject constructor( // region MessagingOperations - override suspend fun getChatId(contact: DeviceContact): Result { - val raw = contactDataSource.getDmChatId(contact.e164) - if (raw.isNullOrEmpty()) { - return Result.failure(NoDmChatInitializedException(contact.e164)) - } - return runCatching { ChatId(raw.decodeBase58()) } - } - - override suspend fun getOtherMemberE164(chatId: ChatId): String? { + override suspend fun getOtherMember(chatId: ChatId): ChatMember? { val selfId = userManager.accountId val localMembers = memberDataSource.getMembersForChat(chatId) - val otherMember = localMembers.firstOrNull { it.userId != selfId } - if (otherMember != null) return otherMember.userProfile.verifiedPhoneNumber + localMembers.firstOrNull { it.userId != selfId }?.let { return it } val metadata = chatController.getChat(chatId).getOrNull() ?: return null memberDataSource.upsert(chatId, metadata.members) - return metadata.members - .firstOrNull { it.userId != selfId } - ?.userProfile?.verifiedPhoneNumber + return metadata.members.firstOrNull { it.userId != selfId } } + override suspend fun getOtherMemberE164(chatId: ChatId): String? = + getOtherMember(chatId)?.userProfile?.verifiedPhoneNumber + override fun setActiveChatId(chatId: ChatId?) { stateHolder.update { it.copy(activeChat = chatId) } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt index 79d497e57..9d07ffa9f 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt @@ -14,10 +14,12 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatUpdate import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.internal.ChatIdGenerator import com.flipcash.shared.chat.internal.ChatStateHolder import com.flipcash.shared.chat.internal.RealChatCoordinator import com.flipcash.shared.chat.internal.delegates.EventStreamDelegate import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate +import com.flipcash.shared.chat.internal.delegates.DmChatResolverDelegate import com.flipcash.shared.chat.internal.delegates.MessagingDelegate import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat @@ -104,15 +106,22 @@ class ChatCoordinatorEagerBalanceTest { metadataDataSource = metadataDataSource, messageDataSource = messageDataSource, memberDataSource = memberDataSource, - contactDataSource = mockk(relaxed = true), notificationManager = mockk(relaxed = true), userManager = userManager, stateHolder = stateHolder, ) + val dmChatResolverDelegate = DmChatResolverDelegate( + chatIdGenerator = ChatIdGenerator(), + userManager = userManager, + contactDataSource = mockk(relaxed = true), + memberDataSource = memberDataSource, + ) + coordinator = RealChatCoordinator( feedDelegate = feedDelegate, eventStreamDelegate = eventStreamDelegate, + dmChatResolverDelegate = dmChatResolverDelegate, messagingDelegate = messagingDelegate, stateHolder = stateHolder, userManager = userManager, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt index 8af6e447d..dc1c72167 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt @@ -18,10 +18,12 @@ import com.flipcash.services.models.chat.ChatUpdate import com.flipcash.services.models.chat.Emoji import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.ReactionUpdate +import com.flipcash.shared.chat.internal.ChatIdGenerator import com.flipcash.shared.chat.internal.ChatStateHolder import com.flipcash.shared.chat.internal.RealChatCoordinator import com.flipcash.shared.chat.internal.delegates.EventStreamDelegate import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate +import com.flipcash.shared.chat.internal.delegates.DmChatResolverDelegate import com.flipcash.shared.chat.internal.delegates.MessagingDelegate import com.getcode.utils.network.NetworkConnectivityListener import com.flipcash.services.user.UserManager @@ -109,15 +111,22 @@ class ChatCoordinatorEventsTest { metadataDataSource = metadataDataSource, messageDataSource = messageDataSource, memberDataSource = memberDataSource, - contactDataSource = mockk(relaxed = true), notificationManager = mockk(relaxed = true), userManager = userManager, stateHolder = stateHolder, ) + val dmChatResolverDelegate = DmChatResolverDelegate( + chatIdGenerator = ChatIdGenerator(), + userManager = userManager, + contactDataSource = mockk(relaxed = true), + memberDataSource = memberDataSource, + ) + coordinator = RealChatCoordinator( feedDelegate = feedDelegate, eventStreamDelegate = eventStreamDelegate, + dmChatResolverDelegate = dmChatResolverDelegate, messagingDelegate = messagingDelegate, stateHolder = stateHolder, userManager = userManager, diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt index 666b1c99a..7ed212956 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt @@ -28,6 +28,22 @@ interface ChatMemberDao { @Query("SELECT * FROM chat_members WHERE chat_id_hex = :chatIdHex AND user_id_hex = :userIdHex LIMIT 1") suspend fun getMember(chatIdHex: String, userIdHex: String): ChatMemberEntity? + /** + * Resolves the chat id of a DM that [userIdHex] is a member of, of the given [chatType] + * (e.g. `"TIP_DM"`). Reuses the already-synced `chat_members` ↔ `chat_metadata` data — no + * extra column/table needed — and stays generic by filtering on the chat type. + */ + @Query( + """ + SELECT m.chat_id_hex FROM chat_members m + INNER JOIN chat_metadata c ON c.chat_id_hex = m.chat_id_hex + WHERE m.user_id_hex = :userIdHex AND c.chat_type = :chatType + ORDER BY c.last_activity_epoch_ms DESC + LIMIT 1 + """ + ) + suspend fun getChatIdForMember(userIdHex: String, chatType: String): String? + @Query("UPDATE chat_members SET pointers_json = :pointersJson WHERE chat_id_hex = :chatIdHex AND user_id_hex = :userIdHex") suspend fun updatePointers(chatIdHex: String, userIdHex: String, pointersJson: String) 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 8d33e9be3..c53d3b72c 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 @@ -4,7 +4,9 @@ import com.flipcash.app.persistence.FlipcashDatabase import com.flipcash.app.persistence.sources.mapper.chat.ChatEntityMapper import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessagePointer +import com.getcode.opencode.model.core.ID import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.map @@ -30,6 +32,14 @@ class ChatMemberDataSource @Inject constructor( .mapValues { (_, members) -> members.map { mapper.toMember(it) } } } ?: emptyFlow() + /** Resolves the [chatType] DM chat id that [userId] is a member of, or null if none is cached. */ + suspend fun getChatIdForUser(userId: ID, chatType: ChatType): ChatId? { + val hex = db?.chatMemberDao() + ?.getChatIdForMember(mapper.userIdHex(userId), chatType.name) + ?: return null + return mapper.chatIdFromHex(hex) + } + suspend fun getMembersForChat(chatId: ChatId): List = getMembersForChat(mapper.chatIdHex(chatId)) From c9b755d9cfceb3124a484c8640a0fc6f26c223ea Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 23 Jul 2026 10:54:26 -0400 Subject: [PATCH 3/3] perf(chat): fetch contact and tip DM feeds concurrently Run the two getDmChatFeed calls in parallel via coroutineScope/async so combined-feed latency is max(contact, tip) instead of their sum. Failure semantics are unchanged: a contact-feed failure is fatal, a tip-feed failure is tolerated. Co-Authored-By: Claude Opus 4.8 --- .../chat/internal/delegates/FeedSyncDelegate.kt | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) 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 47d806de4..f5d6604e7 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 @@ -19,7 +19,9 @@ import com.getcode.utils.TraceType import com.getcode.utils.trace import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn @@ -167,11 +169,15 @@ class FeedSyncDelegate @Inject constructor( * 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())) + internal suspend fun fetchCombinedFeed(): Result> = coroutineScope { + // Fetch both feeds concurrently — total time is the slower of the two, not their sum. + val contactDeferred = async { chatController.getDmChatFeed(ChatType.CONTACT_DM) } + val tipDeferred = async { chatController.getDmChatFeed(ChatType.TIP_DM) } + + // Contact feed is required (its failure fails the whole sync); a TIP_DM failure is tolerated. + val contact = contactDeferred.await().getOrElse { return@coroutineScope Result.failure(it) } + val tip = tipDeferred.await().getOrNull() + Result.success(contact.chats + (tip?.chats ?: emptyList())) } private suspend fun performFeedSync() {