Skip to content

feat(chat): create and join balance-gated public group chats - #1479

Merged
bmc08gt merged 3 commits into
code/cashfrom
feat/public-group-chat
Sep 18, 2026
Merged

bmc08gt merged 3 commits into
code/cashfrom
feat/public-group-chat

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

Public groups gate on a balance, so a chat the user has not joined still has something to show: a title, a picture, a member count, and the rule they are short of. That makes creating and joining one screen apart from each other but one destination — which is also where a push for an unjoined group has to land.

Sections 10153:22901 (creating) and 10153:22902 (joining).

Creating

NewGroupFlowScreen hosts the draft: the currency sheet (node 10127:118100) and the custom-amount keypad edit it and return. StartChat succeeding ends the flow and drops into the conversation — the invite is offered from inside the group rather than on the way out of creating it. The shared CreateGroupViewModel is scoped to the flow rather than to a step, which is also what gives one create attempt's idempotency key a lifetime long enough to survive a retry.

The 0.8.0 semantics the form honours:

  • The 16-byte idempotency key is minted when the user taps Create, not when the call is made, and the same key goes out on every retry of that attempt. A picture already uploaded for that attempt is reused rather than re-uploaded. Both are pinned by tests.
  • All six StartChatResponse.Result arms are handled. TITLE_MODERATED reads its flagged_category through the app's existing moderation-category mapping.
  • The creator has to satisfy the rule they are setting. GroupAccess.evaluate — the same predicate the join gate uses — decides whether Create is enabled, and RULES_NOT_SATISFIED from the server reads the same as that pre-call check. While that check fails, the caption under the requirement card says so in the error color rather than describing what the rule does to everyone else: a rule the creator is short of otherwise looks like a finished draft under a dimmed button.
  • The picture is uploaded first and reaches GroupChatParameters.picture as a blob.v1.BlobId for the original. The response's chat carries the server-derived renditions, so nothing is refetched.
  • Rules go out as a single listener MinimumBalanceRequirement: a fiat amount plus exactly one mint. speaker is left unset.

The heading switches from "Minimum Balance Required" to "Balance Requirement" when the creator picks the mint. Node 10127:118057 still asks, with the title typed and the form's opening mint untouched; node 10127:118194 has flipped on the changed mint with no amount yet. Because the form opens with a mint already named — the largest holding, so the creator starts on a rule they can satisfy — the heading reads a mintChosen flag set by the currency sheet rather than mint != null. Create is a capsule painted with the wallpaper accent, the same treatment the currency creator's bill step gives its Next action; below API 31 it falls back to the theme's action colors.

Joining

GetChat serves a non-member the title, picture, roster_summary and rules, so the gated preview (node 10127:117171) is built from real metadata. GroupGateBar states the group's requirement above the button in both states — node 10127:117120's eligible viewer reads the same line as a blocked one — because it is the chat's standing rule rather than this viewer's shortfall; the gate decides only whether the button joins or sends you to buy more. It reads that rule the same way the info card at the head of the transcript does, so the two lines about one group cannot disagree. The token is named rather than tickered, matching the create form and the balance list a requirement is satisfied from, and the reserve drops out of the requirement line altogether — "$100 of Dollars" says dollars twice, though Buy More still names it. That branding rule moved out of a private composable in LinkCardView into :apps:flipcash:core so one place decides it. A staff-only group is stated the same way, on the gate and on the info card, and a chat may set both rules, so both get a line. Staff-ness is UserFlags.is_staff — the field the rule is written against — rather than a bar the client treats as unsatisfiable; reading it as unsatisfiable meant a staff member who left such a group could not rejoin it. A refused join is reported: RULES_NOT_SATISFIED, DENIED and NOT_FOUND each say something different, and the button spins until the call returns, because membership arrives from the roster rather than from the join's own reply.

Roster updates arrive on event.v1.ChatUpdate.roster_updates and are convergent rather than sequenced, so they are applied by roster_summary.version — greater applies, lesser-or-equal drops, unreconcilable refetches. MemberJoined.metadata is set only when the joining member is the recipient, so that is what inserts the chat into your own list. The member count comes from RosterSummary.member_count.

Handing out the link

Node 10127:118315 is a wrap-content sheet on the chat flow carrying two rows, share and copy. Two things put it up: the empty transcript's CTA, and an "Invite People To Join" row on the group's profile. Both read the one url ChatViewModel builds, which is also what gates them — a DM, or a group this viewer has not joined, has no link and neither row appears.

Leaving

A second profile row, below the invite, sends LeaveChat behind a destructive confirmation. NotFound counts as success — a chat you are not in is the state the call was asking for. The row's condition is the roster's isMember, not the gate, so it goes away as the leave lands rather than when the balance rule next re-decides. A completed leave exits the chat flow to the list; closing back onto the transcript would leave the user reading a conversation from behind the gate they had just stepped outside of.

The design has no group-profile frame, so the row's placement and its confirmation follow the app's own conventions: MenuList offers no destructive style, and the red weight comes from the BottomBarManager alert.

One destination, two entry points

The invite link is https://app.flipcash.com/chat/<uuid>, built and parsed from common.v1.ChatId.value as the 16-byte UUID group chats carry. There is no invite-link RPC. Note the host: tipcard links use bare flipcash.com, and this is app.flipcash.com, which needed its own verified intent filter with a /chat/ path prefix.

A push tap for a group the user has not joined resolves to that same gated preview. ChatTapTargetPlanner makes that decision from push.v1.ChatMetadata.chat_type; the sender is read from message.sender_id rather than the deprecated sending_user_id, and hasMessage() keeps the fetch fallback for pushes that carry no message.

There is no roster push. The contract cannot deliver an "X joined your group" notification reliably, so none is built.

Two things to look at

The blurred transcript is decorative. No RPC returns a transcript to a non-member — GetChat is metadata only. The messages behind the blur in node 10127:117171 are a static placeholder layer, not a fetch. If the gate is meant to preview real content, that needs a contract change first.

The … preset has no specified destination. Nodes 10127:118014 through 10127:118237 show the chip but not what it opens; it opens a custom-amount keypad sheet that writes back to the draft.

Merge note

NotificationService.kt is also edited on the local feat/push-silent-sync-spike branch, which adds PushHandlingPlanner around the receive path. This PR only changes the chat tap-target branch of the tap handler, so the two edits are adjacent rather than competing, but whichever lands second should rebase rather than resolve by hand.

@bmc08gt bmc08gt self-assigned this Sep 17, 2026
@bmc08gt
bmc08gt force-pushed the feat/public-group-chat branch from 249cf2b to a22d7d9 Compare September 17, 2026 14:01
@github-actions github-actions Bot added type: feature New functionality area: network gRPC, connectivity, API, exchange rates area: notifications Push notifications, in-app messaging area: deeplinks Deep link handling, URL routing, and link parsing area: ui Compose UI, theme, components, resources and removed type: feature New functionality labels Sep 17, 2026
@bmc08gt
bmc08gt force-pushed the feat/public-group-chat branch from 7547fea to 12911c4 Compare September 17, 2026 22:06
@github-actions github-actions Bot added the area: tokens Token accounts, balances, token info label Sep 18, 2026
@bmc08gt
bmc08gt force-pushed the feat/public-group-chat branch 2 times, most recently from 72c027d to bf51287 Compare September 18, 2026 14:58
The comment said iOS still sorted on the exact value and carried the same card
swap latent in it. It doesn't: `StoredBalance.walletOrder` compares
`displayedUSDF`, which is `usdf.roundedToSmallestUnit()`, and breaks ties by
name — the same comparison at the same precision, recorded against the same
reasoning.

Comment only. Checked against the iOS working copy after the iOS session
disputed the claim.
…reens

`PhotoSelectionViewModel` held the loop that re-encodes a picked image inside the
upload policy's dimension, pixel and byte caps. The group-avatar picker needs the
same loop, and a second copy of a convergence loop is a second chance to shrink to
just over a cap: the failure is a server rejection at upload time, not a visible bug
at pick time.

`ImageUploadPreparer` in `:apps:flipcash:shared:blob` takes the loop, the four
constants and the `uploadMimeFor` mapping, and answers `Prepared`/`Unsupported`/
`TooLarge`/`Unreadable`. The refusal wording stays with the calling screen, which is
the only part that was ever screen-specific.

`:apps:flipcash:shared:blob` gains `:ui:resources` for `ContentReader`.
Nodes 10153:22901 and 10153:22902. Creating a group is a form over
`CreateGroupViewModel`: name, photo, visibility, and an optional reserve stated in
a chosen token, with Create refusing early — and saying why — when the creator
cannot meet their own rule. Joining one is the messenger screen a member already
sees, with the transcript withheld: a blur over a placeholder, and a gate where
the composer goes that states the group's standing requirement and offers Join or
Buy More.

`GroupAccess` is the one predicate behind both. It moves into
`:apps:flipcash:shared:chat` so the create form and the gate evaluate a viewer the
same way, against the chat's rules plus the viewer's balances and staff flag. The
blur, the placeholder and the gate all read a single flag off the screen's state,
so a composer can never appear over a chat that is still blurred, and the gate
holds its own join confirmation rather than waiting for the roster to replace it.

Invite links are `https://app.flipcash.com/chat/<uuid>`, resolved by `AppRouter`,
and a group push whose chat the user has not joined lands on that same gated
screen — one destination for the link and for the tap. Group profile gains Leave,
which pops back to the chat list, and Invite, which opens the sheet on demand
rather than on creation.

The transcript behind the blur is a static placeholder, not fetched messages:
there is no RPC that serves a non-member's history, and none is invented here.
@bmc08gt
bmc08gt force-pushed the feat/public-group-chat branch from bf51287 to ae59041 Compare September 18, 2026 15:07
@bmc08gt
bmc08gt merged commit 2461902 into code/cash Sep 18, 2026
3 checks passed
bmc08gt added a commit to code-payments/code-ios-app that referenced this pull request Sep 18, 2026
A non-member who cleared a group's balance rule read the real transcript with
Join Chat under it. Satisfying a chat's rules earns the right to join it, not
the right to read it from outside — so `.join` now withholds the transcript the
same way `.blocked` does, and membership is the only thing that lifts the blur.

Eligibility still decides what the panel offers: Join Chat for a viewer who
clears the bar, a buy for one who doesn't. Android draws the same line
(code-payments/code-android-app#1479).
bmc08gt added a commit to code-payments/code-ios-app that referenced this pull request Sep 18, 2026
* feat(chat): create and join a public group chat

A group chat had no way in and no way to make one. This adds both, plus the
push tap that has to land in the same place as an invite link.

Creating (node 10153:22901). "New Chat" replaces the old sheet with a pushed
screen offering "Create a Public Group" and "Find by Username". The group form
takes a title, an optional picture, and a minimum-balance rule over one mint.
Three things the contract requires:

- The idempotency key is minted when the user taps Create, not at the call
  site, and held for every retry of that attempt. The server derives chat
  identity from caller plus key, so a retry returns the chat the first attempt
  created; a key minted inside the call would make each retry a new chat.
- The creator has to satisfy the rule they set, or StartChat answers
  RULES_NOT_SATISFIED. `NewPublicGroupModel` checks the selected mint's balance
  before enabling Create and says why when it doesn't clear the bar.
- The picture uploads first and is held across retries, since StartChat only
  accepts a blob the caller already owns.

The form opens on a mint rather than on a blank: node 10127:118014 draws the
currency row naming a token while everything else is still empty. It seats the
wallet's top card, which `StoredBalance.walletOrder` puts at the largest
holding, so the creator starts on a rule they can satisfy — the same mint
Android's `BalanceOrder` picks. That makes "a mint is set" stop meaning "the
user chose one", which the requirement's heading turns on: node 10127:118014
reads "Minimum Balance Required" over a named mint, and node 10127:118194 reads
"Balance Requirement" over a picked one with no amount collected yet. Hence
`hasChosenMint` alongside the mint itself.

All six StartChatResponse.Result values are handled. TITLE_MODERATED reuses the
existing flagged-category mapping, and a refused picture reuses the profile
picture dialog, which fails the same ways and reads the same to the user.

Joining (node 10153:22902). There is no invite RPC, so the link is built from
the chat's own id: `https://app.flipcash.com/chat/<uuid>`, matching what
Android builds. `common.v1.ChatId.value` is a 16-byte UUID for groups, so the
id round-trips through the link. `Route` now accepts the dashed form on
`/chat/{id}` alongside the base64url one, and `/chat/{id}/send` keeps its
meaning. `app.flipcash.com` is already in the associated-domains entitlement
and the live AASA covers `/chat/*`.

A group the viewer hasn't joined opens the transcript in its gated state rather
than a separate screen, so a push tap and an invite link share one destination.
The server result is authoritative: RULES_NOT_SATISFIED on JoinChat re-renders
the gate rather than trusting the local balance check.

The blurred content behind the gate is a placeholder. The contract has no
non-member message read, so `GatePreviewPlaceholder` draws static rows under
the blur instead of fetching anything.

The group's profile carries the two membership actions. The head card's "Invite
People To Join" disappears once a second person joins, so the profile row is the
only way to the link after that; both entry points open the same sheet, which
builds one URL from the chat id. Leaving sits below it behind a destructive
confirmation, and NOT_FOUND from LeaveChat clears membership rather than
throwing, since the server is saying it holds no membership to remove and the
local flag is the stale one.

Push. A CHAT payload carries `navigation.chatID`, so the tap routes on that id
through `URL.chatDeepLink` rather than on `target_url`, which stays the
fallback for every other push. `DeepLinkAction.chatDestination` is the one
place that decides where a chat id goes.

`ChatService.startChat` now logs the gRPC code when the call fails at the
transport, which is the one failure whose cause isn't in the result.

* fix(chat): withhold a gated transcript before its rules arrive

A chat opened by id — every invite link and every group push — is not in the
store until `GetChat` returns, and the gate read that absence as "not a group,
so nothing to gate". For the whole round trip a gated chat drew a sharp
transcript and a live composer, then took both back when the metadata landed,
which shows the viewer exactly what is being withheld from them.

Adds `ConversationGatePresentation.undetermined` for rules that aren't known
yet, distinct from `.open`, which is a chat that has told us it gates nothing.
It blurs the transcript and draws no bar at all: a composer would be an
affordance the server may refuse, and a gate panel would have to name a
requirement we haven't been told. The placeholder shapes stay behind a separate
`withholdsTranscript`, because standing in for messages the viewer may not see
is a claim only `.blocked` can make.

Both gated overlays also took their opacity from a hardcoded zero in
`install`, which runs at `viewDidLoad` — after `makeUIViewController` has
already assigned the gate. A chat that opened already gated rendered unblurred,
with the shapes invisible underneath, until the gate value next changed. They
now seed from `isShown`, and the blur snaps on and only animates off:
withholding has to be immediate, while revealing is the transition.

A dollar-token requirement now states its amount alone, in the gate panel and
on the head card. "$100 of $USDF" says the same thing twice; any other token
needs naming, because the same $100 is a different quantity of each.

* fix(chat): name the gate's token, not its ticker

The gate copy drew the mint's ticker behind a hardcoded `$` — "Minimum
Balance: $100 of $JEFFY", "Buy More $JEFFY". The rest of the flow already
names tokens: the create form's currency row draws `balance.stored.name`, so a
creator who picks a token saw one string and everyone reading the rule they
set saw another.

Both gate surfaces — the panel and the head card — read the metadata's `name`
now. The `$` a token's name carries still renders; it just comes from the name
rather than from the format string. `mintSymbols` and the `gateSymbol`
plumbing through `ChatScreenRepresentable` and `ConversationBottomBar` rename
to match what they carry.

Also moves a doc comment in `ChatScreenRepresentable` that had drifted onto
the property above the one it describes.

* fix(chat): unblur a gated transcript on joining, not on eligibility

A non-member who cleared a group's balance rule read the real transcript with
Join Chat under it. Satisfying a chat's rules earns the right to join it, not
the right to read it from outside — so `.join` now withholds the transcript the
same way `.blocked` does, and membership is the only thing that lifts the blur.

Eligibility still decides what the panel offers: Join Chat for a viewer who
clears the bar, a buy for one who doesn't. Android draws the same line
(code-payments/code-android-app#1479).

* fix(chat): size the group invite sheet to its two rows

The invite sheet presented at full height for a title and two rows, leaving most
of the screen empty under them. It wraps its content now, like the app's other
bottom sheets.

`PartialSheet` measures the content it wraps, so the sheet carries its own title
row rather than a `NavigationStack` — a stack would report a full-screen height
and defeat the wrap.

* fix(chat): return from a new group to the chat list

Backing out of a freshly created group landed on the "New Chat" picker that
opened the form. The form was already being popped so a filled one couldn't
offer to create the same group twice; the picker behind it has the same problem
in a weaker form, and neither is where the user was headed.

The create flow unwinds to the chat list — this stack's root — before the chat
is pushed, so back from the new group lands on the list that now holds it.

* test(chat): pin membership to the group feed, not to a chat fetched by id

`GetChat` returns the chat without the caller's relationship to it, so a group
opened by invite link or push tap arrives with no membership answer. Recording
that absence as a negative is the trap: it gates a member whose feed has not
landed yet, and it outlives the round trip that would correct it.

iOS avoids it by construction — membership is a positive set, and
`hydratedConversation(withID:)` writes nothing to it — so an unanswered question
stays unanswered until `GetGroupChatFeed` answers it. Nothing held that in
place. Two tests do now: a hydration by id leaves the store and the membership
table alone, and a later feed carrying the same group seats it.

Android had to remove a persisted `false` to reach the same state
(code-payments/code-android-app#1498), where a by-id fetch wrote the guess into
the chat row.

* fix(chat): return to the chat list after leaving a group

Leaving unwound one screen, landing on the chat the user had just left.
The store still holds it, so they got the gated preview of a group they
had chosen to be done with, with Join Chat offering to undo it.

Pop to the stack's root instead. The chat list no longer lists the group,
so unwinding there is the same as popping both screens.

* fix(chat): frost a gated transcript the way a long press does

The gate applied `.systemUltraThinMaterialDark` at full strength with
nothing under it. The material is a translucent light veil, so over a
near-black transcript it raised the black level rather than hiding it,
and the result read lighter than the design and lighter than the blur
the same bubbles get under a long press.

Take that blur instead: the material at 40%, over black at 0.2. Under a
context menu UIKit's own dimming supplies the dark; a gated transcript
has no menu above it, so it carries the same stand-in an edit does.

Both now read the fraction and the alpha from one `ChatBackdrop`, which
also owns the paused-animator trick and its teardown contract, so the
two blurs cannot drift apart.

* feat(groups): show the custom requirement on the fourth preset chip

The `…` chip drew an ellipsis whether or not a custom amount was set, so an
amount entered on the keypad had nowhere to appear: the chip came back selected
but blank while the three presets beside it showed their figures. It now takes
the amount's face, and the ellipsis is what an unused slot looks like — the rule
Android's chip row already follows.

Preset labels are capped to one line and allowed to shrink with it, since four
chips share the row and a custom amount can be wider than $100.

* feat(groups): enter the balance requirement on the shared amount screen

The custom-amount sheet was its own stack — an `AmountField`, a keypad with two
hardcoded decimal places and a button — pinned to USD and titled "Minimum
Balance". Android opens the keypad the rest of the app enters amounts on, titles
it "Balance Requirement", and prints the form's own sentence under a left-aligned
amount.

It now runs on `EnterAmountView` with an `EnterAmountHeader`, the pair the tip
and Minimum To Chat screens use. Entry follows the account's display currency,
and the keypad's rules come from that currency instead of from a constant.

The requirement is still stored in USD, because the gate weighs
`StoredBalance.usdf` and a USD requirement needs no rate to enforce. The one
conversion happens on Done. Done stays disabled while the entry has no rate to
convert through, and while it rounds away to nothing in USD, so neither can set
a requirement nobody has to clear.

`EnterAmountView.Mode` gains `.balanceRequirement`: nothing moves, so no send
limit bounds it, and its action reads "Done".

* feat(chat): send a group invite with an invitation, not a bare link

A group is not discoverable, so the invite link is the only way in — and it
arrived with nothing beside it, leaving the recipient a URL to judge on its own.
The share now leads with the group's name:

    Join Pizza Club on Flipcash and let's chat

    https://app.flipcash.com/chat/...

Built as a `UIActivityItemSource` on the `ShareCashLinkItem` shape, including its
split: AirDrop and Slack render a link themselves and would print the sentence as
stray text beside it, so both still receive the bare URL.

The name comes from the chat record rather than `displayName(for:)`, which
resolves an untitled chat to a counterpart's name or to "Flipcash User" — either
would be worse than no name. An untitled group drops the sentence whole rather
than inviting to "Join  on Flipcash", which is the rule Android's invite follows.

Copy Invite Link still copies the URL alone.

* feat(chat): name the group on the invite share sheet's own card

The card at the top of the share sheet repeated the whole invitation
sentence, which the sender is already about to send, and carried no icon.
Neither reaches the recipient — `LPLinkMetadata` paints the sheet's header
only — so the card's job is to confirm which group is being handed out.

Title it with the group's name, and use the group's avatar as the icon when
it has one. The sheet loads the avatar itself rather than relying on the
screen that presented it; `ProfileAvatarStore.load` returns without a round
trip once the bytes are cached. An untitled group keeps the "Invite to Join
Group" fallback, and one with no picture shows the name alone.

* fix(ui): draw the at icon at full opacity

`IconAt.svg` carried `stroke-opacity="0.5"`, and template rendering keeps a
glyph's alpha, so the @ came out half-strength against the full-white label
beside it on both rows that use it — "Send Invite Link" in `GroupInviteSheet`
and "Find by Username" in `NewChatScreen`.

Drop the attribute. `ChatActionRow` already tints the glyph `textMain`, so any
dimming belongs at the call site, not baked into the asset.
@bmc08gt
bmc08gt deleted the feat/public-group-chat branch September 23, 2026 16:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: deeplinks Deep link handling, URL routing, and link parsing area: network gRPC, connectivity, API, exchange rates area: notifications Push notifications, in-app messaging area: tokens Token accounts, balances, token info area: ui Compose UI, theme, components, resources type: feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant