UPDATED 2026-09-04 — 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.
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 tomobile-chat-guide.md(send/receive, sockets, receipts) andmobile-post-request-dm-guide.md.
- API base URL (local):
http://localhost:3001 - Auth:
Authorization: Bearer <accessToken>on every endpoint (required)
What's new
- 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
clearedis 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_openedis retired — 13 events remain. An old one arrives assystem.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.
{
"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_changedover the socket, and amessage_request_acceptednotification to the requester. (That notification type still exists — only the system message was removed.) post_request_accepteddoes 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 isconversation.statusplus theconversation_status_changedsocket event, as it always was.- An unknown
system.eventis not an error.post_request_reopenedis new in this release; a build that predates it receivessystem.unsupported: trueand 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_conversationstill 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_openedwas retired on 2026-09-01: the request card (aPOST_SHAREmessage 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 assystem.unsupported: truewith 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 ❌ | 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.