Author SHA1 Message Date
J3vbandClaude Opus 5 ad0448df4d ci: stop the lint gate fetching its schema over the network (#1335)
golangci-lint-action verifies .golangci.yml against a JSONSchema it pulls
from https://golangci-lint.run before it lints anything. On d352696 that
fetch hit its client timeout and took the required ubuntu leg of Server
Build & Test red -- with zero linters run and nothing wrong with the code.

Turn the pass off. `golangci-lint run` already rejects a malformed config
on its own, so the only thing lost is a nicer error message for a config
typo, and the thing gained is a required gate that no longer depends on a
third-party website being reachable.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:24:57 +02:00
J3vbandClaude d3526968bb release: v1.2.0-alpha.2 (#1333)
* docs: add bug-detection improvements plan

Plan for mechanical bug detection alongside the agentic hunt: activate the 14
unused Go fuzz harnesses, the configured-but-never-run Stryker setup, and
browser-mode vitest; encode recurring bug classes as semgrep rules; add
model-based and fault-injected ordering tests; add a persistent seen-ledger
and sibling-sweep lens to the hunt.

All local-only and on demand - fuzz crashers are working reproducers, and this
repo is public.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* build: add make fuzz target and ignore mutation-test output

`go test ./...` runs each Fuzz* function against its committed seed corpus
only - one pass per seed, zero generated inputs - so the 17 fuzz harnesses in
Server/ have never actually fuzzed. `make fuzz` enumerates every target and
runs each with a time budget (Go fuzzes one target per package per
invocation, hence the loop). Local-only by design: a crasher is a working
reproducer and this repo is public.

Also gitignore Client/tauri-client/.stryker-tmp/ and reports/ - a Stryker run
left 200+ untracked files, and a surviving-mutant report maps exactly which
behaviour nothing tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(client): pin reconnect auth-frame and replay-dedup arming

Stryker found 14 surviving mutants across ws.ts:413/422/428 - the auth frame
built on reconnect. Every condition there could be flipped with all 4777
tests still green: the replay-dedup arming guard, the resume-vs-fresh-connect
ternary, and the conditional active_channel_id spread.

Seven tests through the public send/isReplaying surface, no new exports. Two
isolate each half of the `reconnectAttempt > 0 && lastSeq > 0` AND condition -
the combination no existing test reached, and the one an && -> || mutant
walked straight through.

Verified by flipping the line 413 guard to `if (true)`: 3 of 7 fail, revert
restores green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: record two fuzz corpus traps

Interrupting a fuzz run manufactures a false crasher: Go cannot distinguish a
worker that crashed on an input from one killed externally, so it saves the
in-flight input to testdata/fuzz/ as a suspect. It looks exactly like a real
security finding. Replay before believing it.

And committed seed corpus shares the testdata/fuzz/<Target>/ directory with
any false crasher, so clearing one by removing the directory deletes the
seeds too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(client): enforce three prose invariants as ESLint rules

CLAUDE.md documents the voice-supersession, E2EE staleness and dispatcher
invariants in English. English fails no build, and bug hunts keep rediscovering
the same classes. Five rules encode them as an inline flat-config plugin - no
new dependency, and `npx eslint src/` is already a blocking CI gate.

- no-leave-voice-when-superseded: a global leaveVoice() inside a branch that
  already confirmed supersession tears down the newer live session
- e2ee-epoch-needs-keypair-check: a non-key-holder never bumps the epoch, so
  an epoch-only staleness guard cannot see a restarted session
- e2ee-verified-status-literal: keeps "verified" tied to a hand-written call
  site that earned it, never a computed status
- no-identity-scope-fallback: a `?? 0` placeholder scope mints a keypair under
  the wrong account
- no-store-write-in-ws-on: page-local ws.on handlers may read stores, not
  write them

Each rule proven to fire by reintroducing the historical bug shape and
reverting; RuleTester cases cover both the real shapes that must stay clean
and the bug shapes that must not.

A fourth candidate - await-then-stale-snapshot - was declined as not
AST-expressible: whether an await needs a guard, and whether the guard is
sufficient, is intent rather than shape, and the rule would flag most of the
already-correct guard code in livekitSession.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: correct dispatcher invariant, record Tier 2 as shipped

The client CLAUDE.md claimed ws.on(...) appears only in dispatcher.ts. Eight
handlers across main.ts, MainPage.ts and ChannelController.ts say otherwise -
page-local UI (ringing, overlays, slow-mode timers) legitimately subscribes.
The real invariant is narrower: dispatcher is the single path by which server
events WRITE to domain stores. That is what local/no-store-write-in-ws-on
enforces, and the doc now matches the code.

Also record that Tier 2 shipped as ESLint rules rather than semgrep, and why
the fourth candidate was declined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): move the status-picker dot onto the avatar corner

The corner dot on the user bar avatar was a static hardcoded-green div —
never reflected real status and did nothing on click. Removed it and
relocated the actual StatusPicker trigger dot (real color, opens the
status dropdown) to that same corner instead of its own row. The
"Online"/"Idle"/... text label under the username is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(client): return the saved password over IPC again

The remember-password box saved a password the client could never read
back. Hardening had put #[serde(skip)] on CredentialData::password, so
load_credential returned a record whose password was always absent and
the login form could not prefill it — the box appeared to work and
silently did nothing.

Drop the skip and carry the field through the TS wrapper, which now maps
a non-string password to undefined rather than trusting the payload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(client): add an auto-connect checkbox to the login form

Auto-connect already existed end to end — ServerProfile.autoConnect,
setAutoLogin(), and the boot auto-login block with its cancel overlay —
but was only reachable through the zap button on a server card. This
surfaces the same state as a checkbox under Remember password, where
users look for it.

Ticking it forces Remember password on and disables it: boot auto-login
replays the stored token, which saveCredential only writes when the
password is remembered, so the two cannot be set independently without
producing a setting that silently does nothing.

Unticking is guarded. setAutoLogin(null) clears autoConnect on every
profile, so a bare toggle-off would wipe another server's setting; the
clear now only fires when this profile is the current holder. The guard
lives in ensureProfileExists, which all four auth paths already route
through.

Also consume the password restored in the previous commit, so selecting
a saved server prefills it instead of leaving the field blank.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(release): bump client to 1.2.0-alpha.2

The client version is not derived from the tag — release.yml's
verify-versions job compares the tag against package.json and
tauri.conf.json and fails the release if they drift, so all five
manifests (both lockfiles included) move together.

Also refreshes the literal version in the README and docs build
examples, and closes the Unreleased changelog section as v1.2.0-alpha.2.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(changelog): record the three bug-hunt sweeps in v1.2.0-alpha.2

PRs #1328, #1331 and #1332 merged to main after v1.2.0-alpha.1 was tagged
and closed 233 verified defects between them, but none of the three left
an entry in the curated changelog — the generated list covers commits,
this file covers behaviour, and nothing bridged the two.

Verified unreleased by ancestry rather than by date (none of the three
merge commits is an ancestor of v1.2.0-alpha.1), so all of it ships for
the first time in alpha.2.

Nine entries grouped by subsystem, leading with the changes an operator
or user would actually notice: the 24h-retention desync, the avatar-
deleting orphan sweep, the zero-byte restore truncation, the six hot-mic
paths, and the TOFU re-pin that would have warned every install at once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(client): drop the e2e assertion for the removed user-bar status dot (#1334)

26b46cc removed the hardcoded-green `.status-dot` div from the user bar
avatar and relocated the real StatusPicker trigger dot into that corner,
adding "status picker dot sits on the avatar" to cover the new element.
The old "user bar has status dot" test was left behind and now fails on
an element that no longer exists by design.

The replacement test already asserts the corner dot is present and
visible, so removing the stale one loses no coverage.


Claude-Session: https://claude.ai/code/session_01Rkv9dVo5YEYArqrDRfW41w

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:50:12 +02:00
J3vbandClaude Opus 5 82be103794 fix(client): resolve 94 verified defects across voice, identity, transport and UI (#1332)
* fix(client): gate every mic re-enable path on the user's mute state

Six separate paths republished the microphone without consulting whether
the user had muted themselves: the audio-device fallback, selecting the
"Default" input, un-deafening, retryMicPermission, a stale PTT ownership
latch, and auto-reconnect's restoreLocalVoiceState. Each one produced a
hot mic while every remote UI still showed the user as muted.

These were six findings but one missing guard. Adds isMicPolicyGated()
(localMuted || localDeafened || localServerMuted || pttGated) and routes
the device-switch cycle, applyMicMuteState's unmute branch and
retryMicPermission through it, which also covers setDeafened(false) --
a call site no finding named.

Also extracts reconnectSuperseded() so all five supersession checkpoints
in the auto-reconnect loop carry the state-type check that only the
give-up path had, and clears the PTT gate on stopPtt and on ptt-error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): stop camera/screenshare publishing after the user turns it off

enableCamera and enableScreenshare set the store flag before awaiting
getUserMedia/getDisplayMedia, so clicking off during the OS picker left
the track publishing to the SFU while the UI showed it off, with no stop
affordance. Adds one shared generation guard: disable bumps, enable
captures before the await and discards the track if it changed.

Also in this area:
- a server refusal of voice_screenshare (or a non-VIDEO_LIMIT refusal of
  voice_camera) never rolled back the published track; the dispatcher now
  correlates the error by envelope id rather than blanket-rolling-back.
- a full-ready resync left every loaded channel with a permanent hole in
  its history, because that tier never replays chat_message frames.
  Loaded windows are now invalidated on a resync (pending and failed rows
  carry through) and the active channel refetched.
- CHANNEL_FULL while joining left voiceStatus stuck; DM mirror rows kept
  phantom entries and stale unread counts across a resync; addMessage and
  setAroundMessages dropped offline/failed optimistic rows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): preserve a mid-setup key-holder promotion, and route the
audio graph through the noise suppressor

setupKeyExchange unconditionally wrote the server's key-holder value
captured at join, clobbering a handleParticipantLeft promotion that
landed during its pre-publish awaits. The joiner then waited for an offer
only it could send, timed out, and was ejected from voice. The write now
preserves an existing promotion; it sits after the existing
session-generation check, and clearState bumps that generation and resets
the flag synchronously, so stale state cannot survive a teardown.

Enhanced Noise Suppression silently disabled the input-volume slider and
the VAD gate: livekit-client's setProcessor() does its own internal
replaceTrack(processedTrack) after awaiting addModule and a fetch, so it
landed after ours and wired the sender straight to the raw mic. The
pipeline now sources from the processed track and re-runs after
attaching, so our replaceTrack wins.

Also scopes the voice identity keypair by host AND user id so two
accounts sharing one OS profile stop sharing an identity keypair, guards
peer-key and TOFU writes against a clearState during their IPC awaits,
and seeds VideoGrid tiles from the persisted per-user volume.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): drop the previous server's bearer token on a host switch

api.setConfig spread the new config over the old, so switching hosts
carried the previous server's session token forward and the login request
to the next server went out holding a live credential for the first one.
The token is now dropped in the shared setConfig when host changes
without an accompanying token, covering login, register and auto-connect
at once.

Also fixes a packaged-build-only failure: the CSP omitted blob: from
img-src, so avatar upload validation (which measures the image via
URL.createObjectURL) always failed in release and never in dev.

Smaller connection and IPC fixes: ws_disconnect now bumps the connection
generation instead of nulling the sender slot, so an in-flight handshake
cannot install after a disconnect; a dead LiveKit proxy listener
deregisters itself instead of being reused forever; httpProxy no longer
caches an origin the Rust side may have torn down; logPersistence stopped
looping on its own flush-failure logs; ConnectPage subscribes to
transientError instead of reading it once; cert-mismatch accept/reject
only act when the event host matches the live session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): guard the quick-switcher against a double-open

openQuickSwitch assigned its instance only after awaiting the profile
load, so a second click during that window mounted a second overlay and
orphaned the first. Every close affordance destroys only the tracked
instance, leaving a body-mounted position:fixed backdrop that blocks all
input until the app is reloaded. Adds the same `opening` flag the sibling
overlay controllers already use; audited every other opener in these
files and found no second instance of the race.

Also: loadOlderMessages and loadMessages now discard a response whose
window was replaced mid-fetch by a same-channel jump; the ArrowUp
edit-last-message scan skips unsent rows, matching the visual affordance;
unpinning from the pinned panel writes the store row; the pinned panel
forwards the channel it captured at open time rather than reading the
active one at click time; the reaction picker closes on channel teardown;
a non-voice channel switch dismisses the video grid; and destroy() closes
the settings overlay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): repair the status-picker stylesheet and a dozen UI defects

The .status-picker rules targeted a root element the component never
toggles, leaving the popup's own chrome unstyled and the root
display:none. Repointed at .status-picker-dropdown and dropped the dead
rules.

Component and store fixes, all test-first: the upload preview bar never
became visible so upload errors were invisible; replying while editing
left the edit text in the textarea; MessageList's load-older latch keyed
off a raw count so a live tail append refired the fetch; drag-reorder
renumbered channels into a 0..n-1 range instead of reusing the group's
own position slots; DM avatars bypassed the authenticated fetch path;
the member-list moderation gate read a mount-time role snapshot; mention
autocomplete offered usernames the mention grammar cannot express;
notifications titled DMs as "#channel"; the update-notifier catch
dereferenced a null banner; and the channel context menu leaked its node
on teardown.

Also resets authStore in member-list.test.ts's shared reset helper: one
test was leaving role="admin" set for every test after it, unnoticed
because no gate read authStore for role until now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): adopt the legacy identity key instead of re-minting one

Scoping the identity keypair by host and user id changed the keyring
account name, so every existing install would have found nothing at the
new account and generated a fresh identity key. Every peer who had
already pinned the old one would then see a TOFU mismatch, which raises
the re-pin modal telling the user to verify the safety number
out-of-band -- a MITM alarm fired at the whole alpha population at once,
which teaches people to click through the one warning meant to matter.

When the scoped account is empty, the legacy host-only account is now
adopted: saved under the scoped name, then the legacy account deleted.
Save happens before delete so a partial failure leaves the legacy key in
place for the next launch rather than stranding the user with neither.
A corrupt legacy blob falls through to fresh generation without throwing.

A second account on the same host still mints its own distinct keypair,
which was the point of the scoping fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): surface server errors that had no dedicated handler

The WebSocket error handler bannered only RATE_LIMITED and FORBIDDEN, so
every other code that reached the fallthrough was dropped in silence --
a rejected chat_edit reported nothing at all while the optimistic
"Message edited" toast still fired. Every specific branch above already
returns, so the fallthrough sees only genuinely unhandled codes; it now
banners all of them.

Also:
- reattachToPresent cleared the detached flag eagerly, so a failed tail
  refetch let a live broadcast splice onto the stale around-window with a
  silent gap. The flag now survives until setMessages lands the tail.
- a mixed-case host and its lowercase-normalized URL form resolved to
  different cert-store pin keys; tofu::cert_store_key and ws.ts's
  normalizeHostForCertCompare both lowercase now. attachments.ts already
  did the right thing and is unchanged.
- clearAuth left the channels store populated for the next login.
- capabilities/default.json was missing
  core:window:allow-request-user-attention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): tear down video tiles, focus and the lightbox on leave

Four defects an earlier pass could not finish because each spanned two
files:

- closeVideoGrid only hid the grid, so remote video tiles survived a
  channel leave and reappeared on the next join. VideoGrid grew a
  clearStreams(), called from the real-leave branch of checkVideoMode
  (not the reconnect branch).
- the grid kept its focused-tile state across a close; setFocusedTile now
  accepts null and closeVideoGrid clears it.
- the per-user volume preference key had no host component, so volumes
  set on one server applied to a different user with the same id on
  another. Scoped via setAudioVolumeHost, mirroring channel-mutes.
- the media lightbox stayed mounted after MainPage.destroy().

Also repairs tests/unit/audio-elements.test.ts, which was missing an
afterEach import and failing to compile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): close eight defects a review found in this batch

Three of these are incomplete fixes from earlier commits on this branch --
the diagnosis landed, the cure stopped short.

- main.ts kept a hand-copied normalizeHostForCertCompare that never got the
  .toLowerCase() its ws.ts original and tofu::cert_store_key both have. Since
  the Rust side always emits the lowercased host and a profile stores it
  verbatim, any uppercase in the hostname broke all three guards -- worst of
  them the mismatch modal's onReject, which then skipped disconnect/clearAuth
  and left the user connected to the server whose certificate they had just
  refused. ws.ts now exports the one implementation and the copy is gone.
- the status-picker stylesheet repair repointed the root and deleted the old
  .status-option rules without adding replacements under the names the
  component emits, so the trigger dot -- a bare div whose only style is an
  inline background -- stayed 0x0, invisible and unclickable. The picker still
  could not be opened.
- ungateMic's re-open branch was unreachable in the one scenario its comment
  described: a PTT release routes through setMuted(true), so localMuted is
  always true there. It now takes the pttOwnsMute latch read *before* each
  call site resets it; reading the module flag from inside would always see
  false and move the bug rather than fix it.

The rest:

- dispatcher.ts statically imported @lib/screenShare, which has value imports
  from livekit-client -- dragging ~1.3 MB into the entry chunk that the file's
  own comment says is deliberately kept out of it. Now lazy, like every other
  voice call site here.
- replay detection compared payload.timestamp (server clock) against
  Date.now() (client clock). A self-hosted server without NTP made every live
  message after a reconnect look like a replay, silently killing notifications
  for the whole drift window. Both sides are now in server time via an
  observed skew estimate; latency biases it toward treat-as-live, which is the
  side that costs a duplicate rather than a dropped notification.
- identity.ts and livekitE2EE.ts each derived the keyring scope with `?? 0`.
  A missing user id would have adopted-and-deleted the real legacy key into a
  bogus host:0 account, then minted a second keypair under host:<realId> --
  published key and signing key permanently disagreeing, which is a false MITM
  warning for every peer. Unreachable today, irreversible if reached.
- per-user volumes were scoped by host with a legacy fallback that only fired
  when currentHost was null, which MainPage never leaves it as -- so every
  saved volume silently read as the default on upgrade. Reads now fall through
  to the unscoped key once and persist under the scoped one.
- a post-resync invalidate ran unconditionally while its refetch was guarded,
  so a missing getMessages left every window dropped with nothing to reload it.

A ninth finding -- that the DM reconcile could strand activeChannelId -- was
checked and rejected: the block 40 lines above already clears it whenever the
id is absent from both channels and dm_channels.

Two test-suite notes: livekit-session's announce-signing test was joining
voice with no authenticated user, which production does not permit, so it now
sets one (below PEER_ID, leaving key-holder election unchanged) and clears it
after. status-picker-userbar reads app.css from disk rather than `?raw`, which
vitest stubs to an empty string for stylesheets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): repair the e2e cert test and four defects found verifying it

The e2e suite caught one behavioural divergence from this branch, and
hand-verifying the hunt's flagged-but-unchecked items turned up four more
defects.

E2E:

- cert-tofu's "disconnect on mismatch returns to the connect page" emitted the
  mismatch for myserver.example:8443 while the session was authenticated
  against localhost:8443, so it asserted the pre-fix behaviour: a certificate
  rotating on ANY unrelated saved profile logs you out of the server you are
  using. That is the bug 8917c28 deliberately fixed. The test now emits for the
  live host, and a new sibling pins the guard itself -- a mismatch for another
  host must leave the session alone. Verified by defeating the guard: only the
  new test goes red, which is why the old one never noticed the change.

Defects found verifying the ledger's open items:

- logging out fired delete_credential fire-and-forget and then navigated to the
  connect page, whose auto-login immediately read the same account back. Since
  B4-3 moved the credential commands to #[tauri::command(async)] they no longer
  serialize on the IPC thread, so a read that wins that race signs the user
  straight back into the server they just left. Two fixes, because the race and
  the intent are separate problems: a CREDENTIAL_LOCK mutex restores the
  one-operation-at-a-time property that also keeps secret_store::set's
  read-modify-write atomic, and the connect page now skips auto-login once
  after a logout that removed the credential -- mirroring the quick-switch
  sessionStorage idiom already in that file. A server_shutdown logout keeps its
  credential and deliberately does not set the flag, so restart auto-login
  still works. e2e-pinned: with the suppression defeated, the user is visibly
  back in the app after clicking Log Out.

- a post-resync history refetch that REJECTED left the active channel's window
  already invalidated but never marked errored, so MessageList fell into its
  "no messages yet" welcome branch -- rendering a failed reload as a genuinely
  empty channel, with no Retry, until the user navigated away and back. Now
  calls setChannelLoadError, reusing MessageController's existing plumbing.

- an invite deep link arriving during the connected overlay's 800ms ready
  countdown hit a gate that assumed isAuthenticated implies the router is on
  "main". It is not: clearAuth() ran without the teardown that only the
  authStore subscriber performs (and only while on "main"), so the overlay's
  timer then mounted MainPage over a nulled-out auth state, and the invite was
  dropped. Gated on the real invariant and the in-flight session is now torn
  down explicitly.

- channel mutes carried the same dead legacy-preference fallback that per-user
  volumes had -- guarded on currentHost === null, which MainPage never leaves
  it as -- so every saved mute was silently discarded on upgrade. Mutes are a
  list, where an empty saved value is real data, so this needed a presence
  probe rather than the volume fix's sentinel.

Also extends the e2e Tauri mock with storedSettings/storedCredential seeds so
auto-login paths are exercisable at all.

Verified clean: 4800 vitest, 293 Playwright, 97 cargo, tsc, tsc -p e2e, eslint,
prettier, clippy -D warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): stop Tab escaping every modal, and a duplicate row after a resync

Two bugs left open by the previous round.

The "flaky" a11y focus-trap test was not flaky -- it was a real accessibility
defect surfacing nondeterministically. FOCUSABLE_SELECTOR is structural and
says nothing about visibility, but this codebase hides controls with inline
`style.display = "none"` (MemberPickerModal's group-name field and confirm
button both start hidden). So focusDialog() picked a display:none input as the
dialog's first focusable and called .focus() on it -- which browsers silently
refuse -- and focus never entered the dialog at all. trapFocus() then computed
first/last as those same hidden elements, so neither Tab branch ever matched
document.activeElement, preventDefault() never fired, and Tab fell through to
the browser's native order and walked straight out of the dialog. Whether the
test noticed depended on how much async sidebar content happened to be
focusable at that moment, which is what made it look intermittent.

Fixed in the shared helper rather than in the one modal that exposed it: about
forty call sites hide controls the same way, so every factory modal had the
same hole. trapFocus and focusDialog now filter out inline-hidden elements.
Reproduced first at 3/10 failures under --repeat-each; 10/10 after, and 20/20
at --workers=4. Note the check reads inline styles only -- an element hidden by
a CSS class would still slip through, which no current call site does.

Second: a message the server persisted but whose chat_send_ok ack was lost to
the same disconnect that forced a resync was displayed twice. The optimistic
row keeps id 0 until confirmSend stamps it, so setMessages' id-based carry-over
could never collide it with the real row, while addMessage had solved exactly
this for the live path by matching on content. Extracted that predicate as
isUnreconciledEcho and used it in both, so the two cannot drift apart.

The dangerous direction here is over-merging, not under-merging: collapsing two
genuinely distinct sends of the same text loses a real message. Three things
bound it -- only rows still awaiting reconciliation qualify (pending, or failed
for OFFLINE specifically, since a SLOW_MODE rejection is never broadcast and
eating that row would kill a live retry draft), author and content must both
match, and each snapshot row is consumed at most once, so N identical pending
sends pair off against N identical real rows instead of collapsing onto one.
Both directions are tested.

Verified: 4804 vitest, 293 Playwright with zero flaky, tsc, tsc -p e2e, eslint,
prettier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 17:56:43 +02:00
J3vbandClaude Opus 5 4ff199e14f fix: resolve 107 verified defects across ws hub, voice/E2EE, db, and client (#1331)
* fix(client): style the user profile popup

The popup rendered unstyled: it appeared at the bottom of the page and
pushed the rest of the app up, with the avatar drawn as a full-width bar.

app.css carried a complete Discord-shaped card under `.user-popup` /
`.up-*`, but nothing in the codebase renders those classes — the
component emits `.upp-*`. The component had been rewritten with a new
prefix and the stylesheet was left pointing at a DOM that no longer
existed. With no rule matching, the card stayed `position: static`, so
the left/top it computes were discarded and both it and its overlay laid
out as ordinary blocks at the end of <body>.

Replace the orphaned block with rules for the classes actually rendered,
following the same anatomy: banner strip, avatar straddling the
banner/body seam inside a ring punched from the card background, panel
sections, action row. Everything routes through existing tokens, so the
card follows the theme contract.

Two latent bugs fixed while there:

  - Placement guessed a 300px card height and clamped only the top edge,
    so a member clicked low in the list opened a card that ran off the
    bottom of the window. Measure the card and clamp both edges.
  - The avatar has to hang off the body's top edge, but the body scrolls,
    and `overflow-y: auto` clips horizontally too. Make it a child of the
    card rather than the body.

The fade+scale moves from inline styles into CSS so a
`prefers-reduced-motion` override can drop it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): stop vite watching src-tauri

`npm run tauri dev` died on Windows partway through the cargo build:

    Error: EBUSY: resource busy or locked, watch
    'src-tauri\target\debug\deps\owncord_client_lib.dll'
    Error The "beforeDevCommand" terminated with a non-zero status code.

Vite's watcher recursed into `src-tauri/target/`, and the moment cargo
wrote the output DLL, node's FSWatcher raised EBUSY as an unhandled
error event and killed the vite process. Vite is tauri's
`beforeDevCommand`, so its death aborted the whole dev session.

The config matched the upstream Tauri vite template in every respect
except the `server.watch.ignored` block that template ships with. Add
it. Tauri already watches `src-tauri` itself for rebuilds, so nothing
is lost.

Windows-specific — EBUSY on an open handle is a Windows filesystem
semantic, and CI only ever runs `tauri build`, never `tauri dev`, so
neither caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(security): add 2026-08-04 whole-codebase security review (#1326)

Read-only security review of the full tree (Go server, admin panel, WASM
plugin host, LiveKit voice, Tauri client). No code changes.

Three findings, all the same defect class — a security predicate enforced
at some members of a handler family but not all:

- A-2026-08-01 (HIGH) handleDeleteChannelPermission omits the hierarchy and
  grantability guards its PUT twin carries, so a MANAGE_CHANNELS holder can
  clear their own role's channel deny and read private channels.
- A-2026-08-02 (HIGH) the admin channel list/patch/delete handlers omit the
  type == "dm" guard their sibling getPermChannel carries, so the same role
  can enumerate and irreversibly cascade-delete arbitrary DMs and group DMs.
- A-2026-08-03 (MEDIUM) DMService.RingTargets omits the block check the five
  other DM interaction sinks perform, so a blocked user can ring the person
  who blocked them.

Also records one non-vulnerability observation (backup restore writes to a
hardcoded database path, silently no-opping when database.path is
customised), the candidates rejected during verification, the areas verified
clean, and the areas not examined.


Claude-Session: https://claude.ai/code/session_01Q7GUJtdsHHHGs4pSiLn6LJ

Co-authored-by: Claude <noreply@anthropic.com>

* Full audit: docs/spec refresh + remediation (security fixes, dead-code removal, test & CI gaps) (#1327)

* docs: fix server reference docs (api, protocol, server-configuration, deployment)

api.md:
- Correct the login rate limit: 5/min per IP (was documented as 60/min);
  document the per-username lockout and lockout persistence
  (Server/api/constants.go, Server/api/auth_handler.go)
- Complete the middleware list to the real 9-entry chain incl. the
  opt-in Coraza WAF (Server/api/router.go)
- Add voice_sessions and broadcast_drops to the metrics sample
  (Server/api/metrics_handler.go) and document the otel-only
  Prometheus /metrics mount
- Add reference sections for the previously undocumented /admin/api
  endpoints: setup, stats, users, audit-log, settings, tokens,
  backups, updates, and the SSE log stream (Server/admin/api.go)

protocol.md:
- Fix type counts (client->server 26, server->client 37) and add the
  missing rows: call_ring, call_decline, emoji_update, call_incoming,
  call_declined
- Correct rate limits: voice join/leave 5/1s (was "None"), E2EE offer
  64/1s (was 5/1s), and add the call-ring limit (1/3s)
- Document the plugin command wire types (chat_command, command_reply,
  plugin_broadcast) and flag that they sit outside protocol-schema.json

server-configuration.md:
- Add missing keys: server.waf_* (3), database.type,
  telemetry.otlp_insecure, and the whole logging section +
  OWNCORD_LOGGING_LEVEL
- Correct plugin-disabled status code to 503 (was 501)

deployment.md:
- Drop the removed "version" field from the /health sample; add
  broadcast_drops to the metrics sample; note the distroless non-root
  image; refresh build version strings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(architecture): rewrite stale architecture pages against 5630aa1

All six pages carried "Verified against ddc49f0 (2026-07-19)" stamps and had
drifted:

- websocket.md: delete the false claim that docs/protocol-schema.json does
  not exist — it is the codegen source of truth (Server/scripts/genprotocol,
  CI-gated by make protocol-verify); note the hand-declared plugin command
  family as the one exception; refresh LOC
- server.md: fix the websocket dependency (github.com/coder/websocket, not
  nhooyr.io), refresh LOC (42k/71k), migrations 016 -> 028
- data-model.md: migrations 001-028, 23 -> 26 tables, add api_tokens and
  channel_user_overrides to the ER diagram, channels.type now includes
  announcement, note 017/024/027/028 columns; drop the claim that schema.md
  is 6 migrations behind (it is current)
- voice-e2ee.md: drop the stale claim that the E2EE flow is absent from
  protocol.md (it has a full section); document livekitE2EE.ts/identity.ts
  and identity-key pinning
- client.md: rewrite — Solid beachhead is gone; the HTTP path is now
  TOFU-pinned through http_proxy.rs (the doc claimed the opposite); shared
  tofu.rs core with explicit-consent pinning; 9 stores (roles store deleted,
  blocks + emoji added); refreshed LOC and tooling figures
- README.md: 26 tables/001-028; client-architecture.md described as the
  redirect stub it is; companion-audit links refreshed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(architecture/ux): align UI flow specs with current client behavior

- Cert first-use is a blocking trust modal on status "first_use" — the Rust
  proxy rejects the first connection until the user confirms (main.ts:146-176,
  tofu.rs); the specs described the pre-F4/F8 behavior (an 8s banner on
  "trusted_first_use"). Fixed in README.md and connection-and-auth.md.
- Dispatcher event table: add the five missing types (chat_bulk_deleted,
  roles_update, emoji_update, voice_moved, voice_disconnected) and note that
  call_incoming/call_declined are page-scoped listeners in MainPage.ts.
- channels-members-dms.md: the "no in-client block button" gap is closed
  (AdminActions.ts context-menu item -> SidebarMemberSection.ts:177-186);
  document group DMs (MemberPickerModal, 10-participant cap, rename/leave)
  and per-channel notification mutes (lib/channel-mutes.ts); refresh stale
  line anchors.
- voice-and-e2ee.md: document the actual E2EE verification surface (roster
  shield badge -> identity-mismatch modal -> rePinPeerIdentity with TOCTOU-
  safe key capture), noise suppression + fallback, device hot-swap, stream
  preview, and DM ring/incoming-call flow; drop the nonexistent
  VoiceChannel.ts reference.
- settings-and-admin.md: the "ban should collect a reason" gap is closed
  (appendBanFlow with reason + duration); document the admin-panel deep-link
  (lib/admin-panel.ts) and the tray status menu.
- messaging.md: correct the pinned-messages empty-state copy and drop the
  nonexistent components/message-input/ directory reference.
- Re-stamp all six specs "Verified against 5630aa1 (2026-08-04)".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs: refresh client-facing and top-level docs

- security.md: drop the stale "hardcoded Tenor API key" limitation (the GIF
  provider is Klipy, proxied server-side with an operator-supplied key —
  nothing ships in the client bundle); describe credential storage accurately
  (OS keyring primary, verified writes, DPAPI/ChaCha20 file fallback);
  complete the audit-log action list against the actual WriteAudit call sites
  and note that backup restore is not audit-logged; fix the firewall
  checklist to include the LiveKit media ports (7880-7881/TCP,
  50000-60000/UDP) and ACME port 80
- credential-storage.md: probe_credential_store sample now shows the real
  serialized backend value ("Keyring") and the full variant union
- quick-start.md + README.md: refresh build version strings to
  1.2.0-alpha.1; README "audits" section now points at the current audit
  documents
- contributing.md: sqlc rows no longer claim a PostgreSQL engine/pgdbgen
  (removed with the store layer); add the protocol-generate/verify targets

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(plans): add verified status headers and fix stale references

Every plan under docs/plans/ now carries a dated status verified against
5630aa1:

- Shipped: audit-2026-07-19-decisions (all 13 rows), channel-visibility-
  unification, http-tofu-proxy, permission-middleware-consolidation (the
  disclosed ws.channelCanSend copy is still open, now at serve_ready.go:119),
  security-hardening-remediation, sqlc-adoption, v2-dispatch-migration,
  tauri-capability-narrowing (DNS-rebinding follow-up still open)
- Shipped with corrections: discord-parity — Phase 1's gap table was never
  re-marked; all six rows have since shipped, including archived channels,
  which are filtered by permissions.VisibleChannelIDs (checker.go:116-121);
  named leftovers (role hoist/mentionable, @RoleName mentions, categories as
  entities, dead-code list) stay open. security-scan-2026-07-22 — all 8
  findings closed; two of the four F3 follow-ups have since shipped (safety
  number rendered in the roster badge; rePinPeerIdentity wired to the
  identity-mismatch modal), getIdentityPin fail-open remains open; noted the
  scan artifact directory is not in the repo
- Design-only: slash-commands — added staleness notes (migration number 016
  now taken, Server/store/ deleted, src/state/ never existed)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(audits): reconcile prior audit statuses with current CI and code

- audit-test-coverage-2026-07-25: T-2026-07-25-21 (HIGH, 229/255 web e2e
  failing) was fixed by the mock repair but the audit was never updated —
  now RESOLVED, re-verified by a local 270/270 run at 5630aa1; the CI gate
  table row updated to match
- audit-2026-07-19: carried-over item 11 ("no Playwright job in ci.yml") is
  resolved — client-e2e (non-blocking, every PR) and the blocking
  client-e2e-parity job both exist; backlog item 10 marked DONE
  (client-tests is blocking, Playwright wired)

Only status/closure cells were edited; original finding text is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(changelog): add Unreleased section for post-v1.2.0-alpha.1 fixes

Three fixes landed after the release with no changelog home (the file had no
Unreleased section at all): the profile-popup styling fix (a308f81), the
vite/src-tauri watch fix (cdcfc03), and the AppImage env-key signing fix
(9d75890). Also corrects the Deferred-work note that still described the
Solid.js removal in the present progressive — it completed 2026-07-19.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(tests): rewrite the e2e issues log against a real suite run

The old file was dated 2026-03-18, claimed 209/209 passing (the suite is now
270 tests), and pointed at a plan document that does not exist in the repo.
Rewritten from an actual run at 5630aa1: 270/270 web tests green (8.6 min),
15/15 @parity subset green (the blocking CI job), with the suite inventory,
CI wiring, the two real open issues (three native specs matched by no
playwright.config.native.ts project; client-e2e still non-blocking), and
dispositions for every claim the old file carried.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(audit): add 2026-08-04 docs-and-coverage audit report

Companion to the same-day security review (disjoint scope). Contains: the
verified architecture summary; real test-run results for every runnable
suite at 5630aa1 (Go race+deadlock, 4394 client unit tests at 94.66% stmt
coverage, 83 Rust tests + clippy, 270/270 web e2e, 15/15 parity, browser
smoke — with env-blocked suites named and their compensating CI evidence
cited); a 52-row UI/UX flow coverage matrix (30 covered / 21 partial /
1 untested / 0 broken, headline gaps: TOFU flow, E2EE verification, admin
panel, updater — all unit-only); per-doc drift findings with the commit that
fixed each; reconciliation of all four prior audits and eleven plans
(including the orphaned 2026-04-07 #8 resurfaced as DC-11); the dead-code
and TODO inventories; and a prioritized DC-01..DC-15 gap list with ordered
next steps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(admin): add hierarchy guard to channel role-override delete (A-2026-08-01)

Deleting an override is a permission mutation: removing a deny row restores
exactly the access the PUT path refuses to grant, so a MANAGE_CHANNELS holder
could unlock a private channel their own role was locked out of. Gate DELETE
identically to handlePutChannelPermission: resolve the role (404 when
missing), fail closed without an actor role, and refuse targets at or above
the actor's position.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(admin): exclude DM channels from the admin channel surface (A-2026-08-02)

DMs and group DMs share the channels table and id space with guild channels,
but they belong to their participants, not to MANAGE_CHANNELS holders:
listing exposed ids and group names of every private conversation, PATCH
could silently rename one, and DELETE cascade-destroyed one irreversibly.
List now filters type=dm; PATCH and DELETE resolve through getAdminChannel,
which answers 404 for DM ids so the surface does not confirm which ids are
private conversations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(service): enforce blocks on DM call rings (A-2026-08-03)

RingTargets checked participation but not blocks, so a blocked user could
still make the blocker's client ring. Route rings through
requireDMNotBlocked like every other DM sink; group DMs stay exempt inside
it, matching the send path (blocks are enforced at group creation instead).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* chore(client): delete dead modules (DC audit remediation)

All verified unreferenced by any import before deletion:
- ServerStrip.ts: removed from the layout when the unified sidebar header
  landed (SidebarArea.ts); only its own orphaned unit test still used it.
  The e2e spec already asserted .unified-sidebar-header, so it is renamed
  to sidebar-header.spec.ts and retitled honestly.
- FileUpload.ts: uploads go through api.uploadFile from MessageInput.
- lib/reconcile.ts: nothing imports it; the messages store carries its own
  pending-send reconciliation.
- public/rnnoise-worklet.ts: unreferenced duplicate of the .js worklet the
  runtime actually loads, and public/ ships verbatim into the bundle.
- api.getSounds/deleteSound + SoundResponse: the server has no /sounds
  routes; these called endpoints that do not exist (pairs with the
  sounds-table drop on the server side).
- dm.store incrementDmMention: zero callers; DM mention counts flow from
  the server mention_count via the dispatcher. This was the one live knip
  error the CI '|| true' was masking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* chore(client): retire the tauri-typegen ritual (DC-05)

src/generated/ was tauri-typegen output frozen on 2026-04-03: it covered 21
of the 29 IPC commands lib.rs registers, nothing ever imported it (0%
coverage), and CI carried a bespoke patch step solely to keep the unused
file lint-clean. Delete the directory and every part of the pipeline that
existed to feed it: the client-check patch step, the tauri-build
generate/patch steps, the tauri.conf.json plugin block, and the inert
Cargo.toml build-dependency (build.rs is bare tauri_build::build(); no Rust
source references the crate). Cargo.lock shrinks by exactly the typegen
subtree — no other resolution changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* ci: make knip blocking (DC-06 follow-through)

Pre-verified green locally after the dead-module deletions; the config
hints knip still prints do not affect its exit code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* chore(server): drop the dead sounds table (A-2026-07-13)

The table shipped in 001 for a soundboard that was never built: no query,
model, sqlc definition, route or handler ever referenced it. Migration 029
drops it; the sqlc model regenerates without the Sound struct (sqlc emits a
struct per schema table even with zero queries). schema.md, the data-model
blueprint, and the 2026-07-19 audit closure table are updated in the same
change per the docs maintenance rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* chore(server): remove dead WAF wrapper, use protocol constants, fix stale comments

- NewWAFMiddleware had no production caller (the router mounts the CRS
  variant); its doc text folds into NewWAFMiddlewareCRS and the tests call
  the survivor directly.
- serve_auth compares against MsgTypeAuth and the DM-close REST path builds
  its WS notification from MsgTypeDMChannelClose instead of restating the
  wire strings, so the generated constants are load-bearing again.
- Comment fixes: DatabaseConfig no longer claims Postgres scaffolding that
  main.go removed; host_ui.go no longer advertises a route that is not
  mounted (DC-09's sibling); buildReady cites docs/protocol.md, the file
  PROTOCOL.md was renamed to (DC-09).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(protocol): add the plugin command family to protocol-schema.json (DC-01)

chat_command, command_reply and plugin_broadcast were the only wire types
outside the schema: the first declared by hand in handlers_command.go, the
other two raw string literals, all bypassing the protocol-verify codegen
gate. Add the three schema entries (27 c2s / 39 s2c), regenerate both
constant files, and swap the hand-rolled declarations for the generated
constants. The ws protocol-contract test's exception list is empty now —
and stays that way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* test(client): wire orphaned native specs and typecheck the Playwright layer

- dm-system, reconnection and theme-persistence (14 tests) matched no
  project's testMatch in playwright.config.native.ts, so they had never
  executed (E2E-ISSUES open issue #1 / DC-03). All three use the persistent
  fixture + ensureLoggedIn, so they join native-authenticated.
- tests/e2e was excluded from tsconfig, leaving 47 spec files with no
  typechecking anywhere. New tsconfig.e2e.json project (+@types/node for
  the node-API fixtures), a typecheck:e2e script, and a CI step. The one
  real error it surfaced is fixed: mockTotpFailure omitted the required
  simulateWsFlow flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* test(client): cover createPromptModal and the external-abort close path

modalFactory.ts was the least-covered file in the repo (57.6%):
createPromptModal had no tests at all and createModal's external-abort
branch never ran with an onClose. Now 100% statements/branches/functions,
including the trimmed-submit, legitimate-empty-submit, Enter-preventDefault
and no-double-close contracts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* test(client): e2e-cover the TOFU certificate ceremony (DC-04 slice)

The first-use confirmation and mismatch warning are the client's core
security ceremony and had no e2e coverage. Six tests drive them through
the mocked Tauri event layer: first-use modal content, trust, cancel,
modal non-stacking, mismatch fingerprint rows, and disconnect-to-connect-
page. The mock now exposes its listener registry so tests can wait for
the async cert-tofu registration instead of racing it (validated with
--repeat-each=3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs: fix the inaccuracies the 2026-08-04 refresh missed

- contributing.md: drop the '-tags postgres' build row (no such tag exists
  anywhere in Server/), add the four Make targets the 07-25 audit created
  (test/test-deadlock/cover/cover-all), align the coverage statement with
  the real gates (client 70%, no Go floor by T-2026-07-25-19), point TS
  style at architecture/client.md instead of the tombstone, and describe
  the real dev-branch PR flow.
- docs/security.md: reporting section now defers to root SECURITY.md as
  the canonical policy (it said 48h where SECURITY.md promises 7 days, and
  described the maintainer's advisory path rather than the reporter's);
  fixed the updater-key link that resolved to docs/Server/... on GitHub.
- audit-2026-04-07.md closure table: #10 and #11 were long-resolved (#10
  verified in db/audit.go, #11 exceeded by per-PR e2e jobs), #6 written in
  future tense for work done 2026-07-19, #7 citing a 113-file count from
  months ago.
- README: Contributing section matched neither ci.yml nor contributing.md
  (branch from dev, not main); Docs Index gains the six missing live docs;
  the plugin system joins the feature list; the security row no longer
  anchors to an aging version string.
- server-configuration.md: the env-var table is explicitly a subset — the
  OWNCORD_<SECTION>_<KEY> scheme covers every key.
- mcp-introspect.md: index.mjs is 266 lines, not ~230.
- types.ts header cited PROTOCOL.md/API.md/SCHEMA.md, filenames that no
  longer exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* ci: pin claude.yml actions by SHA; add docs checkbox to the PR template

claude.yml was the only workflow with unpinned third-party actions —
checkout now uses the same v4.2.2 SHA the other workflows pin, and
claude-code-action pins the commit the v1 tag resolves to (Dependabot's
github-actions ecosystem keeps both fresh).

The PR template gains the docs checkbox A-2026-07-03 recommended: the
architecture/UX maintenance rule ('a PR changing a diagram's source-of-
truth files updates the diagram in the same PR') existed only as prose no
process step ever surfaced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* style(client): prettier-format the cert-tofu spec

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(audit): record the remediation pass and close finding statuses

- Security review: A-2026-08-01/02/03 -> RESOLVED with their pinning tests
  named.
- Docs-and-coverage audit: DC statuses updated in place (01/02/03/05
  resolved, 04/09 partial with the remainder named, 14's keep-decision
  recorded) and a remediation addendum added: what shipped, the decisions
  taken (plugin host API kept, reserved protocol entries kept, e2e soak not
  shortcut, the 404-on-missing-role semantics note), and the full
  verification table from real runs — Go race + deadlock suites green,
  4 tag builds, client 4360/4360 units at 95.35% coverage, Playwright
  276/276 in 8.9 min, parity 15/15.
- CHANGELOG Unreleased: security fixes, migration 029, protocol additions,
  dead-code retirement, CI gates.
- E2E-ISSUES: rewritten against the remediation HEAD (276/276), native
  orphan issue moved to resolved, mock listener-registry note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(admin): index the channel slice in the DM filter (gocritic rangeValCopy)

golangci-lint (CI-only gate) flags the range-value copy of the 152-byte
db.Channel struct in the admin list filter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* chore(client): add .nvmrc pinning Node 20 to match CI (DC-10)

Also re-triggers CI: the previous run's windows server job died to a Go
runtime unwinder fatal ('traceback did not unwind completely') with no
test failure — toolchain flake, and the integration lacks permission to
rerun failed jobs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix: 26 bug-hunt findings across WS hub, voice/E2EE, admin, roles, and client (runs 1-3) (#1328)

* fix(ws): keep pubsub subscriptions when a replaced client is unsubscribed

Both pubsub indexes are keyed by userID, but a reconnect registers a new
*Client under that same userID. UnsubscribeAll and Unsubscribe deleted by
userID alone, so a kick of the already-replaced connection stripped the live
one's topics. The live client stays in h.clients and keeps answering
ping/pong, so it never reconnects -- it just silently stops receiving every
global, user, and channel broadcast.

Guard the forward-index delete in unsubscribeLocked with an identity check and
route UnsubscribeAll through it, so the four Unsubscribe call sites
(voice_leave, hub_broadcast x2, handlers) and the three UnsubscribeAll ones
(kickClient, unregisterNow, registerNow) all share one rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): mark kicked clients offline instead of reporting them replaced

Every kick path deletes the hub entry via kickClient, so the readPump defer's
unregisterNow finds nothing and fell through to "return true", conflating
absent with replaced. serve_pumps.go then skipped MarkUserDisconnected, the
offline presence broadcast, and handleVoiceLeave -- already-connected peers
rendered every kicked user as online until that user reconnected and
disconnected cleanly.

Return exists instead: a different client in the slot is a genuine
replacement, an absent entry is a real disconnect. Only serve_pumps.go reads
the return value; the five serve.go/hub.go call sites discard it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): force a full ready when cold-tier replay hits the row cap

GetEventsSinceForChannels is "ORDER BY seq ASC LIMIT n", so a reconnect gap
larger than maxColdReplay returned the oldest 5000 rows and dropped the
newest. handleReconnect accepted any non-empty result as a successful resume,
and the client only tracks max(seq) with no gap detection -- so it accepted
the next live event and silently lost the range in between, including state
events (channel/role/member changes) that REST history fetches never repair.

Treat a result at the cap as overflow and fall through to the full ready
re-sync. An exactly-cap-remaining gap pays one unnecessary full ready.

maxColdReplay is hoisted to the package const block so the test can seed
exactly enough events to hit it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): re-elect the voice E2EE key holder on the two paths that skipped it

updateKeyHolder had only two callers (voice_join, voice_leave), so two paths
that remove a participant from voice left voiceKeyHolders naming someone who
is gone. IsVoiceKeyHolder then rejects the real lowest-uid participant's rekey
offers with NOT_KEY_HOLDER -- which the client does not handle -- after it has
already applied its rotated key locally, splitting keys across the room.

1. The LiveKit participant_left webhook (media-only loss, WS stays up) cleared
   voice state and broadcast voice_leave with no re-election.
2. registerNow's fresh-connect replacement (F5 reload) drops the old
   connection's voice state without transferring it. handleVoiceLeave never
   runs there: readPump skips it when replaced, and it early-returns on
   already-cleared state.

Both call updateKeyHolder outside h.mu, since it takes keyHolderMu then
h.mu.RLock. The recompute reads live client voice state, so it is idempotent
and stays correct when a network reconnect transfers voice state -- locked by
TestRegisterNow_KeepsKeyHolderWhenVoiceStateTransfers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): stand down as E2EE key holder on offer, keep peer keys on reconnect

Two independent key-holder desync bugs in E2EEManager:

1. _isKeyHolder had no demotion path -- set at join, promoted on participant
   leave, cleared only on voice leave. The server re-elects the lowest userID
   on every join, so a lower-ID joiner left the incumbent still believing it
   held the key with an armed 5-minute timer. Its rotations applied the new
   key locally before the server rejected the offers with NOT_KEY_HOLDER (which
   the client does not handle), so it went deaf and mute every rotation cycle.
   Accepting an offer proves the sender is the server-authoritative holder, so
   treat it as the demotion signal and clear the timer.

2. reannounceForReconnect cleared _peerPublicKeys and peer verifications with
   nothing able to refill them: handleAnnounce replies with an offer rather
   than a counter-announce, and the server relays stored peer keys only on
   voice_join, which an SFU-level reconnect never runs. handleOffer's
   unknown-peer guard then dropped every later rotation, stranding the
   reconnector on the pre-reconnect key. The clear was also unnecessary --
   peers' keys stay valid when we regenerate our own pair.

vitest 4396/4396; typecheck and prettier clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tauri): give the ws sender slot connection ownership, bound livekit TLS

ws_proxy: state.tx is one slot shared by every ws_connect, and both the
install and the teardown clear were unconditional while the mutex was only
held in short scoped blocks. A handshake pends up to CONNECT_TIMEOUT and a
profile switch starts a second ws_connect without awaiting or cancelling the
first, so a stale connect could complete after a newer one was live, emit an
untagged "open", and install its sender over the live one -- routing the next
auth send to the previously-trusted host, then tearing down the live socket
and emitting "closed" while JS believed it was connected.

Add a generation counter claimed at ws_connect entry and checked under the
slot lock before install, plus same_channel ownership on the teardown clear,
mirroring the Arc::ptr_eq guard ptt.rs already uses for ATOMICRACE-001.

livekit_proxy: the outbound TcpStream::connect and TLS handshake were bare
awaits, while the sibling http_proxy.rs bounds both at 10s. TCP connect is
OS-bounded, but a peer that accepts TCP and never answers the ClientHello
blocked the task forever. The task holds `local` without polling it, so the
SDK closing its side never cancels it, and the detached per-connection tasks
survive stop_livekit_proxy -- so they leaked on every SDK retry.

cargo test 80 passed; clippy --all-targets -D warnings clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): keep voice E2EE alive across WS resume and unify voice teardown

A network reconnect transferred voiceChID/joinToken to the new connection
but left it unsubscribed from voice:<id> (the only transport for
voice_e2ee_announce relays) and wiped the announced ECDH key, so a
resumed key holder could never offer the room key to later joiners and
voice_join replayed nothing for the resumed user. registerNow now
transfers the announced key with the voice state and re-subscribes
VoiceTopic unconditionally (it is CONNECT_VOICE-gated at join; only the
message-stream ChannelTopic needs the READ gate).

The LiveKit participant_left webhook and CleanupVoiceForChannel cleared
voice state without dropping the voice-topic subscription, leaving the
socket receiving another room's announces (which carry no channel_id to
filter on) for its lifetime. All take-out-of-voice paths now go through
one clearVoiceAndUnsubscribe helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): route sequenced DMs through the normal FIFO

writePump drains sendHigh to exhaustion before send, so a seq-stamped DM
on the high queue reached the socket ahead of lower-seq events still
queued behind a slow write. The client acks max(seq) and replay is
strictly seq > last_seq, so a disconnect in that window silently and
permanently lost the overtaken events while auth_ok reported a clean
resume. Sequenced frames now share the one per-client FIFO; the high
queue remains for unsequenced targeted messages (DM opens, voice tokens).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(storage): remove the partial file when Save fails after create

The io.Copy and f.Sync error paths returned without deleting the file
created for the upload, and the orphan sweep is DB-row-driven, so a
write-side failure (ENOSPC, disk I/O error) permanently leaked a partial
storage/<uuid> with no DB row. One success-flag deferred cleanup now
covers every failure path (the oversize branch folds into it), fixing
all three callers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): close three voice-E2EE ordering gaps and wire the DM mention badge

- setupKeyExchange generated the room key AFTER draining queued
  announces, so a key holder joining an ongoing call sent drained peers
  no offer — they waited on the 5-minute rotation timer. Keygen now
  precedes the drain and every drained peer gets its offer immediately.
- A key-holder re-election arriving while the elected client was still
  connecting was dropped (getCurrentChannelId is null for the whole
  key-exchange wait), stranding the client until timeout ejection. The
  manager now remembers its channel from setupKeyExchange, and the
  become-holder rotation resolves a pending room-key wait.
- Offers applied concurrently could finish out of order (no epoch on the
  receiver side), leaving the older key active. handleOffer now chains
  applications so offers apply strictly in WS delivery order.
- incrementDmMention had zero callers: the DM @mention badge (dmStore's
  mentionCount, the mute-immune signal DmSidebar renders) never fired
  live, only after a reconnect restored the server count. The dispatcher
  now bumps it under the same guards as the DM unread count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tauri): restart the LiveKit proxy when the TOFU pin changes

After the user accepted a rotated cert, two stale caches kept every
voice rejoin tunneling into the old pin until logout: the Rust reuse
branch returned the running listener (which bakes its fingerprint in at
spawn) without re-reading certs.json, and ensureLiveKitProxy's port
cache never invoked Rust again at all. start_livekit_proxy now loads the
stored fingerprint before the reuse check and tears down on host OR pin
change, and the TS side invokes it on every join — the reuse branch
dedups the unchanged case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(tauri): ignore two unreachable cargo-audit advisories

RUSTSEC-2024-0429 (glib 0.18, Linux-only, Variant::array_iter_str never
called; no semver-compatible fix exists) and RUSTSEC-2026-0097 (rand 0.7
as a phf_generator build-dep with a fixed seed and no log feature; the
pre-release kuchikiki pin blocks the upgrade path). Both entries document
their drop condition inline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): transfer the focused channel on WS resume so the message stream survives

registerNow's replaced-client branch moved voice state and the E2EE key to
the resumed connection but not the focused channel; newClient always starts
with channelID == 0 and the client never re-sends channel_focus on a resume,
so the ChannelTopic re-subscribe was a no-op and the user silently stopped
receiving chat_message until manually switching channels. Transfer the old
connection's focused channel, READ-gated and fail-closed like every other
ChannelTopic subscription.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): always include a voice room's participants in its voice-event audience

broadcastVoiceEvent filtered recipients on READ_MESSAGES while voice
membership is gated on CONNECT_VOICE alone, so a participant in the gap
(e.g. READ revoked mid-call by a channel override) never received the
room's voice_state/voice_leave. The client's E2EE key-holder election and
forward-secrecy rotation run only off the voice_leave WS event, so a
departing key holder was never replaced and new joiners hung until the
e2ee_timeout eject. Union the READ audience with the room's current
participants; what outsiders may observe is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): tear down the failed reconnect attempt's room instead of leaking it

The catch block read this._room, whose typed accessor returns null in the
"reconnecting" state — so the failed attempt's freshly created Room was
never disconnected and kept all its listeners. livekit-client emits
Disconnected synchronously on a failed connect, and in "reconnecting"
state the token/channel/url getters all return values, so each leaked room
spawned an additional concurrent reconnect loop whose AbortController was
discarded and unreachable from leaveVoice. Alias the attempt's room outside
the try and clean it up in the catch, mirroring cleanupAbortedReconnect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(admin): evict voice participants before deleting a channel

CleanupVoiceForChannel was doc-commented 'Called when a channel is
deleted' but had zero production callers, and the voice_states FK cascade
wipes the rows it reads — so deleting a voice channel stranded its
participants with live client voice state, a voice-topic subscription, and
a LiveKit session, and the stale sweeper could never recover them (a
nonexistent channel resolves base-role permission bits). Wire the cleanup
into handleDeleteChannel BEFORE the row delete, via HubBroadcaster.

Also harden the cleanup itself: the row delete and client-state clear are
now conditional on the participant still being in the deleted channel, so
a user who moved rooms mid-cleanup is untouched, and the evicted
participants are always included in their own voice_leave audience (their
client state is already cleared, so the participant union in
broadcastVoiceEvent cannot see them).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): stop leaking an E2EE worker and SetKey listener per voice join

createRoom spun up a fresh E2EE Worker per Room while the key provider
lives for the whole process; livekit's per-room E2EEManager registers a
SetKey listener on the provider with no matching removal and never
terminates the worker. Every join, channel switch, or failed reconnect
attempt therefore permanently added one running worker plus one listener,
and every later setKey posted the new room key into every orphaned worker
— key material outliving its session. Track the worker on the session:
clear provider listeners and terminate the stale worker before each Room,
and terminate it in leaveVoice so the last key does not stay resident.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(service): reject role position collisions on update, matching create

UpdateRole's position branch ran only validatePosition and let an explicit
position land on a slot another role holds — while CreateRole refuses
exactly that, with a comment explaining why: every hierarchy comparison
uses >=/<=, so tied positions read as equal rank and the two roles can no
longer manage each other's members. Refuse a position held by a different
role with the same ErrBadRequest; re-stating the role's own position stays
allowed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): close the window where a dying connection re-takes a pubsub topic

Subscribe had no counterpart to unsubscribeLocked's identity guard: an old
connection's in-flight handler (a channel_focus mid DB round-trip shares no
lock with registerNow) could Subscribe after UnsubscribeAll(old) had run,
stealing the topic from its replacement — whose own unsubscribes then skip
the entry while publishes go to the closed connection. Subscribe now
refuses a client whose send is closed (checked under ps.mu), and
registerNow closes the old client's send BEFORE stripping it, so a late
Subscribe either sees the closed send and is refused or slipped in earlier
and is removed by the subsequent UnsubscribeAll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(ws): rewrite cold-replay if-else chain as switch (gocritic)

Fixes the ifElseChain lint failure on CI for both platforms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): restore incrementDmMention deleted as dead code on dev

The audit PR (#1327) removed it from dm.store.ts because its only caller
lives on this branch (the DM mention badge wiring), which was not merged
yet. The rebase was textually clean but left dispatcher.ts calling a
function that no longer existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Audit closure pass: DC-04/06/08/09/12/13/15 (E2EE + updater e2e, fail-closed pin lookup, a11y pass, UX polish) (#1329)

* ci(server): run the tag-gated wazero/otel Go tests (DC-06)

The build-tag matrix only compiled the otel/wazero variants; the tests
behind those tags (plugin/sandbox_wazero_test.go 462 lines,
telemetry/telemetry_otel_test.go 214 lines) ran nowhere since they were
written (T-2026-07-25-16). Scoped to the two packages that carry tagged
files; verified green locally before wiring:
go test -tags wazero ./plugin/... and -tags otel ./telemetry/... both pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(client): fail closed when the identity pin store is unreadable (DC-08)

getIdentityPin collapsed a keyring read error into "no pin stored", so a
transient failure sent a pinned peer down the TOFU first-sight path:
verifyPeerAnnounce verified against the server-delivered key and then
RE-PINNED it — a fail-open a malicious server could exploit by inducing
store errors (F3 follow-up 3, plans/security-scan-2026-07-22).

getIdentityPin now returns a three-state IdentityPinLookup
(pinned/unpinned/unavailable), mirroring how tofu.rs keeps Err distinct
from Ok(None) first-use. verifyPeerAnnounce rejects the announce on
"unavailable" without any pin write, records the new "unknown"
PeerVerification status, and the roster badge renders it as an amber
shield-question ("could not check") distinct from the legacy
"unverified" state.

Pinned by unit tests: pin present, no pin, store error (identity.ts),
the fail-closed rejection path (livekit-session), and the badge
presentation (channel-sidebar).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* feat(admin): write a backup_restore audit row that survives the restore (DC-09)

Backup restore was the one admin mutation with no audit_log row —
docs/security.md documented the gap as inherent ("the database is closed
as part of the restore"). The row IS writable durably: written
synchronously (LogAudit, deliberately not the async WriteAudit fast path)
before BackupTo takes the pre-restore safety copy, it is captured inside
pre_restore_*.db and survives the file swap forensically.

The extended restore test opens the pre-restore backup as a database and
asserts the backup_restore row is inside it — proving both the write and
its ordering. docs/security.md now documents where the row lives instead
of the gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* feat(client): channel-delete toast + optimistic reaction toggle (DC-12)

Two messaging-surface gaps the UX specs carried as open:

- channel_delete on the active channel now toasts "This channel was
  deleted" alongside the existing redirect (ux/channels-members-dms
  §1.2) — the redirect alone read as the app spontaneously changing
  channels. Non-active deletions stay silent.

- Reactions toggle optimistically (ux/messaging §5): the pill flips on
  the click, registered under the send's WS envelope id — the same
  correlation scheme as the optimistic message rows. updateReaction
  consumes the matching self-echo instead of re-applying it (the
  delta-based arithmetic would double-count), other users' echoes apply
  normally, and an error reply or local transport failure rolls back
  exactly that toggle via rollbackReaction in the dispatcher's error and
  send-failure handlers. The pill reverting is the failure feedback.

Both spec gap notes flipped to implemented; the stale §2 note claiming
the ready payload lacks slow_mode fell in the same edit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(client): guard the role-change submenu against double-fire (DC-12)

Every other destructive admin action already carried an in-flight guard
(withConfirmation, unblockRunning, banRunning, purge) — the role-change
submenu was the residual: currentRole only updates when the
member_update echoes, so a double-click (or a second option clicked
while the first PATCH was in flight) fired onChangeRole twice. One
shared guard now inerts the whole submenu while a change is running,
with the pending class on the clicked option.

The settings-and-admin spec's in-flight gap note flips to implemented,
and the stale messaging §8 slow-mode note is corrected in the same
docs sweep (the countdown shipped with the ready payload's slow_mode —
verified against ChannelController.startSlowMode and its tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(client): drag-reorder document listeners leak — own them per sidebar signal (DC-12)

The shared document mousemove/mouseup pair was reference-counted per
attached channel row: a sidebar with N channels took N refs (and N more
per re-render) while its single destroy returned exactly one, so the
count never reached zero and the listeners plus their activeDrag
closure lived for the rest of the process — the KNOWN BUG the
drag-reorder test pinned since the 2026-07-25 audit.

Ownership is now a Set of AbortSignals (the sidebar's lifetime
controller — the @lib/disposable teardown idiom): acquisition is
idempotent per signal no matter how many rows attach, release is the
signal's abort, and an owner aborted mid-drag clears the in-flight
visual state (which the old containerEl comparison never actually
matched in production — it compared the category container against
channelList). releaseGlobalDragListeners is gone; ChannelSidebar's
destroy releases via its existing ac.abort().

The pinning test changes with the fix, as its own comment instructed;
the lifecycle block now pins the fixed contract, including
re-registration after full teardown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* test(e2e): E2EE identity-verification and updater journeys (DC-04)

The two remaining client-side headline coverage gaps from the 2026-08-04
audit's flow matrix (rows 38 and 49), both driven through the web mock
harness:

voice-e2ee-verify.spec.ts (6 tests, ux/voice-and-e2ee §7): the peer's
announce is real crypto — an ECDSA P-256 identity key signing an ECDH
ephemeral key exactly as e2eeCrypto does — so the badge states come out
of the production verification path. Covers the verified badge with
safety number (+ TOFU pin on first sight), the legacy unverified badge,
the mismatch block, the mismatch modal's reject path (peer stays
blocked, nothing pinned) and Trust New Key (re-pins the displayed key),
and the DC-08 fail-closed 'could not check' badge when the pin store is
unreadable. The harness gap that kept this untestable is closed by a
voice_join handler that grants a key-holder voice_token plus a WebSocket
shim that parks LiveKit's room.connect forever, holding the session
stably in 'securing'.

updater.spec.ts (4 tests, ux/settings-and-admin §5): no-update silence,
banner + Later dismissal, the full banner → download progress (% and MB
fallback via real update-progress events) → automatic relaunch journey,
and the failure state with Dismiss. There is no restart prompt by design
— the applied state IS the relaunch, asserted via the recorded
plugin:process|restart invoke.

Harness: buildTauriMockScript gains per-test identity-pin config
(identityPins / identityPinError) and a window.__invokeLog recording
every IPC call so tests can assert side effects with no DOM footprint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* style(client): prettier-format the drag-reorder module and new e2e specs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* feat(client): accessibility pass over the modal/overlay stack (DC-13)

The repo had exactly one focus-trapped dialog (UserProfilePopup), one
aria-live region, and no role="dialog" anywhere else. This pass
generalizes that one good implementation into lib/a11y.ts
(applyDialogSemantics, trapFocus, focusDialog, setRovingTabindex,
enableRovingNavigation) and applies it across the stack — all additive:
no DOM classes, testids, structure, or visible text changed.

- modalFactory: every factory modal now carries role=dialog + aria-modal,
  moves focus in on open, restores it on every close path, and Tab-cycles
  inside; createPromptModal is labelled by its title.
- Hand-rolled modals (CertMismatch/CertFirstUse/IdentityMismatch,
  Create/Edit/DeleteChannel, InviteManager): dialog semantics labelled by
  their existing headings, focus trap + restore, aria-label on icon-only
  close buttons, and Escape mapped to each modal's SAFE action (reject on
  the trust prompts — dismissal must never grant trust; cancel on the
  channel modals — never the destructive/submit callback).
- SettingsOverlay: dialog on the panel, focus in on open/restore on
  close; the sidebar is a vertical role=tablist with roving tabindex and
  ArrowUp/Down/Home/End activate-on-focus; the content pane is a
  tabpanel labelled by the active tab.
- QuickSwitcher: dialog + combobox/listbox/option wiring with
  aria-activedescendant tracking the active row. QuickSwitchOverlay:
  dialog + keyboard-operable rows (the inert current-server row stays
  unfocusable on purpose).
- EmojiPicker/GifPicker: listbox/option cells with a roving tabindex
  (Arrow/Home/End move the single Tab stop, Enter/Space activate through
  the click path). inline-autocomplete: option ids + combobox attrs and
  aria-activedescendant on the composer textarea — deliberately NOT
  roving tabindex, since moving DOM focus out of the textarea would
  break typing (the combobox pattern).
- Toast and TypingIndicator are polite live regions (role=status).

Tests: +71 unit cases across 18 files (4474 total, all green) pinning
roles, traps, restores, Escape safety, and roving behavior; plus an
axe-style e2e smoke (a11y-smoke.spec.ts, 5 tests) proving the wiring in
the running app — settings tablist + focus restore to the opener, quick
switcher combobox, member-picker Tab containment, live regions, and the
cert first-use dialog where Escape rejects without trusting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(ux): replace file:line anchors with symbol references (DC-15)

The UX specs cited code as file:line anchors, and a three-week-old
snapshot already had 15 of 58 pointing at entirely wrong code (the audit
measured 200-700 lines of drift). All 55 remaining anchors across the
six spec files now reference the owning symbol instead
("validateForm() in pages/connect-page/LoginForm.ts"), each target
verified to exist before rewriting; the stale ones were re-aimed at the
correct symbol, not just de-numbered. Zero file:line references remain
under docs/architecture/ux/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(audit): record the 2026-08-05 closure pass; stamp DC statuses in place

- audit-2026-08-04-docs-and-coverage.md: DC-06/08/12/13/15 marked
  resolved, DC-04 and DC-09 further-resolved (admin-panel journey and the
  handleApplyUpdate TODO are the remainders), matrix rows 38/49 flipped
  from headline gaps to covered, the §4 UX-problem bullets closed, and a
  §12 closure addendum records what shipped and the verification runs.
- CHANGELOG Unreleased: operator-facing entries for the DC-08 fail-closed
  fix, the a11y pass, the UX polish, the backup_restore audit row, the
  tag-gated CI tests, and the new e2e journeys.
- E2E-ISSUES.md: fresh full-suite run recorded at this HEAD — 291/291
  passed in 9.2 min with zero flaky retries (276 baseline + 6 E2EE + 4
  updater + 5 a11y smoke), @parity 15/15; suite inventory now 40 files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* test(client): fix install-settle race in the updater e2e spec

The install settle handles are created only when the app's
download_and_install_update invoke reaches the mock wrapper, but the spec
called them right after asserting the banner text — which flips
synchronously on click, before the invoke's microtask runs. Local runners
won that race; CI lost it three attempts in a row
(window.__rejectInstall is not a function). Both settle sites now wait
for the handles, same pattern as the listener waits the file already uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Final audit closure: admin-panel e2e, container-safe updates, blocking e2e gate, dependency policy (#1330)

* ci(server): run the tag-gated wazero/otel Go tests (DC-06)

The build-tag matrix only compiled the otel/wazero variants; the tests
behind those tags (plugin/sandbox_wazero_test.go 462 lines,
telemetry/telemetry_otel_test.go 214 lines) ran nowhere since they were
written (T-2026-07-25-16). Scoped to the two packages that carry tagged
files; verified green locally before wiring:
go test -tags wazero ./plugin/... and -tags otel ./telemetry/... both pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(client): fail closed when the identity pin store is unreadable (DC-08)

getIdentityPin collapsed a keyring read error into "no pin stored", so a
transient failure sent a pinned peer down the TOFU first-sight path:
verifyPeerAnnounce verified against the server-delivered key and then
RE-PINNED it — a fail-open a malicious server could exploit by inducing
store errors (F3 follow-up 3, plans/security-scan-2026-07-22).

getIdentityPin now returns a three-state IdentityPinLookup
(pinned/unpinned/unavailable), mirroring how tofu.rs keeps Err distinct
from Ok(None) first-use. verifyPeerAnnounce rejects the announce on
"unavailable" without any pin write, records the new "unknown"
PeerVerification status, and the roster badge renders it as an amber
shield-question ("could not check") distinct from the legacy
"unverified" state.

Pinned by unit tests: pin present, no pin, store error (identity.ts),
the fail-closed rejection path (livekit-session), and the badge
presentation (channel-sidebar).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* feat(admin): write a backup_restore audit row that survives the restore (DC-09)

Backup restore was the one admin mutation with no audit_log row —
docs/security.md documented the gap as inherent ("the database is closed
as part of the restore"). The row IS writable durably: written
synchronously (LogAudit, deliberately not the async WriteAudit fast path)
before BackupTo takes the pre-restore safety copy, it is captured inside
pre_restore_*.db and survives the file swap forensically.

The extended restore test opens the pre-restore backup as a database and
asserts the backup_restore row is inside it — proving both the write and
its ordering. docs/security.md now documents where the row lives instead
of the gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* feat(client): channel-delete toast + optimistic reaction toggle (DC-12)

Two messaging-surface gaps the UX specs carried as open:

- channel_delete on the active channel now toasts "This channel was
  deleted" alongside the existing redirect (ux/channels-members-dms
  §1.2) — the redirect alone read as the app spontaneously changing
  channels. Non-active deletions stay silent.

- Reactions toggle optimistically (ux/messaging §5): the pill flips on
  the click, registered under the send's WS envelope id — the same
  correlation scheme as the optimistic message rows. updateReaction
  consumes the matching self-echo instead of re-applying it (the
  delta-based arithmetic would double-count), other users' echoes apply
  normally, and an error reply or local transport failure rolls back
  exactly that toggle via rollbackReaction in the dispatcher's error and
  send-failure handlers. The pill reverting is the failure feedback.

Both spec gap notes flipped to implemented; the stale §2 note claiming
the ready payload lacks slow_mode fell in the same edit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(client): guard the role-change submenu against double-fire (DC-12)

Every other destructive admin action already carried an in-flight guard
(withConfirmation, unblockRunning, banRunning, purge) — the role-change
submenu was the residual: currentRole only updates when the
member_update echoes, so a double-click (or a second option clicked
while the first PATCH was in flight) fired onChangeRole twice. One
shared guard now inerts the whole submenu while a change is running,
with the pending class on the clicked option.

The settings-and-admin spec's in-flight gap note flips to implemented,
and the stale messaging §8 slow-mode note is corrected in the same
docs sweep (the countdown shipped with the ready payload's slow_mode —
verified against ChannelController.startSlowMode and its tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* fix(client): drag-reorder document listeners leak — own them per sidebar signal (DC-12)

The shared document mousemove/mouseup pair was reference-counted per
attached channel row: a sidebar with N channels took N refs (and N more
per re-render) while its single destroy returned exactly one, so the
count never reached zero and the listeners plus their activeDrag
closure lived for the rest of the process — the KNOWN BUG the
drag-reorder test pinned since the 2026-07-25 audit.

Ownership is now a Set of AbortSignals (the sidebar's lifetime
controller — the @lib/disposable teardown idiom): acquisition is
idempotent per signal no matter how many rows attach, release is the
signal's abort, and an owner aborted mid-drag clears the in-flight
visual state (which the old containerEl comparison never actually
matched in production — it compared the category container against
channelList). releaseGlobalDragListeners is gone; ChannelSidebar's
destroy releases via its existing ac.abort().

The pinning test changes with the fix, as its own comment instructed;
the lifecycle block now pins the fixed contract, including
re-registration after full teardown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* test(e2e): E2EE identity-verification and updater journeys (DC-04)

The two remaining client-side headline coverage gaps from the 2026-08-04
audit's flow matrix (rows 38 and 49), both driven through the web mock
harness:

voice-e2ee-verify.spec.ts (6 tests, ux/voice-and-e2ee §7): the peer's
announce is real crypto — an ECDSA P-256 identity key signing an ECDH
ephemeral key exactly as e2eeCrypto does — so the badge states come out
of the production verification path. Covers the verified badge with
safety number (+ TOFU pin on first sight), the legacy unverified badge,
the mismatch block, the mismatch modal's reject path (peer stays
blocked, nothing pinned) and Trust New Key (re-pins the displayed key),
and the DC-08 fail-closed 'could not check' badge when the pin store is
unreadable. The harness gap that kept this untestable is closed by a
voice_join handler that grants a key-holder voice_token plus a WebSocket
shim that parks LiveKit's room.connect forever, holding the session
stably in 'securing'.

updater.spec.ts (4 tests, ux/settings-and-admin §5): no-update silence,
banner + Later dismissal, the full banner → download progress (% and MB
fallback via real update-progress events) → automatic relaunch journey,
and the failure state with Dismiss. There is no restart prompt by design
— the applied state IS the relaunch, asserted via the recorded
plugin:process|restart invoke.

Harness: buildTauriMockScript gains per-test identity-pin config
(identityPins / identityPinError) and a window.__invokeLog recording
every IPC call so tests can assert side effects with no DOM footprint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* style(client): prettier-format the drag-reorder module and new e2e specs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* feat(client): accessibility pass over the modal/overlay stack (DC-13)

The repo had exactly one focus-trapped dialog (UserProfilePopup), one
aria-live region, and no role="dialog" anywhere else. This pass
generalizes that one good implementation into lib/a11y.ts
(applyDialogSemantics, trapFocus, focusDialog, setRovingTabindex,
enableRovingNavigation) and applies it across the stack — all additive:
no DOM classes, testids, structure, or visible text changed.

- modalFactory: every factory modal now carries role=dialog + aria-modal,
  moves focus in on open, restores it on every close path, and Tab-cycles
  inside; createPromptModal is labelled by its title.
- Hand-rolled modals (CertMismatch/CertFirstUse/IdentityMismatch,
  Create/Edit/DeleteChannel, InviteManager): dialog semantics labelled by
  their existing headings, focus trap + restore, aria-label on icon-only
  close buttons, and Escape mapped to each modal's SAFE action (reject on
  the trust prompts — dismissal must never grant trust; cancel on the
  channel modals — never the destructive/submit callback).
- SettingsOverlay: dialog on the panel, focus in on open/restore on
  close; the sidebar is a vertical role=tablist with roving tabindex and
  ArrowUp/Down/Home/End activate-on-focus; the content pane is a
  tabpanel labelled by the active tab.
- QuickSwitcher: dialog + combobox/listbox/option wiring with
  aria-activedescendant tracking the active row. QuickSwitchOverlay:
  dialog + keyboard-operable rows (the inert current-server row stays
  unfocusable on purpose).
- EmojiPicker/GifPicker: listbox/option cells with a roving tabindex
  (Arrow/Home/End move the single Tab stop, Enter/Space activate through
  the click path). inline-autocomplete: option ids + combobox attrs and
  aria-activedescendant on the composer textarea — deliberately NOT
  roving tabindex, since moving DOM focus out of the textarea would
  break typing (the combobox pattern).
- Toast and TypingIndicator are polite live regions (role=status).

Tests: +71 unit cases across 18 files (4474 total, all green) pinning
roles, traps, restores, Escape safety, and roving behavior; plus an
axe-style e2e smoke (a11y-smoke.spec.ts, 5 tests) proving the wiring in
the running app — settings tablist + focus restore to the opener, quick
switcher combobox, member-picker Tab containment, live regions, and the
cert first-use dialog where Escape rejects without trusting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(ux): replace file:line anchors with symbol references (DC-15)

The UX specs cited code as file:line anchors, and a three-week-old
snapshot already had 15 of 58 pointing at entirely wrong code (the audit
measured 200-700 lines of drift). All 55 remaining anchors across the
six spec files now reference the owning symbol instead
("validateForm() in pages/connect-page/LoginForm.ts"), each target
verified to exist before rewriting; the stale ones were re-aimed at the
correct symbol, not just de-numbered. Zero file:line references remain
under docs/architecture/ux/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(audit): record the 2026-08-05 closure pass; stamp DC statuses in place

- audit-2026-08-04-docs-and-coverage.md: DC-06/08/12/13/15 marked
  resolved, DC-04 and DC-09 further-resolved (admin-panel journey and the
  handleApplyUpdate TODO are the remainders), matrix rows 38/49 flipped
  from headline gaps to covered, the §4 UX-problem bullets closed, and a
  §12 closure addendum records what shipped and the verification runs.
- CHANGELOG Unreleased: operator-facing entries for the DC-08 fail-closed
  fix, the a11y pass, the UX polish, the backup_restore audit row, the
  tag-gated CI tests, and the new e2e journeys.
- E2E-ISSUES.md: fresh full-suite run recorded at this HEAD — 291/291
  passed in 9.2 min with zero flaky retries (276 baseline + 6 E2EE + 4
  updater + 5 a11y smoke), @parity 15/15; suite inventory now 40 files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* test(client): fix install-settle race in the updater e2e spec

The install settle handles are created only when the app's
download_and_install_update invoke reaches the mock wrapper, but the spec
called them right after asserting the banner text — which flips
synchronously on click, before the invoke's microtask runs. Local runners
won that race; CI lost it three attempts in a row
(window.__rejectInstall is not a function). Both settle sites now wait
for the handles, same pattern as the listener waits the file already uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* ci: promote client-e2e to blocking (DC-07)

The soak is decided: green full-suite runs at 270, 276 and 291 tests across
the audit branches, and the one hard failure in the window was a real spec
bug a non-blocking job would have let rot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* feat(server): refuse in-place self-update in container deployments

Resolves the long-standing handleApplyUpdate TODO. In a container the
running binary is image content: the staged replacement dies with the
container and the restart comes back as the old image. RunningInContainer
(OWNCORD_CONTAINER authoritative both ways — the shipped Dockerfile sets 1,
bind-mount operators can set 0 — with /.dockerenv//run/.containerenv as
fallback) now gates POST /admin/api/updates/apply with 503
CONTAINER_DEPLOYMENT before any updater logic, GET /admin/api/updates gains
can_apply, and the admin SPA swaps the apply button for an image-upgrade
note when it is false.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs: adopt the dependency pinning/review policy (DC-11, 2026-04-07 #8)

Writes down the policy the lockfiles already enforce: lockfiles
authoritative with npm ci-only installs, weekly Dependabot with majors
adopted deliberately, per-PR security gates (npm audit on shipped deps,
govulncheck, cargo audit, knip), and toolchain-level version pins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* test(admin): add the admin-panel e2e journey against a real server (DC-04)

The admin SPA was the one surface no suite could reach: it is served by the
Go server and mocked nowhere. start-server.sh builds and boots a real
server (fresh temp data dir, TLS off, loopback) and the journey drives the
SPA end to end — first-run wizard creating the owner, dashboard stats,
channel create/rename, audit-log rows for both mutations, and sign-out/
sign-in. One shared page keeps the localStorage session across the serial
steps, mirroring the native suite's persistent fixture and staying under
the 5-logins/min limiter; on a Playwright retry the wizard branch downgrades
to login since setup is one-shot server-side. New non-blocking admin-e2e CI
job on the same graduation convention client-e2e followed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

* docs(audit): final closure — every DC finding resolved or deliberately reserved

Records the owner-directed closure pass (§13): DC-04 fully (admin journey
was the last row), DC-07 (client-e2e blocking), DC-09 fully (container-
aware update refusal), DC-11 + 2026-04-07 #8 (dependency policy).
Remaining open items are all deliberate: DC-14 reserved protocol entries,
the admin-e2e soak graduation, and the accepted/tracked 2026-04 carryovers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(attachments): stop the orphan sweep destroying every avatar and the grace period

The 15-minute maintenance sweep deleted attachment rows and their files for
any attachment with message_id IS NULL. Avatars are exactly that by design:
users.avatar points at the attachment by URL and nothing ever links it to a
message (migration 027). Every avatar in the instance was therefore destroyed
on the first tick past the grace period, permanently 404ing every profile
picture. The query now excludes attachments a user's avatar still points at.

Independently, the cutoff was formatted RFC3339 while uploaded_at is written
by SQLite as 'YYYY-MM-DD HH:MM:SS'. TEXT comparison is bytewise and ' ' sorts
before 'T', so every unlinked upload sharing the cutoff's UTC date was swept
regardless of time -- the one-hour grace collapsed to 'immediately'. Rather
than fix the format at the one call site, DeleteOrphanedAttachments now takes
a time.Time and formats it internally, so no caller can reintroduce the class.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): send old_password so changing a password can succeed

The client posted {current_password, new_password} while the server decodes
json:"old_password" (Server/api/profile_handler.go:43). Go's encoding/json
does no alias matching, so OldPassword was always empty and every password
change returned 400 INVALID_INPUT -- the feature could never work for anyone.
docs/api.md and every server test already document old_password.

The existing unit test asserted the client's own broken payload, so it passed
while the feature was dead; it now asserts the documented server contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(admin): roll back and restart when a backup restore fails mid-copy

copyFile truncates the destination with os.Create before it can know whether
the read will succeed. On the restore path the destination is the live
database, already closed, so a failure in io.Copy or Sync left a zero-byte
chatserver.db, no rollback, and -- because the old code returned before the
restart -- a process still answering requests against a closed DB while the
response and the server_restart broadcast both claimed a restart was underway.

The failure branch now puts the pre-restore safety copy back (saying so
honestly in the error, including when the rollback itself fails) and requests
the restart the success path already did.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(voice): refuse client-side unmute while server-muted by a moderator

Push-to-talk called LiveKitSession.setMuted directly, which had no
server-mute guard -- only the voice widget's own handler checked. Unmuting
re-publishes a fresh mic track, and since MuteParticipantAudio only mutes the
track SIDs that exist at mute time while the LiveKit grant still carries the
microphone publish source, the SFU accepted it: holding PTT lifted a
moderator's mute and never told the server.

The guard now lives in setMuted itself, the one entry point every caller
shares, so PTT and any future caller are covered. Muting stays allowed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): force a full ready when a client's seq is ahead of the ring buffer

EventsSince/EventsSinceFiltered guarded only the lower bound, so a client
asking for events newer than anything the buffer ever held got a non-nil empty
slice -- which handleReconnect reads as a successful, complete replay. It then
registers the client, sends auth_ok with replay_source=buffer and skips ready
entirely, leaving stale members, channels and read state until the counter
climbs back past the client's remembered value.

That disagreement is reachable in normal operation: the hub seeds its counter
from GetMaxEventSeq, which is 0 once the 24h pruner has emptied the table, so
a restart can reseed seq below a lastSeq clients preserve across reconnects.

Both functions now return nil (the existing 'cannot guarantee coverage'
signal) when afterSeq exceeds the newest buffered seq, so the caller falls
through to the cold tier and the intended full ready. afterSeq == newestSeq
remains the legitimate caught-up case and still replays empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): re-send channel_focus on auth_ok so reconnects keep receiving messages

channel_focus was sent only by mountChannel, which early-returns when the
channel id is unchanged, so a reconnect into the same channel never re-sent it.
The server transfers the focused channel from the old connection, but only
while that connection is still registered -- readPump's defer unregisters it
and drops every topic subscription the moment the server observes the close,
about a second before the client's first retry. Any server-observed close
(restart, proxy close, network reset) therefore resumed with no ChannelTopic
subscription: server channel messages, edits and reactions are delivered
exclusively over that topic, so the message stream went silently dead while
global events kept arriving and made the connection look healthy.

auth_ok fires on every connection including resumes and the full-ready
fallback, and it also covers the server-restart case where there is no old
state to transfer from. The server's handler is idempotent, so the extra focus
on a fresh connect is harmless.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(messages): stop persisting HTML-escaped text, safely

bluemonday writes text tokens through html.EscapeString, so sanitizeContent
persisted and broadcast the escaped form: every apostrophe, quote, ampersand
and angle bracket reached other users as a literal entity, and because stored
quote lines began with '&gt;' the client's blockquote regex could never fire.
cleanText (display names, about, custom status, DM names) had the same bug.

Unescaping bluemonday's output alone would be a sanitizer bypass: surviving
text tokens can recombine into live markup -- '<<script>script>alert(1)<'
+ '</script>/script>' reassembles a real end tag. Instead the whole
unescape -> Sanitize -> unescape cycle now runs to a fixpoint, so the stored
result is by construction stable under re-sanitizing: any '<' that the
tokenizer would read as a tag start is stripped rather than re-encoded, and
only inert punctuation survives. The loop is bounded by the input length and
each pass is non-increasing; measured worst case over pathological tag/entity
soup at the 16 KiB input ceiling is under a millisecond.

The fuzz sinks are tightened to match the new contract rather than loosened:
they now require a tag-like start ('<' + letter or '/') because a bare '<'
followed by punctuation is inert plain text under every client render path.
The <script substring and idempotency checks are unchanged. Verified with
4.2M fuzz executions, zero crashers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): close the WS task and TLS socket on disconnect via generation-owned teardown

ws_disconnect dropped the slot's sender expecting the write task to end, but
the monitor task held my_tx — a Sender clone kept only to prove teardown
ownership — so rx.recv() could never yield None: the monitor waits on the
writer via join_next() while the writer waits on the monitor's clone
dropping. Every intentional disconnect or profile-switch reconnect leaked
the writer, the reader, and the TLS socket, and with no server-side read
deadline the connection stayed registered — the user remained presence-online
after logout, and the stale Rust reader kept injecting the old server's
events into the new session's stores.

Ownership is now proven by the connection generation that already guards
install: the monitor captures my_generation plus the generation Arc and
clears/announces only if the generation is still current, checked under the
slot lock (generation only advances inside begin_connection while that lock
is held, so check-and-clear is atomic against new attempts). install_sender
receives the only Sender, so dropping the slot's sender really closes the
channel: writer exits, join_next returns, abort_all reaps the reader, and
the socket drops.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(voice): send voice_leave when the E2EE key exchange times out

connectAndSetup's key-exchange failure branch called leaveVoice(false) — no
voice_leave frame, no leaveVoiceChannel(). The timeout fires BEFORE
room.connect(), so no SFU participant ever exists and no LiveKit webhook can
clean up, while the server already registered the join when it sent
voice_token. The orphaned voice_states row matches the connected client's
channel, so sweepStaleVoiceStates never reaps it; once the ghost has the
lowest uid it wins key-holder election with a cleared E2EE state, every
later joiner's exchange times out and ghosts too, and rejoining the same
channel bounces off ALREADY_JOINED.

Mirror the reconnect-exhausted give-up path: leaveVoice(true) +
leaveVoiceChannel(), so the server drops the row and the local store
converges. The supersession checkpoints keep leaveVoice(false) — there a
newer attempt owns the server-side state and a voice_leave would destroy it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): keep voice state when the replay-failure fallback will transfer it

handleFreshConnect's stale-voice cleanup ran unconditionally, but the
replay-failure fallback (lastSeq > 0, e.g. after a restart reset the seq
counter) reaches it while the old connection is still registered — and
registerNow then transfers that connection's live voice state into the new
client. The cleanup had already deleted the DB row, broadcast voice_leave,
and removed the live LiveKit participant (using the very JoinedAt token
being transferred), so the user ended up "in voice" on the hub only:
voice_join bounced off ALREADY_JOINED and sweepStaleVoiceStates never
reaps in-memory state without a row.

Skip the cleanup when lastSeq > 0 and the still-registered old client's
voiceChID matches the row — exactly the case registerNow transfers. All
other cases (F5 fresh connects, no old client, mismatched channel) keep
the existing cleanup, and if the old client unregisters in the window
before registerNow, the untransferred row is reaped by the next sweep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): stop the mic meter stream when it resolves after teardown

The mic-meter IIFE had no post-await guard: a getUserMedia resolving after
SettingsOverlay.hide() ran cleanup() (or after the tab's signal aborted)
opened the microphone anyway, started the rAF meter loop, and registerMic
re-armed state that cleanupMic() had already cleared — the mic stayed hot
for the rest of the session with nobody left to stop it.

Mirror the camera preview's request-id guard: cleanupMic()'s invalidation
callback now bumps a micRequestId alongside cameraRequestId, the IIFE
captures the id before the await, and a stale or aborted request stops the
just-acquired tracks and bails before touching the AudioContext.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api,db): close the API-token and stale-ban holes in 2FA and account deletion

Four related gaps around sessionless (API-token) principals and account
teardown, all found by the bughunt harvest:

- 2FA enable/disable skipped the BUG-108 "revoke other sessions" step
  entirely when the caller authenticated with an API token (nil session).
  Both handlers now use the change-password pattern: keep=0 matches no
  row, so every login session is revoked.
- verify-totp issued a session to a user banned after the password step;
  it now runs the same IsEffectivelyBanned refusal as login.
- DeleteAccount left API tokens active (they authenticate independently
  of the purged sessions) and left a stale lapsed ban_expires in place,
  which makes banned=1 read as NOT banned — together a previously
  temp-banned self-deleted account stayed fully usable through any
  owner-minted token. Tokens are now revoked in the purge and
  anonymiseUser sets ban_expires = NULL.
- The last-admin guard resolved admin-class roles by display name
  ('Owner','Admin'), so renaming the seeded Admin role silently disabled
  self-deletion protection for its holders. It now keys on the canonical
  OwnerRoleID/AdminRoleID plus any role holding the Administrator bit.
  (The harvest's suggested criterion — Owner ID or Administrator bit
  alone — would have DROPPED seeded Admins, whose 0x3FFFFFFF permissions
  lack bit 30; the ID-based form preserves existing guard semantics.)
- DeleteAccount also now applies LeaveGroupDM's invariant: DM channels
  left with zero participants are removed instead of becoming
  unreachable, undeletable rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api,ws): rate-limit gaps — bucket isolation, focus/mark_read, call_decline, reaper horizon

- Every empty-prefix RateLimitMiddleware mount shared one bare-IP bucket,
  and the limiter records a timestamp per call regardless of the limit
  passed — so unrelated endpoints capped each other at the minimum limit
  (five ordinary profile edits 429'd the password endpoint; NAT'd logins
  blocked register). The prefix is now a required parameter and every
  mount names its own bucket, mirroring the existing client_update:/
  livekit_proxy:/gif: pattern. The sessions-list handler also stops
  401ing API-token principals (nil session only ever fed IsCurrent).
- channel_focus and mark_read were the only user-facing V2 handlers with
  no rate limit, and each drives an unmetered SQLite write plus pubsub
  churn; they now share a 5/s per-user budget (same underlying service
  call), silently dropping over-budget frames like their siblings.
- call_decline gets the same limiter as its sibling call_ring — the
  identical participant-lookup-plus-fan-out cost shape.
- The rate-limiter reaper pruned any entry idle past 15 minutes, but slow
  mode passes windows up to the 6 h admin cap, so long slow modes were
  silently reset; the cleanup horizon now covers the largest real window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(server): unreachable route envelopes, dead env override, admin paging, plugin/updater defects

Seven harvest findings across router, config, admin, plugin and updater:

- The global 1 MiB body cap shadowed every route with a larger documented
  envelope: the 16 MiB plugin install 400'd at ~1 MiB and an at-limit
  avatar could never fit its multipart framing. The exemption list is now
  a named var covering uploads, plugin install, and avatar — each of
  which enforces its own cap at the route/handler level.
- queryInt clamped offset with the limit's 500 cap, so the admin audit
  log and user list could never page past row 550; the cap is now an
  explicit per-call bound (offset callers pass MaxInt32).
- OWNCORD_EVENT_PERSISTENCE_* env overrides were documented but dead:
  envKeyToKoanf cut at the first underscore, producing the unknown path
  event.persistence_* that koanf silently drops.
- InstallPlugin trusted LastInsertId, which SQLite does not update on the
  upsert's DO UPDATE branch — on the shared writer connection a reinstall
  returned the rowid of some unrelated prior INSERT, so EnablePlugin
  no-opped and plugin_kv wrote to a nonexistent plugin id. RETURNING id
  is correct on both branches.
- Every wazero plugin re-activation compiled the module again and leaked
  the previous CompiledModule; the handle is now retained on the instance
  and closed in deactivate, the lost-activation race, and the
  closed-module release path.
- Linux server self-update was gated on the Windows-only
  chatserver.exe.sig asset it never uses; the required-asset check and
  the signature fetch are now GOOS-aware.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ws): batch S4 — seq-gap shed, replay tail merge, drain-on-close, handshake teardown

Eight harvest findings in the hub/replay/pump paths, each locked by a
watched-red test in harvest_s4_internal_test.go / reconnect_db_test.go:

- kickClient closes the send channel BEFORE UnsubscribeAll so a racing
  Subscribe can never leave a dead client holding a topic.
- deliverBroadcast consults the topic limiter BEFORE allocating a seq: a
  shed frame no longer burns a sequence number that sits in the replay
  buffer forever unpublished.
- onStaleTick prunes idle topic-limiter buckets (Cleanup had no caller).
- dm_channel_open bumps the visibility watermark so a client resuming
  from an older seq takes the full-ready path instead of silently losing
  the targeted, unsequenced open.
- computeAllowedChannels treats a DM-lookup failure as fatal (full ready)
  instead of replaying with every DM event silently stripped.
- Cold-tier replay merges the ring-buffer tail past the newest persisted
  row; if the buffer cannot vouch for the flush gap it forces full ready.
- writePump drains queued frames (e.g. the BANNED kick reason) after
  closeSend instead of dropping them on the first closed channel.
- A failed post-registerNow handshake runs the offline teardown when no
  replacement connection holds the slot — no more users stuck online.

Declined by design: hoisting registerNow above the replay snapshot
(report L390) — every fallback path would re-register the same client
and registerNow self-kicks the slot holder; the µs dedup window does not
justify that risk in the hottest path.

The kickClient ordering test is a 300-iteration stress whose race window
is too narrow to hit reliably; it documents the invariant rather than
having been watched red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(client): batch C1 — store merges, ready-badge resync, pending-send teardown

Ten harvest findings in the stores/dispatcher layer, each locked by a
watched-red vitest test:

- setMessages merges instead of clobbering: live broadcasts and
  pending/failed optimistic rows that landed while the history GET was
  in flight survive the snapshot.
- addChannel is idempotent — the re-sent channel_create on role edits no
  longer wipes unread/mention counts, lastMessageId, or canSend.
- setChannels carries client-synthesized DM rows across the rebuild.
- READY marks the focused channel read after the store repopulation so
  stale server read_states cannot resurrect badges on the channel the
  user is actively reading (skipped on first connect).
- setVoiceStates maps the ready payload's camera/screenshare flags
  instead of blanking live streams on a mid-call resync.
- The dm_channels length guard is gone: an empty array is authoritative
  and clears ghost DMs.
- addMessage's defensive pending-row reconcile requires content equality
  so another session's replayed message cannot consume the pending row.
- performSend into a detached history window reattaches to present
  first, mirroring onJumpToPresent.
- prependMessages at the cap keeps the fetched older page and detaches
  the window instead of silently discarding the fetch (which refetched
  the same page forever); hasMore is the server's value again.
- A connection leaving "connected" fails every pending optimistic send
  (retry affordance) instead of letting rows spin forever.

One existing assertion updated to the corrected semantics: trimming on
prepend now drops rows below the window, so hasMore stays the server's
value and the test asserts the detach instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(server): batch S5 — voice eviction scoping, fail-closed camera cap, role-service races

Seven harvest findings in the voice/service layer, each locked by a
watched-red test:

- The CONNECT_VOICE revocation sweep evicts via a channel-conditional
  clear (the in-memory analogue of LeaveVoiceChannelIfMatch): a
  voice_join to a permitted channel that commits while the DB-backed
  permission check runs can no longer be torn down. The report's
  suggested pre-check guard was rejected — it leaves the same race open
  between guard and clear, proven by the interleaving test.
- A failed channel switch's abort branch re-subscribes the restored
  session to its VoiceTopic and re-elects the key holder; without them
  the session silently missed every voice_e2ee relay.
- voice_camera fails closed when the VoiceMaxVideo lookup errors instead
  of skipping the cap check and enabling unconditionally.
- LiveKitProcess starts the child inside the p.mu critical section that
  publishes p.cmd (Wait stays outside), removing the data race between
  Start's cmd.Process write and IsRunning/Stop reads.
- AffectedUserIDs reports lookup success; handlePatchRole falls back to
  a blanket permission-cache invalidation when the member list was
  unreadable, instead of evicting nobody and leaving revoked grants live.
- RoleService serializes its read-check-write mutations (position
  uniqueness and the role cap are snapshot-enforced, not DB-enforced);
  concurrent creates can no longer land on the same position and tie
  every hierarchy comparison.
- channel_focus writes the read state even when the channel has no
  undeleted messages — the upsert is what zeroes mention_count, so
  emptied channels finally clear their badge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(client): batch C2 — voice eviction teardown, supersession scoping, VAD generation

Seven harvest findings in the voice/session layer, each locked by a
watched-red vitest test:

- A server-initiated voice_leave for self tears down the LiveKit session
  (mic publish + E2EE key material), guarded on channel match so a
  late-arriving leave cannot kill a newer join.
- VIDEO_LIMIT refusal rolls back with disableCamera() — max_video has no
  SFU-level enforcement, so the already-published track kept streaming.
- teardownForReconnect sends voice_camera/voice_screenshare OFF frames
  before stopping local tracks, freeing the server-side max_video slot a
  reconnect otherwise occupies forever.
- Supersession checkpoints 3/4/5 disconnect only their own local room
  (mirroring checkpoint 2) instead of calling the global leaveVoice,
  which by then tears down the newer attempt's live session.
- retryMicPermission honors a moderator's server-mute like it honors
  deafen — granting mic while listen-only no longer hands the channel an
  unmuted track.
- handleDisconnected defers to the active reconnect loop (livekit-client
  fires Disconnected synchronously inside the loop's own connect call),
  preventing a second uncancellable retry loop.
- stopVadPolling invalidates an in-flight startVadPolling addModule via
  a VAD-scoped generation counter, so VAD cannot resurrect itself with a
  stale threshold.

Deliberately skipped: the report's optional RATE_LIMITED camera rollback
— that error code is shared by unrelated actions and the payload cannot
attribute it to a camera toggle, so a blind rollback would be wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(client): batch C3 — E2EE rotation races, pin-write tri-state, stale-offer guards

Six harvest findings in the voice-E2EE key-exchange layer, each locked
by a watched-red vitest test; the security-invariant sweep (re-pin
TOCTOU, forward-secrecy rekey, concurrent-rotation, blind-repin — 239
E2EE-adjacent tests) stays green:

- Re-election as key holder during an in-flight rotation defers (sets
  _isKeyHolder + _rotationPending, mirroring the sibling branch) instead
  of dropping the election and stranding the room without a holder.
- storeIdentityPin returns tri-state stored/no-store/failed; a FAILED
  pin write now marks the peer unverified instead of displaying
  "verified" with no pin persisted — an unpinned peer could never trip
  mismatch detection, the exact MITM window the pin exists to close.
- handleOfferInner discards a stale offer when the session keypair
  changed, not just the epoch — a non-key-holder never bumps epoch, so
  an offer surviving clearState() into the next session passed the
  epoch-only check.
- handleAnnounce's wrap-and-offer path gets the same epoch guard as the
  receive path, so a rotation landing mid-wrap cannot ship a dead key.
- The key-exchange retry races a FRESH promise (the first rejection had
  permanently settled the old one, making the retry window zero), and
  aborts cleanly when clearState() tore the session down mid-exchange.
- setupKeyExchange publishes _ecdhKeyPair only after _isKeyHolder and
  _roomKey are ready, so a concurrent announce is queued and drained
  through the offer-sending path instead of being consumed offer-less.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(client): batch R1 — secret store must not report a broken keyring as empty

Three harvest findings in the Tauri credential store, each locked by a
watched-red test:

- secret_store::get treated a keyring read error as "nothing stored"
  whenever the fallback file was also empty, which is indistinguishable
  from first login. loadOrGenerateIdentityKeyPair reads exactly that
  signal, so an unreadable keychain made the client mint and publish a
  fresh identity key over the existing one, invalidating every peer's
  TOFU pin. It now prefers a fallback copy and otherwise propagates the
  error; loadIdentityKey rethrows instead of swallowing to null.
- A failed keyring write left any older entry in place while the fresh
  secret went to the fallback file — and get() reads the keyring first,
  so the stale value shadowed the new one forever. The write-failure arm
  now purges the entry, mirroring the read-back-mismatch arm beside it.
- fallback_crypto deleted nothing when the key file's write or sync
  failed, leaving a short file that every later load rejects; since the
  key file is never rewritten once it exists, one ENOSPC poisoned the
  fallback store permanently.

Both Rust fixes needed a small injectable seam (get_with/set_with,
finish_new_key_file) because the keyring error branches are otherwise
unreachable without a live OS credential store.

The saved-login path is unaffected in behavior: loadCredential still
catches and degrades to "no saved credential" rather than surfacing the
new error. The persistence re-read in loadOrGenerateIdentityKeyPair
deliberately does not rethrow — the keypair already exists in memory by
then, so a transient failure keeps the existing "did not persist" warning.

fallback_crypto is cfg(not(windows)), so its test ran only under a
temporary local gate lift (reverted, verified no residual diff); it
executes for real on the Linux and macOS CI runners.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: rewrite CLAUDE.md per Claude 5 context guidance; commit skills + hunt workflow

The CLAUDE.md files were a manual: build commands, code style, layout Claude
can read off the filesystem. Rewritten so they are short and spend their
tokens on gotchas instead — the things that are invisible until they cost an
afternoon.

Moved out of CLAUDE.md into skills (progressive disclosure), which also fixes
three references to skills that never existed:
- ci-check: the full local CI mirror, including the four Go build-tag variants
  and the deadlock pass a plain build/test misses, and the windows-latest
  runtime.scanstack GC fault that should be rerun rather than investigated.
- db-change: the sqlc workflow plus three silent traps — non-ASCII query files
  truncating the NEXT query's emitted SQL, semicolons in migration comments
  orphaning statements, and LIMIT 1 mis-emitting on a :one query.
- protocol-change: regenerate both constant files and commit the pair.

Dropped: command lists duplicated from the Makefile and package.json, prettier
style rules the formatter already enforces, and layout facts a directory
listing answers. Added the subsystem invariants that keep getting rediscovered
the hard way — the ws seq/FIFO contract, voice-session supersession scoping,
E2EE staleness guards, and the Node 22 webstorage failure mode.

.claude/ is no longer ignored wholesale: skills and workflows are tracked so a
cloud session, which sees only tracked files, starts with instructions rather
than nothing. Machine-local settings and locks stay ignored. Deleted
bughunting.js, a superseded copy declaring the same workflow name as
bughunt.js, which left the registry ambiguous.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(client): batch C4 — cert-latch scoping, stale active channel, credential opt-out

Thirteen harvest findings across the client UI and wiring, each locked by a
watched-red vitest test:

- The TLS cert-mismatch latch fired on any host's event, so an unrelated
  saved profile's rotated certificate permanently killed this socket's
  reconnect loop. It now latches only for the connected host, cancels any
  pending reconnect (a timer armed before the mismatch would otherwise fire
  connect(), clearing the latch and resuming against that host), and resets
  on a fresh connect.
- ready never cleared activeChannelId when the channel vanished from the
  snapshot, and MainPage's subscriber had no else branch — the message list
  and composer stayed mounted and enabled against a channel the server no
  longer recognizes. Both sides fixed; the mark-read from batch C1 is
  suppressed when the clear happens.
- user_update re-saved the session token unconditionally, bypassing the
  remember-password opt-out, and dropped the stored password while doing it.
- A failed older-page fetch latched loadingOlder, permanently killing
  infinite scroll for that view; it now clears in a finally.
- Concurrent message jumps raced, letting the older response overwrite the
  newer window. Guarded by a generation counter.
- A FORBIDDEN send in a group DM flagged participants[0] as blocking, which
  disabled the unrelated 1:1 composer with that person; block gating is
  1:1-only.
- streamPreview added an abort listener per call instead of per signal.
- dm_channel_close had no fallback when the closed DM was being viewed;
  both call sites now share one closeDmLocally helper.
- The GIF picker routed through the textarea and discarded the draft.
- QuickSwitcher listed DM rows that the DM section already shows.
- Accepting a rotated certificate reconnected into a page with nothing left
  listening, stranding the user on the connect screen.
- Logout read voiceStore after clearAuth had already reset it, so the
  voice_leave was never sent; clearAuth now snapshots logoutWasInVoice.
- disconnect() left reconnectAttempt set, carrying a stale backoff ceiling
  into the next login.

Also fixes two lint errors this branch introduced earlier and that only a
full `npm run lint` catches: a useless spread in the C1 pending-send sweep
(now Array.from, which states the snapshot intent), and two floating
promises in C2's voice_leave handler, where converting an implicit-return
arrow to a block body stopped chaining them.

main.ts and MainPage.ts have no unit-test seam, so three focused pieces were
extracted to make the fixes testable: createUserUpdateCredentialSaver,
reconnectAfterCertAccept, and the logoutWasInVoice snapshot.

One existing assertion corrected: a dispatcher test claimed ready must keep
an active channel that was absent from the payload, which locked the bug.
It now keeps a channel that is present, with a sibling test for the absent
case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: resolve 107 verified defects across ws hub, voice/E2EE, db, and client

Findings came from a multi-round hunt, each verified against the tree by an
independent adversarial pass before any code changed, then fixed and re-reviewed.
Every behavioural fix carries a regression test that was confirmed to fail
against the pre-fix code.

Server (Go)
- Reconnect/replay: force a full ready when retention pruning has removed the
  events after a client's last_seq, rather than accepting the surviving suffix
  as a complete resume; close the snapshot-to-registration window under seqMu;
  restore the focused-channel subscription during the handshake via a new
  READ-gated active_channel_id auth field; supplement replay with the client's
  own voice room; tear down transferred voice sessions on a failed handshake.
- Hub: ratchet visibilityChangeSeq upward only (all three writers); make the
  stale-voice sweep error-aware so a transient DB failure no longer evicts
  every participant; re-elect the E2EE key holder on sweep and cleanup paths.
- Voice: preserve moderator mute/deafen across channel switches; deliver
  voice_leave to evicted users; gate camera/screenshare permission checks on
  the enabling direction only; reject joins to non-voice and archived channels.
- Permissions: archived channels are now read-only and unjoinable, and
  can_send is recomputed per client on role/override changes.
- Data: stop cascaded message deletes from stranding uploaded files
  (migration 030 unlinks instead); clear personal data on account deletion;
  exclude banned users from owner lookup; drop the silent 1000-member cap;
  advance the author's own read state on send.

Client (TypeScript / Rust)
- Voice: make joinGeneration monotonic so a superseded attempt can no longer
  pass supersession checks; scope aborted-path cleanup to the attempt's own
  room; send voice_leave on connect failure; stop push-to-talk from writing the
  user's explicit mute flag; gate join-time PTT muting on a new backend
  capability probe so platforms that cannot report key state are unaffected.
- E2EE: act on the tri-state pin-write result instead of reporting an
  unverified peer as verified; use keypair ownership rather than null checks.
- State: reset the message cache on logout; clear NSFW acknowledgements on
  logout; scope channel mutes, NSFW acks and DM notes per server host.
- UI: make the attachment remove button and the failed-send Retry/Discard
  buttons work; fix drag-reorder's phantom-drag latch and its permission gate.

Docs: protocol.md now documents can_send, active_channel_id, the archive
read-only contract, and the sequenced/unsequenced presence split.

Verified: all four Go build tag variants, go vet, go test -race, the ws
deadlock detector, sqlc and protocol generation, tsc, eslint, prettier, and
the full client suite (169 files, 4664 tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWUzUmsjCKNAzAfQzT4fz7

* fix(ci): satisfy golangci-lint, knip, and the host-scoped mute key in e2e

Three CI failures on the previous commit, all genuine fallout from it.

golangci-lint (v2.11.3) — 6 issues in tests added by that commit:
- contextcheck: the temp-ban subtest captured an outer ctx while calling
  seedTokenUser, which builds its own; declare ctx inside the subtest instead.
- modernize: use WaitGroup.Go and range-over-int in three tests.

knip — SessionResponse in lib/types.ts became unused. The getSessions fix
replaced it with SessionInfo in lib/api.ts, which documents why the old
declaration was wrong (it named ip_address/expires_at, which the server never
sends, and omitted ip/is_current, which it always does). Delete the dead type
rather than re-export it, and fold that reasoning into the surviving comment.

Client E2E — the per-channel-mute parity test asserted the pre-scoping
localStorage key. Channel mutes are now keyed mutedChannels:<host>, because
channel ids are per-server autoincrement integers sharing one webview origin;
verified in a browser that the app writes
owncord:settings:mutedChannels:localhost:8443. The test now resolves whichever
scoped key exists instead of pinning the test server's host, so it still
asserts the same thing: the id persists on mute and is gone on unmute.

Verified with the CI linter version built against Go 1.26 (0 issues), all four
build tag variants, go vet, go test -race, the ws deadlock detector, knip,
tsc for both tsconfigs, prettier, the full client unit suite, and the
previously-failing parity specs run in a real browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWUzUmsjCKNAzAfQzT4fz7

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 21:20:48 +02:00
dependabot[bot] b77c2790b0 chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#1324)
Bumps the npm_and_yarn group with 2 updates in the /tools/mcp-introspect directory: [fast-uri](https://github.com/fastify/fast-uri) and [hono](https://github.com/honojs/hono).


Updates `fast-uri` from 3.1.4 to 3.1.5
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.4...v3.1.5)

Updates `hono` from 4.12.32 to 4.13.0
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.32...v4.13.0)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.5
  dependency-type: indirect
- dependency-name: hono
  dependency-version: 4.13.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 15:42:26 +00:00
dependabot[bot] 931a2fd27e chore(deps): bump postcss from 8.5.19 to 8.5.25 in /Client/tauri-client (#1322)
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.19 to 8.5.25.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.19...8.5.25)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.25
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 15:22:06 +00:00
J3vbandClaude Opus 5 1486078265 ci: skip the Tauri full build on Dependabot PRs (#1325)
Dependabot PRs run under the separate `dependabot` secrets scope, so
TAURI_SIGNING_PRIVATE_KEY arrives empty and `npm run tauri build` always
aborted with "failed to decode secret key" while signing the updater
artifact -- after the compile and the NSIS/AppImage/deb bundle had both
already succeeded. Every dependency PR therefore burned ~50 min of runner
time across three platforms to produce a red check carrying no signal,
and the permanent red masked whether the job would have caught a real
break.

Granting Dependabot the signing secret would clear the symptom but hands
a release signing key to workflows triggered by third-party dependency
updates, so the job is skipped for that actor instead.

Coverage is preserved where it matters: `rust-tests` is a required check,
runs on every event, and compiles the crate via `cargo clippy
--all-targets` and `cargo test --lib`, so a dependency bump that breaks
the Rust build is still caught. Given up on Dependabot PRs only:
bundling, Windows/ARM-specific compilation, and the `cargo audit` step --
which overlaps with Dependabot's own cargo scanning.

`Tauri Full Build` is not among the required status checks on main
(Server Build & Test x2, Client Static Checks, Client Unit Tests, Rust
Unit Tests), so skipping it cannot leave a PR waiting on a status.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 15:19:33 +00:00
dependabot[bot] 3f8a26c904 chore(deps): bump fast-uri from 3.1.4 to 3.1.5 in /Client/tauri-client (#1323)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.4 to 3.1.5.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.4...v3.1.5)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 17:08:21 +02:00
dependabot[bot] 4a3b4e0cff ci(deps): bump docker/build-push-action from 6.16.0 to 6.19.2 (#1316)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.16.0 to 6.19.2.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/14487ce63c7a62a4a324b0bfb37086795e31c6c1...10e90e3645eae34f1e60eeb005ba3a3d33f178e8)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 6.19.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 17:05:12 +02:00
dependabot[bot] 15db18a3ac chore(deps): bump modernc.org/sqlite from 1.54.0 to 1.55.0 in /Server (#1315)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.54.0 to 1.55.0.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.54.0...v1.55.0)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:59:42 +02:00
dependabot[bot] 4582a601b3 chore(deps): bump knip from 6.29.0 to 6.31.0 in /Client/tauri-client (#1314)
Bumps [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) from 6.29.0 to 6.31.0.
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.31.0/packages/knip)

---
updated-dependencies:
- dependency-name: knip
  dependency-version: 6.31.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:59:37 +02:00
dependabot[bot] a3d03620e5 chore(deps): bump undici from 7.28.0 to 7.29.0 in /Client/tauri-client (#1321)
Bumps [undici](https://github.com/nodejs/undici) from 7.28.0 to 7.29.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.28.0...v7.29.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 7.29.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:49:55 +02:00
dependabot[bot] 5df58bb9c4 chore(deps): bump ip-address (#1320)
Bumps the npm_and_yarn group with 1 update in the /tools/mcp-introspect directory: [ip-address](https://github.com/beaugunderson/ip-address).


Updates `ip-address` from 10.2.0 to 10.4.0
- [Release notes](https://github.com/beaugunderson/ip-address/releases)
- [Commits](https://github.com/beaugunderson/ip-address/compare/v10.2.0...v10.4.0)

---
updated-dependencies:
- dependency-name: ip-address
  dependency-version: 10.4.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:49:51 +02:00
dependabot[bot] 4bda86d0cb chore(deps): bump zeroize in /Client/tauri-client/src-tauri (#1319)
Bumps [zeroize](https://github.com/RustCrypto/utils) from 1.8.2 to 1.9.0.
- [Commits](https://github.com/RustCrypto/utils/compare/zeroize-v1.8.2...zeroize-v1.9.0)

---
updated-dependencies:
- dependency-name: zeroize
  dependency-version: 1.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:49:47 +02:00
dependabot[bot] 6e841cc75f chore(deps): bump @playwright/test in /Client/tauri-client (#1318)
Bumps [@playwright/test](https://github.com/microsoft/playwright) from 1.62.0 to 1.62.1.
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.62.0...v1.62.1)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.62.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:49:43 +02:00
dependabot[bot] d9c6094b5f chore(deps): bump rustls in /Client/tauri-client/src-tauri (#1317)
Bumps [rustls](https://github.com/rustls/rustls) from 0.23.42 to 0.23.43.
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.42...v/0.23.43)

---
updated-dependencies:
- dependency-name: rustls
  dependency-version: 0.23.43
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:49:39 +02:00
dependabot[bot] 4fa8f6f84d ci(deps): bump actions/setup-go from 5.5.0 to 5.6.0 (#1313)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5.5.0 to 5.6.0.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/d35c59abb061a4a6fb18e82ac0862c26744d6ab5...40f1582b2485089dde7abd97c1529aa768e1baff)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: 5.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:49:36 +02:00
dependabot[bot] b854f5a986 ci(deps): bump docker/setup-buildx-action from 3.10.0 to 3.12.0 (#1312)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.10.0 to 3.12.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2...8d2750c68a42422c14e847fe6c8ac0403b4cbd6f)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 3.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:49:32 +02:00
dependabot[bot] 3c78bcd2b2 ci(deps): bump swatinem/rust-cache from 2.7.8 to 2.9.1 (#1311)
Bumps [swatinem/rust-cache](https://github.com/swatinem/rust-cache) from 2.7.8 to 2.9.1.
- [Release notes](https://github.com/swatinem/rust-cache/releases)
- [Changelog](https://github.com/Swatinem/rust-cache/blob/master/CHANGELOG.md)
- [Commits](https://github.com/swatinem/rust-cache/compare/9d47c6ad4b02e050fd481d890b2ea34778fd09d6...c19371144df3bb44fab255c43d04cbc2ab54d1c4)

---
updated-dependencies:
- dependency-name: swatinem/rust-cache
  dependency-version: 2.9.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:49:28 +02:00
J3vbandClaude Opus 5 9d75890f50 fix(release): sign the stripped AppImage from the env, not a temp key file (#1310)
Both Linux release jobs died at "Strip host-incompatible libs from AppImage
and re-sign":

    error: the argument '--private-key-path <PRIVATE_KEY_PATH>'
           cannot be used with '--private-key <PRIVATE_KEY>'

The step exports TAURI_SIGNING_PRIVATE_KEY so it can write the key to a temp
file, then passes that file with -f. But TAURI_SIGNING_PRIVATE_KEY *is* the
env form of --private-key, so the CLI saw the key supplied twice and aborted.
The strip itself had already succeeded ("stripped 4 bundled wayland libs"),
so only the re-sign was lost — and with it both Linux jobs, which skipped
the publish job.

Signing straight from the env drops the mktemp/printf/trap entirely and
keeps the private key off the runner's disk.

Not a regression from the release: the strip-and-re-sign step arrived on
main with #1297 in this very release, so this code path had never run on a
tag before. CI does not exercise it — ci.yml's tauri-build has no strip or
signing step, which is why all three Tauri builds passed there.

The publish job's server-update signing keeps -f deliberately: it signs with
SERVER_UPDATE_SIGNING_PRIVATE_KEY, which the CLI does not read from the
environment, so there is no conflict to avoid there.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:24:54 +02:00
J3vbandClaude Opus 5 086979b7e8 Release v1.2.0-alpha.1 -> main (#1309)
* fix(admin): accept same-origin first-run setup requests

A freshly generated config.yaml leaves allowed_origins commented out, so the
list is empty. The setup handler's CSRF guard assumed "no Origin header means
same-origin", but browsers send Origin on same-origin POSTs too — Chrome and
Edge always, Firefox since 70. The admin panel's own setup call is one of those
POSTs, so every new install hit "cross-origin setup request blocked" and could
never create an owner account.

The guard now accepts a request whose Origin names the same host:port as the
request's own Host header, falling back to the allowlist otherwise. That is what
the original comment intended. CSRF protection is unaffected: a cross-site
attacker cannot set Origin, the browser does, and a foreign origin still needs
an explicit allowlist entry.

Scheme is not compared. Nothing in this server derives the external scheme (no
r.TLS or X-Forwarded-Proto handling exists anywhere), so a scheme check would
reject legitimate requests behind a TLS-terminating proxy.

Tests: isSameOrigin table covering port/host/suffix/schemeless/opaque-origin
cases, plus two handler-level tests pinning both halves — same-origin succeeds
against an empty allowlist, a foreign origin still 403s and creates no user.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(identity): implement identity keypair caching and error handling

* fix(client): use the real OS credential store, not keyring's mock (#1281)

The `keyring` crate declares no `default` feature. Every platform arm in
its lib.rs selects a backend only when that platform's feature is on and
otherwise falls through to `pub use mock as default`, so the client's
bare `keyring = "3"` compiled the in-memory mock store on Windows, macOS
and Linux alike.

The mock keeps its secret in the `Entry` object itself, and each command
built its own `Entry`:

  save_identity_key -> Entry::new(..) -> set_password -> Ok(())
  load_identity_key -> Entry::new(..) -> get_password -> NoEntry

So a save reported success, the very next read in the same process
returned nothing, `NoEntry` was mapped to `Ok(None)` so neither side
logged anything, and no entry was ever written to Credential Manager on
any machine. Downstream, the voice-E2EE identity keypair was regenerated
on reconnect, the published identity key stopped matching the key that
signed the announce, and peers correctly rejected it as a possible MITM.

Name the platform backends explicitly, and stop trusting a store that
reports a write it did not keep:

- secret_store: read every write back and compare before reporting
  success. If the store returns a value we did not write, purge it so it
  cannot shadow the fallback on the next read.
- On Windows only, fall back to a DPAPI-protected file in the app data
  dir, engaged solely after a proven round-trip failure and cleared as
  soon as the real store works again. The account name is mixed into the
  DPAPI entropy so a blob cannot be moved between entries and decrypt.
  macOS/Linux report an error instead of writing secrets to plaintext.
- Log the compiled backend at startup and add `probe_credential_store`
  so an affected machine can be diagnosed from its own log file.
- Guard the regression: `compiled_keyring_backend_is_persistent` fails
  the build if the features are ever dropped again. Verified to fail
  against `keyring = "3"`.

The E2EE fail-closed posture is unchanged: a peer whose announce
signature does not verify is still rejected.

Linux builds now need `libdbus-1-dev` for the Secret Service backend.


Claude-Session: https://claude.ai/code/session_016oUHtEUWWxC79eB88GvX58

Co-authored-by: Claude <noreply@anthropic.com>

* fix(client, admin): make the settings panel, client, and admin panel do what they say (#1282)

* fix(client): make the settings panel do what it says

Functional review of every control in the settings overlay. Each fix below
closes a gap between what a control promised and what it did.

- Appearance: picking a theme no longer drops a saved accent colour.
  applyThemeByName strips every inline custom property from <body>, which
  includes the accent override; under neon-glow (whose body class sets
  --accent) the user's colour silently reverted until restart.
- Overlay: reopening the panel rebuilds the active tab. The Voice & Audio
  mic meter and camera preview are torn down on close, so a reopened panel
  showed a dead meter and a black preview; tabs also now re-read prefs.
  The Logs tab's live listener is released when you switch away from it.
- Status: the UserBar picker always started at "online" and never persisted,
  while the Account tab read a pref nobody else wrote — the two surfaces
  disagreed. Both now go through lib/userStatus, sync live via the
  pref-change event, and the saved status is re-asserted on connect.
- Notifications: Do Not Disturb now suppresses the desktop notification and
  the chime, as its description in the panel claims. The taskbar flash, a
  passive cue, stays.
- Keybinds: Ctrl+F, Ctrl+M, Ctrl+D, Ctrl+Shift+V and Ctrl+U were listed but
  unimplemented. They are wired now (voice ones only while in voice, all of
  them suspended while the settings panel is open). "Mark as Read" had no
  feature behind it at all and is replaced by the Escape behaviour that
  actually exists.
- Account: backup codes now carry a "you won't see them again" warning and a
  copy button; the change-password form requires the current password before
  spending a server attempt and disables itself while in flight.
- Advanced: removed the Hardware Acceleration toggle. Nothing read the
  preference it wrote — the webview decides GPU compositing before any JS
  runs, so honouring it needs a Rust startup change.
- The settings sidebar name/avatar follow a rename instead of going stale,
  and settings/helpers no longer keeps a drifted copy of lib/preferences
  (the copy lacked the write guard, so a failed save could throw).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm

* fix(client): close silent-failure gaps in the inline admin surface

Continuation of the settings-panel review into the rest of the client.

- Member context menu had no styling at all: AdminActions renders BEM class
  names (context-menu__item and friends) that appear nowhere in the CSS, so
  the menu had no hover, no danger colour, and the "Change Role" submenu
  pushed the menu open instead of flying out. Added the missing rules.
- The submenu offered a hardcoded admin/moderator/member list. On a server
  with custom roles those roles were unreachable, and picking a name that
  didn't resolve to a role id silently did nothing. Roles now come from the
  server's ready payload (owner excluded), and an unresolvable role reports
  an error instead of dead-ending.
- Kick / ban / delete-channel now show an in-flight state, and the two-click
  confirm disarms after a few seconds so a menu left open can't turn a stray
  click into a ban (docs/architecture/ux/settings-and-admin.md §3).
- Ban collects a reason, which the server already stores and displays
  (adminBanMember has always accepted one; the menu never passed it).
- Copying an invite code was silent: no confirmation, and a clipboard
  rejection looked identical to success. It now toasts either way.
- Creating an invite double-click-minted two of them, and revoking — which
  kills a live link — had neither a confirm nor an in-flight guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm

* fix(client): restore moderator message deletion and formatting

- The delete affordance was offered only on your own messages, so a
  moderator could not moderate anything from the client. It now also
  appears when the signed-in user's role carries MANAGE_MESSAGES, derived
  from the role bitmasks the server already sends in `ready` (this is what
  docs/architecture/ux/messaging.md §4 specifies as "Delete (own /
  moderator)"). lib/permissions.ts existed for exactly this and had no
  callers at all.
- Developer-mode "Copy ID" was silent on success and swallowed clipboard
  failures; it toasts either way now.
- prettier --write on AdminActions.ts (Client Static Checks).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm

* fix(admin): stop the panel reporting success it didn't have

Functional review of the server admin web panel.

- An expired admin session left the panel on screen toasting "invalid or
  expired session" for every action, with no way back to the login form —
  only the log-stream code handled it. api() now handles 401 centrally:
  clear the token, return to login, and say why.
- Deleting a backup called fetch() without looking at the response, so a
  failed delete reported "Backup deleted" and left the file in place. It
  now goes through api(), and — like every other destructive action here —
  asks for confirmation first.
- A failed update check rendered as "Up to date. You're running the latest
  version", which is a lie that hides a broken update path. It now says the
  check failed and why. A failed apply no longer leaves the button stuck on
  "Applying...".
- The Edit Channel modal could only rename. PATCH /channels/{id} accepts
  topic, slow_mode, position and archived, and the channel table has an
  Archived column — which was read-only state with no control behind it.
  All four are editable now.
- Banned users showed "Yes" with no reason, even though the ban reason is
  collected on ban and returned by the API. It's now displayed.
- Login and first-run setup had no in-flight guard, so a double-click spent
  two attempts against the login lockout / setup rate limit. Settings' Save
  stayed enabled after a successful save, implying unsaved changes.
- Clipboard copies (invite code, new API token) had no rejection path: a
  refused clipboard looked exactly like a successful copy.
- Backup names in inline onclick handlers go through jsq() like every other
  interpolated string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm

* feat(admin): add the plugin management UI the backend already had

/api/v1/admin/plugins has exposed list/install/enable/disable/uninstall since
Phase C Step 9 — its own header says it "exposes plugin lifecycle operations
to the admin panel", and docs/architecture/ux/settings-and-admin.md tells
operators plugin management lives in the web panel. The panel had no Plugins
section at all, so installing a plugin meant hand-crafting a multipart POST.

Panel:
- Plugins section: installed table (name, manifest description and requested
  permissions, version, enabled state, install date), zip upload with the
  16 MB server cap stated up front, enable/disable, and uninstall behind a
  confirm. One lifecycle call at a time.
- The lifecycle API sits under a different prefix than the rest of the panel
  and answers errors as plain text (http.Error), not JSON, so it gets its own
  fetch helper — sharing api() would have surfaced "unexpected token" instead
  of the server's reason. 401 still routes back to login.

Server:
- PluginRow had no JSON tags, so the list marshalled Go field names and every
  column would have rendered empty. Now snake_case like the rest of the API.
- GET /plugins returns X-Plugin-Runtime: enabled|disabled. An empty list means
  "nothing installed" on a live runtime and "you can't install anything" on a
  disabled one; the body can't tell them apart, so the panel's empty state
  had no way to be honest about it.

The plugin-store test helper now hands back the database the registry writes
to — the existing happy-path test wired a *different* in-memory DB into the
handler, which is why nothing noticed the list was always empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm

* feat(client): gate the composer on slow mode instead of failing the send

Verified the optimistic message lifecycle against docs/architecture/ux —
pending → chat_send_ok → sent, failed rows with mapped reasons, retry and
delete-draft all behave as documented. One thing did not: slow mode.

The UX spec (§5) says slow mode should "disable send with a live countdown in
the composer; do not drop the drafted message". In practice the composer knew
nothing about it: you typed, sent, and got a red failed row back — the exact
enabled-then-rejected pattern §6.2 forbids. The client never even received the
channel's slow_mode value.

- Server: channel payloads (ready, channel_create, channel_update) now carry
  slow_mode alongside can_send, for the same reason can_send is there — the
  client can express the limit as affordance. The server still enforces.
- Client: after an accepted send the composer disables itself for the channel's
  cooldown with a per-second countdown, and a SLOW_MODE refusal restarts the
  full window (the server's limiter is the authority on when the next send is
  allowed). The draft stays in the textarea. Moderators, who bypass slow mode
  server-side, are not gated.
- The MANAGE_MESSAGES lookup added for moderator deletes moves into
  lib/permissions as currentUserPermissions/currentUserHasPermission/
  canManageMessages, so the composer and the message renderer share one
  definition instead of two.
- WsErrorCode listed 9 of the server's 16 codes: SLOW_MODE, CONFLICT,
  BAD_REQUEST, INVALID_JSON, UNKNOWN_TYPE, BAD_PAYLOAD, NOT_KEY_HOLDER and
  ALREADY_JOINED were missing, so code switching on it could not name cases
  the server actually sends. Now mirrors Server/ws/errors.go.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm

* fix(admin): make backup restore actually restart, and fail closed without a safety copy

Verification pass over the remaining review items. Two real defects in restore,
one duplicate resolved; cert TOFU and the replay path checked out as-is.

Restore:
- The handler closed the database, swapped the file underneath it, told the
  admin "database restored — server restarting", broadcast a 5-second restart
  countdown to every client... and then kept running. Nothing restarted it, so
  the server answered every subsequent request against a closed DB until an
  operator noticed. It now respawns for real, reusing the update-apply pattern
  (SpawnDetached → SIGTERM → os.Exit backstop) behind a test seam.
- A failed pre-restore backup was a warning, and the irreversible overwrite
  went ahead anyway — removing the safety net the panel explicitly promises
  ("A pre-restore backup will be created"), precisely when it matters. It now
  aborts with the database untouched.
- The safety copy was written to a cwd-relative "data/backups" while every
  other backup handler uses the absolute backupBaseDir, so a server started
  from another directory filed it somewhere the operator would never find.

Both new tests were confirmed to fail against the previous behaviour.

Client:
- SidebarArea kept a private 140-line copy of the member-list wiring that
  SidebarMemberSection already provides (the extracted, tested one was never
  imported). Fixing the silent role-change failure earlier meant patching both;
  now there is one copy.

Verified without changes: the optimistic send lifecycle (pending →
chat_send_ok → sent, failed rows with mapped reasons, retry, delete-draft),
reconnect replay (monotonic last_seq, dedup on reconnect, replay suppression
of unread/notifications), and cert TOFU (first-use and mismatch modals, accept
re-pins and reconnects, reject disconnects back to connect).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm

* fix(admin): remove the data race in the restart test hook

CI (-race) failed identically on ubuntu and windows: TestHandleRestoreBackup_
Success polled a plain bool that the restore handler's goroutine wrote, and
swapped the restartSelf package var from the test goroutine while that handler
read it.

The hook is now behind a mutex with an atomic flag in StubRestart. Production
behaviour is unchanged — the race was entirely in the test seam I added.

Verified with `go test -race -count=2 ./admin/`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm

---------

Co-authored-by: Claude <noreply@anthropic.com>

* refactor + perf: split largest source files into modules; optimize hot paths (#1283)

* refactor(updater): split updater.go into cohesive files

Split the 1070-line updater.go into four files within the same package:
updater.go (core types, release checking), download.go (download and
tarball extraction), verify.go (signatures, checksums, staged binary),
and assets.go (client assets, text-asset cache, HTTP fetching).

Pure mechanical move — no behavior or API changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

* refactor(ws): split hub.go into cohesive files

Split the 1289-line hub.go into five files within the same package:
hub.go (Hub struct, lifecycle, register/unregister), hub_broadcast.go
(broadcast fan-out and per-user sends), hub_events.go (sequencing,
replay, persistence), hub_sweep.go (stale client/session/voice
sweepers), and hub_livekit.go (LiveKit accessors).

Also optimizes wrapWithSeq on the hot broadcast path: build the seq
prefix with a single preallocated append + strconv.AppendUint instead
of fmt.Sprintf, halving allocations per broadcast message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

* refactor(client): extract E2EEManager from livekitSession

Move all client-side E2EE key-exchange logic (~550 lines) out of
LiveKitSession into a new E2EEManager class in livekitE2EE.ts: ECDH
keypair management, identity signing and TOFU pin verification,
announce/offer handling, key-holder election, membership rekeying, and
periodic key rotation. Dependencies are injected following the existing
roomEventHandlers pattern.

LiveKitSession keeps thin public delegates (handleE2EEAnnounce,
handleE2EEOffer, handleParticipantLeft, rePinPeerIdentity) so the
module-level bound exports and the public API are unchanged.
livekitSession.ts shrinks from 1955 to 1409 lines.

Adds focused unit tests for E2EEManager (key-holder setup, pending
announce queue, offer resolution, clearState, rotation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

* perf(server): hot-path and query optimizations

Logging (biggest win): rewrite the admin log RingBuffer as a true ring
(fixed array + head/count) instead of allocating a fresh 2000-entry
slice + full copy per log line; gate the ring handler on a configurable
level instead of unconditional DEBUG capture; move the broadcast debug
log out of the seqMu critical section; drop the per-message slog.With
clone in the WS handler.

Database: new migration 019 adds idx_attachments_message (message pages
no longer scan the attachments table), a covering role-leading index on
channel_overrides (replacing a duplicate of the UNIQUE auto-index), a
partial index for pinned messages, and narrows the FTS trigger to
content changes only; ANALYZE runs after migrations. Rewrite
GetChannelUnreadCounts and GetUserDMChannels to correlated subqueries
that range-scan idx_messages_channel — O(unread) instead of O(all
messages) per WS connect. New GetUserDMChannelIDs replaces the full DM
query where only IDs are needed. CreateMessage/EditMessageContent use
RETURNING, removing the re-read after every send/edit.

Write-path contention: TouchSession throttled to once per minute per
session (was one UPDATE per authenticated request); EventPersister
flushes its batch in a single transaction with per-row fallback;
revoked-session and stale-voice sweeps run off the hub dispatch
goroutine with an in-flight guard, and session checks are batched into
one IN query; the rate limiter is sharded into 32 buckets with
allocation-free strconv key building (auth.Key).

WS structural: voice E2EE channel fan-out goes through the existing
pubsub voice topic instead of scanning every connected client under
h.mu; channelReadAudience memoizes role lookups per call;
hasChannelAccess drops its redundant duplicate permission check;
voice_join batches SPEAK/VIDEO/SCREENSHARE checks via
HasChannelPermBatch. Also: pubsub topic builders and NewAppMetrics
stop allocating via Sprintf/global mutex.

Verified with go test -race across all packages, go vet, gofmt, and
sqlc generate idempotency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

* perf(client): render-path, logging, and bundle optimizations

Logging: the logger no longer runs permanently at debug — level is set
from the environment at startup (debug in dev, info in prod), so every
hot-path debug entry stops being serialized, buffered, consoled, and
persisted to disk; per-URL debug logs in embed rendering removed.

Render path: MessageList's store selector is scoped to the mounted
channel, so messages in other channels no longer trigger re-renders,
and a new incremental tail-append fast path appends rows instead of
tearing down the whole window; Intl.DateTimeFormat instances are cached
at module level; parseTimestamp memoizes epoch millis; media prefs
(showEmbeds/inlineMedia/showLinkPreviews/animateGifs) are cached with
pref-change invalidation; members store gains a roleRevision counter so
MessageList stops rebuilding a role map on every presence/typing event.

MemberList patches presence changes in place (status dot + offline
class) via a row map instead of rebuilding every row, with single-pass
role grouping. ChannelSidebar splits its voice subscription into a
structural selector (excluding speaking) and a speaking-only patcher
using a cached element map instead of per-user querySelector on every
speaker event.

Memory: GIF/media elements are unobserved before the message window
discards them, fixing unbounded IntersectionObserver retention of
detached DOM (including frozen-frame data URLs).

Bundle: livekit-client (1.3 MB) moves to its own chunk via dynamic
imports and manualChunks; the READY handler's stale-voice check reads
the voice store instead of requiring the module synchronously.

Adds 11 focused tests (different-channel no-rerender, append fast path,
media release, presence patch, speaking patch). Full unit suite:
3606/3606 passing; typecheck, lint, and production build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(ci): skip alloc test under deadlock tag; cut bcrypt cost in tests (#1284)

The deadlock-tag CI pass failed on TestRingBuffer_WriteDoesNotAllocate:
under -tags deadlock, syncutil.Mutex is the go-deadlock mutex whose Lock
allocates, so the steady-state ring write measures 1 alloc/call. Extend
the build constraint to !race && !deadlock — the test's guarantee is
about the ring buffer itself, which the -race-less default pass covers.

Make bcryptCost a var with an exported SetCostForTesting hook that also
resets the dummy timing pad, and call it with bcrypt.MinCost from the
api, auth, and admin TestMains. Password hashing at production cost 12
dominated those suites (~264 hashes): with the race detector the api
package alone took ~860s; it now runs in ~33s. Nothing under test
depends on hash strength, and no test asserts the cost.

Hygiene in the same pass: migration 020 drops idx_sessions_token and
idx_invites_code (exact duplicates of their UNIQUE auto-indexes, pure
write overhead) with updated db_test assertions; remove the dead
tar.TypeRegA comparison in the updater (stdlib normalises it to TypeReg
since Go 1.11); gofmt storage/storage.go comment alignment.


Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

Co-authored-by: Claude <noreply@anthropic.com>

* perf(ws): route hot-path permission checks through the cached PermissionService (#1285)

The ws package was the only major subsystem still doing live per-check
permission queries (GetRoleForUser + GetChannelPermissions per check):
a V2 voice join cost 9+ DB reads across its four gates, and every
channel broadcast resolved one role query per connected client.

Hub now holds svc.Permissions and the voice deps carry it (nil-safe:
bare test fixtures fall back to the existing live path, fail-closed
semantics preserved everywhere). Converted sites: the voice join and
token-refresh permission gates, USE_VIDEO/SHARE_SCREEN controls,
requireChannelAccess, channelReadAudience, and RefreshChannelVisibility.

Caching these is revocation-correct: every permission-changing mutation
already invalidates synchronously before hub fan-out (InvalidateUser on
role change, InvalidateAll on override change), the 30s TTL is only a
backstop, and the service's gen-counter guard prevents a populate that
races an invalidation from caching stale data — the audience-resolution
comments now document that invariant. The stale-voice sweeper's check
deliberately stays live: it is the last-line backstop for revocations
that might bypass an invalidation hook, runs once a minute for only
in-voice clients, and its eviction test pins exactly that guarantee.

requirePerm keeps its INTERNAL-vs-FORBIDDEN distinction by using the
cache only for positive verdicts and falling through to the live path
on denial.

Adds perm_cache_test.go: role-change invalidation is immediate (no TTL
wait), and a counting-store test proving the second check is served
from cache. All pinning tests (authz, voice_perm_stale, channel
visibility agreement, sweep eviction) pass unmodified.


Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

Co-authored-by: Claude <noreply@anthropic.com>

* perf + refactor: SQLite reader pool, async audits, real lazy-livekit, test splits, eslint 10 (#1286)

* perf(db): batch audit writes through an async writer

Audit inserts ran synchronously on the request path — including one
INSERT per WebSocket connect — each an implicit transaction on the
single SQLite connection.

WriteAudit keeps its exact signature and D8 policy (never fail the
caller, never silently discard): it now upgrades to an async path when
the passed Auditor also implements AsyncAuditor. *DB implements that
via an atomic pointer that main.go populates at server startup with an
AuditWriter modeled on the event persister (bounded queue, batched
single-transaction flush with per-row fallback, drain-on-stop, atomic
counters, non-blocking enqueue that error-logs drops without leaking
the detail field). The token CLI and tests never install a writer, so
they keep today's synchronous behavior with zero call-site changes.

The writer's Stop defer registers after database.Close's so the LIFO
unwind drains the queue before the DB shuts.

Adds audit_writer_test.go: batch flush, D8 drop logging, drain-on-stop,
flush-failure accounting, poison-row fallback, concurrent enqueue, and
seam tests pinning sync-without-writer vs async-with-writer behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

* perf(client): actually defer livekit-client; honor saved log level at startup

The manualChunks split was cosmetic: index.html modulepreloaded the
531 kB livekit chunk and the entry statically imported it. All four
import chains from startup are now cut — auth.store's logout leaveVoice
and ptt's setMuted go through dynamic imports, applyStoredAppearance
moved to lib/appearance.ts so main.ts and ConnectPage stop pulling the
settings tree (whose overlay now loads on first open), and MainPage
itself is a dynamic import in renderPage, guarded against the
destroy-before-mount race by a navigation-generation helper and
pre-warmed once the socket connects.

Entry chunk drops 387 kB -> 114 kB (gzip 36 kB); index.html has no
modulepreload links; livekit/MainPage/SettingsOverlay/livekitSession
load as lazy chunks.

The logger now honors the Logs tab's saved minimum level at startup
(applyStoredLogLevel with the legacy-key migration moved into
lib/preferences.ts) and re-applies it live on pref changes.

Dead code: remove unreachable VoiceChannel.ts (superseded by
ChannelSidebar's renderer) and its test, plus all knip-flagged unused
re-exports in message-list/renderers.ts and ConnectPage's unused form
types — knip is now clean apart from pre-existing config hints.

Tests: +12 (navigation guard incl. stale-mount discard; logger startup
pref, migration, and live re-apply); ptt/stored-appearance updated for
dynamic-import plumbing only. Full suite 3593 passing; typecheck, lint,
and production build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

* perf(db): split SQLite into single-writer + multi-reader connection pools

The entire server serialized on one SQLite connection: every read
queued behind every other read and every write, throwing away WAL's
concurrent-reader capability.

File-backed databases now open two pools from a DSN that carries all
seven PRAGMAs as per-connection _pragma parameters (an Exec'd PRAGMA
only configures one arbitrary pooled connection — moving them into the
DSN is what makes >1 connection safe, foreign_keys included): a
single-connection writer with _txlock=immediate, and a reader pool
sized max(4, NumCPU). In-memory databases keep the exact historical
single-connection behavior, which preserves every :memory: test site
and the connection-scoped PRAGMA-toggle tests untouched.

Routing lives in a dbtx router implementing sqlc's DBTX: statements go
to the reader only when provably read-only (leading SELECT/PRAGMA after
skipping comments — necessary because sqlc routes INSERT/UPDATE/DELETE
... RETURNING through QueryRowContext/QueryContext, which must stay on
the writer); Exec, transactions, migrations, ANALYZE, VACUUM INTO, and
the SQLDb() escape hatch all pin to the writer. Every former sqlDB
reference across the package was re-pointed deliberately.

New pool_test.go pins the properties the split must preserve on a
file-backed DB: foreign_keys=1 across many reader connections, WAL
journal mode, FK enforcement through both write paths, 8x8
concurrent reader/writer hammering with exact row counts, and a read
completing against the pre-tx snapshot while a write transaction is
open — the property this change exists to unlock.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

* test(client)+chore: split the two largest test files; eslint 10; audit clean

Split tests/unit/ws.test.ts (3340 lines) into ws-cert / ws-reconnect /
ws-messaging / ws-lifecycle plus a shared helpers/ws-mocks.ts module,
and tests/unit/audio-pipeline.test.ts (2547 lines) into core / gain /
vad-worklet / vad-fallback files. Test bodies moved verbatim; the
suite count is unchanged at 3593 passing.

Upgrade eslint 9 -> 10 (with @eslint/js 10; typescript-eslint's peer
range already covers v10, flat config unchanged, zero new findings)
and pin test-exclude ^8 via the existing overrides block so the
coverage chain picks up patched glob/minimatch/brace-expansion.
npm audit: 8 high -> 0 vulnerabilities.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

* refactor(server): split remaining large files; dependency hygiene notes

Split ws/coverage_boost_test.go (2856 lines) into coverage_helpers /
chat / voice / voice_lifecycle / misc test files — bodies verbatim,
746 passing tests before and after. Split service/message.go (781)
into message_crud / message_reactions / message_query / message_perms
with types and the constructor staying put, and ws/serve.go (754) into
serve / serve_pumps / serve_auth / serve_ready.

Dependency findings (no changes needed): coraza-coreruleset's stale
Feb-2024 pseudo-version is unreachable from our code — it enters the
module graph only through coraza's own internal tests, and our WAF uses
inline directives, never the CRS (fresher rules would require adopting
the /v4 module and rewiring the WAF config — deliberate follow-up, not
hygiene); gogo/protobuf is likewise graph-only via the livekit SDK and
never built into our binaries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

* style: satisfy golangci-lint modernize/staticcheck in new pool and audit code

CI's golangci-lint pass (not run locally until now) flagged the
Phase 3/4 additions: range-over-int loops, interface{} -> any on the
dbtx router, WaitGroup.Go in the pool tests, and a De Morgan
simplification in isReadOnlySQL's identifier-boundary check. Pure
style — verified against the same golangci-lint v2.11.3 binary CI
uses (0 issues) and re-ran db/ws race + deadlock suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(waf) + fix(deps) + test(ws): OWASP CRS, Dependabot fixes, sleep-free ws tests (#1287)

* fix(deps): clear quick-xml RUSTSEC advisories in Tauri lockfile

cargo-audit identified the two Dependabot alerts on the default branch:
quick-xml 0.37.5 and 0.38.4 both carry RUSTSEC-2026-0194 (quadratic
runtime on duplicate-attribute checks) and RUSTSEC-2026-0195 (unbounded
namespace allocation DoS), fixed in >=0.41. Both were transitive:
plist 1.8.0 (via tauri) and tauri-winrt-notification 0.7.2 (via
notify-rust). Semver-compatible updates fix both — plist 1.10.0 moves
to quick-xml 0.41, and tauri-winrt-notification 0.7.3 drops quick-xml
entirely. cargo-audit is now clean of vulnerabilities; the remaining
20 informational notices are the unmaintained GTK3-binding crates
inherent to Tauri v2 on Linux. Verified plist compiles against
quick-xml 0.41 (full Tauri build needs the GTK/WebKit system libs CI
installs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

* feat(waf): layer the maintained OWASP Core Rule Set onto the WAF

The WAF previously ran six inline directives only — the CRS never
loaded (the old coreruleset dep was a stale graph-only pseudo-version).
A second Coraza engine now loads the embedded CRS from
coraza-coreruleset/v4 (v4.25.0), layered on top of the inline rules,
which stay byte-identical and keep blocking exactly as before.

CRS ships in a new server.waf_crs_mode knob (off|detect|block),
defaulting to detect: chat traffic is CRS-false-positive-prone (a new
test pins that block mode rejects benign SQL-ish chat prose at the
default threshold), so operators get rule-match visibility via
structured logs first and opt into blocking after tuning. Setup
mirrors the official connector: Host/Transfer-Encoding restored to the
transaction (else 920280 fires on everything), phase 2 always runs so
query-string attacks are scored, PUT/PATCH/DELETE added to the CRS
method policy for this REST API, body limits matched to the app's
1 MiB cap with uploads excluded from body access and the content-type
policy.

Also fixes a latent middleware bug: the body was previously swapped
for the buffered reader even when nothing was buffered, which would
have handed body-access-off routes an empty body; now pinned by a test
across all modes.

Adds waf_crs_test.go (load, mode wiring, XSS/traversal detection
without blocking, block-mode blocking + benign passthrough, upload
body preservation); waf_test.go passes unmodified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

* test(ws): replace fixed sleeps with condition-based waits

The ws suite paced async hub effects with 537 fixed time.Sleep calls —
slow at best, flaky under load at worst. They are now condition-based:
a small waitFor/waitRegistered/waitClientCount/waitMsgOfType helper set
(waitRegistered exploits the hub's in-order client-event processing),
plus blocking decode-scans for the DM tests.

The bulk deletion is grounded in verified production facts, unchanged
by this commit: sendMsg is a synchronous buffered send (error replies
are already buffered when the handler returns), the voice control /
rollback / cleanup / sweep paths are synchronous, and serve.go
registers the client before writing the ready frame. Absence
assertions were deliberately NOT inverted into polling — they keep
bounded windows, each commented.

20 sleeps remain, all justified in place: poll intervals inside
condition loops, absence windows, clock-granularity pacing, and the
event-pruner's inherently time-based no-prune-after-cancel assertion.

Suite: 746 tests before and after; 62.6s -> 46.1s (30s of the
remainder is GracefulStop's hard-coded production 5s drain, out of
scope here); race flake check passes 3 consecutive iterations;
deadlock pass and golangci-lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix: audit-driven fixes — client leaks/lazy-load, WAF detect logging, audit shutdown race (#1288)

* fix(waf,db): aggregate CRS detect-mode logging; make audit Stop await goroutine exit

WAF detect mode wired logCRSMatch as the engine-level error callback, which
fires one slog.Warn per matched rule on the request goroutine. In the default
detect mode ordinary chat prose trips several CRS SQLi/XSS rules plus anomaly
scoring, so each request logged a burst of Warn lines in the hot path.

Aggregate per request from per-transaction state instead of the shared global
callback: in the default detect path leave the engine error callback nil and,
in the existing crsTx defer, emit at most one Warn per request that had matches
(count + highest-severity rule), demoting the full rule-id list to Debug.
Block mode keeps per-rule logging (blocked requests are rare and their detail
is wanted), and a caller-supplied onCRSMatch callback keeps per-rule delivery
so existing tests stay unmodified. Detection, interruption, and body handling
are unchanged — only the detect-path logging shape.

The audit writer's Stop selected between <-done and <-ctx.Done(); on a slow
flush the 5s ctx could win, returning while run() was still flushing. main.go's
LIFO defers then closed the DB pool under a live flusher, losing audits. Stop
now always waits on done (the goroutine has stopped touching the store) while
ctx bounds only the drain inside run() via a published stopCtxDone channel, so
a slow store delays shutdown by at most one in-flight flush and the pool is
never closed under a live writer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

* fix(client): plug listener leaks, guard lazy livekit load, honor saved log level

Follow-up audit of the recently-landed lazy-livekit and session wiring found
three real issues:

- clearAuth unconditionally dynamic-imported livekitSession to call leaveVoice
  on every logout, pulling the ~531 kB livekit chunk into the logout path even
  when no voice session was ever active. Guard the import on an active voice
  session (currentChannelId set and status not idle) and add a .catch so a
  failed teardown import can't reject unhandled.

- The onStateChange handler unsubscribed session listeners only on the ready
  transition, not on disconnected; user_update and ready listeners registered
  per session were never collected for cleanup. Collect them into a
  sessionUnsubs array cleaned up on both ready and disconnected, preventing
  duplicate handlers accumulating across reconnects.

- The Logs tab min-level select ignored the persisted log level when no
  explicit dropdown preference was saved. Add logger.getLogLevel() and default
  the select to it so the UI reflects the level actually in effect.

Also add .catch to the ptt setMuted dynamic import. New unit tests cover the
clearAuth guard, getLogLevel, and the LogsTab default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(waf): load embedded OWASP CRS ruleset correctly on Windows (#1289)

The CRS WAF engine failed to initialize on Windows, taking the whole api
package's test suite red there. coraza's seclang parser resolves Include
globs through path/filepath: for every match of `Include @owasp_crs/*.conf`
it calls filepath.Join(currentDir, match), which on Windows rewrites the
forward slashes to backslashes. It then feeds names like
`@owasp_crs\REQUEST-901-INITIALIZATION.conf` back into the root fs.FS. That FS
is the ruleset's embed.FS, which is always forward-slash and rejects a
backslash name, so newCRSWAF returned "file does not exist" and no CRS rule
under a subdirectory was ever loaded.

Wrap coreruleset.FS in a small slash-normalizing fs.FS (Open/ReadFile/ReadDir/
Glob) that converts backslashes to forward slashes before delegating. This
fixes CRS loading on Windows without patching coraza or the ruleset module and
is a no-op where the separator is already "/". The Linux-only local
verification for the CRS work missed this because coraza never emits
backslashes there.

The new test reproduces the failure mode on any OS by constructing the exact
backslash name coraza produces on Windows: the raw ruleset FS fails to read
it, the wrapper resolves it, and a forward-slash path still works.


Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

Co-authored-by: Claude <noreply@anthropic.com>

* fix(e2e): repair the Playwright suite so the CI job stops timing out (#1291)

The Client E2E CI job never completed: every run hit its 25-minute cap and
was cancelled. ~229 of the 255 web tests were failing, all cascading from
the shared login helper, and 255 tests x 3 attempts x 20-45s of timeout
burn on 1 worker deterministically exceeds the cap.

Root cause: the e2e Tauri mock predates the Rust HTTP TOFU proxy. api.ts
now awaits invoke("start_http_proxy") and builds REST URLs as
http://127.0.0.1:{port}/api/v1/..., but the mock's invoke returned null for
the unstubbed command, so every URL got a literal "null" port and Request
construction threw before the mocked plugin:http transport was consulted.
Login rejected, [data-testid='app-layout'] never mounted, and every
logged-in test burned its full timeout. Stubbing start_http_proxy with any
numeric port fixes the cascade because route matching is substring-based.

The tail of failures after that fix were tests asserting behavior the app
intentionally changed:

- The ready payload can no longer pre-connect the local user to voice: the
  dispatcher treats "self in ready.voice_states while idle" as stale state
  from a reload and immediately leaves. MOCK_VOICE_STATE now seeds remote
  users only (2, 3), and widget tests join through the real click path via
  a new joinVoiceChannelByName helper.
- The mock's voice_join reply no longer includes a voice_token: a token
  starts a real LiveKit session that deterministically self-destructs in
  the browser mock (E2EE key exchange timeout ~15s / connect-refused
  retries), tearing the widget down mid-test. These web tests validate the
  WS/UI layer only; real LiveKit is covered by the native suite. The reply
  also gained the full VoiceStatePayload shape — the sidebar renders
  user.username directly, and the omitted field broke the whole voice-user
  list render.
- Message-load failure now renders an inline region error + Retry instead
  of a toast (UX spec 2), so the toast specs assert the inline UI and get
  their auto-dismiss vehicle from the delete-confirmation toast.

CI hardening so a future systemic breakage can never burn the full cap
again: maxFailures 20 and a 20-minute globalTimeout in CI (Playwright now
self-terminates with a usable report instead of being SIGKILLed), with the
workflow's timeout-minutes 25 as the outer backstop. The job stays
continue-on-error until it has proven stably green across a few pushes;
the ci.yml comment documents that flip trigger.

Full suite: 255/255 passing locally (~7.5 min at 1 worker, ~4 min at 2).
Unit tests (3598), typecheck, and prettier all clean.


Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu

Co-authored-by: Claude <noreply@anthropic.com>

* feat(admin): first-run setup wizard with config.yaml write-back + LiveKit auto-download (#1290)

* feat(admin): first-run setup wizard with config.yaml write-back

Turn the single-screen owner-account setup into a guided multi-step wizard
so non-technical operators never have to hand-edit YAML:

- config: new comment-preserving config.Save (yaml.Node round-trip, atomic
  temp+rename write, verified loadable before replacing the file) plus a
  shared config.DefaultPath. Persists the runtime-generated LiveKit
  credentials so voice tokens survive restarts.
- admin: POST /admin/api/setup accepts an optional "wizard" object
  (server name, MOTD, registration, port, TLS mode/domain, upload limit,
  voice quality). Values are validated before the account is created; DB
  settings and config.yaml are written after; failures downgrade to
  warnings so the created owner is never orphaned behind a 5xx. When a
  startup-only value changed the server restarts itself (reusing the
  backup/update restart machinery) and returns the new admin URL.
- admin: GET /admin/api/setup/status now returns secret-free prefill
  defaults while setup is pending.
- admin panel: six-step wizard UI (welcome, account, server basics,
  uploads & voice, access, review) with plain-language explanations, a
  restart/reconnect screen, and a "skip" path that keeps the legacy
  account-only flow byte-for-byte.
- legacy payload {username,password} and all existing call sites keep
  working (SetupOptions is a trailing variadic parameter).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB

* fix(lint): satisfy modernize — any over interface{}, new(expr) over ptr helper

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB

* feat(voice): auto-download the LiveKit server binary

Voice now works with zero manual setup: when voice.auto_download_livekit
is enabled and no voice.livekit_binary is configured, the server fetches
the pinned livekit-server release (v1.13.5, overridable via
voice.livekit_version) from the official LiveKit GitHub releases in the
background at startup, verifies it against the release's checksums.txt,
extracts it into data/livekit/, and manages it as the existing companion
process (crash recovery, health checks, graceful shutdown).

- ws: new livekit_download.go — pinned version, per-platform asset
  mapping (linux/windows × amd64/arm64/armv7, matching LiveKit's
  goreleaser config), size-capped downloads, hash verification and
  extraction through one open handle (TOCTOU-safe), O_EXCL staging,
  atomic rename, stale-version cleanup. LiveKitProcess.Start resolves
  the binary asynchronously with retries so boot is never blocked.
- config: voice.auto_download_livekit + voice.livekit_version; enabled
  in the generated default config so fresh installs get working voice
  out of the box, while the compiled-in default stays off for existing
  configs. config.Load now loads the default file it just wrote, so the
  first boot runs with exactly the configuration the file documents.
- wizard: "Voice chat" toggle (on by default) in the Uploads & voice
  step; the choice is written to config.yaml and factored into the
  restart decision.
- docs: livekit-setup, server-configuration, deployment, README.

Verified end-to-end against the real v1.13.5 release: download,
checksum match, extraction, and process spawn all succeed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB

* chore: remove stray server.log, ignore local run logs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(voice): desktop-client origins on LiveKit proxy + client version bump to alpha.5 + release version guard (#1293)

* fix(voice): accept the desktop client's webview origins on the LiveKit proxy

The desktop client's chat connection goes through its Rust proxy, which
sends no Origin header, so the safe-default empty allowed_origins never
blocked it. The LiveKit JS SDK's signal requests and validate probes,
however, are issued directly from the webview and carry its fixed origin
(http(s)://tauri.localhost on WebView2, tauri://localhost on
WKWebView/WebKitGTK). isOriginAllowed treated those as cross-origin and
returned 403, so on every default install voice failed for any desktop
client that wasn't on the server machine — chat worked, voice didn't,
with /livekit/rtc/v1 403s in the server log.

Treat these fixed first-party origins as always allowed. This is the
same trust already extended to absent-Origin requests: web content can
never present them (browsers resolve *.localhost to loopback and cannot
reach the tauri:// scheme), so the CSRF surface is unchanged. Exact,
case-insensitive matching only — lookalikes (tauri.localhost.evil.com,
tauri.localhost:8080) still require an explicit allowlist entry.

Operators no longer need to hand-add these origins to
server.allowed_origins for voice to work; that list is now only for
web/browser clients. Docs and the generated config comment updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB

* chore(client): bump version to 1.1.0-alpha.5

The v1.1.0-alpha.4 release shipped client artifacts still versioned
1.1.0-alpha.3 because the client manifests were never bumped — deployed
desktop clients therefore consider themselves up to date and never
auto-update. Bump package.json, package-lock.json, tauri.conf.json,
Cargo.toml and Cargo.lock to 1.1.0-alpha.5 so the next release's
clients update normally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB

* ci(release): fail the release when client version does not match the tag

Guards against the v1.1.0-alpha.4 mistake recurring: a new
verify-versions job compares the pushed tag against tauri.conf.json,
package.json and Cargo.toml and fails before any build starts; every
build job now depends on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(admin): allow API-token principals to use the SSE log stream (#1294)

The log stream was session-only: POST /admin/api/logs/ticket required a
*db.Session in the request context (deliberately nil for API-token
principals), and the stream handler re-validated the ticket hash against
the sessions table alone. API tokens could reach every other
/admin/api/* route but not the log stream, breaking the mcp-introspect
server_logs tool that docs/mcp-introspect.md documents as working.

Bind tickets to the hash of whichever bearer credential authenticated
the request, and resolve it in the stream handler via
auth.ResolveTokenHash — the same session-first, API-token-fallback path
the admin middleware uses. Ban, role demotion, and mid-stream revocation
of either credential kind cut the stream exactly as before.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(voice): treat the server's own origin as same-origin on the LiveKit proxy (#1295)

A page served by the server itself (e.g. a browser client at
https://<server>:8443) chats fine but cannot join voice: browsers attach
the page origin to every WebSocket handshake, and the LiveKit proxy's
hand-rolled isOriginAllowed only recognized "no Origin" as same-origin,
so the RTC upgrade 403'd while same-origin fetches (which omit Origin)
succeeded — /livekit/rtc/v1 403s with validate flipping 403/200 in the
server log.

Allow an Origin whose host equals the request Host, mirroring
websocket.Accept's default same-origin policy that the chat WS endpoint
already applies — which is exactly why chat worked and voice didn't. Web
content on another origin can never present this origin (the browser pins
it), so the CSRF surface is unchanged. Same host on a different port
remains cross-origin and denied.

Also log rejected origins on the 403 path (origin, path, remote) —
this failure was previously undiagnosable from the server log, which
recorded the 403 but not the offending origin.

Existing allowlist tests used origins colliding with httptest's default
request host (example.com), which the new semantics correctly treat as
same-origin; their fixtures now use distinct hosts so they keep
exercising the allowlist path.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(release): strip bundled libwayland from Linux AppImages (white screen on Arch) (#1297)

linuxdeploy bundles the Ubuntu 22.04 runner's libwayland-{client,cursor,
egl,server} into the AppImage, and AppRun forces them onto
LD_LIBRARY_PATH. On hosts with newer Mesa (Arch, Fedora), EGL init
dlopens libwayland-client, hits the stale bundled copy, and fails with
"Could not create default EGL display: EGL_BAD_PARAMETER. Aborting..."
- WebKit's web process dies and the window stays white. Reproduced in an
Arch container with the published alpha.5 aarch64 AppImage (identical
stderr to the field report); the same image renders normally on Ubuntu
24.04, and removing the four bundled libwayland libs makes it render on
both. WEBKIT_DISABLE_COMPOSITING_MODE=1 does NOT help (tested).

Add scripts/strip-appimage-bundled-libs.sh and run it in both Linux
release jobs after the Tauri build: strip the libs, repack with
appimagetool, regenerate the updater tar.gz, and re-sign both artifacts
with the Tauri updater key. Every supported distro ships libwayland at
or above the 1.20 the client links against, so the host copy is always
the right one.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(client): send the session bearer token when fetching attachments (#1298)

Uploaded images rendered only as loading placeholders: the server's
/api/v1/files/{id} endpoint requires a Bearer token (it enforces
per-channel ACLs), but the client's attachment image fetch and file
download never attached one, so every request came back 401 and the
placeholder was never replaced.

Server-hosted attachment fetches now go through fetchServerFile, which
routes through the cert-pinned TOFU proxy with the session token from
the auth store. The token is only ever sent to the configured server
host — external image URLs keep a plain, credential-free fetch.


Claude-Session: https://claude.ai/code/session_018tt1rh32f75EAtad6qLraa

Co-authored-by: Claude <noreply@anthropic.com>

* fix(client): enable microphone/camera detection on Linux (WebKitGTK) (#1299)

On Linux no audio or video devices were ever detected: WebKitGTK ships
with enable-media-stream and enable-webrtc off, and wry installs no
permission-request handler on its webkitgtk backend (unlike macOS,
where it auto-grants media capture), so WebKit's default denies every
getUserMedia/enumerateDevices request.

Add a Linux-only setup hook that turns both settings on for the main
window's webview and grants WebKitUserMediaPermissionRequest and
WebKitDeviceInfoPermissionRequest. All other permission request types
still fall through to WebKit's default deny.

The webkit2gtk crate becomes a direct dependency, pinned to the exact
version wry already links (=2.0.2, v2_38 for enable-webrtc), so the
binary's native library footprint is unchanged — the AppImage bundle
set stays identical and the libwayland strip step from #1297 is
unaffected.


Claude-Session: https://claude.ai/code/session_018tt1rh32f75EAtad6qLraa

Co-authored-by: Claude <noreply@anthropic.com>

* feat(client): kick to login and reset call state on server shutdown (#1300)

When the server shut down, connected clients stayed on the main page in
an endless "Reconnecting..." loop, and a live call's webcam/screenshare
toggles kept whatever state they had. The server already broadcasts
server_restart with reason "shutdown" from hub.GracefulStop before
closing connections — the client just ignored the reason.

The dispatcher now treats reason "shutdown" as terminal: it signs the
user out (clearAuth), which navigates back to the login screen, leaves
the voice session — stopping any live camera/screenshare tracks — and
resets all call settings (camera, screenshare, mute, deafen, channel)
to their normal state. Other restart reasons (update, setup,
backup_restore) keep the existing countdown-banner + auto-reconnect
behavior.

clearAuth gains a LogoutReason so the logout wiring can tell a
server-initiated kick from a user logout or invalid-token path: on
"server_shutdown" the saved credential is kept (the token is still
valid), so profiles with auto-login reconnect on their own once the
server comes back, instead of losing their stored login on every
server restart. The main page also skips the restart countdown banner
for shutdown notices since the page unmounts immediately.


Claude-Session: https://claude.ai/code/session_018tt1rh32f75EAtad6qLraa

Co-authored-by: Claude <noreply@anthropic.com>

* fix(client): credential fallback store on every OS, not just Windows (#1301)

Credential saves still failed outright on machines where the OS keychain
does not round-trip — most commonly a Linux desktop with no Secret
Service provider (no gnome-keyring / KWallet, e.g. a bare window
manager) and a locked macOS Keychain. The verified-write fallback
introduced for the 2026-07 keyring regression existed on Windows only;
on macOS and Linux secret_store::set returned an error and nothing was
persisted, so logins and the voice-E2EE identity key vanished on every
restart.

The fallback now engages on every desktop platform, under the same
rule as before: only after a keychain write has provably failed to
round-trip, with the OS credential store taking over again the moment
it recovers. Windows keeps DPAPI. macOS/Linux entries are sealed with
ChaCha20-Poly1305 (via ring, already in the tree) under a per-install
random key file written owner-only (0600) to the app data dir; the
account name is bound in as AEAD associated data, mirroring the DPAPI
entropy, so a blob cannot be moved between entries. Secrets at rest
are never plaintext, and a copied fallback store is useless without
the key file beside it.

The shared set/get fallback path is now platform-neutral with only the
sealing primitive per-OS, Backend gains an EncryptedFile variant, and
fallback_crypto ships round-trip, AAD-mismatch, tamper, nonce
uniqueness, and key-file permission tests that run in CI.


Claude-Session: https://claude.ai/code/session_018tt1rh32f75EAtad6qLraa

Co-authored-by: Claude <noreply@anthropic.com>

* fix(voice): keep stream audio playing when the user mutes/deafens (#1302)

Muting yourself in a call (which the deafen control also engages —
deafen forces mute) silenced the audio of any screen-share stream being
watched: the deafen path unsubscribed every remote audio publication,
including ScreenShareAudio tracks, and the subscribe-time guard blocked
new stream-audio tracks the same way.

Muting/deafening yourself gates voices, not the content someone is
streaming. Both paths now exempt ScreenShareAudio: the stream's audio
keeps playing while the user is muted or deafened, and remains
controllable through its own per-tile mute button and volume slider.
Microphone (voice) audio is still fully unsubscribed on deafen exactly
as before.

The mic-mute path itself never touched incoming stream audio (verified
against livekit-client: setMicrophoneEnabled, RemoteParticipant.setVolume
and the audio pipeline are all scoped to the Microphone source) — the
coupling was only ever the deafen subscription sweep.


Claude-Session: https://claude.ai/code/session_018tt1rh32f75EAtad6qLraa

Co-authored-by: Claude <noreply@anthropic.com>

* feat: Discord-parity quick wins (blocks UI, topics, role colors, profile popup, temp bans, archived filtering) (#1303)

* feat: Discord-parity quick wins — blocks UI, topics, role colors, profile popup, temp bans, archived filtering

Adds docs/plans/discord-parity.md (full gap analysis vs Discord free/Nitro,
phased plan) and lands phase 1 — the six features where one side already
existed and the other was never finished:

- Block/unblock from the client: PUT/DELETE /blocks/{userId} were server-only;
  the member context menu now offers Block (with confirm) / Unblock to every
  user, admin actions stay role-gated. New setUserBlockedByMe store helper.
- Channel topics end-to-end: topic now ships in the WS ready payload
  (protocol.md updated), renders live in the chat header, and is editable in
  the client's Edit Channel modal (PATCH already supported it).
- Role colors from server data: member list groups and message username
  colors now use roles.color from ready (with theme-var fallbacks) instead of
  a hardcoded 4-name switch; custom roles render their own groups, and
  members with an unknown role render in a gray group instead of vanishing.
- Profile popup mounted: left-clicking a member opens the existing
  UserProfilePopup (previously dead code); its Message button starts a DM.
  Action buttons without handlers are no longer rendered.
- Temp bans: PATCH /admin/api/users/{id} accepts ban_duration_hours
  (1..8760) feeding the existing BanUser expiry plumbing; ban menu gains a
  duration selector (Forever/1h/1d/7d/30d).
- Archived channels actually hide: VisibleChannelIDs now skips archived
  refs, so REST list, ready payload, and replay filtering all exclude them;
  archiving live-syncs connected clients via RefreshChannelVisibility. The
  admin panel still lists archived channels for unarchiving.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR

* fix(lint): rewrite visibility if-else chain as switch (gocritic ifElseChain)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat: Discord-parity phases 2–6 (moderation, mentions, markdown, roles, social) (#1304)

* feat: parity phase 2 — moderation depth (live permission bits, voice moderation, purge)

- Admin perimeter now admits any role holding a moderation-capable bit
  (AdminPerimeter mask); each route group re-checks its own bit:
  channels/overrides -> MANAGE_CHANNELS, audit log -> VIEW_AUDIT_LOG,
  settings -> MANAGE_SERVER, force-logout -> KICK_MEMBERS. Ban and role
  assignment authorize inside ModerationService (BAN_MEMBERS / MANAGE_ROLES).
  New GET /admin/api/me lets the panel hide tabs and row actions the caller
  cannot use; the desktop member-list menu gates on permission bits from the
  ready role list instead of role names.
- Hierarchy beyond ban: ChangeUserRole requires the actor to strictly
  outrank the target and refuses to assign a role at or above the actor's
  own position (closes "any admin can promote anyone to Owner");
  ForceLogout enforces the same rule.
- Voice moderation on MUTE_MEMBERS: voice_mod_mute/deafen/move/kick WS
  commands (bit + strict outrank, 5/s rate limit, audit-logged).
  voice_states gains server_muted/server_deafened, carried on voice_state;
  server mute is enforced at the SFU via LiveKit MutePublishedTrack and the
  target's own unmute attempts are refused with SERVER_MUTED/SERVER_DEAFENED.
  Move/kick run the hub voice-leave routine then send voice_moved (client
  rejoins through the normal join path) or voice_disconnected. Client
  voice-row menu grows a moderation section gated on the bit.
- Bulk delete: POST /api/v1/channels/{id}/messages/purge {limit 1-100,
  before?} gated on READ|MANAGE_MESSAGES, soft-deletes preserving
  tombstones, one message_purge audit row, fans out a single
  chat_bulk_deleted broadcast. Channel context menu gains "Purge Messages…"
  for holders of MANAGE_MESSAGES.
- Honest kick semantics: the session-revoking "Kick" action is renamed
  Force Logout in the client and admin panel (endpoint unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR

* fix(lint): use slices.Contains in voice moderation tests (modernize)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR

* fix(test): widen the occupied pre_restore window in the abort-restore test

The test blocked the safety backup by occupying pre_restore_<ts>.db names
for the next 4 seconds; on slow Windows CI runners the request outlived the
window and the restore succeeded, failing the 500 assertion. Occupy two
minutes of candidates instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR

* feat: parity phase 3 — real mentions (server resolution, badges, notifications, autocomplete)

- Mentions resolve server-side at send time: whole-word @username parsing
  (address-shaped text rejected), case-insensitive against unique usernames,
  20-mention cap; stored in message_mentions in the same writer transaction
  as the message. chat_message/chat_edited and REST history/pinned/search
  carry mentions + mentions_everyone.
- New MENTION_EVERYONE permission (bit 21, seeded to Owner/Admin/Moderator)
  gates @everyone/@here; never honored in DMs. @here skips offline users.
  Fan-out respects per-channel read permissions and skips users who blocked
  the author.
- read_states.mention_count is live: incremented on insert (never on edit),
  zeroed by channel_focus, shipped per channel in ready.
- Client: mentions highlight only when they resolve; mentioning the current
  user accents the whole row; #channel-name renders a navigating chip;
  channels show a red mention badge that outranks the unread badge;
  notifications say "X mentioned you in #channel" and the suppress-@everyone
  pref now suppresses only honored everyone-mentions; the composer gets an
  @-autocomplete popup (prefix-ranked, keyboard-driven, @everyone/@here
  offered only with the permission).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR

* feat: parity phase 4 — markdown rendering, message navigation, reactions/media/read-state polish

- Discord-flavored markdown via a tokenizer (message-list/markdown.ts):
  bold/italic/underline/strike/spoiler with nesting, escaping and a
  word-boundary rule keeping snake_case literal; line-start quotes,
  headings, lists; masked links restricted to absolute http(s) +
  isSafeUrl (rejects render as literal source); language-tagged code
  fences with a hand-rolled highlighter (no new dependency); markdown is
  inert inside code. Renderer stays a strict DOM builder — no innerHTML.
  Composer gains Ctrl+B/I/U wrapping.
- Message navigation: GET /channels/{id}/messages/around/{messageId}
  (half-before/half-after window, has-more flags via over-fetch);
  detached-window support in the messages store with a "Jump to Present"
  pill; search/pin jumps fetch the window when the target isn't loaded;
  reply previews are clickable; "Copy Message Link" +
  owncord://message/{channel}/{message} deep-link route; pasted message
  links render as jump chips.
- Who-reacted: GET .../reactions/{emoji}/users (100 cap) + hover tooltip
  with per-message+emoji cache invalidated on reaction_update.
- Inline media: video/audio attachments render native players from MIME
  allowlists (unknown containers keep the download chip); SVG stays out.
- Read-state polish: NEW-messages divider, explicit Mark as Read /
  Mark All as Read, DM unread count badges (real counts shipped in ready
  instead of a dot; DM mention counts survive reconnect).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR

* feat: parity phase 5 — role CRUD, per-user overrides, override matrix, client channel management

- Roles are real entities: /admin/api/roles CRUD + reorder behind
  MANAGE_ROLES, with all rules in a new RoleService measured against the
  actor's position (only strictly-below roles may be touched; never grant
  a bit your own role lacks; seeded Owner immutable; default role
  undeletable — deletion reassigns members, drops its overrides, and
  invalidates exactly the moved members' cached perms in one writer
  transaction). Case-insensitive unique names (migration 023), normalized
  colors, roles_update broadcast keeps clients current, and both admin
  surfaces stopped hardcoding the four seeded roles. A new ASCII guard
  test protects sqlc-generated SQL from a byte/rune offset bug that
  silently splices queries when comments contain non-ASCII.
- Per-user channel overrides (migration 024): resolution is now base ->
  role override -> user override with one implementation
  (EffectiveChannelPerms); both layers load in two batch queries behind
  every visibility/permission site, per-role visibility memoization
  removed (two members of one role can now differ), and the @everyone
  fan-out honors user-layer allow and deny. Admin REST + full tri-state
  override matrix UI (role or user per channel) replace the single
  "Can access" checkbox; the visibility-agreement test grew a same-role
  different-overrides case.
- Categories stopped being magic strings: any channel type under any
  free-text category (server + client validation removed), category
  editable everywhere with datalist suggestions, voice channels group
  under their real category.
- Desktop channel management: Edit Channel gains slowmode presets, NSFW
  toggle, and voice user/video limits (bounds-checked server-side,
  broadcast on channel_create/update via one shared constructor); NSFW
  channels show a per-session age-gate overlay; VIEW_AUDIT_LOG holders
  get an Audit Log entry point opening the admin panel at #audit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR

* feat: parity phase 6 — custom emoji, profiles & presence, group DMs, DM calls, channel mutes

- Custom emoji end-to-end: the dormant emoji table gains a mime column
  and real routes (list/upload/delete + authenticated image serving,
  MANAGE_SERVER-gated, 512KiB / 128px caps validated against sniffed
  bytes, SVG refused, 200-emoji cap, audited, emoji_update broadcast).
  :shortcode: renders inline (jumbo when emoji-only, never in code),
  the picker gains a Server category, the composer a :-autocomplete,
  reactions accept and render custom emoji, and the admin panel gets an
  Emoji section.
- Profiles: avatar upload (sniffed, capped, served authenticated) with
  one shared client avatar helper replacing letter-initials everywhere;
  display_name (heading with @username handle preserved for mentions),
  about, and custom_status columns with sanitized bounds; user_update
  broadcast keeps clients current.
- Presence: invisible is a real stored status collapsed to offline for
  every other viewer at every serialization site (owner sees truth);
  connect no longer force-stamps online (idle/dnd/invisible survive
  reconnect — the flash-online bug is gone); auto-idle after 10 minutes
  of inactivity that never overrides a manual status. The @here fan-out
  now collapses status first so invisible users are not pinged.
- Group DMs: channels.is_group discriminator; create (2-8 others,
  bidirectional block checks), rename (participants only), leave
  (channel deleted with the last participant); per-viewer
  dm_channel_open payloads; stacked-avatar rows, multi-select member
  picker, participant headers; 1:1-only composer block gating.
- DM calls: call_ring/call_decline signaling over existing DM voice
  (no new call state), Call button in DM headers, incoming-call banner
  with accept/decline/30s timeout and chime.
- Per-channel mutes (client prefs): muted channels/DMs stay silent for
  non-mention noise (badge dims, mentions still notify), managed from
  context menus and the Notifications tab. The dead Friends nav item is
  removed as the plan prescribed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Pre-release review fixes + v1.2.0-alpha.1 prep (#1305)

* fix(review): pre-release security & performance fixes for the parity work

Security:
- Channel-override endpoints (role + per-user) now enforce grantability:
  a MANAGE_CHANNELS holder can no longer grant itself or a user a
  permission bit its own role lacks, and the role-layer endpoint refuses
  targeting a role at or above the actor's position (Administrator
  bypasses). Closes a privilege-escalation path opened when the override
  routes were downgraded from ADMINISTRATOR-only.
- DM voice events no longer leak: channelReadAudience resolves a DM
  channel's audience from its participants (intersected with connected
  clients) instead of the role scan, which passed every user with base
  READ_MESSAGES since DMs carry no overrides. A private DM call's
  voice_state/voice_leave now reaches only its participants.
- Invisible users no longer flash online on connect: member_join carries
  a viewer-safe status (db.BroadcastStatus) and the client defaults a
  missing status to offline instead of hardcoding online.
- Voice moderation can no longer reach a private DM call: voiceModTarget
  refuses a DM-channel target unless the actor is a participant, with the
  same shape as "not in voice" so nothing about the call leaks.

Correctness:
- Un-deafening a member now also clears the deafen-implied server mute,
  so the target regains the ability to unmute themselves instead of
  staying silenced at the SFU until a separate unmute.

Performance:
- IncrementMentionCounts batches its upserts into chunked multi-row
  statements instead of one exec per recipient, so an @everyone mention
  holds the SQLite writer for one exec per 500 readers instead of N.
- applyMentionCounts resolves mentions against a set built once from the
  readers instead of a nested O(mentions x readers) scan.
- The markdown parser's bracket/paren matching is computed once per line
  instead of rescanned at every opener, removing the O(n^2) worst case on
  pathological input.
- Video/audio attachment blob URLs are now LRU-capped and revoked, and
  the attachment caches are cleared on logout, fixing an unbounded
  per-session Blob leak.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR

* chore(release): prep v1.2.0-alpha.1

Bump the client manifests (package.json, package-lock.json,
tauri.conf.json, Cargo.toml, Cargo.lock) from 1.1.0-alpha.5 to
1.2.0-alpha.1 so the release workflow's verify-versions guard passes for
tag v1.2.0-alpha.1. The server version is injected via ldflags at build
time and needs no bump.

Add a curated CHANGELOG section for v1.2.0-alpha.1 documenting the
Discord-parity feature drop (mentions, markdown, custom emoji, message
navigation, role management, per-user overrides, voice moderation,
profiles, group DMs, DM calls, channel mutes) and the pre-release
security/performance review, plus an operator note covering the nine new
migrations and the new WebSocket message types.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR

* perf(mentions): apply mention counts off the send path

SendMessage resolved every reader and wrote the mention/@everyone badge
counts synchronously after the commit but before returning, so a mention
in a large channel delayed delivering the message to everyone else by the
full reader-resolution chain plus the batched increment.

Move that bookkeeping onto a background goroutine via an injectable
dispatcher field (bg, defaulting to `go fn()`). The write already ran on
a cancellation-detached context and swallowed its errors, so detaching it
from the request is safe; the count is advisory, so the tiny window where
a reader's channel_focus clears it just before the increment lands is
harmless (matching Discord's eventual consistency).

Tests read the counts synchronously right after a send, so the shared
mention fixture and the ws mentions test opt into an inline runner
(RunBackgroundInlineForTest / the hub's RunMentionCountsInlineForTest
seam); a new test exercises the real async path by polling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR

* refactor(client): extract shared inline-autocomplete factory

MentionAutocomplete and EmojiAutocomplete duplicated ~90 lines of
identical listbox scaffolding (AbortController cleanup, suggestions/
activeIndex state, the root listbox + .ma-list, mousedown-to-choose
rows, and a byte-identical arrow/Enter/Tab/Escape keydown switch), so a
fix to one silently diverged from the other.

Factor that into createInlineAutocomplete<T>, parameterized by the four
things that actually differ: the filter, the selected value, the per-row
children, and the row/root test ids + class (emoji keeps the shared
mention-autocomplete base class plus its own, and only mentions prime the
list on create). Both components become thin adapters that keep their
existing exports — createMention/EmojiAutocomplete, the pure filter
functions, and the MIN/MAX constants — unchanged, so MessageInput and
every test are untouched and still pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR

* fix(lint): drop now-unused appendChildren import in MentionAutocomplete

The row rendering moved into the shared inline-autocomplete factory, so
the import is no longer referenced; oxlint fails the Client Static Checks
job on the unused identifier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(review): full-project review — hierarchy, role positions, search, clarity (#1306)

From a full-codebase review (Opus security + Sonnet server/client + Haiku
consistency):

- Per-user channel overrides now enforce the same role-hierarchy guard the
  role-layer endpoint already has: a non-admin MANAGE_CHANNELS holder can no
  longer write or clear a per-user override against a member ranked at or
  above their own. Without it, because the per-user layer is last in the
  resolution order, a Moderator could deny a higher-ranked member the channel
  access their role grants. Applied to both PUT and DELETE.
- CreateRole no longer places two default-positioned roles at the same
  position: it steps to the highest free slot below the actor and rejects an
  explicit position that is already taken. Colliding positions read as equal
  rank in every hierarchy check, so two such roles could never manage each
  other's members. The rank guard still takes precedence over the collision
  message for an at/above-rank position.
- Search overlay no longer silently drops a query that arrives inside the
  500ms rate-limit window (which sits above the 300ms debounce): it reschedules
  the search for when the window opens instead of leaving the previous query's
  results on screen.
- Corrected a misleading TODO on chat_send attachments: they are upload UUIDs
  resolved by ownership at link time, not URLs, so a javascript:/data: string
  is never stored or rendered — a scheme check would wrongly reject valid ids.
  The comment now states this and the loop variable/error name say "id".


Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR

Co-authored-by: Claude <noreply@anthropic.com>

* Test hardening: fuzzing, contract/upgrade, load, e2e (#1307)

* fix(image): reject zero-dimension images in header decode

FuzzImageDimensions found two inputs the emoji/image size guard
accepted as valid with a nil error despite having no real dimensions:

  - a GIF whose logical screen descriptor decodes to height=0 via Go's
    own image.DecodeConfig, and
  - a VP8 keyframe whose size field is all zeros (VP8, unlike VP8L/VP8X,
    stores the size directly, so 0x0 is a validly-shaped header).

Both callers compare the returned size straight against their pixel cap,
so a degenerate 0-dimension header slipped through as a "small" image.
Reject non-positive dimensions centrally in imageDimensions and reject
zero VP8 dimensions in webpDimensions, so the invariant holds even for a
caller that forgets its own bounds check. The two crashers are checked in
as the fuzz regression corpus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR

* test(fuzz): add Go fuzzers and TS property tests for parsers/validators

Adds coverage on the parsers and validators most exposed to hostile
input, each with a tricky seed corpus and invariant assertions:

Server (Go native fuzzing):
  - FuzzParseMentionTokens: never panics; resolved count within cap.
  - FuzzSanitizeFTSQuery: output never errors against real SQLite FTS5.
  - FuzzValidateShortcode: accepted shortcodes match the documented
    charset/length.
  - FuzzEffectivePerms / FuzzEffectiveChannelPerms: ADMINISTRATOR implies
    all bits, user-deny beats role-allow, result is a subset of AllPerms.

Client (fast-check property tests):
  - markdown tokenizer never throws and emits no script/on*/javascript:
    sinks, bounded time on pathological input.
  - mention/emoji content parsing never throws.
  - filterMentionSuggestions/filterEmojiSuggestions never throw and
    respect the caps and the MIN_EMOJI_QUERY/permission gates.

The image-header fuzzer that found the zero-dimension bug landed with its
fix in the preceding commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR

* test(migration): add full-chain and upgrade round-trip tests

Applies every embedded migration to a fresh DB and asserts the resulting
schema is coherent, then applies the full chain on top of a pre-parity
(migration 019) snapshot and asserts it upgrades without error and
preserves seeded rows. Protects existing operators on the v1.2.0 upgrade
(9 new migrations, 020 through 028).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR

* test(protocol): assert protocol schema matches generated Go constants

Asserts every wire constant in docs/protocol-schema.json has a matching
generated Go constant and vice-versa, with a small explicit exception
list for intentionally-undocumented internal constants. Catches the
chat_command-style drift the review flagged before it reaches the wire.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR

* test(load): add hub load/soak harness with goleak verification

Adds a long test (skipped under -short, run under -race in CI) that
concurrently registers and unregisters 200 WS clients across churn rounds
while six broadcaster goroutines fan out to the hub, then asserts via
go.uber.org/goleak that no goroutines leak and no deadlock or panic
occurs. Exercises the client registry, broadcast audience resolution, and
the background mention goroutine under contention -- the class of bug the
race detector only reveals at scale. Adds a BroadcastVoiceEventForTest
seam to export_test.go for the broadcaster loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR

* test(e2e): add blocking parity-feature Playwright specs

Adds end-to-end coverage for the v1.2.0 parity features that had none, all
tagged "@parity" and driven through the existing mocked-Tauri harness
(tests/e2e/helpers.ts) — 15 tests across three files:

  - gating-badges.parity.spec.ts: NSFW age-gate mount/continue, mention red
    badge (ready-payload render + live incoming-mention bump), per-channel
    mute toggle + localStorage persistence.
  - social.parity.spec.ts: group-DM create via the member picker (asserts the
    POST /dms/group request), group render + leave (DELETE), and Change Role
    via the member context menu (asserts the PATCH /admin/api/users/{id}).
  - emoji-voicemod.parity.spec.ts: custom-emoji ":shortcode" autocomplete +
    message-list <img> render, and the voice-moderation menu — both the
    admin-can path (asserts voice_mod_mute / voice_mod_kick ws_send) and the
    gated path (menu absent without MUTE_MEMBERS).

The specs assert the exact outgoing HTTP/WS request where the flow is
request-driven, not just DOM side effects. No product bugs were found.

Adds a dedicated CI job "Client E2E (parity subset, blocking)" that runs only
the @parity specs (playwright --grep "@parity") WITHOUT continue-on-error, so
a regression in these features fails CI. The pre-existing full e2e job stays
non-blocking, per the maintainer note that it needs a few green pushes before
graduating.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR

---------

Co-authored-by: Claude <noreply@anthropic.com>

* More hardening: fuzz the input surface + fix mis-written tests (#1308)

* fix(upload): keep sanitizeUploadFilename output a safe, valid basename

FuzzSanitizeUploadFilename found two inputs the upload-filename sanitizer
returned unchanged in violation of its own contract:

  - "/" survived verbatim: filepath.Base("/") returns "/" (root is its own
    basename), and the final reserved-name check only special-cased "", ".",
    and "..", so a path separator reached the served download name and the
    client's save-dialog prefill.
  - a name longer than the 255-byte cap was truncated with a byte slice
    (name[:max]), which can land mid-rune and yield invalid UTF-8 — which
    then misbehaves in JSON encoding, on disk, and in download-name handling.

Now any residual '/' is dropped in the character filter, and truncation
trims back to the last full rune so the result is always valid UTF-8. The
two crashers are checked in as the fuzz regression corpus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR

* test(fuzz): fuzz the file/path and content/identity input surface

Adds Go native fuzzers on the untrusted-input parsers/validators the first
fuzzing pass didn't reach, each with a tricky seed corpus and both a
never-panics and a semantic/security invariant:

  - storage.sanitizeFilename + resolvedPath composition (a name that passes
    sanitize must resolve inside the storage dir — no traversal), and
    storage.ValidateFileType (error iff a blocked magic prefix matches, for
    any header length).
  - plugin.validateRelativePath (accepted paths are non-absolute, separator-
    and traversal-free).
  - service.sanitizeContent: output carries no surviving <script/js:/on*
    sink, is length-bounded, and is idempotent (the bluemonday StrictPolicy
    contract). Two documented regression seeds pin the "inert plain text that
    merely contains the word javascript:/onclick=" non-bug.
  - auth.ValidateUsername / ValidatePasswordStrength — accept implies the
    documented charset/length.
  - api.validateAvatarURL (never accepts a non-https / javascript: / data:
    URL) and api.validateDisplayName.
  - ws.parseParticipantIdentity / parseRoomChannelID — never panic on
    adversarial LiveKit webhook strings.

Each target survived active fuzzing (hundreds of thousands to millions of
execs) with no crash; the one real bug found (sanitizeUploadFilename) landed
with its fix in the preceding commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR

* test: make mis-written tests actually assert their claimed behavior

A test-quality audit found tests that ran an action but asserted nothing
(or asserted a tautology), so they would pass even if the code under test
were deleted. Each is now wired to the real observable effect it names — no
product code changed, no assertion weakened:

Client (vitest):
  - notifications.test.ts: 19 notifyIncomingMessage tests had zero expect()
    calls; each now asserts the sendNotification / requestUserAttention /
    oscillator mock per its name (suppress vs fire, truncation, fallback
    title), with mockClear() so a stale call can't make it trivially green.
    Three catch-path tests now assert the debug log fired. One test whose
    title contradicted its body (and the code's guard) was renamed to match
    verified behavior.
  - livekit-session.test.ts: token-refresh test asserts the stored token and
    the rearmed refresh timer; the two "no active room" device-switch tests
    assert Room.switchActiveDevice is not called.
  - connection-stats.test.ts: the "start is idempotent" test now advances
    timers and asserts the poll callback fires once per tick (no double
    interval).
  - voice-audio-tab.test.ts: the cleanup test now actually starts a camera
    preview (it previously couldn't reach the camera-stop path) and asserts
    both mic and camera tracks are stopped.
  - dispatcher.test.ts: replaced an expect(true).toBe(true) with assertions
    on the voice-store speaking state the handler writes, incl. a control.
  - sidebar-area.test.ts: performs the back-navigation the test described and
    asserts the pre-DM text channel (not the DM) is restored.
  - profiles.test.ts: asserts no profile is created/mutated for a missing id.
  - log-persistence.test.ts: activeFlush tests assert flush sequencing, and
    the cleanup error test asserts the logged error.

Server (Go):
  - db/coverage_boost_test.go: TestCreateAttachment_WithDimensions now links
    the attachment to a message and verifies the persisted width/height via
    GetAttachmentsByMessageIDs, instead of only checking a row exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR

* style(fuzz): satisfy golangci-lint on the new fuzz seed corpora

- Escape the raw bidi/zero-width Unicode format characters embedded in the
  seed strings as \u escape sequences (staticcheck ST1018) — same runes,
  now greppable and lint-clean.
- Range over strings.SplitSeq instead of strings.Split in the relative-path
  fuzzer's traversal check (modernize).

No change to what any seed exercises.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR

---------

Co-authored-by: Claude <noreply@anthropic.com>

* docs(changelog): note pre-release test hardening and the two bugs it found

#1307 and #1308 landed fuzzing, migration/protocol/load tests, a blocking
@parity e2e job, and a test-quality audit. Two of those were real product
fixes (zero-dimension image headers, sanitizeUploadFilename) that belong in
the release notes, not just the test log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(changelog): restore the alpha.5 behavioural notes dropped in the rewrite

The v1.2.0-alpha.1 section replaced the v1.1.0-alpha.5 one wholesale, taking
the LiveKit-proxy origin-gate and log-stream API-token bullets with it. Both
fixes are in this release's code (#1293, #1294, #1295) — only their operator
notes went missing, and an operator upgrading from alpha.3 would never have
seen them. Restored verbatim from main.

This is the sole content main had that dev lacked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): gate CREDENTIAL_FALLBACK_KEY_FILE to non-Windows

`cargo clippy -- -D warnings` failed the Windows Tauri build with
"constant CREDENTIAL_FALLBACK_KEY_FILE is never used". Its only consumer,
`fallback_crypto`, is `#[cfg(not(windows))]` (lib.rs:6) because Windows
seals fallback entries with DPAPI instead — so on Windows the constant is
genuinely dead and -D warnings promotes that to an error.

Gated the constant to match its consumer rather than silencing it with
#[allow(dead_code)], so it still trips if it ever goes dead on the
platforms that do use it.

Latent on dev, not introduced here: Tauri Full Build is gated on
base_ref == 'main', and the fast suite only compiles Rust on ubuntu
(rust-tests runs on ubuntu-22.04), where fallback_crypto *is* compiled.
Nothing built the Rust lib for Windows until this dev -> main PR.

Verified locally on Windows: `cargo clippy -- -D warnings` and
`cargo clippy --all-targets -- -D warnings` both exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(voice): stop writing a credential byte to the log on bad LiveKit config

CodeQL go/clear-text-logging (high, alert #13): the YAML-safety check in
generateConfig rejected a bad credential with

    fmt.Errorf("LiveKit credential contains unsafe YAML character %q", ch)

where ch is a byte taken from LiveKitAPIKey or LiveKitAPISecret. Start()
wraps that error and api/router.go logs it, so a byte of the API key or
secret reached the server log in clear text.

The check now uses strings.ContainsAny and names the offending config
field instead of echoing the byte — strictly more useful to an operator,
who previously got a character with no indication of which credential it
came from. Same rejection set, so behaviour is otherwise unchanged.

Adds a regression test asserting the error names the field and contains
no part of either credential.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(plugin): resolve UI asset paths at construction, not per request

CodeQL go/path-injection (high, alerts #11 and #12): AssetHandler built
the on-disk path from req.URL.Path on every request, then validated it
with filepath.Rel. The validation was sound — traversal was already
blocked by the manifest allowlist, the Rel check, and the serve-time
Lstat — but a path was still being constructed from user input, which is
the pattern the rule flags and the one that goes wrong when someone later
edits the ordering.

Each declared asset is now resolved and traversal-checked once, when the
handler is built, into an asset-name -> absolute-path map. At serve time
the request path is only ever a map key, so no filesystem path is derived
from user input at all. An asset that fails validation is absent from the
map and 404s, as an undeclared file already did.

Also moves filepath.Abs/Join/Rel off the per-request path. The serve-time
Lstat symlink and IsRegular checks stay exactly as they were — they close
the post-install TOCTOU window and are still needed per request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(plugin): constrain default-build registry tests to !wazero

registry_test.go opens "Registry lifecycle tests for the default
(non-wazero) build" and asserts activation fails with
ErrRuntimeUnavailable, but carried no build constraint. Under
-tags wazero a real runtime is linked in, so TestRegistry_Activate_
WithoutRuntime and TestRegistry_EnablePlugin_RollsBackWhenActivationFails
both failed.

Nothing caught it: CI builds all three tag variants but only runs tests
untagged, so these have been red under -tags wazero without surfacing.

Adds the //go:build !wazero the file always implied, matching the
sandbox_default.go / sandbox_wazero.go split already used here. Its
helpers are used by no other file, so nothing else loses coverage; the
wazero build keeps its own activation tests in sandbox_wazero_test.go.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:06:14 +02:00
649 changed files with 84437 additions and 8073 deletions
+65
View File
@@ -0,0 +1,65 @@
---
name: ci-check
description: Run the local mirror of OwnCord's CI gates before pushing. Use when finishing a change, before a commit or push, or when asked to verify work — CI takes ~15 min and catches things a plain build/test does not.
---
# ci-check
`.github/workflows/ci.yml` is the source of truth. This mirrors it locally.
Run only the sections your change touches. Server and client are independent.
## Server (from `Server/`)
All four build-tag variants must compile — the tags gate whole files, so a
default-build pass proves nothing about the others:
```bash
go build ./... && go build -tags otel ./... && go build -tags wazero ./... && go build -tags otel,wazero ./...
go vet ./...
go test -race ./...
go test -tags deadlock -count=1 ./ws/ # deadlock detector; ws is where lock order actually varies
golangci-lint run # CI pins v2.11.3
make sqlc-verify protocol-verify # generated output must not be stale
```
Add `-tags wazero` to `go vet`/`go test` when you touched `plugin/`.
A `windows-latest` `-race` failure inside `ws` that matches `runtime.scanstack`
or `runtime.(*unwinder).next` is a Go 1.26.5 runtime GC fault, not your change.
Rerun the job (`gh run rerun --job <id>`); a job cannot be rerun while its
parent run is still in progress.
## Client (from `Client/tauri-client/`)
```bash
NODE_OPTIONS=--no-experimental-webstorage npm test
npm run typecheck
npm run lint
npm run format:check
```
The `NODE_OPTIONS` flag is mandatory on Node 22+ — see the client CLAUDE.md.
`npm audit --audit-level=high` and `knip` also run in CI but are advisory.
## Rust (from `Client/tauri-client/src-tauri/`)
```bash
cargo test
cargo clippy --all-targets -- -D warnings
```
`fallback_crypto` is `cfg(not(windows))`, so its tests compile to nothing on a
Windows box and only run on the Linux/macOS runners.
Do not attempt `npm run tauri build` locally — the full desktop build runs in
CI on PRs to `main` and pulls heavy system dependencies.
## Hooks
`npm run hooks:install` (once per clone) points `core.hooksPath` at
`.githooks/`: `pre-commit` runs fast staged-file checks, `pre-push` runs the
server build variants plus tsc and eslint. `OWNCORD_PREPUSH_TESTS=1` adds
server tests. Bypass with `--no-verify` or `OWNCORD_SKIP_HOOKS=1` — CI still
enforces everything.
+44
View File
@@ -0,0 +1,44 @@
---
name: db-change
description: Change OwnCord's SQLite schema or queries — add a migration, edit Server/db/queries/*.sql, and regenerate the sqlc layer. Use before touching anything under Server/db/ or Server/migrations/.
---
# db-change
`Server/db/dbgen/` is generated. Edit the inputs, regenerate, commit both.
1. Add the migration to `Server/migrations/` and/or edit
`Server/db/queries/sqlite/*.sql`.
2. Regenerate: `make sqlc-generate` from `Server/`.
3. Commit the regenerated `Server/db/dbgen/` alongside your inputs. CI runs
`make sqlc-verify` and fails on drift.
`sqlc.version` pins the binary (currently v1.30.0). If `make` is not on PATH:
```bash
go install github.com/sqlc-dev/sqlc/cmd/sqlc@$(cat sqlc.version)
$(go env GOPATH)/bin/sqlc generate
```
## Traps
These are silent — the code generates fine and fails at runtime.
**Query files must be ASCII-only.** sqlc v1.30.0 measures rune positions
against byte offsets, so one multi-byte character (an em-dash in a comment is
the usual culprit) truncates the *next* query's emitted SQL by that many
trailing bytes. Symptom: the `.sql` file looks right but the generated const
in `dbgen/*.sql.go` is cut short — `ORDER BY id ASC` becomes `ORDER BY id A`,
and SQLite reports "incomplete input".
**No semicolons inside migration `--` comments.** `splitStatements` in
`Server/db/migrate.go` splits on `;` before stripping comments, so a semicolon
in comment prose orphans the rest of that comment as a bogus statement
("near <word>: syntax error").
**Do not put `LIMIT 1` on a `:one` query.** It is emitted as a bare `LIMIT`.
A `:one` uses `QueryRow` and reads a single row regardless — use `ORDER BY` to
choose which one.
After regenerating, gopls diagnostics against `dbgen` go stale. Trust
`go build`, not the editor squiggles.
+23
View File
@@ -0,0 +1,23 @@
---
name: protocol-change
description: Add or change a WebSocket message type in OwnCord. Use before editing docs/protocol-schema.json, Server/ws/message_types.go, or Client/tauri-client/src/lib/protocolTypes.ts.
---
# protocol-change
`docs/protocol-schema.json` is the source of truth. Both constant files are
generated from it by `Server/scripts/genprotocol/`.
1. Edit `docs/protocol-schema.json`.
2. Run `make protocol-generate` from `Server/`.
3. Commit **both** outputs — `Server/ws/message_types.go` and
`Client/tauri-client/src/lib/protocolTypes.ts`. One run regenerates the
pair; committing only the Go side is the usual mistake, and CI's
`make protocol-verify` fails on either being stale.
Document the semantics in `docs/protocol.md` — the schema carries names and
shapes, not behaviour.
Adding a message type is not enough to make it work: a server handler must be
registered in the `ws` V1/V2 dispatch tables, and the client needs a
`ws.on(...)` subscription in `Client/tauri-client/src/lib/dispatcher.ts`.
+475
View File
@@ -0,0 +1,475 @@
// Offline harness for bughunt.js - mimics the workflow runtime: wraps the script
// body in an AsyncFunction with stubbed agent/parallel/pipeline/phase/log/args/budget.
// Run: node .claude/workflows/bughunt.harness.mjs [nameFilter]
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import assert from 'node:assert/strict'
const here = dirname(fileURLToPath(import.meta.url))
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
export async function run({ agentStub, args = undefined, budget = undefined }) {
const src = readFileSync(join(here, 'bughunt.js'), 'utf8')
const body = src.replace('export const meta', 'const meta')
const calls = []
const logs = []
const agent = async (prompt, opts = {}) => {
calls.push({ prompt, opts })
return agentStub(prompt, opts)
}
const parallel = (thunks) =>
Promise.all(thunks.map((t) => Promise.resolve().then(t).catch(() => null)))
const pipeline = (items, ...stages) =>
Promise.all(
items.map(async (item, i) => {
let v = item
for (const stage of stages) {
try {
v = await stage(v, item, i)
} catch {
return null
}
}
return v
}),
)
const log = (m) => logs.push(String(m))
const phase = () => {}
const budgetImpl = budget || { total: null, spent: () => 0, remaining: () => Infinity }
const fn = new AsyncFunction('agent', 'parallel', 'pipeline', 'phase', 'log', 'args', 'budget', body)
const result = await fn(agent, parallel, pipeline, phase, log, args, budgetImpl)
return { result, calls, logs }
}
// ---------- stub kit (used from Task 2 onward; harmless now) ----------
export function makeStub({ hunt, verify, report = () => 'REPORT_MD', recon = defaultRecon }) {
return (prompt, opts) => {
const label = opts.label || ''
if (label.startsWith('recon:')) return recon(label)
let m = /^r(\d+):hunt:([a-z0-9-]+):(opus|sonnet)$/.exec(label)
if (m) return hunt(Number(m[1]), m[2], m[3], prompt)
m = /^r(\d+):verify:([a-z0-9-]+?)(:retry)?$/.exec(label)
if (m) {
const candidates = JSON.parse(prompt.split('--- CANDIDATES ---')[1])
return verify(Number(m[1]), m[2], candidates, Boolean(m[3]), prompt)
}
if (label === 'report') return report(prompt)
throw new Error(`unexpected agent label: ${label}`)
}
}
export function defaultRecon() {
return 'Server/ws/hub.go 12\nServer/api/user.go 9\nClient/tauri-client/src/lib/dispatcher.ts 8'
}
export const none = { findings: [] }
export const finding = (n, over = {}) => ({
title: `distinct bug alpha${n} omega${n}`,
file: 'Server/ws/hub.go',
line: 100 + n * 40,
severity: 'high',
why: 'w',
repro: 'r',
evidence: 'e',
...over,
})
export const confirmAll = (cands) => ({
verdicts: cands.map((c) => ({
title: c.title, file: c.file, line: c.line,
refuted: false, reason: 'confirmed', confidence: 'high',
severity: c.severity || 'high', fix: 'fix',
})),
})
export const refuteAll = (cands) => ({
verdicts: cands.map((c) => ({
title: c.title, file: c.file, line: c.line,
refuted: true, reason: 'refuted', confidence: 'high',
severity: c.severity || 'high',
})),
})
// ---------- scenarios ----------
const scenarios = {}
// S1: happy convergence - one bug in round 1, rounds 2-3 dry -> converged.
scenarios.s1_convergence = async () => {
const reportPrompts = []
const { result, calls } = await run({
agentStub: makeStub({
hunt: (round, key, model) =>
round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none,
verify: (round, key, cands) => confirmAll(cands),
report: (prompt) => {
reportPrompts.push(prompt)
return 'REPORT_MD'
},
}),
})
for (const k of ['converged', 'stoppedOnBudget', 'rounds', 'confirmed', 'unverified', 'report'])
assert.ok(k in result, `missing key ${k}`)
assert.equal(result.converged, true)
assert.equal(result.rounds.length, 3)
assert.deepEqual(result.rounds.map((r) => r.dryAfter), [0, 1, 2])
assert.deepEqual(result.rounds.map((r) => r.family), ['surfaces', 'bug-classes', 'flows'])
assert.equal(result.confirmed.length, 1)
assert.equal(result.report, 'REPORT_MD')
assert.ok(!calls.some((c) => (c.opts.label || '').startsWith('r4:')), 'no round 4 after convergence')
assert.match(reportPrompts[0], /CONVERGED after 3 round\(s\)/)
assert.match(reportPrompts[0], /\| 1 \| surfaces \|/)
}
// S2: panel dedupe - opus and sonnet report the same bug -> one candidate, one verify call.
scenarios.s2_panel_dedupe = async () => {
const verifyBatches = []
const { result } = await run({
agentStub: makeStub({
hunt: (round, key, model) => {
if (round !== 1 || key !== 'ws-hub') return none
return model === 'opus'
? { findings: [finding(1, { line: 100 })] }
: { findings: [finding(1, { line: 105, title: 'distinct bug alpha1 omega1 variant' })] }
},
verify: (round, key, cands) => {
verifyBatches.push(cands)
return confirmAll(cands)
},
}),
})
assert.equal(verifyBatches.length, 1)
assert.equal(verifyBatches[0].length, 1)
assert.equal(result.confirmed.length, 1)
}
// S3: refuted findings stay dead - re-reported next round, never re-verified; refutes count toward dry.
scenarios.s3_refuted_permanence = async () => {
const { result, calls } = await run({
agentStub: makeStub({
hunt: (round, key, model) => {
if (round === 1 && key === 'ws-hub' && model === 'opus') return { findings: [finding(2)] }
if (round === 2 && key === 'state-desync' && model === 'opus') return { findings: [finding(2)] }
return none
},
verify: (round, key, cands) => refuteAll(cands),
}),
})
const verifyRounds = calls
.map((c) => /^r(\d+):verify:/.exec(c.opts.label || ''))
.filter(Boolean)
.map((m) => Number(m[1]))
assert.deepEqual(verifyRounds, [1], 'refuted candidate must not be re-verified in round 2')
assert.equal(result.rounds[0].refuted, 1)
assert.equal(result.confirmed.length, 0)
assert.equal(result.rounds.length, 2) // refute-only r1 is dry -> converged after r2
assert.equal(result.converged, true)
assert.ok(!calls.some((c) => c.opts.label === 'report'), 'zero confirmed -> code-built report')
assert.match(result.report, /Converged/i)
}
// S4: backstop - fresh confirmed bug every round with maxRounds=3 -> stops, NOT converged.
scenarios.s4_backstop = async () => {
const firstLens = { 1: 'ws-hub', 2: 'concurrency', 3: 'flow-reconnect' }
const { result } = await run({
args: { maxRounds: 3 },
agentStub: makeStub({
hunt: (round, key, model) =>
model === 'opus' && key === firstLens[round]
? { findings: [finding(round, { file: `Server/ws/f${round}.go` })] }
: none,
verify: (round, key, cands) => confirmAll(cands),
}),
})
assert.equal(result.rounds.length, 3)
assert.equal(result.converged, false)
assert.equal(result.stoppedOnBudget, false)
assert.equal(result.confirmed.length, 3)
assert.deepEqual(result.rounds.map((r) => r.dryAfter), [0, 0, 0])
}
// S5: failed finder -> round dry-ineligible; dry counter neither increments nor resets.
scenarios.s5_finder_failure_ineligible = async () => {
const { result } = await run({
args: { maxRounds: 3 },
agentStub: makeStub({
hunt: (round, key, model) => {
if (round === 1 && key === 'ws-hub' && model === 'opus') return { findings: [finding(1)] }
if (round === 2 && key === 'concurrency' && model === 'opus') return null // dead finder
return none
},
verify: (round, key, cands) => confirmAll(cands),
}),
})
assert.equal(result.rounds[1].dryEligible, false)
assert.deepEqual(result.rounds.map((r) => r.dryAfter), [0, 0, 1])
assert.equal(result.converged, false)
}
// S6: failed verifier retried once, retry succeeds.
scenarios.s6_verifier_retry = async () => {
const { result, calls } = await run({
agentStub: makeStub({
hunt: (round, key, model) =>
round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none,
verify: (round, key, cands, isRetry) => (isRetry ? confirmAll(cands) : null),
}),
})
assert.ok(calls.some((c) => (c.opts.label || '').endsWith(':retry')))
assert.equal(result.confirmed.length, 1)
assert.equal(result.converged, true)
}
// S6b: verifier fails twice -> candidate dropped unconfirmed, round ineligible;
// re-reported later, verified then, and scrubbed from the unverified list.
scenarios.s6b_verifier_double_failure = async () => {
const { result } = await run({
args: { maxRounds: 3 },
agentStub: makeStub({
hunt: (round, key, model) => {
if (round === 1 && key === 'ws-hub' && model === 'opus') return { findings: [finding(3)] }
if (round === 2 && key === 'state-desync' && model === 'opus') return { findings: [finding(3)] }
return none
},
verify: (round, key, cands) => (round === 1 ? null : confirmAll(cands)),
}),
})
assert.equal(result.rounds[0].dryEligible, false)
assert.equal(result.rounds[0].confirmed, 0)
assert.equal(result.confirmed.length, 1)
assert.equal(result.confirmed[0].round, 2)
assert.equal(result.unverified.length, 0, 'later-confirmed candidate must leave the unverified list')
}
// S7: rounds 1-3 each confirm a bug -> round 4 runs adaptive lenses built from the stats.
scenarios.s7_adaptive_lenses = async () => {
const A = finding(1, { file: 'Server/ws/hub.go', line: 120, title: 'alpha race window one' })
const B = finding(2, { file: 'Server/ws/pubsub.go', line: 60, title: 'beta subscription leak two' })
const C = finding(3, { file: 'Client/tauri-client/src/lib/livekitE2EE.ts', line: 200, title: 'gamma epoch desync three' })
const { result, calls } = await run({
agentStub: makeStub({
hunt: (round, key, model) => {
if (model !== 'opus') return none
if (round === 1 && key === 'ws-hub') return { findings: [A] }
if (round === 2 && key === 'concurrency') return { findings: [B] }
if (round === 3 && key === 'flow-voice') return { findings: [C] }
return none
},
verify: (round, key, cands) => confirmAll(cands),
}),
})
assert.equal(result.converged, true)
assert.equal(result.rounds.length, 5) // r4, r5 adaptive + dry
assert.equal(result.rounds[3].family, 'adaptive')
const r4Hunts = calls.filter((c) => /^r4:hunt:/.test(c.opts.label || ''))
const r4Keys = [...new Set(r4Hunts.map((c) => c.opts.label.split(':')[2]))]
assert.ok(r4Keys.includes('hotspot-server-ws'), `r4 keys: ${r4Keys}`)
assert.ok(r4Keys.includes('fresh-eyes'), `r4 keys: ${r4Keys}`)
const hotspot = r4Hunts.find((c) => c.opts.label.includes('hotspot-server-ws'))
assert.match(hotspot.prompt, /Server\/ws\/hub\.go/)
assert.match(hotspot.prompt, /alpha race window one/)
const freshEyes = r4Hunts.find((c) => c.opts.label.includes('fresh-eyes'))
assert.match(freshEyes.prompt, /Server\/api\/user\.go/) // churned, never a finding
assert.equal(result.confirmed.length, 3)
}
// S7b: a lens with 2 consecutive clean rounds is demoted from later rounds.
scenarios.s7b_demotion = async () => {
const { result, calls } = await run({
args: { maxRounds: 6 },
agentStub: makeStub({
hunt: (round, key, model) => {
if (model !== 'opus') return none
const src = { 1: 'ws-hub', 2: 'concurrency', 3: 'flow-reconnect', 4: 'hotspot-server-ws', 5: 'hotspot-server-ws' }
if (key === src[round])
return { findings: [finding(round, { file: `Server/ws/a${round}.go`, title: `unique bug number${round} zeta${round}` })] }
return none
},
verify: (round, key, cands) => confirmAll(cands),
}),
})
const labels = calls.map((c) => c.opts.label || '')
assert.ok(labels.some((l) => /^r5:hunt:fresh-eyes:/.test(l)), 'fresh-eyes still runs in r5 (streak 1)')
assert.ok(!labels.some((l) => /^r6:hunt:fresh-eyes:/.test(l)), 'fresh-eyes demoted in r6 (streak 2)')
assert.ok(labels.some((l) => /^r6:hunt:hotspot-server-ws:/.test(l)), 'producing hotspot keeps running')
assert.equal(result.converged, false)
assert.equal(result.confirmed.length, 5)
}
// S7c: a lens whose VERIFIER died is not demoted; a zero-candidate lens still is.
scenarios.s7c_verifier_failure_not_demoted = async () => {
const early = { 1: 'ws-hub', 2: 'concurrency', 3: 'flow-reconnect' }
const { result, calls } = await run({
args: { maxRounds: 6 },
agentStub: makeStub({
hunt: (round, key, model) => {
if (model !== 'opus') return none
if (round <= 3 && key === early[round])
return { findings: [finding(round, { file: `Server/ws/a${round}.go`, title: `early bug item${round} kappa${round}` })] }
if (round >= 4 && key === 'hotspot-server-ws')
return { findings: [finding(round + 10, { file: `Server/ws/b${round}.go`, title: `late bug item${round} sigma${round}` })] }
return none
},
verify: (round, key, cands) => (round <= 3 ? confirmAll(cands) : null),
}),
})
const labels = calls.map((c) => c.opts.label || '')
assert.ok(labels.some((l) => /^r6:hunt:hotspot-server-ws:/.test(l)), 'verifier-dead lens must NOT be demoted')
assert.ok(!labels.some((l) => /^r6:hunt:fresh-eyes:/.test(l)), 'zero-candidate lens still accrues streak and demotes')
assert.equal(result.confirmed.length, 3)
assert.equal(result.unverified.length, 3)
assert.equal(result.converged, false)
assert.ok(result.rounds.slice(3).every((r) => r.dryEligible === false))
}
// S12: empty adaptive family (no confirms, no churn) must break honestly, not count dry rounds.
scenarios.s12_empty_adaptive_family = async () => {
const { result } = await run({
agentStub: makeStub({
recon: () => 'no parseable churn output',
hunt: (round, key, model) => {
if (round <= 2 && key === (round === 1 ? 'ws-hub' : 'concurrency') && model === 'opus')
return { findings: [finding(round, { file: `Server/ws/c${round}.go`, title: `verifierless bug delta${round} theta${round}` })] }
return none
},
verify: () => null,
}),
})
assert.equal(result.rounds.length, 3)
assert.equal(result.converged, false)
assert.equal(result.confirmed.length, 0)
assert.equal(result.unverified.length, 2)
}
// S8: budget below the round floor before round 1 -> zero rounds, honest non-convergence.
scenarios.s8_budget_floor = async () => {
const { result, calls } = await run({
budget: { total: 1000000, spent: () => 900000, remaining: () => 100000 },
agentStub: makeStub({ hunt: () => none, verify: (r, k, c) => confirmAll(c) }),
})
assert.equal(result.rounds.length, 0)
assert.equal(result.stoppedOnBudget, true)
assert.equal(result.converged, false)
assert.ok(!calls.some((c) => /:hunt:/.test(c.opts.label || '')))
assert.match(result.report, /budget/i)
}
// S8b: budget runs low mid-hunt -> finishes the round it started, stops before the next.
scenarios.s8b_budget_midrun = async () => {
let n = 0
const { result } = await run({
budget: { total: 1000000, spent: () => 0, remaining: () => (n++ === 0 ? 200000 : 100000) },
agentStub: makeStub({
hunt: (round, key, model) =>
round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none,
verify: (round, key, cands) => confirmAll(cands),
}),
})
assert.equal(result.rounds.length, 1)
assert.equal(result.stoppedOnBudget, true)
assert.equal(result.converged, false)
assert.equal(result.confirmed.length, 1)
}
// S14: the title-word dedupe branch applies only near the prior's location.
// Dedupe is permanent, so merging two distinct same-file bugs that happen to
// share half their title words loses the second one forever.
scenarios.s14_title_dedupe_window = async () => {
const near = { file: 'Server/ws/hub.go', line: 140, title: 'hub client map race on register path' }
const far = { file: 'Server/ws/hub.go', line: 900, title: 'hub client map race on unregister' }
const verifyBatches = []
const { result } = await run({
agentStub: makeStub({
hunt: (round, key, model) => {
if (model !== 'opus') return none
if (round === 1 && key === 'ws-hub')
return { findings: [finding(1, { file: 'Server/ws/hub.go', line: 100, title: 'hub client map race on register' })] }
if (round === 2 && key === 'concurrency')
return { findings: [finding(2, far), finding(3, near)] }
return none
},
verify: (round, key, cands) => {
verifyBatches.push(cands.map((c) => c.line))
return confirmAll(cands)
},
}),
})
assert.deepEqual(verifyBatches, [[100], [900]], 'near-duplicate dropped, distant same-file bug kept')
assert.equal(result.confirmed.length, 2)
assert.ok(result.confirmed.some((c) => c.line === 900), 'the distant bug must survive dedupe')
}
// S13: JSON-stringified args must behave identically to object args (observed live: the
// runtime can deliver args as a string; maxRounds:1 silently fell back to 8 before the coercion).
scenarios.s13_string_args = async () => {
const { result, calls } = await run({
args: '{"maxRounds": 1}',
agentStub: makeStub({
hunt: (round, key, model) =>
round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none,
verify: (round, key, cands) => confirmAll(cands),
}),
})
assert.equal(result.rounds.length, 1, 'string maxRounds:1 must cap the loop at one round')
assert.equal(result.converged, false)
assert.equal(result.confirmed.length, 1)
assert.ok(!calls.some((c) => (c.opts.label || '').startsWith('r2:')), 'no round 2 under the cap')
}
// S10: verifier returns truncated (empty) verdict lists on both attempts ->
// candidates land in unverified, round ineligible, dry counter untouched.
scenarios.s10_truncated_verdicts = async () => {
const { result, calls } = await run({
args: { maxRounds: 2 },
agentStub: makeStub({
hunt: (round, key, model) =>
round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none,
verify: () => ({ verdicts: [] }),
}),
})
assert.ok(calls.some((c) => (c.opts.label || '').endsWith(':retry')), 'short verdict list must trigger the retry')
assert.equal(result.rounds[0].dryEligible, false)
assert.deepEqual(result.rounds.map((r) => r.dryAfter), [0, 1])
assert.equal(result.confirmed.length, 0)
assert.equal(result.unverified.length, 1)
assert.equal(result.converged, false)
}
// S11: verdict coordinates drift from the candidate's -> still pairs, confirms once,
// nothing listed unverified, and a round-2 re-report of the ORIGINAL coords is deduped.
scenarios.s11_drifted_verdict = async () => {
const orig = finding(4) // file Server/ws/hub.go, line 260
const { result, calls } = await run({
agentStub: makeStub({
hunt: (round, key, model) => {
if (round === 1 && key === 'ws-hub' && model === 'opus') return { findings: [orig] }
if (round === 2 && key === 'state-desync' && model === 'opus') return { findings: [orig] }
return none
},
verify: (round, key, cands) => ({
verdicts: cands.map((c) => ({
title: c.title, file: c.file, line: c.line + 5,
refuted: false, reason: 'confirmed', confidence: 'high', severity: 'high', fix: 'fix',
})),
}),
}),
})
const verifyRounds = calls
.map((c) => /^r(\d+):verify:/.exec(c.opts.label || ''))
.filter(Boolean)
.map((m) => Number(m[1]))
assert.deepEqual(verifyRounds, [1], 'drifted-but-paired verdict must still suppress the original coords')
assert.equal(result.confirmed.length, 1)
assert.equal(result.unverified.length, 0)
assert.equal(result.converged, true)
}
// ---------- runner ----------
const only = process.argv[2]
for (const [name, fn] of Object.entries(scenarios)) {
if (only && !name.includes(only)) continue
try {
await fn()
} catch (e) {
console.error(`FAIL ${name}`)
throw e
}
console.log(`PASS ${name}`)
}
console.log('all scenarios pass')
+565
View File
@@ -0,0 +1,565 @@
export const meta = {
name: 'bughunt',
description: 'Converging multi-round bug hunt: rotating lens families, dual-model panels, fable refute-by-default verification, dry-threshold stop',
whenToUse: 'Hunting real bugs across the Go server, Tauri Rust backend, and TS client until consecutive rounds go dry. Not a security-only scan.',
phases: [
{ title: 'Recon', detail: 'haiku: churn + concurrency-surface inventory' },
{ title: 'Report', detail: 'fable: ranked findings + convergence table' },
],
}
// ---------- config ----------
// args may arrive JSON-stringified (observed in run wf_9199e623-b83: maxRounds:1 never took) - coerce
const ARGS = (() => {
if (typeof args === 'string') {
try { return JSON.parse(args) || {} } catch { return {} }
}
return args || {}
})()
const MAX_ROUNDS = ARGS.maxRounds || 8
const DRY_THRESHOLD = ARGS.dryThreshold || 2
// ponytail: rough floor for one round (up to 12 high-effort finders + verifiers); tune after live runs
const ROUND_BUDGET_FLOOR = 150000
// The args channel has already been observed delivering something the script
// could not read; an unnoticed fallback here is an 8x cost surprise, so say out
// loud what the run is actually going to do.
log(`config: maxRounds=${MAX_ROUNDS} dryThreshold=${DRY_THRESHOLD}`)
// ---------- schemas: copied VERBATIM from the current bughunt.js ----------
const FINDINGS = {
type: 'object',
required: ['findings'],
properties: {
findings: {
type: 'array',
items: {
type: 'object',
required: ['title', 'file', 'line', 'severity', 'why', 'repro'],
properties: {
title: { type: 'string' },
file: { type: 'string', description: 'repo-relative path' },
line: { type: 'integer' },
severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
why: { type: 'string', description: 'the defect, one or two sentences' },
repro: { type: 'string', description: 'concrete inputs/interleaving -> wrong behavior' },
evidence: { type: 'string', description: 'the code lines that prove it' },
},
},
},
},
}
const VERDICTS = {
type: 'object',
required: ['verdicts'],
properties: {
verdicts: {
type: 'array',
items: {
type: 'object',
required: ['title', 'file', 'line', 'refuted', 'reason', 'confidence', 'severity'],
properties: {
title: { type: 'string' },
file: { type: 'string' },
line: { type: 'integer' },
refuted: { type: 'boolean' },
reason: { type: 'string', description: 'what refutes it, or what confirms it in the code' },
confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
fix: { type: 'string', description: 'smallest correct fix, if confirmed' },
},
},
},
},
}
// ---------- rules ----------
const RULES = `
Repo: OwnCord, at D:/Local-Lab/Repos/OwnCord. Go 1.26 server in Server/, Tauri v2 client in Client/tauri-client/
(Rust in src-tauri/src/, TypeScript in src/lib/ and src/stores/).
You are hunting REAL BUGS: wrong behavior, not style. In scope:
- logic errors, off-by-one, wrong operator, inverted condition, wrong default
- concurrency: data races, deadlocks, lock-order inversion, missed wakeups, goroutine leaks, TOCTOU
- lifecycle: use-after-close, double-close, nil deref on error paths, leaked resources/listeners/timers
- state machines that can reach an unintended state, or desync between two sources of truth
- error paths that silently swallow, lose data, or leave partial writes
- auth/authz checks reading stale state, or missing on one path while present on siblings
Out of scope, do not report: naming, formatting, missing tests, "consider adding", speculative hardening,
performance that is not a hang, anything you cannot point at specific lines for.
Method:
1. Read the actual files. Never report from a filename or a grep hit alone.
2. For every candidate, grep for ALL callers before judging - a guard may already live upstream.
3. Check whether an existing test already locks the behavior you think is wrong. If a test asserts it,
it is intended behavior, not a bug. Test files are *_test.go and tests/unit/*.test.ts.
4. Report EVERY finding you can prove - there is no cap. The quality bar stays: zero findings is a
valid, respectable answer, and each finding needs file, line, and a concrete repro.
You may run read-only shell commands (grep, git log, go doc). Do not modify any file. Do not run the test suite.
`
// ---------- lens catalog ----------
// keys must match /^[a-z0-9-]+$/ - they are embedded in agent labels the harness parses.
const SURFACE_LENSES = [
{
key: 'ws-hub',
prompt:
`Surface: the WebSocket hub and its client lifecycle. Files: Server/ws/*.go (skip *_test.go) - start with ` +
`client.go, hub*.go, emit.go, event.go, event_persister.go, event_pruner.go, handlers*.go, command.go.\n\n` +
`Hunt specifically for: send on closed channel; write to a client after unregister; hub map mutated without ` +
`the right lock held; lock ordering between hub and client; a goroutine that outlives its client; ` +
`read-pump/write-pump shutdown races; events emitted to a client mid-unregister; event ordering that can ` +
`invert under concurrent publish; pruner racing the persister over the same rows.\n` +
`Trace at least one full connect -> subscribe -> emit -> disconnect path end to end before reporting anything.`,
},
{
key: 'voice-e2ee',
prompt:
`Surface: voice/video E2EE key lifecycle, spanning three languages. Files: Server/ws/handler_v2_voice*.go and ` +
`any Server/ws/*voice*.go or *e2ee*.go; Client/tauri-client/src/lib/e2eeCrypto.ts, livekitE2EE.ts, ` +
`livekitSession.ts, identity.ts; Client/tauri-client/src-tauri/src/tofu.rs, secret_store.rs, fallback_crypto.rs, dpapi.rs.\n\n` +
`Hunt specifically for: a key-rotation window where a participant can decrypt after they should be excluded; ` +
`TOFU pin re-check that reads state captured before a rotation (time-of-check/time-of-use); a participant ` +
`joining mid-rotation getting the wrong epoch key; key material outliving the session; an error path that ` +
`falls back to unencrypted or to a zeroed/default key; sender/receiver epoch disagreement after reconnect.\n` +
`This area was hardened before - check git log for the relevant commits and do NOT re-report anything already fixed.`,
},
{
key: 'api-authz',
prompt:
`Surface: REST API auth and authorization. Files: Server/api/*.go (skip *_test.go), Server/auth/*.go, ` +
`Server/permissions/*.go.\n\n` +
`Hunt specifically for: a permission checked against a snapshot that can go stale before it is used; ` +
`a handler that checks channel access but not server/guild access (or vice versa); an ID taken from the ` +
`request body when it should come from the session; sibling handlers where one path has a guard and a ` +
`near-identical one does not; rate limiter keyed on something the caller controls; role/override resolution ` +
`that returns allow on error instead of deny.\n` +
`Compare handlers against each other - the strongest signal here is inconsistency between siblings.`,
},
{
key: 'db-storage',
prompt:
`Surface: persistence. Files: Server/db/*.go (NOT db/dbgen/, that is generated), Server/db/queries/*.sql, ` +
`Server/migrations/*.sql, Server/storage/*.go, Server/service/*.go.\n\n` +
`Hunt specifically for: a multi-statement operation that is not in one transaction and can leave partial state; ` +
`a tx that can be committed twice or leaked without rollback on an early return; sql.ErrNoRows treated as a ` +
`real error or swallowed as success; a query whose SQL semantics disagree with what the caller assumes ` +
`(LIMIT, ordering, NULL handling, JOIN dropping rows); a migration that is not idempotent or that breaks ` +
`an older row shape; unbounded result sets read fully into memory.\n` +
`Read the .sql alongside its Go caller - the bug is usually the gap between them.`,
},
{
key: 'tauri-rust',
prompt:
`Surface: the Tauri Rust backend. Files: Client/tauri-client/src-tauri/src/*.rs.\n\n` +
`Hunt specifically for: a panic reachable from a Tauri command (unwrap/expect on attacker- or ` +
`environment-controlled input) - a panic here can take down the app; a lock held across .await; ` +
`state in tauri::State mutated from two commands without coordination; the http_proxy / livekit_proxy / ` +
`ws_proxy forwarding a header, URL, or origin it should filter; credentials/secret_store material logged, ` +
`left in memory, or written unencrypted on a fallback path; ptt.rs global hook not released on shutdown.\n` +
`For each panic you find, state exactly which input reaches it.`,
},
{
key: 'client-state',
prompt:
`Surface: TypeScript client state and event handling. Files: Client/tauri-client/src/lib/*.ts and ` +
`src/stores/*.ts - prioritize dispatcher.ts, reconcile.ts, read-state.ts, router.ts, roomEventHandlers.ts, ` +
`navigation-guard.ts, rate-limiter.ts, channel-navigation.ts, and whatever the churn recon flagged.\n\n` +
`Hunt specifically for: a listener/interval/observer registered without a matching teardown (check ` +
`disposable.ts for the intended pattern and find who bypasses it); reconcile logic that drops or duplicates ` +
`an entity when events arrive out of order; read-state that can mark unread messages read, or lose an unread ` +
`count, across a reconnect; an async handler whose await lets stale state be written after a newer update ` +
`(last-write-wins race); a route guard bypassable by a rapid navigation sequence.\n` +
`Check tests/unit/ before reporting - much of this behavior is already test-locked.`,
},
]
const BUGCLASS_LENSES = [
{
key: 'concurrency',
prompt:
`Bug class: concurrency and interleaving - sweep the whole repo for THIS CLASS ONLY.\n` +
`Go (Server/): data races on maps/slices/fields shared between goroutines; lock-order inversion; ` +
`missed wakeups; TOCTOU between a check and its use; goroutines racing shutdown; send on closed channel.\n` +
`Rust (src-tauri/src/): a lock held across .await; tauri::State mutated from two commands without ` +
`coordination; Arc<Mutex<_>> cloned into tasks that outlive their owner.\n` +
`TS (src/lib/, src/stores/): two async handlers interleaving on the same store (last-write-wins after ` +
`an await); a stale closure writing state after a newer update already landed.\n` +
`Use the recon concurrency-surface inventory to pick files. For every candidate, name the exact interleaving.`,
},
{
key: 'lifecycle',
prompt:
`Bug class: lifecycle and teardown - sweep the whole repo for THIS CLASS ONLY.\n` +
`Every acquire must have a matching release on EVERY exit path: goroutines outliving their owner; ` +
`timers/intervals/listeners/workers registered without removal (client disposable.ts is the intended ` +
`pattern - find who bypasses it); double-close and use-after-close; teardown-order mistakes; ` +
`Rust Drop not running (mem::forget, leaked handles, the ptt.rs global hook); ` +
`partial teardown when an error interrupts the happy path halfway.`,
},
{
key: 'state-desync',
prompt:
`Bug class: two sources of truth drifting - sweep the whole repo for THIS CLASS ONLY.\n` +
`Pairs to audit: hub client maps vs pubsub registrations; server voice state vs LiveKit vs client ` +
`stores; client read-state vs server acked sequence numbers; DB rows vs in-memory caches; ` +
`any two structures updated by different code paths. Find the path that updates one and not the ` +
`other - reconnect, replacement, and error paths are where they diverge.`,
},
{
key: 'error-paths',
prompt:
`Bug class: error-path data loss - sweep the whole repo for THIS CLASS ONLY.\n` +
`Swallowed errors (err assigned and ignored, empty catch, unwrap_or(default) hiding failure); ` +
`partial writes left behind on early return; fallbacks that silently degrade to wrong behavior; ` +
`an error mapped to success upstream; cleanup skipped when the happy path is interrupted mid-way. ` +
`Read every 'if err != nil', catch block, and .catch in the hot files from recon.`,
},
{
key: 'ordering-boundary',
prompt:
`Bug class: ordering and boundaries - sweep the whole repo for THIS CLASS ONLY.\n` +
`Off-by-one and fence-post errors; LIMIT/pagination silently truncating; sequence-number gaps, ` +
`duplication, or inversion between assignment and delivery; sort-stability and tie assumptions; ` +
`first/last/empty-collection special cases; inclusive-vs-exclusive range disagreements between a ` +
`caller and its callee (read the SQL alongside its Go caller).`,
},
]
const FLOW_LENSES = [
{
key: 'flow-reconnect',
prompt:
`Flow: WebSocket drop -> reconnect -> resume. Trace it END TO END across all three languages before ` +
`reporting anything. Server: the serve handshake/resume path, hub client replacement and state ` +
`transfer (this transfer has needed four separate fixes: unsubscribe identity, VoiceTopic+E2EE key ` +
`transfer, focused-channel transfer, closeSend ordering - hunt for what it STILL misses), topic ` +
`re-subscription, cold/warm replay tiers. Client: the reconnect loop, seq ack tracking, store ` +
`reconcile after resume. Report any state that exists on the old connection and does not provably ` +
`reach the new one.`,
},
{
key: 'flow-voice',
prompt:
`Flow: voice join -> E2EE key announce/offer -> key-holder election -> rotation -> participant ` +
`leave -> LiveKit webhook -> cleanup. Trace it END TO END: Server/ws/*voice*, livekit_webhook.go, ` +
`client livekitE2EE.ts and livekitSession.ts, Rust livekit_proxy.rs. Hunt for: a participant who can ` +
`still decrypt after they should be excluded; holder-election stalls; epoch/key disagreement after ` +
`reconnect; the three take-out-of-voice paths (webhook, sweep, voice_leave) diverging.`,
},
{
key: 'flow-message',
prompt:
`Flow: message send -> permission gate -> persist -> sequence assign -> fan-out -> replay tiers -> ` +
`client store -> read-state/unread counts. Trace it END TO END and hunt the gaps BETWEEN layers: ` +
`persisted but never fanned out; delivered but sequence-skipped; acked via max(seq) while a lower ` +
`seq was dropped; unread counts drifting from actual unread messages across reconnect or channel switch.`,
},
{
key: 'flow-session',
prompt:
`Flow: login -> session/token issue -> per-connection auth -> revocation/sweep -> kick -> API-token ` +
`paths. Trace it END TO END and hunt stale-authorization windows: state checked at connect but not ` +
`re-checked at use; revocation that kicks the WS but leaves another surface authorized; the sweep ` +
`racing an in-flight request; API tokens diverging from session-token semantics on any path.`,
},
]
function lensesForRound(round) {
if (round === 1) return SURFACE_LENSES
if (round === 2) return BUGCLASS_LENSES
if (round === 3) return FLOW_LENSES
return buildAdaptiveLenses()
}
function familyName(round) {
return ['surfaces', 'bug-classes', 'flows'][round - 1] || 'adaptive'
}
function clusterOf(file) {
const parts = String(file).split('/')
return parts.slice(0, parts[0] === 'Client' ? 3 : 2).join('/')
}
function freshEyesLens() {
const files = churnFiles.filter((f) => !seen.some((s) => s.file === f)).slice(0, 10)
if (!files.length) return []
return [
{
key: 'fresh-eyes',
prompt:
`These files churned heavily in the last 8 weeks, yet no hunt round has confirmed or refuted a ` +
`single finding in them - either they are clean or every lens so far walked past them. Read each ` +
`one IN FULL with fresh eyes and hunt for real bugs of any class:\n` +
files.map((f) => ` - ${f}`).join('\n'),
},
]
}
function buildAdaptiveLenses() {
const byCluster = {}
for (const c of confirmedAll) {
const cl = clusterOf(c.file)
if (!byCluster[cl]) byCluster[cl] = []
byCluster[cl].push(c)
}
const top = Object.entries(byCluster)
.sort((a, b) => b[1].length - a[1].length)
.slice(0, 3)
const hotspots = top.map(([cluster, items]) => ({
key: ('hotspot ' + cluster).toLowerCase().replace(/[^a-z0-9]+/g, '-'),
prompt:
`Bugs cluster. Confirmed findings so far in ${cluster}:\n` +
items.map((i) => ` - ${i.file}:${i.line} ${i.title}`).join('\n') +
`\nHunt ADJACENT to these: the same functions' siblings, every caller, the counterpart operations ` +
`(subscribe/unsubscribe, open/close, register/transfer, acquire/release), and the paths a past fix ` +
`here did NOT cover. Do not re-report the findings listed above - they are already known.`,
}))
return [...hotspots, ...freshEyesLens()]
}
// ---------- dedupe + ledger helpers ----------
function normTitle(t) {
return String(t || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').split(/\s+/).filter((w) => w.length > 2)
}
// Dedupe is permanent: a candidate merged into an existing entry never comes
// back, so an over-eager match silently loses a real bug rather than deferring
// it. The title-word branch therefore only applies near the prior's location -
// two distinct bugs in one file often share half their title words ("hub client
// map race on register" vs "...on unregister"), and without a window the second
// one is suppressed forever, sometimes by a merely REFUTED namesake.
const TITLE_MATCH_WINDOW = 60
function isDup(a, b) {
if (a.file !== b.file) return false
const delta = Math.abs((a.line || 0) - (b.line || 0))
if (delta <= 10) return true
if (delta > TITLE_MATCH_WINDOW) return false
const aw = normTitle(a.title)
if (!aw.length) return false
const bw = new Set(normTitle(b.title))
const hits = aw.filter((w) => bw.has(w)).length
return hits * 2 >= aw.length
}
function dedupe(cands, priors) {
const kept = []
for (const c of cands) {
if (priors.some((p) => isDup(c, p)) || kept.some((k) => isDup(c, k))) continue
kept.push(c)
}
return kept
}
function seenBlock(seen) {
if (!seen.length) return ''
const lines = seen.map((s) => ` - ${s.file}:${s.line} [${s.status}] ${s.title}`)
return `\n--- KNOWN FINDINGS (already investigated - do NOT re-report; refuted means examined and rejected) ---\n${lines.join('\n')}\n`
}
function convergenceTable(stats, converged, stoppedOnBudget) {
const verdict = converged
? `CONVERGED after ${stats.length} round(s).`
: stoppedOnBudget
? 'NOT converged - stopped on budget.'
: 'NOT converged - hit the round backstop.'
const rows = stats.map(
(s) =>
`| ${s.round} | ${s.family} | ${s.lenses} | ${s.candidates} | ${s.fresh} | ${s.confirmed} | ${s.refuted} | ${s.dryEligible ? 'yes' : 'NO'} | ${s.dryAfter} |`,
)
return [
'## Convergence',
'',
verdict,
'',
'| round | family | lenses | candidates | fresh | confirmed | refuted | dry-eligible | dry after |',
'|---|---|---|---|---|---|---|---|---|',
...rows,
].join('\n')
}
// ---------- recon (verbatim from the current script, including both prompts) ----------
phase('Recon')
const recon = await parallel([
() =>
agent(
`${RULES}\n\nRECON TASK (mechanical, do not hunt bugs yourself):\n` +
`Run: git -C D:/Local-Lab/Repos/OwnCord log --since="8 weeks ago" --name-only --pretty=format: -- Server Client\n` +
`Count how often each non-test source file changed. Return the 25 most-churned files with their counts, ` +
`plus any file that changed in more than 6 distinct commits. High churn = where bugs concentrate.\n` +
`Return plain text: one "path count" per line, most-churned first. No commentary.`,
{ label: 'recon:churn', phase: 'Recon', model: 'haiku', effort: 'low' },
),
() =>
agent(
`${RULES}\n\nRECON TASK (mechanical, do not hunt bugs yourself):\n` +
`Inventory the concurrency and lifecycle surface so the finders know where to look. Report:\n` +
` (a) every Server/ non-test .go file containing "go func", "sync.", "chan ", "select {", or "context.WithCancel"\n` +
` (b) every Client/tauri-client/src/**/*.ts (non-test) containing "addEventListener", "setInterval", "setTimeout", or "new AbortController"\n` +
` (c) every Client/tauri-client/src-tauri/src/*.rs containing "unsafe", "Mutex", "RwLock", "spawn", or "unwrap()"\n` +
`For each file give the path and a rough hit count. Return plain text grouped under (a)/(b)/(c). No commentary, no analysis.`,
{ label: 'recon:surface', phase: 'Recon', model: 'haiku', effort: 'low' },
),
])
const CONTEXT = `\n\n--- RECON: most-churned files (last 8 weeks) ---\n${recon[0] || 'unavailable'}\n\n--- RECON: concurrency & lifecycle surface ---\n${recon[1] || 'unavailable'}\n`
const churnFiles = String(recon[0] || '')
.split('\n')
.map((l) => l.trim().split(/\s+/)[0])
.filter((p) => p.includes('/'))
log('Recon complete - starting converging rounds')
// ---------- round loop ----------
const seen = []
const confirmedAll = []
const unverified = []
const roundStats = []
const cleanStreak = {}
let dry = 0
let round = 0
let stoppedOnBudget = false
function finderPrompt(lens, rnd) {
return (
`${RULES}${CONTEXT}${seenBlock(seen)}\n\nThis is round ${rnd} of a converging hunt. Everything under ` +
`KNOWN FINDINGS has already been investigated - spend zero effort re-deriving those; hunt for what is ` +
`NOT on that list.\n\n${lens.prompt}`
)
}
function verifyPrompt(lensKey, candidates) {
return (
`${RULES}\n\nYou are an ADVERSARIAL VERIFIER. Another model hunted the "${lensKey}" lens of this repo and ` +
`produced the candidate findings below. Your job is to REFUTE them, not to agree with them.\n\n` +
`For each candidate, independently: open the cited file, read the surrounding function in full, grep every ` +
`caller, and look for an existing test that locks the current behavior. Then ask, in order:\n` +
` 1. Does the cited code actually say what the finding claims? (Misread code is the most common failure.)\n` +
` 2. Is the bad state actually reachable, or does an upstream guard/type/lock make it impossible?\n` +
` 3. Is the described repro real - can you name the concrete inputs or the exact interleaving?\n` +
` 4. Is this intended behavior that a test already asserts?\n\n` +
`Set refuted=true if ANY of those kills it. DEFAULT TO refuted=true when you are uncertain - a false ` +
`positive costs more than a miss here. Only set refuted=false when you can point at the specific lines ` +
`that prove the bug and describe how it fires.\n` +
`Re-rate severity yourself; do not inherit the hunter's rating. For each survivor, give the smallest ` +
`correct fix - one guard in the shared function beats a guard in every caller.\n\n` +
`Return one verdict per candidate, keeping title/file/line so they can be matched up.\n\n` +
`--- CANDIDATES ---\n${JSON.stringify(candidates, null, 2)}`
)
}
while (dry < DRY_THRESHOLD && round < MAX_ROUNDS) {
if (budget.total && budget.remaining() < ROUND_BUDGET_FLOOR) {
stoppedOnBudget = true
log(`Budget floor reached (${Math.round(budget.remaining() / 1000)}k left) - stopping before round ${round + 1}`)
break
}
const family = lensesForRound(round + 1)
if (!family || !family.length) break // nothing to hunt != everything demoted
round++
const lenses = family.filter((l) => (cleanStreak[l.key] || 0) < 2)
if (!lenses.length) {
dry++
roundStats.push({ round, family: familyName(round), lenses: 0, candidates: 0, fresh: 0, confirmed: 0, refuted: 0, dryEligible: true, dryAfter: dry })
log(`Round ${round}: every lens demoted - counts as a dry round (dry=${dry})`)
continue
}
const rnd = round
const seenAtStart = seen.slice()
const lensResults = await pipeline(
lenses,
(lens) =>
parallel([
() => agent(finderPrompt(lens, rnd), { label: `r${rnd}:hunt:${lens.key}:opus`, phase: `Round ${rnd}`, model: 'opus', effort: 'high', schema: FINDINGS }),
() => agent(finderPrompt(lens, rnd), { label: `r${rnd}:hunt:${lens.key}:sonnet`, phase: `Round ${rnd}`, model: 'sonnet', effort: 'high', schema: FINDINGS }),
]).then((pair) => ({ lens, pair })),
async (r) => {
const { lens, pair } = r
const finderFailed = pair.some((p) => p === null)
const union = pair.filter(Boolean).flatMap((p) => p.findings || [])
const fresh = dedupe(union, seenAtStart)
if (!fresh.length) return { lens, finderFailed, unionCount: union.length, fresh: [], verdicts: [] }
log(`r${rnd} ${lens.key}: ${fresh.length} fresh candidate(s) -> verification`)
const vopts = { phase: `Round ${rnd}`, model: 'fable', effort: 'high', schema: VERDICTS }
let v = await agent(verifyPrompt(lens.key, fresh), { ...vopts, label: `r${rnd}:verify:${lens.key}` })
if (!v || (v.verdicts || []).length < fresh.length) {
const retry = await agent(verifyPrompt(lens.key, fresh), { ...vopts, label: `r${rnd}:verify:${lens.key}:retry` })
if (((retry && retry.verdicts) || []).length > ((v && v.verdicts) || []).length) v = retry
}
return { lens, finderFailed, unionCount: union.length, fresh, verdicts: (v && v.verdicts) || [] }
},
)
let eligible = !lensResults.some((r) => !r)
let newConfirmed = 0
let newRefuted = 0
let candCount = 0
let freshCount = 0
for (const r of lensResults.filter(Boolean)) {
candCount += r.unionCount
freshCount += r.fresh.length
if (r.finderFailed) eligible = false
let lensConfirmed = 0
const unmatched = r.fresh.slice()
for (const v of r.verdicts) {
const vRec = { file: v.file, line: v.line, title: v.title }
const idx = unmatched.findIndex((f) => isDup(vRec, f) || isDup(f, vRec))
if (idx === -1) {
log(`r${round} ${r.lens.key}: verifier verdict "${v.title}" (${v.file}:${v.line}) matched no candidate - dropped`)
continue
}
unmatched.splice(idx, 1)
const rec = { file: v.file, line: v.line, title: v.title, status: v.refuted ? 'refuted' : 'confirmed' }
if (seen.some((p) => isDup(rec, p))) continue // cross-lens same-round duplicate
seen.push(rec)
if (v.refuted) newRefuted++
else {
newConfirmed++
lensConfirmed++
confirmedAll.push({ ...v, lens: r.lens.key, round })
}
}
if (unmatched.length) {
eligible = false // partial verifier failure: some candidates got no verdict at all
for (const f of unmatched) unverified.push({ ...f, lens: r.lens.key, round })
}
// a lens hunted at partial panel strength, or whose candidates never got a verdict, is not evidence of cleanliness
if (!r.finderFailed && !unmatched.length) cleanStreak[r.lens.key] = lensConfirmed > 0 ? 0 : (cleanStreak[r.lens.key] || 0) + 1
}
if (newConfirmed > 0) dry = 0
else if (eligible) dry++
// ineligible zero-confirm round: dry unchanged - "we didn't fully look" is not "it's clean"
roundStats.push({ round, family: familyName(round), lenses: lenses.length, candidates: candCount, fresh: freshCount, confirmed: newConfirmed, refuted: newRefuted, dryEligible: eligible, dryAfter: dry })
log(`Round ${round} (${familyName(round)}): ${newConfirmed} confirmed, ${newRefuted} refuted, dry=${dry}${eligible ? '' : ' (ineligible)'}`)
}
const converged = dry >= DRY_THRESHOLD
// ---------- report ----------
phase('Report')
const RANK = { critical: 0, high: 1, medium: 2, low: 3 }
const confirmedSorted = confirmedAll.slice().sort((a, b) => RANK[a.severity] - RANK[b.severity])
const unverifiedFinal = unverified.filter((u) => !seen.some((p) => isDup(u, p)))
const table = convergenceTable(roundStats, converged, stoppedOnBudget)
let report
if (!confirmedSorted.length && !unverifiedFinal.length) {
const outcome = converged ? 'Converged' : stoppedOnBudget ? 'Stopped on budget - NOT converged' : 'Hit the round backstop - NOT converged'
report = `${outcome} after ${round} round(s) with zero confirmed findings.\n\n${table}`
} else {
report = await agent(
`You are writing the final bug-hunt report for OwnCord (D:/Local-Lab/Repos/OwnCord).\n\n` +
`The findings below already survived adversarial verification - do NOT re-litigate them, and do NOT add new ` +
`ones. Your job is presentation and prioritization for a maintainer who will fix these today.\n\n` +
`Spot-check the two highest-severity findings against the real files to make sure file paths and line numbers ` +
`are accurate; correct them silently if they drifted.\n\n` +
`Write markdown:\n` +
` - Open with one paragraph: how many real bugs, in which subsystems, whether the hunt CONVERGED, and which ` +
`finding to fix first and why.\n` +
` - Then one section per finding, ordered by severity: a "### <severity> - <title>" heading, the ` +
`\`file:line\` reference, what breaks and under exactly what conditions, and the smallest correct fix.\n` +
(unverifiedFinal.length
? ` - Then an "## Unverified - re-run" section listing these candidates whose verification failed twice: ` +
`${JSON.stringify(unverifiedFinal)}\n`
: '') +
` - End with the convergence table below, VERBATIM.\n` +
`Write in complete sentences. No emoji, no "consider" hedging.\n\n` +
`--- CONFIRMED FINDINGS ---\n${JSON.stringify(confirmedSorted, null, 2)}\n\n` +
`--- CONVERGENCE TABLE ---\n${table}`,
{ label: 'report', phase: 'Report', model: 'fable', effort: 'high' },
)
}
return { converged, stoppedOnBudget, rounds: roundStats, confirmed: confirmedSorted, unverified: unverifiedFinal, report }
+4
View File
@@ -17,6 +17,10 @@
- [ ] Unit tests pass (`npm test` / `go test ./...`)
- [ ] TypeScript check passes (`npx tsc --noEmit`)
- [ ] Manual testing done (describe below)
- [ ] Docs updated — anything under `docs/architecture/` (incl. `ux/`) whose
"Source of truth" files this PR touches is updated in the same PR
(their maintenance rule), and reference docs (`api.md`, `protocol.md`,
`schema.md`, `server-configuration.md`) reflect any surface changes
## Screenshots
+155 -63
View File
@@ -37,7 +37,7 @@ jobs:
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: "1.26"
cache-dependency-path: Server/go.sum
@@ -75,6 +75,19 @@ jobs:
- name: Run tests with deadlock detection
run: go test -tags deadlock -count=1 ./...
# Tag-gated tests (DC-06 / T-2026-07-25-16). The build-tag matrix above
# only COMPILES the otel/wazero variants; the tests behind those tags
# (plugin/sandbox_wazero_test.go, telemetry/telemetry_otel_test.go) ran
# nowhere until this step. Scoped to the two packages that carry tagged
# files — every other package is tag-invariant and already covered by the
# race run above. One leg is enough; no -race (the runtime under the tag
# is the concern, not new concurrency).
- name: Run tag-gated tests (-tags wazero, -tags otel)
if: matrix.os == 'ubuntu-latest'
run: |
go test -tags wazero -count=1 ./plugin/...
go test -tags otel -count=1 ./telemetry/...
- name: Upload Go coverage
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
@@ -83,11 +96,18 @@ jobs:
path: Server/coverage.out
retention-days: 7
# verify: false — the action's default `config verify` pass fetches
# golangci-lint.run's JSONSchema over HTTPS before linting anything, so a
# timeout on that host fails a required job having run zero linters (it
# took main red on d352696). `golangci-lint run` rejects a bad config on
# its own; the schema pass only bought a prettier error message, priced
# at a third-party site inside the gate.
- name: Lint
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
with:
version: v2.11.3
working-directory: Server/
verify: false
client-check:
name: Client Static Checks
@@ -107,25 +127,6 @@ jobs:
- name: Install npm dependencies
run: npm ci
- name: Patch auto-generated Tauri TypeScript bindings
working-directory: Client/tauri-client/
# tauri-typegen generates an Event type that is intentionally unused in app code.
# Rename it to _Event so @typescript-eslint/no-unused-vars does not fail.
run: |
node -e "
const fs = require('fs');
const p = 'src/generated/events.ts';
if (fs.existsSync(p)) {
let c = fs.readFileSync(p, 'utf8');
c = c.replace(/^type Event\b/gm, 'type _Event').replace(/^interface Event\b/gm, 'interface _Event');
c = c.replace(/,\s*type Event\s*(?=\})/g, ' '); // unused named import from @tauri-apps/api/event
fs.writeFileSync(p, c);
console.log('Patched: renamed Event -> _Event in generated/events.ts');
} else {
console.log('src/generated/events.ts not found, skipping patch.');
}
"
# Scoped to shipped dependencies. The remaining high findings are all one
# advisory, brace-expansion <=5.0.7, reaching us only through dev tooling
# (eslint, @vitest/coverage-v8, stryker). Those are already on their
@@ -144,6 +145,12 @@ jobs:
- name: TypeScript check
run: npx tsc --noEmit
- name: TypeScript check (Playwright specs)
# The main tsconfig excludes tests/e2e from the app graph; this
# project typechecks the 47 spec files + fixtures + the three
# playwright configs so type rot cannot hide there.
run: npx tsc -p tsconfig.e2e.json --noEmit
- name: ESLint (type-aware rules)
run: npx eslint src/
@@ -151,7 +158,9 @@ jobs:
run: npx prettier --check "src/**/*.ts" "tests/**/*.ts"
- name: Knip (unused code & deps)
run: npx knip || true
# Blocking since the 2026-08-04 remediation: the '|| true' era let a
# real unused-export finding sit invisible in every green run.
run: npx knip
# Unit tests live in their own job so a suite failure is visible as exactly one
# failing check instead of masking the static gates above. The suite is GREEN
@@ -218,7 +227,7 @@ jobs:
components: clippy
- name: Rust cache
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: Client/tauri-client/src-tauri
@@ -228,24 +237,23 @@ jobs:
- name: Rust unit tests
run: cargo test --lib
# Playwright e2e against the mocked-Tauri dev server. The suite is green
# since the mock repair (start_http_proxy stub + voice-premise rewrite):
# a full 255-test run passes locally in ~7.5 min at 1 worker. Runaway
# protection lives in playwright.config.ts (maxFailures: 20 aborts a
# systemic cascade early; globalTimeout: 20 min self-terminates with a
# usable report) with timeout-minutes below as the outer backstop.
# Playwright e2e against the mocked-Tauri dev server. Runaway protection
# lives in playwright.config.ts (maxFailures: 20 aborts a systemic cascade
# early; globalTimeout: 20 min self-terminates with a usable report) with
# timeout-minutes below as the outer backstop.
#
# Still continue-on-error for now: a newly-revived 255-test browser suite
# may harbor rare flakes (retries: 2 covers them, but confidence needs a
# few green pushes first). Flip this job to blocking once it has been
# stably green across several pushes.
# BLOCKING since 2026-08-05 (DC-07): the post-repair soak recorded green
# full-suite runs at 270, 276 and 291 tests across the 08-04/08-05 audit
# branches, and the one hard CI failure in that window was a real spec bug
# (updater install-settle race), which a non-blocking job would have let
# rot. retries: 2 absorbs the known rare flake class (see E2E-ISSUES.md's
# flake accounting).
# See docs/audit-test-coverage-2026-07-25.md T-2026-07-25-21.
# The native config (playwright.config.native.ts) is deliberately not wired
# up — it needs a real server and a built desktop binary.
client-e2e:
name: Client E2E (Playwright, non-blocking)
name: Client E2E (Playwright)
runs-on: ubuntu-latest
continue-on-error: true
timeout-minutes: 25
defaults:
run:
@@ -278,6 +286,95 @@ jobs:
Client/tauri-client/test-results/
retention-days: 7
# Admin-panel journey against a REAL server (no mocks): start-server.sh
# builds the Go binary and boots it with a fresh temp data dir, and the
# suite drives the embedded SPA through the first-run wizard, dashboard,
# channel CRUD, audit log and re-login — the one DC-04 surface the mocked
# suites cannot reach. Non-blocking while it earns its soak, same
# graduation convention client-e2e followed.
admin-e2e:
name: Admin Panel E2E (real server, non-blocking)
runs-on: ubuntu-latest
continue-on-error: true
timeout-minutes: 20
defaults:
run:
working-directory: Client/tauri-client/
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: "1.26"
cache-dependency-path: Server/go.sum
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20
cache: npm
cache-dependency-path: Client/tauri-client/package-lock.json
- name: Install npm dependencies
run: npm ci
- name: Install Playwright browser
run: npx playwright install --with-deps chromium
- name: Run admin-panel journey
run: npx playwright test --config=playwright.config.admin.ts
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: admin-e2e-report
path: |
Client/tauri-client/playwright-report/
Client/tauri-client/test-results/
retention-days: 7
# Blocking e2e subset: the parity-feature specs (tagged "@parity"), covering
# the wire paths added in v1.2.0 (mentions/badges, per-channel mute, NSFW
# gate, group DMs, role change, custom-emoji autocomplete, voice moderation).
# These are new and authored green, so unlike the full legacy suite above they
# gate PRs: a regression on one of these features must fail CI. Kept as its own
# job (not folded into the non-blocking suite) so the legacy suite can keep
# earning its "few green pushes" before it too graduates to blocking.
client-e2e-parity:
name: Client E2E (parity subset, blocking)
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: Client/tauri-client/
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20
cache: npm
cache-dependency-path: Client/tauri-client/package-lock.json
- name: Install npm dependencies
run: npm ci
- name: Install Playwright browser
run: npx playwright install --with-deps chromium
- name: Run parity e2e specs
run: npx playwright test --config=playwright.config.ts --grep "@parity"
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: playwright-report-parity
path: |
Client/tauri-client/playwright-report/
Client/tauri-client/test-results/
retention-days: 7
# Image build is verification only, so it is skipped on dev to keep day-to-day
# work on the fast check suite. Runs for main pushes and PRs targeting main.
server-docker-build:
@@ -288,10 +385,10 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build image (no push)
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: Server/
push: false
@@ -299,11 +396,30 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
# Full Tauri build only on PRs to main (expensive: ~15 min x2 multiplier)
# Full Tauri build only on PRs to main (expensive: ~15 min x2 multiplier).
#
# Skipped for Dependabot: its PRs run under the separate `dependabot` secrets
# scope, so TAURI_SIGNING_PRIVATE_KEY arrives empty and `npm run tauri build`
# always aborts with "failed to decode secret key" while signing the updater
# artifact — after a successful compile and bundle. That burned ~50 min of
# runner time per dependency PR to produce a red check that never carried any
# signal. Granting Dependabot the signing key would fix the symptom but hands
# a release key to workflows triggered by third-party dependency updates.
#
# What still covers Dependabot PRs: the required `rust-tests` job compiles the
# crate (cargo clippy --all-targets + cargo test --lib), so a dependency bump
# that breaks the Rust build is still caught.
# What this gives up on those PRs: bundling (NSIS/AppImage/deb), Windows and
# ARM-specific compilation, and the `cargo audit` step below — that last one
# overlaps with Dependabot's own cargo scanning, which is what opens these PRs
# in the first place.
tauri-build:
name: Tauri Full Build (${{ matrix.os }})
needs: client-check
if: github.event_name == 'pull_request' && github.base_ref == 'main'
if: >-
github.event_name == 'pull_request'
&& github.base_ref == 'main'
&& github.actor != 'dependabot[bot]'
strategy:
fail-fast: false
matrix:
@@ -346,37 +462,13 @@ jobs:
components: clippy
- name: Rust cache
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: Client/tauri-client/src-tauri
- name: Install npm dependencies
run: npm ci
- name: Install tauri-typegen
run: cargo install tauri-typegen@0.5.0 --quiet
- name: Generate TypeScript IPC bindings
working-directory: Client/tauri-client/
run: cargo tauri-typegen generate
- name: Fix generated TypeScript bindings (tauri-typegen 0.5.0 workaround)
working-directory: Client/tauri-client/
# tauri-typegen 0.5.0 cannot map serde_json::Value to a TS type — patch post-generation.
# Duplicate events are avoided at source by using one emit() call site per event name.
run: |
node -e "
const fs = require('fs');
const tp = fs.readFileSync('src/generated/types.ts', 'utf8');
if (!tp.includes('export type Value')) {
fs.writeFileSync('src/generated/types.ts', tp.replace(
'export interface CredentialData',
'export type Value = unknown;\n\nexport interface CredentialData'
));
}
console.log('Generated bindings patched.');
"
- name: Clippy lint (Rust)
working-directory: Client/tauri-client/src-tauri/
run: cargo clippy -- -D warnings
+2 -2
View File
@@ -26,13 +26,13 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
uses: anthropics/claude-code-action@9db594c7a0e82298c121c18b7f08aa1579ce7341 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+53 -6
View File
@@ -53,7 +53,7 @@ jobs:
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Rust cache
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: Client/tauri-client/src-tauri
@@ -120,7 +120,7 @@ jobs:
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Rust cache
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: Client/tauri-client/src-tauri
@@ -135,6 +135,31 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: npm run tauri build -- --bundles appimage,deb
# linuxdeploy bundles the runner's libwayland-* into the AppImage, which
# breaks Mesa EGL init on newer hosts (white window on Arch/Fedora —
# EGL_BAD_PARAMETER). Strip them and regenerate the updater artifact +
# signatures for the patched image.
- name: Strip host-incompatible libs from AppImage and re-sign
working-directory: Client/tauri-client
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
BUNDLE_DIR="src-tauri/target/release/bundle/appimage"
APPIMAGE=$(find "$BUNDLE_DIR" -name "*.AppImage" ! -name "*.sig" | head -1)
bash scripts/strip-appimage-bundled-libs.sh "$APPIMAGE"
TARBALL="$APPIMAGE.tar.gz"
rm -f "$TARBALL" "$APPIMAGE.sig" "$TARBALL.sig"
tar czf "$TARBALL" -C "$(dirname "$APPIMAGE")" "$(basename "$APPIMAGE")"
# Sign straight from the environment. TAURI_SIGNING_PRIVATE_KEY is
# the env form of --private-key, so ALSO passing -f/--private-key-path
# makes the CLI abort: "the argument '--private-key-path' cannot be
# used with '--private-key'". Keeping the key in the env instead of a
# temp file also keeps it off the runner's disk.
npx tauri signer sign -p "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$APPIMAGE"
npx tauri signer sign -p "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$TARBALL"
- name: Stage Linux release assets
shell: bash
run: |
@@ -176,7 +201,7 @@ jobs:
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: "1.26"
@@ -251,7 +276,7 @@ jobs:
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Rust cache
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: Client/tauri-client/src-tauri
@@ -266,6 +291,28 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: npm run tauri build -- --bundles appimage,deb
# Same strip + re-sign as the x86_64 job — see the comment there.
- name: Strip host-incompatible libs from AppImage and re-sign
working-directory: Client/tauri-client
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
BUNDLE_DIR="src-tauri/target/release/bundle/appimage"
APPIMAGE=$(find "$BUNDLE_DIR" -name "*.AppImage" ! -name "*.sig" | head -1)
bash scripts/strip-appimage-bundled-libs.sh "$APPIMAGE"
TARBALL="$APPIMAGE.tar.gz"
rm -f "$TARBALL" "$APPIMAGE.sig" "$TARBALL.sig"
tar czf "$TARBALL" -C "$(dirname "$APPIMAGE")" "$(basename "$APPIMAGE")"
# Sign straight from the environment. TAURI_SIGNING_PRIVATE_KEY is
# the env form of --private-key, so ALSO passing -f/--private-key-path
# makes the CLI abort: "the argument '--private-key-path' cannot be
# used with '--private-key'". Keeping the key in the env instead of a
# temp file also keeps it off the runner's disk.
npx tauri signer sign -p "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$APPIMAGE"
npx tauri signer sign -p "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$TARBALL"
- name: Stage Linux ARM64 release assets
shell: bash
run: |
@@ -312,7 +359,7 @@ jobs:
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
@@ -332,7 +379,7 @@ jobs:
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: Server/
push: true
+12 -5
View File
@@ -2,11 +2,13 @@
.env
Server/.env
# Claude Code — local-only, never committed. A slashless pattern matches at any
# depth, so these also cover Server/CLAUDE.md, Client/**/CLAUDE.md and nested
# .claude/ dirs.
.claude/
CLAUDE.md
# Claude Code — local-only by default. The exceptions are committed on purpose:
# a cloud session clones this repo and sees ONLY tracked files, so the CLAUDE.md
# files, skills and workflows have to be here or it starts with no instructions.
# Machine-local state (settings.local.json, locks) stays ignored.
.claude/*
!.claude/skills/
!.claude/workflows/
CLAUDE.local.md
.mcp.json
@@ -25,6 +27,11 @@ docs/research/
docs/superpowers/
/skills/
# Mutation-testing output (npm run test:mutate). Local-only by design: a
# surviving-mutant report maps exactly which behaviour nothing tests.
Client/tauri-client/.stryker-tmp/
Client/tauri-client/reports/
# Server runtime artifacts
Server/chatserver.exe
Server/chatserver.exe~
+319 -6
View File
@@ -5,14 +5,315 @@ tooling (`npm run changelog`) auto-generates entries from commit messages
on each release; this file is the curated counterpart that calls out
behavioural changes operators must know about.
## Unreleased — v1.1.0-alpha series (Phase B + C)
## v1.2.0-alpha.2
- **feat(client):** the login form has an **Auto connect** checkbox under
Remember password. Ticking it makes that server connect automatically on
launch — the same setting as the auto-login button on a server card, so
the two stay in sync, and as before only one server can be auto-connect
at a time.
Ticking it also forces Remember password on and locks it: auto-connect
replays the stored token, which is only written when the password is
remembered, so the two cannot be set independently without producing a
setting that silently does nothing.
- **fix(client):** Remember password works again. The password was saved to
the OS keyring but never returned to the client over IPC, so the login
form could not prefill it — the box appeared to work and did nothing.
- **fix:** three bug-hunt sweeps closed **233 verified defects** since
`v1.2.0-alpha.1` — 26 in #1328, 107 in #1331, 100 in #1332 — each fixed
test-first, with the failing assertion watched red against the unpatched
code before the patch landed. The behavioural consequences worth knowing
about are listed in the nine entries below.
- **server:** WS hub reconnect and replay hardening (#1328, #1331).
Cold-tier replay used to truncate silently instead of forcing a full
ready, and a retention-pruned event log was accepted outright as a
complete resume — the highest-impact fix in #1331, since any client whose
reconnect gap crossed the 24h retention default was permanently desynced.
Resume also silently dropped the focused channel's topic subscription,
stopping message delivery until the user manually switched channels; it
is now restored during the handshake. `visibilityChangeSeq` can now only
move forward across its three writers — it previously could regress and
skip a required resync.
- **server:** voice/E2EE key-holder election and audience gating (#1328,
#1331) — three key-holder desync bugs (no client demotion path, peer keys
cleared on reconnect, missing re-election on the webhook and
fresh-reconnect paths), plus re-election wired into the sweep and
channel-cleanup paths. Voice events were READ-filtered while membership
is CONNECT-only, so participants in that gap silently missed
`voice_leave`, stalling key-holder election and forward-secrecy rotation.
Deleting a channel now evicts its voice participants first — the cleanup
function existed but had zero production callers, so the FK cascade used
to strand them silently. Moderator mute/deafen now survives a
voice-channel switch; joins to non-voice channels are rejected; archived
channels are read-only and unjoinable.
- **security(server):** roles/permissions (#1328, #1331) — `UpdateRole`
allowed position collisions that `CreateRole` already rejected, so tied
positions could read as equal rank in every hierarchy comparison; it now
matches `CreateRole`'s validation. `can_send` is now recomputed per client
on every role/override change, so a permission change takes effect for
connected clients immediately rather than waiting on a reconnect.
- **server:** attachments and admin data-safety (#1331) — migration **030**
unlinks attachments on message delete instead of cascading, so a cascaded
channel/DM delete no longer strands uploaded files on disk with no
reclamation path. The 15-minute orphan-attachment sweep was deleting every
avatar in the instance (avatars are, by design, attachments with no
message link) on its first tick past the grace period, permanently 404ing
every profile picture; a second bug in the same sweep collapsed the
one-hour grace period to effectively zero, from a TEXT-comparison mismatch
between an RFC3339 cutoff and SQLite's own timestamp format. A failed
backup restore used to truncate the live database to zero bytes with no
rollback, while the server kept answering requests against the now-closed
DB and falsely claimed a restart was underway — it now restores the
pre-restore safety copy on failure and requests the restart honestly.
Also fixed: personal data is cleared on account deletion, banned users are
excluded from owner lookup, the silent 1000-member roster cap is gone, and
a sender's own read state now advances on send. Migration applies
automatically on first start; no operator action needed.
- **protocol:** a new READ-gated `active_channel_id` auth field (#1331)
restores the focused-channel subscription during the reconnect handshake
itself, closing the window before the post-`auth_ok` `channel_focus` round
trip lands. `protocol.md` also corrects the presence table, which had
incorrectly documented all presence events as sequenced. Older
clients/servers are unaffected — it is a new, ignorable field.
- **security(client):** identity/TOFU and transport (#1332) — an in-flight
change to scope the identity keypair by host *and* user id would have
re-minted a fresh key on every existing install, firing the TOFU "verify
out-of-band" re-pin warning at the entire alpha population simultaneously,
exactly the pattern that teaches users to click through the one warning
meant to matter. The legacy host-only key is now adopted into the scoped
name instead, saving before deleting so a partial failure cannot strand a
user with neither key. Switching hosts carried the previous server's
bearer token forward into the next login request; `api.setConfig` now
drops it when the host changes without a replacement. A hand-copied,
un-lowercased host normalizer in `main.ts` meant an uppercase hostname's
cert-mismatch *reject* path skipped `disconnect()`/`clearAuth()`, leaving
a user who refused a changed certificate still connected to that server —
the single lowercased implementation in `ws.ts` is now shared everywhere.
- **fix(client):** voice mic/camera reliability (#1331, #1332) — six
separate paths could republish the microphone without checking the user's
mute state (the audio-device fallback, selecting "Default" input,
un-deafening, `retryMicPermission`, a stale PTT ownership latch, and
auto-reconnect's `restoreLocalVoiceState`), each producing a hot mic while
every remote UI still showed the user muted; all now route through
`isMicPolicyGated()`. Camera and screenshare kept publishing to the SFU
after the user turned them off during the OS device picker. Enhanced Noise
Suppression silently disabled the input-volume slider and VAD gate because
`livekit-client`'s own `replaceTrack` call landed after ours. A key-holder
promotion arriving mid voice-setup was clobbered, ejecting the joiner
after a timeout only it could have resolved.
- **fix(client):** messaging and store reliability (#1328, #1331, #1332) —
sequenced DMs could jump the FIFO ahead of `sendHigh`, permanently losing
an event dropped before flush. A full-ready resync left every loaded
channel with a permanent hole in its history, because that tier never
replays `chat_message` frames; loaded windows are now invalidated and the
active channel refetched. The WS error handler only bannered
`RATE_LIMITED` and `FORBIDDEN`, so every other server error code — for
example a rejected `chat_edit` — was dropped in silence while the
optimistic "Message edited" toast still fired. A message whose
`chat_send_ok` was lost to the same disconnect that forced a resync could
render twice; the optimistic row's id-based dedup now shares the
content-based match predicate `addMessage` already used. Replay detection
compared the server's `created_at` against the client's own clock, so a
self-hosted server without NTP made every live message after a reconnect
look like a replay and silently killed its notification; both sides now
use an estimated server-time skew.
- **fix(client):** UI defects (#1331, #1332) — the quick-switcher could
mount a second overlay, orphaning a body-mounted backdrop that blocked all
input until reload. The status-picker stylesheet targeted a root element
the component never toggles; a same-branch repair then left the status dot
itself 0×0 and unclickable, now fixed together with a test pinning the
stylesheet to the classes the component actually emits. The attachment
remove button and the failed-send Retry/Discard buttons did nothing;
drag-reorder's phantom-drag latch and permission gate are fixed; keyboard
Tab could escape every modal because hidden (`display: none`) controls
were still counted as focusable.
- **fix(client):** the user profile popup is styled correctly again
(`a308f81`).
- **fix(client):** Vite no longer watches `src-tauri/`, so a running dev
server does not rebuild the frontend when Rust sources or build artifacts
change (`cdcfc03`).
- **fix(release):** the stripped Linux AppImage is signed from the
environment-provided key instead of a temporary key file (`9d75890`) —
release-pipeline only, no operator action needed.
- **docs:** full documentation audit against `5630aa1` — reference docs,
architecture pages, and UX specs corrected; plans and prior audits given
verified statuses; see `docs/audit-2026-08-04-docs-and-coverage.md`.
- **security(server):** closed the three 2026-08-04 review findings — the
channel role-override **DELETE** now enforces the same hierarchy guard as
PUT (A-2026-08-01); the admin channel list/edit/delete surface no longer
sees DM channels, answering 404 for their ids (A-2026-08-02); DM call
rings respect blocks like every other DM interaction (A-2026-08-03).
Behavioural note: deleting a channel override for a *nonexistent* role now
returns 404 (was 204), matching PUT.
- **server:** migration **029** drops the never-used `sounds` table (dead
since the initial schema; A-2026-07-13). Applies automatically on first
start; no operator action.
- **protocol:** the plugin command family (`chat_command`, `command_reply`,
`plugin_broadcast`) is now part of `protocol-schema.json` and the
generated constants (27 client→server / 39 server→client). Wire strings
are unchanged — no client or plugin impact.
- **chore(client):** dead modules deleted (`ServerStrip`, `FileUpload`,
`reconcile`, a stray worklet copy, orphan sounds API methods) and the
unused tauri-typegen pipeline retired (`src/generated/**`, its CI steps,
config block, and build-dependency).
- **ci:** knip is now blocking; Playwright specs are typechecked
(`typecheck:e2e`); three orphaned native e2e specs run again;
`claude.yml` actions are SHA-pinned; the PR template asks for docs
updates per the architecture maintenance rule.
- **tests(client):** the TOFU certificate ceremony has e2e coverage
(first-use + mismatch journeys), and `modalFactory` is fully covered.
- **security(client):** the voice-E2EE identity pin lookup fails **closed**
on keyring errors (DC-08): a transient store failure used to read as
"never pinned", silently sending a pinned peer down the first-sight path
and re-pinning whatever key the server delivered. An unreadable pin store
now rejects the peer's announce, writes nothing, and shows a distinct
amber "could not check" badge until the store recovers.
- **feat(client):** accessibility pass over the modal/overlay stack
(DC-13): every modal is a labelled `role="dialog"` with a focus trap and
focus restore, Escape maps to each dialog's safe action, the settings
sidebar is a keyboard-navigable tablist, the quick switcher and composer
autocompletes are wired as combobox/listbox, the emoji/GIF pickers are
keyboard-operable, and toasts/typing announce via polite live regions.
- **feat(client):** UX polish (DC-12): deleting the active channel now
says so in a toast; reactions toggle optimistically with rollback on
failure; the role-change menu can no longer double-fire; a document-level
listener leak in channel drag-reorder is fixed.
- **feat(admin):** restoring a backup now writes a `backup_restore`
audit-log row (DC-09). The row is written before the pre-restore safety
copy, so it lives inside the `pre_restore_*.db` backup — the restored
database itself cannot carry it (the restore replaces the file).
- **ci:** the `-tags wazero` / `-tags otel` Go tests now actually run in CI
(DC-06) — previously those variants were only compiled, leaving ~600
lines of plugin/telemetry tests permanently dark.
- **tests(client):** e2e journeys for voice-E2EE identity verification
(badge states + mismatch modal, driven through the real crypto path) and
the updater (banner → progress → auto-relaunch), plus an accessibility
smoke; full web suite now 291 tests.
- **server/admin:** in-place self-update is refused in container
deployments (503 `CONTAINER_DEPLOYMENT`; the shipped image sets
`OWNCORD_CONTAINER=1`, bind-mount operators can set `0` to opt back in).
Container upgrades are image pulls; `GET /admin/api/updates` now reports
`can_apply` and the admin panel says so instead of offering the button.
- **ci:** the full client e2e suite now blocks merges (DC-07); a new
non-blocking `admin-e2e` job drives the admin panel against a real server
(first-run wizard, channel CRUD, audit log, re-login).
- **docs:** the dependency pinning/review policy is written down in
`docs/contributing.md`, closing the last 2026-04 audit carryover that was
still undecided.
## v1.2.0-alpha.1 — Discord feature parity
> **Project reset note:** OwnCord has re-entered alpha. The `v1.0.0` release is
> superseded; versioning continues forward as `v1.1.0-alpha.N` so deployed
> servers and clients keep receiving updates. Releases are published to this
> superseded; versioning continues forward from `v1.1.0-alpha.N` so deployed
> servers and clients keep receiving updates. This release bumps the minor to
> `v1.2.0-alpha.1` to mark a large feature drop. Releases are published to this
> repository's [Releases](https://github.com/J3vb/OwnCord/releases) page,
> including a full source snapshot with every release.
This release closes most of the feature gap against basic Discord (see
[docs/plans/discord-parity.md](docs/plans/discord-parity.md) for the full
gap analysis and per-item detail). The work landed as six phases plus a
pre-release security and performance review.
### Messaging & mentions
- **Real mentions.** `@username` is now resolved server-side against unique
usernames (address-shaped text like `mail@example` is rejected), stored per
message, and carried on the wire — so a mention notifies, highlights the
message, and drives a red per-channel mention badge distinct from the plain
unread count. `@everyone` / `@here` are gated on a new `MENTION_EVERYONE`
permission (`@here` skips offline and invisible users). `#channel` names
render as clickable navigation chips, and the composer gains an `@`
autocomplete.
- **Markdown rendering.** Messages render Discord-flavoured markdown — bold,
italic, underline, strikethrough, spoilers, block quotes, headings, lists,
masked links (`http(s)` only), and fenced code blocks with a language tag
and lightweight syntax highlighting. Rendering is a strict DOM builder with
no `innerHTML`. `Ctrl+B/I/U` wrap the selection in the composer.
- **Custom emoji.** Server emoji can be uploaded and managed (admin panel,
`MANAGE_SERVER`); `:shortcode:` renders inline in messages (jumbo when a
message is emoji-only), appears in the picker and a `:`-autocomplete, and can
be used as a reaction.
- **Message navigation.** Search results, pinned messages, reply previews, and
message permalinks (`owncord://message/…`, copyable from the hover bar) all
jump to the target — fetching a window around it when it is not loaded, with
a "Jump to Present" affordance. Reactions show a who-reacted tooltip on
hover, video and audio attachments get inline players, and a "NEW" divider
plus explicit Mark as Read / Mark All as Read round out read state.
- **Bulk delete.** `POST /channels/{id}/messages/purge` soft-deletes the newest
N messages (`MANAGE_MESSAGES`), broadcasting one `chat_bulk_deleted` event.
### Roles, permissions & moderation
- **Role management.** Roles are now first-class: create, edit, delete, reorder,
and edit permission masks and colours from the admin panel, all gated on
`MANAGE_ROLES` and bounded by the actor's own position (you cannot touch a
role at or above your rank, nor grant a permission bit your own role lacks).
- **The permission bits are live.** The six previously-decorative bits
(`MANAGE_CHANNELS`, `KICK_MEMBERS`, `MUTE_MEMBERS`, `MANAGE_ROLES`,
`MANAGE_SERVER`, `VIEW_AUDIT_LOG`) are now enforced per admin route group, so
a Moderator role can actually moderate without being a full Administrator.
- **Per-user channel overrides.** Channel permissions resolve in Discord's
order — base role → role override → user override — with a tri-state override
matrix editor (role or user) in the admin panel.
- **Voice moderation.** Holders of `MUTE_MEMBERS` can server-mute, server-deafen,
move, or disconnect a lower-ranked user; a server mute is enforced at the SFU.
- **Channel management from the desktop client.** Topics render and are editable,
plus slowmode, an NSFW flag (with a per-session age gate), and voice
user/video limits. Categories are now free text (any type under any name).
### Social & profiles
- **Profiles.** Avatar uploads (replacing letter-initials everywhere), display
names (with the `@username` handle preserved for mentions), an about/bio, and
a custom status line.
- **Presence.** Invisible is now a real status that never leaks to other users
and survives a reconnect (the previous flash-online-on-connect bug is fixed);
a 10-minute auto-idle that never overrides a manual status.
- **Group DMs** (210 participants, name, leave), **DM calls** with ringing
(Call button + incoming-call banner over the existing DM voice path), and
**per-channel notification mutes** (mentions still notify; other noise is
silenced).
- **Quick wins from phase 1.** Block/unblock from the member menu, temporary
bans, server-driven role colours, a mounted profile popup, and archived
channels that actually hide.
### Security & performance review (pre-release)
- Channel-override endpoints now enforce grantability: a `MANAGE_CHANNELS`
holder cannot grant itself or a user a permission bit its own role lacks,
closing a privilege-escalation path.
- DM voice events (`voice_state`/`voice_leave`) are delivered only to the DM's
participants instead of every user with base `READ_MESSAGES`.
- Voice moderation cannot reach a private DM call the actor is not part of.
- Mention-count bookkeeping is batched (one writer exec per 500 readers instead
of one per reader) and resolved against a set; the markdown parser's
bracket matching is amortized-linear; video/audio attachment blobs are
LRU-capped and revoked, and cleared on logout.
### Test hardening (pre-release)
The hostile-input surface is now covered by Go native fuzzers and
client-side property tests (mention/emoji parsing, FTS query sanitizing,
permission resolution, markdown tokenizing, filename/path sanitizing,
content sanitizing, credential validation, avatar URLs, LiveKit webhook
identities), which found and fixed two real bugs:
- **Zero-dimension images are rejected.** A GIF decoding to height 0, and a
VP8 keyframe with an all-zero size field, both passed the image size guard
as "small". `imageDimensions` now rejects non-positive dimensions centrally.
- **Upload filenames stay safe basenames.** `/` survived sanitizing verbatim
(`filepath.Base("/")` is `"/"`), and over-length names were truncated
mid-rune into invalid UTF-8. Both are fixed at the sanitizer.
Also added: a full migration-chain and pre-parity (019) upgrade round-trip
test, a protocol-schema/generated-constant drift test, a 200-client hub
load/soak test with `goleak` verification, and a blocking `@parity`
Playwright job covering the new parity features. Separately, a test-quality
audit rewired tests that asserted nothing (or a tautology) to assert their
claimed behaviour — no product code changed and no assertion weakened.
### Phase B — Acceleration
- **Event persistence layer (Step 7).** A new `events` table backs the
@@ -134,12 +435,24 @@ behavioural changes operators must know about.
- **Plugin admin endpoints require admin session auth in addition to
the existing IP restriction.** A previous prerelease shipped with only
the IP gate; that has been corrected.
- **The parity work adds nine database migrations (`020``028`) that apply
automatically on first boot.** They add the `message_mentions`,
`channel_user_overrides`, and emoji-supporting tables/columns, per-user
profile fields (`display_name`, `about`, `custom_status`), channel flags
(`nsfw`, `is_group`), and the `server_muted`/`server_deafened` voice-state
columns; a migration also seeds the new `MENTION_EVERYONE` permission bit
into the Owner/Admin/Moderator roles. No manual step is required, but take a
backup before upgrading as usual. The release also introduces new WebSocket
message types (`roles_update`, `emoji_update`, `chat_bulk_deleted`,
`voice_mod_*`, `voice_moved`, `voice_disconnected`, `mark_read`,
`call_ring`/`call_incoming`/`call_decline`); older clients ignore unknown
types, and older servers omit the new fields (the client fails safe).
### Deferred work
The project is under a feature freeze until the beta reset completes.
Explicitly deferred (not abandoned unless noted): real OpenTelemetry SDK
wiring, the Postgres backend (scaffolding removed pending real demand),
the slash-command dispatcher (`docs/plans/slash-commands.md`), and the
Solid.js migration (abandoned — the experiment is being removed in favor
of the established vanilla component pattern).
and the slash-command dispatcher (`docs/plans/slash-commands.md`). The
Solid.js migration was abandoned and its experiment fully removed
(2026-07-19) in favor of the established vanilla component pattern.
+30
View File
@@ -0,0 +1,30 @@
# OwnCord
Self-hosted chat platform (alpha). `Server/` is a Go 1.26 REST + WebSocket
server over SQLite with LiveKit voice/video; `Client/tauri-client/` is a Tauri
v2 desktop app (TypeScript frontend, thin Rust backend). Per-component detail
lives in `Server/CLAUDE.md` and `Client/tauri-client/CLAUDE.md`; the protocol
and schema are documented in `docs/protocol.md`, `docs/schema.md`, and
`docs/architecture/README.md`.
## Generated code — never hand-edit
CI fails on drift, and the next generator run silently discards your edit.
| Generated | Source of truth | Workflow |
| --- | --- | --- |
| `Server/db/dbgen/` | `Server/db/queries/*.sql`, `Server/migrations/` | `db-change` skill |
| `Server/ws/message_types.go` **and** `Client/tauri-client/src/lib/protocolTypes.ts` | `docs/protocol-schema.json` | `protocol-change` skill |
| `Client/tauri-client/src/generated/` | `tauri-typegen` | CI patches known typegen bugs — see `.github/workflows/ci.yml` |
## Gotchas
- **Verify with the `ci-check` skill**, not with an ad-hoc `go build && go test`.
CI compiles four Go build-tag variants and runs a deadlock-detection pass;
the default build proves nothing about the tagged ones.
- **The client unit suite is green and must stay green.** Never make a failing
test pass by weakening its assertions.
- Security issues go through GitHub Security Advisories, never public issues
(`docs/security.md`). This repo is public — unfixed defects do not belong in
commits, issues, or PR descriptions.
- Branch from `main`, PR to `main`, squash merge, conventional commit subjects.
+1
View File
@@ -0,0 +1 @@
20
+38
View File
@@ -0,0 +1,38 @@
# OwnCord Client (Tauri v2)
TypeScript frontend (Vite, vanilla TS — no React/Vue) plus a deliberately thin
Rust backend in `src-tauri/` for native APIs only. LiveKit handles voice/video.
## Layout
- `src/stores/` observable stores · `src/lib/` protocol, WS, voice, E2EE ·
`src/pages/`, `src/components/` UI
- `src/lib/protocolTypes.ts` and `src/generated/` are generated — see the root
CLAUDE.md
- `tests/unit`, `tests/integration` (vitest, jsdom) · `tests/e2e` (Playwright) ·
`tests/browser` (vitest browser mode)
## Gotchas
- **On Node 22+ you must run `NODE_OPTIONS=--no-experimental-webstorage npm test`.**
Native Web Storage shadows jsdom's `localStorage` and fails ~478 tests that
have nothing to do with your change. That is a local toolchain artifact, not
a regression — do not "fix" those failures. CI pins Node 20.
- `src/lib/dispatcher.ts` is the single WS-event entry point **into the
stores**: server events reach domain stores only through a `ws.on(...)`
subscription registered there. Other modules do register their own
`ws.on(...)` handlers for page-local UI (`main.ts`, `MainPage.ts`,
`ChannelController.ts` — ringing, overlays, slow-mode timers); that is fine
as long as they only *read* store state. Writing a store from one of those
handlers is the violation, and `local/no-store-write-in-ws-on` now fails the
build on it.
- Voice sessions are superseded, not cancelled. `LiveKitSession` re-entry
points check whether a newer attempt owns the shared state before tearing
anything down, so cleanup in an aborted path must be scoped to that attempt's
own room — a global `leaveVoice()` there kills the live session.
- Voice E2EE is key-holder based with TOFU identity pinning. Anything touching
`livekitE2EE.ts` or `identity.ts` must preserve the epoch/keypair staleness
guards and must never report an unverified peer as verified.
- Do not run `npm run tauri build` locally; the desktop build is CI-only.
- Formatting is prettier-enforced; match the surrounding code rather than
reasoning about style.
+408
View File
@@ -0,0 +1,408 @@
// Custom ESLint rules that turn three of the invariants documented in prose in
// CLAUDE.md into enforced, test-covered lint rules. Each rule is scoped (via
// `files:` in eslint.config.js) to only the module(s) its invariant governs —
// see the per-rule `meta.docs.description` for the invariant it encodes and
// tests/unit/eslint-rules.test.ts for the real-code shapes it was proven
// against (both the shapes that must stay clean and the historical bug shapes
// it must catch).
//
// Plain JS, ESM, no build step — eslint.config.js imports this directly.
/** True when `node` is a `this.<methodName>(...)` call. */
function isThisMethodCall(node, methodName) {
return (
node !== null &&
node.type === "CallExpression" &&
node.callee.type === "MemberExpression" &&
node.callee.object.type === "ThisExpression" &&
!node.callee.computed &&
node.callee.property.type === "Identifier" &&
node.callee.property.name === methodName
);
}
/** True when `node` is a `this.<propertyName>` member access. */
function isThisMember(node, propertyName) {
return (
node !== null &&
node.type === "MemberExpression" &&
node.object.type === "ThisExpression" &&
!node.computed &&
node.property.type === "Identifier" &&
node.property.name === propertyName
);
}
function isFunctionNode(node) {
return (
node.type === "FunctionDeclaration" ||
node.type === "FunctionExpression" ||
node.type === "ArrowFunctionExpression"
);
}
// ─────────────────────────────────────────────────────────────────────────
// Rule: no-leave-voice-when-superseded
//
// Invariant (CLAUDE.md): "Voice sessions are superseded, not cancelled.
// LiveKitSession re-entry points check whether a newer attempt owns the
// shared state before tearing anything down, so cleanup in an aborted path
// must be scoped to that attempt's own room — a global leaveVoice() there
// kills the live session."
//
// livekitSession.ts encodes "this attempt was superseded" with exactly two
// guard predicates, always used the same way: `this.reconnectSuperseded(...)`
// (true = superseded) and `!this.isStateConnected(...)` (negated = true when
// superseded). Once either guard has confirmed supersession, the historical
// bug (see the fix that introduced disconnectSupersededLocalRoom /
// generation-guarded leaveVoice calls) was calling the global
// `this.leaveVoice()` inside that same branch, tearing down whichever session
// currently owns the shared state — which, once superseded, is a newer
// attempt's live session, not this one.
// ─────────────────────────────────────────────────────────────────────────
/** True when `test` (walking through &&/||) asserts "this attempt IS
* superseded" via one of the two named guards used throughout the file. */
function testSignalsSuperseded(test) {
if (test === null) return false;
if (test.type === "LogicalExpression") {
return testSignalsSuperseded(test.left) || testSignalsSuperseded(test.right);
}
if (isThisMethodCall(test, "reconnectSuperseded")) return true;
if (test.type === "UnaryExpression" && test.operator === "!") {
return isThisMethodCall(test.argument, "isStateConnected");
}
return false;
}
const noLeaveVoiceWhenSuperseded = {
meta: {
type: "problem",
docs: {
description:
"Disallow this.leaveVoice() inside a branch that already confirmed this connect/reconnect " +
"attempt was superseded. Voice sessions are superseded, not cancelled — once reconnectSuperseded() " +
"or !isStateConnected() is true, `_state` may already belong to a newer, live attempt, and " +
"leaveVoice() there tears that live session down instead of the aborted one.",
},
schema: [],
messages: {
unsafeLeaveVoice:
"this.leaveVoice() must not run once this attempt is known to be superseded — it acts on " +
"whichever session currently owns `_state`, which may now be a newer, live attempt. Disconnect " +
"only this attempt's own room instead (e.g. disconnectSupersededLocalRoom(localRoom) / " +
"localRoom.disconnect()), or simply return without calling it.",
},
},
create(context) {
return {
CallExpression(node) {
if (!isThisMethodCall(node, "leaveVoice")) return;
let child = node;
let parent = node.parent;
while (parent) {
if (isFunctionNode(parent)) return; // left the enclosing method — stop
if (
parent.type === "IfStatement" &&
child === parent.consequent &&
testSignalsSuperseded(parent.test)
) {
context.report({ node, messageId: "unsafeLeaveVoice" });
return;
}
child = parent;
parent = parent.parent;
}
},
};
},
};
// ─────────────────────────────────────────────────────────────────────────
// Rule: e2ee-epoch-needs-keypair-check
//
// Invariant (CLAUDE.md): "Anything touching livekitE2EE.ts ... must preserve
// the epoch/keypair staleness guards."
//
// Every async E2EE operation that resumes after an await re-checks it is
// still the current attempt before writing shared state. The historical bug
// (see the fix for handleOfferInner / handleAnnounceInner) compared only
// `this._e2eeEpoch !== epochBefore` — insufficient, because a non-key-holder
// never bumps the epoch, so a torn-down-then-restarted session can resume
// with the epoch unchanged in both the old and new session. The fix requires
// ALSO comparing keypair identity (`this._ecdhKeyPair !== keypair`). This
// rule requires both checks to appear together in the same guard.
// ─────────────────────────────────────────────────────────────────────────
/** True when `test` (walking through &&/||) contains `this.<prop> !== X`
* (in either operand order). */
function containsStrictInequality(test, prop) {
if (test === null) return false;
if (test.type === "LogicalExpression") {
return containsStrictInequality(test.left, prop) || containsStrictInequality(test.right, prop);
}
if (test.type === "BinaryExpression" && test.operator === "!==") {
return isThisMember(test.left, prop) || isThisMember(test.right, prop);
}
return false;
}
const e2eeEpochNeedsKeypairCheck = {
meta: {
type: "problem",
docs: {
description:
"Require this._ecdhKeyPair identity checks alongside this._e2eeEpoch staleness checks. A " +
"non-key-holder session never bumps the epoch, so an epoch-only comparison cannot detect a " +
"torn-down-then-restarted session resuming after an await — only the keypair identity can.",
},
schema: [],
messages: {
missingKeypairCheck:
"This staleness check compares this._e2eeEpoch but not this._ecdhKeyPair. A non-key-holder " +
"session never advances the epoch, so this guard alone cannot detect a torn-down-then-restarted " +
"session — add `|| this._ecdhKeyPair !== <the keypair captured before the await>` to the condition.",
},
},
create(context) {
return {
IfStatement(node) {
if (
containsStrictInequality(node.test, "_e2eeEpoch") &&
!containsStrictInequality(node.test, "_ecdhKeyPair")
) {
context.report({ node: node.test, messageId: "missingKeypairCheck" });
}
},
};
},
};
// ─────────────────────────────────────────────────────────────────────────
// Rule: e2ee-verified-status-literal
//
// Invariant (CLAUDE.md): "Anything touching livekitE2EE.ts ... must never
// report an unverified peer as verified."
//
// verifyPeerAnnounce's every write of peer-verification state goes through
// setPeerVerification/setPeerVerificationIfCurrent, and "verified" is reached
// exactly once, only after a real signature check. This rule keeps that
// structurally true: the `status` field at every call site must be a literal
// the author typed by hand at that call site, never a variable/expression —
// which would let a status be computed (and potentially manipulated) instead
// of asserted at the one audited call site that earned it.
// ─────────────────────────────────────────────────────────────────────────
function getCalleeName(node) {
if (node.callee.type === "Identifier") return node.callee.name;
if (
node.callee.type === "MemberExpression" &&
!node.callee.computed &&
node.callee.property.type === "Identifier"
) {
return node.callee.property.name;
}
return null;
}
const VERIFICATION_SETTERS = new Set(["setPeerVerification", "setPeerVerificationIfCurrent"]);
const e2eeVerifiedStatusLiteral = {
meta: {
type: "problem",
docs: {
description:
"Require the `status` field passed to setPeerVerification/setPeerVerificationIfCurrent to be a " +
"string literal. A peer must never be reported verified via a computed/derived status — each " +
"verification outcome is a distinct, hand-written call site that earned its status inline.",
},
schema: [],
messages: {
dynamicStatus:
"The `status` passed here must be a string literal ('verified' | 'unverified' | 'mismatch' | " +
"'unknown'), not a computed expression. Add a new literal call site for this outcome instead of " +
"deriving the status dynamically — that is what keeps 'verified' provably tied to a real signature check.",
},
},
create(context) {
return {
CallExpression(node) {
const name = getCalleeName(node);
if (name === null || !VERIFICATION_SETTERS.has(name)) return;
const objArg = node.arguments[node.arguments.length - 1];
if (objArg === undefined || objArg.type !== "ObjectExpression") return;
const statusProp = objArg.properties.find(
(p) =>
p.type === "Property" &&
!p.computed &&
p.key.type === "Identifier" &&
p.key.name === "status",
);
if (statusProp === undefined) return;
const value = statusProp.value;
if (value.type !== "Literal" || typeof value.value !== "string") {
context.report({ node: statusProp, messageId: "dynamicStatus" });
}
},
};
},
};
// ─────────────────────────────────────────────────────────────────────────
// Rule: no-identity-scope-fallback
//
// Invariant (CLAUDE.md): "Anything touching livekitE2EE.ts or identity.ts
// must preserve the epoch/keypair staleness guards." (Identity-scoping
// analogue: a documented, previously-real bug — see identity.ts's
// `identityKeyPairCache` comment — where a missing user id fell back to a
// placeholder scope like `?? 0`, silently minting/adopting a keypair under
// the wrong account and permanently desyncing the published key from the
// announce-signing key for every peer.)
//
// getOrCreateIdentityKeyPair's userId argument must come from a value that
// was already checked for `undefined` (the pattern both call sites use), not
// a `??`/`||` fallback that would substitute a placeholder id.
// ─────────────────────────────────────────────────────────────────────────
const noIdentityScopeFallback = {
meta: {
type: "problem",
docs: {
description:
"Disallow a ??/|| placeholder fallback as the userId argument to getOrCreateIdentityKeyPair. A " +
"missing user id must abort (see the `userId === undefined` guards at both call sites), never " +
"substitute a placeholder scope — that mints or adopts a keypair under the wrong account and " +
"permanently desyncs the published key from the announce-signing key.",
},
schema: [],
messages: {
placeholderFallback:
"Do not fall back with ??/|| when passing the user id to getOrCreateIdentityKeyPair — a missing " +
"id must abort instead (check `=== undefined` and return, as both existing call sites do). A " +
"placeholder id mints/adopts a keypair under the wrong account and desyncs it from the signing key.",
},
},
create(context) {
return {
CallExpression(node) {
if (
node.callee.type !== "Identifier" ||
node.callee.name !== "getOrCreateIdentityKeyPair"
) {
return;
}
const userIdArg = node.arguments[1];
if (userIdArg === undefined) return;
if (
userIdArg.type === "LogicalExpression" &&
(userIdArg.operator === "??" || userIdArg.operator === "||")
) {
context.report({ node: userIdArg, messageId: "placeholderFallback" });
}
},
};
},
};
// ─────────────────────────────────────────────────────────────────────────
// Rule: no-store-write-in-ws-on
//
// Invariant (CLAUDE.md): "src/lib/dispatcher.ts is the single WS-event entry
// point: server events reach the stores only through a ws.on(...)
// subscription registered there."
//
// Other modules DO register their own ws.on(...) handlers (page-local UI:
// slow-mode timers, the connected overlay, incoming-call ringing) — that
// itself is not the violation. What must never happen outside dispatcher.ts
// is one of those handlers writing to a domain store directly, bypassing the
// dispatcher. Store *reads* (`fooStore.getState()`) are unaffected; this only
// flags calls to an imported store-mutator function (set/add/update/... from
// a `*/stores/*` module) reached from inside a `ws.on(...)` callback.
// ─────────────────────────────────────────────────────────────────────────
const STORE_MUTATOR_PREFIX =
/^(set|add|remove|update|increment|clear|toggle|open|close|join|leave|mark|confirm|bulk|rollback|reset|prepend|reattach|invalidate|load)[A-Z_]/;
function isStoreModuleSource(source) {
// Matches both the "@stores/..." alias and relative "../stores/..." paths.
return typeof source === "string" && /(?:^|\/)@?stores\//.test(source);
}
function isWsOnCall(node) {
return (
node !== null &&
node !== undefined &&
node.type === "CallExpression" &&
node.callee.type === "MemberExpression" &&
!node.callee.computed &&
node.callee.object.type === "Identifier" &&
node.callee.object.name === "ws" &&
node.callee.property.type === "Identifier" &&
node.callee.property.name === "on" &&
node.arguments.length >= 2
);
}
const noStoreWriteInWsOn = {
meta: {
type: "problem",
docs: {
description:
"Disallow calling an imported store-mutator (set*/add*/update*/... from a stores/ module) from " +
"inside a ws.on(...) callback outside dispatcher.ts. dispatcher.ts is the single place server " +
"events are allowed to write into domain stores; a page-local ws.on(...) handler may read store " +
"state and drive its own local UI, but must not mutate a domain store itself.",
},
schema: [],
messages: {
storeWriteOutsideDispatcher:
"'{{name}}' is a store mutator called from a ws.on(...) handler outside dispatcher.ts. " +
"dispatcher.ts is the single WS-event entry point that may write to stores — move this update " +
"into a dispatcher.ts handler for this message type, or have this handler read the store instead " +
"of writing it.",
},
},
create(context) {
const storeMutatorImports = new Set();
return {
ImportDeclaration(node) {
if (!isStoreModuleSource(node.source.value)) return;
for (const spec of node.specifiers) {
if (spec.type === "ImportSpecifier" && STORE_MUTATOR_PREFIX.test(spec.local.name)) {
storeMutatorImports.add(spec.local.name);
}
}
},
CallExpression(node) {
if (node.callee.type !== "Identifier" || !storeMutatorImports.has(node.callee.name)) return;
let parent = node.parent;
while (parent) {
if (
isFunctionNode(parent) &&
isWsOnCall(parent.parent) &&
parent.parent.arguments[1] === parent
) {
context.report({
node,
messageId: "storeWriteOutsideDispatcher",
data: { name: node.callee.name },
});
return;
}
parent = parent.parent;
}
},
};
},
};
export default {
rules: {
"no-leave-voice-when-superseded": noLeaveVoiceWhenSuperseded,
"e2ee-epoch-needs-keypair-check": e2eeEpochNeedsKeypairCheck,
"e2ee-verified-status-literal": e2eeVerifiedStatusLiteral,
"no-identity-scope-fallback": noIdentityScopeFallback,
"no-store-write-in-ws-on": noStoreWriteInWsOn,
},
};
+39 -12
View File
@@ -1,5 +1,6 @@
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import localRules from "./eslint-rules.js";
export default tseslint.config(
eslint.configs.recommended,
@@ -32,10 +33,7 @@ export default tseslint.config(
// Empty functions are used for no-op callbacks
"@typescript-eslint/no-empty-function": "off",
// Project uses void for fire-and-forget promises intentionally
"@typescript-eslint/no-misused-promises": [
"error",
{ checksVoidReturn: false },
],
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: false }],
// Allow require() in config files
"@typescript-eslint/no-require-imports": "off",
// Unbound methods used in singleton export pattern (bind at export)
@@ -67,14 +65,43 @@ export default tseslint.config(
"consistent-return": "off",
},
},
// --- Local rules: three CLAUDE.md invariants enforced as lint rules ---
// See eslint-rules.js for each rule's rationale and the historical bug
// shape it catches. Each is scoped to only the module(s) its invariant
// governs.
{
ignores: [
"dist/",
"src-tauri/",
"node_modules/",
"public/",
"*.js",
"*.cjs",
],
files: ["src/lib/livekitSession.ts"],
plugins: { local: localRules },
rules: {
"local/no-leave-voice-when-superseded": "error",
},
},
{
files: ["src/lib/livekitE2EE.ts"],
plugins: { local: localRules },
rules: {
"local/e2ee-epoch-needs-keypair-check": "error",
"local/e2ee-verified-status-literal": "error",
"local/no-identity-scope-fallback": "error",
},
},
{
files: ["src/lib/identity.ts"],
plugins: { local: localRules },
rules: {
"local/no-identity-scope-fallback": "error",
},
},
{
// dispatcher.ts IS the allowed entry point, so it is exempt from its own rule.
files: ["src/**/*.ts"],
ignores: ["src/lib/dispatcher.ts"],
plugins: { local: localRules },
rules: {
"local/no-store-write-in-ws-on": "error",
},
},
{
ignores: ["dist/", "src-tauri/", "node_modules/", "public/", "*.js", "*.cjs"],
},
);
+182 -123
View File
@@ -1,12 +1,12 @@
{
"name": "owncord-client",
"version": "1.1.0-alpha.5",
"version": "1.2.0-alpha.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "owncord-client",
"version": "1.1.0-alpha.5",
"version": "1.2.0-alpha.2",
"dependencies": {
"@jitsi/rnnoise-wasm": "^0.2.1",
"@tauri-apps/api": "^2.10.1",
@@ -28,11 +28,13 @@
"@stryker-mutator/typescript-checker": "^9.6.1",
"@stryker-mutator/vitest-runner": "^9.6.1",
"@tauri-apps/cli": "^2",
"@types/node": "^20.19.43",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3",
"eslint": "^10.8.0",
"fast-check": "^4.9.0",
"jsdom": "^29.1.1",
"knip": "^6.1.1",
"knip": "^6.31.0",
"oxlint": "^1.76.0",
"prettier": "^3.9.6",
"typescript": "^5.7",
@@ -1951,9 +1953,9 @@
}
},
"node_modules/@oxc-parser/binding-android-arm-eabi": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.140.0.tgz",
"integrity": "sha512-ZfjDZ422mo7eo3b3VltqNsV9kmv1qt/sPEAMSl64iOSwhVfd0eIZ9LB79Mbs1xYXJnk7WSROwzBCKDIiVxPTvQ==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.142.0.tgz",
"integrity": "sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ==",
"cpu": [
"arm"
],
@@ -1968,9 +1970,9 @@
}
},
"node_modules/@oxc-parser/binding-android-arm64": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.140.0.tgz",
"integrity": "sha512-Ia8jSvikUX6Sf+Ht+KOCUF/k1HpR0VlmqIYymubmWDebOEGtsyliHDR6JxsZ4IX3/c/GbrB1uh09aVGQv/LQmQ==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.142.0.tgz",
"integrity": "sha512-WZkvGRLNQTz8lR9zP5nLjUdlroRCopBu3g9zF1p/laE6DzT1UbQo8Rdz5MWhaJUPYg/6gp+jo7HUgsyKaN1FtQ==",
"cpu": [
"arm64"
],
@@ -1985,9 +1987,9 @@
}
},
"node_modules/@oxc-parser/binding-darwin-arm64": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.140.0.tgz",
"integrity": "sha512-G6VK0nK61pH0d0mBjUqSZbVxGqqO5uzeginLDQj+gOO6ObfJjXRwgkD/ol0w1INcnFeAb6YGGO7qc3ueGHaycQ==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.142.0.tgz",
"integrity": "sha512-l4khS8LQOOVYsGRVARo1gSaCT/aBSceUVXgtovWc2+drnxVuDr082WA3OCHVdVzIz5JIrP/y9CWsSKxBDNmYGg==",
"cpu": [
"arm64"
],
@@ -2002,9 +2004,9 @@
}
},
"node_modules/@oxc-parser/binding-darwin-x64": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.140.0.tgz",
"integrity": "sha512-HazBOuZzd2pO1C2uMmp8Gv7mhzMHqKSKDS1OZfcLEvpIcgA+48J92HEtNanVHDIzRD9PRPCV6aS6fkZIWOVl8Q==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.142.0.tgz",
"integrity": "sha512-QBsNF3nqlXmcH2B1YOPqQYmCJoy4HuIjUxGbBO/k5JAJUl68ghU2psRY2zPk+RyBaWqKP/qfL4oaFgEMCdwskA==",
"cpu": [
"x64"
],
@@ -2019,9 +2021,9 @@
}
},
"node_modules/@oxc-parser/binding-freebsd-x64": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.140.0.tgz",
"integrity": "sha512-9hSUU+HmTUyOe4JzMHxNGgLWNY7rrO+6ShicZwImNJacEAACDMIkuEQQkvXSL+WJN50jaNtLYJv8s4OcBdpyUQ==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.142.0.tgz",
"integrity": "sha512-b7Q7m4Cqc6XqNhri3R+QhU+GVy646Pn+bkdhrDdWym/Fdi0ZUa+d73H9dm5H91JtbtAQ/z1d8XKMW3oOV8a4tQ==",
"cpu": [
"x64"
],
@@ -2036,9 +2038,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-arm-gnueabihf": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.140.0.tgz",
"integrity": "sha512-RAEuQsYtS0KcDFqN0ABTjyyNlokS91JeuDuoW9tEbG0JTbRNXnpQUdbYc/16JoA6Z/2ALbNrE3KmxtqDiuIjCQ==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.142.0.tgz",
"integrity": "sha512-3riVS5IhdH3uCZj1Y9ftDQlR0dvLsIlw/edrRqk8JhgNd5K0XSs+UBtgh50N13CAlW9/TXj6sVGXaKNBocd0Yg==",
"cpu": [
"arm"
],
@@ -2053,9 +2055,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-arm-musleabihf": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.140.0.tgz",
"integrity": "sha512-c4CkHvPvqfojouredJ0w3e6+jiBq0SbFyhH61kr/zPb/7XsaYTNKQ54vmlSsopfdQbNDX40ZeK9Abs2Qet6wcw==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.142.0.tgz",
"integrity": "sha512-NmXUOpgpTSkhl795TiXmWppTwmSJ92RC1qvD6e4XOF+slgmo3e6Ah+kEu+6AN8s7NAOEwqGmir58MgSQSWmBSA==",
"cpu": [
"arm"
],
@@ -2070,9 +2072,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-arm64-gnu": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.140.0.tgz",
"integrity": "sha512-yrjmLj8ixPB25yqvPGr28meGjb+keed7m1GqqY/0uqkhZIoT4t9zmfwUgFEtC33C7dtE+UQ7TU0IaVxf97SWJg==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.142.0.tgz",
"integrity": "sha512-gc0EXsKtXgerujmU2Bql3u1L1HsSQ2774R83idq/FoNMPVV/RY/1ErFsvnit7KoiP/sLvzQixeUo4Ut0ic0wmw==",
"cpu": [
"arm64"
],
@@ -2087,9 +2089,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-arm64-musl": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.140.0.tgz",
"integrity": "sha512-ggGMQTN8Agwxp2WiLMpdY671dt0qTDJWiWlJeig3HnUwTnerRl0J2JdGVghWBeDcss2D9S2V2Js6dZHEiVabVA==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.142.0.tgz",
"integrity": "sha512-F2XvmWSE0uWpie+jHKKIFgdVOe9ypGhkEZxKx5DuW215K6cbAC274yYaPkcM7EqY4Df3Weyhpcz3lsURyH2LVg==",
"cpu": [
"arm64"
],
@@ -2104,9 +2106,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-ppc64-gnu": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.140.0.tgz",
"integrity": "sha512-IgTs8xYAFgAUGNmR65tIqjlJ8vKgrfXzC515e9goSdfMyKQV4aJpd2pUUudU4u51G64H0/DSEJEXKOraxm9ZCA==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.142.0.tgz",
"integrity": "sha512-wLMbT21U/QxknQsk+VvNF0b9D2/aGWhcaQQQ+VYlE8FwD5+GoWZIPPXNzyHmkYyhm0KB3itL+TBavjMatqNnYA==",
"cpu": [
"ppc64"
],
@@ -2121,9 +2123,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-riscv64-gnu": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.140.0.tgz",
"integrity": "sha512-A1x+PMWZmSGaFVOx2YeNTFau8uD+QO14/vLP4GrcuvUPs3+nBkUOjy9Lus86ftHsDojjYMbvBelmKc3F7Rv08g==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.142.0.tgz",
"integrity": "sha512-+G8F/4ckwT7FCJV4H2bt09xEzJbjNCfuL4Sp1AYNaFtFMVtgIGMuJlteT82U+K0UIZ/DzAR/LDlMFnEuajG7Kw==",
"cpu": [
"riscv64"
],
@@ -2138,9 +2140,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-riscv64-musl": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.140.0.tgz",
"integrity": "sha512-zBqpfRo2myWPrPo5xUjeZqlnPXPXsX8BcWtWff66/eGRQdbPjhzPgXa/F+AtxT2afUViPxbuDlwscMKzQ5tg+g==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.142.0.tgz",
"integrity": "sha512-hTsHtTLxMAfCo+rpF5K3qZJKW2NpPN/CHd4mYB3y7XlSdspHkd2gehDIofP64AacA9nWQw2tY3O7wR6UY8IVOA==",
"cpu": [
"riscv64"
],
@@ -2155,9 +2157,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-s390x-gnu": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.140.0.tgz",
"integrity": "sha512-2M1DPm/8w9I//YzFlFC9qXw+r2tJFh5CYwRlYTq2vUJQS7qoQftEDeCZ8EnN7KHtvSiXvYj8mZI5pR7DpXmcEw==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.142.0.tgz",
"integrity": "sha512-6y7qYY3TCUDYjqswImdTGl92y+KA/80twALegQPN27kfY+bG7Ib1+L3jbmrCZQx6wrVnai9IPsEZp07I0hx7JQ==",
"cpu": [
"s390x"
],
@@ -2172,9 +2174,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-x64-gnu": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.140.0.tgz",
"integrity": "sha512-8aRDbZ/U/jO8N7go1MO72jtbpb4uswV8d7vOkMvt/BPgZiyEYvl1VIWK4ESxZZhnJ4tqwVldgX7dNiP/eB1Jdg==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.142.0.tgz",
"integrity": "sha512-i69kAWU+2LgoH5bR+zWiiu+UzAw7Oxkwv7COeJTeY19pn4e70nKQcr9Pm6cL2Z0Z54d+gl9qADlK/0yyuCPiBA==",
"cpu": [
"x64"
],
@@ -2189,9 +2191,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-x64-musl": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.140.0.tgz",
"integrity": "sha512-xRqpeI8U2sQQS1W5BMWRyMTxtagkuLG2dEWruet5lFsWHTvBth11/TpSaJatHdqVVwHN0q3uuoS9zRsGinq8hg==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.142.0.tgz",
"integrity": "sha512-4SQs678MmjYVrmhAgCWD4o0vpaFszXw9xLX5p2Z9MMFcltxiLkA88wQjh80YHjPrXtpyZ2CWI5m+1yNKM0m2Pw==",
"cpu": [
"x64"
],
@@ -2206,9 +2208,9 @@
}
},
"node_modules/@oxc-parser/binding-openharmony-arm64": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.140.0.tgz",
"integrity": "sha512-GbGRe26MqAKciFRvXeHNQJ6VAHYs9R4miP89sEAncysM3n+f4lnyLWgsa9kklJNpfnxdq2yRoNYHFqwBckVimw==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.142.0.tgz",
"integrity": "sha512-YHpx9N7Ln3a++Tc8rv+H7mrK1zyJQOAwCFg8LZ3lTs1T5afGWeZrLPhPT9HLnIwSjCyJqPWVMIrMxbjcmBr2oQ==",
"cpu": [
"arm64"
],
@@ -2223,9 +2225,9 @@
}
},
"node_modules/@oxc-parser/binding-wasm32-wasi": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.140.0.tgz",
"integrity": "sha512-vFiC1hqys+hkX1GnQkIoiTQJNiUm43Z0lO35ETKXTw0YtpW7+cN58YRRXFAQQ+TgpkIi3lrhcxdlnqz+Oi3ptQ==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.142.0.tgz",
"integrity": "sha512-3pLDyY3+oogW73RM5uehNgAiR/Xfb7fvO2Q1Z1gIqZ2+50XDVQmBVlRkHXZTU4gKnQHpwETNsYQVsJ3joVB2iA==",
"cpu": [
"wasm32"
],
@@ -2242,9 +2244,9 @@
}
},
"node_modules/@oxc-parser/binding-win32-arm64-msvc": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.140.0.tgz",
"integrity": "sha512-fGSQldwEYKhM+H8uLt76Op8hh5+FYaR6lvvQ1Txw3Mhn86DyQXLcI0fi1EkFlTK7F+46OCk/j0AJMzZQm6g5Xg==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.142.0.tgz",
"integrity": "sha512-Had/VeVY28Oyb0K+Q4FV8KCzoBycIh93oDK6pCbya9lkzdq+ikMHMgBubsdqqlybjJmQRawCQRrnBRHyQwYvcQ==",
"cpu": [
"arm64"
],
@@ -2259,9 +2261,9 @@
}
},
"node_modules/@oxc-parser/binding-win32-ia32-msvc": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.140.0.tgz",
"integrity": "sha512-sDS2Bai+g3ZWYwfZqmosiSuFDBcVnZ3Ta6pszzsiJoLMqsJEWKcxXXbGa7b7yXr++W2lQNPb3ZRJ8czseqL7RA==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.142.0.tgz",
"integrity": "sha512-GGi3+YphVHavvgs6gum2UXoNCqzHAmPt/nXkn8ZQZstV2Q1qZD1Mn8fz/nWrDkefHQtrG/+1/XrbMxsBTo6Svw==",
"cpu": [
"ia32"
],
@@ -2276,9 +2278,9 @@
}
},
"node_modules/@oxc-parser/binding-win32-x64-msvc": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.140.0.tgz",
"integrity": "sha512-kHbE1zWyb5OQgJA6/5P4WjiuB01sYdQwtZnSSyE58FQEXDAMnyeeq4vj7KgN75i5SlBzOs8A5MrtlD3gOlDKqQ==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.142.0.tgz",
"integrity": "sha512-Ny/Wv4Us1LGC/ljwNTp+Hx3r/pH15EFfeDF0p+n898gt+TtRd6C9SccHcuUhDiNTb8s5tt7jdeAMDRQZ4Vq6hg==",
"cpu": [
"x64"
],
@@ -2293,9 +2295,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.140.0.tgz",
"integrity": "sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz",
"integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==",
"dev": true,
"license": "MIT",
"funding": {
@@ -2897,13 +2899,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.0"
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
@@ -3857,6 +3859,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz",
@@ -5028,6 +5040,29 @@
"node": ">=12.0.0"
}
},
"node_modules/fast-check": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz",
"integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^8.0.0"
},
"engines": {
"node": ">=12.17.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -5067,9 +5102,9 @@
}
},
"node_modules/fast-uri": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"dev": true,
"funding": [
{
@@ -5731,9 +5766,9 @@
}
},
"node_modules/knip": {
"version": "6.29.0",
"resolved": "https://registry.npmjs.org/knip/-/knip-6.29.0.tgz",
"integrity": "sha512-A3kXqSBky1tWBAqiU9srdtu0Larhzkuyor0aD/gg+ToiqyBncCCs2Q60sLsxmcKhV0OsKss9LV0hMPpwLv711Q==",
"version": "6.31.0",
"resolved": "https://registry.npmjs.org/knip/-/knip-6.31.0.tgz",
"integrity": "sha512-NbeIEmUS2VUMjAkbiSNOKPJeV9wpCsr0660sUyKyMQbk4Iom0++nTLInVp4MJ+LfR4kORnw67bDi5tvO7YLnzA==",
"dev": true,
"funding": [
{
@@ -5751,13 +5786,13 @@
"formatly": "^0.3.0",
"get-tsconfig": "4.14.0",
"jiti": "^2.7.0",
"oxc-parser": "^0.140.0",
"oxc-parser": "^0.142.0",
"oxc-resolver": "11.24.2",
"picomatch": "^4.0.5",
"smol-toml": "^1.7.0",
"smol-toml": "^1.7.1",
"strip-json-comments": "5.0.3",
"tinyglobby": "^0.2.17",
"unbash": "^4.0.3",
"unbash": "^4.0.4",
"yaml": "^2.9.0",
"zod": "^4.4.3"
},
@@ -6129,13 +6164,13 @@
}
},
"node_modules/oxc-parser": {
"version": "0.140.0",
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.140.0.tgz",
"integrity": "sha512-h6QFWd6lBMfjESqgQ27GjzrSDb0qbznp7VDQqp2zvgsrWut4vcchyMIzOVXvGQ2GMZgKw9RWrFNWv9WqGL0p7Q==",
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.142.0.tgz",
"integrity": "sha512-kKR+jPiRJYJDexVoziIg/FVGvr1fT1FZSSJOk6tVoMKKSlsf1Cso+cgGCJkOEDWOP174vRntCPFKg+AS7InWvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "^0.140.0"
"@oxc-project/types": "^0.142.0"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
@@ -6144,26 +6179,26 @@
"url": "https://github.com/sponsors/Boshen"
},
"optionalDependencies": {
"@oxc-parser/binding-android-arm-eabi": "0.140.0",
"@oxc-parser/binding-android-arm64": "0.140.0",
"@oxc-parser/binding-darwin-arm64": "0.140.0",
"@oxc-parser/binding-darwin-x64": "0.140.0",
"@oxc-parser/binding-freebsd-x64": "0.140.0",
"@oxc-parser/binding-linux-arm-gnueabihf": "0.140.0",
"@oxc-parser/binding-linux-arm-musleabihf": "0.140.0",
"@oxc-parser/binding-linux-arm64-gnu": "0.140.0",
"@oxc-parser/binding-linux-arm64-musl": "0.140.0",
"@oxc-parser/binding-linux-ppc64-gnu": "0.140.0",
"@oxc-parser/binding-linux-riscv64-gnu": "0.140.0",
"@oxc-parser/binding-linux-riscv64-musl": "0.140.0",
"@oxc-parser/binding-linux-s390x-gnu": "0.140.0",
"@oxc-parser/binding-linux-x64-gnu": "0.140.0",
"@oxc-parser/binding-linux-x64-musl": "0.140.0",
"@oxc-parser/binding-openharmony-arm64": "0.140.0",
"@oxc-parser/binding-wasm32-wasi": "0.140.0",
"@oxc-parser/binding-win32-arm64-msvc": "0.140.0",
"@oxc-parser/binding-win32-ia32-msvc": "0.140.0",
"@oxc-parser/binding-win32-x64-msvc": "0.140.0"
"@oxc-parser/binding-android-arm-eabi": "0.142.0",
"@oxc-parser/binding-android-arm64": "0.142.0",
"@oxc-parser/binding-darwin-arm64": "0.142.0",
"@oxc-parser/binding-darwin-x64": "0.142.0",
"@oxc-parser/binding-freebsd-x64": "0.142.0",
"@oxc-parser/binding-linux-arm-gnueabihf": "0.142.0",
"@oxc-parser/binding-linux-arm-musleabihf": "0.142.0",
"@oxc-parser/binding-linux-arm64-gnu": "0.142.0",
"@oxc-parser/binding-linux-arm64-musl": "0.142.0",
"@oxc-parser/binding-linux-ppc64-gnu": "0.142.0",
"@oxc-parser/binding-linux-riscv64-gnu": "0.142.0",
"@oxc-parser/binding-linux-riscv64-musl": "0.142.0",
"@oxc-parser/binding-linux-s390x-gnu": "0.142.0",
"@oxc-parser/binding-linux-x64-gnu": "0.142.0",
"@oxc-parser/binding-linux-x64-musl": "0.142.0",
"@oxc-parser/binding-openharmony-arm64": "0.142.0",
"@oxc-parser/binding-wasm32-wasi": "0.142.0",
"@oxc-parser/binding-win32-arm64-msvc": "0.142.0",
"@oxc-parser/binding-win32-ia32-msvc": "0.142.0",
"@oxc-parser/binding-win32-x64-msvc": "0.142.0"
}
},
"node_modules/oxc-resolver": {
@@ -6379,13 +6414,13 @@
}
},
"node_modules/playwright": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.0"
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
@@ -6398,9 +6433,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -6426,9 +6461,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.19",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
"integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
"version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"dev": true,
"funding": [
{
@@ -6446,7 +6481,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -6554,6 +6589,23 @@
"node": ">=6"
}
},
"node_modules/pure-rand": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
"integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT"
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
@@ -6836,9 +6888,9 @@
}
},
"node_modules/smol-toml": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz",
"integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==",
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.1.tgz",
"integrity": "sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
@@ -7194,9 +7246,9 @@
}
},
"node_modules/unbash": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/unbash/-/unbash-4.0.3.tgz",
"integrity": "sha512-3cudTErfToSc4Ggv8XGXVNVli/xHKUtUZvaY5UVwhOcUPbQGz7PeaEnT/SAVgNziZtX67KEN9swMUYkLghxA1w==",
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/unbash/-/unbash-4.0.5.tgz",
"integrity": "sha512-EE9xv9cr93DSppe086Rnbq0jwG7MBCEe22JZ4UQ8Bn9RyUwDSoUpBmzdb5+7PzocdqBEb/K73FGPaQUMmLxTQQ==",
"dev": true,
"license": "ISC",
"engines": {
@@ -7211,15 +7263,22 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unicorn-magic": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
+6 -2
View File
@@ -1,7 +1,7 @@
{
"name": "owncord-client",
"private": true,
"version": "1.1.0-alpha.5",
"version": "1.2.0-alpha.2",
"type": "module",
"scripts": {
"dev": "vite",
@@ -14,12 +14,14 @@
"test:e2e": "playwright test",
"test:e2e:prod": "npm run build && playwright test --config playwright.config.prod.ts",
"test:e2e:native": "playwright test --config playwright.config.native.ts",
"test:e2e:admin": "playwright test --config playwright.config.admin.ts",
"test:e2e:ui": "playwright test --ui",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:browser": "vitest run --config vitest.config.browser.ts",
"typecheck": "tsc --noEmit",
"typecheck:build": "tsc -p tsconfig.build.json --noEmit",
"typecheck:e2e": "tsc -p tsconfig.e2e.json --noEmit",
"lint": "oxlint src/ && eslint src/",
"lint:fix": "eslint src/ --fix",
"lint:ox": "oxlint src/",
@@ -37,11 +39,13 @@
"@stryker-mutator/typescript-checker": "^9.6.1",
"@stryker-mutator/vitest-runner": "^9.6.1",
"@tauri-apps/cli": "^2",
"@types/node": "^20.19.43",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3",
"eslint": "^10.8.0",
"fast-check": "^4.9.0",
"jsdom": "^29.1.1",
"knip": "^6.1.1",
"knip": "^6.31.0",
"oxlint": "^1.76.0",
"prettier": "^3.9.6",
"typescript": "^5.7",
@@ -0,0 +1,45 @@
import { defineConfig } from "@playwright/test";
/**
* Playwright config for the ADMIN PANEL e2e suite — the server-embedded SPA
* (Server/admin/static/index.html), driven against a REAL server started by
* tests/e2e/admin/start-server.sh (fresh temp data dir, TLS off, loopback).
*
* Unlike the mocked-Tauri web suite this exercises the true stack: chi
* router, admin middleware/gates, SQLite, and the SPA itself. The journey is
* stateful by design (first-run wizard creates the owner the later tests log
* in as), so it runs serially in one worker against one server instance.
*
* Usage: npm run test:e2e:admin (requires the Go toolchain)
*/
const PORT = process.env.OWNCORD_ADMIN_E2E_PORT ?? "18446";
export default defineConfig({
testDir: "./tests/e2e/admin",
timeout: 30_000,
expect: { timeout: 5_000 },
fullyParallel: false,
workers: 1,
retries: process.env.CI ? 2 : 1,
reporter: process.env.CI
? [
["html", { open: "never" }],
["junit", { outputFile: "test-results/admin-junit.xml" }],
]
: "html",
use: {
baseURL: `http://127.0.0.1:${PORT}`,
screenshot: "only-on-failure",
trace: "on-first-retry",
contextOptions: { reducedMotion: "reduce" },
},
webServer: {
command: "bash tests/e2e/admin/start-server.sh",
url: `http://127.0.0.1:${PORT}/health`,
reuseExistingServer: !process.env.CI,
// First run compiles the Go server; CI cold caches need the headroom.
timeout: 240_000,
},
});
@@ -60,7 +60,10 @@ export default defineConfig({
"app-layout.spec.ts",
"channel-navigation.spec.ts",
"chat-operations.spec.ts",
"dm-system.spec.ts",
"reconnection.spec.ts",
"settings-overlay.spec.ts",
"theme-persistence.spec.ts",
"voice-controls.spec.ts",
"overlays.spec.ts",
],
@@ -9,7 +9,7 @@ import { defineConfig, devices } from "@playwright/test";
*/
export default defineConfig({
testDir: "./tests/e2e",
testIgnore: ["**/native/**"],
testIgnore: ["**/native/**", "**/admin/**"],
timeout: 30_000,
expect: {
timeout: 5_000,
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
testIgnore: ["**/native/**"],
testIgnore: ["**/native/**", "**/admin/**"],
timeout: 30_000,
expect: {
timeout: 5_000,
@@ -1,291 +0,0 @@
// =============================================================================
// RNNoise AudioWorklet Processor
//
// Runs on the audio rendering thread. Receives WASM module bytes from the
// main thread, initializes RNNoise, and processes 480-sample frames at 48kHz.
// =============================================================================
const FRAME_SIZE = 480;
const WASM_MEMORY_INITIAL_PAGES = 256;
const OUTPUT_RING_CAPACITY = 50;
const RN_NOISE_INT16_SCALE = 32768;
declare abstract class AudioWorkletProcessor {
readonly port: MessagePort;
}
declare function registerProcessor(
name: string,
processorCtor: typeof RNNoiseProcessor,
): void;
interface RNNoiseWasmExports extends WebAssembly.Exports {
rnnoise_create(): number;
rnnoise_destroy(state: number): void;
rnnoise_process_frame(state: number, outputPtr: number, inputPtr: number): void;
malloc(size: number): number;
free(ptr: number): void;
}
interface RNNoiseWasmInstance extends WebAssembly.Instance {
exports: RNNoiseWasmExports;
}
class RNNoiseProcessor extends AudioWorkletProcessor {
private _instance: RNNoiseWasmInstance | null = null;
private _state: number = 0;
private _inputPtr: number = 0;
private _outputPtr: number = 0;
private _heapF32: Float32Array | null = null;
private _ready: boolean = false;
private _destroyed: boolean = false;
// Ring buffer to accumulate 480-sample frames
private _inputRing: Float32Array;
private _inputRingOffset: number = 0;
// Output ring buffer (contiguous for efficiency)
private _outBuffer: Float32Array;
private _outWritePos: number = 0;
private _outReadPos: number = 0;
private _outAvailable: number = 0;
private _outSampleOffset: number = 0;
constructor() {
super();
this._inputRing = new Float32Array(FRAME_SIZE);
this._outBuffer = new Float32Array(OUTPUT_RING_CAPACITY * FRAME_SIZE);
this.port.onmessage = (event: MessageEvent) => {
if (event.data.type === "init") {
this._initWasm(event.data.wasmBytes);
} else if (event.data.type === "destroy") {
this._cleanup();
}
};
}
/**
* Reports an error to the main thread and logs it.
* @param message - Error message
* @param error - Optional error object
* @private
*/
private _reportError(message: string, error?: unknown): void {
console.error(`RNNoise Processor: ${message}`, error);
this.port.postMessage({ type: "error", message });
}
/**
* Initializes the WASM module and RNNoise state.
* @param wasmBytes - Raw WASM module bytes
* @private
*/
private async _initWasm(wasmBytes: ArrayBuffer): Promise<void> {
let allocated = false;
try {
// Basic validation: check for expected exports
const module = await WebAssembly.compile(wasmBytes);
const expectedExports = ['rnnoise_create', 'rnnoise_destroy', 'rnnoise_process_frame', 'malloc', 'free'];
const availableExports = WebAssembly.Module.exports(module).map(exp => exp.name);
const hasRequiredExports = expectedExports.every(exp => availableExports.includes(exp));
if (!hasRequiredExports) {
throw new Error('WASM module missing required RNNoise exports');
}
const memory = new WebAssembly.Memory({ initial: WASM_MEMORY_INITIAL_PAGES });
const importObject = {
env: {
memory,
emscripten_notify_memory_growth: () => {
this._heapF32 = new Float32Array(memory.buffer);
},
},
wasi_snapshot_preview1: {
proc_exit: () => {},
fd_close: () => 0,
fd_write: () => 0,
fd_seek: () => 0,
},
};
// Try instantiating with the raw WASM bytes
const { instance } = await WebAssembly.instantiate(wasmBytes, importObject);
this._instance = instance as RNNoiseWasmInstance;
this._heapF32 = new Float32Array(memory.buffer);
// Call RNNoise C API
const exports = instance.exports as unknown as RNNoiseWasmExports;
this._state = exports.rnnoise_create();
this._inputPtr = exports.malloc(FRAME_SIZE * 4);
this._outputPtr = exports.malloc(FRAME_SIZE * 4);
allocated = true;
this._ready = true;
this.port.postMessage({ type: "ready" });
} catch (err) {
// Cleanup allocated memory on failure
if (allocated && this._instance) {
try {
const exports = this._instance.exports;
if (this._inputPtr) exports.free(this._inputPtr);
if (this._outputPtr) exports.free(this._outputPtr);
if (this._state) exports.rnnoise_destroy(this._state);
} catch (cleanupErr) {
// Log cleanup errors but don't override original error
console.warn('Failed to cleanup WASM memory:', cleanupErr);
}
}
this._reportError(`WASM initialization failed: ${err instanceof Error ? err.message : String(err)}`, err);
}
}
/**
* Processes a complete 480-sample frame through RNNoise.
* Copies input ring buffer to WASM memory, runs noise suppression,
* and stores the result in the output ring buffer.
* @private
*/
private _processFrame(): void {
if (!this._instance || !this._heapF32) return;
const exports = this._instance.exports;
const inOff = this._inputPtr / 4;
const outOff = this._outputPtr / 4;
// CRITICAL: Bounds check before accessing heap
if (inOff + FRAME_SIZE > this._heapF32.length ||
outOff + FRAME_SIZE > this._heapF32.length) {
console.error('WASM heap bounds exceeded');
return;
}
for (let i = 0; i < FRAME_SIZE; i++) {
this._heapF32[inOff + i] = (this._inputRing[i] ?? 0) * RN_NOISE_INT16_SCALE;
}
exports.rnnoise_process_frame(this._state, this._outputPtr, this._inputPtr);
// Write to contiguous buffer
const writeStart = this._outWritePos * FRAME_SIZE;
for (let i = 0; i < FRAME_SIZE; i++) {
this._outBuffer[writeStart + i] = (this._heapF32[outOff + i] ?? 0) / RN_NOISE_INT16_SCALE;
}
this._outWritePos = (this._outWritePos + 1) % OUTPUT_RING_CAPACITY;
if (this._outAvailable < OUTPUT_RING_CAPACITY) {
this._outAvailable++;
} else {
// Overwrite oldest
this._outReadPos = (this._outReadPos + 1) % OUTPUT_RING_CAPACITY;
this._outSampleOffset = 0;
}
}
/**
* Cleans up WASM resources and marks the processor as destroyed.
* Safe to call multiple times.
* @private
*/
private _cleanup(): void {
if (this._instance && this._state) {
try {
const exports = this._instance.exports;
exports.rnnoise_destroy(this._state);
exports.free(this._inputPtr);
exports.free(this._outputPtr);
} catch (err) {
console.warn('RNNoise cleanup failed:', err);
// Continue cleanup even if individual steps fail
}
}
this._ready = false;
this._destroyed = true;
this._state = 0;
}
/**
* Processes input audio data into the ring buffer and triggers frame processing.
* @param inData - Input audio samples
* @private
*/
private _processInputRingBuffer(inData: Float32Array): void {
let inIdx = 0;
while (inIdx < inData.length) {
const needed = FRAME_SIZE - this._inputRingOffset;
const toCopy = Math.min(needed, inData.length - inIdx);
this._inputRing.set(inData.subarray(inIdx, inIdx + toCopy), this._inputRingOffset);
this._inputRingOffset += toCopy;
inIdx += toCopy;
if (this._inputRingOffset >= FRAME_SIZE) {
this._processFrame();
this._inputRingOffset = 0;
}
}
}
/**
* Fills output buffer from the processed frames ring buffer.
* @param outData - Output audio buffer to fill
* @private
*/
private _fillOutputFromRingBuffer(outData: Float32Array): void {
let outIdx = 0;
while (outIdx < outData.length && this._outAvailable > 0) {
const readStart = this._outReadPos * FRAME_SIZE;
const available = FRAME_SIZE - this._outSampleOffset;
const toWrite = Math.min(available, outData.length - outIdx);
outData.set(this._outBuffer.subarray(readStart + this._outSampleOffset, readStart + this._outSampleOffset + toWrite), outIdx);
outIdx += toWrite;
this._outSampleOffset += toWrite;
if (this._outSampleOffset >= FRAME_SIZE) {
this._outReadPos = (this._outReadPos + 1) % OUTPUT_RING_CAPACITY;
this._outAvailable--;
this._outSampleOffset = 0;
}
}
// Fill remaining with silence
if (outIdx < outData.length) {
outData.fill(0, outIdx);
}
}
process(inputs: Float32Array[][], outputs: Float32Array[][]): boolean {
if (this._destroyed) return false;
// Validate input/output structure
if (!inputs || !inputs[0] || !inputs[0][0] ||
!outputs || !outputs[0] || !outputs[0][0]) {
return true; // Pass through silence or existing data
}
const input = inputs[0];
const output = outputs[0];
const inData = input[0]!;
const outData = output[0]!;
// Validate buffer lengths
if (inData.length === 0 || outData.length === 0) {
return true;
}
if (!this._ready) {
// Pass through until WASM is ready
const copyLength = Math.min(inData.length, outData.length);
outData.set(inData.subarray(0, copyLength));
if (copyLength < outData.length) {
outData.fill(0, copyLength);
}
return true;
}
this._processInputRingBuffer(inData);
this._fillOutputFromRingBuffer(outData);
return true;
}
}
registerProcessor("rnnoise-processor", RNNoiseProcessor);
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Strip host-incompatible libraries from a Tauri-built AppImage.
#
# linuxdeploy bundles the build host's (Ubuntu 22.04) libwayland-* into the
# AppImage and AppRun forces them onto LD_LIBRARY_PATH. Newer hosts' Mesa
# dlopens libwayland-client during EGL init — picking up the stale bundled
# copy makes eglGetDisplay fail (EGL_BAD_PARAMETER) and WebKit aborts,
# leaving a white window. Every supported distro ships libwayland >= the
# 1.20 the client links against, so the host copy is always the right one.
# Verified 2026-07-31: stock alpha.5 AppImage white-screens on Arch; the
# same image with these libs removed renders normally on Arch and Ubuntu.
#
# Usage: strip-appimage-bundled-libs.sh <path-to.AppImage>
# Rewrites the AppImage in place (same filename). Signatures and updater
# tar.gz artifacts must be regenerated afterwards by the caller.
set -euo pipefail
APPIMAGE_PATH="${1:?usage: $0 <path-to.AppImage>}"
APPIMAGE_PATH="$(readlink -f "$APPIMAGE_PATH")"
WORKDIR="$(mktemp -d)"
trap 'rm -rf "$WORKDIR"' EXIT
ARCH="$(uname -m)"
APPIMAGETOOL="$WORKDIR/appimagetool"
curl -fsSL -o "$APPIMAGETOOL" \
"https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${ARCH}.AppImage"
chmod +x "$APPIMAGETOOL"
cd "$WORKDIR"
"$APPIMAGE_PATH" --appimage-extract > /dev/null
removed=0
for lib in squashfs-root/usr/lib/libwayland-*.so*; do
[ -e "$lib" ] || continue
echo "removing bundled $(basename "$lib")"
rm -f "$lib"
removed=$((removed + 1))
done
if [ "$removed" -eq 0 ]; then
echo "::warning::no bundled libwayland-* found in $APPIMAGE_PATH — linuxdeploy may have stopped bundling it; strip step is now a no-op"
exit 0
fi
# --appimage-extract-and-run: run without FUSE (CI containers/runners).
# ARCH is required when repacking on a host arch that differs from the
# payload naming; here it always matches the runner.
ARCH="$ARCH" "$APPIMAGETOOL" --appimage-extract-and-run --no-appstream \
squashfs-root "$WORKDIR/repacked.AppImage"
mv "$WORKDIR/repacked.AppImage" "$APPIMAGE_PATH"
echo "stripped $removed bundled wayland libs from $(basename "$APPIMAGE_PATH")"
@@ -8,4 +8,28 @@ ignore = [
# Drop both entries when the notification chain moves to quick-xml >= 0.41.
"RUSTSEC-2026-0194",
"RUSTSEC-2026-0195",
# glib 0.18.5 is pinned by the whole Linux GTK stack: wry (even 0.56)
# requires `webkit2gtk =2.0.2`, which requires `glib ^0.18.0`. The fix
# landed in glib 0.20.0 and was never backported (0.18.5 is the last 0.18
# release; 0.19.x is still in range), so no semver-compatible route exists.
# The unsoundness is only reachable through `Variant::array_iter_str()` —
# nothing in the dependency tree or in src-tauri/src/ calls it, and the
# crate is Linux-only here (see the cfg(target_os = "linux") block in
# Cargo.toml). Drop this entry when webkit2gtk moves to gtk-rs 0.20.
"RUSTSEC-2024-0429",
# rand 0.7.3 arrives only as a BUILD dependency, three levels down:
# tauri-utils -> kuchikiki 0.8.8-speedreader -> selectors 0.24.0, whose
# build.rs uses phf_codegen -> phf_generator 0.8.0 (which requires
# rand ^0.7). It runs at codegen time and never links into a shipped
# binary. The advisory needs `ThreadRng` reseeding under a custom logger
# with rand's `log` feature on; phf_generator instead uses a fixed-seed
# `SmallRng::seed_from_u64(1234567890)` and never enables `log` — and no
# other crate here depends on rand 0.7, so feature unification cannot
# turn it on. Not upgradable: kuchikiki 0.8.9-speedreader would drop this
# chain, but cargo will not match a pre-release across patch versions
# (`^0.8.8-speedreader` rejects 0.8.9-speedreader) and tauri-utils 2.9.3
# is the latest release. Drop this entry when tauri-utils bumps kuchikiki.
"RUSTSEC-2026-0097",
]
+7 -401
View File
@@ -69,56 +69,6 @@ dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.102"
@@ -423,16 +373,6 @@ dependencies = [
"tinyvec",
]
[[package]]
name = "bstr"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "bumpalo"
version = "3.20.2"
@@ -603,35 +543,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link 0.2.1",
]
[[package]]
name = "chrono-tz"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
dependencies = [
"chrono",
"chrono-tz-build",
"phf 0.11.3",
]
[[package]]
name = "chrono-tz-build"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
dependencies = [
"parse-zoneinfo",
"phf 0.11.3",
"phf_codegen 0.11.3",
]
[[package]]
name = "cipher"
version = "0.4.4"
@@ -642,52 +558,6 @@ dependencies = [
"inout",
]
[[package]]
name = "clap"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "combine"
version = "4.6.7"
@@ -707,18 +577,6 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "console"
version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
dependencies = [
"encode_unicode",
"libc",
"unicode-width",
"windows-sys 0.61.2",
]
[[package]]
name = "const-random"
version = "0.1.18"
@@ -860,25 +718,6 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -1087,12 +926,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "deunicode"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
[[package]]
name = "device_query"
version = "2.1.0"
@@ -1310,12 +1143,6 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -1874,30 +1701,6 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "globset"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
dependencies = [
"aho-corasick",
"bstr",
"log",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "globwalk"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757"
dependencies = [
"bitflags 2.11.0",
"ignore",
"walkdir",
]
[[package]]
name = "gobject-sys"
version = "0.18.0"
@@ -2110,15 +1913,6 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "humansize"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
dependencies = [
"libm",
]
[[package]]
name = "hyper"
version = "1.8.1"
@@ -2195,7 +1989,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.58.0",
"windows-core 0.61.2",
]
[[package]]
@@ -2331,22 +2125,6 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "ignore"
version = "0.4.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a"
dependencies = [
"crossbeam-deque",
"globset",
"log",
"memchr",
"regex-automata",
"same-file",
"walkdir",
"winapi-util",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -2370,19 +2148,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "indicatif"
version = "0.18.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c"
dependencies = [
"console",
"portable-atomic",
"unicode-width",
"unit-prefix",
"web-time",
]
[[package]]
name = "infer"
version = "0.19.0"
@@ -2437,12 +2202,6 @@ dependencies = [
"once_cell",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
@@ -2626,12 +2385,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
version = "0.1.14"
@@ -3208,12 +2961,6 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "open"
version = "5.3.3"
@@ -3274,7 +3021,7 @@ dependencies = [
[[package]]
name = "owncord-client"
version = "1.1.0-alpha.5"
version = "1.2.0-alpha.2"
dependencies = [
"base64 0.22.1",
"device_query",
@@ -3301,11 +3048,11 @@ dependencies = [
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tauri-typegen",
"tokio",
"tokio-rustls",
"tokio-tungstenite",
"url",
"webkit2gtk",
"webpki-roots 1.0.9",
"windows 0.58.0",
"windows-sys 0.60.2",
@@ -3366,15 +3113,6 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "parse-zoneinfo"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24"
dependencies = [
"regex",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
@@ -3387,49 +3125,6 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pest"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662"
dependencies = [
"memchr",
"ucd-trie",
]
[[package]]
name = "pest_derive"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77"
dependencies = [
"pest",
"pest_generator",
]
[[package]]
name = "pest_generator"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f"
dependencies = [
"pest",
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "pest_meta"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220"
dependencies = [
"pest",
"sha2",
]
[[package]]
name = "phf"
version = "0.8.0"
@@ -3691,12 +3386,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -4310,9 +3999,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.42"
version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"once_cell",
"ring",
@@ -4581,12 +4270,6 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-rename-rule"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8a059d895f1a31dd928f40abbea4e7177e3d8ff3aa4152fdb7a396ae1ef63a3"
[[package]]
name = "serde-untagged"
version = "0.1.9"
@@ -4819,16 +4502,6 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "slug"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724"
dependencies = [
"deunicode",
"wasm-bindgen",
]
[[package]]
name = "smallvec"
version = "1.15.1"
@@ -5567,27 +5240,6 @@ dependencies = [
"wry",
]
[[package]]
name = "tauri-typegen"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e761d97bf6c90f13894383493485008d0c51f67ff7b12c0f95dc34b8a9e6e73"
dependencies = [
"chrono",
"clap",
"indicatif",
"proc-macro2",
"quote",
"regex",
"serde",
"serde-rename-rule",
"serde_json",
"syn 2.0.117",
"tera",
"thiserror 2.0.18",
"walkdir",
]
[[package]]
name = "tauri-utils"
version = "2.9.3"
@@ -5684,28 +5336,6 @@ dependencies = [
"utf-8",
]
[[package]]
name = "tera"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8004bca281f2d32df3bacd59bc67b312cb4c70cea46cbd79dbe8ac5ed206722"
dependencies = [
"chrono",
"chrono-tz",
"globwalk",
"humansize",
"lazy_static",
"percent-encoding",
"pest",
"pest_derive",
"rand 0.8.7",
"regex",
"serde",
"serde_json",
"slug",
"unicode-segmentation",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -6133,12 +5763,6 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "ucd-trie"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "uds_windows"
version = "1.2.1"
@@ -6203,24 +5827,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unit-prefix"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -6264,12 +5876,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.22.0"
@@ -7560,9 +7166,9 @@ dependencies = [
[[package]]
name = "zeroize"
version = "1.8.2"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
+9 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "owncord-client"
version = "1.1.0-alpha.5"
version = "1.2.0-alpha.2"
edition = "2021"
# Effective minimum: tauri 2.11 declares rust-version = "1.77.2", so the crate
# cannot build below it. Declaring it here enables Cargo's MSRV-aware resolver
@@ -21,7 +21,6 @@ crate-type = ["lib", "cdylib", "staticlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
tauri-typegen = "0.5"
[features]
default = []
@@ -117,3 +116,11 @@ windows-sys = { version = "0.60", features = [
[target.'cfg(target_os = "linux")'.dependencies]
device_query = "2"
# Direct access to the WebKitGTK webview for voice/video support. WebKitGTK
# denies getUserMedia/enumerateDevices permission requests by default (wry
# installs no handler on Linux, unlike its macOS backend which auto-grants),
# and ships with media-stream/WebRTC settings off — so microphones and cameras
# are invisible to the webview without this hook. Version-pinned to match
# wry's own `=2.0.2` pin so both link the same crate build; v2_38 gates the
# enable-webrtc setting.
webkit2gtk = { version = "=2.0.2", features = ["v2_38"] }
@@ -21,6 +21,7 @@
"core:window:allow-outer-size",
"core:window:allow-available-monitors",
"core:window:allow-center",
"core:window:allow-request-user-attention",
"notification:default",
"notification:allow-notify",
"notification:allow-request-permission",
+12 -2
View File
@@ -8,6 +8,16 @@ pub const IDENTITY_PINS_STORE: &str = "identity_pins.json";
pub const SETTINGS_STORE: &str = "settings.json";
/// Tauri store file for the degraded-mode credential fallback (see
/// `secret_store`). Values are DPAPI ciphertext, never plaintext, and the file
/// only exists on a machine whose OS credential store failed a round-trip.
/// `secret_store`). Values are ciphertext (DPAPI on Windows, ChaCha20-Poly1305
/// elsewhere), never plaintext, and the file only exists on a machine whose OS
/// credential store failed a round-trip.
pub const CREDENTIAL_FALLBACK_STORE: &str = "credential_fallback.json";
/// Per-install key that seals the non-Windows credential fallback entries
/// (see `fallback_crypto`). Written once, owner-only (0600).
///
/// Gated to match its only consumer: `fallback_crypto` is `cfg(not(windows))`
/// because Windows seals fallback entries with DPAPI instead, so on Windows
/// this constant would be dead code and `-D warnings` fails the build.
#[cfg(not(windows))]
pub const CREDENTIAL_FALLBACK_KEY_FILE: &str = "credential_fallback.key";
+179 -81
View File
@@ -1,4 +1,5 @@
use serde::Serialize;
use std::sync::Mutex;
use tauri::AppHandle;
use crate::secret_store::{self, Backend};
@@ -8,9 +9,9 @@ use crate::secret_store::{self, Backend};
pub struct CredentialData {
pub username: String,
pub token: String,
// Password is stored in the credential blob for re-authentication but
// is never serialized back to the frontend over IPC to limit exposure.
#[serde(skip)]
// Password is stored in the credential blob for re-authentication and is
// serialized back to the frontend over IPC so the login form can prefill
// it when the user ticked "Remember password".
pub password: Option<String>,
}
@@ -53,6 +54,42 @@ fn require_non_empty(value: &str, field: &str) -> Result<(), String> {
Ok(())
}
// ---------------------------------------------------------------------------
// Cross-command serialization
// ---------------------------------------------------------------------------
//
// B4-3 moved every command below to `#[tauri::command(async)]` so the
// blocking keyring/DPAPI I/O runs off Tauri's IPC main thread instead of
// freezing the UI on it. Before that, Tauri ran all (sync) commands one at a
// time on that thread, so two overlapping invocations were always fully
// serialized in arrival order. `async` dispatches each invocation onto the
// async runtime's thread pool instead, so two overlapping calls can now
// genuinely run concurrently and interleave their OS credential-store
// operations.
//
// That is reachable, not hypothetical: `identity.ts`'s legacy-key migration
// does a save-then-delete pair for two different accounts, and logging out
// fires a fire-and-forget `delete_credential` for a host whose connect-page
// auto-login can immediately issue `load_credential` for the very same host.
// Nothing upstream awaits the delete before the read can start.
//
// This mutex restores the "only one credential-store operation in flight at
// a time" property that made ordering safe pre-`async`, without giving back
// the perf win: it guards the whole command body (not just the raw OS call),
// so the fallback file's read-modify-write in `secret_store::set_with` is
// still atomic with respect to a concurrent read or delete for the same or a
// different account.
static CREDENTIAL_LOCK: Mutex<()> = Mutex::new(());
/// Run `f` with every other credential-store command excluded. Poisoning is
/// recovered from (the guarded value is `()`, so there is nothing to
/// distrust) rather than propagated, so a panic inside one command cannot
/// permanently wedge every credential operation for the rest of the process.
fn with_credential_lock<T>(f: impl FnOnce() -> T) -> T {
let _guard = CREDENTIAL_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
f()
}
// ---------------------------------------------------------------------------
// Tauri commands
// ---------------------------------------------------------------------------
@@ -68,7 +105,7 @@ fn require_non_empty(value: &str, field: &str) -> Result<(), String> {
/// On macOS it is stored in the system Keychain. The write is read back before
/// this returns — see [`crate::secret_store`] for what happens when it does not
/// come back.
#[tauri::command]
#[tauri::command(async)]
pub fn save_credential(
app: AppHandle,
host: String,
@@ -76,37 +113,41 @@ pub fn save_credential(
token: String,
password: Option<String>,
) -> Result<(), String> {
require_non_empty(&host, "host")?;
require_non_empty(&token, "token")?;
require_non_empty(&username, "username")?;
with_credential_lock(|| {
require_non_empty(&host, "host")?;
require_non_empty(&token, "token")?;
require_non_empty(&username, "username")?;
let mut payload = serde_json::json!({
"username": username,
"token": token,
});
if let Some(ref pw) = password {
payload["password"] = serde_json::Value::String(pw.clone());
}
let mut payload = serde_json::json!({
"username": username,
"token": token,
});
if let Some(ref pw) = password {
payload["password"] = serde_json::Value::String(pw.clone());
}
secret_store::set(&app, &login_account(&host), &payload.to_string())
.map_err(|e| format!("save_credential failed: {e}"))?;
Ok(())
secret_store::set(&app, &login_account(&host), &payload.to_string())
.map_err(|e| format!("save_credential failed: {e}"))?;
Ok(())
})
}
/// Load a credential from the system credential store.
///
/// Returns `None` when no credential exists for the given host.
#[tauri::command]
#[tauri::command(async)]
pub fn load_credential(app: AppHandle, host: String) -> Result<Option<CredentialData>, String> {
require_non_empty(&host, "host")?;
with_credential_lock(|| {
require_non_empty(&host, "host")?;
let Some(json_str) = secret_store::get(&app, &login_account(&host))
.map_err(|e| format!("load_credential failed: {e}"))?
else {
return Ok(None);
};
let Some(json_str) = secret_store::get(&app, &login_account(&host))
.map_err(|e| format!("load_credential failed: {e}"))?
else {
return Ok(None);
};
parse_credential_blob(&json_str).map(Some)
parse_credential_blob(&json_str).map(Some)
})
}
/// Parse the stored credential JSON blob.
@@ -142,11 +183,13 @@ fn parse_credential_blob(json_str: &str) -> Result<CredentialData, String> {
/// Delete a credential from the system credential store.
///
/// Deleting a non-existent credential is not treated as an error.
#[tauri::command]
#[tauri::command(async)]
pub fn delete_credential(app: AppHandle, host: String) -> Result<(), String> {
require_non_empty(&host, "host")?;
secret_store::delete(&app, &login_account(&host))
.map_err(|e| format!("delete_credential failed: {e}"))
with_credential_lock(|| {
require_non_empty(&host, "host")?;
secret_store::delete(&app, &login_account(&host))
.map_err(|e| format!("delete_credential failed: {e}"))
})
}
// ---------------------------------------------------------------------------
@@ -161,37 +204,44 @@ pub fn delete_credential(app: AppHandle, host: String) -> Result<(), String> {
/// Save the long-term identity private key for `host`.
///
/// The write is read back before this returns. A machine whose credential store
/// accepts writes without keeping them falls through to the DPAPI file; if that
/// is also unavailable this returns an error rather than reporting a success
/// that would leave peers rejecting the user's voice announce after a restart.
#[tauri::command]
/// accepts writes without keeping them falls through to the encrypted fallback
/// file (DPAPI on Windows, sealed per-install key elsewhere); if that is also
/// unavailable this returns an error rather than reporting a success that would
/// leave peers rejecting the user's voice announce after a restart.
#[tauri::command(async)]
pub fn save_identity_key(app: AppHandle, host: String, key: String) -> Result<(), String> {
require_non_empty(&host, "host")?;
require_non_empty(&key, "key")?;
with_credential_lock(|| {
require_non_empty(&host, "host")?;
require_non_empty(&key, "key")?;
secret_store::set(&app, &identity_account(&host), &key)
.map_err(|e| format!("save_identity_key failed: {e}"))?;
Ok(())
secret_store::set(&app, &identity_account(&host), &key)
.map_err(|e| format!("save_identity_key failed: {e}"))?;
Ok(())
})
}
/// Load the identity private key for `host`.
///
/// Returns `None` when no identity key exists for the given host.
#[tauri::command]
#[tauri::command(async)]
pub fn load_identity_key(app: AppHandle, host: String) -> Result<Option<String>, String> {
require_non_empty(&host, "host")?;
secret_store::get(&app, &identity_account(&host))
.map_err(|e| format!("load_identity_key failed: {e}"))
with_credential_lock(|| {
require_non_empty(&host, "host")?;
secret_store::get(&app, &identity_account(&host))
.map_err(|e| format!("load_identity_key failed: {e}"))
})
}
/// Delete the identity private key for `host`.
///
/// Deleting a non-existent key is not treated as an error.
#[tauri::command]
#[tauri::command(async)]
pub fn delete_identity_key(app: AppHandle, host: String) -> Result<(), String> {
require_non_empty(&host, "host")?;
secret_store::delete(&app, &identity_account(&host))
.map_err(|e| format!("delete_identity_key failed: {e}"))
with_credential_lock(|| {
require_non_empty(&host, "host")?;
secret_store::delete(&app, &identity_account(&host))
.map_err(|e| format!("delete_identity_key failed: {e}"))
})
}
// ---------------------------------------------------------------------------
@@ -216,44 +266,46 @@ pub struct CredentialStoreProbe {
/// announce: it distinguishes "the credential store is fine" from "writes are
/// accepted and dropped" without touching any real credential. The probe
/// account is removed again whatever the outcome.
#[tauri::command]
#[tauri::command(async)]
pub fn probe_credential_store(app: AppHandle) -> CredentialStoreProbe {
// Underscores are not legal in DNS hostnames, so this cannot collide with a
// real `{host}` or `identity:{host}` account.
const PROBE_ACCOUNT: &str = "__diagnostic_probe__";
const PROBE_SECRET: &str = "owncord-credential-store-probe";
with_credential_lock(|| {
// Underscores are not legal in DNS hostnames, so this cannot collide
// with a real `{host}` or `identity:{host}` account.
const PROBE_ACCOUNT: &str = "__diagnostic_probe__";
const PROBE_SECRET: &str = "owncord-credential-store-probe";
let result = secret_store::set(&app, PROBE_ACCOUNT, PROBE_SECRET).and_then(|backend| {
match secret_store::get(&app, PROBE_ACCOUNT)? {
Some(ref got) if got == PROBE_SECRET => Ok(backend),
Some(_) => Err("read back a different value than was written".into()),
None => Err("the store reported a successful write but returned no entry".into()),
let result = secret_store::set(&app, PROBE_ACCOUNT, PROBE_SECRET).and_then(|backend| {
match secret_store::get(&app, PROBE_ACCOUNT)? {
Some(ref got) if got == PROBE_SECRET => Ok(backend),
Some(_) => Err("read back a different value than was written".into()),
None => Err("the store reported a successful write but returned no entry".into()),
}
});
// Always clean up, including when the probe failed part-way through.
if let Err(e) = secret_store::delete(&app, PROBE_ACCOUNT) {
log::warn!("failed to remove credential store probe entry: {e}");
}
});
// Always clean up, including when the probe failed part-way through.
if let Err(e) = secret_store::delete(&app, PROBE_ACCOUNT) {
log::warn!("failed to remove credential store probe entry: {e}");
}
match result {
Ok(backend) => {
log::info!("credential store probe succeeded (backend: {backend:?})");
CredentialStoreProbe {
ok: true,
backend: Some(backend),
error: None,
match result {
Ok(backend) => {
log::info!("credential store probe succeeded (backend: {backend:?})");
CredentialStoreProbe {
ok: true,
backend: Some(backend),
error: None,
}
}
Err(e) => {
log::error!("credential store probe failed: {e}");
CredentialStoreProbe {
ok: false,
backend: None,
error: Some(e),
}
}
}
Err(e) => {
log::error!("credential store probe failed: {e}");
CredentialStoreProbe {
ok: false,
backend: None,
error: Some(e),
}
}
}
})
}
// ---------------------------------------------------------------------------
@@ -346,14 +398,60 @@ mod tests {
}
#[test]
fn credential_data_skips_password_in_json() {
fn credential_data_serializes_password_for_prefill() {
let data = CredentialData {
username: "alice".into(),
token: "tok".into(),
password: Some("pw".into()),
};
let json = serde_json::to_string(&data).unwrap();
assert!(!json.contains("password"));
assert!(!json.contains("pw"));
assert!(json.contains("password"));
assert!(json.contains("pw"));
}
/// B4-3 follow-up: all 7 commands moved to `#[tauri::command(async)]`,
/// which runs each invocation on the async runtime's thread pool instead
/// of Tauri's single IPC main thread. Two overlapping invocations (e.g.
/// `identity.ts`'s save-then-delete legacy-key migration, or a logout's
/// `delete_credential` racing a connect-page auto-login's
/// `load_credential` for the same host) can now genuinely run
/// concurrently. `with_credential_lock` must serialize them: this proves
/// no two holders of the lock ever run their critical section at the
/// same time, regardless of which OS thread the runtime schedules them
/// on.
#[test]
fn credential_lock_serializes_overlapping_commands() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
let concurrent = Arc::new(AtomicUsize::new(0));
let max_concurrent = Arc::new(AtomicUsize::new(0));
let handles: Vec<_> = (0..8)
.map(|_| {
let concurrent = Arc::clone(&concurrent);
let max_concurrent = Arc::clone(&max_concurrent);
thread::spawn(move || {
with_credential_lock(|| {
let now = concurrent.fetch_add(1, Ordering::SeqCst) + 1;
max_concurrent.fetch_max(now, Ordering::SeqCst);
thread::sleep(Duration::from_millis(5));
concurrent.fetch_sub(1, Ordering::SeqCst);
});
})
})
.collect();
for h in handles {
h.join().unwrap();
}
assert_eq!(
max_concurrent.load(Ordering::SeqCst),
1,
"two credential-store commands ran their critical section concurrently"
);
}
}
@@ -0,0 +1,282 @@
//! Encryption for the non-Windows credential fallback file.
//!
//! Windows parks fallback secrets behind DPAPI, whose key lives with the OS.
//! macOS and Linux have no DPAPI equivalent that works while the Keychain /
//! Secret Service itself is the thing that failed, so this module seals
//! secrets with ChaCha20-Poly1305 (via `ring`, already in the tree) under a
//! per-install random key stored next to the app data (mode 0600).
//!
//! This is damage control, not a vault: an attacker who can read both the key
//! file and the fallback store as this user has the secrets, exactly as they
//! would with DPAPI under the same user account. What it buys is (a) secrets
//! at rest are never plaintext, (b) a copied fallback store is useless without
//! the key file beside it, and (c) an entry cannot be moved between accounts
//! — the account name is bound in as AEAD associated data, mirroring the DPAPI
//! entropy on Windows. The OS credential store always remains the primary
//! store; this file only ever holds entries whose keychain write failed a
//! verified round-trip (see `secret_store`).
use std::fs;
use std::io::Write;
use std::path::Path;
use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, CHACHA20_POLY1305, NONCE_LEN};
use ring::rand::{SecureRandom, SystemRandom};
use crate::constants::CREDENTIAL_FALLBACK_KEY_FILE;
/// Size of the sealing key in bytes (ChaCha20-Poly1305).
pub const KEY_LEN: usize = 32;
/// Load the per-install sealing key from `dir`, creating it on first use.
///
/// The key file is written with owner-only permissions (0600) and never
/// rewritten once it exists — losing it orphans every sealed entry, which the
/// caller treats the same as an absent entry.
pub fn load_or_create_key(dir: &Path) -> Result<[u8; KEY_LEN], String> {
let path = dir.join(CREDENTIAL_FALLBACK_KEY_FILE);
match fs::read(&path) {
Ok(bytes) => {
let key: [u8; KEY_LEN] = bytes.as_slice().try_into().map_err(|_| {
format!(
"credential fallback key file has {} bytes, expected {KEY_LEN}\
refusing to use it",
bytes.len()
)
})?;
return Ok(key);
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("failed to read credential fallback key: {e}")),
}
let mut key = [0u8; KEY_LEN];
SystemRandom::new()
.fill(&mut key)
.map_err(|_| "system RNG failed generating the fallback key".to_string())?;
fs::create_dir_all(dir)
.map_err(|e| format!("failed to create app data dir for fallback key: {e}"))?;
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
match options.open(&path) {
Ok(mut file) => finish_new_key_file(&path, key, || {
file.write_all(&key).and_then(|()| file.sync_all())
}),
// Lost the create race to another thread — use the winner's key.
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
let bytes = fs::read(&path)
.map_err(|e| format!("failed to re-read credential fallback key: {e}"))?;
bytes
.as_slice()
.try_into()
.map_err(|_| "concurrently written fallback key has the wrong size".to_string())
}
Err(e) => Err(format!("failed to create credential fallback key: {e}")),
}
}
/// Finish writing a just-created (empty) key file: run `write_and_sync` — the
/// real write_all + sync_all in production, injected here so the failure
/// path is testable without forcing a genuine disk-full/IO error — and
/// delete the file again if it fails.
///
/// `create_new` above already created `path` with zero bytes in it. Left
/// behind, a write/sync failure leaves a short file that every future
/// `load_or_create_key` call reads back and rejects forever (see this
/// function's doc comment: "never rewritten once it exists") — silently
/// poisoning the fallback store on the first ENOSPC/IO hiccup.
fn finish_new_key_file(
path: &Path,
key: [u8; KEY_LEN],
write_and_sync: impl FnOnce() -> std::io::Result<()>,
) -> Result<[u8; KEY_LEN], String> {
if let Err(e) = write_and_sync() {
let _ = fs::remove_file(path);
return Err(format!("failed to write credential fallback key: {e}"));
}
Ok(key)
}
/// Seal `plaintext` under `key`, binding `aad` (the service + account name).
///
/// Output layout: `nonce (12 bytes) || ciphertext || tag`. The nonce is random
/// per call; at the fallback store's write volume (a handful per login) the
/// birthday bound on 96-bit nonces is not a concern.
pub fn protect(key: &[u8; KEY_LEN], plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, String> {
let unbound = UnboundKey::new(&CHACHA20_POLY1305, key)
.map_err(|_| "failed to build the fallback sealing key".to_string())?;
let sealing = LessSafeKey::new(unbound);
let mut nonce_bytes = [0u8; NONCE_LEN];
SystemRandom::new()
.fill(&mut nonce_bytes)
.map_err(|_| "system RNG failed generating a nonce".to_string())?;
let nonce = Nonce::assume_unique_for_key(nonce_bytes);
let mut in_out = plaintext.to_vec();
sealing
.seal_in_place_append_tag(nonce, Aad::from(aad), &mut in_out)
.map_err(|_| "sealing the fallback entry failed".to_string())?;
let mut blob = Vec::with_capacity(NONCE_LEN + in_out.len());
blob.extend_from_slice(&nonce_bytes);
blob.append(&mut in_out);
Ok(blob)
}
/// Open a blob produced by [`protect`]. Fails on tampering, a wrong key, or a
/// blob moved to a different account's slot (AAD mismatch).
pub fn unprotect(key: &[u8; KEY_LEN], blob: &[u8], aad: &[u8]) -> Result<Vec<u8>, String> {
if blob.len() < NONCE_LEN + CHACHA20_POLY1305.tag_len() {
return Err("fallback entry is too short to be a sealed blob".to_string());
}
let unbound = UnboundKey::new(&CHACHA20_POLY1305, key)
.map_err(|_| "failed to build the fallback sealing key".to_string())?;
let opening = LessSafeKey::new(unbound);
let nonce_bytes: [u8; NONCE_LEN] = blob[..NONCE_LEN].try_into().expect("length checked");
let nonce = Nonce::assume_unique_for_key(nonce_bytes);
let mut in_out = blob[NONCE_LEN..].to_vec();
let plaintext = opening
.open_in_place(nonce, Aad::from(aad), &mut in_out)
.map_err(|_| {
"fallback entry failed authentication — wrong key, tampered data, or an entry \
moved between accounts"
.to_string()
})?;
Ok(plaintext.to_vec())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn test_key() -> [u8; KEY_LEN] {
let mut key = [0u8; KEY_LEN];
SystemRandom::new().fill(&mut key).unwrap();
key
}
#[test]
fn round_trips_a_secret() {
let key = test_key();
let blob = protect(&key, b"hunter2", b"aad").unwrap();
assert_ne!(&blob[NONCE_LEN..], b"hunter2", "blob must not be plaintext");
assert_eq!(unprotect(&key, &blob, b"aad").unwrap(), b"hunter2");
}
#[test]
fn rejects_a_foreign_aad() {
// A blob moved to another account's slot must not decrypt — the same
// property dpapi_entropy provides on Windows.
let key = test_key();
let blob = protect(&key, b"secret", b"com.owncord.client\x01a.example").unwrap();
assert!(unprotect(&key, &blob, b"com.owncord.client\x01b.example").is_err());
}
#[test]
fn rejects_a_wrong_key_and_tampering() {
let key = test_key();
let blob = protect(&key, b"secret", b"aad").unwrap();
let other = test_key();
assert!(unprotect(&other, &blob, b"aad").is_err());
let mut tampered = blob.clone();
let last = tampered.len() - 1;
tampered[last] ^= 0x01;
assert!(unprotect(&key, &tampered, b"aad").is_err());
assert!(unprotect(&key, &blob[..NONCE_LEN], b"aad").is_err(), "truncated blob");
}
#[test]
fn nonces_are_unique_per_seal() {
let key = test_key();
let a = protect(&key, b"same", b"aad").unwrap();
let b = protect(&key, b"same", b"aad").unwrap();
assert_ne!(a, b, "two seals of the same plaintext must differ");
}
#[test]
fn creates_and_reuses_the_key_file() {
let dir = std::env::temp_dir().join(format!(
"owncord-fallback-key-test-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
let first = load_or_create_key(&dir).unwrap();
let second = load_or_create_key(&dir).unwrap();
assert_eq!(first, second, "the key must be stable across loads");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(dir.join(CREDENTIAL_FALLBACK_KEY_FILE))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "key file must be owner-only");
}
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn removes_the_partial_file_when_the_write_fails() {
// A crash / ENOSPC mid-write must not leave a short file behind:
// load_or_create_key's doc comment says the key file is "never
// rewritten once it exists", so a poisoned short file is permanent —
// every future load fails the length check forever.
let dir = std::env::temp_dir().join(format!(
"owncord-fallback-partial-write-test-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join(CREDENTIAL_FALLBACK_KEY_FILE);
// `create_new` in load_or_create_key already created this empty file
// before the write step (which is what's under test) runs.
fs::write(&path, b"").unwrap();
let err = finish_new_key_file(&path, [7u8; KEY_LEN], || {
Err(std::io::Error::other("disk full"))
})
.unwrap_err();
assert!(err.contains("failed to write"), "unexpected error: {err}");
assert!(!path.exists(), "a failed write must not leave a partial key file behind");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn rejects_a_corrupt_key_file() {
let dir = std::env::temp_dir().join(format!(
"owncord-fallback-badkey-test-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join(CREDENTIAL_FALLBACK_KEY_FILE), b"short").unwrap();
let err = load_or_create_key(&dir).unwrap_err();
assert!(err.contains("expected 32"), "unexpected error: {err}");
let _ = fs::remove_dir_all(&dir);
}
}
@@ -33,7 +33,7 @@ use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Arc;
use rustls::pki_types::ServerName;
use tauri::{AppHandle, Runtime};
use tauri::{AppHandle, Manager, Runtime};
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Mutex;
@@ -57,6 +57,18 @@ impl HttpProxyState {
inner: Mutex::new(HashMap::new()),
}
}
/// Remove the `remote_host` entry, but only if it still points at `port`.
/// Used by `run_proxy_loop`'s accept-error exit path to deregister a dead
/// tunnel without racing a newer tunnel that may have already replaced it
/// (e.g. `stop_http_proxy` + a fresh `start_http_proxy` while this loop
/// was mid-shutdown).
async fn remove_if_port_matches(&self, remote_host: &str, port: u16) {
let mut inner = self.inner.lock().await;
if inner.get(remote_host).is_some_and(|entry| entry.port == port) {
inner.remove(remote_host);
}
}
}
/// Validate a remote host string before it is used in header rewriting and
@@ -111,6 +123,7 @@ pub async fn start_http_proxy<R: Runtime>(
app.clone(),
listener,
remote_host.clone(),
port,
shutdown_rx,
));
// Watch the loop so a panic is logged instead of vanishing silently (which
@@ -161,6 +174,7 @@ async fn run_proxy_loop<R: Runtime>(
app: AppHandle<R>,
listener: TcpListener,
remote_host: String,
port: u16,
mut shutdown_rx: tokio::sync::oneshot::Receiver<()>,
) {
let mut consecutive_errors: u32 = 0;
@@ -191,6 +205,21 @@ async fn run_proxy_loop<R: Runtime>(
"[http_proxy] {} consecutive accept errors, stopping proxy loop",
MAX_CONSECUTIVE_ACCEPT_ERRORS
);
// Deregister the dead tunnel BEFORE the break drops
// `listener`, so a future start_http_proxy rebinds a
// fresh port instead of handing back this closed one
// forever. Doing it here rather than after the loop
// returns matters: the listener still holds the port,
// so no newer tunnel can have been handed the same
// number and the port guard cannot misfire.
if let Some(state) = app.try_state::<HttpProxyState>() {
state.remove_if_port_matches(&remote_host, port).await;
} else {
warn!(
"[http_proxy] state unmanaged; cannot deregister dead tunnel for {}",
remote_host
);
}
break;
}
}
@@ -408,6 +437,45 @@ async fn handle_connection<R: Runtime>(
mod tests {
use super::*;
// Regression: the accept-error exit path in run_proxy_loop must be able to
// deregister its own dead entry, but must NOT clobber a newer tunnel that
// has since replaced it under the same remote_host key.
#[tokio::test]
async fn remove_if_port_matches_removes_only_matching_entry() {
let state = HttpProxyState::new();
{
let (tx, _rx) = tokio::sync::oneshot::channel::<()>();
let mut inner = state.inner.lock().await;
inner.insert(
"example.com:8443".to_string(),
ProxyEntry {
port: 4242,
shutdown_tx: tx,
},
);
}
// A stale loop reporting a port that no longer matches the live
// entry must leave the current entry alone.
state
.remove_if_port_matches("example.com:8443", 9999)
.await;
assert_eq!(
state.inner.lock().await.get("example.com:8443").map(|e| e.port),
Some(4242),
"mismatched port must not remove a newer tunnel's entry"
);
// A loop reporting its own still-current port must remove it.
state
.remove_if_port_matches("example.com:8443", 4242)
.await;
assert!(
state.inner.lock().await.get("example.com:8443").is_none(),
"matching port must deregister the dead tunnel"
);
}
#[test]
fn validate_rejects_crlf_and_null() {
assert!(validate_remote_host("evil\r\nhost").is_err());
+9
View File
@@ -3,7 +3,11 @@ mod constants;
mod credentials;
#[cfg(windows)]
mod dpapi;
#[cfg(not(windows))]
mod fallback_crypto;
mod http_proxy;
#[cfg(target_os = "linux")]
mod linux_media;
mod livekit_proxy;
mod ptt;
mod secret_store;
@@ -121,6 +125,7 @@ pub fn run() {
ptt::ptt_stop,
ptt::ptt_set_key,
ptt::ptt_get_key,
ptt::ptt_polling_supported,
ptt::ptt_listen_for_key,
livekit_proxy::start_livekit_proxy,
livekit_proxy::stop_livekit_proxy,
@@ -135,6 +140,10 @@ pub fn run() {
// persistent store, every later credential symptom follows from it.
secret_store::log_compiled_backend();
tray::create_tray(app.handle())?;
// WebKitGTK denies mic/camera access by default — grant it so
// voice/video works on Linux (no-op elsewhere; see linux_media).
#[cfg(target_os = "linux")]
linux_media::enable_media_capture(app.handle());
Ok(())
})
.build(tauri::generate_context!())
@@ -0,0 +1,54 @@
//! Linux-only WebKitGTK media capture support.
//!
//! On Windows and macOS the webview grants media capture itself (wry's
//! WKWebView delegate auto-grants; WebView2 prompts). WebKitGTK does
//! neither: `enable-media-stream` and `enable-webrtc` default to off, and
//! any `permission-request` signal without a handler is denied. The result
//! is that `navigator.mediaDevices.getUserMedia` fails and
//! `enumerateDevices` returns nothing — no microphones or cameras are ever
//! detected on Linux without this hook.
//!
//! Only media-related permission requests are granted here; everything else
//! (geolocation, web notifications, …) falls through to WebKit's default
//! deny so this hook does not widen the webview's surface beyond capture.
use tauri::{AppHandle, Manager};
/// Enable media streams / WebRTC on the main window's WebKitGTK webview and
/// auto-grant its microphone/camera permission requests.
pub fn enable_media_capture(app: &AppHandle) {
let Some(window) = app.get_webview_window("main") else {
log::error!("linux_media: main window not found; media capture stays unavailable");
return;
};
let result = window.with_webview(|webview| {
use webkit2gtk::glib::prelude::Cast;
use webkit2gtk::{
DeviceInfoPermissionRequest, PermissionRequestExt, SettingsExt,
UserMediaPermissionRequest, WebViewExt,
};
let webview = webview.inner();
if let Some(settings) = webview.settings() {
settings.set_enable_media_stream(true);
settings.set_enable_webrtc(true);
} else {
log::error!("linux_media: webview has no settings object");
}
webview.connect_permission_request(|_, request| {
// UserMediaPermissionRequest covers getUserMedia (mic/camera);
// DeviceInfoPermissionRequest covers enumerateDevices labels.
let is_media = request.downcast_ref::<UserMediaPermissionRequest>().is_some()
|| request.downcast_ref::<DeviceInfoPermissionRequest>().is_some();
if is_media {
request.allow();
return true;
}
// Unhandled — WebKit applies its default (deny).
false
});
});
if let Err(e) = result {
log::error!("linux_media: failed to configure webview media capture: {e}");
}
}
@@ -18,7 +18,9 @@
// - Certificate validation uses the TOFU-pinned fingerprint from ws_proxy.
// The WebSocket proxy must connect first to establish trust; the LiveKit
// proxy then pins to that same certificate. If the cert changes between
// WS and LiveKit connections, the LiveKit handshake will fail.
// WS and LiveKit connections, the LiveKit handshake will fail (fail
// closed) until the user accepts the new cert — each start call reloads
// the stored pin and restarts the listener when it changed.
// - Only one proxy instance runs at a time (per remote host). Connecting to
// a different server replaces the proxy. Stale proxy ports are not reused.
// - If the TcpListener errors (extremely unlikely on loopback), the cached
@@ -29,7 +31,7 @@ use log::{debug, error, info, warn};
use std::net::IpAddr;
use std::sync::Arc;
use rustls::pki_types::ServerName;
use tauri::Runtime;
use tauri::{Manager, Runtime};
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Mutex;
@@ -45,6 +47,9 @@ struct ProxyInner {
port: Option<u16>,
/// The remote host:port we're proxying to.
remote_host: String,
/// The TOFU fingerprint the running listener pins. Baked into the proxy
/// loop at spawn, so a re-pin in the cert store requires a restart.
pinned_fingerprint: String,
/// Shutdown signal sender.
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
}
@@ -55,10 +60,26 @@ impl LiveKitProxyState {
inner: Mutex::new(ProxyInner {
port: None,
remote_host: String::new(),
pinned_fingerprint: String::new(),
shutdown_tx: None,
}),
}
}
/// Clear the running-proxy state, but only if it still points at `port`.
/// Mirrors HttpProxyState::remove_if_port_matches; used by run_proxy_loop's
/// accept-error exit path so a dead listener doesn't keep being handed
/// back by start_livekit_proxy's reuse branch, and doesn't race a newer
/// proxy that may have already replaced it.
async fn clear_if_port_matches(&self, port: u16) {
let mut inner = self.inner.lock().await;
if inner.port == Some(port) {
inner.port = None;
inner.remote_host.clear();
inner.pinned_fingerprint.clear();
inner.shutdown_tx = None;
}
}
}
// ---------------------------------------------------------------------------
@@ -121,6 +142,21 @@ pub(crate) fn rewrite_proxy_headers(request: &str, remote_host: &str) -> String
modified
}
/// Decide whether an already-running proxy can serve a new start request:
/// only when both the remote host AND the TOFU-pinned fingerprint are
/// unchanged. The listener bakes its fingerprint in at spawn, so after the
/// user accepts a rotated cert (which rewrites the store), reusing the old
/// listener would fail every TLS handshake against the stale pin until
/// logout — the caller must tear down and restart instead.
pub(crate) fn can_reuse_proxy(
running_host: &str,
running_fingerprint: &str,
requested_host: &str,
stored_fingerprint: &str,
) -> bool {
running_host == requested_host && running_fingerprint == stored_fingerprint
}
/// Extract the TLS server name from a `host[:port]` string.
///
/// IPv6 literals arrive bracketed (`[::1]:8443`); the brackets are stripped and
@@ -162,24 +198,12 @@ pub async fn start_livekit_proxy<R: Runtime>(
info!("[livekit_proxy] start requested for {}", remote_host);
// Reuse existing proxy for same host.
if let Some(port) = inner.port {
if inner.remote_host == remote_host {
debug!("[livekit_proxy] reusing existing proxy on port {} for {}", port, remote_host);
return Ok(port);
}
// Different host — tear down old proxy.
info!("[livekit_proxy] stopping old proxy for {} (switching to {})", inner.remote_host, remote_host);
if let Some(tx) = inner.shutdown_tx.take() {
let _ = tx.send(());
}
inner.port = None;
}
// Load the TOFU-pinned fingerprint from the cert store. The ws_proxy must
// have connected first (establishing the TOFU trust), so the fingerprint
// should already be stored. If not, reject — we refuse to connect without
// a pinned cert.
// Load the TOFU-pinned fingerprint from the cert store BEFORE the reuse
// check — a running listener bakes its pin in at spawn, so a re-pin
// (user accepted a rotated cert) must force a restart, not a reuse. The
// ws_proxy must have connected first (establishing the TOFU trust), so
// the fingerprint should already be stored. If not, reject — we refuse
// to connect without a pinned cert.
let store_key = tofu::cert_store_key(&remote_host);
let fingerprint = tofu::load_stored_fingerprint(&app, &store_key)?
.ok_or_else(|| format!(
@@ -187,6 +211,23 @@ pub async fn start_livekit_proxy<R: Runtime>(
Connect via WebSocket first to establish TOFU trust."
))?;
// Reuse the existing proxy only when host AND pin are unchanged.
if let Some(port) = inner.port {
if can_reuse_proxy(&inner.remote_host, &inner.pinned_fingerprint, &remote_host, &fingerprint) {
debug!("[livekit_proxy] reusing existing proxy on port {} for {}", port, remote_host);
return Ok(port);
}
// Different host or re-pinned cert — tear down the old proxy.
info!(
"[livekit_proxy] stopping old proxy for {} (restarting for {})",
inner.remote_host, remote_host
);
if let Some(tx) = inner.shutdown_tx.take() {
let _ = tx.send(());
}
inner.port = None;
}
let listener = TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| format!("livekit proxy bind failed: {e}"))?;
@@ -198,7 +239,14 @@ pub async fn start_livekit_proxy<R: Runtime>(
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let host = remote_host.clone();
let loop_handle = tokio::spawn(run_proxy_loop(listener, host, fingerprint, shutdown_rx));
let loop_handle = tokio::spawn(run_proxy_loop(
app.clone(),
listener,
host,
port,
fingerprint.clone(),
shutdown_rx,
));
// Watch the loop so a panic is logged instead of vanishing silently.
tokio::spawn(async move {
match loop_handle.await {
@@ -212,6 +260,7 @@ pub async fn start_livekit_proxy<R: Runtime>(
inner.port = Some(port);
inner.remote_host = remote_host;
inner.pinned_fingerprint = fingerprint;
inner.shutdown_tx = Some(shutdown_tx);
Ok(port)
@@ -228,6 +277,7 @@ pub async fn stop_livekit_proxy(
}
inner.port = None;
inner.remote_host.clear();
inner.pinned_fingerprint.clear();
Ok(())
}
@@ -238,9 +288,11 @@ pub async fn stop_livekit_proxy(
/// Maximum consecutive accept errors before the proxy loop exits.
const MAX_CONSECUTIVE_ACCEPT_ERRORS: u32 = 5;
async fn run_proxy_loop(
async fn run_proxy_loop<R: Runtime>(
app: tauri::AppHandle<R>,
listener: TcpListener,
remote_host: String,
port: u16,
pinned_fingerprint: String,
mut shutdown_rx: tokio::sync::oneshot::Receiver<()>,
) {
@@ -272,6 +324,20 @@ async fn run_proxy_loop(
"[livekit_proxy] {} consecutive accept errors, stopping proxy loop",
MAX_CONSECUTIVE_ACCEPT_ERRORS
);
// Deregister the dead proxy BEFORE the break drops
// `listener`, so a future start_livekit_proxy
// rebinds a fresh port instead of handing back
// this closed one forever (the reuse branch keys
// only on host+pin, not liveness). Mirrors
// http_proxy.rs's identical fix.
if let Some(state) = app.try_state::<LiveKitProxyState>() {
state.clear_if_port_matches(port).await;
} else {
warn!(
"[livekit_proxy] state unmanaged; cannot deregister dead proxy for {}",
remote_host
);
}
break;
}
}
@@ -282,6 +348,35 @@ async fn run_proxy_loop(
}
}
/// Bound on the outbound dial and TLS handshake, matching http_proxy.rs.
const PROXY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// Dial `remote_host` and complete the TLS handshake, bounding each step by
/// `limit`.
///
/// Both steps must be bounded. A peer that accepts the TCP connection and then
/// never answers the ClientHello blocks the handshake forever, and the calling
/// task holds `local` without polling it — so the LiveKit SDK closing its side
/// never cancels it. Those tasks and their sockets accumulate on every SDK
/// retry and survive stop_livekit_proxy, whose shutdown oneshot only stops the
/// accept loop; the per-connection tasks are detached.
async fn connect_tls(
connector: &tokio_rustls::TlsConnector,
server_name: ServerName<'static>,
remote_host: &str,
limit: Duration,
) -> Result<tokio_rustls::client::TlsStream<TcpStream>, Box<dyn std::error::Error + Send + Sync>> {
debug!("[livekit_proxy] connecting TCP to {}", remote_host);
let tcp = timeout(limit, TcpStream::connect(remote_host))
.await
.map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from("TCP connect timed out"))??;
debug!("[livekit_proxy] starting TLS handshake with {}", remote_host);
let tls = timeout(limit, connector.connect(server_name, tcp))
.await
.map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from("TLS handshake timed out"))??;
Ok(tls)
}
/// Handle a single proxied connection:
/// 1. Read the HTTP request headers from the local (plain) side
/// 2. Rewrite Host/Origin so the remote server accepts the connection
@@ -344,10 +439,7 @@ async fn handle_connection(
let server_name = parse_server_name(remote_host)?;
debug!("[livekit_proxy] connecting TCP to {}", remote_host);
let tcp = TcpStream::connect(remote_host).await?;
debug!("[livekit_proxy] starting TLS handshake with {}", remote_host);
let mut tls = connector.connect(server_name, tcp).await?;
let mut tls = connect_tls(&connector, server_name, remote_host, PROXY_CONNECT_TIMEOUT).await?;
debug!("[livekit_proxy] TLS handshake complete, forwarding traffic");
// ── 4. Forward request + bidirectional copy ──────────────────────────
@@ -431,6 +523,27 @@ mod tests {
assert!(validate_remote_host("").is_ok());
}
// ── can_reuse_proxy ─────────────────────────────────────────────────────
#[test]
fn reuses_proxy_only_when_host_and_pin_are_unchanged() {
assert!(can_reuse_proxy("example.com:443", "aa:bb", "example.com:443", "aa:bb"));
}
#[test]
fn restarts_proxy_when_host_changes() {
assert!(!can_reuse_proxy("old.example:443", "aa:bb", "new.example:443", "aa:bb"));
}
#[test]
fn restarts_proxy_when_pin_changes() {
// The user accepted a rotated cert (accept_cert_fingerprint rewrote the
// store). The running listener still pins the old fingerprint, so every
// connection through it would fail the TLS handshake — reuse must be
// refused so the caller tears down and restarts with the new pin.
assert!(!can_reuse_proxy("example.com:443", "aa:bb", "example.com:443", "cc:dd"));
}
// ── rewrite_proxy_headers ───────────────────────────────────────────────
#[test]
@@ -558,4 +671,86 @@ mod tests {
fn rejects_an_invalid_dns_name() {
assert!(parse_server_name("not a hostname").is_err());
}
// A peer that accepts the TCP connection and then answers nothing must not
// hang the connection task forever — see connect_tls.
#[tokio::test]
async fn tls_handshake_is_bounded_by_its_timeout() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("local_addr");
tokio::spawn(async move {
let _accepted = listener.accept().await.expect("accept");
// Hold the connection open, answering nothing.
std::future::pending::<()>().await;
});
let tls_config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(tofu::PinnedVerifier::new(
"aa:bb:cc".to_string(),
)))
.with_no_client_auth();
let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
let server_name = ServerName::try_from("localhost").expect("server name");
// The outer bound exists only so a regression fails fast instead of
// hanging the suite; the assertion is that the inner limit fired.
let outcome = timeout(
Duration::from_secs(5),
connect_tls(
&connector,
server_name,
&addr.to_string(),
Duration::from_millis(100),
),
)
.await;
assert!(
outcome.is_ok(),
"connect_tls hung: the TLS handshake is not bounded by its own timeout"
);
assert!(
outcome.expect("bounded").is_err(),
"a silent peer must produce an error, not a usable TLS stream"
);
}
// ── LiveKitProxyState::clear_if_port_matches ────────────────────────────
//
// B4_conn_ipc-7: run_proxy_loop's accept-error exit path drops the
// listener without deregistering it, so ProxyInner.port stays set and
// start_livekit_proxy's reuse branch (unchanged host+pin) hands the dead
// port back forever. Mirrors http_proxy.rs's
// remove_if_port_matches_removes_only_matching_entry test.
#[tokio::test]
async fn clear_if_port_matches_clears_only_a_matching_entry() {
let state = LiveKitProxyState::new();
{
let (tx, _rx) = tokio::sync::oneshot::channel::<()>();
let mut inner = state.inner.lock().await;
inner.port = Some(4242);
inner.remote_host = "example.com:8443".to_string();
inner.pinned_fingerprint = "aa:bb".to_string();
inner.shutdown_tx = Some(tx);
}
// A stale loop reporting a port that no longer matches the live
// listener must leave the current entry alone.
state.clear_if_port_matches(9999).await;
assert_eq!(
state.inner.lock().await.port,
Some(4242),
"mismatched port must not clear a newer proxy's state"
);
// A loop reporting its own still-current port must clear it so the
// next start_livekit_proxy rebinds instead of reusing the dead listener.
state.clear_if_port_matches(4242).await;
let inner = state.inner.lock().await;
assert_eq!(inner.port, None, "matching port must deregister the dead proxy");
assert!(inner.remote_host.is_empty());
assert!(inner.pinned_fingerprint.is_empty());
}
}
+78 -6
View File
@@ -298,10 +298,55 @@ mod linux {
}
}
/// Decide whether the polling loop must emit a `ptt-state` event this tick.
///
/// Returns `Some(new_state)` on a press/release edge, `None` when nothing
/// changed.
///
/// The `vk == 0` (unbound) case is folded into `pressed` here rather than
/// guarding the whole tick: clearing the binding while the key is physically
/// held must still produce the `true -> false` falling edge. With the guard
/// outside, `was_pressed` freezes at `true`, no final `ptt-state=false` is
/// ever emitted, and the microphone stays published.
fn ptt_transition(vk: i32, key_down: bool, was_pressed: bool) -> Option<bool> {
let pressed = vk != 0 && key_down;
(pressed != was_pressed).then_some(pressed)
}
// ---------------------------------------------------------------------------
// Tauri commands
// ---------------------------------------------------------------------------
/// Whether this platform can actually observe global key state, i.e. whether
/// the polling loop can ever emit a `ptt-state` event.
///
/// `ptt_start` spawns its thread unconditionally, so a live thread is NOT
/// evidence that PTT works: on macOS `is_key_down` is a compile-time stub that
/// always returns false, and on a pure-Wayland Linux session
/// `DeviceState::checked_new()` returns None. The frontend gates its join-time
/// PTT mute on this, because muting at join where no event can ever arrive
/// would close the microphone for the whole session with no way to reopen it.
#[tauri::command]
pub fn ptt_polling_supported() -> bool {
#[cfg(windows)]
{
true
}
#[cfg(target_os = "linux")]
{
use device_query::DeviceState;
// Mirrors the availability check inside `is_key_down`: no reachable
// X11/XWayland display means key state is never observable.
DeviceState::checked_new().is_some()
}
#[cfg(not(any(windows, target_os = "linux")))]
{
false
}
}
/// Start the PTT polling loop. Emits `ptt-state` (bool) events.
///
/// Uses `PTT_THREAD`'s Mutex as the critical section to prevent duplicate
@@ -329,12 +374,14 @@ pub fn ptt_start<R: Runtime>(app: AppHandle<R>) {
while !thread_shutdown.load(Ordering::SeqCst) {
let vk = PTT_VKEY.load(Ordering::SeqCst);
if vk != 0 {
let pressed = is_key_down(vk);
if pressed != was_pressed {
was_pressed = pressed;
let _ = app.emit("ptt-state", pressed);
}
// Evaluated on every tick, including vk == 0: clearing the PTT
// key while it is physically held must still produce a falling
// edge, otherwise was_pressed freezes at true and the mic never
// gets its final `ptt-state=false`. `is_key_down` short-circuits
// to false for vk == 0 on every platform, so this costs nothing.
if let Some(pressed) = ptt_transition(vk, is_key_down(vk), was_pressed) {
was_pressed = pressed;
let _ = app.emit("ptt-state", pressed);
}
std::thread::sleep(Duration::from_millis(20));
}
@@ -523,6 +570,31 @@ mod tests {
assert!(g.is_none(), "slot must stay empty when nothing was running");
}
// The loop must emit only on edges, never on every tick — a repeat emit
// would re-run the mute logic (and its user-mute guard) 50x/second.
#[test]
fn ptt_transition_reports_edges_only() {
assert_eq!(ptt_transition(0x41, true, false), Some(true), "rising edge");
assert_eq!(ptt_transition(0x41, true, true), None, "still held");
assert_eq!(ptt_transition(0x41, false, true), Some(false), "falling edge");
assert_eq!(ptt_transition(0x41, false, false), None, "still idle");
}
// Regression for the "hot mic after Clear while the PTT key is held" bug:
// clearing the binding (vk -> 0) with the key still physically down must
// still yield the falling edge that emits the final ptt-state=false. The
// old loop wrapped the whole comparison in `if vk != 0`, so this case
// produced no transition at all and the mic stayed published.
#[test]
fn ptt_transition_emits_release_when_binding_cleared_while_key_held() {
assert_eq!(ptt_transition(0, true, true), Some(false));
// The release is reported once, then the unbound key stays quiet — an
// unbound key must never read as pressed no matter what the raw
// key-down probe says.
assert_eq!(ptt_transition(0, true, false), None);
assert_eq!(ptt_transition(0, false, false), None);
}
#[test]
fn allowed_capture_vk_accepts_safe_non_text_keys() {
assert!(is_allowed_ptt_capture_vk(0x70)); // F1
+237 -58
View File
@@ -32,15 +32,20 @@
//!
//! The keychain is the right store; the fallback is damage control, not a
//! default. It engages only after a write has been proven not to round-trip,
//! and only on Windows, where DPAPI can protect the file at rest with a
//! user-scoped key. On macOS and Linux a failing Keychain / Secret Service is
//! reported as an error rather than silently downgraded to a file — writing a
//! login password or an identity private key to plaintext disk there would be a
//! worse outcome than not persisting it.
//! on every desktop platform. On Windows the fallback file is protected by
//! DPAPI (user-scoped, key held by the OS). On macOS and Linux — where the
//! thing that failed *is* the OS secret store, so no OS-held key is available
//! — entries are sealed with ChaCha20-Poly1305 under a per-install random key
//! file (owner-only, see [`crate::fallback_crypto`]). That is honest
//! damage-control, not a vault: same-user malware can read both files, exactly
//! as it could call DPAPI. What it fixes is the real-world failure this module
//! kept hitting — a Linux desktop with no Secret Service provider (no
//! gnome-keyring / KWallet) or a locked macOS Keychain previously had nowhere
//! to save at all, so credentials and the voice-E2EE identity key silently
//! never survived a restart. Secrets at rest are never plaintext, and the OS
//! credential store always wins again the moment it starts round-tripping.
use serde::Serialize;
// Only the DPAPI fallback stores JSON values, and that is Windows-only.
#[cfg(windows)]
use serde_json::Value;
use tauri::AppHandle;
use tauri_plugin_store::StoreExt;
@@ -59,11 +64,24 @@ pub const SERVICE: &str = "com.owncord.client";
pub enum Backend {
/// The OS credential store. The expected answer on every healthy machine.
Keyring,
/// DPAPI-protected file under the app data dir, used only after the OS
/// credential store accepted a write and then failed to return it.
/// DPAPI-protected file under the app data dir (Windows), used only after
/// the OS credential store accepted a write and then failed to return it.
// Constructed only on its own platform; both variants exist everywhere so
// the serialized Backend union is identical across OS builds.
#[cfg_attr(not(windows), allow(dead_code))]
DpapiFile,
/// ChaCha20-Poly1305-sealed file under the app data dir (macOS/Linux),
/// engaged under the same failed-round-trip condition as `DpapiFile`.
#[cfg_attr(windows, allow(dead_code))]
EncryptedFile,
}
/// The fallback backend this platform's build parks degraded secrets in.
#[cfg(windows)]
const FALLBACK_BACKEND: Backend = Backend::DpapiFile;
#[cfg(not(windows))]
const FALLBACK_BACKEND: Backend = Backend::EncryptedFile;
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
@@ -74,6 +92,28 @@ pub enum Backend {
/// secret — the caller's in-memory copy is all that is left, so the current
/// session still works but nothing survives a restart.
pub fn set(app: &AppHandle, account: &str, secret: &str) -> Result<Backend, String> {
set_with(
account,
secret,
keyring_set,
keyring_get,
keyring_delete,
|acct, sec| set_fallback(app, acct, sec),
|acct| clear_fallback(app, acct),
)
}
/// Core decision logic for [`set`], with the keyring and fallback operations
/// injected so the branching is testable without a live OS credential store.
fn set_with(
account: &str,
secret: &str,
keyring_set: impl Fn(&str, &str) -> Result<(), String>,
keyring_get: impl Fn(&str) -> Result<Option<String>, String>,
keyring_delete: impl Fn(&str) -> Result<(), String>,
fallback_set: impl FnOnce(&str, &str) -> Result<(), String>,
fallback_clear: impl FnOnce(&str),
) -> Result<Backend, String> {
match keyring_set(account, secret) {
Ok(()) => match keyring_get(account) {
// The normal path: written and read back byte-for-byte.
@@ -81,7 +121,7 @@ pub fn set(app: &AppHandle, account: &str, secret: &str) -> Result<Backend, Stri
// A machine that was previously degraded and has since been
// fixed must not keep a stale ciphertext shadowing the real
// store on the next read.
clear_fallback(app, account);
fallback_clear(account);
return Ok(Backend::Keyring);
}
Ok(Some(_)) => {
@@ -109,15 +149,28 @@ pub fn set(app: &AppHandle, account: &str, secret: &str) -> Result<Backend, Stri
but the read-back failed: {e} — falling back"
),
},
Err(e) => log::error!("{SERVICE}: credential store write failed for '{account}': {e}"),
Err(e) => {
log::error!("{SERVICE}: credential store write failed for '{account}': {e}");
// An older secret may already sit in the keyring from a prior
// successful write. get() reads the keyring first, so leaving
// that stale entry in place would shadow the fresh secret parked
// in the fallback below — mirrors the read-back-mismatch arm
// above, which purges for the same reason.
if let Err(de) = keyring_delete(account) {
log::warn!(
"{SERVICE}: could not remove a stale keyring entry for '{account}' after a \
failed write: {de}"
);
}
}
}
set_fallback(app, account, secret)?;
fallback_set(account, secret)?;
log::warn!(
"{SERVICE}: account '{account}' is stored in the DPAPI fallback file, not the OS \
"{SERVICE}: account '{account}' is stored in the encrypted fallback file, not the OS \
credential store. See docs/credential-storage.md"
);
Ok(Backend::DpapiFile)
Ok(FALLBACK_BACKEND)
}
/// Load the secret for `account`, or `None` when nothing is stored.
@@ -125,12 +178,34 @@ pub fn set(app: &AppHandle, account: &str, secret: &str) -> Result<Backend, Stri
/// The OS credential store wins over the fallback file, so a machine that
/// recovers goes back to the real store without any migration step.
pub fn get(app: &AppHandle, account: &str) -> Result<Option<String>, String> {
get_with(account, keyring_get, |acct| get_fallback(app, acct))
}
/// Core decision logic for [`get`], with the keyring and fallback lookups
/// injected so the branching is testable without a live OS credential store.
fn get_with(
account: &str,
keyring_get: impl Fn(&str) -> Result<Option<String>, String>,
get_fallback: impl Fn(&str) -> Option<String>,
) -> Result<Option<String>, String> {
match keyring_get(account) {
Ok(Some(secret)) => return Ok(Some(secret)),
Ok(None) => {}
Err(e) => log::warn!("{SERVICE}: credential store read failed for '{account}': {e}"),
Ok(Some(secret)) => Ok(Some(secret)),
Ok(None) => Ok(get_fallback(account)),
Err(e) => {
log::warn!("{SERVICE}: credential store read failed for '{account}': {e}");
// A read error must not collapse to "nothing stored": on a
// healthy machine set() clears the fallback on every successful
// write, so an empty fallback here is indistinguishable from
// "never stored". Prefer a fallback copy if one exists; only
// report "nothing" when both stores genuinely have nothing, and
// otherwise propagate the error so the caller can tell a broken
// store apart from first login.
match get_fallback(account) {
Some(secret) => Ok(Some(secret)),
None => Err(e),
}
}
}
Ok(get_fallback(app, account))
}
/// Remove `account` from every store. Absent entries are not an error.
@@ -218,25 +293,64 @@ fn keyring_delete(account: &str) -> Result<(), String> {
}
// ---------------------------------------------------------------------------
// Degraded-mode fallback (Windows only, DPAPI-protected)
// Degraded-mode fallback (all desktop platforms; sealing differs per OS)
// ---------------------------------------------------------------------------
/// Entropy bound into the DPAPI blob for `account`.
/// Associated data bound into the sealed blob for `account` (the DPAPI
/// "entropy" on Windows, the AEAD AAD elsewhere).
///
/// Including the service and account means a ciphertext lifted from one entry
/// cannot be pasted over another and still decrypt — the identity key for one
/// host cannot be made to load as another's.
#[cfg(windows)]
fn dpapi_entropy(account: &str) -> Vec<u8> {
fn fallback_aad(account: &str) -> Vec<u8> {
format!("{SERVICE}\u{1}{account}").into_bytes()
}
/// Seal `secret` for the fallback store. Windows: DPAPI (user-scoped, OS-held
/// key). Elsewhere: ChaCha20-Poly1305 under the per-install key file.
#[cfg(windows)]
fn protect_secret(_app: &AppHandle, account: &str, secret: &str) -> Result<Vec<u8>, String> {
crate::dpapi::protect(secret.as_bytes(), &fallback_aad(account))
.map_err(|code| format!("DPAPI protect failed (Win32 error {code})"))
}
#[cfg(not(windows))]
fn protect_secret(app: &AppHandle, account: &str, secret: &str) -> Result<Vec<u8>, String> {
use tauri::Manager;
let dir = app
.path()
.app_data_dir()
.map_err(|e| format!("cannot resolve the app data dir for the fallback key: {e}"))?;
let key = crate::fallback_crypto::load_or_create_key(&dir)?;
crate::fallback_crypto::protect(&key, secret.as_bytes(), &fallback_aad(account))
}
/// Open a blob written by [`protect_secret`]. Errors are logged by the caller.
#[cfg(windows)]
fn unprotect_secret(_app: &AppHandle, account: &str, blob: &[u8]) -> Result<Vec<u8>, String> {
crate::dpapi::unprotect(blob, &fallback_aad(account)).map_err(|code| {
format!(
"DPAPI unprotect failed (Win32 error {code}) — the entry was written by a \
different Windows user or on a different machine"
)
})
}
#[cfg(not(windows))]
fn unprotect_secret(app: &AppHandle, account: &str, blob: &[u8]) -> Result<Vec<u8>, String> {
use tauri::Manager;
let dir = app
.path()
.app_data_dir()
.map_err(|e| format!("cannot resolve the app data dir for the fallback key: {e}"))?;
let key = crate::fallback_crypto::load_or_create_key(&dir)?;
crate::fallback_crypto::unprotect(&key, blob, &fallback_aad(account))
}
fn set_fallback(app: &AppHandle, account: &str, secret: &str) -> Result<(), String> {
use base64::Engine as _;
let blob = crate::dpapi::protect(secret.as_bytes(), &dpapi_entropy(account))
.map_err(|code| format!("DPAPI protect failed (Win32 error {code})"))?;
let blob = protect_secret(app, account, secret)?;
let encoded = base64::engine::general_purpose::STANDARD.encode(blob);
let store = app
@@ -258,20 +372,6 @@ fn set_fallback(app: &AppHandle, account: &str, secret: &str) -> Result<(), Stri
Ok(())
}
#[cfg(not(windows))]
fn set_fallback(_app: &AppHandle, account: &str, _secret: &str) -> Result<(), String> {
// Deliberately no file fallback here: see the module header. The Keychain
// and Secret Service are the right stores on these platforms, and a
// plaintext file holding a login password or an identity private key is a
// worse outcome than failing to persist.
Err(format!(
"the OS credential store did not accept '{account}' and there is no fallback store on \
this platform — check that the Keychain (macOS) or a Secret Service provider such as \
gnome-keyring / KWallet (Linux) is running and unlocked"
))
}
#[cfg(windows)]
fn get_fallback(app: &AppHandle, account: &str) -> Option<String> {
use base64::Engine as _;
@@ -287,22 +387,14 @@ fn get_fallback(app: &AppHandle, account: &str) -> Option<String> {
.decode(encoded)
.map_err(|e| log::warn!("credential fallback entry for '{account}' is not base64: {e}"))
.ok()?;
let plaintext = crate::dpapi::unprotect(&blob, &dpapi_entropy(account))
.map_err(|code| {
log::warn!("DPAPI unprotect failed for '{account}' (Win32 error {code}) — the entry \
was written by a different Windows user or on a different machine")
})
let plaintext = unprotect_secret(app, account, &blob)
.map_err(|e| log::warn!("credential fallback entry for '{account}' did not open: {e}"))
.ok()?;
String::from_utf8(plaintext)
.map_err(|_| log::warn!("credential fallback entry for '{account}' is not valid UTF-8"))
.ok()
}
#[cfg(not(windows))]
fn get_fallback(_app: &AppHandle, _account: &str) -> Option<String> {
None
}
/// Drop any fallback copy of `account`. Best-effort: a failure here is logged,
/// never propagated, because it must not mask the outcome of the real store.
fn clear_fallback(app: &AppHandle, account: &str) {
@@ -353,9 +445,9 @@ mod tests {
}
/// Pins the IPC wire format to the variant names, which is what
/// `tauri-typegen` emits into `generated/types.ts` as
/// `type Backend = "Keyring" | "DpapiFile"`. Renaming a variant, or adding
/// a serde rename, desyncs the generated union from the runtime value.
/// `tauri-typegen` emits into `generated/types.ts`. Renaming a variant, or
/// adding a serde rename, desyncs the generated union from the runtime
/// value.
#[test]
fn backend_serializes_as_its_variant_name() {
assert_eq!(
@@ -366,26 +458,113 @@ mod tests {
serde_json::to_string(&Backend::DpapiFile).unwrap(),
"\"DpapiFile\""
);
assert_eq!(
serde_json::to_string(&Backend::EncryptedFile).unwrap(),
"\"EncryptedFile\""
);
}
#[cfg(windows)]
#[test]
fn dpapi_entropy_is_account_specific() {
assert_ne!(dpapi_entropy("host.example"), dpapi_entropy("identity:host.example"));
assert_eq!(dpapi_entropy("host.example"), dpapi_entropy("host.example"));
fn fallback_aad_is_account_specific() {
assert_ne!(fallback_aad("host.example"), fallback_aad("identity:host.example"));
assert_eq!(fallback_aad("host.example"), fallback_aad("host.example"));
}
// -- get_with: finding "a keyring read error must not read as 'not stored'" --
#[test]
fn get_with_falls_back_when_the_keyring_errors_but_the_fallback_has_a_copy() {
let result = get_with(
"identity:chat.example",
|_| Err("keychain locked".to_string()),
|_| Some("fallback-secret".to_string()),
);
assert_eq!(result, Ok(Some("fallback-secret".to_string())));
}
#[test]
fn get_with_propagates_the_keyring_error_when_the_fallback_is_also_empty() {
// The bug: a keyring read failure must never be reported as "nothing
// stored" (Ok(None)) when the fallback is empty too — that is
// indistinguishable from first login, and the E2EE identity keypair
// loader mints and publishes a brand-new identity key on exactly that
// signal, invalidating every peer's TOFU pin.
let result = get_with("identity:chat.example", |_| Err("keychain locked".to_string()), |_| None);
assert_eq!(result, Err("keychain locked".to_string()));
}
#[test]
fn get_with_prefers_the_live_keyring_value_over_the_fallback() {
let result = get_with("acct", |_| Ok(Some("live".to_string())), |_| Some("stale".to_string()));
assert_eq!(result, Ok(Some("live".to_string())));
}
#[test]
fn get_with_uses_the_fallback_when_the_keyring_has_nothing_stored() {
let result = get_with("acct", |_| Ok(None), |_| Some("fallback".to_string()));
assert_eq!(result, Ok(Some("fallback".to_string())));
}
// -- set_with: finding "a failed keyring write must not leave a stale entry" --
#[test]
fn set_with_deletes_any_stale_keyring_entry_when_the_write_fails() {
// The bug: a write failure with an older secret already sitting in
// the keyring from a prior successful write must not leave that
// stale entry in place — get() reads the keyring first, so it would
// shadow the fresh secret parked in the fallback below forever.
use std::cell::Cell;
let delete_called = Cell::new(false);
let result = set_with(
"acct",
"new-secret",
|_, _| Err("write failed".to_string()),
|_| panic!("keyring_get must not run after a failed write"),
|_| {
delete_called.set(true);
Ok(())
},
|_, _| Ok(()),
|_| {},
);
assert_eq!(result, Ok(FALLBACK_BACKEND));
assert!(
delete_called.get(),
"a failed keyring write must delete any stale prior entry before falling back"
);
}
#[test]
fn set_with_returns_keyring_backend_when_the_write_round_trips() {
use std::cell::Cell;
let cleared = Cell::new(false);
let result = set_with(
"acct",
"secret",
|_, s| {
assert_eq!(s, "secret");
Ok(())
},
|_| Ok(Some("secret".to_string())),
|_| panic!("must not delete a keyring entry that round-tripped"),
|_, _| panic!("must not touch the fallback on a successful round trip"),
|_| cleared.set(true),
);
assert_eq!(result, Ok(Backend::Keyring));
assert!(cleared.get(), "a recovered machine must clear any stale fallback copy");
}
#[cfg(windows)]
#[test]
fn dpapi_round_trips_and_rejects_foreign_entropy() {
let secret = b"eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2In0";
let blob = crate::dpapi::protect(secret, &dpapi_entropy("identity:a.example")).unwrap();
let blob = crate::dpapi::protect(secret, &fallback_aad("identity:a.example")).unwrap();
assert_ne!(blob.as_slice(), secret.as_slice(), "blob must not be plaintext");
let back = crate::dpapi::unprotect(&blob, &dpapi_entropy("identity:a.example")).unwrap();
let back = crate::dpapi::unprotect(&blob, &fallback_aad("identity:a.example")).unwrap();
assert_eq!(back, secret);
// A blob moved to another account's slot must not decrypt.
assert!(crate::dpapi::unprotect(&blob, &dpapi_entropy("identity:b.example")).is_err());
assert!(crate::dpapi::unprotect(&blob, &fallback_aad("identity:b.example")).is_err());
}
}
+20 -1
View File
@@ -283,8 +283,13 @@ impl rustls::client::danger::ServerCertVerifier for HostScopedVerifier {
/// Cert-store key for a host. Strips a default `:443` so the ws proxy (which
/// keys off `wss://host` with no explicit 443) and the http/livekit proxies
/// (which see `host:443`) resolve the SAME pin. Non-default ports are kept.
/// Case-folded (DNS names are case-insensitive): the host reaches this from
/// several places (a profile-entered host verbatim, a `wss://` URL, a URL
/// parsed on the TS side, which lowercases) — without folding case here, two
/// callers with the same server in different case would pin/read different
/// entries, opening a second, unpinned proxy tunnel.
pub(crate) fn cert_store_key(host: &str) -> String {
host.strip_suffix(":443").unwrap_or(host).to_string()
host.strip_suffix(":443").unwrap_or(host).to_ascii_lowercase()
}
/// Extract the host (with any non-default port) from a `wss://` URL.
@@ -390,6 +395,20 @@ mod tests {
assert_eq!(cert_store_key("example.com:8443"), "example.com:8443");
}
// DNS names are case-insensitive, but a raw host string (a profile-entered
// host, or one taken verbatim from a wss:// URL) is not normalized before
// reaching here. Two call sites can derive the SAME host in different
// case (e.g. login uses the host as typed, an attachment fetch resolves
// it through URL parsing, which lowercases) — without folding case here,
// they pin/read two different cert-store entries for the same server,
// opening a second, unpinned proxy tunnel.
#[test]
fn cert_store_key_folds_case() {
assert_eq!(cert_store_key("Example.COM"), "example.com");
assert_eq!(cert_store_key("MyServer.LAN:8443"), "myserver.lan:8443");
assert_eq!(cert_store_key("Example.COM:443"), "example.com");
}
#[test]
fn extract_host_variants() {
assert_eq!(extract_host("wss://example.com/chat"), "example.com");
+220 -27
View File
@@ -13,6 +13,7 @@
use futures_util::{SinkExt, StreamExt};
use log::{debug, error, info, warn};
use serde_json::Value;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, Emitter, Runtime};
@@ -32,14 +33,68 @@ use crate::tofu::{self, TofuOutcome};
/// into its closure and clear the sender even after a worker task panic.
pub struct WsState {
tx: Arc<Mutex<Option<mpsc::Sender<String>>>>,
/// Bumped once per `ws_connect` attempt. The handshake can pend for up to
/// CONNECT_TIMEOUT, and callers (profile switch) start a second connect
/// without awaiting the first, so an attempt must prove it is still the
/// current generation before it may touch the shared sender slot.
generation: Arc<AtomicU64>,
}
impl WsState {
pub fn new() -> Self {
Self {
tx: Arc::new(Mutex::new(None)),
generation: Arc::new(AtomicU64::new(0)),
}
}
/// Claim a generation for a new connection attempt, dropping any existing
/// sender. Every later step of that attempt is conditional on this value
/// still being current.
async fn begin_connection(&self) -> u64 {
let mut tx_lock = self.tx.lock().await;
if tx_lock.is_some() {
debug!("[ws_proxy] dropping existing connection");
}
*tx_lock = None;
self.generation.fetch_add(1, Ordering::SeqCst) + 1
}
/// Install `tx` as the live sender if `generation` is still current.
/// Returns false when a newer `ws_connect` superseded this attempt.
async fn install_sender(&self, generation: u64, tx: mpsc::Sender<String>) -> bool {
// Checked under the slot lock so the decision and the write cannot be
// split by a concurrent attempt.
let mut tx_lock = self.tx.lock().await;
if self.generation.load(Ordering::SeqCst) != generation {
return false;
}
*tx_lock = Some(tx);
true
}
}
/// Clear the live sender slot, but only if `my_generation` is still the
/// current connection generation. Returns false when a newer `ws_connect`
/// superseded this connection — that teardown must not clear the slot or
/// announce a close. Ownership is proven by generation, NOT by holding a
/// Sender clone: a clone kept alive in the monitor task would keep the
/// outbound channel open, so the write task could never observe closure
/// after `ws_disconnect` (circular wait — task, socket, and TLS session
/// would all leak). `generation` only advances inside `begin_connection`
/// while the slot lock is held, so checking it under the same lock makes
/// the check-and-clear atomic with respect to new attempts.
async fn clear_sender_if_current(
slot: &Mutex<Option<mpsc::Sender<String>>>,
generation: &AtomicU64,
my_generation: u64,
) -> bool {
let mut tx_lock = slot.lock().await;
if generation.load(Ordering::SeqCst) != my_generation {
return false;
}
*tx_lock = None;
true
}
/// Single call site for ws-state events — keeps tauri-typegen from generating duplicates.
@@ -69,14 +124,8 @@ pub async fn ws_connect<R: Runtime>(
) -> Result<(), String> {
info!("[ws_proxy] connecting to {}", url);
// Drop any existing connection
{
let mut tx_lock = state.tx.lock().await;
if tx_lock.is_some() {
debug!("[ws_proxy] dropping existing connection");
}
*tx_lock = None;
}
// Drop any existing connection and claim this attempt's generation.
let my_generation = state.begin_connection().await;
// Only allow secure WebSocket connections
if !url.starts_with("wss://") {
@@ -169,23 +218,26 @@ pub async fn ws_connect<R: Runtime>(
}
// ── End TOFU check ───────────────────────────────────────────────────
let (mut sink, mut stream) = ws_stream.split();
// Channel for JS → server messages (bounded for backpressure). The slot
// gets the ONLY Sender: teardown ownership is proven by generation, so no
// clone may outlive the slot — one would keep rx.recv() pending forever.
let (tx, mut rx) = mpsc::channel::<String>(256);
if !state.install_sender(my_generation, tx).await {
info!("[ws_proxy] handshake superseded by a newer connect; dropping stale socket");
return Err("superseded by a newer connection".into());
}
info!("[ws_proxy] connected to {}", host);
emit_ws_state(&app, "open");
let (mut sink, mut stream) = ws_stream.split();
// Channel for JS → server messages (bounded for backpressure)
let (tx, mut rx) = mpsc::channel::<String>(256);
{
let mut tx_lock = state.tx.lock().await;
*tx_lock = Some(tx);
}
let app_read = app.clone();
let app_state = app.clone();
// Clone the Arc so the monitoring closure can clear tx on any exit path,
// Clone the Arcs so the monitoring closure can clear tx on any exit path,
// including worker task panics, without needing tauri::State.
let tx_arc = Arc::clone(&state.tx);
let generation_arc = Arc::clone(&state.generation);
// Single outer task owns a JoinSet containing read and write workers.
// join_next() blocks until the first worker finishes (normally or via panic),
@@ -242,14 +294,16 @@ pub async fn ws_connect<R: Runtime>(
}
// Clear the sender so ws_send returns "not connected". This runs on
// every exit path — normal close, graceful disconnect, and panic.
{
let mut tx_lock = tx_arc.lock().await;
*tx_lock = None;
// every exit path — normal close, graceful disconnect, and panic — but
// only when this connection still owns the slot. Clearing
// unconditionally would kill a newer connection's sender and tell JS
// that the live connection had closed.
if clear_sender_if_current(&tx_arc, &generation_arc, my_generation).await {
// Always emit closed, even after a panic.
emit_ws_state(&app_state, "closed");
} else {
debug!("[ws_proxy] superseded connection torn down; leaving live sender in place");
}
// Always emit closed, even after a panic.
emit_ws_state(&app_state, "closed");
});
Ok(())
@@ -281,8 +335,13 @@ pub async fn ws_send(
/// Disconnect the proxy WebSocket.
#[tauri::command]
pub async fn ws_disconnect(state: tauri::State<'_, WsState>) -> Result<(), String> {
let mut tx_lock = state.tx.lock().await;
*tx_lock = None; // dropping the sender closes the channel → write task ends
// begin_connection() both clears the sender slot (dropping it closes the
// channel so the write task ends) AND bumps the generation counter, so a
// handshake still pending from before this disconnect fails install_sender
// instead of installing itself afterward — reusing the same invalidation
// path a superseding connect() already has. The returned generation is
// unused: nothing will ever install under it.
state.begin_connection().await;
Ok(())
}
@@ -427,4 +486,138 @@ mod tests {
let bad = format!("é{}", &VALID[..93]);
assert!(!is_valid_cert_fingerprint(&bad));
}
// ── Connection-generation ownership of the shared sender slot ───────────
//
// A handshake pends up to CONNECT_TIMEOUT, and a profile switch starts a
// second ws_connect without awaiting or cancelling the first, so two
// attempts can be in flight over one slot. Mirrors the ptt.rs
// ATOMICRACE-001 guard.
#[tokio::test]
async fn superseded_connect_does_not_take_the_sender_slot() {
let state = WsState::new();
// Connection A starts its handshake, then a profile switch starts B
// while A is still pending.
let gen_a = state.begin_connection().await;
let gen_b = state.begin_connection().await;
assert_ne!(gen_a, gen_b);
let (tx_b, _rx_b) = mpsc::channel::<String>(4);
assert!(
state.install_sender(gen_b, tx_b.clone()).await,
"the current generation must be able to install"
);
// A's handshake finally completes. Installing now would route the next
// auth send to the stale host and drop B's sender, ending B's write task.
let (tx_a, _rx_a) = mpsc::channel::<String>(4);
assert!(
!state.install_sender(gen_a, tx_a).await,
"a superseded attempt must not take the slot"
);
let slot = state.tx.lock().await;
assert!(
slot.as_ref().is_some_and(|t| t.same_channel(&tx_b)),
"the live connection's sender must still be installed"
);
}
// ── Generation-owned teardown ───────────────────────────────────────────
//
// Teardown ownership must be provable WITHOUT holding a Sender clone: any
// clone kept alive by the monitor task keeps the outbound channel open, so
// after ws_disconnect drops the slot's sender the write task never sees
// rx.recv() == None — writer, reader, and TLS socket all leak in a
// circular wait (monitor waits on writer, writer waits on the monitor's
// clone dropping).
#[tokio::test]
async fn owning_teardown_clears_the_slot_by_generation() {
let state = WsState::new();
let my_generation = state.begin_connection().await;
let (tx, _rx) = mpsc::channel::<String>(4);
state.install_sender(my_generation, tx).await;
assert!(
clear_sender_if_current(&state.tx, &state.generation, my_generation).await,
"the owning connection must clear its slot without a Sender clone"
);
assert!(
state.tx.lock().await.is_none(),
"ws_send must report not-connected after a real close"
);
}
#[tokio::test]
async fn superseded_teardown_by_generation_leaves_the_live_sender() {
let state = WsState::new();
let gen_a = state.begin_connection().await;
let (tx_a, _rx_a) = mpsc::channel::<String>(4);
state.install_sender(gen_a, tx_a).await;
let gen_b = state.begin_connection().await;
let (tx_b, _rx_b) = mpsc::channel::<String>(4);
assert!(state.install_sender(gen_b, tx_b.clone()).await);
// A's monitor task tears down after B is live. Clearing here would
// kill B's sender and emit "closed" while JS believes B is connected.
assert!(
!clear_sender_if_current(&state.tx, &state.generation, gen_a).await,
"a superseded teardown must not clear the slot or announce a close"
);
let slot = state.tx.lock().await;
assert!(
slot.as_ref().is_some_and(|t| t.same_channel(&tx_b)),
"the live connection's sender must survive a superseded teardown"
);
}
#[tokio::test]
async fn disconnect_closes_the_outbound_channel() {
// ws_disconnect's contract (the comment at its *tx_lock = None):
// dropping the slot's sender closes the channel so the write task
// ends. That holds only while install_sender receives the ONLY
// Sender — no teardown-ownership clone may exist.
let state = WsState::new();
let generation = state.begin_connection().await;
let (tx, mut rx) = mpsc::channel::<String>(4);
state.install_sender(generation, tx).await;
*state.tx.lock().await = None; // ws_disconnect
let got = tokio::time::timeout(Duration::from_secs(1), rx.recv())
.await
.expect("write task would hang forever: channel still open after disconnect");
assert_eq!(got, None, "rx.recv() must yield None so the write task exits");
}
// B4_conn_ipc-9: ws_disconnect must invalidate an in-flight ws_connect
// attempt, not just null the sender slot. A handshake can pend for up to
// CONNECT_TIMEOUT (10s) past a disconnect (JS calls connect fire-and-
// forget — logout during "connecting" is a real interleaving), and
// install_sender checks generation alone, so a manual `*tx_lock = None`
// leaves a "cancelled" connection free to install itself afterward and
// spawn its worker tasks against a socket JS believes closed.
#[tokio::test]
async fn disconnect_invalidates_an_in_flight_connect_attempt() {
let state = WsState::new();
// A's handshake is in flight: generation claimed, sender not yet
// installed (mirrors the pending window before install_sender runs).
let gen_a = state.begin_connection().await;
// ws_disconnect fires while A is still mid-handshake — this is
// ws_disconnect's real body (state.begin_connection().await).
state.begin_connection().await;
// A's handshake finally completes and tries to install its sender.
// It must be rejected: JS already believes the connection is closed.
let (tx_a, _rx_a) = mpsc::channel::<String>(4);
assert!(
!state.install_sender(gen_a, tx_a).await,
"a handshake pending during disconnect must not be able to install after it"
);
}
}
@@ -1,6 +1,6 @@
{
"productName": "OwnCord",
"version": "1.1.0-alpha.5",
"version": "1.2.0-alpha.2",
"identifier": "com.owncord.client",
"build": {
"frontendDist": "../dist",
@@ -24,7 +24,7 @@
],
"withGlobalTauri": false,
"security": {
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://ipc.localhost https: wss: http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:*; img-src 'self' https: data:; media-src 'self' blob:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-src https://www.youtube.com https://youtube.com; worker-src 'self' blob:"
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://ipc.localhost https: wss: http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:*; img-src 'self' blob: https: data:; media-src 'self' blob:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-src https://www.youtube.com https://youtube.com; worker-src 'self' blob:"
}
},
"bundle": {
@@ -65,12 +65,6 @@
}
},
"plugins": {
"tauri-typegen": {
"project_path": ".",
"output_path": "../src/generated",
"validation_library": "none",
"verbose": false
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJCQkQ0NzM1MjkxRTlGQTIKUldTaW54NHBOVWU5dTRqYW0yalI5VTJBd0NXOUZwM2UrcDM4YkhCSmlMZWJKWWVXaGJWdHBaSHgK",
"endpoints": [],
@@ -1,9 +1,10 @@
/**
* AdminActions — context menu helpers for admin operations on members and channels.
* Provides confirmation steps for destructive actions (kick, ban, delete).
* Provides confirmation steps for destructive actions (force logout, ban, delete).
*/
import { createElement, appendChildren, setText } from "@lib/dom";
import { appendPurgeSection } from "./purge-prompt";
// ---------------------------------------------------------------------------
// Types
@@ -14,18 +15,51 @@ export interface MemberContextMenuOptions {
username: string;
currentRole: string;
availableRoles: readonly string[];
/** When false, only the non-admin actions (block/unblock) are rendered. */
showAdminActions: boolean;
/**
* Per-action gates, each defaulting to `showAdminActions`. They mirror the
* server's KICK_MEMBERS / BAN_MEMBERS / MANAGE_ROLES bits so a moderator
* sees only the actions its role actually holds. canKick gates "Force
* Logout" — the KICK_MEMBERS bit buys session revocation, not removal.
*/
canKick?: boolean;
canBan?: boolean;
canManageRoles?: boolean;
/** Whether the local user currently blocks this member (labels the toggle). */
isBlocked: boolean;
onToggleBlock(): Promise<void>;
/** Revokes every session the target holds (the "Force Logout" item). */
onKick(): Promise<void>;
/** The reason is stored and displayed by the server; empty means "no reason given". */
onBan(reason: string): Promise<void>;
/**
* The reason is stored and displayed by the server; empty means "no reason
* given". durationHours 0 = permanent, otherwise the ban auto-expires.
*/
onBan(reason: string, durationHours: number): Promise<void>;
onChangeRole(newRole: string): Promise<void>;
}
/** Ban duration choices offered in the ban flow (label → hours; 0 = permanent). */
const BAN_DURATIONS: readonly { readonly label: string; readonly hours: number }[] = [
{ label: "Forever", hours: 0 },
{ label: "1 hour", hours: 1 },
{ label: "1 day", hours: 24 },
{ label: "7 days", hours: 24 * 7 },
{ label: "30 days", hours: 24 * 30 },
] as const;
export interface ChannelContextMenuOptions {
channelId: number;
channelName: string;
onEdit(): void;
onDelete(): Promise<void>;
onCreate(): void;
/**
* Bulk-delete the newest `count` messages. Omitted when the local user's
* role lacks MANAGE_MESSAGES — the section is then not rendered at all,
* mirroring the server's gate.
*/
onPurge?(count: number): Promise<void>;
}
interface ContextMenuResult {
@@ -60,7 +94,7 @@ const CONFIRM_TIMEOUT_MS = 4000;
*
* The armed state auto-disarms after a few seconds so a menu left open doesn't
* turn a stray second click into a ban, and the item shows progress while the
* request is running — a slow kick used to look like nothing happened.
* request is running — a slow force logout used to look like nothing happened.
*/
function withConfirmation(
item: HTMLDivElement,
@@ -130,66 +164,164 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont
const ac = new AbortController();
const menu = createElement("div", { class: "context-menu" });
// Role submenu trigger
const roleItem = createElement(
// Block / Unblock — available to every member, not just admins. Blocking is
// disruptive (kills DMs both ways) so it confirms; unblocking is one click.
const blockItem = createElement(
"div",
{
class: "context-menu__item",
class: options.isBlocked
? "context-menu__item"
: "context-menu__item context-menu__item--danger",
"data-testid": "block-toggle",
},
"Change Role",
options.isBlocked ? "Unblock" : "Block",
);
const roleSub = createElement("div", { class: "context-menu__submenu" });
for (const role of options.availableRoles) {
const cls =
role === options.currentRole
? "context-menu__item context-menu__item--active"
: "context-menu__item";
const roleOption = createMenuItem(
role,
cls,
() => {
if (role !== options.currentRole) {
void options.onChangeRole(role);
}
if (options.isBlocked) {
let unblockRunning = false;
blockItem.addEventListener(
"click",
(e) => {
e.stopPropagation();
if (unblockRunning) return;
unblockRunning = true;
setText(blockItem, "Unblocking...");
blockItem.classList.add("context-menu__item--pending");
const done = (): void => {
unblockRunning = false;
blockItem.classList.remove("context-menu__item--pending");
setText(blockItem, "Unblock");
};
void options.onToggleBlock().then(done, done);
},
{ signal: ac.signal },
);
} else {
withConfirmation(
blockItem,
"Are you sure?",
() => options.onToggleBlock(),
ac.signal,
"Blocking...",
);
roleSub.appendChild(roleOption);
}
roleItem.addEventListener(
"mouseenter",
() => {
roleSub.style.display = "";
},
{ signal: ac.signal },
);
roleItem.addEventListener(
"mouseleave",
() => {
roleSub.style.display = "none";
},
{ signal: ac.signal },
);
const canManageRoles = options.canManageRoles ?? options.showAdminActions;
const canKick = options.canKick ?? options.showAdminActions;
const canBan = options.canBan ?? options.showAdminActions;
roleSub.style.display = "none";
appendChildren(roleItem, roleSub);
menu.appendChild(roleItem);
if (!options.showAdminActions || (!canManageRoles && !canKick && !canBan)) {
menu.appendChild(blockItem);
return {
element: menu,
destroy(): void {
ac.abort();
menu.remove();
},
};
}
// Role submenu trigger
if (canManageRoles) {
const roleItem = createElement(
"div",
{
class: "context-menu__item",
},
"Change Role",
);
const roleSub = createElement("div", { class: "context-menu__submenu" });
// One guard across every option: `currentRole` only updates when the
// member_update echoes, so without it a double-click (or a second option
// clicked while the first PATCH is in flight) fires twice.
let roleChangeRunning = false;
for (const role of options.availableRoles) {
const cls =
role === options.currentRole
? "context-menu__item context-menu__item--active"
: "context-menu__item";
const roleOption = createMenuItem(
role,
cls,
() => {
if (roleChangeRunning || role === options.currentRole) return;
roleChangeRunning = true;
roleOption.classList.add("context-menu__item--pending");
const done = (): void => {
roleChangeRunning = false;
roleOption.classList.remove("context-menu__item--pending");
};
options.onChangeRole(role).then(done, done);
},
ac.signal,
);
roleSub.appendChild(roleOption);
}
roleItem.addEventListener(
"mouseenter",
() => {
roleSub.style.display = "";
},
{ signal: ac.signal },
);
roleItem.addEventListener(
"mouseleave",
() => {
roleSub.style.display = "none";
},
{ signal: ac.signal },
);
roleSub.style.display = "none";
appendChildren(roleItem, roleSub);
menu.appendChild(roleItem);
menu.appendChild(createSeparator());
}
// Force Logout with confirmation. Named for what it does: the server revokes
// the target's sessions (KICK_MEMBERS), it does not remove a membership —
// there is no membership model — so the user can sign straight back in.
if (canKick) {
const kickItem = createElement(
"div",
{
class: "context-menu__item context-menu__item--danger",
"data-testid": "force-logout",
},
"Force Logout",
);
withConfirmation(
kickItem,
"Log them out?",
() => options.onKick(),
ac.signal,
"Logging out...",
);
menu.appendChild(kickItem);
}
if (canBan) appendBanFlow(menu, options, ac.signal);
menu.appendChild(createSeparator());
menu.appendChild(blockItem);
// Kick with confirmation
const kickItem = createElement(
"div",
{
class: "context-menu__item context-menu__item--danger",
},
"Kick",
);
withConfirmation(kickItem, "Are you sure?", () => options.onKick(), ac.signal, "Kicking...");
menu.appendChild(kickItem);
function destroy(): void {
ac.abort();
menu.remove();
}
return { element: menu, destroy };
}
/** Ban entry plus its reason/duration form. Split out so the member menu can
* omit it wholesale for an actor without BAN_MEMBERS. */
function appendBanFlow(
menu: HTMLDivElement,
options: MemberContextMenuOptions,
signal: AbortSignal,
): void {
// Ban — collects the reason the server stores and displays alongside the ban.
const banItem = createElement(
"div",
@@ -210,12 +342,21 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont
"data-testid": "ban-reason-input",
style: "width:100%;font-size:12px",
});
const banDurationSelect = createElement("select", {
class: "form-input",
"data-testid": "ban-duration-select",
style: "width:100%;font-size:12px;margin-top:4px",
});
for (const d of BAN_DURATIONS) {
const opt = createElement("option", { value: String(d.hours) }, d.label);
banDurationSelect.appendChild(opt);
}
const banConfirm = createElement(
"div",
{ class: "context-menu__item context-menu__item--danger", "data-testid": "ban-confirm" },
"Confirm Ban",
);
appendChildren(banReasonRow, banReasonInput, banConfirm);
appendChildren(banReasonRow, banReasonInput, banDurationSelect, banConfirm);
banItem.addEventListener(
"click",
@@ -225,12 +366,16 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont
banReasonRow.style.display = "";
banReasonInput.focus();
},
{ signal: ac.signal },
{ signal },
);
// Typing a reason must not close the menu or trigger the outside-click guard.
banReasonInput.addEventListener("click", (e) => e.stopPropagation(), { signal: ac.signal });
banReasonInput.addEventListener("mousedown", (e) => e.stopPropagation(), { signal: ac.signal });
banReasonInput.addEventListener("click", (e) => e.stopPropagation(), { signal });
banReasonInput.addEventListener("mousedown", (e) => e.stopPropagation(), { signal });
banDurationSelect.addEventListener("click", (e) => e.stopPropagation(), { signal });
banDurationSelect.addEventListener("mousedown", (e) => e.stopPropagation(), {
signal,
});
let banRunning = false;
function submitBan(): void {
@@ -243,7 +388,8 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont
banConfirm.classList.remove("context-menu__item--pending");
setText(banConfirm, "Confirm Ban");
};
void options.onBan(banReasonInput.value.trim()).then(done, done);
const durationHours = Number.parseInt(banDurationSelect.value, 10) || 0;
void options.onBan(banReasonInput.value.trim(), durationHours).then(done, done);
}
banConfirm.addEventListener(
@@ -252,7 +398,7 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont
e.stopPropagation();
submitBan();
},
{ signal: ac.signal },
{ signal },
);
banReasonInput.addEventListener(
"keydown",
@@ -262,17 +408,10 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont
submitBan();
}
},
{ signal: ac.signal },
{ signal },
);
appendChildren(menu, banItem, banReasonRow);
function destroy(): void {
ac.abort();
menu.remove();
}
return { element: menu, destroy };
}
// ---------------------------------------------------------------------------
@@ -314,6 +453,17 @@ export function createChannelContextMenu(options: ChannelContextMenuOptions): Co
withConfirmation(deleteItem, "Are you sure?", () => options.onDelete(), ac.signal, "Deleting...");
menu.appendChild(deleteItem);
const onPurge = options.onPurge;
if (onPurge !== undefined) {
appendPurgeSection(menu, {
itemClass: "context-menu__item",
dangerItemClass: "context-menu__item context-menu__item--danger",
separatorClass: "context-menu__separator",
onPurge: (count) => onPurge(count),
signal: ac.signal,
});
}
function destroy(): void {
ac.abort();
menu.remove();
@@ -8,6 +8,7 @@
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
import type { MountableComponent } from "@lib/safe-render";
export interface CertMismatchModalOptions {
@@ -21,17 +22,27 @@ export interface CertMismatchModalOptions {
export function createCertMismatchModal(options: CertMismatchModalOptions): MountableComponent {
const { host, storedFingerprint, newFingerprint, onAccept, onReject } = options;
let overlay: HTMLDivElement | null = null;
let restoreFocus: (() => void) | null = null;
const ac = new AbortController();
function mount(container: Element): void {
overlay = createElement("div", { class: "modal-overlay visible" });
const modal = createElement("div", { class: "modal" });
// Ids are unique per factory, not per instance — these three trust prompts
// never stack with each other in practice.
applyDialogSemantics(modal, { labelledBy: "cert-mismatch-title" });
trapFocus(modal, ac.signal);
// Header
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "Certificate Warning");
const closeBtn = createElement("button", { class: "modal-close", type: "button" });
const title = createElement("h3", { id: "cert-mismatch-title" }, "Certificate Warning");
const closeBtn = createElement("button", {
class: "modal-close",
type: "button",
// Icon-only control — the aria-label is its entire accessible name.
"aria-label": "Close",
});
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
closeBtn.addEventListener("click", onReject, { signal: ac.signal });
@@ -94,7 +105,18 @@ export function createCertMismatchModal(options: CertMismatchModalOptions): Moun
{ signal: ac.signal },
);
// Escape maps to reject because that is the fail-closed safe default
// (Disconnect) — dismissing a trust prompt must never grant trust.
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape" && overlay?.isConnected === true) onReject();
},
{ signal: ac.signal },
);
container.appendChild(overlay);
restoreFocus = focusDialog(modal);
}
function destroy(): void {
@@ -103,6 +125,8 @@ export function createCertMismatchModal(options: CertMismatchModalOptions): Moun
overlay.remove();
overlay = null;
}
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -124,15 +148,24 @@ export interface CertFirstUseModalOptions {
export function createCertFirstUseModal(options: CertFirstUseModalOptions): MountableComponent {
const { host, fingerprint, onAccept, onReject } = options;
let overlay: HTMLDivElement | null = null;
let restoreFocus: (() => void) | null = null;
const ac = new AbortController();
function mount(container: Element): void {
overlay = createElement("div", { class: "modal-overlay visible" });
const modal = createElement("div", { class: "modal" });
// Unique per factory, not per instance — the three trust prompts never
// stack with each other in practice.
applyDialogSemantics(modal, { labelledBy: "cert-first-use-title" });
trapFocus(modal, ac.signal);
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "New Server Certificate");
const closeBtn = createElement("button", { class: "modal-close", type: "button" });
const title = createElement("h3", { id: "cert-first-use-title" }, "New Server Certificate");
const closeBtn = createElement("button", {
class: "modal-close",
type: "button",
"aria-label": "Close",
});
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
closeBtn.addEventListener("click", onReject, { signal: ac.signal });
@@ -187,7 +220,18 @@ export function createCertFirstUseModal(options: CertFirstUseModalOptions): Moun
{ signal: ac.signal },
);
// Escape rejects (Cancel) — the fail-closed default: never trust a
// certificate because the prompt was dismissed.
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape" && overlay?.isConnected === true) onReject();
},
{ signal: ac.signal },
);
container.appendChild(overlay);
restoreFocus = focusDialog(modal);
}
function destroy(): void {
@@ -196,6 +240,8 @@ export function createCertFirstUseModal(options: CertFirstUseModalOptions): Moun
overlay.remove();
overlay = null;
}
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -223,15 +269,24 @@ export function createIdentityMismatchModal(
): MountableComponent {
const { username, fingerprint, onAccept, onReject } = options;
let overlay: HTMLDivElement | null = null;
let restoreFocus: (() => void) | null = null;
const ac = new AbortController();
function mount(container: Element): void {
overlay = createElement("div", { class: "modal-overlay visible" });
const modal = createElement("div", { class: "modal" });
// Unique per factory, not per instance — the three trust prompts never
// stack with each other in practice.
applyDialogSemantics(modal, { labelledBy: "identity-mismatch-title" });
trapFocus(modal, ac.signal);
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "Identity Warning");
const closeBtn = createElement("button", { class: "modal-close", type: "button" });
const title = createElement("h3", { id: "identity-mismatch-title" }, "Identity Warning");
const closeBtn = createElement("button", {
class: "modal-close",
type: "button",
"aria-label": "Close",
});
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
closeBtn.addEventListener("click", onReject, { signal: ac.signal });
@@ -287,7 +342,18 @@ export function createIdentityMismatchModal(
{ signal: ac.signal },
);
// Escape rejects (Cancel) — the fail-closed default: dismissing the
// prompt must never re-pin the new identity key.
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape" && overlay?.isConnected === true) onReject();
},
{ signal: ac.signal },
);
container.appendChild(overlay);
restoreFocus = focusDialog(modal);
}
function destroy(): void {
@@ -296,6 +362,8 @@ export function createIdentityMismatchModal(
overlay.remove();
overlay = null;
}
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -7,35 +7,38 @@
import { createElement, setText, clearChildren, appendChildren } from "@lib/dom";
import { createIcon, type IconName } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
import {
channelsStore,
getChannelsByCategory,
setActiveChannel,
clearUnread,
} from "@stores/channels.store";
import { channelsStore, getChannelsByCategory } from "@stores/channels.store";
import { navigateToChannel } from "@lib/channel-navigation";
import { markAllRead, unreadChannelIds } from "@lib/read-state";
import { isChannelMuted } from "@lib/channel-mutes";
import { dmStore } from "@stores/dm.store";
import type { Channel } from "@stores/channels.store";
import { authStore, getCurrentUser } from "@stores/auth.store";
import { uiStore, toggleCategory, isCategoryCollapsed } from "@stores/ui.store";
import { voiceStore, getChannelVoiceUsers, getPeerVerification } from "@stores/voice.store";
import type { PeerVerification } from "@stores/voice.store";
import type { PeerVerification, VoiceUser } from "@stores/voice.store";
import { SCREENSHARE_TILE_ID_OFFSET } from "@lib/constants";
import { attachStreamPreview, attachScrollCollapse } from "@lib/streamPreview";
import { showUserVolumeMenu } from "./channel-sidebar/volume-menu";
import { attachChannelContextMenu } from "./channel-sidebar/context-menu";
import { attachDragHandlers, releaseGlobalDragListeners } from "./channel-sidebar/drag-reorder";
import type { VoiceModMenuOptions } from "./channel-sidebar/volume-menu";
import { attachChannelContextMenu, CHANNEL_MUTE_CHANGED } from "./channel-sidebar/context-menu";
import { attachDragHandlers } from "./channel-sidebar/drag-reorder";
import { rePinPeerIdentity } from "@lib/livekitSession";
import { createIdentityMismatchModal } from "./CertMismatchModal";
import { createLogger } from "@lib/logger";
import { membersStore } from "@stores/members.store";
import { roleHasPermission, canManageChannels } from "@lib/permissions";
import { Permission } from "@lib/types";
import { importIdentityPublicKey, computeKeyFingerprint } from "@lib/e2eeCrypto";
const log = createLogger("ChannelSidebar");
/** Icon, color, and tooltip for a peer's E2EE identity verification badge
* (F3 TOFU). The three states mirror the voice store's PeerVerification:
* (F3 TOFU). The states mirror the voice store's PeerVerification:
* a green shield-check when the announce signature verified against the pinned
* key, a muted shield when the peer published no key (legacy), and a red
* shield-alert when the delivered key differs from the pinned one. */
* key, a muted shield when the peer published no key (legacy), a red
* shield-alert when the delivered key differs from the pinned one, and an
* amber shield-question when the local pin store could not be read (DC-08). */
function verifyPresentation(v: PeerVerification): {
icon: IconName;
color: string;
@@ -58,6 +61,15 @@ function verifyPresentation(v: PeerVerification): {
title: "Identity key changed — click to review and re-pin",
};
}
if (v.status === "unknown") {
return {
icon: "shield-question",
color: "var(--yellow, #f0b232)",
title:
"Could not check this participant's identity — key storage is unavailable, " +
"so they are blocked for E2EE until it recovers",
};
}
// "unverified" — the remaining status: peer published no identity key (legacy).
return {
icon: "shield",
@@ -139,9 +151,30 @@ export interface ChannelReorderData {
readonly newPosition: number;
}
/** Moderator actions on another user's voice session. Supplied by the page,
* which owns the WS socket; the sidebar only decides whether to offer them. */
export interface VoiceModerationCallbacks {
readonly onServerMute: (channelId: number, userId: number, muted: boolean) => void;
readonly onServerDeafen: (channelId: number, userId: number, deafened: boolean) => void;
readonly onMove: (userId: number, toChannelId: number) => void;
readonly onDisconnect: (userId: number) => void;
}
/** Whether the signed-in user's role holds MUTE_MEMBERS. The server enforces
* it (and the rank rule the client cannot evaluate); this only decides whether
* the menu is worth offering. Derived through the same helper as the
* member-list moderation gates so the two cannot disagree about who is a
* moderator. */
export function canModerateVoice(): boolean {
const role = getCurrentUser()?.role ?? "";
return roleHasPermission(role, Permission.MUTE_MEMBERS);
}
export interface ChannelSidebarOptions {
readonly onVoiceJoin: (channelId: number) => void;
readonly onVoiceLeave: () => void;
/** Voice moderation wiring; the moderation menu section is hidden without it. */
readonly onVoiceModerate?: VoiceModerationCallbacks;
/** Called when the user clicks the "+" on a category header. */
readonly onCreateChannel?: (category: string) => void;
/** Called when the user right-clicks a channel and selects Edit. */
@@ -150,6 +183,8 @@ export interface ChannelSidebarOptions {
readonly onDeleteChannel?: (channel: Channel) => void;
/** Called when the user drags a channel to a new position. */
readonly onReorderChannel?: (reorders: readonly ChannelReorderData[]) => void;
/** Bulk-delete the newest `count` messages; gated on MANAGE_MESSAGES. */
readonly onPurgeChannel?: (channel: Channel, count: number) => Promise<void>;
/** Called when the user clicks a voice user row to watch their stream. */
readonly onWatchStream?: (userId: number) => void;
}
@@ -164,6 +199,39 @@ function pickAvatarColor(username: string): string {
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length] ?? "#5865f2";
}
/**
* The marker on an age-restricted channel row.
*
* A glyph plus a title rather than a coloured name: the flag is information
* about the channel, and recolouring the name would collide with the unread
* and mention states the row already encodes that way.
*/
function nsfwIndicator(channelId: number): HTMLSpanElement {
const badge = createElement("span", {
class: "ch-nsfw",
"data-testid": `channel-nsfw-${channelId}`,
"aria-label": "Age restricted",
});
badge.title = "Age-restricted channel";
badge.appendChild(createIcon("shield-alert", 13));
return badge;
}
/**
* "3/5" for a voice channel that has a user limit, or null when it is
* unlimited (0) — a count with no ceiling is already shown by the participant
* rows underneath, and "3/0" would read as a bug.
*
* Purely a readout: the server owns capacity and refuses a join over the limit
* with CHANNEL_FULL. The client never blocks the click, because its copy of
* the participant list can lag and a join it refused locally would be a
* mistake nobody could correct.
*/
function voiceCapacityLabel(channel: Channel, connected: number): string | null {
if (channel.voiceMaxUsers <= 0) return null;
return `${connected}/${channel.voiceMaxUsers}`;
}
function renderTextChannelItem(
channel: Channel,
isActive: boolean,
@@ -173,6 +241,7 @@ function renderTextChannelItem(
"channel-item",
isActive ? "active" : "",
channel.unreadCount > 0 ? "unread" : "",
channel.mentionCount > 0 ? "mentioned" : "",
]
.filter(Boolean)
.join(" ");
@@ -190,29 +259,77 @@ function renderTextChannelItem(
appendChildren(item, prefix, name);
if (channel.unreadCount > 0) {
const badge = createElement("span", { class: "unread-badge" }, String(channel.unreadCount));
// Age-restricted marker. Next to the name rather than replacing the "#", so
// the channel still reads as a channel and the mark is visible whether or
// not the reader has already accepted the gate this session.
if (channel.nsfw) {
item.appendChild(nsfwIndicator(channel.id));
}
// A muted channel still counts its unreads — it has not stopped existing,
// it has stopped shouting — so the badge dims rather than disappearing. The
// mention badge is deliberately left alone: a mute silences chatter, never
// something addressed to the reader.
const muted = isChannelMuted(channel.id);
if (muted) {
item.classList.add("muted");
}
// A mention badge outranks the plain unread badge: only one is shown, and
// it counts the mentions, not the messages.
if (channel.mentionCount > 0) {
const badge = createElement(
"span",
{ class: "mention-badge", "data-testid": `channel-mentions-${channel.id}` },
String(channel.mentionCount),
);
badge.title = `${channel.mentionCount} mention${channel.mentionCount === 1 ? "" : "s"}`;
item.appendChild(badge);
} else if (channel.unreadCount > 0) {
const badge = createElement(
"span",
{ class: muted ? "unread-badge muted" : "unread-badge" },
String(channel.unreadCount),
);
item.appendChild(badge);
}
item.addEventListener(
"click",
() => {
setActiveChannel(channel.id);
clearUnread(channel.id);
},
{ signal },
);
item.addEventListener("click", () => navigateToChannel(channel.id), { signal });
return item;
}
/** Moderation section for one participant row, or undefined when the local
* user may not moderate voice (which hides the section entirely). Move targets
* are the other voice channels; the server re-checks that the TARGET may
* connect to the one picked. */
function buildVoiceModOptions(
channelId: number,
user: VoiceUser,
cb?: VoiceModerationCallbacks,
): VoiceModMenuOptions | undefined {
if (cb === undefined || !canModerateVoice()) return undefined;
const moveTargets = Array.from(channelsStore.getState().channels.values())
.filter((ch) => ch.type === "voice" && ch.id !== channelId)
.map((ch) => ({ id: ch.id, name: ch.name }));
return {
serverMuted: user.serverMuted === true,
serverDeafened: user.serverDeafened === true,
moveTargets,
onServerMute: (muted) => cb.onServerMute(channelId, user.userId, muted),
onServerDeafen: (deafened) => cb.onServerDeafen(channelId, user.userId, deafened),
onMove: (toChannelId) => cb.onMove(user.userId, toChannelId),
onDisconnect: () => cb.onDisconnect(user.userId),
};
}
function renderVoiceChannelItem(
channel: Channel,
signal: AbortSignal,
onVoiceJoin: (channelId: number) => void,
onVoiceLeave: () => void,
onWatchStream?: (userId: number) => void,
onVoiceModerate?: VoiceModerationCallbacks,
): HTMLDivElement {
const voiceState = voiceStore.getState();
const isJoined = voiceState.currentChannelId === channel.id;
@@ -243,6 +360,22 @@ function renderVoiceChannelItem(
appendChildren(item, prefix, name);
if (channel.nsfw) {
item.appendChild(nsfwIndicator(channel.id));
}
const voiceUsers = getChannelVoiceUsers(channel.id);
const capacity = voiceCapacityLabel(channel, voiceUsers.length);
if (capacity !== null) {
const badge = createElement(
"span",
{ class: "ch-capacity", "data-testid": `channel-capacity-${channel.id}` },
capacity,
);
badge.title = `${voiceUsers.length} of ${channel.voiceMaxUsers} connected`;
item.appendChild(badge);
}
item.addEventListener(
"click",
() => {
@@ -260,7 +393,6 @@ function renderVoiceChannelItem(
wrapper.appendChild(item);
// Render connected voice users below the channel
const voiceUsers = getChannelVoiceUsers(channel.id);
if (voiceUsers.length > 0) {
const usersContainer = createElement("div", { class: "voice-users-list" });
for (const user of voiceUsers) {
@@ -293,17 +425,26 @@ function renderVoiceChannelItem(
row.appendChild(liveBadge);
}
// A moderator-imposed mute/deafen gets its own class and tooltip: the
// same mic-off glyph would otherwise read as an ordinary self-mute.
if (user.deafened) {
// Deafened: show both mic-off and headphones-off
const muteIcon = createElement("span", { class: "vu-muted" });
const muteIcon = createElement("span", {
class: user.serverMuted === true ? "vu-muted vu-server-muted" : "vu-muted",
});
if (user.serverMuted === true) muteIcon.title = "Muted by a moderator";
muteIcon.appendChild(createIcon("mic-off", 14));
const deafIcon = createElement("span", { class: "vu-muted" });
const deafIcon = createElement("span", {
class: user.serverDeafened === true ? "vu-muted vu-server-muted" : "vu-muted",
});
if (user.serverDeafened === true) deafIcon.title = "Deafened by a moderator";
deafIcon.appendChild(createIcon("headphones-off", 14));
row.appendChild(muteIcon);
row.appendChild(deafIcon);
} else if (user.muted) {
// Muted only: show mic-off
const muteIcon = createElement("span", { class: "vu-muted" });
const muteIcon = createElement("span", {
class: user.serverMuted === true ? "vu-muted vu-server-muted" : "vu-muted",
});
if (user.serverMuted === true) muteIcon.title = "Muted by a moderator";
muteIcon.appendChild(createIcon("mic-off", 14));
row.appendChild(muteIcon);
}
@@ -349,6 +490,7 @@ function renderVoiceChannelItem(
e.clientX,
e.clientY,
signal,
buildVoiceModOptions(channel.id, user, onVoiceModerate),
);
},
{ signal },
@@ -363,6 +505,11 @@ function renderVoiceChannelItem(
// Don't trigger if the right-click menu is open
if (e.button !== 0) return;
e.stopPropagation();
// Watching a stream needs a live LiveKit room -- join first, same
// as the hover/focus preview's placeholder click below.
if (voiceStore.getState().currentChannelId !== channel.id) {
onVoiceJoin(channel.id);
}
const tileId = user.screenshare
? user.userId + SCREENSHARE_TILE_ID_OFFSET
: user.userId;
@@ -419,14 +566,23 @@ function renderChannelItem(
channels?: readonly Channel[],
onReorderChannel?: (reorders: readonly ChannelReorderData[]) => void,
onWatchStream?: (userId: number) => void,
onVoiceModerate?: VoiceModerationCallbacks,
onPurgeChannel?: (channel: Channel, count: number) => Promise<void>,
): HTMLDivElement {
let el: HTMLDivElement;
if (channel.type === "voice") {
el = renderVoiceChannelItem(channel, signal, onVoiceJoin, onVoiceLeave, onWatchStream);
el = renderVoiceChannelItem(
channel,
signal,
onVoiceJoin,
onVoiceLeave,
onWatchStream,
onVoiceModerate,
);
} else {
el = renderTextChannelItem(channel, isActive, signal);
}
attachChannelContextMenu(el, channel, signal, onEditChannel, onDeleteChannel);
attachChannelContextMenu(el, channel, signal, onEditChannel, onDeleteChannel, onPurgeChannel);
if (containerEl !== undefined && channels !== undefined) {
attachDragHandlers(el, channel, containerEl, channels, signal, onReorderChannel);
}
@@ -445,6 +601,8 @@ function renderCategoryGroup(
onDeleteChannel?: (channel: Channel) => void,
onReorderChannel?: (reorders: readonly ChannelReorderData[]) => void,
onWatchStream?: (userId: number) => void,
onVoiceModerate?: VoiceModerationCallbacks,
onPurgeChannel?: (channel: Channel, count: number) => Promise<void>,
): HTMLDivElement {
const group = createElement("div", {});
@@ -462,11 +620,11 @@ function renderCategoryGroup(
appendChildren(header, arrow, label);
if (onCreateChannel !== undefined) {
const user = getCurrentUser();
const role = user?.role?.toLowerCase() ?? "";
const canManageChannels = role === "owner" || role === "admin";
if (canManageChannels) {
// MANAGE_CHANNELS is enforced server-side on /admin/api/channels*, so
// gate on the bit; the role-name check only stands in when the `ready`
// role list has no entry for this role. Same derivation as the channel
// context menu's Edit/Delete items.
if (canManageChannels()) {
const addBtn = createElement(
"span",
{
@@ -514,6 +672,8 @@ function renderCategoryGroup(
channels,
onReorderChannel,
onWatchStream,
onVoiceModerate,
onPurgeChannel,
),
);
}
@@ -536,6 +696,8 @@ function renderCategoryGroup(
channels,
onReorderChannel,
onWatchStream,
onVoiceModerate,
onPurgeChannel,
),
);
}
@@ -554,11 +716,14 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
onDeleteChannel,
onReorderChannel,
onWatchStream,
onVoiceModerate,
onPurgeChannel,
} = options;
const ac = new AbortController();
let root: HTMLDivElement | null = null;
let channelList: HTMLDivElement | null = null;
let serverNameEl: HTMLSpanElement | null = null;
let markAllBtn: HTMLButtonElement | null = null;
const unsubscribers: Array<() => void> = [];
@@ -576,7 +741,15 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
}
}
/** Hide Mark All as Read while nothing is unread — a header button that can
* never do anything is worse than no button. */
function updateMarkAllBtn(): void {
if (markAllBtn === null) return;
markAllBtn.classList.toggle("visible", unreadChannelIds().length > 0);
}
function renderChannels(): void {
updateMarkAllBtn();
if (channelList === null) {
return;
}
@@ -613,6 +786,8 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
onDeleteChannel,
onReorderChannel,
onWatchStream,
onVoiceModerate,
onPurgeChannel,
),
);
}
@@ -620,8 +795,14 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
rebuildVoiceRowCache();
}
/** Redraw when a row's mute is toggled (see CHANNEL_MUTE_CHANGED). */
function handleMuteChanged(): void {
renderChannels();
}
function mount(container: Element): void {
root = createElement("div", { class: "channel-sidebar", "data-testid": "channel-sidebar" });
root.addEventListener(CHANNEL_MUTE_CHANGED, handleMuteChanged, { signal: ac.signal });
// Header
const header = createElement("div", { class: "channel-sidebar-header" });
@@ -629,6 +810,26 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
serverNameEl = createElement("h2", {}, authState.serverName ?? "Server Name");
header.appendChild(serverNameEl);
// Mark All as Read lives on the server header — it is a server-wide action,
// and it only appears while something is actually unread so the header does
// not carry a permanently dead button.
markAllBtn = createElement("button", {
class: "sidebar-mark-all-read",
title: "Mark All as Read",
"aria-label": "Mark All as Read",
"data-testid": "mark-all-read",
});
markAllBtn.appendChild(createIcon("check", 16));
markAllBtn.addEventListener(
"click",
(e: Event) => {
e.stopPropagation();
markAllRead();
},
{ signal: ac.signal },
);
header.appendChild(markAllBtn);
// Channel list
channelList = createElement("div", { class: "channel-list" });
@@ -638,6 +839,10 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
// Initial render
renderChannels();
// DM badges live in dm.store, and Mark All as Read covers them too, so the
// header button's visibility has to track that store as well.
unsubscribers.push(dmStore.subscribeSelector((s) => s.channels, updateMarkAllBtn));
// Subscribe to channels store changes (channels map OR active channel)
const unsubChannelsMap = channelsStore.subscribeSelector(
(s) => s.channels,
@@ -692,7 +897,7 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
// Include the E2EE verification status so a verified↔unverified↔mismatch
// flip re-renders the badge (it lives outside voiceUsers, in peerVerifications).
const verif = state.peerVerifications?.get(uid);
structSig += `:${uid}${u.muted ? "m" : ""}${u.deafened ? "d" : ""}${u.camera ? "c" : ""}${u.screenshare ? "s" : ""}${verif ? `@${verif.status}` : ""}`;
structSig += `:${uid}${u.muted ? "m" : ""}${u.deafened ? "d" : ""}${u.camera ? "c" : ""}${u.screenshare ? "s" : ""}${u.serverMuted === true ? "M" : ""}${u.serverDeafened === true ? "D" : ""}${verif ? `@${verif.status}` : ""}`;
}
}
return structSig;
@@ -717,8 +922,9 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
}
function destroy(): void {
// ac.abort() also releases this sidebar's hold on the shared document-level
// drag listeners (drag-reorder.ts tracks owners by signal).
ac.abort();
releaseGlobalDragListeners(channelList ?? undefined);
for (const unsub of unsubscribers) {
unsub();
}
@@ -730,6 +936,7 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
}
channelList = null;
serverNameEl = null;
markAllBtn = null;
}
return { mount, destroy };
@@ -1,17 +1,25 @@
/**
* CreateChannelModal — modal for creating a new channel under a specific
* category. The channel type is automatically restricted based on the
* category: voice categories only allow voice channels, text categories
* allow text and announcement channels.
* CreateChannelModal — modal for creating a new channel.
*
* The category is an editable text field pre-filled with the group the "+" was
* clicked on, backed by a <datalist> of the categories already in use. It used
* to be read-only, and the channel TYPE was inferred from the category name
* ("voice" anywhere in it meant voice-only), which made every other category
* name second-class: a voice channel could not live under "Gaming", and
* renaming a category silently changed what could be created there. Categories
* are free text and grouping is a display concern, so every type is offered
* under every category — the server agrees (it validates the type alone).
*/
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
import type { ChannelType } from "@lib/types";
import { getKnownCategories, UNCATEGORIZED_VOICE_CATEGORY } from "@stores/channels.store";
export interface CreateChannelModalOptions {
/** The category this channel will be created under. */
/** The category the create affordance was invoked from ("" = uncategorized). */
readonly category: string;
/** Called when the user submits the form. */
readonly onCreate: (data: { name: string; type: ChannelType; category: string }) => Promise<void>;
@@ -19,25 +27,25 @@ export interface CreateChannelModalOptions {
readonly onClose: () => void;
}
/** Returns true if the category name indicates a voice section. */
export function isVoiceCategory(category: string): boolean {
return category.toLowerCase().includes("voice");
}
/** Every channel type is creatable under every category. */
export const CHANNEL_TYPES: readonly ChannelType[] = ["text", "voice", "announcement"] as const;
/** Returns the allowed channel types for a given category. */
export function allowedTypesForCategory(category: string): readonly ChannelType[] {
if (isVoiceCategory(category)) {
return ["voice"] as const;
}
return ["text", "announcement"] as const;
/**
* The type pre-selected for a category. Only a hint for the dropdown's initial
* value — every type stays selectable. The one case worth guessing is the
* synthetic "Voice" fallback group the sidebar puts uncategorized voice
* channels in: creating from its "+" almost certainly means another voice
* channel.
*/
export function defaultTypeForCategory(category: string): ChannelType {
return category === UNCATEGORIZED_VOICE_CATEGORY ? "voice" : "text";
}
export function createCreateChannelModal(options: CreateChannelModalOptions): MountableComponent {
const { category, onCreate, onClose } = options;
const ac = new AbortController();
let overlay: HTMLDivElement | null = null;
const allowedTypes = allowedTypesForCategory(category);
let restoreFocus: (() => void) | null = null;
function mount(container: Element): void {
overlay = createElement("div", {
@@ -46,13 +54,17 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo
});
const modal = createElement("div", { class: "modal" });
applyDialogSemantics(modal, { labelledBy: "create-channel-title" });
trapFocus(modal, ac.signal);
// Header
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "Create Channel");
const title = createElement("h3", { id: "create-channel-title" }, "Create Channel");
// Icon-only button: without a label a screen reader announces just "button".
const closeBtn = createElement("button", {
class: "modal-close",
type: "button",
"aria-label": "Close",
});
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
@@ -62,15 +74,23 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo
// Body
const body = createElement("div", { class: "modal-body" });
// Category (read-only display)
// Category — free text, with the categories already in use as suggestions.
const categoryGroup = createElement("div", { class: "form-group" });
const categoryLabel = createElement("label", { class: "form-label" }, "Category");
const categoryDisplay = createElement("div", {
const categoryInput = createElement("input", {
class: "form-input",
style: "opacity: 0.7; cursor: default;",
type: "text",
list: "create-channel-categories",
autocomplete: "off",
placeholder: "Leave blank for no category",
"data-testid": "channel-category-input",
});
setText(categoryDisplay, category);
appendChildren(categoryGroup, categoryLabel, categoryDisplay);
categoryInput.value = category;
const categoryList = createElement("datalist", { id: "create-channel-categories" });
for (const known of getKnownCategories()) {
categoryList.appendChild(createElement("option", { value: known }));
}
appendChildren(categoryGroup, categoryLabel, categoryInput, categoryList);
// Channel name
const nameGroup = createElement("div", { class: "form-group" });
@@ -78,7 +98,7 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo
const nameInput = createElement("input", {
class: "form-input",
type: "text",
placeholder: isVoiceCategory(category) ? "lounge" : "general",
placeholder: defaultTypeForCategory(category) === "voice" ? "lounge" : "general",
"data-testid": "channel-name-input",
});
appendChildren(nameGroup, nameLabel, nameInput);
@@ -91,10 +111,11 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo
"data-testid": "channel-type-select",
});
for (const t of allowedTypes) {
for (const t of CHANNEL_TYPES) {
const opt = createElement("option", { value: t }, t.charAt(0).toUpperCase() + t.slice(1));
typeSelect.appendChild(opt);
}
typeSelect.value = defaultTypeForCategory(category);
appendChildren(typeGroup, typeLabel, typeSelect);
// Error display
@@ -146,7 +167,7 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo
await onCreate({
name,
type: typeSelect.value as ChannelType,
category,
category: categoryInput.value.trim(),
});
} catch (err) {
errorEl.style.display = "block";
@@ -173,8 +194,25 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo
{ signal: ac.signal },
);
// Escape cancels — never creates. Document-level so it works wherever
// focus sits; guarded on the overlay still being attached because the
// listener lives until destroy() aborts it.
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape" && overlay?.isConnected === true) {
onClose();
}
},
{ signal: ac.signal },
);
container.appendChild(overlay);
// Capture where focus came from before anything inside the dialog takes
// it, so destroy() can hand it back to the opener.
restoreFocus = focusDialog(modal);
// Focus the name input
nameInput.focus();
}
@@ -185,6 +223,11 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo
overlay.remove();
overlay = null;
}
// Every close path (X, Cancel, backdrop, Escape) funnels through the
// caller's onClose, which calls destroy() — the single place focus
// returns to the opener.
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -3,6 +3,7 @@
* Shows channel name and requires explicit confirmation.
*/
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
@@ -18,6 +19,7 @@ export function createDeleteChannelModal(options: DeleteChannelModalOptions): Mo
const { channelName, onConfirm, onClose } = options;
const ac = new AbortController();
let overlay: HTMLDivElement | null = null;
let restoreFocus: (() => void) | null = null;
function mount(container: Element): void {
overlay = createElement("div", {
@@ -26,13 +28,17 @@ export function createDeleteChannelModal(options: DeleteChannelModalOptions): Mo
});
const modal = createElement("div", { class: "modal" });
applyDialogSemantics(modal, { labelledBy: "delete-channel-title" });
trapFocus(modal, ac.signal);
// Header
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "Delete Channel");
const title = createElement("h3", { id: "delete-channel-title" }, "Delete Channel");
// Icon-only button: without a label a screen reader announces just "button".
const closeBtn = createElement("button", {
class: "modal-close",
type: "button",
"aria-label": "Close",
});
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
@@ -109,7 +115,24 @@ export function createDeleteChannelModal(options: DeleteChannelModalOptions): Mo
{ signal: ac.signal },
);
// Escape cancels — it must never stand in for the destructive confirm.
// Document-level so it works wherever focus sits; guarded on the overlay
// still being attached because the listener lives until destroy() aborts it.
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape" && overlay?.isConnected === true) {
onClose();
}
},
{ signal: ac.signal },
);
container.appendChild(overlay);
// Move focus in (lands on the header's close button, safely away from the
// destructive confirm) and remember the opener for destroy() to restore.
restoreFocus = focusDialog(modal);
}
function destroy(): void {
@@ -118,6 +141,11 @@ export function createDeleteChannelModal(options: DeleteChannelModalOptions): Mo
overlay.remove();
overlay = null;
}
// Every close path (X, Cancel, backdrop, Escape) funnels through the
// caller's onClose, which calls destroy() — the single place focus
// returns to the opener.
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -13,7 +13,8 @@
import { createElement, appendChildren, setText } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
import type { UserStatus } from "@lib/types";
import { isSafeUrl } from "./message-list/attachments";
import { isRenderableAvatar } from "@lib/avatar";
import { fetchImageAsDataUrl, resolveServerUrl } from "./message-list/attachments";
// ---------------------------------------------------------------------------
// Types
@@ -31,6 +32,16 @@ export interface DmProfileData {
export interface DmProfileSidebarOptions {
readonly user: DmProfileData;
readonly onClose: () => void;
/**
* The connected server's host, used to scope the note's localStorage key.
* User ids are per-server, so without this a note about user 5 on one
* server is shown for, and overwritten by, the unrelated user 5 on
* another — real in the multi-profile client (see profiles.ts). Optional,
* and falls back to the legacy unscoped key, so a caller that has not
* been updated to pass it yet keeps today's single-profile behavior
* exactly (including any note already saved under the old key).
*/
readonly host?: string;
}
export type DmProfileSidebarComponent = MountableComponent & {
@@ -49,6 +60,9 @@ const STATUS_COLORS: Readonly<Record<UserStatus, string>> = {
online: "#3ba55d",
idle: "#faa61a",
dnd: "#ed4245",
// A DM partner is never invisible from here — the server maps it to offline
// for everyone but its owner — but the map has to be total over UserStatus.
invisible: "#747f8d",
offline: "#747f8d",
};
@@ -56,6 +70,7 @@ const STATUS_LABELS: Readonly<Record<UserStatus, string>> = {
online: "Online",
idle: "Idle",
dnd: "Do Not Disturb",
invisible: "Invisible",
offline: "Offline",
};
@@ -63,17 +78,34 @@ const STATUS_LABELS: Readonly<Record<UserStatus, string>> = {
// Helpers
// ---------------------------------------------------------------------------
function loadNote(userId: number): string {
/** The legacy unscoped key, from before per-server notes (or when the caller
* has not yet been updated to pass a host). */
function legacyNoteKey(userId: number): string {
return NOTE_STORAGE_PREFIX + String(userId);
}
function scopedNoteKey(userId: number, host: string): string {
return `${NOTE_STORAGE_PREFIX}${host}:${userId}`;
}
function loadNote(userId: number, host: string): string {
try {
return localStorage.getItem(NOTE_STORAGE_PREFIX + String(userId)) ?? "";
if (host !== "") {
const scoped = localStorage.getItem(scopedNoteKey(userId, host));
if (scoped !== null) return scoped;
}
// Fall back to the legacy key so a note saved before per-server scoping
// (or while the host was unknown) is not silently lost.
return localStorage.getItem(legacyNoteKey(userId)) ?? "";
} catch {
return "";
}
}
function saveNote(userId: number, text: string): void {
function saveNote(userId: number, host: string, text: string): void {
try {
localStorage.setItem(NOTE_STORAGE_PREFIX + String(userId), text);
const key = host !== "" ? scopedNoteKey(userId, host) : legacyNoteKey(userId);
localStorage.setItem(key, text);
} catch {
// localStorage may be unavailable or full -- silently ignore
}
@@ -88,7 +120,7 @@ export function createDmProfileSidebar(
): DmProfileSidebarComponent {
const ac = new AbortController();
const { signal } = ac;
const { user, onClose } = options;
const { user, onClose, host = "" } = options;
let panel: HTMLDivElement | null = null;
let open = false;
@@ -115,22 +147,31 @@ export function createDmProfileSidebar(
wrapper.style.position = "relative";
wrapper.style.flexShrink = "0";
if (user.avatar !== null && user.avatar.length > 0 && isSafeUrl(user.avatar)) {
wrapper.style.background = "transparent";
const img = createElement("img", {
src: user.avatar,
alt: user.username,
class: "dps-avatar-img",
// The letter draws immediately; the picture (if any) is fetched through
// the same cert-pinned, bearer-token path attachments use and swapped in
// once the bytes arrive. `<img src>` cannot carry the auth header an
// `/api/v1/files/{id}` avatar needs, so the URL is never assigned raw.
wrapper.style.background = "var(--accent, #5865f2)";
const initial = user.username.charAt(0).toUpperCase() || "?";
const letter = createElement("span", {}, initial);
wrapper.appendChild(letter);
if (isRenderableAvatar(user.avatar)) {
const resolved = resolveServerUrl(user.avatar);
void fetchImageAsDataUrl(resolved).then((dataUrl) => {
if (dataUrl === null || !wrapper.isConnected) return;
const img = createElement("img", {
src: dataUrl,
alt: user.username,
class: "dps-avatar-img",
});
img.style.width = "80px";
img.style.height = "80px";
img.style.borderRadius = "50%";
letter.remove();
wrapper.style.background = "transparent";
wrapper.insertBefore(img, wrapper.firstChild);
});
img.style.width = "80px";
img.style.height = "80px";
img.style.borderRadius = "50%";
wrapper.appendChild(img);
} else {
wrapper.style.background = "var(--accent, #5865f2)";
const initial = user.username.charAt(0).toUpperCase() || "?";
const text = createElement("span", {}, initial);
wrapper.appendChild(text);
}
// Status dot overlay
@@ -324,12 +365,12 @@ export function createDmProfileSidebar(
noteInput.style.fontSize = "13px";
noteInput.style.padding = "8px";
noteInput.style.fontFamily = "inherit";
noteInput.value = loadNote(user.id);
noteInput.value = loadNote(user.id, host);
noteInput.addEventListener(
"input",
() => {
saveNote(user.id, noteInput.value);
saveNote(user.id, host, noteInput.value);
},
{ signal },
);
+204 -63
View File
@@ -4,34 +4,66 @@
*
* Uses the `channel-sidebar` container class (shared with channel sidebar)
* and DM-specific classes from app.css: dm-sidebar-header, dm-search,
* dm-nav-item, dm-section-label, dm-add, dm-item, dm-avatar, dm-status,
* dm-section-label, dm-add, dm-item, dm-avatar, dm-status,
* dm-name, dm-close, dm-unread.
*
* Rows are keyed on the DM *channel*, not on a recipient user: a group DM has
* no single recipient, and the same person can be in both a 1:1 and a group
* with you, so a user id no longer identifies a conversation.
*/
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { showContextMenu } from "@lib/context-menu";
import type { MountableComponent } from "@lib/safe-render";
import { isSafeUrl } from "./message-list/attachments";
import { isRenderableAvatar } from "@lib/avatar";
import { fetchImageAsDataUrl, resolveServerUrl } from "./message-list/attachments";
/** One member of a group DM, as far as the sidebar needs to draw them. */
export interface DmParticipant {
readonly id: number;
readonly username: string;
readonly avatar: string | null;
}
export interface DmConversation {
/** The DM channel. The row's identity — see the module comment. */
readonly channelId: number;
/** The other party of a 1:1 DM; for a group, the first participant. */
readonly userId: number;
/** What the row is labelled: a group's name or joined members, else a user. */
readonly username: string;
readonly avatar: string | null;
readonly avatarColor?: string;
readonly status?: "online" | "idle" | "dnd" | "offline";
/** True for a group DM: draws stacked avatars and a participant count. */
readonly isGroup?: boolean;
/** Everyone but the current user. Drives the stack and the count. */
readonly participants?: readonly DmParticipant[];
readonly lastMessage: string;
readonly timestamp: string;
readonly unread: boolean;
/** Unread message count. Drives the numeric badge; a conversation marked
* `unread` with no count still shows the plain dot (older payloads). */
readonly unreadCount?: number;
/** Unread messages here that mention the current user. Outranks the unread
* badge, exactly as it does in the channel list. */
readonly mentionCount?: number;
/** Muted: the unread badge renders dimmed. The mention badge does not —
* a mute silences chatter, never something addressed to you. */
readonly muted?: boolean;
readonly active?: boolean;
}
export interface DmSidebarOptions {
readonly conversations: readonly DmConversation[];
readonly onSelectConversation: (userId: number) => void;
readonly onSelectConversation: (channelId: number) => void;
readonly onNewDm: () => void;
readonly onCloseDm?: (userId: number) => void;
readonly onFriendsClick?: () => void;
readonly friendsActive?: boolean;
/** Close a 1:1 DM / leave a group. The component does not distinguish —
* which one it is is the server's call, and the label says so. */
readonly onCloseDm?: (channelId: number) => void;
readonly onToggleMute?: (channelId: number) => void;
readonly onRenameGroup?: (channelId: number) => void;
readonly onBack?: () => void;
readonly serverName?: string;
}
@@ -43,67 +75,146 @@ const STATUS_COLORS: Record<string, string> = {
offline: "var(--text-micro)",
};
/**
* Fill one avatar circle: the letter immediately, the picture swapped in once
* fetched. `<img src>` cannot carry the bearer token an authenticated
* `/api/v1/files/{id}` avatar needs, so the URL is always fetched through the
* same cert-pinned path attachments and custom emoji use rather than assigned
* directly.
*/
function paintAvatar(el: HTMLElement, avatar: string | null, label: string): void {
setText(el, label.charAt(0).toUpperCase());
if (!isRenderableAvatar(avatar)) return;
const resolved = resolveServerUrl(avatar);
void fetchImageAsDataUrl(resolved).then((dataUrl) => {
if (dataUrl === null || !el.isConnected) return;
const img = createElement("img", { src: dataUrl, alt: label });
img.style.width = "100%";
img.style.height = "100%";
img.style.borderRadius = "50%";
el.textContent = "";
el.appendChild(img);
});
}
/**
* The avatar block for a row: one circle for a 1:1 DM with a presence dot, or
* two overlapping circles for a group.
*
* A group deliberately gets no presence dot — "is this group online" has no
* answer, and showing the first member's would be a fact about one person
* presented as a fact about the conversation.
*/
function buildAvatar(convo: DmConversation): HTMLDivElement {
const avatarBg = convo.avatarColor ?? "#5865F2";
if (convo.isGroup === true) {
const stack = createElement("div", {
class: "dm-avatar dm-avatar-stack",
"data-testid": `dm-avatar-stack-${convo.channelId}`,
});
const shown = (convo.participants ?? []).slice(0, 2);
// An empty group (every other member has left) still needs a mark, so fall
// back to the row's own label rather than rendering an empty circle.
const faces = shown.length > 0 ? shown : [{ id: 0, username: convo.username, avatar: null }];
faces.forEach((p, i) => {
const face = createElement("div", { class: `dm-avatar-face dm-avatar-face-${i}` });
face.style.background = avatarBg;
paintAvatar(face, p.avatar, p.username);
stack.appendChild(face);
});
return stack;
}
const avatar = createElement("div", { class: "dm-avatar" });
avatar.style.background = avatarBg;
paintAvatar(avatar, convo.avatar, convo.username);
const statusKey = convo.status ?? "offline";
const statusDot = createElement("span", { class: "dm-status" });
statusDot.style.background = STATUS_COLORS[statusKey] ?? "var(--text-micro)";
avatar.appendChild(statusDot);
return avatar;
}
function renderDmItem(
convo: DmConversation,
onSelect: (userId: number) => void,
onClose: ((userId: number) => void) | undefined,
options: DmSidebarOptions,
signal: AbortSignal,
): HTMLDivElement {
const item = createElement("div", { class: "dm-item" });
if (convo.active === true) {
item.classList.add("active");
}
if (convo.muted === true) {
item.classList.add("muted");
}
item.dataset.channelId = String(convo.channelId);
item.dataset.userId = String(convo.userId);
// Avatar with status dot
const avatarBg = convo.avatarColor ?? "#5865F2";
const avatar = createElement("div", { class: "dm-avatar" });
avatar.style.background = avatarBg;
const avatar = buildAvatar(convo);
if (convo.avatar !== null && isSafeUrl(convo.avatar)) {
const img = createElement("img", {
src: convo.avatar,
alt: convo.username,
});
img.style.width = "100%";
img.style.height = "100%";
img.style.borderRadius = "50%";
avatar.appendChild(img);
} else {
setText(avatar, convo.username.charAt(0).toUpperCase());
}
// Status indicator dot
const statusKey = convo.status ?? "offline";
const statusDot = createElement("span", { class: "dm-status" });
statusDot.style.background = STATUS_COLORS[statusKey] ?? "var(--text-micro)";
avatar.appendChild(statusDot);
// Username
const name = createElement("span", { class: "dm-name" }, convo.username);
// Close button (hidden by default, shown on hover via CSS)
appendChildren(item, avatar, name);
// Participant count, groups only: the label may be a name that says nothing
// about size, and "who else is in here" is the first thing you want to know.
if (convo.isGroup === true) {
const count = (convo.participants ?? []).length + 1;
const countEl = createElement(
"span",
{ class: "dm-member-count", "data-testid": `dm-members-${convo.channelId}` },
String(count),
);
countEl.title = `${count} members`;
item.appendChild(countEl);
}
// Close / leave button (hidden by default, shown on hover via CSS)
const closeBtn = createElement("button", {
class: "dm-close",
title: "Close DM",
title: convo.isGroup === true ? "Leave group" : "Close DM",
});
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
closeBtn.addEventListener(
"click",
(e: Event) => {
e.stopPropagation();
if (onClose !== undefined) {
onClose(convo.userId);
}
options.onCloseDm?.(convo.channelId);
},
{ signal },
);
item.appendChild(closeBtn);
appendChildren(item, avatar, name, closeBtn);
// Unread dot
if (convo.unread) {
// A mention badge outranks the unread badge, which in turn outranks the bare
// dot — the dot is only what is left when the payload carries no counts.
//
// A muted conversation dims the unread badge but NOT the mention badge: the
// whole point of Discord's mute is that things addressed to you still get
// through, so dimming both would make a mute unsafe to use.
const mentionCount = convo.mentionCount ?? 0;
const unreadCount = convo.unreadCount ?? 0;
if (mentionCount > 0) {
const badge = createElement(
"span",
{ class: "dm-mention-badge", "data-testid": `dm-mentions-${convo.channelId}` },
String(mentionCount),
);
badge.title = `${mentionCount} mention${mentionCount === 1 ? "" : "s"}`;
item.appendChild(badge);
} else if (unreadCount > 0) {
const badge = createElement(
"span",
{
class: convo.muted === true ? "dm-unread-badge muted" : "dm-unread-badge",
"data-testid": `dm-unread-${convo.channelId}`,
},
String(unreadCount),
);
badge.title = `${unreadCount} unread message${unreadCount === 1 ? "" : "s"}`;
item.appendChild(badge);
} else if (convo.unread) {
const unreadDot = createElement("span", { class: "dm-unread" });
item.appendChild(unreadDot);
}
@@ -118,7 +229,43 @@ function renderDmItem(
}
}
item.classList.add("active");
onSelect(convo.userId);
options.onSelectConversation(convo.channelId);
},
{ signal },
);
item.addEventListener(
"contextmenu",
(e: MouseEvent) => {
e.preventDefault();
const items = [];
if (options.onToggleMute !== undefined) {
const toggle = options.onToggleMute;
items.push({
label: convo.muted === true ? "Unmute Conversation" : "Mute Conversation",
testId: `dm-mute-${convo.channelId}`,
onClick: () => toggle(convo.channelId),
});
}
if (convo.isGroup === true && options.onRenameGroup !== undefined) {
const rename = options.onRenameGroup;
items.push({
label: "Rename Group",
testId: `dm-rename-${convo.channelId}`,
onClick: () => rename(convo.channelId),
});
}
if (options.onCloseDm !== undefined) {
const close = options.onCloseDm;
items.push({
label: convo.isGroup === true ? "Leave Group" : "Close DM",
danger: true,
testId: `dm-close-${convo.channelId}`,
onClick: () => close(convo.channelId),
});
}
if (items.length === 0) return;
showContextMenu({ x: e.clientX, y: e.clientY, items, signal, className: "dm-context-menu" });
},
{ signal },
);
@@ -141,7 +288,7 @@ export function createDmSidebar(options: DmSidebarOptions): MountableComponent {
class: "dm-back-header",
"data-testid": "dm-back-header",
});
const arrow = createElement("span", { class: "dm-back-arrow" }, "\u2190");
const arrow = createElement("span", { class: "dm-back-arrow" }, "");
const backInfo = createElement("div", { class: "dm-back-info" });
const backTitle = createElement(
"div",
@@ -163,22 +310,6 @@ export function createDmSidebar(options: DmSidebarOptions): MountableComponent {
});
header.appendChild(searchInput);
// Friends nav item
const friendsNav = createElement("div", { class: "dm-nav-item" });
if (options.friendsActive === true) {
friendsNav.classList.add("active");
}
setText(friendsNav, "Friends");
friendsNav.addEventListener(
"click",
() => {
if (options.onFriendsClick !== undefined) {
options.onFriendsClick();
}
},
{ signal: ac.signal },
);
// Section label with + button
const sectionLabel = createElement("div", { class: "dm-section-label" });
setText(sectionLabel, "Direct Messages");
@@ -195,11 +326,21 @@ export function createDmSidebar(options: DmSidebarOptions): MountableComponent {
(a, b) => (b.unread ? 1 : 0) - (a.unread ? 1 : 0),
);
const items = sorted.map((convo) =>
renderDmItem(convo, options.onSelectConversation, options.onCloseDm, ac.signal),
const items = sorted.map((convo) => renderDmItem(convo, options, ac.signal));
searchInput.addEventListener(
"input",
() => {
const q = searchInput.value.trim().toLowerCase();
items.forEach((el, i) => {
const match = q === "" || sorted[i]!.username.toLowerCase().includes(q);
el.style.display = match ? "" : "none";
});
},
{ signal: ac.signal },
);
appendChildren(root, header, friendsNav, sectionLabel, ...items);
appendChildren(root, header, sectionLabel, ...items);
container.appendChild(root);
}
@@ -1,11 +1,102 @@
/**
* EditChannelModal — modal for editing an existing channel's name and topic.
* Only visible to admin/owner users.
* EditChannelModal — modal for editing an existing channel's name, topic,
* category, slow mode, NSFW flag and (for voice channels) its capacity limits.
* Mounted only for actors holding MANAGE_CHANNELS; the server enforces the same
* bit on the PATCH behind it.
*
* Category is free text with a <datalist> of the categories already in use:
* moving a channel between groups is a rename, not a recreate, and no category
* name is special (a voice channel groups under whatever it carries).
*
* Slow mode is a preset <select> rather than a number box. The server accepts
* any value in 0…21600, but the useful values are a short list, and a free
* number field mostly produces typos ("300" meant as minutes) that only surface
* when a member cannot post for five hours. A stored value outside the presets
* — set through the admin panel, which does offer a free number — is kept and
* shown as its own option rather than being silently rounded to a neighbour.
*/
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
import { getKnownCategories } from "@stores/channels.store";
/** The server's ceiling for `slow_mode`, mirrored so the UI cannot exceed it. */
export const MAX_SLOW_MODE_SECONDS = 21600;
/** The server's ceiling for both voice capacity limits. */
export const MAX_VOICE_LIMIT = 99;
/** Slow-mode presets, in seconds. 0 = off. */
const SLOW_MODE_PRESETS: readonly { readonly seconds: number; readonly label: string }[] = [
{ seconds: 0, label: "Off" },
{ seconds: 5, label: "5 seconds" },
{ seconds: 10, label: "10 seconds" },
{ seconds: 15, label: "15 seconds" },
{ seconds: 30, label: "30 seconds" },
{ seconds: 60, label: "1 minute" },
{ seconds: 120, label: "2 minutes" },
{ seconds: 300, label: "5 minutes" },
{ seconds: 600, label: "10 minutes" },
{ seconds: 900, label: "15 minutes" },
{ seconds: 1800, label: "30 minutes" },
{ seconds: 3600, label: "1 hour" },
{ seconds: 7200, label: "2 hours" },
{ seconds: 21600, label: "6 hours" },
] as const;
/** Human label for a second count, for a value that is off the preset list. */
export function formatSlowMode(seconds: number): string {
const preset = SLOW_MODE_PRESETS.find((p) => p.seconds === seconds);
if (preset !== undefined) return preset.label;
if (seconds % 3600 === 0) {
const hours = seconds / 3600;
return `${hours} hour${hours === 1 ? "" : "s"}`;
}
if (seconds % 60 === 0) {
const minutes = seconds / 60;
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
}
return `${seconds} seconds`;
}
/**
* Clamp a value into the server's accepted slow-mode range.
* Applied to the STORED value as well as the submitted one, so a row carrying
* something out of range still opens the modal on a legal option.
*/
export function clampSlowMode(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(MAX_SLOW_MODE_SECONDS, Math.max(0, Math.trunc(value)));
}
/**
* Clamp a voice limit into the server's accepted range.
*
* A `<input type="number" max>` is advisory — typing past it, or pasting, still
* produces the larger value — so the bound is applied here rather than trusting
* the attribute and letting the server 400 a form the user had no way to fix.
*/
export function clampVoiceLimit(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(MAX_VOICE_LIMIT, Math.max(0, Math.trunc(value)));
}
/** The fields an edit submits. Mirrors the PATCH body. */
export interface EditChannelData {
readonly name: string;
readonly topic: string;
readonly category: string;
readonly slow_mode: number;
readonly nsfw: boolean;
/**
* Only present for a voice channel. A text channel's PATCH omits them
* entirely rather than sending 0, so an edit here cannot wipe limits the
* channel carries.
*/
readonly voice_max_users?: number;
readonly voice_max_video?: number;
}
export interface EditChannelModalOptions {
/** Current channel ID. */
@@ -14,16 +105,62 @@ export interface EditChannelModalOptions {
readonly channelName: string;
/** Current channel type (displayed, not editable). */
readonly channelType: string;
/** Current channel topic ("" = none). */
readonly channelTopic?: string;
/** Current channel category ("" = uncategorized). */
readonly channelCategory?: string;
/** Current cooldown in seconds (0 = off). */
readonly channelSlowMode?: number;
/** Whether the channel is currently flagged age-restricted. */
readonly channelNsfw?: boolean;
/** Current voice capacity limits (0 = unlimited). Voice channels only. */
readonly channelVoiceMaxUsers?: number;
readonly channelVoiceMaxVideo?: number;
/** Called when the user saves changes. */
readonly onSave: (data: { name: string }) => Promise<void>;
readonly onSave: (data: EditChannelData) => Promise<void>;
/** Called when the modal is closed. */
readonly onClose: () => void;
}
/** A labelled number input constrained to 0…MAX_VOICE_LIMIT. */
function buildVoiceLimitField(
labelText: string,
hintText: string,
testId: string,
value: number,
): { group: HTMLDivElement; input: HTMLInputElement } {
const group = createElement("div", { class: "form-group" });
const label = createElement("label", { class: "form-label" }, labelText);
const input = createElement("input", {
class: "form-input",
type: "number",
min: "0",
max: String(MAX_VOICE_LIMIT),
"data-testid": testId,
});
input.value = String(clampVoiceLimit(value));
const hint = createElement("div", { class: "form-hint" }, hintText);
appendChildren(group, label, input, hint);
return { group, input };
}
export function createEditChannelModal(options: EditChannelModalOptions): MountableComponent {
const { channelName, channelType, onSave, onClose } = options;
const {
channelName,
channelType,
channelTopic,
channelCategory,
channelSlowMode,
channelNsfw,
channelVoiceMaxUsers,
channelVoiceMaxVideo,
onSave,
onClose,
} = options;
const isVoice = channelType === "voice";
const ac = new AbortController();
let overlay: HTMLDivElement | null = null;
let restoreFocus: (() => void) | null = null;
function mount(container: Element): void {
overlay = createElement("div", {
@@ -32,13 +169,17 @@ export function createEditChannelModal(options: EditChannelModalOptions): Mounta
});
const modal = createElement("div", { class: "modal" });
applyDialogSemantics(modal, { labelledBy: "edit-channel-title" });
trapFocus(modal, ac.signal);
// Header
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "Edit Channel");
const title = createElement("h3", { id: "edit-channel-title" }, "Edit Channel");
// Icon-only button: without a label a screen reader announces just "button".
const closeBtn = createElement("button", {
class: "modal-close",
type: "button",
"aria-label": "Close",
});
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
@@ -70,14 +211,119 @@ export function createEditChannelModal(options: EditChannelModalOptions): Mounta
nameInput.value = channelName;
appendChildren(nameGroup, nameLabel, nameInput);
// Channel topic (optional, shown in the chat header)
const topicGroup = createElement("div", { class: "form-group" });
const topicLabel = createElement("label", { class: "form-label" }, "Topic");
const topicInput = createElement("input", {
class: "form-input",
type: "text",
placeholder: "What's this channel about? (optional)",
maxlength: "1024",
"data-testid": "edit-channel-topic-input",
});
topicInput.value = channelTopic ?? "";
appendChildren(topicGroup, topicLabel, topicInput);
// Channel category (free text, suggestions from the categories in use)
const categoryGroup = createElement("div", { class: "form-group" });
const categoryLabel = createElement("label", { class: "form-label" }, "Category");
const categoryInput = createElement("input", {
class: "form-input",
type: "text",
list: "edit-channel-categories",
autocomplete: "off",
placeholder: "Leave blank for no category",
"data-testid": "edit-channel-category-input",
});
categoryInput.value = channelCategory ?? "";
const categoryList = createElement("datalist", { id: "edit-channel-categories" });
for (const known of getKnownCategories()) {
categoryList.appendChild(createElement("option", { value: known }));
}
appendChildren(categoryGroup, categoryLabel, categoryInput, categoryList);
// Slow mode (presets; a stored off-preset value keeps its own option)
const currentSlowMode = clampSlowMode(channelSlowMode ?? 0);
const slowGroup = createElement("div", { class: "form-group" });
const slowLabel = createElement("label", { class: "form-label" }, "Slow Mode");
const slowSelect = createElement("select", {
class: "form-input",
"data-testid": "edit-channel-slowmode-select",
});
const choices = SLOW_MODE_PRESETS.some((p) => p.seconds === currentSlowMode)
? [...SLOW_MODE_PRESETS]
: [
...SLOW_MODE_PRESETS,
{ seconds: currentSlowMode, label: formatSlowMode(currentSlowMode) },
];
for (const choice of choices.toSorted((a, b) => a.seconds - b.seconds)) {
const opt = createElement("option", { value: String(choice.seconds) }, choice.label);
if (choice.seconds === currentSlowMode) opt.selected = true;
slowSelect.appendChild(opt);
}
const slowHint = createElement(
"div",
{ class: "form-hint" },
"Members must wait this long between messages. Holders of Manage Messages are exempt.",
);
appendChildren(slowGroup, slowLabel, slowSelect, slowHint);
// NSFW flag. The copy states the limit of the feature: the server does not
// filter anything, so promising otherwise here would be a lie.
const nsfwGroup = createElement("div", { class: "form-group" });
const nsfwLabelRow = createElement("label", { class: "form-check" });
const nsfwInput = createElement("input", {
type: "checkbox",
"data-testid": "edit-channel-nsfw-checkbox",
});
nsfwInput.checked = channelNsfw === true;
const nsfwText = createElement("span", {}, "Age-restricted (NSFW)");
appendChildren(nsfwLabelRow, nsfwInput, nsfwText);
const nsfwHint = createElement(
"div",
{ class: "form-hint" },
"Members see a one-time warning each session before opening the channel, and the channel is marked in the sidebar. Nothing is filtered.",
);
appendChildren(nsfwGroup, nsfwLabelRow, nsfwHint);
appendChildren(body, typeGroup, nameGroup, topicGroup, categoryGroup, slowGroup, nsfwGroup);
// Voice-only section. Rendered for a voice channel alone: the columns exist
// on every row, but on a text channel they are values nothing reads, and
// offering them would imply an enforcement that does not happen.
let maxUsersInput: HTMLInputElement | null = null;
let maxVideoInput: HTMLInputElement | null = null;
if (isVoice) {
const voiceSection = createElement("div", {
class: "form-section",
"data-testid": "edit-channel-voice-section",
});
const voiceHeading = createElement("div", { class: "form-section-title" }, "Voice Limits");
const users = buildVoiceLimitField(
"User Limit",
"How many members may be connected at once. 0 = unlimited.",
"edit-channel-max-users-input",
channelVoiceMaxUsers ?? 0,
);
const video = buildVoiceLimitField(
"Video Limit",
"How many may have a camera or screen share on at once. 0 = unlimited.",
"edit-channel-max-video-input",
channelVoiceMaxVideo ?? 0,
);
maxUsersInput = users.input;
maxVideoInput = video.input;
appendChildren(voiceSection, voiceHeading, users.group, video.group);
body.appendChild(voiceSection);
}
// Error display
const errorEl = createElement("div", {
class: "form-group",
style: "color: var(--red); font-size: 13px; display: none;",
"data-testid": "edit-channel-error",
});
appendChildren(body, typeGroup, nameGroup, errorEl);
body.appendChild(errorEl);
// Footer
const footer = createElement("div", { class: "modal-footer" });
@@ -114,8 +360,22 @@ export function createEditChannelModal(options: EditChannelModalOptions): Mounta
saveBtn.setAttribute("disabled", "true");
setText(saveBtn, "Saving...");
const data: EditChannelData = {
name,
topic: topicInput.value.trim(),
category: categoryInput.value.trim(),
slow_mode: clampSlowMode(Number.parseInt(slowSelect.value, 10)),
nsfw: nsfwInput.checked,
...(maxUsersInput !== null
? { voice_max_users: clampVoiceLimit(Number.parseInt(maxUsersInput.value, 10)) }
: {}),
...(maxVideoInput !== null
? { voice_max_video: clampVoiceLimit(Number.parseInt(maxVideoInput.value, 10)) }
: {}),
};
try {
await onSave({ name });
await onSave(data);
} catch (err) {
errorEl.style.display = "block";
setText(errorEl, err instanceof Error ? err.message : "Failed to update channel");
@@ -141,7 +401,25 @@ export function createEditChannelModal(options: EditChannelModalOptions): Mounta
{ signal: ac.signal },
);
// Escape cancels — never saves. Document-level so it works wherever focus
// sits; guarded on the overlay still being attached because the listener
// lives until destroy() aborts it.
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape" && overlay?.isConnected === true) {
onClose();
}
},
{ signal: ac.signal },
);
container.appendChild(overlay);
// Capture where focus came from before anything inside the dialog takes
// it, so destroy() can hand it back to the opener.
restoreFocus = focusDialog(modal);
nameInput.focus();
nameInput.select();
}
@@ -152,6 +430,11 @@ export function createEditChannelModal(options: EditChannelModalOptions): Mounta
overlay.remove();
overlay = null;
}
// Every close path (X, Cancel, backdrop, Escape) funnels through the
// caller's onClose, which calls destroy() — the single place focus
// returns to the opener.
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -0,0 +1,163 @@
/**
* EmojiAutocomplete — inline emoji picker the composer opens on ":".
*
* Deliberately the same shape as MentionAutocomplete (setQuery / handleKeydown
* / destroy, mousedown-to-choose, arrow-key navigation): the composer drives
* both through one code path, and a user who has learned one has learned the
* other.
*
* Two sources in one list: the server's custom emoji, which insert their
* `:shortcode:` text, and the built-in unicode set, which inserts the character
* itself. Custom emoji come first — they are the ones a shortcode is really
* for, and there are far fewer of them.
*
* Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
*/
import { createElement, setText } from "@lib/dom";
import { EMOJI_NAMES } from "@components/EmojiPicker";
import { buildCustomEmojiImage } from "@components/message-list/custom-emoji";
import { listCustomEmoji, type CustomEmoji } from "@stores/emoji.store";
import {
createInlineAutocomplete,
type InlineAutocompleteComponent,
} from "@components/inline-autocomplete";
/** Maximum rows shown at once — the popup is a shortcut, not the picker. */
export const MAX_EMOJI_SUGGESTIONS = 10;
/**
* The shortest query that opens the popup. One character after the colon would
* match most of the unicode set and fire on ordinary prose ("note: a thing").
*/
export const MIN_EMOJI_QUERY = 2;
export interface EmojiSuggestion {
/** Row label — the shortcode, or the unicode emoji's primary name. */
readonly label: string;
/** Text inserted into the composer, replacing the `:query` under the caret. */
readonly insert: string;
/** Secondary line: the remaining keywords, or the literal token for custom. */
readonly detail: string;
readonly kind: "custom" | "unicode";
/** The character to show as the preview, or null for a custom emoji image. */
readonly char: string | null;
/** The custom emoji this row stands for, or null for a unicode one. */
readonly emoji: CustomEmoji | null;
}
export interface EmojiAutocompleteOptions {
/** Called with the text to insert (`:wave:` or a unicode character). */
readonly onSelect: (insert: string) => void;
readonly onClose: () => void;
/**
* Composer textarea the popup completes for; carries combobox semantics and
* aria-activedescendant while the popup is open (see inline-autocomplete).
*/
readonly comboboxInput?: HTMLElement;
}
/** Same shape as the shared inline-autocomplete widget. */
export type EmojiAutocompleteComponent = InlineAutocompleteComponent;
function byLabel(a: EmojiSuggestion, b: EmojiSuggestion): number {
return a.label.localeCompare(b.label);
}
/** The preview cell for one row: the custom emoji's image, or the character. */
function buildPreview(s: EmojiSuggestion): HTMLSpanElement {
const preview = createElement("span", { class: "ea-preview" });
if (s.emoji !== null) preview.appendChild(buildCustomEmojiImage(s.emoji));
else setText(preview, s.char ?? "");
return preview;
}
/**
* Suggestions for `query`, in the order the popup lists them: custom emoji
* first (prefix matches before substring), then unicode, alphabetical within
* each group.
*
* A query shorter than MIN_EMOJI_QUERY yields nothing at all, so the composer
* never opens a popup over a lone colon.
*/
export function filterEmojiSuggestions(query: string): EmojiSuggestion[] {
const q = query.toLowerCase();
if (q.length < MIN_EMOJI_QUERY) return [];
const customPrefix: EmojiSuggestion[] = [];
const customSubstring: EmojiSuggestion[] = [];
for (const emoji of listCustomEmoji()) {
const name = emoji.shortcode;
if (!name.includes(q)) continue;
const entry: EmojiSuggestion = {
label: name,
insert: `:${name}:`,
detail: "Server emoji",
kind: "custom",
char: null,
emoji,
};
if (name.startsWith(q)) customPrefix.push(entry);
else customSubstring.push(entry);
}
const unicodePrefix: EmojiSuggestion[] = [];
const unicodeSubstring: EmojiSuggestion[] = [];
for (const [char, keywords] of Object.entries(EMOJI_NAMES)) {
if (!keywords.includes(q)) continue;
const words = keywords.split(" ");
const primary = words[0] ?? keywords;
const entry: EmojiSuggestion = {
label: primary,
insert: char,
detail: words.slice(1).join(" "),
kind: "unicode",
char,
emoji: null,
};
// "Prefix" means some whole keyword starts with the query, not just the
// primary one — typing ":fire" should rank 🔥 ("fire hot flame lit") above
// an emoji that merely contains "fire" mid-word.
if (words.some((w) => w.startsWith(q))) unicodePrefix.push(entry);
else unicodeSubstring.push(entry);
}
customPrefix.sort(byLabel);
customSubstring.sort(byLabel);
unicodePrefix.sort(byLabel);
unicodeSubstring.sort(byLabel);
return [...customPrefix, ...customSubstring, ...unicodePrefix, ...unicodeSubstring].slice(
0,
MAX_EMOJI_SUGGESTIONS,
);
}
/** One emoji row: preview cell, `:label:`/name, and a keyword detail line. */
function renderEmojiRow(s: EmojiSuggestion): HTMLElement[] {
const name = createElement("span", { class: "ma-name" });
setText(name, s.kind === "custom" ? `:${s.label}:` : s.label);
const detail = createElement("span", { class: "ma-detail" });
setText(detail, s.detail);
return [buildPreview(s), name, detail];
}
export function createEmojiAutocomplete(
options: EmojiAutocompleteOptions,
): EmojiAutocompleteComponent {
return createInlineAutocomplete<EmojiSuggestion>({
// Shares the base class deliberately (the composer test selects
// `.mention-autocomplete:not(.emoji-autocomplete)` to distinguish them).
rootClass: "mention-autocomplete emoji-autocomplete",
rootTestId: "emoji-autocomplete",
filter: filterEmojiSuggestions,
valueOf: (s) => s.insert,
rowTestId: (s) => `emoji-option-${s.label}`,
renderRow: renderEmojiRow,
// Unlike mentions, emoji stay empty until the composer types past
// MIN_EMOJI_QUERY, so there is nothing to prime on create.
onSelect: options.onSelect,
onClose: options.onClose,
comboboxInput: options.comboboxInput,
});
}
@@ -2,6 +2,8 @@
// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
import { createElement, setText, clearChildren } from "@lib/dom";
import { enableRovingNavigation, setRovingTabindex } from "@lib/a11y";
import { buildCustomEmojiNode } from "@components/message-list/custom-emoji";
// ---------------------------------------------------------------------------
// Types
@@ -13,11 +15,19 @@ export interface CustomEmoji {
}
export interface EmojiPickerOptions {
/**
* The server's custom emoji, shown as a "Server" category above the unicode
* ones. Selecting one inserts its `:shortcode:` — the composer sends text,
* and the renderer turns that text back into the image.
*/
readonly customEmoji?: readonly CustomEmoji[];
readonly onSelect: (emoji: string) => void;
readonly onClose: () => void;
}
/** The category label the server's own emoji appear under. */
export const SERVER_CATEGORY = "Server";
// ---------------------------------------------------------------------------
// Built-in emoji data (common subset by category)
// ---------------------------------------------------------------------------
@@ -274,8 +284,14 @@ const CATEGORIES: readonly EmojiCategory[] = [
},
];
/** Emoji name lookup for search. Maps emoji character → searchable keywords. */
const EMOJI_NAMES: Readonly<Record<string, string>> = {
/**
* Emoji name lookup for search. Maps emoji character → searchable keywords.
*
* Exported because the composer's `:` autocomplete searches the same list the
* picker does — two independently-maintained name tables would mean typing
* `:fire` and searching "fire" disagreeing about what exists.
*/
export const EMOJI_NAMES: Readonly<Record<string, string>> = {
"😀": "grinning face happy smile",
"😃": "smiley face happy smile",
"😄": "smile happy grin",
@@ -544,21 +560,26 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
header.appendChild(searchInput);
root.appendChild(header);
// Scrollable content area (holds category labels + grids)
// Scrollable content area (holds category labels + grids). Announced as a
// single flat listbox — the category grids are visual grouping only, and
// roving tabindex (DC-13) treats every .ep-emoji cell as one list.
const scrollArea = createElement("div", {
style: "overflow-y: auto; max-height: 320px;",
role: "listbox",
"aria-label": "Emoji",
});
root.appendChild(scrollArea);
enableRovingNavigation(scrollArea, ".ep-emoji", signal);
// Build categories with recent + custom
function getAllCategories(): readonly EmojiCategory[] {
const recent = getRecentEmoji();
const cats: EmojiCategory[] = [{ name: "Recent", emoji: recent }];
// Custom server emoji
// The server's own emoji, as the `:shortcode:` tokens a message carries.
if (options.customEmoji && options.customEmoji.length > 0) {
cats.push({
name: "Custom",
name: SERVER_CATEGORY,
emoji: options.customEmoji.map((e) => `:${e.shortcode}:`),
});
}
@@ -581,8 +602,21 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
const span = createElement("span", {
class: "ep-emoji",
title: emoji,
role: "option",
// Mirrors the title (the character or :shortcode: token) — e2e specs
// select cells by title, so the accessible name must never diverge.
"aria-label": emoji,
});
setText(span, emoji);
// A `:shortcode:` entry shows its image; everything else is the character
// itself. An unresolvable shortcode falls back to the text, which is what
// it would render as in a message anyway.
const image = buildCustomEmojiNode(emoji);
if (image !== null) {
span.classList.add("ep-emoji-custom");
span.appendChild(image);
} else {
setText(span, emoji);
}
span.addEventListener("click", () => handleEmojiClick(emoji), { signal });
return span;
}
@@ -628,6 +662,10 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
);
scrollArea.appendChild(empty);
}
// Every render rebuilds the cell set, so the single Tab stop must be
// re-established or filtering would leave zero tabbable cells.
setRovingTabindex(scrollArea, ".ep-emoji");
}
// Initial render
@@ -1,231 +0,0 @@
// Step 8.59 — File upload component with drag-and-drop, preview, and progress.
// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
/** Default allowed MIME types for file uploads. */
const DEFAULT_ALLOWED_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/avif",
"video/mp4",
"video/webm",
"audio/mpeg",
"audio/ogg",
"audio/wav",
"application/pdf",
"text/plain",
];
export interface FileUploadOptions {
readonly onUpload: (file: File) => Promise<void>;
readonly maxSizeMb?: number;
readonly allowedMimeTypes?: readonly string[];
}
const DEFAULT_MAX_SIZE_MB = 10;
export type FileUploadComponent = MountableComponent & { openPicker(): void };
export function createFileUpload(options: FileUploadOptions): FileUploadComponent {
const maxBytes = (options.maxSizeMb ?? DEFAULT_MAX_SIZE_MB) * 1024 * 1024;
const ac = new AbortController();
const signal = ac.signal;
let root: HTMLDivElement | null = null;
let dropzone: HTMLDivElement;
let fileInput: HTMLInputElement;
let preview: HTMLDivElement;
let thumb: HTMLImageElement;
let nameSpan: HTMLSpanElement;
let sizeSpan: HTMLSpanElement;
let progressBar: HTMLDivElement;
let cancelBtn: HTMLButtonElement;
let errorDiv: HTMLDivElement;
let uploadAbort: AbortController | null = null;
// oxlint-disable-next-line consistent-function-scoping -- co-located with its sole caller for readability
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function showError(message: string): void {
setText(errorDiv, message);
errorDiv.classList.remove("file-upload__error--hidden");
preview.classList.add("file-upload__preview--hidden");
}
function resetPreview(): void {
preview.classList.add("file-upload__preview--hidden");
thumb.src = "";
thumb.style.display = "none";
setText(nameSpan, "");
setText(sizeSpan, "");
progressBar.style.width = "0%";
uploadAbort = null;
errorDiv.classList.add("file-upload__error--hidden");
}
function showPreview(file: File): void {
resetPreview();
setText(nameSpan, file.name);
setText(sizeSpan, formatSize(file.size));
if (file.type.startsWith("image/")) {
const url = URL.createObjectURL(file);
thumb.src = url;
thumb.style.display = "block";
thumb.addEventListener("load", () => URL.revokeObjectURL(url));
}
preview.classList.remove("file-upload__preview--hidden");
}
async function handleFile(file: File): Promise<void> {
errorDiv.classList.add("file-upload__error--hidden");
const allowed = options.allowedMimeTypes ?? DEFAULT_ALLOWED_TYPES;
if (file.type && !allowed.includes(file.type)) {
showError(`File type "${file.type}" is not allowed.`);
return;
}
if (file.size > maxBytes) {
showError(
`File too large (${formatSize(file.size)}). Max ${options.maxSizeMb ?? DEFAULT_MAX_SIZE_MB} MB.`,
);
return;
}
showPreview(file);
uploadAbort = new AbortController();
try {
progressBar.style.width = "50%";
await options.onUpload(file);
progressBar.style.width = "100%";
setTimeout(() => resetPreview(), 1500);
} catch (err) {
if (uploadAbort?.signal.aborted) return;
showError(err instanceof Error ? err.message : "Upload failed");
resetPreview();
}
}
function buildDom(): void {
root = createElement("div", { class: "file-upload" });
dropzone = createElement("div", {
class: "file-upload__dropzone file-upload__dropzone--hidden",
});
appendChildren(
dropzone,
createElement("span", { class: "file-upload__droptext" }, "Drop files here"),
);
const allowed = options.allowedMimeTypes ?? DEFAULT_ALLOWED_TYPES;
fileInput = createElement("input", {
class: "file-upload__input",
type: "file",
accept: allowed.join(","),
});
fileInput.style.display = "none";
preview = createElement("div", { class: "file-upload__preview file-upload__preview--hidden" });
thumb = createElement("img", { class: "file-upload__thumb" });
thumb.style.display = "none";
thumb.alt = "";
nameSpan = createElement("span", { class: "file-upload__name" });
sizeSpan = createElement("span", { class: "file-upload__size" });
const progressContainer = createElement("div", { class: "file-upload__progress" });
progressBar = createElement("div", { class: "file-upload__progress-bar" });
progressBar.style.width = "0%";
appendChildren(progressContainer, progressBar);
cancelBtn = createElement("button", { class: "file-upload__cancel", type: "button" });
cancelBtn.appendChild(createIcon("x", 14));
appendChildren(preview, thumb, nameSpan, sizeSpan, progressContainer, cancelBtn);
errorDiv = createElement("div", { class: "file-upload__error file-upload__error--hidden" });
appendChildren(root, dropzone, fileInput, preview, errorDiv);
}
function attachListeners(): void {
fileInput.addEventListener(
"change",
() => {
const file = fileInput.files?.[0];
if (file) {
void handleFile(file);
fileInput.value = "";
}
},
{ signal },
);
cancelBtn.addEventListener(
"click",
() => {
if (uploadAbort !== null) uploadAbort.abort();
resetPreview();
},
{ signal },
);
let dragCounter = 0;
root!.addEventListener(
"dragenter",
(e) => {
e.preventDefault();
dragCounter++;
dropzone.classList.remove("file-upload__dropzone--hidden");
},
{ signal },
);
root!.addEventListener(
"dragleave",
(e) => {
e.preventDefault();
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
dropzone.classList.add("file-upload__dropzone--hidden");
}
},
{ signal },
);
root!.addEventListener("dragover", (e) => e.preventDefault(), { signal });
root!.addEventListener(
"drop",
(e) => {
e.preventDefault();
dragCounter = 0;
dropzone.classList.add("file-upload__dropzone--hidden");
const file = e.dataTransfer?.files[0];
if (file) void handleFile(file);
},
{ signal },
);
}
function mount(container: Element): void {
buildDom();
attachListeners();
container.appendChild(root!);
}
function destroy(): void {
ac.abort();
if (uploadAbort !== null) uploadAbort.abort();
root?.remove();
root = null;
}
function openPicker(): void {
fileInput.click();
}
return { mount, destroy, openPicker };
}
@@ -3,6 +3,7 @@
// innerHTML with user content.
import { createElement, setText, clearChildren } from "@lib/dom";
import { enableRovingNavigation, setRovingTabindex } from "@lib/a11y";
import { ApiClientError } from "@lib/api";
import { searchGifs, getTrendingGifs } from "@lib/gifProvider";
import type { GifApi, GifResult } from "@lib/gifProvider";
@@ -67,9 +68,15 @@ export function createGifPicker(options: GifPickerOptions): {
root.appendChild(header);
// Grid area (scrollable)
const gridArea = createElement("div", { class: "gp-grid-area" });
// Grid area (scrollable). Announced as a flat listbox of GIF options with
// roving tabindex (DC-13); the inner .gp-grid is layout only.
const gridArea = createElement("div", {
class: "gp-grid-area",
role: "listbox",
"aria-label": "GIFs",
});
root.appendChild(gridArea);
enableRovingNavigation(gridArea, ".gp-item", signal);
// Loading indicator
const loadingEl = createElement("div", { class: "gp-loading" });
@@ -92,7 +99,13 @@ export function createGifPicker(options: GifPickerOptions): {
const grid = createElement("div", { class: "gp-grid" });
for (const gif of gifs) {
const item = createElement("div", { class: "gp-item" });
const item = createElement("div", {
class: "gp-item",
role: "option",
// Same fallback as the img alt below — an untitled GIF still needs a
// pronounceable accessible name.
"aria-label": gif.title || "GIF",
});
const img = createElement("img", {
class: "gp-img",
src: gif.url,
@@ -114,6 +127,9 @@ export function createGifPicker(options: GifPickerOptions): {
}
gridArea.appendChild(grid);
// Each render replaces the cell set, so re-establish the single Tab stop.
setRovingTabindex(gridArea, ".gp-item");
}
function showLoading(): void {
@@ -0,0 +1,97 @@
/**
* IncomingCallBanner — the toast-like strip that appears when somebody rings a
* DM you are in.
*
* It is deliberately a banner and not a modal: a ring is an offer, not a
* demand, and a modal would block the app until the 30s timer expired. Accept
* joins the DM's voice channel; Decline tells the ringer to stop.
*
* All state lives in @lib/call-ring — this only draws whatever it is handed.
*/
import { createElement, appendChildren, setText } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
import type { RingState } from "@lib/call-ring";
export interface IncomingCallBannerOptions {
readonly onAccept: () => void;
readonly onDecline: () => void;
}
export interface IncomingCallBannerComponent extends MountableComponent {
/** Show the banner for a ring, or hide it with null. */
readonly setRing: (state: RingState | null) => void;
}
export function createIncomingCallBanner(
options: IncomingCallBannerOptions,
): IncomingCallBannerComponent {
const ac = new AbortController();
const root = createElement("div", {
class: "incoming-call-banner",
role: "alert",
"data-testid": "incoming-call-banner",
});
root.style.display = "none";
const icon = createElement("div", { class: "incoming-call-icon" });
icon.appendChild(createIcon("phone", 20));
const info = createElement("div", { class: "incoming-call-info" });
const title = createElement("div", {
class: "incoming-call-title",
"data-testid": "incoming-call-title",
});
const subtitle = createElement("div", { class: "incoming-call-subtitle" }, "Incoming call");
appendChildren(info, title, subtitle);
const acceptBtn = createElement(
"button",
{
class: "btn btn-primary incoming-call-accept",
type: "button",
"data-testid": "incoming-call-accept",
},
"Accept",
);
acceptBtn.addEventListener("click", () => options.onAccept(), { signal: ac.signal });
const declineBtn = createElement(
"button",
{
class: "btn btn-danger incoming-call-decline",
type: "button",
"data-testid": "incoming-call-decline",
},
"Decline",
);
declineBtn.addEventListener("click", () => options.onDecline(), { signal: ac.signal });
const actions = createElement("div", { class: "incoming-call-actions" });
appendChildren(actions, acceptBtn, declineBtn);
appendChildren(root, icon, info, actions);
function setRing(state: RingState | null): void {
if (state === null) {
root.style.display = "none";
setText(title, "");
return;
}
// setText, never innerHTML: the username is user-controlled.
setText(title, `${state.fromUsername} is calling`);
root.style.display = "";
}
return {
mount(container: Element): void {
container.appendChild(root);
},
destroy(): void {
ac.abort();
root.remove();
},
setRing,
};
}
@@ -3,6 +3,7 @@
* Create, copy, and revoke invite codes.
*/
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
import { createElement, appendChildren, clearChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
@@ -56,6 +57,7 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom
let root: HTMLDivElement | null = null;
let listEl: HTMLDivElement | null = null;
let emptyEl: HTMLDivElement | null = null;
let restoreFocus: (() => void) | null = null;
let invites: readonly InviteItem[] = options.invites;
function renderList(): void {
@@ -161,11 +163,14 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom
const modal = createElement("div", {
class: "modal",
});
applyDialogSemantics(modal, { labelledBy: "invite-manager-title" });
trapFocus(modal, ac.signal);
// Header
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "Server Invites");
const closeBtn = createElement("button", { class: "modal-close" });
const title = createElement("h3", { id: "invite-manager-title" }, "Server Invites");
// Icon-only button: without a label a screen reader announces just "button".
const closeBtn = createElement("button", { class: "modal-close", "aria-label": "Close" });
closeBtn.appendChild(createIcon("x", 14));
closeBtn.addEventListener("click", () => options.onClose(), { signal: ac.signal });
appendChildren(header, title, closeBtn);
@@ -236,6 +241,10 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom
renderList();
container.appendChild(root);
// Capture where focus came from before anything inside the dialog takes
// it, so destroy() can hand it back to the opener.
restoreFocus = focusDialog(modal);
}
function destroy(): void {
@@ -246,6 +255,10 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom
}
listEl = null;
emptyEl = null;
// Every close path (X, backdrop, Escape) funnels through the caller's
// onClose, which calls destroy() — the single place focus returns.
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
+229 -41
View File
@@ -1,24 +1,47 @@
/**
* MemberList component — shows server members grouped by role with online status.
* Subscribes to membersStore for reactive updates.
* Right-click context menu for admin actions (kick, ban, role change).
* Right-click context menu for admin actions (force logout, ban, role change).
*/
import { createElement, appendChildren, clearChildren, setText } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
import { Disposable } from "@lib/disposable";
import { membersStore, type Member, type MembersState } from "@stores/members.store";
import {
membersStore,
memberDisplayName,
type Member,
type MembersState,
} from "@stores/members.store";
import { authStore } from "@stores/auth.store";
import { channelsStore } from "@stores/channels.store";
import { blocksStore } from "@stores/blocks.store";
import { channelsStore, type ChannelsState } from "@stores/channels.store";
import { createMemberContextMenu } from "@components/AdminActions";
import type { UserStatus } from "@lib/types";
import {
createUserProfilePopup,
type UserProfilePopupComponent,
} from "@components/UserProfilePopup";
import { Permission, type ReadyRole, type UserStatus } from "@lib/types";
import { roleHasPermission } from "@lib/permissions";
import { createAvatarElement } from "@lib/avatar";
/** Options for configuring admin action callbacks on the member list. */
export interface MemberListOptions {
/** Role name of the signed-in user; resolved against the server's role list
* to get the permission mask that gates the moderation menu items. */
readonly currentUserRole: string;
/** Force logout: revokes the target's sessions (KICK_MEMBERS). */
readonly onKick: (userId: number, username: string) => Promise<void>;
readonly onBan: (userId: number, username: string, reason: string) => Promise<void>;
readonly onBan: (
userId: number,
username: string,
reason: string,
durationHours: number,
) => Promise<void>;
readonly onChangeRole: (userId: number, username: string, newRole: string) => Promise<void>;
readonly onToggleBlock: (userId: number, username: string, block: boolean) => Promise<void>;
/** Start a DM with a user (wires the profile popup's Message button). */
readonly onMessageUser?: (userId: number) => void;
}
/** Roles offered in the "Change Role" submenu when the server hasn't sent any. */
@@ -36,18 +59,69 @@ function assignableRoleNames(): readonly string[] {
return roles.length > 0 ? roles : FALLBACK_ASSIGNABLE_ROLES;
}
/** Ordered role groups with display names and CSS color variables. */
const ROLE_GROUPS: readonly {
/** Which moderation menu items the signed-in user may see. */
interface ModerationGates {
readonly canKick: boolean;
readonly canBan: boolean;
readonly canManageRoles: boolean;
}
/**
* Menu items the signed-in user's role permits, from the permission mask the
* server ships in `ready`. Administrator implies all three. When the role name
* has no match in that list (pre-`ready`, or an older server that sent none)
* the legacy owner/admin name check stands in — a mask of 0 would otherwise
* hide moderation from every actual admin.
*/
function moderationGates(roleName: string): ModerationGates {
return {
canKick: roleHasPermission(roleName, Permission.KICK_MEMBERS),
canBan: roleHasPermission(roleName, Permission.BAN_MEMBERS),
canManageRoles: roleHasPermission(roleName, Permission.MANAGE_ROLES),
};
}
interface RoleGroup {
readonly role: string;
readonly label: string;
readonly colorVar: string;
}[] = [
{ role: "owner", label: "OWNER", colorVar: "var(--role-owner, #e74c3c)" },
{ role: "admin", label: "ADMIN", colorVar: "var(--role-admin, #f39c12)" },
{ role: "moderator", label: "MODERATOR", colorVar: "var(--role-mod, #2ecc71)" },
{ role: "member", label: "MEMBER", colorVar: "var(--role-member, #949ba4)" },
}
/** Theme-variable fallbacks for the seeded roles (used when the server sends no color). */
const FALLBACK_ROLE_COLORS: Record<string, string> = {
owner: "var(--role-owner, #e74c3c)",
admin: "var(--role-admin, #f39c12)",
moderator: "var(--role-mod, #2ecc71)",
};
const MEMBER_COLOR = "var(--role-member, #949ba4)";
/** Ordered role groups used when the server hasn't sent a role list. */
const FALLBACK_ROLE_GROUPS: readonly RoleGroup[] = [
{ role: "owner", label: "OWNER", colorVar: FALLBACK_ROLE_COLORS["owner"]! },
{ role: "admin", label: "ADMIN", colorVar: FALLBACK_ROLE_COLORS["admin"]! },
{ role: "moderator", label: "MODERATOR", colorVar: FALLBACK_ROLE_COLORS["moderator"]! },
{ role: "member", label: "MEMBER", colorVar: MEMBER_COLOR },
] as const;
/**
* Role groups from the server's `ready` role list (already ordered by position,
* highest first), colored by the server's role color when set. A hardcoded
* list rendered custom roles nowhere and ignored `roles.color` entirely.
*/
function roleGroups(): readonly RoleGroup[] {
const roles = channelsStore.getState().roles;
if (roles.length === 0) return FALLBACK_ROLE_GROUPS;
return roles.map((r) => {
const key = r.name.toLowerCase();
return {
role: key,
label: r.name.toUpperCase(),
colorVar: r.color ?? FALLBACK_ROLE_COLORS[key] ?? MEMBER_COLOR,
};
});
}
/** Status priority for sorting: lower = higher priority (shown first). */
function statusPriority(status: UserStatus): number {
switch (status) {
@@ -57,6 +131,10 @@ function statusPriority(status: UserStatus): number {
return 1;
case "dnd":
return 2;
// "invisible" only ever describes the signed-in user (the server shows
// everyone else offline), and it sorts with offline because that is where
// they appear to everybody — including, in this list, to themselves.
case "invisible":
case "offline":
return 3;
default:
@@ -72,6 +150,7 @@ function statusColor(status: UserStatus): string {
return "var(--yellow)";
case "dnd":
return "var(--red)";
case "invisible":
case "offline":
return "var(--text-micro)";
default:
@@ -79,7 +158,13 @@ function statusColor(status: UserStatus): string {
}
}
/** True for the statuses that render a member as "not here". */
function isAwayStatus(status: UserStatus): boolean {
return status === "offline" || status === "invisible";
}
let activeMenu: { element: HTMLDivElement; destroy(): void } | null = null;
let activePopup: UserProfilePopupComponent | null = null;
function closeActiveMenu(): void {
if (activeMenu !== null) {
@@ -88,6 +173,13 @@ function closeActiveMenu(): void {
}
}
function closeActivePopup(): void {
if (activePopup !== null) {
activePopup.destroy?.();
activePopup = null;
}
}
function handleOutsideClick(e: MouseEvent): void {
if (activeMenu !== null && !activeMenu.element.contains(e.target as Node)) {
closeActiveMenu();
@@ -102,15 +194,13 @@ function createMemberItem(
signal: AbortSignal,
): HTMLDivElement {
const item = createElement("div", {
class: member.status === "offline" ? "member-item offline" : "member-item",
class: isAwayStatus(member.status) ? "member-item offline" : "member-item",
"data-testid": `member-${member.id}`,
});
const initial = member.username.charAt(0).toUpperCase() || "?";
const avatar = createElement(
"div",
{ class: "mi-avatar", style: `background: ${colorVar}` },
initial,
const avatar = createAvatarElement(
{ username: member.username, displayName: member.displayName, avatar: member.avatar },
{ className: "mi-avatar", background: colorVar },
);
const statusDot = createElement("div", {
@@ -121,10 +211,54 @@ function createMemberItem(
});
avatar.appendChild(statusDot);
// Name + custom status stack. The custom status is only rendered when there
// is one, so a member without it keeps the single-line row it always had.
const nameWrap = createElement("div", { class: "mi-text" });
const name = createElement("span", { class: "mi-name", style: `color: ${colorVar}` });
setText(name, member.username);
setText(name, memberDisplayName(member));
nameWrap.appendChild(name);
const custom = member.customStatus;
if (typeof custom === "string" && custom.length > 0) {
const customEl = createElement("span", {
class: "mi-custom-status",
"data-testid": `member-custom-status-${member.id}`,
});
setText(customEl, custom);
nameWrap.appendChild(customEl);
}
appendChildren(item, avatar, name);
appendChildren(item, avatar, nameWrap);
// Left-click opens the profile popup (previously dead code — built and
// tested but never mounted from anywhere).
item.addEventListener(
"click",
(e) => {
closeActiveMenu();
closeActivePopup();
const currentUserId = authStore.getState().user?.id ?? 0;
const isSelf = member.id === currentUserId;
const onMessageUser = opts.onMessageUser;
activePopup = createUserProfilePopup({
user: {
id: member.id,
username: member.username,
avatar: member.avatar,
role: member.role,
status: member.status,
displayName: member.displayName,
customStatus: member.customStatus,
},
anchorX: e.clientX,
anchorY: e.clientY,
...(isSelf || onMessageUser === undefined
? {}
: { onMessage: (userId: number) => onMessageUser(userId) }),
});
activePopup.mount(document.body);
},
{ signal },
);
// Context menu for admin actions
item.addEventListener(
@@ -136,9 +270,14 @@ function createMemberItem(
const currentUserId = authStore.getState().user?.id ?? 0;
if (member.id === currentUserId) return;
// Only admins and owners can use admin actions
const role = opts.currentUserRole.toLowerCase();
if (role !== "owner" && role !== "admin") return;
// Moderation actions are permission-gated per item (a role name told us
// nothing about what its bits allow); block/unblock is open to everyone.
// The role name is read live from authStore, not the opts snapshot
// taken once at mount -- dispatcher.ts keeps authStore.user.role
// current on every self MEMBER_UPDATE precisely so gates like this one
// see a promotion/demotion without waiting for the sidebar to rebuild.
const gates = moderationGates(authStore.getState().user?.role ?? opts.currentUserRole);
const showAdminActions = gates.canKick || gates.canBan || gates.canManageRoles;
closeActiveMenu();
document.removeEventListener("mousedown", handleOutsideClick);
@@ -147,14 +286,22 @@ function createMemberItem(
// custom roles unreachable and, worse, unresolvable to a role id, so
// picking one silently did nothing.
const availableRoles = assignableRoleNames();
const isBlocked = blocksStore.getState().blockedByMe.has(member.id);
activeMenu = createMemberContextMenu({
userId: member.id,
username: member.username,
currentRole: member.role.toLowerCase(),
availableRoles,
showAdminActions,
canKick: gates.canKick,
canBan: gates.canBan,
canManageRoles: gates.canManageRoles,
isBlocked,
onToggleBlock: () => opts.onToggleBlock(member.id, member.username, !isBlocked),
onKick: () => opts.onKick(member.id, member.username),
onBan: (reason: string) => opts.onBan(member.id, member.username, reason),
onBan: (reason: string, durationHours: number) =>
opts.onBan(member.id, member.username, reason, durationHours),
onChangeRole: (newRole: string) => opts.onChangeRole(member.id, member.username, newRole),
});
@@ -208,25 +355,47 @@ function renderList(
}
}
for (const group of ROLE_GROUPS) {
const groupMembers = (buckets.get(group.role) ?? []).toSorted(
(a, b) => statusPriority(a.status) - statusPriority(b.status),
);
const groups = roleGroups();
const rendered = new Set<string>();
for (const group of groups) {
rendered.add(group.role);
appendGroup(root, group, buckets.get(group.role) ?? [], opts, signal, rowsByUserId);
}
if (groupMembers.length === 0) continue;
// Members whose role isn't in the server's role list (e.g. a role deleted
// mid-session) still render, in a gray group, instead of vanishing.
const leftovers = [...buckets.keys()].filter((role) => !rendered.has(role)).toSorted();
for (const role of leftovers) {
const group: RoleGroup = { role, label: role.toUpperCase(), colorVar: MEMBER_COLOR };
appendGroup(root, group, buckets.get(role) ?? [], opts, signal, rowsByUserId);
}
}
const header = createElement(
"div",
{ class: "member-role-group" },
`${group.label} \u2014 ${groupMembers.length}`,
);
root.appendChild(header);
function appendGroup(
root: HTMLDivElement,
group: RoleGroup,
members: readonly Member[],
opts: MemberListOptions,
signal: AbortSignal,
rowsByUserId: Map<number, HTMLDivElement>,
): void {
const groupMembers = members.toSorted(
(a, b) => statusPriority(a.status) - statusPriority(b.status),
);
for (const member of groupMembers) {
const item = createMemberItem(member, group.colorVar, opts, signal);
rowsByUserId.set(member.id, item);
root.appendChild(item);
}
if (groupMembers.length === 0) return;
const header = createElement(
"div",
{ class: "member-role-group" },
`${group.label} \u2014 ${groupMembers.length}`,
);
root.appendChild(header);
for (const member of groupMembers) {
const item = createMemberItem(member, group.colorVar, opts, signal);
rowsByUserId.set(member.id, item);
root.appendChild(item);
}
}
@@ -246,6 +415,10 @@ function isPresenceOnlyChange(
before.username !== member.username ||
before.role !== member.role ||
before.avatar !== member.avatar ||
before.displayName !== member.displayName ||
// A custom status is rendered as its own line, so a change to it is a
// structural change, not a dot recolor.
before.customStatus !== member.customStatus ||
before.identityPublicKey !== member.identityPublicKey
) {
return false;
@@ -268,7 +441,7 @@ function patchPresence(
if (before === undefined || before.status === member.status) continue;
const row = rowsByUserId.get(id);
if (row === undefined) continue;
row.classList.toggle("offline", member.status === "offline");
row.classList.toggle("offline", isAwayStatus(member.status));
const dot = row.querySelector<HTMLDivElement>(".mi-status");
if (dot !== null) {
dot.style.background = statusColor(member.status);
@@ -305,11 +478,26 @@ export function createMemberList(opts: MemberListOptions): MountableComponent {
},
);
// Role groups, their labels and their colors all come from the server's
// role list, which role management makes mutable at runtime (a roles_update
// broadcast replaces it). Without this the list kept the old grouping until
// some unrelated member change happened to force a re-render.
disposable.onStoreChange<ChannelsState, readonly ReadyRole[]>(
channelsStore,
(s) => s.roles,
() => {
if (root !== null) {
renderList(root, opts, disposable.signal, rowsByUserId);
}
},
);
container.appendChild(root);
}
function destroy(): void {
closeActiveMenu();
closeActivePopup();
document.removeEventListener("mousedown", handleOutsideClick);
disposable.destroy();
rowsByUserId.clear();
@@ -0,0 +1,135 @@
/**
* MentionAutocomplete — inline member picker the composer opens on "@".
* Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
*/
import { createElement, setText } from "@lib/dom";
import { membersStore } from "@stores/members.store";
import { currentUserHasPermission } from "@lib/permissions";
import { Permission } from "@lib/types";
import { EVERYONE_TOKEN, HERE_TOKEN } from "@lib/mentions";
import {
createInlineAutocomplete,
type InlineAutocompleteComponent,
} from "@components/inline-autocomplete";
/** Maximum rows shown at once — the popup is a shortcut, not the member list. */
export const MAX_MENTION_SUGGESTIONS = 10;
export interface MentionSuggestion {
/** Token inserted after the "@", e.g. "alice" or "everyone". */
readonly token: string;
/** Row label. Equal to `token` for users. */
readonly label: string;
/** Secondary line (role for users, meaning for @everyone/@here). */
readonly detail: string;
readonly kind: "user" | "broadcast";
/** User id, or null for @everyone/@here. */
readonly userId: number | null;
}
export interface MentionAutocompleteOptions {
/** Called with the token to insert (without the leading "@"). */
readonly onSelect: (token: string) => void;
readonly onClose: () => void;
/**
* Composer textarea the popup completes for; carries combobox semantics and
* aria-activedescendant while the popup is open (see inline-autocomplete).
*/
readonly comboboxInput?: HTMLElement;
}
/** Same shape as the shared inline-autocomplete widget. */
export type MentionAutocompleteComponent = InlineAutocompleteComponent;
function byLabel(a: MentionSuggestion, b: MentionSuggestion): number {
return a.label.localeCompare(b.label);
}
/**
* Suggestions for `query`, in the order the popup lists them: prefix matches
* before substring matches, alphabetical within each group.
*
* @everyone / @here are offered only when the signed-in user's role holds
* MENTION_EVERYONE — offering a token the server will refuse to honour would
* be a lie. The server still enforces.
*/
export function filterMentionSuggestions(query: string): MentionSuggestion[] {
const q = query.toLowerCase();
const prefix: MentionSuggestion[] = [];
const substring: MentionSuggestion[] = [];
for (const member of membersStore.getState().members.values()) {
// Skip usernames the mention grammar cannot express (a space, an "@",
// etc. truncate the token on insert) -- picking one would insert a dead
// token that resolves to no mention and notifies nobody.
if (!/^[\p{L}\p{N}_.-]{1,64}$/u.test(member.username)) continue;
const lower = member.username.toLowerCase();
if (q !== "" && !lower.includes(q)) continue;
const entry: MentionSuggestion = {
token: member.username,
label: member.username,
detail: member.role,
kind: "user",
userId: member.id,
};
if (q === "" || lower.startsWith(q)) {
prefix.push(entry);
} else {
substring.push(entry);
}
}
prefix.sort(byLabel);
substring.sort(byLabel);
const broadcasts: MentionSuggestion[] = [];
if (currentUserHasPermission(Permission.MENTION_EVERYONE)) {
const all: MentionSuggestion[] = [
{
token: EVERYONE_TOKEN,
label: EVERYONE_TOKEN,
detail: "Notify everyone in this channel",
kind: "broadcast",
userId: null,
},
{
token: HERE_TOKEN,
label: HERE_TOKEN,
detail: "Notify everyone who is online",
kind: "broadcast",
userId: null,
},
];
broadcasts.push(...all.filter((s) => q === "" || s.token.startsWith(q)));
}
return [...broadcasts, ...prefix, ...substring].slice(0, MAX_MENTION_SUGGESTIONS);
}
/** One mention row: `@label` plus a role / broadcast-meaning detail line. */
function renderMentionRow(s: MentionSuggestion): HTMLElement[] {
const name = createElement("span", { class: "ma-name" });
setText(name, `@${s.label}`);
const detail = createElement("span", { class: "ma-detail" });
setText(detail, s.detail);
return [name, detail];
}
export function createMentionAutocomplete(
options: MentionAutocompleteOptions,
): MentionAutocompleteComponent {
return createInlineAutocomplete<MentionSuggestion>({
rootClass: "mention-autocomplete",
rootTestId: "mention-autocomplete",
filter: filterMentionSuggestions,
valueOf: (s) => s.token,
rowTestId: (s) => `mention-option-${s.token}`,
renderRow: renderMentionRow,
// Open already populated with the full member list.
primeOnCreate: true,
onSelect: options.onSelect,
onClose: options.onClose,
comboboxInput: options.comboboxInput,
});
}
@@ -8,6 +8,16 @@ import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
import { createEmojiPicker } from "@components/EmojiPicker";
import { createGifPicker } from "@components/GifPicker";
import {
createMentionAutocomplete,
type MentionAutocompleteComponent,
} from "@components/MentionAutocomplete";
import {
createEmojiAutocomplete,
MIN_EMOJI_QUERY,
type EmojiAutocompleteComponent,
} from "@components/EmojiAutocomplete";
import { listCustomEmoji } from "@stores/emoji.store";
import type { GifApi } from "@lib/gifProvider";
export interface MessageInputOptions {
@@ -50,6 +60,68 @@ export type MessageInputComponent = MountableComponent & {
openFilePicker(): void;
};
/** Ctrl/Cmd shortcut → markdown marker it wraps the selection in. */
const FORMAT_MARKERS: Readonly<Record<string, string>> = {
b: "**",
i: "*",
u: "__",
};
export interface WrapResult {
readonly value: string;
readonly selectionStart: number;
readonly selectionEnd: number;
}
/**
* Wrap (or unwrap) `[start, end)` of `value` in `marker`, returning the new
* value and where the selection should land. With an empty selection the
* markers are inserted around the caret so typing continues inside them.
*
* Pure so the behaviour can be tested without a DOM selection.
*/
export function wrapWithMarker(
value: string,
start: number,
end: number,
marker: string,
): WrapResult {
const selected = value.slice(start, end);
const len = marker.length;
// Already wrapped — pressing the shortcut again takes the markers back off.
// The interior must not itself contain the marker: otherwise a selection
// that merely starts and ends with it (e.g. multiple already-wrapped spans,
// or a longer marker like "**" matching the outer edge of "*x*") would be
// mistaken for a single wrapped span and have its interior markers stripped.
if (
selected.length > 2 * len &&
selected.startsWith(marker) &&
selected.endsWith(marker) &&
!selected.slice(len, selected.length - len).includes(marker)
) {
const inner = selected.slice(len, selected.length - len);
return {
value: value.slice(0, start) + inner + value.slice(end),
selectionStart: start,
selectionEnd: start + inner.length,
};
}
if (value.slice(start - len, start) === marker && value.slice(end, end + len) === marker) {
return {
value: value.slice(0, start - len) + selected + value.slice(end + len),
selectionStart: start - len,
selectionEnd: start - len + selected.length,
};
}
return {
value: value.slice(0, start) + marker + selected + marker + value.slice(end),
selectionStart: start + len,
selectionEnd: start + len + selected.length,
};
}
const TYPING_THROTTLE_MS = 3_000;
const MAX_TEXTAREA_HEIGHT = 200;
const SEND_DEBOUNCE_MS = 200;
@@ -65,6 +137,22 @@ const ALLOWED_TYPES = [
"application/json",
];
/**
* Keys that move the caret without an open autocomplete popup claiming them,
* so the popup has to be resynced against the new caret on keyup. The popup's
* own keys are deliberately absent: it consumes ArrowUp/ArrowDown/Enter/Tab
* (so the caret does not move) and Escape closes it, and resyncing after any
* of those would reset the highlighted row or reopen what Escape dismissed.
*/
const CARET_MOVE_KEYS: ReadonlySet<string> = new Set([
"ArrowLeft",
"ArrowRight",
"Home",
"End",
"PageUp",
"PageDown",
]);
/** Disable the GIF button and say why, instead of silently doing nothing. */
function markGifUnavailable(gifBtn: HTMLButtonElement, reason: string): void {
gifBtn.setAttribute("disabled", "true");
@@ -94,6 +182,12 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
let attachmentPreviewBar: HTMLDivElement | null = null;
/** Set by mount() when file uploads are wired; backs openFilePicker(). */
let openPicker: (() => void) | null = null;
let mentionPopup: MentionAutocompleteComponent | null = null;
/** Index of the "@" the open popup is completing; -1 when closed. */
let mentionStart = -1;
let emojiPopup: EmojiAutocompleteComponent | null = null;
/** Index of the ":" the open emoji popup is completing; -1 when closed. */
let emojiStart = -1;
/** Pending attachment IDs to send with the next message. */
const pendingAttachments: { id: string; filename: string; readonly previewEl: HTMLDivElement }[] =
@@ -105,6 +199,176 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
/** Timer IDs for cleanup on destroy. */
const activeTimers: Set<ReturnType<typeof setTimeout>> = new Set();
/**
* The @token immediately before the caret, or null. The leading boundary
* mirrors the server's mention rule, so the popup never offers a completion
* for text ("mail@dom") that a send would not resolve as a mention.
*/
function activeMentionToken(): { query: string; start: number } | null {
if (textarea === null) return null;
const caret = textarea.selectionStart;
const before = textarea.value.slice(0, caret);
const match = /(?:^|[^\p{L}\p{N}_@])@([\p{L}\p{N}_.-]{0,64})$/u.exec(before);
if (match === null) return null;
const query = match[1] ?? "";
return { query, start: caret - query.length - 1 };
}
function closeMentionPopup(): void {
if (mentionPopup === null) return;
mentionPopup.destroy();
mentionPopup = null;
mentionStart = -1;
}
/** Replace the token under the caret with "@token ". */
function insertMention(token: string): void {
// The popup can outlive the token it was opened over: a caret move the
// composer never observed (Ctrl+A, a programmatic selection) leaves
// mentionStart pointing at an offset the caret no longer follows, and
// splicing there garbles the draft instead of completing it. Re-derive
// the token and only commit while it still starts where the popup thinks.
const active = activeMentionToken();
if (textarea === null || mentionStart < 0 || active === null || active.start !== mentionStart) {
closeMentionPopup();
return;
}
const caret = textarea.selectionStart;
const before = textarea.value.slice(0, mentionStart);
const after = textarea.value.slice(caret);
const inserted = `@${token} `;
textarea.value = before + inserted + after;
const pos = before.length + inserted.length;
textarea.selectionStart = pos;
textarea.selectionEnd = pos;
closeMentionPopup();
autoResize();
textarea.focus();
}
/**
* The `:token` immediately before the caret, or null. The leading boundary
* keeps the popup out of ordinary prose: a colon that follows a word ("see
* this:thing", a "10:30" clock, an "http://" scheme) is punctuation, not the
* start of a shortcode. A completed `:token:` is skipped too — it is already
* an emoji, and re-offering completions over it would fight the user.
*/
function activeEmojiToken(): { query: string; start: number } | null {
if (textarea === null) return null;
const caret = textarea.selectionStart;
const before = textarea.value.slice(0, caret);
const match = /(?:^|\s):([A-Za-z0-9_]{0,32})$/.exec(before);
if (match === null) return null;
const query = match[1] ?? "";
if (query.length < MIN_EMOJI_QUERY) return null;
return { query, start: caret - query.length - 1 };
}
function closeEmojiPopup(): void {
if (emojiPopup === null) return;
emojiPopup.destroy();
emojiPopup = null;
emojiStart = -1;
}
/** Replace the `:token` under the caret with the chosen emoji, plus a space. */
function insertEmoji(insert: string): void {
// Same staleness guard as insertMention: never splice at an anchor the
// caret has since moved away from.
const active = activeEmojiToken();
if (textarea === null || emojiStart < 0 || active === null || active.start !== emojiStart) {
closeEmojiPopup();
return;
}
const caret = textarea.selectionStart;
const before = textarea.value.slice(0, emojiStart);
const after = textarea.value.slice(caret);
const inserted = `${insert} `;
textarea.value = before + inserted + after;
const pos = before.length + inserted.length;
textarea.selectionStart = pos;
textarea.selectionEnd = pos;
closeEmojiPopup();
autoResize();
textarea.focus();
}
/** Open, refilter, or close the emoji popup for whatever is under the caret. */
function syncEmojiPopup(): void {
const active = disabledReason === null ? activeEmojiToken() : null;
if (active === null) {
closeEmojiPopup();
return;
}
if (emojiPopup === null) {
emojiPopup = createEmojiAutocomplete({
onSelect: insertEmoji,
onClose: closeEmojiPopup,
// The popup manages combobox/aria-activedescendant state on the
// textarea for as long as it is open.
comboboxInput: textarea ?? undefined,
});
root?.appendChild(emojiPopup.element);
}
emojiStart = active.start;
if (!emojiPopup.setQuery(active.query)) {
closeEmojiPopup();
}
}
/** Apply a formatting marker to the current textarea selection. */
function applyFormatting(marker: string): void {
if (textarea === null || disabledReason !== null) return;
const result = wrapWithMarker(
textarea.value,
textarea.selectionStart,
textarea.selectionEnd,
marker,
);
textarea.value = result.value;
textarea.selectionStart = result.selectionStart;
textarea.selectionEnd = result.selectionEnd;
autoResize();
maybeEmitTyping();
}
/** Open, refilter, or close the popup for whatever is under the caret. */
function syncMentionPopup(): void {
const active = disabledReason === null ? activeMentionToken() : null;
if (active === null) {
closeMentionPopup();
return;
}
if (mentionPopup === null) {
mentionPopup = createMentionAutocomplete({
onSelect: insertMention,
onClose: closeMentionPopup,
// The popup manages combobox/aria-activedescendant state on the
// textarea for as long as it is open.
comboboxInput: textarea ?? undefined,
});
root?.appendChild(mentionPopup.element);
}
mentionStart = active.start;
if (!mentionPopup.setQuery(active.query)) {
closeMentionPopup();
}
}
/**
* Drive both completion popups from one caret position. Only one can be open:
* the caret sits in exactly one token, and two stacked popups over the same
* textarea would race for the arrow keys.
*/
function syncAutocomplete(): void {
syncMentionPopup();
if (mentionPopup !== null) {
closeEmojiPopup();
return;
}
syncEmojiPopup();
}
function showReplyBar(username: string): void {
if (replyBar === null || replyText === null) return;
setText(replyText, `Replying to @${username}`);
@@ -154,10 +418,21 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
},
message,
);
// app.css only shows the preview bar via .visible -- without this an
// error with no attachments already queued renders into a display:none
// container and is never seen.
attachmentPreviewBar.classList.add("visible");
attachmentPreviewBar.appendChild(errEl);
const t = setTimeout(() => {
activeTimers.delete(t);
errEl.remove();
if (
attachmentPreviewBar !== null &&
pendingAttachments.length === 0 &&
attachmentPreviewBar.childElementCount === 0
) {
attachmentPreviewBar.classList.remove("visible");
}
}, 4000);
activeTimers.add(t);
}
@@ -228,8 +503,8 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
/** Unique counter for preview items (before upload completes and we have a server ID). */
let previewCounter = 0;
function removePreviewItem(tempId: string): void {
const idx = pendingAttachments.findIndex((a) => a.id === tempId);
function removePreviewItem(el: HTMLDivElement): void {
const idx = pendingAttachments.findIndex((a) => a.previewEl === el);
const att = idx !== -1 ? pendingAttachments[idx] : undefined;
if (att !== undefined) {
const img = att.previewEl.querySelector("img");
@@ -258,6 +533,14 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
async function handlePasteFile(file: File): Promise<void> {
if (options.onUploadFile === undefined || attachmentPreviewBar === null) return;
// Attachments queued during an edit are neither sent (the edit branch
// never reads pendingAttachments) nor cleared -- they'd silently ride
// along with the next ordinary message. Refuse at the single entry point.
if (state.editing !== null) {
showUploadError("Can't attach files while editing a message");
return;
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
showUploadError(`File too large: ${file.name} exceeds 100 MB limit`);
@@ -316,7 +599,7 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
"click",
(e) => {
e.stopPropagation();
removePreviewItem(tempId);
removePreviewItem(item);
},
{ signal },
);
@@ -342,7 +625,7 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
}
} catch (err) {
// Upload failed — remove preview and show error
removePreviewItem(tempId);
removePreviewItem(item);
const errMsg = err instanceof Error ? err.message : "Upload failed";
showUploadError(`Upload failed: ${errMsg}`);
} finally {
@@ -351,7 +634,9 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
}
function setReplyTo(messageId: number, username: string): void {
if (state.editing !== null) hideEditBar();
// cancelEdit also clears the textarea -- without it the stale edit text
// survives into reply mode and Enter reposts it as a duplicate.
if (state.editing !== null) cancelEdit();
state = { replyTo: { messageId, username }, editing: null };
showReplyBar(username);
textarea?.focus();
@@ -419,7 +704,7 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
const fileInput = createElement("input", {
type: "file",
style: "display: none;",
accept: "image/*,video/*,audio/*,.pdf,.txt,.zip,.rar,.7z",
accept: "image/*,video/*,audio/*,.pdf,.txt,.zip",
});
fileInput.addEventListener(
"change",
@@ -479,12 +764,32 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
() => {
autoResize();
maybeEmitTyping();
syncAutocomplete();
},
{ signal },
);
textarea.addEventListener(
"keydown",
(e: KeyboardEvent) => {
// Whichever popup is open owns navigation keys, so Enter completes the
// token instead of sending a half-typed message.
if (mentionPopup?.handleKeydown(e) === true) return;
if (emojiPopup?.handleKeydown(e) === true) return;
// Ctrl+B / Ctrl+I / Ctrl+U wrap the selection in markdown markers.
// The composer owns Ctrl+U while it has focus, so the propagation stop
// is load-bearing: without it the global upload shortcut would fire on
// top of the underline.
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey) {
const marker = FORMAT_MARKERS[e.key.toLowerCase()];
if (marker !== undefined) {
e.preventDefault();
e.stopPropagation();
applyFormatting(marker);
return;
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
@@ -520,6 +825,26 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
{ signal },
);
// Caret moves that aren't typing (click, arrow/Home/End keys, blur) also
// decide the popup's fate — without this, completing a mention/emoji
// after moving the caret away with the keyboard splices at a stale offset.
textarea.addEventListener("click", syncAutocomplete, { signal });
textarea.addEventListener(
"keyup",
(e: KeyboardEvent) => {
if (CARET_MOVE_KEYS.has(e.key)) syncAutocomplete();
},
{ signal },
);
textarea.addEventListener(
"blur",
() => {
closeMentionPopup();
closeEmojiPopup();
},
{ signal },
);
sendBtn.addEventListener("click", handleSend, { signal });
// Picker state (declared together so both toggle functions can cross-close)
@@ -558,6 +883,9 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
return;
}
emojiPicker = createEmojiPicker({
// Read the set at open time, not at mount: an emoji_update while the
// composer is alive must be in the next picker the user opens.
customEmoji: listCustomEmoji(),
onSelect: (emoji: string) => {
if (textarea !== null) {
const start = textarea.selectionStart;
@@ -623,9 +951,20 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
markGifUnavailable(gifBtn, reason);
},
onSelect: (gifUrl: string) => {
if (textarea !== null) {
textarea.value = gifUrl;
handleSend();
// Send the GIF directly instead of routing it through the textarea
// (handleSend's read of textarea.value): that overwrote — and
// discarded — whatever draft the user had typed, and on slow
// mode / mid-upload / debounced sends left the raw GIF URL sitting
// in the composer instead of the draft. Guarded by the same
// disabledReason/debounce checks as a normal send; an in-progress
// edit and any typed draft are left untouched.
if (disabledReason === null) {
const now = Date.now();
if (now - lastSendTime >= SEND_DEBOUNCE_MS) {
lastSendTime = now;
options.onSend(gifUrl, state.replyTo?.messageId ?? null, []);
clearReply();
}
}
closeGifPicker();
},
@@ -649,6 +988,8 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
cleanupPickers = () => {
closeEmojiPicker();
closeGifPicker();
closeMentionPopup();
closeEmojiPopup();
};
appendChildren(inputBox, attachBtn, textarea, emojiBtn, gifBtn, sendBtn);
+176 -29
View File
@@ -11,13 +11,22 @@ import {
getChannelMessages,
hasMoreMessages,
getHistoryLoadState,
isWindowDetached,
} from "@stores/messages.store";
import type { Message } from "@stores/messages.store";
import { membersStore } from "@stores/members.store";
import { unobserveMedia } from "@lib/media-visibility";
const log = createLogger("message-list");
import { shouldGroup, isSameDay, renderDayDivider, renderMessage } from "./message-list/renderers";
import {
shouldGroup,
isSameDay,
renderDayDivider,
renderNewDivider,
renderMessage,
} from "./message-list/renderers";
import { getUnreadOnOpen } from "@stores/channels.store";
import { isAudioMime, isVideoMime } from "./message-list/attachments";
import { FenwickTree } from "./message-list/fenwick";
// -- Options ------------------------------------------------------------------
@@ -27,7 +36,9 @@ export interface MessageListOptions {
readonly channelName: string;
readonly channelType?: string;
readonly currentUserId: number;
readonly onScrollTop: () => void;
/** May return a promise (e.g. the underlying fetch); MessageList clears its
* loadingOlder latch once it settles, success or failure. */
readonly onScrollTop: () => void | Promise<void>;
readonly onReplyClick: (messageId: number) => void;
readonly onEditClick: (messageId: number) => void;
readonly onDeleteClick: (messageId: number) => void;
@@ -39,6 +50,17 @@ export interface MessageListOptions {
readonly onDeleteDraft?: (correlationId: string) => void;
/** Retry a failed first-page history fetch. */
readonly onRetryLoad?: () => void;
/**
* Jump to another message in this channel — the reply bar above a reply, and
* any other in-row affordance. May target a message outside the loaded
* window; the handler is expected to fetch the around-window in that case.
*/
readonly onJumpToMessage?: (messageId: number) => void;
/**
* Leave a detached around-window and reload the live tail. Wired to the
* "Jump to Present" pill, which only appears while the window is detached.
*/
readonly onJumpToPresent?: () => void;
}
// -- Constants ----------------------------------------------------------------
@@ -68,20 +90,31 @@ interface VirtualItemDivider {
readonly timestamp: string;
}
type VirtualItem = VirtualItemMessage | VirtualItemDivider;
/** The "NEW" line marking where the reader's unread messages begin. At most
* one per list, and only for a visit that opened with unread messages. */
interface VirtualItemNewDivider {
readonly kind: "new-divider";
}
type VirtualItem = VirtualItemMessage | VirtualItemDivider | VirtualItemNewDivider;
// -- Smart height estimation --------------------------------------------------
function estimateItemHeight(item: VirtualItem): number {
if (item.kind === "divider") return 32;
if (item.kind === "divider" || item.kind === "new-divider") return 32;
// Non-grouped: min-height 2.75rem (44px @16px root) + margin-top 17px = 61px
// Grouped: min-height 1.375rem (22px @16px root) + margin-top 0px = 22px
let height = item.isGrouped ? 22 : 61;
// Image attachments
// Media attachments. Video shares the image box, so it reserves the same
// space; the audio player is a chip-height row.
for (const att of item.message.attachments) {
if (att.mime.startsWith("image/")) {
if (isVideoMime(att.mime)) {
height += 220;
} else if (isAudioMime(att.mime)) {
height += 96;
} else if (att.mime.startsWith("image/")) {
height += 220;
}
}
@@ -108,16 +141,28 @@ function buildVirtualItems(
messages: readonly Message[],
seedPrevMsg: Message | null = null,
seedLastTimestamp: string | null = null,
newDividerAt = -1,
): readonly VirtualItem[] {
const items: VirtualItem[] = [];
let lastTimestamp: string | null = seedLastTimestamp;
let prevMsg: Message | null = seedPrevMsg;
for (const msg of messages) {
for (const [i, msg] of messages.entries()) {
if (lastTimestamp === null || !isSameDay(lastTimestamp, msg.timestamp)) {
items.push({ kind: "divider", timestamp: msg.timestamp });
}
const isGrouped = prevMsg !== null && shouldGroup(prevMsg, msg);
const isFirstUnread = i === newDividerAt;
if (isFirstUnread) {
items.push({ kind: "new-divider" });
}
// A message directly under the NEW line starts a fresh block: rendering it
// as a grouped continuation of a message from before the line hides both
// its author and the fact that the line is there.
const isGrouped =
!isFirstUnread &&
prevMsg !== null &&
isSameDay(prevMsg.timestamp, msg.timestamp) &&
shouldGroup(prevMsg, msg);
items.push({ kind: "message", message: msg, isGrouped });
lastTimestamp = msg.timestamp;
prevMsg = msg;
@@ -125,6 +170,19 @@ function buildVirtualItems(
return items;
}
/**
* Index of the first unread message in `messages`, or -1 for none.
*
* Derived from the unread count the channel had when it was opened (the
* badge itself is cleared by the visit): the last N loaded messages are the
* unread ones. Clamped to 0 when the whole loaded window is unread, and
* suppressed at 0-length so an empty channel never renders a lone divider.
*/
function firstUnreadIndex(messages: readonly Message[], unreadOnOpen: number): number {
if (unreadOnOpen <= 0 || messages.length === 0) return -1;
return Math.max(0, messages.length - unreadOnOpen);
}
// -- Empty state --------------------------------------------------------------
function renderEmptyState(channelName: string, channelType?: string): HTMLDivElement {
@@ -197,17 +255,69 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
let bottomSpacer: HTMLDivElement | null = null;
let contentContainer: HTMLDivElement | null = null;
let scrollToBottomBtn: HTMLButtonElement | null = null;
let jumpToPresentPill: HTMLButtonElement | null = null;
let renderedStart = 0;
let renderedEnd = 0;
/**
* Unread count this channel carried when the visit that created this list
* began. Read once here, not per render: the badge is cleared by the visit
* itself, and the divider must stay put for the whole visit rather than
* jumping as new messages arrive. Zero once the reader comes back, which is
* what makes the divider clear on the next visit.
*
* Suppressed while the window is detached (jumped to an old message): the
* loaded slice is then not the tail, so "the last N messages" would put the
* line somewhere arbitrary.
*/
const unreadOnOpen = isWindowDetached(options.channelId) ? 0 : getUnreadOnOpen(options.channelId);
/**
* Message id the NEW divider is anchored to, once one has been picked.
* `firstUnreadIndex` returns a count-from-the-end offset, which drifts
* whenever the loaded window grows (new messages arrive) between one full
* rebuild and the next — the exact thing unreadOnOpen's doc comment above
* promises won't happen. Latching onto the message id the first valid index
* pointed at keeps the divider glued to that message for the rest of the
* visit regardless of how the window grows around it.
*/
let newDividerAnchorId: number | null = null;
/**
* Resolve the NEW divider's position for this rebuild. Prefers the latched
* anchor id (stable across window growth); falls back to the count formula
* only until an anchor exists, then latches it — skipping id 0 (an
* unconfirmed optimistic row) since that id is not unique across pending
* sends and would anchor to the wrong message once reconciled.
*/
function resolveNewDividerIndex(messages: readonly Message[]): number {
if (newDividerAnchorId !== null) {
return messages.findIndex((m) => m.id === newDividerAnchorId);
}
const idx = firstUnreadIndex(messages, unreadOnOpen);
const anchor = idx !== -1 ? messages[idx] : undefined;
if (anchor !== undefined && anchor.id !== 0) {
newDividerAnchorId = anchor.id;
}
return idx;
}
// ---------------------------------------------------------------------------
// Height estimation (Fenwick tree backed)
// ---------------------------------------------------------------------------
/** Render one virtual item — the single place the three item kinds map to DOM. */
function renderVirtualItem(item: VirtualItem): HTMLElement {
if (item.kind === "divider") return renderDayDivider(item.timestamp);
if (item.kind === "new-divider") return renderNewDivider();
return renderMessage(item.message, item.isGrouped, allMessages, options, ac.signal);
}
function itemKey(index: number): string {
const item = virtualItems[index];
if (item === undefined) return `idx-${index}`;
if (item.kind === "divider") return `div-${item.timestamp}`;
if (item.kind === "new-divider") return "new-divider";
return `msg-${item.message.id}`;
}
@@ -271,6 +381,12 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
}
}
/** The pill is the only signal that the bottom of the list is not "now". */
function updateJumpToPresentPill(): void {
if (jumpToPresentPill === null) return;
jumpToPresentPill.classList.toggle("visible", isWindowDetached(options.channelId));
}
// ---------------------------------------------------------------------------
// Render visible window
// ---------------------------------------------------------------------------
@@ -410,14 +526,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
clearChildren(contentContainer);
const fragment = document.createDocumentFragment();
for (let i = start; i < end; i++) {
const item = virtualItems[i]!;
if (item.kind === "divider") {
fragment.appendChild(renderDayDivider(item.timestamp));
} else {
fragment.appendChild(
renderMessage(item.message, item.isGrouped, allMessages, options, ac.signal),
);
}
fragment.appendChild(renderVirtualItem(virtualItems[i]!));
}
contentContainer.appendChild(fragment);
@@ -439,7 +548,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
function rebuildItems(): void {
allMessages = getChannelMessages(options.channelId);
virtualItems = buildVirtualItems(allMessages);
virtualItems = buildVirtualItems(allMessages, null, null, resolveNewDividerIndex(allMessages));
// Build Fenwick tree initialized with smart estimates / cached heights
tree = new FenwickTree(virtualItems.length);
@@ -516,13 +625,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
// The rendered window includes the old tail — append the new rows.
const fragment = document.createDocumentFragment();
for (const item of appendedItems) {
if (item.kind === "divider") {
fragment.appendChild(renderDayDivider(item.timestamp));
} else {
fragment.appendChild(
renderMessage(item.message, item.isGrouped, allMessages, options, ac.signal),
);
}
fragment.appendChild(renderVirtualItem(item));
}
contentContainer.appendChild(fragment);
renderedEnd = virtualItems.length;
@@ -611,14 +714,22 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
// ---------------------------------------------------------------------------
let loadingOlder = false;
let prevMessageCount = 0;
// The oldest loaded message's id, not the count: a live tail append also
// changes the count while a history fetch is still in flight, and
// resetting the latch on that lets the next scroll refire loadOlderMessages
// with the same unchanged cursor -- the same page then lands twice. Only a
// prepend moves messages[0]. Seeded from the current state (not left at a
// placeholder) so the first change observed after construction is compared
// against reality, not an arbitrary initial value.
let prevOldestId: number | null = getChannelMessages(options.channelId)[0]?.id ?? null;
const unsubLoadingReset = messagesStore.subscribeSelector(
(s) => s.messagesByChannel,
() => {
const msgs = getChannelMessages(options.channelId);
if (msgs.length !== prevMessageCount) {
prevMessageCount = msgs.length;
const oldestId = msgs.length > 0 ? msgs[0]!.id : null;
if (oldestId !== prevOldestId) {
prevOldestId = oldestId;
loadingOlder = false;
}
},
@@ -638,7 +749,14 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
hasMoreMessages(options.channelId)
) {
loadingOlder = true;
options.onScrollTop();
// A failed fetch never changes the message count, so the subscriber
// below (which only reacts to a count change) would leave loadingOlder
// latched forever. Clear it once the load settles either way — the
// subscriber's reset still applies to the success path but is now just
// belt-and-braces.
void Promise.resolve(options.onScrollTop()).finally(() => {
loadingOlder = false;
});
}
// Update floating scroll-to-bottom button visibility
@@ -676,11 +794,21 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
{ signal: ac.signal },
);
jumpToPresentPill = createElement("button", {
class: "jump-to-present-pill",
"data-testid": "jump-to-present",
});
jumpToPresentPill.textContent = "Jump to Present ↓";
jumpToPresentPill.addEventListener("click", () => options.onJumpToPresent?.(), {
signal: ac.signal,
});
root.appendChild(topSpacer);
root.appendChild(contentContainer);
root.appendChild(bottomSpacer);
root.appendChild(scrollAnchor);
root.appendChild(scrollToBottomBtn);
root.appendChild(jumpToPresentPill);
root.addEventListener("scroll", handleScroll, {
signal: ac.signal,
@@ -722,6 +850,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
parentContainer.appendChild(root);
renderAll();
updateJumpToPresentPill();
scrollToBottom();
const initialScrollRaf = requestAnimationFrame(() => scrollToBottom());
ac.signal.addEventListener("abort", () => cancelAnimationFrame(initialScrollRaf));
@@ -750,6 +879,17 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
),
);
// Show/hide the pill as the window detaches from (and reattaches to) the
// live tail. No re-render — only the pill's visibility changes.
unsubscribers.push(
messagesStore.subscribeSelector(
(s) => s.detachedChannels.has(options.channelId),
() => {
updateJumpToPresentPill();
},
),
);
// Only re-render when member roles change, not on presence/typing updates.
// The store bumps roleRevision solely on membership/role mutations, so
// selecting the counter avoids rebuilding a role map per notification.
@@ -801,6 +941,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
topSpacer = null;
bottomSpacer = null;
scrollToBottomBtn = null;
jumpToPresentPill = null;
}
function scrollToMessage(messageId: number): boolean {
@@ -811,6 +952,10 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
if (idx === -1) return false;
root.scrollTop = offsetBefore(idx);
// Force the rebuild path: a scroll-driven renderWindow only moves spacers,
// so without this the target row can sit outside the rendered window and
// there is nothing to flash (and nothing to look at after the scroll).
renderedStart = -1;
renderWindow();
// Briefly highlight the target message element
@@ -819,9 +964,11 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
const el = contentContainer.children[localIdx] as HTMLElement | undefined;
if (el !== undefined) {
el.classList.add("highlight-flash");
setTimeout(() => {
const timer = window.setTimeout(() => {
el.classList.remove("highlight-flash");
}, 1500);
// Unmounting mid-flash must not leave a timer pointing at a dead node.
ac.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
}
}
@@ -0,0 +1,113 @@
/**
* NsfwGate — the age-gate shown over a channel flagged NSFW.
*
* The server does nothing with the flag beyond storing and broadcasting it (see
* `@lib/nsfw-gate`), so this overlay is the whole of the feature on the reading
* side. It covers the message area rather than replacing it: the channel is
* mounted and live underneath, and accepting the warning reveals it without a
* refetch.
*
* Deliberately not a `.modal-overlay`: a modal is a decision about the app,
* while this is a property of the channel you just opened. It fills its
* container, so mounting it into the messages slot gates exactly the content it
* is warning about and leaves the sidebar and header usable.
*/
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
import { acknowledgeNsfw } from "@lib/nsfw-gate";
export interface NsfwGateOptions {
/** Channel being gated — its id keys the per-session acknowledgement. */
readonly channelId: number;
/** Channel name, shown without the leading '#'. */
readonly channelName: string;
/** Called after the acknowledgement is recorded. */
readonly onContinue: () => void;
/**
* Called when the reader declines. Optional: without it the gate offers only
* "Continue", which is right for a container the reader can simply navigate
* away from.
*/
readonly onCancel?: () => void;
}
export function createNsfwGate(options: NsfwGateOptions): MountableComponent {
const { channelId, channelName, onContinue, onCancel } = options;
const ac = new AbortController();
let root: HTMLDivElement | null = null;
function mount(container: Element): void {
root = createElement("div", {
class: "nsfw-gate",
"data-testid": "nsfw-gate",
role: "dialog",
"aria-modal": "false",
"aria-label": `Age restricted channel ${channelName}`,
});
const card = createElement("div", { class: "nsfw-gate-card" });
const iconWrap = createElement("div", { class: "nsfw-gate-icon" });
iconWrap.appendChild(createIcon("shield-alert", 40));
const title = createElement("h2", { class: "nsfw-gate-title" });
setText(title, `#${channelName}`);
const body = createElement("p", { class: "nsfw-gate-body" });
setText(body, "This channel may contain sensitive content — Continue?");
// Says plainly what the flag is and is not, so nobody reads the gate as a
// promise the server is filtering something.
const note = createElement("p", { class: "nsfw-gate-note" });
setText(
note,
"The channel has been marked age-restricted by a moderator. Nothing is filtered — you are only being asked once per session.",
);
const actions = createElement("div", { class: "nsfw-gate-actions" });
if (onCancel !== undefined) {
const backBtn = createElement(
"button",
{ class: "btn-modal-cancel", type: "button", "data-testid": "nsfw-gate-back" },
"Go Back",
);
backBtn.addEventListener("click", onCancel, { signal: ac.signal });
actions.appendChild(backBtn);
}
const continueBtn = createElement(
"button",
{ class: "btn-modal-save", type: "button", "data-testid": "nsfw-gate-continue" },
"Continue",
);
continueBtn.addEventListener(
"click",
() => {
// Record first, then notify: the caller's handler tears this component
// down, and an acknowledgement written afterwards would race it.
acknowledgeNsfw(channelId);
onContinue();
},
{ signal: ac.signal },
);
actions.appendChild(continueBtn);
appendChildren(card, iconWrap, title, body, note, actions);
root.appendChild(card);
container.appendChild(root);
continueBtn.focus();
}
function destroy(): void {
ac.abort();
if (root !== null) {
root.remove();
root = null;
}
}
return { mount, destroy };
}
@@ -61,11 +61,17 @@ function renderPinnedItem(
// Hover actions
const actions = createElement("div", { class: "pinned-msg__actions" });
const jumpBtn = createElement("button", { title: "Jump to message" });
// Icon-only buttons: title= only tooltips for mouse users, so mirror it as
// an aria-label for screen readers.
const jumpBtn = createElement("button", {
title: "Jump to message",
"aria-label": "Jump to message",
});
jumpBtn.appendChild(createIcon("external-link", 14));
const unpinBtn = createElement("button", {
class: "pinned-msg__unpin",
title: "Unpin message",
"aria-label": "Unpin message",
});
unpinBtn.appendChild(createIcon("x", 14));
@@ -94,7 +100,13 @@ export function createPinnedMessages(options: PinnedMessagesOptions): MountableC
let root: HTMLDivElement | null = null;
function mount(container: Element): void {
root = createElement("div", { class: "pinned-panel" });
// A side panel, not a modal: complementary landmark (no aria-modal, no
// focus trap), matching DmProfileSidebar.
root = createElement("div", {
class: "pinned-panel",
role: "complementary",
"aria-label": "Pinned messages",
});
// Header
const header = createElement("div", { class: "pinned-panel__header" });
@@ -106,7 +118,10 @@ export function createPinnedMessages(options: PinnedMessagesOptions): MountableC
const count = createElement("span", { class: "pinned-panel__count" });
count.textContent = String(options.pinnedMessages.length);
const closeBtn = createElement("button", { class: "pinned-panel__close" });
const closeBtn = createElement("button", {
class: "pinned-panel__close",
"aria-label": "Close pinned messages",
});
closeBtn.appendChild(createIcon("x", 16));
closeBtn.addEventListener("click", () => options.onClose(), { signal: ac.signal });
@@ -4,6 +4,7 @@
* Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
*/
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
import { createElement, appendChildren, setText } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
@@ -31,6 +32,7 @@ export interface QuickSwitchOverlayOptions {
export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): MountableComponent {
const ac = new AbortController();
let root: HTMLDivElement | null = null;
let restoreFocus: (() => void) | null = null;
function mount(container: Element): void {
root = createElement("div", {
@@ -48,6 +50,8 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
);
const modal = createElement("div", { class: "quick-switch-modal" });
applyDialogSemantics(modal, { label: "Switch server" });
trapFocus(modal, ac.signal);
// Header
const header = createElement("div", { class: "quick-switch-header" });
@@ -64,11 +68,18 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
for (const profile of options.profiles) {
const isCurrent = profile.host === options.currentHost;
const item = createElement("div", {
const attrs: Record<string, string> = {
class: `quick-switch-item${isCurrent ? " current" : ""}`,
"data-testid": "server-item",
"data-host": profile.host,
});
};
// Only actionable rows get button semantics — the connected row has no
// click handler, and a "button" that does nothing lies to screen readers.
if (!isCurrent) {
attrs["role"] = "button";
attrs["tabindex"] = "0";
}
const item = createElement("div", attrs);
const icon = createElement("div", { class: "quick-switch-icon" });
setText(icon, profile.name.charAt(0).toUpperCase());
@@ -94,6 +105,18 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
},
{ signal: ac.signal },
);
// Divs get no native key activation; Enter/Space mirrors the click
// so the row honors the button role it advertises.
item.addEventListener(
"keydown",
(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
options.onSwitch(profile.host, profile.name);
}
},
{ signal: ac.signal },
);
}
list.appendChild(item);
@@ -103,6 +126,8 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
const addItem = createElement("div", {
class: "quick-switch-item add-new",
"data-testid": "add-server-btn",
role: "button",
tabindex: "0",
});
const addIcon = createElement("div", { class: "quick-switch-icon add" }, "+");
const addInfo = createElement("div", { class: "quick-switch-info" });
@@ -115,6 +140,16 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
appendChildren(addInfo, addName, addHost);
appendChildren(addItem, addIcon, addInfo);
addItem.addEventListener("click", () => options.onAddServer(), { signal: ac.signal });
addItem.addEventListener(
"keydown",
(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
options.onAddServer();
}
},
{ signal: ac.signal },
);
list.appendChild(addItem);
// Footer
@@ -124,6 +159,10 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
root.appendChild(modal);
container.appendChild(root);
// Move focus onto the first actionable row (or the modal itself) and
// remember the opener — the UserBar switch button — for destroy().
restoreFocus = focusDialog(modal);
// Escape key closes overlay
document.addEventListener(
"keydown",
@@ -140,6 +179,10 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
root.remove();
root = null;
}
// Restore after removal so focus cannot land on a node inside the
// just-detached overlay.
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -1,6 +1,7 @@
// Step 8.60 — Quick switcher modal (Ctrl+K) for fast channel navigation.
// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
import { createElement, setText, appendChildren, clearChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { channelsStore } from "@stores/channels.store";
@@ -22,6 +23,7 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
let activeIndex = 0;
let filteredChannels: readonly Channel[] = [];
let unsubscribe: (() => void) | null = null;
let restoreFocus: (() => void) | null = null;
function getChannelIcon(ch: Channel): SVGSVGElement {
return ch.type === "voice" ? createIcon("volume-2", 14) : createIcon("hash", 14);
@@ -29,7 +31,11 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
function getFilteredChannels(query: string): readonly Channel[] {
const state = channelsStore.getState();
const all = Array.from(state.channels.values());
// DM rows are synthesized into channelsStore once opened, but they have
// their own sidebar path (full clearDmUnread/setSidebarMode handling) —
// listing them here too would select via a bare setActiveChannel and
// leave their unread/mention badge lit forever.
const all = Array.from(state.channels.values()).filter((ch) => ch.type !== "dm");
const sorted = [...all].toSorted((a, b) => a.position - b.position);
if (query.length === 0) return sorted;
@@ -51,6 +57,12 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
? "quick-switcher__item quick-switcher__item--active"
: "quick-switcher__item",
"data-channelid": String(ch.id),
// Combobox option wiring: the id feeds aria-activedescendant so a
// screen reader tracks the roving --active highlight without the
// input ever losing DOM focus.
id: `qs-option-${i}`,
role: "option",
"aria-selected": isActive ? "true" : "false",
});
const icon = createElement("span", { class: "quick-switcher__icon" });
@@ -79,6 +91,16 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
resultsDiv.appendChild(item);
}
// Re-point aria-activedescendant on every render — arrow keys, filtering
// and store refreshes all funnel through here, so it can never go stale.
// An empty result set clears it; pointing at a missing id is worse than
// pointing at nothing.
if (filteredChannels.length > 0) {
input.setAttribute("aria-activedescendant", `qs-option-${activeIndex}`);
} else {
input.removeAttribute("aria-activedescendant");
}
}
function handleInput(): void {
@@ -154,21 +176,39 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
// Modal container
const modal = createElement("div", { class: "quick-switcher" });
applyDialogSemantics(modal, { label: "Quick switcher" });
trapFocus(modal, signal);
// Search input
// Search input — combobox over the results listbox: the input keeps DOM
// focus while aria-activedescendant (set in renderResults) names the row
// the arrow keys have highlighted. The list is always rendered, so
// aria-expanded is statically true.
input = createElement("input", {
class: "quick-switcher__input",
type: "text",
placeholder: "Where do you want to go?",
role: "combobox",
"aria-expanded": "true",
"aria-autocomplete": "list",
"aria-controls": "quick-switcher-results",
});
// Results list
resultsDiv = createElement("div", { class: "quick-switcher__results" });
resultsDiv = createElement("div", {
class: "quick-switcher__results",
id: "quick-switcher-results",
role: "listbox",
});
appendChildren(modal, input, resultsDiv);
root.appendChild(modal);
container.appendChild(root);
// Capture the opener before anything inside grabs focus — Ctrl+K comes
// from the composer, and a keyboard user needs destroy() to land them
// back there, not at the top of the document.
restoreFocus = focusDialog(modal);
// Initial render
filteredChannels = getFilteredChannels("");
renderResults();
@@ -194,6 +234,10 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
}
root?.remove();
root = null;
// Restore after the overlay is gone, so focus cannot land on a node the
// removal is about to detach.
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -113,7 +113,16 @@ export function createSearchOverlay(options: SearchOverlayOptions): MountableCom
function doSearch(): void {
const now = Date.now();
if (now - lastSearchTime < MIN_SEARCH_INTERVAL_MS) return;
const sinceLast = now - lastSearchTime;
if (sinceLast < MIN_SEARCH_INTERVAL_MS) {
// Too soon after the previous search. Don't drop this query — that would
// leave the earlier query's results on screen for what the user is now
// typing. Reschedule for when the rate-limit window opens, reusing the
// debounce timer so destroy() still tears it down.
if (debounceTimer !== null) window.clearTimeout(debounceTimer);
debounceTimer = window.setTimeout(doSearch, MIN_SEARCH_INTERVAL_MS - sinceLast);
return;
}
lastSearchTime = now;
const query = input.value.trim();
@@ -1,48 +0,0 @@
/**
* ServerStrip component — vertical strip on the far left showing server icons.
* Single-server for now: Home button, separator, add server button.
*/
import { createElement, appendChildren } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
export function createServerStrip(): MountableComponent {
const ac = new AbortController();
let root: HTMLDivElement | null = null;
function mount(container: Element): void {
root = createElement("div", { class: "server-strip", "data-testid": "server-strip" });
const homeIcon = createElement(
"div",
{ class: "server-icon active", style: "background: var(--accent)" },
"O",
);
const separator = createElement("div", { class: "server-separator" });
const addIcon = createElement("div", { class: "server-icon add" }, "+");
// Add server button click — placeholder for future multi-server support
addIcon.addEventListener(
"click",
() => {
// No-op for single-server mode
},
{ signal: ac.signal },
);
appendChildren(root, homeIcon, separator, addIcon);
container.appendChild(root);
}
function destroy(): void {
ac.abort();
if (root !== null) {
root.remove();
root = null;
}
}
return { mount, destroy };
}
@@ -4,6 +4,7 @@
* Subscribes to uiStore for settingsOpen state.
*/
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
import { createElement, appendChildren, clearChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { IconName } from "@lib/icons";
@@ -28,7 +29,18 @@ import { createLogsTab } from "./settings/LogsTab";
export interface SettingsOverlayOptions {
onClose(): void;
onChangePassword(oldPassword: string, newPassword: string): Promise<void>;
onUpdateProfile(username: string): Promise<void>;
/**
* Patch the signed-in user's profile. Every field is optional and omitted
* means "leave unchanged"; an empty string clears the nullable ones, which
* is how the API itself distinguishes the two.
*/
onUpdateProfile(patch: {
username?: string;
display_name?: string;
about?: string;
}): Promise<void>;
/** Upload an avatar image. Resolves with the URL the server stored. */
onUploadAvatar(file: File): Promise<string>;
onLogout(): void;
onDeleteAccount(password: string): Promise<void>;
onStatusChange(status: UserStatus): void;
@@ -62,6 +74,11 @@ const TAB_ICONS: Record<TabName, IconName> = {
Logs: "scroll-text",
};
/** Stable DOM id for a tab button (aria-labelledby target), e.g. "settings-tab-text-images". */
function tabId(name: TabName): string {
return `settings-tab-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
@@ -72,11 +89,14 @@ export function createSettingsOverlay(
const ac = new AbortController();
const authenticated = options.isAuthenticated !== false;
let root: HTMLDivElement | null = null;
let panel: HTMLDivElement | null = null;
let contentArea: HTMLDivElement | null = null;
let pageTitle: HTMLHeadingElement | null = null;
let activeTab: TabName = authenticated ? "Account" : "Appearance";
/** False once the active tab's content has been torn down by `hide()`. */
let contentLive = false;
/** Puts focus back on whatever opened the panel; null while closed. */
let restoreFocus: (() => void) | null = null;
const tabButtons = new Map<TabName, HTMLButtonElement>();
let unsubUi: (() => void) | null = null;
let unsubAuth: (() => void) | null = null;
@@ -128,16 +148,25 @@ export function createSettingsOverlay(
for (const [name, btn] of tabButtons) {
btn.classList.toggle("active", name === tab);
btn.setAttribute("aria-selected", name === tab ? "true" : "false");
// Roving tabindex: only the active tab sits in the page Tab order.
btn.setAttribute("tabindex", name === tab ? "0" : "-1");
}
contentArea?.setAttribute("aria-labelledby", tabId(tab));
renderActiveTab();
}
function show(): void {
const wasOpen = root?.classList.contains("open") ?? false;
root?.classList.add("open");
// Closing tore down the live parts of the active tab (mic meter, camera
// preview, log listener). Rebuild it so a reopened panel shows live state
// instead of a frozen snapshot — and so every tab re-reads current prefs.
if (!contentLive) renderActiveTab();
// Move focus in only on the closed→open transition — a repeated show()
// would otherwise capture an element inside the panel as the "opener".
if (!wasOpen && panel !== null) {
restoreFocus = focusDialog(panel);
}
}
function hide(): void {
@@ -145,6 +174,9 @@ export function createSettingsOverlay(
// Stop camera preview, mic meter, and the log listener when the overlay closes
cleanupActiveTab();
contentLive = false;
// Hand focus back to whatever opened the panel.
restoreFocus?.();
restoreFocus = null;
}
// ---- MountableComponent ---------------------------------------------------
@@ -152,8 +184,42 @@ export function createSettingsOverlay(
function mount(container: Element): void {
root = createElement("div", { class: "settings-overlay", "data-testid": "settings-overlay" });
// Sidebar
const sidebar = createElement("div", { class: "settings-sidebar" });
// Sidebar. It doubles as the tablist: the profile block, category headings
// ("User Settings" / "App Settings") and the Log Out button also live in
// here, and a tablist should own only tabs — but moving them out would
// change the structure the e2e selectors pin down, so we accept that the
// non-tab children are presentational noise inside the tablist (DC-13).
const sidebar = createElement("div", {
class: "settings-sidebar",
role: "tablist",
"aria-orientation": "vertical",
"aria-label": "Settings sections",
});
// Arrow-key navigation between tabs, activate-on-focus (the simpler
// conformant flavor of the WAI-ARIA tabs pattern). Vertical list, so only
// Up/Down move; Home/End jump to the edges; both directions wrap.
sidebar.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") {
return;
}
const order = [...tabButtons.keys()];
const current = order.findIndex((name) => tabButtons.get(name) === e.target);
if (current === -1) return; // e.g. the Log Out button — not a tab
e.preventDefault();
let next: number;
if (e.key === "ArrowDown") next = (current + 1) % order.length;
else if (e.key === "ArrowUp") next = (current - 1 + order.length) % order.length;
else if (e.key === "Home") next = 0;
else next = order.length - 1;
const name = order[next]!;
setActiveTab(name);
tabButtons.get(name)?.focus();
},
{ signal: ac.signal },
);
// User profile section at top of sidebar
const user = authStore.getState().user;
@@ -202,8 +268,10 @@ export function createSettingsOverlay(
const accountBtn = createElement("button", {
class: `settings-nav-item${activeTab === "Account" ? " active" : ""}`,
id: tabId("Account"),
role: "tab",
"aria-selected": activeTab === "Account" ? "true" : "false",
tabindex: activeTab === "Account" ? "0" : "-1",
});
accountBtn.prepend(createIcon(TAB_ICONS["Account"], 18));
accountBtn.appendChild(document.createTextNode("Account"));
@@ -229,8 +297,10 @@ export function createSettingsOverlay(
for (const name of appTabs) {
const btn = createElement("button", {
class: `settings-nav-item${name === activeTab ? " active" : ""}`,
id: tabId(name),
role: "tab",
"aria-selected": name === activeTab ? "true" : "false",
tabindex: name === activeTab ? "0" : "-1",
});
btn.prepend(createIcon(TAB_ICONS[name], 18));
btn.appendChild(document.createTextNode(name));
@@ -252,8 +322,12 @@ export function createSettingsOverlay(
// Page title (h1) at top of content area — created here, inserted in renderActiveTab
pageTitle = createElement("h1", {}, activeTab);
// Content
contentArea = createElement("div", { class: "settings-content" });
// Content — the single tabpanel, renamed per switch via aria-labelledby
contentArea = createElement("div", {
class: "settings-content",
role: "tabpanel",
"aria-labelledby": tabId(activeTab),
});
// Close button wrapped with ESC label
const closeWrap = createElement("div", { class: "settings-close-wrap" });
@@ -281,7 +355,11 @@ export function createSettingsOverlay(
);
// Inner panel (Discord-style centered card)
const panel = createElement("div", { class: "settings-panel" });
panel = createElement("div", { class: "settings-panel" });
applyDialogSemantics(panel, { label: "Settings" });
// Arming the trap while hidden is safe: Tab can't land inside a
// display:none panel, so the handler only fires while the overlay is open.
trapFocus(panel, ac.signal);
appendChildren(panel, sidebar, contentArea, closeWrap);
// Click backdrop (outside panel) to close
@@ -332,10 +410,14 @@ export function createSettingsOverlay(
logsTab.cleanup();
voiceTab.cleanup();
tabButtons.clear();
// Tearing down while open still hands focus back to the opener.
restoreFocus?.();
restoreFocus = null;
if (root !== null) {
root.remove();
root = null;
}
panel = null;
contentArea = null;
pageTitle = null;
}
@@ -8,6 +8,7 @@ import { createElement, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
import type { UserStatus } from "@lib/types";
import { MAX_CUSTOM_STATUS_LEN } from "@lib/userStatus";
// ---------------------------------------------------------------------------
// Types
@@ -16,11 +17,18 @@ import type { UserStatus } from "@lib/types";
export interface StatusPickerOptions {
readonly currentStatus: UserStatus;
readonly onStatusChange: (status: UserStatus) => void;
/** The custom status line to pre-fill the input with. */
readonly currentCustomStatus?: string;
/** Called when the user commits a custom status (Enter or blur). Passing an
* empty string means "clear it". Omitted = the input is not rendered. */
readonly onCustomStatusChange?: (text: string) => void;
}
export type StatusPickerComponent = MountableComponent & {
/** Update the displayed status without recreating the picker. */
setStatus(status: UserStatus): void;
/** Update the custom status input without recreating the picker. */
setCustomStatus(text: string): void;
};
// ---------------------------------------------------------------------------
@@ -33,11 +41,17 @@ interface StatusDef {
readonly color: string;
}
/**
* "invisible" is its own value now, not "offline" wearing a different label.
* The server stores it as chosen and shows everyone else offline, so the
* picker can finally send what it means — and the status survives a reconnect
* instead of flashing back to online.
*/
const STATUS_DEFS: readonly StatusDef[] = [
{ value: "online", label: "Online", color: "#3ba55d" },
{ value: "idle", label: "Idle", color: "#faa61a" },
{ value: "dnd", label: "Do Not Disturb", color: "#ed4245" },
{ value: "offline", label: "Invisible", color: "#747f8d" },
{ value: "invisible", label: "Invisible", color: "#747f8d" },
];
function colorForStatus(status: UserStatus): string {
@@ -57,6 +71,11 @@ export function createStatusPicker(options: StatusPickerOptions): StatusPickerCo
let dotEl: HTMLDivElement | null = null;
let dropdownEl: HTMLDivElement | null = null;
let checkEls = new Map<UserStatus, HTMLSpanElement>();
let customInputEl: HTMLInputElement | null = null;
/** Last text handed to the callback. Guards the blur-after-Enter double
* send, which would otherwise cost a second presence_update against the
* server's one-per-ten-seconds limit. */
let lastCommittedCustom = options.currentCustomStatus ?? "";
// ---- Dropdown visibility --------------------------------------------------
@@ -144,6 +163,58 @@ export function createStatusPicker(options: StatusPickerOptions): StatusPickerCo
return row;
}
/**
* The "Set a custom status" row. Only built when a handler was supplied —
* an input whose value goes nowhere is worse than no input.
*/
function buildCustomStatusRow(onChange: (text: string) => void): HTMLDivElement {
const row = createElement("div", { class: "status-picker-custom" });
const input = createElement("input", {
class: "status-picker-custom-input",
type: "text",
placeholder: "Set a custom status",
maxlength: String(MAX_CUSTOM_STATUS_LEN),
"aria-label": "Custom status",
"data-testid": "custom-status-input",
});
input.value = options.currentCustomStatus ?? "";
customInputEl = input;
const commit = (): void => {
const text = input.value.trim().slice(0, MAX_CUSTOM_STATUS_LEN);
if (text === lastCommittedCustom) return;
lastCommittedCustom = text;
input.value = text;
onChange(text);
};
input.addEventListener(
"keydown",
(e: KeyboardEvent) => {
// Keystrokes inside the input must not reach the dropdown's own
// Enter/Escape handling, which would close the menu mid-edit.
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
commit();
closeDropdown();
} else if (e.key === "Escape") {
e.preventDefault();
input.value = lastCommittedCustom;
closeDropdown();
}
},
{ signal },
);
input.addEventListener("blur", commit, { signal });
// The row is inside the dropdown; clicking the input must not be treated
// as picking a status or as an outside click.
input.addEventListener("click", (e: MouseEvent) => e.stopPropagation(), { signal });
row.appendChild(input);
return row;
}
// ---- MountableComponent ---------------------------------------------------
function mount(container: Element): void {
@@ -186,6 +257,11 @@ export function createStatusPicker(options: StatusPickerOptions): StatusPickerCo
for (const def of STATUS_DEFS) {
dropdownEl.appendChild(buildOption(def));
}
const onCustomStatusChange = options.onCustomStatusChange;
if (onCustomStatusChange !== undefined) {
dropdownEl.appendChild(createElement("div", { class: "status-picker-divider" }));
dropdownEl.appendChild(buildCustomStatusRow(onCustomStatusChange));
}
appendChildren(root, dotEl, dropdownEl);
container.appendChild(root);
@@ -221,11 +297,17 @@ export function createStatusPicker(options: StatusPickerOptions): StatusPickerCo
root = null;
dotEl = null;
dropdownEl = null;
customInputEl = null;
}
function setStatus(status: UserStatus): void {
applyStatus(status);
}
return { mount, destroy, setStatus };
function setCustomStatus(text: string): void {
lastCommittedCustom = text;
if (customInputEl !== null) customInputEl.value = text;
}
return { mount, destroy, setStatus, setCustomStatus };
}
+10 -1
View File
@@ -100,7 +100,16 @@ export function createToastContainer(): ToastContainer {
}
function mount(container: Element): void {
root = createElement("div", { class: "toast-container", "data-testid": "toast-container" });
// One polite live region for all toasts (DC-13): screen readers announce
// each toast as it is appended without interrupting current speech.
// aria-atomic="false" so only the newly added toast is read, not the stack.
root = createElement("div", {
class: "toast-container",
"data-testid": "toast-container",
role: "status",
"aria-live": "polite",
"aria-atomic": "false",
});
container.appendChild(root);
}
@@ -56,7 +56,13 @@ export function createTypingIndicator(options: TypingIndicatorOptions): Mountabl
}
function mount(container: Element): void {
root = createElement("div", { class: "typing-bar" });
// Polite live region (DC-13): "X is typing" changes are announced without
// interrupting whatever the screen reader is currently speaking.
root = createElement("div", {
class: "typing-bar",
role: "status",
"aria-live": "polite",
});
updateFromState();
@@ -90,6 +90,10 @@ export function createUpdateNotifier(options: UpdateNotifierOptions): MountableC
// App will relaunch — this code won't execute after relaunch()
} catch (err) {
log.error("Update install failed", { error: String(err) });
// The component may have been destroyed while the download was in
// flight (page swap / logout) -- the banner it wanted to repaint is
// already gone, so there is nothing left to do.
if (banner === null) return;
while (banner.firstChild) banner.removeChild(banner.firstChild);
const errorText = createElement(
"span",
+99 -19
View File
@@ -11,7 +11,15 @@ import { authStore } from "@stores/auth.store";
import { openSettings, uiStore } from "@stores/ui.store";
import { createStatusPicker, type StatusPickerComponent } from "@components/StatusPicker";
import type { UserStatus } from "@lib/types";
import { loadUserStatus, onUserStatusChange, saveUserStatus } from "@lib/userStatus";
import {
loadCustomStatus,
loadUserStatus,
onUserStatusChange,
saveCustomStatus,
saveUserStatus,
} from "@lib/userStatus";
import { avatarInitial, isRenderableAvatar, resolveDisplayName } from "@lib/avatar";
import { fetchImageAsDataUrl, resolveServerUrl } from "@components/message-list/attachments";
import type { WsClient } from "@lib/ws";
export interface UserBarOptions {
@@ -19,6 +27,15 @@ export interface UserBarOptions {
readonly ws?: WsClient | null;
}
/** Status labels for the line under the username. */
const STATUS_TEXT: Readonly<Record<UserStatus, string>> = {
online: "Online",
idle: "Idle",
dnd: "Do Not Disturb",
invisible: "Invisible",
offline: "Offline",
};
export function createUserBar(options?: UserBarOptions): MountableComponent {
const disposable = new Disposable();
let root: HTMLDivElement | null = null;
@@ -26,24 +43,71 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
// Element references for targeted updates
let avatarEl: HTMLDivElement | null = null;
let avatarTextEl: HTMLSpanElement | null = null;
let avatarImgEl: HTMLImageElement | null = null;
/** Avatar URL currently rendered, so a re-render for an unrelated auth
* change doesn't re-fetch the same picture. */
let renderedAvatarUrl: string | null = null;
let nameEl: HTMLSpanElement | null = null;
let statusEl: HTMLSpanElement | null = null;
let statusPicker: StatusPickerComponent | null = null;
/** Swap the letter for the uploaded picture, or back again. */
function renderAvatar(subject: {
username: string;
displayName: string | null;
avatar: string | null;
}): void {
if (avatarEl === null) return;
if (avatarTextEl !== null) setText(avatarTextEl, avatarInitial(subject));
const url = isRenderableAvatar(subject.avatar) ? resolveServerUrl(subject.avatar) : null;
if (url === renderedAvatarUrl) return;
renderedAvatarUrl = url;
if (avatarImgEl !== null) {
avatarImgEl.remove();
avatarImgEl = null;
}
if (url === null) {
if (avatarTextEl !== null) avatarTextEl.style.display = "";
avatarEl.style.background = "var(--accent)";
return;
}
void fetchImageAsDataUrl(url).then((dataUrl) => {
// The URL may have changed again (or the bar been torn down) while the
// bytes were in flight.
if (dataUrl === null || avatarEl === null || renderedAvatarUrl !== url) return;
const img = createElement("img", {
class: "avatar-img",
src: dataUrl,
alt: subject.username,
});
avatarImgEl = img;
if (avatarTextEl !== null) avatarTextEl.style.display = "none";
avatarEl.style.background = "transparent";
avatarEl.insertBefore(img, avatarEl.firstChild);
});
}
function updateFromState(): void {
const state = authStore.getState();
const user = state.user;
const username = user?.username ?? "Unknown";
const initial = username.charAt(0).toUpperCase() || "?";
const subject = {
username: user?.username ?? "Unknown",
displayName: user?.display_name ?? null,
avatar: user?.avatar ?? null,
};
if (avatarTextEl !== null) {
setText(avatarTextEl, initial);
}
renderAvatar(subject);
if (nameEl !== null) {
setText(nameEl, username);
setText(nameEl, resolveDisplayName(subject));
}
if (statusEl !== null) {
setText(statusEl, state.isAuthenticated ? "Online" : "Offline");
// The bar shows the user's own chosen status, invisible included —
// everyone else is told offline, but lying to the owner about their own
// state is exactly the bug real invisible exists to fix.
const text = state.isAuthenticated ? (STATUS_TEXT[loadUserStatus()] ?? "Online") : "Offline";
setText(statusEl, text);
}
}
@@ -56,19 +120,15 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
});
avatarTextEl = createElement("span", {});
avatarEl.appendChild(avatarTextEl);
const statusDot = createElement("div", {
class: "status-dot",
style:
"background: var(--green); width: 10px; height: 10px; border-radius: 50%; position: absolute; bottom: 0; right: 0;",
});
avatarEl.appendChild(statusDot);
const info = createElement("div", { class: "ub-info" });
nameEl = createElement("span", { class: "ub-name", "data-testid": "user-bar-name" });
statusEl = createElement("span", { class: "ub-status" });
appendChildren(info, nameEl, statusEl);
// Status picker — anchored below username, opens upward
// Status picker — the dot itself lives in the avatar's corner (same spot
// the old plain status indicator occupied) so it doubles as the status
// display and its click target; the dropdown still opens upward from there.
const statusPickerWrap = createElement("div", {
class: "ub-status-picker-wrap",
"data-testid": "status-picker-wrap",
@@ -86,21 +146,39 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
// Start from the stored selection, not a hardcoded "online" — otherwise
// this picker and the settings Account tab show different statuses.
currentStatus: loadUserStatus(),
currentCustomStatus: loadCustomStatus(),
onStatusChange: (status: UserStatus) => {
saveUserStatus(status);
updateFromState();
const ws = options?.ws;
if (ws !== null && ws !== undefined && canSetStatus()) {
// No custom_status field: a plain status change must leave whatever
// text the user set standing.
ws.send({ type: "presence_update", payload: { status } } as never);
}
},
onCustomStatusChange: (text: string) => {
saveCustomStatus(text);
const ws = options?.ws;
if (ws !== null && ws !== undefined && canSetStatus()) {
ws.send({
type: "presence_update",
payload: { status: loadUserStatus(), custom_status: text },
} as never);
}
},
});
statusPicker.mount(statusPickerWrap);
// Reflect status changes made on the settings Account tab.
disposable.addCleanup(
onUserStatusChange((status) => statusPicker?.setStatus(status), {
signal: disposable.signal,
}),
onUserStatusChange(
(status) => {
statusPicker?.setStatus(status);
updateFromState();
},
{ signal: disposable.signal },
),
);
// Disable picker (with a reason) when the connection is down
@@ -121,7 +199,7 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
() => updatePickerDisabled(),
);
info.appendChild(statusPickerWrap);
avatarEl.appendChild(statusPickerWrap);
const buttons = createElement("div", { class: "ub-controls" });
@@ -172,6 +250,8 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
}
avatarEl = null;
avatarTextEl = null;
avatarImgEl = null;
renderedAvatarUrl = null;
nameEl = null;
statusEl = null;
}
@@ -3,17 +3,19 @@
* in the chat or member list. Shows avatar, username, role badge, status dot,
* about section, join date, and Message/Call action buttons.
*
* Position: anchored to click point, flips if <100px from viewport edge.
* Animation: fade+scale 100ms.
* Position: anchored to the click point, flipped to the other side and clamped
* against the measured card height so it always lands fully on screen.
* Animation: fade+scale, defined in CSS so reduced-motion can drop it.
* Close: outside click or Escape.
* A11y: role="dialog", aria-label, focus trap, return focus on close.
*/
import { createElement, appendChildren } from "@lib/dom";
import { createElement, appendChildren, setText } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
import type { UserStatus } from "@lib/types";
import { isSafeUrl } from "./message-list/attachments";
import { createAvatarElement, resolveDisplayName } from "@lib/avatar";
import { roleColorVar } from "./message-list/formatting";
// ---------------------------------------------------------------------------
// Types
@@ -25,7 +27,12 @@ export interface UserProfileData {
readonly avatar: string | null;
readonly role: string;
readonly status: UserStatus;
/** Nickname. When set the popup shows it as the heading and the username
* underneath, because the username is still the handle you @mention. */
readonly displayName?: string | null;
readonly about?: string | null;
/** Free-text status line, shown under the name. */
readonly customStatus?: string | null;
readonly joinDate?: string | null;
readonly isDeleted?: boolean;
}
@@ -51,13 +58,18 @@ export type UserProfilePopupComponent = MountableComponent & {
// ---------------------------------------------------------------------------
const POPUP_WIDTH = 300;
const EDGE_THRESHOLD = 100;
const ANIMATION_DURATION_MS = 100;
/** Keeps the card clear of the window edges on both axes. */
const VIEWPORT_MARGIN = 8;
/** Breathing room between the click point and the card. */
const ANCHOR_GAP = 8;
const STATUS_COLORS: Record<UserStatus, string> = {
online: "#3ba55d",
idle: "#faa61a",
dnd: "#ed4245",
// Only ever reached for the signed-in user looking at their own profile —
// the server maps invisible to offline for everyone else.
invisible: "#747f8d",
offline: "#747f8d",
};
@@ -65,16 +77,10 @@ const STATUS_LABELS: Record<UserStatus, string> = {
online: "Online",
idle: "Idle",
dnd: "Do Not Disturb",
invisible: "Invisible",
offline: "Offline",
};
const ROLE_COLORS: Record<string, string> = {
owner: "#e74c3c",
admin: "#f39c12",
moderator: "#2ecc71",
member: "#949ba4",
};
// ---------------------------------------------------------------------------
// Component factory
// ---------------------------------------------------------------------------
@@ -107,53 +113,56 @@ export function createUserProfilePopup(
}
}
function computePosition(anchorX: number, anchorY: number): { left: number; top: number } {
/**
* Place the card beside the anchor, flipping and clamping so it always lands
* fully on screen — Discord opens its popout away from whichever edge the
* clicked row is nearest.
*
* The height is measured rather than assumed. The previous version guessed
* 300px and only clamped the top edge, so a member clicked low in the list
* opened a card that ran off the bottom of the window.
*/
function position(el: HTMLElement, anchorX: number, anchorY: number): void {
const vw = window.innerWidth;
const vh = window.innerHeight;
const height = el.offsetHeight;
let left = anchorX;
// Prefer the right of the anchor and flip left when there is no room. The
// member list sits against the right edge, so flipping is the usual case.
let left = anchorX + ANCHOR_GAP;
if (left + POPUP_WIDTH > vw - VIEWPORT_MARGIN) {
left = anchorX - POPUP_WIDTH - ANCHOR_GAP;
}
left = Math.max(VIEWPORT_MARGIN, Math.min(left, vw - POPUP_WIDTH - VIEWPORT_MARGIN));
// Align the top with the click, then lift the card just enough to fit.
let top = anchorY;
// Flip horizontally if too close to right edge
if (vw - anchorX < EDGE_THRESHOLD) {
left = anchorX - POPUP_WIDTH;
if (top + height > vh - VIEWPORT_MARGIN) {
top = vh - height - VIEWPORT_MARGIN;
}
top = Math.max(VIEWPORT_MARGIN, top);
// Flip vertically if too close to bottom edge
if (vh - anchorY < EDGE_THRESHOLD) {
top = anchorY - 300; // approximate popup height
}
// Clamp to viewport
left = Math.max(8, Math.min(left, vw - POPUP_WIDTH - 8));
top = Math.max(8, top);
return { left, top };
el.style.left = `${left}px`;
el.style.top = `${top}px`;
}
function buildAvatar(user: UserProfileData): HTMLDivElement {
const wrapper = createElement("div", { class: "upp-avatar" });
if (user.isDeleted === true) {
wrapper.style.background = "#4e5058";
const text = createElement("span", {}, "?");
wrapper.appendChild(text);
} else if (user.avatar !== null && user.avatar.length > 0 && isSafeUrl(user.avatar)) {
const img = createElement("img", {
src: user.avatar,
alt: user.username,
class: "upp-avatar-img",
});
img.style.width = "64px";
img.style.height = "64px";
img.style.borderRadius = "50%";
wrapper.appendChild(img);
} else {
wrapper.style.background = "var(--accent, #5865f2)";
const initial = user.username.charAt(0).toUpperCase() || "?";
const text = createElement("span", {}, initial);
wrapper.appendChild(text);
}
// The shared helper is what makes uploaded avatars work here and in the
// message rows and member list at the same time: it fetches the
// authenticated file through the cert-pinned path and falls back to the
// letter until (or unless) the bytes arrive.
const wrapper = createAvatarElement(
{
username: user.username,
displayName: user.displayName,
avatar: user.avatar,
isDeleted: user.isDeleted,
},
{
className: "upp-avatar",
background: user.isDeleted === true ? "#4e5058" : "var(--accent, #5865f2)",
},
);
// Status dot overlay
const statusDot = createElement("div", { class: "upp-status-dot" });
@@ -167,7 +176,7 @@ export function createUserProfilePopup(
function mount(container: Element): void {
previousFocus = document.activeElement;
const user = options.user;
const displayName = user.isDeleted === true ? "[deleted]" : user.username;
const displayName = user.isDeleted === true ? "[deleted]" : resolveDisplayName(user);
// Overlay for outside-click detection
overlay = createElement("div", {
@@ -185,17 +194,8 @@ export function createUserProfilePopup(
"data-testid": "user-profile-popup",
});
// Position the popup
const pos = computePosition(options.anchorX, options.anchorY);
popup.style.left = `${pos.left}px`;
popup.style.top = `${pos.top}px`;
popup.style.width = `${POPUP_WIDTH}px`;
// Animation: fade + scale
popup.style.opacity = "0";
popup.style.transform = "scale(0.95)";
popup.style.transition = `opacity ${ANIMATION_DURATION_MS}ms ease, transform ${ANIMATION_DURATION_MS}ms ease`;
// --- Content ---
// Avatar
@@ -207,10 +207,24 @@ export function createUserProfilePopup(
nameEl.style.color = "var(--text-faint, #80848e)";
}
// Username line, shown only when a display name is standing in for it.
// @mentions still resolve by username, so the popup has to keep telling
// you what to type.
const handleEl = createElement("div", { class: "upp-username-handle" });
if (user.isDeleted !== true && displayName !== user.username) {
setText(handleEl, `@${user.username}`);
}
// Custom status line — the user's own words, under the name.
const customStatusEl = createElement("div", { class: "upp-custom-status" });
if (typeof user.customStatus === "string" && user.customStatus.length > 0) {
setText(customStatusEl, user.customStatus);
}
// Role badge
const roleBadge = createElement("span", { class: "upp-role-badge" });
const roleDot = createElement("span", { class: "upp-role-dot" });
roleDot.style.background = ROLE_COLORS[user.role] ?? ROLE_COLORS.member ?? "";
roleDot.style.background = roleColorVar(user.role.toLowerCase());
const roleLabel = createElement(
"span",
{},
@@ -244,62 +258,82 @@ export function createUserProfilePopup(
// Divider
const divider = createElement("div", { class: "upp-divider" });
// Actions
// Actions — only render buttons that are actually wired up, so the popup
// never shows a dead control (e.g. Call before DM calls exist, or Message
// on your own profile).
const actions = createElement("div", { class: "upp-actions" });
const messageBtn = createElement("button", {
class: "upp-action-btn",
"data-testid": "upp-message-btn",
});
messageBtn.appendChild(createIcon("send", 16));
messageBtn.appendChild(document.createTextNode(" Message"));
messageBtn.addEventListener(
"click",
() => {
options.onMessage?.(user.id);
close();
},
{ signal },
);
if (options.onMessage !== undefined) {
const onMessage = options.onMessage;
const messageBtn = createElement("button", {
class: "upp-action-btn",
"data-testid": "upp-message-btn",
});
messageBtn.appendChild(createIcon("send", 16));
messageBtn.appendChild(document.createTextNode(" Message"));
messageBtn.addEventListener(
"click",
() => {
onMessage(user.id);
close();
},
{ signal },
);
actions.appendChild(messageBtn);
}
const callBtn = createElement("button", {
class: "upp-action-btn",
"data-testid": "upp-call-btn",
});
callBtn.appendChild(createIcon("phone", 16));
callBtn.appendChild(document.createTextNode(" Call"));
callBtn.addEventListener(
"click",
() => {
options.onCall?.(user.id);
close();
},
{ signal },
);
if (options.onCall !== undefined) {
const onCall = options.onCall;
const callBtn = createElement("button", {
class: "upp-action-btn",
"data-testid": "upp-call-btn",
});
callBtn.appendChild(createIcon("phone", 16));
callBtn.appendChild(document.createTextNode(" Call"));
callBtn.addEventListener(
"click",
() => {
onCall(user.id);
close();
},
{ signal },
);
actions.appendChild(callBtn);
}
appendChildren(actions, messageBtn, callBtn);
// Assemble popup
// Assemble the card: a banner strip and a body, with the avatar straddling
// the seam between them the way Discord's popout does.
const banner = createElement("div", { class: "upp-banner" });
const body = createElement("div", { class: "upp-body" });
appendChildren(
popup,
avatar,
body,
nameEl,
handleEl,
customStatusEl,
roleBadge,
statusLine,
aboutSection,
joinSection,
divider,
actions,
);
if (actions.childElementCount > 0) {
appendChildren(body, divider, actions);
}
// The avatar hangs off the body's top edge, so it is a child of the card
// rather than the body — the body scrolls, and a scroll container clips.
// Appending it last puts it over the banner without needing a z-index.
appendChildren(popup, banner, body, avatar);
overlay.appendChild(popup);
container.appendChild(overlay);
// Trigger animation
// Measure, then place: the card has to be in the document before it has a
// height to clamp against.
position(popup, options.anchorX, options.anchorY);
// The fade+scale itself lives in CSS so `prefers-reduced-motion` can drop it.
requestAnimationFrame(() => {
if (popup !== null) {
popup.style.opacity = "1";
popup.style.transform = "scale(1)";
popup.classList.add("open");
}
});
@@ -8,6 +8,7 @@ import { createIcon } from "@lib/icons";
import {
getScreenshareAudioMuted,
getScreenshareAudioVolume,
getUserVolume,
muteScreenshareAudio,
setScreenshareAudioVolume,
setUserVolume,
@@ -26,8 +27,11 @@ export interface TileConfig {
export interface VideoGridComponent extends MountableComponent {
addStream(userId: number, username: string, stream: MediaStream, config?: TileConfig): void;
removeStream(userId: number): void;
/** Remove every tile — used on a real voice leave so stale remote tiles
* from the previous session don't survive into the next join. */
clearStreams(): void;
hasStreams(): boolean;
setFocusedTile(tileId: number): void;
setFocusedTile(tileId: number | null): void;
getFocusedTileId(): number | null;
}
@@ -224,7 +228,7 @@ export function createVideoGrid(): VideoGridComponent {
}
}
function setFocusedTile(tileId: number): void {
function setFocusedTile(tileId: number | null): void {
focusedTileId = tileId;
rebuildFocusLayout();
}
@@ -306,13 +310,18 @@ export function createVideoGrid(): VideoGridComponent {
// Add audio control overlay for remote tiles
if (config !== undefined && !config.isSelf) {
// Screenshare audio state survives tile rebuilds — initialize from it.
// Screenshare sliders are 0-100 (HTMLAudioElement.volume caps at 1.0);
// mic sliders keep 0-200 (LiveKit setVolume supports boost up to 2.0).
let muted = config.isScreenshare ? getScreenshareAudioMuted(config.audioUserId) : false;
let currentVolume = config.isScreenshare
// Mic and screenshare audio state both survive tile rebuilds —
// initialize from the same persisted values the sidebar volume menu
// reads, instead of hardcoding "unmuted at 100%" (B3-5). Screenshare
// sliders are 0-100 (HTMLAudioElement.volume caps at 1.0); mic sliders
// keep 0-200 (LiveKit setVolume supports boost up to 2.0).
const savedVolume = config.isScreenshare
? Math.round(getScreenshareAudioVolume(config.audioUserId) * 100)
: 100;
: getUserVolume(config.audioUserId);
let currentVolume = savedVolume;
let muted = config.isScreenshare
? getScreenshareAudioMuted(config.audioUserId)
: savedVolume === 0;
const overlay = createElement("div", { class: "video-tile-overlay" });
@@ -421,6 +430,15 @@ export function createVideoGrid(): VideoGridComponent {
}
}
/** Remove every tile (trackCleanup + srcObject=null via removeStream).
* Deleting the current key mid-iteration is well-defined for Map — no
* entries are skipped — so this needs no snapshot copy of the keys. */
function clearStreams(): void {
for (const userId of cells.keys()) {
removeStream(userId);
}
}
function hasStreams(): boolean {
return cells.size > 0;
}
@@ -470,6 +488,7 @@ export function createVideoGrid(): VideoGridComponent {
destroy,
addStream,
removeStream,
clearStreams,
hasStreams,
setFocusedTile,
getFocusedTileId: getFocusedTileIdFn,
@@ -11,6 +11,7 @@ import type { IconName } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
import { voiceStore, type VoiceStatus } from "@stores/voice.store";
import { channelsStore } from "@stores/channels.store";
import { dmStore, dmDisplayName } from "@stores/dm.store";
import { uiStore } from "@stores/ui.store";
import {
createConnectionStatsPoller,
@@ -230,22 +231,44 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
updateStatus(voice.voiceStatus);
updateFrozen(uiStore.getState().connectionStatus);
// Channel name
// Channel name. A DM call resolves through the DM store rather than the
// channels store: the channels-store row for a DM is synthesised when the
// conversation is opened, so accepting a call for a DM the user has not
// looked at yet would otherwise label the call "Voice Channel".
const channel = channelsStore.getState().channels.get(channelId);
setText(channelNameEl, channel?.name ?? "Voice Channel");
const dm = dmStore.getState().channels.find((c) => c.channelId === channelId);
setText(
channelNameEl,
dm !== undefined ? dmDisplayName(dm) : (channel?.name ?? "Voice Channel"),
);
// Toggle button active states, swap icons, and update aria-pressed
muteBtn?.classList.toggle("active-ctrl", voice.localMuted);
deafenBtn?.classList.toggle("active-ctrl", voice.localDeafened);
cameraBtn?.classList.toggle("active-ctrl", voice.localCamera);
// A moderator-imposed mute/deafen is not ours to lift: the server refuses
// the unmute, so disable the control and say why instead of letting the
// click bounce off with an error toast.
const serverMuted = voice.localServerMuted === true;
const serverDeafened = voice.localServerDeafened === true;
if (muteBtn) {
swapIcon(muteBtn, voice.localMuted ? "mic-off" : "mic");
muteBtn.setAttribute("aria-pressed", String(voice.localMuted));
// Only ever tighten: updateFrozen ran above and owns the socket-down
// disable, which must not be relaxed here.
if (serverMuted) {
muteBtn.disabled = true;
muteBtn.title = "You were muted by a moderator";
}
}
if (deafenBtn) {
swapIcon(deafenBtn, voice.localDeafened ? "headphones-off" : "headphones");
deafenBtn.setAttribute("aria-pressed", String(voice.localDeafened));
if (serverDeafened) {
deafenBtn.disabled = true;
deafenBtn.title = "You were deafened by a moderator";
}
}
if (cameraBtn) {
swapIcon(cameraBtn, voice.localCamera ? "camera-off" : "camera");
@@ -435,6 +458,8 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
channelId: s.currentChannelId,
muted: s.localMuted,
deafened: s.localDeafened,
serverMuted: s.localServerMuted,
serverDeafened: s.localServerDeafened,
camera: s.localCamera,
screenshare: s.localScreenshare,
listenOnly: s.listenOnly,
@@ -445,6 +470,8 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
a.channelId === b.channelId &&
a.muted === b.muted &&
a.deafened === b.deafened &&
a.serverMuted === b.serverMuted &&
a.serverDeafened === b.serverDeafened &&
a.camera === b.camera &&
a.screenshare === b.screenshare &&
a.listenOnly === b.listenOnly &&
@@ -1,26 +1,52 @@
/**
* Channel context menu — right-click on a channel for Edit/Delete actions.
* Only shown to admin/owner roles.
* Channel context menu — right-click on a channel for Mark as Read/Edit/Delete/
* Purge. Mark as Read is offered to everyone (it only touches the caller's own
* read state); Edit and Delete follow the server's MANAGE_CHANNELS gate; Purge
* follows its MANAGE_MESSAGES gate.
*
* Both gates are permission bits, not role names: a custom role granted
* MANAGE_CHANNELS could edit a channel through the API while the client hid
* the menu item, because the old check asked whether the role was literally
* called "owner" or "admin".
*/
import { createElement } from "@lib/dom";
import type { Channel } from "@stores/channels.store";
import { getCurrentUser } from "@stores/auth.store";
import { hasPermission, currentUserPermissions, canManageChannels } from "@lib/permissions";
import { Permission } from "@lib/types";
import { markChannelRead, hasUnread } from "@lib/read-state";
import { isChannelMuted, toggleChannelMute } from "@lib/channel-mutes";
import { appendPurgeSection } from "@components/purge-prompt";
/** Attach a right-click context menu to a channel element for edit/delete. */
/** Bubbles from a channel row when its mute is toggled. */
export const CHANNEL_MUTE_CHANGED = "owncord:channel-mute-changed";
/** Attach a right-click context menu to a channel element for edit/delete/purge. */
export function attachChannelContextMenu(
el: HTMLElement,
channel: Channel,
signal: AbortSignal,
onEdit?: (channel: Channel) => void,
onDelete?: (channel: Channel) => void,
onPurge?: (channel: Channel, count: number) => Promise<void>,
): void {
if (onEdit === undefined && onDelete === undefined) {
return;
}
const user = getCurrentUser();
const role = user?.role?.toLowerCase() ?? "";
if (role !== "owner" && role !== "admin") {
const canManage = canManageChannels();
// Voice channels hold no messages, and the server rejects a purge in a DM,
// so the section is offered only where it can succeed.
const canPurge =
onPurge !== undefined &&
channel.type !== "voice" &&
hasPermission(currentUserPermissions(), Permission.MANAGE_MESSAGES);
const showEdit = canManage && onEdit !== undefined;
const showDelete = canManage && onDelete !== undefined;
// Mark as Read touches only the caller's own read state, so it needs no
// permission — but a voice channel holds no messages to read.
const showMarkRead = channel.type !== "voice";
// Muting silences notifications, which a voice channel does not produce.
const showMute = channel.type !== "voice";
if (!showMarkRead && !showMute && !showEdit && !showDelete && !canPurge) {
return;
}
@@ -40,7 +66,67 @@ export function attachChannelContextMenu(
menu.style.left = `${e.clientX}px`;
menu.style.top = `${e.clientY}px`;
if (onEdit !== undefined) {
if (showMarkRead) {
// Disabled rather than hidden: a menu whose entries move between
// right-clicks is harder to use than one with a greyed-out row.
const unread = hasUnread(channel.id);
const markItem = createElement(
"div",
{
class: unread ? "context-menu-item" : "context-menu-item disabled",
"data-testid": "ctx-mark-read",
},
"Mark as Read",
);
if (unread) {
markItem.addEventListener(
"click",
() => {
closeMenu();
markChannelRead(channel.id);
},
{ signal },
);
}
menu.appendChild(markItem);
}
if (showMute) {
// "Until turned off": there is no timed mute, because a timed one needs
// a stored expiry the client would have to sweep, and the affordance it
// buys ("quiet for 8 hours") is one the user can reproduce by unmuting.
const muted = isChannelMuted(channel.id);
const muteItem = createElement(
"div",
{ class: "context-menu-item", "data-testid": "ctx-mute-channel" },
muted ? "Unmute Channel" : "Mute Channel",
);
muteItem.addEventListener(
"click",
() => {
closeMenu();
toggleChannelMute(channel.id);
// Mute state lives in localStorage, so there is no store change to
// subscribe to. A bubbling DOM event lets the sidebar redraw the
// row without threading a callback through four layers of
// positional render arguments.
el.dispatchEvent(
new CustomEvent(CHANNEL_MUTE_CHANGED, {
bubbles: true,
detail: { channelId: channel.id },
}),
);
},
{ signal },
);
menu.appendChild(muteItem);
}
if ((showMarkRead || showMute) && (showEdit || showDelete || canPurge)) {
menu.appendChild(createElement("div", { class: "context-menu-sep" }));
}
if (showEdit && onEdit !== undefined) {
const editItem = createElement(
"div",
{ class: "context-menu-item", "data-testid": "ctx-edit-channel" },
@@ -57,8 +143,8 @@ export function attachChannelContextMenu(
menu.appendChild(editItem);
}
if (onDelete !== undefined) {
if (onEdit !== undefined) {
if (showDelete && onDelete !== undefined) {
if (showEdit) {
menu.appendChild(createElement("div", { class: "context-menu-sep" }));
}
const deleteItem = createElement(
@@ -77,6 +163,17 @@ export function attachChannelContextMenu(
menu.appendChild(deleteItem);
}
if (canPurge && onPurge !== undefined) {
appendPurgeSection(menu, {
itemClass: "context-menu-item",
dangerItemClass: "context-menu-item danger",
separatorClass: showEdit || showDelete ? "context-menu-sep" : "",
onPurge: (count) => onPurge(channel, count),
signal,
onDone: () => closeMenu(),
});
}
document.body.appendChild(menu);
// Close menu on click elsewhere — use a per-menu AbortController
@@ -85,7 +182,10 @@ export function attachChannelContextMenu(
menu.remove();
menuAc.abort();
};
signal.addEventListener("abort", () => menuAc.abort());
// Tie this bridge listener's own lifetime to menuAc so it does not
// outlive the menu it belongs to — closeMenu (which aborts menuAc)
// already fires far more often than the sidebar's own teardown.
signal.addEventListener("abort", closeMenu, { signal: menuAc.signal });
// Defer so this click event doesn't immediately close it
setTimeout(() => {
if (menuAc.signal.aborted) return;
@@ -1,13 +1,13 @@
/**
* Channel drag-reorder — mouse-based drag-and-drop for channel reordering.
* Uses mousedown/mousemove/mouseup (avoids WebView2 HTML5 DnD issues).
* Admin/owner only.
* Gated on MANAGE_CHANNELS, like every other channel-management affordance.
*/
import { getCurrentUser } from "@stores/auth.store";
import { updateChannelPosition } from "@stores/channels.store";
import type { Channel } from "@stores/channels.store";
import type { ChannelReorderData } from "../ChannelSidebar";
import { canManageChannels } from "@lib/permissions";
// ── Drag state (mouse-based, avoids WebView2 HTML5 DnD issues) ──
interface DragState {
@@ -16,17 +16,49 @@ interface DragState {
containerEl: HTMLElement;
channels: readonly Channel[];
onReorder: (reorders: readonly ChannelReorderData[]) => void;
/** The signal of the sidebar that started this drag, so its teardown can
* clear the in-flight visual state without touching another sidebar's. */
owner: AbortSignal;
}
let activeDrag: DragState | null = null;
/** Global mousemove/mouseup handlers for drag reordering. Registered once.
* Reference-counted so multiple sidebar instances share the same listeners
* and only the last destroy tears them down. */
/** Global mousemove/mouseup handlers for drag reordering, shared by every
* sidebar instance. Ownership is tracked per AbortSignal — the sidebar's
* lifetime controller — not per attached channel row: attachDragHandlers runs
* once per row per render, and the per-row ref-count this replaced meant a
* sidebar took N references its single destroy could never return, so the two
* document listeners lived for the rest of the process (the KNOWN BUG
* drag-reorder.test.ts pinned until this fix). An owner's release is its
* signal's abort — the same AbortController teardown idiom as
* {@link ../../lib/disposable} — so there is no separate release call to
* forget or miscount. */
const listenerOwners = new Set<AbortSignal>();
let globalDragAc: AbortController | null = null;
let globalDragRefCount = 0;
export function ensureGlobalDragListeners(): void {
globalDragRefCount++;
function releaseOwner(owner: AbortSignal): void {
listenerOwners.delete(owner);
// A sidebar destroyed mid-drag must not leave the row stuck in the dragging
// state or the body stuck in reorder mode.
if (activeDrag !== null && activeDrag.owner === owner) {
activeDrag.sourceEl.classList.remove("dragging");
document.body.classList.remove("channel-reordering");
activeDrag.containerEl.querySelectorAll(".channel-drop-indicator").forEach((x) => {
x.classList.remove("channel-drop-indicator");
});
activeDrag = null;
}
if (listenerOwners.size === 0 && globalDragAc !== null) {
globalDragAc.abort();
globalDragAc = null;
}
}
export function ensureGlobalDragListeners(owner: AbortSignal): void {
if (owner.aborted || listenerOwners.has(owner)) {
return;
}
listenerOwners.add(owner);
owner.addEventListener("abort", () => releaseOwner(owner), { once: true });
if (globalDragAc !== null) {
return;
}
@@ -111,17 +143,23 @@ export function ensureGlobalDragListeners(): void {
...withoutDrag.slice(insertIdx),
];
// Build reorder data and update store immediately
// Build reorder data and update store immediately. Reassign the
// group's own existing position slots, not a 0..n-1 range: the
// server's position space is global, so a category can sit at
// non-contiguous positions (interleaved with other categories), and
// renumbering from 0 would stomp another category's slots.
const slots = drag.channels.map((c) => c.position).sort((a, b) => a - b);
const reorders: ChannelReorderData[] = [];
for (let i = 0; i < reorderedIds.length; i++) {
const id = reorderedIds[i];
if (id === undefined) {
const newPosition = slots[i];
if (id === undefined || newPosition === undefined) {
continue;
}
const ch = drag.channels.find((c) => c.id === id);
if (ch !== undefined && ch.position !== i) {
reorders.push({ channelId: id, newPosition: i });
updateChannelPosition(id, i);
if (ch !== undefined && ch.position !== newPosition) {
reorders.push({ channelId: id, newPosition });
updateChannelPosition(id, newPosition);
}
}
@@ -133,7 +171,7 @@ export function ensureGlobalDragListeners(): void {
);
}
/** Make a channel element draggable via mousedown (admin/owner only). */
/** Make a channel element draggable via mousedown (MANAGE_CHANNELS only). */
export function attachDragHandlers(
el: HTMLElement,
channel: Channel,
@@ -145,13 +183,15 @@ export function attachDragHandlers(
if (onReorderChannel === undefined) {
return;
}
const user = getCurrentUser();
const role = user?.role?.toLowerCase() ?? "";
if (role !== "owner" && role !== "admin") {
// The one derivation for every channel-management affordance (create, edit,
// delete, reorder) — a custom role holding the bit gets the same rows the
// Edit/Delete menu already offers it, and a role merely *named* "admin"
// without the bit does not get a drag the server will 403.
if (!canManageChannels()) {
return;
}
ensureGlobalDragListeners();
ensureGlobalDragListeners(signal);
el.classList.add("channel-draggable");
el.dataset.dragChannelId = String(channel.id);
@@ -173,6 +213,15 @@ export function attachDragHandlers(
el.addEventListener(
"mousemove",
(e) => {
// Defuse a stale latch: pendingDrag is cleared only by a mouseup on
// this same row (see the listener below), so releasing the button
// anywhere else — off this row entirely, or via a fast flick — leaves
// it armed. A later button-free hover would otherwise promote it into
// a real drag on the next `if` below.
if (e.buttons === 0) {
pendingDrag = null;
return;
}
if (pendingDrag === null || activeDrag !== null) {
return;
}
@@ -189,6 +238,7 @@ export function attachDragHandlers(
containerEl,
channels,
onReorder: onReorderChannel,
owner: signal,
};
el.classList.add("dragging");
document.body.classList.add("channel-reordering");
@@ -204,18 +254,3 @@ export function attachDragHandlers(
{ signal },
);
}
/** Decrement global drag listener ref-count; tear down when no more sidebars. */
export function releaseGlobalDragListeners(containerEl?: HTMLElement): void {
// Clear stale drag state if the destroyed sidebar owns the active drag
if (containerEl !== undefined && activeDrag?.containerEl === containerEl) {
activeDrag.sourceEl.classList.remove("dragging");
document.body.classList.remove("channel-reordering");
activeDrag = null;
}
globalDragRefCount = Math.max(0, globalDragRefCount - 1);
if (globalDragRefCount === 0 && globalDragAc !== null) {
globalDragAc.abort();
globalDragAc = null;
}
}
@@ -1,17 +1,33 @@
/**
* Per-user volume context menu — right-click on a voice user row
* to adjust their playback volume locally.
* Per-user context menu on a voice participant row: local playback volume for
* everyone, plus a moderation section for users whose role holds MUTE_MEMBERS.
*/
import { createElement, setText, appendChildren } from "@lib/dom";
import { setUserVolume, getUserVolume } from "@lib/livekitSession";
/** Moderation section wiring. Passed only when the local user may moderate
* voice; the menu renders the section iff this is present, so the permission
* decision stays with the caller (which knows the role list). */
export interface VoiceModMenuOptions {
/** Current moderator-imposed state of the target, for the toggle labels. */
readonly serverMuted: boolean;
readonly serverDeafened: boolean;
/** Voice channels the target can be moved to (the current one excluded). */
readonly moveTargets: readonly { readonly id: number; readonly name: string }[];
readonly onServerMute: (muted: boolean) => void;
readonly onServerDeafen: (deafened: boolean) => void;
readonly onMove: (toChannelId: number) => void;
readonly onDisconnect: () => void;
}
export function showUserVolumeMenu(
userId: number,
username: string,
x: number,
y: number,
signal: AbortSignal,
mod?: VoiceModMenuOptions,
): void {
// Remove any existing context menus and abort their dismiss controllers
document.querySelectorAll(".user-vol-menu").forEach((el) => {
@@ -85,6 +101,12 @@ export function showUserVolumeMenu(
});
menu.appendChild(resetBtn);
if (mod !== undefined) {
appendModerationSection(menu, mod, () => {
menu.remove();
});
}
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
document.body.appendChild(menu);
@@ -106,9 +128,92 @@ export function showUserVolumeMenu(
);
}, 0);
// Also clean up if the parent component is destroyed
signal.addEventListener("abort", () => {
menu.remove();
dismissAc.abort();
// Also clean up if the parent component is destroyed. Tied to dismissAc's
// own signal (mirrors context-menu.ts's menuAc pattern) so this bridge
// listener is torn down with the menu itself — otherwise it never runs
// (the parent signal is long-lived) and every right-click permanently
// accumulates one closure retaining a detached .user-vol-menu subtree.
signal.addEventListener(
"abort",
() => {
menu.remove();
dismissAc.abort();
},
{ signal: dismissAc.signal },
);
}
/** Builds the moderation rows. close() runs after any action so the menu does
* not linger showing stale labels while the server round-trip is in flight. */
function appendModerationSection(
menu: HTMLElement,
mod: VoiceModMenuOptions,
close: () => void,
): void {
menu.appendChild(createElement("div", { class: "context-menu-sep" }));
const muteItem = createElement(
"div",
{ class: "context-menu-item", "data-action": "server-mute" },
mod.serverMuted ? "Server Unmute" : "Server Mute",
);
muteItem.addEventListener("click", () => {
mod.onServerMute(!mod.serverMuted);
close();
});
menu.appendChild(muteItem);
const deafenItem = createElement(
"div",
{ class: "context-menu-item", "data-action": "server-deafen" },
mod.serverDeafened ? "Server Undeafen" : "Server Deafen",
);
deafenItem.addEventListener("click", () => {
mod.onServerDeafen(!mod.serverDeafened);
close();
});
menu.appendChild(deafenItem);
if (mod.moveTargets.length > 0) {
// Hover-revealed flyout, same shape as the AdminActions role submenu.
const moveWrap = createElement("div", {
class: "context-menu-item context-menu-item--submenu",
"data-action": "move-to",
});
moveWrap.appendChild(createElement("span", {}, "Move to"));
const sub = createElement("div", { class: "context-menu__submenu" });
sub.style.display = "none";
moveWrap.addEventListener("mouseenter", () => {
sub.style.display = "";
});
moveWrap.addEventListener("mouseleave", () => {
sub.style.display = "none";
});
for (const ch of mod.moveTargets) {
const item = createElement(
"div",
{ class: "context-menu-item", "data-move-channel": String(ch.id) },
ch.name,
);
item.addEventListener("click", (e) => {
e.stopPropagation();
mod.onMove(ch.id);
close();
});
sub.appendChild(item);
}
moveWrap.appendChild(sub);
menu.appendChild(moveWrap);
}
const kickItem = createElement(
"div",
{ class: "context-menu-item danger", "data-action": "voice-disconnect" },
"Disconnect",
);
kickItem.addEventListener("click", () => {
mod.onDisconnect();
close();
});
menu.appendChild(kickItem);
}
@@ -0,0 +1,203 @@
/**
* inline-autocomplete — the shared listbox the composer opens over the textarea
* for "@" mentions and ":" emoji. Both popups are the same widget: a filtered,
* keyboard-navigable list whose rows are chosen on mousedown (never click, so
* the textarea keeps focus). Only the suggestion source, the row contents, and
* a couple of flags differ, so those are injected and everything else — arrow
* navigation, Enter/Tab/Escape handling, AbortController cleanup — lives here
* once instead of being duplicated in each popup.
*
* Accessibility-wise this is a WAI-ARIA combobox, not a menu: DOM focus stays
* in the composer textarea the whole time (moving it into the list would stop
* keystrokes from reaching the textarea, so the rows deliberately get no
* roving tabindex) and the "focused" row is conveyed purely through
* aria-activedescendant on the textarea, pointing at per-row ids stamped on
* every render.
*
* Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
*/
import { createElement, clearChildren, appendChildren } from "@lib/dom";
export interface InlineAutocompleteConfig<T> {
/**
* CSS class(es) on the root element. Mentions use `"mention-autocomplete"`;
* emoji use `"mention-autocomplete emoji-autocomplete"` (sharing the base
* class deliberately — a composer test selects
* `.mention-autocomplete:not(.emoji-autocomplete)` to tell them apart).
*/
readonly rootClass: string;
/** `data-testid` on the root element. */
readonly rootTestId: string;
/** Suggestions for the text typed after the trigger, already ordered/capped. */
readonly filter: (query: string) => T[];
/** The value passed to onSelect when a row is chosen (token / insert text). */
readonly valueOf: (item: T) => string;
/** `data-testid` for one row. */
readonly rowTestId: (item: T) => string;
/** The children of one row (name/detail spans, an optional preview, …). */
readonly renderRow: (item: T) => readonly HTMLElement[];
/**
* When true, prime the list with `setQuery("")` on creation so the popup
* opens already populated (mentions list every member; emoji stay empty
* until the composer types past the minimum query).
*/
readonly primeOnCreate?: boolean;
/** Called with `valueOf(picked)` when a row is chosen. */
readonly onSelect: (value: string) => void;
/** Called when the user dismisses the popup (Escape). */
readonly onClose: () => void;
/**
* The composer control this popup completes for (the textarea). While the
* popup exists it carries combobox semantics — role="combobox",
* aria-autocomplete="list", aria-expanded="true", aria-controls={list id} —
* plus aria-activedescendant tracking the active row; destroy() removes
* them all again. DOM focus never moves here: it must stay in the textarea
* so typing keeps working, which is why the rows have no tabindex.
*/
readonly comboboxInput?: HTMLElement;
}
export interface InlineAutocompleteComponent {
readonly element: HTMLDivElement;
/**
* Re-filter for `query`. Returns false when nothing matches, which the
* composer treats as "close the popup" rather than leaving an empty box.
*/
setQuery(query: string): boolean;
/** Handle a composer keydown. Returns true when the key was consumed. */
handleKeydown(e: KeyboardEvent): boolean;
destroy(): void;
}
/** The combobox state a popup stamps on its input, removed again on destroy. */
const COMBOBOX_ATTRS = [
"role",
"aria-autocomplete",
"aria-expanded",
"aria-controls",
"aria-activedescendant",
] as const;
export function createInlineAutocomplete<T>(
cfg: InlineAutocompleteConfig<T>,
): InlineAutocompleteComponent {
const ac = new AbortController();
const signal = ac.signal;
let suggestions: T[] = [];
let activeIndex = 0;
// The testid is already unique per widget, so it doubles as a stable DOM id
// for aria-controls / aria-activedescendant to point at.
const rootId = cfg.rootTestId;
const root = createElement("div", {
class: cfg.rootClass,
id: rootId,
role: "listbox",
"data-testid": cfg.rootTestId,
});
const list = createElement("div", { class: "ma-list" });
root.appendChild(list);
const input = cfg.comboboxInput ?? null;
if (input !== null) {
input.setAttribute("role", "combobox");
input.setAttribute("aria-autocomplete", "list");
// The popup only exists while it is open (the composer destroys it to
// close), so "expanded" holds for this component's whole lifetime.
input.setAttribute("aria-expanded", "true");
input.setAttribute("aria-controls", rootId);
}
function choose(index: number): void {
const picked = suggestions[index];
if (picked === undefined) return;
cfg.onSelect(cfg.valueOf(picked));
}
function render(): void {
clearChildren(list);
for (let i = 0; i < suggestions.length; i++) {
const s = suggestions[i]!;
const row = createElement("div", {
class: i === activeIndex ? "ma-item ma-item--active" : "ma-item",
id: `${rootId}-option-${i}`,
role: "option",
"aria-selected": i === activeIndex ? "true" : "false",
"data-testid": cfg.rowTestId(s),
});
appendChildren(row, ...cfg.renderRow(s));
// mousedown, not click: the textarea must not lose focus before the
// insertion runs.
row.addEventListener(
"mousedown",
(e: MouseEvent) => {
e.preventDefault();
choose(i);
},
{ signal },
);
list.appendChild(row);
}
// Rows are rebuilt with index-based ids, so the pointer must be re-aimed
// on every render, not just when activeIndex moves.
if (input !== null) {
if (suggestions.length > 0) {
input.setAttribute("aria-activedescendant", `${rootId}-option-${activeIndex}`);
} else {
input.removeAttribute("aria-activedescendant");
}
}
}
function setQuery(query: string): boolean {
suggestions = cfg.filter(query);
activeIndex = 0;
render();
return suggestions.length > 0;
}
function handleKeydown(e: KeyboardEvent): boolean {
if (suggestions.length === 0) return false;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
activeIndex = (activeIndex + 1) % suggestions.length;
render();
return true;
case "ArrowUp":
e.preventDefault();
activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;
render();
return true;
case "Enter":
case "Tab":
e.preventDefault();
choose(activeIndex);
return true;
case "Escape":
e.preventDefault();
cfg.onClose();
return true;
default:
return false;
}
}
function destroy(): void {
ac.abort();
// Another popup may have claimed the input between this one's open and
// close (the composer opens the mention popup before closing the emoji
// one), so only strip the combobox state while it still points here.
if (input !== null && input.getAttribute("aria-controls") === rootId) {
for (const attr of COMBOBOX_ATTRS) input.removeAttribute(attr);
}
root.remove();
}
if (cfg.primeOnCreate === true) setQuery("");
return { element: root, setQuery, handleKeydown, destroy };
}
@@ -10,6 +10,7 @@ import { loadPref } from "@components/settings/helpers";
import { createLogger } from "@lib/logger";
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { ensureHttpProxy } from "@lib/httpProxy";
import { getToken } from "@stores/auth.store";
import { save } from "@tauri-apps/plugin-dialog";
const log = createLogger("attachments");
@@ -55,8 +56,48 @@ export function formatFileSize(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/** Strip any `; codecs=…` parameters and normalise case before matching. */
function baseMime(mime: string): string {
return (mime.split(";")[0] ?? "").trim().toLowerCase();
}
/** Whether the attachment should render as an inline <img>.
* image/svg+xml is excluded: an SVG can carry script, and it is the one image
* type the data-URI allowlist already refuses — inlining it only ever produced
* a permanently-loading placeholder, so it belongs on the download chip. */
export function isImageMime(mime: string): boolean {
return mime.startsWith("image/");
const base = baseMime(mime);
return base.startsWith("image/") && base !== "image/svg+xml";
}
/** Container MIME types we are willing to hand to a <video> element.
* An allowlist, not a `video/` prefix test: an unknown container gets the
* download chip rather than a player that silently fails to decode. */
const INLINE_VIDEO_MIMES = new Set(["video/mp4", "video/webm", "video/ogg"]);
/** Container MIME types we are willing to hand to an <audio> element.
* Includes the common aliases servers emit for MP3 and WAV. */
const INLINE_AUDIO_MIMES = new Set([
"audio/mpeg",
"audio/mp3",
"audio/ogg",
"audio/opus",
"audio/wav",
"audio/wave",
"audio/x-wav",
"audio/webm",
]);
/** Whether the attachment should render as an inline <video> player.
* image/svg+xml can never reach here — SVG stays excluded from every inline
* path because it can carry script. */
export function isVideoMime(mime: string): boolean {
return INLINE_VIDEO_MIMES.has(baseMime(mime));
}
/** Whether the attachment should render as an inline <audio> player. */
export function isAudioMime(mime: string): boolean {
return INLINE_AUDIO_MIMES.has(baseMime(mime));
}
export function isSafeUrl(url: string): boolean {
@@ -81,6 +122,11 @@ export function clearAttachmentCaches(): void {
attachmentCacheGeneration += 1;
memoryCache.clear();
inFlight.clear();
for (const objectUrl of mediaObjectUrls.values()) {
revokeObjectUrl(objectUrl);
}
mediaObjectUrls.clear();
mediaInFlight.clear();
}
/** Safe MIME types allowed in data: URIs — blocks script injection via crafted Content-Type. */
@@ -125,16 +171,23 @@ export function isTrustedServerUrl(url: string): boolean {
}
/**
* If `url` targets the OwnCord server, return an equivalent URL pointing at the
* Rust HTTP TOFU proxy's loopback origin (cert-pinned) with the same path and
* query. Non-server URLs (external images) are returned unchanged so they use a
* normal validated HTTPS fetch.
* Fetch `url`, routing OwnCord-server URLs through the Rust HTTP TOFU proxy's
* loopback origin (cert-pinned) with the session bearer token attached —
* /api/v1/files/{id} enforces channel ACLs, so an unauthenticated request
* would 401. The token is only ever sent to the configured server host;
* non-server URLs (external images) get a normal validated HTTPS fetch with
* no credentials.
*/
async function toFetchUrl(url: string): Promise<string> {
if (!isServerUrl(url)) return url;
async function fetchServerFile(url: string): Promise<Response> {
if (!isServerUrl(url)) return tauriFetch(url);
const parsed = new URL(url);
const origin = await ensureHttpProxy(parsed.host);
return `${origin}${parsed.pathname}${parsed.search}`;
const headers: Record<string, string> = {};
const token = getToken();
if (token !== null) {
headers["Authorization"] = `Bearer ${token}`;
}
return tauriFetch(`${origin}${parsed.pathname}${parsed.search}`, { headers });
}
/** In-flight fetch promises to prevent duplicate concurrent requests. */
@@ -253,7 +306,7 @@ export function fetchImageAsDataUrl(url: string): Promise<string | null> {
// use a normal validated HTTPS fetch. isSafeUrl restricts to http/https and
// responses are only used as image data, never executed.
try {
const res = await tauriFetch(await toFetchUrl(url));
const res = await fetchServerFile(url);
if (!res.ok) return null;
const rawCt = res.headers.get("content-type") ?? "";
@@ -291,11 +344,183 @@ export function fetchImageAsDataUrl(url: string): Promise<string | null> {
return promise;
}
// ---------------------------------------------------------------------------
// Media (video/audio) sources
// ---------------------------------------------------------------------------
/** Resolved blob: URLs keyed by attachment URL, so re-rendering a row (virtual
* scroll rebuilds the window constantly) reuses one download. */
const mediaObjectUrls = new Map<string, string>();
/** In-flight media fetches, deduplicated the same way images are. */
const mediaInFlight = new Map<string, Promise<string | null>>();
/** FIFO cap mirroring memoryCache's CACHE_MAX, kept far lower: each entry
* pins a whole video/audio Blob (not a small base64 thumbnail string), so an
* unbounded map here quietly holds every clip ever viewed in the session. */
const MEDIA_CACHE_MAX = 20;
function createObjectUrl(blob: Blob): string | null {
// jsdom (and any non-browser host) may not implement the object-URL API.
if (typeof URL.createObjectURL !== "function") return null;
return URL.createObjectURL(blob);
}
function revokeObjectUrl(objectUrl: string): void {
if (typeof URL.revokeObjectURL !== "function") return;
URL.revokeObjectURL(objectUrl);
}
/**
* Fetch a video/audio attachment through the same authenticated,
* cert-pinned path images use (fetchServerFile attaches the session bearer
* token, which /api/v1/files/{id} requires) and hand back a blob: URL.
*
* Deliberately not the image path: a data: URI means base64-inflating the whole
* file into a string and parking it in the LRU + IndexedDB caches, which is
* fine for a 200 KB thumbnail and ruinous for a 50 MB video. The Content-Type
* goes through the same allowlist so a crafted header cannot turn a
* permission-checked download into an executable type.
*/
export function fetchMediaAsObjectUrl(url: string): Promise<string | null> {
const generation = attachmentCacheGeneration;
const cached = mediaObjectUrls.get(url);
if (cached !== undefined) return Promise.resolve(cached);
const existing = mediaInFlight.get(url);
if (existing !== undefined) return existing;
const promise = (async (): Promise<string | null> => {
try {
const res = await fetchServerFile(url);
if (!res.ok) return null;
const contentType = sanitizeContentType(res.headers.get("content-type") ?? "");
const buffer = await res.arrayBuffer();
const objectUrl = createObjectUrl(new Blob([buffer], { type: contentType }));
if (objectUrl === null) return null;
// A cache clear (channel switch, logout) during the fetch means this
// blob belongs to a session that is gone — release it rather than
// resurrecting it into the fresh cache.
if (generation !== attachmentCacheGeneration) {
revokeObjectUrl(objectUrl);
return null;
}
if (mediaObjectUrls.size >= MEDIA_CACHE_MAX) {
const firstKey = mediaObjectUrls.keys().next().value;
if (firstKey !== undefined) {
const evicted = mediaObjectUrls.get(firstKey);
mediaObjectUrls.delete(firstKey);
if (evicted !== undefined) revokeObjectUrl(evicted);
}
}
mediaObjectUrls.set(url, objectUrl);
return objectUrl;
} catch (err) {
log.error("Failed to fetch media attachment", { url, error: String(err) });
return null;
}
})();
mediaInFlight.set(url, promise);
void promise.finally(() => {
if (mediaInFlight.get(url) === promise) {
mediaInFlight.delete(url);
}
});
return promise;
}
// -- Attachment rendering -----------------------------------------------------
/** The filename + size + download row shared by the audio player and the
* generic file chip. */
function buildFileMeta(att: Attachment, resolvedUrl: string): HTMLDivElement {
const info = createElement("div", { class: "msg-file-meta" });
const nameEl = createElement("div", { class: "msg-file-name" }, att.filename);
nameEl.addEventListener("click", () => {
void downloadFile(resolvedUrl, att.filename);
});
const sizeEl = createElement("div", { class: "msg-file-size" }, formatFileSize(att.size));
appendChildren(info, nameEl, sizeEl);
return info;
}
/** The circular download button used by every non-image attachment shape. */
function buildDownloadButton(att: Attachment, resolvedUrl: string): HTMLButtonElement {
const btn = createElement("button", {
class: "msg-file-download",
title: "Download",
"aria-label": `Download ${att.filename}`,
});
btn.appendChild(createIcon("download", 16));
btn.addEventListener("click", () => {
void downloadFile(resolvedUrl, att.filename);
});
return btn;
}
/** Inline <video> player. Sized by the same .msg-image box as images so a
* video never blows the message column out; the source arrives asynchronously
* because it needs the session token attached. */
function renderVideoAttachment(att: Attachment, resolvedUrl: string): HTMLDivElement {
const wrap = createElement("div", { class: "msg-image msg-video" });
const video = createElement("video", { preload: "metadata" });
video.controls = true;
video.setAttribute("aria-label", att.filename);
wrap.appendChild(video);
const overlay = createElement("div", { class: "msg-media-overlay" });
overlay.appendChild(buildDownloadButton(att, resolvedUrl));
wrap.appendChild(overlay);
void fetchMediaAsObjectUrl(resolvedUrl).then((objectUrl) => {
if (objectUrl !== null) {
video.src = objectUrl;
} else {
wrap.classList.add("msg-media-failed");
}
});
return wrap;
}
/** Inline <audio> player: a compact row carrying the player plus the same
* filename / size / download affordances as the file chip. */
function renderAudioAttachment(att: Attachment, resolvedUrl: string): HTMLDivElement {
const wrap = createElement("div", { class: "msg-file msg-audio" });
const inner = createElement("div", { class: "msg-file-inner" });
const info = buildFileMeta(att, resolvedUrl);
const audio = createElement("audio", { preload: "metadata" });
audio.controls = true;
audio.setAttribute("aria-label", att.filename);
info.appendChild(audio);
appendChildren(inner, info, buildDownloadButton(att, resolvedUrl));
wrap.appendChild(inner);
void fetchMediaAsObjectUrl(resolvedUrl).then((objectUrl) => {
if (objectUrl !== null) {
audio.src = objectUrl;
} else {
wrap.classList.add("msg-media-failed");
}
});
return wrap;
}
export function renderAttachment(att: Attachment): HTMLDivElement {
const resolvedUrl = resolveServerUrl(att.url);
if (isImageMime(att.mime) && isSafeUrl(resolvedUrl)) {
const inlineable = isSafeUrl(resolvedUrl);
if (inlineable && isVideoMime(att.mime)) {
return renderVideoAttachment(att, resolvedUrl);
}
if (inlineable && isAudioMime(att.mime)) {
return renderAudioAttachment(att, resolvedUrl);
}
if (isImageMime(att.mime) && inlineable) {
const wrap = createElement("div", { class: "msg-image" });
// Reserve space using server-provided dimensions to prevent layout shift.
@@ -383,22 +608,12 @@ export function renderAttachment(att: Attachment): HTMLDivElement {
const inner = createElement("div", { class: "msg-file-inner" });
const icon = createElement("div", { class: "msg-file-icon" });
icon.appendChild(createIcon("file-text", 20));
const nameEl = createElement("div", { class: "msg-file-name" }, att.filename);
nameEl.addEventListener("click", () => {
void downloadFile(resolvedUrl, att.filename);
});
const sizeEl = createElement("div", { class: "msg-file-size" }, formatFileSize(att.size));
const info = createElement("div", {});
appendChildren(info, nameEl, sizeEl);
const downloadBtn = createElement("button", {
class: "msg-file-download",
title: "Download",
});
downloadBtn.appendChild(createIcon("download", 16));
downloadBtn.addEventListener("click", () => {
void downloadFile(resolvedUrl, att.filename);
});
appendChildren(inner, icon, info, downloadBtn);
appendChildren(
inner,
icon,
buildFileMeta(att, resolvedUrl),
buildDownloadButton(att, resolvedUrl),
);
wrap.appendChild(inner);
return wrap;
}
@@ -413,8 +628,9 @@ async function downloadFile(url: string, filename: string): Promise<void> {
const filePath = await save({ defaultPath: filename });
if (filePath === null) return; // User cancelled
// Fetch file data — server downloads go through the cert-pinned HTTP proxy.
const res = await tauriFetch(await toFetchUrl(url));
// Fetch file data — server downloads go through the cert-pinned HTTP proxy
// with the session bearer token (the files endpoint requires auth).
const res = await fetchServerFile(url);
if (!res.ok) {
log.error("Download failed", { filename, status: res.status });
alert(`Download failed: server returned ${res.status}`);
@@ -1,41 +1,162 @@
/**
* Text content parsing — XSS-safe DOM builders for message text including
* inline code, code blocks, @mentions, and URL linkification.
* Text content parsing — XSS-safe DOM builders for message text.
*
* This is the *only* renderer for message content: Discord-flavoured markdown
* (inline styles, spoilers, quotes, headings, lists, masked links, fenced code
* with language tags), plus @mentions, #channel links and URL linkification.
*
* Everything here builds DOM nodes — never innerHTML — and every href is
* checked with isSafeUrl before it reaches an anchor.
*/
import { createElement, setText } from "@lib/dom";
import { navigateToChannel, findChannelByName, findChannelById } from "@lib/channel-navigation";
import { parseMessageLink } from "@lib/deep-link";
import { jumpToMessage } from "@lib/message-navigation";
import { authStore } from "@stores/auth.store";
import {
CHANNEL_TOKEN_REGEX,
MENTION_TOKEN_REGEX,
isEveryoneToken,
resolveMentionUserId,
type MentionInfo,
} from "@lib/mentions";
import { isSafeUrl } from "./attachments";
import { EMOJI_TOKEN_REGEX, buildCustomEmojiNode, isEmojiOnlyMessage } from "./custom-emoji";
import {
parseInline,
parseBlocks,
type BlockNode,
type InlineNode,
type InlineStyle,
} from "./markdown";
import { highlightCode, resolveLanguage } from "./syntax-highlight";
// -- Regex constants ----------------------------------------------------------
export const MENTION_REGEX = /@(\w+)/g;
export const CODE_BLOCK_REGEX = /```([\s\S]*?)```/g;
export const INLINE_CODE_REGEX = /`([^`]+)`/g;
export const URL_REGEX = /https?:\/\/[^\s<>"']+/g;
/** `[text](url)` — used to keep masked links from spawning link embeds. */
export const MASKED_LINK_REGEX = /\[[^\]\n]+\]\((?:[^()\s]|\([^()\s]*\))+\)/g;
/** `owncord://message/<channelId>/<messageId>` pasted into a message. */
export const MESSAGE_LINK_REGEX = /owncord:\/\/message\/\d+\/\d+/g;
// -- Content rendering --------------------------------------------------------
export type { MentionInfo };
export function renderInlineContent(text: string): DocumentFragment {
const fragment = document.createDocumentFragment();
let lastIndex = 0;
for (const match of text.matchAll(INLINE_CODE_REGEX)) {
const idx = match.index;
if (idx === undefined) continue;
if (idx > lastIndex) {
fragment.appendChild(renderMentions(text.slice(lastIndex, idx)));
/** Quotes may contain blocks, but a quote inside a quote inside a quote is a
* fight the renderer does not need to have. */
const MAX_BLOCK_DEPTH = 2;
// -- Inline rendering ---------------------------------------------------------
const STYLE_TAGS = {
strong: "strong",
em: "em",
underline: "u",
strike: "s",
} as const satisfies Record<Exclude<InlineStyle, "spoiler">, keyof HTMLElementTagNameMap>;
const STYLE_CLASSES = {
strong: "md-bold",
em: "md-italic",
underline: "md-underline",
strike: "md-strike",
} as const;
/** A spoiler: obscured until the reader asks for it, one span at a time. */
function buildSpoiler(
node: { readonly children: readonly InlineNode[] },
info?: MentionInfo,
): HTMLSpanElement {
const span = createElement("span", {
class: "msg-spoiler",
role: "button",
tabindex: "0",
"aria-pressed": "false",
"aria-label": "Spoiler — click to reveal",
});
appendInline(span, node.children, info);
const reveal = (e: Event): void => {
if (span.classList.contains("revealed")) return;
// Swallow the activation that revealed the text: a link hiding under a
// spoiler must not open on the same click that uncovers it.
e.preventDefault();
e.stopPropagation();
span.classList.add("revealed");
span.setAttribute("aria-pressed", "true");
span.setAttribute("aria-label", "Spoiler — revealed");
};
span.addEventListener("click", reveal);
span.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") reveal(e);
});
return span;
}
/** A `[text](url)` anchor, or null when the URL is not a safe http(s) one. */
function buildMaskedLink(
node: { readonly url: string; readonly children: readonly InlineNode[] },
info?: MentionInfo,
): HTMLAnchorElement | null {
// Absolute http(s) only: isSafeUrl resolves relatives against the app
// origin, which is not something a message author gets to link to.
if (!/^https?:\/\//i.test(node.url) || !isSafeUrl(node.url)) return null;
const link = createElement("a", {
class: "msg-link",
href: node.url,
title: node.url,
target: "_blank",
rel: "noopener noreferrer",
});
appendInline(link, node.children, info);
return link;
}
/** Turn inline nodes into DOM under `parent`. */
function appendInline(parent: Node, nodes: readonly InlineNode[], info?: MentionInfo): void {
for (const node of nodes) {
switch (node.type) {
case "text":
// Plain runs are where mentions, #channels and bare URLs live.
parent.appendChild(renderMentions(node.value, info));
break;
case "code": {
const code = createElement("code", {});
setText(code, node.value);
parent.appendChild(code);
break;
}
case "link": {
const link = buildMaskedLink(node, info);
if (link !== null) parent.appendChild(link);
else parent.appendChild(document.createTextNode(node.raw));
break;
}
case "spoiler":
parent.appendChild(buildSpoiler(node, info));
break;
default: {
const el = createElement(STYLE_TAGS[node.type], { class: STYLE_CLASSES[node.type] });
appendInline(el, node.children, info);
parent.appendChild(el);
}
}
const code = createElement("code", {});
setText(code, match[1]!);
fragment.appendChild(code);
lastIndex = idx + match[0].length;
}
if (lastIndex < text.length) {
fragment.appendChild(renderMentions(text.slice(lastIndex)));
}
}
/**
* Render one run of inline text: markdown styles, code spans, masked links,
* mentions and autolinked URLs.
*/
export function renderInlineContent(text: string, info?: MentionInfo): DocumentFragment {
const fragment = document.createDocumentFragment();
appendInline(fragment, parseInline(text), info);
return fragment;
}
export function renderMentions(text: string): DocumentFragment {
export function renderMentions(text: string, info?: MentionInfo): DocumentFragment {
// First pass: split by URLs, then handle mentions in non-URL segments
const fragment = document.createDocumentFragment();
let lastIndex = 0;
@@ -43,7 +164,7 @@ export function renderMentions(text: string): DocumentFragment {
const idx = match.index;
if (idx === undefined) continue;
if (idx > lastIndex) {
fragment.appendChild(renderMentionSegment(text.slice(lastIndex, idx)));
fragment.appendChild(renderMentionSegment(text.slice(lastIndex, idx), info));
}
// Strip trailing punctuation that is likely sentence-level, not part of the URL
const rawUrl = match[0];
@@ -68,25 +189,158 @@ export function renderMentions(text: string): DocumentFragment {
lastIndex = idx + rawUrl.length;
}
if (lastIndex < text.length) {
fragment.appendChild(renderMentionSegment(text.slice(lastIndex)));
fragment.appendChild(renderMentionSegment(text.slice(lastIndex), info));
}
return fragment;
}
/** Render @mentions within a text segment (no URLs). */
export function renderMentionSegment(text: string): DocumentFragment {
const fragment = document.createDocumentFragment();
let lastIndex = 0;
for (const match of text.matchAll(MENTION_REGEX)) {
/** One recognised token in a prose segment, with the span it renders to. */
interface TokenMatch {
readonly start: number;
readonly end: number;
readonly node: Node;
}
/** Build the highlight span for a resolved @token, or null to leave it as text. */
function buildMentionNode(raw: string, token: string, info?: MentionInfo): HTMLSpanElement | null {
if (isEveryoneToken(token)) {
// A token the sender lacked MENTION_EVERYONE for carries no mention
// semantics at all — the server says so, and it must not read as one.
if (info?.mentionsEveryone !== true) return null;
const span = createElement("span", { class: "mention mention-everyone mention-self" });
setText(span, raw);
return span;
}
const userId = resolveMentionUserId(token, info);
if (userId === null) return null;
const isSelf = authStore.getState().user?.id === userId;
const span = createElement("span", {
class: isSelf ? "mention mention-self" : "mention",
"data-user-id": String(userId),
});
setText(span, raw);
return span;
}
/** Build the clickable chip for a `#name` that resolves, or null. */
function buildChannelNode(name: string): HTMLSpanElement | null {
const channel = findChannelByName(name);
if (channel === null) return null;
const chip = createElement("span", {
class: "channel-mention",
role: "link",
tabindex: "0",
"data-channel-id": String(channel.id),
title: `Go to #${channel.name}`,
});
setText(chip, `#${channel.name}`);
// Listeners are attached per node with no signal, matching the code-block
// copy button above: these spans live and die with the message row.
chip.addEventListener("click", () => navigateToChannel(channel.id));
chip.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigateToChannel(channel.id);
}
});
return chip;
}
/**
* Build the compact chip for a pasted `owncord://message/…` permalink, or null
* when the link does not parse or points at a channel this user cannot see —
* an unreachable jump reads better as the raw text it was typed as.
*/
function buildMessageLinkNode(url: string): HTMLSpanElement | null {
const link = parseMessageLink(url);
if (link === null) return null;
const channel = findChannelById(link.channelId);
if (channel === null) return null;
const chip = createElement("span", {
class: "message-link-chip",
role: "link",
tabindex: "0",
"data-channel-id": String(link.channelId),
"data-message-id": String(link.messageId),
title: `Jump to message in #${channel.name}`,
});
const label = createElement("span", { class: "mlc-channel" });
setText(label, `#${channel.name}`);
const action = createElement("span", { class: "mlc-action" });
setText(action, "Jump");
chip.appendChild(label);
chip.appendChild(action);
const go = (): void => jumpToMessage(link.channelId, link.messageId);
// Per-node listeners with no signal, like the #channel chip above: these
// spans live and die with the message row.
chip.addEventListener("click", go);
chip.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
go();
}
});
return chip;
}
/**
* Render @mentions, #channel links and message permalinks within a text
* segment (no http URLs). Tokens that resolve to nothing are left as plain text.
*/
export function renderMentionSegment(text: string, info?: MentionInfo): DocumentFragment {
const matches: TokenMatch[] = [];
for (const match of text.matchAll(MESSAGE_LINK_REGEX)) {
const idx = match.index;
if (idx === undefined) continue;
if (idx > lastIndex) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex, idx)));
const node = buildMessageLinkNode(match[0]);
if (node !== null) matches.push({ start: idx, end: idx + match[0].length, node });
}
for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {
const idx = match.index;
const lead = match[1];
const token = match[2];
if (idx === undefined || lead === undefined || token === undefined) continue;
if (match[3] === "@") continue; // address-shaped, e.g. "@bob@example.com"
const start = idx + lead.length;
const node = buildMentionNode(`@${token}`, token, info);
if (node !== null) matches.push({ start, end: start + token.length + 1, node });
}
for (const match of text.matchAll(CHANNEL_TOKEN_REGEX)) {
const idx = match.index;
const lead = match[1];
const name = match[2];
if (idx === undefined || lead === undefined || name === undefined) continue;
const start = idx + lead.length;
const node = buildChannelNode(name);
if (node !== null) matches.push({ start, end: start + name.length + 1, node });
}
// `:shortcode:` custom emoji. This runs on prose segments only — code spans
// never reach here (appendInline renders them verbatim) and fenced blocks are
// split off before any of this, so a shortcode inside code stays code.
for (const match of text.matchAll(EMOJI_TOKEN_REGEX)) {
const idx = match.index;
const shortcode = match[1];
if (idx === undefined || shortcode === undefined) continue;
const node = buildCustomEmojiNode(shortcode);
if (node !== null) matches.push({ start: idx, end: idx + match[0].length, node });
}
const fragment = document.createDocumentFragment();
matches.sort((a, b) => a.start - b.start);
let lastIndex = 0;
for (const m of matches) {
if (m.start < lastIndex) continue; // overlapping token, keep the first
if (m.start > lastIndex) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex, m.start)));
}
const span = createElement("span", { class: "mention" });
setText(span, match[0]);
fragment.appendChild(span);
lastIndex = idx + match[0].length;
fragment.appendChild(m.node);
lastIndex = m.end;
}
if (lastIndex < text.length) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
@@ -94,57 +348,176 @@ export function renderMentionSegment(text: string): DocumentFragment {
return fragment;
}
export function renderMessageContent(content: string): DocumentFragment {
const fragment = document.createDocumentFragment();
// -- Block rendering ----------------------------------------------------------
// Split on triple-backtick boundaries to avoid ReDoS from greedy regex.
// Odd-indexed segments are code block contents; even-indexed are prose.
const parts = content.split("```");
/** Render list items, folding indented ones into a single nested level. */
function buildList(
block: Extract<BlockNode, { type: "list" }>,
info: MentionInfo | undefined,
): HTMLElement {
const root = createElement(block.ordered ? "ol" : "ul", { class: "md-list" });
if (block.ordered && block.start !== 1) root.setAttribute("start", String(block.start));
let sublist: HTMLElement | null = null;
for (let i = 0; i < parts.length; i++) {
const segment = parts[i]!;
if (i % 2 === 0) {
// Prose segment
const trimmed = i === 0 ? segment : i === parts.length - 1 ? segment.trim() : segment;
if (trimmed.length > 0) {
const text = createElement("div", { class: "msg-text" });
text.appendChild(renderInlineContent(trimmed));
fragment.appendChild(text);
for (const item of block.items) {
const li = createElement("li", { class: "md-li" });
appendInline(li, parseInline(item.text), info);
const parentLi = root.lastElementChild;
if (item.level === 1 && parentLi !== null) {
if (sublist === null) {
sublist = createElement(item.ordered ? "ol" : "ul", { class: "md-list md-list-nested" });
parentLi.appendChild(sublist);
}
sublist.appendChild(li);
continue;
}
sublist = null;
root.appendChild(li);
}
return root;
}
/** Append the block structure of `text` to `parent`. */
function appendBlocks(parent: HTMLElement, text: string, info?: MentionInfo, depth = 0): void {
for (const block of parseBlocks(text)) {
switch (block.type) {
case "heading": {
const heading = createElement(`h${block.level}`, {
class: `md-heading md-h${block.level}`,
});
appendInline(heading, parseInline(block.text), info);
parent.appendChild(heading);
break;
}
case "quote": {
const quote = createElement("blockquote", { class: "md-quote" });
if (depth + 1 >= MAX_BLOCK_DEPTH) {
const para = createElement("div", { class: "md-p" });
appendInline(para, parseInline(block.text), info);
quote.appendChild(para);
} else {
appendBlocks(quote, block.text, info, depth + 1);
}
parent.appendChild(quote);
break;
}
case "list":
parent.appendChild(buildList(block, info));
break;
default: {
const para = createElement("div", { class: "md-p" });
appendInline(para, parseInline(block.text), info);
parent.appendChild(para);
}
} else {
// Code block segment
const codeContent = segment.trim();
const codeWrap = createElement("div", { class: "msg-codeblock-wrap" });
const codeBlock = createElement("div", { class: "msg-codeblock" });
setText(codeBlock, codeContent);
const copyBtn = createElement("button", { class: "msg-codeblock-copy" });
setText(copyBtn, "Copy");
copyBtn.addEventListener("click", () => {
void navigator.clipboard
.writeText(codeContent)
.then(() => {
setText(copyBtn, "Copied!");
setTimeout(() => setText(copyBtn, "Copy"), 2000);
})
.catch(() => {
setText(copyBtn, "Failed");
setTimeout(() => setText(copyBtn, "Copy"), 2000);
});
});
codeWrap.appendChild(codeBlock);
codeWrap.appendChild(copyBtn);
fragment.appendChild(codeWrap);
}
}
}
// If there were no code blocks at all, ensure at least one text node
if (parts.length === 1) {
const text = createElement("div", { class: "msg-text" });
text.appendChild(renderInlineContent(content));
// Replace the fragment content (it already has the same, but handle empty edge case)
if (fragment.childNodes.length === 0) {
fragment.appendChild(text);
// -- Code fences --------------------------------------------------------------
interface Segment {
readonly kind: "prose" | "code";
readonly text: string;
/** Raw fence tag, e.g. "ts" — present only on code segments that had one. */
readonly lang: string | null;
}
const FENCE = "```";
const LANG_TAG_REGEX = /^[A-Za-z][\w+#-]{0,19}$/;
/** Split a message into prose and fenced-code segments. */
export function splitCodeFences(content: string): Segment[] {
const segments: Segment[] = [];
let i = 0;
while (i < content.length) {
const open = content.indexOf(FENCE, i);
const close = open < 0 ? -1 : content.indexOf(FENCE, open + FENCE.length);
if (open < 0 || close < 0) break;
if (open > i) segments.push({ kind: "prose", text: content.slice(i, open), lang: null });
const inner = content.slice(open + FENCE.length, close);
const newline = inner.indexOf("\n");
const tag = newline > 0 ? inner.slice(0, newline).trim() : "";
if (tag.length > 0 && LANG_TAG_REGEX.test(tag)) {
segments.push({
kind: "code",
text: inner.slice(newline + 1).replace(/\s+$/, ""),
lang: tag,
});
} else {
segments.push({ kind: "code", text: inner.trim(), lang: null });
}
i = close + FENCE.length;
}
if (i < content.length) segments.push({ kind: "prose", text: content.slice(i), lang: null });
return segments;
}
/** A code block: language label, highlighted body, copy button. */
function renderCodeBlock(code: string, lang: string | null): HTMLDivElement {
const wrap = createElement("div", { class: "msg-codeblock-wrap" });
if (lang !== null) {
const label = createElement("span", { class: "msg-codeblock-lang" });
setText(label, lang);
wrap.appendChild(label);
}
const block = createElement("div", { class: "msg-codeblock" });
const canonical = resolveLanguage(lang);
if (canonical !== null) block.setAttribute("data-lang", canonical);
for (const token of highlightCode(code, canonical)) {
if (token.cls === null) {
block.appendChild(document.createTextNode(token.text));
continue;
}
const span = createElement("span", { class: `tok-${token.cls}` });
setText(span, token.text);
block.appendChild(span);
}
const copyBtn = createElement("button", { class: "msg-codeblock-copy" });
setText(copyBtn, "Copy");
copyBtn.addEventListener("click", () => {
void navigator.clipboard
.writeText(code)
.then(() => {
setText(copyBtn, "Copied!");
setTimeout(() => setText(copyBtn, "Copy"), 2000);
})
.catch(() => {
setText(copyBtn, "Failed");
setTimeout(() => setText(copyBtn, "Copy"), 2000);
});
});
wrap.appendChild(block);
wrap.appendChild(copyBtn);
return wrap;
}
export function renderMessageContent(content: string, info?: MentionInfo): DocumentFragment {
const fragment = document.createDocumentFragment();
// A message that is nothing but emoji renders them large, the way Discord
// does. Decided once over the whole content — the class is what sizes both
// the unicode glyphs and the custom-emoji images, so nothing downstream has
// to be told about it.
const jumboClass = isEmojiOnlyMessage(content) ? "msg-text msg-text-jumbo" : "msg-text";
for (const segment of splitCodeFences(content)) {
if (segment.kind === "code") {
fragment.appendChild(renderCodeBlock(segment.text, segment.lang));
continue;
}
// Blank lines hugging a fence are formatting, not content.
const prose = segment.text.replace(/^\n+/, "").replace(/\n+$/, "");
if (prose.trim().length === 0) continue;
const text = createElement("div", { class: jumboClass });
appendBlocks(text, prose, info);
fragment.appendChild(text);
}
return fragment;
@@ -0,0 +1,114 @@
/**
* Custom-emoji tokens in message content.
*
* `:shortcode:` renders as an inline image when the server knows that
* shortcode and as the literal text it was typed as when it does not — the
* same rule @mentions follow, and the reason a message full of colons never
* turns into a wall of broken images.
*
* The image itself is behind the session token (GET /api/v1/emoji/{id}/image
* is authenticated), so it is fetched through the same cert-pinned,
* bearer-token path attachments use and swapped in as a data: URI. Assigning
* the server URL straight to `img.src` would 401.
*/
import { createElement } from "@lib/dom";
import { resolveEmoji, type CustomEmoji } from "@stores/emoji.store";
import { fetchImageAsDataUrl, resolveServerUrl } from "./attachments";
/**
* A `:shortcode:` token. Case-insensitive on the way in (the store lowercases
* before lookup) so `:WAVE:` finds the same emoji `:wave:` does; the length
* bounds mirror the server's validator, so a token this matches is one the
* server could actually have stored.
*/
export const EMOJI_TOKEN_REGEX = /:([A-Za-z0-9_]{2,32}):/g;
/**
* How many emoji a message may hold and still render jumbo. Discord's number.
* Past it the message is a picture wall, not an expression, and 48px each
* would push the rest of the channel off screen.
*/
export const MAX_JUMBO_EMOJI = 27;
/** One unicode emoji, including skin tones, ZWJ sequences, flags and keycaps. */
const UNICODE_EMOJI = new RegExp(
"^(?:" +
// Keycap: digit/#/* + optional VS16 + the combining enclosing keycap.
"[0-9#*]\\uFE0F?\\u{20E3}" +
"|" +
// Regional-indicator pair (flags) or any pictographic base.
"(?:[\\u{1F1E6}-\\u{1F1FF}]|\\p{Extended_Pictographic})" +
// Modifiers, variation selectors and ZWJ-joined continuations.
"(?:\\uFE0F|\\u{20E3}|[\\u{1F3FB}-\\u{1F3FF}]|\\u200D(?:[\\u{1F1E6}-\\u{1F1FF}]|\\p{Extended_Pictographic})(?:\\uFE0F|[\\u{1F3FB}-\\u{1F3FF}])*)*" +
")",
"u",
);
/** A single `:shortcode:` anchored at the start of the remaining text. */
const LEADING_EMOJI_TOKEN = /^:([A-Za-z0-9_]{2,32}):/;
/**
* Whether a message is nothing but emoji, which is what earns the jumbo size.
*
* "Nothing but" is literal: whitespace, unicode emoji, and `:shortcodes:` that
* actually resolve. An unresolved shortcode is plain text, so `:nosuch:` alone
* is a normal message — sizing it jumbo would promise an image that is never
* going to appear.
*/
export function isEmojiOnlyMessage(content: string): boolean {
let rest = content.trim();
if (rest === "") return false;
let count = 0;
while (rest.length > 0) {
const ws = /^\s+/.exec(rest);
if (ws !== null) {
rest = rest.slice(ws[0].length);
continue;
}
const token = LEADING_EMOJI_TOKEN.exec(rest);
if (token !== null && resolveEmoji(token[1] ?? "") !== null) {
count++;
rest = rest.slice(token[0].length);
continue;
}
const unicode = UNICODE_EMOJI.exec(rest);
if (unicode !== null && unicode[0].length > 0) {
count++;
rest = rest.slice(unicode[0].length);
continue;
}
return false;
}
return count > 0 && count <= MAX_JUMBO_EMOJI;
}
/**
* The inline image for one custom emoji. The element is returned immediately
* with no `src`; the bytes arrive asynchronously and are swapped in when they
* do. Until then (and forever, if the fetch fails) the `alt` text is the
* shortcode, so the message still reads correctly.
*/
export function buildCustomEmojiImage(emoji: CustomEmoji): HTMLImageElement {
const img = createElement("img", {
class: "custom-emoji",
alt: `:${emoji.shortcode}:`,
title: `:${emoji.shortcode}:`,
"data-shortcode": emoji.shortcode,
draggable: "false",
});
void fetchImageAsDataUrl(resolveServerUrl(emoji.url)).then((dataUrl) => {
if (dataUrl !== null) img.src = dataUrl;
});
return img;
}
/**
* The image node for `token` (with or without colons), or null when no such
* emoji exists — the caller leaves an unresolved token as plain text.
*/
export function buildCustomEmojiNode(token: string): HTMLImageElement | null {
const emoji = resolveEmoji(token);
return emoji === null ? null : buildCustomEmojiImage(emoji);
}
@@ -3,6 +3,7 @@
* Pure functions for timestamp parsing, display formatting, and role resolution.
*/
import { channelsStore } from "@stores/channels.store";
import { membersStore } from "@stores/members.store";
import type { Message } from "@stores/messages.store";
import { loadPref } from "@components/settings/helpers";
@@ -123,10 +124,49 @@ export function getUserRole(userId: number): string {
return membersStore.getState().members.get(userId)?.role ?? "member";
}
/**
* The author identity to render for a message, resolved against the member
* store first and the message payload second.
*
* The store is preferred because it is the live copy: a rename or an avatar
* change arrives as a `user_update` and patches every member, while the
* messages already on screen keep whatever the author looked like when they
* posted. The payload is the fallback for someone who is not in the member
* list at all — a deleted account, or a poster from before this session.
*/
export function resolveAuthor(user: {
id: number;
username: string;
avatar: string | null;
display_name?: string | null;
}): { username: string; displayName: string | null; avatar: string | null } {
const member = membersStore.getState().members.get(user.id);
if (member !== undefined) {
return {
username: member.username,
displayName: member.displayName ?? null,
avatar: member.avatar,
};
}
return {
username: user.username,
displayName: user.display_name ?? null,
avatar: user.avatar,
};
}
export function roleColorVar(role: string): string {
if (!roleColorsEnabled) {
return "var(--role-member)";
}
// Prefer the server's role color (shipped in `ready`); the theme variables
// below are the fallback for the seeded roles when no color is set.
const serverRole = channelsStore
.getState()
.roles.find((r) => r.name.toLowerCase() === role.toLowerCase());
if (serverRole?.color != null && serverRole.color !== "") {
return serverRole.color;
}
switch (role) {
case "owner":
return "var(--role-owner)";
@@ -0,0 +1,422 @@
/**
* Discord-flavoured inline markdown tokenizer.
*
* Pure and DOM-free on purpose: this module only decides *what* the text
* means, `content-parser.ts` decides what nodes it becomes. Keeping the two
* apart is what lets the renderer stay a strict DOM builder (no innerHTML)
* while the grammar gets tested on its own.
*
* The tokenizer is a single left-to-right scan with recursive descent into
* matched delimiter pairs — not a stack of regexes — so nesting
* (`**bold *and italic* **`), escaping (`\*literal\*`) and "markdown is dead
* inside code" all fall out of one rule set instead of fighting each other.
*/
/** Emphasis-style wrappers, in the flavour Discord uses. */
export type InlineStyle = "strong" | "em" | "underline" | "strike" | "spoiler";
export type InlineNode =
| { readonly type: "text"; readonly value: string }
| { readonly type: "code"; readonly value: string }
| {
readonly type: "link";
readonly url: string;
readonly raw: string;
readonly children: readonly InlineNode[];
}
| { readonly type: InlineStyle; readonly children: readonly InlineNode[] };
/** Characters a backslash can neutralise. Anything else keeps its backslash. */
const ESCAPABLE = "\\`*_~|[]()>#-+.!";
/** Nesting cap — a guard against pathological input, not a style choice. */
const MAX_DEPTH = 6;
/** Bare-URL shape, kept in sync with URL_REGEX in content-parser. */
const URL_START = /^https?:\/\//i;
/** Shared empty map for `parseInline` calls with nothing to bracket-match. */
const EMPTY_MATCHES: ReadonlyMap<number, number> = new Map();
interface DelimSpec {
readonly marker: string;
/** Outermost style first; `***x***` is bold wrapping italic. */
readonly styles: readonly InlineStyle[];
}
/** Longest markers first — `***` must win over `**`, and `**` over `*`. */
const DELIMS: readonly DelimSpec[] = [
{ marker: "***", styles: ["strong", "em"] },
{ marker: "___", styles: ["underline", "em"] },
{ marker: "**", styles: ["strong"] },
{ marker: "__", styles: ["underline"] },
{ marker: "~~", styles: ["strike"] },
{ marker: "||", styles: ["spoiler"] },
{ marker: "*", styles: ["em"] },
{ marker: "_", styles: ["em"] },
];
function isWordChar(ch: string | undefined): boolean {
return ch !== undefined && /[A-Za-z0-9]/.test(ch);
}
function runLength(src: string, i: number, ch: string): number {
let n = 0;
while (i + n < src.length && src[i + n] === ch) n++;
return n;
}
/**
* End index (exclusive) of the code span opening at `i`, or -1 when it never
* closes. Supports single and double backtick fences.
*/
function codeSpanEnd(src: string, i: number): number {
const run = Math.min(runLength(src, i, "`"), 2);
const fence = "`".repeat(run);
const close = src.indexOf(fence, i + run);
if (close < 0) return -1;
return close + run;
}
/**
* End index (exclusive) of a bare URL starting at `i`, or `i` when there is
* none. Trailing delimiter runs are given back so `**https://a/b**` still
* closes its bold — a URL swallowing the closer is worse than a URL losing a
* trailing asterisk it almost certainly never had.
*/
function urlEnd(src: string, i: number): number {
if (!URL_START.test(src.slice(i, i + 8))) return i;
let end = i;
while (end < src.length && !/[\s<>"'`]/.test(src[end]!)) end++;
const min = i + 8;
for (;;) {
const tail = src.slice(min, end);
const run = /([*_~|])\1+$/.exec(tail);
if (run !== null) {
end -= run[0].length;
continue;
}
if (end > min && src[end - 1] === "*") {
end--;
continue;
}
break;
}
return end;
}
/** The delimiter opening at `i`, or null. */
function matchDelim(src: string, i: number): DelimSpec | null {
for (const d of DELIMS) {
if (!src.startsWith(d.marker, i)) continue;
// `snake_case_names` must stay literal: an underscore only opens emphasis
// on a word boundary.
if (d.marker[0] === "_" && isWordChar(src[i - 1])) continue;
return d;
}
return null;
}
/**
* Index of the delimiter run that closes `marker`, searching from `from`, or
* -1. Escapes, code spans and bare URLs are skipped so a closer hiding inside
* them is not mistaken for the real one.
*/
function scanClose(src: string, from: number, marker: string): number {
const ch = marker[0]!;
const len = marker.length;
let j = from;
while (j < src.length) {
const c = src[j]!;
if (c === "\\") {
j += 2;
continue;
}
if (c === "`") {
const end = codeSpanEnd(src, j);
if (end > 0) {
j = end;
continue;
}
}
const u = urlEnd(src, j);
if (u > j) {
j = u;
continue;
}
if (c === ch) {
const run = runLength(src, j, ch);
const usable = len > 1 ? run >= len : run === 1;
if (usable && (ch !== "_" || !isWordChar(src[j + len]))) return j;
j += run;
continue;
}
j++;
}
return -1;
}
/**
* Matches for every `openCh` in `src` to its balanced `closeCh`, computed in
* one linear pass with a stack (an opener that never closes just never gets
* an entry). A run's mismatched openers therefore cost O(1) each to look up
* instead of a fresh O(n) rescan apiece — the same semantics as calling a
* depth-counting scan from every individual opener (nesting balances the
* same way, escapes swallow the following character unconditionally, and a
* bare newline strands whatever is still open across it), just computed once
* per `parseInline` invocation instead of once per opener.
*/
function buildMatches(src: string, openCh: string, closeCh: string): ReadonlyMap<number, number> {
const matches = new Map<number, number>();
const stack: number[] = [];
for (let j = 0; j < src.length; j++) {
const c = src[j]!;
if (c === "\\") {
j++;
continue;
}
if (c === "\n") {
// Nothing left open can span a newline; abandon it rather than let it
// match something on a later line.
stack.length = 0;
continue;
}
if (c === openCh) stack.push(j);
else if (c === closeCh) {
const open = stack.pop();
if (open !== undefined) matches.set(open, j);
}
}
return matches;
}
/** Parse `[text](url)` at `i`. The URL is *not* validated here — that is the
* renderer's job, which is why the raw source travels with the node. */
function parseLink(
src: string,
i: number,
depth: number,
bracketMatches: ReadonlyMap<number, number>,
parenMatches: ReadonlyMap<number, number>,
): { node: InlineNode; end: number } | null {
const close = bracketMatches.get(i) ?? -1;
if (close < 0 || src[close + 1] !== "(") return null;
const urlClose = parenMatches.get(close + 1) ?? -1;
if (urlClose < 0) return null;
const url = src.slice(close + 2, urlClose).trim();
if (url.length === 0 || /\s/.test(url)) return null;
const text = src.slice(i + 1, close);
if (text.length === 0) return null;
return {
node: {
type: "link",
url,
raw: src.slice(i, urlClose + 1),
children: parseInline(text, depth + 1),
},
end: urlClose + 1,
};
}
/**
* Tokenize one run of inline text (no block constructs, no newline meaning).
*/
export function parseInline(src: string, depth = 0): InlineNode[] {
const out: InlineNode[] = [];
let buf = "";
const flush = (): void => {
if (buf.length > 0) {
out.push({ type: "text", value: buf });
buf = "";
}
};
// Built once per invocation (not once per `[`) — see buildMatches. Skipped
// entirely when the substring has nothing to match, which is the common
// case for recursive calls into styled/link text.
const bracketMatches = src.includes("[") ? buildMatches(src, "[", "]") : EMPTY_MATCHES;
const parenMatches = src.includes("(") ? buildMatches(src, "(", ")") : EMPTY_MATCHES;
let i = 0;
while (i < src.length) {
const c = src[i]!;
if (c === "\\" && i + 1 < src.length && ESCAPABLE.includes(src[i + 1]!)) {
buf += src[i + 1]!;
i += 2;
continue;
}
if (c === "`") {
const end = codeSpanEnd(src, i);
if (end > i) {
const run = Math.min(runLength(src, i, "`"), 2);
flush();
out.push({ type: "code", value: src.slice(i + run, end - run) });
i = end;
continue;
}
}
// A bare URL is opaque: its underscores and asterisks are address, not
// markup, and it is autolinked later by the renderer.
const u = urlEnd(src, i);
if (u > i) {
buf += src.slice(i, u);
i = u;
continue;
}
if (depth < MAX_DEPTH && c === "[") {
const link = parseLink(src, i, depth, bracketMatches, parenMatches);
if (link !== null) {
flush();
out.push(link.node);
i = link.end;
continue;
}
}
if (depth < MAX_DEPTH) {
const d = matchDelim(src, i);
if (d !== null) {
const innerStart = i + d.marker.length;
const close = scanClose(src, innerStart, d.marker);
if (close > innerStart) {
flush();
const children = parseInline(src.slice(innerStart, close), depth + 1);
let node: InlineNode = { type: d.styles[d.styles.length - 1]!, children };
for (let k = d.styles.length - 2; k >= 0; k--) {
node = { type: d.styles[k]!, children: [node] };
}
out.push(node);
i = close + d.marker.length;
continue;
}
}
}
buf += c;
i++;
}
flush();
return out;
}
// -- Block constructs ---------------------------------------------------------
export type BlockNode =
| { readonly type: "paragraph"; readonly text: string }
| { readonly type: "heading"; readonly level: 1 | 2 | 3; readonly text: string }
| { readonly type: "quote"; readonly text: string }
| {
readonly type: "list";
readonly ordered: boolean;
readonly start: number;
readonly items: readonly ListItem[];
};
export interface ListItem {
readonly text: string;
/** 0 for a top-level item, 1 for a single level of indentation. */
readonly level: 0 | 1;
readonly ordered: boolean;
}
const HEADING_RE = /^(#{1,3}) +(.*)$/;
const QUOTE_RE = /^> ?(.*)$/;
const BLOCK_QUOTE_ALL_RE = /^>>> ?(.*)$/;
const BULLET_RE = /^( *)([-*]) +(.*)$/;
const ORDERED_RE = /^( *)(\d{1,9})[.)] +(.*)$/;
function listItemAt(line: string): ListItem | null {
const bullet = BULLET_RE.exec(line);
if (bullet !== null) {
return { text: bullet[3]!, level: bullet[1]!.length >= 2 ? 1 : 0, ordered: false };
}
const ordered = ORDERED_RE.exec(line);
if (ordered !== null) {
return { text: ordered[3]!, level: ordered[1]!.length >= 2 ? 1 : 0, ordered: true };
}
return null;
}
function orderedStart(line: string): number {
const m = ORDERED_RE.exec(line);
return m === null ? 1 : Number(m[2]);
}
/**
* Split a prose segment into block nodes. Block markers are only recognised at
* the start of a line, exactly like Discord; everything else joins the
* surrounding paragraph so inline styles may span line breaks.
*/
export function parseBlocks(text: string): BlockNode[] {
const lines = text.split("\n");
const out: BlockNode[] = [];
let para: string[] = [];
const flushPara = (): void => {
if (para.length > 0) {
out.push({ type: "paragraph", text: para.join("\n") });
para = [];
}
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
const all = BLOCK_QUOTE_ALL_RE.exec(line);
if (all !== null) {
flushPara();
const rest = [all[1]!, ...lines.slice(i + 1)].join("\n");
out.push({ type: "quote", text: rest });
return out;
}
const quote = QUOTE_RE.exec(line);
if (quote !== null) {
flushPara();
const collected = [quote[1]!];
while (i + 1 < lines.length) {
const next = QUOTE_RE.exec(lines[i + 1]!);
if (next === null) break;
collected.push(next[1]!);
i++;
}
out.push({ type: "quote", text: collected.join("\n") });
continue;
}
const heading = HEADING_RE.exec(line);
if (heading !== null) {
flushPara();
out.push({ type: "heading", level: heading[1]!.length as 1 | 2 | 3, text: heading[2]! });
continue;
}
const item = listItemAt(line);
if (item !== null) {
flushPara();
const items: ListItem[] = [item];
const ordered = item.ordered;
const start = ordered ? orderedStart(line) : 1;
while (i + 1 < lines.length) {
const next = listItemAt(lines[i + 1]!);
// A list ends when the marker style changes at the top level; nested
// items may differ from their parent.
if (next === null || (next.level === 0 && next.ordered !== ordered)) break;
items.push(next);
i++;
}
out.push({ type: "list", ordered, start, items });
continue;
}
para.push(line);
}
flushPara();
return out;
}
@@ -11,7 +11,12 @@ import { observeMedia } from "@lib/media-visibility";
import { loadPref } from "@components/settings/helpers";
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { isSafeUrl } from "./attachments";
import { CODE_BLOCK_REGEX, INLINE_CODE_REGEX, URL_REGEX } from "./content-parser";
import {
CODE_BLOCK_REGEX,
INLINE_CODE_REGEX,
MASKED_LINK_REGEX,
URL_REGEX,
} from "./content-parser";
import { renderGenericLinkPreview } from "./embeds";
const log = createLogger("media");
@@ -341,6 +346,13 @@ export function renderInlineImage(url: string): HTMLDivElement {
// properly remove document-level listeners from the previous instance.
let activeLightboxClose: (() => void) | null = null;
/** Close the active lightbox, if any. Called on page teardown (logout, page
* swap) so an open overlay doesn't survive onto the next page with live
* document listeners and a revoked blob URL. */
export function closeActiveLightbox(): void {
activeLightboxClose?.();
}
/** Open a full-screen lightbox overlay with zoom and pan. */
export function openImageLightbox(src: string, alt: string): void {
// Close any existing lightbox (including its document listeners)
@@ -494,8 +506,12 @@ export function openImageLightbox(src: string, alt: string): void {
/** Extract all URLs from a message content string. */
export function extractUrls(content: string): string[] {
// Skip URLs inside code blocks
const withoutCodeBlocks = content.replace(CODE_BLOCK_REGEX, "").replace(INLINE_CODE_REGEX, "");
// Skip URLs inside code blocks, and inside masked links: `[text](url)` is a
// deliberate act of hiding the address, so it gets no embed either.
const withoutCodeBlocks = content
.replace(CODE_BLOCK_REGEX, "")
.replace(INLINE_CODE_REGEX, "")
.replace(MASKED_LINK_REGEX, "");
const matches = withoutCodeBlocks.match(URL_REGEX);
return matches ?? [];
}
@@ -0,0 +1,312 @@
/**
* Who-reacted tooltip — hovering a reaction pill names the people behind the
* count.
*
* The reactor list is not part of the message payload (a page of chat carries
* dozens of pills and almost none are ever hovered), so it is fetched on demand
* from GET /channels/{id}/messages/{messageId}/reactions/{emoji}/users and
* cached per message+emoji. The cache is invalidated by `reaction_update` for
* that message, which is the only event that can change the answer.
*
* Hover is debounced 300ms, mirroring lib/streamPreview.ts: a pointer crossing
* a row of pills must not fire a request per pill.
*/
import { createElement, setText, appendChildren } from "@lib/dom";
import { createLogger } from "@lib/logger";
import type { ReactionUser } from "@lib/types";
const log = createLogger("reaction-tooltip");
/** Debounce before the hover turns into a fetch + tooltip. */
export const REACTION_TOOLTIP_DEBOUNCE_MS = 300;
/** How many names are spelled out before collapsing into "and N others". */
const MAX_NAMES = 3;
// ---------------------------------------------------------------------------
// Fetcher injection
// ---------------------------------------------------------------------------
export type ReactionUsersFetcher = (
channelId: number,
messageId: number,
emoji: string,
) => Promise<readonly ReactionUser[]>;
let fetcher: ReactionUsersFetcher | null = null;
/**
* Register the transport used to fetch reactor lists. Called once from
* MainPage with the live ApiClient, the same way setServerHost is. Until it is
* set, hovering a pill is a no-op rather than an error — the renderer is used
* by tests and previews that have no server.
*/
export function setReactionUsersFetcher(next: ReactionUsersFetcher | null): void {
fetcher = next;
}
// ---------------------------------------------------------------------------
// Cache
// ---------------------------------------------------------------------------
/** NUL separator: the server rejects control characters in an emoji, so no
* emoji can contain it and no two (message, emoji) pairs can collide. */
function cacheKey(messageId: number, emoji: string): string {
return `${messageId}\u0000${emoji}`;
}
/** Resolved reactor lists, keyed by message+emoji. */
const cache = new Map<string, readonly ReactionUser[]>();
/** In-flight requests, so a re-hover during the fetch does not duplicate it. */
const inFlight = new Map<string, Promise<readonly ReactionUser[] | null>>();
/**
* Drop every cached reactor list for a message. Called from the `reaction_update`
* dispatch: any add/remove on that message makes all of its lists stale, and
* the event carries only the one emoji that changed, so scoping the eviction to
* that emoji would leave the others silently wrong after a race.
*/
export function invalidateReactionUsers(messageId: number): void {
// Deleting the key currently being visited is well-defined for a Map
// iterator, so no snapshot of the key set is needed.
const prefix = `${messageId}\u0000`;
for (const key of cache.keys()) {
if (key.startsWith(prefix)) cache.delete(key);
}
for (const key of inFlight.keys()) {
if (key.startsWith(prefix)) inFlight.delete(key);
}
}
/** Drop every cached reactor list (channel switch, logout, reconnect). */
export function clearReactionUsersCache(): void {
cache.clear();
inFlight.clear();
}
/** Cached reactor list for a message+emoji, or undefined when not fetched. */
export function getCachedReactionUsers(
messageId: number,
emoji: string,
): readonly ReactionUser[] | undefined {
return cache.get(cacheKey(messageId, emoji));
}
/**
* Reactor list for a message+emoji, from cache when present. Returns null when
* there is no fetcher registered or the request failed — callers show nothing
* rather than an error, since this is a hover affordance.
*/
export function loadReactionUsers(
channelId: number,
messageId: number,
emoji: string,
): Promise<readonly ReactionUser[] | null> {
const key = cacheKey(messageId, emoji);
const cached = cache.get(key);
if (cached !== undefined) return Promise.resolve(cached);
const existing = inFlight.get(key);
if (existing !== undefined) return existing;
const activeFetcher = fetcher;
if (activeFetcher === null) return Promise.resolve(null);
const promise = activeFetcher(channelId, messageId, emoji).then(
(users) => {
// A concurrent invalidation dropped this key: the response describes a
// state that has already changed, so it must not repopulate the cache.
if (inFlight.get(key) === promise) {
cache.set(key, users);
}
return users;
},
(err: unknown) => {
log.warn("failed to load reaction users", {
messageId,
emoji,
error: String(err),
});
return null;
},
);
inFlight.set(key, promise);
void promise.finally(() => {
if (inFlight.get(key) === promise) {
inFlight.delete(key);
}
});
return promise;
}
// ---------------------------------------------------------------------------
// Text
// ---------------------------------------------------------------------------
/**
* "A", "A and B", "A, B and C", "A, B, C and 4 others".
*
* `totalCount` is the pill's count, which can exceed the fetched list (the
* server caps it at 100) — the overflow phrasing is driven by it so a pill
* reading 250 does not claim only 100 people reacted.
*/
export function formatReactorNames(
usernames: readonly string[],
totalCount = usernames.length,
): string {
if (usernames.length === 0) return "";
const total = Math.max(totalCount, usernames.length);
const shown = usernames.slice(0, MAX_NAMES);
const others = total - shown.length;
if (others > 0) {
return `${shown.join(", ")} and ${others} ${others === 1 ? "other" : "others"}`;
}
if (shown.length === 1) return shown[0]!;
return `${shown.slice(0, -1).join(", ")} and ${shown[shown.length - 1]!}`;
}
// ---------------------------------------------------------------------------
// Tooltip DOM
// ---------------------------------------------------------------------------
/** Build the tooltip body. Text only — usernames are user-controlled, so they
* go in via textContent, never markup. */
export function buildReactionTooltip(
emoji: string,
users: readonly ReactionUser[],
totalCount: number,
): HTMLDivElement {
const tip = createElement("div", {
class: "reaction-tooltip",
role: "tooltip",
"data-testid": "reaction-tooltip",
});
const names = createElement("span", { class: "reaction-tooltip-names" });
setText(
names,
formatReactorNames(
users.map((u) => u.username),
totalCount,
),
);
const reacted = createElement("span", { class: "reaction-tooltip-emoji" });
setText(reacted, `reacted with ${emoji}`);
appendChildren(tip, names, reacted);
return tip;
}
// ---------------------------------------------------------------------------
// Hover wiring
// ---------------------------------------------------------------------------
export interface ReactionTooltipTarget {
readonly channelId: number;
readonly messageId: number;
readonly emoji: string;
/** The pill's displayed count, used for the "and N others" tail. */
readonly count: number;
}
interface HoverState {
timer: number;
/** Bumped on every hide so a late fetch cannot show a stale tooltip. */
generation: number;
}
const hoverStates = new WeakMap<HTMLElement, HoverState>();
/**
* Chips currently mid-hover (debounce timer running or tooltip showing),
* keyed by the message list's AbortSignal. A single abort listener per signal
* hides whatever is in the set instead of registering a bare, never-removed
* `abort` listener per chip on every render — the latter permanently pinned
* every past chip (and, via parentNode, its whole detached row) in memory for
* the rest of the channel visit. start()/stop() add/remove the chip, so the
* set only ever holds the handful of chips actually being hovered.
*/
const hoveringChips = new WeakMap<AbortSignal, Set<HTMLElement>>();
function chipSetFor(signal: AbortSignal): Set<HTMLElement> {
const existing = hoveringChips.get(signal);
if (existing !== undefined) return existing;
const set = new Set<HTMLElement>();
hoveringChips.set(signal, set);
signal.addEventListener(
"abort",
() => {
for (const chip of set) hide(chip);
set.clear();
},
{ once: true },
);
return set;
}
function removeTooltip(chip: HTMLElement): void {
chip.querySelector(".reaction-tooltip")?.remove();
}
function hide(chip: HTMLElement): void {
const state = hoverStates.get(chip);
if (state !== undefined) {
clearTimeout(state.timer);
state.generation += 1;
}
removeTooltip(chip);
}
/**
* Attach who-reacted hover behaviour to a reaction pill. Listeners are removed
* with the message list's AbortSignal; the debounce timer is cleared on
* mouseleave/focusout and on abort.
*/
export function attachReactionTooltip(
chip: HTMLElement,
target: ReactionTooltipTarget,
signal: AbortSignal,
): void {
const show = (): void => {
const state = hoverStates.get(chip);
if (state === undefined) return;
const generation = state.generation;
void loadReactionUsers(target.channelId, target.messageId, target.emoji).then((users) => {
if (users === null || users.length === 0) return;
// The pointer left (or the row was rebuilt) while the fetch was in
// flight — do not pop a tooltip nobody is hovering.
const current = hoverStates.get(chip);
if (current === undefined || current.generation !== generation) return;
if (!chip.isConnected) return;
removeTooltip(chip);
chip.appendChild(buildReactionTooltip(target.emoji, [...users], target.count));
});
};
const chips = chipSetFor(signal);
const start = (): void => {
hide(chip);
const existing = hoverStates.get(chip);
const generation = existing === undefined ? 0 : existing.generation;
const timer = window.setTimeout(show, REACTION_TOOLTIP_DEBOUNCE_MS);
hoverStates.set(chip, { timer, generation });
chips.add(chip);
};
const stop = (): void => {
chips.delete(chip);
hide(chip);
};
chip.addEventListener("mouseenter", start, { signal });
chip.addEventListener("mouseleave", stop, { signal });
// Keyboard accessibility: focus mirrors hover.
chip.addEventListener("focusin", start, { signal });
chip.addEventListener("focusout", stop, { signal });
}
@@ -5,6 +5,8 @@
import { createElement } from "@lib/dom";
import type { Message } from "@stores/messages.store";
import type { MessageListOptions } from "../MessageList";
import { attachReactionTooltip } from "./reaction-tooltip";
import { buildCustomEmojiNode } from "./custom-emoji";
// -- Reaction rendering -------------------------------------------------------
@@ -17,12 +19,30 @@ export function renderReactions(
for (const reaction of msg.reactions) {
const chip = createElement("span", {
class: reaction.me ? "reaction-chip me" : "reaction-chip",
// Focusable so the who-reacted tooltip is reachable without a pointer.
tabindex: "0",
"data-emoji": reaction.emoji,
});
const emoji = document.createTextNode(reaction.emoji);
// Reaction strings are free-form, so a custom reaction is stored as the
// literal ":shortcode:" text. Render the image when that resolves; when it
// does not (the emoji was deleted, or the reaction predates it) the plain
// text is exactly what the reaction is, and toggling it still works.
const emoji: Node =
buildCustomEmojiNode(reaction.emoji) ?? document.createTextNode(reaction.emoji);
const count = createElement("span", { class: "rc-count" }, String(reaction.count));
chip.appendChild(emoji);
chip.appendChild(count);
chip.addEventListener("click", () => opts.onReactionClick(msg.id, reaction.emoji), { signal });
attachReactionTooltip(
chip,
{
channelId: msg.channelId,
messageId: msg.id,
emoji: reaction.emoji,
count: reaction.count,
},
signal,
);
container.appendChild(chip);
}
const addBtn = createElement("span", { class: "reaction-chip add-reaction" }, "+");
@@ -10,6 +10,7 @@ import { createIcon } from "@lib/icons";
import { loadPref } from "@lib/preferences";
import { canManageMessages } from "@lib/permissions";
import { showToast } from "@lib/toast";
import { formatMessageLink } from "@lib/deep-link";
import type { Message } from "@stores/messages.store";
import type { MessageListOptions } from "../MessageList";
@@ -47,8 +48,10 @@ export { setServerHost } from "./attachments";
// -- Imports for composite functions ------------------------------------------
import { formatTime, formatFullDate, formatMessageTimestamp } from "./formatting";
import { getUserRole, roleColorVar } from "./formatting";
import { getUserRole, resolveAuthor, roleColorVar } from "./formatting";
import { createAvatarElement, resolveDisplayName } from "@lib/avatar";
import { renderMentions, renderMessageContent } from "./content-parser";
import { highlightsCurrentUser } from "@lib/mentions";
import { renderUrlEmbeds } from "./media";
import { renderAttachment } from "./attachments";
import { renderReactions } from "./reactions";
@@ -66,24 +69,70 @@ export function renderDayDivider(iso: string): HTMLDivElement {
return divider;
}
function renderReplyRef(replyToId: number, allMessages: readonly Message[]): HTMLDivElement {
/**
* The "NEW" line above the first message the reader has not seen. Built exactly
* like the day divider — same rule/label/rule shape — so the two read as one
* family; only the accent colour distinguishes them.
*/
export function renderNewDivider(): HTMLDivElement {
const divider = createElement("div", {
class: "msg-new-divider",
role: "separator",
"data-testid": "new-messages-divider",
});
appendChildren(
divider,
createElement("span", { class: "line" }),
createElement("span", { class: "label" }, "NEW"),
createElement("span", { class: "line" }),
);
return divider;
}
/**
* The quoted bar above a reply. Clicking it jumps to the replied-to message —
* including when that message is outside the loaded window, which is why the
* bar stays clickable even in the "unknown message" case: the id is known, and
* the jump path can fetch the window around it.
*/
function renderReplyRef(
replyToId: number,
allMessages: readonly Message[],
opts: MessageListOptions,
signal: AbortSignal,
): HTMLDivElement {
const ref = allMessages.find((m) => m.id === replyToId);
const bar = createElement("div", { class: "msg-reply-ref" });
const bar = createElement("div", {
class: "msg-reply-ref",
role: "button",
tabindex: "0",
"data-reply-to": String(replyToId),
title: "Jump to the replied-to message",
});
const jump = (): void => opts.onJumpToMessage?.(replyToId);
bar.addEventListener("click", jump, { signal });
bar.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
jump();
}
},
{ signal },
);
if (ref) {
const preview = ref.deleted ? "[message deleted]" : ref.content.slice(0, 100);
const role = getUserRole(ref.user.id);
const miniAvatar = createElement(
"div",
{
class: "rr-avatar",
style: `background: ${roleColorVar(role)}`,
},
ref.user.username.charAt(0).toUpperCase(),
);
const author = resolveAuthor(ref.user);
const miniAvatar = createAvatarElement(author, {
className: "rr-avatar",
background: roleColorVar(role),
});
appendChildren(
bar,
miniAvatar,
createElement("span", { class: "rr-author" }, ref.user.username),
createElement("span", { class: "rr-author" }, resolveDisplayName(author)),
createElement("span", { class: "rr-text" }, preview),
);
} else {
@@ -136,21 +185,23 @@ export function renderMessage(
const statusClass =
msg.status === "pending" ? " pending" : msg.status === "failed" ? " failed" : "";
const mentionInfo = { mentions: msg.mentions, mentionsEveryone: msg.mentionsEveryone };
// A deleted row shows no content, so it must not keep the mention accent.
const mentionedClass =
!msg.deleted && highlightsCurrentUser(msg.content, mentionInfo) ? " mentioned" : "";
const el = createElement("div", {
class: (isGrouped ? "message grouped" : "message") + statusClass,
class: (isGrouped ? "message grouped" : "message") + statusClass + mentionedClass,
"data-testid": `message-${msg.id}`,
});
const role = getUserRole(msg.user.id);
const initial = msg.user.username.charAt(0).toUpperCase();
const avatar = createElement(
"div",
{
class: "msg-avatar",
style: `background: ${roleColorVar(role)}`,
},
initial,
);
// The author's current identity, not the one frozen into the payload: a
// rename or a new avatar has to show up on the messages already on screen.
const author = resolveAuthor(msg.user);
const avatar = createAvatarElement(author, {
className: "msg-avatar",
background: roleColorVar(role),
});
el.appendChild(avatar);
if (isGrouped) {
@@ -166,24 +217,27 @@ export function renderMessage(
}
if (msg.replyTo !== null) {
el.appendChild(renderReplyRef(msg.replyTo, allMessages));
el.appendChild(renderReplyRef(msg.replyTo, allMessages, opts, signal));
}
const header = createElement("div", { class: "msg-header" });
const author = createElement(
const authorEl = createElement(
"span",
{
class: "msg-author",
// The username stays as the title so the handle you would @mention is
// one hover away even when a display name is standing in for it.
title: author.username,
style: `color: ${roleColorVar(role)}`,
},
msg.user.username,
resolveDisplayName(author),
);
const time = createElement(
"span",
{ class: "msg-time", title: formatFullDate(msg.timestamp) },
formatMessageTimestamp(msg.timestamp),
);
appendChildren(header, author, time);
appendChildren(header, authorEl, time);
el.appendChild(header);
if (msg.deleted) {
@@ -193,7 +247,7 @@ export function renderMessage(
setText(text, "[message deleted]");
el.appendChild(text);
} else {
el.appendChild(renderMessageContent(msg.content));
el.appendChild(renderMessageContent(msg.content, mentionInfo));
if (msg.editedAt !== null) {
el.appendChild(createElement("span", { class: "msg-edited" }, "(edited)"));
}
@@ -293,6 +347,26 @@ export function renderMessage(
actionsBar.appendChild(deleteBtn);
}
const copyLinkBtn = createElement("button", {
"data-testid": `msg-copy-link-${msg.id}`,
"aria-label": "Copy Message Link",
});
copyLinkBtn.appendChild(createIcon("link", 16));
copyLinkBtn.title = "Copy Message Link";
copyLinkBtn.addEventListener(
"click",
() => {
// No silent success: a copy with no feedback is indistinguishable
// from a clipboard that refused.
void navigator.clipboard.writeText(formatMessageLink(msg.channelId, msg.id)).then(
() => showToast("Message link copied", "success"),
() => showToast("Couldn't copy the message link", "error"),
);
},
{ signal },
);
actionsBar.appendChild(copyLinkBtn);
if (developerModeEnabled) {
const copyIdBtn = createElement("button", {
"data-testid": `msg-copy-id-${msg.id}`,
@@ -0,0 +1,250 @@
/**
* A small, dependency-free syntax highlighter for fenced code blocks.
*
* The client ships no highlighting library (checked against package.json) and
* a message list is the wrong place to pull one in, so this is a deliberately
* shallow tokenizer: comments, strings, numbers and keywords — the four things
* that make code scannable — and nothing that pretends to be a parser.
*
* Pure and DOM-free; the renderer turns tokens into spans.
*/
export type TokenClass = "keyword" | "string" | "comment" | "number";
export interface CodeToken {
readonly text: string;
/** null renders as plain text. */
readonly cls: TokenClass | null;
}
interface Pattern {
/** Sticky: matched only at the current index. */
readonly re: RegExp;
readonly cls: TokenClass;
}
interface LangSpec {
readonly patterns: readonly Pattern[];
readonly keywords: ReadonlySet<string>;
readonly ident?: RegExp;
}
const kw = (words: string): ReadonlySet<string> => new Set(words.split(" "));
// -- Shared token patterns ----------------------------------------------------
const SLASH_LINE_COMMENT: Pattern = { re: /\/\/[^\n]*/y, cls: "comment" };
const C_BLOCK_COMMENT: Pattern = { re: /\/\*[\s\S]*?(?:\*\/|$)/y, cls: "comment" };
const HASH_COMMENT: Pattern = { re: /#[^\n]*/y, cls: "comment" };
const DQ_STRING: Pattern = { re: /"(?:\\.|[^"\\\n])*"?/y, cls: "string" };
const SQ_STRING: Pattern = { re: /'(?:\\.|[^'\\\n])*'?/y, cls: "string" };
const BACKTICK_STRING: Pattern = { re: /`(?:\\.|[^`\\])*`?/y, cls: "string" };
const NUMBER: Pattern = {
re: /(?:0[xXbBoO][0-9a-fA-F_]+|\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?)[a-zA-Z_]*/y,
cls: "number",
};
const JS_LIKE: readonly Pattern[] = [
SLASH_LINE_COMMENT,
C_BLOCK_COMMENT,
DQ_STRING,
SQ_STRING,
BACKTICK_STRING,
NUMBER,
];
const LANGS: Readonly<Record<string, LangSpec>> = {
javascript: {
patterns: JS_LIKE,
keywords: kw(
"const let var function return if else for while do break continue class extends new this " +
"typeof instanceof in of null undefined true false async await import export from default " +
"try catch finally throw switch case yield static get set delete void super",
),
},
typescript: {
patterns: JS_LIKE,
keywords: kw(
"const let var function return if else for while do break continue class extends new this " +
"typeof instanceof in of null undefined true false async await import export from default " +
"try catch finally throw switch case yield static get set delete void super " +
"interface type enum implements public private protected readonly as satisfies keyof " +
"namespace declare abstract infer never unknown any string number boolean",
),
},
go: {
patterns: [SLASH_LINE_COMMENT, C_BLOCK_COMMENT, DQ_STRING, BACKTICK_STRING, SQ_STRING, NUMBER],
keywords: kw(
"func package import var const type struct interface map chan go defer select switch case " +
"if else for range return break continue fallthrough default goto nil true false iota " +
"make new len cap append copy delete panic recover string int int8 int16 int32 int64 uint " +
"uint8 uint16 uint32 uint64 float32 float64 bool byte rune error any",
),
},
python: {
patterns: [
HASH_COMMENT,
{ re: /(?:"""[\s\S]*?"""|'''[\s\S]*?''')/y, cls: "string" },
{ re: /[rbfu]{0,2}"(?:\\.|[^"\\\n])*"?/y, cls: "string" },
{ re: /[rbfu]{0,2}'(?:\\.|[^'\\\n])*'?/y, cls: "string" },
NUMBER,
],
keywords: kw(
"def class return if elif else for while import from as pass break continue try except " +
"finally raise with lambda None True False and or not in is global nonlocal yield async " +
"await del assert self print len range str int float bool list dict set tuple",
),
},
rust: {
patterns: [
SLASH_LINE_COMMENT,
C_BLOCK_COMMENT,
{ re: /r#*"[\s\S]*?"#*/y, cls: "string" },
DQ_STRING,
{ re: /'(?:\\.|[^'\\\n])'/y, cls: "string" },
NUMBER,
],
keywords: kw(
"fn let mut const static struct enum impl trait use pub mod match if else for while loop " +
"return break continue as where type dyn ref move unsafe crate in true false self Self " +
"async await Some None Ok Err String Vec Option Result Box i8 i16 i32 i64 u8 u16 u32 u64 " +
"usize isize f32 f64 bool str char",
),
},
json: {
patterns: [DQ_STRING, NUMBER],
keywords: kw("true false null"),
},
bash: {
patterns: [
HASH_COMMENT,
DQ_STRING,
SQ_STRING,
{ re: /\$(?:\{[^}\n]*\}|[A-Za-z_][A-Za-z0-9_]*|[0-9?@#*])/y, cls: "keyword" },
NUMBER,
],
keywords: kw(
"if then else elif fi for while until do done case esac function return in export local " +
"readonly source alias unset shift trap eval exec set echo cd exit sudo apt npm git make",
),
},
css: {
patterns: [
C_BLOCK_COMMENT,
DQ_STRING,
SQ_STRING,
{ re: /@[-a-zA-Z]+/y, cls: "keyword" },
{ re: /!important/y, cls: "keyword" },
{ re: /[-a-zA-Z]+(?= *:)/y, cls: "keyword" },
{ re: /#[0-9a-fA-F]{3,8}\b/y, cls: "number" },
{
re: /-?\d[\d.]*(?:px|em|rem|ex|ch|%|vh|vw|vmin|vmax|s|ms|deg|turn|fr|pt)?/y,
cls: "number",
},
],
keywords: kw(""),
},
html: {
patterns: [
{ re: /<!--[\s\S]*?(?:-->|$)/y, cls: "comment" },
{ re: /<!DOCTYPE[^>\n]*>?/iy, cls: "keyword" },
{ re: /<\/?[A-Za-z][\w:-]*/y, cls: "keyword" },
DQ_STRING,
SQ_STRING,
],
keywords: kw(""),
},
};
/** Aliases accepted after the opening fence. */
const ALIASES: Readonly<Record<string, string>> = {
js: "javascript",
jsx: "javascript",
mjs: "javascript",
cjs: "javascript",
node: "javascript",
javascript: "javascript",
ts: "typescript",
tsx: "typescript",
typescript: "typescript",
go: "go",
golang: "go",
py: "python",
python: "python",
python3: "python",
rs: "rust",
rust: "rust",
json: "json",
jsonc: "json",
sh: "bash",
bash: "bash",
zsh: "bash",
shell: "bash",
console: "bash",
css: "css",
scss: "css",
html: "html",
xml: "html",
svg: "html",
};
const DEFAULT_IDENT = /[A-Za-z_$][A-Za-z0-9_$]*/y;
/** Canonical language id for a fence tag, or null when unknown. */
export function resolveLanguage(tag: string | null): string | null {
if (tag === null) return null;
return ALIASES[tag.toLowerCase()] ?? null;
}
/**
* Tokenize `code` for `lang` (a canonical id from {@link resolveLanguage}).
* Unknown languages return a single plain token, so the caller never has to
* branch on support.
*/
export function highlightCode(code: string, lang: string | null): CodeToken[] {
const spec = lang === null ? undefined : LANGS[lang];
if (spec === undefined) return code.length > 0 ? [{ text: code, cls: null }] : [];
const tokens: CodeToken[] = [];
let plain = "";
const flush = (): void => {
if (plain.length > 0) {
tokens.push({ text: plain, cls: null });
plain = "";
}
};
const ident = spec.ident ?? DEFAULT_IDENT;
let i = 0;
outer: while (i < code.length) {
for (const p of spec.patterns) {
p.re.lastIndex = i;
const m = p.re.exec(code);
if (m !== null && m[0].length > 0) {
flush();
tokens.push({ text: m[0], cls: p.cls });
i += m[0].length;
continue outer;
}
}
ident.lastIndex = i;
const word = ident.exec(code);
if (word !== null && word[0].length > 0) {
if (spec.keywords.has(word[0])) {
flush();
tokens.push({ text: word[0], cls: "keyword" });
} else {
plain += word[0];
}
i += word[0].length;
continue;
}
plain += code[i]!;
i++;
}
flush();
return tokens;
}
@@ -0,0 +1,136 @@
/**
* Shared "Purge Messages" context-menu section — the count prompt used by both
* channel menus (the sidebar right-click menu and AdminActions'
* createChannelContextMenu). The two menus predate each other and use different
* class conventions, so the caller supplies the class names; the clamp, the
* confirm step and the in-flight state live here once.
*/
import { createElement, appendChildren, setText } from "@lib/dom";
/** Server-side bounds on one purge request (docs/api.md). */
export const PURGE_MIN_COUNT = 1;
export const PURGE_MAX_COUNT = 100;
export const PURGE_DEFAULT_COUNT = 50;
export interface PurgeSectionOptions {
/** Class for ordinary rows in the host menu. */
readonly itemClass: string;
/** Class for the destructive confirm row. */
readonly dangerItemClass: string;
/** Class for the separator above the section, or "" to omit the separator. */
readonly separatorClass: string;
/** Runs the purge. Rejections are swallowed by the caller's toast handling. */
readonly onPurge: (count: number) => void | Promise<void>;
/** Aborts the section's listeners when the host menu is torn down. */
readonly signal: AbortSignal;
/** Called once the purge settles, so the host can close itself. */
readonly onDone?: () => void;
}
/** Clamp a raw input value into the server's accepted range. */
export function clampPurgeCount(raw: string): number {
const parsed = Number.parseInt(raw, 10);
if (Number.isNaN(parsed)) return PURGE_DEFAULT_COUNT;
return Math.min(PURGE_MAX_COUNT, Math.max(PURGE_MIN_COUNT, parsed));
}
/**
* Append the trigger row plus its hidden count prompt to `menu`. The prompt is
* revealed in place (rather than opening a modal) so the menu's outside-click
* dismissal keeps working; typing in the input does not close it.
*/
export function appendPurgeSection(menu: HTMLElement, opts: PurgeSectionOptions): void {
const { signal } = opts;
if (opts.separatorClass !== "") {
menu.appendChild(createElement("div", { class: opts.separatorClass }));
}
const trigger = createElement(
"div",
{ class: opts.itemClass, "data-testid": "ctx-purge-messages" },
"Purge Messages…",
);
const form = createElement("div", {
class: "context-menu__reason",
style: "display:none;padding:6px 8px",
"data-testid": "purge-form",
});
const countInput = createElement("input", {
class: "form-input",
type: "number",
min: String(PURGE_MIN_COUNT),
max: String(PURGE_MAX_COUNT),
value: String(PURGE_DEFAULT_COUNT),
"data-testid": "purge-count-input",
style: "width:100%;font-size:12px",
});
const hint = createElement(
"div",
{ style: "font-size:11px;color:var(--text-muted);margin-top:4px" },
`Deletes the newest ${PURGE_MIN_COUNT}${PURGE_MAX_COUNT} messages.`,
);
const confirm = createElement(
"div",
{ class: opts.dangerItemClass, "data-testid": "purge-confirm" },
"Confirm Purge",
);
appendChildren(form, countInput, hint, confirm);
trigger.addEventListener(
"click",
(e) => {
e.stopPropagation();
trigger.style.display = "none";
form.style.display = "";
countInput.focus();
},
{ signal },
);
// Typing a count must not trip the host menu's outside-click dismissal.
for (const event of ["click", "mousedown"] as const) {
countInput.addEventListener(event, (e: Event) => e.stopPropagation(), { signal });
}
let running = false;
function submit(): void {
if (running) return;
running = true;
setText(confirm, "Purging…");
const done = (): void => {
running = false;
setText(confirm, "Confirm Purge");
opts.onDone?.();
};
const result = opts.onPurge(clampPurgeCount(countInput.value));
if (result instanceof Promise) {
void result.then(done, done);
} else {
done();
}
}
confirm.addEventListener(
"click",
(e) => {
e.stopPropagation();
submit();
},
{ signal },
);
countInput.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
submit();
}
},
{ signal },
);
appendChildren(menu, trigger, form);
}
@@ -8,8 +8,19 @@ import { createElement, appendChildren, setText } from "@lib/dom";
import type { UserStatus } from "@lib/types";
import { authStore } from "@stores/auth.store";
import { loadUserStatus, saveUserStatus } from "@lib/userStatus";
import { avatarInitial, isRenderableAvatar, resolveDisplayName } from "@lib/avatar";
import { fetchImageAsDataUrl, resolveServerUrl } from "@components/message-list/attachments";
import type { SettingsOverlayOptions } from "../SettingsOverlay";
/** Mirrors the server's caps so the form can bound itself instead of learning
* about the limits from a rejected request. */
const MAX_DISPLAY_NAME_LEN = 32;
const MAX_ABOUT_LEN = 300;
/** Mirrors maxAvatarFileBytes / maxAvatarDimension on the server. */
const MAX_AVATAR_BYTES = 1024 * 1024;
const MAX_AVATAR_DIMENSION = 1024;
const ACCEPTED_AVATAR_TYPES = "image/png,image/jpeg,image/webp";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -20,13 +31,15 @@ interface ProfileCardResult {
readonly usernameValue: HTMLDivElement;
readonly editUserProfileBtn: HTMLButtonElement;
readonly editUsernameBtn: HTMLButtonElement;
/** The big avatar; the uploader swaps its contents on success. */
readonly avatarLarge: HTMLDivElement;
}
// ---------------------------------------------------------------------------
// Profile card builder
// ---------------------------------------------------------------------------
function buildProfileCard(username: string): ProfileCardResult {
function buildProfileCard(displayName: string, username: string): ProfileCardResult {
const card = createElement("div", { class: "account-card" });
const banner = createElement("div", { class: "account-banner" });
@@ -34,15 +47,15 @@ function buildProfileCard(username: string): ProfileCardResult {
const avatarWrap = createElement("div", { class: "account-avatar-wrap" });
const avatarLarge = createElement(
"div",
{ class: "account-avatar-large" },
username.charAt(0).toUpperCase(),
{ class: "account-avatar-large", "data-testid": "account-avatar" },
avatarInitial({ username, displayName }),
);
const statusDot = createElement("div", { class: "account-status-dot" });
appendChildren(avatarWrap, avatarLarge, statusDot);
// Header row
const accountHeader = createElement("div", { class: "account-header" });
const headerName = createElement("div", { class: "account-header-name" }, username);
const headerName = createElement("div", { class: "account-header-name" }, displayName);
const editUserProfileBtn = createElement("button", { class: "ac-btn" }, "Edit User Profile");
appendChildren(accountHeader, headerName, editUserProfileBtn);
@@ -59,7 +72,247 @@ function buildProfileCard(username: string): ProfileCardResult {
appendChildren(card, banner, avatarWrap, accountHeader, fieldsContainer);
return { card, headerName, usernameValue, editUserProfileBtn, editUsernameBtn };
return { card, headerName, usernameValue, editUserProfileBtn, editUsernameBtn, avatarLarge };
}
// ---------------------------------------------------------------------------
// Avatar preview + uploader
// ---------------------------------------------------------------------------
/**
* Draw `url` into the big avatar, replacing the letter. Falls back to the
* letter when there is nothing to draw or the fetch fails, because the file
* route is authenticated and `<img src>` cannot carry the session token.
*/
function paintAvatar(
target: HTMLDivElement,
url: string | null,
alt: string,
initial: string,
): void {
const showInitial = (): void => {
target.replaceChildren(document.createTextNode(initial));
target.style.background = "";
};
if (url === null) {
showInitial();
return;
}
void fetchImageAsDataUrl(url).then((dataUrl) => {
if (dataUrl === null || !target.isConnected) return;
const img = createElement("img", { class: "avatar-img", src: dataUrl, alt });
target.replaceChildren(img);
target.style.background = "transparent";
});
}
/**
* Read a File into an object URL and measure it, so an image the server would
* refuse is caught before a megabyte goes over the wire — and so the preview
* shows what was actually picked rather than a spinner that ends in a 400.
*/
function measureImage(file: File): Promise<{ width: number; height: number } | null> {
return new Promise((resolve) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.addEventListener(
"load",
() => {
URL.revokeObjectURL(url);
resolve({ width: img.naturalWidth, height: img.naturalHeight });
},
{ once: true },
);
img.addEventListener(
"error",
() => {
URL.revokeObjectURL(url);
resolve(null);
},
{ once: true },
);
img.src = url;
});
}
/** Local validation mirroring the server's rules. Returns an error message. */
export function validateAvatarFile(
file: { size: number; type: string },
dimensions: { width: number; height: number } | null,
): string | null {
if (!ACCEPTED_AVATAR_TYPES.split(",").includes(file.type)) {
return "Avatar must be a PNG, JPEG or WebP image.";
}
if (file.size > MAX_AVATAR_BYTES) {
return `Avatar must be at most ${MAX_AVATAR_BYTES / 1024} KB.`;
}
if (dimensions === null) {
return "That file could not be read as an image.";
}
if (dimensions.width > MAX_AVATAR_DIMENSION || dimensions.height > MAX_AVATAR_DIMENSION) {
return `Avatar must be at most ${MAX_AVATAR_DIMENSION}x${MAX_AVATAR_DIMENSION} pixels.`;
}
return null;
}
function buildAvatarUploader(
options: SettingsOverlayOptions,
avatarLarge: HTMLDivElement,
signal: AbortSignal,
): HTMLDivElement {
const wrapper = createElement("div", { class: "account-avatar-upload" });
const input = createElement("input", {
type: "file",
accept: ACCEPTED_AVATAR_TYPES,
style: "display:none",
"data-testid": "avatar-file-input",
});
const uploadBtn = createElement(
"button",
{ class: "ac-btn", "data-testid": "avatar-upload-btn" },
"Change Avatar",
);
const errorEl = createElement("div", {
style: "color:var(--red);font-size:13px;margin-top:6px",
"data-testid": "avatar-error",
});
uploadBtn.addEventListener("click", () => input.click(), { signal });
input.addEventListener(
"change",
() => {
const file = input.files?.[0];
if (file === undefined) return;
setText(errorEl, "");
void (async () => {
const dimensions = await measureImage(file);
const problem = validateAvatarFile(file, dimensions);
if (problem !== null) {
setText(errorEl, problem);
input.value = "";
return;
}
uploadBtn.disabled = true;
setText(uploadBtn, "Uploading...");
try {
const url = await options.onUploadAvatar(file);
const user = authStore.getState().user;
paintAvatar(
avatarLarge,
resolveServerUrl(url),
user?.username ?? "avatar",
avatarInitial({
username: user?.username ?? "?",
displayName: user?.display_name ?? null,
}),
);
} catch (err) {
setText(errorEl, err instanceof Error ? err.message : "Failed to upload avatar.");
} finally {
input.value = "";
uploadBtn.disabled = false;
setText(uploadBtn, "Change Avatar");
}
})();
},
{ signal },
);
appendChildren(wrapper, input, uploadBtn, errorEl);
return wrapper;
}
// ---------------------------------------------------------------------------
// Display name + about
// ---------------------------------------------------------------------------
function buildProfileFields(
options: SettingsOverlayOptions,
onSaved: (displayName: string) => void,
signal: AbortSignal,
): HTMLDivElement {
const wrapper = createElement("div", {});
const separator = createElement("div", { class: "settings-separator" });
const header = createElement("div", { class: "settings-section-title" }, "Profile");
const user = authStore.getState().user;
const nameLabel = createElement("div", { class: "account-field-label" }, "Display Name");
const nameInput = createElement("input", {
class: "form-input",
type: "text",
placeholder: "Shown instead of your username",
maxlength: String(MAX_DISPLAY_NAME_LEN),
style: "margin-bottom:12px",
"data-testid": "display-name-input",
});
nameInput.value = user?.display_name ?? "";
const aboutLabel = createElement("div", { class: "account-field-label" }, "About Me");
const aboutInput = createElement("textarea", {
class: "form-input",
rows: "3",
placeholder: "A little about you",
maxlength: String(MAX_ABOUT_LEN),
style: "margin-bottom:8px;resize:vertical",
"data-testid": "about-input",
});
aboutInput.value = user?.about ?? "";
const statusEl = createElement("div", {
style: "color:var(--red);font-size:13px;margin-bottom:8px",
"data-testid": "profile-error",
});
const saveBtn = createElement(
"button",
{ class: "ac-btn", "data-testid": "profile-save-btn" },
"Save Profile",
);
saveBtn.addEventListener(
"click",
() => {
const displayName = nameInput.value.trim();
const about = aboutInput.value.trim();
// Both are sent unconditionally, empty string included: "" is how the
// API says "clear it", and omitting a field means "leave it alone".
statusEl.style.color = "var(--red)";
setText(statusEl, "");
saveBtn.disabled = true;
setText(saveBtn, "Saving...");
void options
.onUpdateProfile({ display_name: displayName, about })
.then(() => {
statusEl.style.color = "var(--green)";
setText(statusEl, "Profile saved.");
onSaved(
displayName.length > 0 ? displayName : (authStore.getState().user?.username ?? ""),
);
})
.catch((err: unknown) => {
setText(statusEl, err instanceof Error ? err.message : "Failed to save profile.");
})
.finally(() => {
saveBtn.disabled = false;
setText(saveBtn, "Save Profile");
});
},
{ signal },
);
appendChildren(
wrapper,
separator,
header,
nameLabel,
nameInput,
aboutLabel,
aboutInput,
statusEl,
saveBtn,
);
return wrapper;
}
// ---------------------------------------------------------------------------
@@ -580,8 +833,10 @@ const STATUS_OPTIONS: readonly StatusOption[] = [
color: "#ed4245",
},
{
value: "offline",
label: "Offline",
// Its own status now, not "offline" relabeled: the server stores it as
// chosen, shows everyone else offline, and honours it across reconnects.
value: "invisible",
label: "Invisible",
description: "You will appear offline but still have full access",
color: "#747f8d",
},
@@ -804,12 +1059,38 @@ export function buildAccountTab(
const section = createElement("div", { class: "settings-pane active" });
const user = authStore.getState().user;
const username = user?.username ?? "Unknown";
const displayName = resolveDisplayName({
username,
displayName: user?.display_name ?? null,
});
// Profile card
const { card, headerName, usernameValue, editUserProfileBtn, editUsernameBtn } =
buildProfileCard(username);
const { card, headerName, usernameValue, editUserProfileBtn, editUsernameBtn, avatarLarge } =
buildProfileCard(displayName, username);
section.appendChild(card);
// Existing avatar, if any — the letter is only a fallback now.
if (isRenderableAvatar(user?.avatar)) {
paintAvatar(
avatarLarge,
resolveServerUrl(user.avatar),
username,
avatarInitial({ username, displayName: user?.display_name ?? null }),
);
}
section.appendChild(buildAvatarUploader(options, avatarLarge, signal));
// Display name + about
section.appendChild(
buildProfileFields(
options,
(name) => {
setText(headerName, name);
},
signal,
),
);
// Status selector
section.appendChild(buildStatusSelector(options, signal));
@@ -822,6 +1103,7 @@ export function buildAccountTab(
class: "form-input",
type: "text",
placeholder: "New username",
"data-testid": "username-edit-input",
});
const saveBtn = createElement("button", { class: "ac-btn" }, "Save");
const cancelBtn = createElement(
@@ -864,7 +1146,7 @@ export function buildAccountTab(
}
setText(usernameError, "");
void options
.onUpdateProfile(newName)
.onUpdateProfile({ username: newName })
.then(() => {
setText(headerName, newName);
setText(usernameValue, newName);
@@ -176,6 +176,9 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
const msgBinds: [string, string][] = [
["Upload File", "Ctrl + U"],
["Edit Last Message", "Arrow Up"],
["Bold", "Ctrl + B"],
["Italic", "Ctrl + I"],
["Underline", "Ctrl + U"],
];
for (const [label, shortcut] of msgBinds) {
const row = createElement("div", { class: "keybind-row" });
@@ -187,5 +190,15 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
section.appendChild(row);
}
section.appendChild(
createElement(
"div",
{
style: "font-size: 11px; color: var(--text-micro); margin: 4px 0 0 0; line-height: 1.4;",
},
"Formatting shortcuts wrap the selected text while the message box has focus; Ctrl + U uploads a file everywhere else.",
),
);
return section;
}
@@ -2,8 +2,11 @@
* Notifications settings tab — desktop notifications, taskbar flash, sounds.
*/
import { createElement, appendChildren } from "@lib/dom";
import { createElement, appendChildren, clearChildren, setText } from "@lib/dom";
import { loadPref, savePref, createToggle } from "./helpers";
import { listMutedChannels, unmuteChannel } from "@lib/channel-mutes";
import { channelsStore } from "@stores/channels.store";
import { dmStore, dmDisplayName } from "@stores/dm.store";
export function buildNotificationsTab(signal: AbortSignal): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
@@ -24,7 +27,7 @@ export function buildNotificationsTab(signal: AbortSignal): HTMLDivElement {
{
key: "suppressEveryone",
label: "Suppress @everyone",
desc: "Mute @everyone and @here mentions",
desc: "Mute @everyone and @here — messages that name you still notify",
fallback: false,
},
{
@@ -54,5 +57,82 @@ export function buildNotificationsTab(signal: AbortSignal): HTMLDivElement {
section.appendChild(row);
}
section.appendChild(buildMutedChannelsSection(signal));
return section;
}
/** Best name available for a muted id: a channel, a DM, or neither. */
function mutedChannelName(channelId: number): string {
const ch = channelsStore.getState().channels.get(channelId);
if (ch !== undefined && ch.type !== "dm") return `#${ch.name}`;
const dm = dmStore.getState().channels.find((c) => c.channelId === channelId);
if (dm !== undefined) return `@${dmDisplayName(dm)}`;
// A mute can outlive the channel it names (deleted channel, left group). It
// is shown rather than hidden so the user can clear it.
return `Channel ${channelId}`;
}
/**
* The muted-channel list.
*
* Mutes are set from a right-click on a row, which makes them easy to set and
* easy to forget — a channel muted six weeks ago is silent for a reason nobody
* remembers. This is the one place that answers "what have I silenced", and
* the only place to undo it without finding the row again.
*/
function buildMutedChannelsSection(signal: AbortSignal): HTMLDivElement {
const wrapper = createElement("div", { class: "setting-row", style: "display:block;" });
const label = createElement("div", { class: "setting-label" }, "Muted Channels");
const desc = createElement(
"div",
{ class: "setting-desc" },
"Muted channels never notify you, but messages that mention you still do.",
);
const list = createElement("div", {
class: "settings-muted-list",
"data-testid": "muted-channel-list",
});
appendChildren(wrapper, label, desc, list);
function render(): void {
clearChildren(list);
const muted = listMutedChannels();
if (muted.length === 0) {
list.appendChild(
createElement(
"div",
{ class: "setting-desc", "data-testid": "muted-empty" },
"Nothing is muted.",
),
);
return;
}
for (const channelId of muted) {
const row = createElement("div", { class: "settings-muted-row" });
const name = createElement("span", { class: "settings-muted-name" });
setText(name, mutedChannelName(channelId));
const btn = createElement(
"button",
{
class: "btn btn-secondary",
type: "button",
"data-testid": `unmute-${channelId}`,
},
"Unmute",
);
btn.addEventListener(
"click",
() => {
unmuteChannel(channelId);
render();
},
{ signal },
);
appendChildren(row, name, btn);
list.appendChild(row);
}
}
render();
return wrapper;
}
@@ -179,9 +179,16 @@ function buildVoiceAudioTabInner(
const onUp = (): void => {
meterThreshold.removeEventListener("pointermove", onMove);
meterThreshold.removeEventListener("pointerup", onUp);
meterThreshold.removeEventListener("pointercancel", onUp);
};
meterThreshold.addEventListener("pointermove", onMove, { signal });
meterThreshold.addEventListener("pointerup", onUp, { signal });
// A touch/pen drag that the OS claims as a pan (or any other
// mid-drag pointer loss) fires pointercancel instead of pointerup.
// Without this, onMove stays attached for the tab's lifetime and
// every later hover over the handle silently rewrites and persists
// voiceSensitivity with no button held (v097).
meterThreshold.addEventListener("pointercancel", onUp, { signal });
},
{ signal },
);
@@ -411,10 +418,14 @@ function buildVoiceAudioTabInner(
{ signal },
);
// Race guard: prevent stale getUserMedia results from overwriting a newer request
// Race guard: prevent stale getUserMedia results from overwriting a newer
// request. cleanupMic() invalidates both counters, so a stream resolving
// after teardown is stopped instead of re-arming state nobody cleans up.
let cameraRequestId = 0;
let micRequestId = 0;
registerCameraInvalidation(() => {
cameraRequestId += 1;
micRequestId += 1;
});
function stopCameraPreview(): void {
@@ -482,6 +493,7 @@ function buildVoiceAudioTabInner(
// Start mic level monitoring for visual feedback
void (async () => {
const thisRequest = ++micRequestId;
try {
const savedDevice = loadPref<string>("audioInputDevice", "");
const constraints: MediaStreamConstraints = {
@@ -489,6 +501,13 @@ function buildVoiceAudioTabInner(
video: false,
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
// Race guard: teardown (cleanup or abort) may have run while we awaited
// — opening the mic now would leave it hot with nobody left to stop it,
// and registerMic would re-arm state cleanupMic() already cleared.
if (signal.aborted || thisRequest !== micRequestId) {
for (const track of stream.getTracks()) track.stop();
return;
}
const audioCtx = new AudioContext();
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
@@ -1,7 +0,0 @@
{
"version": 1,
"commands_hash": "ca3b770e3d69abf7",
"structs_hash": "2c0574a96a92e42f",
"config_hash": "c72a07caa5bc6ed4",
"combined_hash": "6a107ade235e2401"
}
@@ -1,107 +0,0 @@
/**
* Auto-generated TypeScript bindings for Tauri commands
* Generated by tauri-typegen v0.5.0
* Generated at: 2026-04-03T09:09:31.628896400+00:00
* Generator: none
*
* Do not edit manually - regenerate using: cargo tauri-typegen generate
*/
import { invoke } from "@tauri-apps/api/core";
import * as types from "./types";
export async function startLivekitProxy(params: types.StartLivekitProxyParams): Promise<number> {
return invoke("start_livekit_proxy", params);
}
export async function stopLivekitProxy(): Promise<void> {
return invoke("stop_livekit_proxy");
}
export async function checkClientUpdate(
params: types.CheckClientUpdateParams,
): Promise<types.UpdateCheckResult> {
return invoke("check_client_update", params);
}
export async function downloadAndInstallUpdate(
params: types.DownloadAndInstallUpdateParams,
): Promise<void> {
return invoke("download_and_install_update", params);
}
export async function pttStart(): Promise<void> {
return invoke("ptt_start");
}
export async function pttStop(): Promise<void> {
return invoke("ptt_stop");
}
export async function pttSetKey(params: types.PttSetKeyParams): Promise<void> {
return invoke("ptt_set_key", params);
}
export async function pttGetKey(): Promise<number> {
return invoke("ptt_get_key");
}
export async function pttListenForKey(): Promise<number> {
return invoke("ptt_listen_for_key");
}
export async function saveCredential(params: types.SaveCredentialParams): Promise<void> {
return invoke("save_credential", params);
}
export async function loadCredential(
params: types.LoadCredentialParams,
): Promise<types.CredentialData | null> {
return invoke("load_credential", params);
}
export async function deleteCredential(params: types.DeleteCredentialParams): Promise<void> {
return invoke("delete_credential", params);
}
export async function wsConnect(params: types.WsConnectParams): Promise<void> {
return invoke("ws_connect", params);
}
export async function wsSend(params: types.WsSendParams): Promise<void> {
return invoke("ws_send", params);
}
export async function wsDisconnect(): Promise<void> {
return invoke("ws_disconnect");
}
export async function acceptCertFingerprint(
params: types.AcceptCertFingerprintParams,
): Promise<void> {
return invoke("accept_cert_fingerprint", params);
}
export async function getSettings(): Promise<types.Value> {
return invoke("get_settings");
}
export async function saveSettings(params: types.SaveSettingsParams): Promise<void> {
return invoke("save_settings", params);
}
export async function storeCertFingerprint(
params: types.StoreCertFingerprintParams,
): Promise<void> {
return invoke("store_cert_fingerprint", params);
}
export async function getCertFingerprint(
params: types.GetCertFingerprintParams,
): Promise<string | null> {
return invoke("get_cert_fingerprint", params);
}
export async function openDevtools(): Promise<void> {
return invoke("open_devtools");
}
@@ -1,48 +0,0 @@
/**
* Auto-generated TypeScript bindings for Tauri commands
* Generated by tauri-typegen v0.5.0
* Generated at: 2026-04-03T09:09:31.629251800+00:00
* Generator: none
*
* Do not edit manually - regenerate using: cargo tauri-typegen generate
*/
/**
* Event Listeners
* Type-safe event listener helpers for Tauri events
*/
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import * as types from "./types";
/**
* Listen for 'status-change' events
* @param handler - Callback function to handle the event
* @returns Promise that resolves to an unlisten function
*/
export async function onStatusChange(handler: (payload: string) => void): Promise<UnlistenFn> {
return listen<string>("status-change", (event) => {
handler(event.payload);
});
}
/**
* Listen for 'ws-state' events
* @param handler - Callback function to handle the event
* @returns Promise that resolves to an unlisten function
*/
export async function onWsState(handler: (payload: string) => void): Promise<UnlistenFn> {
return listen<string>("ws-state", (event) => {
handler(event.payload);
});
}
/**
* Listen for 'cert-tofu' events
* @param handler - Callback function to handle the event
* @returns Promise that resolves to an unlisten function
*/
export async function onCertTofu(handler: (payload: types.Value) => void): Promise<UnlistenFn> {
return listen<types.Value>("cert-tofu", (event) => {
handler(event.payload);
});
}
@@ -1,12 +0,0 @@
/**
* Auto-generated TypeScript bindings for Tauri commands
* Generated by tauri-typegen v0.5.0
* Generated at: 2026-04-03T09:09:31.629428700+00:00
* Generator: none
*
* Do not edit manually - regenerate using: cargo tauri-typegen generate
*/
export * from "./types";
export * from "./commands";
export * from "./events";
@@ -1,92 +0,0 @@
/**
* Auto-generated TypeScript bindings for Tauri commands
* Generated by tauri-typegen v0.5.0
* Generated at: 2026-04-03T09:09:31.628377200+00:00
* Generator: none
*
* Do not edit manually - regenerate using: cargo tauri-typegen generate
*/
export interface UpdateCheckResult {
available: boolean;
version?: string | null;
body?: string | null;
}
export type Value = unknown;
export interface CredentialData {
username: string;
token: string;
}
export interface StartLivekitProxyParams {
remoteHost: string;
[key: string]: unknown;
}
export interface CheckClientUpdateParams {
serverUrl: string;
[key: string]: unknown;
}
export interface DownloadAndInstallUpdateParams {
serverUrl: string;
[key: string]: unknown;
}
export interface PttSetKeyParams {
vkCode: number;
[key: string]: unknown;
}
export interface SaveCredentialParams {
host: string;
username: string;
token: string;
password?: string | null;
[key: string]: unknown;
}
export interface LoadCredentialParams {
host: string;
[key: string]: unknown;
}
export interface DeleteCredentialParams {
host: string;
[key: string]: unknown;
}
export interface WsConnectParams {
url: string;
[key: string]: unknown;
}
export interface WsSendParams {
message: string;
[key: string]: unknown;
}
export interface AcceptCertFingerprintParams {
host: string;
fingerprint: string;
[key: string]: unknown;
}
export interface SaveSettingsParams {
key: string;
value: Value;
[key: string]: unknown;
}
export interface StoreCertFingerprintParams {
host: string;
fingerprint: string;
[key: string]: unknown;
}
export interface GetCertFingerprintParams {
host: string;
[key: string]: unknown;
}
+173
View File
@@ -0,0 +1,173 @@
/**
* Shared dialog accessibility helpers (DC-13).
*
* Generalizes the pattern UserProfilePopup pioneered — dialog semantics, a
* Tab-cycling focus trap, and focus save/restore — so every modal applies the
* same behavior instead of re-implementing (or forgetting) it. All listeners
* register against the caller's AbortSignal, matching the component teardown
* idiom used across the codebase.
*/
/** The elements a dialog's Tab cycle visits. */
const FOCUSABLE_SELECTOR =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
/**
* Elements the app hides via inline `style.display = "none"` (the codebase's
* standard show/hide idiom — e.g. a group-name field revealed only once a
* second member is picked) still match FOCUSABLE_SELECTOR: the selector is
* structural, not a visibility check. A browser silently refuses to move
* focus onto a display:none element, so treating one as the dialog's "first"
* or "last" focusable leaves .focus() a no-op and the Tab trap comparing
* against an edge focus never actually reached — Tab then falls through to
* the browser's native order and can walk out of the dialog entirely.
*/
function isFocusable(el: HTMLElement): boolean {
return el.style.display !== "none" && el.style.visibility !== "hidden";
}
function queryFocusable(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
isFocusable,
);
}
export interface DialogSemanticsOptions {
/** Accessible name for the dialog (aria-label). */
readonly label?: string;
/** Id of the element naming the dialog (aria-labelledby); wins over label. */
readonly labelledBy?: string;
}
/**
* Stamp WAI-ARIA dialog semantics on a modal container: role="dialog",
* aria-modal="true", and tabindex="-1" so the container itself can take
* initial focus when it holds no focusable control.
*/
export function applyDialogSemantics(el: HTMLElement, opts: DialogSemanticsOptions = {}): void {
el.setAttribute("role", "dialog");
el.setAttribute("aria-modal", "true");
el.setAttribute("tabindex", "-1");
if (opts.labelledBy !== undefined) {
el.setAttribute("aria-labelledby", opts.labelledBy);
} else if (opts.label !== undefined) {
el.setAttribute("aria-label", opts.label);
}
}
/**
* Trap Tab/Shift+Tab inside `container` for as long as `signal` lives:
* tabbing past the last focusable wraps to the first and vice versa. The
* focusable set is queried per keystroke, so contents may change freely.
*/
export function trapFocus(container: HTMLElement, signal: AbortSignal): void {
container.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const focusable = queryFocusable(container);
if (focusable.length === 0) {
// Nothing tabbable inside — keep focus on the container itself.
e.preventDefault();
return;
}
const first = focusable[0]!;
const last = focusable[focusable.length - 1]!;
// Focus outside the set (e.g. on the container) also wraps to an edge.
const active = document.activeElement;
if (e.shiftKey && (active === first || active === container)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && (active === last || active === container)) {
e.preventDefault();
first.focus();
}
},
{ signal },
);
}
/**
* Make exactly one cell in `container` tabbable (the first) and the rest
* focusable only programmatically. Call after every render that replaces the
* cell set — search results swap the cells out from under the tabindex, and a
* grid with zero (or many) Tab stops breaks the "Tab enters the grid once"
* contract.
*/
export function setRovingTabindex(container: HTMLElement, cellSelector: string): void {
const cells = container.querySelectorAll<HTMLElement>(cellSelector);
cells.forEach((cell, i) => {
cell.setAttribute("tabindex", i === 0 ? "0" : "-1");
});
}
/**
* Roving-tabindex keyboard support for a flat list of option cells:
* ArrowLeft/ArrowRight step, Home/End jump to the edges, and Enter/Space
* activate the focused cell through its own click handler so keyboard and
* mouse take the identical code path. The grid is deliberately treated as a
* flat list — row-aware Up/Down would need layout knowledge the DOM doesn't
* expose reliably.
*
* The listener lives on the container (which survives re-renders) and the
* cell set is queried per keystroke, so callers may rebuild cells freely as
* long as they re-run setRovingTabindex afterwards.
*/
export function enableRovingNavigation(
container: HTMLElement,
cellSelector: string,
signal: AbortSignal,
): void {
container.addEventListener(
"keydown",
(e: KeyboardEvent) => {
// Only keystrokes originating on a cell rove; the search input above
// the grid keeps its native caret behavior for arrows and Home/End.
const origin =
e.target instanceof HTMLElement ? e.target.closest<HTMLElement>(cellSelector) : null;
if (origin === null) return;
const cells = Array.from(container.querySelectorAll<HTMLElement>(cellSelector));
const from = cells.indexOf(origin);
if (from === -1) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
origin.click();
return;
}
let to: number;
if (e.key === "ArrowRight") to = Math.min(from + 1, cells.length - 1);
else if (e.key === "ArrowLeft") to = Math.max(from - 1, 0);
else if (e.key === "Home") to = 0;
else if (e.key === "End") to = cells.length - 1;
else return;
e.preventDefault();
// Move the single Tab stop along with focus so tabbing away and back
// returns to the last visited cell, not the first.
origin.setAttribute("tabindex", "-1");
const target = cells[to]!;
target.setAttribute("tabindex", "0");
target.focus();
},
{ signal },
);
}
/**
* Move initial focus into a just-opened dialog (its first focusable control,
* else the container itself) and return a restorer that puts focus back on
* whatever held it before — call the restorer on close. Capturing happens NOW,
* so call this before anything inside the dialog grabs focus.
*/
export function focusDialog(container: HTMLElement): () => void {
const previous = document.activeElement;
const firstFocusable = queryFocusable(container)[0];
(firstFocusable ?? container).focus();
return () => {
if (previous instanceof HTMLElement && previous.isConnected) {
previous.focus();
}
};
}
@@ -0,0 +1,33 @@
/**
* Opening the server's admin panel in the user's browser.
*
* The audit log stays admin-panel-only: it is a long, filterable, paginated
* table over a REST endpoint the desktop client has no other use for, and
* rebuilding it here would mean maintaining two of them. What the desktop
* client owes its moderators is a way to *reach* it — hence this, rather than
* a port of the view.
*
* The panel is opened in the real browser at `https://{host}/admin`, NOT
* through the local TOFU proxy the REST client uses: that proxy exists so the
* webview can talk to a self-signed server, and its loopback origin means
* nothing to an external browser. A self-signed deployment therefore shows the
* browser's certificate warning, which is the honest outcome — the operator is
* the one who chose the certificate.
*/
/** The admin-panel URL for `host`, deep-linked to `section` when given. */
export function adminPanelUrl(host: string, section?: string): string {
const base = `https://${host}/admin`;
return section === undefined || section === "" ? base : `${base}#${section}`;
}
/**
* Open the admin panel in the user's default browser.
*
* The opener plugin is imported lazily so this module can be loaded (and the
* URL builder tested) in an environment with no Tauri runtime.
*/
export async function openAdminPanel(host: string, section?: string): Promise<void> {
const { openUrl } = await import("@tauri-apps/plugin-opener");
await openUrl(adminPanelUrl(host, section));
}
+245 -21
View File
@@ -9,19 +9,21 @@ import type {
RegisterResponse,
HealthResponse,
MessagesResponse,
MessagesAroundResponse,
ReactionUsersResponse,
PurgeResponse,
SearchResponse,
ApiError,
ChannelType,
ChannelResponse,
EmojiResponse,
SoundResponse,
InviteResponse,
SessionResponse,
UploadResponse,
VoiceCredentialsResponse,
MemberResponse,
DmChannelsResponse,
CreateDmResponse,
GroupDmResponse,
BlockedUsersResponse,
GifSearchResponse,
} from "./types";
@@ -47,6 +49,30 @@ export class ApiClientError extends Error {
export type OnUnauthorized = () => void;
/**
* Single session object from GET /users/me/sessions, matching the server's
* wire shape (Server/api/profile_handler.go's sessionResponse, wrapped in a
* `{sessions: [...]}` envelope — docs/api.md). Defined here, next to its only
* consumer, rather than in `./types`: the declaration that used to live there
* had drifted from the actual contract (it declared `ip_address`/`expires_at`,
* which the server never sends, and omitted `ip`/`is_current`, which it always
* does), and nothing else needs this shape.
*/
export interface SessionInfo {
readonly id: number;
/** Never null: the server's fields are plain Go strings, so an unknown
* device or address arrives as "" rather than being omitted. */
readonly device: string;
readonly ip: string;
readonly created_at: string;
readonly last_used: string;
readonly is_current: boolean;
}
interface SessionsListResponse {
readonly sessions: SessionInfo[];
}
const log = createLogger("api");
/** Create the REST API client. */
@@ -185,7 +211,20 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
log.error("setConfig rejected invalid host", { host: newConfig.host });
throw new Error("Invalid host format");
}
config = { ...config, ...newConfig };
// Switching to a different host without an accompanying new token must
// not carry the previous host's bearer token forward — otherwise the
// login/register request to the new host rides a still-live session
// token for the old one. Callers that only rotate the token (post-auth)
// never pass `host`, so this never touches a same-host token refresh.
if (
newConfig.host !== undefined &&
newConfig.host !== config.host &&
newConfig.token === undefined
) {
config = { ...config, ...newConfig, token: undefined };
} else {
config = { ...config, ...newConfig };
}
},
/** Get current config (for debugging). Token is redacted. */
@@ -276,12 +315,52 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
},
updateProfile(
data: { username?: string; avatar?: string; identity_public_key?: string },
data: {
username?: string;
avatar?: string;
identity_public_key?: string;
/** Omit to leave unchanged; "" clears the field. */
display_name?: string;
about?: string;
},
signal?: AbortSignal,
): Promise<MemberResponse> {
return request<MemberResponse>("PATCH", "/users/me", data, signal);
},
/**
* Upload an avatar image (PNG/JPEG/WebP, max 1 MB, max 1024x1024).
*
* Multipart rather than JSON for the same reason attachments are, and it
* shares uploadFile's shape: no Content-Type header (the browser has to
* set the multipart boundary) and the bearer token attached by hand.
* On success the server has already pointed the user's avatar at the
* served file and broadcast a user_update.
*/
async uploadAvatar(file: File, signal?: AbortSignal): Promise<UploadResponse> {
const formData = new FormData();
formData.append("file", file);
const url = `${await baseUrl()}/users/me/avatar`;
const h: Record<string, string> = {};
if (config.token) {
h["Authorization"] = `Bearer ${config.token}`;
}
const res = await fetch(url, { method: "POST", headers: h, body: formData, signal });
if (res.status === 401) {
onUnauthorized?.();
const err = await parseError(res);
throw new ApiClientError(401, err.error, err.message);
}
if (!res.ok) {
const err = await parseError(res);
throw new ApiClientError(res.status, err.error, err.message);
}
return res.json() as Promise<UploadResponse>;
},
changePassword(
currentPassword: string,
newPassword: string,
@@ -290,7 +369,7 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
return request<void>(
"PUT",
"/users/me/password",
{ current_password: currentPassword, new_password: newPassword },
{ old_password: currentPassword, new_password: newPassword },
signal,
);
},
@@ -310,8 +389,10 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
return request<void>("DELETE", "/users/me/totp", { password }, signal);
},
getSessions(signal?: AbortSignal): Promise<SessionResponse[]> {
return request<SessionResponse[]>("GET", "/users/me/sessions", undefined, signal);
getSessions(signal?: AbortSignal): Promise<SessionInfo[]> {
return request<SessionsListResponse>("GET", "/users/me/sessions", undefined, signal).then(
(r) => r.sessions,
);
},
revokeSession(sessionId: number, signal?: AbortSignal): Promise<void> {
@@ -337,6 +418,67 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
);
},
/**
* The window of history centred on `messageId`, for jumping to a message
* outside the loaded page. Messages come back oldest-first (already in
* render order) — see MessagesAroundResponse. 404 when the message does
* not live in this channel or has been deleted.
*/
getMessagesAround(
channelId: number,
messageId: number,
options?: { limit?: number },
signal?: AbortSignal,
): Promise<MessagesAroundResponse> {
const params = new URLSearchParams();
if (options?.limit !== undefined) params.set("limit", String(options.limit));
const qs = params.toString();
return request<MessagesAroundResponse>(
"GET",
`/channels/${channelId}/messages/around/${messageId}${qs ? `?${qs}` : ""}`,
undefined,
signal,
);
},
/**
* Bulk-delete the newest `limit` messages in a channel (1-100). Requires
* MANAGE_MESSAGES; the server broadcasts one chat_bulk_deleted event, so
* the local store is updated by the dispatcher rather than here.
*/
purgeMessages(
channelId: number,
limit: number,
options?: { before?: number },
signal?: AbortSignal,
): Promise<PurgeResponse> {
return request<PurgeResponse>(
"POST",
`/channels/${channelId}/messages/purge`,
{ limit, ...(options?.before !== undefined ? { before: options.before } : {}) },
signal,
);
},
/**
* The users who reacted to a message with one emoji, for the who-reacted
* tooltip. Oldest reaction first, capped at 100 server-side. The emoji is a
* path segment, so it must be percent-encoded.
*/
getReactionUsers(
channelId: number,
messageId: number,
emoji: string,
signal?: AbortSignal,
): Promise<ReactionUsersResponse> {
return request<ReactionUsersResponse>(
"GET",
`/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/users`,
undefined,
signal,
);
},
getPins(channelId: number, signal?: AbortSignal): Promise<MessagesResponse> {
return request<MessagesResponse>("GET", `/channels/${channelId}/pins`, undefined, signal);
},
@@ -439,26 +581,51 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
return request<void>("DELETE", `/invites/${code}`, undefined, signal);
},
// ── Emoji ─────────────────────────────────────────────
// ── Custom emoji ──────────────────────────────────────
//
// Reading is open to any member; upload and delete require MANAGE_SERVER
// and are refused server-side with 403 regardless of what the UI offers.
getEmoji(signal?: AbortSignal): Promise<EmojiResponse[]> {
/** The server's whole custom-emoji set. */
listEmoji(signal?: AbortSignal): Promise<EmojiResponse[]> {
return request<EmojiResponse[]>("GET", "/emoji", undefined, signal);
},
/**
* Upload one custom emoji. The image is validated server-side (PNG/JPEG/
* GIF/WebP, at most 512 KB and 128x128), so the only thing this promises
* is to send it; a rejection arrives as an ApiClientError with the reason.
*/
async uploadEmoji(shortcode: string, file: File, signal?: AbortSignal): Promise<EmojiResponse> {
const formData = new FormData();
formData.append("shortcode", shortcode);
formData.append("file", file);
const url = `${await baseUrl()}/emoji`;
const h: Record<string, string> = {};
if (config.token) {
h["Authorization"] = `Bearer ${config.token}`;
}
// Don't set Content-Type — browser sets multipart boundary
const res = await fetch(url, { method: "POST", headers: h, body: formData, signal });
if (res.status === 401) {
onUnauthorized?.();
const err = await parseError(res);
throw new ApiClientError(401, err.error, err.message);
}
if (!res.ok) {
const err = await parseError(res);
throw new ApiClientError(res.status, err.error, err.message);
}
return res.json() as Promise<EmojiResponse>;
},
deleteEmoji(emojiId: number, signal?: AbortSignal): Promise<void> {
return request<void>("DELETE", `/emoji/${emojiId}`, undefined, signal);
},
// ── Sounds ────────────────────────────────────────────
getSounds(signal?: AbortSignal): Promise<SoundResponse[]> {
return request<SoundResponse[]>("GET", "/sounds", undefined, signal);
},
deleteSound(soundId: number, signal?: AbortSignal): Promise<void> {
return request<void>("DELETE", `/sounds/${soundId}`, undefined, signal);
},
// ── Direct Messages ─────────────────────────────────────
/** List user's open DM channels. */
@@ -471,7 +638,30 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
return request<CreateDmResponse>("POST", "/dms", { recipient_id: recipientId }, signal);
},
/** Close a DM (hide from sidebar). */
/** Create a group DM with 2..8 other users (3..10 total). */
createGroupDm(
recipientIds: readonly number[],
name?: string,
signal?: AbortSignal,
): Promise<GroupDmResponse> {
return request<GroupDmResponse>(
"POST",
"/dms/group",
{ recipient_ids: [...recipientIds], name: name ?? "" },
signal,
);
},
/** Set or clear a group DM's name. Any participant may; 1:1 DMs refuse. */
renameGroupDm(channelId: number, name: string, signal?: AbortSignal): Promise<GroupDmResponse> {
return request<GroupDmResponse>("PATCH", `/dms/${channelId}`, { name }, signal);
},
/**
* Remove a DM from the sidebar. For a 1:1 this only hides it — the next
* message from either side brings it back. For a group it is a *leave*:
* the caller comes out of the participant list and cannot return unaided.
*/
closeDm(channelId: number, signal?: AbortSignal): Promise<void> {
return request<void>("DELETE", `/dms/${channelId}`, undefined, signal);
},
@@ -481,6 +671,16 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
return request<BlockedUsersResponse>("GET", "/blocks", undefined, signal);
},
/** Block a user (prevents DMs in both directions). */
blockUser(userId: number, signal?: AbortSignal): Promise<void> {
return request<void>("PUT", `/blocks/${userId}`, undefined, signal);
},
/** Unblock a previously blocked user. */
unblockUser(userId: number, signal?: AbortSignal): Promise<void> {
return request<void>("DELETE", `/blocks/${userId}`, undefined, signal);
},
// ── Voice ─────────────────────────────────────────────
getVoiceCredentials(signal?: AbortSignal): Promise<VoiceCredentialsResponse> {
@@ -527,9 +727,24 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
data: {
name?: string;
topic?: string;
// Moving a channel between categories is a rename of free text; an
// omitted field keeps the channel's current category server-side.
category?: string;
slow_mode?: number;
position?: number;
archived?: boolean;
/**
* Age-restriction label. Stored, broadcast and audited by the server,
* which applies no content behaviour of its own to a flagged channel.
*/
nsfw?: boolean;
/**
* Voice capacity limits (0 = unlimited), enforced by the server on
* join. Omit them on a text channel rather than sending 0 — every
* field the body leaves out keeps its stored value.
*/
voice_max_users?: number;
voice_max_video?: number;
},
signal?: AbortSignal,
): Promise<ChannelResponse> {
@@ -546,13 +761,22 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
return adminRequest<void>("DELETE", `/users/${userId}/sessions`, undefined, signal);
},
adminBanMember(userId: number, reason?: string, signal?: AbortSignal): Promise<void> {
adminBanMember(
userId: number,
reason?: string,
durationHours?: number,
signal?: AbortSignal,
): Promise<void> {
return adminRequest<void>(
"PATCH",
`/users/${userId}`,
{
banned: true,
ban_reason: reason ?? "",
// Omitted/0 = permanent; otherwise the ban expires after this many hours.
...(durationHours !== undefined && durationHours > 0
? { ban_duration_hours: durationHours }
: {}),
},
signal,
);

Some files were not shown because too many files have changed in this diff Show More