Skip to content

feat(chat): create and join a public group chat - #790

Merged
bmc08gt merged 14 commits into
mainfrom
feat/public-group-chat
Sep 18, 2026
Merged

bmc08gt merged 14 commits into
mainfrom
feat/public-group-chat

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

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 StartChat requires, and where they live:

  • The idempotency key is minted when the user taps Create, in NewPublicGroupModel, 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 site would make each retry a new chat.
  • The creator has to satisfy the rule they set, or the server answers RULES_NOT_SATISFIED. The model checks the selected mint's balance before enabling Create and says why when it doesn't clear the bar. The server result is still handled.
  • The picture uploads first and is held across retries, since StartChat only accepts a blob the caller already owns.

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.

The form opens on a mint, not on a blank. Node 10127:118014 draws the currency row naming a token while the name, the amount and Create are all still empty, so the row starts seated rather than on a placeholder. It takes the wallet's top card — StoredBalance.walletOrder sorts by largest displayed holding, which is the same ordering Android's BalanceOrder gives, so both platforms open on the same mint. Starting there also means the creator opens on a rule they can satisfy instead of one they are short of in every mint.

That makes "a mint is set" stop meaning "the user chose one", and the requirement's heading turns on the choice: node 10127:118014 reads "Minimum Balance Required" over a named mint, node 10127:118194 reads "Balance Requirement" over a picked one with the amount still uncollected. Hence hasChosenMint next to the mint itself. The placeholder survives for the one case the design has no frame for — a wallet holding nothing giveable.

Rules are written as a listener MinimumBalanceRequirement only: a fiat amount plus at most one mint, with speaker left unset.

The amount is entered on the keypad the rest of the app uses. The custom-amount sheet was its own stack — an AmountField, a keypad with two hardcoded decimal places, a button — pinned to USD and titled "Minimum Balance". It runs on EnterAmountView with an EnterAmountHeader now, the pair behind the tip and Minimum To Chat screens, titled "Balance Requirement" as Android titles it. Entry follows the account's display currency, and the keypad's rules come from that currency instead of from a constant.

Storage stays USD, because the gate weighs StoredBalance.usdf and a USD requirement needs no rate to enforce, so the one conversion is on Done. Done stays disabled while the entry has no rate to convert through, and while it rounds away to nothing in USD — 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".

The fourth preset chip drew its 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 and blank while the three presets beside it showed their figures. It takes the amount's face now, 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.

A created group replaces the flow that made it. The whole create stack unwinds to the chat list before the new chat is pushed, so back from the group lands on the list holding it rather than on a filled form offering to create it twice, or on the picker behind that.

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>, the same form 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/* for both bundle ids, so nothing is needed server-side.

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

The blurred content behind the gate is a placeholder, not real messages. The contract has no non-member message read, so GatePreviewPlaceholder draws static rows under the blur. If that assumption is wrong, this is the thing to push back on.

Gating before the rules arrive

A chat opened by its id is not in the store until GetChat returns, and the gate read that absence as "not a group, so nothing to gate". Every invite link and every group push spent that round trip drawing a sharp transcript and a live composer over a chat the viewer may not be allowed to read — then took both back when the metadata landed, which is the one moment that shows what is being withheld.

ConversationGatePresentation.undetermined now covers that window, 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 is not allowed to see is a claim only .blocked can make — under .undetermined nothing is known to be withheld yet.

Both overlays 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 seed from isShown now, and the blur snaps on and only animates off: fading it in animates readable messages into covered ones, so withholding has to be immediate while revealing is the part that can be a transition.

The frost itself is the one a long press gives. 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 read lighter than the design and lighter than the blur the same bubbles get under a context menu. It takes that blur instead — the material at 40%, over black at 0.2. UIKit's own dimming supplies the dark under a menu; 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 cannot drift apart.

No third membership state

Android's companion change (code-payments/code-android-app#1498) makes isMember nullable, because a by-id GetChat used to write false into the chat row — gating a member whose feed hadn't landed yet, past the round trip that would have corrected it.

iOS has nothing to correct. Membership is a positive set: joined ids in the store, the same ids in a table on disk. hydratedConversation(withID:) writes to neither, and GetGroupChatFeed, which answers only the groups the caller has joined, assigns the set wholesale. A group opened by link or push therefore has no membership answer rather than a negative one, and the answer arrives with the feed.

Nor does the rendering need a third case. Everything reading membership has to treat unknown as it treats non-member — the gate, the transcript fetch, the chat list, the invite link — while the gate's button resolves off balance, not off membership, so an unknown viewer still gets Join Chat or Buy More. Android lands on the same split.

What was missing was anything holding that shape in place. Two tests now do: a hydration by id leaves the store and the membership table untouched, and a later feed carrying the same group seats it. The second is a member on a fresh install unblurring their own chat without the screen asking again.

One case survives it. Open an invite link mid-session for a group joined on another device, after the feed has already landed, and the stale set offers Join Chat for a group you are in. Only the server can settle that; GetChat carrying the caller's membership would, for both platforms.

Naming the token

Two fixes to how the gate states a minimum-balance rule, both about the same thing — the rule a creator sets and the rule a reader sees should be one string.

The gate drew the mint's ticker behind a hardcoded $: "Minimum Balance: $100 of $JEFFY", "Buy More $JEFFY". The create form's currency row already drew balance.stored.name, so the two ends of the same rule disagreed. Both gate surfaces — the panel and the head card — read name now, and prepend nothing. A token's name carries its own $; it just comes from the metadata rather than from the format string. The mintSymbols / gateSymbol plumbing renamed to match what it carries.

A dollar-token requirement states its amount alone. Naming the token as well says the same thing twice, since every requirement is already denominated in dollars; any other token needs naming, because the same $100 is a different quantity of each.

Android reached the same two conclusions from its own bug (code-payments/code-android-app#1479) — one flag driving every gated renderer, and an asymmetric reveal. They also agree now on what lifts the blur: membership, not eligibility. A non-member who satisfies the rule sees the blur with Join Chat under it (node 10125:19102) — clearing the bar changes what the panel offers, never whether the transcript is legible. Satisfying a chat's rules earns the right to join it, not the right to read it from outside.

The group's profile

The profile behind the head card carries the two membership actions. "Invite People To Join" leaves the head card once a second person joins — node 10127:118280 puts it there on an empty group — so the profile row is the only way to the link after that. Both entry points open the same GroupInviteSheet, which builds one URL from the chat id and wraps its two rows rather than taking the full screen.

The invite carries a sentence, not a bare link. A group is not discoverable, so a URL arriving on its own gives the recipient nothing to judge it by. "Send Invite Link" shares "Join <title> on Flipcash and let's chat" above the URL. AirDrop and Slack get the URL alone — both render a link themselves and would otherwise print the sentence beside it — which is the split ShareCashLinkItem already makes. A group with no title drops the sentence whole rather than sending one with a hole in it, the rule Android's invite follows, and the title comes from the chat record rather than displayName(for:), which resolves an untitled chat to a counterpart or to "Flipcash User". The share sheet's own card names the group and carries its picture; neither reaches the recipient, so the card's only job is to confirm which group is about to be handed out. The copy row is unchanged.

IconAt, on that row and on Find by Username, carried stroke-opacity="0.5" in its artwork. Template rendering replaces a glyph's color but keeps its alpha, so the @ drew half-strength beside a full-white label; the attribute is gone.

Leaving sits below it behind a destructive confirmation. NOT_FOUND from LeaveChat clears membership rather than throwing: the server is saying it holds no membership to remove, so the local flag is the stale one. Leaving pops to the stack's root: unwinding one screen landed on the chat just left, which the store still holds, so the user got the gated preview of a group they had chosen to be done with and a Join Chat offering to undo it. The list no longer carries the group, so popping to root is the same as popping both screens. Both rows read the roster's isMember rather than the gate, since satisfying the balance rule is not membership, and a DM opens the counterpart's profile instead of this screen.

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 single place that decides where a chat id goes, which is what keeps the two entry points together.

There is no push for roster changes, so nothing here announces a join.

Contract

No version bump. 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.

@bmc08gt bmc08gt self-assigned this Sep 16, 2026
@bmc08gt
bmc08gt force-pushed the feat/public-group-chat branch 2 times, most recently from 258a4cc to 9de6446 Compare September 18, 2026 04:37
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.
@bmc08gt
bmc08gt force-pushed the feat/public-group-chat branch from 9de6446 to f9dfdee Compare September 18, 2026 04:41
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.
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.
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).
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.
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.
…y 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.
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.
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.
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.
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".
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.
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.
`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 merged commit 215a490 into main Sep 18, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant