Skip to main content
Version: Latest

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) and mobile-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

InputResult
Parent exists in this conversationOK
Parent soft-deletedOK — preview carries the deleted placeholder (avoids race 400s when the parent is deleted mid-send)
Parent id nonexistent / bogus400 "Parent message not found in this conversation"
Parent lives in another conversation400 — same message as above (existence can't be probed)
Reply to your own messageOK, 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

CasePayloadRender
Not a replyparentMessageId: null, parent: nullno quote box
Normal replyboth setquote box: parent.sender name + parent.content (clamp to ~2 lines)
Parent has media, no textparent.type === "MEDIA"first parent.media item as thumb; fallback label "📷 Media"
Parent soft-deletedparent.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_message embeds 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_message arrives with parentMessageId matching a message in your cache, increment that cached message's replyCount locally.
  • message_edited / message_deleted patch 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)

  1. Parent already in your loaded window → scroll to it + highlight.
  2. 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 limit window 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 message notification.
  • Reply to your own message → no reply notification (senders never get notified of their own sends).
  • @mentioned users keep getting the mention notification 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).

⤓ Download .md