| 1 | Block/unblock UI | Full server enforcement (`user_blocks`, DM create + send checks), `GET /blocks` client call | Client never calls `PUT/DELETE /api/v1/blocks/{userId}`; no menu items |
| 2 | Channel topics in client | `channels.topic` column, admin-panel editing | Not in WS `ready` payload; chat header never renders it; client edit modal is name-only |
| 3 | Role colors | `roles.color` stored and shipped in `ready` | Client hardcodes a switch on 4 role names (`formatting.ts`) |
| 4 | Profile popup | `UserProfilePopup.ts` built and unit-tested | Never mounted; member click opens only the admin context menu |
| 5 | Temp bans | `users.ban_expires`, `BanUser(..., expires)`, `IsEffectivelyBanned` all honor expiry | Every caller passes `nil`; no API field, no UI |
| 6 | Archived channels | `channels.archived` stored, settable in admin panel | No read path filters on it — archived channels appear everywhere |
- **DONE (2026-07-31)** — Honest kick semantics. There is no membership model, so `DELETE /admin/api/users/{id}/sessions` cannot remove anyone; it revokes the target's sessions and they can sign straight back in. Rather than invent a membership table, the user-facing action is renamed to what it does: the desktop member-list menu item is now **Force Logout** (confirm "Log them out?", pending "Logging out...", toast "Forced {name} to log out"), and the admin panel's row button, modal and toast say Force Logout too, with the modal spelling out that the user can sign back in. The endpoint, the `KICK_MEMBERS` bit and the `onKick`/`adminKickMember` call sites are unchanged.
- **DONE (2026-07-31)** — Enforce the decorative permission bits. The `/admin/api` perimeter now admits any role holding a bit of `permissions.AdminPerimeter` instead of requiring `ADMINISTRATOR`, and each route group re-checks its own bit: channels + channel overrides → `MANAGE_CHANNELS`, audit log → `VIEW_AUDIT_LOG`, settings → `MANAGE_SERVER`, force-logout → `KICK_MEMBERS`; ban/unban (`BAN_MEMBERS`) and role assignment (`MANAGE_ROLES`) are authorized inside `ModerationService`. Stats/users/`GET /me` stay perimeter-level; backups, updates, API tokens, plugins and the log stream are unchanged. `GET /admin/api/me` reports the caller's mask so the admin panel hides tabs and row actions it cannot use, and the desktop member-list context menu gates Kick/Ban/Change Role on the bits from the `ready` role list instead of on the role _name_. `MUTE_MEMBERS` admits to the perimeter but still has no route behind it (see voice moderation below).
- **DONE (2026-07-31)** — Hierarchy checks beyond ban/unban: `ModerationService.ChangeUserRole` requires the actor to strictly outrank the target _and_ refuses to assign a role positioned at or above the actor's own, closing the "any admin can promote anyone to Owner" hole. `ModerationService.ForceLogout` enforces the same outranks rule.
- **DONE (2026-07-31)** — Voice moderation on `MUTE_MEMBERS` (the bit is now live): `voice_mod_mute`, `voice_mod_deafen`, `voice_mod_move` and `voice_mod_kick`, each requiring the bit plus a strict role-position outrank of the target, rate limited 5/sec and audit-logged. `voice_states` gained `server_muted` / `server_deafened`, which the `voice_state` broadcast now carries; a server mute is also applied to the target's published audio track via the LiveKit RoomService, and the target's own `voice_mute` / `voice_deafen` unmute attempts are refused with `SERVER_MUTED` / `SERVER_DEAFENED`. Move and disconnect run the hub's voice-leave routine for the target and then send them `voice_moved` (client re-joins the destination through the ordinary join path) or `voice_disconnected`. The desktop client's voice-user context menu grows a moderation section gated on the bit, renders a distinct server-muted icon, and disables the widget's mute/deafen buttons with a reason while server muted.
- **DONE (2026-07-31)** — Bulk message delete. `POST /api/v1/channels/{id}/messages/purge` takes `{limit: 1-100, before?}` and soft-deletes the newest matching messages, gated on `READ_MESSAGES|MANAGE_MESSAGES` for that channel (per-channel overrides apply, DMs rejected — a DM has no MANAGE_MESSAGES gate to answer to). Deletion is the same soft delete a single delete performs, so tombstones and `reply_to` targets survive; already-deleted rows are skipped and the select+update run in one writer transaction. One `message_purge` audit entry per call carries the count, and the fan-out is a single new `chat_bulk_deleted {channel_id, ids}` server->client message instead of N `chat_deleted` events. The desktop channel context menu grows a "Purge Messages…" item — gated on the actor's `MANAGE_MESSAGES` bit and hidden on voice channels — opening an inline 1-100 count prompt with a confirm step; the dispatcher marks every id in the broadcast as deleted.
## Phase 3 — mentions done right
The largest single messaging gap: `@word` was regex-highlighted with no
resolution, no notification and no badge, and `read_states.mention_count` was a
dead column. The server is now the authority on what a mention is — the client
highlights and badges from the resolved fields rather than re-parsing content.
- **DONE (2026-07-31)** — Server-side mention resolution and storage. `MessageService.resolveMentions` parses `@token`s out of sanitized content with a word-boundary rule (`mentionTokenRe`) that refuses address-shaped text: `mail@example` and `@@name` never match, and `@bob@example.com` is rejected whole rather than half-matched. Tokens are lowercased, deduplicated, ordered by first appearance and resolved case-insensitively against `users.username` (`UNIQUE COLLATE NOCASE`), with a second spelling that drops trailing `.`/`-` so "@bob." resolves to bob when nobody is literally named "bob.". A token matching no username resolves to nothing and stays plain text. Two caps bound the work one send can cause: at most 60 distinct tokens are looked up (`maxMentionCandidates`) and at most 20 resolve (`maxMentionsPerMessage`). Resolved IDs land in the new `message_mentions` table (migration `022`, PK `(message_id, mentioned_user_id)` plus `idx_message_mentions_user` for the per-user direction), written in the same writer transaction as the message row and rewritten wholesale on edit. Resolution failures are logged and degrade to "no mentions" — a message is never rejected because its mention lookup failed. `mentions` and `mentions_everyone` now ride on the `chat_message` and `chat_edited` broadcasts, on `GET /channels/{id}/messages`, on pinned-message responses and on FTS search results; `mentions` is always present and empty rather than null. `buildChatMessage` took a `chatMessageArgs` struct in the process — the positional list had outgrown a readable call site.
- **DONE (2026-07-31)** — `read_states.mention_count` is live. `applyMentionCounts` raises it on message insert for every mentioned user who can actually read the channel — the role walk applies channel overrides, and DMs skip it entirely since participation is membership, not permissions. The author is always excluded, and users who have blocked the author are dropped (fail-closed: a `ListBlockersOf` error skips the whole increment, because a badge from a blocked user is worse than no badge). Edits deliberately never increment: only the original send can raise a badge, which is the simplest rule that makes double-counting a re-added mention impossible. `channel_focus` resets the count to 0 via the `UpdateReadState` upsert, and the `ready` payload ships `mention_count` per channel. Because `GetChannelUnreadCounts` covers `text`/`announcement` channels, a DM mention badge is raised live by the dispatcher but starts at 0 on reconnect — DM unreads are surfaced separately in the DM sidebar.
- **DONE (2026-07-31)** — Client rendering, badges and notifications. `@lib/mentions` is the single source of truth shared by the renderer, the badge path and the notification gate, so all three agree on what counts as a mention; its regex mirrors the server's, and the server's `mentions`/`mentions_everyone` decide the outcome whenever present (the local token parse only stands in for servers predating the fields). Resolved mentions render as a highlighted `.mention` span, with `.mention-self` when the mention is the current user; an unresolvable token renders as plain text. In the channel sidebar a red `.mention-badge` outranks the plain unread badge — only one shows, and it counts mentions rather than messages. Desktop notifications retitle to "{user} mentioned you in #{channel}", and "Suppress @everyone" now means exactly that: it drops only a notification the `@everyone`/`@here` alone caused, so a message that also names you still notifies, and an `@everyone` the sender lacked the bit for was never a mention to suppress. No OS dock/taskbar count badge was added — the existing taskbar _flash_ is the only OS-level signal; a real badge count needs a Tauri-side API and is left for a later pass.
- **DONE (2026-07-31)** — `@everyone`/`@here` behind a permission, plus composer autocomplete. New `MENTION_EVERYONE` bit (21, `0x200000`); migration `022` grants it to the seeded Owner/Admin/Moderator roles, moving the Moderator mask from `0x000FFFFF` to `0x002FFFFF`. Without the bit the token carries no mention semantics at all — no highlight, no badge, no notification — and DM channels have no `@everyone` semantics since there is no permission surface to answer to. `@here` narrows the fan-out to readers whose status is not `offline`; `@everyone` reaches every reader. The composer opens an inline member picker on `@` (`MentionAutocomplete`, max 10 rows) whose active-token rule mirrors the server's, so it never offers a completion for text a send would not resolve; `@everyone`/`@here` appear as rows only for users who hold the bit.
- **DONE (2026-07-31)** — Clickable `#channel` links. `#name` tokens in message content resolve case-insensitively against the channel store (DM channels excluded — they have no user-visible `#name`) and render as links; unresolvable tokens stay plain text. Navigation funnels through the new `@lib/channel-navigation.navigateToChannel`, now the single entry point shared by the sidebar item and `#channel` links, so every affordance clears the same unread and mention badges. Role mentions remain out of scope by design — they need role management, which is phase 5.
## Phase 4 — markdown and message polish
- **DONE (2026-08-01)** — Full markdown rendering, client-side. The content parser grew a real tokenizer (`message-list/markdown.ts`): one left-to-right scan with recursive descent into matched delimiter pairs, which is what makes nesting (`**bold *and italic***`), backslash escaping and "markdown is dead inside code" fall out of a single rule set instead of a pile of regexes fighting over overlaps. Inline: bold, italic (`*`/`_`, with a word-boundary rule so `snake_case_names` stay literal), underline, strikethrough and spoilers; blocks (line-start only): `>` quotes that merge contiguous lines, `>>>` for the rest of the message, `#`–`###` headings that require the space, `-`/`*`/`1.` lists with one level of nesting. Masked links accept absolute `http(s)` only — `javascript:`, `data:` and relatives render as their literal source — and are excluded from `extractUrls`, so hiding an address does not get it previewed back. Code fences take a language tag that renders as a label and drives a hand-rolled highlighter (`syntax-highlight.ts`: comments/strings/numbers/keywords for js/ts, go, python, rust, json, bash, css, html, plain fallback) — no highlighting dependency was added. Spoilers are per-span `role="button"` elements with `aria-pressed`, and the revealing click is swallowed so a link underneath cannot open with it. Rendering stays a strict DOM builder: no `innerHTML` anywhere, every `href` through `isSafeUrl`. Composer: Ctrl+B/I/U wrap (and unwrap) the selection, stopping propagation so Ctrl+U formats while typing and still uploads elsewhere.
- **DONE (2026-08-01)** — Message navigation: fetch-around, reply jumps, permalinks. Server gained `GET /api/v1/channels/{id}/messages/around/{messageId}?limit=50` — the same read gate as history (READ_MESSAGES / DM membership), the window split half-and-half around the centre and returned **oldest-first**, with `has_more_before`/`has_more_after` derived by over-fetching one row per side rather than two extra count queries. A centre that is soft-deleted is a 404, not an empty window: history omits deleted rows, so there is nothing to centre on. The three duplicated read-permission blocks in `MessageService` collapsed into one `requireChannelRead`, and the three copies of limit parsing in the handlers into one `parseLimitParam`. Client-side every jump affordance — search hit, pinned entry, the quoted reply bar, a permalink chip, an `owncord://message/…` link from the OS — now routes through a single jumper (`lib/message-navigation.ts` registry → `main-page/MessageJump.ts`): scroll + flash when the target is loaded, otherwise fetch the around-window, swap it in, scroll + flash. A window with newer messages below it is _detached_: the store refuses to append live broadcasts onto it (they belong below a gap) and the list shows a **Jump to Present** pill that reattaches and refetches the tail. Permalinks are `owncord://message/{channelId}/{messageId}` — copied from the hover bar, parsed by the same `deep-link.ts` that owns the invite scheme (whose bare-code form now refuses the `message` route), and rendered as a compact channel-name chip when pasted into chat; a link to a channel the reader cannot see stays plain text.
- **DONE (2026-08-01)** — Who-reacted list. Server added `GET /api/v1/channels/{id}/messages/{messageId}/reactions/{emoji}/users` (emoji percent-encoded; chi routes on `RawPath`, so the handler unescapes it) behind the same `requireChannelRead` gate as history, returning up to 100 reactors oldest-first. A separate endpoint rather than `user_ids` inline on every reaction summary: a page of chat carries dozens of pills and almost none are hovered, so the payload stays small. A message that lives in another channel is a 404 — the channel in the URL is the one the permission check ran against. Client: hovering or focusing a pill for 300 ms (the `lib/streamPreview.ts` debounce, so a pointer crossing a row fires nothing) fetches and shows _"alice, bob, carol and 4 others reacted with 👍"_. Lists are cached per message+emoji and evicted wholesale for a message on `reaction_update`, which names only the emoji that changed; a response that lands after an invalidation or after the pointer left is discarded rather than repopulating the cache or popping a tooltip nobody is hovering. Usernames go in as text nodes.
- **DONE (2026-08-01)** — Inline video/audio players. `video/mp4|webm|ogg` render as `<video controls preload="metadata">` inside the same max box as an image (download button on hover); the common audio containers render as an `<audio controls preload="metadata">` row with filename, size and download. Both are allowlists, not `video/`/`audio/` prefix tests — an unknown container gets the download chip rather than a player that fails to decode — and `image/svg+xml` is now excluded from the image path too (it can carry script, and the data-URI allowlist already refused it, so inlining only ever produced a stuck placeholder). `/api/v1/files/{id}` is permission-checked, so the source is fetched through the same cert-pinned proxy with the session bearer token images use, then handed over as a `blob:` URL rather than the image path's base64 data URI, which would inflate a 50 MB video into a string and cache it in IndexedDB.
- **DONE (2026-08-01)** — Read-state polish. A red **NEW** divider marks the first unread message when a channel is opened with unread; because opening clears the badge, `setActiveChannel` snapshots the count first (`getUnreadOnOpen`) and the list places the line above the last _N_ loaded messages — suppressed while the window is detached (a slice around an old message is not the tail) and gone on the next visit. Explicit mark-as-read arrived as a new client→server WS message `mark_read`: `channel_focus` already advances read state but also rebinds the connection's focused channel, which is wrong when marking a channel the user is not looking at. It backs **Mark as Read** in the channel context menu (disabled when already read, absent for voice) and **Mark All as Read** on the sidebar's server header, which only appears while something is unread. DM sidebar rows now show real unread counts and a red mention count that outranks them; `GetChannelUnreadCounts` includes the caller's DM rows so `ready` ships a DM `mention_count` — previously absent, which silently reset every DM mention badge on reconnect.
- **DONE (2026-08-01)** — Role CRUD. Roles were four seeded rows whose permission masks were frozen at migration time; they are now real entities behind `/admin/api/roles` (`GET`, `POST`, `PATCH /{id}`, `DELETE /{id}`, `PATCH /roles/reorder`), gated on `MANAGE_ROLES` with the whole rule set in a new `service.RoleService` rather than in the handlers. Every rule is measured against the **actor's** role position: you may only create/edit/delete/reorder roles strictly _below_ your own (equality is refused too, so a role cannot rewrite itself, and nothing outranks position 100 — which is what makes the seeded Owner role immutable and undeletable for everyone including the owner), and you may never _grant_ a bit your own role lacks, though removing one is allowed because de-escalation is always safe (`ADMINISTRATOR` bypasses). The default role is undeletable — every member falls back to it — and deleting a role moves its members onto that fallback in one `UPDATE`, drops the role's `channel_overrides` rows and deletes the role in a single writer transaction, then invalidates exactly the moved members' cached permissions. Names are unique **case-insensitively** (migration `023` adds `idx_roles_name_nocase`; the column's own `UNIQUE` is BINARY, so "Moderator" and "moderator" used to be two roles the client's case-insensitive lookup could not tell apart), colors are `#rgb`/`#rrggbb` normalized to uppercase, and unknown permission bits are masked off rather than rejected. Reorder takes an ordered id list that must name exactly the roles below the actor — a partial list is refused rather than leaving the omitted ones at positions that now collide — and normalizes them to `N…1`. Every mutation audits (`role_create`/`role_update`/`role_delete`/`role_reorder`).
Cache and client sync follow the existing patterns rather than inventing one: the permission cache is invalidated _before_ the hub calls (as the channel-override handlers do), a permission change runs the new `Hub.RefreshAllChannelVisibility` — `RefreshChannelVisibility` across every non-DM channel, because a role's mask is the base every channel's effective permission derives from, where an override edit touches exactly one — and a delete additionally sends one `member_update` per reassigned member. A new `roles_update` server→client message (schema + `make protocol-generate` + `docs/protocol.md`) carries the **full** new list, so clients refresh `channelsStore.roles` without reconnecting; replacing rather than patching means a dropped intermediate event can never leave a deleted role on screen. The member list now subscribes to that list too — grouping, labels and name colors all derive from it, and before this they only re-rendered when some unrelated member change happened along. The admin panel grows a Roles section (nav gated on `MANAGE_ROLES`) listing roles by position with a color swatch and member count, up/down reorder arrows scoped to the manageable slice, a create/edit modal with a permission checkbox grid grouped as `docs/schema.md` groups the bitfield — bits the caller's own role lacks are rendered disabled — and a delete confirmation that names how many members move and where. Hoist and mentionable are still out of scope: neither has a column, and role mentions need the mention resolver to learn about roles.
- **DONE (2026-08-01)** — Per-user channel overrides + the full override matrix UI. New table `channel_user_overrides` (migration `024`, PK `(channel_id, user_id)` plus `idx_channel_user_overrides_user` for the per-user direction) makes the resolution order Discord's: **base role permissions → role override → user override**, with the narrower layer last, so a user deny beats a role allow and a user allow beats a user deny; `ADMINISTRATOR` still bypasses both. The formula has exactly one implementation, `permissions.EffectiveChannelPerms`, which `Checker.HasChannelPerm`, `Checker.HasChannelPermBatch` and through it `VisibleChannelIDs` all route through — so extending the order was a change to one function plus the fetch, not to the dozens of `HasChannelPerm` call sites. `HasChannelPerm` grew a `userID` parameter (`0` = "no member in hand", skip the user layer), and both layers are loaded together by `db.GetChannelOverridesFor(roleID, userID)` — two batch queries, never per channel — which is now the single fetch behind `buildReady`, `computeAllowedChannels`, REST `ListVisibleChannels`, `MessageService.GetAccessibleChannelIDs`, the voice-join publish grants and the cached `service.PermissionService`. `channelCanSend` resolves both layers too, and the `@everyone` fan-out (`mentionReaders`) folds the user layer in _both_ directions: a user deny drops a reader the role walk admitted (unless they hold `ADMINISTRATOR`), a user allow adds one it excluded. `Hub.RefreshChannelVisibility` and `channelReadAudience` stopped memoising visibility per role — two members of one role can now legitimately disagree about a channel, which is exactly what a per-user override edit creates. `Server/ws/channel_visibility_agreement_test.go` grew a second case proving REST, `ready` and replay filtering still agree for three members of the _same_ role carrying different overrides.
API: `PUT`/`DELETE /admin/api/channels/{id}/user-permissions/{userId}` with `{allow, deny}` masks, gated `MANAGE_CHANNELS` like the role layer, unknown bits masked off, audited as `channel_user_perms_update`/`channel_user_perms_clear`. They invalidate only the **target's** cache (`InvalidateUser`) rather than the whole cache the role layer must drop — a per-user override cannot change anyone else's verdict — before the hub re-sync. `GET .../permissions` now returns `users` alongside `roles`: every role (zero masks when unset) but only the members who actually carry an override row. The admin panel's single "Can access" checkbox survives as the quick private-channel shortcut, writing exactly the mask it always did, and gained a real matrix editor beneath it: pick a role **or** a member, then set allow / inherit / deny per channel-scoped bit (READ, SEND, ATTACH_FILES, ADD_REACTIONS, MANAGE_MESSAGES, MENTION_EVERYONE, CONNECT, SPEAK, VIDEO, SHARE_SCREEN). An all-inherit row is sent as a `DELETE`, because storing `(0,0)` would leave a row that resolves to nothing. `perm_grid_test.go` ties the matrix's bit list to `permissions` the same way it already tied the role grid.
- **DONE (2026-08-01)** — Categories stopped being magic strings. The server refused any non-voice channel under a category literally named "Voice Channels" and any voice channel outside it (`validateCategoryType`), and the client mirrored the rule with a substring test on the category name. Both are gone: `POST /admin/api/channels` validates the **type** alone, categories are free text, and any type lives under any name. `PATCH /admin/api/channels/{id}` accepts `category`, so moving a channel between categories is an edit rather than a delete-and-recreate. The desktop `CreateChannelModal`'s read-only category display became an editable text input with a `<datalist>` of the categories in use (`channelsStore.getKnownCategories`), offering all three types; `EditChannelModal` gained the same field; the admin panel's create and edit forms got the same input plus datalist. The sidebar groups voice channels under whatever category they carry — sharing a group with text channels is fine — and falls back to a synthetic "Voice" group only for voice channels with no category at all (`displayCategoryOf`). Collapse persistence stays client-side, unchanged.
- Categories as real entities (own permissions, ordering).
- **DONE (2026-08-01)** — Channel management moved into the desktop client. `EditChannelModal` offered name, topic and category; it now also carries **slow mode** (a preset `<select>` from Off to the server's 6-hour ceiling — a free number field mostly produces typos like "300" meant as minutes, and a stored off-preset value set through the admin panel is kept as its own option rather than silently rounded), an **NSFW** toggle, and a **voice section** (User Limit / Video Limit, 0–99, 0 = unlimited) rendered for voice channels alone — the columns exist on every row, but on a text channel they are values nothing reads, so a text-channel edit omits the keys entirely rather than sending `0` and wiping limits the row happens to hold. Every control pre-fills from `channelsStore` (which `channel_update` writes into), not from the sidebar row, so the modal opens on current values. `PATCH /admin/api/channels/{id}` and `db.AdminUpdateChannel` grew `nsfw`, `voice_max_users` and `voice_max_video`; the positional argument list became `db.ChannelUpdate` once it reached nine fields, four of them ints. Bounds (`slow_mode` 0…21600, both voice limits 0…99) are validated _before_ the write and refused with `400 INVALID_INPUT` rather than clamped — a caller that sent `-1` meant something — so a rejected body writes nothing at all. The whole UI is gated on **MANAGE_CHANNELS**, not on role names: `permissions.canManageChannels()` is now the single derivation behind the category "+" and the context menu's Edit/Delete, which were still asking whether the role was literally called "owner" or "admin" (a custom role the server would happily let edit a channel saw no way to, and a role merely _called_ "admin" with no channel bit saw items every click would be refused for). `channel_create`/`channel_update` and `ready` all carry `slow_mode`, `nsfw` and both voice limits — always present with their zero values, never omitted, so "absent" never means two things — built by one `channelPayloadFrom` constructor so the two events cannot drift. The store applies a partial `channel_update` field by field (an absent key is left alone, not cleared) and finally handles `category`, so a category move regroups the sidebar without a reconnect.
- **DONE (2026-08-01)** — NSFW flag end-to-end, and the voice limits surfaced. Migration `025` adds `channels.nsfw` (0/1, like `archived`). **The server does nothing with it** and says so in `schema.md`, `api.md`, `protocol.md` and the migration itself: it stores, broadcasts and audits the flag (`updated #foo (marked NSFW)` / `(unmarked NSFW)`, plain when it did not move) and applies no filtering, no age check and no restriction on who may read or post — a client ignoring the field behaves exactly as before it existed. Every consequence is the desktop client's: `@lib/nsfw-gate` remembers acknowledgement per channel in **sessionStorage** (the promise is "once per session", so localStorage would quietly make it "once ever"; a throwing storage reads as _not_ acknowledged, erring toward asking again), and `NsfwGate` mounts over the messages slot — not as a modal, since the channel is live underneath and the sidebar stays usable — with "This channel may contain sensitive content — Continue?", a note stating plainly that nothing is filtered, and a Go Back that leaves the channel rather than stranding the reader. The sidebar marks flagged channels with a shield beside the name (not a recolour: unread/mention/active already own the row's colour). Voice limits: the row shows "3/5" when a user limit is set and nothing when unlimited ("3/0" would read as a bug), and the client still never pre-blocks a join — its participant list can lag and an invented refusal would be uncorrectable, so the server answers `CHANNEL_FULL`, which the dispatcher now surfaces as a toast (it was logged and otherwise silent, as was `VIDEO_LIMIT`).
- **DONE (2026-08-01)** — The audit log stays admin-panel-only — it is a paginated, filterable table over an endpoint the desktop client otherwise never calls, and a second implementation would be a second thing to keep correct — but it stopped being unreachable. The sidebar's server header grows an "Audit Log" entry gated on `VIEW_AUDIT_LOG` (kept in sync with `authStore`_and_ the role list, because `ready` can land after the header is built), opening `https://{host}/admin#audit` in the user's browser via the opener plugin. Deliberately not through the loopback TOFU proxy the REST client uses: that origin means nothing to an external browser, so a self-signed deployment shows the browser's certificate warning, which is the honest outcome. The admin panel learned to honour a `#section` fragment on load (falling back to the dashboard when the principal may not open it, exactly as a stale stored section does), so the entry lands on the log rather than on the dashboard with a tab still to find.
- **DONE (2026-08-01)** — Custom emoji end-to-end. The `emoji` table had shipped in migration `001` with zero server code, and the client carried `getEmoji`/`deleteEmoji` methods aimed at routes nobody had registered plus an `EmojiPicker` option nothing ever passed; all three are now real. Server: `GET /api/v1/emoji` (open to any member — an emoji nobody can render is not an emoji, and the set is server-wide with no per-channel scope to leak), `POST /api/v1/emoji` and `DELETE /api/v1/emoji/{id}` gated on **MANAGE_SERVER**. No new permission bit was added, and that is the decision rather than an omission: a bit is a schema-visible, forever choice, and "who may change server-wide branding" is exactly what MANAGE_SERVER already answers for the server name, icon and settings. The gate runs _before_ the multipart body is read, so a member without it never causes a spool to disk. Uploads are capped at 512 KiB, sniffed from their own bytes (`image/png|jpeg|gif|webp` only — SVG is refused outright: it is markup with script and external-fetch capability, and an emoji is by definition rendered inline), and re-measured from the sniffed image against a 128×128 ceiling; WebP headers are parsed by hand (`webpDimensions`, all three of VP8/VP8L/VP8X) because the standard library has no WebP decoder and none was vendored for a dimension read. Shortcodes are `[a-z0-9_]{2,32}`, lowercased on the way in — which is what makes the table's plain `UNIQUE` index a case-insensitive one without a `COLLATE` change — with a collision answering `409 CONFLICT` and a 200-emoji cap per server. Bytes go through the existing storage layer under a UUID; migration `026` adds the one column the table lacked, `mime_type`, so `GET /api/v1/emoji/{id}/image` can set a Content-Type without re-sniffing the file on every request. That route is authenticated (an emoji must not be usable as an unauthenticated tracking pixel) but has no per-channel ACL to apply, and is `immutable`-cacheable because an emoji's bytes never change for a given id. A failed insert unlinks the orphaned file; a failed unlink after a successful delete is logged rather than failing the delete.
New `emoji_update` server→client message (schema + `make protocol-generate` + `docs/protocol.md`) carries the **whole** set after every mutation, for the same reason `roles_update` does: replacing rather than patching means a dropped event can never leave a deleted emoji rendering in the messages that name it. It is deliberately _not_ in the `ready` payload — the set belongs to the server, not the session, so clients load it once over REST on ready and keep it fresh from the event. Client: a new `emojiStore` whose `resolveEmoji` is the single answer to "is `:name:` a real emoji here", consulted by message rendering, the picker, the composer autocomplete and reaction pills so none of them can disagree. `:shortcode:` renders as a 22px inline image — jumbo 48px when the message is nothing but emoji (unicode included, capped at Discord's 27, and an _unresolved_ shortcode is plain text so it never earns jumbo) — via a `.msg-text-jumbo` class that sizes glyphs and images together rather than threading a flag through four render functions. It is added in the same token pass as `@mentions` and `#channels`, so code spans and fenced blocks are excluded for free: inline code never reaches the token pass, and fences are split off before it. Images are fetched through the same cert-pinned, bearer-token path attachments use and swapped in as a data URI — assigning the server URL to `img.src` would 401 — with the shortcode as `alt`, so a message reads correctly before (and if) the bytes arrive. Reactions are free-form strings already, so a custom reaction is stored as the literal `:shortcode:` and the pill renders the image when it resolves and the text when it does not (a deleted emoji leaves a working, if plain, reaction). The reaction length cap stopped being a bare `32` and is now derived as `MaxShortcodeLen + 2`: a 31- or 32-character shortcode was a legal emoji that rendered in messages and was silently refused as a reaction, which is exactly the kind of gap a hardcoded constant on each side produces. The composer's picker finally gets its `customEmoji` option, showing a **Server** category that inserts `:shortcode:`, and gained a `:`-autocomplete mirroring the `@`-mention one — colon plus 2+ characters, custom emoji ranked above the built-in unicode set, only one popup open at a time. The admin panel grows an Emoji section (nav gated on `MANAGE_SERVER`) with upload, list and delete; it calls the ordinary member API rather than a duplicate `/admin/api` handler set, and loads thumbnails as blob URLs because `<img src>` cannot send an Authorization header (the panel's CSP gained `img-src 'self' blob:` for exactly that).
- **DONE (2026-08-01)** — Profiles & presence depth: avatar upload, display names, about-me, custom status, real invisible, auto-idle. Migration `027` adds `users.display_name` (32), `about` (300) and `custom_status` (128) — all nullable, all bounded and HTML-sanitized in `UserService`/`ChannelService` rather than in a handler, so every transport gets the same rules and "omitted = unchanged, empty string = cleared" is one decision rather than four. `display_name` is display-only on purpose: `@mentions` keep resolving against `username`, because it is the unique case-insensitive key and a non-unique nickname would make `@alice` ambiguous the moment two people pick the same one.
`POST /api/v1/users/me/avatar` takes a multipart PNG/JPEG/WebP (1 MiB, 1024×1024, both re-measured from the sniffed bytes; GIF is refused because an animated avatar renders in every message row, SVG for the reason emoji refuse it). The bytes land in the ordinary attachments table with no channel and `users.avatar` is pointed at `/api/v1/files/{id}` — which is what makes the picture readable: an unlinked attachment is uploader-only, and the file route now _also_ admits one that some user's avatar column currently equals (covered by a partial index added in the same migration). An avatar is public exactly while it is somebody's avatar and stops being readable the instant it is replaced; the previous file's bytes are deliberately left on disk, since a blind delete would race any request already in flight for a message rendered with it. `PATCH /users/me` still takes an https URL, and both paths end at the same column.
**Real invisible** is the load-bearing change. `users.status` stores the status the user _chose_, invisible included; the collapse to `offline` happens at read time in exactly two functions (`db.BroadcastStatus`, `db.StatusForViewer`) that every payload builder delegates to, so a new payload cannot leak it by forgetting. A presence change to invisible splits into two events — a global broadcast excluding the owner that says `offline`, and a targeted one carrying their true state — because a client told it was offline would render its own picker wrong and re-announce online on the next reconnect. That reconnect flash is gone at the source too: `ws serve` no longer stamps `online` on connect, it reads the saved status (`db.ConnectStatus`: idle/dnd/invisible survive, anything else becomes online) and announces _that_, before `buildReady` runs so the member list and the broadcast cannot disagree. A chosen status now survives a disconnect (`MarkUserDisconnected` clears only `online`) and a restart, and the stale-choice problem that would otherwise create is handled at read time: a member with no live connection renders offline whatever the column says. The client's `restoreSavedPresence` shrank to a no-op safeguard that only speaks up when the server genuinely disagrees. The one place that read the column as a _value_ rather than through the two collapse functions was the `@here` fan-out, which tested `status == "offline"` literally and so would have pinged exactly the people who had asked not to be seen; it now collapses through `db.BroadcastStatus` first, so `@here` skips invisible readers and `@everyone` still reaches them.
Custom status rides the presence payload rather than getting its own message: `presence_update` takes an optional `custom_status` where **omitted means "leave it alone"** and `""` clears — a distinction that exists because the auto-idle timer sends a bare status flip several times an hour and must not blank the text the user typed. It persists across reconnects and is cleared on logout (a "what I am doing right now" note that outlives the session states something no longer true, unlike the status itself, which is a preference).
**Auto-idle** is client-side (`@lib/autoIdle`): ten quiet minutes → idle, any input → online, input listening throttled to 1 Hz because mousemove fires hundreds of times a second against a timer measured in minutes. Its whole safety property is one function, `nextAutoStatus`: only a _manual_ Online becomes an automatic Idle, and only an _automatic_ Idle goes back to Online — a manually chosen Idle is a statement, and dnd/invisible are never touched in either direction. That needed `userStatus` to record who chose the status ("auto" vs "manual"), which is also what lets a stored pre-phase-6 `"offline"` be migrated to `invisible` on read.
Client: a shared `@lib/avatar` helper is now the single answer to "how do I draw this user" — message rows, the reply preview, the member list, the user bar, the profile popup and the account card all went through it, and it fetches the authenticated file through the same cert-pinned bearer-token path attachments and emoji use (an `<img src>` cannot carry an Authorization header, so the URL would 401) while keeping the letter as the fallback until and unless the bytes arrive. Display names render everywhere with a username fallback, resolved from the _member store_ first so a rename patches messages already on screen; the popup shows the `@handle` underneath so the thing you would actually type is still one glance away. The `about` section the popup has rendered since the quick-wins phase finally has real data behind it. The Account tab grew an avatar uploader (client-side type/size/dimension check mirroring the server's, so a refusal costs no upload) plus display-name and about fields, and the StatusPicker gained a custom-status input and sends `invisible` as its own value.
- **DONE (2026-08-01)** — Group DMs. `dm_participants` always held N rows per channel; what was missing was a create path, a way to tell a group from a two-person DM, and a client that did not assume one recipient. Server: `POST /api/v1/dms/group` (2–8 others, 3–10 total), `PATCH /api/v1/dms/{id}` to set or clear the name (any participant may — a group DM has no owner column and no roles, so that is the only rule that does not require inventing an ownership model; a 1:1 refuses, since its name is who is in it), and `DELETE /api/v1/dms/{id}` which is now two operations behind one gesture: a _hide_ for a 1:1 (unchanged) and a **leave** for a group, deleting the channel when the last participant goes.
Migration `028` adds `channels.is_group`, and that column rather than a participant count is the load-bearing decision. A group of three that two people leave has two participants, and the 1:1 lookup — "the dm channel both of these users are in" — would then match it, so "message Bob" would silently deliver into the remnants of a group in front of whoever was still there. The same count would also make leaving destructive for the third-from-last leaver and non-destructive for the second-from-last. Group-ness is therefore decided once at creation and never recomputed.
`db.DMChannelInfo` grew `recipients`, `name` and `is_group`, and `recipient` stayed as the first of `recipients` so a pre-group client still renders somebody; `db.NewDMChannelInfo` is the single place that answers "which of these is the recipient", so `GET /dms`, the `ready` payload and `dm_channel_open` cannot disagree about a channel. `dm_channel_open` is now built **per viewer** — `recipient` and `recipients` are defined relative to who is reading, so one shared payload would list a group member as their own DM partner. `GetUserDMChannels` became two queries (channel rows + every participant of every open DM) rather than one, because a single joined query returns one row per (channel, participant) pair and the caller has to de-duplicate anyway.
**Blocks are a 1:1 rule**, which is Discord's semantics and the only coherent one for a shared room: `requireDMNotBlocked` exempts groups, because dropping one member's messages for one other member would leave the two of them reading different conversations under the same name. They are enforced at _creation_ instead — a user may neither add someone they have blocked nor add someone who has blocked them — where "may these two be in a room together" still has one answer. The client's composer gate follows the same line and applies to 1:1 DMs alone.
Message/typing/read fan-out already went through `dm_participants` and needed no change; the tests pin that it genuinely reaches the third member. Voice in a group DM works through the existing participant check (`hasChannelAccess` → `IsDMParticipant`), also unchanged. Client: DM rows are keyed on the **channel**, not the recipient — a group has no single recipient, and the same person can be in both a 1:1 and a group with you — with stacked avatars, a participant count, and `dmDisplayName` as the one answer to "what is this conversation called" (a group's name, else its members joined and capped at three plus a count, else the other person). The New DM picker became multi-select with one button relabelled by the selection size, because "new conversation" is one intent and making the user pick "DM" or "group" before choosing who is in it asks them to declare it twice.
- **DONE (2026-08-01)** — DM calls with ringing. New `call_ring`/`call_decline` (client→server) and `call_incoming`/`call_declined` (server→client) via the schema + `make protocol-generate`. **No new DB state**: a call in a DM _is_ presence in that DM's voice channel, which `voice_state` already broadcasts, and ringing is transient signalling on top. A persisted call row would be one more thing a crashed client can leave dangling in exchange for information the presence already carries. `call_ring` is participant-gated and rate limited to one every 3 seconds per _user_ (not per channel — the abuse is spamming somebody with banners); the fan-out reaches whichever participants are connected, since a targeted event to an offline user is a no-op by construction and a ring that arrives after the fact is worse than no ring. `call_decline` is addressed to all other participants rather than "the ringer", because with no call state the server does not know who that was, and in a group more than one person may be ringing.
Client: the DM header gained a Call button that **joins the voice channel before ringing** — the ring is only truthful once the caller is actually there. Incoming calls draw a banner (not a modal: a ring is an offer, and blocking the app until a 30s timer expires is not one) with Accept/Decline and a repeating chime. The whole lifetime is a statechart in `@lib/call-ring` with no DOM in it — accept, decline, 30s timeout, `call_declined`, and the ringer's `voice_leave` all exit through one `stopRinging`, so there is exactly one place that can leave the chime playing. A timeout deliberately sends no decline: it means "nobody was there", and claiming a refusal that did not happen would be a lie to the ringer.
- **DONE (2026-08-01)** — Friends list: the dead nav item is **removed**, which is the option this plan already listed. It was a row in the DM sidebar whose `onFriendsClick` no call site ever passed and whose `friendsActive` no call site ever set; building a friends list behind it would have meant a follow/request model, a table, and a second notion of "who can DM whom" alongside blocks. The item, both dead props and its CSS are gone, and a test pins the deletion.
- **DONE (2026-08-01)** — Per-channel notification mutes. Client-side prefs in `localStorage` (`@lib/channel-mutes`), because the server has no per-user channel settings table and "which of my devices bothers me" is a property of the device, not the account — the same reason `desktopNotifications` and `notificationSounds` live next to it. Discord's semantics exactly: a muted channel fires no desktop notification, no chime and no taskbar flash (a flashing taskbar is precisely the interruption the mute was asked for); its unread badge **still counts** but renders dimmed, because the channel has not stopped existing, it has stopped shouting; and **a message that mentions you still notifies and still shows the red mention badge**. That last rule is what makes a mute safe to use, and it lives in one function (`notificationAllowed`) so the popup, the chime and the flash cannot end up applying three slightly different copies of it. Channel and DM context menus gained Mute/Unmute (until turned off — a timed mute needs a stored expiry the client would have to sweep, to buy an affordance the user can reproduce by unmuting), and the Notifications tab lists what is muted with unmute buttons, including mutes that outlived their channel, since otherwise there is no way to clear them.