Skip to content

feat(chat): open and join private groups from invite links - #1870

Merged
bmc08gt merged 20 commits into
feat/group-invites-hostfrom
feat/group-invites-joiner
Oct 29, 2026
Merged

bmc08gt merged 20 commits into
feat/group-invites-hostfrom
feat/group-invites-joiner

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Stacks on #1869 (host surfaces), which stacks on #1868 (the key bundle and wrong-key state); this PR's base is #1869's branch, so the diff shows only the joiner side.

A shared /chat/invite#s=… link now opens a landing screen instead of falling through to the chat route.

  • Routing: /chat/invite goes through InviteLink.route ahead of /chat/{uuid}. A malformed fragment becomes InvalidGroupInvite and shows the broken-link state; it never reaches the chat route. GroupInvite and AppRoute.Messaging.GroupInvite redact the secret from toString, and analytics sees only the type name.
  • Landing (nodes 12846:59, 126, 148, 160): GetInvite with auth. A member opens the chat directly. Otherwise the preview shows the group and a roster fetched with the invite token, only while the invite is active. A dead invite shows its reason in place of the subtitle and offers Request to Join. If a later roster page is denied, the loaded rows stay and the screen flips to the dead state, re-checking GetInvite for the reason. NOT_FOUND and BANNED have their own states.
  • Join (4a to 4c): JoinChat with the token and the sealed secret. EXHAUSTED, EXPIRED and REVOKED switch to the dead state in place with no alert, and isExpectedInviteFailure now covers them so joinChat no longer reports them. Transport failure and DENIED show the Couldn't Join Group alert.
  • Wrong key (4d): ChatViewModel observes WrongKeyChats for the open chat; the banner offers Leave Group and the composer is replaced by the "can't send" bar.
  • System message (5b to 5d): "{joiner} joined with {inviter}'s invite link" with you/your forms, in the transcript and the chat-list preview. The inviter's name uses the existing system-message user link.
  • Link previews: invite URLs get no unfurl card. link_detection.json has no /chat/invite case (its group-invite case is a bare /chat/<uuid>), so nothing in it contradicts that.

Open points:

  • A roster denial after the invite was active shows the dead state without a reason when the GetInvite re-check also fails; the copy for that is unspecified.
  • The frames are iOS: the preview keeps the Android grid, so the share button, the "+N more" tile and the cover art differ.

@bmc08gt bmc08gt self-assigned this Oct 28, 2026
@github-actions github-actions Bot added type: feature New functionality area: network gRPC, connectivity, API, exchange rates area: scanner QR/Kikcode scanning, camera area: deeplinks Deep link handling, URL routing, and link parsing and removed type: feature New functionality labels Oct 28, 2026
@bmc08gt
bmc08gt force-pushed the feat/group-invites-joiner branch from 0ccb656 to 8818f7c Compare October 28, 2026 17:32
@bmc08gt
bmc08gt changed the base branch from feat/group-invites-key-bundle to feat/group-invites-host October 28, 2026 17:32
@bmc08gt
bmc08gt added this pull request to stack #1871 October 28, 2026 17:32
@bmc08gt
bmc08gt force-pushed the feat/group-invites-joiner branch 2 times, most recently from 6e891a5 to f59745c Compare October 28, 2026 18:33
@bmc08gt
bmc08gt force-pushed the feat/group-invites-joiner branch from f59745c to de3cd3a Compare October 28, 2026 19:14
@github-actions github-actions Bot added the area: notifications Push notifications, in-app messaging label Oct 28, 2026
@bmc08gt
bmc08gt force-pushed the feat/group-invites-joiner branch 3 times, most recently from 54b9913 to 7564e6c Compare October 28, 2026 21:31
@bmc08gt
bmc08gt force-pushed the feat/group-invites-joiner branch 2 times, most recently from 0475688 to ce89928 Compare October 29, 2026 00:04
@bmc08gt
bmc08gt force-pushed the feat/group-invites-joiner branch from ce89928 to b2f1464 Compare October 29, 2026 00:18
A valid fragment becomes GroupInvite, a malformed one InvalidGroupInvite,
so a broken invite never falls through to a chat. The secret is held in
bytes and left out of toString, and a classify failure on an invite-shaped
link traces only the exception type.
JoinChat now skips ErrorUtils for INVITE_EXHAUSTED, INVITE_EXPIRED and
INVITE_REVOKED, the same expected-failure list GetInvite already uses.
Previews for every landing state plus Robolectric renders of 3a to 3f, 4a, 4d and 5b to 5d. Settle the wrong-key and banner strings on the repo's naming.
DENIED and RULES_NOT_SATISFIED fell through to the connection alert
("Check your connection and try again."). Neither is a connection
problem: the server answers DENIED to a private group joined without an
invite, and a private group has no rules, so both point to a client bug.
Map them to a new InviteJoinResult.Refused that keeps the viewer on the
preview with "Couldn't Join Group" / "Something went wrong. Please try
again." and writes a trace naming the chat id. The repository already
sends both errors to ErrorUtils; InviteFailureTest now pins that they are
not suppressed.

NOT_FOUND shared the INVITE_NOT_FOUND frame ("Link Not Found"). The spec
gives it "This group no longer exists", so it gets its own GroupGone
frame and preview. Transport failures keep the connection alert.
InviteNotFound, NotFound and Banned are user outcomes the joiner sees as a message, so they join the dead-invite results in isExpectedInviteFailure. DENIED and RULES_NOT_SATISFIED stay reported.
…e state

/chat/invite/extra#s=... on the apex host fell through to OpenExternally with its fragment. When mayCarryInviteSecret() holds and classify returns null, route to InvalidGroupInvite.
…d joins

open() now reads LobbyRequests so a viewer who already asked sees Request Sent on a dead link, and a join clears the stored request and drops the held secret. JoinChat NOT_FOUND, BANNED and INVITE_NOT_FOUND show a message alert and pop the preview instead of swapping to a full-screen frame; the 3e and 3f frames stay for GetInvite results.
SystemMessageText.userLinks located the span with lastIndexOf on a hard-coded possessive. It now formats the resource with a marker in the inviter's place and takes the position from that, so a translation moves the link with it. SystemMessageRow no longer captures a stale onUserClick.
The GroupInvite and InvalidGroupInvite mapping moves to a function the scanner calls, so a test can pin it.
An apex /chat/invite link without a fragment became a web card. Also moves initWrongKey so each KDoc sits above its own function, and covers the wrong-key reducer and chat switch.
Frame 4a keeps the button filled and shows the spinner on it. The bar passed
enabled = !joining, which drew the disabled grey. isLoading already blocks the
tap and the view model ignores a second join.
4a now lays the button out before switching to joining, so the render shows
the size the button keeps. 4b is the preview turning into 3c in place; 4c is the
preview under the Couldn't Join Group alert. The host and joiner invite-join
renders were saved under each other's names; frame 5c is the host line and 5d
the joiner line.
A tapped /chat/invite link in a message went to the system's link
handling, which on a build without verified app links opens the browser
and hands it the secret in the fragment. The transcript now routes an
invite link on our hosts to the invite landing itself, as it already
does for message links. A link with no valid secret opens the
broken-link state.
Spec surface 6: an in-chat /chat/invite link gets the group card, filled
from GetInvite (name, photo, member count) with "Private group · Invite
link" or the dead link's reason under it, and a View button that opens
the invite landing. Nothing about the web page is fetched (Decision 6).

The answer is held per visit only, never in LinkCardMemory, because its
key is the URL and the URL's fragment is the group's key.
The chat list showed the raw /chat/invite#s=… URL as a row's last message,
fragment included, and the fragment is the group's key. Every place that
shows message text outside its bubble now replaces each invite URL with
"Invite link" and keeps the rest of the text and the sender prefix: the
chat-list preview, push lines, reply citations, the composer's reply strip,
the reaction alert's quote, a quoted-message card's excerpt, and search
results. Search matches on the labelled text, so a query can't find a
message by its secret.

Invite URLs are found with the same detectUrls pass the link card reads, on
our hosts and through a jump link. A URL with a bad secret is still an
invite, and the replaced span runs to the next whitespace so no part of its
fragment is left behind. Plain /chat/{uuid} links are unchanged.
@bmc08gt
bmc08gt force-pushed the feat/group-invites-joiner branch from b2f1464 to beca728 Compare October 29, 2026 04:05
@bmc08gt
bmc08gt merged commit 7237d98 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: deeplinks Deep link handling, URL routing, and link parsing area: network gRPC, connectivity, API, exchange rates area: notifications Push notifications, in-app messaging area: scanner QR/Kikcode scanning, camera type: feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant