Skip to content

feat(chat): render a group conversation - #1470

Merged
bmc08gt merged 19 commits into
code/cashfrom
feat/group-chat-ui
Sep 16, 2026
Merged

bmc08gt merged 19 commits into
code/cashfrom
feat/group-chat-ui

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Stack:

  1. feat(chat): group chat contract and service layer #1468 — contract and service layer
  2. feat(chat): group chats in the conversation feed #1469 — feed and roster
  3. feat(chat): render a group conversation #1470 — UI (this PR)

Third of three, on top of feat/group-chat-feed. A group chat renders, and the Chats tab carries groups.

The conversation screen used to read a contact. It now reads a ChatSubject — a contact DM, a tip DM, or a group — and the title bar, info card and messenger each render from that one thing rather than each deciding what a null contact means.

  • Per-sender attribution in the transcript: a picture in the gutter at the start of a run, resolved from the roster, with senders the roster subset omits requested on demand.
  • What a viewer may do in a group comes from its rules, not from having tipped someone.
  • A group the viewer has not joined shows its chrome over a blurred transcript with a join gate, rather than an empty screen.
  • Groups in the Chats tab, and screenshot coverage for the group chrome.

The last stretch is what device testing turned up: the group feed syncs regardless of the feature flag (the flag is presentation filtering, so a chat that arrives while it is off is still there when it goes on), a synced group fetches its transcript instead of opening on its one cached preview, the sender-run peek stays inside the loaded page, and the cash button is left out of a group, which has no single counterparty to send to.

Tapping a sender's picture opens their profile; tapping the info card opens the group's own. Leaving a group is wired but held back behind a constant — a way out is not what to hand someone while joining is still settling.

@bmc08gt bmc08gt self-assigned this Sep 16, 2026
@github-actions github-actions Bot added the type: feature New functionality label Sep 16, 2026
@bmc08gt
bmc08gt force-pushed the feat/group-chat-ui branch 2 times, most recently from 5dfed6d to a14d31f Compare September 16, 2026 15:34
@bmc08gt
bmc08gt force-pushed the feat/group-chat-feed branch 2 times, most recently from 598da87 to f8f2ec5 Compare September 16, 2026 16:03
The flag was read once inside onUserLoggedIn, so flipping it on only took
effect after a process restart replayed login hydration — the toggle looked
dead until you killed the app.

Make it what its description already claims: a filter on the chat list. Every
read of the chat cache is scoped to the chat types its caller asks for —
feed(), feedPaged() and observeUnreadConversations() all take the types, and
observeFeedPaged filters in SQL — so a group stored with the flag off reaches
no list and no badge. Gating the fetch also contradicted the roster-change
routing beside it, which was never gated.

Drops the chat module's dependency on featureflags; the flag now lives only in
the chat list that filters on it.
A group feed sync persisted each group's last-message preview and stopped
there, so opening a group the device had only ever seen in the feed showed
exactly one bubble. The DM feed has handled this since it was written: after
persisting, it compares each chat's applied cursor against the feed head and
emits LoadMessages or DeltaSyncNeeded. The group feed now does the same, and
the coordinator routes the new DeltaSyncNeeded to the event stream.

The applied cursor is the right test rather than "does this chat have
messages" — persisting the preview makes the latter true for a chat whose
transcript was never pulled.
MessageRow asked LazyPagingItems for the item above the one it was drawing —
peek(index + 1) under reverseLayout — without checking the snapshot had one.
The oldest loaded message never does, so a transcript holding a single
message crashed on compose:

  IndexOutOfBoundsException: Illegal attempt to access index 1 in
  ItemSnapshotList of size 1

That was reachable in group chats, which until the previous commit cached one
message and no more, but the bound was missing for any list. bottomSpacingFor
already guards its peek(index - 1) the same way; startsSenderRun treats a null
neighbour as the start of a run, which is what the top of a loaded page is.
Typing unlocked once the chat held a cash message. That is the tip DM's rule —
the bottom bar is a "Send Tip" call to action until the first payment — and a
group inherited it by falling through, so a group nobody had tipped into had
no composer at all.

A group already has an answer to who may post: its chat rules, evaluated into
GroupAccess against the live balance. A group that reaches the composer is
Membered, because every other state renders the gate bar in its place, so
typing follows membership rather than tip history. The DM path is unchanged,
and so is the TIP_DM-scoped check that decides whether a send goes out as a
tip.
Sending cash into a group is not wired up, so the composer row drops the
button rather than showing one that does nothing. Where it is shown it now
condenses to the icon for a group the same way it does for a tip chat: a
group has no single counterparty to "Send $" to, and the expanded pill
would take the row from a composer that is always open there.
A group's transcript had two things that look tappable and were not: the
picture beside each incoming bubble, and the info card at the head of the
thread that a DM uses to reach its counterparty's profile.

The gutter picture opens the sender's profile. The transcript has already
resolved that person to draw the picture, so the tap answers from the same
map rather than fetching them again — `senderProfiles` becomes a held
StateFlow for it.

The card opens the group's own profile, a new step in the chat flow. It
carries no payload: a group is identified by the conversation the flow is
already on, and parcelling one would mean parcelling its rules. The screen
reads it off the shared view model, as the transcript does.

That profile shows the group's picture, title and size, and offers Leave
Chat to a member. `leave` already clears the membership before the call
returns, so the screen closes onto a transcript that is showing the join
gate again.
The row is the only thing on a group's profile that acts, and a way out of
a group is not what to hand someone while joining one is still settling.
The screen is its identity header until then.

Everything behind the row stays wired — the confirmation, the call, the
close on success — so bringing it back is the one constant.
A citation asked `State.participant` who wrote the message it cites. In a
DM that is right — the counterparty sent everything the viewer did not. A
group has no participant, so every quote of someone else came out with a
blank name, in the composer strip and in the sent bubble alike.

The cited message carries its own sender, so the name now comes from that
sender's profile, out of the map the transcript already uses to draw their
bubble. A sender the device has not seen is requested rather than left
unresolved for the session.
@bmc08gt
bmc08gt changed the base branch from feat/group-chat-feed to code/cash September 16, 2026 16:07
@bmc08gt
bmc08gt merged commit a20c29a into code/cash Sep 16, 2026
3 checks passed
@bmc08gt
bmc08gt deleted the feat/group-chat-ui branch September 16, 2026 16:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant