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 99ff557a2d..640e4e339f 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
@@ -885,6 +891,19 @@
From Your Contacts
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
+ 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
@@ -898,6 +917,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/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
new file mode 100644
index 0000000000..2aa6dc6c5f
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt
@@ -0,0 +1,121 @@
+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
+
+/**
+ * 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`. 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
+ // 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
+ // 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
+ }
+}
+
+/**
+ * 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
+}
+
+/**
+ * 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 7ae8a5fb54..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
@@ -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
@@ -48,8 +49,11 @@ 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.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
@@ -72,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
@@ -137,7 +142,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
@@ -218,10 +223,31 @@ 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,
+ /**
+ * 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,
) {
- // 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 +272,27 @@ 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
+
+ /** 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
+
+ /** 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
@@ -352,9 +399,48 @@ 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: StateFlow