Skip to content

v0.99.0 — Chat scope

Shipped 2026-09-20 — merged to main as 552591d2, tagged v0.99.0. Built on version/0.99.0, seventeen commits. The ninth of the releases the near-term roadmap sequences from the August 2026 heuristic evaluation; the roadmap page is chat-scope. The help is Chat, Voice, and the Combat Log → Channels.

Chat had one log per campaign. It has channels now: Campaign (that log), Map, private messages, Personal, the owner's Secret channel with per-message reveal, and custom channels — behind an iMessage-style conversation list in the sidebar, the phone's Chat tab and a full page. Membership is a grant (v0.95.0), a channel is a socket room (v0.90.0's Q.14), and every sender posts into the channel the user is looking at. Decisions CH-D1 (a PM is one conversation across campaigns) and CH-D2 (a reveal cannot be undone) at their recommendations.

The model

  • Virtual built-ins (C.1) — the one deviation from the page. A Campaign channel's name, membership and lifetime are its campaign's, so a row for it would carry nothing and could be forgotten by a code path that makes a campaign. Its id names the parent instead — campaign:<id>, map:<id>, secret:<campaignId>, personal:<userId> — and nothing is stored; the channels table (pg 0037, sqlite boot DDL) holds only PMs (pm: + the members sorted, one conversation per set of people) and custom channels. chat_messages gains channel_id (null = the Campaign channel), revealed_from and revealed_to; the migration points every existing row at campaign:<its campaign>. shared/types/channel.ts is the one reader of an id's shape.
  • Membership through grants (C.2) — channel is a grant entity type with a cascade loader by kind: Campaign → its campaign, Map → its map (so readers read, players post — play is the posting verb; a Viewer reads and cannot post), Secret → no parent and the campaign's owner as its Owner, Personal → its user, a PM → no parent, its members equal, a custom channel → its creator as Owner and its grants. No parent is what keeps a PM private from the GM.
  • Routes (C.3) — GET /api/channels?campaignId=&mapId= synthesises the built-ins the actor may read and joins the stored ones they hold a grant on, each with canWrite, a preview and an unread count; POST opens a PM by username (idempotent) or makes a custom channel; GET /:id/messages is the log; POST /:id/messages/:messageId/reveal; DELETE /:id for a custom channel. POST /api/chat-messages takes channelId and needs play on it; every read, patch and delete checks the channel after the campaign.
  • Rooms (C.4) — channel:<id>, joined through mayJoinChannel (the channel's read, through the resolver); chatMessage:* and dice:roll go to the channel's room and nowhere else; joinCampaign also joins the Campaign channel's.

The UI

  • The conversation list (C.5) — chatChannelStore holds the current channel per campaign (the Campaign channel by default, named with no fetch), useChannels the list, joined to every room it names and kept live; useChatMessages(campaignId, channelId?) is per channel, and every send posts into the channel current at call time. A ChannelBar above every log opens the list in the same panel — This campaign / This map / Direct messages / Personal, previews (chatPreviewText: a dice roll reduced to its total), unread badges, Mute — with New conversation and New channel; the phone's View sheet lists the channels above the four views.
  • The route (C.6) — /chat/:campaignId[/:channelId] is the full page (the list as a column beside the log on a wide screen; the URL follows the channel), /play/…?channel=<id> opens the panel on one, and the rail lists Chat under a campaign.
  • /w (C.7) — /w alice hello, /w @alice @bob hello, /w alice,bob hello; usernames only; the PM opened and made current, the text sent into it. UsernamePicker for the modals, through the permissions search.
  • Custom channels (C.8) — New channel, and Members… hosting the one PermissionsEditor on the channel entity.
  • The Secret channel (C.9) — Reveal on every row (the bubble and the action-doc frame), Revealed on the original, Revealed from the Secret channel on the copy; secret dice; the other channels' cached logs kept live so the copy is there when the owner switches back.

The rest of the app

  • Actions and rolls know their channel (C.10) by construction, and a requestRoll carries the channel it was asked in so the answer goes there; a guard holds that no sender names a channel of its own but that answer and /w.
  • Notifications (C.11) — a chat-message Message for every other member of a PM or a custom channel, with Open; the bell neither toasts nor rings one for the channel being viewed or a muted one, and the log marks the viewed one read. A channel's unread count is its unread Messages.
  • Combat log and transcript per channel (C.12) — type filters over the channel's log, free.

Guards and verification

Fifteen route-harness tests for the API (the third user refused a PM's log, row, post and delete — and so is the GM), a real three-socket test that a PM's pushes reach its members and nobody else, eight loader tests on the in-memory cascade, and on the client the store, the grouping, /w, the senders guard and the contract tests. Verified live in Chrome: the list with four groups and real previews, a message into the map's channel and not the campaign's, /w to one and to two people, a custom channel with its Members editor, a roll in Secret revealed into the campaign log, the full page's URL following the channel, ?channel= on /play.

Decisions

CH-D1 and CH-D2 at their recommendations. Made here: the built-ins are virtual (above); the full page is /chat/:id/:channelId, not the roadmap's /campaigns/:id/chat/…, because /chat/:id has been the campaign chat's route since v0.42.0 with slug resolution and href helpers; posting is the play verb, so a campaign Viewer reads the chat and cannot post — what "viewer" has meant since v0.96.0; and a PM has no Owner and no parent, so nobody manages it and nobody above it sees in.