Repository navigation
feat(groups): private groups - #1762
Merged
Merged
Conversation
New Chat gets a "Create a Private Group" row (node 11203:59) that opens the public group form without Minimum Balance, with the private-group footnote under the name (node 11203:65). Create is two calls: StartChat(private_group), then SetKeyEnvelope with the creator's own envelope. If the envelope fails, it is retried three times; after that the next Create goes straight back to the envelope rather than starting a second chat. A finished create opens the group's profile, not the chat. ChatCipher gains the KeyEnvelope X25519_XCHACHA20POLY1305 wrap, following chat/v1/model.proto steps 1-4. It shares the DM key's X25519 + HKDF steps under the flipcash-group-key-wrap-v1 label. There are no shared vectors for it yet, so GroupKeyWrapTest checks round trips and rejections rather than bytes. GroupKeyEnvelopes owns the key: generate and store on create, wrap for a recipient on approve. ALREADY_SET counts as success, since a retry after a lost response lands there.
…tnote A private group's name and image stay editable after create, so the sentence was wrong.
A private group's profile now shows Request to Join to a non-member, then Request Sent with "Requested <age> · ...once <host> approves" and Cancel Request (EnterLobby / LeaveLobby). LOBBY_FULL and TOO_MANY_LOBBIES get their own sheets; ALREADY_MEMBER refreshes the feed. Requested chat ids are stored per account and dropped once GetChat reports in_lobby false, checked when the profile opens and when the chat list loads. A private group a non-member opens by link or QR swaps to its profile. chat_metadata gains is_private (schema 45). The upsert ORs it in, since a group never stops being private and a partial row must not reset it.
The pending button reads "Request Sent" for the first minute, then "Requested 10m ago", "2h ago", "3d ago". The line above it keeps only "You'll be able to open the chat once <host> approves.", so the age is no longer said twice.
HostLobbies (shared/chat) holds each hosted group's lobby: it pages GetLobbyMembers, applies LobbyUpdate from the event stream, and on Approve wraps the group key for the requester before AdmitLobbyMember. Deny and a requester who already left both just drop the row. The host sees the lobby in three places, all read from that store: - Join Requests (node 11206:60), with its empty state (node 11362:237): text and Share Group, no icon. - A card on the group profile with a grey count (node 11205:68), hidden at 0. - A row pinned above Archived in the chat list under the All filter (node 11214:67). With several hosted groups waiting it opens the one with the newest request; there is no switcher frame. Edit Group has no Balance Requirements section on Android, so nothing needed hiding there.
The host of a private group sees a glass card under the chat top bar while anyone is waiting: up to three avatars, "<name> wants to join" with the request's age for one requester, or "N people want to join" with the first names, and a Review button that opens Join Requests. It reads the host lobby store from the previous slice, so it hides as soon as the last request is approved, denied or withdrawn. The card sits in the scaffold's top slot under ChatTopBar, so the transcript's top inset grows by its height. It blurs the transcript with the same haze recipe as ComposerReplyStrip and draws flat when there is no HazeState. Figma nodes 11354:70, 11354:81, 11354:83.
A member's private-group profile shows a Members grid with a count that
opens a full list (host pinned with a Host tag, "Joined …" lines).
GroupRosters pages GetRoster and applies RosterUpdate by version, fed
from the same path as RosterStateHolder.
The Encryption menu item gets a filled lock for a private group and
opens a private-group E2EE sheet (node 11210:80). The Reactions row is
left out until its encryption status is confirmed. Leaving a private
group warns that rejoining needs the host's approval.
Dialog copy added on this branch (leave, lobby full, too many lobbies,
request/cancel/approve/deny failures) drops its trailing period.
GetRoster currently returns UNAVAILABLE ("roster reads are disabled"),
so the grid stays hidden until the server enables it.
…heet Matches node 11210:80: reactions in a private group are not encrypted, as in a DM.
The denial sheet said "public groups" whatever form was open.
An admitted member's key envelope is wrapped by the creator, and GroupKeyEnvelopes only ever unwrapped against the viewer's own key, so a member could not open the group key, read the transcript, or send. - GroupKeyEnvelopes follows GetKeyEnvelope.wrapped_by: self (or unset) opens with the own key; the creator opens with the creator's key, then self-wraps and stores it with SetKeyEnvelope (ALREADY_SET refetches); any other wrapper is rejected. NO_ENVELOPE regenerates the key only for the creator. - GroupContentCrypto seals and opens scheme 2 messages and blobs under the group key, with the sender's user id in the AAD. - E2eePolicy encrypts every private group. Send, photo sealing, the incoming opener and the photo loader branch on the chat's is_private before the single-peer DM path. - ChatPreviewsReadyTest gets the lobby and roster constructor args it missed when it arrived from code/cash.
…ed marker
The marker in a private group opened the DM copy ("only you and the
person you're chatting with"). Pick the sheet from the chat subject.
…s header The pinned Join Requests row on the Chats list is replaced by a button on the leading side of the Chats header. It shows while a request is pending in either direction and carries a green dot only when someone is waiting on the viewer in a group they host; sent requests alone show the button without a dot. The button opens a Requests screen with two sections. "Waiting on you" lists hosted private groups with people waiting, newest request first, each opening that group's Join Requests. "Sent" lists groups the viewer asked to join, with how long ago, each opening the group's profile where Cancel Request lives. Sent knows only about requests made on this device, since the server can't list them. NotificationDot is the 11dp ring-cut dot from node 11440:189, kept in ui/components so the settings gear can reuse it. RequestAge moves to shared/chat so the tipping module can word the request age.
The leading header item is now a three-dot menu with one item, Requests, as on iOS. It shows while a request is pending either way; the dot sits on the three-dot icon and marks only requests the viewer has to decide. The item's second line counts the people waiting.
…s item As on iOS, the menu shows with no pending requests, since Requests has an empty state. The Requests item drops its people-waiting line and carries the green dot instead, and the header dot moves in to sit just above and right of the top dot.
…reads open GetRoster is off and SampleChatters is denied for private groups, so the Members grid was hidden. Until the roster loads, it shows the members this device knows: the host, then the viewer, then everyone who has posted, newest first, named from the profiles on the device. Anyone without a profile is left out. The grid has no count while it shows this list, since there is no full list to open. A loaded roster replaces it.
… Requests screen to iOS
The server can't read a private group message, so its push body is a stand-in. Open it on the device as DMs already do; unencrypted group messages open to nothing and keep the server's body.
A key wrapped by someone other than the viewer or the host never opens, so asking the sender to resend can't help. Show the update-app hint, as iOS does.
ChatPhotoLoader picked the DM path whenever the chat's row was missing, so a private group photo opened against an arbitrary member and failed for good. An unstored chat now reports KeyPending, which retries.
GroupContentCrypto had no direct test, and the send and push tests only stubbed it out. Cover text and photo sends under the group key, a send with no key, group push bodies, and the chat, sender and blob-id binding.
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.
Private groups on Android: create one, request to join, approve or deny as host, and read and send end-to-end encrypted messages as a member. Uses the group cipher from #1755.
This re-lands #1756 on
code/cash. #1756 merged intofeat/chat-cipher-group-messages, its stacked base, after #1755 had already merged, so none of it reachedcode/cash. The commits are the same, cherry-picked ontocode/cash, plus one test fix:ChatShareGatewayTestfrom #1757 now passes thegroupCryptothat outgoing encryption takes.One commit per slice, then follow-ups:
SetKeyEnvelope, and reads and sends scheme 2 messages. The Encrypted marker in a private group opens the private-group sheet, not the DM one.SetKeyEnvelopeno longer drops the member's key; the save is best effort, as on iOS.The members list reads the roster through
GetRoster, which the server currently returns asUNAVAILABLE("roster reads are disabled"), so it stays hidden until that flag is on. Until then the members grid is built on the device: host, then self, then anyone who has posted, with no count. OnceGetRosterreturns members, the roster replaces it.