Skip to main content
Version: Latest
UPDATED 2026-09-04 Group-settings copy now names the mechanism ("changed the group settings so only admins can send messages"), so a reader who didn't know the setting exists learns that it does.
## What's new <!-- Newest first, plain language, max 5 entries. Every session that changes this guide adds one line here and drops the oldest past 5 — see CLAUDE.md rule 8. --> - **2026-09-01** — Group-settings copy now names the mechanism ("changed the group settings so only admins can send messages"), so a reader who didn't know the setting exists learns that it does.- **2026-09-01** — A group you left or were removed from stays in your list, read-only. Section 8 has the full `viewerLeft` contract: what you still get, what returns 403, and how clearing dismisses the thread.- **2026-08-31** — Some chat decisions are recorded but not built yet. The "decided, not yet shipped" section says what not to build against — check it before following the Figma screens. ---

Mobile Chat Groups — Integration Guide

Everything about group conversations: the single-admin model, group profile (name / image / description), membership, admin-only messaging, leaving (and who becomes admin when the admin walks), deleting, group info, shared content, and reporting. Companion to mobile-chat-guide.md — read that first for auth, the conversation list, send/receive, sockets, and the message object. This doc only adds the group bits.

  • API base URL (local): http://localhost:3001
  • Auth: Authorization: Bearer <accessToken> on every endpoint (required)
  • Headers: Content-Type: application/json, X-Client-Type: mobile
  • A group is a conversation with isGroup: true. Everything in the main guide (sending, receipts, typing, mute, replies, reactions, media) works identically in a group — only the endpoints below are group-specific.

What's new

  • 2026-09-01 — Group-settings copy now names the mechanism ("changed the group settings so only admins can send messages"), so a reader who didn't know the setting exists learns that it does.
  • 2026-09-01 — A group you left or were removed from stays in your list, read-only. Section 8 has the full viewerLeft contract: what you still get, what returns 403, and how clearing dismisses the thread.
  • 2026-08-31 — Some chat decisions are recorded but not built yet. The "decided, not yet shipped" section says what not to build against — check it before following the Figma screens.

1 — The model in one screen

One admin per group. The creator is the admin. There is no second admin, no promote, and no hand-over.

AdminMember
Send messagesalwaysunless messaging is locked
React, reply, mute
View group info + members
View shared content
Report a message / the group
Leave✅ (no successor needed)
Add / remove members❌ 403
Edit name / image / description❌ 403
Lock messaging to admin only❌ 403
Delete the group❌ 403
Become adminonly by auto-promotion (§6)

Reading the viewer's role

Off the participant rows you already have — from the conversation list, GET /conversations/:id, or the create / patch response:

const me = conv.participants.find((p) => p.userId === myId);
const isAdmin = me?.role === "OWNER";

⚠️ Treat only "OWNER" as admin. "ADMIN" exists in the enum and a few legacy rows still carry it, but the server ignores it — such a row has no management powers and cannot send while messaging is locked. Rendering it as an admin badge would lie to the user.


2 — Create a group

POST /api/chat/conversations
{
"isGroup": true,
"name": "Weekend football",
"participantIds": ["usr_b", "usr_c"]
}

You become OWNER; everyone else joins as MEMBER. Two rules bite here and again on every later add (§5):

  • Follow gate — you must follow, or be followed by, every invitee (ACCEPTED). Blocked either direction is rejected outright.
  • 50 active participants max, counting you.

Filter the invite picker to people you follow and the 403 mostly disappears.


3 — Group info + members

GET /api/chat/conversations/:id

Returns one conversation in exactly the list-item shape — participants (each with user.profile), the last message, unreadCount, plus imageUrl and description. Use it for the group-info screen and for a deep link opened cold, when the list cache is empty.

404 for non-members (never 403 — ids must not be probeable). A member who left is also 404.


4 — Group profile: name, image, description

PATCH /api/chat/conversations/:id
{
"name": "Weekend football",
"imageUrl": "https://…/media/abc.png", // null clears
"description": "Sunday 7am, turf 2", // null clears, ≤ 500 chars
"messagingRestricted": false
}

Admin only (403 otherwise). Omit a key to leave it untouched; send null to clear imageUrl / description. Image and description are group-only400 on a DM. Emits conversation_updated to the room.

One PATCH that changes two fields writes two system messages, one per field; a PATCH that changes nothing writes none. The description line carries a "View description" affordance: when a group_description_changed event arrives with cleared: false, render a link beside the bubble that opens group info. The description text is deliberately not in the system payload — it can be paragraphs and it can change again later, so read the live value from this conversation's description. The settings line reads "Alice changed the group settings so only admins can send messages." / "…so everyone can send messages."

Uploading the group image

Same three steps as an avatar — presign, PUT the bytes, then PATCH the URL:

// 1. presign
const { data } = await api.post("/api/media/presign", {
files: [{ filename: "group.png", contentType: "image/png", size: bytes.length }],
});
const { uploadUrl, publicUrl } = data[0];

// 2. PUT the raw bytes straight to storage (no auth header on this request)
await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": "image/png" },
body: bytes,
});

// 3. persist it
await api.patch(`/api/chat/conversations/${id}`, { imageUrl: publicUrl });

The server byte-verifies step 3 — it downloads the object and sniffs it:

CodeCause
400 "Invalid media URL"The URL isn't an object in our storage
415 "Could not verify file content"The bytes aren't a real image (a .png name proves nothing)

Replacing or clearing the image deletes the previous object for you — no cleanup call on your side.

Admin-only messaging

messagingRestricted: true locks sending to the admin. Members attempting to send get 403 "Only admins can send messages in this conversation" — hide the composer for them instead of letting the send fail. The flag rides on the conversation object, so you can read it anywhere you have the conversation.


5 — Members: add and remove

Add (admin only)

POST /api/chat/conversations/:conversationId/participants
{ "userIds": ["usr_d", "usr_e"] }

data is every requested user's active participant row (each with user), so an id that is already in the group comes back as a harmless no-op rather than an error. Duplicate ids in one request collapse to one row. Emits participants_added to the room and added_to_conversation to each new user's personal room.

Re-adding someone who was removed clears their leftAt — they regain access including history from before the removal. Say so in the confirm dialog.

CodeWhenSuggested copy
403An invitee doesn't follow you and you don't follow them"You can only add people you follow or who follow you"
403An invitee is blocked (either direction)"You cannot add this user to a conversation"
403An invitee id doesn't exist"One or more participants do not exist"
400The group would exceed 50 active members"Conversations are capped at 50 participants"
403You aren't the admin, or you were removedhide the UI instead

The gate is all-or-nothing: one bad invitee rejects the whole batch.

Remove (admin only)

DELETE /api/chat/conversations/:conversationId/participants/:userId

Soft — sets leftAt. The removed user keeps history up to that moment but can't send, and every management call starts returning 403 for them. Emits participant_removed to the room and removed_from_conversation to that user's personal room.

The removed member keeps the thread, read-only (since 2026-09-01). It stays in their conversation list, frozen at leftAt, ending in the "Alice removed you." system line — so someone whose app was closed still finds out. The row carries viewerLeft: { at, reason: "removed" }; render it with no composer. They dismiss it with POST /conversations/:id/clear, which now works for a departed member. Every write still 403s. See §8.

⚠️ The admin cannot be removed (403). They can only leave (§6).

Promote / demote — not available

PATCH /conversations/:cid/participants/:userId exists and is disabled:

// 403 — for every caller, including the admin
{ "success": false, "error": "Multiple group admins are not enabled" }

Don't build promote/demote UI. The only path to admin is §6.


6 — Leave a group

POST /api/chat/conversations/:id/leave
{
"success": true,
"data": {
"left": true,
"promotedUserId": "usr_b", // non-null = someone just became admin
"deleted": false, // true = you were the last one out
"remainingParticipants": 2
}
}

Any member may leave, including the admin — they are not made to hand over first. Three consequences, all worth surfacing before the tap:

  1. Leaving is soft, and the thread stays. Your leftAt is set; the group remains in your list, read-only, frozen at that moment and ending in "You left the group." You stop receiving messages, unread counts and notifications, and every write returns 403. Clear it to dismiss it. See §8.
  2. The admin leaving auto-promotes. If nobody remaining can administer the group, the longest-standing remaining member (earliest joinedAt) becomes admin. They are not asked first. Everyone in the room receives participant_updated — that member's client must gain the admin controls off that event, without a refetch.
  3. The last member out deletes the group. deleted: true means the conversation and every attachment were hard-deleted. Drop it from the list.

Groups only — 400 on a DM. To get out of a DM, delete the chat (below), block the other user (main guide §13), or delete your messages.

Leave vs. "Delete chat"

Both remove the row from the list, so put them in the same menu and make the difference obvious — they are not interchangeable:

Delete chat (POST …/clear)Leave (POST …/leave)
Membershipkept — you're still in the groupended
Other members seenothingparticipant_removed
Notificationskeep arrivingstop
Comes backyes, on the next message from anyone (including you)never
Works on a DMyesno — 400
Admin side effectsnonemay auto-promote a successor

Someone who only wants a quieter list wants Delete chat, or Mute — not Leave, which they can't undo without an admin re-adding them. Full contract for clear in the main guide §3.3.


7 — Delete a group (admin only)

DELETE /api/chat/conversations/:id
→ 204 No Content

Irreversible, and for everyone — not just you. Messages, receipts, reactions, participants, and every shared attachment (including the group image) go with it. Each member's personal room receives conversation_deleted ({ conversationId }).

Confirm destructively: a two-step dialog at minimum, ideally type-the-name. 403 if you aren't the admin.

400 on any DM, including an unanswered message request: deleting one would erase the other person's copy. POST /conversations/:id/clear hides a DM for you alone, and is also how a request is dismissed (chat guide §6.2).


8 — Shared content

GET /api/chat/conversations/:id/media?limit=30&cursor=<nextCursor>
{
"success": true,
"data": [
{
"id": "01a0…",
"type": "image", // image | gif | file
"url": "https://…",
"mimeType": "image/png",
"width": null, "height": null,
"altText": null,
"processingStatus": "complete",
"variants": [
{ "variantType": "thumbnail", "url": "https://…", "width": 320, "height": 320, "sizeBytes": 8123 }
]
}
],
"nextCursor": "eyJjcmVhdGVkQXQi…"
}

Every attachment ever sent in the conversation, newest first, paginated over the media itself — so a page is never diluted by long text-only stretches. limit default 30, max 50. Stop when nextCursor is null. Attachments of deleted messages are excluded automatically.

⚠️ Read display dimensions from a variants[] entry, not the top-level width / height — those stay null after processing. Use the thumbnail variant for the grid and the largest available for the viewer.

Active participants only — 404 otherwise, same as group info.


9 — Reporting

POST /api/reports (full contract in the report/block guide) accepts two chat targets:

targetTypetargetIdResolves toUse for
MESSAGEthe message idthe message's sender"Report this message" in the bubble menu
CONVERSATIONthe group idthe group's admin"Report this group" in the group-info overflow
USERthe user idthat user"Report member" in the member list

Both chat targets require you to be an active participant — anything else is 404, never 403.

⚠️ Order the menu: Report above Leave. Leaving forfeits the ability to report — a left participant gets 404. If a user taps "Leave" because someone was abusive, they've just lost the report path.

Other edges: the admin cannot report their own group (400 — they can delete it); a DM is not a CONVERSATION target (404 — report the other person as USER); soft-deleted messages stay reportable. Reporting is idempotent per (you, target) — a repeat returns duplicate: true, so a double tap is safe.


10 — Socket events that matter for groups

Full reference in mobile-chat-socket-guide.md; these are the group-relevant ones:

EventRoomPayloadDo
conversation_updatedconversationfull conversationapply new name / image / description / lock
participants_addedconversation{ conversationId, participants }add to the roster
participant_removedconversation{ conversationId, userId }remove from the roster (also fires when someone leaves)
participant_updatedconversation{ conversationId, participant }a member was auto-promoted to admin (§6) — if it's you, reveal admin controls
added_to_conversationyour personal{ conversationId }refetch the list
removed_from_conversationyour personal{ conversationId }drop from list; drop every admin affordance
conversation_deletedyour personal{ conversationId }drop from list; if the screen is open, pop it
conversation_clearedyour personal{ conversationId, clearedUpToSequence }you deleted the chat on another device — hide the row (it returns on the next message)

11 — UI state matrix

What to render, keyed on the viewer's own participant row:

ControlAdmin (role === "OWNER")Member
Edit name / image / descriptionshowhide
"Only admins can send" toggleshowhide
Add membersshowhide
Remove member (row swipe / menu)show, except on yourselfhide
Make adminnever — disabled server-sidenever
Composeralwayshide when messagingRestricted
Leave groupshowshow
Delete groupshowhide
Report message / group / membershowshow
Shared contentshowshow
Muteshowshow

12 — Checklist

  • Group-info screen fetches GET /conversations/:id (works from a cold deep link — don't depend on the list cache).
  • Admin detection is role === "OWNER"; "ADMIN" is not treated as admin.
  • Invite picker is filtered to people you follow, and the batch-level 403 / 400 are handled with the copy in §5.
  • Group image goes through presign → PUT → PATCH, with 400/415 handled.
  • Composer hidden (not just erroring) when messagingRestricted and you're not the admin.
  • Leave dialog explains: history stops, and — if you're the admin — that someone else takes over automatically.
  • participant_updated grants admin controls live, no refetch.
  • conversation_deleted drops the chat and pops the screen if open.
  • Delete-group confirm is two-step and says "for everyone".
  • Report sits above Leave in every menu.
  • Shared-content grid reads sizes from variants[], not top-level width/height.

13 — Not built

  • Deliberate admin transfer. No route. A member becomes admin only via the §6 auto-promotion. If an admin wants a specific successor today, the only approximation is to leave and let the longest-standing member take over.
  • A second admin. See §5 — the endpoint is disabled on purpose.

8. Threads you are no longer in

A group you left or were removed from does not disappear. It stays in the conversation list, frozen at the moment you left, and reads like any other thread up to that point — ending with the system line that says what happened.

{
"id": "conv_...",
"name": "Science Club",
"viewerLeft": { "at": "2026-09-01T10:04:12.000Z", "reason": "removed" }
}
FieldMeaning
viewerLeft presentyou are not a member any more → hide the composer, hide every admin affordance
viewerLeft absentordinary writable conversation. Branch on presence; you never need to compare timestamps
reason"removed" | "left" | null. Drives the banner copy. null means the thread predates the system rows that record a departure — use a neutral line
atwhen your membership ended. Your history is clamped here; nothing after it is readable

Where it appears:

  • GET /api/chat/conversations — in the normal inbox, sorted by activity like anything else.
  • GET /api/chat/conversations/:id — opens, with viewerLeft set. (It used to 404.)
  • GET /api/chat/conversations/:id/messages — serves history clamped to at. Unchanged; this always worked.

What still fails, exactly as before: sending (403), reacting, editing, deleting, mark-read, muting, leaving again, and every participant-management call.

Dismissing it. POST /api/chat/conversations/:id/clear now works for a departed member — same endpoint, same conversation_cleared event, same semantics (it moves your personal boundary; nothing is deleted and nobody else is affected). Without it the row stays forever, so give the user the affordance.

One exception: a blocked DM never comes back. Blocking soft-leaves both sides of a DM, which is the same leftAt state — but those threads stay out of both lists and still 404 on fetch. The blocker asked for that chat to disappear, and a "you can't message this account" banner would tell the other person they were blocked. There is no viewerLeft for a DM, ever.

⤓ Download .md