From b566eb15ad35978821e0fef7677574cf674a313e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 12:16:08 -0400 Subject: [PATCH] fix(chat): stop a group re-rendering as a DM with one of its members When a group's roster changed, the chat screen redrew for about half a second as a tip DM with the first other member: their name in the title bar, no sender avatars, and the "$" composer, then "Send a Tip", before flipping back. The tip counterparty collector took the first non-self member of any chat, and OnTipUserResolved replaced the Group subject with it. observeMetadata combines the members table too, so one roster change emitted on both flows and OnGroupResolved only put the group back afterwards. Opening a group by chat id also dispatched OnTipDmDetected, since a group has no device contact either. Both reducers now keep a resolved group, the collector skips chats whose metadata says GROUP, and the open path checks the cached metadata before marking a tip DM. --- .../app/messenger/internal/ChatViewModel.kt | 28 +++++++++++++--- .../internal/ChatViewModelStateTest.kt | 32 +++++++++++++++++++ 2 files changed, 55 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 de8205c6a3..22c20b7af5 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 @@ -117,6 +117,7 @@ import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn @@ -1083,9 +1084,11 @@ internal class ChatViewModel @Inject constructor( val contact = contactCoordinator.lookupContactByDmChatId( identifier.chatId.toString() ) + val isGroup = chatCoordinator.observeMetadata(identifier.chatId).first() + ?.metadata?.type == ChatType.GROUP if (contact != null) { dispatchEvent(Event.OnContactFound(contact)) - } else { + } else if (!isGroup) { // No device contact backs this chat — it's a tip DM. Mark it immediately // (this lookup is local) so the send button renders condensed without // waiting on the profile below, then warm the member store (fetch + @@ -1135,10 +1138,19 @@ internal class ChatViewModel @Inject constructor( // profile — the same source the tips list uses. Reactive so it settles as soon as the // members are available and can't be missed by the send gate. Never clobbers a device // contact: the OnTipUserResolved reducer keeps an existing Contact participant. + // + // Skipped for a group, whose "other member" is only the first row of the roster. A roster + // change re-emits the members and the group's metadata together, so resolving one here would + // re-title the group as a DM with that member until OnGroupResolved lands again. stateFlow.mapNotNull { it.chatId } .distinctUntilChanged() - .flatMapLatest { chatCoordinator.observeMembers(it) } - .mapNotNull { members -> members.firstOrNull { it.userId != userManager.accountId } } + .flatMapLatest { chatId -> + combine( + chatCoordinator.observeMembers(chatId), + chatCoordinator.observeMetadata(chatId).map { it?.metadata?.type }, + ) { members, type -> members.takeUnless { type == ChatType.GROUP } } + } + .mapNotNull { members -> members?.firstOrNull { it.userId != userManager.accountId } } .distinctUntilChanged() .onEach { member -> dispatchEvent(Event.OnTipUserResolved(member.userId, member.userProfile)) } .launchIn(viewModelScope) @@ -2048,7 +2060,12 @@ internal class ChatViewModel @Inject constructor( chatType = ChatType.CONTACT_DM, ) } - Event.OnTipDmDetected -> { state -> state.copy(chatType = ChatType.TIP_DM) } + // "No device contact" is also true of every group, so a group that has already + // resolved keeps its type. + Event.OnTipDmDetected -> { state -> + if (state.subject is ChatSubject.Group) state + else state.copy(chatType = ChatType.TIP_DM) + } is Event.OnGroupResolved -> { state -> val metadata = event.membership.metadata state.copy( @@ -2092,7 +2109,8 @@ internal class ChatViewModel @Inject constructor( // 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.subject is ChatSubject.Contact) state + // A group has no counterparty: the member this names is just someone in the room. + if (state.subject is ChatSubject.Contact || state.subject is ChatSubject.Group) state else state.copy( subject = ChatSubject.TipUser( ChatParticipant.TipUser(event.userId, event.profile) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt index 6737467c55..8d8f7105c5 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt @@ -3,8 +3,10 @@ package com.flipcash.app.messenger.internal import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd 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.ChatType import com.flipcash.shared.chat.ChatDraftReply import com.flipcash.shared.chat.ChatDraftSnapshot import com.flipcash.shared.chat.ChatDraftSnippet @@ -242,6 +244,36 @@ class ChatViewModelStateTest { assertNotNull(restored.nameAccent) } + /** + * A roster change re-emits the members and the group's metadata together, and the tip + * counterparty resolver reads the first member that isn't the viewer. In a group that member is + * just someone in the room, so letting it through re-titles the group as a DM with them — header, + * avatars and composer — until the metadata lands again. + */ + @Test + fun `a member resolving as a tip counterparty does not turn a group into a DM`() { + val grouped = ChatViewModel.State(subject = group(isMember = true), chatType = ChatType.GROUP) + + val after = ChatViewModel.updateStateForEvent( + ChatViewModel.Event.OnTipUserResolved( + userId = List(16) { 1 }, + profile = UserProfile.Empty.copy(displayName = "Tanner"), + ) + )(grouped) + + assertEquals(grouped.subject, after.subject) + assertEquals(ChatType.GROUP, after.chatType) + } + + @Test + fun `a group with no device contact is not marked a tip DM`() { + val grouped = ChatViewModel.State(subject = group(isMember = true), chatType = ChatType.GROUP) + + val after = ChatViewModel.updateStateForEvent(ChatViewModel.Event.OnTipDmDetected)(grouped) + + assertEquals(ChatType.GROUP, after.chatType) + } + /** * Rule 5 against the real reducer rather than a model of it. `Event.EditMessage` is what moves * the user's words into the stash, and everything downstream reads them from there — so an