# Mobile Chat Sockets — Integration Guide

> The dedicated **real-time** reference: how the RN client connects, which
> events it **emits**, which events it **listens** for, the delivery/read
> receipt lifecycle, and a drop-in client wrapper. Companion to
> `mobile-chat-guide.md` (read that first for the HTTP endpoints and the
> message object). Nothing here needs a backend change on your side.

- WebSocket URL: same host as the API, Socket.IO **v4** client.
- Auth: JWT in the handshake `auth.token` field (same access token as HTTP).
- Transport: **hybrid push/pull** — the socket pushes live deltas; HTTP pulls
  history, the conversation list, and reconnect gap-fill. Never rely on the
  socket alone for correctness — it can miss events across a drop. HTTP is the
  source of truth; the socket is the fast path.

## 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** — `chat_list_update`'s `pendingPostRequest` now carries `selectedOptions` — the options the requester ticked on the post. Render them on the row's request card without a fetch; `[]` when nothing was ticked.

---

## 1 — The room model (read this first)

Every event is scoped to a **room**. There are exactly two kinds, and knowing
which room an event targets tells you when you'll receive it.

| Room | Who's in it | Joined how | Carries |
| --- | --- | --- | --- |
| **Personal** `user:<yourId>` | just your sockets (all your devices) | **auto** on connect | cross-conversation deltas: sidebar bumps, your own receipts, tray moves, mutes, group membership, notifications |
| **Conversation** `conversation:<id>` | everyone with that chat screen open | **you emit** `join_conversation` | in-thread deltas: new messages, read receipts, typing, edits/deletes, reactions |

Consequences:

- You get sidebar / badge / notification events **without** joining any
  conversation room — the personal room is automatic.
- You only receive `new_message`, `messages_read`, typing, etc. for a
  conversation **while you're joined to its room**. Miss a window (backgrounded,
  dropped) → those events never replay. Recover with **HTTP gap-fill** (§7).
- You only need **one** conversation room joined at a time (the open chat).

---

## 2 — Connect + authenticate

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

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

socket.on("connect", () => {
  // You're already in your personal room (user:<id>).
  // The server immediately emits `unread_counts_init` (§5) and sweeps every
  // conversation to mark undelivered messages delivered (fires `message_delivered`
  // to the senders). Re-join the open conversation room here (see §7).
});

socket.on("connect_error", (err) => {
  // "Authentication required" (no token) | "Invalid token" (rejected).
  // Refresh the access token, set socket.auth, and let reconnection retry.
  // Do NOT hammer reconnect with the same bad token.
  if (err.message === "Invalid token" || err.message === "Authentication required") {
    refreshAccessToken().then((t) => { socket.auth = { token: t }; });
  }
});

socket.on("disconnect", (reason) => {
  // "io server disconnect"  → server kicked us (often auth expiry, §3). Won't auto-reconnect.
  // "transport close" / "ping timeout" → transient; socket.io reconnects on its own.
  // On the next `connect`, run gap-fill (§7).
});
```

The `userId` is **always** taken from the verified token, never from the
handshake — you cannot impersonate another user by tweaking the connection.

---

## 3 — Token lifecycle (refresh while connected)

The handshake JWT expires (15 min default). The server tracks each socket's
expiry and drives a two-stage timer so a live socket refreshes without a full
reconnect.

```javascript
// ~60s BEFORE expiry — refresh now and hand the new token back.
socket.on("auth_expiring", ({ expiresAt }) => {
  refreshAccessToken().then((newToken) => socket.emit("refresh_auth", newToken));
});

// AT expiry (never refreshed, or the new token was rejected). Server
// disconnects immediately after this — no further events arrive.
socket.on("auth_expired", ({ reason }) => {
  // "token_expired"     → ran out (also the at-expiry timer): refresh + reconnect.
  // "invalid_token"     → never valid (bad signature/malformed): bounce 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();
});
```

Rules:

- Send the **new access token** (not the refresh token) on `refresh_auth`.
- The identity in the new token **must** match the socket's original user —
  switching users requires a full reconnect.
- Prefer `refresh_auth` over reconnecting: reconnect is heavy (re-seeds
  `unread_counts_init`, re-runs the delivered sweep, needs a room re-join).

---

## 4 — Client → Server (events you emit)

> ⚠️ **Payload-shape gotcha.** Room/typing events take a **bare string**;
> the receipt events take an **object**. Mixing them up = a silently ignored
> emit.

| Event | Payload | Emit when | Server effect |
| --- | --- | --- | --- |
| `join_conversation` | `conversationId` *(string)* | chat screen opens / regains focus, and on every reconnect | join the conv room; **auto-marks unread read** → broadcasts `messages_read` |
| `leave_conversation` | `conversationId` *(string)* | chat screen closes / loses focus | leave the conv room |
| `typing_start` | `conversationId` *(string)* | first keystroke (debounce 200–500ms) | broadcast `user_typing` to the room (excl. you) |
| `typing_stop` | `conversationId` *(string)* | on send, 3s idle, or blur | broadcast `user_stopped_typing` |
| `mark_read` | `{ conversationId }` *(object)* | a message is read while already in the room (see §6) | mark your unread receipts read → broadcast `messages_read` |
| `mark_delivered` | `{ conversationId }` *(object)* | rarely — you receive but can't yet render (backgrounded) | mark your undelivered receipts delivered → `message_delivered` to each sender |
| `refresh_auth` | `token` *(string)* | on `auth_expiring` | re-verify the socket, reset the expiry timer |

Notes:

- `join_conversation` **is** a read trigger — you usually don't need a separate
  `mark_read` on open. Use `mark_read` only for the "already open, new message
  lands" case.
- `mark_delivered` is mostly redundant: the server already marks delivered on
  connect (the sweep), on `join_conversation`, and on every history GET. Reach
  for it only when your client received a `new_message` it can't render yet.
- A non-participant emitting `join_conversation` is silently ignored (no room
  join, no error) — the server checks membership first.

---

## 5 — Server → Client (events you listen for)

Grouped by the room that carries them (§1).

### 5.1 — Personal room (`user:<id>`) — always on, no join needed

| Event | Payload | Handle by |
| --- | --- | --- |
| `unread_counts_init` | `{ counts, inbox, requests }` | seed sidebar badges on connect. `counts` == `inbox` (back-compat). `requests` keys = distinct pending convs |
| `message_delivered` | `{ messageId, conversationId, userId, deliveredAt }` | **sender view** — one row per recipient. Bump `deliveredCount` on that message (§6) |
| `message_status_update` | `{ messageId, conversationId, status }` | **sender view** — `status: "sent"` confirms persistence |
| `chat_list_update` | `{ conversationId, status, requesterId, pendingPostRequest, tray, lastMessage, unreadIncrement }` | bump the sidebar row + unread, and **route by `tray`** — `"inbox"` or `"requests"`, computed by the server for *you* (the event is per-recipient). Don't derive it from `status`: a post request the requester hasn't written into yet is PENDING but belongs in neither chat tray. `pendingPostRequest` (`{ id, postId, selectedOptions }` \| null) drives the "awaiting a response to your post" banner and carries the options the requester ticked, so an incoming row needs no fetch to render. `unreadIncrement` is 0 for a pending recipient |
| `conversation_status_changed` | `{ conversationId, status: "ACCEPTED" }` | move the row requests → inbox locally |
| `conversation_deleted` | `{ conversationId }` | a **group** was hard-deleted (admin, or last member left) — drop the row from every cache; kill any open room sub. Never fires for a DM or a request |
| `conversation_muted` | `{ conversationId, mutedUntil, isMuted }` | fired to **your own** devices — sync mute state across devices |
| `conversation_cleared` | `{ conversationId, clearedUpToSequence }` | fired to **your own** devices — you deleted a chat, or dismissed a message request, elsewhere; drop the row. It returns on the next `new_message` for that conversation. The other participant is never told |
| `added_to_conversation` | `{ conversationId }` | you were added to a group — refetch the list |
| `removed_from_conversation` | `{ conversationId }` | you were removed — drop it from the list |
| `notification` | notification object | in-app notification / badge (see main guide §12) |

### 5.2 — Conversation room (`conversation:<id>`) — only while joined

| Event | Payload | Handle by |
| --- | --- | --- |
| `new_message` | full message + `status` | append to the thread. `POST_SHARE` rows carry `sharedPostId` only → resolve the card (main guide §5.10). If it's **your own** echo, **merge** by `clientMessageId`, don't replace (§6) |
| `messages_read` | `{ conversationId, readByUserId, readAt, isGroupChat, messageReadStatuses? }` | **sender view** — flip your sent messages to read. DM: all messages ≤ `readAt`. Group: per-message via `messageReadStatuses[]` |
| `user_typing` | `{ userId, conversationId }` | show the indicator (auto-hide after ~5s as a safety net) |
| `user_stopped_typing` | `{ userId, conversationId }` | hide the indicator |
| `message_edited` | `{ messageId, conversationId, content, editedAt, mediaRemovedId? }` | update content + "edited" label; splice out `mediaRemovedId` if present |
| `message_deleted` | `{ messageId, conversationId }` | swap to "This message was deleted" + dim. Bulk delete fires **N** of these |
| `message_reaction` | reaction summary | update counts, **preserve your own `mine`** (reactions guide) |
| `conversation_updated` | full conversation | apply new name / restriction |
| `participants_added` | `{ conversationId, participants }` | add members to the roster |
| `participant_removed` | `{ conversationId, userId }` | remove from roster |
| `participant_updated` | `{ conversationId, participant }` | apply role change — **dormant**, promote/demote is disabled (main guide §8.3) |

### 5.3 — Socket-level (Socket.IO built-ins + auth)

`connect`, `connect_error`, `disconnect` (§2); `auth_expiring`, `auth_expired`
(§3).

---

## 6 — The receipt lifecycle end-to-end

This is where "read receipts don't update for files" bugs live. The **server
fires receipts identically for text and media** — the difference is purely
client-side timing. Walk the whole path:

### Sent → Delivered → Read

1. **Sent.** You POST a message. Sender receives `message_status_update`
   (`status: "sent"`).
2. **Delivered** — the sender receives `message_delivered` (one per recipient)
   whenever a recipient becomes reachable. That happens on **any** of:
   - the message is sent while the recipient is already in the room,
   - the recipient's socket **connects** (server sweeps all convs),
   - the recipient **GETs history** for the conversation,
   - the recipient emits `mark_delivered`.
3. **Read** — the recipient triggers a read (`join_conversation`, `mark_read`,
   or `POST /read`). Server broadcasts `messages_read` to the conversation room.
   - **DM:** payload has **no per-message id** — apply "all my sent messages
     with time ≤ `readAt` are read."
   - **Group:** `messageReadStatuses[]` gives `{ messageId, readCount,
     totalRecipients, allRead, readByUserIds }` per message.

Every `messages_read` — whether triggered over the socket or via HTTP
`POST /read` — carries the **same shape**, keyed on `readByUserId`. (Older
builds leaked a `userId` field from the HTTP path; unified now. Key strictly on
`readByUserId`.)

### The merge rule — don't clobber a receipt you already applied

Media messages upload their bytes **before** the send round-trip, so the send
response (and its `new_message` echo) can arrive **after** a `message_delivered`
/ `messages_read` for that same message has already advanced its `status`. If
you reconcile your own message by **replacing** the cached row with the
freshly-sent object (whose `status` still reads `deliveredCount: 0,
readCount: 0`), you erase the receipt — and it only "comes back" on the next
history refetch. That's the classic "ticks only update on refresh, and only for
image/file messages" report.

**Fix: merge, never replace, when reconciling your own message** (and again on
the async image-variant refetch). Keep the more-advanced `status`:

```typescript
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,
      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,
    },
  };
}
```

Full context in the main guide §5.5.1 and the media guide "Thumbnails / variants".

### PENDING DM caveat

In a PENDING DM (a message request), the **recipient generates no receipts** —
the sender's `deliveredCount`/`readCount` stay 0 so you can't tell whether a
stranger has seen your request. On `POST /accept` the server backfills every
prior receipt with `deliveredAt = acceptTime`; the sender's next
`message_delivered` reflects it. Surface pending sends as "Request pending", not
"Sent".

---

## 7 — Reconnect: what the socket does NOT replay

A dropped socket misses every event fired while it was gone. On reconnect the
server re-seeds `unread_counts_init` and re-runs the delivered sweep, but it
does **not** replay `new_message` / `messages_read` for the gap. You recover
those over HTTP:

1. Re-`join_conversation` the open chat (rejoins the room; also marks read).
2. **Gap-fill** each open conversation from the highest sequence you've seen:

   ```http
   GET /api/chat/conversations/:id/messages?afterSequence=<highestSeqSeen>
   ```

   Returns messages with `sequenceNumber > N`, ascending. Walk `nextCursor`
   until null. Store the highest `sequenceNumber` per conversation locally.

3. For the sidebar you don't need to gap-fill — `unread_counts_init` +
   `chat_list_update` keep badges current.

Full pagination detail: main guide §7.

---

## 8 — Drop-in RN client wrapper

A thin singleton: connect, auto-refresh, typed emit helpers, and a single place
to register listeners. Keep your cache-mutation logic (merge rules from §6) in
the handlers you pass to `onServerEvent`.

```typescript
import { io, Socket } from "socket.io-client";

let socket: Socket | null = null;

export function connectChatSocket(getToken: () => string, refresh: () => Promise<string>) {
  if (socket) return socket;
  socket = io(API_URL, {
    auth: { token: getToken() },
    transports: ["websocket"],
    reconnection: true,
    reconnectionDelay: 1000,
    reconnectionDelayMax: 5000,
  });

  socket.on("connect_error", async (err) => {
    if (err.message === "Invalid token" || err.message === "Authentication required") {
      socket!.auth = { token: await refresh() };
    }
  });
  socket.on("auth_expiring", async () => socket!.emit("refresh_auth", await refresh()));
  socket.on("auth_expired", () => {/* bounce to your re-auth flow */});

  return socket;
}

// Room + typing events take a BARE STRING; receipt events take an OBJECT.
export const joinConversation  = (id: string) => socket?.emit("join_conversation", id);
export const leaveConversation = (id: string) => socket?.emit("leave_conversation", id);
export const startTyping       = (id: string) => socket?.emit("typing_start", id);
export const stopTyping        = (id: string) => socket?.emit("typing_stop", id);
export const markRead          = (conversationId: string) => socket?.emit("mark_read", { conversationId });
export const markDelivered     = (conversationId: string) => socket?.emit("mark_delivered", { conversationId });

// Register every listener once (e.g. in your root layout). Handlers own the
// cache writes — apply the merge rule (§6) for your own new_message echoes.
export function onServerEvent(handlers: {
  onNewMessage?: (m: any) => void;
  onMessagesRead?: (e: any) => void;
  onMessageDelivered?: (e: any) => void;
  onTyping?: (e: any) => void;
  onStoppedTyping?: (e: any) => void;
  onEdited?: (e: any) => void;
  onDeleted?: (e: any) => void;
  onChatListUpdate?: (e: any) => void;
  onUnreadInit?: (e: any) => void;
  // …add the rest from §5 as your screens need them
}) {
  if (!socket) return;
  handlers.onNewMessage      && socket.on("new_message", handlers.onNewMessage);
  handlers.onMessagesRead    && socket.on("messages_read", handlers.onMessagesRead);
  handlers.onMessageDelivered&& socket.on("message_delivered", handlers.onMessageDelivered);
  handlers.onTyping          && socket.on("user_typing", handlers.onTyping);
  handlers.onStoppedTyping   && socket.on("user_stopped_typing", handlers.onStoppedTyping);
  handlers.onEdited          && socket.on("message_edited", handlers.onEdited);
  handlers.onDeleted         && socket.on("message_deleted", handlers.onDeleted);
  handlers.onChatListUpdate  && socket.on("chat_list_update", handlers.onChatListUpdate);
  handlers.onUnreadInit      && socket.on("unread_counts_init", handlers.onUnreadInit);
}

export function disconnectChatSocket() {
  socket?.disconnect();
  socket = null;
}
```

Wire `join_conversation` / `leave_conversation` to the chat screen's
focus/blur, and re-`join_conversation` on every `connect` (then gap-fill, §7).

---

## 9 — Checklist

1. **Connect** on foreground/login; `disconnect` on logout. Handle
   `connect_error` (refresh token, don't spam).
2. **Auth lifecycle** — listen for `auth_expiring` → `refresh_auth`; handle
   `auth_expired`.
3. **Personal-room listeners** (always): `unread_counts_init`,
   `chat_list_update` (place the row by its `tray` field — never re-derive it),
   `conversation_status_changed`, `conversation_deleted`,
   `conversation_cleared`,
   `conversation_muted`, `added_/removed_from_conversation`, `message_delivered`,
   `message_status_update`, `notification`.
4. **Chat screen**: `join_conversation` on open, `leave_conversation` on close.
   Listen for `new_message`, `messages_read`, `user_typing`/`user_stopped_typing`,
   `message_edited`, `message_deleted`, `message_reaction`.
5. **Receipts**: apply `message_delivered` / `messages_read` to the sender view;
   key `messages_read` on `readByUserId`; **merge, don't replace** on your own
   `new_message` echo and on variant refetch (§6).
6. **Reconnect**: on every `connect`, re-`join_conversation` the open chat and
   gap-fill via `?afterSequence=` (§7).
7. **Payload shapes**: bare string for room/typing, object for `mark_read` /
   `mark_delivered`.
