Skip to main content
Version: Latest
UPDATED 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.
## 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. ---
| `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 }` \| null) drives the "awaiting a response to your post" banner, so an incoming row needs no fetch to render. `unreadIncrement` is 0 for a pending recipient || `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 |

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

  • 2026-09-09chat_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.

RoomWho's in itJoined howCarries
Personal user:<yourId>just your sockets (all your devices)auto on connectcross-conversation deltas: sidebar bumps, your own receipts, tray moves, mutes, group membership, notifications
Conversation conversation:<id>everyone with that chat screen openyou emit join_conversationin-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

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.

// ~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.

EventPayloadEmit whenServer effect
join_conversationconversationId (string)chat screen opens / regains focus, and on every reconnectjoin the conv room; auto-marks unread read → broadcasts messages_read
leave_conversationconversationId (string)chat screen closes / loses focusleave the conv room
typing_startconversationId (string)first keystroke (debounce 200–500ms)broadcast user_typing to the room (excl. you)
typing_stopconversationId (string)on send, 3s idle, or blurbroadcast 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_authtoken (string)on auth_expiringre-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

EventPayloadHandle 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 viewstatus: "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
notificationnotification objectin-app notification / badge (see main guide §12)

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

EventPayloadHandle by
new_messagefull message + statusappend 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_reactionreaction summaryupdate counts, preserve your own mine (reactions guide)
conversation_updatedfull conversationapply 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:

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:

    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.

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_expiringrefresh_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.
⤓ Download .md