# 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

<!-- 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.

---

## 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:

```ts
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

```http
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

```http
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

```http
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:

```ts
// 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 |
| `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)

```http
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)

```http
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**:

```jsonc
// 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

```http
POST /api/chat/conversations/:id/leave
```

```jsonc
{
  "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`) |
|---|---|---|
| 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)

```http
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

```http
GET /api/chat/conversations/:id/media?limit=30&cursor=<nextCursor>
```

```jsonc
{
  "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:

| `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 from list; **drop every admin affordance** |
| `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 `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.

```json
{
  "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"` \| `null`. Drives the banner copy. `null` means the 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, 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.
