# Mobile Chat — Integration Guide

> For mobile development. Everything needed to authenticate, list conversations,
> send / receive messages in real time, edit / delete, mark read, handle
> typing indicators, manage groups, recover from network drops, and receive
> push notifications. No backend changes needed on your side.

- API base URL (local): `http://localhost:3001`
- WebSocket URL: same host, Socket.IO v4 client
- All HTTP requests are JSON. Send `Content-Type: application/json`.
- Send `X-Client-Type: mobile` on every request.
- Auth: `Authorization: Bearer <accessToken>` on every endpoint below.
- WebSocket auth: pass JWT in the handshake `auth.token` field (see §4).

## 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-09** — `pendingPostRequest` now carries `selectedOptions` — the options the requester ticked when they submitted. Show them on the chat request card the same way the author's Responses tray does; the array is `[]` when nothing was ticked.
- **2026-09-01** — A group you left stays in your conversation list, read-only rather than disappearing (section 2.1a). Blocked DMs are the exception — they still vanish for both sides.

---

## Short version — just the calls

```
0.  POST   /api/devices                            → register push token (once per install)
1.  GET    /api/chat/conversations?folder=inbox    → main inbox (cursor-paginated) with unreadCount
    GET    /api/chat/conversations?folder=requests → message-requests tray (incoming PENDING DMs)
    GET    /api/chat/conversations?folder=requests&q=<term>
                                                  → filter a tray (groups by name, DMs/requests by the other person)
1b. GET    /api/chat/search?q=<term>               → search existing chats + startable contacts (new-message screen)
2.  POST   /api/chat/conversations                 → create DM or group (idempotent for DM; DMs to
                                                    non-followers land in recipient's requests tray)
3.  POST   /api/chat/conversations/:id/accept      → recipient accepts a PENDING request → ACCEPTED
    POST   /api/chat/conversations/:id/clear       → recipient dismisses ("deletes") a request — their
                                                    tray only; sender keeps theirs and may write again
    POST   /api/blocks/:userId                     → block: soft-leaves the DM for both sides
4.  PATCH  /api/chat/conversations/:id             → name / image / description / lock messaging (admin)
    GET    /api/chat/conversations/:id             → one conversation: group info + members
    GET    /api/chat/conversations/:id/media       → shared content, newest first (cursor)
    POST   /api/chat/conversations/:id/leave       → leave a group (admin leaving auto-promotes)
    DELETE /api/chat/conversations/:id             → delete a group, for everyone (admin)
5.  GET    /api/chat/conversations/:id/messages?limit=30
                                                  → paginated history (newest-first)
    GET    /api/chat/conversations/:id/messages?afterSequence=N
                                                  → reconnect gap-fill (oldest-first)
6.  POST   /api/chat/conversations/:id/messages    → send (idempotent with clientMessageId; a PENDING
                                                    request is text-only — no media, GIF or postId)
7.  POST   /api/chat/conversations/:id/read        → mark all read
    PATCH  /api/chat/conversations/:id/mute        → mute/unmute for you (duration: 1h|8h|1d|1w|always|off)
    POST   /api/chat/conversations/:id/clear       → "delete chat" for you only (§3.3)
8.  PATCH  /api/chat/messages/:messageId           → edit (15-min window)
    DELETE /api/chat/messages/:messageId           → soft delete (one)
    POST   /api/chat/messages/bulk-delete          → soft delete many (your own, ≤100)
    GET    /api/chat/messages/:messageId/info      → per-recipient receipts (sender only)
9.  POST   /api/chat/conversations/:cid/participants
                                                  → add members (group, admin only)
    DELETE /api/chat/conversations/:cid/participants/:userId
                                                  → remove member (soft, admin only)
    PATCH  /api/chat/conversations/:cid/participants/:userId
                                                  → promote/demote — DISABLED, 403 for everyone (§8)
10. POST   /api/blocks/:userId                     → block user (cascades: unfollow + soft-leave shared DMs)
    DELETE /api/blocks/:userId                     → unblock (no auto-resume)
    GET    /api/blocks                             → blocked accounts list (paginated)
11. WebSocket events — see §4 + §5
```

`GET /conversations`, single message endpoints, and message lists are NOT
cached at the CDN; they're authenticated per-user. Rely on the WebSocket for
realtime, the HTTP endpoints for sync.

---

## 1 — Push device registration (do this on first launch + after login)

```http
POST /api/devices
{
  "token": "<FCM or APNs token>",
  "platform": "ANDROID",          // IOS | ANDROID | WEB
  "appVersion": "1.0.3",          // optional
  "locale": "en-US",              // optional
  "timezone": "Asia/Tokyo"        // optional
}
```

Response: `{ success: true, data: { id, userId, token, platform, ... } }`.

Idempotent on the token — calling again with the same token updates `lastSeenAt`
and any metadata you pass. Store the returned `id` so you can `DELETE
/api/devices/:id` on logout.

> **Note:** FCM/APNs delivery is implemented end-to-end on the server side
> _except_ for the production FCM adapter (currently a console stub). Once
> the adapter is wired (see `CHAT_IMPLEMENTATION_V2.md` §6), all the
> notifications you already register for will actually fan out to your device.

### 1.1 — On logout

```http
DELETE /api/devices/:id
```

Stops sending pushes to that token. The user's other devices keep receiving.

### 1.2 — Optional: list devices

```http
GET /api/devices
```

Returns this user's registered devices — handy for "where am I logged in?"
screens.

---

## 2 — Listing conversations

```http
GET /api/chat/conversations?folder=inbox&limit=30
GET /api/chat/conversations?folder=requests&limit=30
GET /api/chat/conversations?folder=inbox&cursor=<opaque>
```

Cursor-paginated. Ordered by `updatedAt DESC`. Returns conversations the
user is an active participant of (excludes groups the user was removed
from). Default `limit` 30, max 100. Default `folder` is `inbox`.

### 2.1 — Folder semantics

- **`inbox`** — main tray. Contains:
  - all ACCEPTED conversations the user participates in, AND
  - PENDING DMs where the **viewer is the sender** (per Instagram: your
    sent message requests stay in your main inbox).
- **`requests`** — incoming message-requests tray. Contains PENDING DMs
  where the viewer is the **recipient** (someone the user does not yet
  follow has DM'd them). Render these as a separate tab with accept /
  delete / block actions (see §6). A dismissed request leaves this tray and
  returns if the sender writes again.

### 2.1a — The three trays

The server decides which tray a row belongs to. Never derive it on the client.

| Tray | Meaning | Where it comes from |
|---|---|---|
| **General Inbox** | conversations I can take part in — plus, since 2026-09-01, groups I left or was removed from, read-only (see below) | `GET /api/chat/conversations?folder=inbox` |
| **Requests** | someone is asking me to start a conversation — *except* post requests | `GET /api/chat/conversations?folder=requests` |
| **Responses** | people responding to my intent posts | `GET /api/posts/requests/inbox` |

**A row carrying `viewerLeft` is read-only.** A group you left or were removed
from stays in the inbox, frozen at `viewerLeft.at`, ending in the system line
that says why (`reason` is `"removed"`, `"left"`, or `null` for an old thread).
Hide the composer and every action whenever the field is present — absence
means an ordinary writable chat, so presence is the whole test. `POST
/conversations/:id/clear` dismisses the row. Full contract:
`mobile-chat-groups-guide.md` §8. A **blocked DM** is not this — it stays out
of both people's lists entirely and never carries `viewerLeft`.

**A post request is a real conversation from the moment it is sent.** Submitting
a request against a CTA post (`POST /api/posts/:id/requests`) opens the DM
immediately and seeds it with one `POST_SHARE` message carrying the note and the
post card. So:

- the **requester** sees it in their **General Inbox** straight away, ordered by
  `updatedAt` among their other chats — nothing to fetch separately, nothing to
  merge, nothing to sort;
- the **post author** sees it in **Responses**, and *only* there for as long as
  the thread is nothing but the request. The moment the requester sends a message
  of their own, the row also appears in the author's **Requests** tray carrying
  the whole history — post card, note and the new message — with the usual
  Accept / Delete / Block;
- on accept the same conversation flips to `ACCEPTED` and carries on. No second
  conversation is ever created — one user-to-user relationship has exactly one
  conversation, always.

Rows carrying an unanswered request are marked, so the client needs no rules of
its own:

```jsonc
"pendingPostRequest": {
  "id": "req_x",
  "postId": "post_y",
  "selectedOptions": ["Frontend", "Design"]   // what the requester ticked; [] when none
}   // or null
```

```ts
if (conv.pendingPostRequest) {
  // Banner: "Awaiting a response to your post". Tapping the card opens the post.
  // NOT a composer lock — see below.
}
```

> ⚠ **Changed 2026-08-31.** `pendingPostRequest` used to mean "nobody may send"
> and both sides got `403 "Waiting for the author to accept your request"`. That
> lock is gone and so is that error. The field is now **provenance only**.

Routing needs no derivation either: **`chat_list_update` carries `tray`**
(`"inbox"` | `"requests"`), computed by the server for the recipient it is sent
to, alongside `pendingPostRequest`. A row arriving over the socket can be placed
and rendered without a fetch — see the socket guide's event table.

**Composer state comes from `status` + `requesterId`, for every conversation,
whatever it grew out of:**

```ts
if (conv.status === "ACCEPTED") full();              // text + media + GIF + share
else if (conv.requesterId === me) textOnly();        // my outgoing request, cold or post
else locked();                                       // Accept / Delete / Block
```

So a post requester keeps writing into their own request before the author
answers, exactly as they would into a cold message request — text only
(`media[]` / `postId` are `403` until accepted).

**One event, two views.** If the requester and author already had a chat, the
request appends to *that* thread instead of opening a new one. The author then
legitimately sees it in **General Inbox** (a new message in an ongoing
conversation) *and* in **Responses** (a response to their post). That is not a
duplicate — the two views mean different things.

Full request lifecycle, CTA states and the author's side:
[`../post/mobile-post-requests-guide.md`](../post/mobile-post-requests-guide.md)
and
[`../post/mobile-post-request-dm-guide.md`](../post/mobile-post-request-dm-guide.md).

```jsonc
{
  "success": true,
  "folder": "inbox", // ← echoes the requested tray
  "data": [
    {
      "id": "cnv_xxx",
      "name": null, // null for DMs, optional for groups
      "isGroup": false,
      "status": "ACCEPTED", // ← NEW: "PENDING" | "ACCEPTED"
      "requesterId": null, // ← NEW: initiator of a PENDING DM (preserved after accept)
      "messagingRestricted": false,
      "createdAt": "...",
      "updatedAt": "...", // bumped on every new message
      "participants": [
        {
          "id": "cpt_yyy",
          "userId": "usr_a",
          "role": "OWNER",
          "joinedAt": "...",
          "leftAt": null,
          "user": {
            "id": "usr_a",
            "username": "alice",
            "profile": { "displayName": "Alice", "avatarUrl": "..." },
          },
        },
      ],
      "messages": [
        {
          "id": "msg_zzz",
          "content": "...",
          "senderId": "usr_a",
          "sequenceNumber": 42,
          "createdAt": "...",
        },
      ],
      "unreadCount": 3,
    },
  ],
  "nextCursor": "MjAyNi0wNS0yNlQxMDow...", // null on final page
}
```

Pass `nextCursor` back as `?cursor=` for the next page. Stop when null.
Cursor is opaque — base64-encoded `updatedAt|id` server-side; do not parse.

Render:

- DMs (`isGroup=false`) → show the other participant's display name + avatar.
- Groups (`isGroup=true`) → show `name` or fall back to a comma-joined member
  list.
- Unread badge → `unreadCount`. The socket pushes deltas via
  `chat_list_update`; trust the HTTP value on every refetch.
- **Requests badge** → drive from the `requests` bucket in
  `unread_counts_init` (see §4.4) or `GET /api/chat/conversations?folder=requests`.
  Count of distinct PENDING conversations is the canonical "X new
  requests" number, not the message count inside them.

### 2.2 — Filtering a tray (`q`)

Add `q=<term>` to `GET /api/chat/conversations` to filter the **current
folder** in place — same shape, same cursor pagination, just fewer rows.

```http
GET /api/chat/conversations?folder=requests&q=alex   // find a request by who sent it
GET /api/chat/conversations?folder=inbox&q=study      // find a chat in the main tray
```

- **Groups** match by their `name`; **DMs** (and requests, which are DMs)
  match by the **other participant's** username / displayName. A group member's
  name does **not** pull in a group.
- Lenient: `q` is trimmed and applied only when **≥ 2 chars** — a shorter/empty
  `q` is ignored and you get the full tray (no error). Use it to back a search
  box inside each tab.
- `unreadCount`, `nextCursor`, and folder scoping are unchanged — `q` only
  narrows the set.

### 2.3 — Search: existing chats + startable contacts (new-message screen)

`GET /api/chat/search?q=<term>` powers a "new message" search: it returns
**both** the chats you already have and the people you can start one with, in a
single call.

```http
GET /api/chat/search?q=ale
```

```jsonc
{
  "success": true,
  "data": {
    "conversations": [ /* same shape as the list rows, each with unreadCount */ ],
    "users":         [ /* startable contacts: { id, username, profile } */ ]
  }
}
```

- **`conversations`** — chats you're in (both trays), matched the same way as
  §2.2 (groups by name, DMs by the other person). Tap → open the chat.
- **`users`** — people you **follow** (accepted) that you don't have a DM with
  yet. Tap → `POST /api/chat/conversations` (§3) to start the chat. Blocked
  users are excluded; people who follow you but you don't follow back are not
  included.
- `q` must be **≥ 2 chars** (trimmed) — shorter returns `400`. Rate-limited
  (30 req / 10s). No cursor — capped at 20 per bucket (typeahead, not a full
  list). Debounce keystrokes ~250ms.

> Message **body** search (finding a chat by words inside its messages) is a
> separate, not-yet-built feature — see §15. `q` here matches names/titles only.

---

## 3 — Creating a conversation (DM or group)

```http
POST /api/chat/conversations
{
  "participantIds": ["usr_b"],   // other users — current user added server-side
  "isGroup": false,              // omit / false for DM
  "name": "Study group"          // optional, group only
}
```

- **DM dedup is automatic.** If a DM between the same two active users
  already exists, the server returns that one — no duplicate.
- **DM gate (NEW):** strangers can DM each other. Behavior depends on the
  follow graph at create time:
  - If the **recipient already follows the sender** → conversation created
    with `status: "ACCEPTED"`, lands in recipient's main inbox.
  - Otherwise → conversation created with `status: "PENDING"`,
    `requesterId = sender`. Lands in recipient's **Requests** tray
    (see §6). Sender still sees the DM in their own main inbox.
- **Auto-accept on dedup:** if a PENDING DM already exists and the
  recipient (not the requester) calls `POST /conversations` for that pair
  _after_ the recipient has come to follow the requester, the server
  flips the row to ACCEPTED in-line and fires
  `conversation_status_changed` to both sides. Use this if the user
  opens a DM via search rather than the requests tray.
- **Group gate (unchanged):** the requester must have an accepted follow
  relationship (in either direction) with every invitee. Otherwise →
  **403** with reason `NOT_CONNECTED`.
- **403 + BLOCKED:** at least one direction of `Block` exists between
  the sender and a target. Surface as "You cannot start a conversation
  with this user."
- **403 + UNKNOWN_USER:** one or more participantIds don't exist.
- **400:** more than 50 participants (current cap).

Response is the full conversation object (same shape as the list, with
`status` + `requesterId`).

Navigate to `/chat/:id` after creation — the conversation room socket
subscription happens on the chat screen mount (see §4). For a PENDING
DM you can still open the room as the sender; the recipient will see it
in their requests tray (not their inbox) until they accept.

### 3.1 — Update group settings (admin only)

```http
PATCH /api/chat/conversations/:id
{
  "name": "New name",                 // optional
  "imageUrl": "https://…/groups/x.jpg", // optional, group only. null clears it
  "description": "Weekend football",    // optional, group only, ≤500. null clears it
  "messagingRestricted": true         // optional — when true, only the admin can send
}
```

When `messagingRestricted: true`, members trying to send get a **403**.

**`imageUrl` is a two-step upload**, same as an avatar: presign → PUT the bytes
→ PATCH with the returned public URL. The server byte-verifies it (a
non-image, or a URL outside our storage, is **415**), and replacing or clearing
the image deletes the old object for you. `description` is plain text.

Omit a key to leave it alone; send `null` to clear `imageUrl` / `description`.
Both are group-only — **400** on a DM.

> **One admin per group.** The admin is whoever created it (`role: "OWNER"` on
> their participant row). There is no second admin and no way to grant the role
> — see the group guide. Show the settings UI only when the viewer's own row
> has `role === "OWNER"`.

### 3.2 — Mute / unmute a conversation (per-user, any member)

Silences a DM or group for **you only** — a personal setting, not visible to
other participants. Works on both DMs and groups; any active member can mute.

```http
PATCH /api/chat/conversations/:id/mute
{ "duration": "1h" | "8h" | "1d" | "1w" | "always" | "off" }
```

```jsonc
// 200 — mirrors the stored state (also arrives on the socket, see §14)
{ "success": true, "data": {
    "conversationId": "…",
    "mutedUntil": "2026-08-03T13:10:22.615Z",  // absolute instant; null when off
    "isMuted": true                            // convenience: mutedUntil > now
} }
```

- **What it does:** stops the generic message / reply push (and the in-app
  notification) for that chat until `mutedUntil`. **@mentions still notify** —
  a group mention pierces the mute.
- **`always`** = muted until you turn it off (stored as a far-future instant).
  **`off`** = clear (`mutedUntil: null`). Re-muting overwrites.
- **Timed mutes auto-expire** server-side — no unmute call needed; a past
  `mutedUntil` simply stops suppressing.
- **Not muted:** messages still arrive over the socket, and the conversation's
  `unreadCount` still increments. Mute is about *notifications*, not delivery —
  grey the row / drop it from a global badge, but keep showing per-chat unread.
- Non-member or a chat you've left → **404**. Bad `duration` → **422/400**.

**Reading mute state** for a chat: it rides your own participant row in the
list (and create / patch) responses — no separate field on the envelope:

```ts
const me = conv.participants.find((p) => p.userId === myUserId);
const isMuted = !!me?.mutedUntil && new Date(me.mutedUntil) > new Date();
```

Keep it in sync live via the `conversation_muted` socket event (§14) — fired to
**your own** devices when any of them mutes/unmutes, so a second device updates
without a refetch.

### 3.3 — Delete a chat (for you only)

The swipe-to-delete on a chat row. Works on DMs and groups.

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

```jsonc
{ "success": true, "data": { "conversationId": "cnv_x", "clearedUpToSequence": 42 } }
```

Everything up to `sequenceNumber: 42` is now invisible **to you**: the row
leaves your list and your search results, your history starts empty, the shared
media gallery drops those attachments, and the unread badge goes to 0. Nothing
is deleted, and the other participants are not affected or told.

**The chat comes back the moment anything newer arrives** — a message from
them, or **one you send yourself** — showing only messages after the boundary.
So don't treat the row as gone forever: on `new_message` for a conversation
missing from your cached list, re-insert it (or just refetch the list).

| Delete chat | Leave group (§8) |
|---|---|
| You stay a member | You stop being a member |
| Nobody else is told | Everyone sees `participant_removed` |
| Notifications keep arriving | They stop |
| Returns on any newer message | Never returns |
| Works on DMs too | Groups only |

> ⚠️ **Don't label it "Delete for everyone".** The other person keeps their full
> copy of the conversation and can keep messaging you. "Delete chat" (WhatsApp's
> wording) is accurate; "Delete conversation permanently" is not.

Fires `conversation_cleared` (`{ conversationId, clearedUpToSequence }`) to
**your own** devices only, so a second device hides the row without a refetch.

Two edges worth knowing:

- Clearing again later moves the boundary forward; clearing twice with nothing
  in between is a no-op.
- A reply that quotes a message you cleared will still show that quote in its
  preview **if it arrives live over the socket** — one payload goes to the whole
  room. Reload the history and the quote is gone. Harmless, but don't be
  surprised by it.

---

## 4 — WebSocket connection (real-time)

> **Deep dive:** `mobile-chat-socket-guide.md` is the dedicated socket
> reference — the full room model, every event you **emit** and every event
> you **listen** for (with when + why), the receipt lifecycle, and a drop-in
> RN client wrapper. This section is the quick-start; reach for that doc when
> wiring the transport layer.

The system is a **hybrid push/pull** model:

- **PUSH** (WebSocket) — new messages, read receipts, typing, edits, deletes.
- **PULL** (HTTP) — history, conversation list, reconnect gap-fill.

### 4.1 — Connect

```javascript
import { io } from "socket.io-client";

const socket = io("http://localhost:3001", {
  auth: { token: accessToken }, // JWT — verified server-side
  transports: ["websocket"], // skip long-polling on mobile
  reconnection: true,
  reconnectionDelay: 1000,
  reconnectionDelayMax: 5000,
});

socket.on("connect", () => {
  // You're in your personal room (user:<id>) automatically.
  // The server emits unread_counts_init right after — see §4.4.
});

socket.on("connect_error", (err) => {
  // Token rejected or invalid. Re-auth and retry, do NOT spam reconnect.
  if (
    err.message === "Invalid token" ||
    err.message === "Authentication required"
  ) {
    refreshAccessToken().then(() => (socket.auth = { token: newToken }));
  }
});

socket.on("disconnect", (reason) => {
  // reason === "io server disconnect" → don't auto-reconnect, server kicked us
  //   Check if it's an auth expiry — see §4.5 below
  // reason === "transport close" / "ping timeout" → socket.io will reconnect
  // On reconnect, run the gap-fill flow (see §6)
});
```

### 4.5 — Token lifecycle (refresh while connected)

The JWT used for the socket handshake expires (15 min by default). The
server tracks each socket's token expiry and emits two events:

```javascript
// Fired ~60s BEFORE the token expires. Refresh the access token now.
socket.on("auth_expiring", ({ expiresAt }) => {
  refreshAccessToken().then((newToken) => {
    socket.emit("refresh_auth", newToken);
    // On success, server resets the lifecycle timer silently.
    // On failure, server emits "auth_expired" and disconnects.
  });
});

// Fired AT expiry (refresh failed, never sent, or new token rejected).
// Server disconnects immediately after this — no further events arrive.
socket.on("auth_expired", ({ reason }) => {
  // "token_expired"     → the token just ran out: refresh, then reconnect.
  // "invalid_token"     → never valid (bad signature/malformed): go to login.
  // "identity_mismatch" → token is for another user: reconnect as them.
  // "no_exp_claim"      → token has no exp: get one from the auth endpoints.
  if (reason === "token_expired") return refreshAndReconnect();
  goToLogin();
});
```

Refresh contract:

- Send the **new access token** (not the refresh token) on
  `socket.emit("refresh_auth", token)`.
- The user identity inside the new token MUST match the socket's original
  user — switching users requires a full reconnect.
- If you don't refresh in time, the server disconnects you. Reconnect with
  the fresh token in `auth.token` and run gap-fill (see §6).
- Token must contain `exp` (any token signed via the API's auth endpoints
  does).

> Don't pre-emptively reconnect on every token refresh — just emit
> `refresh_auth`. Reconnect is a much heavier operation (re-deliver
> `unread_counts_init`, re-join rooms, etc.).

### 4.2 — Join the active conversation room

When the user opens a chat screen:

```javascript
socket.emit("join_conversation", conversationId);
// Server auto-marks all unread messages in this conversation as read
// and broadcasts a `messages_read` event to other participants.
```

When the user closes the chat screen:

```javascript
socket.emit("leave_conversation", conversationId);
```

You only need to be in one conversation room at a time. The personal room
(`user:<id>`) handles all the cross-conversation sidebar updates.

### 4.3 — Typing indicator

```javascript
// On keystroke (debounce 200–500ms so you don't fire on every char)
socket.emit("typing_start", conversationId);

// On send OR 3s of inactivity OR screen blur
socket.emit("typing_stop", conversationId);

// Listen for others
socket.on("user_typing", ({ userId, conversationId }) => {
  // Show indicator. Auto-hide on `user_stopped_typing` or after a 5s timeout
  // (defensive — covers a dropped stop event).
});
socket.on("user_stopped_typing", ({ userId, conversationId }) => {
  /* hide */
});
```

### 4.4 — On connect: unread counts seed

The server emits `unread_counts_init` automatically right after connect.
Payload now splits the badge into the two trays:

```javascript
socket.on("unread_counts_init", ({ counts, inbox, requests }) => {
  // counts:   Record<conversationId, number>  — back-compat: same as inbox
  // inbox:    Record<conversationId, number>  — main-tray unread
  // requests: Record<conversationId, number>  — requests-tray unread
  // Use `inbox` for the main sidebar badge. Use `requests` (count of keys =
  // distinct pending conversations with >0 unread) for the Requests tab dot.
});
```

- Legacy clients reading `counts` keep working — it carries the inbox
  bucket only.
- It's safe to also call `GET /api/chat/conversations?folder=...` —
  per-row `unreadCount` is the authoritative value for that tray.

### 4.6 — Tray transitions (PENDING → ACCEPTED, or dismissed)

```javascript
// Recipient accepted a PENDING request, OR sender's pending DM got
// auto-accepted by the dedup-auto-accept path (see §3). Fires to both
// participants.
socket.on("conversation_status_changed", ({ conversationId, status }) => {
  // status: "ACCEPTED"
  // Move the row from `requests` → `inbox` locally. No need to refetch.
});

// A GROUP was hard-deleted (by its admin, or because the last member left).
// Fires to every member. Drop it from local caches. DMs and requests never
// produce this — dismissing a request is `conversation_cleared` below, and it
// reaches your own devices only.
socket.on("conversation_deleted", ({ conversationId }) => {
  // Drop the row and any open chat-room subscription for this id.
});

// YOU dismissed a request (or cleared a chat) — on this device or another.
// Private to you: the sender is never told. Hide the row; it returns on the
// next message they send.
socket.on("conversation_cleared", ({ conversationId, clearedUpToSequence }) => {
  // Remove from the requests tray (or inbox) on your other devices too.
});

// `chat_list_update` now carries `status` so the client routes to the
// right tray. For a PENDING DM, the recipient's `unreadIncrement` is
// always 0 — the requests-tab dot tracks distinct conversations, not
// messages inside them.
socket.on(
  "chat_list_update",
  ({ conversationId, status, lastMessage, unreadIncrement }) => {
    // status: "PENDING" | "ACCEPTED" — pick the tray to bump
  },
);
```

---

## 5 — Sending and receiving messages

### 5.1 — Send

```http
POST /api/chat/conversations/:id/messages
{
  "content": "hello world",
  "clientMessageId": "f8a1c2e0-…"   // ⚠️ recommended — see §5.2
}
```

Response:

```jsonc
{
  "success": true,
  "data": {
    "id": "msg_xxx",
    "content": "hello world",
    "senderId": "usr_a",
    "conversationId": "cnv_yyy",
    "sequenceNumber": 43,
    "clientMessageId": "f8a1c2e0-…",
    "type": "TEXT",
    "isDeleted": false,
    "editedAt": null,
    "createdAt": "2026-05-26T10:30:00Z",
    "sender": { "id": "usr_a", "username": "alice", "profile": { ... } },
    "status": {
      "type": "sent",
      "totalRecipients": 2,
      "deliveredCount": 0,
      "readCount": 0,
      "allDelivered": false,
      "allRead": false
    },
    "deduplicated": false          // present + true on idempotent replay
  }
}
```

Error codes:

| code | meaning                                                  |
| ---- | -------------------------------------------------------- |
| 403  | Not a participant / removed / messagingRestricted member |
| 404  | Conversation not found                                   |
| 429  | Rate limit (60 messages/min per user)                    |

### 5.2 — Idempotency (do this — mobile networks are flaky)

Generate a UUID (`clientMessageId`) on the **client** before the first send
attempt. Send the same UUID on every retry of the same intended message.

- First successful response → save the `id` server returned, mark the
  message as sent locally.
- Network timeout / 5xx → safe to retry with the same `clientMessageId`.
- The server will return the **existing row** with `deduplicated: true`
  instead of creating a duplicate.
- Don't reuse a `clientMessageId` for a different message — once persisted,
  it's bound to that content.

```typescript
// Pseudo-code
const clientMessageId = crypto.randomUUID();
async function sendWithRetry(content: string, attempt = 0): Promise<Message> {
  try {
    const res = await api.post(`/chat/conversations/${id}/messages`, {
      content,
      clientMessageId,
    });
    return res.data.data;
  } catch (err) {
    if (attempt < 3 && isTransient(err)) {
      await sleep(2 ** attempt * 1000);
      return sendWithRetry(content, attempt + 1);
    }
    throw err;
  }
}
```

### 5.3 — Optimistic UI (mandatory pattern)

1. **On tap-send** — append a local message with `id: "temp-<timestamp>"`,
   `status: "pending"`, your `clientMessageId`. Show clock icon.
2. **Fire** POST in the background. Do not block the input on the response.
3. **On `new_message` socket event** — if it's your own message (sender
   matches you), reconcile by `clientMessageId` → replace the temp row.
4. **On HTTP success** — if the socket beat you to it (race), the temp row
   is already gone. Otherwise, replace temp with the returned row.
5. **On HTTP error after retries** — flip status to `failed`, surface
   "Tap to retry". User can retry with the same `clientMessageId`.

### 5.4 — Receive

```javascript
socket.on("new_message", (message) => {
  // Only fires for sockets in the conversation room (open chat screens).
  // Append to cache. Auto-mark read if we're viewing this conversation.
  if (currentConversationId === message.conversationId) {
    socket.emit("mark_read", { conversationId: message.conversationId });
  }
});

socket.on(
  "chat_list_update",
  ({ conversationId, lastMessage, unreadIncrement }) => {
    // Fires for the personal room (all online users in the conversation).
    // Use this to bump sidebar badges without re-fetching the whole list.
  },
);
```

### 5.5 — Delivery & read receipts (sender view)

```javascript
socket.on("message_status_update", ({ messageId, conversationId, status }) => {
  // status: "sent"      — server has persisted the row
  // (status flips to delivered/read are emitted via message_delivered / messages_read)
});

socket.on(
  "message_delivered",
  ({ messageId, conversationId, userId, deliveredAt }) => {
    // One row per recipient. For 1:1 chats, a single event means "delivered".
    // For groups, aggregate across recipients to show "1 of 3 received".
  },
);

socket.on(
  "messages_read",
  ({
    conversationId,
    readByUserId,
    readAt,
    isGroupChat,
    messageReadStatuses,
  }) => {
    // For DMs: flip all messages older than readAt to "read" for this conversation.
    // For groups: messageReadStatuses[] gives per-message read counts.
  },
);
```

> **Key the handler on `readByUserId`.** Every `messages_read` event carries
> the **same shape** no matter how the read was triggered — socket
> `mark_read` / `join_conversation` **or** the HTTP `POST /read` endpoint
> (§5.6). Both emit `{ conversationId, readByUserId, readAt, isGroupChat,
> messageReadStatuses? }`. (Older server builds emitted `userId` instead of
> `readByUserId` from the HTTP path — if your client special-cased that, drop
> the workaround; it's unified now.)

#### 5.5.1 — Don't clobber receipts on reconcile (why ticks "only show on refresh")

When you reconcile your **own** message — the `new_message` echo of something
you sent, or a refetch of a media message after its upload — **merge** it into
the cached row. **Do not replace the row wholesale.**

Media sends are slower than text: the bytes upload first, so the send round-trip
(and its `new_message` echo) can land **after** a `message_delivered` /
`messages_read` for that same message has already advanced its `status`. If your
reconcile step overwrites the cached row with the freshly-sent object (whose
`status` still reads `deliveredCount: 0, readCount: 0`), you wipe the receipt you
already applied — and it only reappears on the next history refetch. This is the
#1 cause of read/delivery ticks looking stuck on **image / file** messages while
text works.

```typescript
// reconcile own message: keep the more-advanced status
function mergeOwn(cached: Message | undefined, incoming: Message): Message {
  if (!cached?.status || cached.status.type !== "sent") return incoming;
  const inc = incoming.status;
  if (!inc || inc.type !== "sent") return { ...incoming, status: cached.status };
  return {
    ...incoming,
    status: {
      ...inc,
      // never regress a count we already advanced from a receipt event
      deliveredCount: Math.max(inc.deliveredCount, cached.status.deliveredCount),
      readCount: Math.max(inc.readCount, cached.status.readCount),
      allDelivered: inc.allDelivered || cached.status.allDelivered,
      allRead: inc.allRead || cached.status.allRead,
    },
  };
}
```

The same rule applies to the async image-variant refetch (media guide §
"Thumbnails / variants") — swap in the new `media[].variants`, keep the live
`status`.

To inspect aggregate status for a sent message:

```http
GET /api/chat/messages/:messageId/info
```

Returns the message + every recipient's receipt + the active participant
list. Sender-only — 403 otherwise.

### 5.6 — Mark read (manual)

If the user lingers without scrolling but a new message arrives, the
auto-mark from `join_conversation` is enough. To force-mark from elsewhere:

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

Or via socket (no HTTP roundtrip):

```javascript
socket.emit("mark_read", { conversationId });
```

Both broadcast the **same** `messages_read` payload to the conversation room
(§5.5) — the HTTP path is just a socket-less way to fire the identical event.
Pick one per trigger; don't call both for the same read.

### 5.7 — Edit (15-minute window)

```http
PATCH /api/chat/messages/:messageId
{ "content": "fixed typo" }
```

- 403 if not the sender, or if older than 15 minutes.
- 404 if not found.
- Emits `message_edited` to the conversation room.

```javascript
socket.on(
  "message_edited",
  ({ messageId, conversationId, content, editedAt }) => {
    // Update local cache. Show "edited" label.
  },
);
```

### 5.8 — Delete (soft)

```http
DELETE /api/chat/messages/:messageId
```

Sets `isDeleted = true`, `content = "This message was deleted"`. Mentions
tied to the message are dropped. Emits `message_deleted`.

```javascript
socket.on("message_deleted", ({ messageId, conversationId }) => {
  // Replace the row's content with "This message was deleted" + dim styling.
});
```

### 5.8.1 — Delete many at once (bulk)

```http
POST /api/chat/messages/bulk-delete
{ "messageIds": ["msg_a", "msg_b", "msg_c"] }   // your own only, 1–100
```

Same per-message effect as the single delete (content redacted,
`isDeleted=true`, mentions dropped, media purged) applied to the whole batch.

- **All-or-nothing.** If **any** id is unknown → **404**; if **any** belongs to
  another user → **403**. Nothing is deleted on rejection — fix the list and
  retry. So only send ids the current user actually sent.
- Emits one `message_deleted` per message (reuse the §5.8 handler — no new
  event). Messages may span conversations; each fires to its own room.
- Duplicate ids are de-duped server-side. Cap is 100 per request.

```jsonc
{ "success": true, "data": { "deletedIds": ["msg_a", "msg_b", "msg_c"] } }
```

Use this to back a multi-select "delete selected" action. Filter the selection
to the user's own messages client-side first — the server rejects the whole
batch otherwise.

---

## 5.9 — Sending into a PENDING DM (request thread)

The sender can keep writing into a PENDING thread — there is **no per-thread
message cap** — but **text only** until it is accepted (§6.4.1): `media[]` and
`postId` are both 403. Two more things to know on the UI side:

- For the **sender**, the per-message `status` aggregate stays at
  `deliveredCount=0, readCount=0` until the recipient accepts. The
  recipient is intentionally not generating delivery receipts during
  PENDING — we don't leak "the stranger you DM'd has seen your
  message" before they decide.
- When the recipient calls `/accept`, the server backfills receipts for
  every prior message the requester sent and sets
  `deliveredAt = acceptTime` on each. The sender's `status` aggregate
  jumps to `allDelivered=true` after the next `message_delivered`
  socket event or `GET /messages/:id/info` call.

The **recipient** can read PENDING messages via the usual
`GET /api/chat/conversations/:id/messages` without flipping the
conversation to ACCEPTED — peeking at a request is not the same as
accepting it.

---

## 5.10 — Sharing a post (native card)

In-app post sharing uses a native message type — **not** a pasted URL. The
message stores only the `postId`; the server renders a live, per-viewer card
on read. This is the in-app path; the URL flow
(`POST /api/posts/:id/share`, see `mobile-posts-guide.md`) stays for
**external** channels (WhatsApp, Messages, email).

Why native: the card renders instantly (no OG-preview generation), works with
link previews disabled, and its counts/thumbnail are always current because the
body is resolved fresh on every read — never a frozen snapshot.

### Send

```http
POST /api/chat/conversations/:id/messages
{
  "postId": "pst_abc",          // the post being shared
  "content": "you should join!", // optional caption (≤2000)
  "clientMessageId": "…"         // same idempotency rules as any message
}
```

- The message comes back with `type: "POST_SHARE"` and `sharedPostId`, but
  **no** card body — resolve it (see below).
- `postId` and `media` are mutually exclusive → 400 if both sent.
- `404` if the post is missing/hidden or **you** can't view it (can't share
  what you can't see).
- A `PostShare` row (`channel: CHAT`) is recorded, so the post's share count
  increments like a URL share.

### Resolving the card (one path for history + real-time)

The card is **never** carried on the `new_message` socket broadcast (a single
broadcast can't hold a viewer-specific card). Two ways to get it, both the same
resolver + shape:

- **History** — `GET /api/chat/conversations/:id/messages` already inlines
  `sharedPost` on each `POST_SHARE` row.
- **Real-time** — on a `new_message` with `type: "POST_SHARE"`, batch-resolve
  via:

  ```http
  POST /api/posts/share-cards
  { "postIds": ["pst_abc", "pst_def"] }   // dedupe across the visible page
  ```

  Returns `data.cards` in request order. Cache per postId; the counts are live
  so refetch when you want fresh numbers (e.g. reopening the thread).

### `sharedPost` shape + the three render states

```jsonc
// available — render the full card
{
  "id": "pst_abc",
  "available": true,
  "author": { "id": "…", "username": "alice", "grade": "8", "school": "Lincoln HS",
              "profile": { "displayName": "Alice", "avatarUrl": "…" } },
  "type": "FIND_TEAMMATES",
  "title": "Chess team",
  "content": "Looking for a chess team…",   // whole body — clamp for display
  "thumbnailUrl": "https://…/thumb.jpg",     // or null
  "mediaCount": 2,
  "counts": { "likes": 12, "comments": 3, "reposts": 1, "shares": 4 },
  "createdAt": "2026-07-09T…Z"
}

// private — author is private and the viewer isn't an accepted follower.
// Render an identity-only locked card ("Follow to view").
{ "id": "pst_abc", "available": false, "reason": "private",
  "author": { "id": "…", "username": "alice", "profile": { … } } }

// unavailable — post deleted/hidden, or the author blocked the viewer.
// NO identity. Render "Post unavailable".
{ "id": "pst_abc", "available": false, "reason": "unavailable" }
```

Tapping an available card deep-links to the post; a `private`/`unavailable`
card is non-navigable.

---

> **System messages:** lifecycle events (a request opened or accepted, members
> added or removed, a group renamed) now arrive as ordinary messages with
> `type: "SYSTEM"`. Render `system.text`, never `content`, and treat them as
> non-interactive — see
> [mobile-chat-system-messages-guide.md](./mobile-chat-system-messages-guide.md).

## 6 — Message requests (accept / delete / block)

The requests tray is a separate tab. Render the row count from
`unread_counts_init.requests` (count of keys) or from the length of the
`folder=requests` page.

A request has exactly **three** answers, and every row exposes all three:

| Action | Call | Result |
|--------|------|--------|
| **Accept** | `POST /api/chat/conversations/:id/accept` | Row moves to the inbox, composer opens |
| **Delete** | `POST /api/chat/conversations/:id/clear` | Row leaves **your** tray; the sender is untouched |
| **Block** | `POST /api/blocks/:userId` | Both sides soft-leave the DM, and the pair is blocked |

There is no fourth path and no implicit one. In particular there is **no
composer** on a PENDING thread — see §6.4.

**Nothing here destroys the sender's copy.** Delete and Block both act on your
own side of the conversation; the sender's row, their message and their ability
to write are unaffected by Delete, and merely gated by Block. Only a group
admin can hard-delete anything, and only a group.

On the **sending** side there is one restriction: a request carries **text
only** until it is accepted — no media, no GIFs, no shared posts. See §6.4.1.

### 6.1 — Accept

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

- 200 with the updated conversation (`status="ACCEPTED"`).
- Idempotent — already-ACCEPTED returns 200 no-op.
- 403 if the caller is the requester (only the recipient can accept).
- 403 if a Block was created between request and accept.

Side effects you'll observe on the client:

- `conversation_status_changed` socket event fires to both participants.
- The requester receives a `message_request_accepted` notification with
  `data.conversationId`. Deep-link straight into the chat.
- All `MessageReceipt` rows the requester was missing are backfilled
  with `deliveredAt = now`. The next `message_delivered` socket event
  for the sender reflects this.

Only after this returns should the composer become usable. Sending before it
lands is a 403 (§6.4).

### 6.1.1 — Two ways a request gets accepted

The explicit tap is the one you build. There is a second path you will see
fire without a tap, and it lands in **identical** state — `status: "ACCEPTED"`,
receipts backfilled, `conversation_status_changed` to both participants, and a
`message_request_accepted` notification to the requester — so don't
special-case it.

| # | Trigger | Notes |
|---|---------|-------|
| 1 | Recipient taps Accept (`POST …/accept`) | The explicit path |
| 2 | Recipient opens the thread via `POST /conversations` **after following the requester back** | Following someone means you'll accept their DMs; the request should never have been in the tray. Opening alone does nothing — the follow-back is what satisfies the gate |

What does **not** accept a request:

- **Replying.** The recipient cannot send into a PENDING thread at all — the
  server 403s it (§6.4). This changed: replying used to accept implicitly.
- **Reading it.** `GET …/messages` leaves it PENDING — peeking is not consenting.
- **Opening it without having followed back.** Path 2 requires *you* to follow
  *them*; the sender following you is what got them into your tray in the first
  place and grants nothing further.
- **The requester sending more messages.** Nobody accepts their own request.

### 6.2 — Delete (dismiss)

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

**Delete is one-sided.** It empties *your* tray. It does not touch the sender.

- 200 with `{ conversationId, clearedUpToSequence }`.
- The request leaves your requests tray, and your history of it starts empty.
- The sender keeps their copy, still sees the message they sent, and may send
  again. To them a dismissed request is indistinguishable from one you never
  opened — which is the point.
- **The request comes back when they write again.** The new message lands in
  your tray as a fresh request, showing only what arrived after you dismissed.
- 404 if you're not an active participant.
- Emits `conversation_cleared` to **your own devices only** — never to the
  sender. A second device hides the row without a refetch.

This is the same endpoint as "delete chat" on a normal conversation (§3.3),
because it is the same operation: a boundary on your own membership row.
Nothing about a request is ever hard-deleted.

> ⚠️ **`DELETE /api/chat/conversations/:id` is not this.** That route deletes a
> **group** for every member and returns **400** on any DM, request or not. Do
> not wire the Delete button to it.

### 6.3 — Block

```http
POST /api/blocks/:userId
```

- 204. Blocks both directions, drops follow edges either way, and deletes
  pending follow notifications between the pair (§13).
- The shared DM — request or accepted — is **soft-left by both sides**:
  `leftAt` is set on both participant rows, so the row leaves both lists and
  any send 403s. Nothing is deleted.

Block is enough on its own. You don't need to dismiss first, and you can't
afterwards — you're no longer an active participant, so `/clear` would 404.

### 6.4 — No composer on a pending thread

```http
POST /api/chat/conversations/:id/messages   → 403
{ "error": "Accept this message request before replying" }
```

The recipient of a PENDING request cannot send into it. Keep the composer
hidden or disabled behind the three buttons until `status === "ACCEPTED"`;
the 403 is the backstop, not the mechanism.

The **requester** may keep sending while it's pending — but **text only**
(§6.4.1). This holds whatever the request grew out of: a post request's
requester writes into their own thread on exactly these terms (there is no
separate post-request lock — §2.1a).

While PENDING, no `MessageReceipt` rows exist at all, so the requester's ticks
stay at 0 no matter what the recipient does. That is deliberate: a stranger
must not learn whether their message was delivered or read. Accepting
backfills them in one go.

### 6.4.1 — A request is text-only (sender side)

Until the recipient accepts, the **requester** may send plain text and nothing
else. Everything the composer offers beyond the text field is refused:

```http
POST /api/chat/conversations/:id/messages   → 403
{ "error": "Only text messages can be sent in a message request" }
```

| Body carries | While PENDING |
|---|---|
| `content` only | ✅ sends |
| `content` + `parentMessageId` (quoted reply) | ✅ sends — still a text message |
| `media[]` — image, file, **or GIF** (`remoteUrl`) | ❌ 403 |
| `postId` — native shared-post card | ❌ 403 |

A stranger should not be able to push an image or a post card into someone's
tray, where the preview renders before any decision has been made. Text is
legible, reportable, and easy to ignore.

**Hide the attachment, GIF and share affordances on a pending outgoing
request** — leave only the text field and Send. The 403 is the backstop, not
the mechanism, and the client should never let a user pick an image and then
lose it to an error.

The restriction lifts the moment the request is accepted (`status` flips to
`"ACCEPTED"` — you'll see it on `conversation_status_changed`, §4.6). Restore
the full composer then.

Groups and accepted DMs are unaffected — this applies only to a PENDING DM, and
it applies to **every** PENDING DM: a thread opened by a post request is a
PENDING DM like any other, so `pendingPostRequest` changes nothing about who may
send.

### 6.5 — UI suggestions

- Stack the request rows with the actor's avatar + the first ~60
  characters of the latest message as a preview.
- Render the three actions as one row: `Accept` · `Delete` · `Block`. Delete is
  recoverable in practice (the sender can write again and the row returns), so a
  confirm there is optional; Block is worth confirming.
- Open the thread read-only. Show the three buttons where the composer would
  be; swap in the real composer on `conversation_status_changed`.
- After accept, stay in the conversation. After delete or block, pop back to
  the requests tab and remove the row. Don't show "deleted forever" copy on
  Delete — the request can reappear, and it will look like a bug if you claimed
  otherwise.
- Drive all of it from `conversation.status` + `requesterId` off the server,
  not from local state — the same account on another device gets the same
  socket events and must land in the same place.

---

## 7 — Loading history & reconnect gap-fill

### 6.1 — Initial history (newest-first)

```http
GET /api/chat/conversations/:id/messages?limit=30
```

Response:

```jsonc
{
  "success": true,
  "data": {
    "items": [
      /* messages, oldest→newest within page */
    ],
    "nextCursor": "42", // a sequenceNumber, or null on first page exhaustion
    "mode": "history",
  },
}
```

`limit` defaults to 50, capped at 200. Calling this endpoint also marks
undelivered receipts for _you_ as delivered (covers the offline-but-app-open
case where the socket isn't bound yet).

### 6.2 — Paginate older

```http
GET /api/chat/conversations/:id/messages?cursor=42&limit=30
```

Returns messages with `sequenceNumber < 42`. Stop when `nextCursor` is `null`.

### 6.3 — Reconnect gap-fill (the important one)

When the socket reconnects after a network drop, fetch every message you
might have missed:

```http
GET /api/chat/conversations/:id/messages?afterSequence=42
```

Returns messages with `sequenceNumber > 42` **ascending** (oldest-first).
`mode` in the response will be `"gap"`. Use `nextCursor` to keep walking
forward until it's `null`. Each conversation tracks its own
`sequenceNumber` — store the highest seen per conversation locally.

```typescript
// On socket reconnect:
async function backfillConversation(conversationId: string) {
  let cursor: string | null = String(
    localState.highestSeqSeen[conversationId] ?? 0,
  );
  while (cursor !== null) {
    const res = await api.get(
      `/chat/conversations/${conversationId}/messages?afterSequence=${cursor}&limit=100`,
    );
    for (const msg of res.data.data.items) {
      appendToCache(conversationId, msg);
    }
    cursor = res.data.data.nextCursor;
  }
}
```

You only need to gap-fill conversations the user actually opens — for the
sidebar, the `unread_counts_init` event + `chat_list_update` events keep
badges current.

---

## 8 — Groups

Group conversations have their own guide: **`mobile-chat-groups-guide.md`** —
the single-admin model, group name / image / description, adding and removing
members (and the gates that reject an add), admin-only messaging, leaving with
its auto-promotion rule, deleting, group info, shared content, and reporting a
message or a group.

The short version, so this guide stands alone:

| Need | Call |
|---|---|
| Group info + members | `GET /api/chat/conversations/:id` |
| Name / image / description / lock messaging | `PATCH /api/chat/conversations/:id` (admin) |
| Add members | `POST /api/chat/conversations/:cid/participants` (admin) |
| Remove a member | `DELETE /api/chat/conversations/:cid/participants/:userId` (admin) |
| Leave | `POST /api/chat/conversations/:id/leave` |
| Delete the group | `DELETE /api/chat/conversations/:id` (admin) |
| Shared content | `GET /api/chat/conversations/:id/media` |

Three things that catch people out, covered in full in the group guide:

- **One admin per group — the creator.** Detect it as `role === "OWNER"`;
  `"ADMIN"` is a legacy value the server ignores. Promote/demote is disabled
  (403), so don't build that UI.
- **The admin can leave without handing over.** The longest-standing remaining
  member is auto-promoted and `participant_updated` fires; the last member out
  deletes the group and everyone gets `conversation_deleted`.
- **Adding members runs the same follow/block gate as creating**, plus the
  50-participant cap — one bad invitee rejects the whole batch.

Group socket events (`participants_added`, `participant_removed`,
`participant_updated`, `added_to_conversation`, `removed_from_conversation`,
`conversation_deleted`) are in §14 and in the group guide.

---

## 9 — Mentions (@username) in chat

Server auto-detects `@username` in `content`. In **group** chats:

- Mentioned users get a `mention` notification (`type: "mention"`).
- The generic `message` notification is suppressed for those users — they
  only see the more specific mention push.

In **DMs**: mention notifications are skipped (the recipient already gets a
`message` notification regardless).

Render the mention by detecting `@` + matching the resolved username from
the message; the actual `Mention` rows can be fetched from the post/comment
mention endpoint if needed. Most clients just style `@x` substrings inline.

---

## 10 — Pagination cursors

Chat messages use a numeric `sequenceNumber` cursor (string-encoded in the
URL). Treat it as opaque — pass back `nextCursor` exactly as received. The
server returns `nextCursor: null` on the final page.

Conversation list is **cursor-paginated** per folder (`inbox` or
`requests`). Default 30 / max 100. Cursor is an opaque base64-encoded
`updatedAt|id` tuple — just pass `nextCursor` back as `?cursor=`. Adding `q`
(§2.2) keeps the same cursor — it walks the filtered set. `GET /api/chat/search`
(§2.3) is **not** paginated (capped at 20 per bucket, no cursor).

---

## 11 — Error responses

Validation errors → HTTP 400 with details (same shape as posts guide §12).

| code | meaning                                                                                                          |
| ---- | ---------------------------------------------------------------------------------------------------------------- |
| 401  | Missing or invalid `Authorization` / socket `auth.token`                                                         |
| 400  | `DELETE /conversations/:id` on a DM (groups only — dismiss with `/clear`) or a malformed body                     |
| 403  | Not a participant / removed / messagingRestricted / not connected / BLOCKED / not the group admin / not the recipient (accept) / replying to a PENDING request / attaching media or a shared post to one |
| 404  | Conversation, message, or participant not found; or `/clear` when you are no longer an active participant          |
| 429  | Rate limit (60 messages/min/user; 100 mentions/24h)                                                              |
| 500  | Server error — capture `request_id` if present                                                                   |

---

## 12 — Push notifications (mobile background)

There are now **three chat-related notification types** you must route:

| type                       | When it fires                                                                                                                                                                    | Deep-link                                                                                                                      |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `message`                  | New message in an ACCEPTED conversation, OR new message from the requester in their own PENDING DM (sender hits send-confirm push themselves only if they have multiple devices) | `ksn://chat/:conversationId?messageId=:id`                                                                                     |
| `message_request`          | First / subsequent message from a non-follower in a PENDING DM, to the recipient                                                                                                 | `ksn://chat/requests/:conversationId`                                                                                          |
| `message_request_accepted` | Recipient tapped Accept on a request the user sent                                                                                                                               | `ksn://chat/:conversationId`                                                                                                   |
| `mention`                  | `@username` mention in a group chat                                                                                                                                              | `ksn://chat/:conversationId?messageId=:id` (mentions take priority — the `message` notif is suppressed for the mentioned user) |

`message` payload shape:

```jsonc
{
  "notification": {
    "title": "Alice", // or "Group name: Alice" for groups
    "body": "hello world", // truncated to 140 chars
  },
  "data": {
    "entityType": "message",
    "entityId": "msg_xxx",
    "actorId": "usr_a",
    "actorName": "Alice",
    "conversationId": "cnv_yyy",
    "deepLink": "ksn://chat/cnv_yyy?messageId=msg_xxx",
    "webPath": "/chat/cnv_yyy",
  },
}
```

`message_request` payload shape (route this into the requests tray, not
the main inbox):

```jsonc
{
  "notification": {
    "title": "Alice sent you a message request",
    "body": "hello world",
  },
  "data": {
    "entityType": "conversation",
    "entityId": "cnv_yyy",
    "actorId": "usr_a",
    "actorName": "Alice",
    "conversationId": "cnv_yyy",
    "deepLink": "ksn://chat/requests/cnv_yyy",
    "webPath": "/chat/requests/cnv_yyy",
  },
}
```

`message_request_accepted` payload shape (sender gets this when their
request lands in the recipient's inbox):

```jsonc
{
  "notification": {
    "title": "Bob accepted your message request",
    "body": null,
  },
  "data": {
    "entityType": "conversation",
    "entityId": "cnv_yyy",
    "actorId": "usr_b",
    "actorName": "Bob",
    "conversationId": "cnv_yyy",
    "deepLink": "ksn://chat/cnv_yyy",
    "webPath": "/chat/cnv_yyy",
  },
}
```

### 12.1 — Deep-link handling

Parse `data.deepLink` (or `data.conversationId` + `data.entityId`). Open
the chat screen, scroll to / highlight the referenced message (gap-fill
if your local history doesn't reach `sequenceNumber` yet).

For `message_request` deep-links (`/chat/requests/:id`), open the
Requests tab and scroll the row into view rather than entering the
conversation directly — the user might just want to accept, delete or
block without opening the thread.

### 12.2 — Notification preferences

The user can mute each chat-related type independently:

```http
GET  /api/notifications/preferences
PUT  /api/notifications/preferences
{
  "preferences": [
    { "type": "message",                  "pushEnabled": false, "inAppEnabled": true,  "emailEnabled": false },
    { "type": "message_request",          "pushEnabled": true,  "inAppEnabled": true,  "emailEnabled": false },
    { "type": "message_request_accepted", "pushEnabled": true,  "inAppEnabled": true,  "emailEnabled": false }
  ]
}
```

Defaults: all three are `push=true, inApp=true, email=false`. Apply
prefs in the app's settings screen. The server gates push fan-out by
these prefs automatically — you don't need to suppress client-side.

---

## 13 — Block / unblock

A mutual block list lives at `/api/blocks`. Effects fan out across the
whole API — feed exposure, profile visibility, follow create, DM
create, mentions, search, discover. Mobile responsibilities:

- Provide a block / unblock affordance on the user profile screen and in
  the chat conversation menu.
- Surface a "Blocked accounts" list in Settings driven by `GET /api/blocks`.
- Drop locally-cached UI state for the blocked user on a successful
  block (their DM rows are soft-left server-side; the participation gate
  will 403 any send anyway, but cleaning up locally avoids stale UI).

### 13.1 — Block

```http
POST /api/blocks/:userId
```

204 on success. Idempotent — re-blocking the same user returns 204.
Side effects (one server-side transaction):

- Any `Follow` edges between the pair are deleted (both directions).
- Every shared 1-on-1 DM has `leftAt = now()` set on both users'
  `ConversationParticipant` rows. New sends from either side 403.
  Groups untouched (would leak the block to others).
- Pending `follow_request` / `follow_accepted` / `follow` notifications
  between the pair are deleted from both inboxes.
- The blocked user's posts disappear from the blocker's feed pools.
- Both users see each other's profile as 404 going forward, with one
  exception: the **blocker** can still see the blocked user via
  `GET /api/blocks` (you must always be able to see who you blocked).

Comments / likes / mentions / reposts that existed _before_ the block
stay in place — only future actions are gated.

```http
DELETE /api/blocks/:userId
```

204. Removes the block row. **Does NOT auto-resume** follows or DM
     participation — the user must re-follow / re-create the DM going
     forward. Future follows / DMs / mentions work normally (subject to the
     usual privacy + request flows).

### 13.2 — List blocked users

```http
GET /api/blocks?limit=30
GET /api/blocks?limit=30&cursor=<createdAtISO>
```

Cursor is an ISO timestamp from the previous `nextCursor`. Newest-first.
Bypasses the visibility gate — the blocker always sees this list even
though `canViewProfile` returns false for the same target elsewhere.

```jsonc
{
  "success": true,
  "data": {
    "items": [
      {
        "id": "usr_x",
        "username": "alice",
        "profile": { "displayName": "Alice", "avatarUrl": "..." },
        "blockedAt": "2026-06-18T12:34:56.789Z",
      },
    ],
    "nextCursor": "2026-06-18T12:34:56.789Z",
  },
}
```

### 13.3 — UX notes

- In the requests tray, Block is the third of the three actions alongside
  Accept and Delete (§6). Call it **on its own**: the shared DM is soft-left by
  both sides, so the row is already gone from your tray — and you can't dismiss
  it afterwards, since `/clear` 404s once you're no longer an active
  participant.
- A **request** and an **accepted** DM are treated the same by a block: both
  are soft-left, nothing is deleted, `POST /messages` 403s on both sides. The
  sender's copy of a request they sent is not destroyed — blocking gates them,
  it doesn't erase them.
- After successful block: navigate the user back to their profile root
  or settings. Re-fetching the profile will 404.
- After unblock: the user is back to a clean stranger relationship —
  refresh the profile fetch; private profiles will gate behind the
  follow-request flow per `private-profile-implementation.md`.

---

## 14 — Socket event reference (cheat sheet)

> Full event semantics + an RN client wrapper live in
> `mobile-chat-socket-guide.md`. This table is the at-a-glance version.

### Client → Server

| Event                | Payload                          | Effect                                                  |
| -------------------- | -------------------------------- | ------------------------------------------------------- |
| `join_conversation`  | `conversationId: string`         | Join room, auto-mark unread as read                     |
| `leave_conversation` | `conversationId: string`         | Leave room                                              |
| `typing_start`       | `conversationId: string`         | Broadcast `user_typing` to room (excl. self)            |
| `typing_stop`        | `conversationId: string`         | Broadcast `user_stopped_typing`                         |
| `mark_read`          | `{ conversationId }`             | Mark unread receipts read; emit `messages_read`         |
| `mark_delivered`     | `{ conversationId }`             | Mark undelivered receipts delivered                     |
| `refresh_auth`       | `token: string` (new access JWT) | Re-verify socket against new token; resets expiry timer |

### Server → Client

| Event                         | Target                | Payload                                                                                                                      |
| ----------------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `new_message`                 | conversation room     | Full message + status. `POST_SHARE` rows carry `sharedPostId` only — resolve the card via `POST /api/posts/share-cards` (§5.10) |
| `chat_list_update`            | user personal room    | `{ conversationId, status, requesterId, pendingPostRequest, tray, lastMessage, unreadIncrement }` — `tray` is `"inbox"` \| `"requests"`, computed per recipient; place the row by it. increment = 0 for a PENDING recipient |
| `conversation_status_changed` | user personal room    | `{ conversationId, status: "ACCEPTED" }` — request was accepted, flip tray                                                   |
| `conversation_deleted`        | each member's personal room | `{ conversationId }` — a **group** was hard-deleted (admin, or last member left); drop the row. Never fires for a DM or a request |
| `message_status_update`       | sender personal room  | `{ messageId, conversationId, status }`                                                                                      |
| `message_delivered`           | sender personal room  | `{ messageId, conversationId, userId, deliveredAt }`                                                                         |
| `messages_read`               | conversation room     | `{ conversationId, readByUserId, readAt, isGroupChat, messageReadStatuses? }`                                                |
| `user_typing`                 | conv room (excl self) | `{ userId, conversationId }`                                                                                                 |
| `user_stopped_typing`         | conv room (excl self) | `{ userId, conversationId }`                                                                                                 |
| `message_edited`              | conversation room     | `{ messageId, conversationId, content, editedAt }`                                                                           |
| `message_deleted`             | conversation room     | `{ messageId, conversationId }` — one per message (bulk delete fires N of these, §5.8.1)                                     |
| `conversation_updated`        | conversation room     | Full updated conversation object                                                                                             |
| `conversation_muted`          | your own personal room | `{ conversationId, mutedUntil, isMuted }` — you muted/unmuted (from any device); sync mute state (§3.2)                     |
| `conversation_cleared`        | your own personal room | `{ conversationId, clearedUpToSequence }` — you deleted the chat on another device; hide the row (§3.3)                     |
| `participant_updated`         | conversation room     | `{ conversationId, participant }` — a member was auto-promoted to admin after the admin left (group guide §6)                           |
| `participant_removed`         | conversation room     | `{ conversationId, userId }`                                                                                                 |
| `removed_from_conversation`   | removed user's room   | `{ conversationId }`                                                                                                         |
| `participants_added`          | conversation room     | `{ conversationId, participants }`                                                                                           |
| `added_to_conversation`       | added user's room     | `{ conversationId }`                                                                                                         |
| `unread_counts_init`          | connecting user       | `{ counts: { [convId]: n }, inbox: { ... }, requests: { ... } }` — `counts` = `inbox` (back-compat)                          |
| `auth_expiring`               | the socket            | `{ expiresAt }` — fired ~60s before token expiry                                                                             |
| `auth_expired`                | the socket            | `{ reason? }` — fired at expiry; server disconnects immediately after                                                        |

---

## 15 — Open questions / not yet implemented

> already on the radar — see `CHAT_IMPLEMENTATION_V2.md` for status:

- **Media in chat** — ✅ shipped: images / GIFs / files. See
  `mobile-chat-media-guide.md`.
- **Reactions + GIF picker** — ✅ shipped. See `mobile-chat-reactions-gifs-guide.md`.
- **Threaded replies** — ✅ shipped. See `mobile-chat-replies-guide.md`.
- **Groups** — ✅ shipped: single admin, group image / description, add /
  remove, leave with auto-promotion, delete, group info, shared content,
  reporting. See `mobile-chat-groups-guide.md`. Not built: a deliberate admin
  **transfer**, and a second admin (promote/demote is disabled).
- **Voice / video calls** — not implemented.
- **Chat + contact search** — ✅ shipped: `GET /api/chat/search` (existing
  chats + startable contacts, §2.3) and the `q` filter on the conversation
  list (§2.2). What's **not** built is message **body** full-text search
  (finding a chat by words typed inside its messages).
- **Production FCM/APNs adapter** — push _registration_ works, dispatch
  works through the dispatcher, but the FCM channel is a stub. Once wired
  (see `CHAT_IMPLEMENTATION_V2.md` §6), pushes light up automatically.
- **Redis adapter for Socket.IO** — single-server only today; multi-server
  WebSocket sync isn't wired.

---

## 16 — Quick implementation checklist

When wiring this into the mobile app, do the work in this order:

1. **Auth refresh + token storage** — chat reuses your existing JWT.
2. **Register device** — POST `/api/devices` on first launch + after each
   login; store the returned device id; DELETE on logout.
3. **Socket connection** — connect on app foreground / login, disconnect
   on logout. Handle reconnect + listen for `auth_expiring` to fire
   `refresh_auth` (see §4.5).
4. **Conversation list screen** — TWO TABS:
   - Inbox: GET `/api/chat/conversations?folder=inbox&limit=30`
   - Requests: GET `/api/chat/conversations?folder=requests&limit=30`
     Paginate via `nextCursor`. Listen for `chat_list_update` (route by its
     `tray` field — the server's own answer; `status` alone cannot place a
     post-request row) + `unread_counts_init` (use `inbox` / `requests`
     buckets for tab badges) + `conversation_status_changed` (move row
     inbox ↔ requests) + `conversation_cleared` (you dismissed a row on
     another device) + `conversation_deleted` (a group was deleted).
   - Search box: add `&q=<term>` to filter the active tab (§2.2), or wire a
     dedicated "new message" screen off `GET /api/chat/search` (§2.3, chats +
     startable contacts). Debounce ~250ms; require ≥ 2 chars.
5. **Chat screen** — emit `join_conversation`, GET history with `limit=30`,
   listen for `new_message` / `message_edited` / `message_deleted` /
   `messages_read` / `user_typing` / `user_stopped_typing`.
6. **Requests-tab actions** — the three answers, and only these three (the tab
   can also hold a post-request thread once the requester has written into it;
   the actions are identical, and Accept opens the chat without answering the
   post request itself — that stays in Responses):
   Accept (`POST /accept`) / Delete (`POST /conversations/:id/clear`) /
   Block (`POST /api/blocks/:userId`). Delete is one-sided — it empties your
   tray and the sender never knows; the request returns if they write again.
   Do **not** wire Delete to `DELETE /conversations/:id` (groups only, 400 on
   a DM). After accept, navigate into chat; after delete or block, stay on the
   requests tab and remove the row. No composer on a PENDING thread — the
   server 403s a reply (§6.4). Drive the buttons off `conversation.status` +
   `requesterId` from the server, never off local state.
7. **Send flow** — UUID `clientMessageId` per message, optimistic append,
   POST with retry on transient errors, reconcile on socket / response.
   Two PENDING-DM notes for the sender: `status.deliveredCount` stays at 0
   until the recipient accepts (surface as "Request pending", not "Sent"),
   and the composer must offer **text only** — hide the attachment, GIF and
   share buttons until `status === "ACCEPTED"` (§6.4.1).
8. **Gap-fill** — on socket reconnect, for each open conversation: GET
   `?afterSequence=<highestSeqSeen>`.
9. **Push handling** — register notification listeners for three
   `data.entityType` cases:
   - `message` (or `mention`) → open chat at the referenced message
   - `conversation` with `data.deepLink` containing `/requests/` →
     open requests tab, surface accept / delete / block
   - `conversation` with `data.deepLink` containing `/chat/:id` (not
     `/requests/`) → `message_request_accepted` — open chat normally
10. **Block / blocked-accounts screen** — Settings → "Blocked accounts"
    drives off `GET /api/blocks`. Per-row Unblock fires
    `DELETE /api/blocks/:userId`. Profile screen gets a Block action
    in the overflow menu (`POST /api/blocks/:userId`).
11. **Group management screens** (later) — add / remove members and lock
    messaging, shown only to the single admin (the creator). No promote /
    demote — see `mobile-chat-groups-guide.md` for the full contract.
