Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b2b1a7b
WIP: group chats (native binding, records file, models, requests)
xss3cut10n3r Nov 14, 2026
26189c8
Group chats model: GroupService over the MLS core, with tests.
xss3cut10n3r Nov 14, 2026
c6d0a13
Group chat screens, permissions, and edits, deletes and reactions in …
xss3cut10n3r Nov 14, 2026
9395f60
Replace the Start chat dialog with a New chat panel.
xss3cut10n3r Nov 14, 2026
48e051a
Fix a crash when saving a group message edit or a rename.
xss3cut10n3r Nov 14, 2026
357ce37
Groups: the invitation does the MLS Add, so Join works at once.
xss3cut10n3r Nov 14, 2026
2544baf
Groups: make the screens match 1:1 chats, with colours, lines and unr…
xss3cut10n3r Nov 14, 2026
0589669
Render the group screens for review, and colour avatars in Group info.
xss3cut10n3r Nov 14, 2026
5c99260
Record round 2 of group chats in decision 0050 and the protocol note.
xss3cut10n3r Nov 14, 2026
d7c34d9
Reach the reused 1:1 widgets through public files, as the layer check…
xss3cut10n3r Nov 14, 2026
a599941
Groups: open the conversation on Join, and ask once for colours a pho…
xss3cut10n3r Nov 14, 2026
243292b
Use an initializing formal for the colour catch-up pause.
xss3cut10n3r Nov 14, 2026
3d9d563
Groups: a circle for the group's avatar, like a person's (owner's cho…
xss3cut10n3r Nov 14, 2026
3097318
Groups: mute a group on this phone, like a 1:1 chat.
xss3cut10n3r Nov 14, 2026
01c6480
Point the group note at the issue for the in-chat invitation card.
xss3cut10n3r Nov 14, 2026
cc68a77
Merge remote-tracking branch 'origin/main' into feat/groups
xss3cut10n3r Nov 14, 2026
ded8987
Add previews of the group screens, and say what changed in plain words.
xss3cut10n3r Nov 14, 2026
e89b52b
Describe the server side without naming its notes.
xss3cut10n3r Nov 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 150 additions & 2 deletions docs/decisions/0050-group-chats.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# 0050: Group chats: profile, delivery and limits

Date: 2026-11-14. Status: **decisions accepted by the owner; implementation gated**
(see the end). Develops [0036](0036-groups-use-mls.md). The full proposal, written
Date: 2026-11-14, amended the same day (*Changes after the first emulator test*,
below). Status: **decisions
accepted by the owner; implementation gated** (see the end). Develops [0036](0036-groups-use-mls.md). The full proposal, written
with GPT, is [protocol/group-chat-design.md](../protocol/group-chat-design.md); this
note records the owner's choices and **amends the proposal's delivery path**. Where
they differ, this note wins.
Expand All @@ -18,6 +19,22 @@ The interfaces, Firestore schema, rules, function and screens are specified in
| Size | 16 accounts, 4 participating devices each (64 leaves), 32 groups per account |
| Dependency | OpenMLS 0.9.2 with openmls_rust_crypto 0.6.0 approved **for a spike only**; production use needs a second approval |

### Who may do what (owner, 2026-11-14)

| Action | Who |
|---|---|
| Create a group | anyone (5 a day; at most 32 groups per account) |
| Invite someone | **any member**, but only a person they already have an accepted 1:1 chat with; the backend function checks this. The invitee must accept. |
| Add the invited person to MLS | the inviter's device, **when it sends the invitation** (amended; see below). Joining then needs no other phone. |
| Remove someone, change roles | administrators |
| Rename | administrators; other members' phones refuse anyone else's rename |
| Leave | anyone, at any time |
| Last administrator leaves | every remaining member becomes an administrator |
| End a group | no one ends it for others; it ends when its last member leaves, and the server then deletes all its records. Members keep their own copies of past messages. |
| Edit, delete for everyone | **only the message's author**, as in 1:1 chats (edits up to 10 times within 24 hours); the change shows on every member's phone |
| React | anyone |
| Delete for me | anyone, on their own phone only |

### What the suite means

- **Confidentiality is post-quantum:** every key exchange is hybrid X25519 + ML-KEM-768,
Expand Down Expand Up @@ -78,6 +95,137 @@ document. So:

Membership changes cost more under either plan and are rare.

## Changes after the first emulator test (2026-11-14)

The owner tried groups on the Android emulator and asked for six changes. The
server's rules and membership function changed with them.

### Joining must not wait for the inviter's phone

MLS is end to end, so the server cannot add anyone: only a member's device can sign
the Add Commit and write the Welcome. The first version did that after the invitee
tapped Join, on the inviter's phone, while its app was open. In testing Join did
nothing until the inviter opened the app.

Now the **invitation does the Add**. The inviter's phone is online when it invites,
so it fetches a key package for each of the invitee's devices (up to four), and
sends the Commit, the Welcome and the packages it used in the same request.

- **Join** processes the Welcome the phone already has, so the group opens at once.
The server then moves the account from *invited* into the roster, which is what
lets the phone read and send.
- **Decline, or seven days without an answer:** the server fences the invited
devices as it does for a leave, and any member's phone sends the removing Commit
through `finalize`. The server has no scheduler, so the first member's phone to
notice an invitation past its seven days asks for the expiry.
- **Declining stays quiet.** The group sees only that the person is no longer
listed; nothing says whether they declined or the invitation ran out.
- **The trade-off.** From the invitation on, the invited devices are MLS members
and hold the keys for messages sent afterwards, before the person accepts. The
rules still refuse them everything but their own Welcome, and the app shows
nothing from before they joined. The group chose to invite them, and *Group info*
lists people who are **Invited**, apart from the members, so nobody is surprised.
- **Limits.** The 16 people and 64 devices count people who are invited.
- **Key packages run out:** the invitation is refused with "They need to open
Fireplace before they can be invited." A device that had none, or is linked
later, is added by the account's own device after it joins (`addDevice`; the
server has it, the app does not send it yet).
- **A phone that was offline** when someone was invited and then declined still
applies both Commits in order. It does not check an Add against the roster,
because the roster it can read is today's, not the one at that epoch.

**Cost** (Blaze; all bounded by the 100 membership requests an account may make a
day):

| | Writes | Reads | Functions |
|---|---|---|---|
| An invitation | 5 + one delete per key package (≤4) | about 13 + one per device and package (≤8); the inviter's phone reads the invitee's device list and one package each (≤5) | 1 request, 1 push |
| Joining | 4 | 3 + the account's group list; the phone reads its Welcome | 1 request |
| Declining or an expiry | 3 | 4 | 1 request, then a `finalize` (4 writes, 5 reads, 1 request) |
| A newcomer's colours | one message from the newcomer and one from each member (≤16 messages, 2 writes each) | | ≤16 push triggers, throttled per person |
| A member applying a membership Commit | | 1 extra read of the roster | |

### Colours

Your own messages use your primary colour; every other member's use the soft tint
of **that member's** primary colour, and their name and avatar its text shade. The
colour travels only inside the group, as an encrypted `profile` message.

The first version sent a profile only on creating or joining a group or changing
the colour, so a later joiner never received the earlier members' colours and saw
them in Stone. Now a newcomer's profile is marked `hello`, and **every member
answers a `hello` with their own profile**, once. Colours are kept per group, and
avatars use them too.

### The start of the history

Someone who joins a group that already existed sees a quiet line above their first
message: *Messages sent before you joined aren’t shown.* The phone reads and shows
nothing from before the time the server recorded the join.

### The same look as 1:1 chats

The group row reuses the 1:1 row's layout (avatar size, bold name, date, two-line
preview, hairline). Its avatar is a **circle** with the group's initial, like a
person's: the owner changed this from the first version's rounded square after
seeing it on the emulator (2026-11-14). The conversation reuses the
1:1 bubble, held-message menu, reactions, day separators and composer, and editing
happens in the composer, not a dialog. The differences are the sender's name above
the first bubble of a run, their avatar beside the last, and the colours.

### Unread counts, lines and pushes

- **Unread count** on the group row, counted on the phone from the local history
with the same per-chat record of what has been shown as 1:1 chats. No server
field and no read receipt. Group messages count on the app icon's badge too.
- **Muting a group** works as for a 1:1 chat: hold the row, or use *Group info*.
It is kept on the phone with the 1:1 mutes; the server and the other members
are not told. A muted group keeps its count on the row, in grey, and is left
out of the app icon's badge. Pushes are unchanged, as for a muted 1:1 chat.
- **Lines in the conversation**, small and centred: *alice added bob_jones*,
*mira left*, *alice removed mira*, *alice made bob_jones an administrator*,
*alice renamed the group*. They are built on the phone from the MLS state before
and after each **authenticated Commit**, never from the server's roster.
- A removal looks the same in MLS whether the person left or was removed. So a
member who leaves first says so in an encrypted group message, and an
administrator who removes someone says that; MLS vouches for the sender. With
neither (a declined invitation, a deleted account) the line reads *… is no
longer in the group*.
- The lines are stored in the encrypted local history, are never counted as
unread and are never matched by search.
- **Push when someone is invited:** one, to the invited person only (owner's
choice), following their existing notification settings: *Fireplace / You were
invited by alice.* when the sender's name is shown, *Fireplace / New Invitation*
when it is hidden. The owner asked for the group's name as the title; the server
never has it, so that waits for the phone to read the Welcome in the background.

### Administrators

In *Group info* an administrator taps a member for **Make administrator** or
**Remove as administrator**. It is an MLS Commit carrying the new list (group-context
extension `0xF0F1`) and the same list to the server (`roles`). Every phone refuses
a role change, like a rename, from someone who is not an administrator. Someone
invited cannot be made one until they join. If the last administrator leaves,
everyone left is one, as before.

### Tests

`test/model/groups/group_service_test.dart` (23, two and three phones on the real
Rust core): joining with the inviter's phone off, key packages run out, expiry,
decline, removing someone invited, colours for a later joiner, each line, roles,
and a refused role change. `test/view/groups/group_screens_test.dart` (11): the
1:1 menu and composer editing, colours of bubbles, names and avatars, the lines,
the start-of-history line, the unread count, muting, Make administrator, and
*Invited*.
`scripts/render/render_groups_test.dart` renders the screens for review.

### Not in this round

- The invitation card inside the 1:1 chat with the inviter (it shows in Message
requests): [issue #164](https://github.com/fireplacechat/mobile/issues/164).
- `addDevice` from the app.
- The group's name in the invitation push.

## Spike results

[fireplace-crypto#3](https://github.com/fireplacechat/fireplace-crypto/pull/3)
Expand Down
31 changes: 21 additions & 10 deletions docs/protocol/group-chats-v1.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Group chats, version 1: specification

Date: 2026-11-14. Status: **design for review**. Implements
Date: 2026-11-14. Status: **design for review**; sections 2 to 4 were amended by
*Changes after the first emulator test* in decision 0050 (the invitation carries the Add; `hello` profiles;
lines; roles), and where they differ that note wins. Implements
[decision 0050](../decisions/0050-group-chats.md) and the
[proposal](group-chat-design.md) it amends. Fixes the interfaces three pieces of
work will build against at the same time: the Rust core, the backend, and the app.
Expand Down Expand Up @@ -58,7 +60,8 @@ Handle-based and bounded, wiping buffers with `fp_free`, like `FpSession`:

A staged commit's summary lists the leaves added, removed and updated and the new
name and roles, so the app can check it against the server roster and its rules
(only administrators change membership, names and roles) before merging.
(only administrators remove people and change names and roles; any member's device
may add someone whose invitation was accepted) before merging.

The **name and roles** live in an application-defined group-context extension, changed
only by Commits. They are therefore encrypted, authenticated by MLS, and included in a
Expand Down Expand Up @@ -126,9 +129,10 @@ challenge, roles and limits.
| Operation | Who | What the transaction does |
|---|---|---|
| `create` | any account (5 a day) | `groups/{gid}`, creator as administrator, `groupRefs` |
| `invite(uid)` | administrator | `groups/{gid}/invites/{uid}`, refused if either has blocked the other; at most 20 pending per recipient |
| `accept` / `decline` | the invitee | marks the invite |
| `add(uid, epoch, commit, welcomes, packages)` | an administrator's device, after `accept` | checks `epoch`, consumes the packages, writes the Commit, the Welcomes, `members` and `groupRefs`, and raises `epoch`, all at once |
| `invite(uid, devices, epoch, commit, welcome, packages)` | any member's device | needs an accepted 1:1 chat between the two; refused if either has blocked the other; at most 20 pending per recipient. Checks `epoch`, consumes the packages, writes the Commit and the Welcome, records the account in `invited`, and raises `epoch`, all at once |
| `accept` | the invitee | moves the account from `invited` to `members`, writes `groupRefs` with `joinedAt`. No Commit |
| `decline`, `expire(uid)` | the invitee; any member after 7 days | removes the account from `invited` and fences its devices |
| `roles(admins, epoch, commit)` | administrator | the Commit carrying the new list; sets each member's `role` |
| `addDevice(device, …)` | the same account's other device | the same, for a newly linked device |
| `remove(uid, epoch, commit)` | administrator | removes every leaf of that account |
| `leave` | a member | removes it from `members` and `groupRefs` at once (it can no longer read) and adds its leaves to `fence` |
Expand Down Expand Up @@ -172,8 +176,9 @@ devices, rechecking fences, as `pushOnMessage` does.
group*. *New group* asks for a name (up to 40 characters) and lets you tick people
from your accepted chats, at most 15 besides you, then *Create group*. The people
are invited, not added: each joins when they accept.
- **In the chat list.** A group's avatar is its name's first letter in a rounded
square, so groups have a different shape from people's round avatars. The preview
- **In the chat list.** A group's avatar is its name's first letter in a circle,
like a person's (the first version used a rounded square; the owner changed it in
the emulator test). The preview
names the sender: *Mira: See you at 7?* A group you've been invited to doesn't
appear until you join.
- **Invitations** show in both places:
Expand All @@ -186,16 +191,22 @@ devices, rechecking fences, as `pushOnMessage` does.
the first bubble and a small avatar beside the last. The header shows the group
name and the number of people. Tapping it opens *Group info*:
- the members and who are administrators;
- for administrators: *Add people*, *Rename* and *Remove*;
- for everyone: *Leave*.
- for everyone: *Add people* (from your 1:1 contacts) and *Leave*;
- for administrators also: *Rename* and *Remove*.
- **Message actions.** Hold a message: react (anyone); *Edit* and *Delete for
everyone* (your own messages only; every member's phone applies them only from the
author); *Delete for me* (any message, this phone only).
- **Colours.**
- **Your** messages use your primary chat colour, as in 1:1 chats.
- **Each other member's** messages use the secondary (soft) version of *their own*
primary colour, with their name and avatar in that colour's text shade. If Mira
chose Plum, everyone sees her bubbles in the plum tint.
- A member's colour is shared only inside the group, as an encrypted `profile`
message (`type: profile, color`) sent on joining and whenever they change it. The
server never stores it, and 1:1 chats are unchanged.
server never stores it, and 1:1 chats are unchanged. A newcomer's is marked
`hello: true`, and each member answers it with their own.
- `type: notice` messages (`kind: left`, or `kind: removing, uid` from an
administrator) say why a removal is about to happen, for the line it shows.
- A member whose colour hasn't arrived yet shows in Stone.
- **System lines**, small and centred: *Rowan added Mira*, *Elliot left*, *Rowan
renamed the group*. They come from the authenticated Commits, never from the
Expand Down
25 changes: 25 additions & 0 deletions docs/reviews/group-chats/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Group chats: screen previews

Widget renders of the group screens (decision [0050](../../decisions/0050-group-chats.md)),
in both themes, at 390 x 844 points. They are produced by

```sh
bash scripts/build_crypto_host.sh
TZ=UTC flutter test scripts/render/render_groups_test.dart
```

which runs real group services over the Rust MLS core, so times and ids differ
between runs. The names and messages are fictional. These are renders, not device
screenshots.

| State | Light | Dark |
|---|---|---|
| Chat list: groups beside 1:1 chats, with unread counts | [light-list.png](light-list.png) | [dark-list.png](dark-list.png) |
| Conversation: names, avatars and colours per member; lines for changes | [light-conversation.png](light-conversation.png) | [dark-conversation.png](dark-conversation.png) |
| Held message: the 1:1 menu and reactions | [light-conversation-menu.png](light-conversation-menu.png) | [dark-conversation-menu.png](dark-conversation-menu.png) |
| Editing in the composer | [light-conversation-editing.png](light-conversation-editing.png) | [dark-conversation-editing.png](dark-conversation-editing.png) |
| Someone who joined later | [light-joined-later.png](light-joined-later.png) | [dark-joined-later.png](dark-joined-later.png) |
| Group info: mute, members, roles, people invited | [light-info.png](light-info.png) | [dark-info.png](dark-info.png) |

The reaction bar in the held-message previews shows empty boxes: the SDK preview
font has no emoji (see `scripts/render/README.md`).
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/reviews/group-chats/dark-conversation.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/reviews/group-chats/dark-info.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/reviews/group-chats/dark-joined-later.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/reviews/group-chats/dark-list.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/reviews/group-chats/light-conversation.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/reviews/group-chats/light-info.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/reviews/group-chats/light-joined-later.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/reviews/group-chats/light-list.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading