Mobile Chat Replies — Integration Guide
WhatsApp-style threaded replies: any message can quote another, replies can chain (reply → reply → reply), and every reply embeds a preview of its immediate parent. Companion to
mobile-chat-guide.md(send/receive, sockets, receipts) andmobile-chat-media-guide.md(attachments).
- API base URL (local):
http://localhost:3001 - Auth:
Authorization: Bearer <accessToken>on every endpoint (required)
Data model in one paragraph
A reply carries parentMessageId → its immediate parent. There is no root
id: a chain is traced by walking parent links (each hop's preview carries the
next parentMessageId). The embedded parent preview is computed from the
live parent row on every read — never snapshotted — so parent edits and
soft-deletes always show fresh on the next fetch.
Sending a reply
Same send endpoint, one extra field:
POST /api/chat/conversations/:id/messages
{
"content": "yes!! after homework",
"clientMessageId": "uuid-from-client",
"parentMessageId": "<id of the message being quoted>"
}
Works with media too (media: [...] rides along as usual — reply-with-GIF is
just both fields set).
Validation matrix
| Input | Result |
|---|---|
| Parent exists in this conversation | OK |
| Parent soft-deleted | OK — preview carries the deleted placeholder (avoids race 400s when the parent is deleted mid-send) |
| Parent id nonexistent / bogus | 400 "Parent message not found in this conversation" |
| Parent lives in another conversation | 400 — same message as above (existence can't be probed) |
| Reply to your own message | OK, normal reply |
The message shape
Every serialized message (GET page, POST response, socket new_message) now
carries:
{
"id": "…",
"content": "yes!! after homework",
"parentMessageId": "abc123", // null when not a reply
"parent": { // null ⟺ parentMessageId null (server invariant)
"id": "abc123",
"senderId": "…",
"content": "want to play minecraft",
"type": "TEXT", // "MEDIA" when the parent has no text
"isDeleted": false,
"createdAt": "2026-07-07T…",
"sequenceNumber": 41, // ← jump-to-original target (see below)
"sender": { "id": "…", "username": "bob", "profile": { "displayName": "Bob", "avatarUrl": null } },
"media": [ { "type": "image", "url": "…", … } ] // parent attachments
},
"replyCount": 3, // direct replies to THIS message
… // everything else unchanged
}
parent cases — render rules
| Case | Payload | Render |
|---|---|---|
| Not a reply | parentMessageId: null, parent: null | no quote box |
| Normal reply | both set | quote box: parent.sender name + parent.content (clamp to ~2 lines) |
| Parent has media, no text | parent.type === "MEDIA" | first parent.media item as thumb; fallback label "📷 Media" |
| Parent soft-deleted | parent.isDeleted: true, content = "This message was deleted" | quote box with the placeholder, italic |
| Parent hard-deleted (author's account deleted) | both become null (DB SET NULL) | reply renders as a normal message |
Server invariant: parentMessageId non-null ⟺ parent non-null in every
response. If your cache ever holds an id without a preview, you dropped the
embedded preview — don't (see socket rules).
replyCount semantics
Direct children only (not descendants — chains don't accumulate). Counts ALL reply rows including soft-deleted ones (they stay visible as "deleted" bubbles, so the count matches what a user sees). Soft-deleting a reply does NOT decrement.
A ← B, C (deleted), D → A.replyCount === 3
Socket rules (uniform across clients — don't improvise)
new_messageembeds the full parent preview. Rendering a quote NEVER requires the parent to be cached — no lazy fetch, no placeholder-forever. Render from the embedded preview unconditionally.- When a
new_messagearrives withparentMessageIdmatching a message in your cache, increment that cached message'sreplyCountlocally. message_edited/message_deletedpatch only the target message. Do NOT walk your cache patching embedded previews of other messages — previews refresh on the next GET (accepted, short-lived staleness).
Jump-to-original (tap the quote box)
- Parent already in your loaded window → scroll to it + highlight.
- Not loaded → fetch a window centred on it:
GET /api/chat/conversations/:id/messages?aroundSequence=<parent.sequenceNumber>&limit=30
{
"success": true,
"data": {
"items": [ … ], // ascending, window centred on the target
"nextCursor": null, // around mode has no cursor of its own
"mode": "around"
}
}
- Near the conversation's start or end the short side's deficit fills from the
other side — you still get a full
limitwindow when enough messages exist. - Continue older from the window with the existing
?cursor=<lowest sequenceNumber>, newer with?afterSequence=<highest sequenceNumber>. - Mode precedence when combined:
aroundSequence>afterSequence>cursor. - Non-numeric
aroundSequence→ 400. - Tracing a chain to its origin = repeat: read
parent.sequenceNumber, fetch around it, tap again. Each hop is one request.
Notifications
The author of the quoted parent gets a distinct flavor:
{
"type": "message_reply", // instead of "message"
"title": "Alice replied to your message", // groups: "Group name: Alice replied to your message"
"body": "<reply text, 140-char truncated>",
"data": {
"entityType": "message",
"entityId": "<replyMessageId>",
"parentMessageId": "<parentId>", // only present on replies
"conversationId": "…",
"deepLink": "ksn://chat/<conversationId>?messageId=<replyMessageId>"
}
}
- Everyone else in the conversation still gets the plain
messagenotification. - Reply to your own message → no reply notification (senders never get notified of their own sends).
@mentioned users keep getting thementionnotification instead (mention wins over reply flavor).
Smoke reference
Server-side contract is covered by pnpm --filter @ksn/api smoke:chat-replies
(18 checks: preview round-trip, deleted/edited parents, counts-all semantics,
20-way concurrency, 12-deep chains, around-window edges, guard 400s).