* chore(format): one Prettier config at the repository root Every formatting rule in this repository lived under Client/ and covered exactly two globs: Client/src/**/*.ts and Client/tests/**/*.ts. Root Markdown, all of docs/, every YAML and JSON, all CSS, the root scripts and tools/mcp-introspect were formatted by nothing. There was no .editorconfig. The obvious fix -- a second Prettier config at the root for "everything else" -- gives two configs and two ignore files that can silently disagree about the same file. So the root takes ownership instead: config, ignore file and gate move up, and Client/ folds in. Client's inline "prettier" block, its .prettierignore, its format/format:check scripts and its now-unused prettier devDependency are all deleted; knip would have failed client-check on that last one. The .prettierrc.json values are lifted byte-for-byte from Client/package.json, which is what keeps the reformat commit free of client TypeScript churn: 87 tracked files need reformatting and not one of them is under Client/src or Client/tests. .prettierignore carries only what .gitignore does not. Prettier 3 reads the root .gitignore by default, so node_modules/, dist/, coverage/, Client/src/generated/ and docs/security-findings/ need no entry. It does NOT read nested .gitignore files, which is why .remember/ is listed explicitly -- 38 untracked per-machine scratch files were otherwise able to turn a shared gate red. graphify-out/ is listed because its seven files are tracked and .graphify_labels.json is signed byte-for-byte by its .sig, so formatting it would silently invalidate the signature. check:hygiene is registered in scripts/run.mjs and folded into check and release:preflight. It deliberately contains no `gofmt -l` step: gofmt -l prints offenders and still exits 0, so it cannot fail a build. Go formatting is enforced separately. shellcheck and actionlint take their file lists from `git ls-files`, never a filesystem glob -- .claude/worktrees/ holds a gitignored pre-flatten copy of the tree with three .sh files a glob would happily lint. This commit leaves the tree non-conformant on purpose. The reformat is the next commit, so the 87-file diff is reviewable separately from the rule that caused it. Not included: editorconfig-checker. .editorconfig is the editor baseline the audit asked for; Prettier, gofmt and rustfmt already fail CI on the same indentation and newline rules, so a fourth tool checking them again is a gate with no failure mode of its own. Verified: `npx prettier --check .` names 87 tracked files and zero untracked ones; the same command listed 38 .remember/ scratch files before the ignore entry and none after. `node scripts/run.mjs --list` resolves check:hygiene to 8 shell targets and 4 workflow targets. Both package.json files parse. Refs RL-19 / L-13, S-05. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(format): reformat the tree to the repository Prettier rules Mechanical. This commit is `npx prettier --write .` and nothing else -- the rule that caused it landed in the previous commit so this diff can be reviewed as a transformation rather than as 84 files of hunks. 84 tracked files: 54 Markdown, 7 .mjs, 7 JSON, 6 YAML, 4 .js, 3 CSS, 2 TypeScript (the two Playwright configs at Client's root, which the old Client/src + Client/tests globs never covered). No file under Client/src or Client/tests moves, because .prettierrc.json carries Client's former inline values byte-for-byte. Prettier rewrote 87 files, not 84. The three in .github/ISSUE_TEMPLATE/ had CRLF on disk and differ only in line endings, which .gitattributes (`* text=auto eol=lf`) already normalises, so their committed blobs are unchanged. Worth knowing before someone reconciles the two numbers. The largest single diff is .superpowers/findings-ledger.json at 7976 lines rewritten. That is safe to format: nothing writes the ledger programmatically -- render-ledger.mjs reads it and writes only FINDINGS.md -- so no tool will fight Prettier over its style on the next hunt. FINDINGS.md itself is ignored as generated. Verified: `npx prettier --check .` reports "All matched files use Prettier code style", so the pass is both complete and idempotent. All 7 reformatted JSON files were parsed before and after and compared as values: semantically identical, zero content changes. `node .superpowers/render-ledger.mjs --check` still reports 348 valid findings and leaves FINDINGS.md untouched. `node scripts/check-doc-counts.mjs` still passes its selftest and still agrees on 27 claims across 9 watched documents -- table realignment did not break the patterns it matches on. `node scripts/run.mjs --list` still parses. Refs RL-19 / L-13, S-05. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(lint): enforce Go formatting in the Server linter S-05: repository-wide Go formatting was not a required gate. The only gofmt enforcement anywhere was .githooks/pre-commit, which is opt-in per clone (`npm run hooks:install`), only sees staged files, and warns-and-skips when gofmt is off PATH. The obvious fix -- a `gofmt -l` step in CI -- does not work: `gofmt -l` prints its offenders and still exits 0, so the step passes no matter what it finds. scripts/run.mjs has the same problem, which is why check:hygiene has no Go step either. So gofmt goes where it can actually fail something: Server/.golangci.yml. The file was already `version: "2"` but had no `formatters:` block at all, so the 19 enabled linters ran with zero formatters. In v2 gofmt/gofumpt/goimports moved out of `linters.enable` into their own section with its own exclusions. Adding it there means the gate reports through the Lint step of "Server Build & Test", which is already pinned as required on dev -- no new job and no new pin. Every tracked .go file is under Server/ (551 of them, one go.mod), so Server-scoped is repository-wide here. One file was genuinely misformatted: a one-space struct field alignment in Server/admin/handlers_users_broadcast_test.go, fixed in the same commit because a single line does not need its own reformat commit. Trap worth recording: `gofmt -l .` on a Windows working tree lists every file that has CRLF on disk, because gofmt normalises line endings. That reported 18 offenders here, 17 of them ghosts. The blobs are all LF -- .gitattributes forces `eol=lf` -- so CI never saw them, and the honest test is to run gofmt over `git show HEAD:<file>` rather than the working copy. Doing that across all 551 tracked Go files found exactly the one real offender above. Verified both directions with golangci-lint v2 locally: `golangci-lint run ./...` reports 0 issues on the formatted tree; appending a misformatted function to Server/auth/constants.go produces 2 gofmt findings; appending the same misformatted function to Server/db/dbgen/admin.sql.go produces 0, so the exclusion holds. Both files restored and verified clean afterwards. Refs RL-19 / L-13, S-05. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(scripts): escape the NUL separator instead of embedding one The `tracked()` helper added earlier in this branch splits `git ls-files -z` output on NUL. The separator was written as a literal NUL byte rather than the two-character JavaScript escape, so scripts/run.mjs became a binary file: `git diff` refused to show it, `grep` reported "Binary file matches" instead of the line, and `* text=auto` in .gitattributes stops normalising line endings for a blob it detects as binary. The code worked -- splitting on a raw NUL and splitting on "\0" are the same operation -- which is exactly why this is worth fixing before it is inherited. A source file that tooling classifies as binary is a file nobody can review. Verified: zero NUL bytes remain, `grep -n "split("` now prints line 50 instead of "Binary file matches", `node scripts/run.mjs --list` still resolves the same 8 shell and 4 workflow targets, and prettier still reports the file clean. * chore(lint): enforce Rust formatting Rust had no formatting gate of any kind: no rustfmt.toml, no `cargo fmt` anywhere in CI, in scripts/run.mjs, in the Makefile or in the git hooks. Clippy was the only Rust gate, and clippy does not check layout. `cargo fmt --all -- --check` now runs in the rust-tests job, ahead of clippy: a formatting failure is cheap to produce and cheap to fix, and there is no reason to spend a clippy pass to surface one. The stable toolchain in that job requested `components: clippy` only, so rustfmt is added there. Only that job. ci.yml has a second, byte-identical `Install Rust` block in tauri-build; it stays clippy-only, because a full desktop build is the wrong place to discover a misplaced brace. No rustfmt.toml. The default profile is the point of a baseline -- a config file here would be a second opinion about style with nothing to say. Client/src-tauri is a single `[package]`, not a workspace, so `--all` is a safeguard against a future member rather than a fan-out today. Verified: `node scripts/run.mjs --list` resolves check:rust to three steps with `cargo fmt --all -- --check` first, `npm run format` now also runs `cargo fmt --all`, and prettier reports ci.yml, run.mjs and the ci-check skill clean. `cargo fmt --all -- --check` currently fails on 13 files -- that is the reformat, and it is the next commit. Refs RL-19 / L-13. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(format): reformat the Rust crate to rustfmt defaults Mechanical. This commit is `cargo fmt --all` and nothing else; the gate that demands it landed in the previous commit so this diff is reviewable on its own. 13 of the 17 tracked .rs files, +343/-164. The crate had never been formatted, so the changes are the usual first-run set: aligned trailing comments collapsed to single spaces, single-element slice literals folded onto one line, long method chains broken across lines, closure bodies expanded into blocks. Verified: `cargo fmt --all -- --check` is clean, so the pass is complete and idempotent. `cargo clippy --all-targets -- -D warnings` finishes with no warnings, and `cargo test --lib` reports 115 passed / 0 failed -- identical to before the reformat, which is what "mechanical" has to mean for a commit that touches this much of the crate. Refs RL-19 / L-13. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(scripts): make the root facade actually run on Windows Adding the first gate that a contributor would run from the repository root exposed two bugs in the facade, both of which made it silently wrong on the platform this project is developed on. 1. Every npm and npx step failed. bin() appends `.cmd` on Windows, but Node refuses to spawn a .cmd or .bat with shell:false -- the CVE-2024-27980 mitigation -- and fails with EINVAL and a *null* exit status. run.mjs only special-cased ENOENT, so the result was `FAILED: npx prettier --check . exited null` with nothing to explain it. check:client has three npm steps and has never been able to run here. Fixed by spawning only the npm shims through a shell. They are concatenated into a single command string rather than passed as an args array, because shell:true plus a separate array is deprecated (DEP0190) and prints a warning on every invocation; no argument in this file contains a space. 2. Every optional() step was skipped, always. onPath() shelled out to `where` on Windows, but where.exe lives in C:\WINDOWS\System32, which a Git Bash PATH does not necessarily contain -- on this machine PATH carries System32\Wbem, System32\WindowsPowerShell\v1.0 and System32\OpenSSH but not System32 itself. The probe could not start, `probe.status === 0` was false, and golangci-lint and sqlc reported as "not installed" while installed. Fixed by resolving against PATH and PATHEXT directly. No subprocess, and no dependency on which directories happen to be on PATH. A spawn error other than ENOENT now reports its code instead of surfacing as a null exit status. Verified: before, `node scripts/run.mjs check:hygiene` died with "exited null" and both optional steps printed SKIP with the tools present on PATH. After, the same command runs prettier, shellcheck and actionlint and prints "check:hygiene: passed", with no deprecation warning. `golangci-lint` is detected by the new onPath where the old one missed it. Refs RL-20 / L-14. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(format): ignore build output that nested gitignores hide Prettier honours the root .gitignore and no other. Every build and scratch directory in this repository is ignored by a *nested* one -- Client/.gitignore, .serena/.gitignore, .superpowers/sdd/.gitignore -- so none of them were excluded from the new repository-wide gate. The effect is not subtle. Running `cargo test` once drops roughly 850 formattable files into Client/src-tauri/target/, and the hygiene gate goes from clean to "Code style issues found in 939 files". CI never sees it, because a fresh checkout has no build output; every contributor sees it on their second command. Mirrors the three nested files rather than inventing a list: dist, coverage, playwright-report, test-results, .vite, src-tauri/target and src-tauri/gen from Client/.gitignore, plus .serena/ and .superpowers/sdd/. node_modules needs no entry -- Prettier ignores it by default. Verified: `npx prettier --check .` reports "All matched files use Prettier code style" with a fully populated Client/src-tauri/target/ present on disk, and still names README.md when a misformatted table is appended to it. Refs RL-19 / L-13. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(ci): shellcheck, actionlint, and a repository hygiene job The last two gates RL-19 asks for. Neither existed: the shell scripts were never linted, the workflows were never syntax-checked, and .githooks/pre-commit carried hand-written `# shellcheck disable=` directives that nothing had ever read. New `hygiene` job, ubuntu-only and root-scoped, modelled on docs-consistency for the same reason: every gate in it is platform-independent text analysis, and .gitattributes pins eol=lf so a second OS would only re-prove line endings. It runs `npm run check:hygiene` -- the same entry point a contributor runs, not a parallel copy of the commands. shellcheck ships in the runner image. actionlint does not, so it is pinned by version and verified by sha256: an installer script piped from a branch would be the one unverified download in a workflow file that pins every action by commit SHA. Prettier's step moves here from client-check, where it no longer belongs. Both linters found real defects. shellcheck, 3 findings in 8 scripts. Two are SC1125 errors in .githooks/pre-commit: `# shellcheck disable=SC2086 - repo paths contain no spaces` is not a valid directive. Trailing prose makes shellcheck discard the rest of the line, so neither suppression was ever in effect -- and one of the two was written earlier in this same branch, which is a fair demonstration of why the gate is worth having. The prose moves to its own line above. The third is SC2015 in start-server.sh, rewritten as an explicit if. actionlint, 5 findings, all inside `run:` blocks it shellchecks once shellcheck is on PATH. Three SC2015 in load-baseline.yml, rewritten as explicit ifs. Two SC2035 in release.yml, where `sha256sum *` should not become `sha256sum ./*`: the comment four lines above records that ParseChecksumFile exact-matches the last field, so a "./" prefix would strand every deployed server exactly as a "windows/" prefix would. `sha256sum -- *` satisfies the linter and leaves the output bytes identical. Verified all three gates in both directions with shellcheck 0.10.0 and actionlint 1.7.7 on PATH. Passing: `node scripts/run.mjs check:hygiene` prints "check:hygiene: passed" with all three steps run, not skipped. Failing: appending `bait_fn() { cat $1; }` to Server/scripts/voice-test.sh fails on SC2086; changing a runs-on to `ubunt-latest` fails on runner-label; appending a misformatted table to README.md fails prettier. All three files restored and confirmed clean afterwards. actionlint validates the new job in ci.yml itself. Refs RL-19 / L-13, S-05. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(plans): record B1 progress through B1-3 The header still read "B1-0 is complete; B1-1 is the next step" three merged phases later. A plan that misstates where it is costs a reader the same confusion whether it is stale by one phase or three. B1-0 (#1410), B1-1 (#1411), B1-2 (#1412) and B1-3 (this branch) are done; B1-4, dependency automation, is next. Verified: `node scripts/check-doc-counts.mjs` still agrees on 27 claims across 9 watched documents -- this file is one of them -- and prettier reports it clean. * chore(ci): pin Repository Hygiene as a required check on dev The second half of S-05. Its acceptance criterion is "tree is formatted AND a fast required gate fails future drift" -- a check that runs but is not pinned lets a formatting regression merge, so the gate is not a gate until this lands. The name was read off PR #1414 with `gh pr checks` after the job reported `pass` in 26s, not copied out of ci.yml. That order matters: the B0 script records that three pinned names exist in no workflow file at all, and that a required check which never reports blocks every PR forever. Extends the existing script rather than adding a second one, per the B1 plan. Also records, in the "deliberately NOT pinned" list, that Docs & Ledger Consistency reports and passes on a dev PR yet is unpinned. That reads as an oversight from the 2026-08-25 pass rather than a decision, but it belongs to G-04, so it is documented here and not changed. NOT APPLIED YET. Running this script now would pin a check that PR #1413 cannot report -- its branch predates the hygiene job, so the job does not exist in its workflow file and the check would never arrive. Run it after #1414 merges; #1413 needs a rebase onto dev regardless. Verified: shellcheck clean, the embedded JSON parses, and `check:hygiene` passes with prettier, shellcheck and actionlint all running. Refs S-05, RL-14 / G-03. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
50 KiB
Discord feature parity — gap analysis and plan
Status (verified 2026-08-04): Shipped — phases 1–6 complete. Phase 1's table below was written as a gap list and never re-marked; all six rows have since shipped (block/unblock UI
SidebarMemberSection.ts:177-186; topics inreadyviachannelPayloadFrom; role colors from the server list with seeded-name fallbackformatting.ts:158-175; profile popup mounted fromMemberList.ts; temp bansBanDurationHoursinPATCH /admin/api/users/{id}; archived channels hidden by the unified predicatepermissions/checker.go:116-121). Named leftovers remain open and are listed in-line: role hoist/mentionable flags +@RoleNamementions (Phase 5), categories as real entities (Phase 5), and the §"still-dead code" cleanup list (voice_speakers,voice_config.bitrate, macOS PTT stub — thesoundstable was dropped by migration 029 on 2026-08-04, A-2026-07-13).
Status: phase 6 complete (2026-08-01)
This is a depth audit of features OwnCord already has, compared against what Discord's version of the same feature can do (free tier, with Nitro notes where relevant). Wholly absent features (threads, forums, polls, soundboard, …) are listed at the end for reference but are out of scope here — the focus is finishing the features we have.
Where OwnCord already meets or beats Nitro
- Uploads: 100 MB default (free Discord: 10 MB, Nitro Basic: 50 MB, Nitro: 500 MB) — and
max_upload_bytesis operator-configurable. - Message length: 4000 runes, equal to Nitro's limit (free Discord: 2000).
- Streaming: source-quality preset and up to 120 fps, above Nitro's 1080p60/4K60.
- Client themes: full custom CSS-var theming with import/export — a paid perk on Discord.
- E2EE voice with TOFU identity verification — Discord has no equivalent at any tier.
Phase 1 — quick wins (this branch)
Features where one side is already built and the other was never finished.
| # | Item | What exists | What's missing |
|---|---|---|---|
| 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 |
Phase 2 — moderation depth
- DONE (2026-07-31) — Honest kick semantics. There is no membership model, so
DELETE /admin/api/users/{id}/sessionscannot 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, theKICK_MEMBERSbit and theonKick/adminKickMembercall sites are unchanged. - DONE (2026-07-31) — Enforce the decorative permission bits. The
/admin/apiperimeter now admits any role holding a bit ofpermissions.AdminPerimeterinstead of requiringADMINISTRATOR, 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 insideModerationService. Stats/users/GET /mestay perimeter-level; backups, updates, API tokens, plugins and the log stream are unchanged.GET /admin/api/mereports 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 thereadyrole list instead of on the role name.MUTE_MEMBERSadmits to the perimeter but still has no route behind it (see voice moderation below). - DONE (2026-07-31) — Hierarchy checks beyond ban/unban:
ModerationService.ChangeUserRolerequires 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.ForceLogoutenforces 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_moveandvoice_mod_kick, each requiring the bit plus a strict role-position outrank of the target, rate limited 5/sec and audit-logged.voice_statesgainedserver_muted/server_deafened, which thevoice_statebroadcast now carries; a server mute is also applied to the target's published audio track via the LiveKit RoomService, and the target's ownvoice_mute/voice_deafenunmute attempts are refused withSERVER_MUTED/SERVER_DEAFENED. Move and disconnect run the hub's voice-leave routine for the target and then send themvoice_moved(client re-joins the destination through the ordinary join path) orvoice_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/purgetakes{limit: 1-100, before?}and soft-deletes the newest matching messages, gated onREAD_MESSAGES|MANAGE_MESSAGESfor 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 andreply_totargets survive; already-deleted rows are skipped and the select+update run in one writer transaction. Onemessage_purgeaudit entry per call carries the count, and the fan-out is a single newchat_bulk_deleted {channel_id, ids}server->client message instead of Nchat_deletedevents. The desktop channel context menu grows a "Purge Messages…" item — gated on the actor'sMANAGE_MESSAGESbit 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.resolveMentionsparses@tokens out of sanitized content with a word-boundary rule (mentionTokenRe) that refuses address-shaped text:mail@exampleand@@namenever match, and@bob@example.comis rejected whole rather than half-matched. Tokens are lowercased, deduplicated, ordered by first appearance and resolved case-insensitively againstusers.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 newmessage_mentionstable (migration022, PK(message_id, mentioned_user_id)plusidx_message_mentions_userfor 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.mentionsandmentions_everyonenow ride on thechat_messageandchat_editedbroadcasts, onGET /channels/{id}/messages, on pinned-message responses and on FTS search results;mentionsis always present and empty rather than null.buildChatMessagetook achatMessageArgsstruct in the process — the positional list had outgrown a readable call site. - DONE (2026-07-31) —
read_states.mention_countis live.applyMentionCountsraises 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: aListBlockersOferror 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_focusresets the count to 0 via theUpdateReadStateupsert, and thereadypayload shipsmention_countper channel. BecauseGetChannelUnreadCountscoverstext/announcementchannels, 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/mentionsis 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'smentions/mentions_everyonedecide the outcome whenever present (the local token parse only stands in for servers predating the fields). Resolved mentions render as a highlighted.mentionspan, with.mention-selfwhen the mention is the current user; an unresolvable token renders as plain text. In the channel sidebar a red.mention-badgeoutranks 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/@herealone caused, so a message that also names you still notifies, and an@everyonethe 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/@herebehind a permission, plus composer autocomplete. NewMENTION_EVERYONEbit (21,0x200000); migration022grants it to the seeded Owner/Admin/Moderator roles, moving the Moderator mask from0x000FFFFFto0x002FFFFF. Without the bit the token carries no mention semantics at all — no highlight, no badge, no notification — and DM channels have no@everyonesemantics since there is no permission surface to answer to.@herenarrows the fan-out to readers whose status is notoffline;@everyonereaches 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/@hereappear as rows only for users who hold the bit. - DONE (2026-07-31) — Clickable
#channellinks.#nametokens 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#channellinks, 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 sosnake_case_namesstay 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 absolutehttp(s)only —javascript:,data:and relatives render as their literal source — and are excluded fromextractUrls, 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-spanrole="button"elements witharia-pressed, and the revealing click is swallowed so a link underneath cannot open with it. Rendering stays a strict DOM builder: noinnerHTMLanywhere, everyhrefthroughisSafeUrl. 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, withhas_more_before/has_more_afterderived 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 inMessageServicecollapsed into onerequireChannelRead, and the three copies of limit parsing in the handlers into oneparseLimitParam. Client-side every jump affordance — search hit, pinned entry, the quoted reply bar, a permalink chip, anowncord://message/…link from the OS — now routes through a single jumper (lib/message-navigation.tsregistry →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 areowncord://message/{channelId}/{messageId}— copied from the hover bar, parsed by the samedeep-link.tsthat owns the invite scheme (whose bare-code form now refuses themessageroute), 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 onRawPath, so the handler unescapes it) behind the samerequireChannelReadgate as history, returning up to 100 reactors oldest-first. A separate endpoint rather thanuser_idsinline 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 (thelib/streamPreview.tsdebounce, 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 onreaction_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|oggrender 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, notvideo//audio/prefix tests — an unknown container gets the download chip rather than a player that fails to decode — andimage/svg+xmlis 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 ablob: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,
setActiveChannelsnapshots 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 messagemark_read:channel_focusalready 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;GetChannelUnreadCountsincludes the caller's DM rows soreadyships a DMmention_count— previously absent, which silently reset every DM mention badge on reconnect.
Phase 5 — roles & channels management
- 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 onMANAGE_ROLESwith the whole rule set in a newservice.RoleServicerather 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 (ADMINISTRATORbypasses). The default role is undeletable — every member falls back to it — and deleting a role moves its members onto that fallback in oneUPDATE, drops the role'schannel_overridesrows and deletes the role in a single writer transaction, then invalidates exactly the moved members' cached permissions. Names are unique case-insensitively (migration023addsidx_roles_name_nocase; the column's ownUNIQUEis BINARY, so "Moderator" and "moderator" used to be two roles the client's case-insensitive lookup could not tell apart), colors are#rgb/#rrggbbnormalized 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 toN…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 newHub.RefreshAllChannelVisibility—RefreshChannelVisibilityacross 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 onemember_updateper reassigned member. A newroles_updateserver→client message (schema +make protocol-generate+docs/protocol.md) carries the full new list, so clients refreshchannelsStore.roleswithout 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 onMANAGE_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 asdocs/schema.mdgroups 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. - Role CRUD leftovers: hoist and mentionable flags (no columns yet), role mentions (
@RoleName). - DONE (2026-08-01) — Per-user channel overrides + the full override matrix UI. New table
channel_user_overrides(migration024, PK(channel_id, user_id)plusidx_channel_user_overrides_userfor 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;ADMINISTRATORstill bypasses both. The formula has exactly one implementation,permissions.EffectiveChannelPerms, whichChecker.HasChannelPerm,Checker.HasChannelPermBatchand through itVisibleChannelIDsall route through — so extending the order was a change to one function plus the fetch, not to the dozens ofHasChannelPermcall sites.HasChannelPermgrew auserIDparameter (0= "no member in hand", skip the user layer), and both layers are loaded together bydb.GetChannelOverridesFor(roleID, userID)— two batch queries, never per channel — which is now the single fetch behindbuildReady,computeAllowedChannels, RESTListVisibleChannels,MessageService.GetAccessibleChannelIDs, the voice-join publish grants and the cachedservice.PermissionService.channelCanSendresolves both layers too, and the@everyonefan-out (mentionReaders) folds the user layer in both directions: a user deny drops a reader the role walk admitted (unless they holdADMINISTRATOR), a user allow adds one it excluded.Hub.RefreshChannelVisibilityandchannelReadAudiencestopped 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.gogrew a second case proving REST,readyand 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, gatedMANAGE_CHANNELSlike the role layer, unknown bits masked off, audited aschannel_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 .../permissionsnow returnsusersalongsideroles: 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 aDELETE, because storing(0,0)would leave a row that resolves to nothing.perm_grid_test.goties the matrix's bit list topermissionsthe 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/channelsvalidates the type alone, categories are free text, and any type lives under any name.PATCH /admin/api/channels/{id}acceptscategory, so moving a channel between categories is an edit rather than a delete-and-recreate. The desktopCreateChannelModal's read-only category display became an editable text input with a<datalist>of the categories in use (channelsStore.getKnownCategories), offering all three types;EditChannelModalgained 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.
EditChannelModaloffered 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 sending0and wiping limits the row happens to hold. Every control pre-fills fromchannelsStore(whichchannel_updatewrites into), not from the sidebar row, so the modal opens on current values.PATCH /admin/api/channels/{id}anddb.AdminUpdateChannelgrewnsfw,voice_max_usersandvoice_max_video; the positional argument list becamedb.ChannelUpdateonce it reached nine fields, four of them ints. Bounds (slow_mode0…21600, both voice limits 0…99) are validated before the write and refused with400 INVALID_INPUTrather than clamped — a caller that sent-1meant 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_updateandreadyall carryslow_mode,nsfwand both voice limits — always present with their zero values, never omitted, so "absent" never means two things — built by onechannelPayloadFromconstructor so the two events cannot drift. The store applies a partialchannel_updatefield by field (an absent key is left alone, not cleared) and finally handlescategory, 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
025addschannels.nsfw(0/1, likearchived). The server does nothing with it and says so inschema.md,api.md,protocol.mdand 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-gateremembers 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), andNsfwGatemounts 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 answersCHANNEL_FULL, which the dispatcher now surfaces as a toast (it was logged and otherwise silent, as wasVIDEO_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 withauthStoreand the role list, becausereadycan land after the header is built), openinghttps://{host}/admin#auditin 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#sectionfragment 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.
Phase 6 — social & profiles
- DONE (2026-08-01) — Custom emoji end-to-end. The
emojitable had shipped in migration001with zero server code, and the client carriedgetEmoji/deleteEmojimethods aimed at routes nobody had registered plus anEmojiPickeroption 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/emojiandDELETE /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|webponly — 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 plainUNIQUEindex a case-insensitive one without aCOLLATEchange — with a collision answering409 CONFLICTand a 200-emoji cap per server. Bytes go through the existing storage layer under a UUID; migration026adds the one column the table lacked,mime_type, soGET /api/v1/emoji/{id}/imagecan 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 isimmutable-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. Newemoji_updateserver→client message (schema +make protocol-generate+docs/protocol.md) carries the whole set after every mutation, for the same reasonroles_updatedoes: 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 thereadypayload — 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 newemojiStorewhoseresolveEmojiis 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-jumboclass that sizes glyphs and images together rather than threading a flag through four render functions. It is added in the same token pass as@mentionsand#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 toimg.srcwould 401 — with the shortcode asalt, 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 bare32and is now derived asMaxShortcodeLen + 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 itscustomEmojioption, 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 onMANAGE_SERVER) with upload, list and delete; it calls the ordinary member API rather than a duplicate/admin/apihandler set, and loads thumbnails as blob URLs because<img src>cannot send an Authorization header (the panel's CSP gainedimg-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
027addsusers.display_name(32),about(300) andcustom_status(128) — all nullable, all bounded and HTML-sanitized inUserService/ChannelServicerather than in a handler, so every transport gets the same rules and "omitted = unchanged, empty string = cleared" is one decision rather than four.display_nameis display-only on purpose:@mentionskeep resolving againstusername, because it is the unique case-insensitive key and a non-unique nickname would make@aliceambiguous the moment two people pick the same one.POST /api/v1/users/me/avatartakes 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 andusers.avataris 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/mestill takes an https URL, and both paths end at the same column. Real invisible is the load-bearing change.users.statusstores the status the user chose, invisible included; the collapse toofflinehappens 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 saysoffline, 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 serveno longer stampsonlineon connect, it reads the saved status (db.ConnectStatus: idle/dnd/invisible survive, anything else becomes online) and announces that, beforebuildReadyruns so the member list and the broadcast cannot disagree. A chosen status now survives a disconnect (MarkUserDisconnectedclears onlyonline) 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'srestoreSavedPresenceshrank 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@herefan-out, which testedstatus == "offline"literally and so would have pinged exactly the people who had asked not to be seen; it now collapses throughdb.BroadcastStatusfirst, so@hereskips invisible readers and@everyonestill reaches them. Custom status rides the presence payload rather than getting its own message:presence_updatetakes an optionalcustom_statuswhere 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 neededuserStatusto record who chose the status ("auto" vs "manual"), which is also what lets a stored pre-phase-6"offline"be migrated toinvisibleon read. Client: a shared@lib/avatarhelper 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@handleunderneath so the thing you would actually type is still one glance away. Theaboutsection 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 sendsinvisibleas its own value. - DONE (2026-08-01) — Group DMs.
dm_participantsalways 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), andDELETE /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. Migration028addschannels.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.DMChannelInfogrewrecipients,nameandis_group, andrecipientstayed as the first ofrecipientsso a pre-group client still renders somebody;db.NewDMChannelInfois the single place that answers "which of these is the recipient", soGET /dms, thereadypayload anddm_channel_opencannot disagree about a channel.dm_channel_openis now built per viewer —recipientandrecipientsare defined relative to who is reading, so one shared payload would list a group member as their own DM partner.GetUserDMChannelsbecame 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:requireDMNotBlockedexempts 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 throughdm_participantsand 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, anddmDisplayNameas 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) andcall_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, whichvoice_statealready 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_ringis 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_declineis 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-ringwith no DOM in it — accept, decline, 30s timeout,call_declined, and the ringer'svoice_leaveall exit through onestopRinging, 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
onFriendsClickno call site ever passed and whosefriendsActiveno 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 reasondesktopNotificationsandnotificationSoundslive 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.
Absent wholesale (not planned here)
Threads, forum/stage channels, webhooks, bot accounts, stickers, polls,
message forwarding, TTS, soundboard (dead sounds table), priority speaker,
streamer mode, video backgrounds, multi-guild, email/account recovery,
slash commands (separate plan: slash-commands.md).
Known dead code to reconcile as phases land
Still dead after phase 6 (nothing in this plan reconciles them):
soundstable — the soundboard is absent wholesale, so the table has no feature to belong to; it and the client'sgetSounds/deleteSound, which still call unregistered routes, are the largest remaining piece.voice_speakersreserved WS type (never sent).voice_config.bitrate— sent to clients, never applied client-side.- PTT stub on macOS.
Came off the list:
read_states.mention_count(phase 3) — now written, shipped inreadyand cleared bychannel_focus.- The
emojitable and the client'sgetEmoji/deleteEmoji(phase 6) — the table is written by/api/v1/emoji, and the two dead methods were replaced bylistEmoji/uploadEmoji/deleteEmojiagainst the real routes. UserProfilePopup'saboutsection (phase 6) — built, styled and tested while every call site passed a hardcodednull;users.aboutnow feeds it.- The DM sidebar's Friends nav item (phase 6) — deleted rather than
implemented. Its
onFriendsClickwas never passed by any call site and itsfriendsActivewas never set; giving it a destination would have meant a follow/request model, a table and a second notion of "who may DM whom" alongside blocks. The item, both dead props and its CSS are gone, and a test pins the deletion.