Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 +
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading