# Mobile Chat System Messages — Integration Guide

> The centred grey bubbles: "You added Bob", "Alice renamed the group",
> "Nadia accepted your request". They are ordinary messages in the timeline
> with `type: "SYSTEM"`, written by the server, and **their text is rendered
> for you specifically** — the other person reads a different sentence off the
> same row. Companion to `mobile-chat-guide.md` (send/receive, sockets,
> receipts) and `mobile-post-request-dm-guide.md`.

- API base URL (local): `http://localhost:3001`
- Auth: `Authorization: Bearer <accessToken>` on every endpoint (**required**)

## 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-01** — Group-settings lines now name the setting: "Alice changed the group settings so only admins can send messages." The description-changed line carries a **View description** affordance — render it when `cleared` is false.
- **2026-09-01** — A group you left or were removed from no longer disappears. It stays in the list, read-only, frozen at the moment you left, ending with the line that says what happened. Blocked DMs still vanish.
- **2026-09-01** — Request lines now carry the post title — "You accepted Bob's request for "Debate Partner"." The title is snapshotted when the line is written and clipped to 40 characters for display.
- **2026-09-01** — The request card's status chip was dropped. It was planned in the design review, then withdrawn: every state it would show is already a line in the same thread. Don't build it.
- **2026-09-01** — `post_request_opened` is retired — 13 events remain. An old one arrives as `system.unsupported`, so keep that fallback. A rejected request is now worded "wasn't accepted", never "declined", in the bubble and the notification title alike.

---

## The one rule

**Render `message.system.text`. Never render `message.content` for a SYSTEM
message.** `content` holds the neutral third-person copy ("Alice renamed the
group…") that the conversation list shows as a preview; `system.text` is the
same event written for the reader ("You renamed the group…"). Two participants
fetching the same message id get different `system.text`, so never cache it
across users.

The conversation list is the one place `content` is correct to render. The
three endpoints that return a conversation — `GET /api/chat/conversations`,
`GET /api/chat/search`, `GET /api/chat/conversations/:id` — carry the last
message in `messages[0]` with **no `system` object**, and `content` is written
to be neutral for exactly that reason: it names both parties ("Alice sent a
request on Bob's post") rather than addressing a reader. Anywhere a `system`
object *is* present, use `system.text`.

`messages[0]` on those three no longer carries the internal `systemEvent` /
`systemData` columns — for **any** message type, not just SYSTEM ones (they
were present and `null` on a TEXT row). Nothing else about that object changed;
both types now expose the same key set.

If you were reading those two columns anywhere, read the **`system` object**
instead, on the message-history endpoint
(`GET /api/chat/conversations/:id/messages`) or the `new_message` socket event.
Do not look for the raw columns there either: a SYSTEM row on those two
surfaces is serialised, and serialising strips them. (A TEXT row still carries
them, always `null` — which is exactly why they are not a signal.) `system` is
the only supported way to read an event code or its payload.

## Wire shape

A system message arrives in exactly the places a normal message does. The
**full shape below, with its `system` object, comes from two of them** — the
history page (`GET /api/chat/conversations/:id/messages`) and the `new_message`
socket event. The conversation list's `messages[0]` preview carries the same
row **without** `system` (see above): render its `content` there.

```json
{
  "id": "msg_...",
  "type": "SYSTEM",
  "conversationId": "conv_...",
  "senderId": "user_alice",
  "sender": { "id": "user_alice", "username": "alice", "profile": { "displayName": "Alice" } },
  "sequenceNumber": 42,
  "createdAt": "2026-08-21T10:00:00.000Z",
  "content": "Alice renamed the group to \"Science Club\".",
  "media": [],
  "reactions": [],
  "parent": null,
  "replyCount": 0,
  "system": {
    "event": "group_renamed",
    "actor": { "id": "user_alice", "username": "alice", "displayName": "Alice" },
    "data": { "name": "Science Club", "previousName": "Untitled" },
    "text": "You renamed the group to \"Science Club\"."
  }
}
```

Notes on that payload:

| Field | Contract |
| --- | --- |
| `type` | `"SYSTEM"` — the only signal you need to switch renderers |
| `system.text` | **what you display.** Already resolved for the requesting user |
| `system.event` | stable code, for icons/analytics/localisation later |
| `system.actor` | who did it. `senderId` is the same user — it is not a "sender" in any meaningful sense |
| `system.data` | per-event payload (see the table below) |
| `system.unsupported` | present and `true` when the server could not render the row (a newer event code). Show `text` — a neutral fallback line — or skip the bubble |
| `status` | **absent.** A system message has no receipts, so there are no ticks |
| `media` / `reactions` / `parent` / `replyCount` | always `[]` / `null` / `0` |

## Behaviour

| | SYSTEM |
| --- | --- |
| appears in the timeline, paginates normally | yes |
| becomes the conversation-list preview (`content`) and re-sorts the thread | yes |
| raises the unread badge | **no** — it writes no receipts |
| shows delivered/read ticks | **no** |
| can be replied to, reacted to, edited, deleted, reported | **no** — the API returns 400 (404 for report) |

Do not offer reply/react/edit/delete/report affordances on a SYSTEM bubble, and
exclude them from multi-select delete. `mark_read` / `mark_delivered` on one is
harmless but pointless.

## The 13 events

`system.data` shapes, by `system.event`:

| Event | `data` | Fires when |
| --- | --- | --- |
| `post_request_accepted` | `{ requestId, postId, requester, postTitle }` | the post author accepts. Copy: "You accepted Bob's request for \"Debate Partner\"." / "Your request for \"Debate Partner\" was accepted." — the requester's line names nobody, since the other party is already in the chat header |
| `post_request_declined` | `{ requestId, postId, requester, postTitle }` | the author does not accept, **and the thread survives** the cleanup. Copy: "You didn't accept Bob's request for \"Debate Partner\"." / "Your request for \"Debate Partner\" wasn't accepted." — never "declined" |
| `post_request_reopened` | `{ requestId, postId, requester, postTitle }` | the author takes an accepted request back to PENDING. Changes nothing about who may send — the conversation is ACCEPTED by then and stays that way. The requester reads "Your request for \"Debate Partner\" is being looked at again." |
| `post_request_withdrawn` | `{ requestId, postId, postTitle }` | the requester withdraws, same condition |
| `group_created` | `{ name }` | a group is created |
| `participants_added` | `{ targets: UserRef[] }` | members added |
| `participant_removed` | `{ targets: UserRef[] }` | a member is removed by an admin |
| `participant_left` | `{}` | a member leaves |
| `participant_promoted` | `{ target: UserRef }` | an admin left without a successor and the longest-standing member was auto-promoted. **The only event with no "You …" form for the actor** — nobody chose it; only the promoted member reads second person ("You're now the group admin") |
| `group_renamed` | `{ name, previousName }` | group name changed |
| `group_photo_changed` | `{ cleared }` | photo set (`cleared: false`) or removed (`true`) |
| `group_description_changed` | `{ cleared }` | description set or removed. **Render a "View description" link beside this line when `cleared` is false**, opening group info — this is the one event whose content cannot fit in its own sentence. The description is **not** in the payload (it can be paragraphs, and it can change later); read it from the conversation's own `description` field |
| `messaging_restricted` | `{ restricted }` | admin-only messaging turned on (`true`) or off (`false`). Copy: "Alice changed the group settings so only admins can send messages." / "…so everyone can send messages." |

`UserRef` is `{ id, username, displayName }`, snapshotted at write time — a
later display-name change does **not** rewrite old events, by design.

`postTitle` is snapshotted the same way and for the same reason: deleting the
post later must not blank a sentence written months ago. It is `null` on rows
written before 2026-09-01, and the sentence then simply drops the `for "…"`
clause — **it does not mean the post is gone**. Long titles arrive already
clipped to ~40 characters with an ellipsis; don't clip again.

A single `PATCH /conversations/:id` that changes the name *and* the photo emits
**two** system messages, one per field. A PATCH that changes nothing emits none.

## Sockets

System messages arrive as `new_message`, exactly like a normal message, but
they are sent **per user** rather than to the conversation room — that is how
each participant gets their own `system.text`. A `chat_list_update` follows
with `unreadIncrement: 0`.

Nothing else changes: keep your existing `new_message` handler, branch on
`type === "SYSTEM"`, and render the bubble.

## Things that will bite if you assume otherwise

- **Accepting a message request adds nothing to the thread.** There is no
  system message for it, by design, so do not wait for one or treat its absence
  as a dropped event. The recipient chose Accept from Accept / Delete / Block,
  and the requester was never blocked from sending in the first place — a cold
  request lets its sender keep messaging while the thread is PENDING; only the
  *recipient* is refused until they accept. What you do get on accept is
  unchanged: `conversation_status_changed` over the socket, and a
  `message_request_accepted` **notification** to the requester. (That
  notification type still exists — only the system message was removed.)
- **`post_request_accepted` does not promise a newly unlocked chat.** It reads
  "Maya accepted your request." and nothing more: the same row is written when
  the request attached to a conversation that was open the whole time. The
  thread's real state is `conversation.status` plus the
  `conversation_status_changed` socket event, as it always was.
- **An unknown `system.event` is not an error.** `post_request_reopened` is new
  in this release; a build that predates it receives `system.unsupported: true`
  and a neutral fallback line. Keep that branch — the event list will grow again.
- **There is no history before this release.** Renames, adds and removes that
  happened earlier left no record that could be reconstructed, so old
  conversations simply have no system bubbles. Not a bug.
- **A removed member DOES see "Alice removed you"** since 2026-09-01 — the row
  is written before `leftAt`, so it lands inside their frozen history, and the
  thread stays in their list read-only (`viewerLeft`). `removed_from_conversation`
  still fires; it is now a refresh hint rather than the only notice.
- **A newly added member sees the whole back history**, including system events
  from before they joined. Same as ordinary messages.
- **Submitting a post request puts no bubble in the thread.** `post_request_opened`
  was retired on 2026-09-01: the request card (a `POST_SHARE` message with the
  post, the note and View original post) is the whole announcement. Threads
  from before that date still hold the old row — it arrives as
  `system.unsupported: true` with the neutral fallback line, which is exactly
  what your unknown-event branch is for.
- **Very rarely, an event can be missing.** The row is written after the action
  it describes commits, best-effort — if the server dies in between, the rename
  still happened and the bubble never appears. Never treat the timeline as the
  source of truth for group state; the conversation object is.

---

## Decisions from the 2026-08-31 design review

> **Four of the six are done** (✅ below) and folded into the guide above; one
> is withdrawn (❌); one is blocked on a product spec. Full reasoning:
> `docs/CHAT_SYSTEM_MESSAGES.md` (internal).

| # | Decision | What it means for the client |
| --- | --- | --- |
| 1 ✅ | **"not accepted"** replaced "declined" everywhere | **Shipped 2026-09-01.** `post_request_declined` now renders "You didn't accept Bob's request." / "Your request wasn't accepted." / "Alice didn't accept Bob's request.", and the notification **title** reads "Your request to join wasn't accepted". Event code and the internal `post_request_rejected` type are unchanged — don't key anything off the words |
| 2 ✅ | **Removed / left threads are visible and read-only** | **Shipped 2026-09-01**, groups only. The thread stays in the inbox with `viewerLeft: { at, reason }`, frozen at `at` — hide the composer when it is present. "Alice removed you." / "You left the group." are now reachable, and are written *before* `leftAt` so they land inside the departed member's history. `POST /conversations/:id/clear` dismisses the row. **Blocked DMs were deliberately excluded** and still vanish for both sides. See `mobile-chat-groups-guide.md` §8 |
| 3 | **Declining a bare request keeps clearing the thread** | No change — confirming today's behaviour so it isn't treated as a bug. A request declined before the requester chatted retires the thread for both, with **no** system bubble; the outcome arrives as a notification |
| 4 ✅ | **`post_request_opened` is retired** | **Shipped 2026-09-01.** No row is written on submit; the request card is the only artifact. Rows written before that date remain in old threads and now arrive as `system.unsupported: true` with the neutral fallback line — render it or skip the bubble, same as any unknown code |
| 5 ❌ | ~~The request card's status chip~~ | **Withdrawn 2026-09-01 — build no chip.** Every state it would show is already a line in the same thread (accepted, not accepted, reopened), and pending is the absence of one. No server field is coming; don't wait for one |
| 6 | **The card's trait chip is a property of the requester**, not a post topic | Blocked on a product spec. No API for it yet — leave the slot out until it is specced |

**If you are building the post-request screens now:** #4 has landed, so build
the card as the primary artifact — card plus the system lines below it, no
status chip (#5 withdrawn). The thread narrates the outcome; the card does not
need to repeat it.

**Post titles shipped 2026-09-01 too**, in the same four events — see the
`postTitle` note under the event table. Two request lines changed voice with
them: the requester now reads "Your request for … was accepted." rather than
"Maya accepted your request.", and a reopen reads "Your request for … is being
looked at again." Both are `system.text`, so nothing to do on your side.
