Skip to content

feat(messenger): host surfaces for group invite links - #1869

Merged
bmc08gt merged 20 commits into
feat/group-invites-key-bundlefrom
feat/group-invites-host
Oct 29, 2026
Merged

bmc08gt merged 20 commits into
feat/group-invites-key-bundlefrom
feat/group-invites-host

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Adds the host-side surfaces for private-group invite links.

  • Host sheet (1a, node 12844:60): the group profile's share circle and the Join Requests empty state open "Invite to {group}" for a viewer with can_invite, built on the existing option-sheet style. Members without can_invite keep today's sheet. The Join Requests button reads "Share Invite Link" for hosts.
  • Share sheets (1b/1f/1g): the existing Invite People sheet is parameterised by link type. The invite variant shows the elided /chat/invite#s=… pill, an "Expires … · N joined" line, Share Link and Copy Link, Recent Chats limited to private groups and E2EE DMs, and the footnote. The join variant is unchanged. Nothing is created when the sheet opens; the first Share or Copy tap creates a 7-day, no-limit link, reusing one secret across retries. DENIED shows 1e, TOO_MANY_INVITES shows 1c, anything else a generic "Couldn't Create Link".
  • Manage Invites (2a–2e): reached from the sheet tile and the host's ⋯ menu ("Invite Links"). Pages GetChatInvites to the end, splits Active and Inactive, hides inactive invites older than 30 days, marks the current link, and shows "Can't share from this device" with Revoke only when a secret envelope won't open. The row sheet offers Share Link and Revoke Link; new links take the 1 day/7 days/30 days/Never and No limit/1/10/50 presets as drawn.
  • Invite URLs stay out of nav keys, logs and toString. Orchestration lives in HostInvites and small screen ViewModels, not ChatViewModel.

Stacks on #1868 (the key bundle and the Room can_invite column); this PR's base is that branch, so the diff shows only the host side.

@bmc08gt bmc08gt self-assigned this Oct 28, 2026
@bmc08gt
bmc08gt force-pushed the feat/group-invites-host branch from c1decea to 2f0e8d1 Compare October 28, 2026 16:38
@github-actions github-actions Bot added type: feature New functionality area: ui Compose UI, theme, components, resources area: network gRPC, connectivity, API, exchange rates and removed type: feature New functionality labels Oct 28, 2026
@bmc08gt
bmc08gt force-pushed the feat/group-invites-host branch from 2f0e8d1 to 6c6073b Compare October 28, 2026 17:32
@bmc08gt
bmc08gt changed the base branch from code/cash to feat/group-invites-key-bundle October 28, 2026 17:32
@bmc08gt
bmc08gt added this pull request to stack #1871 October 28, 2026 17:32
@github-actions github-actions Bot added the type: feature New functionality label Oct 28, 2026
@bmc08gt
bmc08gt force-pushed the feat/group-invites-host branch 6 times, most recently from 4f04b04 to 1098f77 Compare October 29, 2026 00:18
HostInvites holds a chat's invites with each link rebuilt from its
secret_envelope, so the invite sheet and Manage Invites agree on which
link is Current: the newest ACTIVE invite the viewer made that this
device can open.

Create keeps one secret per attempt until the server answers, so a retry
after a dropped response resends the same token. An OK that comes back
non-ACTIVE drops the secret and tries once more; a refused expires_at
retries once at the next shorter preset. DENIED maps to NotAvailable and
is not retried (CreateInvite is staff-only for now).
The local store drops can_invite, so a chat read from disk always says the
viewer can't invite. HostInvites keeps the value GetChat last returned for
each chat it checked, and a DENIED from GetChatInvites or RevokeInvite clears
it. Logout clears it with the invites.
A private group's invite link carries its secret in the fragment, so Shareable.GroupInvite no longer prints the url.
Adds HostInvites.currentOrCreate, which returns the current link or creates one with the
default 7-day, no-limit settings.
A destructive choice, such as Revoke Link, passes the error text colour.
Adds the host's three-way invite sheet, the invite variant of the share-to-chats sheet (create on first tap, Recent Chats limited to private groups and encrypted DMs), Manage Invites with its row sheet, revoke and new-link sheets, and the Invite Links menu item. Join Requests' empty-state button reads Share Invite Link for a viewer who can invite.
Covers create on first tap, each CreateInvite error, the current-link choice, revoke DENIED, the undecryptable row and the encrypted-only Recent Chats. Adds a preview for every frame state and a Robolectric test that renders them to PNGs.
The invite variant read Share Invite Link while the join variant read Invite People. Both now use title_invitePeople. The send button already reads Invite on both variants and is unchanged.
Who can join offered No limit, 1, 10 and 50, which is the Figma frame. The spec lists 1, 5, 10, 25 and No limit; the chips now follow it, and No limit stays the default. Also drops title_shareInviteLink, unused since the title change.
The spec (group-invites.md, Surfaces 1 step 4) retries INVALID_EXPIRES_AT once
at the next shorter preset and only then shows the generic error. Restores
the behaviour db579ca removed.
…lear

load, attemptCreate and revoke no longer turn a cancelled caller into a
failure result. currentOrCreate re-checks the current link under the create
lock so two waiting callers make one link. clear() bumps an epoch, so an
in-flight load or create for the old account cannot write into the next
one's state, and the pending-secret map is concurrent.

Adds HostInvites tests for unopenable envelopes, reuse, duplicate ids,
clear between retries, serialized creates, load and revoke failures, the
lost-access truth table and cancellation.
Adds setSensitiveText, which sets EXTRA_IS_SENSITIVE on the clip so Android hides the link from the clipboard preview. setText is unchanged for other callers.
The sheet now opens in the creating state and creates at once when the host has no current link; a specific invite from Manage Invites never creates. Share, Copy and Send tiles stay disabled until the link is ready. Too Many Active Links offers Manage Invites and Cancel, errors close the sheet afterwards, and a lost invite permission closes it as Manage Invites does.
Manage Invites shows a spinner on first load and a Retry on failure, inactive rows show status and dates only, an unknown status reads as unavailable, and the use-limit chips are radio buttons at least 48dp tall.
…age Invites

InviteLinkViewModel: creating to ready, each error mapping, sensitive clipboard copy, send, re-open, join-link and Manage Invites offers, denied at open. ManageInvitesViewModel: each create result, double-tap guard, revoke failure, retry. Also restrictToEncrypted, the profile menu truth table, no card tile on an invite link, and teardown clearing HostInvites.
…scene

The Invite Link row sheet on Manage Invites was a raw Material3
ModalBottomSheet, which added a drag handle and top spacing the other
choice sheets (Mute Chat, Paid Reactions) do not have.

Make it a ChatStep.InviteLinkActions marked WrapContentSheet so it renders
on the same scene. The step carries only the invite id; the sheet looks the
invite up in the host's loaded invites. It returns Share or Revoke to Manage
Invites, which opens the share sheet or runs the existing revoke confirm.
The Time to Pay sheet's preset chips slide one white highlight to the
chosen chip and fade it out for a custom date. New Invite Link needs the
same row, so the chips move to ui:components as SlidingChipRow, with the
same colours, padding, caption style and spring.

The chips now carry the radio-button role in a selectable group, where
they were buttons with a selected state. Nothing on screen changes.
Matches iOS's SlidingChipPicker: chips weight(1f) across the full width, 6dp
apart, with 6dp side padding and centred labels. The row no longer scrolls.
Time To Pay's presets change with it.

A label too wide for its chip shrinks by up to 20%, like iOS's
minimumScaleFactor(0.8), then wraps at the space. Every chip takes the height
of the tallest. TextAutoSize can't do this alone: with two lines allowed it
wraps before it shrinks, so the row measures each label and picks the size.

At font scale 2, "Weeks" at 80% is wider than a fifth of the row, so Time To
Pay's "2 Weeks" and "4 Weeks" break inside the word; in a 320dp row "1 Week"
does too. They stay whole on screen.
New Invite Link on Manage Invites was a raw Material3 ModalBottomSheet,
so it showed a drag handle and a left-aligned title with no close button,
unlike Mute Chat and the invite link row sheet.

Make it ChatStep.NewInviteLink, a WrapContentSheet with the same
AppBarWithTitle header (centred title, Close). It returns the chosen
expiry and use limit as a NewInviteLinkChoice, which holds preset names
only, and closes. Manage Invites creates the link from the result; the
New Link button (and the empty-state button) show a loading state while
ManageInvitesViewModel.creating is set. Error handling is unchanged.

The Created effect is gone, since there is no sheet left to close.
Only the chip row: the sheet is a ModalBottomSheet, whose window the
view-drawing harness doesn't capture. At font scale 2 each chip shows only its
number, so 1 Day and 1 Week read the same.
@bmc08gt
bmc08gt force-pushed the feat/group-invites-host branch from 1098f77 to b1f9b3e Compare October 29, 2026 04:05
@bmc08gt
bmc08gt merged commit 27e9827 into code/cash Oct 29, 2026
8 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: network gRPC, connectivity, API, exchange rates 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