mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad0448df4d | ||
|
|
d3526968bb | ||
|
|
82be103794 | ||
|
|
4ff199e14f | ||
|
|
b77c2790b0 | ||
|
|
931a2fd27e | ||
|
|
1486078265 | ||
|
|
3f8a26c904 | ||
|
|
4a3b4e0cff | ||
|
|
15db18a3ac | ||
|
|
4582a601b3 | ||
|
|
a3d03620e5 | ||
|
|
5df58bb9c4 | ||
|
|
4bda86d0cb | ||
|
|
6e841cc75f | ||
|
|
d9c6094b5f | ||
|
|
4fa8f6f84d | ||
|
|
b854f5a986 | ||
|
|
3c78bcd2b2 | ||
|
|
9d75890f50 | ||
|
|
086979b7e8 |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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')
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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** (2–10 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
20
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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"],
|
||||
},
|
||||
);
|
||||
|
||||
Generated
+182
-123
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
Generated
+7
-401
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user