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.
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.tokenfield (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-09 —
chat_list_update'spendingPostRequestnow carriesselectedOptions— 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
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_authover reconnecting: reconnect is heavy (re-seedsunread_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_conversationis a read trigger — you usually don't need a separatemark_readon open. Usemark_readonly for the "already open, new message lands" case.mark_deliveredis mostly redundant: the server already marks delivered on connect (the sweep), onjoin_conversation, and on every history GET. Reach for it only when your client received anew_messageit can't render yet.- A non-participant emitting
join_conversationis 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
- Sent. You POST a message. Sender receives
message_status_update(status: "sent"). - 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.
- Read — the recipient triggers a read (
join_conversation,mark_read, orPOST /read). Server broadcastsmessages_readto the conversation room.- DM: payload has no per-message id — apply "all my sent messages
with time ≤
readAtare read." - Group:
messageReadStatuses[]gives{ messageId, readCount, totalRecipients, allRead, readByUserIds }per message.
- DM: payload has no per-message id — apply "all my sent messages
with time ≤
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:
-
Re-
join_conversationthe open chat (rejoins the room; also marks read). -
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. WalknextCursoruntil null. Store the highestsequenceNumberper conversation locally. -
For the sidebar you don't need to gap-fill —
unread_counts_init+chat_list_updatekeep 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
- Connect on foreground/login;
disconnecton logout. Handleconnect_error(refresh token, don't spam). - Auth lifecycle — listen for
auth_expiring→refresh_auth; handleauth_expired. - Personal-room listeners (always):
unread_counts_init,chat_list_update(place the row by itstrayfield — never re-derive it),conversation_status_changed,conversation_deleted,conversation_cleared,conversation_muted,added_/removed_from_conversation,message_delivered,message_status_update,notification. - Chat screen:
join_conversationon open,leave_conversationon close. Listen fornew_message,messages_read,user_typing/user_stopped_typing,message_edited,message_deleted,message_reaction. - Receipts: apply
message_delivered/messages_readto the sender view; keymessages_readonreadByUserId; merge, don't replace on your ownnew_messageecho and on variant refetch (§6). - Reconnect: on every
connect, re-join_conversationthe open chat and gap-fill via?afterSequence=(§7). - Payload shapes: bare string for room/typing, object for
mark_read/mark_delivered.