From ad0dc63be21a1f39229674f9ac22d6a0f9fcd800 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 15 Sep 2026 22:27:36 -0400 Subject: [PATCH 01/19] feat(chat): observe a single chat's row --- .../app/persistence/dao/ChatMetadataDao.kt | 14 ++++++++++ .../persistence/dao/ChatMetadataDaoTest.kt | 26 +++++++++++++++++++ .../sources/ChatMetadataDataSource.kt | 4 +++ 3 files changed, 44 insertions(+) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt index e8e2cc3278..683078121d 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt @@ -54,6 +54,20 @@ interface ChatMetadataDao { @Query("SELECT * FROM chat_metadata WHERE chat_id_hex = :chatIdHex") suspend fun getById(chatIdHex: String): ChatMetadataEntity? + /** + * One chat's row, re-emitted whenever it changes. + * + * The chrome and the access gate read title, picture, member count, rules and membership off + * this row, and every one of them moves under the user — a roster event changes the count, a + * join flips membership. Room re-runs the query on any write to `chat_metadata`, so a screen + * observing this needs nothing that polls. + * + * Emits `null` for a chat this device has not stored, which is the state a chat opened from a + * link sits in until its sync lands. + */ + @Query("SELECT * FROM chat_metadata WHERE chat_id_hex = :chatIdHex") + fun observeById(chatIdHex: String): Flow + @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertIfAbsent(entity: ChatMetadataEntity): Long diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt index 1545ad1919..120bdf7559 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.paging.PagingSource import androidx.room.Room import androidx.test.core.app.ApplicationProvider +import app.cash.turbine.test import com.flipcash.app.persistence.FlipcashDatabase import com.flipcash.app.persistence.converters.ChatRulesSerialized import com.flipcash.app.persistence.entities.ChatMetadataEntity @@ -15,6 +16,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import kotlin.test.assertEquals +import kotlin.test.assertNull /** * Covers what a feed sync is allowed to overwrite. `latest_event_sequence` (the applied @@ -280,6 +282,30 @@ class ChatMetadataDaoTest { assertEquals(null, dao.getRosterVersion(OTHER_HEX)) } + @Test + fun `observeById re-emits when the row changes`() = runTest { + dao.upsert(entity(title = "Flipcash Staff", memberCount = 2, rosterVersion = 1)) + + dao.observeById(CHAT_HEX).test { + assertEquals(2L, awaitItem()?.memberCount) + + // Through `updateRosterIfNewer`, not `upsert`: the roster columns are versioned and + // an upsert deliberately leaves them alone, so this is the write a roster event makes. + dao.updateRosterIfNewer(CHAT_HEX, memberCount = 3, rosterVersion = 2) + assertEquals(3L, awaitItem()?.memberCount) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `observeById emits null for a chat this device has never stored`() = runTest { + dao.observeById(CHAT_HEX).test { + assertNull(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + private companion object { const val CHAT_HEX = "aabbccdd" const val OTHER_HEX = "eeff0011" diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt index e6b3f57b41..262b53de12 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt @@ -26,6 +26,10 @@ class ChatMetadataDataSource @Inject constructor( fun observeAll(): Flow> = db?.chatMetadataDao()?.observeAll() ?: emptyFlow() + /** One chat's row by [chatId], re-emitted on change; `null` while it is not stored. */ + fun observeById(chatId: ChatId): Flow = + db?.chatMetadataDao()?.observeById(mapper.chatIdHex(chatId)) ?: emptyFlow() + /** * The merged feed of [chatTypes] as a Paging source. * From 71c7edec7fc935dde09b562321d90b296f0acc7e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 15 Sep 2026 22:29:34 -0400 Subject: [PATCH 02/19] feat(chat): observe a chat's metadata and membership together --- .../flipcash/shared/chat/ChatCoordinator.kt | 10 ++ .../flipcash/shared/chat/ChatMembership.kt | 16 +++ .../internal/delegates/MessagingDelegate.kt | 16 +++ .../chat/MessagingMetadataObservationTest.kt | 124 ++++++++++++++++++ 4 files changed, 166 insertions(+) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatMembership.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMetadataObservationTest.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 11b6d5abcd..f3f4730c82 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 @@ -169,6 +169,16 @@ interface MessagingOperations { /** Observes the member list for [chatId]. */ fun observeMembers(chatId: ChatId): Flow> + /** + * One chat's metadata and this device's membership of it, re-emitted on change, or `null` + * while the chat is not stored locally. + * + * Rebuilt per emission the way the feed builds a summary: the row supplies title, picture, + * roster summary and rules, the member table supplies the roster subset, and the message table + * supplies the last message. + */ + fun observeMetadata(chatId: ChatId): Flow + /** Observes the other member's read pointer in [chatId] (for read receipts). */ fun observeOtherReadPointer(chatId: ChatId): Flow diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatMembership.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatMembership.kt new file mode 100644 index 0000000000..2df27aa7b7 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatMembership.kt @@ -0,0 +1,16 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatMetadata + +/** + * A chat's metadata together with whether this device is a member of it. + * + * [ChatMetadata] is server truth about the chat; membership is a fact about the viewer, stored on + * the same row and written by a join, a leave or a feed sync. The access gate needs both to decide + * what it renders, and a caller that had to ask twice could observe them a frame apart and blur a + * chat it had just joined. + */ +data class ChatMembership( + val metadata: ChatMetadata, + val isMember: Boolean, +) 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 779d01cb00..b7a554061f 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 @@ -26,6 +26,7 @@ import com.flipcash.services.models.chat.TypingState import com.flipcash.services.models.DeleteMessageError import com.flipcash.services.models.EditMessageError import com.flipcash.shared.chat.ChatHydrationState +import com.flipcash.shared.chat.ChatMembership import com.flipcash.shared.chat.MessagingOperations import com.flipcash.shared.chat.PendingMutation import com.flipcash.shared.chat.internal.ChatStateHolder @@ -37,6 +38,7 @@ import com.getcode.utils.trace import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map @@ -162,6 +164,20 @@ class MessagingDelegate @Inject constructor( return memberDataSource.observeMembers(chatId) } + override fun observeMetadata(chatId: ChatId): Flow = + combine( + metadataDataSource.observeById(chatId), + memberDataSource.observeMembers(chatId), + ) { entity, members -> + entity ?: return@combine null + val lastMessage = entity.lastMessageId + ?.let { messageDataSource.getLatestVisible(entity.chatIdHex) } + ChatMembership( + metadata = metadataDataSource.toMetadata(entity, members, lastMessage), + isMember = entity.isMember, + ) + }.distinctUntilChanged() + override fun observeOtherReadPointer(chatId: ChatId): Flow { val selfId = userManager.accountId return memberDataSource.observeMembers(chatId) diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMetadataObservationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMetadataObservationTest.kt new file mode 100644 index 0000000000..71004deebc --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMetadataObservationTest.kt @@ -0,0 +1,124 @@ +package com.flipcash.shared.chat + +import app.cash.turbine.test +import com.flipcash.app.core.dispatchers.TestDispatchers +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.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.RosterSummary +import com.flipcash.shared.chat.internal.delegates.MessagingDelegate +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * `observeMetadata` is the one read the chrome and the access gate share. It carries membership + * alongside the metadata because [ChatMetadata] has no place for it — membership is a column on + * the row, and the gate is the first thing that needs to ask. + */ +class MessagingMetadataObservationTest { + + private val scheduler = TestCoroutineScheduler() + private val dispatchers = TestDispatchers(scheduler) + + private val chatId = ChatId(ByteArray(32) { 7 }.toList()) + private val chatIdHex = "07".repeat(32) + + private fun entity(memberCount: Long, isMember: Boolean) = ChatMetadataEntity( + chatIdHex = chatIdHex, + chatType = ChatType.GROUP.name, + lastActivityEpochMs = 1_000, + lastMessageId = null, + title = "Flipcash Staff", + memberCount = memberCount, + rosterVersion = 1, + isMember = isMember, + ) + + private fun metadata(memberCount: Long) = ChatMetadata( + chatId = chatId, + type = ChatType.GROUP, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochMilliseconds(1_000), + title = "Flipcash Staff", + rosterSummary = RosterSummary(memberCount = memberCount, version = 1), + ) + + @Test + fun `observeMetadata carries the row's membership and re-emits on change`() = + runTest(dispatchers.dispatcher) { + val rows = MutableStateFlow(entity(memberCount = 2, isMember = false)) + + val metadataDataSource = mockk(relaxed = true) { + every { observeById(chatId) } returns rows + every { toMetadata(entity(2, false), any(), any()) } returns metadata(2) + every { toMetadata(entity(3, true), any(), any()) } returns metadata(3) + } + val memberDataSource = mockk(relaxed = true) { + every { observeMembers(chatId) } returns flowOf(emptyList()) + } + val messageDataSource = mockk(relaxed = true) + + val subject = delegate(metadataDataSource, memberDataSource, messageDataSource) + + subject.observeMetadata(chatId).test { + val first = awaitItem()!! + assertEquals(2L, first.metadata.rosterSummary.memberCount) + assertFalse(first.isMember) + + rows.value = entity(memberCount = 3, isMember = true) + val second = awaitItem()!! + assertEquals(3L, second.metadata.rosterSummary.memberCount) + assertTrue(second.isMember) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `observeMetadata emits null while the chat is not stored`() = + runTest(dispatchers.dispatcher) { + val metadataDataSource = mockk(relaxed = true) { + every { observeById(chatId) } returns flowOf(null) + } + val memberDataSource = mockk(relaxed = true) { + every { observeMembers(chatId) } returns flowOf(emptyList()) + } + + val subject = delegate(metadataDataSource, memberDataSource, mockk(relaxed = true)) + + subject.observeMetadata(chatId).test { + assertEquals(null, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + private fun delegate( + metadataDataSource: ChatMetadataDataSource, + memberDataSource: ChatMemberDataSource, + messageDataSource: ChatMessageDataSource, + ) = MessagingDelegate( + chatController = mockk(relaxed = true), + messagingController = mockk(relaxed = true), + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + notificationManager = mockk(relaxed = true), + userManager = mockk(relaxed = true), + stateHolder = mockk(relaxed = true), + analytics = mockk(relaxed = true), + ) +} From 7c67213cbc3ae92f2bf568896863f3564e3128a4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 15 Sep 2026 22:35:26 -0400 Subject: [PATCH 03/19] feat(chat): resolve senders the roster subset omits --- .../flipcash/shared/chat/ChatCoordinator.kt | 15 +++ .../shared/chat/internal/SenderResolver.kt | 91 +++++++++++++++ .../internal/delegates/MessagingDelegate.kt | 8 ++ .../chat/ChatCoordinatorEagerBalanceTest.kt | 1 + .../shared/chat/ChatCoordinatorEventsTest.kt | 1 + .../chat/ChatCoordinatorTeardownTest.kt | 1 + .../shared/chat/ChatHistoryHydrationTest.kt | 1 + .../shared/chat/GroupChatRoutingTest.kt | 1 + .../shared/chat/MessagingLoadCursorTest.kt | 1 + .../chat/MessagingMetadataObservationTest.kt | 1 + .../shared/chat/MessagingMutationTest.kt | 1 + .../shared/chat/MessagingPushedMessageTest.kt | 1 + .../shared/chat/ReceivedCounterTest.kt | 1 + .../flipcash/shared/chat/ReceivedEventTest.kt | 1 + .../shared/chat/SenderResolverTest.kt | 110 ++++++++++++++++++ 15 files changed, 235 insertions(+) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/SenderResolver.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/SenderResolverTest.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 f3f4730c82..043ba6fd0a 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 @@ -5,6 +5,7 @@ package com.flipcash.shared.chat import androidx.paging.ExperimentalPagingApi import androidx.paging.PagingData import com.flipcash.app.core.contacts.DeviceContact +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 @@ -179,6 +180,20 @@ interface MessagingOperations { */ fun observeMetadata(chatId: ChatId): Flow + /** + * Every profile this device holds, keyed by user-id hex. + * + * The group transcript indexes into this by a bubble's `senderId` to draw a name and avatar. + * A DM never reads it: its only other participant is already the screen's subject. + */ + fun observeSenderProfiles(): Flow> + + /** + * Asks for [userId]'s profile if nothing has asked already, for a sender the roster subset + * does not cover. Returns immediately; the answer arrives through [observeSenderProfiles]. + */ + fun requestSenderProfile(userId: ID) + /** Observes the other member's read pointer in [chatId] (for read receipts). */ fun observeOtherReadPointer(chatId: ChatId): Flow diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/SenderResolver.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/SenderResolver.kt new file mode 100644 index 0000000000..105d34ec3e --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/SenderResolver.kt @@ -0,0 +1,91 @@ +package com.flipcash.shared.chat.internal + +import com.flipcash.app.persistence.sources.UserProfileDataSource +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.UserProfile +import com.getcode.opencode.model.core.ID +import com.getcode.utils.TraceType +import com.getcode.utils.hexEncodedString +import com.getcode.utils.trace +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Resolves the profiles of message senders the roster does not cover. + * + * `ChatMetadata.members` is a subset for a large group, so a transcript bubble can carry a + * `senderId` no local table has a name or picture for. This fetches that profile once and writes + * it to `user_profiles` — the table every other avatar in the app already reads — rather than to + * `chat_members`, because the fact is about the user, not about their membership of this chat. + * `ChatMemberWithProfile.profile` is already nullable, so nothing needs a migration. + * + * [request] never blocks and never returns the answer. A bubble whose fetch is outstanding renders + * a neutral avatar with no name line; when the write lands, [profiles] re-emits and the bubble + * fills in. That is why there is no suspend `resolve(userId)` here: the transcript is a Paging + * stream, and a per-item suspend call would stall page composition on the network. + * + * Owns its own scope because [com.flipcash.shared.chat.internal.delegates.MessagingDelegate] — its + * only caller — has no `initialize(scope)` and giving it one would churn `RealChatCoordinator` and + * every construction of it in tests. + */ +@Singleton +class SenderResolver @Inject constructor( + private val profileController: ProfileController, + private val userProfileDataSource: UserProfileDataSource, + private val dispatchers: DispatcherProvider, +) { + private val scope = CoroutineScope(dispatchers.IO + SupervisorJob()) + + /** + * User-id hexes with a fetch in flight. Marked *before* the launch, so two requests arriving + * in the same frame collapse on the calling thread instead of racing to the network. + */ + private val inFlight = mutableSetOf() + + /** Every profile this device holds, keyed by user-id hex. The transcript indexes into it. */ + val profiles: Flow> = userProfileDataSource.observeProfiles() + + /** + * Asks for [userId]'s profile if nothing has asked already. Returns immediately. + * + * A failure releases the id rather than caching the miss: the common cause is being offline, + * and a permanently poisoned id would leave the sender nameless for the life of the process. + */ + fun request(userId: ID) { + val hex = userId.hexEncodedString() + synchronized(inFlight) { + if (!inFlight.add(hex)) return + } + scope.launch { + profileController.getProfileForUser(userId) + .onSuccess { profile -> + userProfileDataSource.store(userId, profile) + // Kept marked on success: the profile is in `user_profiles` now, so the + // transcript reads it from [profiles] and never asks again. + } + .onFailure { error -> + synchronized(inFlight) { inFlight.remove(hex) } + trace( + tag = TAG, + message = "failed to resolve sender $hex", + error = error, + type = TraceType.Error, + ) + } + } + } + + /** Drops what this process has asked for, so a re-login re-resolves against the new account. */ + fun clear() { + synchronized(inFlight) { inFlight.clear() } + } + + private companion object { + const val TAG = "SenderResolver" + } +} 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 b7a554061f..6680931158 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 @@ -25,7 +25,9 @@ import com.flipcash.services.models.chat.PointerType import com.flipcash.services.models.chat.TypingState import com.flipcash.services.models.DeleteMessageError import com.flipcash.services.models.EditMessageError +import com.flipcash.services.models.UserProfile import com.flipcash.shared.chat.ChatHydrationState +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 @@ -74,6 +76,7 @@ class MessagingDelegate @Inject constructor( private val userManager: UserManager, private val stateHolder: ChatStateHolder, private val analytics: FlipcashAnalyticsService, + private val senderResolver: SenderResolver, ) : MessagingOperations { /** @@ -178,6 +181,10 @@ class MessagingDelegate @Inject constructor( ) }.distinctUntilChanged() + override fun observeSenderProfiles(): Flow> = senderResolver.profiles + + override fun requestSenderProfile(userId: ID) = senderResolver.request(userId) + override fun observeOtherReadPointer(chatId: ChatId): Flow { val selfId = userManager.accountId return memberDataSource.observeMembers(chatId) @@ -473,6 +480,7 @@ class MessagingDelegate @Inject constructor( metadataDataSource.clear() messageDataSource.clear() memberDataSource.clear() + senderResolver.clear() } // endregion 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 c029b1621c..9fbbfd5fbd 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 @@ -113,6 +113,7 @@ class ChatCoordinatorEagerBalanceTest { userManager = userManager, stateHolder = stateHolder, analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), ) val dmChatResolverDelegate = DmChatResolverDelegate( 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 82846e894b..f487255958 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 @@ -116,6 +116,7 @@ class ChatCoordinatorEventsTest { userManager = userManager, stateHolder = stateHolder, analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), ) val dmChatResolverDelegate = DmChatResolverDelegate( diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt index 61d109e430..ce2b5afec2 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt @@ -107,6 +107,7 @@ class ChatCoordinatorTeardownTest { userManager = userManager, stateHolder = stateHolder, analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), ), stateHolder = stateHolder, userManager = userManager, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt index f5c469108a..d981a2459f 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt @@ -139,6 +139,7 @@ class ChatHistoryHydrationTest { userManager = userManager, stateHolder = stateHolder, analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), ), stateHolder = stateHolder, userManager = userManager, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupChatRoutingTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupChatRoutingTest.kt index 4fa156c2c7..b6804d3c32 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupChatRoutingTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupChatRoutingTest.kt @@ -118,6 +118,7 @@ class GroupChatRoutingTest { userManager = userManager, stateHolder = stateHolder, analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), ), groupFeedDelegate = groupFeedDelegate, stateHolder = stateHolder, diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingLoadCursorTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingLoadCursorTest.kt index a5473ab98f..28066303a5 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingLoadCursorTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingLoadCursorTest.kt @@ -44,6 +44,7 @@ class MessagingLoadCursorTest { userManager = mockk(relaxed = true), stateHolder = mockk(relaxed = true), analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), ) @Test diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMetadataObservationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMetadataObservationTest.kt index 71004deebc..6244ae7225 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMetadataObservationTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMetadataObservationTest.kt @@ -120,5 +120,6 @@ class MessagingMetadataObservationTest { userManager = mockk(relaxed = true), stateHolder = mockk(relaxed = true), analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), ) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMutationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMutationTest.kt index 0c3a6a775e..5ae5ab56f9 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMutationTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingMutationTest.kt @@ -70,6 +70,7 @@ class MessagingMutationTest { userManager = userManager, stateHolder = mockk(relaxed = true), analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), ) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushedMessageTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushedMessageTest.kt index 9eb95bb982..252f9661d8 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushedMessageTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushedMessageTest.kt @@ -49,6 +49,7 @@ class MessagingPushedMessageTest { userManager = mockk(relaxed = true), stateHolder = mockk(relaxed = true), analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), ) @Test diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt index a349a99aad..6770cd70ad 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt @@ -134,6 +134,7 @@ class ReceivedCounterTest { userManager = userManager, stateHolder = stateHolder, analytics = analytics, + senderResolver = mockk(relaxed = true), ) val dmChatResolverDelegate = DmChatResolverDelegate( diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt index 1b75fda32f..ebdd09b2f7 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt @@ -137,6 +137,7 @@ class ReceivedEventTest { userManager = userManager, stateHolder = stateHolder, analytics = analytics, + senderResolver = mockk(relaxed = true), ) val dmChatResolverDelegate = DmChatResolverDelegate( diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/SenderResolverTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/SenderResolverTest.kt new file mode 100644 index 0000000000..9423f75a02 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/SenderResolverTest.kt @@ -0,0 +1,110 @@ +package com.flipcash.shared.chat + +import app.cash.turbine.test +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.persistence.sources.UserProfileDataSource +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.UserProfile +import com.flipcash.shared.chat.internal.SenderResolver +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals + +/** + * `GetProfile` is single-user, so every distinct unknown sender in a group costs one call. The + * guarantee this test exists for is that it costs no more than one: the transcript asks for a + * sender on every recomposition that sees a bubble from them, and an unguarded resolver would + * fan that out into a call per frame. + * + * The dedupe marks a user id as in-flight *before* launching the fetch, so the second request + * is dropped on the calling thread. That makes the behaviour deterministic under + * `StandardTestDispatcher` rather than dependent on when the coroutine happens to be scheduled. + */ +class SenderResolverTest { + + private val scheduler = TestCoroutineScheduler() + private val dispatchers = TestDispatchers(scheduler) + + private val userId = ByteArray(16) { 3 }.toList() + private val userIdHex = "03".repeat(16) + private val profile = UserProfile.Empty.copy(displayName = "Ada", userId = userId) + + private val profileController = mockk() + private val userProfileDataSource = mockk(relaxed = true) { + every { observeProfiles() } returns MutableStateFlow(emptyMap()) + } + + private fun subject() = SenderResolver( + profileController = profileController, + userProfileDataSource = userProfileDataSource, + dispatchers = dispatchers, + ) + + @Test + fun `a miss triggers one fetch and is written to user_profiles`() = runTest(dispatchers.dispatcher) { + coEvery { profileController.getProfileForUser(userId) } returns Result.success(profile) + val resolver = subject() + + resolver.request(userId) + advanceUntilIdle() + + coVerify(exactly = 1) { profileController.getProfileForUser(userId) } + coVerify(exactly = 1) { userProfileDataSource.store(userId, profile) } + resolver.clear() + } + + @Test + fun `concurrent requests for the same sender are deduped`() = runTest(dispatchers.dispatcher) { + coEvery { profileController.getProfileForUser(userId) } returns Result.success(profile) + val resolver = subject() + + resolver.request(userId) + resolver.request(userId) + resolver.request(userId) + advanceUntilIdle() + + coVerify(exactly = 1) { profileController.getProfileForUser(userId) } + resolver.clear() + } + + @Test + fun `a failed fetch releases the id so a later request retries`() = runTest(dispatchers.dispatcher) { + coEvery { profileController.getProfileForUser(userId) } returns + Result.failure(RuntimeException("offline")) + val resolver = subject() + + resolver.request(userId) + advanceUntilIdle() + resolver.request(userId) + advanceUntilIdle() + + coVerify(exactly = 2) { profileController.getProfileForUser(userId) } + coVerify(exactly = 0) { userProfileDataSource.store(any(), any()) } + resolver.clear() + } + + @Test + fun `resolved profiles are observed off the shared user_profiles table`() = + runTest(dispatchers.dispatcher) { + val table = MutableStateFlow>(emptyMap()) + every { userProfileDataSource.observeProfiles() } returns table + val resolver = subject() + + resolver.profiles.test { + assertEquals(emptyMap(), awaitItem()) + + table.value = mapOf(userIdHex to profile) + assertEquals("Ada", awaitItem()[userIdHex]?.displayName) + + cancelAndIgnoreRemainingEvents() + } + resolver.clear() + } +} From ac0632b2258cec5b70793807fe0181b1c53ca130 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 15 Sep 2026 22:38:45 -0400 Subject: [PATCH 04/19] feat(chat): render the messenger from a chat subject --- .../app/messenger/internal/ChatSubject.kt | 88 +++++++++++++++++ .../app/messenger/internal/ChatViewModel.kt | 62 ++++++++++-- .../internal/ChatIdentityScreenshotTest.kt | 5 +- .../app/messenger/internal/ChatSubjectTest.kt | 98 +++++++++++++++++++ 4 files changed, 244 insertions(+), 9 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSubjectTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt new file mode 100644 index 0000000000..cb51d55a0a --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt @@ -0,0 +1,88 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatRules +import com.flipcash.services.models.chat.MediaItem + +/** + * What the messenger screen is a conversation *with*. + * + * [ChatParticipant] answers this for a DM and is unchanged — [Contact] and [TipUser] wrap it + * rather than restating it, so DM rendering keeps reading the same fields off the same type. What + * the subject adds is a [Group] arm and, with it, exhaustiveness: the title bar, the info card and + * the profile route each become a `when` over three arms, so a fourth chat type is a compile error + * at every site that has to decide something rather than a blank render at one of them. + * + * Deliberately not `Parcelable`. Nothing bundles a subject — + * [com.flipcash.app.messenger.internal.screens.ChatStep.Profile] takes a [ChatParticipant], which + * is why [asParticipant] exists — and parcelling one would mean parcelling [ChatRules]. + */ +sealed interface ChatSubject { + + /** What to call this conversation. Never null; a nameless subject reads as empty, not absent. */ + val title: String + + /** + * The line under the title, or `null` when there is none. + * + * [Group] answers `null` even though it has one to show: "3 people" is a plural resource and + * resolving it needs a `Context` this type has no business holding. The title bar renders it + * from [Group.memberCount] instead. + */ + val subtitle: String? + + /** Whether tapping the title or the info card's chevron opens a profile. */ + val canViewProfile: Boolean + + /** + * The [ChatParticipant] behind this subject, or `null` for a [Group]. + * + * The bridge to code that predates the subject: the profile route, the tip recipient flow and + * the payment paths all still speak `ChatParticipant`, and none of them can be reached from a + * group. + */ + fun asParticipant(): ChatParticipant? + + /** + * A `CONTACT_DM`. Carried, not extended: contact DMs are end-of-life, and this arm exists so + * their phone line and add-to-contacts pill keep rendering exactly as they did. + */ + data class Contact(val participant: ChatParticipant.Contact) : ChatSubject { + override val title: String get() = participant.name.orEmpty() + override val subtitle: String? get() = null + override val canViewProfile: Boolean get() = false + override fun asParticipant(): ChatParticipant = participant + } + + /** A `TIP_DM`. The only arm with a profile to open — blocking is reached through it. */ + data class TipUser(val participant: ChatParticipant.TipUser) : ChatSubject { + override val title: String get() = participant.name.orEmpty() + // `handle` already carries the leading `@` (see `String.asHandle`), so this is the raw + // value rather than one this type prefixes again. + override val subtitle: String? get() = participant.handle + override val canViewProfile: Boolean get() = true + override fun asParticipant(): ChatParticipant = participant + } + + /** + * A `GROUP`. Everything here comes off the chat's own row rather than off a counterparty, + * because a group has none. + * + * [isMember] rides along because the access gate reads it in the same breath as [rules], and + * splitting them would let the screen blur a chat it had just joined for one frame. + */ + data class Group( + val chatId: ChatId, + val groupTitle: String?, + val picture: MediaItem?, + val memberCount: Long, + val rules: ChatRules?, + val isMember: Boolean, + ) : ChatSubject { + override val title: String get() = groupTitle.orEmpty() + override val subtitle: String? get() = null + override val canViewProfile: Boolean get() = false + override fun asParticipant(): ChatParticipant? = null + } +} 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 7ae8a5fb54..ebbd3f3cc4 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 @@ -48,6 +48,7 @@ import com.flipcash.shared.amountentry.AmountEntryLabel import com.flipcash.shared.amountentry.AmountEntryStyle import com.flipcash.shared.chat.ActiveTypist import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatMembership import com.flipcash.shared.payments.ContactPaymentDelegate import com.flipcash.shared.payments.TipPaymentDelegate import com.getcode.opencode.model.core.ID @@ -137,7 +138,7 @@ internal class ChatViewModel @Inject constructor( data class State( val separatorConfig: SeparatorConfig = SeparatorConfig.Continuous(), val chatId: ChatId? = null, - val participant: ChatParticipant? = null, + val subject: ChatSubject? = null, // The kind of DM this conversation is, resolved from the fast local contact lookup ahead of // the participant's server profile (which resolves over the network for tip DMs). Starts // UNKNOWN and settles to CONTACT_DM / TIP_DM as soon as the chat opens; the send button and @@ -219,9 +220,20 @@ internal class ChatViewModel @Inject constructor( */ val messagePolicy: MessagePolicy = MessagePolicy.Default, ) { - // Opening the participant's profile (the entry point to blocking) is only available for tip DMs. + /** + * The DM counterparty, or `null` for a group. + * + * Kept as a derived property so the paths that predate [ChatSubject] — the quote accent, + * the tip recipient flow and the payment branches — keep reading what they always read. + * None of them is reachable from a group, which is exactly what a `null` says here. + */ + val participant: ChatParticipant? + get() = subject?.asParticipant() + + // Opening the participant's profile (the entry point to blocking) is the tip arm's answer + // alone. Asking the subject rather than comparing chat types means a new arm has to say. val canViewProfile: Boolean - get() = chatType == ChatType.TIP_DM + get() = subject?.canViewProfile == true /** What the selection bar may offer, straight from what the transcript already resolved. */ val selectionCapabilities: Set @@ -246,6 +258,9 @@ internal class ChatViewModel @Inject constructor( data class OnContactFound(val contact: DeviceContact): Event data class OnTipUserResolved(val userId: ID, val profile: UserProfile): Event data object OnTipDmDetected : Event + + /** The chat this screen is showing turned out to be a group, with this metadata. */ + data class OnGroupResolved(val membership: ChatMembership) : Event data class OnCurrencySymbolUpdated(val symbol: String): Event data class OnChatInitFeeUpdated(val formatted: String?) : Event data object RefreshContact : Event @@ -696,6 +711,18 @@ internal class ChatViewModel @Inject constructor( .onEach { member -> dispatchEvent(Event.OnTipUserResolved(member.userId, member.userProfile)) } .launchIn(viewModelScope) + // A group's identity is its own row, not a counterparty. Observed rather than read once + // because every field the chrome shows moves under the user: a roster event changes the + // member count, a join flips membership, a rules change moves the gate. + stateFlow.mapNotNull { it.chatId } + .distinctUntilChanged() + .flatMapLatest { chatCoordinator.observeMetadata(it) } + .filterNotNull() + .filter { it.metadata.type == ChatType.GROUP } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.OnGroupResolved(it)) } + .launchIn(viewModelScope) + // Observe member identity — if the other member loses identity (e.g. unlinked // their phone), mark the chat as read-only. Gated by chat type through the same rule the // feed filters on, so a tip DM — addressed by user id, named by handle — is never @@ -1290,7 +1317,7 @@ internal class ChatViewModel @Inject constructor( when (val id = event.identifier) { is ChatIdentifier.ByContact -> state.copy( - participant = ChatParticipant.Contact(id.contact), + subject = ChatSubject.Contact(ChatParticipant.Contact(id.contact)), chatType = ChatType.CONTACT_DM, ) is ChatIdentifier.ByChatId -> state @@ -1300,7 +1327,9 @@ internal class ChatViewModel @Inject constructor( // profile to observe. is ChatIdentifier.ByUser -> state.copy( - participant = ChatParticipant.TipUser(id.userId, id.profile), + subject = ChatSubject.TipUser( + ChatParticipant.TipUser(id.userId, id.profile) + ), chatType = ChatType.TIP_DM, resolveState = ResolveState.Resolved, ) @@ -1308,19 +1337,36 @@ internal class ChatViewModel @Inject constructor( } is Event.OnContactFound -> { state -> state.copy( - participant = ChatParticipant.Contact(event.contact), + subject = ChatSubject.Contact(ChatParticipant.Contact(event.contact)), chatType = ChatType.CONTACT_DM, ) } Event.OnTipDmDetected -> { state -> state.copy(chatType = ChatType.TIP_DM) } + is Event.OnGroupResolved -> { state -> + val metadata = event.membership.metadata + state.copy( + subject = ChatSubject.Group( + chatId = metadata.chatId, + groupTitle = metadata.title, + picture = metadata.picture, + memberCount = metadata.rosterSummary.memberCount, + rules = metadata.rules, + isMember = event.membership.isMember, + ), + chatType = ChatType.GROUP, + resolveState = ResolveState.Resolved, + ) + } is Event.OnTipUserResolved -> { state -> // A device contact, once matched, wins over the server profile (it carries the // phone number and the user's own naming). Otherwise this is a tip DM: adopt the // profile identity and mark the recipient resolved so the send can proceed (the // tip user is known to exist; the tip send resolves their address at send time). - if (state.participant is ChatParticipant.Contact) state + if (state.subject is ChatSubject.Contact) state else state.copy( - participant = ChatParticipant.TipUser(event.userId, event.profile), + subject = ChatSubject.TipUser( + ChatParticipant.TipUser(event.userId, event.profile) + ), chatType = ChatType.TIP_DM, resolveState = ResolveState.Resolved, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt index 561ae58212..3495847204 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt @@ -108,7 +108,10 @@ class ChatIdentityScreenshotTest { ChatTopBar( navigator = navigator, state = ChatViewModel.State( - participant = participant, + subject = when (participant) { + is ChatParticipant.TipUser -> ChatSubject.TipUser(participant) + is ChatParticipant.Contact -> ChatSubject.Contact(participant) + }, chatType = if (participant is ChatParticipant.TipUser) { ChatType.TIP_DM } else { diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSubjectTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSubjectTest.kt new file mode 100644 index 0000000000..119d272211 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSubjectTest.kt @@ -0,0 +1,98 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.core.contacts.DeviceContact +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules +import com.getcode.opencode.model.financial.Fiat +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The subject exists so that identity questions are exhaustive `when`s rather than chat-type + * comparisons. These assert the three answers every renderer asks for, arm by arm, and that the + * legacy contact arm still hands back the `ChatParticipant` the profile route needs. + */ +class ChatSubjectTest { + + private val contact = DeviceContact( + e164 = "+15551234567", + androidContactId = 1L, + displayName = "Ada Lovelace", + photoUri = null, + displayNumber = "(555) 123-4567", + ) + private val profile = UserProfile.Empty.copy(displayName = "Grace", username = "grace") + private val userId = ByteArray(16) { 1 }.toList() + private val chatId = ChatId(ByteArray(32) { 9 }.toList()) + + @Test + fun `a contact subject titles by the device contact and stays a participant`() { + val subject = ChatSubject.Contact(ChatParticipant.Contact(contact)) + + assertEquals("Ada Lovelace", subject.title) + assertNull(subject.subtitle) + assertEquals(ChatParticipant.Contact(contact), subject.asParticipant()) + } + + @Test + fun `a tip subject titles by the profile and subtitles by the handle`() { + val subject = ChatSubject.TipUser(ChatParticipant.TipUser(userId, profile)) + + assertEquals("Grace", subject.title) + assertEquals("@grace", subject.subtitle) + assertEquals(ChatParticipant.TipUser(userId, profile), subject.asParticipant()) + } + + @Test + fun `a group subject titles by the group and has no participant to open a profile for`() { + val subject = ChatSubject.Group( + chatId = chatId, + groupTitle = "Flipcash Staff", + picture = null, + memberCount = 3L, + rules = ChatRules( + listener = listOf( + ChatRuleRequirement.MinimumBalance(Fiat(100.0), emptyList()) + ), + speaker = emptyList(), + ), + isMember = true, + ) + + assertEquals("Flipcash Staff", subject.title) + // The member count is a plural resource, so the chrome renders it off `memberCount`. + assertNull(subject.subtitle) + assertNull(subject.asParticipant()) + assertEquals(3L, subject.memberCount) + } + + @Test + fun `a group with no server title falls back rather than rendering blank`() { + val subject = ChatSubject.Group( + chatId = chatId, + groupTitle = null, + picture = null, + memberCount = 0L, + rules = null, + isMember = false, + ) + + assertEquals("", subject.title) + assertFalse(subject.isMember) + } + + @Test + fun `only a tip subject opens a profile`() { + assertTrue(ChatSubject.TipUser(ChatParticipant.TipUser(userId, profile)).canViewProfile) + assertFalse(ChatSubject.Contact(ChatParticipant.Contact(contact)).canViewProfile) + assertFalse( + ChatSubject.Group(chatId, "Flipcash Staff", null, 3L, null, true).canViewProfile + ) + } +} From 64556e9e21e964b9a135223ce192490b398be6d1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 15 Sep 2026 22:41:48 -0400 Subject: [PATCH 05/19] feat(chat): render the chat title bar from its subject --- .../core/src/main/res/values/strings.xml | 6 +++ .../app/messenger/internal/ChatSubject.kt | 13 ++++++ .../screens/components/ChatSubjectAvatar.kt | 45 +++++++++++++++++++ .../internal/screens/components/ChatTopBar.kt | 42 ++++++++++++----- .../components/ContactInfoContainer.kt | 5 ++- .../screens/components/ParticipantAvatar.kt | 33 -------------- .../screens/profile/ChatProfileScreen.kt | 7 +-- 7 files changed, 102 insertions(+), 49 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatSubjectAvatar.kt delete mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ParticipantAvatar.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 99ff557a2d..fbbd194474 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -250,6 +250,12 @@ %1$s members + + 0 people + 1 person + %1$s people + + Add Cash Select Method Amount to Add diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt index cb51d55a0a..849088e419 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt @@ -86,3 +86,16 @@ sealed interface ChatSubject { override fun asParticipant(): ChatParticipant? = null } } + +/** + * The subject for a DM counterparty. + * + * The bridge in the other direction from [ChatSubject.asParticipant]: the profile route still + * carries a [ChatParticipant] through navigation, and its header renders the same avatar the + * conversation bar does. A group has no participant to arrive here with, so `null` in is `null` out. + */ +internal fun ChatParticipant?.asSubject(): ChatSubject? = when (this) { + is ChatParticipant.Contact -> ChatSubject.Contact(this) + is ChatParticipant.TipUser -> ChatSubject.TipUser(this) + null -> null +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatSubjectAvatar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatSubjectAvatar.kt new file mode 100644 index 0000000000..cb45413c65 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatSubjectAvatar.kt @@ -0,0 +1,45 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.flipcash.app.messenger.internal.ChatSubject +import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.shared.common.ui.ContactAvatar + +/** + * The picture for whatever the screen is a conversation with. + * + * Replaces `ParticipantAvatar`: a group's picture is a + * [com.flipcash.services.models.chat.MediaItem] on the chat's own row, read through + * [BlobAccessContext.ChatProfile] rather than through a profile id, so there was no participant + * arm it could have been added to. + */ +@Composable +internal fun ChatSubjectAvatar(subject: ChatSubject?, modifier: Modifier = Modifier) { + when (subject) { + is ChatSubject.Contact -> + ContactAvatar(contact = subject.participant.contact, modifier = modifier) + + // The member's own id, not profile.userId: the server sets the id on the chat member and + // leaves it unset inside the nested profile, and without it the picture's expired download + // URL cannot be re-minted. + is ChatSubject.TipUser -> + ContactAvatar( + userProfile = subject.participant.profile, + modifier = modifier, + userId = subject.participant.userId, + ) + + is ChatSubject.Group -> + ContactAvatar( + image = subject.picture, + displayName = subject.title, + access = BlobAccessContext.ChatProfile(subject.chatId), + modifier = modifier, + ) + + // The frame before the subject resolves. The gradient-and-initials fallback renders with + // no contact, which is the same thing a DM has always shown here. + null -> ContactAvatar(contact = null, modifier = modifier) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index 8e9c787501..23688fd983 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues @@ -37,10 +38,12 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp +import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.features.messenger.R import com.flipcash.shared.chat.MessageCapability @@ -141,7 +144,7 @@ private fun ConversationTitleBar( }, title = { Row( - // Profile open is only available for tip DMs (see State.canViewProfile). + // Profile open is the tip arm's answer alone (see State.canViewProfile). modifier = Modifier .fillMaxWidth() .then( @@ -156,21 +159,38 @@ private fun ConversationTitleBar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), ) { - ParticipantAvatar( - participant = state.participant, + ChatSubjectAvatar( + subject = state.subject, modifier = Modifier .requiredSize(CodeTheme.dimens.staticGrid.x8) .clip(CircleShape), ) - Text( - modifier = Modifier.weight(1f), - // Name-or-handle: the bar is one line (node 9443:9094), and the handle is - // the only identity a name-less tip DM counterparty has. - text = state.participant?.name.orEmpty(), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, - ) + Column(modifier = Modifier.weight(1f)) { + Text( + // Name-or-handle for a DM, the group's title for a group. A DM bar stays + // one line (node 9443:9094): the handle is the only identity a name-less + // tip counterparty has, so it is the title there rather than a second row. + text = state.subject?.title.orEmpty(), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + // A group always shows its size, whether or not the viewer is in it — the + // count is what a join changes, and node 10125:19153 -> 10125:19201 is the + // same bar at two values of it. A DM has nothing to count. + val group = state.subject as? ChatSubject.Group + if (group != null) { + Text( + text = pluralStringResource( + R.plurals.subtitle_chatMemberCount, + group.memberCount.toInt(), + group.memberCount.toString(), + ), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + } } } ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt index 3a5285b3df..76be67ba2b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.tooling.preview.PreviewWrapper import com.flipcash.app.core.android.IntentUtils import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.messenger.internal.asSubject import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.features.messenger.R import com.flipcash.services.models.UserProfile @@ -67,8 +68,8 @@ internal fun ContactInfoContainer( }, horizontalAlignment = Alignment.CenterHorizontally, ) { - ParticipantAvatar( - participant = participant, + ChatSubjectAvatar( + subject = participant.asSubject(), modifier = Modifier .size(CodeTheme.dimens.staticGrid.x17) .clip(CircleShape), diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ParticipantAvatar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ParticipantAvatar.kt deleted file mode 100644 index 065e99a21f..0000000000 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ParticipantAvatar.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.flipcash.app.messenger.internal.screens.components - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import com.flipcash.app.core.chat.ChatParticipant -import com.flipcash.shared.common.ui.ContactAvatar - -/** - * Renders a [ChatParticipant]'s avatar, selecting the identity source by chat type: a device - * contact's photo/initials for a [ChatParticipant.Contact], the server profile picture for a - * [ChatParticipant.TipUser]. A null participant falls through to the unknown-contact avatar. - * - * ContactAvatar sizes the profile-picture rendition to the avatar's measured bounds, so both the - * small top-bar avatar and the large info card get an appropriately crisp image. - */ -@Composable -internal fun ParticipantAvatar( - participant: ChatParticipant?, - modifier: Modifier = Modifier, -) { - when (participant) { - is ChatParticipant.Contact -> ContactAvatar(contact = participant.contact, modifier = modifier) - // The member's own id, not profile.userId: the server sets the id on the chat member and - // leaves it unset inside the nested profile, and without it the picture's expired download - // URL cannot be re-minted. - is ChatParticipant.TipUser -> ContactAvatar( - userProfile = participant.profile, - modifier = modifier, - userId = participant.userId, - ) - null -> ContactAvatar(contact = null, modifier = modifier) - } -} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index c433de17f9..1ee7127da7 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -22,7 +22,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.menu.MenuList -import com.flipcash.app.messenger.internal.screens.components.ParticipantAvatar +import com.flipcash.app.messenger.internal.asSubject +import com.flipcash.app.messenger.internal.screens.components.ChatSubjectAvatar import com.flipcash.features.messenger.R import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.theme.CodeTheme @@ -91,8 +92,8 @@ internal fun ProfileHeader( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, ) { - ParticipantAvatar( - participant = participant, + ChatSubjectAvatar( + subject = participant.asSubject(), modifier = Modifier .size(CodeTheme.dimens.staticGrid.x17) .clip(CircleShape), From 8c1d5751e2f4b478d59d350f563db2b9003eec99 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 15 Sep 2026 22:47:38 -0400 Subject: [PATCH 06/19] feat(chat): render the chat info card from its subject --- .../core/src/main/res/values/strings.xml | 2 + .../app/messenger/internal/ChatSubject.kt | 17 ++++ .../app/messenger/internal/ChatViewModel.kt | 24 +++++ ...ontactInfoContainer.kt => ChatInfoCard.kt} | 89 ++++++++++++++++--- .../screens/components/MessageList.kt | 5 +- .../internal/ChatIdentityScreenshotTest.kt | 11 ++- .../internal/ChatRulesSummaryTest.kt | 65 ++++++++++++++ 7 files changed, 196 insertions(+), 17 deletions(-) rename apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/{ContactInfoContainer.kt => ChatInfoCard.kt} (74%) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatRulesSummaryTest.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index fbbd194474..2a6d3ae655 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -891,6 +891,8 @@ From Your Contacts Add Contact + Balance Requirement: %1$s of $%2$s + Balance Requirement: %1$s Are You Sure? Anyone you sent the link to won\'t be able to collect the cash diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt index 849088e419..19af6aeca6 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt @@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatRuleRequirement import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.MediaItem @@ -99,3 +100,19 @@ internal fun ChatParticipant?.asSubject(): ChatSubject? = when (this) { is ChatParticipant.TipUser -> ChatSubject.TipUser(this) null -> null } + +/** + * The balance requirement to show the user, or `null` when there is nothing actionable to show. + * + * Listener rules gate reading and joining, speaker rules gate sending, and speaker rules apply on + * top of listener rules — so the first thing standing between the viewer and the chat is the + * listener requirement when there is one. + * + * [ChatRuleRequirement.Staff] deliberately produces nothing: it is not a bar a user can clear by + * doing something, and rendering it as a requirement would read as an instruction. + */ +internal fun ChatRules?.balanceRequirement(): ChatRuleRequirement.MinimumBalance? { + val rules = this ?: return null + return rules.listener.filterIsInstance().firstOrNull() + ?: rules.speaker.filterIsInstance().firstOrNull() +} 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 ebbd3f3cc4..7bb9d31964 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 @@ -51,6 +51,7 @@ import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatMembership import com.flipcash.shared.payments.ContactPaymentDelegate import com.flipcash.shared.payments.TipPaymentDelegate +import com.getcode.solana.keys.Mint import com.getcode.opencode.model.core.ID import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager @@ -219,6 +220,11 @@ internal class ChatViewModel @Inject constructor( * the reducer is where the selection is set. */ val messagePolicy: MessagePolicy = MessagePolicy.Default, + /** + * The symbol of the token a group's balance requirement names — "BadBoys", not its mint. + * Null until the token cache has it, and null for any chat without such a rule. + */ + val ruleTicker: String? = null, ) { /** * The DM counterparty, or `null` for a group. @@ -261,6 +267,9 @@ internal class ChatViewModel @Inject constructor( /** The chat this screen is showing turned out to be a group, with this metadata. */ data class OnGroupResolved(val membership: ChatMembership) : Event + + /** The token cache learned the symbol behind the group's balance requirement. */ + data class OnRuleTickerResolved(val ticker: String?) : Event data class OnCurrencySymbolUpdated(val symbol: String): Event data class OnChatInitFeeUpdated(val formatted: String?) : Event data object RefreshContact : Event @@ -723,6 +732,20 @@ internal class ChatViewModel @Inject constructor( .onEach { dispatchEvent(Event.OnGroupResolved(it)) } .launchIn(viewModelScope) + // The cache starts empty and fills in, so this is observed rather than read once — a + // requirement resolved against an empty cache would render its amount with no token beside + // it and never correct itself. + combine( + stateFlow.map { (it.subject as? ChatSubject.Group)?.rules.balanceRequirement() } + .distinctUntilChanged(), + tokenCoordinator.observeTokenCache(), + ) { requirement, tokens -> + requirement?.mints?.firstOrNull()?.let { tokens[Mint(it.bytes)]?.symbol } + } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.OnRuleTickerResolved(it)) } + .launchIn(viewModelScope) + // Observe member identity — if the other member loses identity (e.g. unlinked // their phone), mark the chat as read-only. Gated by chat type through the same rule the // feed filters on, so a tip DM — addressed by user id, named by handle — is never @@ -1357,6 +1380,7 @@ internal class ChatViewModel @Inject constructor( resolveState = ResolveState.Resolved, ) } + is Event.OnRuleTickerResolved -> { state -> state.copy(ruleTicker = event.ticker) } is Event.OnTipUserResolved -> { state -> // A device contact, once matched, wins over the server profile (it carries the // phone number and the user's own naming). Otherwise this is a tip DM: adopt the diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatInfoCard.kt similarity index 74% rename from apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt rename to apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatInfoCard.kt index 76be67ba2b..93fee31276 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatInfoCard.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -33,24 +34,46 @@ import androidx.compose.ui.tooling.preview.PreviewWrapper import com.flipcash.app.core.android.IntentUtils import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.core.chat.ChatParticipant -import com.flipcash.app.messenger.internal.asSubject +import com.flipcash.app.messenger.internal.ChatSubject +import com.flipcash.app.messenger.internal.balanceRequirement import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.features.messenger.R import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules +import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.Mint import com.getcode.theme.CodeTheme import com.getcode.ui.core.addIf +/** + * The identity card at the head of a transcript. + * + * Was `ContactInfoContainer`, which opened with `participant as? ChatParticipant.Contact` and + * rendered a bare avatar and name for anything else. Driving it from [ChatSubject] makes each arm + * say what it shows: the contact arm keeps its phone line and add-to-contacts pill, the tip arm + * keeps its handle, and the group arm gets the rules line from node 10125:19201. + */ @Composable -internal fun ContactInfoContainer( - participant: ChatParticipant?, +internal fun ChatInfoCard( + subject: ChatSubject?, modifier: Modifier = Modifier, includeBorder: Boolean = true, onOpenProfile: (() -> Unit)? = null, onRefreshContact: () -> Unit = {}, + /** + * The symbol of the token a group's balance rule names, once the token cache has it. + * + * Resolved in the view model rather than read here: `observeTokenCache()` starts empty and + * fills in, so a snapshot read would render the rule with no token and never correct itself. + */ + ticker: String? = null, ) { + val participant = subject?.asParticipant() // Phone number and the add-to-contacts pill only apply to a device contact; a tip DM's // counterparty (a server profile) has neither. - val contact = (participant as? ChatParticipant.Contact)?.contact + val contact = (subject as? ChatSubject.Contact)?.participant?.contact Column( modifier = modifier .addIf(includeBorder) { @@ -69,7 +92,7 @@ internal fun ContactInfoContainer( horizontalAlignment = Alignment.CenterHorizontally, ) { ChatSubjectAvatar( - subject = participant.asSubject(), + subject = subject, modifier = Modifier .size(CodeTheme.dimens.staticGrid.x17) .clip(CircleShape), @@ -82,7 +105,7 @@ internal fun ContactInfoContainer( ) { Text( modifier = if (onOpenProfile != null) Modifier.weight(1f, fill = false) else Modifier, - text = participant?.name.orEmpty(), + text = subject?.title.orEmpty(), autoSize = TextAutoSize.StepBased( minFontSize = CodeTheme.typography.textSmall.fontSize, maxFontSize = CodeTheme.typography.textLarge.fontSize, @@ -123,6 +146,31 @@ internal fun ContactInfoContainer( ) } + // Node 10125:19201. Rendered for a member as well as a non-member: it is the chat's + // standing requirement, not a gate message, and it is the one thing the card can say + // about a group that it cannot say about a person. + val requirement = (subject as? ChatSubject.Group)?.rules.balanceRequirement() + if (requirement != null) { + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + text = if (ticker != null) { + stringResource( + R.string.label_chat_balanceRequirement, + requirement.amount.formatted(), + ticker, + ) + } else { + stringResource( + R.string.label_chat_balanceRequirement_anyToken, + requirement.amount.formatted(), + ) + }, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult() ) { onRefreshContact() } @@ -249,7 +297,7 @@ private fun TipPill( @Preview @PreviewWrapper(FlipcashThemeWrapper::class) @Composable -private fun Preview_AllStates() { +private fun Preview_ChatInfoCard() { // A saved device contact: name + number + the "From your contacts" pill. val knownContact = ChatParticipant.Contact( DeviceContact( @@ -286,16 +334,35 @@ private fun Preview_AllStates() { profile = UserProfile.Empty.copy(username = "sally_streamer"), ) + // The group arm: title, member count, and the balance rule from node 10125:19201. + val group = ChatSubject.Group( + chatId = ChatId(byteArrayOf(1)), + groupTitle = "Bad Boys", + picture = null, + memberCount = 412L, + rules = ChatRules( + listener = listOf( + ChatRuleRequirement.MinimumBalance( + amount = Fiat(100.0), + mints = listOf(Mint(List(32) { 7.toByte() })), + ), + ), + speaker = emptyList(), + ), + isMember = false, + ) + // Fixed width so every state renders at the same size regardless of name/number length. val cardWidth = Modifier.width(300.dp) Column( modifier = Modifier.padding(CodeTheme.dimens.grid.x4), verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x4), ) { - ContactInfoContainer(participant = knownContact, modifier = cardWidth) - ContactInfoContainer(participant = unknownContact, modifier = cardWidth) - ContactInfoContainer(participant = tipUser, modifier = cardWidth) - ContactInfoContainer(participant = handleOnlyUser, modifier = cardWidth) + ChatInfoCard(subject = ChatSubject.Contact(knownContact), modifier = cardWidth) + ChatInfoCard(subject = ChatSubject.Contact(unknownContact), modifier = cardWidth) + ChatInfoCard(subject = ChatSubject.TipUser(tipUser), modifier = cardWidth) + ChatInfoCard(subject = ChatSubject.TipUser(handleOnlyUser), modifier = cardWidth) + ChatInfoCard(subject = group, modifier = cardWidth, ticker = "BADBOYS") } } 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 9b46611a60..d89a8d5866 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 @@ -322,8 +322,8 @@ internal fun MessageList( .fillParentMaxWidth(), contentAlignment = Alignment.Center, ) { - ContactInfoContainer( - participant = state.participant, + ChatInfoCard( + subject = state.subject, modifier = Modifier .fillMaxWidth(0.63f), onRefreshContact = { onAction(ChatAction.RefreshContact) }, @@ -334,6 +334,7 @@ internal fun MessageList( } else { null }, + ticker = state.ruleTicker, ) } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt index 3495847204..035c8278c0 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatIdentityScreenshotTest.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.unit.dp import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.messenger.internal.screens.components.ChatTopBar -import com.flipcash.app.messenger.internal.screens.components.ContactInfoContainer +import com.flipcash.app.messenger.internal.screens.components.ChatInfoCard import com.flipcash.app.messenger.internal.screens.profile.ProfileHeader import com.flipcash.app.theme.FlipcashPreview import com.flipcash.services.models.UserProfile @@ -83,9 +83,12 @@ class ChatIdentityScreenshotTest { verticalArrangement = Arrangement.spacedBy(16.dp), ) { val cardWidth = Modifier.width(300.dp) - ContactInfoContainer(participant = knownContact, modifier = cardWidth) - ContactInfoContainer(participant = namedTipUser, modifier = cardWidth) - ContactInfoContainer(participant = handleOnlyTipUser, modifier = cardWidth) + ChatInfoCard(subject = ChatSubject.Contact(knownContact), modifier = cardWidth) + ChatInfoCard(subject = ChatSubject.TipUser(namedTipUser), modifier = cardWidth) + ChatInfoCard( + subject = ChatSubject.TipUser(handleOnlyTipUser), + modifier = cardWidth, + ) } } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatRulesSummaryTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatRulesSummaryTest.kt new file mode 100644 index 0000000000..4d46575536 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatRulesSummaryTest.kt @@ -0,0 +1,65 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules +import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.PublicKey +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The info card's rules line. Only a minimum balance has anything to say on it — staff membership + * is not something a user can go and acquire, so showing it as a requirement would be an + * instruction with no action behind it. + */ +class ChatRulesSummaryTest { + + private val mint = PublicKey(ByteArray(32) { 5 }.toList()) + + @Test + fun `a minimum balance names the amount and the ticker`() { + val rules = ChatRules( + listener = listOf(ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(mint))), + speaker = emptyList(), + ) + + assertEquals( + ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(mint)), + rules.balanceRequirement(), + ) + } + + @Test + fun `a speaker balance rule counts when there is no listener rule`() { + val rules = ChatRules( + listener = emptyList(), + speaker = listOf(ChatRuleRequirement.MinimumBalance(Fiat(50.0), emptyList())), + ) + + assertEquals(Fiat(50.0), (rules.balanceRequirement())?.amount) + } + + @Test + fun `a listener rule wins over a speaker rule`() { + val rules = ChatRules( + listener = listOf(ChatRuleRequirement.MinimumBalance(Fiat(100.0), emptyList())), + speaker = listOf(ChatRuleRequirement.MinimumBalance(Fiat(500.0), emptyList())), + ) + + assertEquals(Fiat(100.0), rules.balanceRequirement()?.amount) + } + + @Test + fun `staff-only rules have no line to render`() { + val rules = ChatRules(listener = listOf(ChatRuleRequirement.Staff), speaker = emptyList()) + + assertNull(rules.balanceRequirement()) + } + + @Test + fun `no rules at all have no line to render`() { + assertNull(ChatRules(emptyList(), emptyList()).balanceRequirement()) + assertNull((null as ChatRules?).balanceRequirement()) + } +} From e364b2ca3a148a1063fc327c653d5de35f6e6c87 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 15 Sep 2026 22:50:46 -0400 Subject: [PATCH 07/19] feat(chat): attribute group messages to who sent them --- .../app/messenger/internal/ChatViewModel.kt | 49 ++++++- .../internal/screens/components/MessageRow.kt | 134 +++++++++++++----- .../app/messenger/internal/SenderRunTest.kt | 65 +++++++++ .../shared/chat/models/ChatListItem.kt | 5 + .../shared/chat/models/SenderIdentity.kt | 18 +++ 5 files changed, 235 insertions(+), 36 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SenderRunTest.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/SenderIdentity.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 7bb9d31964..d62b095b83 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 @@ -24,6 +24,7 @@ import com.flipcash.shared.chat.withinWindows import com.flipcash.shared.chat.applying import com.flipcash.shared.chat.resolveCapabilities import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.SenderIdentity import com.flipcash.shared.chat.models.ChatQuote import com.flipcash.shared.chat.models.ChatQuoteSnippet import com.flipcash.shared.chat.models.ReceiptStatus @@ -52,6 +53,7 @@ import com.flipcash.shared.chat.ChatMembership import com.flipcash.shared.payments.ContactPaymentDelegate import com.flipcash.shared.payments.TipPaymentDelegate import com.getcode.solana.keys.Mint +import com.getcode.utils.hexEncodedString import com.getcode.opencode.model.core.ID import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager @@ -376,9 +378,33 @@ internal class ChatViewModel @Inject constructor( */ private val messagePolicy = stateFlow.map { it.messagePolicy }.distinctUntilChanged() + /** + * Null for a DM, a map for a group — the distinction the transcript needs, because a DM's + * counterparty is already named in the title bar and attributing each of their bubbles would be + * noise. `null` rather than an empty map so the difference survives: an empty map in a group is + * a real state (nothing resolved yet) and has to keep asking for profiles, where a DM must not + * ask at all. + * + * Two sources behind it, because the roster is a truncated subset — members come from it, and + * anyone it omits (a sender who left, a member past the cap) is asked for one profile at a time. + * See SenderResolver. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private val senderProfiles: Flow?> = + stateFlow.map { it.chatType == ChatType.GROUP } + .distinctUntilChanged() + .flatMapLatest { isGroup -> + if (isGroup) chatCoordinator.observeSenderProfiles() else flowOf(null) + } + @OptIn(ExperimentalCoroutinesApi::class) val messages: Flow> = - combine(messageStream, pendingMutations, messagePolicy) { pagingData, mutations, policy -> + combine( + messageStream, + pendingMutations, + messagePolicy, + senderProfiles, + ) { pagingData, mutations, policy, profiles -> pagingData.flatMap { stored -> val message = stored.applying(mutations[stored.messageId]) message.content.mapIndexed { index, content -> @@ -407,6 +433,26 @@ internal class ChatViewModel @Inject constructor( } } else null + // A message the viewer sent needs no attribution, and neither does a DM — + // `profiles` is null for one. Asking for a profile that is missing is what keeps + // a sender from showing as a blank name for the rest of the session; the + // resolver dedupes, so asking once per page is asking once. + val sender = profiles?.let { resolved -> + message.senderId?.takeIf { !message.isFromSelf }?.let { senderId -> + val profile = resolved[senderId.hexEncodedString()] + if (profile == null) { + chatCoordinator.requestSenderProfile(senderId) + null + } else { + SenderIdentity( + userId = senderId, + displayName = profile.displayName, + picture = profile.profilePicture, + ) + } + } + } + ChatListItem.ContentBubble( messageId = message.messageId, contentIndex = index, @@ -424,6 +470,7 @@ internal class ChatViewModel @Inject constructor( // each action site. capabilities = resolveCapabilities(message, policy), quote = quote, + sender = sender, ) } }.insertSeparators { before: ChatListItem.ContentBubble?, after: ChatListItem.ContentBubble? -> 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 3befb5439c..1a416ccb5c 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 @@ -9,7 +9,11 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset @@ -19,6 +23,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Reply import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -37,6 +42,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.paging.compose.LazyPagingItems import com.flipcash.app.messenger.internal.screens.ChatAnimations +import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.MessagePointer import com.flipcash.shared.chat.MessageCapability import com.flipcash.shared.chat.models.ChatAction @@ -45,6 +51,7 @@ import com.flipcash.shared.chat.models.LocalChatActionHandler import com.flipcash.shared.chat.models.ReceiptStatus import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.shared.chat.ui.ContentBubble +import com.flipcash.shared.common.ui.ContactAvatar import com.flipcash.shared.chat.ui.bubblePositionOf import com.getcode.theme.CodeTheme import com.getcode.ui.core.addIf @@ -202,47 +209,88 @@ internal fun MessageRow( if (item.receiptStatus == ReceiptStatus.SENDING) { wasSending = true } - Column( + // Bound to a local: `sender` is a property of another module's public API, so + // Kotlin will not smart-cast it to non-null inside the branches below. + val sender = item.sender + val runStart = startsSenderRun( + current = item, + older = messages.peek(index + 1), + ) + Row( modifier = Modifier.fillMaxWidth(), - horizontalAlignment = if (item.isFromSelf) Alignment.End else Alignment.Start, + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), ) { - Box(insertionModifier) { - ContentBubble( - item = item, - // The bubble's own targets go with the row's: a cash - // bubble behind the backdrop would otherwise open token - // info from under the bar. - interactive = !selecting, - // A bubble with a tap target of its own consumes the press, - // so the row's long-press never reaches it. Handing it the - // same gesture is what makes a cash bubble selectable. - onLongClick = select, - position = bubblePositionOf( - index, - item, - messages, - separatorConfig - ), - attention = attention, - ) + // The gutter is reserved on every row of a sender run, not only the labelled + // one, so the bubbles above and below line up on the same left edge instead of + // stepping in and out as the run starts. + if (sender != null) { + Box(modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x6)) { + if (runStart) { + ContactAvatar( + image = sender.picture, + displayName = sender.displayName, + access = BlobAccessContext.profile(sender.userId), + modifier = Modifier + .fillMaxSize() + .clip(CircleShape), + ) + } + } } - val showReceipt = - shouldShowReceiptLabel(index, item, messages, otherReadPointer) - AnimatedVisibility( - visible = showReceipt && effectiveStatus != null, - enter = EnterTransition.None, - exit = ChatAnimations.receiptExit, + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = if (item.isFromSelf) Alignment.End else Alignment.Start, ) { - if (effectiveStatus != null) { - ReceiptLabel( - status = effectiveStatus, - readPointer = otherReadPointer, - animateEntrance = wasSending, - onRetryFailed = if (effectiveStatus == ReceiptStatus.FAILED) { - { onAction(ChatAction.RetryMessage(item)) } - } else null, + if (sender != null && runStart) { + Text( + modifier = Modifier.padding( + start = CodeTheme.dimens.grid.x1, + bottom = CodeTheme.dimens.grid.x1, + ), + text = sender.displayName, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + Box(insertionModifier) { + ContentBubble( + item = item, + // The bubble's own targets go with the row's: a cash + // bubble behind the backdrop would otherwise open token + // info from under the bar. + interactive = !selecting, + // A bubble with a tap target of its own consumes the press, + // so the row's long-press never reaches it. Handing it the + // same gesture is what makes a cash bubble selectable. + onLongClick = select, + position = bubblePositionOf( + index, + item, + messages, + separatorConfig + ), + attention = attention, ) } + val showReceipt = + shouldShowReceiptLabel(index, item, messages, otherReadPointer) + AnimatedVisibility( + visible = showReceipt && effectiveStatus != null, + enter = EnterTransition.None, + exit = ChatAnimations.receiptExit, + ) { + if (effectiveStatus != null) { + ReceiptLabel( + status = effectiveStatus, + readPointer = otherReadPointer, + animateEntrance = wasSending, + onRetryFailed = if (effectiveStatus == ReceiptStatus.FAILED) { + { onAction(ChatAction.RetryMessage(item)) } + } else null, + ) + } + } } } } @@ -332,3 +380,19 @@ private fun bottomSpacingFor( else -> tight } } + +/** + * Whether [current] is the bubble that wears its sender's name and picture. + * + * `older` is the item drawn *above* [current] — under `reverseLayout` that is `peek(index + 1)`, + * the mirror of [bottomSpacingFor]'s `index - 1`. A run is labelled at its top, so the bubble that + * starts one is the one whose upper neighbour came from someone else. + * + * 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. + */ +internal fun startsSenderRun(current: ChatListItem.ContentBubble, older: ChatListItem?): Boolean { + val sender = current.sender ?: return false + val olderBubble = older as? ChatListItem.ContentBubble ?: return true + return olderBubble.sender?.userId != sender.userId +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SenderRunTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SenderRunTest.kt new file mode 100644 index 0000000000..4f1f5bc3d6 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SenderRunTest.kt @@ -0,0 +1,65 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.app.messenger.internal.screens.components.startsSenderRun +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.SenderIdentity +import com.flipcash.services.models.chat.MessageContent +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Which bubble in a run wears the name and the picture. + * + * The list is `reverseLayout`, so index 0 is the newest message at the bottom and index + 1 is the + * message drawn *above*. A run therefore starts at its oldest message — the topmost one — which is + * the one whose upper neighbour is someone else, a date separator, or nothing. + */ +class SenderRunTest { + + private val alice = SenderIdentity(userId = listOf(1), displayName = "Alice", picture = null) + private val bob = SenderIdentity(userId = listOf(2), displayName = "Bob", picture = null) + + private fun bubble(sender: SenderIdentity?, isFromSelf: Boolean = false) = + ChatListItem.ContentBubble( + messageId = 1L, + contentIndex = 0, + content = MessageContent.Text("hi"), + isFromSelf = isFromSelf, + timestamp = Instant.fromEpochMilliseconds(1_000), + sender = sender, + ) + + @Test + fun `a bubble with no sender never starts a run`() { + assertFalse(startsSenderRun(bubble(sender = null), older = null)) + assertFalse(startsSenderRun(bubble(sender = null, isFromSelf = true), older = bubble(alice))) + } + + @Test + fun `the oldest loaded bubble starts a run`() { + assertTrue(startsSenderRun(bubble(alice), older = null)) + } + + @Test + fun `a bubble under a different sender starts a run`() { + assertTrue(startsSenderRun(bubble(alice), older = bubble(bob))) + } + + @Test + fun `a bubble under the same sender continues the run`() { + assertFalse(startsSenderRun(bubble(alice), older = bubble(alice))) + } + + @Test + fun `a bubble under one of the viewer's own starts a run`() { + assertTrue(startsSenderRun(bubble(alice), older = bubble(sender = null, isFromSelf = true))) + } + + @Test + fun `a bubble under a date separator starts a run`() { + val separator = ChatListItem.DateSeparator(Instant.fromEpochMilliseconds(1_000)) + assertTrue(startsSenderRun(bubble(alice), older = separator)) + } +} 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 8a4eadba77..010090e869 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 @@ -43,6 +43,11 @@ sealed interface ChatListItem { * never synced still reads as a message. */ val quote: ChatQuote? = null, + /** + * Who sent this, when that is not implied. Null for the viewer's own messages and for every + * message in a DM; set only for another member's message in a group. + */ + val sender: SenderIdentity? = null, ) : ChatListItem { /** The body a Copy or an Edit acts on, or `null` for a bubble that carries no text. */ val plainText: String? diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/SenderIdentity.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/SenderIdentity.kt new file mode 100644 index 0000000000..0051d336cc --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/SenderIdentity.kt @@ -0,0 +1,18 @@ +package com.flipcash.shared.chat.models + +import com.flipcash.services.models.chat.MediaItem +import com.getcode.opencode.model.core.ID + +/** + * Who sent a message, as the transcript needs to show it. + * + * Deliberately not a `UserProfile`: the row renders a name and a picture and nothing else, and a + * profile would drag social accounts, contact methods and a DM fee through `PagingData`'s equality + * on every page. It is also nullable on [ChatListItem.ContentBubble] — a DM has one counterparty + * named in the title bar, so attributing each bubble there would be noise. + */ +data class SenderIdentity( + val userId: ID, + val displayName: String, + val picture: MediaItem?, +) From 60cc4b3b013b36154d4293630f4b8638f6d1afc1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 15 Sep 2026 22:51:56 -0400 Subject: [PATCH 08/19] feat(chat): decide what a viewer may do in a group --- .../app/messenger/internal/GroupAccess.kt | 97 +++++++++ .../app/messenger/internal/GroupAccessTest.kt | 187 ++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupAccess.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupAccessTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupAccess.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupAccess.kt new file mode 100644 index 0000000000..9c3327effe --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/GroupAccess.kt @@ -0,0 +1,97 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.TokenWithBalance +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +/** + * What this viewer may do in this group. + * + * One value for the whole screen, because the transcript's blur, the composer's replacement and the + * button's label are three views of the same answer and must never disagree. Resolved in the feature + * rather than in `shared/chat`: the chat layer stores the rules and the membership flag, and what + * they *mean* depends on a token balance that lives on the other side of that module boundary. + */ +internal sealed interface GroupAccess { + + /** The viewer is in the chat. Everything is open. */ + data object Membered : GroupAccess + + /** Not in the chat, but nothing is standing in the way of joining it. */ + data object Eligible : GroupAccess + + /** Not in the chat, and [unmet] is what is standing in the way. */ + data class Blocked(val unmet: ChatRuleRequirement) : GroupAccess + + companion object { + /** + * The gate, as a pure function of the three things that decide it. + * + * Membership wins outright. Re-checking the rules for a member would blur the transcript of + * a chat they are already in the moment their balance dipped below the bar they joined + * over — the server does not remove them for that, so neither does the client. + * + * Only *listener* rules are checked. Speaker rules gate sending inside a chat the viewer can + * already read, which is a different screen state and not this one. + * + * A [ChatRuleRequirement.MinimumBalance] is satisfied by the largest single balance among + * the mints it names, not by their sum: the requirement is "hold $100 of this", and two + * unrelated $60 positions are not that. An empty `mints` names no token in particular, so + * any one of them can satisfy it. + */ + fun evaluate( + isMember: Boolean, + rules: ChatRules?, + balances: List, + ): GroupAccess { + if (isMember) return Membered + val listener = rules?.listener.orEmpty() + if (listener.isEmpty()) return Eligible + + // Keyed by bytes, not by the key object: `class Mint(bytes) : PublicKey(bytes)` + // (libs/encryption/keys/.../Mint.kt), so the `PublicKey`s in `mints` are not `Mint`s and + // a map keyed by `Mint` would miss every one of them. + val byMint: Map, Fiat> = + balances.associate { it.token.address.bytes to it.balance } + + val unmet = listener.firstOrNull { requirement -> + when (requirement) { + is ChatRuleRequirement.MinimumBalance -> { + val candidates = if (requirement.mints.isEmpty()) { + byMint.values + } else { + requirement.mints.mapNotNull { byMint[it.bytes] } + } + val best = candidates.maxOrNull() + best == null || best < requirement.amount + } + // Nothing the client can check and nothing the user can go and acquire. A + // non-member facing a staff chat is blocked, and Task 9 gives that arm a + // disabled button rather than a purchase the user cannot make. + ChatRuleRequirement.Staff -> true + } + } + + return if (unmet == null) Eligible else Blocked(unmet) + } + } +} + +/** + * [GroupAccess.evaluate] over the live balance. + * + * `distinctUntilChanged` because `tokenBalances` re-emits on every price tick, and the gate only + * ever has three answers — without it the transcript's blur would recompose several times a second + * to arrive at the value it already had. + */ +internal fun TokenCoordinator.groupAccess( + isMember: Boolean, + rules: ChatRules?, +): Flow = tokenBalances + .map { GroupAccess.evaluate(isMember = isMember, rules = rules, balances = it) } + .distinctUntilChanged() diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupAccessTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupAccessTest.kt new file mode 100644 index 0000000000..16cbb29351 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupAccessTest.kt @@ -0,0 +1,187 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.HolderMetrics +import com.getcode.opencode.model.financial.MintMetadata +import com.getcode.opencode.model.financial.TokenWithBalance +import com.getcode.opencode.model.financial.VmMetadata +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey +import org.junit.Test +import kotlin.test.assertEquals + +/** + * The gate, as a table. + * + * Membership short-circuits everything: a member is past the rules by definition, and re-evaluating + * them would blur the transcript of a chat they are already in the moment their balance dipped. + */ +class GroupAccessTest { + + private fun mint(seed: Byte) = Mint(ByteArray(32) { seed }.toList()) + + private fun token(seed: Byte, symbol: String) = MintMetadata( + address = mint(seed), + decimals = 6, + name = symbol, + symbol = symbol, + createdAt = null, + description = "", + imageUrl = "", + vmMetadata = VmMetadata( + vm = PublicKey.fromBase58("11111111111111111111111111111111"), + authority = PublicKey.fromBase58("11111111111111111111111111111111"), + lockDurationInDays = 21, + ), + launchpadMetadata = null, + billCustomizations = null, + socialLinks = emptyList(), + holderMetrics = HolderMetrics.None, + ) + + private fun held(seed: Byte, symbol: String, dollars: Double) = + TokenWithBalance(token = token(seed, symbol), balance = Fiat(dollars)) + + private val badBoys = mint(1) + private val other = mint(2) + + private fun rules(vararg listener: ChatRuleRequirement) = + ChatRules(listener = listener.toList(), speaker = emptyList()) + + @Test + fun `a member is in, whatever the rules say`() { + val access = GroupAccess.evaluate( + isMember = true, + rules = rules(ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(badBoys))), + balances = emptyList(), + ) + + assertEquals(GroupAccess.Membered, access) + } + + @Test + fun `no rules means anyone may join`() { + assertEquals( + GroupAccess.Eligible, + GroupAccess.evaluate(isMember = false, rules = null, balances = emptyList()), + ) + assertEquals( + GroupAccess.Eligible, + GroupAccess.evaluate(isMember = false, rules = rules(), balances = emptyList()), + ) + } + + @Test + fun `enough of the named token clears the bar`() { + val access = GroupAccess.evaluate( + isMember = false, + rules = rules(ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(badBoys))), + balances = listOf(held(1, "BadBoys", 100.0)), + ) + + assertEquals(GroupAccess.Eligible, access) + } + + @Test + fun `too little of the named token is blocked by that requirement`() { + val requirement = ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(badBoys)) + + val access = GroupAccess.evaluate( + isMember = false, + rules = rules(requirement), + balances = listOf(held(1, "BadBoys", 99.99)), + ) + + assertEquals(GroupAccess.Blocked(requirement), access) + } + + @Test + fun `a balance in the wrong token does not count`() { + val requirement = ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(badBoys)) + + val access = GroupAccess.evaluate( + isMember = false, + rules = rules(requirement), + balances = listOf(held(2, "Other", 1_000.0)), + ) + + assertEquals(GroupAccess.Blocked(requirement), access) + } + + @Test + fun `no named mints accepts any single token`() { + val access = GroupAccess.evaluate( + isMember = false, + rules = rules(ChatRuleRequirement.MinimumBalance(Fiat(100.0), emptyList())), + balances = listOf(held(2, "Other", 150.0)), + ) + + assertEquals(GroupAccess.Eligible, access) + } + + @Test + fun `several named mints are satisfied by the largest, not the sum`() { + val requirement = + ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(badBoys, other)) + + assertEquals( + GroupAccess.Blocked(requirement), + GroupAccess.evaluate( + isMember = false, + rules = rules(requirement), + balances = listOf(held(1, "BadBoys", 60.0), held(2, "Other", 60.0)), + ), + ) + assertEquals( + GroupAccess.Eligible, + GroupAccess.evaluate( + isMember = false, + rules = rules(requirement), + balances = listOf(held(1, "BadBoys", 40.0), held(2, "Other", 120.0)), + ), + ) + } + + @Test + fun `a staff rule blocks with nothing to buy`() { + val access = GroupAccess.evaluate( + isMember = false, + rules = rules(ChatRuleRequirement.Staff), + balances = listOf(held(1, "BadBoys", 1_000.0)), + ) + + assertEquals(GroupAccess.Blocked(ChatRuleRequirement.Staff), access) + } + + @Test + fun `the first unmet requirement is the one reported`() { + val balance = ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(badBoys)) + + val access = GroupAccess.evaluate( + isMember = false, + rules = ChatRules( + listener = listOf(balance, ChatRuleRequirement.Staff), + speaker = emptyList(), + ), + balances = emptyList(), + ) + + assertEquals(GroupAccess.Blocked(balance), access) + } + + @Test + fun `speaker rules do not gate reading`() { + val access = GroupAccess.evaluate( + isMember = false, + rules = ChatRules( + listener = emptyList(), + speaker = listOf(ChatRuleRequirement.MinimumBalance(Fiat(500.0), listOf(badBoys))), + ), + balances = emptyList(), + ) + + assertEquals(GroupAccess.Eligible, access) + } +} From dbcb2ecbdd61271312e12749b735ee0410e3410b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 15 Sep 2026 22:57:56 -0400 Subject: [PATCH 09/19] feat(chat): gate a group the viewer has not joined --- .../core/src/main/res/values/strings.xml | 5 + .../app/messenger/internal/ChatViewModel.kt | 40 ++++++++ .../internal/screens/MessengerScreen.kt | 42 +++++--- .../screens/components/ChatBottomBar.kt | 10 ++ .../screens/components/GroupGateBar.kt | 99 +++++++++++++++++++ .../flipcash/shared/chat/models/ChatAction.kt | 3 + 6 files changed, 184 insertions(+), 15 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupGateBar.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 2a6d3ae655..a1a0998029 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -893,6 +893,11 @@ Add Contact Balance Requirement: %1$s of $%2$s Balance Requirement: %1$s + Minimum Balance: %1$s of $%2$s + Minimum Balance: %1$s + This chat is open to Flipcash staff only. + Buy More $%1$s + Join Chat Are You Sure? Anyone you sent the link to won\'t be able to collect the cash 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 d62b095b83..f21fa63401 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 @@ -227,6 +227,11 @@ internal class ChatViewModel @Inject constructor( * Null until the token cache has it, and null for any chat without such a rule. */ val ruleTicker: String? = null, + /** + * What this viewer may do here. Null for anything that is not a group — a DM has no gate, + * and rendering one from a default would blur every contact conversation in the app. + */ + val groupAccess: GroupAccess? = null, ) { /** * The DM counterparty, or `null` for a group. @@ -272,6 +277,12 @@ internal class ChatViewModel @Inject constructor( /** The token cache learned the symbol behind the group's balance requirement. */ data class OnRuleTickerResolved(val ticker: String?) : Event + + /** The gate re-decided, because membership, the rules, or the balance moved. */ + data class OnGroupAccessResolved(val access: GroupAccess) : Event + + /** The gate's "Join Chat" button. */ + data object JoinChat : Event data class OnCurrencySymbolUpdated(val symbol: String): Event data class OnChatInitFeeUpdated(val formatted: String?) : Event data object RefreshContact : Event @@ -793,6 +804,22 @@ internal class ChatViewModel @Inject constructor( .onEach { dispatchEvent(Event.OnRuleTickerResolved(it)) } .launchIn(viewModelScope) + // Re-resolved whenever membership or the rules move, and internally whenever the balance + // does. flatMapLatest rather than combine because the balance flow is the inner one: a new + // subject must cancel the gate it was deciding, not race it. + stateFlow.map { it.subject as? ChatSubject.Group } + .distinctUntilChanged() + .flatMapLatest { group -> + if (group == null) { + flowOf(null) + } else { + tokenCoordinator.groupAccess(isMember = group.isMember, rules = group.rules) + } + } + .filterNotNull() + .onEach { dispatchEvent(Event.OnGroupAccessResolved(it)) } + .launchIn(viewModelScope) + // Observe member identity — if the other member loses identity (e.g. unlinked // their phone), mark the chat as read-only. Gated by chat type through the same rule the // feed filters on, so a tip DM — addressed by user id, named by handle — is never @@ -1018,6 +1045,17 @@ internal class ChatViewModel @Inject constructor( } .launchIn(viewModelScope) + eventFlow.filterIsInstance() + .onEach { + val chatId = stateFlow.value.chatId ?: return@onEach + // No optimistic flip. `join` caches the chat and the membership flag comes back + // through observeMetadata, which is the same path a join from another device takes — + // one source for the gate rather than two that can disagree. + chatCoordinator.join(chatId) + .onFailure { trace("failed to join chat - ${it.localizedMessage}") } + } + .launchIn(viewModelScope) + // A bubble is what the UI has; a citation is what the composer needs, and building one // reads the stored message. A message this device never stored drops the request rather // than opening a strip with nothing in it. @@ -1428,6 +1466,8 @@ internal class ChatViewModel @Inject constructor( ) } is Event.OnRuleTickerResolved -> { state -> state.copy(ruleTicker = event.ticker) } + is Event.OnGroupAccessResolved -> { state -> state.copy(groupAccess = event.access) } + Event.JoinChat -> { state -> state } is Event.OnTipUserResolved -> { state -> // A device contact, once matched, wins over the server profile (it carries the // phone number and the user's own naming). Otherwise this is a tip DM: adopt the diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 2fefbf828b..cfaaee27e5 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -16,11 +16,13 @@ import androidx.paging.compose.collectAsLazyPagingItems import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.GroupAccess import com.flipcash.app.messenger.internal.screens.components.ChatTopBar import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge.softTopEdge import com.flipcash.app.messenger.internal.screens.components.MessageList import com.flipcash.app.messenger.internal.screens.components.UserControlBottomBar +import com.getcode.ui.components.BlurredContent import com.flipcash.shared.chat.models.ChatAction import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.theme.CodeScaffold @@ -104,6 +106,8 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { viewModel.dispatchEvent(ChatViewModel.Event.JumpToMessage(action.messageId)) } + ChatAction.JoinChat -> viewModel.dispatchEvent(ChatViewModel.Event.JoinChat) + is ChatAction.ViewProfile -> { // The triggers (top-bar tap, contact-card chevron) are only clickable for tip DMs // (see State.canViewProfile), so no gating is needed here. @@ -151,20 +155,28 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { ) }, ) { overlapPadding -> - MessageList( - modifier = Modifier - .fillMaxSize() - .testTag("chat_message_list") - .softTopEdge(ChatTopEdge.blurHold(barHeight)) - .hazeSource(hazeState), - state = state, - contentPadding = overlapPadding, - messages = messages, - separatorConfig = state.separatorConfig, - otherReadPointer = otherReadPointer, - onAction = chatActionHandler, - canViewProfile = state.canViewProfile, - onJumpConsumed = { viewModel.dispatchEvent(ChatViewModel.Event.JumpConsumed) }, - ) + // The transcript and the info card go behind a blur together, because they are one surface + // to the reader: legible metadata over an unreadable conversation would be the gate half + // open. The title bar stays sharp — it is how a non-member knows what they are looking at. + BlurredContent( + modifier = Modifier.fillMaxSize(), + enabled = state.groupAccess != null && state.groupAccess != GroupAccess.Membered, + ) { + MessageList( + modifier = Modifier + .fillMaxSize() + .testTag("chat_message_list") + .softTopEdge(ChatTopEdge.blurHold(barHeight)) + .hazeSource(hazeState), + state = state, + contentPadding = overlapPadding, + messages = messages, + separatorConfig = state.separatorConfig, + otherReadPointer = otherReadPointer, + onAction = chatActionHandler, + canViewProfile = state.canViewProfile, + onJumpConsumed = { viewModel.dispatchEvent(ChatViewModel.Event.JumpConsumed) }, + ) + } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index c257161739..1dc1459bad 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.GroupAccess import com.flipcash.app.messenger.internal.screens.ChatAnimations import com.flipcash.services.models.chat.ChatType import com.flipcash.features.messenger.R @@ -72,6 +73,15 @@ internal fun UserControlBottomBar( return } + // The same shape as the block above it, and for the same reason: there is no composer to + // configure when the viewer cannot post. Membered is not a gate, so it falls through to the + // composer like any DM. + val access = state.groupAccess + if (access != null && access != GroupAccess.Membered) { + GroupGateBar(access = access, ticker = state.ruleTicker) + return + } + val keyboard = rememberKeyboardController() val focusRequester = remember { FocusRequester() } var buttonHeight by remember { mutableStateOf(0.dp) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupGateBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupGateBar.kt new file mode 100644 index 0000000000..87ae037981 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/GroupGateBar.kt @@ -0,0 +1,99 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +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.stringResource +import androidx.compose.ui.text.style.TextAlign +import com.flipcash.app.messenger.internal.GroupAccess +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.shared.chat.models.ChatAction +import com.flipcash.shared.chat.models.LocalChatActionHandler +import com.getcode.solana.keys.Mint +import com.getcode.theme.CodeTheme +import com.getcode.ui.theme.CodeButton + +/** + * What stands where the composer does for someone who is not in the group. + * + * Node 10125:19153. The caption states the bar and the button is the one thing that clears it, so + * both come from the same [GroupAccess.Blocked.unmet] the transcript's blur is decided by — there is + * no second evaluation here that could disagree with it. + */ +@Composable +internal fun GroupGateBar( + access: GroupAccess, + ticker: String?, + modifier: Modifier = Modifier, +) { + val onAction = LocalChatActionHandler.current + val unmet = (access as? GroupAccess.Blocked)?.unmet + val balance = unmet as? ChatRuleRequirement.MinimumBalance + + Column( + modifier = modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(vertical = CodeTheme.dimens.grid.x3), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + val caption = when { + balance != null && ticker != null -> stringResource( + R.string.subtitle_chatGate_minimumBalance, + balance.amount.formatted(), + ticker, + ) + balance != null -> stringResource( + R.string.subtitle_chatGate_minimumBalance_anyToken, + balance.amount.formatted(), + ) + unmet == ChatRuleRequirement.Staff -> + stringResource(R.string.subtitle_chatGate_staffOnly) + // Eligible, and Membered for the frame between a successful join and the membership + // flag arriving back through observeMetadata. + else -> null + } + + if (caption != null) { + Text( + text = caption, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + + if (balance != null) { + CodeButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.action_buyMoreToken, ticker.orEmpty()), + // Nothing to buy until the symbol resolves: the label would read "Buy More $" and + // the token screen has no mint to open. + enabled = ticker != null, + onClick = { + balance.mints.firstOrNull() + ?.let { onAction(ChatAction.ViewToken(Mint(it.bytes))) } + }, + ) + } else { + CodeButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.action_joinChat), + // A staff rule is not something the user can go and satisfy, so the button says + // what it would do and refuses to do it, rather than sending a join the server + // will reject. + enabled = unmet == null, + onClick = { onAction(ChatAction.JoinChat) }, + ) + } + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index 506062d1bb..7793527335 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -10,6 +10,9 @@ sealed interface ChatAction { data class ViewToken(val mint: Mint) : ChatAction data object ViewProfile : ChatAction + /** Joins the group this screen is showing. */ + data object JoinChat : ChatAction + /** * Adds [bubble] to the selection, or removes it if it is already selected. * From f9110403487e1e182b4366819cb59a8e28a7e686 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 15 Sep 2026 23:02:27 -0400 Subject: [PATCH 10/19] feat(chat): show groups in the chats tab --- .../core/src/main/res/values/strings.xml | 3 + .../app/tipping/internal/TipFlowViewModel.kt | 15 ++- .../tipping/internal/components/TipChatRow.kt | 3 +- .../shared/chat/ui/ChatSummaryMapping.kt | 52 +++++++-- .../shared/chat/ui/ConversationReference.kt | 24 +++- .../shared/chat/ui/ChatSummaryPreviewTest.kt | 104 ++++++++++++++++++ 6 files changed, 184 insertions(+), 17 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index a1a0998029..d94a055ab7 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -911,6 +911,9 @@ You tipped %1$s Someone tipped you %1$s You: %1$s + %1$s: %2$s + %1$s sent %2$s + %1$s tipped %2$s Is Typing… Edited diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt index d1c53e0ea7..57257f7932 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt @@ -4,6 +4,8 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.data.Loadable +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.core.extensions.onResult import com.flipcash.app.core.tipping.TipStep import com.flipcash.app.shareable.ShareSheetController @@ -19,15 +21,18 @@ import com.flipcash.shared.chat.ui.toConversationReference import com.flipcash.shared.tipping.TippingCoordinator import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import javax.inject.Inject +@OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel internal class TipFlowViewModel @Inject constructor( chatCoordinator: ChatCoordinator, @@ -36,6 +41,7 @@ internal class TipFlowViewModel @Inject constructor( tokenCoordinator: TokenCoordinator, shareable: ShareSheetController, tipCodePreviewCache: TipCodePreviewCache, + featureFlags: FeatureFlagController, private val resources: ResourceHelper, ) : BaseViewModel( initialState = State(), @@ -87,7 +93,14 @@ internal class TipFlowViewModel @Inject constructor( .launchIn(viewModelScope) combine( - chatCoordinator.feed(ChatType.TIP_DM), + featureFlags.observe(FeatureFlag.GroupChats) + .flatMapLatest { groupsOn -> + if (groupsOn) { + chatCoordinator.feed(ChatType.TIP_DM, ChatType.GROUP) + } else { + chatCoordinator.feed(ChatType.TIP_DM) + } + }, tokenCoordinator.tokens, ) { summaries, tokens -> val selfId = userManager.accountId diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt index c98008eed8..74ecfd4314 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt @@ -6,7 +6,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.shared.chat.ui.ChatListRow import com.flipcash.shared.chat.ui.ChatRowSubtitle import com.flipcash.shared.chat.ui.ChatRowTrailing @@ -29,7 +28,7 @@ internal fun TipChatRow( ContactAvatar( image = chat.image, displayName = chat.name.orEmpty(), - access = BlobAccessContext.profile(chat.userId), + access = chat.avatarAccess, modifier = Modifier .requiredSize(CodeTheme.dimens.staticGrid.x8) .clip(CircleShape), diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt index 89dfd60676..ef28929149 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt @@ -1,6 +1,7 @@ package com.flipcash.shared.chat.ui import com.flipcash.core.R +import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.handle import com.flipcash.shared.chat.ChatSummary @@ -27,13 +28,18 @@ fun ChatSummary.toConversationReference( tokensByMint: Map, resources: ResourceHelper, ): ConversationReference { + val isGroup = metadata.type == ChatType.GROUP val other = metadata.members.firstOrNull { it.userId != selfId } return ConversationReference( chatId = metadata.chatId, - userId = other?.userId, - displayName = other?.userProfile?.displayName, - handle = other?.userProfile?.handle, - image = other?.userProfile?.profilePicture, + // A group has no single counterparty, and passing one would name the row after whichever + // member happened to be first in a truncated roster. + userId = other?.userId.takeUnless { isGroup }, + displayName = other?.userProfile?.displayName.takeUnless { isGroup }, + handle = other?.userProfile?.handle.takeUnless { isGroup }, + image = if (isGroup) metadata.picture else other?.userProfile?.profilePicture, + title = metadata.title, + isGroup = isGroup, lastMessagePreview = formatPreview(selfId, tokensByMint, resources), lastActivity = metadata.lastActivity, unreadCount = unreadCount, @@ -47,7 +53,16 @@ private fun ChatSummary.formatPreview( ): String? { val lastMsg = metadata.lastMessage ?: return null val sentBySelf = lastMsg.senderId != null && lastMsg.senderId == selfId - return lastMsg.content.firstOrNull()?.previewText(sentBySelf, tokensByMint, resources) + // Only a group attributes, and only someone else's message: "You:" already covers the viewer's, + // and a DM's counterparty is the row's own name. Null when the roster subset does not have the + // sender — the list does not fetch profiles, so an unattributed body is the honest fallback. + val senderName = if (metadata.type == ChatType.GROUP && !sentBySelf) { + metadata.members.firstOrNull { it.userId == lastMsg.senderId }?.userProfile?.displayName + } else { + null + } + return lastMsg.content.firstOrNull() + ?.previewText(sentBySelf, senderName, tokensByMint, resources) } /** @@ -58,6 +73,7 @@ private fun ChatSummary.formatPreview( */ private fun MessageContent.previewText( sentBySelf: Boolean, + senderName: String?, tokensByMint: Map, resources: ResourceHelper, depth: Int = 0, @@ -67,6 +83,8 @@ private fun MessageContent.previewText( when { message == null -> null sentBySelf -> resources.getString(R.string.label_chat_preview_sentMessage, message) + senderName != null -> + resources.getString(R.string.label_chat_preview_senderMessage, senderName, message) else -> message } } @@ -85,13 +103,23 @@ private fun MessageContent.previewText( } else { formatted } - val previewRes = when (action) { - MessageContent.Cash.Action.TIPPED -> - if (sentBySelf) R.string.label_chat_preview_tippedCash else R.string.label_chat_preview_receivedCash - MessageContent.Cash.Action.SENT -> - if (sentBySelf) R.string.label_chat_preview_sentCash else R.string.label_chat_preview_receivedCash + // "You received" does not carry over: in a DM the cash came to the viewer, in a group it + // went to the group and the viewer may have got none of it. + if (senderName != null) { + val previewRes = when (action) { + MessageContent.Cash.Action.TIPPED -> R.string.label_chat_preview_tippedCash_bySender + MessageContent.Cash.Action.SENT -> R.string.label_chat_preview_sentCash_bySender + } + resources.getString(previewRes, senderName, label) + } else { + val previewRes = when (action) { + MessageContent.Cash.Action.TIPPED -> + if (sentBySelf) R.string.label_chat_preview_tippedCash else R.string.label_chat_preview_receivedCash + MessageContent.Cash.Action.SENT -> + if (sentBySelf) R.string.label_chat_preview_sentCash else R.string.label_chat_preview_receivedCash + } + resources.getString(previewRes, label) } - resources.getString(previewRes, label) } // A reply wraps what the sender actually typed, so it previews as that content would have @@ -101,7 +129,7 @@ private fun MessageContent.previewText( is MessageContent.Reply -> if (depth >= MAX_REPLY_UNWRAP_DEPTH) { null } else { - content.firstOrNull()?.previewText(sentBySelf, tokensByMint, resources, depth + 1) + content.firstOrNull()?.previewText(sentBySelf, senderName, tokensByMint, resources, depth + 1) } // The feed carries the newest message that still has content, so a tombstone only 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 index 4bf9cf1cc9..d7a916e11c 100644 --- 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 @@ -1,6 +1,7 @@ package com.flipcash.shared.chat.ui import com.getcode.opencode.model.core.ID +import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.models.nameOrHandle @@ -24,6 +25,10 @@ data class ConversationReference( val handle: String? = null, /** Counterparty avatar media; resolve a URL via [MediaItem.url]. */ val image: MediaItem? = null, + /** The chat's own title. Only groups have one; a DM is named by its counterparty. */ + val title: String? = null, + /** Whether this row is a group. Decides where the name and the avatar's authority come from. */ + val isGroup: Boolean = false, val lastMessagePreview: String? = null, /** The chat's last-activity timestamp; drives recency sorting and the row's trailing timestamp. */ val lastActivity: Instant? = null, @@ -31,10 +36,25 @@ data class ConversationReference( val isTyping: Boolean = false, ) { /** - * What to call the counterparty: [displayName] when they have one, [handle] when they don't. + * What to call this row: the chat's [title] for a group, otherwise the counterparty's + * [displayName], or their [handle] when they have no name. * * The same rule [com.flipcash.app.core.chat.ChatParticipant.name] applies in the messenger, so a * tip DM reads the same in the list as it does once opened. */ - val name: String? get() = nameOrHandle(displayName, handle) + val name: String? get() = if (isGroup) title else nameOrHandle(displayName, handle) + + /** + * What authorizes re-minting [image]'s download URL. + * + * A group's picture hangs off the chat's public profile, not a member's — passing a user id for + * it resolves nothing and the row falls back to initials. Answered here rather than at the call + * site so a row cannot get it wrong. + */ + val avatarAccess: BlobAccessContext + get() = if (isGroup) { + BlobAccessContext.ChatProfile(chatId) + } else { + BlobAccessContext.profile(userId) + } } \ No newline at end of file diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt index 28d6760a22..370de9c58b 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt @@ -2,6 +2,7 @@ package com.flipcash.shared.chat.ui import com.flipcash.core.R import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage @@ -16,6 +17,7 @@ import io.mockk.mockk import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlin.test.assertTrue import kotlin.time.Instant /** @@ -105,4 +107,106 @@ class ChatSummaryPreviewTest { assertNull(preview(listOf(nested))) } + + private fun groupSummary( + content: List, + senderId: ID?, + ) = ChatSummary( + metadata = ChatMetadata( + chatId = ChatId(byteArrayOf(9)), + type = ChatType.GROUP, + members = listOf( + ChatMember(userId = self, userProfile = profile("Me"), pointers = emptyList()), + ChatMember(userId = other, userProfile = profile("Alice"), pointers = emptyList()), + ), + lastMessage = ChatMessage( + messageId = 42, + senderId = senderId, + content = content, + timestamp = sentAt, + unreadSeq = 0, + ), + lastActivity = sentAt, + title = "Bad Boys", + ), + unreadCount = 0, + ) + + private fun groupReference(content: List, senderId: ID? = other) = + groupSummary(content, senderId) + .toConversationReference(selfId = self, tokensByMint = emptyMap(), resources = resources) + + @Test + fun `a group is named by its title`() { + // Building the reference formats the preview eagerly, so these identity tests have to + // stub it even though they do not assert on it. + every { + resources.getString(R.string.label_chat_preview_senderMessage, "Alice", "gm") + } returns "Alice: gm" + + val reference = groupReference(listOf(MessageContent.Text("gm"))) + + assertEquals("Bad Boys", reference.name) + assertTrue(reference.isGroup) + } + + @Test + fun `a group's avatar authorizes off the chat's own profile`() { + // Building the reference formats the preview eagerly, so these identity tests have to + // stub it even though they do not assert on it. + every { + resources.getString(R.string.label_chat_preview_senderMessage, "Alice", "gm") + } returns "Alice: gm" + + val reference = groupReference(listOf(MessageContent.Text("gm"))) + + assertEquals( + BlobAccessContext.ChatProfile(ChatId(byteArrayOf(9))), + reference.avatarAccess, + ) + } + + @Test + fun `a DM's avatar still reads off the counterparty's profile`() { + val reference = summary(listOf(MessageContent.Text("gm")), senderId = other) + .toConversationReference(selfId = self, tokensByMint = emptyMap(), resources = resources) + + assertEquals(BlobAccessContext.Profile(other), reference.avatarAccess) + } + + @Test + fun `another member's message is prefixed with their name`() { + every { + resources.getString(R.string.label_chat_preview_senderMessage, "Alice", "gm") + } returns "Alice: gm" + + assertEquals("Alice: gm", groupReference(listOf(MessageContent.Text("gm"))).lastMessagePreview) + } + + @Test + fun `the viewer's own message keeps the You prefix in a group`() { + every { + resources.getString(R.string.label_chat_preview_sentMessage, "gm") + } returns "You: gm" + + assertEquals( + "You: gm", + groupReference(listOf(MessageContent.Text("gm")), senderId = self).lastMessagePreview, + ) + } + + @Test + fun `a message from someone the roster subset omits is not prefixed`() { + val stranger: ID = listOf(7) + + assertEquals( + "gm", + groupReference(listOf(MessageContent.Text("gm")), senderId = stranger).lastMessagePreview, + ) + } + + @Test + fun `a DM is not prefixed with the counterparty's name`() { + assertEquals("gm", preview(listOf(MessageContent.Text("gm")))) + } } From 6bc8ce5c36516eb8af7489dad21b5f4f92557231 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 15 Sep 2026 23:03:41 -0400 Subject: [PATCH 11/19] test(chat): render the group chrome to screenshots --- .../internal/GroupChromeScreenshotTest.kt | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupChromeScreenshotTest.kt diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupChromeScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupChromeScreenshotTest.kt new file mode 100644 index 0000000000..dabe7a950b --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/GroupChromeScreenshotTest.kt @@ -0,0 +1,212 @@ +package com.flipcash.app.messenger.internal + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import com.flipcash.app.messenger.internal.screens.components.ChatInfoCard +import com.flipcash.app.messenger.internal.screens.components.ChatTopBar +import com.flipcash.app.messenger.internal.screens.components.GroupGateBar +import com.flipcash.app.theme.FlipcashPreview +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules +import com.flipcash.services.models.chat.ChatType +import com.getcode.navigation.core.CodeNavigator +import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.Mint +import io.mockk.mockk +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File + +/** + * Renders the group chrome — title bar, info card, gate bar — to PNGs in `build/screenshots/` so + * the layout can be eyeballed without an emulator. Not an assertion test. + * + * Same mechanics as [ChatIdentityScreenshotTest]: pause the clock, pump a fixed number of frames, + * and draw the Android view directly, so a composable that keeps scheduling frames can't hang + * `captureToImage()`'s implicit `waitForIdle`. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w400dp-h1100dp-xhdpi") +class GroupChromeScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private val mint = Mint(List(32) { 7.toByte() }) + + private val balanceRule = ChatRules( + listener = listOf( + ChatRuleRequirement.MinimumBalance( + mints = listOf(mint), + amount = Fiat(100.0), + ), + ), + speaker = emptyList(), + ) + + // The member's view: no rules line worth gating on, a real member count. + private val memberedGroup = ChatSubject.Group( + chatId = ChatId(byteArrayOf(1)), + groupTitle = "Bad Boys", + picture = null, + memberCount = 412L, + rules = null, + isMember = true, + ) + + // The outsider's view: same group, carrying the balance rule the gate reports. + private val gatedGroup = memberedGroup.copy(rules = balanceRule, isMember = false) + + // The degenerate roster: the plural string's "1 person" arm and an untitled group. + private val soloGroup = memberedGroup.copy(groupTitle = null, memberCount = 1) + + @Test + fun rendersGroupTitleBar() { + val navigator = mockk(relaxed = true) + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashPreview(showBackground = true) { + Column( + modifier = Modifier.width(360.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + listOf(memberedGroup, gatedGroup, soloGroup).forEach { group -> + ChatTopBar( + navigator = navigator, + state = ChatViewModel.State( + subject = group, + chatType = ChatType.GROUP, + ), + onBarHeightChange = {}, + chatActionHandler = {}, + dispatch = {}, + ) + } + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + capture("group_top_bar.png") + } + + @Test + fun rendersGroupInfoCard() { + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashPreview(showBackground = true) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + val cardWidth = Modifier.width(300.dp) + ChatInfoCard(subject = memberedGroup, modifier = cardWidth) + // The ticker resolved, as it is once the token cache fills in. + ChatInfoCard(subject = gatedGroup, modifier = cardWidth, ticker = "BADBOYS") + // The ticker not yet resolved — the frame the card renders first. + ChatInfoCard(subject = gatedGroup, modifier = cardWidth) + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + capture("group_info_card.png") + } + + @Test + fun rendersGroupGateBar() { + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashPreview(showBackground = true) { + Column( + modifier = Modifier.width(360.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + // Blocked on a balance, ticker resolved: "Buy More $BADBOYS", enabled. + GroupGateBar( + access = GroupAccess.Blocked( + unmet = ChatRuleRequirement.MinimumBalance( + mints = listOf(mint), + amount = Fiat(100.0), + ), + ), + ticker = "BADBOYS", + ) + // The same state before the ticker resolves: the button is disabled. + GroupGateBar( + access = GroupAccess.Blocked( + unmet = ChatRuleRequirement.MinimumBalance( + mints = emptyList(), + amount = Fiat(100.0), + ), + ), + ticker = null, + ) + // Blocked on staff: no action the viewer can take. + GroupGateBar( + access = GroupAccess.Blocked(unmet = ChatRuleRequirement.Staff), + ticker = null, + ) + // Eligible: one button, "Join Chat". + GroupGateBar(access = GroupAccess.Eligible, ticker = null) + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + capture("group_gate_bar.png") + } + + private fun capture(name: String) { + val root: View = composeRule.activity.findViewById(android.R.id.content) + val width = root.width.takeIf { it > 0 } ?: 1080 + val height = root.height.takeIf { it > 0 } ?: 1920 + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + root.draw(Canvas(bitmap)) + val cropped = bitmap.trimmedToDrawnArea() + + val outDir = File("build/screenshots").apply { mkdirs() } + val file = File(outDir, name) + file.outputStream().use { cropped.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath} (${cropped.width}x${cropped.height})") + } + + /** + * The content view is the full device, but the previews wrap their content — crop away the + * untouched (fully transparent) margin so the PNG is just what was composed. + */ + private fun Bitmap.trimmedToDrawnArea(): Bitmap { + val pixels = IntArray(width * height) + getPixels(pixels, 0, width, 0, 0, width, height) + var left = width + var top = height + var right = -1 + var bottom = -1 + for (y in 0 until height) { + for (x in 0 until width) { + if (pixels[y * width + x] ushr 24 == 0) continue + if (x < left) left = x + if (x > right) right = x + if (y < top) top = y + if (y > bottom) bottom = y + } + } + if (right < left || bottom < top) return this + return Bitmap.createBitmap(this, left, top, right - left + 1, bottom - top + 1) + } +} From f3b073cc1a5f226cf001d981e1f42596ed7983c8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 16 Sep 2026 09:00:01 -0400 Subject: [PATCH 12/19] fix(chat): sync the group feed regardless of the GroupChats flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flag was read once inside onUserLoggedIn, so flipping it on only took effect after a process restart replayed login hydration — the toggle looked dead until you killed the app. Make it what its description already claims: a filter on the chat list. Every read of the chat cache is scoped to the chat types its caller asks for — feed(), feedPaged() and observeUnreadConversations() all take the types, and observeFeedPaged filters in SQL — so a group stored with the flag off reaches no list and no badge. Gating the fetch also contradicted the roster-change routing beside it, which was never gated. Drops the chat module's dependency on featureflags; the flag now lives only in the chat list that filters on it. --- apps/flipcash/shared/chat/build.gradle.kts | 1 - .../chat/internal/RealChatCoordinator.kt | 14 +++++------ .../chat/ChatCoordinatorEagerBalanceTest.kt | 1 - .../shared/chat/ChatCoordinatorEventsTest.kt | 1 - .../chat/ChatCoordinatorTeardownTest.kt | 1 - .../shared/chat/ChatHistoryHydrationTest.kt | 1 - .../shared/chat/GroupChatRoutingTest.kt | 25 +++---------------- .../shared/chat/ReceivedCounterTest.kt | 1 - .../flipcash/shared/chat/ReceivedEventTest.kt | 1 - 9 files changed, 9 insertions(+), 37 deletions(-) diff --git a/apps/flipcash/shared/chat/build.gradle.kts b/apps/flipcash/shared/chat/build.gradle.kts index 6004b3e160..1dedaba8c6 100644 --- a/apps/flipcash/shared/chat/build.gradle.kts +++ b/apps/flipcash/shared/chat/build.gradle.kts @@ -19,7 +19,6 @@ dependencies { implementation(project(":apps:flipcash:shared:persistence:sources")) implementation(project(":apps:flipcash:shared:persistence:db")) implementation(project(":apps:flipcash:shared:contacts")) - implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:tokens")) implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":services:flipcash")) 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 8a1f3a4544..2002e42fb9 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 @@ -5,8 +5,6 @@ package com.flipcash.shared.chat.internal import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.models.chat.ChatId import com.flipcash.services.user.UserManager @@ -80,7 +78,6 @@ class RealChatCoordinator @Inject constructor( private val stateHolder: ChatStateHolder, private val userManager: UserManager, private val networkObserver: NetworkConnectivityListener, - private val featureFlags: FeatureFlagController, private val dispatchers: DispatcherProvider, ) : ChatCoordinator, SessionListener, @@ -126,11 +123,12 @@ class RealChatCoordinator @Inject constructor( feedDelegate.observeFeedFromDb() feedDelegate.syncFeed() groupFeedDelegate.initialize(scope) - // The only gate on the branch. Everything behind it is built and tested; what it decides - // is whether the device asks the server for groups at all. - if (featureFlags.get(FeatureFlag.GroupChats)) { - groupFeedDelegate.syncGroupFeed() - } + // Synced unconditionally. The GroupChats flag filters the chat list, and every read of + // this cache is already scoped to the chat types its caller asked for, so a group sitting + // in the database with the flag off is invisible rather than latent. Gating the fetch + // instead made the toggle do nothing until the process restarted, and contradicted the + // roster-change routing below, which was never gated. + groupFeedDelegate.syncGroupFeed() eventStreamDelegate.open() eventStreamDelegate.startHeartbeat { feedDelegate.syncFeed() } } 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 9fbbfd5fbd..670ecce947 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 @@ -133,7 +133,6 @@ class ChatCoordinatorEagerBalanceTest { networkObserver = mockk(relaxed = true), dispatchers = testDispatchers, groupFeedDelegate = mockk(relaxed = true), - featureFlags = mockk(relaxed = true), ) } 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 f487255958..ebb63ca3e7 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 @@ -136,7 +136,6 @@ class ChatCoordinatorEventsTest { networkObserver = mockk(relaxed = true), dispatchers = testDispatchers, groupFeedDelegate = mockk(relaxed = true), - featureFlags = mockk(relaxed = true), ) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt index ce2b5afec2..f53cee6e6c 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt @@ -114,7 +114,6 @@ class ChatCoordinatorTeardownTest { networkObserver = mockk(relaxed = true), dispatchers = TestDispatchers(TestCoroutineScheduler()), groupFeedDelegate = mockk(relaxed = true), - featureFlags = mockk(relaxed = true), ) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt index d981a2459f..a3e14a066f 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatHistoryHydrationTest.kt @@ -146,7 +146,6 @@ class ChatHistoryHydrationTest { networkObserver = mockk(relaxed = true), dispatchers = TestDispatchers(testScheduler), groupFeedDelegate = mockk(relaxed = true), - featureFlags = mockk(relaxed = true), ) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupChatRoutingTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupChatRoutingTest.kt index b6804d3c32..042100d7ae 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupChatRoutingTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupChatRoutingTest.kt @@ -1,8 +1,6 @@ package com.flipcash.shared.chat import com.flipcash.app.core.dispatchers.TestDispatchers -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.persistence.sources.ChatMemberDataSource import com.flipcash.app.persistence.sources.ChatMessageDataSource import com.flipcash.app.persistence.sources.ChatMetadataDataSource @@ -60,7 +58,6 @@ class GroupChatRoutingTest { private val chatUpdatesChannel = Channel(capacity = Channel.UNLIMITED) private val groupFeedDelegate = mockk(relaxed = true) - private val featureFlags = mockk(relaxed = true) private val testDispatchers = TestDispatchers(TestCoroutineScheduler()) @@ -124,7 +121,6 @@ class GroupChatRoutingTest { stateHolder = stateHolder, userManager = userManager, networkObserver = mockk(relaxed = true), - featureFlags = featureFlags, dispatchers = testDispatchers, ) } @@ -141,7 +137,6 @@ class GroupChatRoutingTest { @Test fun `a roster change on the stream reaches the group delegate`() = runTest(testDispatchers.dispatcher) { - coEvery { featureFlags.get(FeatureFlag.GroupChats) } returns true val subject = coordinator() subject.onUserLoggedIn(mockk(relaxed = true)) runCurrent() @@ -155,7 +150,6 @@ class GroupChatRoutingTest { @Test fun `an update with no roster change does not reach the group delegate`() = runTest(testDispatchers.dispatcher) { - coEvery { featureFlags.get(FeatureFlag.GroupChats) } returns true val subject = coordinator() subject.onUserLoggedIn(mockk(relaxed = true)) runCurrent() @@ -168,32 +162,19 @@ class GroupChatRoutingTest { } @Test - fun `logging in syncs the group feed when the flag is on`() = runTest(testDispatchers.dispatcher) { - coEvery { featureFlags.get(FeatureFlag.GroupChats) } returns true - + fun `logging in syncs the group feed`() = runTest(testDispatchers.dispatcher) { val subject = coordinator() subject.onUserLoggedIn(mockk(relaxed = true)) runCurrent() + // Unconditional: the GroupChats flag filters the list, it does not decide whether the + // cache is populated. Gating the fetch here made the toggle a no-op until the next launch. coVerify { groupFeedDelegate.syncGroupFeed() } subject.teardown() } - @Test - fun `logging in does not sync the group feed when the flag is off`() = runTest(testDispatchers.dispatcher) { - coEvery { featureFlags.get(FeatureFlag.GroupChats) } returns false - - val subject = coordinator() - subject.onUserLoggedIn(mockk(relaxed = true)) - runCurrent() - - coVerify(exactly = 0) { groupFeedDelegate.syncGroupFeed() } - subject.teardown() - } - @Test fun `teardown cancels the group delegate's jobs`() = runTest(testDispatchers.dispatcher) { - coEvery { featureFlags.get(FeatureFlag.GroupChats) } returns true val subject = coordinator() subject.onUserLoggedIn(mockk(relaxed = true)) runCurrent() diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt index 6770cd70ad..2f12aa4294 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt @@ -154,7 +154,6 @@ class ReceivedCounterTest { networkObserver = mockk(relaxed = true), dispatchers = testDispatchers, groupFeedDelegate = mockk(relaxed = true), - featureFlags = mockk(relaxed = true), ) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt index ebdd09b2f7..3c5633d12b 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt @@ -157,7 +157,6 @@ class ReceivedEventTest { networkObserver = mockk(relaxed = true), dispatchers = testDispatchers, groupFeedDelegate = mockk(relaxed = true), - featureFlags = mockk(relaxed = true), ) } From fd115ed309de8012ab61a1deea014a776dc26ee7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 16 Sep 2026 09:31:29 -0400 Subject: [PATCH 13/19] fix(chat): fetch a synced group's transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A group feed sync persisted each group's last-message preview and stopped there, so opening a group the device had only ever seen in the feed showed exactly one bubble. The DM feed has handled this since it was written: after persisting, it compares each chat's applied cursor against the feed head and emits LoadMessages or DeltaSyncNeeded. The group feed now does the same, and the coordinator routes the new DeltaSyncNeeded to the event stream. The applied cursor is the right test rather than "does this chat have messages" — persisting the preview makes the latter true for a chat whose transcript was never pulled. --- .../chat/internal/RealChatCoordinator.kt | 2 + .../internal/delegates/GroupFeedDelegate.kt | 19 ++++++++ .../shared/chat/GroupFeedDelegateTest.kt | 46 ++++++++++++++++++- 3 files changed, 66 insertions(+), 1 deletion(-) 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 2002e42fb9..740a0bd1c1 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 @@ -184,6 +184,8 @@ class RealChatCoordinator @Inject constructor( when (event) { is GroupFeedDelegate.Event.LoadMessages -> messagingDelegate.loadMessages(event.chatId) + is GroupFeedDelegate.Event.DeltaSyncNeeded -> + eventStreamDelegate.performDeltaSync(event.chatId) } }.launchIn(scope) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt index 21e6289784..abb125ffcf 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt @@ -50,6 +50,13 @@ class GroupFeedDelegate @Inject constructor( sealed interface Event { /** A group whose transcript the device does not have yet. */ data class LoadMessages(val chatId: ChatId) : Event + + /** + * The group's applied cursor is behind the feed's head, so the device is missing a window + * of its transcript. Same contract as + * [FeedSyncDelegate.Event.DeltaSyncNeeded][FeedSyncDelegate.Event.DeltaSyncNeeded]. + */ + data class DeltaSyncNeeded(val chatId: ChatId) : Event } private val _events = Channel(Channel.UNLIMITED) @@ -87,6 +94,18 @@ class GroupFeedDelegate @Inject constructor( return } persist(page.chats) + + // Caching the feed is not the same as having the transcript: [persist] writes each group's + // last-message preview and nothing else, so without this a synced group opens to a single + // bubble. The applied cursor is what says whether the transcript was ever pulled — a feed + // sync never writes it. Same rule the DM feed applies in [FeedSyncDelegate]. + for (chat in page.chats) { + val cursor = metadataDataSource.getLatestEventSequence(chat.chatId) + when { + cursor <= 0L -> _events.send(Event.LoadMessages(chat.chatId)) + chat.latestEventSequence > cursor -> _events.send(Event.DeltaSyncNeeded(chat.chatId)) + } + } } /** diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupFeedDelegateTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupFeedDelegateTest.kt index 7fe77fe449..6c8757ce28 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupFeedDelegateTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupFeedDelegateTest.kt @@ -22,8 +22,10 @@ import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeoutOrNull import org.junit.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Instant @@ -62,7 +64,7 @@ class GroupFeedDelegateTest { pointers = emptyList(), ) - private fun group(version: Long = 4) = ChatMetadata( + private fun group(version: Long = 4, latestEventSequence: Long = 0) = ChatMetadata( chatId = chatId, type = ChatType.GROUP, members = listOf(member(selfId)), @@ -70,8 +72,15 @@ class GroupFeedDelegateTest { lastActivity = Instant.fromEpochSeconds(1_000), title = "Flipcash Staff", rosterSummary = RosterSummary(memberCount = 12, version = version), + latestEventSequence = latestEventSequence, ) + private fun feedReturns(vararg chats: ChatMetadata) { + coEvery { controller.getGroupChatFeed(any()) } returns Result.success( + ChatFeedPage(chats = chats.toList(), pagingToken = null, hasMore = false) + ) + } + @Test fun `a sync persists the groups it fetched`() = runTest { coEvery { controller.getGroupChatFeed(any()) } returns Result.success( @@ -108,6 +117,41 @@ class GroupFeedDelegateTest { coVerify(exactly = 0) { metadataDataSource.upsert(any>()) } } + /** + * A feed sync caches each group's last-message preview and nothing else, so a group the device + * has never fetched would otherwise open to a single bubble. The applied cursor, not the + * presence of a message row, is what says the transcript was pulled. + */ + @Test + fun `a synced group the device never fetched asks for its messages`() = runTest { + feedReturns(group(latestEventSequence = 9)) + coEvery { metadataDataSource.getLatestEventSequence(chatId) } returns 0 + + subject.performGroupFeedSync() + + assertEquals(GroupFeedDelegate.Event.LoadMessages(chatId), subject.events.first()) + } + + @Test + fun `a synced group whose cursor is behind the feed head streams the gap`() = runTest { + feedReturns(group(latestEventSequence = 9)) + coEvery { metadataDataSource.getLatestEventSequence(chatId) } returns 5 + + subject.performGroupFeedSync() + + assertEquals(GroupFeedDelegate.Event.DeltaSyncNeeded(chatId), subject.events.first()) + } + + @Test + fun `a synced group that is already caught up asks for nothing`() = runTest { + feedReturns(group(latestEventSequence = 9)) + coEvery { metadataDataSource.getLatestEventSequence(chatId) } returns 9 + + subject.performGroupFeedSync() + + assertNull(withTimeoutOrNull(1_000) { subject.events.first() }) + } + @Test fun `joining persists the chat and asks for its messages`() = runTest { coEvery { controller.joinChat(chatId) } returns Result.success(group()) From d3d0785e36a37efb6866f67ce1b613209cbcbaab Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 16 Sep 2026 09:31:35 -0400 Subject: [PATCH 14/19] fix(messenger): bound the sender-run peek MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MessageRow asked LazyPagingItems for the item above the one it was drawing — peek(index + 1) under reverseLayout — without checking the snapshot had one. The oldest loaded message never does, so a transcript holding a single message crashed on compose: IndexOutOfBoundsException: Illegal attempt to access index 1 in ItemSnapshotList of size 1 That was reachable in group chats, which until the previous commit cached one message and no more, but the bound was missing for any list. bottomSpacingFor already guards its peek(index - 1) the same way; startsSenderRun treats a null neighbour as the start of a run, which is what the top of a loaded page is. --- .../app/messenger/internal/screens/components/MessageRow.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 1a416ccb5c..04492fac3a 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 @@ -214,7 +214,9 @@ internal fun MessageRow( val sender = item.sender val runStart = startsSenderRun( current = item, - older = messages.peek(index + 1), + // Bounded: `peek` throws off the end of the snapshot, and the oldest loaded + // message has no item above it — a one-message transcript crashed here. + older = if (index + 1 < messages.itemCount) messages.peek(index + 1) else null, ) Row( modifier = Modifier.fillMaxWidth(), From c9a694daf19ab4e0e5664d2e4c7d5d97191abada Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 16 Sep 2026 09:31:42 -0400 Subject: [PATCH 15/19] fix(messenger): let a group's rules decide typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing unlocked once the chat held a cash message. That is the tip DM's rule — the bottom bar is a "Send Tip" call to action until the first payment — and a group inherited it by falling through, so a group nobody had tipped into had no composer at all. A group already has an answer to who may post: its chat rules, evaluated into GroupAccess against the live balance. A group that reaches the composer is Membered, because every other state renders the gate bar in its place, so typing follows membership rather than tip history. The DM path is unchanged, and so is the TIP_DM-scoped check that decides whether a send goes out as a tip. --- .../app/messenger/internal/ChatViewModel.kt | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) 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 f21fa63401..4b155d9f43 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 @@ -953,16 +953,27 @@ internal class ChatViewModel @Inject constructor( .onEach { typists -> dispatchEvent(Event.TypistsUpdated(typists)) } .launchIn(viewModelScope) - // Enable typing notifications once a payment has been exchanged - stateFlow.mapNotNull { it.chatId } - .distinctUntilChanged() - .flatMapLatest { chatId -> - chatCoordinator.observeMessages(chatId) - .map { messages -> - messages.any { msg -> msg.content.any { it is MessageContent.Cash } } - } - .distinctUntilChanged() + // A DM opens its composer once a payment has been exchanged. A group has no such + // exchange to wait for: its own rules say who may post, and [GroupAccess] has already + // applied them — a group that reaches the composer at all is [GroupAccess.Membered], and + // one that has not is showing the gate bar instead. Letting a group fall through to the + // DM rule left it unable to type until someone tipped into it. + combine( + stateFlow.mapNotNull { it.chatId }.distinctUntilChanged(), + stateFlow.map { it.subject is ChatSubject.Group }.distinctUntilChanged(), + ::Pair, + ) + .flatMapLatest { (chatId, isGroup) -> + if (isGroup) { + flowOf(true) + } else { + chatCoordinator.observeMessages(chatId) + .map { messages -> + messages.any { msg -> msg.content.any { it is MessageContent.Cash } } + } + } } + .distinctUntilChanged() .onEach { dispatchEvent(Event.TypingEnabled(it)) } .launchIn(viewModelScope) } From 9f7b66c4ac9b709e9049670f5eaa8e29d81cecca Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 16 Sep 2026 10:16:43 -0400 Subject: [PATCH 16/19] feat(messenger): leave the cash button out of a group Sending cash into a group is not wired up, so the composer row drops the button rather than showing one that does nothing. Where it is shown it now condenses to the icon for a group the same way it does for a tip chat: a group has no single counterparty to "Send $" to, and the expanded pill would take the row from a composer that is always open there. --- .../messenger/internal/screens/components/ChatBottomBar.kt | 4 +++- .../messenger/internal/screens/components/SendCashButton.kt | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index 1dc1459bad..a02b95716c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -220,7 +220,9 @@ internal fun UserControlBottomBar( CancelEditButton( onClick = { dispatch(ChatViewModel.Event.CancelEdit) }, ) - } else { + } else if (state.chatType != ChatType.GROUP) { + // Sending cash into a group is not wired up yet, so the button is left + // out rather than shown and dead. The composer takes the row on its own. SendCashButton( state = state, hazeState = hazeState, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt index 4e9350c65a..b0de4a28ef 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SendCashButton.kt @@ -63,7 +63,11 @@ internal fun RowScope.SendCashButton( // is the entire bar. Condensing it there would leave a full-width transparent "$"; what the // chat actually needs is its one call to action, so it stays white and says what it does. val isCallToAction = isTipChat && !canType - val isTyping = !isCallToAction && (isTipChat || state.chatInputState.text.isNotEmpty()) + // A group is condensed like a tip chat: the group is not one counterparty to "Send $" to, and + // the expanded pill would take the row from a composer that is always open there. + val isGroupChat = state.chatType == ChatType.GROUP + val isTyping = !isCallToAction && + (isTipChat || isGroupChat || state.chatInputState.text.isNotEmpty()) // Colors ease slowly and independently of the width/label so the fill change reads as one calm // transition instead of snapping with the resize — but NOT on the first settle. A tip chat opens From e16a5b9173234adde574ec708af3eee2d0b5080d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 16 Sep 2026 10:28:23 -0400 Subject: [PATCH 17/19] feat(messenger): open a profile from a group's transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A group's transcript had two things that look tappable and were not: the picture beside each incoming bubble, and the info card at the head of the thread that a DM uses to reach its counterparty's profile. The gutter picture opens the sender's profile. The transcript has already resolved that person to draw the picture, so the tap answers from the same map rather than fetching them again — `senderProfiles` becomes a held StateFlow for it. The card opens the group's own profile, a new step in the chat flow. It carries no payload: a group is identified by the conversation the flow is already on, and parcelling one would mean parcelling its rules. The screen reads it off the shared view model, as the transcript does. That profile shows the group's picture, title and size, and offers Leave Chat to a member. `leave` already clears the membership before the call returns, so the screen closes onto a transcript that is showing the join gate again. --- .../com/flipcash/app/core/chat/ChatStep.kt | 12 ++ .../core/src/main/res/values/strings.xml | 6 + .../flipcash/app/messenger/ChatFlowScreen.kt | 15 +++ .../app/messenger/internal/ChatSubject.kt | 7 +- .../app/messenger/internal/ChatViewModel.kt | 68 +++++++++- .../internal/screens/MessengerScreen.kt | 22 +++- .../internal/screens/components/MessageRow.kt | 11 +- .../screens/profile/GroupProfileMenuItems.kt | 28 ++++ .../screens/profile/GroupProfileScreen.kt | 124 ++++++++++++++++++ .../app/messenger/internal/ChatSubjectTest.kt | 11 +- .../flipcash/shared/chat/models/ChatAction.kt | 11 ++ 11 files changed, 302 insertions(+), 13 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index f68858a255..8746b801c0 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -45,4 +45,16 @@ sealed interface ChatStep : FlowStep, Parcelable { @Parcelize @Serializable data class Profile(val contact: ChatParticipant): ChatStep + + /** + * The group's own profile — the counterpart of [Profile] for a chat that has no counterparty. + * + * Carries nothing, because there is nothing a group can be identified by that the flow does + * not already hold: the screen reads the group off the conversation's view model, the same + * way the transcript does. [Profile] has to carry its participant because a member's profile + * is opened from a bubble rather than from the chat the flow was opened on. + */ + @Parcelize + @Serializable + data object GroupProfile : ChatStep } diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index d94a055ab7..640e4e339f 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -898,6 +898,12 @@ This chat is open to Flipcash staff only. Buy More $%1$s Join Chat + Leave Chat + Leave + Leave %1$s? + You won’t receive new messages from this chat. You can join it again if it still lets you in + Something Went Wrong + We were unable to leave the chat. Please try again Are You Sure? Anyone you sent the link to won\'t be able to collect the cash diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 878d9bcf45..8ff43e177e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -24,6 +24,7 @@ import com.flipcash.app.messenger.internal.screens.cash.ChatAmountEntryContent import com.flipcash.app.messenger.internal.screens.cash.ChatInitPaymentSheet import com.flipcash.app.messenger.internal.screens.profile.ChatProfileScreen import com.flipcash.app.messenger.internal.screens.profile.ChatProfileViewModel +import com.flipcash.app.messenger.internal.screens.profile.GroupProfileScreen import com.getcode.navigation.annotatedEntry import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.FlowHost @@ -89,6 +90,9 @@ private fun chatEntryProvider( annotatedEntry { step -> FlowChatProfileScreen(step.contact) } + annotatedEntry { + FlowGroupProfileScreen() + } } @Composable @@ -230,3 +234,14 @@ private fun FlowChatProfileScreen(participant: ChatParticipant) { }.launchIn(this) } } + +/** + * The group's profile, on the conversation's own view model. + * + * No [LaunchedEffect] seeding it the way [FlowChatProfileScreen] seeds a participant: the shared + * view model is already open on this group, so there is nothing to hand it. + */ +@Composable +private fun FlowGroupProfileScreen() { + GroupProfileScreen(flowSharedViewModel()) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt index 19af6aeca6..2aa6dc6c5f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt @@ -56,7 +56,7 @@ sealed interface ChatSubject { override fun asParticipant(): ChatParticipant = participant } - /** A `TIP_DM`. The only arm with a profile to open — blocking is reached through it. */ + /** A `TIP_DM`. Blocking is reached through its profile. */ data class TipUser(val participant: ChatParticipant.TipUser) : ChatSubject { override val title: String get() = participant.name.orEmpty() // `handle` already carries the leading `@` (see `String.asHandle`), so this is the raw @@ -83,7 +83,10 @@ sealed interface ChatSubject { ) : ChatSubject { override val title: String get() = groupTitle.orEmpty() override val subtitle: String? get() = null - override val canViewProfile: Boolean get() = false + // The group's own profile, not a counterparty's — which is why this is true while + // [asParticipant] stays null. The route the tap takes is decided per arm, in + // MessengerScreen's ChatAction.ViewProfile handler. + override val canViewProfile: Boolean get() = true override fun asParticipant(): ChatParticipant? = null } } 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 4b155d9f43..347e07a0a6 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 @@ -76,6 +76,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop @@ -283,6 +284,15 @@ internal class ChatViewModel @Inject constructor( /** The gate's "Join Chat" button. */ data object JoinChat : Event + + /** The group profile's "Leave Chat" row, which puts the confirmation up. */ + data object LeaveChat : Event + + /** The user confirmed the leave. */ + data object LeaveConfirmed : Event + + /** The leave went through, so whatever is showing the group's profile should close. */ + data object LeftChat : Event data class OnCurrencySymbolUpdated(val symbol: String): Event data class OnChatInitFeeUpdated(val formatted: String?) : Event data object RefreshContact : Event @@ -401,12 +411,27 @@ internal class ChatViewModel @Inject constructor( * See SenderResolver. */ @OptIn(ExperimentalCoroutinesApi::class) - private val senderProfiles: Flow?> = + private val senderProfiles: StateFlow?> = stateFlow.map { it.chatType == ChatType.GROUP } .distinctUntilChanged() .flatMapLatest { isGroup -> if (isGroup) chatCoordinator.observeSenderProfiles() else flowOf(null) } + // Held rather than cold so a tap on a sender's picture can read the profile that drew + // it. The transcript is the only thing that asks for these, so the map the paging + // stream is using is the one to answer from — looking the sender up again would be a + // second source for an identity already on screen. + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + /** + * The member behind [userId], as a participant a profile screen can be opened on. + * + * Null until the transcript has resolved them, which it has by the time their picture is + * drawn — the picture is what this is reached from. + */ + fun memberParticipant(userId: ID): ChatParticipant.TipUser? = + senderProfiles.value?.get(userId.hexEncodedString()) + ?.let { ChatParticipant.TipUser(userId, it) } @OptIn(ExperimentalCoroutinesApi::class) val messages: Flow> = @@ -1067,6 +1092,42 @@ internal class ChatViewModel @Inject constructor( } .launchIn(viewModelScope) + eventFlow.filterIsInstance() + .mapNotNull { stateFlow.value.subject as? ChatSubject.Group } + .onEach { group -> + BottomBarManager.showAlert( + title = resources.getString( + R.string.prompt_title_leaveChat, + group.title, + ), + message = resources.getString(R.string.prompt_description_leaveChat), + actions = listOf( + BottomBarAction(text = resources.getString(R.string.action_leaveChat)) { + dispatchEvent(Event.LeaveConfirmed) + } + ), + showCancel = true, + ) + } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { + val chatId = stateFlow.value.chatId ?: return@onEach + // `leave` clears the membership locally before the call, so the gate is back in + // place by the time the profile closes — the same single source the join reads. + chatCoordinator.leave(chatId) + .onSuccess { dispatchEvent(Event.LeftChat) } + .onFailure { + trace("failed to leave chat - ${it.localizedMessage}") + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToLeave), + message = resources.getString(R.string.error_description_failedToLeave), + ) + } + } + .launchIn(viewModelScope) + // A bubble is what the UI has; a citation is what the composer needs, and building one // reads the stored message. A message this device never stored drops the request rather // than opening a strip with nothing in it. @@ -1478,7 +1539,10 @@ internal class ChatViewModel @Inject constructor( } is Event.OnRuleTickerResolved -> { state -> state.copy(ruleTicker = event.ticker) } is Event.OnGroupAccessResolved -> { state -> state.copy(groupAccess = event.access) } - Event.JoinChat -> { state -> state } + Event.JoinChat, + Event.LeaveChat, + Event.LeaveConfirmed, + Event.LeftChat -> { state -> state } is Event.OnTipUserResolved -> { state -> // A device contact, once matched, wins over the server profile (it carries the // phone number and the user's own naming). Otherwise this is a tip DM: adopt the diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index cfaaee27e5..c6871f8e21 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -15,6 +15,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.compose.collectAsLazyPagingItems import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.GroupAccess import com.flipcash.app.messenger.internal.screens.components.ChatTopBar @@ -109,14 +110,25 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { ChatAction.JoinChat -> viewModel.dispatchEvent(ChatViewModel.Event.JoinChat) is ChatAction.ViewProfile -> { - // The triggers (top-bar tap, contact-card chevron) are only clickable for tip DMs - // (see State.canViewProfile), so no gating is needed here. - state.participant?.let { - keyboard.hideIfVisible { - navigator.push(ChatStep.Profile(it)) + // The triggers (top-bar tap, info-card chevron) are only clickable for subjects + // that have a profile (see State.canViewProfile), so no gating is needed here. + // Which profile depends on the subject: a DM's is its counterparty's, and a group + // is its own — it has no participant to open one on. + keyboard.hideIfVisible { + when (state.subject) { + is ChatSubject.Group -> navigator.push(ChatStep.GroupProfile) + else -> state.participant?.let { navigator.push(ChatStep.Profile(it)) } } } } + + is ChatAction.ViewMemberProfile -> { + // Resolved in the view model: the profile that drew the picture in the gutter is + // the one to open, and the transcript already holds it. + viewModel.memberParticipant(action.userId)?.let { + keyboard.hideIfVisible { navigator.push(ChatStep.Profile(it)) } + } + } } Unit 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 04492fac3a..baf28b438a 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 @@ -5,6 +5,7 @@ import androidx.compose.animation.EnterTransition import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState @@ -235,7 +236,15 @@ internal fun MessageRow( access = BlobAccessContext.profile(sender.userId), modifier = Modifier .fillMaxSize() - .clip(CircleShape), + .clip(CircleShape) + // The picture is the only handle the transcript gives on + // the person behind a bubble. Inert while the backdrop is + // up, like every other target on the row. + .addIf(!selecting) { + Modifier.clickable { + onAction(ChatAction.ViewMemberProfile(sender.userId)) + } + }, ) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt new file mode 100644 index 0000000000..ab90b97082 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt @@ -0,0 +1,28 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Logout +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.stringResource +import com.flipcash.app.menu.FullMenuItem +import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.features.messenger.R + +/** + * The one thing a group's profile can act on. + * + * Its action is a [ChatViewModel.Event] rather than an event of the profile's own, because the + * group is the conversation's — the leave has to clear the membership the transcript is reading, + * and there is one view model holding it. + */ +internal data object LeaveChat : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.AutoMirrored.Outlined.Logout) + + override val name: String + @Composable get() = stringResource(R.string.title_leaveChat) + + override val action: ChatViewModel.Event = ChatViewModel.Event.LeaveChat +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt new file mode 100644 index 0000000000..80144dac89 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt @@ -0,0 +1,124 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import android.os.Parcelable +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.menu.MenuItem +import com.flipcash.app.menu.MenuList +import com.flipcash.app.messenger.internal.ChatSubject +import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.screens.components.ChatSubjectAvatar +import com.flipcash.features.messenger.R +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.CodeScaffold +import kotlinx.coroutines.flow.filterIsInstance + +/** + * The group's own profile, reached from the info card at the head of its transcript. + * + * Driven by the conversation's [ChatViewModel] rather than one of its own: everything on this + * screen — the title, the picture, the member count, the membership the leave clears — is state the + * transcript is already holding and keeping current from the roster. A second view model would + * mean a second copy going stale behind this one. + */ +@Composable +internal fun GroupProfileScreen(viewModel: ChatViewModel) { + val flowNavigator = rememberFlowNavigator() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val group = state.subject as? ChatSubject.Group + + // A group you have left has nothing left on this screen to act on, so it closes back to the + // transcript — which is now showing the gate in place of the composer. + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .collect { flowNavigator.back() } + } + + CodeScaffold( + topBar = { + AppBarWithTitle(onBackIconClicked = { flowNavigator.back() }) + }, + ) { innerPadding -> + MenuList( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + // Leaving is the only thing on offer, and only to someone who is in the group. For + // anyone else this is the identity header on its own. + items = if (group?.isMember == true) { + listOf>(LeaveChat) + } else { + emptyList() + }, + header = { + GroupProfileHeader( + group = group, + modifier = Modifier + .fillMaxWidth() + .padding(top = CodeTheme.dimens.grid.x7), + ) + }, + onItemClick = { viewModel.dispatchEvent(it.action) }, + ) + } +} + +@VisibleForTesting +@Composable +internal fun GroupProfileHeader( + group: ChatSubject.Group?, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ChatSubjectAvatar( + subject = group, + modifier = Modifier + .size(CodeTheme.dimens.staticGrid.x17) + .clip(CircleShape), + ) + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + text = group?.title.orEmpty(), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // The size stands where a person's handle does: it is the one thing a group is identified + // by beyond its name, and it is what a join or a leave changes. + if (group != null) { + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1), + text = pluralStringResource( + R.plurals.subtitle_chatMemberCount, + group.memberCount.toInt(), + group.memberCount.toString(), + ), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSubjectTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSubjectTest.kt index 119d272211..aed53e1e35 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSubjectTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSubjectTest.kt @@ -87,12 +87,17 @@ class ChatSubjectTest { assertFalse(subject.isMember) } + /** + * A contact DM is the only subject with no profile behind it: its counterparty is a phone + * number in the address book, not an account. A group's profile is its own — which is why it + * says yes here while [ChatSubject.asParticipant] still says there is no one to open one on. + */ @Test - fun `only a tip subject opens a profile`() { + fun `every subject but a contact DM opens a profile`() { assertTrue(ChatSubject.TipUser(ChatParticipant.TipUser(userId, profile)).canViewProfile) - assertFalse(ChatSubject.Contact(ChatParticipant.Contact(contact)).canViewProfile) - assertFalse( + assertTrue( ChatSubject.Group(chatId, "Flipcash Staff", null, 3L, null, true).canViewProfile ) + assertFalse(ChatSubject.Contact(ChatParticipant.Contact(contact)).canViewProfile) } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index 7793527335..31945dd602 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -1,6 +1,7 @@ package com.flipcash.shared.chat.models import androidx.compose.runtime.staticCompositionLocalOf +import com.getcode.opencode.model.core.ID import com.getcode.solana.keys.Mint sealed interface ChatAction { @@ -10,6 +11,16 @@ sealed interface ChatAction { data class ViewToken(val mint: Mint) : ChatAction data object ViewProfile : ChatAction + /** + * Opens the profile of the member who sent a bubble, from their picture in the gutter. + * + * Separate from [ViewProfile], which opens whoever the chat itself is with. A group is + * with no one, so the only profile reachable from its transcript is a sender's, and the + * id is how the handler finds them — the profile it needs is the one the transcript + * already resolved to draw this picture. + */ + data class ViewMemberProfile(val userId: ID) : ChatAction + /** Joins the group this screen is showing. */ data object JoinChat : ChatAction From 076fe7975e5b7d0d467e11773bc1c47f9d995fae Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 16 Sep 2026 10:38:54 -0400 Subject: [PATCH 18/19] feat(messenger): hold back the group leave action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row is the only thing on a group's profile that acts, and a way out of a group is not what to hand someone while joining one is still settling. The screen is its identity header until then. Everything behind the row stays wired — the confirmation, the call, the close on success — so bringing it back is the one constant. --- .../screens/profile/GroupProfileScreen.kt | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt index 80144dac89..7a52d3db86 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt @@ -31,6 +31,15 @@ import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.theme.CodeScaffold import kotlinx.coroutines.flow.filterIsInstance +/** + * Whether the group's profile offers a way out of the group. + * + * Off while the rest of group membership settles: a leave is not something to hand someone before + * the join it undoes is finished. Everything behind it — the confirmation, the call, the close — + * is in place, so this is the only line to change when it ships. + */ +private const val SHOW_LEAVE_ACTION = false + /** * The group's own profile, reached from the info card at the head of its transcript. * @@ -62,9 +71,10 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { modifier = Modifier .fillMaxSize() .padding(innerPadding), - // Leaving is the only thing on offer, and only to someone who is in the group. For - // anyone else this is the identity header on its own. - items = if (group?.isMember == true) { + // Leaving is the only thing on offer, and only to someone who is in the group — but + // it is held back for now, so this is the identity header on its own. The leave + // itself is wired end to end behind [SHOW_LEAVE_ACTION]; flipping it puts the row back. + items = if (SHOW_LEAVE_ACTION && group?.isMember == true) { listOf>(LeaveChat) } else { emptyList() From 73fc6763b953c7f54304122bf3cc7e6c705a7a36 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 16 Sep 2026 10:46:07 -0400 Subject: [PATCH 19/19] fix(messenger): name the author of a quoted group message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A citation asked `State.participant` who wrote the message it cites. In a DM that is right — the counterparty sent everything the viewer did not. A group has no participant, so every quote of someone else came out with a blank name, in the composer strip and in the sent bubble alike. The cited message carries its own sender, so the name now comes from that sender's profile, out of the map the transcript already uses to draw their bubble. A sender the device has not seen is requested rather than left unresolved for the session. --- .../app/messenger/internal/ChatViewModel.kt | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) 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 347e07a0a6..11ae602285 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 @@ -524,16 +524,32 @@ internal class ChatViewModel @Inject constructor( * [ChatParticipant.Contact] wraps a device contact and carries no user id, so the participant * is not a usable source for a counterparty's colour. */ + /** + * Who the citation says said it. + * + * A DM's counterparty is the chat's, so the participant answers for every message they sent. + * A group has no counterparty and a different name per message, so the answer is the cited + * sender's own profile — the same map the transcript draws their bubble from. Missing means + * the device has never seen them, which a request fixes for the next emission; the strip + * showing a blank name is what it looked like before. + */ + private suspend fun ChatMessage.quoteAuthorName(): String { + if (isFromSelf) return resources.getString(R.string.title_you) + + val profiles = senderProfiles.value ?: return stateFlow.value.participant?.name.orEmpty() + val senderId = senderId ?: return "" + val profile = profiles[senderId.hexEncodedString()] + ?: return "".also { chatCoordinator.requestSenderProfile(senderId) } + + return profile.displayName + } + private suspend fun ChatMessage.toQuote(): ChatQuote { val body = content.firstOrNull() val palette = senderId?.let { generateComplementaryColorPalette(it) } return ChatQuote( messageId = messageId, - authorName = if (isFromSelf) { - resources.getString(R.string.title_you) - } else { - stateFlow.value.participant?.name.orEmpty() - }, + authorName = quoteAuthorName(), snippet = when (body) { is MessageContent.Cash -> ChatQuoteSnippet.Cash( amount = body.amount,