Repository navigation
fix(chat): render the gate for a group this device has never synced - #1498
Merged
Merged
Conversation
An invite link or a push tap can name a group no sync has brought down, and nothing fetched it: the group feed, a join and the roster stream all carry only chats the viewer is already in, so `observeMetadata` emitted null and the screen had no title, no info card and no gate — with no later read that would supply them. Our own testing missed it because the group was always already cached, from having created it or left it. `hydrateChat` fetches that chat by id, and only that chat: a stored row returns null so the Room observation stays the only source and a synced chat costs no round trip. `GetChat` does not say whether the caller is in the chat, so membership comes back unknown rather than guessed, and nothing is written to the row — a stored guess would either open a group the viewer has not joined or gate a member whose feed has not landed yet. Membership is `Boolean?` from the delegate through to the screen's subject. Unknown withholds what the viewer sees: the blur, the gate, and the invite link, since handing out an invite is an action rather than a view. It does not withhold what the gate's button offers — access still resolves with `isMember == true`, so an unknown viewer gets Join or Buy More off their own balance. The transcript is fetched only for a viewer the gate lets through, so a withheld group never pulls the messages the blur is over; a member on a fresh install gets theirs when the feed sync lands, which already loads a group whose applied cursor is still zero.
`ic_at` belongs to the row inside the sheet this opens, where it stands for the username an invite link is sent to (node 10127:118328). On the row that opens the sheet it said the wrong thing: what the row does is add people to the group, which is what New Chat's group row already uses `ic_group_3` for.
bmc08gt
added a commit
to code-payments/code-ios-app
that referenced
this pull request
Sep 18, 2026
…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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
An invite link or a push tap can name a group the local feed has never brought
down, and nothing fetched it. The group feed,
create,joinand the rosterstream all write only chats the viewer is already in, so
observeMetadataemittednull and the screen had no title, no info card, no gate and no blur — with no
later read that would supply them. #1479 shipped with that hole; testing missed it
because the group was always already cached, from having created it or left it.
hydrateChatfetches that chat by id, and only that chat: a stored row returnsnull, so the Room observation stays the single source and a synced group costs no
round trip.
Membership becomes
Boolean?GetChatreturns the chat without the caller's relationship to it, so a hydratedgroup has no membership answer rather than a negative one, and nothing is written
to the row — a stored guess would either open a group the viewer has not joined or
gate a member whose feed has not landed yet.
Unknown withholds what the viewer sees: the blur stays on, the gate renders, and
the invite link is withheld, since handing out an invite is an action rather than a
view. It does not withhold what the gate's button offers — access still resolves
off
isMember == true, so an unknown viewer gets Join Chat or Buy More againsttheir own balance rather than a vague button. The transcript is fetched only for a
viewer the gate lets through; a member on a fresh install gets theirs a beat later,
when the feed sync loads a group whose applied cursor is still zero.
That trade is deliberate. Withholding from a member for the length of a feed sync
is the smaller error than showing a non-member the transcript the gate exists to
withhold.
Invite row icon
ic_atbelongs to the row inside the sheet that row opens, where it stands forthe username an invite is sent to (node 10127:118328). On the row that opens the
sheet it said the wrong thing: what the row does is add people to the group, which
is what New Chat's group row already uses
ic_group_3for.