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