# 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:

```http
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:

```jsonc
{
  "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_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:

```http
GET /api/chat/conversations/:id/messages?aroundSequence=<parent.sequenceNumber>&limit=30
```

```jsonc
{
  "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:

```jsonc
{
  "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).
- `@mention`ed 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).
