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-10-07 — Blocking no longer leaves either person's real identity on the blocked side: in a shared group the blocker appears to the blocked user as "Clustr user" (
isUnavailable: true), in system lines too. Both stay members; reply/react between them is 403. Section 8. - 2026-10-07 — A blocked DM is no longer frozen forever: unblocking re-opens it (same thread,
viewerLeftgoes back to absent, livechat_list_updatewithviewerLeft: null) unless the other side still blocks. Section 8. - 2026-10-05 — The group image must be a photo you uploaded (
/api/media/presign), and it now goes through the same content check as a post photo: a rejected image returns422 MEDIA_BLOCKED, someone else's or a foreign URL returns400. Nothing to change if you already upload via presign and PATCH the returnedpublicUrl. - 2026-10-01 — A blocked DM now stays too, read-only, with
viewerLeft.reason: "blocked"and a neutral "can't send messages to this account" banner for both sides (Section 8). Also: the Section 10 socket table has saidremoved_from_conversationmeans "drop from list" since before the 2026-09-01 fix — it was never updated and was wrong; corrected to say update-in-place. - 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.
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.
| Admin | Member | |
|---|---|---|
| Send messages | always | unless 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 admin | — | only 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-only —
400 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:
| Code | Cause |
|---|---|
400 "Invalid media URL" | The URL isn't an object in our storage |
400 MEDIA_KEY_REJECTED | The object isn't one you uploaded (another user's, or not under your media/ uploads) |
422 MEDIA_BLOCKED | The image failed the content check — ask for a different one |
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.
| Code | When | Suggested copy |
|---|---|---|
403 | An invitee doesn't follow you and you don't follow them | "You can only add people you follow or who follow you" |
403 | An invitee is blocked (either direction) | "You cannot add this user to a conversation" |
403 | An invitee id doesn't exist | "One or more participants do not exist" |
400 | The group would exceed 50 active members | "Conversations are capped at 50 participants" |
403 | You aren't the admin, or you were removed | hide 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 carriesviewerLeft: { at, reason: "removed" }; render it with no composer. They dismiss it withPOST /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:
- Leaving is soft, and the thread stays. Your
leftAtis 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. - 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 receivesparticipant_updated— that member's client must gain the admin controls off that event, without a refetch. - The last member out deletes the group.
deleted: truemeans 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) | |
|---|---|---|
| Membership | kept — you're still in the group | ended |
| Other members see | nothing | participant_removed |
| Notifications | keep arriving | stop |
| Comes back | yes, on the next message from anyone (including you) | never |
| Works on a DM | yes | no — 400 |
| Admin side effects | none | may 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-levelwidth/height— those staynullafter 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:
targetType | targetId | Resolves to | Use for |
|---|---|---|---|
MESSAGE | the message id | the message's sender | "Report this message" in the bubble menu |
CONVERSATION | the group id | the group's admin | "Report this group" in the group-info overflow |
USER | the user id | that 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:
| Event | Room | Payload | Do |
|---|---|---|---|
conversation_updated | conversation | full conversation | apply new name / image / description / lock |
participants_added | conversation | { conversationId, participants } | add to the roster |
participant_removed | conversation | { conversationId, userId } | remove from the roster (also fires when someone leaves) |
participant_updated | conversation | { conversationId, participant } | a member was auto-promoted to admin (§6) — if it's you, reveal admin controls |
added_to_conversation | your personal | { conversationId } | refetch the list |
removed_from_conversation | your personal | { conversationId } | drop every admin affordance — but do NOT drop the row. It stays in your list, read-only (§8); a chat_list_update carrying viewerLeft lands around the same time and is what actually tells you to hide the composer |
conversation_deleted | your personal | { conversationId } | drop from list; if the screen is open, pop it |
conversation_cleared | your 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:
| Control | Admin (role === "OWNER") | Member |
|---|---|---|
| Edit name / image / description | show | hide |
| "Only admins can send" toggle | show | hide |
| Add members | show | hide |
| Remove member (row swipe / menu) | show, except on yourself | hide |
| Make admin | never — disabled server-side | never |
| Composer | always | hide when messagingRestricted |
| Leave group | show | show |
| Delete group | show | hide |
| Report message / group / member | show | show |
| Shared content | show | show |
| Mute | show | show |
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
messagingRestrictedand you're not the admin. - Leave dialog explains: history stops, and — if you're the admin — that someone else takes over automatically.
-
participant_updatedgrants admin controls live, no refetch. -
conversation_deleteddrops 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-levelwidth/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. (A blocked DM gets the same frozen, read-only treatment — see the note at the end of this section; it ends in a banner instead of a system line, since blocking isn't narrated in the timeline.)
{
"id": "conv_...",
"name": "Science Club",
"viewerLeft": { "at": "2026-09-01T10:04:12.000Z", "reason": "removed" }
}
| Field | Meaning |
|---|---|
viewerLeft present | you are not a member any more → hide the composer, hide every admin affordance |
viewerLeft absent | ordinary writable conversation. Branch on presence; you never need to compare timestamps |
reason | "removed" | "left" | "blocked" | null. Drives the banner copy. "blocked" is DM-only (a departed DM participant can only be a block) and needs no neutral fallback — it's never null for a DM. null means a GROUP thread predates the system rows that record a departure — use a neutral line |
at | when 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, withviewerLeftset. (It used to 404.)GET /api/chat/conversations/:id/messages— serves history clamped toat. 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.
A blocked DM gets this too, as of 2026-10-01. Blocking soft-leaves both
sides of a DM, the same leftAt state as a departed group, and the thread now
stays in both lists the same way: read-only, frozen, viewerLeft: { reason: "blocked" } instead of "removed" / "left". Render a neutral "Messaging
unavailable — you can't send messages to this account" banner for both the
blocker and the blocked; neither screen says who blocked whom. See the main
guide §13 for the full blocking contract. Unlike a group, a blocked DM thaws:
since 2026-10-07 unblocking re-opens it for both (viewerLeft absent again)
once no block remains in either direction.
A block inside a shared group (since 2026-10-07). The group is untouched —
both people stay members and can send. What changes is identity: to the
blocked user, the blocker is a dummy everywhere in the group (participants,
message sender, restrictedByUser, system lines like "Clustr user renamed
the group…"), with isUnavailable: true. Treat it as non-navigable. The
blocker sees the blocked user normally, with blockedByMe: true. Replying to
or reacting to each other's messages is 403 — hide those actions.