Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ad0dc63
feat(chat): observe a single chat's row
bmc08gt Sep 16, 2026
71c7ede
feat(chat): observe a chat's metadata and membership together
bmc08gt Sep 16, 2026
7c67213
feat(chat): resolve senders the roster subset omits
bmc08gt Sep 16, 2026
ac0632b
feat(chat): render the messenger from a chat subject
bmc08gt Sep 16, 2026
64556e9
feat(chat): render the chat title bar from its subject
bmc08gt Sep 16, 2026
8c1d575
feat(chat): render the chat info card from its subject
bmc08gt Sep 16, 2026
e364b2c
feat(chat): attribute group messages to who sent them
bmc08gt Sep 16, 2026
60cc4b3
feat(chat): decide what a viewer may do in a group
bmc08gt Sep 16, 2026
dbcb2ec
feat(chat): gate a group the viewer has not joined
bmc08gt Sep 16, 2026
f911040
feat(chat): show groups in the chats tab
bmc08gt Sep 16, 2026
6bc8ce5
test(chat): render the group chrome to screenshots
bmc08gt Sep 16, 2026
f3b073c
fix(chat): sync the group feed regardless of the GroupChats flag
bmc08gt Sep 16, 2026
fd115ed
fix(chat): fetch a synced group's transcript
bmc08gt Sep 16, 2026
d3d0785
fix(messenger): bound the sender-run peek
bmc08gt Sep 16, 2026
c9a694d
fix(messenger): let a group's rules decide typing
bmc08gt Sep 16, 2026
9f7b66c
feat(messenger): leave the cash button out of a group
bmc08gt Sep 16, 2026
e16a5b9
feat(messenger): open a profile from a group's transcript
bmc08gt Sep 16, 2026
076fe79
feat(messenger): hold back the group leave action
bmc08gt Sep 16, 2026
73fc676
fix(messenger): name the author of a quoted group message
bmc08gt Sep 16, 2026
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 @@ -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
}
22 changes: 22 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,12 @@
<item quantity="other">%1$s members</item>
</plurals>

<plurals name="subtitle_chatMemberCount">
<item quantity="zero">0 people</item>
<item quantity="one">1 person</item>
<item quantity="other">%1$s people</item>
</plurals>

<string name="action_addCash">Add Cash</string>
<string name="title_selectMethod">Select Method</string>
<string name="title_amountToAdd">Amount to Add</string>
Expand Down Expand Up @@ -885,6 +891,19 @@

<string name="label_fromYourContacts">From Your Contacts</string>
<string name="label_addContact">Add Contact</string>
<string name="label_chat_balanceRequirement">Balance Requirement: %1$s of $%2$s</string>
<string name="label_chat_balanceRequirement_anyToken">Balance Requirement: %1$s</string>
<string name="subtitle_chatGate_minimumBalance">Minimum Balance: %1$s of $%2$s</string>
<string name="subtitle_chatGate_minimumBalance_anyToken">Minimum Balance: %1$s</string>
<string name="subtitle_chatGate_staffOnly">This chat is open to Flipcash staff only.</string>
<string name="action_buyMoreToken">Buy More $%1$s</string>
<string name="action_joinChat">Join Chat</string>
<string name="title_leaveChat">Leave Chat</string>
<string name="action_leaveChat">Leave</string>
<string name="prompt_title_leaveChat">Leave %1$s?</string>
<string name="prompt_description_leaveChat">You won’t receive new messages from this chat. You can join it again if it still lets you in</string>
<string name="error_title_failedToLeave">Something Went Wrong</string>
<string name="error_description_failedToLeave">We were unable to leave the chat. Please try again</string>

<string name="prompt_title_cancelCashLinkPostShare">Are You Sure?</string>
<string name="prompt_description_cancelCashLinkPostShare">Anyone you sent the link to won\'t be able to collect the cash</string>
Expand All @@ -898,6 +917,9 @@
<string name="label_chat_preview_tippedCash">You tipped %1$s</string>
<string name="label_chat_preview_receivedTip">Someone tipped you %1$s</string>
<string name="label_chat_preview_sentMessage">You: %1$s</string>
<string name="label_chat_preview_senderMessage">%1$s: %2$s</string>
<string name="label_chat_preview_sentCash_bySender">%1$s sent %2$s</string>
<string name="label_chat_preview_tippedCash_bySender">%1$s tipped %2$s</string>
<string name="label_isTyping">Is Typing…</string>

<string name="label_edited">Edited</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -89,6 +90,9 @@ private fun chatEntryProvider(
annotatedEntry<ChatStep.Profile> { step ->
FlowChatProfileScreen(step.contact)
}
annotatedEntry<ChatStep.GroupProfile> {
FlowGroupProfileScreen()
}
}

@Composable
Expand Down Expand Up @@ -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<ChatViewModel>())
}
Original file line number Diff line number Diff line change
@@ -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<ChatRuleRequirement.MinimumBalance>().firstOrNull()
?: rules.speaker.filterIsInstance<ChatRuleRequirement.MinimumBalance>().firstOrNull()
}
Loading
Loading