Merge pull request #82 from J3vb/feature/livekit-migration

feat: LiveKit voice migration, DMs, themes, and remote voice support
This commit is contained in:
J3vb
2026-03-28 19:10:01 +01:00
committed by GitHub
288 changed files with 47542 additions and 13055 deletions
+3
View File
@@ -42,3 +42,6 @@ Client/publish-release/
Client/login-mockup.html
Client/ui-mockup.html
.gstack/
.claude-flow/
.mcp.json
.superpowers/
+136 -3
View File
@@ -1,12 +1,12 @@
{
"name": "owncord-client",
"version": "0.1.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "owncord-client",
"version": "0.1.0",
"version": "1.2.0",
"dependencies": {
"@jitsi/rnnoise-wasm": "^0.2.1",
"@tauri-apps/api": "^2.10.1",
@@ -18,7 +18,8 @@
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-updater": "^2.10.0"
"@tauri-apps/plugin-updater": "^2.10.0",
"livekit-client": "^2.17.3"
},
"devDependencies": {
"@playwright/test": "^1",
@@ -178,6 +179,12 @@
"specificity": "bin/cli.js"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
@@ -850,6 +857,21 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@livekit/mutex": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz",
"integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==",
"license": "Apache-2.0"
},
"node_modules/@livekit/protocol": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.44.0.tgz",
"integrity": "sha512-/vfhDUGcUKO8Q43r6i+5FrDhl5oZjm/X3U4x2Iciqvgn5C8qbj+57YPcWSJ1kyIZm5Cm6AV2nAPjMm3ETD/iyg==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.10.0"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -1553,6 +1575,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/dom-mediacapture-record": {
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
"license": "MIT",
"peer": true
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2011,6 +2040,15 @@
"@types/estree": "^1.0.0"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -2250,6 +2288,15 @@
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jose": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
@@ -2308,6 +2355,39 @@
"node": "20 || >=22"
}
},
"node_modules/livekit-client": {
"version": "2.17.3",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.17.3.tgz",
"integrity": "sha512-htwsAL/BMylY/zwdcT/z00U789csbi9DldSW7DO+5tz7Q15pwu++E1X+ZdtZDfkmlysfQLLibdcqlyg9FY7veQ==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.44.0",
"events": "^3.3.0",
"jose": "^6.1.0",
"loglevel": "^1.9.2",
"sdp-transform": "^2.15.0",
"tslib": "2.8.1",
"typed-emitter": "^2.1.0",
"webrtc-adapter": "^9.0.1"
},
"peerDependencies": {
"@types/dom-mediacapture-record": "^1"
}
},
"node_modules/loglevel": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@@ -2644,6 +2724,16 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
@@ -2657,6 +2747,21 @@
"node": ">=v12.22.7"
}
},
"node_modules/sdp": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz",
"integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==",
"license": "MIT"
},
"node_modules/sdp-transform": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
"license": "MIT",
"bin": {
"sdp-verify": "checker.js"
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -3003,6 +3108,21 @@
"node": ">=20"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
"license": "MIT",
"optionalDependencies": {
"rxjs": "*"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -3221,6 +3341,19 @@
"node": ">=20"
}
},
"node_modules/webrtc-adapter": {
"version": "9.0.4",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.4.tgz",
"integrity": "sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==",
"license": "BSD-3-Clause",
"dependencies": {
"sdp": "^3.2.0"
},
"engines": {
"node": ">=6.0.0",
"npm": ">=3.10.0"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "owncord-client",
"private": true,
"version": "1.1.1",
"version": "1.3.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -38,6 +38,7 @@
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-updater": "^2.10.0"
"@tauri-apps/plugin-updater": "^2.10.0",
"livekit-client": "^2.17.3"
}
}
@@ -4,9 +4,21 @@ import { defineConfig } from "@playwright/test";
* Playwright config for testing against the REAL Tauri production app.
*
* Connects to the WebView2 window via Chrome DevTools Protocol (CDP).
* The custom fixture in tests/e2e/native-fixture.ts launches the Tauri
* exe with WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port
* and connects Playwright to it via chromium.connectOverCDP().
*
* Two projects:
*
* 1. `native-no-auth` — Tests that do NOT need login (smoke tests,
* connect page UI, auth flow verification). Each test gets a fresh
* Tauri exe via the original per-test fixture.
*
* 2. `native-authenticated` — Tests that need a logged-in session
* (channel nav, chat ops, settings, voice, overlays, app layout).
* Uses the persistent fixture: one Tauri exe for the entire project,
* login happens once, all tests reuse the same page.
*
* This design eliminates server rate limiting (5 logins/min, 10-failure
* lockout) that previously caused test failures when 8+ spec files each
* launched a fresh exe and logged in.
*
* Requirements:
* - Built Tauri exe: npm run tauri build
@@ -15,12 +27,11 @@ import { defineConfig } from "@playwright/test";
* Usage: npm run test:e2e:native
*/
export default defineConfig({
testDir: "./tests/e2e/native",
timeout: 60_000,
expect: {
timeout: 10_000,
},
// Native tests are slower (real app startup) — run sequentially
// Native tests run sequentially — one app instance at a time
fullyParallel: false,
workers: 1,
retries: 2,
@@ -36,6 +47,24 @@ export default defineConfig({
video: "on-first-retry",
},
// No webServer — we launch the Tauri app ourselves in the fixture.
// No projects — we connect directly to WebView2 via CDP, not via browser launch.
projects: [
{
name: "native-no-auth",
testDir: "./tests/e2e/native",
testMatch: ["smoke.spec.ts", "auth-flow.spec.ts"],
},
{
name: "native-authenticated",
testDir: "./tests/e2e/native",
testMatch: [
"app-layout.spec.ts",
"channel-navigation.spec.ts",
"chat-operations.spec.ts",
"settings-overlay.spec.ts",
"voice-controls.spec.ts",
"overlays.spec.ts",
],
dependencies: ["native-no-auth"],
},
],
});
+142 -53
View File
@@ -6,6 +6,9 @@
// =============================================================================
const FRAME_SIZE = 480;
const WASM_MEMORY_INITIAL_PAGES = 256;
const OUTPUT_RING_CAPACITY = 50;
const RN_NOISE_INT16_SCALE = 32768;
class RNNoiseProcessor extends AudioWorkletProcessor {
constructor() {
@@ -30,12 +33,11 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
this._inputRing = new Float32Array(FRAME_SIZE);
this._inputRingOffset = 0;
// Output ring buffer (fixed-size, prevents unbounded growth)
this._outCapacity = 50;
this._outRing = new Array(this._outCapacity);
this._outWriteIdx = 0;
this._outReadIdx = 0;
this._outCount = 0;
// Output ring buffer (contiguous for efficiency)
this._outBuffer = new Float32Array(OUTPUT_RING_CAPACITY * FRAME_SIZE);
this._outWritePos = 0;
this._outReadPos = 0;
this._outAvailable = 0;
this._outSampleOffset = 0;
this.port.onmessage = (event) => {
@@ -47,9 +49,36 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
};
}
/**
* Reports an error to the main thread and logs it.
* @param {string} message - Error message
* @param {*} [error] - Optional error object
* @private
*/
_reportError(message, error) {
console.error(`RNNoise Processor: ${message}`, error);
this.port.postMessage({ type: "error", message });
}
/**
* Initializes the WASM module and RNNoise state.
* @param {ArrayBuffer} wasmBytes - Raw WASM module bytes
* @private
*/
async _initWasm(wasmBytes) {
let allocated = false;
try {
const memory = new WebAssembly.Memory({ initial: 256 });
// 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,
@@ -75,45 +104,73 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
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) {
// Fallback: the WASM module may use Emscripten-style exports
// that need the full runtime. Signal failure so the main thread
// can fall back to ScriptProcessorNode.
this.port.postMessage({ type: "error", message: String(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
*/
_processFrame() {
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] * 32768;
this._heapF32[inOff + i] = this._inputRing[i] * RN_NOISE_INT16_SCALE;
}
exports.rnnoise_process_frame(this._state, this._outputPtr, this._inputPtr);
const outOff = this._outputPtr / 4;
const result = new Float32Array(FRAME_SIZE);
// Write to contiguous buffer
const writeStart = this._outWritePos * FRAME_SIZE;
for (let i = 0; i < FRAME_SIZE; i++) {
result[i] = this._heapF32[outOff + i] / 32768;
this._outBuffer[writeStart + i] = this._heapF32[outOff + i] / RN_NOISE_INT16_SCALE;
}
// Write to ring buffer, dropping oldest if full
if (this._outCount >= this._outCapacity) {
this._outReadIdx = (this._outReadIdx + 1) % this._outCapacity;
this._outCount--;
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;
}
this._outRing[this._outWriteIdx] = result;
this._outWriteIdx = (this._outWriteIdx + 1) % this._outCapacity;
this._outCount++;
}
/**
* Cleans up WASM resources and marks the processor as destroyed.
* Safe to call multiple times.
* @private
*/
_cleanup() {
if (this._instance && this._state) {
try {
@@ -121,8 +178,9 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
exports.rnnoise_destroy(this._state);
exports.free(this._inputPtr);
exports.free(this._outputPtr);
} catch {
// Best-effort cleanup
} catch (err) {
console.warn('RNNoise cleanup failed:', err);
// Continue cleanup even if individual steps fail
}
}
this._ready = false;
@@ -130,26 +188,12 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
this._state = 0;
}
process(inputs, outputs) {
if (this._destroyed) return false;
if (!this._ready) {
// Pass through until WASM is ready
const input = inputs[0];
const output = outputs[0];
if (input && output && input[0] && output[0]) {
output[0].set(input[0]);
}
return true;
}
const input = inputs[0];
const output = outputs[0];
if (!input || !output || !input[0] || !output[0]) return true;
const inData = input[0];
const outData = output[0];
// Feed input into ring buffer, process complete frames
/**
* Processes input audio data into the ring buffer and triggers frame processing.
* @param {Float32Array} inData - Input audio samples
* @private
*/
_processInputRingBuffer(inData) {
let inIdx = 0;
while (inIdx < inData.length) {
const needed = FRAME_SIZE - this._inputRingOffset;
@@ -163,19 +207,25 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
this._inputRingOffset = 0;
}
}
}
// Drain processed frames into output
/**
* Fills output buffer from the processed frames ring buffer.
* @param {Float32Array} outData - Output audio buffer to fill
* @private
*/
_fillOutputFromRingBuffer(outData) {
let outIdx = 0;
while (outIdx < outData.length && this._outCount > 0) {
const chunk = this._outRing[this._outReadIdx];
const available = chunk.length - this._outSampleOffset;
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(chunk.subarray(this._outSampleOffset, this._outSampleOffset + toWrite), outIdx);
outData.set(this._outBuffer.subarray(readStart + this._outSampleOffset, readStart + this._outSampleOffset + toWrite), outIdx);
outIdx += toWrite;
this._outSampleOffset += toWrite;
if (this._outSampleOffset >= chunk.length) {
this._outReadIdx = (this._outReadIdx + 1) % this._outCapacity;
this._outCount--;
if (this._outSampleOffset >= FRAME_SIZE) {
this._outReadPos = (this._outReadPos + 1) % OUTPUT_RING_CAPACITY;
this._outAvailable--;
this._outSampleOffset = 0;
}
}
@@ -183,6 +233,45 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
if (outIdx < outData.length) {
outData.fill(0, outIdx);
}
}
/**
* Main audio processing method called by the AudioWorklet.
* @param {Float32Array[][]} inputs - Input audio buffers
* @param {Float32Array[][]} outputs - Output audio buffers
* @returns {boolean} - Whether to continue processing
*/
process(inputs, outputs) {
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;
}
@@ -0,0 +1,291 @@
// =============================================================================
// 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);
+63 -43
View File
@@ -875,17 +875,17 @@ dependencies = [
[[package]]
name = "dom_query"
version = "0.25.1"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d9c2e7f1d22d0f2ce07626d259b8a55f4a47cb0938d4006dd8ae037f17d585e"
checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
dependencies = [
"bit-set",
"cssparser 0.36.0",
"foldhash 0.2.0",
"html5ever 0.36.1",
"html5ever 0.38.0",
"precomputed-hash",
"selectors 0.35.0",
"tendril",
"selectors 0.36.1",
"tendril 0.5.0",
]
[[package]]
@@ -926,9 +926,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "embed-resource"
version = "3.0.6"
version = "3.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e"
checksum = "47ec73ddcf6b7f23173d5c3c5a32b5507dc0a734de7730aa14abc5d5e296bb5f"
dependencies = [
"cc",
"memchr",
@@ -1651,12 +1651,12 @@ dependencies = [
[[package]]
name = "html5ever"
version = "0.36.1"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6452c4751a24e1b99c3260d505eaeee76a050573e61f30ac2c924ddc7236f01e"
checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2"
dependencies = [
"log",
"markup5ever 0.36.1",
"markup5ever 0.38.0",
]
[[package]]
@@ -1979,9 +1979,9 @@ dependencies = [
[[package]]
name = "itoa"
version = "1.0.17"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "javascriptcore-rs"
@@ -2209,17 +2209,17 @@ dependencies = [
"phf_codegen 0.11.3",
"string_cache 0.8.9",
"string_cache_codegen 0.5.4",
"tendril",
"tendril 0.4.3",
]
[[package]]
name = "markup5ever"
version = "0.36.1"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c3294c4d74d0742910f8c7b466f44dda9eb2d5742c1e430138df290a1e8451c"
checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862"
dependencies = [
"log",
"tendril",
"tendril 0.5.0",
"web_atoms",
]
@@ -2382,9 +2382,9 @@ dependencies = [
[[package]]
name = "num_enum"
version = "0.7.5"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c"
checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
dependencies = [
"num_enum_derive",
"rustversion",
@@ -2392,9 +2392,9 @@ dependencies = [
[[package]]
name = "num_enum_derive"
version = "0.7.5"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
@@ -2594,7 +2594,7 @@ dependencies = [
[[package]]
name = "owncord-client"
version = "0.1.0"
version = "1.3.0"
dependencies = [
"futures-util",
"ring",
@@ -2613,6 +2613,7 @@ dependencies = [
"tauri-plugin-store",
"tauri-plugin-updater",
"tokio",
"tokio-rustls",
"tokio-tungstenite",
"url",
"windows 0.58.0",
@@ -3012,7 +3013,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.25.4+spec-1.1.0",
"toml_edit 0.25.5+spec-1.1.0",
]
[[package]]
@@ -3710,9 +3711,9 @@ dependencies = [
[[package]]
name = "selectors"
version = "0.35.0"
version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fdfed56cd634f04fe8b9ddf947ae3dc493483e819593d2ba17df9ad05db8b2"
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
dependencies = [
"bitflags 2.11.0",
"cssparser 0.36.0",
@@ -4247,9 +4248,9 @@ dependencies = [
[[package]]
name = "tar"
version = "0.4.44"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
dependencies = [
"filetime",
"libc",
@@ -4708,6 +4709,16 @@ dependencies = [
"utf-8",
]
[[package]]
name = "tendril"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24"
dependencies = [
"new_debug_unreachable",
"utf-8",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -4916,9 +4927,9 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "1.0.0+spec-1.1.0"
version = "1.0.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9"
dependencies = [
"serde_core",
]
@@ -4949,30 +4960,30 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.25.4+spec-1.1.0"
version = "0.25.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2"
checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1"
dependencies = [
"indexmap 2.13.0",
"toml_datetime 1.0.0+spec-1.1.0",
"toml_datetime 1.0.1+spec-1.1.0",
"toml_parser",
"winnow 0.7.15",
"winnow 1.0.0",
]
[[package]]
name = "toml_parser"
version = "1.0.9+spec-1.1.0"
version = "1.0.10+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420"
dependencies = [
"winnow 0.7.15",
"winnow 1.0.0",
]
[[package]]
name = "toml_writer"
version = "1.0.6+spec-1.1.0"
version = "1.0.7+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d"
[[package]]
name = "tower"
@@ -6087,6 +6098,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8"
dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.55.0"
@@ -6193,9 +6213,9 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]]
name = "wry"
version = "0.54.3"
version = "0.54.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a24eda84b5d488f99344e54b807138896cee8df0b2d16c793f1f6b80e6d8df1f"
checksum = "e5a8135d8676225e5744de000d4dff5a082501bf7db6a1c1495034f8c314edbc"
dependencies = [
"base64 0.22.1",
"block2",
@@ -6375,18 +6395,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.42"
version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.42"
version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
dependencies = [
"proc-macro2",
"quote",
+7 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "owncord-client"
version = "1.1.1"
version = "1.3.0"
edition = "2021"
description = "OwnCord Desktop Client"
@@ -11,6 +11,10 @@ crate-type = ["lib", "cdylib", "staticlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[features]
default = ["devtools"]
devtools = ["tauri/devtools"]
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-store = "2"
@@ -27,7 +31,8 @@ tauri-plugin-process = "2"
url = "2"
tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots"] }
futures-util = "0.3.32"
tokio = { version = "1", features = ["sync"] }
tokio = { version = "1", features = ["sync", "net", "io-util", "rt", "macros"] }
tokio-rustls = { version = "0.26", default-features = false }
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
ring = "0.17"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 B

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 858 B

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 B

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 35 KiB

@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#f97316"/>
<stop offset="30%" style="stop-color:#ec4899"/>
<stop offset="65%" style="stop-color:#8b5cf6"/>
<stop offset="100%" style="stop-color:#06b6d4"/>
</linearGradient>
<filter id="glow">
<feGaussianBlur stdDeviation="8" result="blur"/>
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
</filter>
</defs>
<rect width="256" height="256" rx="48" fill="#1a1a2e"/>
<text x="128" y="170" text-anchor="middle" font-family="Segoe UI,system-ui,sans-serif" font-size="160" font-weight="900" fill="url(#g)" letter-spacing="-10" opacity="0.4" filter="url(#glow)">OC</text>
<text x="128" y="170" text-anchor="middle" font-family="Segoe UI,system-ui,sans-serif" font-size="160" font-weight="900" fill="url(#g)" letter-spacing="-10">OC</text>
</svg>

After

Width:  |  Height:  |  Size: 1004 B

@@ -4,6 +4,30 @@ use tauri_plugin_store::StoreExt;
const SETTINGS_STORE: &str = "settings.json";
const CERTS_STORE: &str = "certs.json";
/// Maximum length for a settings key to prevent denial-of-service.
const MAX_SETTINGS_KEY_LEN: usize = 128;
/// Allowed key prefixes and exact keys for the settings store.
/// Keys must either match an exact entry or start with an allowed prefix.
const ALLOWED_SETTINGS_PREFIXES: &[&str] = &[
"owncord:", // owncord:profiles, owncord:settings:*, owncord:recent-emoji
"userVolume_", // per-user volume: userVolume_{userId}
];
const ALLOWED_SETTINGS_EXACT: &[&str] = &[
"windowState",
];
fn is_settings_key_allowed(key: &str) -> bool {
if key.len() > MAX_SETTINGS_KEY_LEN || key.is_empty() {
return false;
}
if ALLOWED_SETTINGS_EXACT.contains(&key) {
return true;
}
ALLOWED_SETTINGS_PREFIXES.iter().any(|prefix| key.starts_with(prefix))
}
// ---------------------------------------------------------------------------
// Settings commands
// ---------------------------------------------------------------------------
@@ -26,6 +50,10 @@ pub fn get_settings(app: tauri::AppHandle) -> Result<Value, String> {
#[tauri::command]
pub fn save_settings(app: tauri::AppHandle, key: String, value: Value) -> Result<(), String> {
if !is_settings_key_allowed(&key) {
return Err(format!("unknown settings key: {key}"));
}
let store = app
.store(SETTINGS_STORE)
.map_err(|e| format!("failed to open settings store: {e}"))?;
@@ -47,6 +75,9 @@ pub fn store_cert_fingerprint(
host: String,
fingerprint: String,
) -> Result<(), String> {
// Normalize to lowercase for consistent comparison with ws_proxy fingerprints
let fingerprint = fingerprint.to_lowercase();
if host.is_empty() {
return Err("host must not be empty".into());
}
@@ -54,6 +85,20 @@ pub fn store_cert_fingerprint(
return Err("fingerprint must not be empty".into());
}
// Validate SHA-256 colon-hex format: "aa:bb:cc:..." (95 chars, 32 hex pairs)
if fingerprint.len() != 95 {
return Err("fingerprint must be a SHA-256 colon-hex string (95 chars)".into());
}
for (i, ch) in fingerprint.chars().enumerate() {
if i % 3 == 2 {
if ch != ':' {
return Err("fingerprint must use colon-separated hex pairs".into());
}
} else if !ch.is_ascii_hexdigit() {
return Err("fingerprint contains invalid hex character".into());
}
}
let store = app
.store(CERTS_STORE)
.map_err(|e| format!("failed to open certs store: {e}"))?;
@@ -88,3 +133,16 @@ pub fn get_cert_fingerprint(
Ok(value)
}
// ---------------------------------------------------------------------------
// DevTools command
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn open_devtools(_window: tauri::WebviewWindow) {
#[cfg(feature = "devtools")]
{
use tauri::Manager;
_window.open_devtools();
}
}
@@ -8,7 +8,7 @@ use windows::Win32::Security::Credentials::{
};
/// Data returned from `load_credential`.
#[derive(Serialize, Clone, Debug)]
#[derive(Serialize, Clone)]
pub struct CredentialData {
pub username: String,
pub token: String,
@@ -16,6 +16,16 @@ pub struct CredentialData {
pub password: Option<String>,
}
impl std::fmt::Debug for CredentialData {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CredentialData")
.field("username", &self.username)
.field("token", &"[REDACTED]")
.field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
.finish()
}
}
/// Build the target name used in Windows Credential Manager.
fn target_name(host: &str) -> Vec<u16> {
let name = format!("OwnCord/{host}");
@@ -31,10 +41,15 @@ fn to_wide(s: &str) -> Vec<u16> {
// Tauri commands
// ---------------------------------------------------------------------------
/// Save a credential (username + token) to Windows Credential Manager.
/// Save a credential (username + token + optional password) to Windows
/// Credential Manager.
///
/// Target name: `OwnCord/{host}`
/// Blob: JSON `{"username":"...","token":"..."}`
/// Blob: JSON `{"username":"...","token":"...","password":"..."}`
///
/// The password field is only included when the user checks "Remember
/// password". Windows Credential Manager encrypts the blob at rest using
/// DPAPI, tied to the logged-in Windows user — plaintext is never on disk.
#[tauri::command]
pub fn save_credential(host: String, username: String, token: String, password: Option<String>) -> Result<(), String> {
if host.is_empty() {
@@ -114,40 +129,42 @@ pub fn load_credential(host: String) -> Result<Option<CredentialData>, String> {
}
// SAFETY: `pcred` is valid after a successful CredReadW call.
let result = unsafe {
// Copy the blob bytes and free immediately — CredFree must run even if
// parsing fails, otherwise the credential memory leaks.
let blob = unsafe {
let cred = &*pcred;
let blob_slice = std::slice::from_raw_parts(
let bytes = std::slice::from_raw_parts(
cred.CredentialBlob,
cred.CredentialBlobSize as usize,
);
let json_str = String::from_utf8(blob_slice.to_vec())
.map_err(|e| format!("credential blob is not valid UTF-8: {e}"))?;
let parsed: serde_json::Value = serde_json::from_str(&json_str)
.map_err(|e| format!("credential blob is not valid JSON: {e}"))?;
let username = parsed
.get("username")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let token = parsed
.get("token")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let password = parsed
.get("password")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// Free the credential memory allocated by Windows.
)
.to_vec();
CredFree(pcred as *const std::ffi::c_void);
Ok(Some(CredentialData { username, token, password }))
bytes
};
result
// Parse outside the unsafe block — CredFree has already been called.
let json_str = String::from_utf8(blob)
.map_err(|e| format!("credential blob is not valid UTF-8: {e}"))?;
let parsed: serde_json::Value = serde_json::from_str(&json_str)
.map_err(|e| format!("credential blob is not valid JSON: {e}"))?;
let username = parsed
.get("username")
.and_then(|v| v.as_str())
.ok_or("credential blob missing 'username' field")?
.to_string();
let token = parsed
.get("token")
.and_then(|v| v.as_str())
.ok_or("credential blob missing 'token' field")?
.to_string();
let password = parsed
.get("password")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Ok(Some(CredentialData { username, token, password }))
}
/// Delete a credential from Windows Credential Manager.
+5
View File
@@ -1,6 +1,7 @@
mod commands;
mod credentials;
mod hotkeys;
mod livekit_proxy;
mod ptt;
mod tray;
mod update_commands;
@@ -19,6 +20,7 @@ pub fn run() {
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.manage(ws_proxy::WsState::new())
.manage(livekit_proxy::LiveKitProxyState::new())
.invoke_handler(tauri::generate_handler![
commands::get_settings,
commands::save_settings,
@@ -38,6 +40,9 @@ pub fn run() {
ptt::ptt_set_key,
ptt::ptt_get_key,
ptt::ptt_listen_for_key,
livekit_proxy::start_livekit_proxy,
livekit_proxy::stop_livekit_proxy,
commands::open_devtools,
])
.setup(|app| {
tray::create_tray(app.handle())?;
@@ -0,0 +1,381 @@
// Local TCP-to-TLS proxy for LiveKit signal connections.
//
// Problem: The LiveKit JS SDK opens its own WebSocket from WebView2 directly.
// WebView2's native fetch/WS rejects self-signed TLS certificates, so remote
// connections to an OwnCord server using self-signed TLS fail with
// "could not establish signal connection: Failed to fetch".
//
// Solution: This module starts a plain TCP listener on localhost. The LiveKit
// SDK connects to ws://127.0.0.1:{port}/livekit/... (trusted, no TLS issues).
// The proxy opens a TLS connection to the remote server (accepting self-signed
// certs) and shovels bytes bidirectionally — transparently tunneling the HTTP
// upgrade and subsequent WebSocket frames.
//
// KNOWN LIMITATIONS / POTENTIAL ISSUES:
// - The proxy rewrites Host and Origin headers so the remote server's
// WebSocket origin check accepts the connection. If the server adds
// stricter origin validation this may need updating.
// - 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.
// - 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
// port in JS becomes stale until the next voice join resets it.
// - The accept loop exits after 5 consecutive errors to prevent CPU spin.
use ring::digest::{digest, SHA256};
use std::net::IpAddr;
use std::sync::Arc;
use rustls::pki_types::ServerName;
use serde_json::Value;
use tauri::Runtime;
use tauri_plugin_store::StoreExt;
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Mutex;
/// Tauri-managed state for the LiveKit TLS proxy.
pub struct LiveKitProxyState {
inner: Mutex<ProxyInner>,
}
struct ProxyInner {
/// Port the proxy is listening on (None if not running).
port: Option<u16>,
/// The remote host:port we're proxying to.
remote_host: String,
/// Shutdown signal sender.
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
}
impl LiveKitProxyState {
pub fn new() -> Self {
Self {
inner: Mutex::new(ProxyInner {
port: None,
remote_host: String::new(),
shutdown_tx: None,
}),
}
}
}
// ---------------------------------------------------------------------------
// TLS certificate verifier — pinned fingerprint check
// ---------------------------------------------------------------------------
/// Tauri store file for certificate fingerprints (shared with ws_proxy).
const CERTS_STORE: &str = "certs.json";
/// Verifies the server certificate against a known SHA-256 fingerprint.
/// Reuses the fingerprint stored by ws_proxy's TOFU handshake for the same
/// host, so LiveKit connections are pinned to the same certificate the user
/// already trusted during WebSocket setup.
#[derive(Debug)]
struct PinnedVerifier {
/// Expected SHA-256 colon-hex fingerprint (e.g. "aa:bb:cc:...").
expected_fingerprint: String,
}
impl PinnedVerifier {
fn new(expected_fingerprint: String) -> Self {
Self { expected_fingerprint }
}
}
impl rustls::client::danger::ServerCertVerifier for PinnedVerifier {
fn verify_server_cert(
&self,
end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
let hash = digest(&SHA256, end_entity.as_ref());
let hex = hash
.as_ref()
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(":");
if hex == self.expected_fingerprint {
Ok(rustls::client::danger::ServerCertVerified::assertion())
} else {
Err(rustls::Error::General(format!(
"certificate fingerprint mismatch: expected {}, got {}",
self.expected_fingerprint, hex
)))
}
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
// ---------------------------------------------------------------------------
// Tauri commands
// ---------------------------------------------------------------------------
/// Produce the cert store key matching ws_proxy's format.
/// ws_proxy extracts the host from "wss://host/path" which omits port 443.
/// We normalise by stripping the default ":443" suffix so the keys match.
fn cert_store_key(remote_host: &str) -> String {
remote_host.strip_suffix(":443").unwrap_or(remote_host).to_string()
}
/// Load the stored certificate fingerprint for a host from the Tauri cert store.
fn load_stored_fingerprint<R: Runtime>(
app: &tauri::AppHandle<R>,
host: &str,
) -> Result<Option<String>, String> {
let store = app
.store(CERTS_STORE)
.map_err(|e| format!("failed to open certs store: {e}"))?;
Ok(store.get(host).and_then(|v| {
if let Value::String(s) = v {
Some(s)
} else {
None
}
}))
}
/// Start a local TCP proxy that tunnels LiveKit signal connections to the
/// remote OwnCord server over TLS, pinning the certificate to the fingerprint
/// already trusted via ws_proxy's TOFU handshake.
///
/// If a proxy is already running for the same `remote_host`, returns the
/// existing port. If running for a different host, stops the old proxy first.
#[tauri::command]
pub async fn start_livekit_proxy<R: Runtime>(
app: tauri::AppHandle<R>,
state: tauri::State<'_, LiveKitProxyState>,
remote_host: String,
) -> Result<u16, String> {
let mut inner = state.inner.lock().await;
// Reuse existing proxy for same host.
if let Some(port) = inner.port {
if inner.remote_host == remote_host {
return Ok(port);
}
// Different host — tear down old proxy.
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.
let store_key = cert_store_key(&remote_host);
let fingerprint = load_stored_fingerprint(&app, &store_key)?
.ok_or_else(|| format!(
"no trusted certificate fingerprint for {remote_host}. \
Connect via WebSocket first to establish TOFU trust."
))?;
let listener = TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| format!("livekit proxy bind failed: {e}"))?;
let port = listener
.local_addr()
.map_err(|e| format!("livekit proxy local_addr: {e}"))?
.port();
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let host = remote_host.clone();
tokio::spawn(run_proxy_loop(listener, host, fingerprint, shutdown_rx));
inner.port = Some(port);
inner.remote_host = remote_host;
inner.shutdown_tx = Some(shutdown_tx);
Ok(port)
}
/// Stop the LiveKit TLS proxy if running.
#[tauri::command]
pub async fn stop_livekit_proxy(
state: tauri::State<'_, LiveKitProxyState>,
) -> Result<(), String> {
let mut inner = state.inner.lock().await;
if let Some(tx) = inner.shutdown_tx.take() {
let _ = tx.send(());
}
inner.port = None;
inner.remote_host.clear();
Ok(())
}
// ---------------------------------------------------------------------------
// Proxy internals
// ---------------------------------------------------------------------------
/// Maximum consecutive accept errors before the proxy loop exits.
const MAX_CONSECUTIVE_ACCEPT_ERRORS: u32 = 5;
async fn run_proxy_loop(
listener: TcpListener,
remote_host: String,
pinned_fingerprint: String,
mut shutdown_rx: tokio::sync::oneshot::Receiver<()>,
) {
let mut consecutive_errors: u32 = 0;
loop {
tokio::select! {
result = listener.accept() => {
match result {
Ok((stream, _)) => {
consecutive_errors = 0;
let host = remote_host.clone();
let fp = pinned_fingerprint.clone();
tokio::spawn(async move {
if let Err(e) = handle_connection(stream, &host, &fp).await {
eprintln!("[livekit_proxy] connection error: {e}");
}
});
}
Err(e) => {
consecutive_errors += 1;
eprintln!(
"[livekit_proxy] accept error ({}/{}): {e}",
consecutive_errors, MAX_CONSECUTIVE_ACCEPT_ERRORS
);
if consecutive_errors >= MAX_CONSECUTIVE_ACCEPT_ERRORS {
eprintln!(
"[livekit_proxy] {} consecutive accept errors, stopping proxy loop",
MAX_CONSECUTIVE_ACCEPT_ERRORS
);
break;
}
}
}
}
_ = &mut shutdown_rx => break,
}
}
}
/// 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
/// 3. Open a TLS tunnel to the remote server
/// 4. Forward the rewritten request, then shovel bytes bidirectionally
async fn handle_connection(
mut local: TcpStream,
remote_host: &str,
pinned_fingerprint: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// ── 1. Read HTTP request headers (up to \r\n\r\n) ────────────────────
let mut buf = Vec::with_capacity(4096);
let mut trailer = [0u8; 4];
loop {
let mut byte = [0u8; 1];
local.read_exact(&mut byte).await?;
buf.push(byte[0]);
trailer[0] = trailer[1];
trailer[1] = trailer[2];
trailer[2] = trailer[3];
trailer[3] = byte[0];
if trailer == *b"\r\n\r\n" {
break;
}
if buf.len() > 16_384 {
return Err("HTTP request headers too large".into());
}
}
// ── 2. Rewrite Host and Origin headers ───────────────────────────────
let request = String::from_utf8_lossy(&buf);
let mut modified = String::with_capacity(buf.len() + 128);
for (i, line) in request.split("\r\n").enumerate() {
if i > 0 {
modified.push_str("\r\n");
}
let lower = line.to_lowercase();
if lower.starts_with("host:") {
modified.push_str("Host: ");
modified.push_str(remote_host);
} else if lower.starts_with("origin:") {
modified.push_str("Origin: https://");
modified.push_str(remote_host);
} else {
modified.push_str(line);
}
}
// ── 3. Connect to remote over TLS ────────────────────────────────────
let tls_config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(
PinnedVerifier::new(pinned_fingerprint.to_string()),
))
.with_no_client_auth();
let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
// Parse hostname (strip brackets for IPv6, e.g. "[::1]:8443").
// Default to port 443 (standard HTTPS) when no port is specified — the
// server is typically behind a reverse proxy (nginx) on the standard port.
let (raw_hostname, _port) = remote_host.rsplit_once(':').unwrap_or((remote_host, "443"));
let hostname = raw_hostname
.trim_start_matches('[')
.trim_end_matches(']');
let server_name = if let Ok(ip) = hostname.parse::<IpAddr>() {
ServerName::IpAddress(ip.into())
} else {
ServerName::try_from(hostname.to_string())
.map_err(|e| format!("invalid server name '{hostname}': {e}"))?
};
let tcp = TcpStream::connect(remote_host).await?;
let mut tls = connector.connect(server_name, tcp).await?;
// ── 4. Forward request + bidirectional copy ──────────────────────────
tls.write_all(modified.as_bytes()).await?;
let _ = io::copy_bidirectional(&mut local, &mut tls).await;
Ok(())
}
@@ -33,10 +33,14 @@ pub async fn check_client_update(
.parse()
.map_err(|e: url::ParseError| format!("bad endpoint URL: {e}"))?;
// OwnCord is self-hosted and commonly uses self-signed TLS certs.
// The updater connects to the user's own server, so accept invalid certs
// (the update artifact itself is verified via Ed25519 signature).
let updater = app
.updater_builder()
.endpoints(vec![url])
.map_err(|e| format!("failed to set endpoints: {e}"))?
.configure_client(|client| client.danger_accept_invalid_certs(true))
.build()
.map_err(|e| format!("failed to build updater: {e}"))?;
@@ -87,6 +91,7 @@ pub async fn download_and_install_update(
.updater_builder()
.endpoints(vec![url])
.map_err(|e| format!("failed to set endpoints: {e}"))?
.configure_client(|client| client.danger_accept_invalid_certs(true))
.build()
.map_err(|e| format!("failed to build updater: {e}"))?;
@@ -1,6 +1,6 @@
{
"productName": "OwnCord",
"version": "1.1.1",
"version": "1.3.0",
"identifier": "com.owncord.client",
"build": {
"frontendDist": "../dist",
@@ -18,11 +18,13 @@
"minHeight": 500,
"decorations": true,
"resizable": true,
"center": true
"center": true,
"additionalBrowserArgs": "--autoplay-policy=no-user-gesture-required"
}
],
"withGlobalTauri": true,
"security": {
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' https: wss:; img-src 'self' https: data:; frame-src https://www.youtube.com https://youtube.com"
"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"
}
},
"bundle": {
@@ -47,8 +49,6 @@
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJCQkQ0NzM1MjkxRTlGQTIKUldTaW54NHBOVWU5dTRqYW0yalI5VTJBd0NXOUZwM2UrcDM4YkhCSmlMZWJKWWVXaGJWdHBaSHgK",
"endpoints": [],
"dangerousAcceptInvalidCerts": true,
"dangerousAcceptInvalidHostnames": true,
"windows": {
"installMode": "passive"
}
@@ -7,6 +7,7 @@
*/
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
export interface CertMismatchModalOptions {
@@ -33,7 +34,8 @@ export function createCertMismatchModal(
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "Certificate Warning");
const closeBtn = createElement("button", { class: "modal-close", type: "button" });
setText(closeBtn, "\u2715");
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
closeBtn.addEventListener("click", onReject, { signal: ac.signal });
appendChildren(header, title, closeBtn);
@@ -41,7 +43,7 @@ export function createCertMismatchModal(
const body = createElement("div", { class: "modal-body" });
const warning = createElement("div", { class: "cert-warning" });
warning.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
warning.appendChild(createIcon("triangle-alert", 24));
const certTitle = createElement("div", { class: "cert-title" });
setText(certTitle, "Certificate Changed");
@@ -10,6 +10,7 @@ import {
clearChildren,
appendChildren,
} from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
import {
channelsStore,
@@ -26,7 +27,7 @@ import {
isCategoryCollapsed,
} from "@stores/ui.store";
import { voiceStore, getChannelVoiceUsers } from "@stores/voice.store";
import { setUserVolume, getUserVolume } from "@lib/voiceSession";
import { setUserVolume, getUserVolume } from "@lib/livekitSession";
// ---------------------------------------------------------------------------
// Per-user volume context menu (right-click on voice user row)
@@ -101,8 +102,8 @@ function showUserVolumeMenu(
// Close on click outside
const dismissAc = new AbortController();
const combinedSignal = signal;
setTimeout(() => {
if (dismissAc.signal.aborted) return;
document.addEventListener("mousedown", (e: MouseEvent) => {
if (!menu.contains(e.target as Node)) {
menu.remove();
@@ -112,7 +113,7 @@ function showUserVolumeMenu(
}, 0);
// Also clean up if the parent component is destroyed
combinedSignal.addEventListener("abort", () => {
signal.addEventListener("abort", () => {
menu.remove();
dismissAc.abort();
});
@@ -134,6 +135,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;
/** Called when the user clicks a voice user row to watch their stream. */
readonly onWatchStream?: (userId: number) => void;
}
// ── Drag state (mouse-based, avoids WebView2 HTML5 DnD issues) ──
@@ -203,6 +206,7 @@ function renderVoiceChannelItem(
signal: AbortSignal,
onVoiceJoin: (channelId: number) => void,
onVoiceLeave: () => void,
onWatchStream?: (userId: number) => void,
): HTMLDivElement {
const voiceState = voiceStore.getState();
const isJoined = voiceState.currentChannelId === channel.id;
@@ -216,7 +220,8 @@ function renderVoiceChannelItem(
const item = createElement("div", { class: classes, "data-testid": `channel-${channel.id}` });
item.dataset.channelId = String(channel.id);
const prefix = createElement("span", { class: "ch-icon" }, "\uD83D\uDD0A");
const prefix = createElement("span", { class: "ch-icon" });
prefix.appendChild(createIcon("volume-2", 16));
const name = createElement("span", { class: "ch-name" }, channel.name);
appendChildren(item, prefix, name);
@@ -243,7 +248,7 @@ function renderVoiceChannelItem(
const rowClasses = user.speaking
? "voice-user-item speaking"
: "voice-user-item";
const row = createElement("div", { class: rowClasses });
const row = createElement("div", { class: rowClasses, "data-voice-uid": String(user.userId) });
const initial = user.username.length > 0
? user.username.charAt(0).toUpperCase()
@@ -259,15 +264,33 @@ function renderVoiceChannelItem(
);
row.appendChild(nameEl);
if (user.camera) {
const cameraIcon = createElement("span", { class: "vu-status" });
cameraIcon.appendChild(createIcon("camera", 14));
row.appendChild(cameraIcon);
}
if (user.screenshare) {
const screenIcon = createElement("span", { class: "vu-status" });
screenIcon.appendChild(createIcon("monitor", 14));
row.appendChild(screenIcon);
const liveBadge = createElement("span", { class: "vu-live-badge" }, "LIVE");
row.appendChild(liveBadge);
}
if (user.deafened) {
// Deafened: show both crossed mic and crossed headphone
const muteIcon = createElement("span", { class: "vu-muted vu-icon-crossed" }, "\uD83C\uDFA4");
const deafIcon = createElement("span", { class: "vu-muted vu-icon-crossed" }, "\uD83C\uDFA7");
// Deafened: show both mic-off and headphones-off
const muteIcon = createElement("span", { class: "vu-muted" });
muteIcon.appendChild(createIcon("mic-off", 14));
const deafIcon = createElement("span", { class: "vu-muted" });
deafIcon.appendChild(createIcon("headphones-off", 14));
row.appendChild(muteIcon);
row.appendChild(deafIcon);
} else if (user.muted) {
// Muted only: show crossed mic
const muteIcon = createElement("span", { class: "vu-muted vu-icon-crossed" }, "\uD83C\uDFA4");
// Muted only: show mic-off
const muteIcon = createElement("span", { class: "vu-muted" });
muteIcon.appendChild(createIcon("mic-off", 14));
row.appendChild(muteIcon);
}
@@ -281,6 +304,17 @@ function renderVoiceChannelItem(
}, { signal });
}
// Click to watch stream (if user has camera or screenshare)
if (onWatchStream !== undefined && (user.camera || user.screenshare)) {
row.addEventListener("click", (e) => {
// Don't trigger if the right-click menu is open
if (e.button !== 0) return;
e.stopPropagation();
onWatchStream(user.userId);
}, { signal });
row.style.cursor = "pointer";
}
usersContainer.appendChild(row);
}
wrapper.appendChild(usersContainer);
@@ -375,14 +409,18 @@ function attachChannelContextMenu(
);
}
/** Global mousemove/mouseup handlers for drag reordering. Registered once. */
let globalDragListenersAttached = false;
/** 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. */
let globalDragAc: AbortController | null = null;
let globalDragRefCount = 0;
function ensureGlobalDragListeners(): void {
if (globalDragListenersAttached) {
globalDragRefCount++;
if (globalDragAc !== null) {
return;
}
globalDragListenersAttached = true;
globalDragAc = new AbortController();
document.addEventListener("mousemove", (e) => {
if (activeDrag === null) {
@@ -405,7 +443,7 @@ function ensureGlobalDragListeners(): void {
break;
}
}
});
}, { signal: globalDragAc.signal });
document.addEventListener("mouseup", (e) => {
if (activeDrag === null) {
@@ -470,7 +508,7 @@ function ensureGlobalDragListeners(): void {
if (reorders.length > 0) {
drag.onReorder(reorders);
}
});
}, { signal: globalDragAc.signal });
}
/** Make a channel element draggable via mousedown (admin/owner only). */
@@ -556,10 +594,11 @@ function renderChannelItem(
containerEl?: HTMLElement,
channels?: readonly Channel[],
onReorderChannel?: (reorders: readonly ChannelReorderData[]) => void,
onWatchStream?: (userId: number) => void,
): HTMLDivElement {
let el: HTMLDivElement;
if (channel.type === "voice") {
el = renderVoiceChannelItem(channel, signal, onVoiceJoin, onVoiceLeave);
el = renderVoiceChannelItem(channel, signal, onVoiceJoin, onVoiceLeave, onWatchStream);
} else {
el = renderTextChannelItem(channel, isActive, signal);
}
@@ -581,6 +620,7 @@ function renderCategoryGroup(
onEditChannel?: (channel: Channel) => void,
onDeleteChannel?: (channel: Channel) => void,
onReorderChannel?: (reorders: readonly ChannelReorderData[]) => void,
onWatchStream?: (userId: number) => void,
): HTMLDivElement {
const group = createElement("div", {});
@@ -591,11 +631,8 @@ function renderCategoryGroup(
});
header.dataset.category = categoryName;
const arrow = createElement(
"span",
{ class: "category-arrow" },
collapsed ? "\u25B6" : "\u25BC",
);
const arrow = createElement("span", { class: "category-arrow" });
arrow.appendChild(createIcon(collapsed ? "chevron-right" : "chevron-down", 12));
const label = createElement("span", { class: "category-name" }, categoryName);
appendChildren(header, arrow, label);
@@ -637,7 +674,7 @@ function renderCategoryGroup(
const channelsContainer = createElement("div", { class: "category-channels-container" });
for (const ch of channels) {
channelsContainer.appendChild(
renderChannelItem(ch, ch.id === activeChannelId, signal, onVoiceJoin, onVoiceLeave, onEditChannel, onDeleteChannel, channelsContainer, channels, onReorderChannel),
renderChannelItem(ch, ch.id === activeChannelId, signal, onVoiceJoin, onVoiceLeave, onEditChannel, onDeleteChannel, channelsContainer, channels, onReorderChannel, onWatchStream),
);
}
group.appendChild(channelsContainer);
@@ -647,7 +684,7 @@ function renderCategoryGroup(
const channelsContainer = createElement("div", { class: "category-channels-container" });
for (const ch of channels) {
channelsContainer.appendChild(
renderChannelItem(ch, ch.id === activeChannelId, signal, onVoiceJoin, onVoiceLeave, onEditChannel, onDeleteChannel, channelsContainer, channels, onReorderChannel),
renderChannelItem(ch, ch.id === activeChannelId, signal, onVoiceJoin, onVoiceLeave, onEditChannel, onDeleteChannel, channelsContainer, channels, onReorderChannel, onWatchStream),
);
}
group.appendChild(channelsContainer);
@@ -657,7 +694,7 @@ function renderCategoryGroup(
}
export function createChannelSidebar(options: ChannelSidebarOptions): MountableComponent {
const { onVoiceJoin, onVoiceLeave, onCreateChannel, onEditChannel, onDeleteChannel, onReorderChannel } = options;
const { onVoiceJoin, onVoiceLeave, onCreateChannel, onEditChannel, onDeleteChannel, onReorderChannel, onWatchStream } = options;
const ac = new AbortController();
let root: HTMLDivElement | null = null;
let channelList: HTMLDivElement | null = null;
@@ -674,9 +711,18 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
const grouped = getChannelsByCategory();
const state = channelsStore.getState();
if (grouped.size === 0) {
const emptyState = createElement("div", { class: "channel-list-empty" });
const msg = createElement("p", { class: "channel-list-empty-text" }, "No channels yet");
const hint = createElement("p", { class: "channel-list-empty-hint" }, "Right-click a category to create one");
appendChildren(emptyState, msg, hint);
channelList.appendChild(emptyState);
return;
}
for (const [category, channels] of grouped) {
channelList.appendChild(
renderCategoryGroup(category, channels, state.activeChannelId, ac.signal, onVoiceJoin, onVoiceLeave, onCreateChannel, onEditChannel, onDeleteChannel, onReorderChannel),
renderCategoryGroup(category, channels, state.activeChannelId, ac.signal, onVoiceJoin, onVoiceLeave, onCreateChannel, onEditChannel, onDeleteChannel, onReorderChannel, onWatchStream),
);
}
}
@@ -703,35 +749,77 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
// Initial render
renderChannels();
// Subscribe to channels store changes
const unsubChannels = channelsStore.subscribe(() => {
renderChannels();
});
unsubscribers.push(unsubChannels);
// Subscribe to channels store changes (channels map OR active channel)
const unsubChannelsMap = channelsStore.subscribeSelector(
(s) => s.channels,
() => renderChannels(),
);
unsubscribers.push(unsubChannelsMap);
const unsubActiveChannel = channelsStore.subscribeSelector(
(s) => s.activeChannelId,
() => renderChannels(),
);
unsubscribers.push(unsubActiveChannel);
// Subscribe to auth store for server name updates
const unsubAuth = authStore.subscribe((state) => {
if (serverNameEl !== null) {
setText(serverNameEl, state.serverName ?? "Server Name");
}
});
const unsubAuth = authStore.subscribeSelector(
(s) => s.serverName,
(serverName) => {
if (serverNameEl !== null) {
setText(serverNameEl, serverName ?? "Server Name");
}
},
);
unsubscribers.push(unsubAuth);
// Subscribe to UI store for category collapse changes
const unsubUi = uiStore.subscribe(() => {
renderChannels();
});
const unsubUi = uiStore.subscribeSelector(
(s) => s.collapsedCategories,
() => renderChannels(),
);
unsubscribers.push(unsubUi);
// Subscribe to voice store for connected user updates
const unsubVoice = voiceStore.subscribe(() => {
renderChannels();
// Subscribe to voice store — only full re-render when users join/leave
// or mute/deafen/camera changes. Speaking state is patched in-place via
// CSS class toggle to avoid destroying DOM elements (which kills hover).
let prevVoiceStructureSig = "";
const unsubVoice = voiceStore.subscribe((state) => {
// Structural signature: who is in which channel + mute/deafen/camera.
// Excludes speaking — that's patched in-place below.
let structSig = String(state.currentChannelId ?? "");
for (const [chId, users] of state.voiceUsers) {
structSig += `|${chId}`;
for (const [uid, u] of users) {
structSig += `:${uid}${u.muted ? "m" : ""}${u.deafened ? "d" : ""}${u.camera ? "c" : ""}${u.screenshare ? "s" : ""}`;
}
}
if (structSig !== prevVoiceStructureSig) {
prevVoiceStructureSig = structSig;
renderChannels();
return;
}
// Patch speaking state in-place — toggle CSS class without re-rendering.
if (channelList === null) return;
for (const [, users] of state.voiceUsers) {
for (const [uid, u] of users) {
const row = channelList.querySelector<HTMLElement>(`.voice-user-item[data-voice-uid="${uid}"]`);
if (row !== null) {
row.classList.toggle("speaking", u.speaking);
}
}
}
});
unsubscribers.push(unsubVoice);
}
function destroy(): void {
ac.abort();
globalDragRefCount = Math.max(0, globalDragRefCount - 1);
if (globalDragRefCount === 0 && globalDragAc !== null) {
globalDragAc.abort();
globalDragAc = null;
}
for (const unsub of unsubscribers) {
unsub();
}
@@ -5,6 +5,7 @@
*/
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
export interface ConnectedOverlayOptions {
readonly serverName: string;
@@ -98,7 +99,9 @@ export function createConnectedOverlay(
if (ac.signal.aborted) return;
spinner.style.display = "none";
setText(loaderText, "\u2714 Ready!");
loaderText.textContent = "";
loaderText.appendChild(createIcon("check", 16));
loaderText.appendChild(document.createTextNode(" Ready!"));
const timer = setTimeout(() => {
if (!ac.signal.aborted) {
@@ -6,6 +6,7 @@
*/
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";
@@ -61,7 +62,8 @@ export function createCreateChannelModal(
class: "modal-close",
type: "button",
});
setText(closeBtn, "\u2715");
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
closeBtn.addEventListener("click", onClose, { signal: ac.signal });
appendChildren(header, title, closeBtn);
@@ -4,6 +4,7 @@
*/
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
export interface DeleteChannelModalOptions {
@@ -35,14 +36,20 @@ export function createDeleteChannelModal(
class: "modal-close",
type: "button",
});
setText(closeBtn, "\u2715");
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
closeBtn.addEventListener("click", onClose, { signal: ac.signal });
appendChildren(header, title, closeBtn);
// Body
const body = createElement("div", { class: "modal-body" });
const warning = createElement("div", { class: "modal-danger-text" });
warning.innerHTML = `Are you sure you want to delete <strong>#${channelName}</strong>? This action cannot be undone and all messages in this channel will be lost.`;
appendChildren(
warning,
"Are you sure you want to delete ",
createElement("strong", {}, `#${channelName}`),
"? This action cannot be undone and all messages in this channel will be lost.",
);
body.appendChild(warning);
// Error display
@@ -14,6 +14,7 @@ import {
clearChildren,
appendChildren,
} from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
export interface DmConversation {
@@ -35,6 +36,8 @@ export interface DmSidebarOptions {
readonly onCloseDm?: (userId: number) => void;
readonly onFriendsClick?: () => void;
readonly friendsActive?: boolean;
readonly onBack?: () => void;
readonly serverName?: string;
}
const STATUS_COLORS: Record<string, string> = {
@@ -88,7 +91,8 @@ function renderDmItem(
class: "dm-close",
title: "Close DM",
});
setText(closeBtn, "\u00d7");
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
closeBtn.addEventListener(
"click",
(e: Event) => {
@@ -130,6 +134,24 @@ export function createDmSidebar(options: DmSidebarOptions): MountableComponent {
// Reuse channel-sidebar container class per mockup
root = createElement("div", { class: "channel-sidebar" });
// Back to server header (optional)
if (options.onBack !== undefined) {
const backFn = options.onBack;
const backHeader = createElement("div", {
class: "dm-back-header",
"data-testid": "dm-back-header",
});
const arrow = createElement("span", { class: "dm-back-arrow" }, "\u2190");
const backInfo = createElement("div", { class: "dm-back-info" });
const backTitle = createElement("div", { class: "dm-back-title" },
`Back to ${options.serverName ?? "Server"}`);
const backSub = createElement("div", { class: "dm-back-subtitle" }, "Return to channels");
appendChildren(backInfo, backTitle, backSub);
appendChildren(backHeader, arrow, backInfo);
backHeader.addEventListener("click", () => backFn(), { signal: ac.signal });
root.appendChild(backHeader);
}
// Search header
const header = createElement("div", { class: "dm-sidebar-header" });
const searchInput = createElement("input", {
@@ -4,6 +4,7 @@
*/
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
export interface EditChannelModalOptions {
@@ -41,7 +42,8 @@ export function createEditChannelModal(
class: "modal-close",
type: "button",
});
setText(closeBtn, "\u2715");
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
closeBtn.addEventListener("click", onClose, { signal: ac.signal });
appendChildren(header, title, closeBtn);
@@ -2,6 +2,7 @@
// 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";
export interface FileUploadOptions {
@@ -105,7 +106,8 @@ export function createFileUpload(options: FileUploadOptions): FileUploadComponen
progressBar = createElement("div", { class: "file-upload__progress-bar" });
progressBar.style.width = "0%";
appendChildren(progressContainer, progressBar);
cancelBtn = createElement("button", { class: "file-upload__cancel", type: "button" }, "\u00d7") as HTMLButtonElement;
cancelBtn = createElement("button", { class: "file-upload__cancel", type: "button" }) as HTMLButtonElement;
cancelBtn.appendChild(createIcon("x", 14));
appendChildren(preview, thumb, nameSpan, sizeSpan, progressContainer, cancelBtn);
errorDiv = createElement("div", { class: "file-upload__error file-upload__error--hidden" });
@@ -4,6 +4,7 @@
*/
import { createElement, appendChildren, clearChildren, setText } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
// ---------------------------------------------------------------------------
@@ -70,15 +71,22 @@ export function createInviteManager(
for (const invite of invites) {
const row = createElement("div", { class: "invite-item" });
const code = createElement("span", { class: "invite-item__code" }, maskCode(invite.code));
const info = createElement("span", { class: "invite-item__info" }, formatInviteInfo(invite));
const copyBtn = createElement("button", { class: "invite-item__copy" }, "Copy");
// Top row: code + action buttons
const headerRow = createElement("div", { class: "invite-item__header" });
const code = createElement("span", { class: "invite-item__code" }, maskCode(invite.code));
const actions = createElement("div", { class: "invite-item__actions" });
const copyBtn = createElement("button", { class: "invite-item__copy" });
copyBtn.appendChild(createIcon("external-link", 14));
copyBtn.appendChild(document.createTextNode(" Copy"));
copyBtn.addEventListener("click", () => {
options.onCopyLink(invite.code);
}, { signal: ac.signal });
const revokeBtn = createElement("button", { class: "invite-item__revoke" }, "Revoke");
const revokeBtn = createElement("button", { class: "invite-item__revoke" });
revokeBtn.appendChild(createIcon("trash-2", 14));
revokeBtn.appendChild(document.createTextNode(" Revoke"));
revokeBtn.addEventListener("click", () => {
void options.onRevokeInvite(invite.code).then(() => {
invites = invites.filter((i) => i.code !== invite.code);
@@ -88,31 +96,45 @@ export function createInviteManager(
});
}, { signal: ac.signal });
appendChildren(row, code, info, copyBtn, revokeBtn);
appendChildren(actions, copyBtn, revokeBtn);
appendChildren(headerRow, code, actions);
// Bottom row: meta info
const meta = createElement("div", { class: "invite-item__meta" }, formatInviteInfo(invite));
appendChildren(row, headerRow, meta);
listEl.appendChild(row);
}
}
function mount(container: Element): void {
root = createElement("div", {
class: "invite-manager-overlay",
style: "position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:1000;display:flex;justify-content:center;align-items:center;",
class: "modal-overlay visible",
});
const modal = createElement("div", {
class: "invite-manager",
style: "background:var(--bg-secondary,#2f3136);border-radius:8px;padding:16px;min-width:400px;max-width:520px;",
class: "modal",
});
// Header
const header = createElement("div", { class: "invite-manager__header" });
const title = createElement("h2", {}, "Server Invites");
const closeBtn = createElement("button", { class: "invite-manager__close" }, "\u00D7");
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "Server Invites");
const closeBtn = createElement("button", { class: "modal-close" });
closeBtn.appendChild(createIcon("x", 14));
closeBtn.addEventListener("click", () => options.onClose(), { signal: ac.signal });
appendChildren(header, title, closeBtn);
// Create button
const createBtn = createElement("button", { class: "invite-manager__create" }, "Create Invite");
// Body
const body = createElement("div", { class: "modal-body" });
listEl = createElement("div", { class: "invite-manager__list" });
emptyEl = createElement("div", { class: "invite-manager__empty" }, "No active invites");
appendChildren(body, listEl, emptyEl);
// Footer
const footer = createElement("div", { class: "modal-footer" });
const createBtn = createElement("button", { class: "invite-manager__create btn-modal-save" });
createBtn.appendChild(createIcon("external-link", 14));
createBtn.appendChild(document.createTextNode(" Create Invite"));
createBtn.addEventListener("click", () => {
void options.onCreateInvite().then((newInvite) => {
invites = [...invites, newInvite];
@@ -121,10 +143,7 @@ export function createInviteManager(
options.onError?.("Failed to create invite");
});
}, { signal: ac.signal });
// List
listEl = createElement("div", { class: "invite-manager__list" });
emptyEl = createElement("div", { class: "invite-manager__empty" }, "No active invites");
footer.appendChild(createBtn);
// Escape key
document.addEventListener("keydown", (e: KeyboardEvent) => {
@@ -140,7 +159,7 @@ export function createInviteManager(
}
}, { signal: ac.signal });
appendChildren(modal, header, createBtn, listEl, emptyEl);
appendChildren(modal, header, body, footer);
root.appendChild(modal);
renderList();
+102 -17
View File
@@ -1,13 +1,25 @@
/**
* 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).
*/
import { createElement, appendChildren, clearChildren, setText } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
import { Disposable } from "@lib/disposable";
import { membersStore, type Member } from "@stores/members.store";
import { authStore } from "@stores/auth.store";
import { createMemberContextMenu } from "@components/AdminActions";
import type { UserStatus } from "@lib/types";
/** Options for configuring admin action callbacks on the member list. */
export interface MemberListOptions {
readonly currentUserRole: string;
readonly onKick: (userId: number, username: string) => Promise<void>;
readonly onBan: (userId: number, username: string) => Promise<void>;
readonly onChangeRole: (userId: number, username: string, newRole: string) => Promise<void>;
}
/** Ordered role groups with display names and CSS color variables. */
const ROLE_GROUPS: readonly {
readonly role: string;
@@ -39,7 +51,28 @@ function statusColor(status: UserStatus): string {
}
}
function createMemberItem(member: Member, colorVar: string): HTMLDivElement {
let activeMenu: { element: HTMLDivElement; destroy(): void } | null = null;
function closeActiveMenu(): void {
if (activeMenu !== null) {
activeMenu.destroy();
activeMenu = null;
}
}
function handleOutsideClick(e: MouseEvent): void {
if (activeMenu !== null && !activeMenu.element.contains(e.target as Node)) {
closeActiveMenu();
document.removeEventListener("mousedown", handleOutsideClick);
}
}
function createMemberItem(
member: Member,
colorVar: string,
opts: MemberListOptions,
signal: AbortSignal,
): HTMLDivElement {
const item = createElement("div", {
class: member.status === "offline" ? "member-item offline" : "member-item",
"data-testid": `member-${member.id}`,
@@ -55,6 +88,8 @@ function createMemberItem(member: Member, colorVar: string): HTMLDivElement {
const statusDot = createElement("div", {
class: "mi-status",
style: `background: ${statusColor(member.status)}`,
"aria-label": member.status,
title: member.status,
});
avatar.appendChild(statusDot);
@@ -65,15 +100,64 @@ function createMemberItem(member: Member, colorVar: string): HTMLDivElement {
setText(name, member.username);
appendChildren(item, avatar, name);
// Context menu for admin actions
item.addEventListener("contextmenu", (e) => {
e.preventDefault();
// Don't show context menu for yourself
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;
closeActiveMenu();
document.removeEventListener("mousedown", handleOutsideClick);
const availableRoles = ["admin", "moderator", "member"];
activeMenu = createMemberContextMenu({
userId: member.id,
username: member.username,
currentRole: member.role.toLowerCase(),
availableRoles,
onKick: () => opts.onKick(member.id, member.username),
onBan: () => opts.onBan(member.id, member.username),
onChangeRole: (newRole: string) => opts.onChangeRole(member.id, member.username, newRole),
});
// Position at mouse
activeMenu.element.style.position = "fixed";
activeMenu.element.style.left = `${e.clientX}px`;
activeMenu.element.style.top = `${e.clientY}px`;
activeMenu.element.style.zIndex = "1000";
document.body.appendChild(activeMenu.element);
// Close on outside click (deferred so this click doesn't close it)
setTimeout(() => {
document.addEventListener("mousedown", handleOutsideClick);
}, 0);
}, { signal });
return item;
}
function renderList(root: HTMLDivElement): void {
function renderList(root: HTMLDivElement, opts: MemberListOptions, signal: AbortSignal): void {
clearChildren(root);
const state = membersStore.getState();
const allMembers = Array.from(state.members.values());
if (allMembers.length === 0) {
const emptyState = createElement("div", { class: "member-list-empty" });
const msg = createElement("p", { class: "member-list-empty-text" }, "No members online");
emptyState.appendChild(msg);
root.appendChild(emptyState);
return;
}
for (const group of ROLE_GROUPS) {
const groupMembers = allMembers
.filter((m) => m.role.toLowerCase() === group.role)
@@ -89,35 +173,36 @@ function renderList(root: HTMLDivElement): void {
root.appendChild(header);
for (const member of groupMembers) {
root.appendChild(createMemberItem(member, group.colorVar));
root.appendChild(createMemberItem(member, group.colorVar, opts, signal));
}
}
}
export function createMemberList(): MountableComponent {
const ac = new AbortController();
export function createMemberList(opts: MemberListOptions): MountableComponent {
const disposable = new Disposable();
let root: HTMLDivElement | null = null;
let unsubscribe: (() => void) | null = null;
function mount(container: Element): void {
root = createElement("div", { class: "member-list", "data-testid": "member-list" });
renderList(root);
renderList(root, opts, disposable.signal);
unsubscribe = membersStore.subscribe(() => {
if (root !== null) {
renderList(root);
}
});
disposable.onStoreChange(
membersStore,
(s) => s.members,
() => {
if (root !== null) {
renderList(root, opts, disposable.signal);
}
},
);
container.appendChild(root);
}
function destroy(): void {
ac.abort();
if (unsubscribe !== null) {
unsubscribe();
unsubscribe = null;
}
closeActiveMenu();
document.removeEventListener("mousedown", handleOutsideClick);
disposable.destroy();
if (root !== null) {
root.remove();
root = null;
@@ -4,6 +4,7 @@
*/
import { createElement, appendChildren, setText } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
import { createEmojiPicker } from "@components/EmojiPicker";
import { createGifPicker } from "@components/GifPicker";
@@ -27,6 +28,17 @@ export type MessageInputComponent = MountableComponent & {
const TYPING_THROTTLE_MS = 3_000;
const MAX_TEXTAREA_HEIGHT = 200;
const SEND_DEBOUNCE_MS = 200;
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB matches server limit
const ALLOWED_TYPES = [
"image/",
"video/",
"audio/",
"application/pdf",
"text/",
"application/zip",
"application/x-zip-compressed",
"application/json",
];
export function createMessageInput(
options: MessageInputOptions,
@@ -47,6 +59,10 @@ export function createMessageInput(
/** Pending attachment IDs to send with the next message. */
const pendingAttachments: { id: string; filename: string; readonly previewEl: HTMLDivElement }[] = [];
/** Count of file uploads currently in flight. */
let pendingUploadCount = 0;
/** References to picker close functions, set by mount() for destroy() to call. */
let cleanupPickers: (() => void) | null = null;
function showReplyBar(username: string): void {
if (replyBar === null || replyText === null) return;
@@ -82,12 +98,27 @@ export function createMessageInput(
}
}
function showUploadError(message: string): void {
if (attachmentPreviewBar === null) return;
const errEl = createElement("div", {
class: "attachment-upload-error",
}, message);
attachmentPreviewBar.appendChild(errEl);
setTimeout(() => errEl.remove(), 4000);
}
function handleSend(): void {
if (textarea === null) return;
const content = textarea.value.trim();
const hasAttachments = pendingAttachments.length > 0;
if (content.length === 0 && !hasAttachments) return;
// Block send while uploads are still in flight
if (pendingUploadCount > 0) {
showUploadError("Please wait for uploads to finish");
return;
}
// Debounce to prevent double-click duplicate sends
const now = Date.now();
if (now - lastSendTime < SEND_DEBOUNCE_MS) return;
@@ -143,6 +174,18 @@ export function createMessageInput(
async function handlePasteFile(file: File): Promise<void> {
if (options.onUploadFile === undefined || attachmentPreviewBar === null) return;
// Validate file size
if (file.size > MAX_FILE_SIZE) {
showUploadError(`File too large: ${file.name} exceeds 100 MB limit`);
return;
}
// Validate file type (allow empty type for files without MIME info)
if (file.type !== "" && !ALLOWED_TYPES.some((t) => file.type.startsWith(t))) {
showUploadError(`Unsupported file type: ${file.type}`);
return;
}
const tempId = `pending-${++previewCounter}`;
const isImage = file.type.startsWith("image/");
@@ -165,19 +208,22 @@ export function createMessageInput(
img.replaceWith(nameEl);
});
} else {
const icon = createElement("div", { class: "attachment-preview-file" }, "\uD83D\uDCC4");
const icon = createElement("div", { class: "attachment-preview-file" });
icon.appendChild(createIcon("file-text", 16));
const nameEl = createElement("span", { class: "attachment-preview-name" }, file.name);
appendChildren(item, icon, nameEl);
}
// Loading spinner overlay
const spinner = createElement("div", { class: "attachment-preview-spinner" }, "\u23F3");
const spinner = createElement("div", { class: "attachment-preview-spinner" });
spinner.appendChild(createIcon("loader", 16));
item.appendChild(spinner);
const removeBtn = createElement("button", {
class: "attachment-preview-remove",
"data-testid": "attachment-remove",
}, "\u00D7");
});
removeBtn.appendChild(createIcon("x", 14));
removeBtn.addEventListener("click", (e) => {
e.stopPropagation();
removePreviewItem(tempId);
@@ -188,6 +234,7 @@ export function createMessageInput(
pendingAttachments.push({ id: tempId, filename: file.name, previewEl: item });
// Upload in background
pendingUploadCount++;
try {
const result = await options.onUploadFile(file);
// Replace temp ID with real server ID
@@ -202,12 +249,9 @@ export function createMessageInput(
// Upload failed — remove preview and show error
removePreviewItem(tempId);
const errMsg = err instanceof Error ? err.message : "Upload failed";
// Show error inline since we may not have toast access here
const errEl = createElement("div", {
class: "attachment-upload-error",
}, `Upload failed: ${errMsg}`);
attachmentPreviewBar.appendChild(errEl);
setTimeout(() => errEl.remove(), 4000);
showUploadError(`Upload failed: ${errMsg}`);
} finally {
pendingUploadCount--;
}
}
@@ -247,7 +291,8 @@ export function createMessageInput(
const replyInner = createElement("div", { class: "reply-bar-inner" });
replyText = createElement("strong", {});
replyInner.appendChild(replyText);
const replyClose = createElement("button", { class: "reply-close" }, "\u00D7");
const replyClose = createElement("button", { class: "reply-close" });
replyClose.appendChild(createIcon("x", 14));
replyClose.addEventListener("click", clearReply, { signal });
replyInner.appendChild(replyClose);
replyBar.appendChild(replyInner);
@@ -256,7 +301,8 @@ export function createMessageInput(
const editInner = createElement("div", { class: "reply-bar-inner" });
const editText = createElement("strong", {}, "Editing message");
editInner.appendChild(editText);
const editClose = createElement("button", { class: "reply-close" }, "\u00D7");
const editClose = createElement("button", { class: "reply-close" });
editClose.appendChild(createIcon("x", 14));
editClose.addEventListener("click", () => cancelEdit(), { signal });
editInner.appendChild(editClose);
editBar.appendChild(editInner);
@@ -276,7 +322,7 @@ export function createMessageInput(
}) as HTMLInputElement;
fileInput.addEventListener("change", () => {
const file = fileInput.files?.[0];
if (file !== undefined) {
if (file != null) {
void handlePasteFile(file);
}
fileInput.value = "";
@@ -292,15 +338,21 @@ export function createMessageInput(
"data-testid": "msg-textarea",
});
const emojiBtn = createElement("button",
{ class: "input-btn emoji-btn", "aria-label": "Emoji" }, "\uD83D\uDE00");
{ class: "input-btn emoji-btn", "aria-label": "Emoji" });
emojiBtn.appendChild(createIcon("smile", 20));
const gifBtn = createElement("button",
{ class: "input-btn gif-btn", "aria-label": "GIF" }, "GIF");
const sendBtn = createElement("button",
{ class: "input-btn send-btn", "aria-label": "Send message", "data-testid": "send-btn" }, "\u27A4");
{ class: "input-btn send-btn", "aria-label": "Send message", "data-testid": "send-btn" });
sendBtn.appendChild(createIcon("send", 20));
textarea.addEventListener("input", () => { autoResize(); maybeEmitTyping(); }, { signal });
textarea.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); }
if (e.key === "Escape") {
if (state.editing !== null) { cancelEdit(); }
else if (state.replyTo !== null) { clearReply(); }
}
if (e.key === "ArrowUp" && textarea !== null && textarea.value.length === 0) {
root?.dispatchEvent(new CustomEvent("edit-last-message", { bubbles: true }));
}
@@ -425,6 +477,9 @@ export function createMessageInput(
gifBtn.addEventListener("click", toggleGifPicker, { signal });
// Store picker cleanup for destroy()
cleanupPickers = () => { closeEmojiPicker(); closeGifPicker(); };
appendChildren(inputBox, attachBtn, textarea, emojiBtn, gifBtn, sendBtn);
appendChildren(root, replyBar, editBar, attachmentPreviewBar, inputBox);
container.appendChild(root);
@@ -432,14 +487,12 @@ export function createMessageInput(
}
function destroy(): void {
// Close any open pickers and their document listeners before aborting
cleanupPickers?.();
cleanupPickers = null;
ac.abort();
// Revoke any blob URLs for image previews
for (const att of pendingAttachments) {
const img = att.previewEl.querySelector("img");
if (img !== null && img.src.startsWith("blob:")) {
URL.revokeObjectURL(img.src);
}
}
// Image previews now use data: URLs (via readFileAsDataUrl) which don't
// require revocation — just clear the array and let GC reclaim them.
pendingAttachments.length = 0;
root?.remove();
root = null;
+315 -116
View File
@@ -4,27 +4,34 @@
* virtual scrolling (DOM windowing) for performance with large message counts.
*/
import { createElement, clearChildren } from "@lib/dom";
import { createLogger } from "@lib/logger";
import type { MountableComponent } from "@lib/safe-render";
import { messagesStore, getChannelMessages, hasMoreMessages } from "@stores/messages.store";
import type { Message } from "@stores/messages.store";
import { membersStore } from "@stores/members.store";
const log = createLogger("message-list");
import {
shouldGroup,
isSameDay,
renderDayDivider,
renderMessage,
} from "./message-list/renderers";
import { FenwickTree } from "./message-list/fenwick";
// -- Options ------------------------------------------------------------------
export interface MessageListOptions {
readonly channelId: number;
readonly channelName: string;
readonly channelType?: string;
readonly currentUserId: number;
readonly onScrollTop: () => void;
readonly onReplyClick: (messageId: number) => void;
readonly onEditClick: (messageId: number) => void;
readonly onDeleteClick: (messageId: number) => void;
readonly onReactionClick: (messageId: number, emoji: string) => void;
readonly onPinClick: (messageId: number, channelId: number, currentlyPinned: boolean) => void;
}
// -- Constants ----------------------------------------------------------------
@@ -35,8 +42,11 @@ const SCROLL_BOTTOM_THRESHOLD = 100;
/** Number of items to render beyond visible viewport in each direction. */
const OVERSCAN = 20;
/** Estimated pixel height per row (message or day divider) for initial layout. */
const ESTIMATED_ROW_HEIGHT = 52;
/** Regex for direct image URLs in message content. */
const IMAGE_URL_RE = /\.(?:png|jpe?g|gif|webp)(?:\?[^\s]*)?(?:\s|$)/i;
/** Regex for YouTube URLs in message content. */
const YOUTUBE_URL_RE = /(?:youtube\.com\/watch|youtu\.be\/)/i;
// -- Virtual item types -------------------------------------------------------
@@ -53,6 +63,35 @@ interface VirtualItemDivider {
type VirtualItem = VirtualItemMessage | VirtualItemDivider;
// -- Smart height estimation --------------------------------------------------
function estimateItemHeight(item: VirtualItem): number {
if (item.kind === "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
for (const att of item.message.attachments) {
if (att.mime.startsWith("image/")) {
height += 220;
}
}
// Inline image URLs in content
if (IMAGE_URL_RE.test(item.message.content)) {
height += 220;
}
// YouTube embeds
if (YOUTUBE_URL_RE.test(item.message.content)) {
height += 320;
}
return height;
}
// -- Pre-process messages into virtual items ----------------------------------
function buildVirtualItems(messages: readonly Message[]): readonly VirtualItem[] {
@@ -72,6 +111,32 @@ function buildVirtualItems(messages: readonly Message[]): readonly VirtualItem[]
return items;
}
// -- Empty state --------------------------------------------------------------
function renderEmptyState(channelName: string, channelType?: string): HTMLDivElement {
const isDm = channelType === "dm";
const icon = createElement("div", { class: "channel-welcome-icon" });
icon.textContent = isDm ? "@" : "#";
const title = createElement("h2", { class: "channel-welcome-title" });
title.textContent = isDm
? channelName
: `Welcome to #${channelName}!`;
const text = createElement("p", { class: "channel-welcome-text" });
text.textContent = isDm
? `This is the beginning of your direct message history with ${channelName}.`
: `This is the start of the #${channelName} channel.`;
const wrapper = createElement("div", { class: "channel-welcome" });
wrapper.appendChild(icon);
wrapper.appendChild(title);
wrapper.appendChild(text);
return wrapper;
}
// -- Factory ------------------------------------------------------------------
export type MessageListComponent = MountableComponent & {
@@ -88,15 +153,17 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
// Virtual scroll state
let virtualItems: readonly VirtualItem[] = [];
let allMessages: readonly Message[] = [];
const heightCache = new Map<string, number>(); // itemKey measured px
const heightCache = new Map<string, number>(); // itemKey -> measured px
let tree: FenwickTree | null = null;
let topSpacer: HTMLDivElement | null = null;
let bottomSpacer: HTMLDivElement | null = null;
let contentContainer: HTMLDivElement | null = null;
let scrollToBottomBtn: HTMLButtonElement | null = null;
let renderedStart = 0;
let renderedEnd = 0;
// ---------------------------------------------------------------------------
// Height estimation
// Height estimation (Fenwick tree backed)
// ---------------------------------------------------------------------------
function itemKey(index: number): string {
@@ -107,10 +174,13 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
}
function getItemHeight(index: number): number {
return heightCache.get(itemKey(index)) ?? ESTIMATED_ROW_HEIGHT;
const cached = heightCache.get(itemKey(index));
if (cached !== undefined) return cached;
return estimateItemHeight(virtualItems[index]!);
}
function totalHeight(): number {
if (tree !== null) return tree.total();
let h = 0;
for (let i = 0; i < virtualItems.length; i++) {
h += getItemHeight(i);
@@ -119,6 +189,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
}
function offsetToIndex(scrollTop: number): number {
if (tree !== null) return tree.findIndex(scrollTop);
let offset = 0;
for (let i = 0; i < virtualItems.length; i++) {
const h = getItemHeight(i);
@@ -129,6 +200,8 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
}
function offsetBefore(index: number): number {
if (tree !== null && index > 0) return tree.prefixSum(index - 1);
if (tree !== null && index <= 0) return 0;
let offset = 0;
for (let i = 0; i < index && i < virtualItems.length; i++) {
offset += getItemHeight(i);
@@ -151,23 +224,58 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
root.scrollTop = root.scrollHeight;
}
function updateScrollToBottomBtn(): void {
if (scrollToBottomBtn === null) return;
if (isNearBottom()) {
scrollToBottomBtn.classList.remove("visible");
} else {
scrollToBottomBtn.classList.add("visible");
}
}
// ---------------------------------------------------------------------------
// Render visible window
// ---------------------------------------------------------------------------
function measureRendered(): void {
if (contentContainer === null) return;
if (contentContainer === null || renderedStart < 0) return;
const children = contentContainer.children;
for (let i = 0; i < children.length; i++) {
const globalIdx = renderedStart + i;
if (globalIdx < 0 || (tree !== null && globalIdx >= tree.size)) continue;
const el = children[i] as HTMLElement;
const h = el.offsetHeight;
const style = getComputedStyle(el);
const h = el.offsetHeight + parseFloat(style.marginTop) + parseFloat(style.marginBottom);
if (h > 0) {
heightCache.set(itemKey(globalIdx), h);
const key = itemKey(globalIdx);
heightCache.set(key, h);
if (tree !== null) {
tree.set(globalIdx, h);
}
}
}
}
function updateSpacers(): void {
if (topSpacer !== null) {
topSpacer.style.height = `${offsetBefore(renderedStart)}px`;
}
if (bottomSpacer !== null) {
if (tree !== null) {
const totalH = tree.total();
const endOffset = renderedEnd > 0 ? tree.prefixSum(renderedEnd - 1) : 0;
bottomSpacer.style.height = `${totalH - endOffset}px`;
} else {
let bh = 0;
for (let i = renderedEnd; i < virtualItems.length; i++) bh += getItemHeight(i);
bottomSpacer.style.height = `${bh}px`;
}
}
}
let renderWindowCount = 0;
let renderWindowResetTimer = 0;
function renderWindow(): void {
if (root === null || contentContainer === null || topSpacer === null || bottomSpacer === null) return;
@@ -176,6 +284,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
if (virtualItems.length === 0) {
clearChildren(contentContainer);
contentContainer.appendChild(renderEmptyState(options.channelName, options.channelType));
topSpacer.style.height = "0px";
bottomSpacer.style.height = "0px";
renderedStart = 0;
@@ -190,41 +299,60 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
const start = Math.max(0, firstVisible - OVERSCAN);
const end = Math.min(virtualItems.length, lastVisible + OVERSCAN + 1);
// Skip re-render if the range hasn't changed
if (start === renderedStart && end === renderedEnd) return;
// Measure current elements before replacing
measureRendered();
renderedStart = start;
renderedEnd = end;
// Rebuild content
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),
);
// Only rebuild DOM if explicitly requested by renderAll (which sets
// renderedStart to -1). Scroll-driven renderWindow calls only update
// spacers — never rebuild content. This prevents the height oscillation
// loop where images loading → height change → range recalculation →
// DOM rebuild → images reload → repeat forever.
if (renderedStart < 0) {
// Rate-limit DOM rebuilds only (expensive path).
// Scroll-driven spacer updates are cheap and don't need limiting.
renderWindowCount++;
if (renderWindowCount > 30) {
log.error("[MessageList] renderWindow REBUILD called >30 times in 2s — breaking loop");
return;
}
if (renderWindowResetTimer === 0) {
renderWindowResetTimer = window.setTimeout(() => {
renderWindowCount = 0;
renderWindowResetTimer = 0;
}, 2000);
}
// Full rebuild requested by renderAll
log.debug("renderWindow REBUILD", { start, end });
// Measure current elements before replacing.
measureRendered();
renderedStart = start;
renderedEnd = end;
// Rebuild content
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),
);
}
}
contentContainer.appendChild(fragment);
// Measure newly rendered elements and update spacers
measureRendered();
updateSpacers();
} else {
// Scroll-driven: no-op. The ResizeObserver handles measurement and
// spacer updates when element sizes change. Calling measureRendered +
// updateSpacers here creates an infinite feedback loop:
// spacer change → scrollHeight change → scroll event → renderWindow
// → spacer change → ...
}
contentContainer.appendChild(fragment);
// Set spacer heights
topSpacer.style.height = `${offsetBefore(start)}px`;
let bottomHeight = 0;
for (let i = end; i < virtualItems.length; i++) {
bottomHeight += getItemHeight(i);
}
bottomSpacer.style.height = `${bottomHeight}px`;
// Measure newly rendered elements
measureRendered();
}
// ---------------------------------------------------------------------------
@@ -234,53 +362,80 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
function rebuildItems(): void {
allMessages = getChannelMessages(options.channelId);
virtualItems = buildVirtualItems(allMessages);
// Build Fenwick tree initialized with smart estimates / cached heights
tree = new FenwickTree(virtualItems.length);
for (let i = 0; i < virtualItems.length; i++) {
const cached = heightCache.get(itemKey(i));
const h = cached !== undefined ? cached : estimateItemHeight(virtualItems[i]!);
tree.set(i, h);
}
}
/** Render all items temporarily to measure their actual heights, then
* restore the normal virtual window. This eliminates the first-scroll
* jump caused by estimated heights differing from measured ones. */
function premeasureAll(): void {
if (contentContainer === null || virtualItems.length === 0) return;
clearChildren(contentContainer);
const fragment = document.createDocumentFragment();
for (let i = 0; i < virtualItems.length; 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),
);
}
}
contentContainer.appendChild(fragment);
// Measure all
const children = contentContainer.children;
for (let i = 0; i < children.length; i++) {
const h = (children[i] as HTMLElement).offsetHeight;
if (h > 0) heightCache.set(itemKey(i), h);
}
// Restore virtual window
renderedStart = -1;
renderedEnd = -1;
clearChildren(contentContainer);
renderWindow();
}
// Guard against re-entrant renderAll calls (e.g. if a subscriber fires
// during rendering). Also detects rapid-fire loops.
let renderAllRunning = false;
let renderAllCount = 0;
let renderAllResetTimer = 0;
function renderAll(): void {
if (root === null) return;
wasAtBottom = isNearBottom();
if (renderAllRunning) return; // prevent re-entrancy
rebuildItems();
// Detect rapid-fire loops: if renderAll is called more than 20 times
// within 2 seconds, something is wrong — bail out to prevent freeze.
renderAllCount++;
if (renderAllCount > 20) {
log.error("[MessageList] renderAll called >20 times in 2s — breaking loop");
return;
}
if (renderAllResetTimer === 0) {
renderAllResetTimer = window.setTimeout(() => {
renderAllCount = 0;
renderAllResetTimer = 0;
}, 2000);
}
// Reset rendered range to force full re-render
renderedStart = -1;
renderedEnd = -1;
renderAllRunning = true;
try {
log.debug("renderAll START", { count: renderAllCount });
wasAtBottom = isNearBottom();
renderWindow();
rebuildItems();
log.debug("renderAll rebuildItems done", { itemCount: virtualItems.length });
if (wasAtBottom) {
scrollToBottom();
// If user was at bottom, pre-set scroll position using estimated total
// height so renderWindow renders the correct range for the bottom.
// Without this, renderWindow renders from the top (range [0, N]) and
// items near the bottom are never shown.
//
// IMPORTANT: inflate the spacers to the full estimated height BEFORE
// setting scrollTop. The browser clamps scrollTop to
// (scrollHeight - clientHeight), so if the spacers are still sized
// from the previous (empty) render the assignment is silently ignored
// and renderWindow renders from index 0 instead of the bottom.
if (wasAtBottom && root !== null) {
const estTotal = totalHeight();
if (topSpacer !== null) topSpacer.style.height = "0px";
if (bottomSpacer !== null) bottomSpacer.style.height = `${estTotal}px`;
root.scrollTop = Math.max(0, estTotal - root.clientHeight);
}
// Reset rendered range to force full re-render
renderedStart = -1;
renderedEnd = -1;
renderWindow();
log.debug("renderAll renderWindow done");
// Correct scroll position with actual DOM measurements
if (wasAtBottom) {
scrollToBottom();
updateScrollToBottomBtn();
}
log.debug("renderAll END");
} finally {
renderAllRunning = false;
}
}
@@ -291,16 +446,20 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
let loadingOlder = false;
let prevMessageCount = 0;
const unsubLoadingReset = messagesStore.subscribe(() => {
const msgs = getChannelMessages(options.channelId);
if (msgs.length !== prevMessageCount) {
prevMessageCount = msgs.length;
loadingOlder = false;
}
});
const unsubLoadingReset = messagesStore.subscribeSelector(
(s) => s.messagesByChannel,
() => {
const msgs = getChannelMessages(options.channelId);
if (msgs.length !== prevMessageCount) {
prevMessageCount = msgs.length;
loadingOlder = false;
}
},
);
let scrollRafId = 0;
let resizeRafId = 0;
let resizeDirty = false;
function handleScroll(): void {
if (root === null) return;
@@ -314,6 +473,9 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
options.onScrollTop();
}
// Update floating scroll-to-bottom button visibility
updateScrollToBottomBtn();
// Debounce virtual window updates to animation frames
if (scrollRafId === 0) {
scrollRafId = requestAnimationFrame(() => {
@@ -335,10 +497,18 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
bottomSpacer = createElement("div", { class: "virtual-spacer-bottom" });
const scrollAnchor = createElement("div", { class: "scroll-anchor" });
scrollToBottomBtn = createElement("button", { class: "scroll-to-bottom-btn" }) as HTMLButtonElement;
scrollToBottomBtn.textContent = "↓";
scrollToBottomBtn.addEventListener("click", () => {
scrollToBottom();
updateScrollToBottomBtn();
}, { signal: ac.signal });
root.appendChild(topSpacer);
root.appendChild(contentContainer);
root.appendChild(bottomSpacer);
root.appendChild(scrollAnchor);
root.appendChild(scrollToBottomBtn);
root.addEventListener("scroll", handleScroll, {
signal: ac.signal,
@@ -346,24 +516,36 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
});
// Watch for height changes in rendered items (images loading, embeds expanding).
// Re-measure heights and update spacers. The CSS scroll-anchor element handles
// pin-to-bottom automatically; for "scrolled up" we preserve distance-from-bottom.
// Batched via RAF with anchor-based scroll preservation.
const resizeObserver = new ResizeObserver(() => {
if (root === null || contentContainer === null) return;
// Capture scroll position relative to the bottom (stable reference point)
const distFromBottom = root.scrollHeight - root.scrollTop - root.clientHeight;
measureRendered();
// Update spacer heights with new measurements
if (topSpacer !== null) topSpacer.style.height = `${offsetBefore(renderedStart)}px`;
if (bottomSpacer !== null) {
let bh = 0;
for (let i = renderedEnd; i < virtualItems.length; i++) bh += getItemHeight(i);
bottomSpacer.style.height = `${bh}px`;
}
// Restore scroll position (distance from bottom stays the same)
if (distFromBottom > SCROLL_BOTTOM_THRESHOLD) {
root.scrollTop = root.scrollHeight - root.clientHeight - distFromBottom;
}
resizeDirty = true;
if (resizeRafId !== 0) return;
resizeRafId = requestAnimationFrame(() => {
resizeRafId = 0;
resizeDirty = false;
if (root === null || contentContainer === null) return;
const atBottom = isNearBottom();
// Capture anchor: topmost visible item and its offset from viewport top
const anchorIdx = offsetToIndex(root.scrollTop);
const anchorOffset = root.scrollTop - offsetBefore(anchorIdx);
// Re-measure rendered elements
measureRendered();
// Update spacer heights with new measurements
updateSpacers();
// Restore scroll position using anchor
if (atBottom) {
scrollToBottom();
} else {
root.scrollTop = offsetBefore(anchorIdx) + anchorOffset;
}
});
});
resizeObserver.observe(contentContainer);
ac.signal.addEventListener("abort", () => resizeObserver.disconnect());
@@ -371,22 +553,25 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
parentContainer.appendChild(root);
renderAll();
// Pre-measure all items to warm the height cache so scrolling up
// doesn't cause jumps from estimate→measured height corrections.
premeasureAll();
scrollToBottom();
requestAnimationFrame(() => scrollToBottom());
const initialScrollRaf = requestAnimationFrame(() => scrollToBottom());
ac.signal.addEventListener("abort", () => cancelAnimationFrame(initialScrollRaf));
unsubscribers.push(messagesStore.subscribe(() => { renderAll(); }));
unsubscribers.push(messagesStore.subscribeSelector(
(s) => s.messagesByChannel,
() => { renderAll(); },
));
// Only re-render when member roles change, not on typing updates
let prevMembers = membersStore.getState().members;
unsubscribers.push(membersStore.subscribe((state) => {
if (state.members !== prevMembers) {
prevMembers = state.members;
renderAll();
}
}));
// Only re-render when member roles change, not on presence/typing updates.
// Extract a role-only map so shallowEqual ignores status changes.
unsubscribers.push(membersStore.subscribeSelector(
(s) => {
const roles = new Map<number, string>();
for (const [id, m] of s.members) roles.set(id, m.role);
return roles;
},
() => { renderAll(); },
));
}
function destroy(): void {
@@ -395,14 +580,28 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
cancelAnimationFrame(scrollRafId);
scrollRafId = 0;
}
if (resizeRafId !== 0) {
cancelAnimationFrame(resizeRafId);
resizeRafId = 0;
}
if (renderAllResetTimer !== 0) {
clearTimeout(renderAllResetTimer);
renderAllResetTimer = 0;
}
if (renderWindowResetTimer !== 0) {
clearTimeout(renderWindowResetTimer);
renderWindowResetTimer = 0;
}
unsubLoadingReset();
for (const unsub of unsubscribers) { unsub(); }
unsubscribers.length = 0;
heightCache.clear();
tree = null;
if (root !== null) { root.remove(); root = null; }
contentContainer = null;
topSpacer = null;
bottomSpacer = null;
scrollToBottomBtn = null;
}
function scrollToMessage(messageId: number): boolean {
@@ -1,14 +1,14 @@
/**
* PinnedMessages component — slide-out panel showing pinned messages
* for a channel with jump-to and unpin actions.
* PinnedMessages component — Discord-style pinned messages panel
* with avatars, hover actions, and entry animation.
*/
import {
createElement,
setText,
clearChildren,
appendChildren,
} from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
export interface PinnedMessage {
@@ -16,6 +16,7 @@ export interface PinnedMessage {
readonly content: string;
readonly author: string;
readonly timestamp: string;
readonly avatarColor: string;
}
export interface PinnedMessagesOptions {
@@ -26,28 +27,70 @@ export interface PinnedMessagesOptions {
readonly onClose: () => void;
}
function formatPinTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
function getInitial(name: string): string {
return name.length > 0 ? name.charAt(0).toUpperCase() : "?";
}
function renderPinnedItem(
msg: PinnedMessage,
options: PinnedMessagesOptions,
signal: AbortSignal,
): HTMLDivElement {
const item = createElement("div", { class: "pinned-msg" });
item.dataset.messageId = String(msg.id);
const card = createElement("div", { class: "pinned-msg" });
card.dataset.messageId = String(msg.id);
// Row: avatar + body
const row = createElement("div", { class: "pinned-msg__row" });
const avatar = createElement("div", { class: "pinned-msg__avatar" });
avatar.style.backgroundColor = msg.avatarColor;
avatar.textContent = getInitial(msg.author);
const body = createElement("div", { class: "pinned-msg__body" });
const head = createElement("div", { class: "pinned-msg__head" });
const authorEl = createElement("span", { class: "pinned-msg__author" }, msg.author);
const timeEl = createElement("span", { class: "pinned-msg__time" }, formatPinTime(msg.timestamp));
appendChildren(head, authorEl, timeEl);
const author = createElement("div", { class: "pinned-msg__author" }, msg.author);
const content = createElement("div", { class: "pinned-msg__content" }, msg.content);
const time = createElement("div", { class: "pinned-msg__time" }, msg.timestamp);
appendChildren(body, head, content);
appendChildren(row, avatar, body);
// Hover actions
const actions = createElement("div", { class: "pinned-msg__actions" });
const jumpBtn = createElement("button", {}, "Jump");
const unpinBtn = createElement("button", {}, "Unpin");
const jumpBtn = createElement("button", { title: "Jump to message" });
jumpBtn.appendChild(createIcon("external-link", 14));
const unpinBtn = createElement("button", {
class: "pinned-msg__unpin",
title: "Unpin message",
});
unpinBtn.appendChild(createIcon("x", 14));
jumpBtn.addEventListener("click", () => options.onJumpToMessage(msg.id), { signal });
unpinBtn.addEventListener("click", () => options.onUnpin(msg.id), { signal });
appendChildren(actions, jumpBtn, unpinBtn);
appendChildren(item, author, content, time, actions);
return item;
appendChildren(card, row, actions);
return card;
}
function renderEmptyState(): HTMLDivElement {
const empty = createElement("div", { class: "pinned-panel__empty" });
const icon = createElement("div", { class: "pinned-panel__empty-icon" });
icon.textContent = "";
icon.appendChild(createIcon("pin", 20));
const text = createElement("div", { class: "pinned-panel__empty-text" });
text.textContent = "This channel doesn't have any pinned messages\u2026 yet!";
appendChildren(empty, icon, text);
return empty;
}
export function createPinnedMessages(
@@ -59,27 +102,38 @@ export function createPinnedMessages(
function mount(container: Element): void {
root = createElement("div", { class: "pinned-panel" });
// Header
const header = createElement("div", { class: "pinned-panel__header" });
const title = createElement("h3", {}, "Pinned Messages");
const closeBtn = createElement("button", { class: "pinned-panel__close" }, "\u00D7");
const title = createElement("h3", {});
title.textContent = "";
title.appendChild(createIcon("pin", 16));
title.appendChild(document.createTextNode(" Pinned Messages"));
const count = createElement("span", { class: "pinned-panel__count" });
count.textContent = String(options.pinnedMessages.length);
const closeBtn = createElement("button", { class: "pinned-panel__close" });
closeBtn.appendChild(createIcon("x", 16));
closeBtn.addEventListener("click", () => options.onClose(), { signal: ac.signal });
appendChildren(header, title, closeBtn);
const list = createElement("div", { class: "pinned-panel__list" });
const empty = createElement("div", { class: "pinned-panel__empty" }, "No pinned messages");
const titleGroup = createElement("div", {
class: "pinned-panel__title-group",
});
appendChildren(titleGroup, title, count);
appendChildren(header, titleGroup, closeBtn);
// Body — list or empty state
if (options.pinnedMessages.length === 0) {
empty.style.display = "";
list.style.display = "none";
const empty = renderEmptyState();
appendChildren(root, header, empty);
} else {
empty.style.display = "none";
list.style.display = "";
const list = createElement("div", { class: "pinned-panel__list" });
for (const msg of options.pinnedMessages) {
list.appendChild(renderPinnedItem(msg, options, ac.signal));
}
appendChildren(root, header, list);
}
appendChildren(root, header, list, empty);
container.appendChild(root);
}
@@ -0,0 +1,124 @@
/**
* QuickSwitchOverlay — modal for switching between saved server profiles.
* Appears when the user clicks the disconnect/switch button in UserBar.
* Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
*/
import { createElement, appendChildren, setText } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface QuickSwitchProfile {
readonly name: string;
readonly host: string;
}
export interface QuickSwitchOverlayOptions {
readonly profiles: readonly QuickSwitchProfile[];
readonly currentHost: string;
readonly onSwitch: (host: string, name: string) => void;
readonly onAddServer: () => void;
readonly onClose: () => void;
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): MountableComponent {
const ac = new AbortController();
let root: HTMLDivElement | null = null;
function mount(container: Element): void {
root = createElement("div", {
class: "quick-switch-backdrop",
"data-testid": "quick-switch-overlay",
});
// Close on backdrop click (not on modal content)
root.addEventListener("click", (e) => {
if (e.target === root) options.onClose();
}, { signal: ac.signal });
const modal = createElement("div", { class: "quick-switch-modal" });
// Header
const header = createElement("div", { class: "quick-switch-header" });
const title = createElement("h2", {}, "Switch Server");
const subtitle = createElement("p", { class: "quick-switch-subtitle" },
"You\u2019ll disconnect from the current server.");
appendChildren(header, title, subtitle);
// Server list
const list = createElement("div", { class: "quick-switch-list" });
for (const profile of options.profiles) {
const isCurrent = profile.host === options.currentHost;
const item = createElement("div", {
class: `quick-switch-item${isCurrent ? " current" : ""}`,
"data-testid": "server-item",
"data-host": profile.host,
});
const icon = createElement("div", { class: "quick-switch-icon" });
setText(icon, profile.name.charAt(0).toUpperCase());
const info = createElement("div", { class: "quick-switch-info" });
const nameEl = createElement("div", { class: "quick-switch-name" }, profile.name);
const hostEl = createElement("div", { class: "quick-switch-host" },
`${profile.host}${isCurrent ? " \u00B7 Connected" : ""}`);
appendChildren(info, nameEl, hostEl);
if (isCurrent) {
const dot = createElement("div", { class: "quick-switch-connected-dot" });
appendChildren(item, icon, info, dot);
} else {
appendChildren(item, icon, info);
item.addEventListener("click", () => {
options.onSwitch(profile.host, profile.name);
}, { signal: ac.signal });
}
list.appendChild(item);
}
// Add new server button
const addItem = createElement("div", {
class: "quick-switch-item add-new",
"data-testid": "add-server-btn",
});
const addIcon = createElement("div", { class: "quick-switch-icon add" }, "+");
const addInfo = createElement("div", { class: "quick-switch-info" });
const addName = createElement("div", { class: "quick-switch-name" }, "Add new server");
const addHost = createElement("div", { class: "quick-switch-host" }, "Connect to another OwnCord server");
appendChildren(addInfo, addName, addHost);
appendChildren(addItem, addIcon, addInfo);
addItem.addEventListener("click", () => options.onAddServer(), { signal: ac.signal });
list.appendChild(addItem);
// Footer
const footer = createElement("div", { class: "quick-switch-footer" }, "Press Escape to cancel");
appendChildren(modal, header, list, footer);
root.appendChild(modal);
container.appendChild(root);
// Escape key closes overlay
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") options.onClose();
}, { signal: ac.signal });
}
function destroy(): void {
ac.abort();
if (root !== null) {
root.remove();
root = null;
}
}
return { mount, destroy };
}
@@ -2,6 +2,7 @@
// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
import { createElement, setText, appendChildren, clearChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { channelsStore } from "@stores/channels.store";
import type { Channel } from "@stores/channels.store";
import type { MountableComponent } from "@lib/safe-render";
@@ -22,8 +23,8 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
let filteredChannels: readonly Channel[] = [];
let unsubscribe: (() => void) | null = null;
function getChannelIcon(ch: Channel): string {
return ch.type === "voice" ? "\ud83d\udd0a" : "#";
function getChannelIcon(ch: Channel): SVGSVGElement {
return ch.type === "voice" ? createIcon("volume-2", 14) : createIcon("hash", 14);
}
function getFilteredChannels(query: string): readonly Channel[] {
@@ -52,7 +53,8 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
"data-channelid": String(ch.id),
});
const icon = createElement("span", { class: "quick-switcher__icon" }, getChannelIcon(ch));
const icon = createElement("span", { class: "quick-switcher__icon" });
icon.appendChild(getChannelIcon(ch));
const name = createElement("span", { class: "quick-switcher__name" });
setText(name, ch.name);
@@ -173,7 +175,10 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
document.addEventListener("keydown", handleGlobalKeydown, { signal });
// Subscribe to store changes
unsubscribe = channelsStore.subscribe(refreshFromStore);
unsubscribe = channelsStore.subscribeSelector(
(s) => s.channels,
refreshFromStore,
);
// Auto-focus
requestAnimationFrame(() => input.focus());
@@ -1,19 +1,27 @@
/**
* SettingsOverlay component — full-screen overlay with tabbed settings panels.
* Tabs: Account, Appearance, Notifications, Voice & Audio, Keybinds, Logs.
* Tabs: Account, Appearance, Notifications, Text & Images, Accessibility, Voice & Audio, Keybinds, Advanced, Logs.
* Subscribes to uiStore for settingsOpen state.
*/
import { createElement, appendChildren, clearChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { IconName } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
import type { UserStatus } from "@lib/types";
import { uiStore } from "@stores/ui.store";
import { authStore } from "@stores/auth.store";
import { loadPref, applyTheme } from "./settings/helpers";
import type { ThemeName } from "./settings/helpers";
import { syncOsMotionListener } from "@lib/os-motion";
import { buildAccountTab } from "./settings/AccountTab";
import { buildAppearanceTab } from "./settings/AppearanceTab";
import { buildNotificationsTab } from "./settings/NotificationsTab";
import { buildTextImagesTab } from "./settings/TextImagesTab";
import { buildAccessibilityTab } from "./settings/AccessibilityTab";
import { createVoiceAudioTab } from "./settings/VoiceAudioTab";
import { buildKeybindsTab } from "./settings/KeybindsTab";
import { buildAdvancedTab } from "./settings/AdvancedTab";
import { createLogsTab } from "./settings/LogsTab";
// ---------------------------------------------------------------------------
@@ -25,18 +33,23 @@ export interface SettingsOverlayOptions {
onChangePassword(oldPassword: string, newPassword: string): Promise<void>;
onUpdateProfile(username: string): Promise<void>;
onLogout(): void;
onDeleteAccount(password: string): Promise<void>;
onStatusChange(status: UserStatus): void;
}
export type TabName = "Account" | "Appearance" | "Notifications" | "Voice & Audio" | "Keybinds" | "Logs";
export type TabName = "Account" | "Appearance" | "Notifications" | "Text & Images" | "Accessibility" | "Voice & Audio" | "Keybinds" | "Advanced" | "Logs";
const TAB_NAMES: readonly TabName[] = [
"Account",
"Appearance",
"Notifications",
"Voice & Audio",
"Keybinds",
"Logs",
] as const;
const TAB_ICONS: Record<TabName, IconName> = {
Account: "user",
Appearance: "palette",
Notifications: "bell",
"Text & Images": "image",
Accessibility: "eye",
"Voice & Audio": "mic",
Keybinds: "keyboard",
Advanced: "settings",
Logs: "scroll-text",
};
// ---------------------------------------------------------------------------
// Apply stored appearance (called at app startup)
@@ -47,7 +60,7 @@ const TAB_NAMES: readonly TabName[] = [
* Call at app startup so the UI doesn't flash default styles.
*/
export function applyStoredAppearance(): void {
applyTheme(loadPref<ThemeName>("theme", "dark"));
applyTheme(loadPref<ThemeName>("theme", "neon-glow"));
document.documentElement.style.setProperty(
"--font-size",
`${loadPref<number>("fontSize", 16)}px`,
@@ -56,6 +69,14 @@ export function applyStoredAppearance(): void {
"compact-mode",
loadPref<boolean>("compactMode", false),
);
document.documentElement.classList.toggle("reduced-motion", loadPref<boolean>("reducedMotion", false));
document.documentElement.classList.toggle("high-contrast", loadPref<boolean>("highContrast", false));
document.documentElement.classList.toggle("large-font", loadPref<boolean>("largeFont", false));
const storedAccent = loadPref<string>("accentColor", "#00c8ff");
document.documentElement.style.setProperty("--accent", storedAccent);
document.body.style.setProperty("--accent", storedAccent);
syncOsMotionListener(loadPref<boolean>("syncOsMotion", false));
}
// ---------------------------------------------------------------------------
@@ -68,6 +89,7 @@ export function createSettingsOverlay(
const ac = new AbortController();
let root: HTMLDivElement | null = null;
let contentArea: HTMLDivElement | null = null;
let pageTitle: HTMLHeadingElement | null = null;
let activeTab: TabName = "Account";
const tabButtons = new Map<TabName, HTMLButtonElement>();
let unsubUi: (() => void) | null = null;
@@ -82,8 +104,11 @@ export function createSettingsOverlay(
Account: () => buildAccountTab(options, ac.signal),
Appearance: () => buildAppearanceTab(ac.signal),
Notifications: () => buildNotificationsTab(ac.signal),
"Text & Images": () => buildTextImagesTab(ac.signal),
Accessibility: () => buildAccessibilityTab(ac.signal),
"Voice & Audio": () => voiceTab.build(),
Keybinds: () => buildKeybindsTab(ac.signal),
Advanced: () => buildAdvancedTab(ac.signal),
Logs: () => logsTab.build(),
};
@@ -92,6 +117,9 @@ export function createSettingsOverlay(
function renderActiveTab(): void {
if (contentArea === null) return;
clearChildren(contentArea);
if (pageTitle === null) return;
pageTitle.textContent = activeTab;
contentArea.appendChild(pageTitle);
const builder = TAB_BUILDERS[activeTab];
contentArea.appendChild(builder());
}
@@ -103,6 +131,7 @@ export function createSettingsOverlay(
activeTab = tab;
for (const [name, btn] of tabButtons) {
btn.classList.toggle("active", name === tab);
btn.setAttribute("aria-selected", name === tab ? "true" : "false");
}
renderActiveTab();
}
@@ -124,25 +153,77 @@ export function createSettingsOverlay(
// Sidebar
const sidebar = createElement("div", { class: "settings-sidebar" });
const catLabel = createElement("div", { class: "settings-cat" }, "User Settings");
sidebar.appendChild(catLabel);
for (const name of TAB_NAMES) {
// User profile section at top of sidebar
const user = authStore.getState().user;
const profileSection = createElement("div", { class: "settings-sidebar-profile" });
const avatarEl = createElement("div", { class: "settings-sidebar-avatar" },
(user?.username ?? "U").charAt(0).toUpperCase());
const profileInfo = createElement("div", {});
const profileName = createElement("div", { class: "settings-sidebar-name" },
user?.username ?? "Unknown");
const editProfileLink = createElement("div", { class: "settings-sidebar-edit" }, "Edit Profile");
editProfileLink.addEventListener("click", () => setActiveTab("Account"), { signal: ac.signal });
appendChildren(profileInfo, profileName, editProfileLink);
appendChildren(profileSection, avatarEl, profileInfo);
sidebar.appendChild(profileSection);
// "User Settings" category — only Account belongs here
const userSettingsCat = createElement("div", { class: "settings-cat" }, "User Settings");
sidebar.appendChild(userSettingsCat);
const accountBtn = createElement("button", {
class: `settings-nav-item${activeTab === "Account" ? " active" : ""}`,
role: "tab",
"aria-selected": activeTab === "Account" ? "true" : "false",
});
accountBtn.prepend(createIcon(TAB_ICONS["Account"], 18));
accountBtn.appendChild(document.createTextNode("Account"));
accountBtn.addEventListener("click", () => setActiveTab("Account"), { signal: ac.signal });
tabButtons.set("Account", accountBtn);
sidebar.appendChild(accountBtn);
// "App Settings" category — remaining tabs
const appSettingsCat = createElement("div", { class: "settings-cat" }, "App Settings");
sidebar.appendChild(appSettingsCat);
const appTabs: readonly TabName[] = ["Appearance", "Notifications", "Text & Images", "Accessibility", "Voice & Audio", "Keybinds", "Advanced", "Logs"];
for (const name of appTabs) {
const btn = createElement("button", {
class: `settings-nav-item${name === activeTab ? " active" : ""}`,
}, name);
role: "tab",
"aria-selected": name === activeTab ? "true" : "false",
});
btn.prepend(createIcon(TAB_ICONS[name], 18));
btn.appendChild(document.createTextNode(name));
btn.addEventListener("click", () => setActiveTab(name), { signal: ac.signal });
tabButtons.set(name, btn);
sidebar.appendChild(btn);
}
// Separator + Log Out at sidebar bottom
const logoutWrap = createElement("div", { class: "settings-sidebar-logout" });
const logoutSep = createElement("div", { class: "settings-sep" });
const logoutBtn = createElement("button", { class: "settings-nav-item danger" }, "Log Out");
logoutBtn.addEventListener("click", () => options.onLogout(), { signal: ac.signal });
appendChildren(logoutWrap, logoutSep, logoutBtn);
sidebar.appendChild(logoutWrap);
// Page title (h1) at top of content area — created here, inserted in renderActiveTab
pageTitle = createElement("h1", {}, activeTab);
// Content
contentArea = createElement("div", { class: "settings-content" });
// Close button
const closeBtn = createElement("button", { class: "settings-close-btn" }, "\u00D7");
// Close button wrapped with ESC label
const closeWrap = createElement("div", { class: "settings-close-wrap" });
const closeBtn = createElement("button", { class: "settings-close-btn" });
closeBtn.appendChild(createIcon("x", 18));
closeBtn.addEventListener("click", () => {
options.onClose();
}, { signal: ac.signal });
const escLabel = createElement("div", { class: "settings-esc-label" }, "ESC");
appendChildren(closeWrap, closeBtn, escLabel);
// Escape key
document.addEventListener("keydown", (e: KeyboardEvent) => {
@@ -151,17 +232,29 @@ export function createSettingsOverlay(
}
}, { signal: ac.signal });
appendChildren(root, sidebar, contentArea, closeBtn);
// Inner panel (Discord-style centered card)
const panel = createElement("div", { class: "settings-panel" });
appendChildren(panel, sidebar, contentArea, closeWrap);
// Click backdrop (outside panel) to close
root.addEventListener("click", (e: MouseEvent) => {
if (e.target === root) options.onClose();
}, { signal: ac.signal });
root.appendChild(panel);
renderActiveTab();
// Subscribe to uiStore for open/close
unsubUi = uiStore.subscribe((state) => {
if (state.settingsOpen) {
show();
} else {
hide();
}
});
unsubUi = uiStore.subscribeSelector(
(s) => s.settingsOpen,
(settingsOpen) => {
if (settingsOpen) {
show();
} else {
hide();
}
},
);
// Sync initial state
if (uiStore.getState().settingsOpen) {
@@ -185,6 +278,7 @@ export function createSettingsOverlay(
root = null;
}
contentArea = null;
pageTitle = null;
}
function open(): void {
@@ -1,117 +0,0 @@
/**
* Step 6.53 — Soundboard component.
* Grid of sound buttons with cooldown enforcement (1 play per 3s).
*/
import { createElement, appendChildren, setText, clearChildren } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface SoundItem {
readonly id: number;
readonly name: string;
readonly durationMs: number;
}
export interface SoundboardOptions {
readonly sounds: readonly SoundItem[];
readonly onPlaySound: (soundId: number) => void;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const COOLDOWN_MS = 3_000;
function formatDuration(ms: number): string {
return `${(ms / 1000).toFixed(1)}s`;
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function createSoundboard(options: SoundboardOptions): MountableComponent {
let root: HTMLDivElement | null = null;
let cooldownTimer: ReturnType<typeof setTimeout> | null = null;
const ac = new AbortController();
function mount(container: Element): void {
root = createElement("div", { class: "soundboard" });
if (options.sounds.length === 0) {
const empty = createElement(
"div",
{ class: "soundboard__empty" },
"No sounds available",
);
root.appendChild(empty);
container.appendChild(root);
return;
}
const grid = createElement("div", { class: "soundboard__grid" });
const buttons: HTMLButtonElement[] = [];
for (const sound of options.sounds) {
const btn = createElement("button", { class: "sound-btn", type: "button" });
const nameSpan = createElement("span", { class: "sound-btn__name" }, sound.name);
const durSpan = createElement(
"span",
{ class: "sound-btn__duration" },
formatDuration(sound.durationMs),
);
appendChildren(btn, nameSpan, durSpan);
btn.addEventListener("click", () => {
if (btn.disabled) return;
options.onPlaySound(sound.id);
startCooldown(buttons);
}, { signal: ac.signal });
buttons.push(btn);
grid.appendChild(btn);
}
root.appendChild(grid);
container.appendChild(root);
}
function startCooldown(buttons: readonly HTMLButtonElement[]): void {
for (const btn of buttons) {
btn.disabled = true;
btn.classList.add("sound-btn--cooldown");
}
if (cooldownTimer !== null) {
clearTimeout(cooldownTimer);
}
cooldownTimer = setTimeout(() => {
cooldownTimer = null;
for (const btn of buttons) {
btn.disabled = false;
btn.classList.remove("sound-btn--cooldown");
}
}, COOLDOWN_MS);
}
function destroy(): void {
ac.abort();
if (cooldownTimer !== null) {
clearTimeout(cooldownTimer);
cooldownTimer = null;
}
if (root !== null) {
root.remove();
root = null;
}
}
return { mount, destroy };
}
@@ -6,6 +6,7 @@
import { createElement, appendChildren, setText, clearChildren } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
import { Disposable } from "@lib/disposable";
import { membersStore, getTypingUsers } from "@stores/members.store";
import type { Member } from "@stores/members.store";
@@ -27,8 +28,8 @@ function formatTypingText(users: readonly Member[]): string {
export function createTypingIndicator(
options: TypingIndicatorOptions,
): MountableComponent {
const disposable = new Disposable();
let root: HTMLDivElement | null = null;
let unsubscribe: (() => void) | null = null;
function updateFromState(): void {
if (root === null) return;
@@ -61,18 +62,17 @@ export function createTypingIndicator(
updateFromState();
unsubscribe = membersStore.subscribe(() => {
updateFromState();
});
disposable.onStoreChange(
membersStore,
(s) => s.typingUsers,
() => { updateFromState(); },
);
container.appendChild(root);
}
function destroy(): void {
if (unsubscribe !== null) {
unsubscribe();
unsubscribe = null;
}
disposable.destroy();
if (root !== null) {
root.remove();
root = null;
+30 -19
View File
@@ -4,16 +4,19 @@
*/
import { createElement, appendChildren, setText } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
import { Disposable } from "@lib/disposable";
import { authStore } from "@stores/auth.store";
import { openSettings } from "@stores/ui.store";
export type UserBarOptions = Record<string, never>;
export interface UserBarOptions {
readonly onDisconnect?: () => void;
}
export function createUserBar(options?: UserBarOptions): MountableComponent {
const ac = new AbortController();
const disposable = new Disposable();
let root: HTMLDivElement | null = null;
let unsubscribe: (() => void) | null = null;
// Element references for targeted updates
let avatarEl: HTMLDivElement | null = null;
@@ -63,37 +66,45 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
const settingsBtn = createElement(
"button",
{ title: "Settings", "aria-label": "Settings" },
"\u2699",
);
settingsBtn.appendChild(createIcon("settings", 18));
settingsBtn.addEventListener(
"click",
() => {
openSettings();
},
{ signal: ac.signal },
);
disposable.onEvent(settingsBtn, "click", () => {
openSettings();
});
buttons.appendChild(settingsBtn);
if (options?.onDisconnect !== undefined) {
const disconnectFn = options.onDisconnect;
const disconnectBtn = createElement("button", {
class: "ub-ctrl-btn",
title: "Switch server",
"aria-label": "Switch server",
"data-testid": "disconnect-btn",
});
disconnectBtn.appendChild(createIcon("log-out", 18));
disposable.onEvent(disconnectBtn, "click", () => disconnectFn());
buttons.appendChild(disconnectBtn);
}
appendChildren(root, avatarEl, info, buttons);
// Initial render
updateFromState();
// Subscribe to auth changes
unsubscribe = authStore.subscribe(() => {
updateFromState();
});
disposable.onStoreChange(
authStore,
(s) => s.user,
() => updateFromState(),
);
container.appendChild(root);
}
function destroy(): void {
ac.abort();
if (unsubscribe !== null) {
unsubscribe();
unsubscribe = null;
}
disposable.destroy();
if (root !== null) {
root.remove();
root = null;
+214 -23
View File
@@ -4,12 +4,35 @@
*/
import { createElement, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { muteScreenshareAudio, setUserVolume } from "@lib/livekitSession";
import type { MountableComponent } from "@lib/safe-render";
export interface TileConfig {
/** True if this is the local user's own tile (no audio controls) */
readonly isSelf: boolean;
/** The real userId for audio control (differs from tile ID for screenshare tiles) */
readonly audioUserId: number;
/** True if this tile represents a screenshare (vs camera) */
readonly isScreenshare: boolean;
}
export interface VideoGridComponent extends MountableComponent {
addStream(userId: number, username: string, stream: MediaStream): void;
addStream(userId: number, username: string, stream: MediaStream, config?: TileConfig): void;
removeStream(userId: number): void;
hasStreams(): boolean;
setFocusedTile(tileId: number): void;
getFocusedTileId(): number | null;
}
/** Create a fresh volume icon element. */
function volumeIcon(): SVGSVGElement { return createIcon("volume-2", 16); }
/** Create a fresh volume-x (muted) icon element. */
function volumeXIcon(): SVGSVGElement { return createIcon("volume-x", 16); }
/** Replace a button's icon child with a new one. */
function setButtonIcon(btn: HTMLButtonElement, icon: SVGSVGElement): void {
while (btn.firstChild) btn.removeChild(btn.firstChild);
btn.appendChild(icon);
}
function computeGridColumns(count: number): string {
@@ -21,19 +44,97 @@ function computeGridColumns(count: number): string {
export function createVideoGrid(): VideoGridComponent {
let root: HTMLDivElement | null = null;
const cells = new Map<number, HTMLDivElement>();
const cells = new Map<number, { el: HTMLDivElement; config?: TileConfig }>();
let focusedTileId: number | null = null;
function rebuildFocusLayout(): void {
if (root === null) return;
// Clear root children (we'll re-append in focus layout order)
while (root.firstChild) root.removeChild(root.firstChild);
if (focusedTileId === null || cells.size === 0) {
// No focus — use regular grid layout
root.classList.remove("focus-mode");
root.style.gridTemplateColumns = computeGridColumns(cells.size);
for (const entry of cells.values()) {
entry.el.classList.remove("focused", "thumb");
root.appendChild(entry.el);
}
return;
}
root.classList.add("focus-mode");
root.style.gridTemplateColumns = ""; // Clear grid columns, focus uses flex
// Main area
const mainArea = createElement("div", { class: "video-focus-main" });
// Strip area
const stripArea = createElement("div", { class: "video-focus-strip" });
const focusedEntry = cells.get(focusedTileId);
if (focusedEntry !== undefined) {
focusedEntry.el.classList.add("focused");
focusedEntry.el.classList.remove("thumb");
mainArea.appendChild(focusedEntry.el);
}
for (const [id, entry] of cells) {
if (id === focusedTileId) continue;
entry.el.classList.remove("focused");
entry.el.classList.add("thumb");
stripArea.appendChild(entry.el);
}
root.appendChild(mainArea);
// Only show strip if there are thumbnails
if (stripArea.childElementCount > 0) {
root.appendChild(stripArea);
}
}
function setFocusedTile(tileId: number): void {
focusedTileId = tileId;
rebuildFocusLayout();
}
function getFocusedTileIdFn(): number | null {
return focusedTileId;
}
function updateLayout(): void {
if (root === null) return;
if (focusedTileId !== null) {
rebuildFocusLayout();
return;
}
root.style.gridTemplateColumns = computeGridColumns(cells.size);
}
function addStream(userId: number, username: string, stream: MediaStream): void {
function addStream(userId: number, username: string, stream: MediaStream, config?: TileConfig): void {
if (root === null) return;
// Remove existing cell for this user first
if (cells.has(userId)) {
removeStream(userId);
// If a cell already exists for this user, update it in place
const existing = cells.get(userId);
if (existing !== undefined) {
const video = existing.el.querySelector("video");
if (video !== null) {
// Only replace srcObject if the underlying tracks changed
const oldTracks = (video.srcObject as MediaStream | null)?.getTracks() ?? [];
const newTracks = stream.getTracks();
const tracksMatch =
oldTracks.length === newTracks.length &&
oldTracks.every((t, i) => t.id === newTracks[i]?.id);
if (!tracksMatch) {
video.srcObject = stream;
}
}
// Update username label in case it changed
const label = existing.el.querySelector(".video-username");
if (label !== null) {
label.textContent = username;
}
return;
}
const video = createElement("video", {
@@ -47,27 +148,118 @@ export function createVideoGrid(): VideoGridComponent {
const cell = createElement("div", {
class: "video-cell",
"data-userId": String(userId),
"data-user-id": String(userId),
});
appendChildren(cell, video, label);
cells.set(userId, cell);
cell.addEventListener("click", (e) => {
// Don't switch focus if clicking the mute button
if ((e.target as Element).closest(".tile-mute-btn")) return;
if (focusedTileId !== null && focusedTileId !== userId) {
focusedTileId = userId;
rebuildFocusLayout();
}
});
// Add audio control overlay for remote tiles
if (config !== undefined && !config.isSelf) {
let muted = false;
let currentVolume = 100;
const overlay = createElement("div", { class: "video-tile-overlay" });
// Volume slider
const volumeSlider = createElement("input", {
type: "range",
min: "0",
max: "200",
value: "100",
class: "tile-volume-slider",
"aria-label": "Volume",
}) as HTMLInputElement;
volumeSlider.addEventListener("input", () => {
currentVolume = Number(volumeSlider.value);
const wasMuted = muted;
muted = currentVolume === 0;
if (config.isScreenshare) {
muteScreenshareAudio(config.audioUserId, muted);
} else {
setUserVolume(config.audioUserId, currentVolume);
}
setButtonIcon(muteBtn, muted ? volumeXIcon() : volumeIcon());
muteBtn.setAttribute("aria-label", muted ? "Unmute" : "Mute");
if (muted !== wasMuted) {
overlay.classList.toggle("muted", muted);
}
});
// Mute button
const muteBtn = createElement("button", {
class: "tile-mute-btn",
"aria-label": "Mute",
}) as HTMLButtonElement;
muteBtn.appendChild(volumeIcon());
muteBtn.addEventListener("click", () => {
muted = !muted;
if (muted) {
if (config.isScreenshare) {
muteScreenshareAudio(config.audioUserId, true);
} else {
setUserVolume(config.audioUserId, 0);
}
volumeSlider.value = "0";
} else {
if (currentVolume === 0) currentVolume = 100;
if (config.isScreenshare) {
muteScreenshareAudio(config.audioUserId, false);
} else {
setUserVolume(config.audioUserId, currentVolume);
}
volumeSlider.value = String(currentVolume);
}
setButtonIcon(muteBtn, muted ? volumeXIcon() : volumeIcon());
muteBtn.setAttribute("aria-label", muted ? "Unmute" : "Mute");
overlay.classList.toggle("muted", muted);
});
overlay.appendChild(volumeSlider);
overlay.appendChild(muteBtn);
cell.appendChild(overlay);
}
cells.set(userId, { el: cell, config });
root.appendChild(cell);
updateLayout();
if (focusedTileId !== null) {
rebuildFocusLayout();
} else {
updateLayout();
}
}
function removeStream(userId: number): void {
const cell = cells.get(userId);
if (cell === undefined) return;
const entry = cells.get(userId);
if (entry === undefined) return;
const video = cell.querySelector("video");
if (video !== null) {
video.srcObject = null;
const video = entry.el.querySelector("video");
if (video !== null) video.srcObject = null;
entry.el.remove();
cells.delete(userId);
// If focused tile was removed, focus the first remaining tile or clear
const wasFocusMode = focusedTileId !== null;
if (focusedTileId === userId) {
const firstKey = cells.keys().next().value;
focusedTileId = firstKey ?? null;
}
cell.remove();
cells.delete(userId);
updateLayout();
if (focusedTileId !== null || wasFocusMode) {
rebuildFocusLayout();
} else {
updateLayout();
}
}
function hasStreams(): boolean {
@@ -83,13 +275,12 @@ export function createVideoGrid(): VideoGridComponent {
}
function destroy(): void {
for (const [, cell] of cells) {
const video = cell.querySelector("video");
if (video !== null) {
video.srcObject = null;
}
for (const [, entry] of cells) {
const video = entry.el.querySelector("video");
if (video !== null) video.srcObject = null;
}
cells.clear();
focusedTileId = null;
if (root !== null) {
root.remove();
@@ -97,5 +288,5 @@ export function createVideoGrid(): VideoGridComponent {
}
}
return { mount, destroy, addStream, removeStream, hasStreams };
return { mount, destroy, addStream, removeStream, hasStreams, setFocusedTile, getFocusedTileId: getFocusedTileIdFn };
}
@@ -5,10 +5,11 @@
*/
import { createElement, appendChildren, clearChildren, setText } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { voiceStore } from "@stores/voice.store";
import type { VoiceUser } from "@stores/voice.store";
import { membersStore } from "@stores/members.store";
import { setUserVolume, getUserVolume } from "@lib/voiceSession";
import { setUserVolume, getUserVolume } from "@lib/livekitSession";
import { authStore } from "@stores/auth.store";
export interface VoiceChannelOptions {
@@ -42,7 +43,8 @@ export function createVoiceChannel(options: VoiceChannelOptions): VoiceChannelRe
// Channel item row (same structure as text channels)
const channelItem = createElement("div", { class: "channel-item voice" });
const icon = createElement("span", { class: "ch-icon" }, "\uD83D\uDD0A");
const icon = createElement("span", { class: "ch-icon" });
icon.appendChild(createIcon("volume-2", 16));
const nameEl = createElement("span", { class: "ch-name" }, options.channelName);
appendChildren(channelItem, icon, nameEl);
@@ -139,6 +141,7 @@ export function createVoiceChannel(options: VoiceChannelOptions): VoiceChannelRe
menuDismissAc = new AbortController();
const dismissSignal = menuDismissAc.signal;
setTimeout(() => {
if (dismissSignal.aborted) return;
document.addEventListener("mousedown", (e: MouseEvent) => {
if (!menu.contains(e.target as Node)) {
closeContextMenu();
@@ -162,9 +165,15 @@ export function createVoiceChannel(options: VoiceChannelOptions): VoiceChannelRe
const name = createElement("span", { class: "vu-name" }, username);
row.appendChild(name);
if (user.camera) {
const cameraEl = createElement("span", { class: "vu-status" });
cameraEl.appendChild(createIcon("camera", 14));
row.appendChild(cameraEl);
}
if (user.muted || user.deafened) {
const mutedIcon = user.deafened ? "\uD83D\uDD08" : "\uD83D\uDD07";
const mutedEl = createElement("span", { class: "vu-muted" }, mutedIcon);
const mutedEl = createElement("span", { class: "vu-muted" });
mutedEl.appendChild(createIcon(user.deafened ? "headphones-off" : "mic-off", 14));
row.appendChild(mutedEl);
}
@@ -218,8 +227,8 @@ export function createVoiceChannel(options: VoiceChannelOptions): VoiceChannelRe
// Initial render and subscribe
update();
unsubs.push(voiceStore.subscribe(() => update()));
unsubs.push(membersStore.subscribe(() => update()));
unsubs.push(voiceStore.subscribeSelector((s) => s.voiceUsers, () => update()));
unsubs.push(membersStore.subscribeSelector((s) => s.members, () => update()));
function destroy(): void {
closeContextMenu();
+246 -12
View File
@@ -6,9 +6,20 @@
*/
import { createElement, appendChildren, setText } from "@lib/dom";
import { createIcon, createSignalIcon } from "@lib/icons";
import type { IconName } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
import { voiceStore } from "@stores/voice.store";
import { channelsStore } from "@stores/channels.store";
import {
createConnectionStatsPoller,
formatBytes,
formatRate,
type ConnectionStats,
type ConnectionStatsPoller,
type QualityLevel,
} from "@lib/connectionStats";
import { getRoomForStats } from "@lib/livekitSession";
export interface VoiceWidgetOptions {
onDisconnect(): void;
@@ -18,6 +29,31 @@ export interface VoiceWidgetOptions {
onScreenshareToggle(): void;
}
const QUALITY_COLORS: Record<QualityLevel, string> = {
excellent: "var(--green, #23a559)",
fair: "var(--yellow, #f0b232)",
poor: "var(--red, #f23f43)",
bad: "var(--red, #f23f43)",
};
const QUALITY_BARS: Record<QualityLevel, number> = {
excellent: 4,
fair: 3,
poor: 2,
bad: 1,
};
/** Format milliseconds elapsed into HH:MM:SS or MM:SS. */
function formatElapsed(ms: number): string {
const totalSec = Math.floor(ms / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
const mm = String(m).padStart(2, "0");
const ss = String(s).padStart(2, "0");
return h > 0 ? `${String(h).padStart(2, "0")}:${mm}:${ss}` : `${mm}:${ss}`;
}
export function createVoiceWidget(options: VoiceWidgetOptions): MountableComponent {
const ac = new AbortController();
let root: HTMLDivElement | null = null;
@@ -25,9 +61,98 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
let muteBtn: HTMLButtonElement | null = null;
let deafenBtn: HTMLButtonElement | null = null;
let cameraBtn: HTMLButtonElement | null = null;
let shareBtn: HTMLButtonElement | null = null;
// Connection stats
let signalWrap: HTMLDivElement | null = null;
let pingLabel: HTMLSpanElement | null = null;
let statsPane: HTMLDivElement | null = null;
let statsPoller: ConnectionStatsPoller | null = null;
let statsUnlisten: (() => void) | null = null;
// Elapsed timer
let timerEl: HTMLSpanElement | null = null;
let timerInterval: ReturnType<typeof setInterval> | null = null;
// Stats pane field elements (set during mount)
let outRateEl: HTMLSpanElement | null = null;
let outPacketsEl: HTMLSpanElement | null = null;
let rttEl: HTMLSpanElement | null = null;
let inRateEl: HTMLSpanElement | null = null;
let inPacketsEl: HTMLSpanElement | null = null;
let totalUpEl: HTMLSpanElement | null = null;
let totalDownEl: HTMLSpanElement | null = null;
const unsubs: Array<() => void> = [];
function swapIcon(btn: HTMLButtonElement, name: IconName): void {
const existing = btn.querySelector("svg");
if (existing) existing.remove();
btn.appendChild(createIcon(name, 18));
}
function updateSignalIcon(stats: ConnectionStats): void {
if (signalWrap === null || pingLabel === null) return;
const color = QUALITY_COLORS[stats.quality];
const bars = QUALITY_BARS[stats.quality];
// Replace signal icon
const oldSvg = signalWrap.querySelector("svg");
if (oldSvg) oldSvg.remove();
signalWrap.insertBefore(createSignalIcon(bars, color, 14), pingLabel);
// Update ping text
const rttText = stats.rtt > 0 ? `${Math.round(stats.rtt)}ms` : "—";
setText(pingLabel, rttText);
pingLabel.style.color = color;
// Update expanded stats pane fields if they exist
if (outRateEl) setText(outRateEl, formatRate(stats.outRate));
if (outPacketsEl) setText(outPacketsEl, String(stats.outPackets));
if (rttEl) {
setText(rttEl, stats.rtt > 0 ? `${stats.rtt.toFixed(1)} ms` : "—");
rttEl.style.color = color;
}
if (inRateEl) setText(inRateEl, formatRate(stats.inRate));
if (inPacketsEl) setText(inPacketsEl, String(stats.inPackets));
if (totalUpEl) setText(totalUpEl, formatBytes(stats.totalUp));
if (totalDownEl) setText(totalDownEl, formatBytes(stats.totalDown));
}
function startStatsPoller(): void {
if (statsPoller !== null) return;
statsPoller = createConnectionStatsPoller(() => getRoomForStats());
statsUnlisten = statsPoller.onUpdate(updateSignalIcon);
statsPoller.start();
}
function stopStatsPoller(): void {
statsUnlisten?.();
statsUnlisten = null;
statsPoller?.stop();
statsPoller = null;
}
function updateElapsedTimer(): void {
const joinedAt = voiceStore.getState().joinedAt;
if (timerEl === null || joinedAt === null) return;
setText(timerEl, formatElapsed(Date.now() - joinedAt));
}
function startElapsedTimer(): void {
if (timerInterval !== null) return;
updateElapsedTimer();
timerInterval = setInterval(updateElapsedTimer, 1000);
}
function stopElapsedTimer(): void {
if (timerInterval !== null) {
clearInterval(timerInterval);
timerInterval = null;
}
if (timerEl !== null) setText(timerEl, "00:00");
}
function render(): void {
if (root === null || channelNameEl === null) return;
@@ -36,31 +161,43 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
if (channelId === null) {
root.classList.remove("visible");
stopStatsPoller();
stopElapsedTimer();
statsPane?.classList.remove("visible");
return;
}
root.classList.add("visible");
startStatsPoller();
startElapsedTimer();
// Channel name
const channel = channelsStore.getState().channels.get(channelId);
setText(channelNameEl, channel?.name ?? "Voice Channel");
// Toggle button active states
// 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);
if (muteBtn) { swapIcon(muteBtn, voice.localMuted ? "mic-off" : "mic"); muteBtn.setAttribute("aria-pressed", String(voice.localMuted)); }
if (deafenBtn) { swapIcon(deafenBtn, voice.localDeafened ? "headphones-off" : "headphones"); deafenBtn.setAttribute("aria-pressed", String(voice.localDeafened)); }
if (cameraBtn) { swapIcon(cameraBtn, voice.localCamera ? "camera-off" : "camera"); cameraBtn.setAttribute("aria-pressed", String(voice.localCamera)); }
shareBtn?.classList.toggle("active-ctrl", voice.localScreenshare);
if (shareBtn) { swapIcon(shareBtn, voice.localScreenshare ? "monitor-off" : "monitor"); shareBtn.setAttribute("aria-pressed", String(voice.localScreenshare)); }
}
function createControlButton(
label: string,
icon: string,
icon: IconName,
handler: () => void,
extraClass?: string,
): HTMLButtonElement {
const btn = createElement("button", {
class: extraClass ?? "",
"aria-label": label,
}, icon);
});
btn.appendChild(createIcon(icon, 18));
btn.addEventListener("click", handler, { signal: ac.signal });
return btn;
}
@@ -68,32 +205,117 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
function mount(container: Element): void {
root = createElement("div", { class: "voice-widget", "data-testid": "voice-widget" });
// Header row: "Voice Connected" + channel name + signal icon
const header = createElement("div", { class: "vw-header" });
const connLabel = createElement("span", { class: "vw-connected" }, "Voice Connected");
timerEl = createElement("span", { class: "vw-timer" }, "00:00");
channelNameEl = createElement("span", { class: "vw-channel" }, "Voice Channel");
appendChildren(header, connLabel, channelNameEl);
signalWrap = createElement("div", { class: "vw-signal", "aria-label": "Connection quality" });
signalWrap.appendChild(createSignalIcon(4, QUALITY_COLORS.excellent, 14));
pingLabel = createElement("span", { class: "vw-ping" }, "—");
pingLabel.style.color = QUALITY_COLORS.excellent;
signalWrap.appendChild(pingLabel);
signalWrap.addEventListener("click", () => {
statsPane?.classList.toggle("visible");
}, { signal: ac.signal });
appendChildren(header, connLabel, timerEl, channelNameEl, signalWrap);
// Expanded stats pane (hidden by default)
statsPane = createElement("div", { class: "vw-stats" });
const statsTitle = createElement("div", { class: "vw-stats-title" }, "Transport Statistics");
const statsGrid = createElement("div", { class: "vw-stats-grid" });
// Outgoing column
const outCol = createElement("div", {});
const outLabel = createElement("div", { class: "vw-stats-col-label out" }, "Outgoing");
outRateEl = createElement("span", {}, "0 B/s");
outPacketsEl = createElement("span", {}, "0");
rttEl = createElement("span", {}, "—");
rttEl.style.fontWeight = "600";
const outBody = createElement("div", { class: "vw-stats-row" });
for (const [label, el] of [["Rate: ", outRateEl], ["Packets: ", outPacketsEl], ["RTT: ", rttEl]] as const) {
outBody.appendChild(document.createTextNode(label));
outBody.appendChild(el);
outBody.appendChild(createElement("br", {}));
}
appendChildren(outCol, outLabel, outBody);
// Incoming column
const inCol = createElement("div", {});
const inLabel = createElement("div", { class: "vw-stats-col-label in" }, "Incoming");
inRateEl = createElement("span", {}, "0 B/s");
inPacketsEl = createElement("span", {}, "0");
const inBody = createElement("div", { class: "vw-stats-row" });
for (const [label, el] of [["Rate: ", inRateEl], ["Packets: ", inPacketsEl]] as const) {
inBody.appendChild(document.createTextNode(label));
inBody.appendChild(el);
inBody.appendChild(createElement("br", {}));
}
appendChildren(inCol, inLabel, inBody);
appendChildren(statsGrid, outCol, inCol);
// Session totals
const totals = createElement("div", { class: "vw-stats-totals" });
const totalsLabel = createElement("div", { class: "vw-stats-totals-label" }, "Session Totals");
const totalsRow = createElement("div", { class: "vw-stats-totals-row" });
totalUpEl = createElement("span", {}, "0 B");
totalDownEl = createElement("span", {}, "0 B");
const upWrap = createElement("span", {});
upWrap.appendChild(document.createTextNode("\u2191 "));
upWrap.appendChild(totalUpEl);
const downWrap = createElement("span", {});
downWrap.appendChild(document.createTextNode("\u2193 "));
downWrap.appendChild(totalDownEl);
appendChildren(totalsRow, upWrap, downWrap);
appendChildren(totals, totalsLabel, totalsRow);
appendChildren(statsPane, statsTitle, statsGrid, totals);
// Controls row
const controls = createElement("div", { class: "vw-controls" });
muteBtn = createControlButton("Mute", "\uD83C\uDFA4", options.onMuteToggle);
deafenBtn = createControlButton("Deafen", "\uD83C\uDFA7", options.onDeafenToggle);
cameraBtn = createControlButton("Camera", "\uD83D\uDCF7", options.onCameraToggle);
const shareBtn = createControlButton("Screenshare", "\uD83D\uDDA5", options.onScreenshareToggle);
muteBtn = createControlButton("Mute", "mic", options.onMuteToggle);
deafenBtn = createControlButton("Deafen", "headphones", options.onDeafenToggle);
cameraBtn = createControlButton("Camera", "camera", options.onCameraToggle);
shareBtn = createControlButton("Screenshare", "monitor", options.onScreenshareToggle);
const disconnectBtn = createControlButton(
"Disconnect", "\u260E", options.onDisconnect, "disconnect",
"Disconnect", "phone", options.onDisconnect, "disconnect",
);
appendChildren(controls, muteBtn, deafenBtn, cameraBtn, shareBtn, disconnectBtn);
appendChildren(root, header, controls);
appendChildren(root, header, statsPane, controls);
render();
unsubs.push(voiceStore.subscribe(() => render()));
unsubs.push(channelsStore.subscribe(() => render()));
unsubs.push(voiceStore.subscribeSelector(
(s) => ({
channelId: s.currentChannelId,
muted: s.localMuted,
deafened: s.localDeafened,
camera: s.localCamera,
screenshare: s.localScreenshare,
}),
() => render(),
(a, b) =>
a.channelId === b.channelId &&
a.muted === b.muted &&
a.deafened === b.deafened &&
a.camera === b.camera &&
a.screenshare === b.screenshare,
));
unsubs.push(channelsStore.subscribeSelector(
(s) => s.channels,
() => render(),
));
container.appendChild(root);
}
function destroy(): void {
stopStatsPoller();
stopElapsedTimer();
ac.abort();
for (const unsub of unsubs) {
unsub();
@@ -105,6 +327,18 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
muteBtn = null;
deafenBtn = null;
cameraBtn = null;
shareBtn = null;
signalWrap = null;
pingLabel = null;
timerEl = null;
statsPane = null;
outRateEl = null;
outPacketsEl = null;
rttEl = null;
inRateEl = null;
inPacketsEl = null;
totalUpEl = null;
totalDownEl = null;
}
return { mount, destroy };
@@ -0,0 +1,311 @@
/**
* File attachment rendering and image caching (memory + IndexedDB).
* Also owns the server host state and URL resolution used by other modules.
*/
import {
createElement,
setText,
appendChildren,
} from "@lib/dom";
import { createIcon } from "@lib/icons";
import { observeMedia } from "@lib/media-visibility";
import { loadPref } from "@components/settings/helpers";
import { createLogger } from "@lib/logger";
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { save } from "@tauri-apps/plugin-dialog";
const log = createLogger("attachments");
import { writeFile } from "@tauri-apps/plugin-fs";
import type { Attachment } from "@lib/types";
import { openImageLightbox } from "./media";
// -- Server host state --------------------------------------------------------
/** Module-level server host for resolving relative attachment URLs. */
let _serverHost: string | null = null;
/** Set the server host (called once from MainPage on connect). */
export function setServerHost(host: string): void {
_serverHost = host;
}
/** Resolve a potentially relative URL to a full URL using the server host. */
export function resolveServerUrl(url: string): string {
if (url.startsWith("http://") || url.startsWith("https://")) {
return url;
}
if (_serverHost !== null) {
return `https://${_serverHost}${url}`;
}
return url;
}
// -- Helpers ------------------------------------------------------------------
export function formatFileSize(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`;
}
export function isImageMime(mime: string): boolean {
return mime.startsWith("image/");
}
export function isSafeUrl(url: string): boolean {
try {
const parsed = new URL(url, window.location.origin);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Image cache: memory + IndexedDB for persistence across restarts
// ---------------------------------------------------------------------------
/** In-memory cache for instant re-render. */
const memoryCache = new Map<string, string>();
/** In-flight fetch promises to prevent duplicate concurrent requests. */
const inFlight = new Map<string, Promise<string | null>>();
/** IndexedDB database name and store. */
const IDB_NAME = "owncord-image-cache";
const IDB_STORE = "images";
const IDB_VERSION = 1;
/** Open (or create) the IndexedDB database. */
export function openCacheDb(): Promise<IDBDatabase | null> {
return new Promise((resolve) => {
try {
const req = indexedDB.open(IDB_NAME, IDB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(IDB_STORE)) {
db.createObjectStore(IDB_STORE);
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
} catch {
resolve(null);
}
});
}
/** Read a cached data URL from IndexedDB. */
async function idbGet(url: string): Promise<string | null> {
const db = await openCacheDb();
if (db === null) return null;
return new Promise((resolve) => {
try {
const tx = db.transaction(IDB_STORE, "readonly");
const store = tx.objectStore(IDB_STORE);
const req = store.get(url);
req.onsuccess = () => resolve(typeof req.result === "string" ? req.result : null);
req.onerror = () => resolve(null);
} catch {
resolve(null);
}
});
}
/** Write a data URL to IndexedDB. */
async function idbPut(url: string, dataUrl: string): Promise<void> {
const db = await openCacheDb();
if (db === null) return;
try {
const tx = db.transaction(IDB_STORE, "readwrite");
tx.objectStore(IDB_STORE).put(dataUrl, url);
} catch {
// IndexedDB full or unavailable — ignore
}
}
/** Convert a Uint8Array to a base64 string. */
export function uint8ToBase64(bytes: Uint8Array): string {
// Process in chunks to avoid call stack overflow on large files
const CHUNK = 8192;
let binary = "";
for (let i = 0; i < bytes.length; i += CHUNK) {
const slice = bytes.subarray(i, Math.min(i + CHUNK, bytes.length));
binary += String.fromCharCode(...slice);
}
return btoa(binary);
}
/** Fetch an image and return a data: URI. Uses memory → IndexedDB → network. */
export function fetchImageAsDataUrl(url: string): Promise<string | null> {
// 1. Memory cache (instant)
const cached = memoryCache.get(url);
if (cached !== undefined) return Promise.resolve(cached);
// 2. Deduplicate concurrent requests for the same URL
const existing = inFlight.get(url);
if (existing !== undefined) return existing;
const promise = (async (): Promise<string | null> => {
// 3. IndexedDB cache (persists across restarts)
const idbCached = await idbGet(url);
if (idbCached !== null) {
memoryCache.set(url, idbCached);
return idbCached;
}
// 4. Network fetch via Tauri HTTP plugin
// acceptInvalidCerts is required for self-hosted OwnCord servers with self-signed
// TLS certificates. This means the client will accept any certificate from any server
// for image fetching, which could enable SSRF to internal endpoints via malicious
// chat messages containing internal URLs. Mitigated by: (1) isSafeUrl only allows
// http/https, (2) responses are only used as image data, not executed.
try {
const res = await tauriFetch(url, {
danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false },
} as RequestInit);
if (!res.ok) return null;
const contentType = res.headers.get("content-type") ?? "image/png";
const buffer = await res.arrayBuffer();
const base64 = uint8ToBase64(new Uint8Array(buffer));
const dataUrl = `data:${contentType};base64,${base64}`;
// Store in both caches
memoryCache.set(url, dataUrl);
void idbPut(url, dataUrl);
return dataUrl;
} catch (err) {
log.error("Failed to fetch attachment image", { url, error: String(err) });
return null;
}
})();
inFlight.set(url, promise);
void promise.finally(() => inFlight.delete(url));
return promise;
}
// -- Attachment rendering -----------------------------------------------------
export function renderAttachment(att: Attachment): HTMLDivElement {
const resolvedUrl = resolveServerUrl(att.url);
if (isImageMime(att.mime) && isSafeUrl(resolvedUrl)) {
const wrap = createElement("div", { class: "msg-image" });
// Reserve space using server-provided dimensions to prevent layout shift.
if (att.width != null && att.height != null && att.width > 0 && att.height > 0) {
const maxW = 400, maxH = 350;
const scale = Math.min(1, maxW / att.width, maxH / att.height);
const w = Math.round(att.width * scale);
const h = Math.round(att.height * scale);
wrap.style.width = `${w}px`;
wrap.style.height = `${h}px`;
} else {
// Fallback for old attachments without dimensions — use placeholder height.
wrap.style.minHeight = "200px";
}
function attachLightbox(img: HTMLImageElement): void {
img.addEventListener("click", () => {
openImageLightbox(img.src, att.filename);
});
}
const isGif = att.mime === "image/gif";
// Clear min-height reservation and cache the natural height so virtual
// scroll rebuilds don't oscillate between estimated and actual heights.
// Measure synchronously to avoid rAF race with ResizeObserver.
const clearReservation = (): void => {
wrap.style.minHeight = "";
const h = wrap.offsetHeight;
if (h > 0 && att.width == null) {
// Only cache for fallback path (no server-provided dimensions).
// Set min-height to prevent oscillation on virtual scroll rebuild.
wrap.style.minHeight = `${h}px`;
}
};
// Check cache first for instant render
const cached = memoryCache.get(resolvedUrl);
if (cached !== undefined) {
const img = createElement("img", {
src: cached,
alt: att.filename,
}) as HTMLImageElement;
attachLightbox(img);
img.addEventListener("load", () => {
clearReservation();
if (isGif) observeMedia(img, cached, wrap, !loadPref("animateGifs", true));
}, { once: true });
wrap.appendChild(img);
} else {
// Show loading placeholder, then replace with image
const placeholder = createElement("div", { class: "placeholder-img loading" }, att.filename);
wrap.appendChild(placeholder);
void fetchImageAsDataUrl(resolvedUrl).then((dataUrl) => {
if (dataUrl !== null) {
const img = createElement("img", {
src: dataUrl,
alt: att.filename,
}) as HTMLImageElement;
attachLightbox(img);
img.addEventListener("load", () => {
clearReservation();
if (isGif) observeMedia(img, dataUrl, wrap, !loadPref("animateGifs", true));
}, { once: true });
placeholder.replaceWith(img);
}
});
}
return wrap;
}
const wrap = createElement("div", { class: "msg-file" });
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);
wrap.appendChild(inner);
return wrap;
}
/** Download a file via Tauri HTTP plugin and save to disk with native dialog. */
async function downloadFile(url: string, filename: string): Promise<void> {
try {
// Show native save dialog with suggested filename
const filePath = await save({ defaultPath: filename });
if (filePath === null) return; // User cancelled
// Fetch file data
const res = await tauriFetch(url, {
danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false },
} as RequestInit);
if (!res.ok) return;
const buffer = await res.arrayBuffer();
await writeFile(filePath, new Uint8Array(buffer));
} catch (err) {
log.error("Download failed", { filename, error: String(err) });
}
}
@@ -0,0 +1,137 @@
/**
* Text content parsing — XSS-safe DOM builders for message text including
* inline code, code blocks, @mentions, and URL linkification.
*/
import {
createElement,
setText,
} from "@lib/dom";
import { isSafeUrl } from "./attachments";
// -- 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;
// -- Content rendering --------------------------------------------------------
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)));
}
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)));
}
return fragment;
}
export function renderMentions(text: string): DocumentFragment {
// First pass: split by URLs, then handle mentions in non-URL segments
const fragment = document.createDocumentFragment();
let lastIndex = 0;
for (const match of text.matchAll(URL_REGEX)) {
const idx = match.index;
if (idx === undefined) continue;
if (idx > lastIndex) {
fragment.appendChild(renderMentionSegment(text.slice(lastIndex, idx)));
}
const url = match[0];
if (isSafeUrl(url)) {
const link = createElement("a", {
class: "msg-link",
href: url,
target: "_blank",
rel: "noopener noreferrer",
});
setText(link, url);
fragment.appendChild(link);
} else {
fragment.appendChild(document.createTextNode(url));
}
lastIndex = idx + match[0].length;
}
if (lastIndex < text.length) {
fragment.appendChild(renderMentionSegment(text.slice(lastIndex)));
}
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)) {
const idx = match.index;
if (idx === undefined) continue;
if (idx > lastIndex) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex, idx)));
}
const span = createElement("span", { class: "mention" });
setText(span, match[0]);
fragment.appendChild(span);
lastIndex = idx + match[0].length;
}
if (lastIndex < text.length) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
}
return fragment;
}
export function renderMessageContent(content: string): DocumentFragment {
const fragment = document.createDocumentFragment();
let lastIndex = 0;
for (const match of content.matchAll(CODE_BLOCK_REGEX)) {
const idx = match.index;
if (idx === undefined) continue;
if (idx > lastIndex) {
const text = createElement("div", { class: "msg-text" });
text.appendChild(renderInlineContent(content.slice(lastIndex, idx)));
fragment.appendChild(text);
}
const codeWrap = createElement("div", { class: "msg-codeblock-wrap" });
const codeBlock = createElement("div", { class: "msg-codeblock" });
const codeContent = match[1]!.trim();
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);
lastIndex = idx + match[0].length;
}
if (lastIndex === 0) {
const text = createElement("div", { class: "msg-text" });
text.appendChild(renderInlineContent(content));
fragment.appendChild(text);
} else if (lastIndex < content.length) {
const remaining = content.slice(lastIndex).trim();
if (remaining.length > 0) {
const text = createElement("div", { class: "msg-text" });
text.appendChild(renderInlineContent(remaining));
fragment.appendChild(text);
}
}
return fragment;
}
@@ -0,0 +1,274 @@
/**
* Link preview / Open Graph tag rendering — fetches and displays OG metadata
* (title, description, image) for generic URLs as compact link cards.
*/
import {
createElement,
setText,
} from "@lib/dom";
import { observeMedia } from "@lib/media-visibility";
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { createLogger } from "@lib/logger";
import { isSafeUrl } from "./attachments";
const log = createLogger("embeds");
// -- OG metadata types --------------------------------------------------------
/** Open Graph metadata extracted from a page. */
export interface OgMeta {
readonly title: string | null;
readonly description: string | null;
readonly image: string | null;
readonly siteName: string | null;
}
// -- Caches -------------------------------------------------------------------
/** Cache for OG metadata to avoid re-fetching on re-render. */
const ogCache = new Map<string, OgMeta>();
/** In-flight fetch promises keyed by URL — concurrent callers share the same promise. */
const ogInFlight = new Map<string, Promise<OgMeta>>();
// -- OG tag parsing -----------------------------------------------------------
/** Escape special regex characters in a string for safe use in `new RegExp()`. */
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** Extract Open Graph meta tags from raw HTML using regex (no DOM parser needed). */
export function parseOgTags(html: string): OgMeta {
function getMetaContent(property: string): string | null {
// Match both property="og:X" and name="og:X" patterns
const escaped = escapeRegex(property);
const regex = new RegExp(
`<meta[^>]*(?:property|name)=["']${escaped}["'][^>]*content=["']([^"']*)["']` +
`|<meta[^>]*content=["']([^"']*)["'][^>]*(?:property|name)=["']${escaped}["']`,
"i",
);
const match = html.match(regex);
if (match !== null) {
return match[1] ?? match[2] ?? null;
}
return null;
}
// Fallback: extract <title> tag if no og:title
function getTitle(): string | null {
const og = getMetaContent("og:title");
if (og !== null) return og;
const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i);
return titleMatch?.[1]?.trim() ?? null;
}
// Fallback: extract meta description if no og:description
function getDescription(): string | null {
const og = getMetaContent("og:description");
if (og !== null) return og;
return getMetaContent("description");
}
return {
title: getTitle(),
description: getDescription(),
image: getMetaContent("og:image"),
siteName: getMetaContent("og:site_name"),
};
}
// -- SSRF protection ----------------------------------------------------------
/** Block link previews to private/internal IP ranges to prevent SSRF.
* The connected OwnCord server host is NOT blocked (it's trusted). */
function isPrivateHost(hostname: string): boolean {
// Block localhost variants
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]") return true;
// Block link-local, RFC1918, and cloud metadata endpoints
if (hostname.startsWith("10.") || hostname.startsWith("192.168.") || hostname === "169.254.169.254") return true;
if (hostname.startsWith("172.")) {
const second = parseInt(hostname.split(".")[1] ?? "", 10);
if (second >= 16 && second <= 31) return true;
}
return false;
}
function isBlockedForPreview(url: string): boolean {
try {
const parsed = new URL(url);
return isPrivateHost(parsed.hostname);
} catch {
return true; // Malformed URLs are blocked
}
}
// -- OG fetch -----------------------------------------------------------------
const EMPTY_OG: OgMeta = { title: null, description: null, image: null, siteName: null };
/** Fetch OG metadata for a URL using the Tauri native HTTP client (no CORS).
* Concurrent requests for the same URL share the same in-flight promise. */
function fetchOgMeta(url: string): Promise<OgMeta> {
const cached = ogCache.get(url);
if (cached !== undefined) return Promise.resolve(cached);
// Return the existing in-flight promise so all callers get the real result.
const existing = ogInFlight.get(url);
if (existing !== undefined) return existing;
// Block link previews to internal/private hosts to prevent SSRF
if (isBlockedForPreview(url)) {
log.debug("fetchOgMeta blocked (private host)", url.slice(0, 100));
ogCache.set(url, EMPTY_OG);
return Promise.resolve(EMPTY_OG);
}
log.debug("fetchOgMeta START", url.slice(0, 100));
const promise = (async (): Promise<OgMeta> => {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
const res = await tauriFetch(url, {
signal: controller.signal,
headers: { "User-Agent": "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)" },
danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false },
} as RequestInit);
clearTimeout(timer);
if (!res.ok) {
ogCache.set(url, EMPTY_OG);
return EMPTY_OG;
}
// Only parse HTML responses (skip binary, JSON, etc.)
const contentType = res.headers.get("content-type") ?? "";
if (!contentType.includes("text/html")) {
ogCache.set(url, EMPTY_OG);
return EMPTY_OG;
}
const html = await res.text();
// Only parse the first 50KB to avoid parsing huge pages
const meta = parseOgTags(html.slice(0, 50_000));
ogCache.set(url, meta);
return meta;
} catch {
ogCache.set(url, EMPTY_OG);
return EMPTY_OG;
}
})();
ogInFlight.set(url, promise);
void promise.finally(() => ogInFlight.delete(url));
return promise;
}
// -- Link preview rendering ---------------------------------------------------
/** Render a link preview card with OG metadata (title, description, image). */
export function renderGenericLinkPreview(url: string): HTMLDivElement {
const wrap = createElement("div", { class: "msg-embed msg-embed-link" });
let displayHost = "";
try {
displayHost = new URL(url).hostname;
} catch {
displayHost = url;
}
const content = createElement("div", { class: "msg-embed-link-content" });
const hostEl = createElement("div", { class: "msg-embed-host" }, displayHost);
content.appendChild(hostEl);
const titleEl = createElement("a", {
class: "msg-embed-link-title",
href: url,
target: "_blank",
rel: "noopener noreferrer",
});
content.appendChild(titleEl);
const descEl = createElement("div", { class: "msg-embed-link-desc" });
content.appendChild(descEl);
wrap.appendChild(content);
// Image container (shown if og:image exists)
const imageWrap = createElement("div", { class: "msg-embed-link-image" });
imageWrap.style.display = "none";
wrap.appendChild(imageWrap);
// Check cache first for instant render
const cached = ogCache.get(url);
if (cached !== undefined) {
applyOgMeta(cached, titleEl, descEl, hostEl, imageWrap, url, displayHost);
} else {
// Show URL as fallback title while loading
setText(titleEl, displayHost);
void fetchOgMeta(url).then((meta) => {
applyOgMeta(meta, titleEl, descEl, hostEl, imageWrap, url, displayHost);
});
}
return wrap;
}
/** Apply fetched OG metadata to the preview card elements. */
export function applyOgMeta(
meta: OgMeta,
titleEl: HTMLElement,
descEl: HTMLElement,
hostEl: HTMLElement,
imageWrap: HTMLElement,
url: string,
displayHost: string,
): void {
setText(titleEl, meta.title ?? displayHost);
if (meta.siteName !== null) {
setText(hostEl, meta.siteName);
}
if (meta.description !== null) {
const desc = meta.description.length > 200
? meta.description.slice(0, 197) + "..."
: meta.description;
setText(descEl, desc);
descEl.style.display = "";
} else {
descEl.style.display = "none";
}
if (meta.image !== null && meta.image.length > 0) {
// Resolve relative image URLs
let imgSrc = meta.image;
if (imgSrc.startsWith("/")) {
try {
const base = new URL(url);
imgSrc = `${base.origin}${imgSrc}`;
} catch { /* keep as-is */ }
}
if (isSafeUrl(imgSrc)) {
const isGif = imgSrc.toLowerCase().endsWith(".gif");
const attrs: Record<string, string> = {
class: "msg-embed-link-img",
src: imgSrc,
alt: meta.title ?? "",
loading: "lazy",
};
if (isGif) {
attrs.crossorigin = "anonymous";
}
const img = createElement("img", attrs);
img.addEventListener("error", () => {
imageWrap.style.display = "none";
});
if (isGif) {
(img as HTMLImageElement).addEventListener("load", () => {
observeMedia(img as HTMLImageElement, imgSrc, imageWrap);
}, { once: true });
}
imageWrap.appendChild(img);
imageWrap.style.display = "";
}
}
}
@@ -0,0 +1,65 @@
/**
* Fenwick Tree (Binary Indexed Tree) for O(log n) prefix sums and updates.
* Used by the virtual scroll to efficiently compute item offsets.
*/
export class FenwickTree {
private readonly tree: Float64Array;
private readonly values: Float64Array;
readonly size: number;
constructor(size: number) {
this.size = size;
this.tree = new Float64Array(size + 1);
this.values = new Float64Array(size);
}
/** Set value at index and update tree. */
set(i: number, value: number): void {
const prev = this.values[i] as number;
const delta = value - prev;
if (delta === 0) return;
this.values[i] = value;
for (let x = i + 1; x <= this.size; x += x & (-x)) {
(this.tree as Float64Array)[x] = (this.tree[x] as number) + delta;
}
}
/** Get value at index. */
get(i: number): number {
return this.values[i] as number;
}
/** Prefix sum of [0..i] inclusive. */
prefixSum(i: number): number {
if (i < 0) return 0;
let s = 0;
for (let x = i + 1; x > 0; x -= x & (-x)) {
s += this.tree[x] as number;
}
return s;
}
/** Total sum of all values. */
total(): number {
return this.prefixSum(this.size - 1);
}
/** Find smallest index where prefix sum > target (for scroll offset to index). */
findIndex(target: number): number {
let pos = 0;
let bitMask = 1;
while (bitMask <= this.size) bitMask <<= 1;
bitMask >>= 1;
let sum = 0;
while (bitMask > 0) {
const next = pos + bitMask;
if (next <= this.size && sum + (this.tree[next] as number) <= target) {
pos = next;
sum += this.tree[next] as number;
}
bitMask >>= 1;
}
return Math.min(pos, this.size - 1);
}
}
@@ -0,0 +1,109 @@
/**
* Date/time formatting helpers and message grouping logic.
* Pure functions for timestamp parsing, display formatting, and role resolution.
*/
import { membersStore } from "@stores/members.store";
import type { Message } from "@stores/messages.store";
import { loadPref } from "@components/settings/helpers";
// -- Constants ----------------------------------------------------------------
export const GROUP_THRESHOLD_MS = 5 * 60 * 1000;
// -- Timestamp helpers --------------------------------------------------------
/** Parse a timestamp string, appending 'Z' if no timezone info is present
* so that UTC timestamps from SQLite are correctly interpreted. */
export function parseTimestamp(raw: string): Date {
// SQLite datetime('now') produces "2026-03-19 08:29:41" (UTC, no suffix).
// If there's no Z, +, or T with offset, treat as UTC by appending Z.
if (!raw.endsWith("Z") && !raw.includes("+") && !/T\d{2}:\d{2}:\d{2}[+-]/.test(raw)) {
return new Date(raw.replace(" ", "T") + "Z");
}
return new Date(raw);
}
export function formatTime(iso: string): string {
const d = parseTimestamp(iso);
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
export function formatFullDate(iso: string): string {
return parseTimestamp(iso).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
/** Discord-style relative timestamp: "Today at 2:34 PM", "Yesterday at 2:34 PM",
* or "MM/DD/YYYY H:MM AM/PM" for older dates. */
export function formatMessageTimestamp(iso: string): string {
const date = parseTimestamp(iso);
const now = new Date();
const timeStr = date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterdayStart = new Date(todayStart.getTime() - 86_400_000);
if (date >= todayStart) {
return `Today at ${timeStr}`;
}
if (date >= yesterdayStart) {
return `Yesterday at ${timeStr}`;
}
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
const yyyy = date.getFullYear();
return `${mm}/${dd}/${yyyy} ${timeStr}`;
}
export function isSameDay(a: string, b: string): boolean {
const da = parseTimestamp(a);
const db = parseTimestamp(b);
return (
da.getFullYear() === db.getFullYear() &&
da.getMonth() === db.getMonth() &&
da.getDate() === db.getDate()
);
}
export function shouldGroup(prev: Message, curr: Message): boolean {
if (prev.user.id !== curr.user.id) return false;
if (prev.deleted || curr.deleted) return false;
const dt = parseTimestamp(curr.timestamp).getTime() - parseTimestamp(prev.timestamp).getTime();
return dt < GROUP_THRESHOLD_MS;
}
// -- Role helpers -------------------------------------------------------------
/** Cached value of the roleColors preference. Invalidated on pref change. */
let roleColorsEnabled = loadPref<boolean>("roleColors", true);
window.addEventListener("owncord:pref-change", ((e: CustomEvent<{ key: string }>) => {
if (e.detail.key === "roleColors") {
roleColorsEnabled = loadPref<boolean>("roleColors", true);
}
}) as EventListener);
export function getUserRole(userId: number): string {
return membersStore.getState().members.get(userId)?.role ?? "member";
}
export function roleColorVar(role: string): string {
if (!roleColorsEnabled) {
return "var(--role-member)";
}
switch (role) {
case "owner": return "var(--role-owner)";
case "admin": return "var(--role-admin)";
case "moderator": return "var(--role-mod)";
default: return "var(--role-member)";
}
}
@@ -0,0 +1,454 @@
/**
* Image and video rendering — YouTube embeds, direct image URLs,
* inline image rendering, lightbox overlay, and URL embed orchestration.
*/
import {
createElement,
setText,
appendChildren,
} from "@lib/dom";
import { createIcon } from "@lib/icons";
import { createLogger } from "@lib/logger";
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 { renderGenericLinkPreview } from "./embeds";
const log = createLogger("media");
/**
* Cache of rendered image heights keyed by URL. When virtual scroll rebuilds
* DOM elements, new images use the cached height as min-height instead of the
* generic 200px estimate. This prevents height oscillation (200px → actual →
* 200px → actual …) that causes infinite DOM rebuild loops.
*/
const imageHeightCache = new Map<string, number>();
const MAX_IMAGE_HEIGHT_CACHE = 500;
function cacheImageHeight(url: string, h: number): void {
if (imageHeightCache.size >= MAX_IMAGE_HEIGHT_CACHE) {
// Evict oldest entry (first inserted key)
const firstKey = imageHeightCache.keys().next().value;
if (firstKey !== undefined) imageHeightCache.delete(firstKey);
}
imageHeightCache.set(url, h);
}
/** Check if a URL points to an animated GIF. */
function isGifUrl(url: string): boolean {
try {
const pathname = new URL(url, "https://placeholder").pathname.toLowerCase();
return pathname.endsWith(".gif");
} catch {
return false;
}
}
// -- YouTube ------------------------------------------------------------------
/** Extract YouTube video ID from various YouTube URL formats. */
export function extractYouTubeId(url: string): string | null {
try {
const parsed = new URL(url);
// youtube.com/watch?v=ID
if (
(parsed.hostname === "www.youtube.com" || parsed.hostname === "youtube.com") &&
parsed.pathname === "/watch"
) {
return parsed.searchParams.get("v");
}
// youtu.be/ID
if (parsed.hostname === "youtu.be") {
const id = parsed.pathname.slice(1);
return id.length > 0 ? id : null;
}
// youtube.com/embed/ID
if (
(parsed.hostname === "www.youtube.com" || parsed.hostname === "youtube.com") &&
parsed.pathname.startsWith("/embed/")
) {
const id = parsed.pathname.slice(7);
return id.length > 0 ? id : null;
}
// youtube.com/shorts/ID
if (
(parsed.hostname === "www.youtube.com" || parsed.hostname === "youtube.com") &&
parsed.pathname.startsWith("/shorts/")
) {
const id = parsed.pathname.slice(8);
return id.length > 0 ? id : null;
}
} catch {
// Invalid URL
}
return null;
}
/** Cache for YouTube video titles to avoid re-fetching on every re-render. */
const ytTitleCache = new Map<string, string>();
/** Strict pattern for YouTube video IDs (alphanumeric, hyphens, underscores). */
const YOUTUBE_ID_RE = /^[\w-]{1,20}$/;
/** Render a YouTube embed player with title header. */
export function renderYouTubeEmbed(videoId: string, originalUrl: string): HTMLDivElement {
// Validate videoId to prevent injection into iframe src / img src.
if (!YOUTUBE_ID_RE.test(videoId)) {
const fallback = createElement("div", { class: "msg-embed" });
const link = createElement("a", { href: originalUrl, target: "_blank", rel: "noopener noreferrer" });
setText(link, originalUrl);
fallback.appendChild(link);
return fallback;
}
const wrap = createElement("div", { class: "msg-embed msg-embed-youtube" });
// Header: channel name + video title
const header = createElement("div", { class: "msg-embed-yt-header" });
const channelLabel = createElement("div", { class: "msg-embed-host" }, "YouTube");
const titleLink = createElement("a", {
class: "msg-embed-yt-title",
href: originalUrl,
target: "_blank",
rel: "noopener noreferrer",
});
const cached = ytTitleCache.get(videoId);
if (cached !== undefined) {
setText(titleLink, cached);
} else {
setText(titleLink, "Loading...");
const oembedUrl = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${encodeURIComponent(videoId)}&format=json`;
tauriFetch(oembedUrl, {
signal: AbortSignal.timeout(5000),
} as RequestInit)
.then((res) => (res.ok ? (res.json() as Promise<{ title?: string } | null>) : null))
.then((data) => {
const title = data?.title ?? "YouTube Video";
ytTitleCache.set(videoId, title);
setText(titleLink, title);
})
.catch(() => {
ytTitleCache.set(videoId, "YouTube Video");
setText(titleLink, "YouTube Video");
});
}
appendChildren(header, channelLabel, titleLink);
wrap.appendChild(header);
// Thumbnail container with play button overlay
const thumbWrap = createElement("div", { class: "msg-embed-yt-player" });
const thumbUrl = `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`;
const thumb = createElement("img", {
class: "msg-embed-thumb",
src: thumbUrl,
alt: "YouTube video",
loading: "lazy",
});
const playBtn = createElement("div", { class: "msg-embed-play" });
playBtn.appendChild(createIcon("play", 24));
appendChildren(thumbWrap, thumb, playBtn);
wrap.appendChild(thumbWrap);
// On click thumbnail, replace with iframe player
thumbWrap.addEventListener("click", () => {
const iframe = document.createElement("iframe");
iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1`;
iframe.setAttribute("allowfullscreen", "");
iframe.setAttribute("allow", "autoplay; encrypted-media");
iframe.className = "msg-embed-iframe";
thumbWrap.replaceChildren(iframe);
}, { once: true });
return wrap;
}
// -- Direct images ------------------------------------------------------------
/** Check if a URL points directly to an image or GIF file. */
export function isDirectImageUrl(url: string): boolean {
try {
const pathname = new URL(url).pathname.toLowerCase();
return /\.(gif|png|jpg|jpeg|webp)$/.test(pathname);
} catch {
return false;
}
}
/** Render a direct image/GIF URL as an inline image with lightbox. */
export function renderInlineImage(url: string): HTMLDivElement {
// Use cached height from a previous render if available, otherwise 200px.
// This prevents height oscillation when virtual scroll rebuilds DOM.
const cachedH = imageHeightCache.get(url);
const minH = cachedH ?? 200;
const wrap = createElement("div", {
class: "msg-image",
style: `max-width: 400px; min-height: ${minH}px;`,
});
const attrs: Record<string, string> = {
src: url,
alt: "Image",
style: "max-width: 100%; max-height: 350px; display: block; border-radius: 4px; cursor: pointer;",
};
// Enable CORS for GIFs so canvas capture works for freeze/unfreeze
if (isGifUrl(url)) {
attrs.crossorigin = "anonymous";
}
const img = createElement("img", attrs);
// On load: clear min-height reservation and cache the natural rendered
// height so future virtual-scroll rebuilds start at the correct size.
// Measure synchronously — deferring to rAF loses the race with
// ResizeObserver which can rebuild the DOM before the rAF fires.
img.addEventListener("load", () => {
log.info("Image loaded", { url: url.slice(0, 80), naturalW: (img as HTMLImageElement).naturalWidth, naturalH: (img as HTMLImageElement).naturalHeight });
wrap.style.minHeight = "";
const h = wrap.offsetHeight;
if (h > 0) cacheImageHeight(url, h);
log.debug("Image height cached", { url: url.slice(0, 80), h });
}, { once: true });
// On error: clear min-height so the wrapper collapses instead of
// holding a 200px empty reservation that can oscillate with virtual scroll.
img.addEventListener("error", () => {
log.error("Image failed to load", { url });
wrap.style.minHeight = "";
}, { once: true });
// Observe GIFs for visibility-based freeze/unfreeze + play/pause button.
// When the animateGifs pref is disabled, start frozen so the first frame is
// shown by default; the user can still click the play button to animate.
if (isGifUrl(url)) {
img.addEventListener("load", () => {
log.debug("Calling observeMedia for GIF", { url: url.slice(0, 80) });
const startFrozen = !loadPref("animateGifs", true);
observeMedia(img, url, wrap, startFrozen);
log.debug("observeMedia complete", { startFrozen });
}, { once: true });
}
img.addEventListener("click", () => {
openImageLightbox(url, "Image");
});
wrap.appendChild(img);
return wrap;
}
// -- Lightbox -----------------------------------------------------------------
// Store the cleanup function for the active lightbox so rapid reopens
// properly remove document-level listeners from the previous instance.
let activeLightboxClose: (() => void) | null = null;
/** 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)
if (activeLightboxClose !== null) {
activeLightboxClose();
activeLightboxClose = null;
}
const overlay = createElement("div", { class: "image-lightbox" });
const imgWrap = createElement("div", { class: "image-lightbox-wrap" });
const img = createElement("img", { src, alt }) as HTMLImageElement;
imgWrap.appendChild(img);
overlay.appendChild(imgWrap);
const closeBtn = createElement("button", { class: "image-lightbox-close" });
closeBtn.appendChild(createIcon("x", 20));
overlay.appendChild(closeBtn);
// Zoom & pan state
let scale = 1;
let panX = 0;
let panY = 0;
let isDragging = false;
let dragStartX = 0;
let dragStartY = 0;
let panStartX = 0;
let panStartY = 0;
function applyTransform(): void {
img.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
}
function resetZoom(): void {
scale = 1;
panX = 0;
panY = 0;
applyTransform();
}
function onMove(e: MouseEvent): void {
if (!isDragging) return;
panX = panStartX + (e.clientX - dragStartX);
panY = panStartY + (e.clientY - dragStartY);
applyTransform();
}
function onUp(): void {
if (isDragging) {
isDragging = false;
overlay.classList.remove("dragging");
}
}
function close(): void {
overlay.remove();
document.removeEventListener("keydown", onKey);
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
if (activeLightboxClose === close) activeLightboxClose = null;
}
// Mouse wheel zoom
imgWrap.addEventListener("wheel", (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.15 : 0.15;
const newScale = Math.max(0.5, Math.min(10, scale + delta * scale));
// Zoom towards cursor position
const rect = img.getBoundingClientRect();
const cx = e.clientX - rect.left - rect.width / 2;
const cy = e.clientY - rect.top - rect.height / 2;
const factor = newScale / scale;
panX = panX - cx * (factor - 1);
panY = panY - cy * (factor - 1);
scale = newScale;
applyTransform();
});
// Single click to toggle zoom, with drag detection to avoid zoom on pan
let clickStartX = 0;
let clickStartY = 0;
img.addEventListener("mousedown", (e) => {
e.preventDefault();
clickStartX = e.clientX;
clickStartY = e.clientY;
if (scale > 1.1) {
// Zoomed in — start panning
isDragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
panStartX = panX;
panStartY = panY;
overlay.classList.add("dragging");
}
});
img.addEventListener("click", (e) => {
e.stopPropagation();
// Only toggle zoom if mouse didn't move (not a pan gesture)
const dx = Math.abs(e.clientX - clickStartX);
const dy = Math.abs(e.clientY - clickStartY);
if (dx > 5 || dy > 5) return;
if (scale > 1.1) {
resetZoom();
} else {
// Zoom to 3x towards click position
const rect = img.getBoundingClientRect();
const cx = e.clientX - rect.left - rect.width / 2;
const cy = e.clientY - rect.top - rect.height / 2;
scale = 3;
panX = -cx * 2;
panY = -cy * 2;
applyTransform();
}
});
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
close();
});
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close();
});
function onKey(e: KeyboardEvent): void {
if (e.key === "Escape") close();
if (e.key === "+" || e.key === "=") {
scale = Math.min(10, scale * 1.3);
applyTransform();
}
if (e.key === "-") {
scale = Math.max(0.5, scale / 1.3);
applyTransform();
}
if (e.key === "0") resetZoom();
}
document.addEventListener("keydown", onKey);
activeLightboxClose = close;
document.body.appendChild(overlay);
}
// -- URL extraction and embed orchestration -----------------------------------
/** 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, "");
const matches = withoutCodeBlocks.match(URL_REGEX);
return matches ?? [];
}
/** Render URL embeds (YouTube players, generic link previews). */
export function renderUrlEmbeds(content: string): DocumentFragment {
const fragment = document.createDocumentFragment();
const urls = extractUrls(content);
log.debug("renderUrlEmbeds", { urlCount: urls.length, urls });
const seen = new Set<string>();
// Read preferences once before the loop to avoid per-URL localStorage reads
const showEmbeds = loadPref("showEmbeds", true);
const inlineMedia = loadPref("inlineMedia", true);
const showLinkPreviews = loadPref("showLinkPreviews", true);
for (const url of urls) {
if (seen.has(url)) continue;
seen.add(url);
// YouTube embed
const ytId = extractYouTubeId(url);
if (ytId !== null) {
if (!showEmbeds) continue;
fragment.appendChild(renderYouTubeEmbed(ytId, url));
continue;
}
// Direct image/GIF URL — render inline
const isDirect = isDirectImageUrl(url);
const isSafe = isSafeUrl(url);
log.debug("URL classification", { url: url.slice(0, 80), isDirect, isSafe, isGif: isGifUrl(url) });
if (isDirect && isSafe) {
if (!inlineMedia) continue;
fragment.appendChild(renderInlineImage(url));
continue;
}
// Generic URL preview (compact link card)
if (isSafe) {
if (!showLinkPreviews) continue;
log.debug("Falling through to generic link preview", { url: url.slice(0, 80) });
fragment.appendChild(renderGenericLinkPreview(url));
}
}
log.debug("renderUrlEmbeds complete");
return fragment;
}
@@ -0,0 +1,32 @@
/**
* Reaction pill rendering — emoji reaction chips with counts and toggle behavior.
*/
import { createElement } from "@lib/dom";
import type { Message } from "@stores/messages.store";
import type { MessageListOptions } from "../MessageList";
// -- Reaction rendering -------------------------------------------------------
export function renderReactions(
msg: Message,
opts: MessageListOptions,
signal: AbortSignal,
): HTMLDivElement {
const container = createElement("div", { class: "msg-reactions" });
for (const reaction of msg.reactions) {
const chip = createElement("span", {
class: reaction.me ? "reaction-chip me" : "reaction-chip",
});
const 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 });
container.appendChild(chip);
}
const addBtn = createElement("span", { class: "reaction-chip add-reaction" }, "+");
addBtn.addEventListener("click", () => opts.onReactionClick(msg.id, ""), { signal });
container.appendChild(addBtn);
return container;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,86 @@
/**
* Accessibility settings tab — reduced motion, high contrast, role colors, OS motion sync, large font.
*/
import { createElement, appendChildren } from "@lib/dom";
import { loadPref, savePref, createToggle } from "./helpers";
import { syncOsMotionListener } from "@lib/os-motion";
type ToggleItem = {
readonly key: string;
readonly label: string;
readonly desc: string;
readonly fallback: boolean;
readonly sideEffect?: (nowOn: boolean) => void;
};
const TOGGLES: ReadonlyArray<ToggleItem> = [
{
key: "reducedMotion",
label: "Reduce Motion",
desc: "Disable animations and transitions",
fallback: false,
sideEffect: (nowOn) => {
document.documentElement.classList.toggle("reduced-motion", nowOn);
},
},
{
key: "highContrast",
label: "High Contrast",
desc: "Increase contrast for better readability",
fallback: false,
sideEffect: (nowOn) => {
document.documentElement.classList.toggle("high-contrast", nowOn);
},
},
{
key: "roleColors",
label: "Role Colors",
desc: "Show colored usernames based on role in chat",
fallback: true,
},
{
key: "syncOsMotion",
label: "Sync with OS",
desc: "Automatically enable reduced motion based on your OS accessibility settings",
fallback: false,
sideEffect: (nowOn) => { syncOsMotionListener(nowOn); },
},
{
key: "largeFont",
label: "Large Font",
desc: "Use larger text throughout the app for better readability",
fallback: false,
sideEffect: (nowOn) => {
document.documentElement.classList.toggle("large-font", nowOn);
},
},
];
export function buildAccessibilityTab(signal: AbortSignal): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
for (const item of TOGGLES) {
const row = createElement("div", { class: "setting-row" });
const info = createElement("div", {});
const label = createElement("div", { class: "setting-label" }, item.label);
const desc = createElement("div", { class: "setting-desc" }, item.desc);
appendChildren(info, label, desc);
const isOn = loadPref<boolean>(item.key, item.fallback);
const toggle = createToggle(isOn, {
signal,
onChange: (nowOn) => {
savePref(item.key, nowOn);
if (item.sideEffect !== undefined) {
item.sideEffect(nowOn);
}
},
});
appendChildren(row, info, toggle);
section.appendChild(row);
}
return section;
}
@@ -1,78 +1,93 @@
/**
* Account settings tab — profile editing, password change, logout.
* Account settings tab — profile editing, password change.
* Discord-style profile card with colored banner, overlapping avatar,
* and separated field rows.
*/
import { createElement, appendChildren, setText } from "@lib/dom";
import type { UserStatus } from "@lib/types";
import { authStore } from "@stores/auth.store";
import type { SettingsOverlayOptions } from "../SettingsOverlay";
import { loadPref, savePref } from "./helpers";
export function buildAccountTab(
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ProfileCardResult {
readonly card: HTMLDivElement;
readonly headerName: HTMLDivElement;
readonly usernameValue: HTMLDivElement;
readonly editUserProfileBtn: HTMLButtonElement;
readonly editUsernameBtn: HTMLButtonElement;
}
// ---------------------------------------------------------------------------
// Profile card builder
// ---------------------------------------------------------------------------
function buildProfileCard(username: string): ProfileCardResult {
const card = createElement("div", { class: "account-card" });
const banner = createElement("div", { class: "account-banner" });
// Avatar overlapping the banner
const avatarWrap = createElement("div", { class: "account-avatar-wrap" });
const avatarLarge = createElement("div", { class: "account-avatar-large" },
username.charAt(0).toUpperCase(),
);
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 editUserProfileBtn = createElement("button", { class: "ac-btn" }, "Edit User Profile");
appendChildren(accountHeader, headerName, editUserProfileBtn);
// Username field row
const fieldsContainer = createElement("div", { class: "account-fields" });
const usernameField = createElement("div", { class: "account-field" });
const usernameLeft = createElement("div", {});
const usernameLabel = createElement("div", { class: "account-field-label" }, "Username");
const usernameValue = createElement("div", { class: "account-field-value" }, username);
appendChildren(usernameLeft, usernameLabel, usernameValue);
const editUsernameBtn = createElement("button", { class: "account-field-edit" }, "Edit");
appendChildren(usernameField, usernameLeft, editUsernameBtn);
fieldsContainer.appendChild(usernameField);
appendChildren(card, banner, avatarWrap, accountHeader, fieldsContainer);
return { card, headerName, usernameValue, editUserProfileBtn, editUsernameBtn };
}
// ---------------------------------------------------------------------------
// Password section builder
// ---------------------------------------------------------------------------
function buildPasswordSection(
options: SettingsOverlayOptions,
signal: AbortSignal,
): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
const user = authStore.getState().user;
const wrapper = createElement("div", {});
// Account card
const accountCard = createElement("div", { class: "account-card" });
const acAvatar = createElement("div", {
class: "ac-avatar",
style: "background: var(--accent)",
}, (user?.username ?? "U").charAt(0).toUpperCase());
const acInfo = createElement("div", {});
const acName = createElement("div", { class: "ac-name" }, user?.username ?? "Unknown");
const acId = createElement("div", { class: "ac-id" }, `ID: ${user?.id ?? "?"}`);
appendChildren(acInfo, acName, acId);
const editBtn = createElement("button", { class: "ac-btn" }, "Edit Profile");
appendChildren(accountCard, acAvatar, acInfo, editBtn);
section.appendChild(accountCard);
const separator = createElement("div", { class: "settings-separator" });
const pwHeader = createElement("div", { class: "settings-section-title" }, "Password and Authentication");
const editForm = createElement("div", { class: "setting-row", style: "display:none" });
const editInput = createElement("input", { class: "form-input", type: "text", placeholder: "New username" });
const saveBtn = createElement("button", { class: "ac-btn" }, "Save");
const cancelBtn = createElement("button", { class: "ac-btn", style: "background:var(--bg-active)" }, "Cancel");
const usernameValue = acName;
appendChildren(editForm, editInput, saveBtn, cancelBtn);
editBtn.addEventListener("click", () => {
editForm.style.display = "flex";
editInput.value = user?.username ?? "";
editInput.focus();
}, { signal });
cancelBtn.addEventListener("click", () => {
editForm.style.display = "none";
}, { signal });
const usernameError = createElement("div", { style: "color:var(--red);font-size:13px;margin-top:4px" });
editForm.appendChild(usernameError);
const MAX_USERNAME_LEN = 32;
saveBtn.addEventListener("click", () => {
const newName = editInput.value.trim();
if (newName.length === 0 || newName.length > MAX_USERNAME_LEN) {
setText(usernameError, `Username must be 1\u2013${MAX_USERNAME_LEN} characters.`);
return;
}
setText(usernameError, "");
void options.onUpdateProfile(newName).then(() => {
setText(usernameValue, newName);
editForm.style.display = "none";
}).catch((err: unknown) => {
setText(usernameError, err instanceof Error ? err.message : "Failed to update username.");
});
}, { signal });
section.appendChild(editForm);
// Change password
const pwHeader = createElement("h3", {}, "Change Password");
const oldPw = createElement("input", { class: "form-input", type: "password", placeholder: "Old password", style: "margin-bottom:8px" });
const newPw = createElement("input", { class: "form-input", type: "password", placeholder: "New password", style: "margin-bottom:8px" });
const confirmPw = createElement("input", { class: "form-input", type: "password", placeholder: "Confirm new password", style: "margin-bottom:8px" });
const oldPw = createElement("input", {
class: "form-input", type: "password",
placeholder: "Old password", style: "margin-bottom:12px",
});
const newPw = createElement("input", {
class: "form-input", type: "password",
placeholder: "New password", style: "margin-bottom:12px",
});
const confirmPw = createElement("input", {
class: "form-input", type: "password",
placeholder: "Confirm new password", style: "margin-bottom:12px",
});
const pwError = createElement("div", { style: "color:var(--red);font-size:13px;margin-bottom:8px" });
const pwBtn = createElement("button", { class: "ac-btn" }, "Change Password");
let pwSuccessTimer: ReturnType<typeof setTimeout> | null = null;
pwBtn.addEventListener("click", () => {
const oldVal = oldPw.value;
@@ -92,23 +107,260 @@ export function buildAccountTab(
oldPw.value = "";
newPw.value = "";
confirmPw.value = "";
if (pwSuccessTimer !== null) clearTimeout(pwSuccessTimer);
pwError.style.color = "var(--green)";
setText(pwError, "Password changed successfully.");
setTimeout(() => { setText(pwError, ""); pwError.style.color = "var(--red)"; }, 3000);
pwSuccessTimer = setTimeout(() => {
setText(pwError, "");
pwError.style.color = "var(--red)";
pwSuccessTimer = null;
}, 3000);
}).catch((err: unknown) => {
setText(pwError, err instanceof Error ? err.message : "Failed to change password.");
});
}, { signal });
appendChildren(section, pwHeader, oldPw, newPw, confirmPw, pwError, pwBtn);
appendChildren(wrapper, separator, pwHeader, oldPw, newPw, confirmPw, pwError, pwBtn);
return wrapper;
}
// Logout
const logoutBtn = createElement("button", {
class: "settings-nav-item danger",
style: "margin-top:16px;width:auto;padding:8px 16px",
}, "Log Out");
logoutBtn.addEventListener("click", () => options.onLogout(), { signal });
section.appendChild(logoutBtn);
// ---------------------------------------------------------------------------
// Status selector builder
// ---------------------------------------------------------------------------
interface StatusOption {
readonly value: UserStatus;
readonly label: string;
readonly description: string;
readonly color: string;
}
const STATUS_OPTIONS: readonly StatusOption[] = [
{ value: "online", label: "Online", description: "", color: "#3ba55d" },
{ value: "idle", label: "Idle", description: "You will appear as idle", color: "#faa61a" },
{ value: "dnd", label: "Do Not Disturb", description: "You will not receive desktop notifications", color: "#ed4245" },
{ value: "offline", label: "Invisible", description: "You will appear offline but still have full access", color: "#747f8d" },
];
function buildStatusSelector(
options: SettingsOverlayOptions,
signal: AbortSignal,
): HTMLDivElement {
const wrapper = createElement("div", {});
const separator = createElement("div", { class: "settings-separator" });
const sectionTitle = createElement("div", { class: "settings-section-title" }, "Status");
const optionsList = createElement("div", { class: "settings-status-options" });
const currentStatus = loadPref<UserStatus>("userStatus", "online");
const rowElements = new Map<UserStatus, HTMLDivElement>();
for (const opt of STATUS_OPTIONS) {
const row = createElement("div", {
class: `settings-status-option${opt.value === currentStatus ? " active" : ""}`,
});
const dot = createElement("div", { class: "settings-status-dot" });
dot.style.background = opt.color;
const labelWrap = createElement("div", {});
const labelEl = createElement("div", { class: "settings-status-label" }, opt.label);
appendChildren(labelWrap, labelEl);
if (opt.description.length > 0) {
const descEl = createElement("div", { class: "settings-status-desc" }, opt.description);
labelWrap.appendChild(descEl);
}
appendChildren(row, dot, labelWrap);
row.addEventListener("click", () => {
for (const [, el] of rowElements) {
el.classList.remove("active");
}
row.classList.add("active");
savePref("userStatus", opt.value);
options.onStatusChange(opt.value);
}, { signal });
rowElements.set(opt.value, row);
optionsList.appendChild(row);
}
appendChildren(wrapper, separator, sectionTitle, optionsList);
return wrapper;
}
// ---------------------------------------------------------------------------
// Delete account (danger zone) builder
// ---------------------------------------------------------------------------
function buildDeleteAccountSection(
options: SettingsOverlayOptions,
signal: AbortSignal,
): HTMLDivElement {
const wrapper = createElement("div", {});
const separator = createElement("div", { class: "settings-separator" });
const header = createElement("div", {
class: "settings-section-title",
style: "color:var(--red)",
}, "Danger Zone");
const description = createElement("div", {
style: "color:var(--text-muted);font-size:13px;margin-bottom:12px",
}, "Permanently delete your account and all associated data.");
const deleteBtn = createElement("button", {
class: "ac-btn account-delete-btn",
"data-testid": "delete-account-trigger",
}, "Delete Account");
// Inline confirmation area (hidden by default)
const confirmArea = createElement("div", {
class: "account-delete-confirm",
style: "display:none",
"data-testid": "delete-account-confirm-area",
});
const warningText = createElement("div", {
style: "color:var(--red);font-size:13px;margin-bottom:12px;line-height:1.4",
}, "This action is permanent and cannot be undone. All your data will be deleted. Enter your password to confirm.");
const passwordInput = createElement("input", {
class: "form-input",
type: "password",
placeholder: "Enter your password",
style: "margin-bottom:12px",
"data-testid": "delete-account-password",
});
const errorEl = createElement("div", {
style: "color:var(--red);font-size:13px;margin-bottom:8px",
"data-testid": "delete-account-error",
});
const btnRow = createElement("div", { style: "display:flex;gap:8px" });
const confirmBtn = createElement("button", {
class: "ac-btn account-delete-btn",
"data-testid": "delete-account-confirm",
}, "Confirm Delete");
const cancelBtn = createElement("button", {
class: "ac-btn",
style: "background:var(--bg-active)",
}, "Cancel");
appendChildren(btnRow, confirmBtn, cancelBtn);
appendChildren(confirmArea, warningText, passwordInput, errorEl, btnRow);
// Show confirmation area
deleteBtn.addEventListener("click", () => {
deleteBtn.style.display = "none";
confirmArea.style.display = "block";
passwordInput.value = "";
setText(errorEl, "");
passwordInput.focus();
}, { signal });
// Cancel — hide confirmation
cancelBtn.addEventListener("click", () => {
confirmArea.style.display = "none";
deleteBtn.style.display = "";
passwordInput.value = "";
setText(errorEl, "");
}, { signal });
// Confirm delete
confirmBtn.addEventListener("click", () => {
const pw = passwordInput.value;
if (pw.length === 0) {
setText(errorEl, "Password is required.");
return;
}
setText(errorEl, "");
confirmBtn.disabled = true;
setText(confirmBtn, "Deleting...");
void options.onDeleteAccount(pw).then(() => {
// Success — cleanup is handled by the callback (clears auth, navigates away)
}).catch((err: unknown) => {
setText(errorEl, err instanceof Error ? err.message : "Failed to delete account.");
confirmBtn.disabled = false;
setText(confirmBtn, "Confirm Delete");
});
}, { signal });
appendChildren(wrapper, separator, header, description, deleteBtn, confirmArea);
return wrapper;
}
// ---------------------------------------------------------------------------
// Main tab builder
// ---------------------------------------------------------------------------
const MAX_USERNAME_LEN = 32;
export function buildAccountTab(
options: SettingsOverlayOptions,
signal: AbortSignal,
): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
const user = authStore.getState().user;
const username = user?.username ?? "Unknown";
// Profile card
const { card, headerName, usernameValue, editUserProfileBtn, editUsernameBtn } =
buildProfileCard(username);
section.appendChild(card);
// Status selector
section.appendChild(buildStatusSelector(options, signal));
// Inline edit form
const editForm = createElement("div", { class: "setting-row", style: "display:none;margin-bottom:16px" });
const editInput = createElement("input", { class: "form-input", type: "text", placeholder: "New username" });
const saveBtn = createElement("button", { class: "ac-btn" }, "Save");
const cancelBtn = createElement("button", { class: "ac-btn", style: "background:var(--bg-active)" }, "Cancel");
appendChildren(editForm, editInput, saveBtn, cancelBtn);
const usernameError = createElement("div", { style: "color:var(--red);font-size:13px;margin-top:4px" });
editForm.appendChild(usernameError);
const openEditForm = () => {
editForm.style.display = "flex";
editInput.value = authStore.getState().user?.username ?? "";
editInput.focus();
};
editUserProfileBtn.addEventListener("click", openEditForm, { signal });
editUsernameBtn.addEventListener("click", openEditForm, { signal });
cancelBtn.addEventListener("click", () => {
editForm.style.display = "none";
setText(usernameError, "");
}, { signal });
saveBtn.addEventListener("click", () => {
const newName = editInput.value.trim();
if (newName.length === 0 || newName.length > MAX_USERNAME_LEN) {
setText(usernameError, `Username must be 1\u2013${MAX_USERNAME_LEN} characters.`);
return;
}
setText(usernameError, "");
void options.onUpdateProfile(newName).then(() => {
setText(headerName, newName);
setText(usernameValue, newName);
editForm.style.display = "none";
}).catch((err: unknown) => {
setText(usernameError, err instanceof Error ? err.message : "Failed to update username.");
});
}, { signal });
section.appendChild(editForm);
// Password section
section.appendChild(buildPasswordSection(options, signal));
// Delete account (danger zone)
section.appendChild(buildDeleteAccountSection(options, signal));
return section;
}
@@ -0,0 +1,74 @@
/**
* Advanced settings tab — developer mode, hardware acceleration, and debug tools.
*/
import { createElement, appendChildren } from "@lib/dom";
import { invoke } from "@tauri-apps/api/core";
import { loadPref, savePref, createToggle } from "./helpers";
export function buildAdvancedTab(signal: AbortSignal): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
// ---- Toggles ---------------------------------------------------------------
const toggles: ReadonlyArray<{ key: string; label: string; desc: string; fallback: boolean }> = [
{
key: "developerMode",
label: "Developer Mode",
desc: "Show message IDs, user IDs, and channel IDs on context menus",
fallback: false,
},
{
key: "hardwareAcceleration",
label: "Hardware Acceleration",
desc: "Use GPU for rendering. Requires restart to take effect",
fallback: true,
},
];
for (const item of toggles) {
const row = createElement("div", { class: "setting-row" });
const info = createElement("div", {});
const label = createElement("div", { class: "setting-label" }, item.label);
const desc = createElement("div", { class: "setting-desc" }, item.desc);
appendChildren(info, label, desc);
const isOn = loadPref<boolean>(item.key, item.fallback);
const toggle = createToggle(isOn, {
signal,
onChange: (nowOn) => { savePref(item.key, nowOn); },
});
appendChildren(row, info, toggle);
section.appendChild(row);
}
// ---- Separator -------------------------------------------------------------
const sep = createElement("div", { class: "settings-separator" });
section.appendChild(sep);
// ---- Debug section ---------------------------------------------------------
const debugTitle = createElement("div", { class: "settings-section-title" }, "Debug");
section.appendChild(debugTitle);
// DevTools button row
const devtoolsRow = createElement("div", { class: "setting-row" });
const devtoolsInfo = createElement("div", {});
const devtoolsLabel = createElement("div", { class: "setting-label" }, "Open DevTools");
const devtoolsDesc = createElement("div", { class: "setting-desc" }, "Open the browser developer tools for debugging");
appendChildren(devtoolsInfo, devtoolsLabel, devtoolsDesc);
const devtoolsBtn = createElement("button", { class: "ac-btn" }, "Open DevTools");
devtoolsBtn.addEventListener("click", () => {
void invoke("open_devtools").catch((err: unknown) => {
console.warn("DevTools not available:", err);
});
}, { signal });
appendChildren(devtoolsRow, devtoolsInfo, devtoolsBtn);
section.appendChild(devtoolsRow);
return section;
}
@@ -9,7 +9,7 @@ import { setTheme } from "@stores/ui.store";
export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
const currentTheme = loadPref<ThemeName>("theme", "dark");
const currentTheme = loadPref<ThemeName>("theme", "neon-glow");
const currentFontSize = loadPref<number>("fontSize", 16);
const currentCompact = loadPref<boolean>("compactMode", false);
@@ -67,10 +67,119 @@ export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement {
appendChildren(compactRow, compactLabel, compactToggle);
section.appendChild(compactRow);
// Accent color picker
const ACCENT_PRESETS: readonly string[] = [
"#5865f2", // Discord blurple (default)
"#57f287", // green
"#fee75c", // yellow
"#eb459e", // fuchsia/pink
"#ed4245", // red
"#f47b67", // salmon
"#e78b38", // orange
"#3ba55d", // dark green
"#45ddff", // cyan
"#b9bbbe", // grey
];
const currentAccent = loadPref<string>("accentColor", "#00c8ff");
function applyAccent(color: string): void {
// Set on both documentElement and body so the accent wins over
// theme class specificity (body.theme-neon-glow sets --accent)
document.documentElement.style.setProperty("--accent", color);
document.body.style.setProperty("--accent", color);
}
function saveAccent(color: string): void {
savePref("accentColor", color);
applyAccent(color);
}
const accentHeader = createElement("h3", {}, "Accent Color");
const swatchesRow = createElement("div", { class: "accent-swatches" });
// Declare hexInput early so swatch closures can reference it after construction
const hexInputRow = createElement("div", { class: "accent-hex-row" });
const hexPrefix = createElement("span", { class: "accent-hex-prefix" }, "#");
const hexInput = createElement("input", {
class: "form-input",
type: "text",
maxlength: "6",
placeholder: "5865f2",
value: currentAccent.replace("#", ""),
style: "width:120px",
}) as HTMLInputElement;
for (const color of ACCENT_PRESETS) {
const swatch = createElement("div", {
class: `accent-swatch${color === currentAccent ? " active" : ""}`,
title: color,
role: "radio",
tabindex: "0",
"aria-label": color,
"aria-checked": color === currentAccent ? "true" : "false",
});
swatch.style.backgroundColor = color;
// Setting color = backgroundColor lets .active use currentColor in box-shadow
swatch.style.color = color;
const activateSwatch = (): void => {
saveAccent(color);
// Update active state on all swatches
for (const child of swatchesRow.children) {
child.classList.remove("active");
child.setAttribute("aria-checked", "false");
}
swatch.classList.add("active");
swatch.setAttribute("aria-checked", "true");
hexInput.value = color.replace("#", "");
};
swatch.addEventListener("click", activateSwatch, { signal });
swatch.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
activateSwatch();
}
}, { signal });
swatchesRow.appendChild(swatch);
}
hexInput.addEventListener("input", () => {
const raw = hexInput.value.replace(/[^0-9a-fA-F]/g, "").slice(0, 6);
hexInput.value = raw;
if (raw.length === 6) {
const color = `#${raw}`;
saveAccent(color);
// Clear active preset swatches since it's a custom color
for (const child of swatchesRow.children) {
const isMatch = (child as HTMLElement).style.backgroundColor === hexToRgb(color);
child.classList.toggle("active", isMatch);
child.setAttribute("aria-checked", isMatch ? "true" : "false");
}
}
}, { signal });
appendChildren(hexInputRow, hexPrefix, hexInput);
appendChildren(section, accentHeader, swatchesRow, hexInputRow);
// Apply stored preferences on render
applyTheme(currentTheme);
document.documentElement.style.setProperty("--font-size", `${currentFontSize}px`);
document.documentElement.classList.toggle("compact-mode", currentCompact);
applyAccent(currentAccent);
return section;
}
/**
* Convert a hex color string to the CSS rgb() format browsers use for
* element.style.backgroundColor comparisons (e.g. "#5865f2" → "rgb(88, 101, 242)").
*/
function hexToRgb(hex: string): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgb(${r}, ${g}, ${b})`;
}
@@ -9,8 +9,6 @@ import { updatePttKey, captureKeyPress, vkName } from "@lib/ptt";
export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
const header = createElement("h1", {}, "Keybinds");
section.appendChild(header);
// ── Push to Talk ──────────────────────────────────────────
const pttRow = createElement("div", { class: "keybind-row" });
@@ -76,12 +74,70 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
}, "PTT works globally and does not hijack the key \u2014 you can still type and use other apps normally. Mouse buttons (Mouse 4/5) also work.");
section.appendChild(pttHint);
// ── Quick Switcher ────────────────────────────────────────
const searchRow = createElement("div", { class: "keybind-row" });
const searchLabel = createElement("span", { class: "setting-label" }, "Quick Switcher");
const searchValue = createElement("span", { class: "kbd" }, "Ctrl + K");
appendChildren(searchRow, searchLabel, searchValue);
section.appendChild(searchRow);
// ── Navigation section ────────────────────────────────────
section.appendChild(createElement("div", { class: "settings-separator" }));
const navHeader = createElement("div", {
class: "keybind-section-header",
}, "Navigation");
section.appendChild(navHeader);
const navBinds: [string, string][] = [
["Quick Switcher", "Ctrl + K"],
["Mark as Read", "Escape"],
["Search Messages", "Ctrl + F"],
];
for (const [label, shortcut] of navBinds) {
const row = createElement("div", { class: "keybind-row" });
appendChildren(row,
createElement("span", { class: "setting-label" }, label),
createElement("span", { class: "kbd" }, shortcut),
);
section.appendChild(row);
}
// ── Communication section ──────────────────────────────────
section.appendChild(createElement("div", { class: "settings-separator" }));
const commHeader = createElement("div", {
class: "keybind-section-header",
}, "Communication");
section.appendChild(commHeader);
const commBinds: [string, string][] = [
["Toggle Mute", "Ctrl + M"],
["Toggle Deafen", "Ctrl + D"],
["Toggle Camera", "Ctrl + Shift + V"],
];
for (const [label, shortcut] of commBinds) {
const row = createElement("div", { class: "keybind-row" });
appendChildren(row,
createElement("span", { class: "setting-label" }, label),
createElement("span", { class: "kbd" }, shortcut),
);
section.appendChild(row);
}
// ── Messages section ───────────────────────────────────────
section.appendChild(createElement("div", { class: "settings-separator" }));
const msgHeader = createElement("div", {
class: "keybind-section-header",
}, "Messages");
section.appendChild(msgHeader);
const msgBinds: [string, string][] = [
["Upload File", "Ctrl + U"],
["Edit Last Message", "Arrow Up"],
];
for (const [label, shortcut] of msgBinds) {
const row = createElement("div", { class: "keybind-row" });
appendChildren(row,
createElement("span", { class: "setting-label" }, label),
createElement("span", { class: "kbd" }, shortcut),
);
section.appendChild(row);
}
return section;
}
@@ -6,7 +6,7 @@ import { createElement, appendChildren, clearChildren } from "@lib/dom";
import { getLogBuffer, clearLogBuffer, addLogListener, setLogLevel } from "@lib/logger";
import type { LogEntry, LogLevel } from "@lib/logger";
import type { TabName } from "../SettingsOverlay";
import { getSessionDebugInfo, measureStreamLevel, getRemoteStreams, getLocalProcessedStream } from "@lib/voiceSession";
import { getSessionDebugInfo } from "@lib/livekitSession";
// ---------------------------------------------------------------------------
// Constants
@@ -61,7 +61,7 @@ export function createLogsTab(
signal: AbortSignal,
): LogsTabHandle {
let logListEl: HTMLDivElement | null = null;
let logFilterLevel: LogLevel | "all" = "all";
let logFilterLevel: LogLevel | "all" = (localStorage.getItem("logs_filter_level") as LogLevel | "all") ?? "all";
let unsubLogListener: (() => void) | null = null;
function renderLogEntries(): void {
@@ -80,8 +80,6 @@ export function createLogsTab(
function build(): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
const header = createElement("h1", {}, "Logs");
section.appendChild(header);
// Version display
const versionEl = createElement("div", {
@@ -108,8 +106,10 @@ export function createLogsTab(
if (lvl === logFilterLevel) opt.setAttribute("selected", "");
filterSelect.appendChild(opt);
}
filterSelect.value = logFilterLevel;
filterSelect.addEventListener("change", () => {
logFilterLevel = filterSelect.value as LogLevel | "all";
localStorage.setItem("logs_filter_level", logFilterLevel);
renderLogEntries();
}, { signal });
@@ -123,8 +123,15 @@ export function createLogsTab(
const opt = createElement("option", { value: lvl }, lvl.toUpperCase());
levelSelect.appendChild(opt);
}
const savedMinLevel = localStorage.getItem("logs_min_level") as LogLevel | null;
if (savedMinLevel !== null) {
levelSelect.value = savedMinLevel;
setLogLevel(savedMinLevel);
}
levelSelect.addEventListener("change", () => {
setLogLevel(levelSelect.value as LogLevel);
const level = levelSelect.value as LogLevel;
setLogLevel(level);
localStorage.setItem("logs_min_level", level);
}, { signal });
// Copy All button
@@ -175,53 +182,7 @@ export function createLogsTab(
function refreshDiag(): void {
const info = getSessionDebugInfo();
const ctx = info.sharedAudioCtx as { state: string; sampleRate: number } | null;
const localTracks = info.localTracks as Array<{ id: string; enabled: boolean; muted: boolean; readyState: string }>;
const remoteEls = info.remoteAudioElements as Array<{
streamId: string; userId: number; audioPaused: boolean; audioMuted: boolean;
audioVolume: number; audioReadyState: number; hasSrcObject: boolean;
gainValue: number | string;
tracks: Array<{ id: string; enabled: boolean; muted: boolean; readyState: string }>;
}>;
const webrtcStreams = info.webrtcRemoteStreams as Array<{
streamId: string; trackCount: number;
audioTracks: Array<{ id: string; enabled: boolean; muted: boolean; readyState: string }>;
}>;
const lines: string[] = [
`=== Session ===`,
`WebRTC: ${info.hasWebrtc} VAD: ${info.hasVad} Suppressor: ${info.hasNoiseSuppressor}`,
`Join in progress: ${info.joinInProgress} Silence suppression: ${info.silenceSuppressionEnabled}`,
`SharedAudioCtx: ${ctx ? `${ctx.state} @ ${ctx.sampleRate}Hz` : "none"}`,
``,
`=== Local Audio ===`,
`Stream: ${info.hasLocalStream} Processed: ${info.hasProcessedStream}`,
];
for (const t of localTracks) {
lines.push(` Track ${t.id.slice(0, 8)}: enabled=${t.enabled} muted=${t.muted} state=${t.readyState}`);
}
lines.push(``, `=== WebRTC Remote Streams ===`);
if (webrtcStreams.length === 0) lines.push(` (none)`);
for (const s of webrtcStreams) {
lines.push(` Stream ${s.streamId}: ${s.trackCount} tracks`);
for (const t of s.audioTracks) {
lines.push(` Track ${t.id.slice(0, 8)}: enabled=${t.enabled} muted=${t.muted} state=${t.readyState}`);
}
}
lines.push(``, `=== Remote Audio Elements ===`);
if (remoteEls.length === 0) lines.push(` (none)`);
for (const el of remoteEls) {
lines.push(` [user ${el.userId}] stream=${el.streamId}`);
lines.push(` <audio> paused=${el.audioPaused} muted=${el.audioMuted} volume=${el.audioVolume} readyState=${el.audioReadyState} srcObject=${el.hasSrcObject}`);
lines.push(` GainNode: ${typeof el.gainValue === "number" ? el.gainValue.toFixed(2) : el.gainValue}`);
for (const t of el.tracks) {
lines.push(` Track ${t.id.slice(0, 8)}: enabled=${t.enabled} muted=${t.muted} state=${t.readyState}`);
}
}
diagPanel.textContent = lines.join("\n");
diagPanel.textContent = JSON.stringify(info, null, 2);
}
refreshDiag();
@@ -236,100 +197,10 @@ export function createLogsTab(
});
}, { signal });
// Live audio level probe — measures actual signal flowing through streams
const levelBtn = createElement("button", { class: "ac-btn", style: "margin: 6px 0 0 6px;" }, "Probe Audio Levels");
const levelResult = createElement("pre", {
style: "margin: 6px 0 0 0; color: #ccc; font-family: monospace; font-size: 12px; white-space: pre-wrap;",
});
levelBtn.addEventListener("click", () => {
levelBtn.textContent = "Probing...";
levelResult.textContent = "";
const info = getSessionDebugInfo();
const promises: Array<Promise<string>> = [];
// 1. Probe local mic (what we're sending)
const localStream = getLocalProcessedStream();
if (localStream) {
promises.push(
measureStreamLevel(localStream).then((lvl) => `Local mic (outgoing): level=${lvl} ${lvl > 0 ? "✅ AUDIO FLOWING" : "❌ SILENCE"}`),
);
} else {
promises.push(Promise.resolve("Local mic: no stream"));
}
// 2. Probe raw WebRTC remote streams (before GainNode)
const rawRemoteStreams = getRemoteStreams();
rawRemoteStreams.forEach((s, i) => {
promises.push(
measureStreamLevel(s).then((lvl) => `Remote [${i}] (raw WebRTC ${s.id}): level=${lvl} ${lvl > 0 ? "✅ AUDIO FLOWING" : "❌ SILENCE"}`),
);
});
// 3. Probe GainNode output (what <audio> element plays)
const audioContainer = document.getElementById("voice-audio-container");
const audioEls = audioContainer?.querySelectorAll("audio") ?? [];
audioEls.forEach((el, i) => {
const a = el as HTMLAudioElement;
const src = a.srcObject as MediaStream | null;
if (src) {
promises.push(
measureStreamLevel(src).then((lvl) => `Remote [${i}] (GainNode output): level=${lvl} ${lvl > 0 ? "✅ AUDIO FLOWING" : "❌ SILENCE"}`),
);
}
});
if (promises.length === 0) {
levelResult.textContent = "No audio streams to probe";
levelBtn.textContent = "Probe Audio Levels";
return;
}
void Promise.all(promises).then((results) => {
levelResult.textContent = results.join("\n");
levelBtn.textContent = "Probe Audio Levels";
});
}, { signal });
section.appendChild(diagPanel);
const diagBtns = createElement("div", { style: "display: flex; flex-wrap: wrap;" });
appendChildren(diagBtns, diagRefresh, diagCopy, levelBtn);
appendChildren(diagBtns, diagRefresh, diagCopy);
section.appendChild(diagBtns);
section.appendChild(levelResult);
// Direct playback test — bypasses GainNode pipeline entirely
const directBtn = createElement("button", { class: "ac-btn", style: "margin: 6px 0 0 6px;" }, "Test Direct Playback");
const directResult = createElement("pre", {
style: "margin: 6px 0 0 0; color: #ccc; font-family: monospace; font-size: 12px; white-space: pre-wrap;",
});
directBtn.addEventListener("click", () => {
const rawStreams = getRemoteStreams();
if (rawStreams.length === 0) {
directResult.textContent = "No remote streams to test";
return;
}
const lines: string[] = [];
for (const s of rawStreams) {
const testAudio = document.createElement("audio");
testAudio.srcObject = s;
testAudio.autoplay = true;
testAudio.volume = 1.0;
document.body.appendChild(testAudio);
testAudio.play().then(() => {
lines.push(`Stream ${s.id}: play() succeeded, paused=${testAudio.paused}, readyState=${testAudio.readyState}`);
lines.push(` tracks: ${s.getAudioTracks().map((t) => `${t.id.slice(0,8)} enabled=${t.enabled} muted=${t.muted} readyState=${t.readyState}`).join(", ")}`);
directResult.textContent = lines.join("\n") + "\n\nDirect <audio> element added — can you hear audio now? (playing raw WebRTC stream, no GainNode)";
// Clean up after 10 seconds
setTimeout(() => { testAudio.srcObject = null; testAudio.remove(); }, 10000);
}).catch((err) => {
lines.push(`Stream ${s.id}: play() FAILED — ${err instanceof Error ? err.message : String(err)}`);
directResult.textContent = lines.join("\n");
testAudio.remove();
});
}
}, { signal });
diagBtns.appendChild(directBtn);
section.appendChild(directResult);
// Log count
const countEl = createElement("div", {
@@ -7,8 +7,6 @@ import { loadPref, savePref, createToggle } from "./helpers";
export function buildNotificationsTab(signal: AbortSignal): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
const header = createElement("h1", {}, "Notifications");
section.appendChild(header);
const toggles: ReadonlyArray<{ key: string; label: string; desc: string; fallback: boolean }> = [
{ key: "desktopNotifications", label: "Desktop Notifications", desc: "Show desktop notifications for messages", fallback: true },
@@ -0,0 +1,66 @@
/**
* Text & Images settings tab — link previews, embeds, inline media, GIF/emoji animation, spoilers.
*/
import { createElement, appendChildren } from "@lib/dom";
import { loadPref, savePref, createToggle } from "./helpers";
export function buildTextImagesTab(signal: AbortSignal): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
const toggles: ReadonlyArray<{ key: string; label: string; desc: string; fallback: boolean }> = [
{
key: "showLinkPreviews",
label: "Link Preview",
desc: "Show website previews for links shared in chat",
fallback: true,
},
{
key: "showEmbeds",
label: "Show Embeds",
desc: "Display rich embeds in chat messages",
fallback: true,
},
{
key: "inlineMedia",
label: "Inline Attachment Preview",
desc: "Automatically display images, videos, and GIFs inline",
fallback: true,
},
{
key: "animateGifs",
label: "Animate GIFs",
desc: "Play GIF animations automatically. When disabled, GIFs show as static images",
fallback: true,
},
{
key: "animateEmoji",
label: "Animate Emoji",
desc: "Play animated emoji automatically",
fallback: true,
},
{
key: "showSpoilers",
label: "Show Spoiler Content",
desc: "Always reveal spoiler content (click to reveal when disabled)",
fallback: false,
},
];
for (const item of toggles) {
const row = createElement("div", { class: "setting-row" });
const info = createElement("div", {});
const label = createElement("div", { class: "setting-label" }, item.label);
const desc = createElement("div", { class: "setting-desc" }, item.desc);
appendChildren(info, label, desc);
const isOn = loadPref<boolean>(item.key, item.fallback);
const toggle = createToggle(isOn, {
signal,
onChange: (nowOn) => { savePref(item.key, nowOn); },
});
appendChildren(row, info, toggle);
section.appendChild(row);
}
return section;
}
@@ -4,8 +4,7 @@
import { createElement, appendChildren, setText } from "@lib/dom";
import { loadPref, savePref, createToggle } from "./helpers";
import { switchInputDevice, switchOutputDevice, setVoiceSensitivity, updateSilenceSuppressionPref } from "@lib/voiceSession";
import { sensitivityToThreshold } from "@lib/vad";
import { switchInputDevice, switchOutputDevice, setVoiceSensitivity, setInputVolume, setOutputVolume, reapplyAudioProcessing } from "@lib/livekitSession";
export interface VoiceAudioTabHandle {
build(): HTMLDivElement;
@@ -63,8 +62,6 @@ type CameraRegistrar = (stream: MediaStream | null) => void;
function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar, registerCamera: CameraRegistrar): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
const header = createElement("h1", {}, "Voice & Audio");
section.appendChild(header);
// Input device selector
const inputHeader = createElement("h3", {}, "Input Device");
@@ -77,6 +74,85 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar,
section.appendChild(inputHeader);
section.appendChild(inputSelect);
// Input Volume slider
const inputVolumeHeader = createElement("h3", {}, "Input Volume");
section.appendChild(inputVolumeHeader);
const inputVolumeRow = createElement("div", { class: "slider-row" });
const savedInputVolume = loadPref<number>("inputVolume", 100);
const inputVolumeSlider = createElement("input", {
class: "settings-slider",
type: "range",
min: "0",
max: "200",
step: "1",
value: String(savedInputVolume),
});
const inputVolumeLabel = createElement("span", { class: "slider-val" }, `${savedInputVolume}%`);
inputVolumeSlider.addEventListener("input", () => {
const val = Number(inputVolumeSlider.value);
setText(inputVolumeLabel, `${val}%`);
setInputVolume(val);
}, { signal });
appendChildren(inputVolumeRow, inputVolumeSlider, inputVolumeLabel);
section.appendChild(inputVolumeRow);
// ── Mic level meter with draggable sensitivity threshold ────────
const sensitivityHeader = createElement("h3", {}, "Input Sensitivity");
section.appendChild(sensitivityHeader);
// Real-time mic level bar with embedded draggable threshold handle
const meterWrap = createElement("div", { class: "mic-meter-wrap" });
const meterBar = createElement("div", { class: "mic-meter-bar" });
const meterLevel = createElement("div", { class: "mic-meter-level" });
const meterThreshold = createElement("div", { class: "mic-meter-threshold" });
meterBar.appendChild(meterLevel);
meterBar.appendChild(meterThreshold);
meterWrap.appendChild(meterBar);
section.appendChild(meterWrap);
let currentSensitivity = loadPref<number>("voiceSensitivity", 50);
function updateThresholdIndicator(sensitivity: number): void {
// Invert: sensitivity 100 (no gating) → handle at LEFT (0%),
// sensitivity 0 (max gating) → handle at RIGHT (100%).
// This matches Discord: drag LEFT = easier to pass, RIGHT = harder.
meterThreshold.style.left = `${100 - sensitivity}%`;
}
updateThresholdIndicator(currentSensitivity);
/** Compute sensitivity % from a mouse/touch X position relative to the meter bar. */
function sensitivityFromPointer(clientX: number): number {
const rect = meterBar.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
// Invert: clicking LEFT = high sensitivity, RIGHT = low sensitivity
return Math.round((1 - ratio) * 100);
}
function applySensitivity(val: number): void {
currentSensitivity = val;
savePref("voiceSensitivity", val);
setVoiceSensitivity(val);
updateThresholdIndicator(val);
}
// Drag the threshold handle
meterThreshold.addEventListener("pointerdown", (e: PointerEvent) => {
e.preventDefault();
meterThreshold.setPointerCapture(e.pointerId);
const onMove = (ev: PointerEvent): void => { applySensitivity(sensitivityFromPointer(ev.clientX)); };
const onUp = (): void => {
meterThreshold.removeEventListener("pointermove", onMove);
meterThreshold.removeEventListener("pointerup", onUp);
};
meterThreshold.addEventListener("pointermove", onMove, { signal });
meterThreshold.addEventListener("pointerup", onUp, { signal });
}, { signal });
// Click on the meter bar to jump the threshold
meterBar.addEventListener("click", (e: MouseEvent) => {
applySensitivity(sensitivityFromPointer(e.clientX));
}, { signal });
// Output device selector
const outputHeader = createElement("h3", {}, "Output Device");
const outputSelect = createElement("select", {
@@ -88,6 +164,57 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar,
section.appendChild(outputHeader);
section.appendChild(outputSelect);
// Output Volume slider
const outputVolumeHeader = createElement("h3", {}, "Output Volume");
section.appendChild(outputVolumeHeader);
const outputVolumeRow = createElement("div", { class: "slider-row" });
const savedOutputVolume = loadPref<number>("outputVolume", 100);
const outputVolumeSlider = createElement("input", {
class: "settings-slider",
type: "range",
min: "0",
max: "200",
step: "1",
value: String(savedOutputVolume),
});
const outputVolumeLabel = createElement("span", { class: "slider-val" }, `${savedOutputVolume}%`);
outputVolumeSlider.addEventListener("input", () => {
const val = Number(outputVolumeSlider.value);
setText(outputVolumeLabel, `${val}%`);
setOutputVolume(val);
}, { signal });
appendChildren(outputVolumeRow, outputVolumeSlider, outputVolumeLabel);
section.appendChild(outputVolumeRow);
// Stream quality selector
const qualityHeader = createElement("h3", {}, "Stream Quality");
const qualityDesc = createElement("p", {
style: "color:var(--text-muted);font-size:12px;margin:0 0 8px",
}, "Applies to camera and screenshare. Higher quality uses more bandwidth. Changes take effect on next voice join.");
const qualitySelect = createElement("select", {
class: "form-input",
style: "width:100%;margin-bottom:16px",
}) as HTMLSelectElement;
const qualityOptions: Array<[string, string]> = [
["low", "Low (360p cam / 720p screen)"],
["medium", "Medium (720p)"],
["high", "High (1080p)"],
["source", "Source (1080p max bitrate)"],
];
const savedQuality = loadPref<string>("streamQuality", "high");
for (const [value, label] of qualityOptions) {
const opt = createElement("option", { value }, label);
if (value === savedQuality) opt.setAttribute("selected", "");
qualitySelect.appendChild(opt);
}
qualitySelect.value = savedQuality;
qualitySelect.addEventListener("change", () => {
savePref("streamQuality", qualitySelect.value);
}, { signal });
section.appendChild(qualityHeader);
section.appendChild(qualityDesc);
section.appendChild(qualitySelect);
// Video device selector
const videoHeader = createElement("h3", {}, "Video Device");
const videoSelect = createElement("select", {
@@ -202,58 +329,16 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar,
startCameraPreview(videoSelect.value);
}, { signal });
// Start initial camera preview
startCameraPreview(loadPref<string>("videoInputDevice", ""));
// Start initial camera preview only if a device has been explicitly selected
const savedVideoDevice = loadPref<string>("videoInputDevice", "");
if (savedVideoDevice !== "") {
startCameraPreview(savedVideoDevice);
}
signal.addEventListener("abort", () => {
stopCameraPreview();
});
// ── Mic level meter + sensitivity slider ──────────────────────────
const sensitivityHeader = createElement("h3", {}, "Input Sensitivity");
section.appendChild(sensitivityHeader);
// Real-time mic level bar
const meterWrap = createElement("div", { class: "mic-meter-wrap" });
const meterBar = createElement("div", { class: "mic-meter-bar" });
const meterLevel = createElement("div", { class: "mic-meter-level" });
const meterThreshold = createElement("div", { class: "mic-meter-threshold" });
meterBar.appendChild(meterLevel);
meterBar.appendChild(meterThreshold);
meterWrap.appendChild(meterBar);
section.appendChild(meterWrap);
// Sensitivity slider
const sensitivityRow = createElement("div", { class: "slider-row" });
const savedSensitivity = loadPref<number>("voiceSensitivity", 50);
const sensitivitySlider = createElement("input", {
class: "settings-slider",
type: "range",
min: "0",
max: "100",
value: String(savedSensitivity),
});
const sensitivityLabel = createElement("span", { class: "slider-val" }, `${savedSensitivity}%`);
// Position threshold indicator
function updateThresholdIndicator(sensitivity: number): void {
const threshold = sensitivityToThreshold(sensitivity);
// Map threshold (0-0.15) to percentage position (0-100%)
const pct = Math.min((threshold / 0.15) * 100, 100);
meterThreshold.style.left = `${pct}%`;
}
updateThresholdIndicator(savedSensitivity);
sensitivitySlider.addEventListener("input", () => {
const val = Number(sensitivitySlider.value);
setText(sensitivityLabel, `${val}%`);
savePref("voiceSensitivity", val);
setVoiceSensitivity(val);
updateThresholdIndicator(val);
}, { signal });
appendChildren(sensitivityRow, sensitivitySlider, sensitivityLabel);
section.appendChild(sensitivityRow);
// Start mic level monitoring for visual feedback
void (async () => {
try {
@@ -272,6 +357,7 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar,
const dataArray = new Uint8Array(analyser.frequencyBinCount);
let latestFrame = 0;
function updateMeter(): void {
if (signal.aborted) return;
analyser.getByteFrequencyData(dataArray);
@@ -287,18 +373,18 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar,
meterLevel.style.width = `${visual * 100}%`;
// Color: green if above threshold, yellow/red if below
const threshold = sensitivityToThreshold(Number(sensitivitySlider.value));
const threshold = ((100 - currentSensitivity) / 100) * 0.15;
if (rms >= threshold) {
meterLevel.style.background = "#43b581"; // green — voice detected
} else {
meterLevel.style.background = "#faa61a"; // yellow — below threshold
}
const frame = requestAnimationFrame(updateMeter);
registerMic(stream, audioCtx, frame);
latestFrame = requestAnimationFrame(updateMeter);
registerMic(stream, audioCtx, latestFrame);
}
const firstFrame = requestAnimationFrame(updateMeter);
registerMic(stream, audioCtx, firstFrame);
latestFrame = requestAnimationFrame(updateMeter);
registerMic(stream, audioCtx, latestFrame);
} catch {
// Mic access denied or unavailable — meter stays empty
}
@@ -310,7 +396,6 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar,
{ key: "noiseSuppression", label: "Noise Suppression", desc: "Filter out background noise from your microphone", fallback: true },
{ key: "autoGainControl", label: "Automatic Gain Control", desc: "Automatically adjust microphone volume", fallback: true },
{ key: "enhancedNoiseSuppression", label: "Enhanced Noise Suppression", desc: "ML-powered noise removal (RNNoise) — filters keyboard, pets, and other non-voice sounds", fallback: false },
{ key: "silenceSuppression", label: "Silence Suppression", desc: "Stop sending audio during silence to save bandwidth", fallback: true },
];
for (const item of audioToggles) {
@@ -325,12 +410,8 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar,
signal,
onChange: (nowOn) => {
savePref(item.key, nowOn);
if (item.key === "silenceSuppression") {
updateSilenceSuppressionPref();
} else {
const currentDevice = loadPref<string>("audioInputDevice", "");
void switchInputDevice(currentDevice);
}
// Reapply audio processing constraints to the live mic track
void reapplyAudioProcessing();
},
});
@@ -3,6 +3,7 @@
*/
import { createElement } from "@lib/dom";
import { applyThemeByName } from "@lib/themes";
// ---------------------------------------------------------------------------
// Constants
@@ -12,6 +13,7 @@ export const STORAGE_PREFIX = "owncord:settings:";
export const THEMES = {
dark: { "--bg-primary": "#313338", "--bg-secondary": "#2b2d31", "--bg-tertiary": "#1e1f22", "--text-normal": "#dbdee1" },
"neon-glow": { "--bg-primary": "#1a1b1e", "--bg-secondary": "#111214", "--bg-tertiary": "#0d0e10", "--text-normal": "#dbdee1" },
midnight: { "--bg-primary": "#1a1a2e", "--bg-secondary": "#16213e", "--bg-tertiary": "#0f3460", "--text-normal": "#e0e0e0" },
light: { "--bg-primary": "#ffffff", "--bg-secondary": "#f2f3f5", "--bg-tertiary": "#e3e5e8", "--text-normal": "#313338" },
} as const;
@@ -25,7 +27,12 @@ export type ThemeName = keyof typeof THEMES;
export function loadPref<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(STORAGE_PREFIX + key);
return raw !== null ? (JSON.parse(raw) as T) : fallback;
if (raw === null) return fallback;
const parsed: unknown = JSON.parse(raw);
// Basic typeof guard against corrupted localStorage (covers boolean,
// number, string fallbacks used by current call sites).
if (typeof parsed !== typeof fallback) return fallback;
return parsed as T;
} catch {
return fallback;
}
@@ -33,6 +40,9 @@ export function loadPref<T>(key: string, fallback: T): T {
export function savePref(key: string, value: unknown): void {
localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(value));
// Dispatch a custom event so same-window listeners can invalidate caches.
// The native `storage` event only fires for cross-tab changes.
window.dispatchEvent(new CustomEvent("owncord:pref-change", { detail: { key } }));
}
// ---------------------------------------------------------------------------
@@ -77,9 +87,12 @@ export function createToggle(
// ---------------------------------------------------------------------------
export function applyTheme(name: ThemeName): void {
const vars = THEMES[name];
// Apply CSS variables for the theme (keeps existing behavior for inline var overrides)
const theme = THEMES[name];
const root = document.documentElement;
for (const [prop, val] of Object.entries(vars)) {
root.style.setProperty(prop, val);
for (const [key, value] of Object.entries(theme)) {
root.style.setProperty(key, value);
}
// Delegate body class and persistence to the theme manager
applyThemeByName(name);
}
+115 -66
View File
@@ -19,6 +19,8 @@ import type {
UploadResponse,
VoiceCredentialsResponse,
MemberResponse,
DmChannelsResponse,
CreateDmResponse,
} from "./types";
/** Configuration for the API client. */
@@ -69,13 +71,15 @@ export function createApiClient(
return h;
}
async function request<T>(
async function doFetch<T>(
label: string,
urlBase: string,
method: string,
path: string,
body?: unknown,
signal?: AbortSignal,
): Promise<T> {
const url = `${baseUrl()}${path}`;
const url = `${urlBase}${path}`;
const init: RequestInit & { danger?: { acceptInvalidCerts: boolean; acceptInvalidHostnames: boolean } } = {
method,
headers: headers(),
@@ -86,21 +90,20 @@ export function createApiClient(
init.body = JSON.stringify(body);
}
log.debug("API →", { method, path });
log.debug(`${label}`, { method, path });
let res: Response;
try {
res = await fetch(url, init as RequestInit);
} catch (fetchErr) {
// Tauri plugin errors may not be standard Error instances
log.error("API fetch failed", { method, path, error: String(fetchErr) });
log.error(`${label} fetch failed`, { method, path, error: String(fetchErr) });
if (fetchErr instanceof Error) {
throw fetchErr;
}
throw new Error(typeof fetchErr === "string" ? fetchErr : String(fetchErr));
}
log.debug("API ←", { method, path, status: res.status });
log.debug(`${label}`, { method, path, status: res.status });
if (res.status === 401) {
onUnauthorized?.();
@@ -110,7 +113,7 @@ export function createApiClient(
if (!res.ok) {
const err = await parseError(res);
log.warn("API error", { method, path, status: res.status, code: err.error, message: err.message });
log.warn(`${label} error`, { method, path, status: res.status, code: err.error, message: err.message });
throw new ApiClientError(res.status, err.error, err.message);
}
@@ -122,55 +125,12 @@ export function createApiClient(
return res.json() as Promise<T>;
}
async function adminRequest<T>(
method: string,
path: string,
body?: unknown,
signal?: AbortSignal,
): Promise<T> {
const url = `${adminBaseUrl()}${path}`;
const init: RequestInit & { danger?: { acceptInvalidCerts: boolean; acceptInvalidHostnames: boolean } } = {
method,
headers: headers(),
signal,
danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false },
};
if (body !== undefined) {
init.body = JSON.stringify(body);
}
function request<T>(method: string, path: string, body?: unknown, signal?: AbortSignal): Promise<T> {
return doFetch<T>("API", baseUrl(), method, path, body, signal);
}
log.debug("Admin API →", { method, path });
let res: Response;
try {
res = await fetch(url, init as RequestInit);
} catch (fetchErr) {
log.error("Admin API fetch failed", { method, path, error: String(fetchErr) });
if (fetchErr instanceof Error) {
throw fetchErr;
}
throw new Error(typeof fetchErr === "string" ? fetchErr : String(fetchErr));
}
log.debug("Admin API ←", { method, path, status: res.status });
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);
log.warn("Admin API error", { method, path, status: res.status, code: err.error, message: err.message });
throw new ApiClientError(res.status, err.error, err.message);
}
if (res.status === 204) {
return undefined as T;
}
return res.json() as Promise<T>;
function adminRequest<T>(method: string, path: string, body?: unknown, signal?: AbortSignal): Promise<T> {
return doFetch<T>("Admin API", adminBaseUrl(), method, path, body, signal);
}
async function parseError(res: Response): Promise<ApiError> {
@@ -232,22 +192,56 @@ export function createApiClient(
return request<void>("POST", "/auth/logout", undefined, signal);
},
verifyTotp(
async verifyTotp(
code: string,
partialToken: string,
signal?: AbortSignal,
): Promise<AuthResponse> {
// Temporarily set token for this request; restore in .finally()
const prevToken = config.token;
config = { ...config, token: partialToken };
return request<AuthResponse>(
"POST",
"/auth/verify-totp",
{ code },
// Don't mutate shared config — make direct fetch with the partial token
const url = `${baseUrl()}/auth/verify-totp`;
const init: RequestInit & { danger?: { acceptInvalidCerts: boolean; acceptInvalidHostnames: boolean } } = {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${partialToken}`,
},
body: JSON.stringify({ code }),
signal,
).finally(() => {
config = { ...config, token: prevToken };
});
danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false },
};
let res: Response;
try {
res = await fetch(url, init as RequestInit);
} catch (fetchErr) {
log.error("API fetch failed", { method: "POST", path: "/auth/verify-totp", error: String(fetchErr) });
if (fetchErr instanceof Error) {
throw fetchErr;
}
throw new Error(typeof fetchErr === "string" ? fetchErr : String(fetchErr));
}
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<AuthResponse>;
},
deleteAccount(password: string, signal?: AbortSignal): Promise<void> {
return request<void>(
"DELETE",
"/auth/account",
{ password },
signal,
);
},
// ── Users ─────────────────────────────────────────────
@@ -447,6 +441,31 @@ export function createApiClient(
return request<void>("DELETE", `/sounds/${soundId}`, undefined, signal);
},
// ── Direct Messages ─────────────────────────────────────
/** List user's open DM channels. */
getDmChannels(signal?: AbortSignal): Promise<DmChannelsResponse> {
return request<DmChannelsResponse>("GET", "/dms", undefined, signal);
},
/** Create or get a DM channel with a user. */
createDm(
recipientId: number,
signal?: AbortSignal,
): Promise<CreateDmResponse> {
return request<CreateDmResponse>(
"POST",
"/dms",
{ recipient_id: recipientId },
signal,
);
},
/** Close a DM (hide from sidebar). */
closeDm(channelId: number, signal?: AbortSignal): Promise<void> {
return request<void>("DELETE", `/dms/${channelId}`, undefined, signal);
},
// ── Voice ─────────────────────────────────────────────
getVoiceCredentials(
@@ -518,6 +537,36 @@ export function createApiClient(
): Promise<void> {
return adminRequest<void>("DELETE", `/channels/${id}`, undefined, signal);
},
// ── Admin: Members ──────────────────────────────────────
adminKickMember(
userId: number,
signal?: AbortSignal,
): Promise<void> {
return adminRequest<void>("DELETE", `/users/${userId}/sessions`, undefined, signal);
},
adminBanMember(
userId: number,
reason?: string,
signal?: AbortSignal,
): Promise<void> {
return adminRequest<void>("PATCH", `/users/${userId}`, {
banned: true,
ban_reason: reason ?? "",
}, signal);
},
adminChangeRole(
userId: number,
roleId: number,
signal?: AbortSignal,
): Promise<void> {
return adminRequest<void>("PATCH", `/users/${userId}`, {
role_id: roleId,
}, signal);
},
};
}
-155
View File
@@ -1,155 +0,0 @@
// =============================================================================
// Audio Device Manager — enumerate devices, acquire streams, set output
// =============================================================================
import { loadPref } from "@components/settings/helpers";
import { createLogger } from "@lib/logger";
const log = createLogger("audio");
export interface AudioDevice {
readonly deviceId: string;
readonly label: string;
readonly kind: "audioinput" | "audiooutput";
}
export interface AudioManager {
enumerateDevices(): Promise<readonly AudioDevice[]>;
getUserMedia(deviceId?: string): Promise<MediaStream>;
setOutputDevice(element: HTMLAudioElement, deviceId: string): Promise<void>;
getInputDeviceId(): string | null;
getOutputDeviceId(): string | null;
onDeviceChange(callback: (devices: readonly AudioDevice[]) => void): () => void;
destroy(): void;
}
type DeviceChangeCallback = (devices: readonly AudioDevice[]) => void;
function toAudioDevice(info: MediaDeviceInfo): AudioDevice | null {
if (info.kind !== "audioinput" && info.kind !== "audiooutput") return null;
return {
deviceId: info.deviceId,
label: info.label || `${info.kind === "audioinput" ? "Microphone" : "Speaker"} (${info.deviceId.slice(0, 8)})`,
kind: info.kind,
};
}
export function createAudioManager(): AudioManager {
let currentInputDeviceId: string | null = null;
let currentOutputDeviceId: string | null = null;
let destroyed = false;
const activeStreams = new Set<MediaStream>();
const deviceChangeCallbacks = new Set<DeviceChangeCallback>();
async function listAudioDevices(): Promise<readonly AudioDevice[]> {
const devices = await navigator.mediaDevices.enumerateDevices();
const audioDevices: AudioDevice[] = [];
for (const d of devices) {
const mapped = toAudioDevice(d);
if (mapped !== null) {
audioDevices.push(mapped);
}
}
return audioDevices;
}
function handleDeviceChange(): void {
if (destroyed) return;
void listAudioDevices().then((devices) => {
log.info("Audio device change detected", {
inputs: devices.filter((d) => d.kind === "audioinput").length,
outputs: devices.filter((d) => d.kind === "audiooutput").length,
});
for (const cb of deviceChangeCallbacks) {
cb(devices);
}
});
}
navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange);
return {
async enumerateDevices(): Promise<readonly AudioDevice[]> {
if (destroyed) throw new Error("AudioManager has been destroyed");
return listAudioDevices();
},
async getUserMedia(deviceId?: string): Promise<MediaStream> {
if (destroyed) throw new Error("AudioManager has been destroyed");
const constraints: MediaStreamConstraints = {
audio: {
deviceId: deviceId !== undefined ? { exact: deviceId } : undefined,
echoCancellation: loadPref<boolean>("echoCancellation", true),
noiseSuppression: loadPref<boolean>("noiseSuppression", true),
autoGainControl: loadPref<boolean>("autoGainControl", true),
},
video: false,
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
activeStreams.add(stream);
// Determine actual device ID from the track settings
const audioTrack = stream.getAudioTracks()[0];
if (audioTrack !== undefined) {
const settings = audioTrack.getSettings();
currentInputDeviceId = settings.deviceId ?? deviceId ?? null;
log.info("Microphone acquired", {
deviceId: currentInputDeviceId,
sampleRate: settings.sampleRate,
channelCount: settings.channelCount,
echoCancellation: settings.echoCancellation,
noiseSuppression: settings.noiseSuppression,
autoGainControl: settings.autoGainControl,
});
}
return stream;
},
async setOutputDevice(element: HTMLAudioElement, deviceId: string): Promise<void> {
if (destroyed) throw new Error("AudioManager has been destroyed");
// setSinkId is not available in all browsers; check before calling
if (typeof element.setSinkId !== "function") {
throw new Error("Audio output device selection is not supported in this browser");
}
await element.setSinkId(deviceId);
currentOutputDeviceId = deviceId;
},
getInputDeviceId(): string | null {
return currentInputDeviceId;
},
getOutputDeviceId(): string | null {
return currentOutputDeviceId;
},
onDeviceChange(callback: DeviceChangeCallback): () => void {
deviceChangeCallbacks.add(callback);
return () => { deviceChangeCallbacks.delete(callback); };
},
destroy(): void {
if (destroyed) return;
destroyed = true;
navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange);
// Stop all tracks on all active streams
log.debug("AudioManager destroying", { activeStreams: activeStreams.size });
for (const stream of activeStreams) {
for (const track of stream.getTracks()) {
track.stop();
}
}
activeStreams.clear();
deviceChangeCallbacks.clear();
currentInputDeviceId = null;
currentOutputDeviceId = null;
},
};
}
@@ -0,0 +1,203 @@
// Connection stats poller — extracts WebRTC metrics from LiveKit Room
import type { Room } from "livekit-client";
import { createLogger } from "@lib/logger";
const log = createLogger("connection-stats");
const POLL_INTERVAL_MS = 2000;
export type QualityLevel = "excellent" | "fair" | "poor" | "bad";
export interface ConnectionStats {
readonly rtt: number;
readonly quality: QualityLevel;
readonly outRate: number;
readonly inRate: number;
readonly outPackets: number;
readonly inPackets: number;
readonly totalUp: number;
readonly totalDown: number;
}
export interface ConnectionStatsPoller {
start(): void;
stop(): void;
getStats(): ConnectionStats;
onUpdate(cb: (stats: ConnectionStats) => void): () => void;
}
const EMPTY_STATS: ConnectionStats = {
rtt: 0,
quality: "excellent",
outRate: 0,
inRate: 0,
outPackets: 0,
inPackets: 0,
totalUp: 0,
totalDown: 0,
};
function qualityFromRtt(rtt: number): QualityLevel {
if (rtt < 100) return "excellent";
if (rtt < 200) return "fair";
if (rtt < 400) return "poor";
return "bad";
}
interface PrevSnapshot {
readonly timestamp: number;
readonly outBytes: number;
readonly inBytes: number;
}
/** Collect stats from both publisher and subscriber PeerConnections.
* RTT is typically on the subscriber PC in LiveKit's SFU model. */
async function collectAllStats(
room: Room,
): Promise<RTCStatsReport[]> {
try {
const engine = room.engine as unknown as Record<string, unknown>;
const pcManager = engine.pcManager as
| { publisher?: { pc?: RTCPeerConnection }; subscriber?: { pc?: RTCPeerConnection } }
| undefined;
const reports: RTCStatsReport[] = [];
if (pcManager?.publisher?.pc) {
reports.push(await pcManager.publisher.pc.getStats());
}
if (pcManager?.subscriber?.pc) {
reports.push(await pcManager.subscriber.pc.getStats());
}
return reports;
} catch {
log.warn("Failed to access peer connection stats — LiveKit SDK internals may have changed");
return [];
}
}
function extractMetrics(reports: RTCStatsReport[]): {
rtt: number;
totalUp: number;
totalDown: number;
outPackets: number;
inPackets: number;
outBytes: number;
inBytes: number;
} {
let rtt = 0;
let totalUp = 0;
let totalDown = 0;
let outPackets = 0;
let inPackets = 0;
let outBytes = 0;
let inBytes = 0;
for (const report of reports) {
report.forEach((entry: Record<string, unknown>) => {
// Look for candidate-pair with RTT — accept any state that has a valid RTT,
// not just "succeeded", because LiveKit's subscriber PC may report "in-progress".
if (entry.type === "candidate-pair") {
const rawRtt = entry.currentRoundTripTime;
if (typeof rawRtt === "number" && rawRtt > 0 && (rtt === 0 || rawRtt * 1000 < rtt)) {
rtt = rawRtt * 1000;
}
// Use max across candidate-pairs (avoid double-counting across PCs)
if (typeof entry.bytesSent === "number" && entry.bytesSent > totalUp) totalUp = entry.bytesSent;
if (typeof entry.bytesReceived === "number" && entry.bytesReceived > totalDown) totalDown = entry.bytesReceived;
}
if (entry.type === "outbound-rtp") {
if (typeof entry.packetsSent === "number") outPackets += entry.packetsSent;
if (typeof entry.bytesSent === "number") outBytes += entry.bytesSent;
}
if (entry.type === "inbound-rtp") {
if (typeof entry.packetsReceived === "number") inPackets += entry.packetsReceived;
if (typeof entry.bytesReceived === "number") inBytes += entry.bytesReceived;
}
});
}
return { rtt, totalUp, totalDown, outPackets, inPackets, outBytes, inBytes };
}
export function createConnectionStatsPoller(
getRoom: () => Room | null,
): ConnectionStatsPoller {
let current: ConnectionStats = EMPTY_STATS;
let prev: PrevSnapshot = { timestamp: Date.now(), outBytes: 0, inBytes: 0 };
let intervalId: ReturnType<typeof setInterval> | null = null;
const listeners = new Set<(stats: ConnectionStats) => void>();
async function poll(): Promise<void> {
const room = getRoom();
if (!room) return;
const reports = await collectAllStats(room);
if (reports.length === 0) return;
const metrics = extractMetrics(reports);
const now = Date.now();
const elapsed = (now - prev.timestamp) / 1000;
const outRate = elapsed > 0 ? (metrics.outBytes - prev.outBytes) / elapsed : 0;
const inRate = elapsed > 0 ? (metrics.inBytes - prev.inBytes) / elapsed : 0;
prev = { timestamp: now, outBytes: metrics.outBytes, inBytes: metrics.inBytes };
current = {
rtt: metrics.rtt,
quality: qualityFromRtt(metrics.rtt),
outRate: Math.max(0, outRate),
inRate: Math.max(0, inRate),
outPackets: metrics.outPackets,
inPackets: metrics.inPackets,
totalUp: metrics.totalUp,
totalDown: metrics.totalDown,
};
listeners.forEach((cb) => cb(current));
}
function start(): void {
if (intervalId !== null) return;
log.info("Starting connection stats poller");
prev = { timestamp: Date.now(), outBytes: 0, inBytes: 0 };
current = EMPTY_STATS;
intervalId = setInterval(() => void poll(), POLL_INTERVAL_MS);
}
function stop(): void {
if (intervalId === null) return;
log.info("Stopping connection stats poller");
clearInterval(intervalId);
intervalId = null;
current = EMPTY_STATS;
prev = { timestamp: Date.now(), outBytes: 0, inBytes: 0 };
}
function getStats(): ConnectionStats {
return current;
}
function onUpdate(cb: (stats: ConnectionStats) => void): () => void {
listeners.add(cb);
return () => {
listeners.delete(cb);
};
}
return { start, stop, getStats, onUpdate };
}
// --- Formatting helpers ---
export function formatBytes(bytes: number): string {
if (bytes < 1000) return `${Math.round(bytes)} B`;
if (bytes < 1_000_000) return `${(bytes / 1000).toFixed(2)} kB`;
return `${(bytes / 1_000_000).toFixed(2)} MB`;
}
export function formatRate(bytesPerSec: number): string {
return `${formatBytes(bytesPerSec)}/s`;
}
@@ -0,0 +1,92 @@
/**
* Shared context menu utility.
* Creates a positioned context menu with items, handles click-outside
* dismissal, and cleans up via AbortSignal.
*/
import { createElement } from "./dom";
export interface ContextMenuItem {
readonly label: string;
readonly onClick: () => void;
readonly danger?: boolean;
readonly testId?: string;
}
export interface ContextMenuOptions {
readonly x: number;
readonly y: number;
readonly items: readonly ContextMenuItem[];
/** AbortSignal for automatic cleanup when parent component is destroyed. */
readonly signal: AbortSignal;
/** CSS class added to the menu root (for styling/selection). */
readonly className?: string;
}
/**
* Show a context menu at the given coordinates.
* Automatically removes any existing menu with the same className.
* Closes on click outside or when signal is aborted.
*/
export function showContextMenu(opts: ContextMenuOptions): void {
const { x, y, items, signal, className } = opts;
const menuClass = className ?? "context-menu";
// Remove any existing context menu with same class
document.querySelectorAll(`.${menuClass}`).forEach((el) => el.remove());
const menu = createElement("div", { class: `context-menu ${menuClass}` });
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
let hasSeparator = false;
for (const item of items) {
if (hasSeparator && item.danger) {
menu.appendChild(createElement("div", { class: "context-menu-sep" }));
}
const attrs: Record<string, string> = {
class: item.danger ? "context-menu-item danger" : "context-menu-item",
};
if (item.testId !== undefined) {
attrs["data-testid"] = item.testId;
}
const el = createElement("div", attrs, item.label);
el.addEventListener(
"click",
() => {
menu.remove();
dismissAc.abort();
item.onClick();
},
{ signal },
);
menu.appendChild(el);
hasSeparator = !item.danger;
}
document.body.appendChild(menu);
// Close on click outside (deferred so the opening click doesn't immediately close)
const dismissAc = new AbortController();
setTimeout(() => {
if (dismissAc.signal.aborted) return;
document.addEventListener(
"mousedown",
(e: MouseEvent) => {
if (!menu.contains(e.target as Node)) {
menu.remove();
dismissAc.abort();
}
},
{ signal: dismissAc.signal },
);
}, 0);
// Clean up if parent component is destroyed
signal.addEventListener("abort", () => {
menu.remove();
dismissAc.abort();
});
}
+99 -20
View File
@@ -7,6 +7,7 @@ import { authStore, setAuth, clearAuth } from "@stores/auth.store";
import { setTransientError } from "@stores/ui.store";
import {
setChannels,
setRoles,
setActiveChannel,
addChannel,
updateChannel,
@@ -39,15 +40,38 @@ import {
leaveVoiceChannel,
} from "@stores/voice.store";
import {
handleServerOffer,
handleServerAnswer,
handleServerIce,
} from "@lib/voiceSession";
dmStore,
setDmChannels,
addDmChannel,
removeDmChannel,
updateDmLastMessage,
updateDmLastMessagePreview,
} from "@stores/dm.store";
import type { DmChannel } from "@stores/dm.store";
import type { DmChannelPayload } from "./types";
import { handleVoiceToken } from "@lib/livekitSession";
import { notifyIncomingMessage } from "./notifications";
import { createLogger } from "./logger";
const log = createLogger("dispatcher");
/** Map a server DM channel payload to the client DmChannel type. */
function mapDmPayload(p: DmChannelPayload): DmChannel {
return {
channelId: p.channel_id,
recipient: {
id: p.recipient.id,
username: p.recipient.username,
avatar: p.recipient.avatar,
status: p.recipient.status,
},
lastMessageId: p.last_message_id,
lastMessage: p.last_message,
lastMessageAt: p.last_message_at,
unreadCount: p.unread_count,
};
}
/** Unsubscribe all listeners. */
export type DispatcherCleanup = () => void;
@@ -84,6 +108,7 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
unsubs.push(
ws.on("ready", (payload) => {
setChannels(payload.channels);
setRoles(payload.roles ?? []);
setMembers(payload.members);
setVoiceStates(payload.voice_states);
@@ -96,14 +121,37 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
}
}
// Populate DM channels if present in the ready payload
const dmPayloads = payload.dm_channels ?? [];
if (dmPayloads.length > 0) {
setDmChannels(dmPayloads.map(mapDmPayload));
}
log.info("Ready payload applied", {
channels: payload.channels.length,
members: payload.members.length,
voiceStates: payload.voice_states.length,
dmChannels: dmPayloads.length,
});
}),
);
// ── DM Channels ─────────────────────────────────────
unsubs.push(
ws.on("dm_channel_open", (payload) => {
log.info("DM channel opened", { channelId: payload.channel_id });
addDmChannel(mapDmPayload(payload));
}),
);
unsubs.push(
ws.on("dm_channel_close", (payload) => {
log.info("DM channel closed", { channelId: payload.channel_id });
removeDmChannel(payload.channel_id);
}),
);
// ── Chat Messages ─────────────────────────────────────
unsubs.push(
@@ -114,13 +162,46 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
user: payload.user.username,
});
addMessage(payload);
// Increment unread for non-active channels
const activeId = channelsStore.select(
(s) => s.activeChannelId,
);
if (payload.channel_id !== activeId) {
// Check if this is a DM channel and whether the message is from self.
const dmChannels = dmStore.getState().channels;
const isDm = dmChannels.some((c) => c.channelId === payload.channel_id);
const currentUserId = authStore.getState().user?.id ?? null;
const isOwnMessage = currentUserId !== null && payload.user.id === currentUserId;
// Increment channel-level unread for non-active, non-own-message channels.
// DM channel IDs are not in channelsStore (they use dmStore), so
// incrementUnread is a no-op for DMs, but the own-message guard is
// applied here for defence-in-depth.
if (payload.channel_id !== activeId && !isOwnMessage) {
incrementUnread(payload.channel_id);
}
// Update DM store last message if this message belongs to a DM channel.
// Skip unread increment for own messages and for the currently focused DM.
if (isDm) {
const isDmActive = payload.channel_id === activeId;
if (isOwnMessage || isDmActive) {
// Update last message preview but don't increment unread count.
updateDmLastMessagePreview(
payload.channel_id,
payload.id,
payload.content,
payload.timestamp,
);
} else {
updateDmLastMessage(
payload.channel_id,
payload.id,
payload.content,
payload.timestamp,
);
}
}
// Fire desktop notification, taskbar flash, and sound
notifyIncomingMessage(payload);
}),
@@ -269,20 +350,8 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
);
unsubs.push(
ws.on("voice_offer", (payload) => {
handleServerOffer(payload.sdp, payload.channel_id);
}),
);
unsubs.push(
ws.on("voice_answer", (payload) => {
handleServerAnswer(payload.sdp);
}),
);
unsubs.push(
ws.on("voice_ice", (payload) => {
handleServerIce(payload.candidate);
ws.on("voice_token", (payload) => {
void handleVoiceToken(payload.token, payload.url, payload.channel_id, payload.direct_url);
}),
);
@@ -294,6 +363,7 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
reason: payload.reason,
delaySeconds: payload.delay_seconds,
});
setTransientError(`Server is restarting: ${payload.reason ?? "maintenance"}`);
}),
);
@@ -303,6 +373,15 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
code: payload.code,
message: payload.message,
});
if (payload.code === "BANNED") {
// Banned users must not reconnect — show error and force logout.
setTransientError(payload.message || "You have been banned");
clearAuth();
return;
}
if (payload.code === "RATE_LIMITED" || payload.code === "FORBIDDEN") {
setTransientError(payload.message || "Server error");
}
}),
);
+67
View File
@@ -0,0 +1,67 @@
/**
* Disposable — automatic cleanup manager for component lifecycles.
* Tracks subscriptions, event listeners, and intervals. Calling destroy()
* flushes all cleanups at once, preventing memory leaks from forgotten unsubs.
*/
type CleanupFn = () => void;
export class Disposable {
private readonly cleanups: CleanupFn[] = [];
private readonly ac = new AbortController();
private destroyed = false;
/** The AbortSignal for this disposable — pass to addEventListener({ signal }). */
get signal(): AbortSignal {
return this.ac.signal;
}
/** Register an arbitrary cleanup function. */
addCleanup(fn: CleanupFn): void {
if (this.destroyed) {
fn();
return;
}
this.cleanups.push(fn);
}
/** Subscribe to a store with a selector, auto-tracked for cleanup. */
onStoreChange<S, R>(
store: { subscribeSelector(selector: (s: S) => R, callback: (val: R) => void): () => void },
selector: (s: S) => R,
callback: (val: R) => void,
): void {
const unsub = store.subscribeSelector(selector, callback);
this.addCleanup(unsub);
}
/** Add an event listener auto-tracked via AbortController signal. */
onEvent<K extends keyof HTMLElementEventMap>(
target: HTMLElement | Window | Document,
event: K,
handler: (e: HTMLElementEventMap[K]) => void,
options?: AddEventListenerOptions,
): void {
target.addEventListener(event, handler as EventListener, {
...options,
signal: this.ac.signal,
});
}
/** Set an interval, auto-tracked for cleanup. */
onInterval(fn: () => void, ms: number): void {
const id = setInterval(fn, ms);
this.addCleanup(() => clearInterval(id));
}
/** Flush all cleanups: abort listeners, run cleanup fns. */
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
this.ac.abort();
for (const fn of this.cleanups) {
fn();
}
this.cleanups.length = 0;
}
}
-4
View File
@@ -29,10 +29,6 @@ export function createElement<K extends keyof HTMLElementTagNameMap>(
for (const [key, value] of Object.entries(attrs)) {
if (key === "class") {
el.className = value;
} else if (key.startsWith("data-")) {
el.dataset[key.slice(5)] = value;
} else if (key.startsWith("aria-")) {
el.setAttribute(key, value);
} else {
el.setAttribute(key, value);
}
+277
View File
@@ -0,0 +1,277 @@
/**
* Centralized Lucide SVG icon factory.
*
* All path data is taken verbatim from the Lucide icon set (lucide.dev).
* Icons are rendered as inline SVG elements with `currentColor` stroke so
* they inherit the text color of their parent element.
*
* Usage:
* import { createIcon } from "./icons";
* const svg = createIcon("mic", 20);
* parent.appendChild(svg);
*/
// ---------------------------------------------------------------------------
// Icon name union
// ---------------------------------------------------------------------------
export type IconName =
| "mic"
| "mic-off"
| "headphones"
| "headphones-off"
| "camera"
| "camera-off"
| "monitor"
| "monitor-off"
| "phone"
| "phone-off"
| "volume-2"
| "volume-x"
| "pin"
| "pin-off"
| "users"
| "settings"
| "smile"
| "send"
| "reply"
| "pencil"
| "trash-2"
| "file-text"
| "download"
| "chevron-down"
| "chevron-right"
| "x"
| "eye"
| "eye-off"
| "play"
| "pause"
| "check"
| "external-link"
| "loader"
| "arrow-right"
| "hash"
| "triangle-alert"
| "user"
| "palette"
| "bell"
| "keyboard"
| "scroll-text"
| "image"
| "signal"
| "log-out"
| "zap";
// ---------------------------------------------------------------------------
// SVG inner content (innerHTML) — Lucide 0.x path data
// Each string contains the full set of child elements that live inside <svg>.
// ---------------------------------------------------------------------------
const ICON_PATHS: Record<IconName, string> = {
// Microphone
mic: `<path d="M12 2a3 3 0 0 1 3 3v7a3 3 0 0 1-6 0V5a3 3 0 0 1 3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/>`,
// Microphone with slash
"mic-off": `<line x1="2" x2="22" y1="2" y2="22"/><path d="M18.89 13.23A7.12 7.12 0 0 0 19 12v-2"/><path d="M5 10v2a7 7 0 0 0 12 5"/><path d="M15 9.34V5a3 3 0 0 0-5.68-1.33"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12"/><line x1="12" x2="12" y1="19" y2="22"/>`,
// Headphones
headphones: `<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3"/>`,
// Headphones with a diagonal slash (Lucide does not have headphones-off; we add a slash line)
"headphones-off": `<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3"/><line x1="2" x2="22" y1="2" y2="22"/>`,
// Camera / video camera
camera: `<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3z"/><circle cx="12" cy="13" r="3"/>`,
// Camera with slash
"camera-off": `<line x1="2" x2="22" y1="2" y2="22"/><path d="M7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16"/><path d="M9.5 4h5L17 7h3a2 2 0 0 1 2 2v7.5"/><path d="M14.121 15.121A3 3 0 1 1 9.88 10.88"/>`,
// Desktop monitor
monitor: `<rect width="20" height="14" x="2" y="3" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/>`,
// Monitor with slash
"monitor-off": `<path d="M17 17H4a2 2 0 0 1-2-2V5c0-1.5 1-2 1-2"/><path d="M22 15V5a2 2 0 0 0-2-2H9"/><path d="M8 21h8"/><path d="M12 17v4"/><line x1="2" x2="22" y1="2" y2="22"/>`,
// Phone handset (disconnect — styled red via CSS)
phone: `<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>`,
// Phone with slash (kept for potential future use)
"phone-off": `<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07"/><path d="M14.5 6.5a4 4 0 0 0-5.79 5.49"/><line x1="2" x2="22" y1="2" y2="22"/>`,
// Speaker with sound waves
"volume-2": `<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>`,
// Speaker muted (X)
"volume-x": `<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" x2="17" y1="9" y2="15"/><line x1="17" x2="23" y1="9" y2="15"/>`,
// Map pin / thumbtack
pin: `<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/>`,
// Pin with slash
"pin-off": `<line x1="2" x2="22" y1="2" y2="22"/><path d="M12 17.001V22"/><path d="M9 9a3 3 0 0 0 5.12 2.12"/><path d="M20 10a8 8 0 0 0-8-8 8 8 0 0 0-5.46 2.13"/>`,
// Multiple people
users: `<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>`,
// Gear / settings cog
settings: `<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>`,
// Smiley face
smile: `<circle cx="12" cy="12" r="10"/><path d="M8 13s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/>`,
// Paper plane / send
send: `<path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/>`,
// Reply / corner-up-left
reply: `<polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/>`,
// Edit pencil
pencil: `<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/>`,
// Trash can
"trash-2": `<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>`,
// Document with text lines
"file-text": `<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>`,
// Download arrow
download: `<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>`,
// Down chevron
"chevron-down": `<path d="m6 9 6 6 6-6"/>`,
// Right chevron
"chevron-right": `<path d="m9 18 6-6-6-6"/>`,
// X / close
x: `<path d="M18 6 6 18"/><path d="m6 6 12 12"/>`,
// Eye open
eye: `<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>`,
// Eye with slash
"eye-off": `<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" x2="22" y1="2" y2="22"/>`,
// Play triangle
play: `<polygon points="5 3 19 12 5 21 5 3"/>`,
// Pause bars
pause: `<rect width="4" height="16" x="6" y="4"/><rect width="4" height="16" x="14" y="4"/>`,
// Checkmark
check: `<path d="M20 6 9 17l-5-5"/>`,
// External link arrow out of box
"external-link": `<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>`,
// Loading spinner circle (partial arc with rotating convention)
loader: `<path d="M21 12a9 9 0 1 1-6.219-8.56"/>`,
// Right arrow
"arrow-right": `<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>`,
// Hash / pound symbol
hash: `<line x1="4" x2="20" y1="9" y2="9"/><line x1="4" x2="20" y1="15" y2="15"/><line x1="10" x2="8" y1="3" y2="21"/><line x1="16" x2="14" y1="3" y2="21"/>`,
// Alert triangle / warning
"triangle-alert": `<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>`,
// Single person / user
user: `<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>`,
// Artist palette
palette: `<circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/>`,
// Notification bell
bell: `<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>`,
// Keyboard
keyboard: `<rect width="20" height="16" x="2" y="4" rx="2" ry="2"/><path d="M6 8h.001"/><path d="M10 8h.001"/><path d="M14 8h.001"/><path d="M18 8h.001"/><path d="M8 12h.001"/><path d="M12 12h.001"/><path d="M16 12h.001"/><path d="M7 16h10"/>`,
// Scroll with text
"scroll-text": `<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2"/>`,
// Image / photo
image: `<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>`,
// Signal strength (4 bars)
signal: `<rect x="2" y="12" width="3" height="4" rx="0.5" fill="currentColor"/><rect x="7" y="8" width="3" height="8" rx="0.5" fill="currentColor"/><rect x="12" y="4" width="3" height="12" rx="0.5" fill="currentColor"/><rect x="17" y="0" width="3" height="16" rx="0.5" fill="currentColor"/>`,
// Log out / exit door with arrow
"log-out": `<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>`,
// Lightning bolt (auto-login indicator)
zap: `<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>`,
};
// ---------------------------------------------------------------------------
// Icon factory
// ---------------------------------------------------------------------------
const SVG_NS = "http://www.w3.org/2000/svg";
/**
* Create a Lucide SVG icon element.
*
* @param name - One of the defined icon names.
* @param size - Width and height in pixels (default: 24).
* @returns An SVGSVGElement ready to be appended to the DOM.
*/
export function createIcon(name: IconName, size = 24): SVGSVGElement {
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("width", String(size));
svg.setAttribute("height", String(size));
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "2");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("data-icon", name);
svg.classList.add("icon");
// Safe: path data comes entirely from the static ICON_PATHS constant above,
// never from user-provided input.
svg.innerHTML = ICON_PATHS[name];
return svg;
}
/** Create a signal-strength icon with per-bar coloring based on quality level.
* Bars are colored by the quality thresholds; unfilled bars use --bg-active. */
export function createSignalIcon(
barsLit: number,
color: string,
size = 16,
): SVGSVGElement {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("width", String(size));
svg.setAttribute("height", String(size));
svg.setAttribute("viewBox", "0 0 22 16");
svg.setAttribute("fill", "none");
const bars = [
{ x: 2, y: 12, w: 3, h: 4 },
{ x: 7, y: 8, w: 3, h: 8 },
{ x: 12, y: 4, w: 3, h: 12 },
{ x: 17, y: 0, w: 3, h: 16 },
];
const dimColor = "var(--bg-active, #383a40)";
for (let i = 0; i < bars.length; i++) {
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
const b = bars[i]!;
rect.setAttribute("x", String(b.x));
rect.setAttribute("y", String(b.y));
rect.setAttribute("width", String(b.w));
rect.setAttribute("height", String(b.h));
rect.setAttribute("rx", "0.5");
rect.setAttribute("fill", i < barsLit ? color : dimColor);
svg.appendChild(rect);
}
return svg;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,324 @@
/**
* Media visibility manager — freezes animated GIFs when they leave
* the viewport, when the window loses focus, or after an auto-pause
* timeout. Provides a play/pause button overlay on each GIF.
*
* Flow:
* GIF loads ──► observeMedia(img, src, wrapper)
* │
* ├─► Plays for AUTO_PAUSE_MS (10s)
* │ then freezes + shows ▶ button
* │
* ├─► Leaves viewport → freeze immediately
* │
* ├─► Window blur/minimize → freeze immediately
* │
* └─► User clicks ▶ → plays for another 10s
* User clicks ❚❚ → freeze immediately
*/
import { createElement } from "./dom";
import { createIcon } from "./icons";
/** How long a GIF plays before auto-pausing (ms). */
const AUTO_PAUSE_MS = 10_000;
interface MediaEntry {
readonly originalSrc: string;
frozenSrc: string | null;
isIntersecting: boolean;
isPlaying: boolean;
autoTimer: ReturnType<typeof setTimeout> | null;
readonly button: HTMLButtonElement;
readonly wrapper: HTMLElement;
}
const tracked = new WeakMap<HTMLImageElement, MediaEntry>();
const allTracked = new Set<WeakRef<HTMLImageElement>>();
let observer: IntersectionObserver | null = null;
let visibilityListenerAttached = false;
let documentHidden = false;
// ---------------------------------------------------------------------------
// Canvas freeze / unfreeze
// ---------------------------------------------------------------------------
function captureStaticFrame(img: HTMLImageElement): string | null {
try {
const canvas = document.createElement("canvas");
const w = Math.min(img.naturalWidth, img.width || 400);
const h = Math.min(img.naturalHeight, img.height || 350);
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
if (ctx === null) return null;
ctx.drawImage(img, 0, 0, w, h);
return canvas.toDataURL("image/png");
} catch {
return null;
}
}
function freezeImage(img: HTMLImageElement, entry: MediaEntry): void {
if (entry.autoTimer !== null) {
clearTimeout(entry.autoTimer);
entry.autoTimer = null;
}
entry.isPlaying = false;
if (img.src === entry.originalSrc) {
if (entry.frozenSrc === null) {
entry.frozenSrc = captureStaticFrame(img);
}
if (entry.frozenSrc !== null) {
img.src = entry.frozenSrc;
}
}
updateButton(entry);
}
function unfreezeImage(img: HTMLImageElement, entry: MediaEntry): void {
if (img.src !== entry.originalSrc) {
img.src = entry.originalSrc;
}
entry.isPlaying = true;
updateButton(entry);
startAutoTimer(img, entry);
}
// ---------------------------------------------------------------------------
// Play/pause button
// ---------------------------------------------------------------------------
function createPlayPauseButton(): HTMLButtonElement {
const btn = createElement("button", {
class: "gif-play-btn",
type: "button",
"aria-label": "Play/pause GIF",
});
btn.textContent = "";
btn.appendChild(createIcon("play", 14));
return btn;
}
function updateButton(entry: MediaEntry): void {
if (entry.isPlaying) {
entry.button.textContent = "";
entry.button.appendChild(createIcon("pause", 14));
entry.button.classList.add("playing");
entry.wrapper.classList.remove("gif-paused");
} else {
entry.button.textContent = "";
entry.button.appendChild(createIcon("play", 14));
entry.button.classList.remove("playing");
entry.wrapper.classList.add("gif-paused");
}
}
// ---------------------------------------------------------------------------
// Auto-pause timer
// ---------------------------------------------------------------------------
function startAutoTimer(img: HTMLImageElement, entry: MediaEntry): void {
if (entry.autoTimer !== null) {
clearTimeout(entry.autoTimer);
}
entry.autoTimer = setTimeout(() => {
entry.autoTimer = null;
if (entry.isPlaying) {
freezeImage(img, entry);
}
}, AUTO_PAUSE_MS);
}
// ---------------------------------------------------------------------------
// IntersectionObserver
// ---------------------------------------------------------------------------
function getObserver(): IntersectionObserver | null {
if (typeof IntersectionObserver === "undefined") return null;
if (observer !== null) return observer;
observer = new IntersectionObserver(
(entries) => {
for (const ioEntry of entries) {
const img = ioEntry.target as HTMLImageElement;
const data = tracked.get(img);
if (data === undefined) continue;
data.isIntersecting = ioEntry.isIntersecting;
if (!ioEntry.isIntersecting) {
freezeImage(img, data);
}
// Don't auto-unfreeze on intersection — user controls play via button
// Only resume if the image was playing when it scrolled into view
}
},
{ root: null, rootMargin: "0px", threshold: 0 },
);
return observer;
}
// ---------------------------------------------------------------------------
// Visibility change (window minimize / blur)
// ---------------------------------------------------------------------------
function ensureVisibilityListener(): void {
if (visibilityListenerAttached) return;
visibilityListenerAttached = true;
document.addEventListener("visibilitychange", () => {
documentHidden = document.hidden;
if (documentHidden) {
pauseAllMedia();
}
// Don't auto-resume on visibility — user controls play via button
});
window.addEventListener("blur", () => {
documentHidden = true;
pauseAllMedia();
});
window.addEventListener("focus", () => {
documentHidden = false;
// Don't auto-resume — user clicks play when ready
});
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Start observing a GIF image. Call after the image's `load` event.
* Returns the wrapper element that should replace the bare img in the DOM.
* The wrapper includes the play/pause button overlay.
*
* @param startFrozen - If true, the GIF starts frozen (first frame shown)
* instead of auto-playing. The user can still click the play button to
* start playback. Used when the `animateGifs` preference is disabled.
*/
export function observeMedia(
img: HTMLImageElement,
originalSrc: string,
wrapper: HTMLElement,
startFrozen?: boolean,
): void {
if (tracked.has(img)) return;
const button = createPlayPauseButton();
wrapper.style.position = "relative";
wrapper.appendChild(button);
const entry: MediaEntry = {
originalSrc,
frozenSrc: null,
isIntersecting: true,
isPlaying: true,
autoTimer: null,
button,
wrapper,
};
tracked.set(img, entry);
allTracked.add(new WeakRef(img));
// Wire button click
button.addEventListener("click", (e) => {
e.stopPropagation(); // don't trigger lightbox
const data = tracked.get(img);
if (data === undefined) return;
if (data.isPlaying) {
freezeImage(img, data);
} else {
unfreezeImage(img, data);
}
});
if (startFrozen === true) {
// Start frozen: set isPlaying false, capture first frame, show ▶ button
entry.isPlaying = false;
freezeImage(img, entry);
} else {
// Default: start playing, auto-pause after AUTO_PAUSE_MS
updateButton(entry);
startAutoTimer(img, entry);
}
ensureVisibilityListener();
getObserver()?.observe(img);
}
/** Stop observing an image element. */
export function unobserveMedia(img: HTMLImageElement): void {
const entry = tracked.get(img);
if (entry === undefined) return;
if (entry.autoTimer !== null) {
clearTimeout(entry.autoTimer);
}
// Restore original src without starting a new auto-timer.
if (img.src !== entry.originalSrc) {
img.src = entry.originalSrc;
}
tracked.delete(img);
observer?.unobserve(img);
// Remove from allTracked to prevent unbounded WeakRef accumulation.
for (const ref of allTracked) {
const target = ref.deref();
if (target === img || target === undefined) {
allTracked.delete(ref);
}
}
}
/** Freeze all tracked GIFs (called on window hide/blur). */
export function pauseAllMedia(): void {
for (const ref of allTracked) {
const img = ref.deref();
if (img === undefined) {
allTracked.delete(ref);
continue;
}
const entry = tracked.get(img);
if (entry !== undefined) {
freezeImage(img, entry);
}
}
}
/** Unfreeze only GIFs that are currently in the viewport. */
export function resumeVisibleMedia(): void {
for (const ref of allTracked) {
const img = ref.deref();
if (img === undefined) {
allTracked.delete(ref);
continue;
}
const entry = tracked.get(img);
if (entry !== undefined && entry.isIntersecting) {
unfreezeImage(img, entry);
}
}
}
/** Clean up observer (for testing or app teardown). */
export function destroyObserver(): void {
// Clear all auto-pause timers
for (const ref of allTracked) {
const img = ref.deref();
if (img !== undefined) {
const entry = tracked.get(img);
if (entry?.autoTimer !== null && entry?.autoTimer !== undefined) {
clearTimeout(entry.autoTimer);
}
}
}
observer?.disconnect();
observer = null;
allTracked.clear();
}
+172 -218
View File
@@ -1,14 +1,17 @@
// =============================================================================
// Noise Suppression — RNNoise ML-based noise removal via Web Audio API
// Noise Suppression — RNNoise ML-based noise removal as a LiveKit TrackProcessor
//
// Implements LiveKit's TrackProcessor<Track.Kind.Audio> interface so it
// integrates with setProcessor() / stopProcessor() lifecycle, device switching,
// and mid-call toggling automatically.
//
// Inserts between getUserMedia stream and the PeerConnection to clean audio.
// RNNoise processes 480-sample frames at 48kHz (10ms).
//
// Uses AudioWorklet (modern, runs on audio thread) with ScriptProcessorNode
// fallback (deprecated but widely supported).
// =============================================================================
import { createRNNWasmModule } from "@jitsi/rnnoise-wasm";
import { Track, type TrackProcessor, type AudioProcessorOptions } from "livekit-client";
import { createLogger } from "@lib/logger";
const log = createLogger("noise-suppression");
@@ -16,13 +19,8 @@ const log = createLogger("noise-suppression");
const RNNOISE_FRAME_SIZE = 480;
const SCRIPT_PROCESSOR_BUFFER = 4096;
export interface NoiseSuppressor {
process(input: MediaStream): Promise<MediaStream>;
destroy(): void;
}
// ---------------------------------------------------------------------------
// Shared WASM module cache (used by ScriptProcessorNode fallback)
// Shared WASM module cache
// ---------------------------------------------------------------------------
interface RNNoiseModule {
@@ -52,102 +50,78 @@ async function loadRNNoise(): Promise<RNNoiseModule> {
return mod;
}
/** Check if AudioWorklet is available in this browser context. */
function supportsAudioWorklet(): boolean {
try {
return typeof AudioWorkletNode !== "undefined"
&& typeof AudioContext !== "undefined"
&& "audioWorklet" in AudioContext.prototype;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// AudioWorklet-based suppressor (preferred, runs on audio thread)
// Internal processing pipeline — shared by both init strategies
// ---------------------------------------------------------------------------
function createWorkletSuppressor(): NoiseSuppressor {
let audioContext: AudioContext | null = null;
let sourceNode: MediaStreamAudioSourceNode | null = null;
let destNode: MediaStreamAudioDestinationNode | null = null;
let workletNode: AudioWorkletNode | null = null;
let destroyed = false;
interface ProcessingPipeline {
readonly processedTrack: MediaStreamTrack;
destroy(): void;
}
/** AudioWorklet-based pipeline (preferred, runs on audio thread). */
async function createWorkletPipeline(
inputTrack: MediaStreamTrack,
audioContext: AudioContext,
): Promise<ProcessingPipeline> {
await audioContext.audioWorklet.addModule("/rnnoise-worklet.js");
const wasmResponse = await fetch("/rnnoise.wasm");
const wasmBytes = await wasmResponse.arrayBuffer();
const source = audioContext.createMediaStreamSource(new MediaStream([inputTrack]));
const dest = audioContext.createMediaStreamDestination();
const workletNode = new AudioWorkletNode(audioContext, "rnnoise-processor", {
numberOfInputs: 1,
numberOfOutputs: 1,
outputChannelCount: [1],
});
const initPromise = new Promise<void>((resolve, reject) => {
workletNode.port.onmessage = (event: MessageEvent) => {
if (event.data.type === "ready") resolve();
else if (event.data.type === "error") reject(new Error(event.data.message));
};
});
workletNode.port.postMessage({ type: "init", wasmBytes }, [wasmBytes]);
await initPromise;
source.connect(workletNode);
workletNode.connect(dest);
log.info("RNNoise AudioWorklet processing active");
return {
async process(input: MediaStream): Promise<MediaStream> {
if (destroyed) throw new Error("NoiseSuppressor destroyed");
audioContext = new AudioContext({ sampleRate: 48000 });
// Load the worklet processor module
await audioContext.audioWorklet.addModule("/rnnoise-worklet.js");
// Fetch WASM bytes to send to the worklet thread
const wasmResponse = await fetch("/rnnoise.wasm");
const wasmBytes = await wasmResponse.arrayBuffer();
sourceNode = audioContext.createMediaStreamSource(input);
destNode = audioContext.createMediaStreamDestination();
workletNode = new AudioWorkletNode(audioContext, "rnnoise-processor", {
numberOfInputs: 1,
numberOfOutputs: 1,
outputChannelCount: [1],
});
// Wait for WASM init in the worklet
const initPromise = new Promise<void>((resolve, reject) => {
if (workletNode === null) { reject(new Error("No worklet")); return; }
workletNode.port.onmessage = (event: MessageEvent) => {
if (event.data.type === "ready") {
resolve();
} else if (event.data.type === "error") {
reject(new Error(event.data.message));
}
};
});
// Send WASM bytes to the worklet for initialization
workletNode.port.postMessage({ type: "init", wasmBytes }, [wasmBytes]);
await initPromise;
sourceNode.connect(workletNode);
workletNode.connect(destNode);
log.info("RNNoise AudioWorklet processing active");
return destNode.stream;
},
destroy(): void {
if (destroyed) return;
destroyed = true;
if (workletNode !== null) {
workletNode.port.postMessage({ type: "destroy" });
workletNode.disconnect();
workletNode = null;
}
if (sourceNode !== null) {
sourceNode.disconnect();
sourceNode = null;
}
if (destNode !== null) {
destNode.disconnect();
destNode = null;
}
if (audioContext !== null) {
void audioContext.close();
audioContext = null;
}
log.info("RNNoise AudioWorklet destroyed");
processedTrack: dest.stream.getAudioTracks()[0]!,
destroy() {
workletNode.port.postMessage({ type: "destroy" });
workletNode.disconnect();
source.disconnect();
dest.disconnect();
log.info("RNNoise AudioWorklet pipeline destroyed");
},
};
}
// ---------------------------------------------------------------------------
// ScriptProcessorNode fallback (deprecated but universal)
// ---------------------------------------------------------------------------
function createScriptProcessorSuppressor(): NoiseSuppressor {
let audioContext: AudioContext | null = null;
let sourceNode: MediaStreamAudioSourceNode | null = null;
let destNode: MediaStreamAudioDestinationNode | null = null;
let processorNode: ScriptProcessorNode | null = null;
let rnnoiseState: number = 0;
let inputPtr: number = 0;
let outputPtr: number = 0;
let wasmModule: RNNoiseModule | null = null;
let destroyed = false;
/** ScriptProcessorNode-based pipeline (fallback). */
async function createScriptProcessorPipeline(
inputTrack: MediaStreamTrack,
audioContext: AudioContext,
): Promise<ProcessingPipeline> {
const wasmModule = await loadRNNoise();
const rnnoiseState = wasmModule._rnnoise_create();
const inputPtr = wasmModule._malloc(RNNOISE_FRAME_SIZE * 4);
const outputPtr = wasmModule._malloc(RNNOISE_FRAME_SIZE * 4);
const inputRing = new Float32Array(RNNOISE_FRAME_SIZE);
let inputRingOffset = 0;
@@ -160,7 +134,6 @@ function createScriptProcessorSuppressor(): NoiseSuppressor {
let outSampleOffset = 0;
function processFrame(): void {
if (wasmModule === null) return;
const inOff = inputPtr / 4;
for (let i = 0; i < RNNOISE_FRAME_SIZE; i++) {
wasmModule.HEAPF32[inOff + i] = (inputRing[i] ?? 0) * 32768;
@@ -181,140 +154,121 @@ function createScriptProcessorSuppressor(): NoiseSuppressor {
outCount++;
}
const source = audioContext.createMediaStreamSource(new MediaStream([inputTrack]));
const dest = audioContext.createMediaStreamDestination();
const processorNode = audioContext.createScriptProcessor(SCRIPT_PROCESSOR_BUFFER, 1, 1);
processorNode.onaudioprocess = (event: AudioProcessingEvent) => {
const inData = event.inputBuffer.getChannelData(0);
const outData = event.outputBuffer.getChannelData(0);
let inIdx = 0;
while (inIdx < inData.length) {
const needed = RNNOISE_FRAME_SIZE - inputRingOffset;
const toCopy = Math.min(needed, inData.length - inIdx);
inputRing.set(inData.subarray(inIdx, inIdx + toCopy), inputRingOffset);
inputRingOffset += toCopy;
inIdx += toCopy;
if (inputRingOffset >= RNNOISE_FRAME_SIZE) {
processFrame();
inputRingOffset = 0;
}
}
let outIdx = 0;
while (outIdx < outData.length && outCount > 0) {
const chunk = outRing[outReadIdx]!;
const available = chunk.length - outSampleOffset;
const toWrite = Math.min(available, outData.length - outIdx);
outData.set(chunk.subarray(outSampleOffset, outSampleOffset + toWrite), outIdx);
outIdx += toWrite;
outSampleOffset += toWrite;
if (outSampleOffset >= chunk.length) {
outReadIdx = (outReadIdx + 1) % OUT_RING_CAPACITY;
outCount--;
outSampleOffset = 0;
}
}
if (outIdx < outData.length) {
outData.fill(0, outIdx);
}
};
source.connect(processorNode);
processorNode.connect(dest);
log.info("RNNoise ScriptProcessor processing active (fallback)");
return {
async process(input: MediaStream): Promise<MediaStream> {
if (destroyed) throw new Error("NoiseSuppressor destroyed");
wasmModule = await loadRNNoise();
rnnoiseState = wasmModule._rnnoise_create();
inputPtr = wasmModule._malloc(RNNOISE_FRAME_SIZE * 4);
outputPtr = wasmModule._malloc(RNNOISE_FRAME_SIZE * 4);
audioContext = new AudioContext({ sampleRate: 48000 });
sourceNode = audioContext.createMediaStreamSource(input);
destNode = audioContext.createMediaStreamDestination();
processorNode = audioContext.createScriptProcessor(SCRIPT_PROCESSOR_BUFFER, 1, 1);
processorNode.onaudioprocess = (event: AudioProcessingEvent) => {
const inData = event.inputBuffer.getChannelData(0);
const outData = event.outputBuffer.getChannelData(0);
let inIdx = 0;
while (inIdx < inData.length) {
const needed = RNNOISE_FRAME_SIZE - inputRingOffset;
const toCopy = Math.min(needed, inData.length - inIdx);
inputRing.set(inData.subarray(inIdx, inIdx + toCopy), inputRingOffset);
inputRingOffset += toCopy;
inIdx += toCopy;
if (inputRingOffset >= RNNOISE_FRAME_SIZE) {
processFrame();
inputRingOffset = 0;
}
}
let outIdx = 0;
while (outIdx < outData.length && outCount > 0) {
const chunk = outRing[outReadIdx]!;
const available = chunk.length - outSampleOffset;
const toWrite = Math.min(available, outData.length - outIdx);
outData.set(chunk.subarray(outSampleOffset, outSampleOffset + toWrite), outIdx);
outIdx += toWrite;
outSampleOffset += toWrite;
if (outSampleOffset >= chunk.length) {
outReadIdx = (outReadIdx + 1) % OUT_RING_CAPACITY;
outCount--;
outSampleOffset = 0;
}
}
if (outIdx < outData.length) {
outData.fill(0, outIdx);
}
};
sourceNode.connect(processorNode);
processorNode.connect(destNode);
log.info("RNNoise ScriptProcessor processing active (fallback)");
return destNode.stream;
},
destroy(): void {
if (destroyed) return;
destroyed = true;
if (processorNode !== null) {
processorNode.onaudioprocess = null;
processorNode.disconnect();
processorNode = null;
}
if (sourceNode !== null) {
sourceNode.disconnect();
sourceNode = null;
}
if (destNode !== null) {
destNode.disconnect();
destNode = null;
}
if (audioContext !== null) {
void audioContext.close();
audioContext = null;
}
if (wasmModule !== null && rnnoiseState !== 0) {
wasmModule._rnnoise_destroy(rnnoiseState);
wasmModule._free(inputPtr);
wasmModule._free(outputPtr);
rnnoiseState = 0;
}
outWriteIdx = 0;
outReadIdx = 0;
outCount = 0;
outSampleOffset = 0;
log.info("RNNoise ScriptProcessor destroyed");
processedTrack: dest.stream.getAudioTracks()[0]!,
destroy() {
processorNode.onaudioprocess = null;
processorNode.disconnect();
source.disconnect();
dest.disconnect();
wasmModule._rnnoise_destroy(rnnoiseState);
wasmModule._free(inputPtr);
wasmModule._free(outputPtr);
log.info("RNNoise ScriptProcessor pipeline destroyed");
},
};
}
// ---------------------------------------------------------------------------
// Factory — tries AudioWorklet first, falls back to ScriptProcessorNode
// LiveKit TrackProcessor implementation
// ---------------------------------------------------------------------------
/** Check if AudioWorklet is available in this browser context. */
function supportsAudioWorklet(): boolean {
try {
return typeof AudioWorkletNode !== "undefined"
&& typeof AudioContext !== "undefined"
&& "audioWorklet" in AudioContext.prototype;
} catch {
return false;
}
}
/**
* Creates an RNNoise TrackProcessor compatible with LiveKit's
* LocalAudioTrack.setProcessor() API.
*
* Usage:
* const processor = createRNNoiseProcessor();
* await localAudioTrack.setProcessor(processor);
* // Later:
* await localAudioTrack.stopProcessor();
*/
export function createRNNoiseProcessor(): TrackProcessor<Track.Kind.Audio, AudioProcessorOptions> {
let pipeline: ProcessingPipeline | null = null;
export function createNoiseSuppressor(): NoiseSuppressor {
log.debug("Creating noise suppressor", { audioWorkletSupported: supportsAudioWorklet() });
if (supportsAudioWorklet()) {
// Wrap in a facade that falls back to ScriptProcessor on failure
const worklet = createWorkletSuppressor();
let fallback: NoiseSuppressor | null = null;
let activeSuppressor: NoiseSuppressor = worklet;
return {
name: "rnnoise",
return {
async process(input: MediaStream): Promise<MediaStream> {
async init(opts: AudioProcessorOptions): Promise<void> {
log.debug("RNNoise processor init", { audioWorkletSupported: supportsAudioWorklet() });
const ctx = opts.audioContext;
if (supportsAudioWorklet()) {
try {
return await worklet.process(input);
pipeline = await createWorkletPipeline(opts.track, ctx);
return;
} catch (err) {
log.warn("AudioWorklet failed, falling back to ScriptProcessorNode", err);
worklet.destroy();
fallback = createScriptProcessorSuppressor();
activeSuppressor = fallback;
return fallback.process(input);
}
},
destroy(): void {
activeSuppressor.destroy();
},
};
}
}
log.info("AudioWorklet not supported, using ScriptProcessorNode");
return createScriptProcessorSuppressor();
pipeline = await createScriptProcessorPipeline(opts.track, ctx);
},
async restart(opts: AudioProcessorOptions): Promise<void> {
log.debug("RNNoise processor restart");
if (pipeline !== null) {
pipeline.destroy();
pipeline = null;
}
await this.init(opts);
},
async destroy(): Promise<void> {
if (pipeline !== null) {
pipeline.destroy();
pipeline = null;
}
log.info("RNNoise processor destroyed");
},
get processedTrack(): MediaStreamTrack | undefined {
return pipeline?.processedTrack;
},
};
}
+24
View File
@@ -0,0 +1,24 @@
/**
* OS reduced-motion sync — managed listener with safe re-registration.
* Extracted to its own module to avoid circular dependencies between
* SettingsOverlay and AccessibilityTab.
*/
let ac: AbortController | null = null;
/** Enable or disable the OS reduced-motion sync listener. Safe to call multiple times. */
export function syncOsMotionListener(enabled: boolean): void {
// Tear down any previous listener
if (ac !== null) {
ac.abort();
ac = null;
}
if (!enabled) return;
ac = new AbortController();
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
document.documentElement.classList.toggle("reduced-motion", mq.matches);
mq.addEventListener("change", (e: MediaQueryListEvent) => {
document.documentElement.classList.toggle("reduced-motion", e.matches);
}, { signal: ac.signal });
}
+3 -3
View File
@@ -41,14 +41,14 @@ export function hasAllPermissions(userPerms: number, ...perms: Permission[]): bo
*
* - If the base permissions contain ADMINISTRATOR the result is all bits set
* (deny/allow are ignored).
* - Otherwise: start with `basePerms`, add `allow` bits, then remove `deny` bits.
* Deny takes precedence over allow.
* - Otherwise: remove `deny` bits first, then add `allow` bits.
* Allow takes precedence over deny (matches server semantics).
*/
export function computeEffective(basePerms: number, allow: number, deny: number): number {
if ((basePerms & Permission.ADMINISTRATOR) === Permission.ADMINISTRATOR) {
return ALL_PERMISSIONS;
}
return (basePerms | allow) & ~deny;
return (basePerms & ~deny) | allow;
}
/** Shorthand check for the ADMINISTRATOR bit. */
+33 -5
View File
@@ -37,6 +37,7 @@ export interface HealthStatus {
readonly status: "online" | "slow" | "offline" | "checking";
readonly latencyMs: number | null;
readonly version: string | null;
readonly onlineUsers: number | null;
}
export interface ProfilesState {
@@ -157,6 +158,13 @@ export interface ProfileManager {
/** Returns the first profile with autoConnect=true, or null. */
getAutoConnectProfile(): ServerProfile | null;
/**
* Set the auto-login profile. Only one profile can be auto-login at a time.
* Passing null clears auto-login on all profiles.
* Setting auto-login also forces rememberPassword to true on the target.
*/
setAutoLogin(id: string | null): void;
/** Set lastConnected to current ISO timestamp. */
setLastConnected(id: string): void;
@@ -185,7 +193,7 @@ export function createProfileManager(
const store = createStore<ProfilesState>(initialState);
// Resolve which fetch to use: injected mock, Tauri plugin, or global
const doFetch: FetchFn = fetchFn ?? (fetch as unknown as FetchFn);
const doFetch: FetchFn = fetchFn ?? fetch;
// ── Helpers ────────────────────────────────────────────────
@@ -229,16 +237,17 @@ export function createProfileManager(
const elapsed = Math.round(performance.now() - start);
if (!res.ok) {
return { status: "offline", latencyMs: elapsed, version: null };
return { status: "offline", latencyMs: elapsed, version: null, onlineUsers: null };
}
const body = (await res.json()) as { version?: string };
const body = (await res.json()) as { version?: string; online_users?: number };
const version = typeof body.version === "string" ? body.version : null;
const onlineUsers = typeof body.online_users === "number" ? body.online_users : null;
const status = elapsed > SLOW_THRESHOLD_MS ? "slow" : "online";
return { status, latencyMs: elapsed, version };
return { status, latencyMs: elapsed, version, onlineUsers };
} catch {
return { status: "offline", latencyMs: null, version: null };
return { status: "offline", latencyMs: null, version: null, onlineUsers: null };
} finally {
clearTimeout(timer);
}
@@ -301,6 +310,22 @@ export function createProfileManager(
return currentProfiles().find((p) => p.autoConnect) ?? null;
},
setAutoLogin(id: string | null): void {
const profiles = currentProfiles();
const updated = profiles.map((p) => {
if (id === null) {
// Clear auto-login on all profiles
return p.autoConnect ? { ...p, autoConnect: false } : p;
}
if (p.id === id) {
return { ...p, autoConnect: true, rememberPassword: true };
}
// Clear auto-login on all other profiles
return p.autoConnect ? { ...p, autoConnect: false } : p;
});
setProfiles(updated);
},
setLastConnected(id: string): void {
const profiles = currentProfiles();
const index = profiles.findIndex((p) => p.id === id);
@@ -321,6 +346,7 @@ export function createProfileManager(
status: "offline",
latencyMs: null,
version: null,
onlineUsers: null,
};
return offline;
}
@@ -329,6 +355,7 @@ export function createProfileManager(
status: "checking",
latencyMs: null,
version: null,
onlineUsers: null,
});
const result = await pingHost(profile.host);
@@ -345,6 +372,7 @@ export function createProfileManager(
status: "checking",
latencyMs: null,
version: null,
onlineUsers: null,
});
}
+1 -1
View File
@@ -6,7 +6,7 @@
import { loadPref, savePref } from "@components/settings/helpers";
import { voiceStore } from "@stores/voice.store";
import { setMuted } from "./voiceSession";
import { setMuted } from "./livekitSession";
import { createLogger } from "./logger";
const log = createLogger("ptt");
+77
View File
@@ -0,0 +1,77 @@
/**
* DOM list reconciliation utility.
* Efficiently patches a container's children to match a new list of items,
* preserving existing DOM elements where possible (no nuke-and-rebuild).
*
* Algorithm:
* 1. Build a map of existing elements by key
* 2. Walk the new items list:
* - If key exists in map → update in place, move to correct position
* - If key is new → create element, insert at correct position
* 3. Remove any elements whose keys are no longer in the list
*
* This preserves hover states, focus, CSS transitions, and scroll position.
*/
export interface ReconcileOptions<T> {
/** The container element whose children will be patched. */
readonly container: Element;
/** The new list of items to render. */
readonly items: readonly T[];
/** Extract a unique string key from each item. */
readonly key: (item: T) => string;
/** Create a new DOM element for an item. */
readonly create: (item: T) => Element;
/** Update an existing DOM element with new item data. Return the element. */
readonly update: (el: Element, item: T) => void;
}
/**
* Reconcile a container's children against a list of keyed items.
* Preserves existing DOM elements, only adding/removing/reordering as needed.
*/
export function reconcileList<T>(opts: ReconcileOptions<T>): void {
const { container, items, key, create, update } = opts;
// Build map of existing children by data-key attribute
const existingByKey = new Map<string, Element>();
for (let i = container.children.length - 1; i >= 0; i--) {
const child = container.children[i]!;
const k = child.getAttribute("data-reconcile-key");
if (k !== null) {
existingByKey.set(k, child);
}
}
const newKeys = new Set<string>();
// Walk new items, create/update/reorder
for (let i = 0; i < items.length; i++) {
const item = items[i]!;
const k = key(item);
newKeys.add(k);
let el = existingByKey.get(k);
if (el !== undefined) {
// Update existing element
update(el, item);
} else {
// Create new element
el = create(item);
el.setAttribute("data-reconcile-key", k);
}
// Move/insert to correct position
const currentAtPosition = container.children[i];
if (currentAtPosition !== el) {
container.insertBefore(el, currentAtPosition ?? null);
}
}
// Remove elements whose keys are no longer in the list
for (const [k, el] of existingByKey) {
if (!newKeys.has(k)) {
el.remove();
}
}
}
+87 -1
View File
@@ -2,6 +2,18 @@
* Generic reactive store foundation for OwnCord Tauri client.
* Immutable state updates only — setState receives an updater
* that must return a NEW state object.
*
* Subscription flow:
*
* setState(updater)
* │
* ▼
* queueMicrotask (batch)
* │
* ├─► subscribe() listeners ← fire on EVERY change
* │
* └─► subscribeSelector() ← fire only when selected
* listeners slice changes (via ===)
*/
export interface Store<T> {
@@ -21,6 +33,22 @@ export interface Store<T> {
*/
subscribe(listener: (state: T) => void): () => void;
/**
* Subscribe to a derived slice of state. The listener only fires when
* the selector's return value changes (by default via `===`).
*
* IMPORTANT: Selectors must return stable references for unchanged data.
* A selector like `s => ({ ...s.users })` creates a new object every time
* and will fire on every update, defeating the purpose. Instead use
* `s => s.users` to return the existing reference, or pass a custom
* `isEqual` comparator for value-based comparison.
*/
subscribeSelector<S>(
selector: (state: T) => S,
listener: (selected: S) => void,
isEqual?: (a: S, b: S) => boolean,
): () => void;
/** Derive a value from the current state using a selector function. */
select<S>(selector: (state: T) => S): S;
@@ -28,6 +56,49 @@ export interface Store<T> {
flush(): void;
}
/** Shallow-compare two values. Returns true if they are structurally equal
* at the top level (same keys/length and identical element references). */
function shallowEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
// Map: compare by size and entry identity
if (a instanceof Map && b instanceof Map) {
if (a.size !== b.size) return false;
for (const [key, val] of a) {
if (!b.has(key) || b.get(key) !== val) return false;
}
return true;
}
// Set: compare by size and membership
if (a instanceof Set && b instanceof Set) {
if (a.size !== b.size) return false;
for (const val of a) {
if (!b.has(val)) return false;
}
return true;
}
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if ((a as Record<string, unknown>)[key] !== (b as Record<string, unknown>)[key]) return false;
}
return true;
}
export { shallowEqual };
export function createStore<T>(initialState: T): Store<T> {
let state: T = initialState;
const listeners: Set<(state: T) => void> = new Set();
@@ -57,6 +128,21 @@ export function createStore<T>(initialState: T): Store<T> {
};
}
function subscribeSelector<S>(
selector: (state: T) => S,
listener: (selected: S) => void,
isEqual: (a: S, b: S) => boolean = (a, b) => shallowEqual(a, b),
): () => void {
let prev: S = selector(state);
return subscribe((newState) => {
const next = selector(newState);
if (!isEqual(prev, next)) {
prev = next;
listener(next);
}
});
}
function select<S>(selector: (state: T) => S): S {
return selector(state);
}
@@ -70,5 +156,5 @@ export function createStore<T>(initialState: T): Store<T> {
}
}
return { getState, setState, subscribe, select, flush };
return { getState, setState, subscribe, subscribeSelector, select, flush };
}
+150
View File
@@ -0,0 +1,150 @@
/**
* Theme manager for OwnCord.
*
* Built-in themes are applied via body CSS class (e.g. `theme-dark`).
* Custom themes override CSS variables inline on document.body.
* The active theme name is persisted to localStorage.
*/
const STORAGE_KEY_ACTIVE = "owncord:theme:active";
const STORAGE_KEY_CUSTOM_PREFIX = "owncord:theme:custom:";
export interface OwnCordTheme {
readonly name: string;
readonly author: string;
readonly version: string;
readonly colors: Readonly<Record<string, string>>;
}
const BUILT_IN_THEMES: readonly string[] = ["dark", "neon-glow", "midnight", "light"];
/** Returns all known theme names: built-ins first, then any saved custom themes. */
export function listThemeNames(): readonly string[] {
const custom: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key !== null && key.startsWith(STORAGE_KEY_CUSTOM_PREFIX)) {
custom.push(key.slice(STORAGE_KEY_CUSTOM_PREFIX.length));
}
}
return [...BUILT_IN_THEMES, ...custom];
}
/**
* Apply a theme by name.
* - Built-in themes: adds `theme-<name>` class to document.body.
* - Custom themes: adds `theme-custom` class and sets inline CSS variables.
* - Persists the active theme name to localStorage.
*/
export function applyThemeByName(name: string): void {
// Remove all existing theme- classes
for (const cls of [...document.body.classList]) {
if (cls.startsWith("theme-")) {
document.body.classList.remove(cls);
}
}
// Remove any previously injected inline CSS variable overrides
const style = document.body.style;
for (let i = style.length - 1; i >= 0; i--) {
const prop = style.item(i);
if (prop.startsWith("--")) {
style.removeProperty(prop);
}
}
if (BUILT_IN_THEMES.includes(name)) {
document.body.classList.add(`theme-${name}`);
} else {
const theme = loadCustomTheme(name);
if (theme !== null) {
document.body.classList.add("theme-custom");
for (const [prop, value] of Object.entries(theme.colors)) {
// Validate: property must be a CSS custom property with a spec-compliant
// ident name; value must only contain safe CSS value characters to
// prevent CSS injection from untrusted theme JSON files.
if (!prop.startsWith("--") || !/^[a-zA-Z_][\w-]*$/.test(prop.slice(2))) continue;
if (typeof value !== "string") continue;
// Allowlist: only permit characters found in typical CSS color/sizing values.
// Blocks url(), expression(), semicolons, braces, and !important.
if (!/^[\w\s#().,%+\-/]+$/.test(value)) continue;
// Deny-list: block dangerous CSS functions that slip through the allowlist.
if (/\b(url|expression|import|image|cross-fade|element)\s*\(/i.test(value)) continue;
style.setProperty(prop, value);
}
}
}
localStorage.setItem(STORAGE_KEY_ACTIVE, name);
}
/** Returns the currently active theme name, defaulting to "neon-glow". */
export function getActiveThemeName(): string {
return localStorage.getItem(STORAGE_KEY_ACTIVE) ?? "neon-glow";
}
/** Persists a custom theme to localStorage. */
export function saveCustomTheme(theme: OwnCordTheme): void {
localStorage.setItem(
STORAGE_KEY_CUSTOM_PREFIX + theme.name,
JSON.stringify(theme),
);
}
/** Loads a custom theme by name, or null if not found / parse error / invalid shape. */
export function loadCustomTheme(name: string): OwnCordTheme | null {
const raw = localStorage.getItem(STORAGE_KEY_CUSTOM_PREFIX + name);
if (raw === null) return null;
try {
const parsed: unknown = JSON.parse(raw);
if (
typeof parsed !== "object" || parsed === null ||
typeof (parsed as Record<string, unknown>).name !== "string" ||
typeof (parsed as Record<string, unknown>).colors !== "object"
) {
return null;
}
return parsed as OwnCordTheme;
} catch {
return null;
}
}
/**
* Removes a custom theme from localStorage.
* If it was the active theme, falls back to "dark".
*/
export function deleteCustomTheme(name: string): void {
localStorage.removeItem(STORAGE_KEY_CUSTOM_PREFIX + name);
if (getActiveThemeName() === name) {
applyThemeByName("dark");
}
}
/** Serialises a theme to a JSON string suitable for file export/import. */
export function exportTheme(theme: OwnCordTheme): string {
return JSON.stringify(theme, null, 2);
}
/**
* Restores the previously persisted theme and accent color on application startup.
* Call once from the app entry point.
*/
export function restoreTheme(): void {
applyThemeByName(getActiveThemeName());
// Restore the user's accent color override (saved by AppearanceTab).
// The accent must be applied after the theme so it wins over the theme's
// --accent value via inline style specificity.
try {
const raw = localStorage.getItem("owncord:settings:accentColor");
if (raw !== null) {
const accent = JSON.parse(raw);
if (typeof accent === "string" && /^#[\da-fA-F]{3,8}$/.test(accent)) {
document.documentElement.style.setProperty("--accent", accent);
document.body.style.setProperty("--accent", accent);
}
}
} catch {
// Corrupted localStorage — ignore, theme default will apply.
}
}
+64 -29
View File
@@ -12,26 +12,23 @@
export type UserStatus = "online" | "idle" | "dnd" | "offline";
/** Channel types supported by the server. */
export type ChannelType = "text" | "voice" | "announcement";
export type ChannelType = "text" | "voice" | "announcement" | "dm";
/** Voice quality presets. */
export type VoiceQuality = "low" | "medium" | "high";
/** Voice threshold mode. CRITICAL: always "threshold_mode", never "mode". */
export type ThresholdMode = "forwarding" | "selective";
/** Reaction action direction. */
export type ReactionAction = "add" | "remove";
/** WebSocket error codes returned by the server. */
export type WsErrorCode =
| "BANNED"
| "FORBIDDEN"
| "NOT_FOUND"
| "RATE_LIMITED"
| "INVALID_INPUT"
| "SERVER_ERROR"
| "CHANNEL_FULL"
| "INVALID_SDP"
| "VOICE_ERROR"
| "VIDEO_LIMIT";
@@ -70,6 +67,8 @@ export interface Attachment {
readonly size: number;
readonly mime: string;
readonly url: string;
readonly width?: number;
readonly height?: number;
}
/** Reaction summary on a REST message response. */
@@ -175,6 +174,7 @@ export interface ReadyPayload {
readonly members: readonly ReadyMember[];
readonly voice_states: readonly ReadyVoiceState[];
readonly roles: readonly ReadyRole[];
readonly dm_channels?: readonly DmChannelPayload[];
}
export interface ChatMessagePayload {
@@ -262,7 +262,7 @@ export interface VoiceConfigPayload {
readonly channel_id: number;
readonly quality: VoiceQuality;
readonly bitrate: number;
readonly threshold_mode: ThresholdMode;
readonly threshold_mode: string;
readonly mixing_threshold: number;
readonly top_speakers: number;
readonly max_users: number;
@@ -272,22 +272,14 @@ export interface VoiceConfigPayload {
export interface VoiceSpeakersPayload {
readonly channel_id: number;
readonly speakers: readonly number[];
readonly threshold_mode: ThresholdMode;
readonly threshold_mode?: string;
}
export interface VoiceOfferPayload {
export interface VoiceTokenPayload {
readonly channel_id: number;
readonly sdp: string;
}
export interface VoiceAnswerPayload {
readonly channel_id: number;
readonly sdp: string;
}
export interface VoiceIcePayload {
readonly channel_id: number;
readonly candidate: RTCIceCandidateInit;
readonly token: string;
readonly url: string;
readonly direct_url?: string;
}
export interface MemberJoinPayload {
@@ -307,6 +299,41 @@ export interface MemberBanPayload {
readonly user_id: number;
}
// -----------------------------------------------------------------------------
// DM Payloads (Server → Client)
// -----------------------------------------------------------------------------
/** DM recipient object in DM channel payloads. */
export interface DmRecipient {
readonly id: number;
readonly username: string;
readonly avatar: string;
readonly status: string;
}
/** DM channel object in ready payload and dm_channel_open event. */
export interface DmChannelPayload {
readonly channel_id: number;
readonly recipient: DmRecipient;
readonly last_message_id: number | null;
readonly last_message: string;
readonly last_message_at: string;
readonly unread_count: number;
}
export interface DmChannelOpenPayload {
readonly channel_id: number;
readonly recipient: DmRecipient;
readonly last_message_id: number | null;
readonly last_message: string;
readonly last_message_at: string;
readonly unread_count: number;
}
export interface DmChannelClosePayload {
readonly channel_id: number;
}
export interface ServerRestartPayload {
readonly reason: string;
readonly delay_seconds: number;
@@ -323,6 +350,7 @@ export interface ErrorPayload {
export interface AuthPayload {
readonly token: string;
readonly last_seq?: number;
}
export interface ChatSendPayload {
@@ -390,10 +418,6 @@ export interface SoundboardPlayPayload {
readonly sound_id: string;
}
// Note: VoiceOfferPayload, VoiceAnswerPayload, VoiceIcePayload are
// bidirectional — the same interface is used for both client→server
// and server→client directions. See definitions above.
// -----------------------------------------------------------------------------
// Discriminated Union: Server → Client Messages
// -----------------------------------------------------------------------------
@@ -416,13 +440,13 @@ export type ServerMessage =
| (WsEnvelope<VoiceLeavePayload> & { readonly type: "voice_leave" })
| (WsEnvelope<VoiceConfigPayload> & { readonly type: "voice_config" })
| (WsEnvelope<VoiceSpeakersPayload> & { readonly type: "voice_speakers" })
| (WsEnvelope<VoiceOfferPayload> & { readonly type: "voice_offer" })
| (WsEnvelope<VoiceAnswerPayload> & { readonly type: "voice_answer" })
| (WsEnvelope<VoiceIcePayload> & { readonly type: "voice_ice" })
| (WsEnvelope<VoiceTokenPayload> & { readonly type: "voice_token" })
| (WsEnvelope<MemberJoinPayload> & { readonly type: "member_join" })
| (WsEnvelope<MemberLeavePayload> & { readonly type: "member_leave" })
| (WsEnvelope<MemberUpdatePayload> & { readonly type: "member_update" })
| (WsEnvelope<MemberBanPayload> & { readonly type: "member_ban" })
| (WsEnvelope<DmChannelOpenPayload> & { readonly type: "dm_channel_open" })
| (WsEnvelope<DmChannelClosePayload> & { readonly type: "dm_channel_close" })
| (WsEnvelope<ServerRestartPayload> & { readonly type: "server_restart" })
| (WsEnvelope<ErrorPayload> & { readonly type: "error" });
@@ -447,9 +471,7 @@ export type ClientMessage =
| (WsEnvelope<VoiceCameraPayload> & { readonly type: "voice_camera" })
| (WsEnvelope<VoiceScreensharePayload> & { readonly type: "voice_screenshare" })
| (WsEnvelope<SoundboardPlayPayload> & { readonly type: "soundboard_play" })
| (WsEnvelope<VoiceOfferPayload> & { readonly type: "voice_offer" })
| (WsEnvelope<VoiceAnswerPayload> & { readonly type: "voice_answer" })
| (WsEnvelope<VoiceIcePayload> & { readonly type: "voice_ice" });
| (WsEnvelope<Record<string, never>> & { readonly type: "voice_token_refresh" });
// -----------------------------------------------------------------------------
// REST API Response Types
@@ -473,6 +495,7 @@ export interface HealthResponse {
readonly status: string;
readonly version: string;
readonly uptime: number;
readonly online_users: number;
}
/** Single channel object from REST API. */
@@ -583,6 +606,18 @@ export interface UploadResponse {
readonly url: string;
}
/** GET /api/v1/dms response. */
export interface DmChannelsResponse {
readonly dm_channels: readonly DmChannelPayload[];
}
/** POST /api/v1/dms response. */
export interface CreateDmResponse {
readonly channel_id: number;
readonly recipient: DmRecipient;
readonly created: boolean;
}
/** TURN/STUN credentials from GET /api/voice/credentials. */
export interface IceServer {
readonly urls: string;
-183
View File
@@ -1,183 +0,0 @@
// =============================================================================
// Voice Activity Detection — Web Audio API based speech detection
// =============================================================================
import { createLogger } from "@lib/logger";
const log = createLogger("vad");
export interface VadOptions {
/** Audio volume threshold (0-1) to detect speech. Default 0.01 */
readonly threshold?: number;
/** How often to check volume in ms. Default 50 */
readonly intervalMs?: number;
/** Minimum consecutive detections before triggering. Default 3 */
readonly minConsecutive?: number;
}
export interface VadDetector {
start(stream: MediaStream): void;
stop(): void;
setThreshold(threshold: number): void;
onSpeakingChange(callback: (speaking: boolean) => void): () => void;
isSpeaking(): boolean;
destroy(): void;
}
type SpeakingCallback = (speaking: boolean) => void;
const DEFAULT_THRESHOLD = 0.01;
const DEFAULT_INTERVAL_MS = 50;
const DEFAULT_MIN_CONSECUTIVE = 3;
/** Max VAD threshold value. Sensitivity 0% maps to this threshold. */
const MAX_THRESHOLD = 0.15;
/** Convert sensitivity slider (0-100) to VAD threshold (0-MAX_THRESHOLD).
* High sensitivity = low threshold (picks up quiet sounds).
* 0% sensitivity = threshold 0.15 (only loud sounds trigger).
* 100% sensitivity = threshold 0.0 (everything triggers). */
export function sensitivityToThreshold(sensitivity: number): number {
return ((100 - sensitivity) / 100) * MAX_THRESHOLD;
}
// Require more silence samples than speech samples to prevent flicker
const SILENCE_MULTIPLIER = 2;
function computeRms(data: Uint8Array): number {
let sum = 0;
for (let i = 0; i < data.length; i++) {
const val = data[i];
if (val === undefined) continue;
// getByteFrequencyData returns 0-255 where 0 = silence, 255 = max.
// Normalize to 0-1 range.
const normalized = val / 255;
sum += normalized * normalized;
}
return Math.sqrt(sum / data.length);
}
export function createVadDetector(options?: VadOptions): VadDetector {
let threshold = options?.threshold ?? DEFAULT_THRESHOLD;
const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS;
const minConsecutive = options?.minConsecutive ?? DEFAULT_MIN_CONSECUTIVE;
const silenceRequired = minConsecutive * SILENCE_MULTIPLIER;
let audioContext: AudioContext | null = null;
let analyser: AnalyserNode | null = null;
let sourceNode: MediaStreamAudioSourceNode | null = null;
let intervalId: ReturnType<typeof setInterval> | null = null;
let destroyed = false;
let speaking = false;
let consecutiveAbove = 0;
let consecutiveBelow = 0;
const callbacks = new Set<SpeakingCallback>();
function emitChange(newState: boolean): void {
if (speaking === newState) return;
speaking = newState;
for (const cb of callbacks) {
cb(speaking);
}
}
function tick(): void {
if (analyser === null) return;
const data = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(data);
const rms = computeRms(data);
if (rms >= threshold) {
consecutiveAbove++;
consecutiveBelow = 0;
if (!speaking && consecutiveAbove >= minConsecutive) {
emitChange(true);
}
} else {
consecutiveBelow++;
consecutiveAbove = 0;
if (speaking && consecutiveBelow >= silenceRequired) {
emitChange(false);
}
}
}
function cleanup(): void {
if (intervalId !== null) {
clearInterval(intervalId);
intervalId = null;
}
if (sourceNode !== null) {
sourceNode.disconnect();
sourceNode = null;
}
if (analyser !== null) {
analyser.disconnect();
analyser = null;
}
if (audioContext !== null) {
void audioContext.close();
audioContext = null;
}
consecutiveAbove = 0;
consecutiveBelow = 0;
if (speaking) {
emitChange(false);
}
}
return {
start(stream: MediaStream): void {
if (destroyed) throw new Error("VadDetector has been destroyed");
// Stop any existing monitoring first
cleanup();
// Force 48kHz so FFT bins cover the voice-frequency range (0-24kHz)
// consistently regardless of the system audio device's native rate.
// At high native rates (e.g. 192kHz), most bins would be above voice
// frequencies, making the RMS calculation artificially low.
audioContext = new AudioContext({ sampleRate: 48000 });
analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.5;
sourceNode = audioContext.createMediaStreamSource(stream);
sourceNode.connect(analyser);
intervalId = setInterval(tick, intervalMs);
log.debug("VAD started", { threshold, intervalMs, minConsecutive, sampleRate: audioContext.sampleRate });
},
stop(): void {
if (destroyed) return;
cleanup();
},
setThreshold(newThreshold: number): void {
if (newThreshold < 0 || newThreshold > 1) {
throw new Error("Threshold must be between 0 and 1");
}
log.debug("VAD threshold changed", { old: threshold, new: newThreshold });
threshold = newThreshold;
},
onSpeakingChange(callback: SpeakingCallback): () => void {
callbacks.add(callback);
return () => { callbacks.delete(callback); };
},
isSpeaking(): boolean {
return speaking;
},
destroy(): void {
if (destroyed) return;
destroyed = true;
cleanup();
callbacks.clear();
log.debug("VAD destroyed");
},
};
}
-144
View File
@@ -1,144 +0,0 @@
// =============================================================================
// Video Device Manager — enumerate cameras, acquire streams, stop capture
// =============================================================================
import { createLogger } from "@lib/logger";
const log = createLogger("video");
export interface VideoDevice {
readonly deviceId: string;
readonly label: string;
readonly kind: "videoinput";
}
export interface VideoManager {
enumerateDevices(): Promise<readonly VideoDevice[]>;
getCameraStream(deviceId?: string): Promise<MediaStream>;
stopCameraStream(): void;
getCurrentStream(): MediaStream | null;
onDeviceChange(callback: (devices: readonly VideoDevice[]) => void): () => void;
destroy(): void;
}
type DeviceChangeCallback = (devices: readonly VideoDevice[]) => void;
function toVideoDevice(info: MediaDeviceInfo): VideoDevice | null {
if (info.kind !== "videoinput") return null;
return {
deviceId: info.deviceId,
label: info.label || `Camera (${info.deviceId.slice(0, 8)})`,
kind: "videoinput",
};
}
export function createVideoManager(): VideoManager {
let currentStream: MediaStream | null = null;
let destroyed = false;
const deviceChangeCallbacks = new Set<DeviceChangeCallback>();
async function listVideoDevices(): Promise<readonly VideoDevice[]> {
const devices = await navigator.mediaDevices.enumerateDevices();
const videoDevices: VideoDevice[] = [];
for (const d of devices) {
const mapped = toVideoDevice(d);
if (mapped !== null) {
videoDevices.push(mapped);
}
}
return videoDevices;
}
function handleDeviceChange(): void {
if (destroyed) return;
void listVideoDevices().then((devices) => {
log.info("Video device change detected", {
cameras: devices.length,
});
for (const cb of deviceChangeCallbacks) {
cb(devices);
}
});
}
navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange);
return {
async enumerateDevices(): Promise<readonly VideoDevice[]> {
if (destroyed) throw new Error("VideoManager has been destroyed");
return listVideoDevices();
},
async getCameraStream(deviceId?: string): Promise<MediaStream> {
if (destroyed) throw new Error("VideoManager has been destroyed");
// Stop any existing stream before acquiring a new one
if (currentStream !== null) {
for (const track of currentStream.getTracks()) {
track.stop();
}
currentStream = null;
}
const constraints: MediaStreamConstraints = {
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
deviceId: deviceId !== undefined ? { exact: deviceId } : undefined,
},
audio: false,
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
currentStream = stream;
const videoTrack = stream.getVideoTracks()[0];
if (videoTrack !== undefined) {
const settings = videoTrack.getSettings();
log.info("Camera acquired", {
deviceId: settings.deviceId ?? deviceId ?? null,
width: settings.width,
height: settings.height,
frameRate: settings.frameRate,
});
}
return stream;
},
stopCameraStream(): void {
if (currentStream === null) return;
for (const track of currentStream.getTracks()) {
track.stop();
}
currentStream = null;
log.info("Camera stream stopped");
},
getCurrentStream(): MediaStream | null {
return currentStream;
},
onDeviceChange(callback: DeviceChangeCallback): () => void {
deviceChangeCallbacks.add(callback);
return () => { deviceChangeCallbacks.delete(callback); };
},
destroy(): void {
if (destroyed) return;
destroyed = true;
navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange);
log.debug("VideoManager destroying", { hasStream: currentStream !== null });
if (currentStream !== null) {
for (const track of currentStream.getTracks()) {
track.stop();
}
currentStream = null;
}
deviceChangeCallbacks.clear();
},
};
}
File diff suppressed because it is too large Load Diff
-394
View File
@@ -1,394 +0,0 @@
// =============================================================================
// WebRTC Service — peer connection management for voice communication
// =============================================================================
import { createLogger } from "@lib/logger";
const log = createLogger("webrtc");
export interface WebRtcConfig {
readonly iceServers: readonly RTCIceServer[];
readonly opusBitrate?: number;
}
export interface WebRtcService {
createConnection(config: WebRtcConfig): void;
handleOffer(sdp: string): Promise<string>;
handleAnswer(sdp: string): Promise<void>;
handleServerOffer(sdp: string): Promise<string>;
createOffer(iceRestart?: boolean): Promise<string>;
handleIceCandidate(candidate: RTCIceCandidateInit): Promise<void>;
/** Add a video track to the PeerConnection. Returns the sender for removal. */
addVideoTrack(stream: MediaStream): RTCRtpSender | null;
/** Remove the video track sender from the PeerConnection. */
removeVideoTrack(sender: RTCRtpSender): void;
setLocalStream(stream: MediaStream): void;
/** Swap the media track on existing senders without SDP renegotiation. */
replaceTrack(stream: MediaStream): Promise<void>;
getRemoteStreams(): readonly MediaStream[];
setMuted(muted: boolean): void;
setSilenced(silenced: boolean): void;
onIceCandidate(callback: (candidate: RTCIceCandidateInit) => void): () => void;
onRemoteTrack(callback: (stream: MediaStream) => void): () => void;
onStateChange(callback: (state: RTCPeerConnectionState) => void): () => void;
onIceStateChange(callback: (state: RTCIceConnectionState) => void): () => void;
destroy(): void;
}
type IceCandidateCallback = (candidate: RTCIceCandidateInit) => void;
type RemoteTrackCallback = (stream: MediaStream) => void;
type StateChangeCallback = (state: RTCPeerConnectionState) => void;
type IceStateCallback = (state: RTCIceConnectionState) => void;
/** Apply Opus bitrate and FEC constraints via SDP munging. */
function applyOpusSettings(sdp: string, bitrate: number | undefined): string {
const lines = sdp.split("\r\n");
const result: string[] = [];
let inAudioSection = false;
let bitrateInserted = false;
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
if (line === undefined) continue;
// Track which media section we're in
if (line.startsWith("m=audio")) {
inAudioSection = true;
bitrateInserted = false;
} else if (line.startsWith("m=")) {
inAudioSection = false;
}
// Enable Opus in-band FEC for packet loss resilience
if (line.startsWith("a=fmtp:111 ")) {
if (!line.includes("useinbandfec=")) {
line += ";useinbandfec=1";
}
}
result.push(line);
// Insert b=AS after m=audio line (always present, unlike c= which may
// only exist at session level)
if (inAudioSection && !bitrateInserted && bitrate !== undefined && line.startsWith("m=audio")) {
result.push(`b=AS:${Math.round(bitrate / 1000)}`);
bitrateInserted = true;
}
}
return result.join("\r\n");
}
export function createWebRtcService(): WebRtcService {
let pc: RTCPeerConnection | null = null;
let localSenders: readonly RTCRtpSender[] = [];
let isMuted = false;
let isSilenced = false;
let remoteStreams: readonly MediaStream[] = [];
let opusBitrate: number | undefined;
let destroyed = false;
/** True once setRemoteDescription has been called (ICE candidates are safe). */
let hasRemoteDescription = false;
/** Queue ICE candidates that arrive before the remote description is set. */
const pendingIceCandidates: RTCIceCandidateInit[] = [];
const iceCandidateCallbacks = new Set<IceCandidateCallback>();
const remoteTrackCallbacks = new Set<RemoteTrackCallback>();
const stateChangeCallbacks = new Set<StateChangeCallback>();
const iceStateCallbacks = new Set<IceStateCallback>();
function assertConnection(): RTCPeerConnection {
if (destroyed) throw new Error("WebRTC service has been destroyed");
if (pc === null) throw new Error("No peer connection created");
return pc;
}
/** Flush queued ICE candidates now that the remote description is set. */
async function flushIceCandidates(conn: RTCPeerConnection): Promise<void> {
hasRemoteDescription = true;
const queued = pendingIceCandidates.splice(0);
if (queued.length > 0) {
log.debug("Flushing queued ICE candidates", { count: queued.length });
}
for (const c of queued) {
await conn.addIceCandidate(c);
}
}
/** Apply track.enabled based on current mute + silence state. */
function applyTrackEnabled(): void {
for (const sender of localSenders) {
const track = sender.track;
if (track !== null) {
track.enabled = !isMuted && !isSilenced;
}
}
}
function handleIceCandidateEvent(event: RTCPeerConnectionIceEvent): void {
if (event.candidate === null) {
log.debug("ICE gathering complete");
return;
}
const c = event.candidate;
log.debug("Local ICE candidate", {
type: c.type,
address: c.address,
port: c.port,
protocol: c.protocol,
candidate: c.candidate,
});
const init: RTCIceCandidateInit = {
candidate: c.candidate,
sdpMid: c.sdpMid,
sdpMLineIndex: c.sdpMLineIndex,
};
for (const cb of iceCandidateCallbacks) {
cb(init);
}
}
function handleTrackEvent(event: RTCTrackEvent): void {
const stream = event.streams[0];
if (stream === undefined) {
log.warn("Remote track event with no stream");
return;
}
const isNew = !remoteStreams.some((s) => s.id === stream.id);
if (isNew) {
remoteStreams = [...remoteStreams, stream];
}
log.info("Remote track received", {
streamId: stream.id,
trackId: event.track.id,
kind: event.track.kind,
isNew,
totalStreams: remoteStreams.length,
});
// Always notify — renegotiation may add new tracks to existing
// streams (e.g. after leave/rejoin with the same stream ID).
for (const cb of remoteTrackCallbacks) {
cb(stream);
}
}
function handleConnectionStateChange(): void {
if (pc === null) return;
const state = pc.connectionState;
for (const cb of stateChangeCallbacks) {
cb(state);
}
}
function handleIceConnectionStateChange(): void {
if (pc === null) return;
const state = pc.iceConnectionState;
for (const cb of iceStateCallbacks) {
cb(state);
}
}
function handleNegotiationNeeded(): void {
// Log canary — if this fires, something triggered SDP renegotiation
// that our explicit offer/answer flow didn't handle. Upgrade to a
// full handler (auto-create offer) if this shows up in production.
log.warn("negotiationneeded fired unexpectedly", { signalingState: pc?.signalingState ?? "none" });
}
function mungeIfNeeded(sdp: string | undefined): string {
if (sdp === undefined) return "";
return applyOpusSettings(sdp, opusBitrate);
}
return {
createConnection(config: WebRtcConfig): void {
if (destroyed) throw new Error("WebRTC service has been destroyed");
if (pc !== null) {
pc.close();
}
opusBitrate = config.opusBitrate;
remoteStreams = [];
localSenders = [];
isMuted = false;
isSilenced = false;
hasRemoteDescription = false;
pendingIceCandidates.length = 0;
pc = new RTCPeerConnection({
iceServers: [...config.iceServers],
});
pc.addEventListener("icecandidate", handleIceCandidateEvent);
pc.addEventListener("track", handleTrackEvent);
pc.addEventListener("connectionstatechange", handleConnectionStateChange);
pc.addEventListener("iceconnectionstatechange", handleIceConnectionStateChange);
pc.addEventListener("negotiationneeded", handleNegotiationNeeded);
log.info("PeerConnection created", {
iceServerCount: config.iceServers.length,
opusBitrate: config.opusBitrate,
});
},
async handleOffer(sdp: string): Promise<string> {
const conn = assertConnection();
await conn.setRemoteDescription({ type: "offer", sdp });
await flushIceCandidates(conn);
const answer = await conn.createAnswer();
const mungedSdp = mungeIfNeeded(answer.sdp);
await conn.setLocalDescription({ type: "answer", sdp: mungedSdp });
return mungedSdp;
},
async handleAnswer(sdp: string): Promise<void> {
const conn = assertConnection();
await conn.setRemoteDescription({ type: "answer", sdp });
await flushIceCandidates(conn);
},
async handleServerOffer(sdp: string): Promise<string> {
const conn = assertConnection();
if (conn.signalingState === "have-local-offer") {
log.info("Rolling back local offer for server renegotiation (glare)");
await conn.setLocalDescription({ type: "rollback" });
}
await conn.setRemoteDescription({ type: "offer", sdp });
await flushIceCandidates(conn);
const answer = await conn.createAnswer();
const mungedSdp = mungeIfNeeded(answer.sdp);
await conn.setLocalDescription({ type: "answer", sdp: mungedSdp });
return mungedSdp;
},
async createOffer(iceRestart = false): Promise<string> {
const conn = assertConnection();
const offer = await conn.createOffer({ iceRestart });
const mungedSdp = mungeIfNeeded(offer.sdp);
await conn.setLocalDescription({ type: "offer", sdp: mungedSdp });
return mungedSdp;
},
async handleIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
const conn = assertConnection();
if (!hasRemoteDescription) {
pendingIceCandidates.push(candidate);
log.debug("ICE candidate queued (no remote description yet)", { queueDepth: pendingIceCandidates.length });
return;
}
await conn.addIceCandidate(candidate);
},
setLocalStream(stream: MediaStream): void {
const conn = assertConnection();
const removedCount = localSenders.length;
for (const sender of localSenders) {
conn.removeTrack(sender);
}
const newSenders = stream.getTracks().map((track) => conn.addTrack(track, stream));
localSenders = newSenders;
// Apply current mute/silence state to new tracks
applyTrackEnabled();
log.debug("Local stream set", { removedSenders: removedCount, addedTracks: newSenders.length });
},
async replaceTrack(stream: MediaStream): Promise<void> {
assertConnection();
const newTracks = stream.getAudioTracks();
if (newTracks.length === 0) {
log.warn("replaceTrack called with no audio tracks");
return;
}
const newTrack = newTracks[0]!;
if (localSenders.length > 0) {
// Swap track on existing sender — no SDP renegotiation needed
for (const sender of localSenders) {
await sender.replaceTrack(newTrack);
}
log.debug("Track replaced on existing senders", { senderCount: localSenders.length, trackId: newTrack.id });
} else {
// No existing senders — fall back to addTrack (initial attach)
log.debug("replaceTrack fallback: no senders, using addTrack");
const conn = assertConnection();
const newSenders = stream.getTracks().map((track) => conn.addTrack(track, stream));
localSenders = newSenders;
}
// Apply current mute/silence state to the new track
applyTrackEnabled();
},
addVideoTrack(stream: MediaStream): RTCRtpSender | null {
const conn = assertConnection();
const videoTrack = stream.getVideoTracks()[0];
if (videoTrack === undefined) {
log.warn("addVideoTrack called with no video tracks");
return null;
}
const sender = conn.addTrack(videoTrack, stream);
log.info("Video track added to PeerConnection", { trackId: videoTrack.id });
return sender;
},
removeVideoTrack(sender: RTCRtpSender): void {
const conn = assertConnection();
conn.removeTrack(sender);
log.info("Video track removed from PeerConnection");
},
getRemoteStreams(): readonly MediaStream[] {
return remoteStreams;
},
setMuted(muted: boolean): void {
isMuted = muted;
applyTrackEnabled();
},
setSilenced(silenced: boolean): void {
isSilenced = silenced;
applyTrackEnabled();
},
onIceCandidate(callback: IceCandidateCallback): () => void {
iceCandidateCallbacks.add(callback);
return () => { iceCandidateCallbacks.delete(callback); };
},
onRemoteTrack(callback: RemoteTrackCallback): () => void {
remoteTrackCallbacks.add(callback);
return () => { remoteTrackCallbacks.delete(callback); };
},
onStateChange(callback: StateChangeCallback): () => void {
stateChangeCallbacks.add(callback);
return () => { stateChangeCallbacks.delete(callback); };
},
onIceStateChange(callback: IceStateCallback): () => void {
iceStateCallbacks.add(callback);
return () => { iceStateCallbacks.delete(callback); };
},
destroy(): void {
if (destroyed) return;
destroyed = true;
log.debug("WebRTC service destroying", { remoteStreams: remoteStreams.length, localSenders: localSenders.length });
if (pc !== null) {
pc.removeEventListener("icecandidate", handleIceCandidateEvent);
pc.removeEventListener("track", handleTrackEvent);
pc.removeEventListener("connectionstatechange", handleConnectionStateChange);
pc.removeEventListener("iceconnectionstatechange", handleIceConnectionStateChange);
pc.removeEventListener("negotiationneeded", handleNegotiationNeeded);
pc.close();
pc = null;
}
localSenders = [];
remoteStreams = [];
pendingIceCandidates.length = 0;
iceCandidateCallbacks.clear();
remoteTrackCallbacks.clear();
stateChangeCallbacks.clear();
iceStateCallbacks.clear();
},
};
}
+19 -4
View File
@@ -78,6 +78,7 @@ export function createWsClient() {
let intentionalClose = false;
let certMismatchBlock = false; // blocks reconnect on TOFU mismatch
let proxyOpen = false;
let lastSeq = 0;
// Tauri event unsubscribe functions
const eventUnsubs: Array<() => void> = [];
@@ -95,7 +96,11 @@ export function createWsClient() {
if (state !== newState) {
state = newState;
for (const listener of stateListeners) {
listener(state);
try {
listener(state);
} catch (err) {
log.error("State listener error", err);
}
}
}
}
@@ -151,14 +156,20 @@ export function createWsClient() {
return;
}
let parsed: { type?: string; payload?: unknown; id?: string };
let parsed: { type?: string; payload?: unknown; id?: string; seq?: number };
try {
parsed = JSON.parse(raw) as { type?: string; payload?: unknown; id?: string };
parsed = JSON.parse(raw) as { type?: string; payload?: unknown; id?: string; seq?: number };
} catch {
log.warn("Failed to parse WS message", { data: raw });
return;
}
// Track the highest sequence number for reconnection replay.
const seq = typeof parsed.seq === "number" ? parsed.seq : 0;
if (seq > lastSeq) {
lastSeq = seq;
}
// Server pong messages have no payload — silently ignore.
if (parsed.type === "pong") return;
@@ -227,7 +238,7 @@ export function createWsClient() {
proxyOpen = true;
log.info("WebSocket open, sending auth");
setState("authenticating");
send({ type: "auth", payload: { token: config!.token } });
send({ type: "auth", payload: { token: config!.token, last_seq: lastSeq } });
} else if (rustState === "closed") {
proxyOpen = false;
log.info("WebSocket closed (proxy)");
@@ -359,6 +370,10 @@ export function createWsClient() {
cleanupEventListeners();
void disconnectProxy();
setState("disconnected");
// Reset lastSeq — disconnect() is only called for intentional close
// (logout). Automatic reconnects go through scheduleReconnect() which
// preserves lastSeq for server-side event replay.
lastSeq = 0;
}
return {
+133 -33
View File
@@ -4,6 +4,7 @@ import "@styles/tokens.css";
import "@styles/base.css";
import "@styles/login.css";
import "@styles/app.css";
import "@styles/theme-neon-glow.css";
import { installGlobalErrorHandlers, safeMount } from "@lib/safe-render";
import { createRouter } from "@lib/router";
@@ -12,15 +13,16 @@ import { createWsClient } from "@lib/ws";
import { wireDispatcher } from "@lib/dispatcher";
import { authStore, setAuth, clearAuth } from "@stores/auth.store";
import { voiceStore, leaveVoiceChannel } from "@stores/voice.store";
import { leaveVoice as voiceSessionLeave } from "@lib/voiceSession";
import { leaveVoice as voiceSessionLeave } from "@lib/livekitSession";
import { createConnectPage } from "@pages/ConnectPage";
import { createMainPage } from "@pages/MainPage";
import { applyStoredAppearance } from "@components/SettingsOverlay";
import { restoreTheme } from "@lib/themes";
import { initPtt } from "@lib/ptt";
import { createConnectedOverlay } from "@components/ConnectedOverlay";
import type { ConnectedOverlayControl } from "@components/ConnectedOverlay";
import { createLogger } from "@lib/logger";
import { saveCredential, deleteCredential } from "@lib/credentials";
import { saveCredential, loadCredential, deleteCredential } from "@lib/credentials";
import { initWindowState } from "@lib/window-state";
import { createCertMismatchModal } from "@components/CertMismatchModal";
import { createProfileManager, createTauriBackend } from "@lib/profiles";
@@ -35,6 +37,16 @@ document.addEventListener("contextmenu", (e) => {
e.preventDefault();
});
// F12 or Ctrl+Shift+I opens WebView2 DevTools.
document.addEventListener("keydown", (e) => {
if (e.key === "F12" || (e.ctrlKey && e.shiftKey && e.key === "I")) {
e.preventDefault();
void import("@tauri-apps/api/core").then(({ invoke }) => {
void invoke("open_devtools");
});
}
});
// Open external links (target="_blank") in the user's default browser.
document.addEventListener("click", (e) => {
const link = (e.target as HTMLElement).closest("a[target='_blank']") as HTMLAnchorElement | null;
@@ -52,6 +64,9 @@ installGlobalErrorHandlers();
// Apply stored theme/font/compact preferences before first render
applyStoredAppearance();
// Restore saved theme (body class) before first render
restoreTheme();
// Start push-to-talk listener (Rust-side polling, non-consuming)
void initPtt();
@@ -113,7 +128,7 @@ let currentPage: { destroy?(): void } | null = null;
/** Run health checks for a list of profiles and update the connect page. */
function runHealthChecks(
connectPage: { updateHealthStatus(host: string, status: { status: string; latencyMs: number | null; version: string | null }): void },
connectPage: { updateHealthStatus(host: string, status: { status: string; latencyMs: number | null; version: string | null; onlineUsers: number | null }): void },
profiles: readonly { host: string }[],
): void {
for (const profile of profiles) {
@@ -123,6 +138,7 @@ function runHealthChecks(
status: "checking",
latencyMs: null,
version: null,
onlineUsers: null,
});
const start = performance.now();
const health = await api.getHealth(profile.host, 3000);
@@ -131,12 +147,14 @@ function runHealthChecks(
status: elapsed > 1500 ? "slow" : "online",
latencyMs: elapsed,
version: health.version,
onlineUsers: health.online_users ?? null,
});
} catch {
connectPage.updateHealthStatus(profile.host, {
status: "offline",
latencyMs: null,
version: null,
onlineUsers: null,
});
}
})();
@@ -207,11 +225,11 @@ function renderPage(pageId: "connect" | "main"): void {
}
// Auto-save a profile for a host after successful login (if not already saved)
function ensureProfileExists(host: string, username: string): void {
function ensureProfileExists(host: string, username: string, rememberPassword: boolean): void {
const existing = profileManager.getAll().find((p) => p.host === host);
if (existing) {
// Update username and lastConnected
profileManager.updateProfile(existing.id, { username });
// Update username, rememberPassword preference, and lastConnected
profileManager.updateProfile(existing.id, { username, rememberPassword });
profileManager.setLastConnected(existing.id);
} else {
const created = profileManager.addProfile({
@@ -219,7 +237,7 @@ function renderPage(pageId: "connect" | "main"): void {
host,
username,
autoConnect: false,
rememberPassword: false,
rememberPassword,
color: "#5865F2",
});
profileManager.setLastConnected(created.id);
@@ -239,16 +257,18 @@ function renderPage(pageId: "connect" | "main"): void {
return;
}
if (result.token) {
const savedPassword = connectPage.getRememberPassword() ? password : undefined;
ensureProfileExists(host, username);
const remember = connectPage.getRememberPassword();
const savedPassword = remember ? password : undefined;
ensureProfileExists(host, username, remember);
wirePostAuth(host, result.token, username, savedPassword);
}
},
async onRegister(host, username, password, inviteCode) {
api.setConfig({ host });
const result = await api.register(username, password, inviteCode);
const savedPassword = connectPage.getRememberPassword() ? password : undefined;
ensureProfileExists(host, username);
const remember = connectPage.getRememberPassword();
const savedPassword = remember ? password : undefined;
ensureProfileExists(host, username, remember);
wirePostAuth(host, result.token, username, savedPassword);
},
async onTotpSubmit(code) {
@@ -258,8 +278,9 @@ function renderPage(pageId: "connect" | "main"): void {
}
const result = await api.verifyTotp(code, pendingTotpPartialToken);
if (result.token) {
const savedPassword = connectPage.getRememberPassword() ? connectPage.getPassword() : undefined;
ensureProfileExists(pendingTotpHost, pendingTotpUsername);
const remember = connectPage.getRememberPassword();
const savedPassword = remember ? connectPage.getPassword() : undefined;
ensureProfileExists(pendingTotpHost, pendingTotpUsername, remember);
wirePostAuth(pendingTotpHost, result.token, pendingTotpUsername, savedPassword);
}
},
@@ -282,10 +303,32 @@ function renderPage(pageId: "connect" | "main"): void {
void profileManager.saveProfiles();
connectPage.refreshProfiles(getProfileList());
},
onToggleAutoLogin(profileId, enabled) {
profileManager.setAutoLogin(enabled ? profileId : null);
void profileManager.saveProfiles();
connectPage.refreshProfiles(getProfileList());
},
onAutoLoginCancel() {
autoLoginCancelled = true;
},
}, getProfileList());
let autoLoginCancelled = false;
safeMount(connectPage, appEl!);
currentPage = connectPage;
// Periodic health check — re-run every 15s so offline servers update when they come back
const healthCheckInterval = setInterval(() => {
runHealthChecks(connectPage, getProfileList());
}, 15_000);
// Wrap destroy to clear the interval
currentPage = {
destroy() {
clearInterval(healthCheckInterval);
connectPage.destroy?.();
},
};
// Load saved profiles and kick off health checks
void (async () => {
@@ -298,6 +341,58 @@ function renderPage(pageId: "connect" | "main"): void {
log.warn("Failed to load profiles, using defaults", err);
runHealthChecks(connectPage, getProfileList());
}
// Quick-switch: if the user switched servers via the overlay, auto-select
// the target server profile so they can reconnect with one click.
const quickSwitchTarget = sessionStorage.getItem("owncord:quick-switch-target");
if (quickSwitchTarget !== null) {
sessionStorage.removeItem("owncord:quick-switch-target");
const targetProfile = profileManager.getAll().find((p) => p.host === quickSwitchTarget);
connectPage.selectServer(
quickSwitchTarget,
targetProfile?.username ?? undefined,
);
return; // Skip auto-login when switching servers
}
// Auto-login: if a profile has autoConnect enabled, try to connect automatically.
const autoProfile = profileManager.getAutoConnectProfile();
if (autoProfile) {
try {
const cred = await loadCredential(autoProfile.host);
if (cred?.username && cred?.password && !autoLoginCancelled) {
connectPage.selectServer(autoProfile.host, cred.username);
connectPage.showAutoConnecting(autoProfile.name);
// Attempt login
api.setConfig({ host: autoProfile.host });
const result = await api.login(cred.username, cred.password);
if (autoLoginCancelled) return;
if (result.requires_2fa) {
// Can't auto-login with 2FA — show TOTP overlay
pendingTotpHost = autoProfile.host;
pendingTotpPartialToken = result.partial_token ?? "";
pendingTotpUsername = cred.username;
connectPage.showTotp();
return;
}
if (result.token) {
ensureProfileExists(autoProfile.host, cred.username, true);
wirePostAuth(autoProfile.host, result.token, cred.username, cred.password);
return;
}
}
} catch (err) {
if (!autoLoginCancelled) {
const message = err instanceof Error ? err.message : "Auto-login failed";
log.warn("Auto-login failed", { host: autoProfile.host, error: message });
connectPage.showError(`Auto-login failed: ${message}`);
}
}
}
})();
} else {
const mainPage = createMainPage({ ws, api });
@@ -310,26 +405,31 @@ function renderPage(pageId: "connect" | "main"): void {
router.onNavigate(renderPage);
// Handle logout / disconnect
authStore.subscribe((state) => {
if (!state.isAuthenticated && router.getCurrentPage() === "main") {
// Leave voice channel before disconnecting so other clients see it immediately
const voice = voiceStore.getState();
if (voice.currentChannelId !== null) {
voiceSessionLeave(false); // false: we send voice_leave below
ws.send({ type: "voice_leave", payload: {} });
leaveVoiceChannel();
authStore.subscribeSelector(
(s) => s.isAuthenticated,
(isAuthenticated) => {
if (!isAuthenticated && router.getCurrentPage() === "main") {
// Leave voice channel before disconnecting so other clients see it immediately
const voice = voiceStore.getState();
if (voice.currentChannelId !== null) {
voiceSessionLeave(false); // false: we send voice_leave below
ws.send({ type: "voice_leave", payload: {} });
leaveVoiceChannel();
}
dispatcherCleanup?.();
dispatcherCleanup = null;
ws.disconnect();
lastConnectToken = "";
lastConnectHost = "";
// Clear stored credential on logout
const host = api.getConfig().host;
if (host) {
void deleteCredential(host);
}
router.navigate("connect");
}
dispatcherCleanup?.();
dispatcherCleanup = null;
ws.disconnect();
// Clear stored credential on logout
const host = api.getConfig().host;
if (host) {
void deleteCredential(host);
}
router.navigate("connect");
}
});
},
);
// Send voice_leave on window close (best-effort — server readPump defer is the safety net)
window.addEventListener("beforeunload", () => {
+164 -716
View File
@@ -1,28 +1,23 @@
// ConnectPage — login/register page component.
// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
// Thin composition shell that wires ServerPanel and LoginForm together.
import {
createElement,
setText,
appendChildren,
clearChildren,
qs,
} from "@lib/dom";
import { createElement, appendChildren } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
import { openSettings, closeSettings, uiStore, setTransientError } from "@stores/ui.store";
import { createSettingsOverlay } from "@components/SettingsOverlay";
import type { HealthStatus, ServerProfile } from "@lib/profiles";
import type { HealthStatus } from "@lib/profiles";
import { createServerPanel } from "./connect-page/ServerPanel";
import { createLoginForm } from "./connect-page/LoginForm";
import { loadCredential } from "@lib/credentials";
// ---------------------------------------------------------------------------
// Types
// Re-exports (public API must not change)
// ---------------------------------------------------------------------------
/** Form state machine states. */
export type FormState = "idle" | "loading" | "totp" | "connecting" | "error";
export type { FormState, FormMode } from "./connect-page/LoginForm";
export type { SimpleProfile } from "./connect-page/ServerPanel";
/** Form mode: login or register. */
export type FormMode = "login" | "register";
import type { SimpleProfile } from "./connect-page/ServerPanel";
/** Callbacks for external wiring (API integration added later). */
export interface ConnectPageCallbacks {
@@ -36,42 +31,18 @@ export interface ConnectPageCallbacks {
onTotpSubmit(code: string): Promise<void>;
onAddProfile?(name: string, host: string): void;
onDeleteProfile?(profileId: string): void;
}
/** Minimal profile shape for the default profile list (backward compat). */
export interface SimpleProfile {
readonly name: string;
readonly host: string;
onToggleAutoLogin?(profileId: string, enabled: boolean): void;
onAutoLoginCancel?(): void;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const MIN_PASSWORD_LENGTH = 8;
const DEFAULT_PROFILES: readonly SimpleProfile[] = [
{ name: "Local Server", host: "localhost:8443" },
];
/** Color palette for server icons. */
const ICON_COLORS = [
"#5865F2", "#57F287", "#FEE75C", "#EB459E", "#ED4245",
"#3BA55D", "#FAA61A", "#5865F2",
];
function getIconColor(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = (hash * 31 + name.charCodeAt(i)) | 0;
}
return ICON_COLORS[Math.abs(hash) % ICON_COLORS.length] ?? "#5865f2";
}
function getIconInitials(name: string): string {
return name.slice(0, 2).toUpperCase();
}
// ---------------------------------------------------------------------------
// ConnectPage
// ---------------------------------------------------------------------------
@@ -82,6 +53,7 @@ export function createConnectPage(
): MountableComponent & {
showTotp(): void;
showConnecting(): void;
showAutoConnecting(serverName: string): void;
showError(message: string): void;
resetToIdle(): void;
updateHealthStatus(host: string, status: HealthStatus): void;
@@ -89,35 +61,48 @@ export function createConnectPage(
getPassword(): string;
/** Re-render the server profile list with updated data. */
refreshProfiles(profiles: readonly SimpleProfile[]): void;
/** Pre-select a server by host — fills the login form and loads saved credentials. */
selectServer(host: string, username?: string): void;
} {
// --- internal state (mutable, local to this instance) ---
let formState: FormState = "idle";
let formMode: FormMode = "login";
let errorMessage = "";
let container: Element | null = null;
let root: HTMLDivElement;
// Cleanup tracking
const abortController = new AbortController();
const { signal } = abortController;
// --- cached DOM references (set during build) ---
let root: HTMLDivElement;
let serverListEl: HTMLDivElement;
let formTitle: HTMLHeadingElement;
let hostInput: HTMLInputElement;
let usernameInput: HTMLInputElement;
let passwordInput: HTMLInputElement;
let inviteGroup: HTMLDivElement;
let inviteInput: HTMLInputElement;
let submitBtn: HTMLButtonElement;
let submitBtnText: HTMLSpanElement;
let toggleModeBtn: HTMLAnchorElement;
let errorBanner: HTMLDivElement;
let totpOverlay: HTMLDivElement;
let totpInput: HTMLInputElement;
let totpSubmitBtn: HTMLButtonElement;
let rememberPasswordCheckbox: HTMLInputElement;
let statusBar: HTMLDivElement;
let statusBarFill: HTMLDivElement;
// --- Create sub-components ---
const loginForm = createLoginForm({
signal,
onLogin: callbacks.onLogin,
onRegister: callbacks.onRegister,
onTotpSubmit: callbacks.onTotpSubmit,
onSettingsOpen: () => openSettings(),
onAutoLoginCancel: callbacks.onAutoLoginCancel,
});
const serverPanel = createServerPanel(
{
signal,
onServerClick(host: string, username?: string) {
loginForm.setHost(host);
if (username) {
loginForm.setCredentials(username);
}
},
onCredentialLoaded(host: string, username: string, password?: string) {
// Guard: user may have clicked a different profile while loading
if (loginForm.getHost() === host) {
loginForm.setCredentials(username, password);
}
},
onAddProfile: callbacks.onAddProfile,
onDeleteProfile: callbacks.onDeleteProfile,
onToggleAutoLogin: callbacks.onToggleAutoLogin,
},
initialProfiles,
);
// ---------------------------------------------------------------------------
// DOM construction
@@ -126,652 +111,102 @@ export function createConnectPage(
function buildRoot(): HTMLDivElement {
root = createElement("div", { class: "connect-page" });
const leftPanel = buildServerPanel();
const rightPanel = buildFormPanel();
// OC Logo branding — prepended to server panel
const branding = createElement("div", { class: "server-branding" });
appendChildren(root, leftPanel, rightPanel);
const logoSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
logoSvg.setAttribute("width", "80");
logoSvg.setAttribute("height", "48");
logoSvg.setAttribute("viewBox", "0 0 120 70");
logoSvg.setAttribute("class", "oc-logo");
// Status bar at bottom (hidden by default, shown with .visible class)
statusBar = createElement("div", { class: "status-bar" });
statusBarFill = createElement("div", { class: "status-bar-fill" });
statusBar.appendChild(statusBarFill);
root.appendChild(statusBar);
const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
const grad = document.createElementNS("http://www.w3.org/2000/svg", "linearGradient");
grad.setAttribute("id", "oc-grad");
grad.setAttribute("x1", "0%");
grad.setAttribute("y1", "0%");
grad.setAttribute("x2", "100%");
grad.setAttribute("y2", "0%");
const stops = [
{ offset: "0%", color: "#f97316" },
{ offset: "30%", color: "#ec4899" },
{ offset: "65%", color: "#8b5cf6" },
{ offset: "100%", color: "#06b6d4" },
];
for (const s of stops) {
const stop = document.createElementNS("http://www.w3.org/2000/svg", "stop");
stop.setAttribute("offset", s.offset);
stop.setAttribute("style", `stop-color:${s.color}`);
grad.appendChild(stop);
}
const filter = document.createElementNS("http://www.w3.org/2000/svg", "filter");
filter.setAttribute("id", "oc-glow");
const blur = document.createElementNS("http://www.w3.org/2000/svg", "feGaussianBlur");
blur.setAttribute("stdDeviation", "4");
blur.setAttribute("result", "blur");
filter.appendChild(blur);
const composite = document.createElementNS("http://www.w3.org/2000/svg", "feComposite");
composite.setAttribute("in", "SourceGraphic");
composite.setAttribute("in2", "blur");
composite.setAttribute("operator", "over");
filter.appendChild(composite);
defs.appendChild(grad);
defs.appendChild(filter);
logoSvg.appendChild(defs);
// TOTP overlay (hidden by default)
totpOverlay = buildTotpOverlay();
root.appendChild(totpOverlay);
const glowText = document.createElementNS("http://www.w3.org/2000/svg", "text");
glowText.setAttribute("x", "60");
glowText.setAttribute("y", "56");
glowText.setAttribute("text-anchor", "middle");
glowText.setAttribute("font-family", "'Segoe UI',system-ui,sans-serif");
glowText.setAttribute("font-size", "68");
glowText.setAttribute("font-weight", "900");
glowText.setAttribute("fill", "url(#oc-grad)");
glowText.setAttribute("letter-spacing", "-4");
glowText.setAttribute("opacity", "0.4");
glowText.setAttribute("filter", "url(#oc-glow)");
glowText.setAttribute("class", "oc-glow-layer");
glowText.textContent = "OC";
logoSvg.appendChild(glowText);
const sharpText = document.createElementNS("http://www.w3.org/2000/svg", "text");
sharpText.setAttribute("x", "60");
sharpText.setAttribute("y", "56");
sharpText.setAttribute("text-anchor", "middle");
sharpText.setAttribute("font-family", "'Segoe UI',system-ui,sans-serif");
sharpText.setAttribute("font-size", "68");
sharpText.setAttribute("font-weight", "900");
sharpText.setAttribute("fill", "url(#oc-grad)");
sharpText.setAttribute("letter-spacing", "-4");
sharpText.textContent = "OC";
logoSvg.appendChild(sharpText);
branding.appendChild(logoSvg);
const brandName = createElement("div", { class: "brand-name" }, "OwnCord");
const brandTag = createElement("div", { class: "brand-tagline" }, "Self-hosted chat \u2014 Your server, your rules");
appendChildren(branding, brandName, brandTag);
serverPanel.element.insertBefore(branding, serverPanel.element.firstChild);
appendChildren(root, serverPanel.element, loginForm.element);
// Status bar at bottom
root.appendChild(loginForm.statusBarElement);
// TOTP overlay
root.appendChild(loginForm.totpOverlayElement);
// Auto-connect overlay
root.appendChild(loginForm.autoConnectOverlayElement);
return root;
}
function buildServerPanel(): HTMLDivElement {
const panel = createElement("div", { class: "server-panel" });
const header = createElement("div", { class: "server-panel-header" });
const heading = createElement("h2", {}, "Servers");
header.appendChild(heading);
serverListEl = createElement("div", { class: "server-list" });
renderServerProfiles(initialProfiles);
// Footer with "Add Server" button
const footer = createElement("div", { class: "server-panel-footer" });
const addBtn = createElement("button", {
class: "btn-add-server",
type: "button",
});
setText(addBtn, "+ Add Server");
addBtn.addEventListener("click", handleAddServer, { signal: abortController.signal });
footer.appendChild(addBtn);
appendChildren(panel, header, serverListEl, footer);
return panel;
}
// Map of host -> DOM elements for health status updates
const healthElements = new Map<string, { dot: HTMLDivElement; latency: HTMLSpanElement }>();
function renderServerProfiles(profiles: readonly SimpleProfile[]): void {
clearChildren(serverListEl);
healthElements.clear();
for (const profile of profiles) {
const item = createElement("div", {
class: "server-item",
"data-host": profile.host,
});
const icon = createElement("div", {
class: "srv-icon",
style: `background:${getIconColor(profile.name)}`,
});
setText(icon, getIconInitials(profile.name));
// Health status dot on the icon
const statusDot = createElement("div", { class: "srv-status-dot unknown" });
icon.appendChild(statusDot);
const info = createElement("div", { class: "srv-info" });
const name = createElement("div", { class: "srv-name" }, profile.name);
const meta = createElement("div", { class: "srv-meta" });
const host = createElement("span", { class: "srv-host" }, profile.host);
const latency = createElement("span", { class: "srv-latency" });
appendChildren(meta, host, latency);
// Show username if available (full profile has it)
const fullProfile = profile as Partial<ServerProfile>;
if (fullProfile.username) {
const usernameEl = createElement("span", { class: "srv-host" }, fullProfile.username);
appendChildren(meta, usernameEl);
}
appendChildren(info, name, meta);
healthElements.set(profile.host, { dot: statusDot, latency });
// Delete button (only for full profiles that have an id)
const actions = createElement("div", { class: "srv-actions" });
if (fullProfile.id && callbacks.onDeleteProfile) {
const deleteBtn = createElement("button", {
class: "srv-btn danger",
type: "button",
"aria-label": "Delete server",
});
setText(deleteBtn, "\u2715");
deleteBtn.addEventListener(
"click",
(e) => {
e.stopPropagation();
callbacks.onDeleteProfile!(fullProfile.id!);
},
{ signal: abortController.signal },
);
actions.appendChild(deleteBtn);
}
appendChildren(item, icon, info, actions);
item.addEventListener(
"click",
() => {
hostInput.value = profile.host;
// Auto-fill username from profile
if (fullProfile.username) {
usernameInput.value = fullProfile.username;
}
// Auto-fill credentials from credential store
const requestedHost = profile.host;
void (async () => {
const cred = await loadCredential(requestedHost);
// Guard: user may have clicked a different profile while loading
if (cred && hostInput.value === requestedHost) {
usernameInput.value = cred.username;
if (cred.password) {
passwordInput.value = cred.password;
rememberPasswordCheckbox.checked = true;
}
}
})();
},
{ signal: abortController.signal },
);
serverListEl.appendChild(item);
}
}
function updateHealthStatus(host: string, status: HealthStatus): void {
const els = healthElements.get(host);
if (!els) return;
// Update status dot
els.dot.className = `srv-status-dot ${status.status}`;
// Update latency badge
if (status.latencyMs !== null) {
const ms = status.latencyMs;
setText(els.latency, `${ms}ms`);
els.latency.className = `srv-latency ${ms < 100 ? "good" : ms < 500 ? "warn" : "bad"}`;
} else {
setText(els.latency, "");
els.latency.className = "srv-latency";
}
}
function buildFormPanel(): HTMLDivElement {
const panel = createElement("div", { class: "form-panel" });
// Settings gear (top right)
const settingsBtn = createElement("button", {
class: "settings-gear",
type: "button",
"aria-label": "Settings",
});
setText(settingsBtn, "\u2699");
settingsBtn.addEventListener("click", () => openSettings(), { signal: abortController.signal });
// Form container
const formContainer = createElement("div", { class: "form-container" });
// Logo section
const formLogo = createElement("div", { class: "form-logo" });
const logoMark = createElement("div", { class: "form-logo-mark" }, "OC");
const logoTitle = createElement("h1", {}, "OwnCord");
const logoSubtitle = createElement("p", {}, "Connect to your server");
appendChildren(formLogo, logoMark, logoTitle, logoSubtitle);
// Form title
formTitle = createElement("h1", {}, "Login");
// Error banner (hidden by default via CSS display:none, shown with .visible)
errorBanner = createElement("div", {
class: "error-banner",
role: "alert",
});
// Form
const form = createElement("form", { class: "connect-form" });
form.setAttribute("novalidate", "");
// Host
const hostGroup = buildFormGroup("host", "Server Address", "text", "localhost:8443");
hostInput = qs("input", hostGroup) as HTMLInputElement;
// Username
const usernameGroup = buildFormGroup("username", "Username", "text", "");
usernameInput = qs("input", usernameGroup) as HTMLInputElement;
// Password
const passwordGroup = buildFormGroup("password", "Password", "password", "");
passwordInput = qs("input", passwordGroup) as HTMLInputElement;
// Remember password checkbox
const rememberGroup = createElement("div", { class: "form-group remember-password-group" });
rememberPasswordCheckbox = createElement("input", {
type: "checkbox",
id: "remember-password",
});
const rememberLabel = createElement("label", {
for: "remember-password",
class: "remember-password-label",
}, "Remember password");
appendChildren(rememberGroup, rememberPasswordCheckbox, rememberLabel);
// Invite code (register only, hidden by default)
inviteGroup = buildFormGroup("invite", "Invite Code", "text", "");
inviteGroup.classList.add("form-group--hidden");
inviteInput = qs("input", inviteGroup) as HTMLInputElement;
// Submit button
submitBtn = createElement("button", {
class: "btn-primary",
type: "submit",
});
submitBtnText = createElement("span", { class: "btn-text" }, "Login");
const spinnerWrapper = createElement("span", { class: "btn-spinner" });
const spinner = createElement("div", { class: "spinner" });
spinnerWrapper.appendChild(spinner);
appendChildren(submitBtn, spinnerWrapper, submitBtnText);
// Toggle mode link
const formSwitch = createElement("div", { class: "form-switch" });
toggleModeBtn = createElement("a", {}, "Need an account? Register") as HTMLAnchorElement;
formSwitch.appendChild(toggleModeBtn);
appendChildren(form, hostGroup, usernameGroup, passwordGroup, rememberGroup, inviteGroup, submitBtn, formSwitch);
// Wire form events
form.addEventListener("submit", handleFormSubmit, { signal: abortController.signal });
toggleModeBtn.addEventListener("click", handleToggleMode, { signal: abortController.signal });
appendChildren(formContainer, formLogo, errorBanner, form);
appendChildren(panel, settingsBtn, formContainer);
return panel;
}
function buildFormGroup(
id: string,
labelText: string,
inputType: string,
placeholder: string,
): HTMLDivElement {
const group = createElement("div", { class: "form-group" });
const label = createElement("label", { class: "form-label", for: id }, labelText);
const input = createElement("input", {
class: "form-input",
id,
name: id,
type: inputType,
placeholder,
autocomplete: inputType === "password" ? "current-password" : "off",
});
if (id === "host") {
input.setAttribute("required", "");
}
if (id === "username" || id === "password") {
input.setAttribute("required", "");
}
if (inputType === "password") {
const wrapper = createElement("div", { class: "password-wrapper" });
const toggle = createElement("button", {
class: "password-toggle",
type: "button",
"aria-label": "Toggle password visibility",
}, "\uD83D\uDC41");
toggle.addEventListener(
"click",
() => {
const isPassword = input.getAttribute("type") === "password";
input.setAttribute("type", isPassword ? "text" : "password");
},
{ signal: abortController.signal },
);
appendChildren(wrapper, input, toggle);
appendChildren(group, label, wrapper);
} else {
appendChildren(group, label, input);
}
return group;
}
function buildTotpOverlay(): HTMLDivElement {
const overlay = createElement("div", { class: "totp-overlay totp-overlay--hidden" });
const card = createElement("div", { class: "totp-card" });
const title = createElement("h2", { class: "totp-title" }, "Two-Factor Authentication");
const description = createElement("p", {
class: "totp-subtitle",
}, "Enter the 6-digit code from your authenticator app.");
totpInput = createElement("input", {
class: "form-input",
type: "text",
maxlength: "6",
placeholder: "000000",
inputmode: "numeric",
pattern: "[0-9]{6}",
autocomplete: "one-time-code",
});
totpSubmitBtn = createElement("button", {
class: "btn-primary",
type: "button",
}, "Verify");
const cancelBtn = createElement("button", {
class: "totp-back",
type: "button",
}, "Cancel");
totpSubmitBtn.addEventListener("click", handleTotpSubmit, { signal: abortController.signal });
cancelBtn.addEventListener("click", handleTotpCancel, { signal: abortController.signal });
// Allow Enter key in TOTP input
totpInput.addEventListener(
"keydown",
(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleTotpSubmit();
}
},
{ signal: abortController.signal },
);
appendChildren(card, title, description, totpInput, totpSubmitBtn, cancelBtn);
overlay.appendChild(card);
return overlay;
}
// ---------------------------------------------------------------------------
// Add Server modal
// ---------------------------------------------------------------------------
function handleAddServer(): void {
if (!callbacks.onAddProfile) return;
const overlay = createElement("div", { class: "modal-overlay visible" });
const modal = createElement("div", { class: "modal" });
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "Add Server");
const closeBtn = createElement("button", { class: "modal-close", type: "button" });
setText(closeBtn, "\u2715");
appendChildren(header, title, closeBtn);
const body = createElement("div", { class: "modal-body" });
const nameGroup = createElement("div", { class: "form-group" });
const nameLabel = createElement("label", { class: "form-label" }, "Server Name");
const nameInput = createElement("input", {
class: "form-input",
type: "text",
placeholder: "My Server",
});
appendChildren(nameGroup, nameLabel, nameInput);
const hostGroup = createElement("div", { class: "form-group" });
const hostLabel = createElement("label", { class: "form-label" }, "Host Address");
const hostAddrInput = createElement("input", {
class: "form-input",
type: "text",
placeholder: "example.com:8443",
});
appendChildren(hostGroup, hostLabel, hostAddrInput);
appendChildren(body, nameGroup, hostGroup);
const footer = createElement("div", { class: "modal-footer" });
const cancelBtn = createElement("button", { class: "btn-ghost", type: "button" });
setText(cancelBtn, "Cancel");
const saveBtn = createElement("button", { class: "btn-primary", type: "button" });
setText(saveBtn, "Add Server");
appendChildren(footer, cancelBtn, saveBtn);
appendChildren(modal, header, body, footer);
overlay.appendChild(modal);
function closeModal(): void {
overlay.remove();
}
function handleSave(): void {
const name = (nameInput as HTMLInputElement).value.trim();
const addr = (hostAddrInput as HTMLInputElement).value.trim();
if (!name || !addr) return;
callbacks.onAddProfile!(name, addr);
closeModal();
}
closeBtn.addEventListener("click", closeModal, { signal: abortController.signal });
cancelBtn.addEventListener("click", closeModal, { signal: abortController.signal });
saveBtn.addEventListener("click", handleSave, { signal: abortController.signal });
overlay.addEventListener("click", (e) => {
if (e.target === overlay) closeModal();
}, { signal: abortController.signal });
// Allow backdrop stop propagation on modal body
modal.addEventListener("click", (e) => e.stopPropagation(), { signal: abortController.signal });
// Enter key submits
hostAddrInput.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") handleSave();
}, { signal: abortController.signal });
root.appendChild(overlay);
(nameInput as HTMLInputElement).focus();
}
// ---------------------------------------------------------------------------
// State transitions
// ---------------------------------------------------------------------------
function transitionTo(state: FormState, error?: string): void {
formState = state;
errorMessage = error ?? "";
// Update UI based on state
updateSubmitButton();
updateErrorBanner();
updateStatusBar();
updateTotpOverlay();
updateFormInputsDisabled();
}
function updateSubmitButton(): void {
const isLoading = formState === "loading" || formState === "connecting";
submitBtn.disabled = isLoading;
submitBtn.classList.toggle("loading", isLoading);
if (formState === "connecting") {
setText(submitBtnText, "Connecting\u2026");
} else if (formState === "loading") {
setText(submitBtnText, formMode === "login" ? "Logging in\u2026" : "Registering\u2026");
} else {
setText(submitBtnText, formMode === "login" ? "Login" : "Register");
}
}
function updateErrorBanner(): void {
if (formState === "error" && errorMessage) {
setText(errorBanner, errorMessage);
errorBanner.classList.add("visible");
// The shakeX animation plays automatically via CSS on .error-banner
// Re-trigger animation by removing and re-adding the element
errorBanner.style.animation = "none";
// Force reflow to restart animation
void errorBanner.offsetWidth;
errorBanner.style.animation = "";
} else {
errorBanner.classList.remove("visible");
}
}
function updateStatusBar(): void {
switch (formState) {
case "idle":
statusBar.classList.remove("visible", "indeterminate");
break;
case "loading":
statusBar.classList.add("visible", "indeterminate");
break;
case "totp":
statusBar.classList.remove("visible", "indeterminate");
break;
case "connecting":
statusBar.classList.add("visible", "indeterminate");
break;
case "error":
statusBar.classList.remove("visible", "indeterminate");
break;
}
}
function updateTotpOverlay(): void {
if (formState === "totp") {
totpOverlay.classList.remove("totp-overlay--hidden");
totpInput.value = "";
totpInput.focus();
} else {
totpOverlay.classList.add("totp-overlay--hidden");
}
}
function updateFormInputsDisabled(): void {
const disable = formState === "loading" || formState === "connecting";
hostInput.disabled = disable;
usernameInput.disabled = disable;
passwordInput.disabled = disable;
inviteInput.disabled = disable;
}
// ---------------------------------------------------------------------------
// Event handlers
// ---------------------------------------------------------------------------
function handleToggleMode(): void {
formMode = formMode === "login" ? "register" : "login";
setText(formTitle, formMode === "login" ? "Login" : "Register");
setText(submitBtnText, formMode === "login" ? "Login" : "Register");
setText(
toggleModeBtn,
formMode === "login" ? "Need an account? Register" : "Already have an account? Login",
);
inviteGroup.classList.toggle("form-group--hidden", formMode === "login");
// Clear any existing error
if (formState === "error") {
transitionTo("idle");
}
}
function validateForm(): string | null {
const host = hostInput.value.trim();
const username = usernameInput.value.trim();
const password = passwordInput.value;
if (!host) {
return "Server address is required.";
}
if (!username) {
return "Username is required.";
}
if (!password) {
return "Password is required.";
}
if (password.length < MIN_PASSWORD_LENGTH) {
return `Password must be at least ${MIN_PASSWORD_LENGTH} characters.`;
}
if (formMode === "register") {
const inviteCode = inviteInput.value.trim();
if (!inviteCode) {
return "Invite code is required for registration.";
}
}
return null;
}
async function handleFormSubmit(e: Event): Promise<void> {
e.preventDefault();
if (formState === "loading" || formState === "connecting") {
return;
}
const validationError = validateForm();
if (validationError !== null) {
transitionTo("error", validationError);
return;
}
const host = hostInput.value.trim();
const username = usernameInput.value.trim();
const password = passwordInput.value;
transitionTo("loading");
try {
if (formMode === "login") {
await callbacks.onLogin(host, username, password);
} else {
const inviteCode = inviteInput.value.trim();
await callbacks.onRegister(host, username, password, inviteCode);
}
// If the callback didn't throw, the caller handles navigation.
// The caller may also call showTotp() or showError() on this page.
} catch (err: unknown) {
let message: string;
if (err instanceof Error) {
message = err.message;
} else if (typeof err === "string") {
message = err;
} else if (err !== null && typeof err === "object" && "message" in err) {
message = String((err as { message: unknown }).message);
} else {
message = String(err);
}
transitionTo("error", message);
}
}
async function handleTotpSubmit(): Promise<void> {
const code = totpInput.value.trim();
if (code.length !== 6 || !/^\d{6}$/.test(code)) {
// Simple inline feedback — add error class to the input
totpInput.classList.add("error");
setTimeout(() => totpInput.classList.remove("error"), 500);
return;
}
totpSubmitBtn.disabled = true;
setText(totpSubmitBtn, "Verifying\u2026");
try {
await callbacks.onTotpSubmit(code);
} catch (err) {
const message = err instanceof Error ? err.message : "Verification failed.";
transitionTo("error", message);
} finally {
totpSubmitBtn.disabled = false;
setText(totpSubmitBtn, "Verify");
}
}
function handleTotpCancel(): void {
transitionTo("idle");
}
// ---------------------------------------------------------------------------
// Public API for external state control
// ---------------------------------------------------------------------------
/** Called externally when login returns requires_2fa. */
function showTotp(): void {
transitionTo("totp");
}
/** Called externally to show a connection-in-progress state. */
function showConnecting(): void {
transitionTo("connecting");
}
/** Called externally to display an error. */
function showError(message: string): void {
transitionTo("error", message);
}
/** Reset form to idle state. */
function resetToIdle(): void {
transitionTo("idle");
}
// ---------------------------------------------------------------------------
// MountableComponent
// ---------------------------------------------------------------------------
// Settings overlay instance
let settingsOverlay: ReturnType<typeof createSettingsOverlay> | null = null;
function mount(target: Element): void {
@@ -785,18 +220,20 @@ export function createConnectPage(
onChangePassword: async () => { /* no-op on connect page */ },
onUpdateProfile: async () => { /* no-op on connect page */ },
onLogout: () => { /* no-op on connect page */ },
onDeleteAccount: async () => { /* no-op on connect page */ },
onStatusChange: () => { /* no-op on connect page */ },
});
settingsOverlay.mount(rootEl);
// Show any pending auth error (e.g. "already connected from another client")
const pendingError = uiStore.getState().transientError;
if (pendingError) {
transitionTo("error", pendingError);
loginForm.showError(pendingError);
setTransientError(null);
}
// Focus the first input
hostInput.focus();
loginForm.focusHost();
}
function destroy(): void {
@@ -814,23 +251,34 @@ export function createConnectPage(
return {
mount,
destroy,
// Extended API for external control
showTotp,
showConnecting,
showError,
resetToIdle,
updateHealthStatus,
/** Whether the "Remember Password" checkbox is checked. */
getRememberPassword(): boolean {
return rememberPasswordCheckbox?.checked ?? false;
},
/** Get the current password input value (for saving when remember is checked). */
getPassword(): string {
return passwordInput?.value ?? "";
},
/** Re-render the server profile list with updated data. */
showTotp: () => loginForm.showTotp(),
showConnecting: () => loginForm.showConnecting(),
showAutoConnecting: (serverName: string) => loginForm.showAutoConnecting(serverName),
showError: (message: string) => loginForm.showError(message),
resetToIdle: () => loginForm.resetToIdle(),
updateHealthStatus: (host: string, status: HealthStatus) =>
serverPanel.updateHealthStatus(host, status),
getRememberPassword: () => loginForm.getRememberPassword(),
getPassword: () => loginForm.getPassword(),
refreshProfiles(profiles: readonly SimpleProfile[]): void {
renderServerProfiles(profiles);
serverPanel.renderProfiles(profiles);
},
selectServer(host: string, username?: string): void {
loginForm.setHost(host);
if (username) {
loginForm.setCredentials(username);
}
// Load saved credentials asynchronously (same flow as clicking a server card)
void (async () => {
try {
const cred = await loadCredential(host);
if (cred && loginForm.getHost() === host) {
loginForm.setCredentials(cred.username, cred.password);
}
} catch {
// Credential loading is best-effort; user can type manually
}
})();
},
};
}
+171 -325
View File
@@ -1,5 +1,6 @@
// MainPage — primary app layout after login.
// Composes standalone components; never sets innerHTML with user content.
// Delegates sidebar and chat-area DOM construction to sub-orchestrators.
import { createElement, appendChildren } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
@@ -7,44 +8,31 @@ import type { WsClient } from "@lib/ws";
import type { ApiClient } from "@lib/api";
import { createLogger } from "@lib/logger";
import { createRateLimiterSet } from "@lib/rate-limiter";
import { createServerStrip } from "@components/ServerStrip";
import { createChannelSidebar } from "@components/ChannelSidebar";
import { createCreateChannelModal } from "@components/CreateChannelModal";
import { createEditChannelModal } from "@components/EditChannelModal";
import { createDeleteChannelModal } from "@components/DeleteChannelModal";
import { createUserBar } from "@components/UserBar";
import { createVideoGrid } from "@components/VideoGrid";
import type { VideoGridComponent } from "@components/VideoGrid";
import { createVoiceWidget } from "@components/VoiceWidget";
import { createMemberList } from "@components/MemberList";
import { createServerBanner } from "@components/ServerBanner";
import type { ServerBannerControl } from "@components/ServerBanner";
import { createSettingsOverlay } from "@components/SettingsOverlay";
import { createToastContainer } from "@components/Toast";
import type { ToastContainer } from "@components/Toast";
import { authStore, clearAuth, updateUser } from "@stores/auth.store";
import { closeSettings, toggleMemberList, uiStore } from "@stores/ui.store";
import { closeSettings } from "@stores/ui.store";
import { updatePresence } from "@stores/members.store";
import { channelsStore, getActiveChannel } from "@stores/channels.store";
import { dmStore } from "@stores/dm.store";
import { voiceStore } from "@stores/voice.store";
import {
joinVoice,
leaveVoice as voiceSessionLeave,
cleanupAll as voiceCleanupAll,
setOnRemoteVideo,
setOnRemoteVideoRemoved,
clearOnRemoteVideo,
setWsClient,
setServerHost as setLiveKitServerHost,
setOnError as setVoiceOnError,
clearOnError as clearVoiceOnError,
} from "@lib/voiceSession";
import { buildChatHeader } from "./main-page/ChatHeader";
} from "@lib/livekitSession";
import { setServerHost } from "@components/message-list/renderers";
import {
createQuickSwitcherManager,
createInviteManagerController,
createPinnedPanelController,
createSearchOverlayController,
} from "./main-page/OverlayManagers";
import type { SearchOverlayController } from "./main-page/OverlayManagers";
import { createQuickSwitcherManager } from "./main-page/OverlayManagers";
import {
createMessageController,
createPendingDeleteManager,
@@ -54,10 +42,11 @@ import { createReactionController } from "./main-page/ReactionController";
import type { ReactionController } from "./main-page/ReactionController";
import { createVideoModeController } from "./main-page/VideoModeController";
import type { VideoModeController } from "./main-page/VideoModeController";
import { createVoiceWidgetCallbacks, createSidebarVoiceCallbacks } from "./main-page/VoiceCallbacks";
import { createChannelController } from "./main-page/ChannelController";
import type { ChannelController } from "./main-page/ChannelController";
import { createUpdateNotifier } from "@components/UpdateNotifier";
import { createSidebarArea } from "./main-page/SidebarArea";
import { createChatArea } from "./main-page/ChatArea";
const log = createLogger("main-page");
@@ -80,10 +69,11 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
// Let voiceSession send signaling messages over this WS connection
setWsClient(ws);
// Set server host for resolving relative attachment URLs
// Set server host for resolving relative attachment URLs and LiveKit proxy
const apiConfig = api.getConfig();
if (apiConfig.host) {
setServerHost(apiConfig.host);
setLiveKitServerHost(apiConfig.host);
}
const limiters = createRateLimiterSet();
@@ -97,16 +87,9 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
// Refs we need to update reactively
let banner: ServerBannerControl | null = null;
let chatHeaderName: HTMLSpanElement | null = null;
// Containers for swappable sub-components
let messagesSlot: HTMLDivElement | null = null;
let typingSlot: HTMLDivElement | null = null;
let inputSlot: HTMLDivElement | null = null;
// Video grid (owned by mount, controller manages toggle state)
// Video grid (owned by ChatArea, referenced for remote video wiring)
let videoGrid: VideoGridComponent | null = null;
let videoGridSlot: HTMLDivElement | null = null;
// Pending delete confirmations (double-click to delete pattern)
const pendingDeleteManager = createPendingDeleteManager();
@@ -120,14 +103,6 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
// Toast container for user-facing error feedback
let toast: ToastContainer | null = null;
// Active modal (channel create/edit/delete) — tracked for cleanup
let activeModal: MountableComponent | null = null;
// Overlay controllers — created in mount()
let pinnedCtrl: ReturnType<typeof createPinnedPanelController> | null = null;
let inviteCtrl: ReturnType<typeof createInviteManagerController> | null = null;
let searchCtrl: SearchOverlayController | null = null;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -136,11 +111,14 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
return authStore.getState().user?.id ?? 0;
}
// ---------------------------------------------------------------------------
// Channel switching — rebuild channel-dependent components
// ---------------------------------------------------------------------------
// mountChannelComponents / destroyChannelComponents delegated to channelCtrl
/** Resolve display name for a channel — for DMs, use recipient username from DM store. */
function resolveChannelName(channelId: number, channelName: string, channelType?: string): string {
if (channelType === "dm" && (!channelName || channelName === "")) {
const dm = dmStore.getState().channels.find((c) => c.channelId === channelId);
if (dm !== undefined) return dm.recipient.username;
}
return channelName;
}
// ---------------------------------------------------------------------------
// Mount / Destroy
@@ -160,258 +138,73 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
unsubscribers.push(
ws.onStateChange((wsState) => {
if (banner === null) return;
if (wsState === "reconnecting") {
banner.showReconnecting();
} else if (wsState === "connected") {
banner.hide();
try {
if (banner === null) return;
if (wsState === "reconnecting") {
banner.showReconnecting();
} else if (wsState === "connected") {
banner.hide();
}
} catch (err) {
log.error("State change handler error", err);
}
}),
);
unsubscribers.push(
ws.on("server_restart", (payload) => {
if (banner !== null) {
banner.showRestart(payload.delay_seconds);
try {
if (banner !== null) {
banner.showRestart(payload.delay_seconds);
}
} catch (err) {
log.error("Server restart handler error", err);
}
}),
);
// --- Voice config: trigger WebRTC join flow ---
unsubscribers.push(
ws.on("voice_config", (payload) => {
void joinVoice(payload.channel_id, payload, async () => {
const creds = await api.getVoiceCredentials();
return creds.ice_servers;
});
}),
);
// --- Main .app row ---
const app = createElement("div", { class: "app", "data-testid": "app-layout" });
// Server strip
const serverStripSlot = createElement("div", {});
const serverStrip = createServerStrip();
serverStrip.mount(serverStripSlot);
children.push(serverStrip);
// Channel sidebar (composed: sidebar + voice widget + user bar)
const sidebarWrapper = createElement("div", { class: "channel-sidebar", "data-testid": "channel-sidebar" });
const channelSidebarSlot = createElement("div", {});
const sidebarVoice = createSidebarVoiceCallbacks(ws);
const channelSidebar = createChannelSidebar({
onVoiceJoin: sidebarVoice.onVoiceJoin,
onVoiceLeave: sidebarVoice.onVoiceLeave,
onCreateChannel: (category) => {
if (activeModal !== null) {
return;
}
const modal = createCreateChannelModal({
category,
onCreate: async (data) => {
try {
await api.adminCreateChannel(data);
// Server broadcasts channel_create via WS — store updates automatically
modal.destroy?.();
activeModal = null;
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to create channel";
toast?.show(msg, "error");
}
},
onClose: () => {
modal.destroy?.();
activeModal = null;
},
});
activeModal = modal;
modal.mount(document.body);
},
onEditChannel: (channel) => {
if (activeModal !== null) {
return;
}
const modal = createEditChannelModal({
channelId: channel.id,
channelName: channel.name,
channelType: channel.type,
onSave: async (data) => {
try {
await api.adminUpdateChannel(channel.id, data);
// Server broadcasts channel_update via WS — store updates automatically
modal.destroy?.();
activeModal = null;
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to update channel";
toast?.show(msg, "error");
}
},
onClose: () => {
modal.destroy?.();
activeModal = null;
},
});
activeModal = modal;
modal.mount(document.body);
},
onDeleteChannel: (channel) => {
if (activeModal !== null) {
return;
}
const modal = createDeleteChannelModal({
channelId: channel.id,
channelName: channel.name,
onConfirm: async () => {
try {
await api.adminDeleteChannel(channel.id);
// Server broadcasts channel_delete via WS — store updates automatically
modal.destroy?.();
activeModal = null;
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to delete channel";
toast?.show(msg, "error");
}
},
onClose: () => {
modal.destroy?.();
activeModal = null;
},
});
activeModal = modal;
modal.mount(document.body);
},
onReorderChannel: (reorders) => {
for (const r of reorders) {
void api.adminUpdateChannel(r.channelId, { position: r.newPosition });
}
// --- Sidebar (server strip + channel sidebar + voice widget + user bar) ---
const sidebar = createSidebarArea({
ws,
api,
limiters,
getRoot: () => root,
getToast: () => toast,
onWatchStream: (userId) => {
if (videoModeCtrl === null) return;
videoModeCtrl.showVideoGrid();
videoModeCtrl.setFocus(userId);
},
});
channelSidebar.mount(channelSidebarSlot);
children.push(channelSidebar);
children.push(...sidebar.children);
unsubscribers.push(...sidebar.unsubscribers);
const mountedSidebar = channelSidebarSlot.firstElementChild;
if (mountedSidebar !== null) {
while (mountedSidebar.firstChild !== null) {
sidebarWrapper.appendChild(mountedSidebar.firstChild);
}
}
// Invite button in sidebar header
inviteCtrl = createInviteManagerController({
// --- Chat area ---
const chatAreaResult = createChatArea({
api,
getRoot: () => root,
getToast: () => toast,
getChannelCtrl: () => channelCtrl,
});
const sidebarHeader = sidebarWrapper.querySelector(".channel-sidebar-header");
if (sidebarHeader !== null) {
const inviteBtn = createElement("button", {
class: "invite-btn",
title: "Invite",
}, "Invite");
inviteBtn.addEventListener("click", () => {
void inviteCtrl!.open();
});
sidebarHeader.appendChild(inviteBtn);
}
unsubscribers.push(() => { inviteCtrl?.cleanup(); });
// Voice widget
const voiceWidgetSlot = createElement("div", {});
const voiceWidget = createVoiceWidget(
createVoiceWidgetCallbacks(ws, limiters),
);
voiceWidget.mount(voiceWidgetSlot);
children.push(voiceWidget);
sidebarWrapper.appendChild(voiceWidgetSlot);
// User bar
const userBarSlot = createElement("div", {});
const userBar = createUserBar();
userBar.mount(userBarSlot);
children.push(userBar);
sidebarWrapper.appendChild(userBarSlot);
// Chat area
const chatArea = createElement("div", { class: "chat-area", "data-testid": "chat-area" });
pinnedCtrl = createPinnedPanelController({
api,
getRoot: () => root,
getToast: () => toast,
getCurrentChannelId: () => channelCtrl?.currentChannelId ?? null,
onJumpToMessage: (msgId: number) => {
if (channelCtrl?.messageList === null || channelCtrl?.messageList === undefined) return false;
return channelCtrl.messageList.scrollToMessage(msgId);
},
});
unsubscribers.push(() => { pinnedCtrl?.cleanup(); });
// Search overlay controller
searchCtrl = createSearchOverlayController({
api,
getRoot: () => root,
getToast: () => toast,
getCurrentChannelId: () => channelCtrl?.currentChannelId ?? null,
onJumpToMessage: (_channelId: number, msgId: number) => {
if (channelCtrl?.messageList === null || channelCtrl?.messageList === undefined) return false;
return channelCtrl.messageList.scrollToMessage(msgId);
},
});
unsubscribers.push(() => { searchCtrl?.cleanup(); });
const chatHeader = buildChatHeader({
onTogglePins: () => { void pinnedCtrl!.toggle(); },
onToggleMembers: () => toggleMemberList(),
onSearchFocus: () => { searchCtrl?.open(); },
});
chatHeaderName = chatHeader.refs.nameEl;
chatArea.appendChild(chatHeader.element);
messagesSlot = createElement("div", { class: "messages-slot", "data-testid": "messages-slot" });
typingSlot = createElement("div", { class: "typing-slot", "data-testid": "typing-slot" });
inputSlot = createElement("div", { class: "input-slot", "data-testid": "input-slot" });
videoGridSlot = createElement("div", {
class: "video-grid-slot",
"data-testid": "video-grid-slot",
style: "display:none;flex:1;min-height:0",
}) as HTMLDivElement;
videoGrid = createVideoGrid();
videoGrid.mount(videoGridSlot);
children.push(videoGrid);
children.push(...chatAreaResult.children);
unsubscribers.push(...chatAreaResult.unsubscribers);
videoGrid = chatAreaResult.videoGrid;
// Video mode controller (chat/video toggle + tile management)
videoModeCtrl = createVideoModeController({
slots: {
messagesSlot: messagesSlot as HTMLDivElement,
typingSlot: typingSlot as HTMLDivElement,
inputSlot: inputSlot as HTMLDivElement,
videoGridSlot: videoGridSlot as HTMLDivElement,
},
videoGrid,
slots: chatAreaResult.slots,
videoGrid: chatAreaResult.videoGrid,
getCurrentUserId,
});
appendChildren(chatArea, messagesSlot, typingSlot, inputSlot, videoGridSlot);
// Member list
const memberListSlot = createElement("div", {});
const memberList = createMemberList();
memberList.mount(memberListSlot);
children.push(memberList);
const memberListEl = memberListSlot.querySelector(".member-list");
const unsubMemberList = uiStore.subscribe((state) => {
if (memberListEl !== null) {
memberListEl.classList.toggle("hidden", !state.memberListVisible);
}
});
unsubscribers.push(unsubMemberList);
appendChildren(app, serverStripSlot, sidebarWrapper, chatArea, memberListSlot);
appendChildren(
app,
sidebar.sidebarWrapper,
chatAreaResult.chatArea,
);
root.appendChild(app);
// Settings overlay
@@ -439,6 +232,18 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
}
},
onLogout: () => clearAuth(),
onDeleteAccount: async (password) => {
await api.deleteAccount(password);
clearAuth();
toast?.show("Account deleted successfully", "success");
},
onStatusChange: (status) => {
const userId = getCurrentUserId();
if (userId !== 0) {
updatePresence(userId, status);
}
ws.send({ type: "presence_update", payload: { status } });
},
});
settingsOverlay.mount(root);
children.push(settingsOverlay);
@@ -477,36 +282,68 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
showToast: (msg, type) => toast?.show(msg, type as "success" | "error" | "info"),
getCurrentUserId,
slots: {
messagesSlot: messagesSlot as HTMLDivElement,
typingSlot: typingSlot as HTMLDivElement,
inputSlot: inputSlot as HTMLDivElement,
messagesSlot: chatAreaResult.slots.messagesSlot,
typingSlot: chatAreaResult.slots.typingSlot,
inputSlot: chatAreaResult.slots.inputSlot,
},
chatHeaderName,
chatHeaderName: chatAreaResult.chatHeaderName,
chatHeaderRefs: chatAreaResult.chatHeaderRefs,
});
// Wire voice error callback to toast
setVoiceOnError((msg) => toast?.show(msg, "error"));
// Wire remote video callbacks to video grid
setOnRemoteVideo((userId, stream) => {
const SCREENSHARE_TILE_ID_OFFSET = 1_000_000;
setOnRemoteVideo((userId, stream, isScreenshare) => {
if (videoGrid === null) return;
const voice = voiceStore.getState();
const channelId = voice.currentChannelId;
if (channelId === null) return;
const channelUsers = voice.voiceUsers.get(channelId);
const user = channelUsers?.get(userId);
const username = user?.username ?? `User ${userId}`;
videoGrid.addStream(userId, username, stream);
const tileId = isScreenshare ? userId + SCREENSHARE_TILE_ID_OFFSET : userId;
const username = isScreenshare
? (user?.username ? `${user.username} (Screen)` : `User ${userId} (Screen)`)
: (user?.username ?? `User ${userId}`);
videoGrid.addStream(tileId, username, stream, {
isSelf: false,
audioUserId: userId,
isScreenshare,
});
videoModeCtrl?.checkVideoMode();
});
setOnRemoteVideoRemoved((userId) => {
videoGrid?.removeStream(userId);
setOnRemoteVideoRemoved((userId, isScreenshare) => {
const tileId = isScreenshare ? userId + SCREENSHARE_TILE_ID_OFFSET : userId;
videoGrid?.removeStream(tileId);
videoModeCtrl?.checkVideoMode();
});
unsubscribers.push(() => clearOnRemoteVideo());
// Subscribe to voice store for camera state changes
unsubscribers.push(voiceStore.subscribe(() => videoModeCtrl?.checkVideoMode()));
// Subscribe to voice store for camera/screenshare state changes only (not speaking ticks)
let prevVideoSignature = "";
unsubscribers.push(voiceStore.subscribe((state) => {
try {
// Build a lightweight signature of video-relevant state (camera + screenshare)
let sig = (state.localCamera ? "c" : "") + (state.localScreenshare ? "s" : "");
const channelId = state.currentChannelId;
if (channelId !== null) {
const users = state.voiceUsers.get(channelId);
if (users) {
for (const [uid, u] of users) {
if (u.camera) sig += `:c${uid}`;
if (u.screenshare) sig += `:s${uid}`;
}
}
}
if (sig !== prevVideoSignature) {
prevVideoSignature = sig;
videoModeCtrl?.checkVideoMode();
}
} catch (err) {
log.error("Voice store subscription error", err);
}
}));
// Auto-update notifier — checks server for newer client version
if (apiConfig.host) {
@@ -519,67 +356,76 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
container.appendChild(root);
// --- Subscribe to channel changes ---
const unsubChannels = channelsStore.subscribe(() => {
const active = getActiveChannel();
if (active !== null) {
channelCtrl!.mountChannel(active.id, active.name);
}
});
const unsubChannels = channelsStore.subscribeSelector(
(s) => s.activeChannelId,
() => {
try {
const active = getActiveChannel();
if (active !== null) {
if (active.type === "text") {
videoModeCtrl?.showChat();
}
channelCtrl!.mountChannel(active.id, resolveChannelName(active.id, active.name, active.type), active.type);
}
} catch (err) {
log.error("Channel mount failed", err);
}
},
);
unsubscribers.push(unsubChannels);
const active = getActiveChannel();
if (active !== null) {
channelCtrl!.mountChannel(active.id, active.name);
channelCtrl!.mountChannel(active.id, resolveChannelName(active.id, active.name, active.type), active.type);
}
}
function destroy(): void {
log.info("MainPage destroying");
// Clean up voice session before destroying UI — prevents stale
// module-level state persisting across logout/reconnect cycles.
voiceSessionLeave(false);
clearVoiceOnError();
clearOnRemoteVideo();
channelCtrl?.destroyChannel();
channelCtrl = null;
try {
// Full voice cleanup — tears down room, callbacks, ws ref, serverHost.
// Prevents stale module-level state persisting across logout/reconnect cycles.
voiceCleanupAll();
channelCtrl?.destroyChannel();
channelCtrl = null;
reactionCtrl?.destroy();
reactionCtrl = null;
msgCtrl = null;
videoModeCtrl?.destroy();
videoModeCtrl = null;
reactionCtrl?.destroy();
reactionCtrl = null;
msgCtrl = null;
videoModeCtrl?.destroy();
videoModeCtrl = null;
if (activeModal !== null) {
activeModal.destroy?.();
activeModal = null;
}
if (videoGrid !== null) {
videoGrid.destroy?.();
videoGrid = null;
}
videoGridSlot = null;
for (const child of children) {
child.destroy?.();
}
children = [];
for (const child of children) {
try {
child.destroy?.();
} catch (err) {
log.error("Child destroy error", err);
}
}
children = [];
for (const unsub of unsubscribers) {
unsub();
}
unsubscribers = [];
for (const unsub of unsubscribers) {
try {
unsub();
} catch (err) {
log.error("Unsubscribe error", err);
}
}
unsubscribers = [];
if (banner !== null) {
banner.destroy();
banner = null;
if (banner !== null) {
banner.destroy();
banner = null;
}
} finally {
if (root !== null) {
root.remove();
root = null;
}
container = null;
}
if (root !== null) {
root.remove();
root = null;
}
container = null;
}
return { mount, destroy };
@@ -0,0 +1,646 @@
// LoginForm — login/register form sub-component for ConnectPage.
// Pure extraction from ConnectPage.ts. No behavior changes.
import {
createElement,
setText,
appendChildren,
qs,
} from "@lib/dom";
import { createIcon } from "@lib/icons";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Form state machine states. */
export type FormState = "idle" | "loading" | "totp" | "connecting" | "error" | "auto-connecting";
/** Form mode: login or register. */
export type FormMode = "login" | "register";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const MIN_PASSWORD_LENGTH = 8;
// ---------------------------------------------------------------------------
// Options & Return type
// ---------------------------------------------------------------------------
export interface LoginFormOptions {
readonly signal: AbortSignal;
readonly onLogin: (host: string, username: string, password: string) => Promise<void>;
readonly onRegister: (
host: string,
username: string,
password: string,
inviteCode: string,
) => Promise<void>;
readonly onTotpSubmit: (code: string) => Promise<void>;
readonly onSettingsOpen: () => void;
readonly onAutoLoginCancel?: () => void;
}
export interface LoginFormApi {
/** The form panel DOM element. */
readonly element: HTMLDivElement;
/** The status bar element (mounted separately at bottom of page). */
readonly statusBarElement: HTMLDivElement;
/** The TOTP overlay element (mounted separately). */
readonly totpOverlayElement: HTMLDivElement;
/** The auto-connecting overlay element (mounted separately). */
readonly autoConnectOverlayElement: HTMLDivElement;
showTotp(): void;
showConnecting(): void;
showAutoConnecting(serverName: string): void;
showError(message: string): void;
resetToIdle(): void;
getRememberPassword(): boolean;
getPassword(): string;
/** Set the host input value (called when ServerPanel clicks a server). */
setHost(host: string): void;
/** Set credentials (called for auto-fill from profile or credential store). */
setCredentials(username: string, password?: string): void;
/** Get host input value (for guard checks). */
getHost(): string;
/** Focus the host input. */
focusHost(): void;
destroy(): void;
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
const { signal, onLogin, onRegister, onTotpSubmit, onSettingsOpen, onAutoLoginCancel } = opts;
// --- internal state ---
let formState: FormState = "idle";
let formMode: FormMode = "login";
let errorMessage = "";
// --- cached DOM references ---
let formTitle: HTMLHeadingElement;
let hostInput: HTMLInputElement;
let usernameInput: HTMLInputElement;
let passwordInput: HTMLInputElement;
let inviteGroup: HTMLDivElement;
let inviteInput: HTMLInputElement;
let submitBtn: HTMLButtonElement;
let submitBtnText: HTMLSpanElement;
let toggleModeBtn: HTMLAnchorElement;
let errorBanner: HTMLDivElement;
let totpOverlay: HTMLDivElement;
let totpInput: HTMLInputElement;
let totpSubmitBtn: HTMLButtonElement;
let rememberPasswordCheckbox: HTMLInputElement;
let statusBar: HTMLDivElement;
let statusBarFill: HTMLDivElement;
let autoConnectOverlay: HTMLDivElement;
let autoConnectServerName: HTMLSpanElement;
// ---------------------------------------------------------------------------
// DOM construction
// ---------------------------------------------------------------------------
function buildFormPanel(): HTMLDivElement {
const panel = createElement("div", { class: "form-panel" });
// Settings gear (top right)
const settingsBtn = createElement("button", {
class: "settings-gear",
type: "button",
"aria-label": "Settings",
});
settingsBtn.textContent = "";
settingsBtn.appendChild(createIcon("settings", 16));
settingsBtn.addEventListener("click", () => onSettingsOpen(), { signal });
// Form container
const formContainer = createElement("div", { class: "form-container" });
// Logo section — OC neon glow SVG
const formLogo = createElement("div", { class: "form-logo" });
const logoSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
logoSvg.setAttribute("width", "70");
logoSvg.setAttribute("height", "42");
logoSvg.setAttribute("viewBox", "0 0 120 70");
logoSvg.setAttribute("class", "oc-logo");
const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
const grad = document.createElementNS("http://www.w3.org/2000/svg", "linearGradient");
grad.setAttribute("id", "oc-grad-form");
grad.setAttribute("x1", "0%"); grad.setAttribute("y1", "0%");
grad.setAttribute("x2", "100%"); grad.setAttribute("y2", "0%");
for (const [offset, color] of [["0%","#f97316"],["30%","#ec4899"],["65%","#8b5cf6"],["100%","#06b6d4"]] as const) {
const stop = document.createElementNS("http://www.w3.org/2000/svg", "stop");
stop.setAttribute("offset", offset);
stop.setAttribute("style", `stop-color:${color}`);
grad.appendChild(stop);
}
const filter = document.createElementNS("http://www.w3.org/2000/svg", "filter");
filter.setAttribute("id", "oc-glow-form");
const blur = document.createElementNS("http://www.w3.org/2000/svg", "feGaussianBlur");
blur.setAttribute("stdDeviation", "4"); blur.setAttribute("result", "blur");
filter.appendChild(blur);
const comp = document.createElementNS("http://www.w3.org/2000/svg", "feComposite");
comp.setAttribute("in", "SourceGraphic"); comp.setAttribute("in2", "blur"); comp.setAttribute("operator", "over");
filter.appendChild(comp);
defs.appendChild(grad); defs.appendChild(filter);
logoSvg.appendChild(defs);
for (const [opacity, filterAttr] of [["0.4", "url(#oc-glow-form)"], [null, null]] as const) {
const t = document.createElementNS("http://www.w3.org/2000/svg", "text");
t.setAttribute("x", "60"); t.setAttribute("y", "56"); t.setAttribute("text-anchor", "middle");
t.setAttribute("font-family", "'Segoe UI',system-ui,sans-serif");
t.setAttribute("font-size", "68"); t.setAttribute("font-weight", "900");
t.setAttribute("fill", "url(#oc-grad-form)"); t.setAttribute("letter-spacing", "-4");
if (opacity) { t.setAttribute("opacity", opacity); t.setAttribute("class", "oc-glow-layer"); }
if (filterAttr) t.setAttribute("filter", filterAttr);
t.textContent = "OC";
logoSvg.appendChild(t);
}
const logoTitle = createElement("h1", {}, "OwnCord");
const logoSubtitle = createElement("p", {}, "Connect to your server");
appendChildren(formLogo, logoSvg, logoTitle, logoSubtitle);
// Form title
formTitle = createElement("h1", {}, "Login");
// Error banner (hidden by default via CSS display:none, shown with .visible)
errorBanner = createElement("div", {
class: "error-banner",
role: "alert",
});
// Form
const form = createElement("form", { class: "connect-form" });
form.setAttribute("novalidate", "");
// Host
const hostGroup = buildFormGroup("host", "Server Address", "text", "localhost:8443");
hostInput = qs("input", hostGroup) as HTMLInputElement;
// Username
const usernameGroup = buildFormGroup("username", "Username", "text", "");
usernameInput = qs("input", usernameGroup) as HTMLInputElement;
// Password
const passwordGroup = buildFormGroup("password", "Password", "password", "");
passwordInput = qs("input", passwordGroup) as HTMLInputElement;
// Remember password checkbox
const rememberGroup = createElement("div", { class: "form-group remember-password-group" });
rememberPasswordCheckbox = createElement("input", {
type: "checkbox",
id: "remember-password",
});
const rememberLabel = createElement("label", {
for: "remember-password",
class: "remember-password-label",
}, "Remember password");
appendChildren(rememberGroup, rememberPasswordCheckbox, rememberLabel);
// Invite code (register only, hidden by default)
inviteGroup = buildFormGroup("invite", "Invite Code", "text", "");
inviteGroup.classList.add("form-group--hidden");
inviteInput = qs("input", inviteGroup) as HTMLInputElement;
// Submit button
submitBtn = createElement("button", {
class: "btn-primary",
type: "submit",
});
submitBtnText = createElement("span", { class: "btn-text" }, "Login");
const spinnerWrapper = createElement("span", { class: "btn-spinner" });
const spinner = createElement("div", { class: "spinner" });
spinnerWrapper.appendChild(spinner);
appendChildren(submitBtn, spinnerWrapper, submitBtnText);
// Toggle mode link
const formSwitch = createElement("div", { class: "form-switch" });
toggleModeBtn = createElement("a", {}, "Need an account? Register") as HTMLAnchorElement;
formSwitch.appendChild(toggleModeBtn);
appendChildren(form, hostGroup, usernameGroup, passwordGroup, rememberGroup, inviteGroup, submitBtn, formSwitch);
// Wire form events
form.addEventListener("submit", handleFormSubmit, { signal });
toggleModeBtn.addEventListener("click", handleToggleMode, { signal });
appendChildren(formContainer, formLogo, errorBanner, form);
appendChildren(panel, settingsBtn, formContainer);
return panel;
}
function buildFormGroup(
id: string,
labelText: string,
inputType: string,
placeholder: string,
): HTMLDivElement {
const group = createElement("div", { class: "form-group" });
const label = createElement("label", { class: "form-label", for: id }, labelText);
const input = createElement("input", {
class: "form-input",
id,
name: id,
type: inputType,
placeholder,
autocomplete: inputType === "password" ? "current-password" : "off",
});
if (id === "host") {
input.setAttribute("required", "");
}
if (id === "username" || id === "password") {
input.setAttribute("required", "");
}
if (inputType === "password") {
const wrapper = createElement("div", { class: "password-wrapper" });
const toggle = createElement("button", {
class: "password-toggle",
type: "button",
"aria-label": "Toggle password visibility",
});
toggle.appendChild(createIcon("eye", 16));
toggle.addEventListener(
"click",
() => {
const isPassword = input.getAttribute("type") === "password";
input.setAttribute("type", isPassword ? "text" : "password");
toggle.textContent = "";
toggle.appendChild(createIcon(isPassword ? "eye-off" : "eye", 16));
},
{ signal },
);
appendChildren(wrapper, input, toggle);
appendChildren(group, label, wrapper);
} else {
appendChildren(group, label, input);
}
return group;
}
function buildTotpOverlay(): HTMLDivElement {
const overlay = createElement("div", { class: "totp-overlay totp-overlay--hidden" });
const card = createElement("div", { class: "totp-card" });
const title = createElement("h2", { class: "totp-title" }, "Two-Factor Authentication");
const description = createElement("p", {
class: "totp-subtitle",
}, "Enter the 6-digit code from your authenticator app.");
totpInput = createElement("input", {
class: "form-input",
type: "text",
maxlength: "6",
placeholder: "000000",
inputmode: "numeric",
pattern: "[0-9]{6}",
autocomplete: "one-time-code",
});
totpSubmitBtn = createElement("button", {
class: "btn-primary",
type: "button",
}, "Verify");
const cancelBtn = createElement("button", {
class: "totp-back",
type: "button",
}, "Cancel");
totpSubmitBtn.addEventListener("click", handleTotpSubmit, { signal });
cancelBtn.addEventListener("click", handleTotpCancel, { signal });
// Allow Enter key in TOTP input
totpInput.addEventListener(
"keydown",
(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleTotpSubmit();
}
},
{ signal },
);
appendChildren(card, title, description, totpInput, totpSubmitBtn, cancelBtn);
overlay.appendChild(card);
return overlay;
}
function buildAutoConnectOverlay(): HTMLDivElement {
const overlay = createElement("div", { class: "auto-connect-overlay auto-connect-overlay--hidden" });
const card = createElement("div", { class: "auto-connect-card" });
const spinner = createElement("div", { class: "auto-connect-spinner" });
const spinnerEl = createElement("div", { class: "spinner" });
spinner.appendChild(spinnerEl);
const title = createElement("h2", { class: "auto-connect-title" }, "Auto-connecting...");
autoConnectServerName = createElement("span", { class: "auto-connect-server" });
const cancelBtn = createElement("button", {
class: "btn-ghost auto-connect-cancel",
type: "button",
}, "Cancel");
cancelBtn.addEventListener("click", () => {
transitionTo("idle");
onAutoLoginCancel?.();
}, { signal });
appendChildren(card, spinner, title, autoConnectServerName, cancelBtn);
overlay.appendChild(card);
return overlay;
}
// ---------------------------------------------------------------------------
// State transitions
// ---------------------------------------------------------------------------
function transitionTo(state: FormState, error?: string): void {
formState = state;
errorMessage = error ?? "";
// Update UI based on state
updateSubmitButton();
updateErrorBanner();
updateStatusBar();
updateTotpOverlay();
updateAutoConnectOverlay();
updateFormInputsDisabled();
}
function updateSubmitButton(): void {
const isLoading = formState === "loading" || formState === "connecting" || formState === "auto-connecting";
submitBtn.disabled = isLoading;
submitBtn.classList.toggle("loading", isLoading);
if (formState === "connecting" || formState === "auto-connecting") {
setText(submitBtnText, "Connecting\u2026");
} else if (formState === "loading") {
setText(submitBtnText, formMode === "login" ? "Logging in\u2026" : "Registering\u2026");
} else {
setText(submitBtnText, formMode === "login" ? "Login" : "Register");
}
}
function updateErrorBanner(): void {
if (formState === "error" && errorMessage) {
setText(errorBanner, errorMessage);
errorBanner.classList.add("visible");
// The shakeX animation plays automatically via CSS on .error-banner
// Re-trigger animation by removing and re-adding the element
errorBanner.style.animation = "none";
// Force reflow to restart animation
void errorBanner.offsetWidth;
errorBanner.style.animation = "";
} else {
errorBanner.classList.remove("visible");
}
}
function updateStatusBar(): void {
switch (formState) {
case "idle":
case "totp":
case "error":
statusBar.classList.remove("visible", "indeterminate");
break;
case "loading":
case "connecting":
case "auto-connecting":
statusBar.classList.add("visible", "indeterminate");
break;
}
}
function updateTotpOverlay(): void {
if (formState === "totp") {
totpOverlay.classList.remove("totp-overlay--hidden");
totpInput.value = "";
totpInput.focus();
} else {
totpOverlay.classList.add("totp-overlay--hidden");
}
}
function updateAutoConnectOverlay(): void {
if (formState === "auto-connecting") {
autoConnectOverlay.classList.remove("auto-connect-overlay--hidden");
} else {
autoConnectOverlay.classList.add("auto-connect-overlay--hidden");
}
}
function updateFormInputsDisabled(): void {
const disable = formState === "loading" || formState === "connecting" || formState === "auto-connecting";
hostInput.disabled = disable;
usernameInput.disabled = disable;
passwordInput.disabled = disable;
inviteInput.disabled = disable;
}
// ---------------------------------------------------------------------------
// Event handlers
// ---------------------------------------------------------------------------
function handleToggleMode(): void {
formMode = formMode === "login" ? "register" : "login";
setText(formTitle, formMode === "login" ? "Login" : "Register");
setText(submitBtnText, formMode === "login" ? "Login" : "Register");
setText(
toggleModeBtn,
formMode === "login" ? "Need an account? Register" : "Already have an account? Login",
);
inviteGroup.classList.toggle("form-group--hidden", formMode === "login");
// Clear any existing error
if (formState === "error") {
transitionTo("idle");
}
}
function validateForm(): string | null {
const host = hostInput.value.trim();
const username = usernameInput.value.trim();
const password = passwordInput.value;
if (!host) {
return "Server address is required.";
}
if (!username) {
return "Username is required.";
}
if (!password) {
return "Password is required.";
}
if (password.length < MIN_PASSWORD_LENGTH) {
return `Password must be at least ${MIN_PASSWORD_LENGTH} characters.`;
}
if (formMode === "register") {
const inviteCode = inviteInput.value.trim();
if (!inviteCode) {
return "Invite code is required for registration.";
}
}
return null;
}
async function handleFormSubmit(e: Event): Promise<void> {
e.preventDefault();
if (formState === "loading" || formState === "connecting") {
return;
}
const validationError = validateForm();
if (validationError !== null) {
transitionTo("error", validationError);
return;
}
const host = hostInput.value.trim();
const username = usernameInput.value.trim();
const password = passwordInput.value;
transitionTo("loading");
try {
if (formMode === "login") {
await onLogin(host, username, password);
} else {
const inviteCode = inviteInput.value.trim();
await onRegister(host, username, password, inviteCode);
}
// If the callback didn't throw, the caller handles navigation.
// The caller may also call showTotp() or showError() on this page.
} catch (err: unknown) {
let message: string;
if (err instanceof Error) {
message = err.message;
} else if (typeof err === "string") {
message = err;
} else if (err !== null && typeof err === "object" && "message" in err) {
message = String((err as { message: unknown }).message);
} else {
message = String(err);
}
transitionTo("error", message);
}
}
async function handleTotpSubmit(): Promise<void> {
const code = totpInput.value.trim();
if (code.length !== 6 || !/^\d{6}$/.test(code)) {
// Simple inline feedback — add error class to the input
totpInput.classList.add("error");
setTimeout(() => totpInput.classList.remove("error"), 500);
return;
}
totpSubmitBtn.disabled = true;
setText(totpSubmitBtn, "Verifying\u2026");
try {
await onTotpSubmit(code);
} catch (err) {
const message = err instanceof Error ? err.message : "Verification failed.";
transitionTo("error", message);
} finally {
totpSubmitBtn.disabled = false;
setText(totpSubmitBtn, "Verify");
}
}
function handleTotpCancel(): void {
transitionTo("idle");
}
// ---------------------------------------------------------------------------
// Build elements
// ---------------------------------------------------------------------------
const panelEl = buildFormPanel();
// Status bar (hidden by default, shown with .visible class)
statusBar = createElement("div", { class: "status-bar" });
statusBarFill = createElement("div", { class: "status-bar-fill" });
statusBar.appendChild(statusBarFill);
// TOTP overlay (hidden by default)
totpOverlay = buildTotpOverlay();
// Auto-connect overlay (hidden by default)
autoConnectOverlay = buildAutoConnectOverlay();
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
return {
element: panelEl,
statusBarElement: statusBar,
totpOverlayElement: totpOverlay,
autoConnectOverlayElement: autoConnectOverlay,
showTotp(): void {
transitionTo("totp");
},
showConnecting(): void {
transitionTo("connecting");
},
showAutoConnecting(serverName: string): void {
setText(autoConnectServerName, serverName);
transitionTo("auto-connecting");
},
showError(message: string): void {
transitionTo("error", message);
},
resetToIdle(): void {
transitionTo("idle");
},
getRememberPassword(): boolean {
return rememberPasswordCheckbox?.checked ?? false;
},
getPassword(): string {
return passwordInput?.value ?? "";
},
setHost(host: string): void {
hostInput.value = host;
},
setCredentials(username: string, password?: string): void {
usernameInput.value = username;
if (password) {
passwordInput.value = password;
rememberPasswordCheckbox.checked = true;
}
},
getHost(): string {
return hostInput?.value ?? "";
},
focusHost(): void {
hostInput.focus();
},
destroy(): void {
// Cleanup is handled by the shared AbortSignal from the parent
},
};
}
@@ -0,0 +1,337 @@
// ServerPanel — server profile list sub-component for ConnectPage.
// Pure extraction from ConnectPage.ts. No behavior changes.
import {
createElement,
setText,
appendChildren,
clearChildren,
} from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { HealthStatus, ServerProfile } from "@lib/profiles";
import { loadCredential } from "@lib/credentials";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Minimal profile shape for the default profile list (backward compat). */
export interface SimpleProfile {
readonly name: string;
readonly host: string;
}
/** Color palette for server icons. */
const ICON_COLORS = [
"#5865F2", "#57F287", "#FEE75C", "#EB459E", "#ED4245",
"#3BA55D", "#FAA61A", "#5865F2",
];
function getIconColor(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = (hash * 31 + name.charCodeAt(i)) | 0;
}
return ICON_COLORS[Math.abs(hash) % ICON_COLORS.length] ?? "#5865f2";
}
function getIconInitials(name: string): string {
return name.charAt(0).toUpperCase();
}
// ---------------------------------------------------------------------------
// Options & Return type
// ---------------------------------------------------------------------------
export interface ServerPanelOptions {
readonly signal: AbortSignal;
/** Called immediately when the user clicks a server profile. */
readonly onServerClick: (host: string, username?: string) => void;
/** Called after async credential lookup succeeds (may set password). */
readonly onCredentialLoaded: (host: string, username: string, password?: string) => void;
readonly onAddProfile?: (name: string, host: string) => void;
readonly onDeleteProfile?: (profileId: string) => void;
/** Called when the user toggles auto-login on a server profile. */
readonly onToggleAutoLogin?: (profileId: string, enabled: boolean) => void;
}
export interface ServerPanelApi {
readonly element: HTMLDivElement;
renderProfiles(profiles: readonly SimpleProfile[]): void;
updateHealthStatus(host: string, status: HealthStatus): void;
destroy(): void;
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function createServerPanel(
opts: ServerPanelOptions,
initialProfiles: readonly SimpleProfile[],
): ServerPanelApi {
const { signal, onServerClick, onCredentialLoaded, onAddProfile, onDeleteProfile, onToggleAutoLogin } = opts;
// Map of host -> DOM elements for health status updates
const healthElements = new Map<string, { dot: HTMLDivElement; latency: HTMLSpanElement; onlineUsers: HTMLSpanElement }>();
// Cached DOM references
let serverListEl: HTMLDivElement;
// ---------------------------------------------------------------------------
// DOM construction
// ---------------------------------------------------------------------------
function buildPanel(): HTMLDivElement {
const panel = createElement("div", { class: "server-panel" });
const header = createElement("div", { class: "server-panel-header" });
const heading = createElement("h2", {}, "Servers");
header.appendChild(heading);
serverListEl = createElement("div", { class: "server-list" });
renderServerProfiles(initialProfiles);
// Footer with "Add Server" button
const footer = createElement("div", { class: "server-panel-footer" });
const addBtn = createElement("button", {
class: "btn-add-server",
type: "button",
});
setText(addBtn, "+ Add Server");
addBtn.addEventListener("click", handleAddServer, { signal });
footer.appendChild(addBtn);
appendChildren(panel, header, serverListEl, footer);
return panel;
}
function renderServerProfiles(profiles: readonly SimpleProfile[]): void {
clearChildren(serverListEl);
healthElements.clear();
for (const profile of profiles) {
const item = createElement("div", {
class: "server-item",
"data-host": profile.host,
});
const icon = createElement("div", {
class: "srv-icon",
style: `background:${getIconColor(profile.name)}`,
});
setText(icon, getIconInitials(profile.name));
// Health status dot — placed as sibling after info, not inside icon
const statusDot = createElement("div", { class: "srv-status-dot unknown" });
const info = createElement("div", { class: "srv-info" });
const name = createElement("div", { class: "srv-name" }, profile.name);
const meta = createElement("div", { class: "srv-meta" });
const host = createElement("span", { class: "srv-host" }, profile.host);
const latency = createElement("span", { class: "srv-latency" });
const onlineUsersEl = createElement("span", { class: "srv-online-users" });
appendChildren(meta, host, latency, onlineUsersEl);
// Show username if available (full profile has it)
const fullProfile = profile as Partial<ServerProfile>;
if (fullProfile.username) {
const usernameEl = createElement("span", { class: "srv-host" }, fullProfile.username);
appendChildren(meta, usernameEl);
}
appendChildren(info, name, meta);
healthElements.set(profile.host, { dot: statusDot, latency, onlineUsers: onlineUsersEl });
// Action buttons (auto-login toggle + delete)
const actions = createElement("div", { class: "srv-actions" });
// Auto-login toggle (only for full profiles)
if (fullProfile.id && onToggleAutoLogin) {
const isAutoLogin = fullProfile.autoConnect === true;
const autoLoginBtn = createElement("button", {
class: `srv-btn auto-login${isAutoLogin ? " active" : ""}`,
type: "button",
"aria-label": isAutoLogin ? "Disable auto-login" : "Enable auto-login",
title: isAutoLogin ? "Auto-login enabled" : "Enable auto-login",
});
autoLoginBtn.textContent = "";
autoLoginBtn.appendChild(createIcon("zap", 14));
autoLoginBtn.addEventListener(
"click",
(e) => {
e.stopPropagation();
onToggleAutoLogin(fullProfile.id!, !isAutoLogin);
},
{ signal },
);
actions.appendChild(autoLoginBtn);
}
// Delete button (only for full profiles that have an id)
if (fullProfile.id && onDeleteProfile) {
const deleteBtn = createElement("button", {
class: "srv-btn danger",
type: "button",
"aria-label": "Delete server",
});
deleteBtn.textContent = "";
deleteBtn.appendChild(createIcon("x", 14));
deleteBtn.addEventListener(
"click",
(e) => {
e.stopPropagation();
onDeleteProfile(fullProfile.id!);
},
{ signal },
);
actions.appendChild(deleteBtn);
}
appendChildren(item, icon, info, statusDot, actions);
item.addEventListener(
"click",
() => {
// Immediately fill host + username from profile
onServerClick(profile.host, fullProfile.username);
// Auto-fill credentials from credential store (async)
const requestedHost = profile.host;
void (async () => {
const cred = await loadCredential(requestedHost);
if (cred) {
onCredentialLoaded(requestedHost, cred.username, cred.password);
}
})();
},
{ signal },
);
serverListEl.appendChild(item);
}
}
function updateHealthStatus(host: string, status: HealthStatus): void {
const els = healthElements.get(host);
if (!els) return;
// Update status dot
els.dot.className = `srv-status-dot ${status.status}`;
// Update latency badge
if (status.latencyMs !== null) {
const ms = status.latencyMs;
setText(els.latency, `${ms}ms`);
els.latency.className = `srv-latency ${ms < 100 ? "good" : ms < 500 ? "warn" : "bad"}`;
} else {
setText(els.latency, "");
els.latency.className = "srv-latency";
}
// Update online users count
if (status.onlineUsers !== null && status.onlineUsers >= 0) {
setText(els.onlineUsers, `${status.onlineUsers} online`);
els.onlineUsers.className = `srv-online-users ${status.onlineUsers > 0 ? "has-users" : ""}`;
} else {
setText(els.onlineUsers, "");
els.onlineUsers.className = "srv-online-users";
}
}
// ---------------------------------------------------------------------------
// Add Server modal
// ---------------------------------------------------------------------------
function handleAddServer(): void {
if (!onAddProfile) return;
const overlay = createElement("div", { class: "modal-overlay visible" });
const modal = createElement("div", { class: "modal" });
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "Add Server");
const closeBtn = createElement("button", { class: "modal-close", type: "button" });
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
appendChildren(header, title, closeBtn);
const body = createElement("div", { class: "modal-body" });
const nameGroup = createElement("div", { class: "form-group" });
const nameLabel = createElement("label", { class: "form-label" }, "Server Name");
const nameInput = createElement("input", {
class: "form-input",
type: "text",
placeholder: "My Server",
});
appendChildren(nameGroup, nameLabel, nameInput);
const hostGroup = createElement("div", { class: "form-group" });
const hostLabel = createElement("label", { class: "form-label" }, "Host Address");
const hostAddrInput = createElement("input", {
class: "form-input",
type: "text",
placeholder: "example.com:8443",
});
appendChildren(hostGroup, hostLabel, hostAddrInput);
appendChildren(body, nameGroup, hostGroup);
const footer = createElement("div", { class: "modal-footer" });
const cancelBtn = createElement("button", { class: "btn-ghost", type: "button" });
setText(cancelBtn, "Cancel");
const saveBtn = createElement("button", { class: "btn-primary", type: "button" });
setText(saveBtn, "Add Server");
appendChildren(footer, cancelBtn, saveBtn);
appendChildren(modal, header, body, footer);
overlay.appendChild(modal);
function closeModal(): void {
overlay.remove();
}
function handleSave(): void {
const name = (nameInput as HTMLInputElement).value.trim();
const addr = (hostAddrInput as HTMLInputElement).value.trim();
if (!name || !addr) return;
onAddProfile!(name, addr);
closeModal();
}
closeBtn.addEventListener("click", closeModal, { signal });
cancelBtn.addEventListener("click", closeModal, { signal });
saveBtn.addEventListener("click", handleSave, { signal });
overlay.addEventListener("click", (e) => {
if (e.target === overlay) closeModal();
}, { signal });
// Allow backdrop stop propagation on modal body
modal.addEventListener("click", (e) => e.stopPropagation(), { signal });
// Enter key submits
hostAddrInput.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") handleSave();
}, { signal });
// Mount onto the panel's closest connect-page root
const root = panelEl.closest(".connect-page") ?? document.body;
root.appendChild(overlay);
(nameInput as HTMLInputElement).focus();
}
// ---------------------------------------------------------------------------
// Build & return
// ---------------------------------------------------------------------------
const panelEl = buildPanel();
return {
element: panelEl,
renderProfiles: renderServerProfiles,
updateHealthStatus,
destroy(): void {
// Cleanup is handled by the shared AbortSignal from the parent
},
};
}
@@ -9,15 +9,20 @@ import { createLogger } from "@lib/logger";
import type { MountableComponent } from "@lib/safe-render";
import type { WsClient } from "@lib/ws";
import type { ApiClient } from "@lib/api";
import type { ChannelType } from "@lib/types";
import { createMessageList } from "@components/MessageList";
import type { MessageListComponent } from "@components/MessageList";
import { createMessageInput } from "@components/MessageInput";
import type { MessageInputComponent } from "@components/MessageInput";
import { createTypingIndicator } from "@components/TypingIndicator";
import { getChannelMessages } from "@stores/messages.store";
import { getChannelMessages, setMessagePinned } from "@stores/messages.store";
import type { MessageController } from "./MessageController";
import type { PendingDeleteManager } from "./MessageController";
import type { ReactionController } from "./ReactionController";
import { updateChatHeaderForDm } from "./ChatHeader";
import type { ChatHeaderRefs } from "./ChatHeader";
import { dmStore } from "@stores/dm.store";
import { membersStore } from "@stores/members.store";
const log = createLogger("channel-ctrl");
@@ -40,11 +45,12 @@ export interface ChannelControllerOptions {
readonly inputSlot: HTMLDivElement;
};
readonly chatHeaderName: HTMLSpanElement | null;
readonly chatHeaderRefs: ChatHeaderRefs | null;
}
export interface ChannelController {
/** Mount components for a channel. No-op if same channel already mounted. */
mountChannel(channelId: number, channelName: string): void;
mountChannel(channelId: number, channelName: string, channelType?: ChannelType): void;
/** Destroy current channel components and reset state. */
destroyChannel(): void;
/** Currently mounted channel ID, or null. */
@@ -71,6 +77,7 @@ export function createChannelController(
getCurrentUserId,
slots,
chatHeaderName,
chatHeaderRefs,
} = opts;
let _currentChannelId: number | null = null;
@@ -106,7 +113,7 @@ export function createChannelController(
_currentChannelId = null;
}
function mountChannel(channelId: number, channelName: string): void {
function mountChannel(channelId: number, channelName: string, channelType?: ChannelType): void {
if (_currentChannelId === channelId) return;
destroyChannel();
@@ -128,6 +135,8 @@ export function createChannelController(
// MessageList
messageList = createMessageList({
channelId,
channelName,
channelType,
currentUserId: userId,
onScrollTop: () => {
if (channelAbort !== null) {
@@ -161,6 +170,18 @@ export function createChannelController(
onReactionClick: (msgId: number, emoji: string) => {
reactionCtrl.handleReaction(msgId, emoji);
},
onPinClick: (msgId: number, chId: number, currentlyPinned: boolean) => {
const action = currentlyPinned
? api.unpinMessage(chId, msgId)
: api.pinMessage(chId, msgId);
action.then(() => {
setMessagePinned(chId, msgId, !currentlyPinned);
showToast(currentlyPinned ? "Message unpinned" : "Message pinned", "success");
}).catch((err) => {
log.error("Pin/unpin failed", { error: String(err) });
showToast("Failed to pin/unpin message", "error");
});
},
});
messageList.mount(slots.messagesSlot);
@@ -229,8 +250,37 @@ export function createChannelController(
});
messageInput.mount(slots.inputSlot);
// Arrow-up edit: listen for edit-last-message bubbling from MessageInput
slots.inputSlot.addEventListener("edit-last-message", () => {
const msgs = getChannelMessages(channelId);
const myId = getCurrentUserId();
// Find the last message sent by the current user (array is chronological)
for (let i = msgs.length - 1; i >= 0; i--) {
const m = msgs[i]!;
if (m.user.id === myId && !m.deleted) {
messageInput?.startEdit(m.id, m.content);
break;
}
}
}, { signal });
// Update header
if (chatHeaderName !== null) {
if (chatHeaderRefs !== null && channelType === "dm") {
// Look up the recipient's actual status from DM store or members store
const dmChannel = dmStore.getState().channels.find((c) => c.channelId === channelId);
let recipientStatus = "Offline";
if (dmChannel !== undefined) {
const member = membersStore.getState().members.get(dmChannel.recipient.id);
recipientStatus = member?.status ?? dmChannel.recipient.status ?? "Offline";
}
const displayStatus = recipientStatus.charAt(0).toUpperCase() + recipientStatus.slice(1);
updateChatHeaderForDm(chatHeaderRefs, { username: channelName, status: displayStatus });
} else if (chatHeaderRefs !== null) {
updateChatHeaderForDm(chatHeaderRefs, null);
if (chatHeaderName !== null) {
setText(chatHeaderName, channelName);
}
} else if (chatHeaderName !== null) {
setText(chatHeaderName, channelName);
}
}
@@ -0,0 +1,144 @@
/**
* ChatArea — chat column DOM construction and overlay/video wiring.
* Composes ChatHeader, message/typing/input slots, VideoGrid, pinned panel,
* and search overlay. Extracted from MainPage to reduce orchestrator size.
*/
import { createElement, appendChildren } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
import type { ApiClient } from "@lib/api";
import type { ToastContainer } from "@components/Toast";
import { createVideoGrid } from "@components/VideoGrid";
import type { VideoGridComponent } from "@components/VideoGrid";
import { buildChatHeader } from "./ChatHeader";
import type { ChatHeaderRefs } from "./ChatHeader";
import {
createPinnedPanelController,
createSearchOverlayController,
} from "./OverlayManagers";
import type { SearchOverlayController } from "./OverlayManagers";
import type { ChannelController } from "./ChannelController";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ChatAreaOptions {
readonly api: ApiClient;
readonly getRoot: () => HTMLDivElement | null;
readonly getToast: () => ToastContainer | null;
readonly getChannelCtrl: () => ChannelController | null;
}
export interface ChatAreaResult {
/** The chat area element (center column). */
readonly chatArea: HTMLDivElement;
/** Message/typing/input/videoGrid slots for ChannelController and VideoModeController. */
readonly slots: {
readonly messagesSlot: HTMLDivElement;
readonly typingSlot: HTMLDivElement;
readonly inputSlot: HTMLDivElement;
readonly videoGridSlot: HTMLDivElement;
};
/** The VideoGrid component instance. */
readonly videoGrid: VideoGridComponent;
/** The chat header channel-name element (updated reactively). */
readonly chatHeaderName: HTMLSpanElement | null;
/** Full chat header refs (hash, name, topic) for DM mode updates. */
readonly chatHeaderRefs: ChatHeaderRefs;
/** The search overlay controller. */
readonly searchCtrl: SearchOverlayController;
/** All child MountableComponents for cleanup. */
readonly children: readonly MountableComponent[];
/** Unsubscribe / cleanup functions. */
readonly unsubscribers: readonly (() => void)[];
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function createChatArea(opts: ChatAreaOptions): ChatAreaResult {
const { api, getRoot, getToast, getChannelCtrl } = opts;
const children: MountableComponent[] = [];
const unsubscribers: Array<() => void> = [];
// --- Overlay controllers ---
const pinnedCtrl = createPinnedPanelController({
api,
getRoot,
getToast,
getCurrentChannelId: () => getChannelCtrl()?.currentChannelId ?? null,
onJumpToMessage: (msgId: number) => {
const ctrl = getChannelCtrl();
if (ctrl == null || ctrl.messageList == null) return false;
return ctrl.messageList.scrollToMessage(msgId);
},
});
unsubscribers.push(() => { pinnedCtrl.cleanup(); });
const searchCtrl = createSearchOverlayController({
api,
getRoot,
getToast,
getCurrentChannelId: () => getChannelCtrl()?.currentChannelId ?? null,
onJumpToMessage: (_channelId: number, msgId: number) => {
const ctrl = getChannelCtrl();
if (ctrl == null || ctrl.messageList == null) return false;
return ctrl.messageList.scrollToMessage(msgId);
},
});
unsubscribers.push(() => { searchCtrl.cleanup(); });
// --- Chat header ---
const chatHeader = buildChatHeader({
onTogglePins: () => { void pinnedCtrl.toggle(); },
onSearchFocus: () => { searchCtrl.open(); },
});
const chatHeaderName = chatHeader.refs.nameEl;
// --- Chat area element ---
const chatArea = createElement("div", {
class: "chat-area",
"data-testid": "chat-area",
}) as HTMLDivElement;
chatArea.appendChild(chatHeader.element);
// --- Slots ---
const messagesSlot = createElement("div", {
class: "messages-slot",
"data-testid": "messages-slot",
}) as HTMLDivElement;
const typingSlot = createElement("div", {
class: "typing-slot",
"data-testid": "typing-slot",
}) as HTMLDivElement;
const inputSlot = createElement("div", {
class: "input-slot",
"data-testid": "input-slot",
}) as HTMLDivElement;
const videoGridSlot = createElement("div", {
class: "video-grid-slot",
"data-testid": "video-grid-slot",
style: "display:none;flex:1;min-height:0",
}) as HTMLDivElement;
// --- Video grid ---
const videoGrid = createVideoGrid();
videoGrid.mount(videoGridSlot);
children.push(videoGrid);
appendChildren(chatArea, messagesSlot, typingSlot, inputSlot, videoGridSlot);
return {
chatArea,
slots: { messagesSlot, typingSlot, inputSlot, videoGridSlot },
videoGrid,
chatHeaderName,
chatHeaderRefs: chatHeader.refs,
searchCtrl,
children,
unsubscribers,
};
}
@@ -1,22 +1,22 @@
/**
* ChatHeader — builds the channel header bar with name, topic, pins, search,
* and member-list toggle.
* ChatHeader — builds the channel header bar with name, topic, pins, and search.
*/
import { createElement, appendChildren } from "@lib/dom";
import { createElement, appendChildren, setText } from "@lib/dom";
import { createIcon } from "@lib/icons";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ChatHeaderRefs {
readonly hashEl: HTMLSpanElement;
readonly nameEl: HTMLSpanElement;
readonly topicEl: HTMLSpanElement;
}
export interface ChatHeaderOptions {
readonly onTogglePins: () => void;
readonly onToggleMembers: () => void;
readonly onSearchFocus?: () => void;
}
@@ -40,7 +40,8 @@ export function buildChatHeader(
title: "Pins",
"aria-label": "Pins",
"data-testid": "pin-btn",
}, "\uD83D\uDCCC");
});
pinBtn.appendChild(createIcon("pin", 18));
pinBtn.addEventListener("click", () => { opts.onTogglePins(); });
const searchInput = createElement("input", {
class: "search-input",
@@ -55,14 +56,25 @@ export function buildChatHeader(
(searchInput as HTMLInputElement).blur();
});
}
const membersToggle = createElement("button", {
type: "button",
"aria-label": "Toggle member list",
"data-testid": "members-toggle",
}, "\uD83D\uDC65");
membersToggle.addEventListener("click", () => opts.onToggleMembers());
appendChildren(tools, searchInput, pinBtn, membersToggle);
appendChildren(tools, searchInput, pinBtn);
appendChildren(header, hash, nameEl, divider, topicEl, tools);
return { element: header, refs: { nameEl, topicEl } };
return { element: header, refs: { hashEl: hash, nameEl, topicEl } };
}
// ---------------------------------------------------------------------------
// DM mode helper
// ---------------------------------------------------------------------------
export function updateChatHeaderForDm(
refs: ChatHeaderRefs,
recipient: { username: string; status: string } | null,
): void {
if (recipient !== null) {
setText(refs.hashEl, "@");
setText(refs.nameEl, recipient.username);
setText(refs.topicEl, recipient.status);
} else {
setText(refs.hashEl, "#");
}
}
@@ -44,6 +44,15 @@ export function mapInviteResponse(r: InviteResponse): InviteItem {
// Pinned message mapping
// ---------------------------------------------------------------------------
function pickPinAvatarColor(username: string): string {
let hash = 0;
for (let i = 0; i < username.length; i++) {
hash = username.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash) % 360;
return `hsl(${hue}, 55%, 55%)`;
}
export function mapToPinnedMessage(msg: {
readonly id: number;
readonly user: { readonly username: string };
@@ -56,6 +65,7 @@ export function mapToPinnedMessage(msg: {
author: msg.user.username,
content: msg.content,
timestamp: msg.created_at ?? msg.timestamp ?? "",
avatarColor: pickPinAvatarColor(msg.user.username),
};
}
@@ -0,0 +1,919 @@
/**
* SidebarArea — unified sidebar DOM construction and component wiring.
* Composes a server header, ChannelSidebar or DmSidebar (based on store mode),
* VoiceWidget, and UserBar. The ServerStrip has been removed in favor of the
* unified sidebar layout with a quick-switch overlay for server switching.
*/
import { createElement, setText, clearChildren, appendChildren } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
import type { WsClient } from "@lib/ws";
import type { ApiClient } from "@lib/api";
import type { RateLimiterSet } from "@lib/rate-limiter";
import type { ToastContainer } from "@components/Toast";
import { createChannelSidebar } from "@components/ChannelSidebar";
import { createMemberList } from "@components/MemberList";
import { createDmSidebar, type DmConversation } from "@components/DmSidebar";
import { createCreateChannelModal } from "@components/CreateChannelModal";
import { createEditChannelModal } from "@components/EditChannelModal";
import { createDeleteChannelModal } from "@components/DeleteChannelModal";
import { createUserBar } from "@components/UserBar";
import { createVoiceWidget } from "@components/VoiceWidget";
import { createQuickSwitchOverlay } from "@components/QuickSwitchOverlay";
import type { QuickSwitchProfile } from "@components/QuickSwitchOverlay";
import { createVoiceWidgetCallbacks, createSidebarVoiceCallbacks } from "./VoiceCallbacks";
import { createInviteManagerController } from "./OverlayManagers";
import { uiStore, setSidebarMode, setActiveDmUser, loadCollapsedCategories } from "@stores/ui.store";
import { authStore, clearAuth } from "@stores/auth.store";
import { membersStore, getOnlineMembers } from "@stores/members.store";
import { channelsStore, setActiveChannel, getRoleIdByName } from "@stores/channels.store";
import type { Channel } from "@stores/channels.store";
import { dmStore, clearDmUnread, addDmChannel, removeDmChannel } from "@stores/dm.store";
import type { DmChannel } from "@stores/dm.store";
import {
createProfileManager,
createTauriBackend,
} from "@lib/profiles";
import type { ProfileManager } from "@lib/profiles";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface SidebarAreaOptions {
readonly ws: WsClient;
readonly api: ApiClient;
readonly limiters: RateLimiterSet;
readonly getRoot: () => HTMLDivElement | null;
readonly getToast: () => ToastContainer | null;
readonly onWatchStream?: (userId: number) => void;
}
export interface SidebarAreaResult {
/** The composed sidebar wrapper element. */
readonly sidebarWrapper: HTMLDivElement;
/** All child MountableComponents for cleanup. */
readonly children: readonly MountableComponent[];
/** Unsubscribe / cleanup functions. */
readonly unsubscribers: readonly (() => void)[];
/** Open the quick-switch overlay (used for disconnect flow). */
readonly openQuickSwitch: () => void;
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
const { ws, api, limiters, getRoot, getToast } = opts;
const children: MountableComponent[] = [];
const unsubscribers: Array<() => void> = [];
// Track active modal for channel create/edit/delete
let activeModal: MountableComponent | null = null;
// Remember the channel the user was on before entering DM mode
let channelBeforeDm: number | null = null;
// Track the currently mounted sidebar content component
let activeSidebarContent: MountableComponent | null = null;
// Track invite controller cleanup (recreated on each channels mount)
let inviteCleanup: (() => void) | null = null;
// Track extra channel-mode components (member list) for cleanup on mode switch
let channelModeExtras: MountableComponent[] = [];
let channelModeUnsubs: Array<() => void> = [];
// Profile manager for quick-switch overlay
let profileManager: ProfileManager | null = null;
// Quick-switch overlay instance
let quickSwitchInstance: MountableComponent | null = null;
// ---------------------------------------------------------------------------
// Sidebar wrapper (replaces old channel-sidebar root)
// ---------------------------------------------------------------------------
const sidebarWrapper = createElement("div", {
class: "unified-sidebar",
"data-testid": "unified-sidebar",
}) as HTMLDivElement;
// ---------------------------------------------------------------------------
// Server header
// ---------------------------------------------------------------------------
const serverHeader = createElement("div", { class: "unified-sidebar-header" });
const serverIcon = createElement("div", { class: "server-icon-sm" }, "OC");
const serverInfoCol = createElement("div", { style: "display:flex;flex-direction:column;overflow:hidden;" });
const serverNameEl = createElement("span", { class: "server-name" },
authStore.getState().serverName ?? "Server",
);
const onlineCount = getOnlineMembers().length;
const serverOnlineEl = createElement("span", { class: "server-online" },
`${onlineCount} online`,
);
serverInfoCol.appendChild(serverNameEl);
serverInfoCol.appendChild(serverOnlineEl);
serverHeader.appendChild(serverIcon);
serverHeader.appendChild(serverInfoCol);
// Invite button in the server header (proper styled button)
const headerInviteCtrl = createInviteManagerController({ api, getRoot, getToast });
const headerInviteBtn = createElement("button", {
class: "sidebar-invite-btn",
title: "Invite people",
"data-testid": "invite-btn",
}, "Invite");
headerInviteBtn.addEventListener("click", () => { void headerInviteCtrl.open(); });
serverHeader.appendChild(headerInviteBtn);
unsubscribers.push(() => { headerInviteCtrl.cleanup(); });
sidebarWrapper.appendChild(serverHeader);
// Load per-server collapsed category state from localStorage
const initialServerName = authStore.getState().serverName ?? "Server";
loadCollapsedCategories(initialServerName);
// Keep server name in sync with auth store
const unsubServerName = authStore.subscribeSelector(
(s) => s.serverName,
(name) => {
setText(serverNameEl, name ?? "Server");
},
);
unsubscribers.push(unsubServerName);
// Keep online count in sync with members store
const unsubOnlineCount = membersStore.subscribeSelector(
(s) => s.members,
() => {
const count = getOnlineMembers().length;
setText(serverOnlineEl, `${count} online`);
},
);
unsubscribers.push(unsubOnlineCount);
// ---------------------------------------------------------------------------
// Switchable content slot
// ---------------------------------------------------------------------------
const contentSlot = createElement("div", {
style: "flex:1;display:flex;flex-direction:column;overflow:hidden;",
});
sidebarWrapper.appendChild(contentSlot);
// ---------------------------------------------------------------------------
// Channel sidebar builder (channels mode)
// ---------------------------------------------------------------------------
function buildChannelSidebar(): MountableComponent {
const sidebarVoice = createSidebarVoiceCallbacks(ws);
return createChannelSidebar({
onVoiceJoin: sidebarVoice.onVoiceJoin,
onVoiceLeave: sidebarVoice.onVoiceLeave,
onWatchStream: opts.onWatchStream,
onCreateChannel: (category) => {
if (activeModal !== null) return;
const modal = createCreateChannelModal({
category,
onCreate: async (data) => {
try {
await api.adminCreateChannel(data);
modal.destroy?.();
activeModal = null;
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to create channel";
getToast()?.show(msg, "error");
}
},
onClose: () => {
modal.destroy?.();
activeModal = null;
},
});
activeModal = modal;
modal.mount(document.body);
},
onEditChannel: (channel) => {
if (activeModal !== null) return;
const modal = createEditChannelModal({
channelId: channel.id,
channelName: channel.name,
channelType: channel.type,
onSave: async (data) => {
try {
await api.adminUpdateChannel(channel.id, data);
modal.destroy?.();
activeModal = null;
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to update channel";
getToast()?.show(msg, "error");
}
},
onClose: () => {
modal.destroy?.();
activeModal = null;
},
});
activeModal = modal;
modal.mount(document.body);
},
onDeleteChannel: (channel) => {
if (activeModal !== null) return;
const modal = createDeleteChannelModal({
channelId: channel.id,
channelName: channel.name,
onConfirm: async () => {
try {
await api.adminDeleteChannel(channel.id);
modal.destroy?.();
activeModal = null;
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to delete channel";
getToast()?.show(msg, "error");
}
},
onClose: () => {
modal.destroy?.();
activeModal = null;
},
});
activeModal = modal;
modal.mount(document.body);
},
onReorderChannel: (reorders) => {
for (const r of reorders) {
void api.adminUpdateChannel(r.channelId, { position: r.newPosition });
}
},
});
}
// ---------------------------------------------------------------------------
// Helpers: select a DM conversation and switch to its channel
// ---------------------------------------------------------------------------
function selectDmConversation(dmChannel: DmChannel): void {
// Save current channel so we can restore it when user clicks "Back"
// Only save if the current channel is a real text/voice channel, not another DM
const currentActive = channelsStore.getState().activeChannelId;
if (currentActive !== null) {
const currentCh = channelsStore.getState().channels.get(currentActive);
if (currentCh !== undefined && currentCh.type !== "dm") {
channelBeforeDm = currentActive;
}
}
setActiveDmUser(dmChannel.recipient.id);
setSidebarMode("dms");
clearDmUnread(dmChannel.channelId);
// Add the DM channel to channelsStore so ChannelController can load it
addDmToChannelsStore(dmChannel);
setActiveChannel(dmChannel.channelId);
}
/** Ensure a DM channel exists in channelsStore so ChannelController can switch to it. */
function addDmToChannelsStore(dmChannel: DmChannel): void {
const existing = channelsStore.getState().channels.get(dmChannel.channelId);
// If the channel exists but has an empty name (server sends DMs with name=''),
// update it with the recipient's username
if (existing !== undefined && existing.name !== "") return;
const newChannel: Channel = {
id: dmChannel.channelId,
name: dmChannel.recipient.username,
type: "dm",
category: null,
position: 0,
unreadCount: dmChannel.unreadCount,
lastMessageId: dmChannel.lastMessageId,
};
channelsStore.setState((prev) => {
const next = new Map(prev.channels);
next.set(newChannel.id, newChannel);
return { ...prev, channels: next };
});
}
/** Show a simple member picker modal and call createDm on selection. */
function showMemberPicker(): void {
if (activeModal !== null) return;
const members = membersStore.getState().members;
const currentUserId = authStore.getState().user?.id ?? 0;
const overlay = createElement("div", { class: "modal-overlay visible" });
const modal = createElement("div", { class: "modal dm-member-picker-modal", style: "padding:20px;" });
const title = createElement("h3", {}, "New Direct Message");
const subtitle = createElement("p", { style: "color:var(--text-secondary);font-size:0.85rem;margin:0 0 8px;" },
"Select a member to start a conversation");
const listContainer = createElement("div", {
class: "dm-member-picker-list",
style: "max-height:300px;overflow-y:auto;",
});
for (const member of members.values()) {
if (member.id === currentUserId) continue;
const item = createElement("div", {
class: "dm-member-picker-item channel-item",
style: "cursor:pointer;padding:6px 8px;display:flex;align-items:center;gap:8px;",
});
const avatar = createElement("div", {
class: "dm-avatar",
style: "width:28px;height:28px;border-radius:50%;background:#5865F2;display:flex;align-items:center;justify-content:center;font-size:0.75rem;color:white;flex-shrink:0;",
});
setText(avatar, member.username.charAt(0).toUpperCase());
const nameEl = createElement("span", {}, member.username);
const statusEl = createElement("span", {
style: `font-size:0.75rem;margin-left:auto;color:${member.status === "online" ? "var(--green)" : "var(--text-micro)"};`,
}, member.status);
appendChildren(item, avatar, nameEl, statusEl);
item.addEventListener("click", () => {
closePickerModal();
void handleCreateDm(member.id);
});
listContainer.appendChild(item);
}
const cancelBtn = createElement("button", {
class: "btn btn-secondary",
style: "margin-top:12px;width:100%;",
}, "Cancel");
cancelBtn.addEventListener("click", () => closePickerModal());
appendChildren(modal, title, subtitle, listContainer, cancelBtn);
overlay.appendChild(modal);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) closePickerModal();
});
const pickerComponent: MountableComponent = {
mount: (container: Element) => { container.appendChild(overlay); },
destroy: () => { overlay.remove(); },
};
activeModal = pickerComponent;
pickerComponent.mount(document.body);
}
function closePickerModal(): void {
if (activeModal !== null) {
activeModal.destroy?.();
activeModal = null;
}
}
/** Create a DM with a user via the API and switch to it. */
async function handleCreateDm(recipientId: number): Promise<void> {
try {
const result = await api.createDm(recipientId);
const member = membersStore.getState().members.get(recipientId);
const dmChannel: DmChannel = {
channelId: result.channel_id,
recipient: {
id: result.recipient.id,
username: result.recipient.username,
avatar: result.recipient.avatar,
status: result.recipient.status ?? member?.status ?? "offline",
},
lastMessageId: null,
lastMessage: "",
lastMessageAt: "",
unreadCount: 0,
};
addDmChannel(dmChannel);
selectDmConversation(dmChannel);
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to create DM";
getToast()?.show(msg, "error");
}
}
// ---------------------------------------------------------------------------
// DM sidebar builder (dms mode)
// ---------------------------------------------------------------------------
function buildDmSidebar(): MountableComponent {
const serverName = authStore.getState().serverName ?? "Server";
const activeDmUserId = uiStore.getState().activeDmUserId;
// Build DM conversations from the DM store (real data)
const dmChannels = dmStore.getState().channels;
const conversations: readonly DmConversation[] = dmChannels.map((dm) => ({
userId: dm.recipient.id,
username: dm.recipient.username,
avatar: dm.recipient.avatar || null,
status: (dm.recipient.status as DmConversation["status"]) ?? "offline",
lastMessage: dm.lastMessage || "No messages yet",
timestamp: dm.lastMessageAt,
unread: dm.unreadCount > 0,
active: dm.recipient.id === activeDmUserId,
}));
return createDmSidebar({
conversations,
onSelectConversation: (userId) => {
const dmChannel = dmChannels.find((c) => c.recipient.id === userId);
if (dmChannel !== undefined) {
selectDmConversation(dmChannel);
}
},
onCloseDm: (userId) => {
const dmChannel = dmChannels.find((c) => c.recipient.id === userId);
if (dmChannel !== undefined) {
const wasActive = channelsStore.getState().activeChannelId === dmChannel.channelId;
// Remove from store immediately (optimistic), then call API
removeDmChannel(dmChannel.channelId);
void api.closeDm(dmChannel.channelId);
// If the closed DM was the active chat, switch away
if (wasActive) {
const remaining = dmStore.getState().channels;
if (remaining.length > 0) {
// Switch to the next DM
selectDmConversation(remaining[0]!);
} else {
// No DMs left — go back to channels
setSidebarMode("channels");
if (channelBeforeDm !== null) {
setActiveChannel(channelBeforeDm);
} else {
const channels = channelsStore.getState().channels;
for (const ch of channels.values()) {
if (ch.type === "text") { setActiveChannel(ch.id); break; }
}
}
}
}
}
},
onNewDm: () => {
showMemberPicker();
},
onBack: () => {
setSidebarMode("channels");
// Restore the channel the user was on before entering DMs
if (channelBeforeDm !== null) {
setActiveChannel(channelBeforeDm);
channelBeforeDm = null;
} else {
// Fall back to the first text channel
const channels = channelsStore.getState().channels;
for (const ch of channels.values()) {
if (ch.type === "text") {
setActiveChannel(ch.id);
break;
}
}
}
},
serverName,
});
}
// ---------------------------------------------------------------------------
// Mount sidebar content for current mode
// ---------------------------------------------------------------------------
function mountSidebarContent(mode: "channels" | "dms"): void {
// Tear down the existing content
if (activeSidebarContent !== null) {
activeSidebarContent.destroy?.();
activeSidebarContent = null;
}
if (inviteCleanup !== null) {
inviteCleanup();
inviteCleanup = null;
}
// Clean up channel-mode extras (member list, subscriptions)
for (const comp of channelModeExtras) {
comp.destroy?.();
}
channelModeExtras = [];
for (const unsub of channelModeUnsubs) {
unsub();
}
channelModeUnsubs = [];
clearChildren(contentSlot);
const innerSlot = createElement("div", { style: "flex:1;overflow:hidden;display:flex;flex-direction:column;" });
if (mode === "channels") {
// --- DM section (above channels, below server header) ---
const dmSection = createElement("div", { class: "sidebar-dm-section" });
const dmHeader = createElement("div", { class: "category" });
const dmArrow = createElement("span", { class: "category-arrow" }, "\u25BC");
const dmLabelEl = createElement("span", { class: "category-name" }, "DIRECT MESSAGES");
const dmUnreadBadge = createElement("span", { class: "dm-header-unread-badge" });
const dmAddBtn = createElement("button", { class: "category-add-btn", title: "New DM" }, "+");
dmAddBtn.style.opacity = "1";
appendChildren(dmHeader, dmArrow, dmLabelEl, dmUnreadBadge, dmAddBtn);
dmSection.appendChild(dmHeader);
let dmCollapsed = false;
const dmList = createElement("div", { class: "category-channels sidebar-dm-list" });
// "View All" button (shown when more than 5 DMs exist)
const viewAllBtn = createElement("button", {
class: "sidebar-dm-view-all",
}, "View all messages");
viewAllBtn.addEventListener("click", () => {
setSidebarMode("dms");
});
/** Render DM items from the DM store into the sidebar DM list. */
function renderDmListItems(): void {
clearChildren(dmList);
const dmChannels = dmStore.getState().channels;
const displayChannels = dmChannels.slice(0, 3);
for (const dm of displayChannels) {
const dmItem = createElement("div", {
class: "channel-item",
"data-testid": "dm-entry",
});
const statusColor = dm.recipient.status === "online" ? "var(--green)"
: dm.recipient.status === "idle" ? "var(--yellow)"
: dm.recipient.status === "dnd" ? "var(--red)"
: "var(--text-micro)";
const statusDot = createElement("span", {
style: `display:inline-block;width:8px;height:8px;border-radius:50%;background:${statusColor};flex-shrink:0;`,
});
const name = createElement("span", { class: "ch-name" }, dm.recipient.username);
const parts: Element[] = [statusDot, name];
if (dm.unreadCount > 0) {
const badge = createElement("span", {
class: "dm-unread-badge",
style: "margin-left:auto;background:var(--red);color:white;border-radius:10px;padding:1px 6px;font-size:0.7rem;",
}, String(dm.unreadCount));
parts.push(badge);
}
appendChildren(dmItem, ...parts);
dmItem.addEventListener("click", () => {
selectDmConversation(dm);
});
dmList.appendChild(dmItem);
}
// Show/hide "View All" button based on DM count
if (dmChannels.length > 3) {
setText(viewAllBtn, `View all messages (${dmChannels.length})`);
viewAllBtn.style.display = "";
} else {
viewAllBtn.style.display = "none";
}
// Update total unread badge on the DM header
const totalUnread = dmChannels.reduce((sum, c) => sum + c.unreadCount, 0);
if (totalUnread > 0) {
setText(dmUnreadBadge, String(totalUnread));
dmUnreadBadge.style.display = "";
} else {
dmUnreadBadge.style.display = "none";
}
}
renderDmListItems();
dmSection.appendChild(dmList);
dmSection.appendChild(viewAllBtn);
// Re-render DM list when DM store changes
const unsubDmSection = dmStore.subscribeSelector(
(s) => s.channels,
() => { renderDmListItems(); },
);
channelModeUnsubs.push(unsubDmSection);
dmHeader.addEventListener("click", () => {
dmCollapsed = !dmCollapsed;
dmHeader.classList.toggle("collapsed", dmCollapsed);
dmArrow.textContent = dmCollapsed ? "\u25B6" : "\u25BC";
dmList.style.display = dmCollapsed ? "none" : "";
viewAllBtn.style.display = dmCollapsed ? "none" : (dmStore.getState().channels.length > 5 ? "" : "none");
});
dmAddBtn.addEventListener("click", (e) => {
e.stopPropagation();
showMemberPicker();
});
// DM section goes first (above channels)
contentSlot.appendChild(dmSection);
const channelSidebar = buildChannelSidebar();
channelSidebar.mount(innerSlot);
activeSidebarContent = channelSidebar;
// Inject the channel sidebar content into contentSlot.
contentSlot.appendChild(innerSlot);
// Hide the redundant channel-sidebar-header (server name + invite are now in the unified header)
const oldSidebarHeader = innerSlot.querySelector(".channel-sidebar-header");
if (oldSidebarHeader !== null) {
(oldSidebarHeader as HTMLElement).style.display = "none";
}
// --- Member list (below DM section) ---
const memberListContainer = createElement("div", {
class: "sidebar-members-section",
"data-testid": "sidebar-members",
});
// Member header (styled like category headers)
const memberHeader = createElement("div", { class: "category sidebar-members-header" });
const memberArrow = createElement("span", { class: "category-arrow" }, "\u25BC");
const memberLabelEl = createElement("span", { class: "category-name" }, "MEMBERS");
appendChildren(memberHeader, memberArrow, memberLabelEl);
memberListContainer.appendChild(memberHeader);
// Resize handle
const resizeHandle = createElement("div", { class: "sidebar-resize-handle" });
memberListContainer.appendChild(resizeHandle);
// Restore saved height
const savedHeight = localStorage.getItem("owncord:member-list-height");
if (savedHeight !== null) {
memberListContainer.style.height = `${savedHeight}px`;
}
// Drag-to-resize logic
const resizeAbort = new AbortController();
let isDragging = false;
let startY = 0;
let startHeight = 0;
resizeHandle.addEventListener("mousedown", (e: MouseEvent) => {
isDragging = true;
startY = e.clientY;
startHeight = memberListContainer.offsetHeight;
e.preventDefault();
}, { signal: resizeAbort.signal });
document.addEventListener("mousemove", (e: MouseEvent) => {
if (!isDragging) return;
const delta = startY - e.clientY;
const maxH = window.innerHeight * 0.65;
const newHeight = Math.max(80, Math.min(startHeight + delta, maxH));
memberListContainer.style.height = `${newHeight}px`;
}, { signal: resizeAbort.signal });
document.addEventListener("mouseup", () => {
if (!isDragging) return;
isDragging = false;
localStorage.setItem("owncord:member-list-height", String(memberListContainer.offsetHeight));
}, { signal: resizeAbort.signal });
channelModeUnsubs.push(() => { resizeAbort.abort(); });
// Restore collapsed state from localStorage
const savedCollapsed = localStorage.getItem("owncord:member-list-collapsed");
let membersCollapsed = savedCollapsed === "true";
const memberContent = createElement("div", { class: "sidebar-members-content" });
function applyMembersCollapsed(): void {
memberHeader.classList.toggle("collapsed", membersCollapsed);
memberArrow.textContent = membersCollapsed ? "\u25B6" : "\u25BC";
memberContent.style.display = membersCollapsed ? "none" : "";
resizeHandle.style.display = membersCollapsed ? "none" : "";
if (membersCollapsed) {
memberListContainer.style.height = "auto";
} else {
const h = localStorage.getItem("owncord:member-list-height");
if (h !== null) {
memberListContainer.style.height = `${h}px`;
} else {
memberListContainer.style.height = "";
}
}
}
// Apply initial state
applyMembersCollapsed();
memberHeader.addEventListener("click", () => {
membersCollapsed = !membersCollapsed;
localStorage.setItem("owncord:member-list-collapsed", String(membersCollapsed));
applyMembersCollapsed();
});
const memberList = createMemberList({
currentUserRole: authStore.getState().user?.role ?? "member",
onKick: async (userId, username) => {
try {
await api.adminKickMember(userId);
getToast()?.show(`Kicked ${username}`, "success");
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to kick member";
getToast()?.show(msg, "error");
}
},
onBan: async (userId, username) => {
try {
await api.adminBanMember(userId);
getToast()?.show(`Banned ${username}`, "success");
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to ban member";
getToast()?.show(msg, "error");
}
},
onChangeRole: async (userId, username, newRole) => {
const roleId = getRoleIdByName(newRole);
if (roleId === undefined) return;
try {
await api.adminChangeRole(userId, roleId);
getToast()?.show(`Changed ${username}'s role to ${newRole}`, "success");
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to change role";
getToast()?.show(msg, "error");
}
},
});
memberList.mount(memberContent);
memberListContainer.appendChild(memberContent);
contentSlot.appendChild(memberListContainer);
channelModeExtras.push(memberList);
} else {
const dmSidebar = buildDmSidebar();
dmSidebar.mount(innerSlot);
activeSidebarContent = dmSidebar;
contentSlot.appendChild(innerSlot);
/**
* Re-render the DM sidebar from fresh store data.
*
* TODO(H16): This is an O(n) DOM thrash — it destroys and recreates the
* entire DM sidebar on every store change. For a small number of DMs this
* is acceptable, but should be optimized to diff/patch individual DM items
* once the DM list grows or store updates become more frequent.
*/
function refreshDmSidebar(): void {
if (activeSidebarContent !== null) {
activeSidebarContent.destroy?.();
}
clearChildren(contentSlot);
const freshSlot = createElement("div", { style: "flex:1;overflow:hidden;display:flex;flex-direction:column;" });
const freshDm = buildDmSidebar();
freshDm.mount(freshSlot);
activeSidebarContent = freshDm;
contentSlot.appendChild(freshSlot);
}
// Re-render DM sidebar when DM store changes (new DMs, message updates)
const unsubDmStore = dmStore.subscribeSelector(
(s) => s.channels,
() => { refreshDmSidebar(); },
);
channelModeUnsubs.push(unsubDmStore);
// Re-render DM sidebar when active DM user changes
const unsubDmActive = uiStore.subscribeSelector(
(s) => s.activeDmUserId,
() => { refreshDmSidebar(); },
);
channelModeUnsubs.push(unsubDmActive);
}
}
// Initial mount based on current store state
const initialMode = uiStore.getState().sidebarMode;
mountSidebarContent(initialMode);
// Subscribe to sidebar mode changes
const unsubSidebarMode = uiStore.subscribeSelector(
(s) => s.sidebarMode,
(mode) => {
mountSidebarContent(mode);
},
);
unsubscribers.push(unsubSidebarMode);
// ---------------------------------------------------------------------------
// Voice widget (always visible)
// ---------------------------------------------------------------------------
const voiceWidgetSlot = createElement("div", {});
const voiceWidget = createVoiceWidget(
createVoiceWidgetCallbacks(ws, limiters),
);
voiceWidget.mount(voiceWidgetSlot);
children.push(voiceWidget);
sidebarWrapper.appendChild(voiceWidgetSlot);
// ---------------------------------------------------------------------------
// Quick-switch overlay
// ---------------------------------------------------------------------------
function openQuickSwitch(): void {
if (quickSwitchInstance !== null) return;
const currentHost = api.getConfig().host ?? "";
// Load profiles asynchronously, then show overlay
void (async () => {
let profiles: readonly QuickSwitchProfile[] = [];
try {
if (profileManager === null) {
profileManager = createProfileManager(createTauriBackend());
}
await profileManager.loadProfiles();
profiles = profileManager.getAll().map((p) => ({
name: p.name,
host: p.host,
}));
} catch {
// If profiles fail to load (e.g., outside Tauri), show empty list
profiles = [];
}
// Ensure we haven't been cleaned up while awaiting
if (sidebarWrapper.parentElement === null) return;
quickSwitchInstance = createQuickSwitchOverlay({
profiles,
currentHost,
onSwitch: (host, _name) => {
closeQuickSwitch();
// Store target for ConnectPage to auto-select after navigation
sessionStorage.setItem("owncord:quick-switch-target", host);
// Trigger normal logout flow (clears auth -> ws disconnect -> navigate to connect)
clearAuth();
},
onAddServer: () => {
closeQuickSwitch();
// Navigate to ConnectPage so the user can add a new server
clearAuth();
},
onClose: closeQuickSwitch,
});
quickSwitchInstance.mount(document.body);
})();
}
function closeQuickSwitch(): void {
if (quickSwitchInstance !== null) {
quickSwitchInstance.destroy?.();
quickSwitchInstance = null;
}
}
// ---------------------------------------------------------------------------
// User bar (always visible, with disconnect wired)
// ---------------------------------------------------------------------------
const userBarSlot = createElement("div", {});
const userBar = createUserBar({ onDisconnect: openQuickSwitch });
userBar.mount(userBarSlot);
children.push(userBar);
sidebarWrapper.appendChild(userBarSlot);
// ---------------------------------------------------------------------------
// Cleanup for active modal
// ---------------------------------------------------------------------------
unsubscribers.push(() => {
if (activeModal !== null) {
activeModal.destroy?.();
activeModal = null;
}
});
unsubscribers.push(() => {
if (activeSidebarContent !== null) {
activeSidebarContent.destroy?.();
activeSidebarContent = null;
}
if (inviteCleanup !== null) {
inviteCleanup();
inviteCleanup = null;
}
for (const comp of channelModeExtras) {
comp.destroy?.();
}
channelModeExtras = [];
for (const unsub of channelModeUnsubs) {
unsub();
}
channelModeUnsubs = [];
});
unsubscribers.push(() => {
closeQuickSwitch();
});
return {
sidebarWrapper,
children,
unsubscribers,
openQuickSwitch,
};
}
@@ -4,8 +4,8 @@
*/
import { voiceStore } from "@stores/voice.store";
import { getLocalCameraStream } from "@lib/voiceSession";
import type { VideoGridComponent } from "@components/VideoGrid";
import { getLocalCameraStream, getLocalScreenshareStream } from "@lib/livekitSession";
import type { VideoGridComponent, TileConfig } from "@components/VideoGrid";
// ---------------------------------------------------------------------------
// Types
@@ -33,6 +33,10 @@ export interface VideoModeController {
showVideoGrid(): void;
/** Whether video grid is currently visible. */
isVideoMode(): boolean;
/** Set focus on a specific video tile (focus mode). */
setFocus(tileId: number): void;
/** Get the currently focused tile ID, or null if none. */
getFocusedTileId(): number | null;
/** Reset state on teardown. */
destroy(): void;
}
@@ -46,6 +50,11 @@ export function createVideoModeController(
): VideoModeController {
const { slots, videoGrid, getCurrentUserId } = opts;
let videoMode = false;
/** Track whether we've already added the local self-view tile. */
let localTileAdded = false;
let localScreenshareTileAdded = false;
let focusedTileId: number | null = null;
const SCREENSHARE_TILE_ID_OFFSET = 1_000_000;
function showVideoGrid(): void {
if (videoMode) return;
@@ -59,6 +68,9 @@ export function createVideoModeController(
function showChat(): void {
if (!videoMode) return;
videoMode = false;
focusedTileId = null;
localTileAdded = false;
localScreenshareTileAdded = false;
slots.messagesSlot.style.display = "";
slots.typingSlot.style.display = "";
slots.inputSlot.style.display = "";
@@ -78,42 +90,67 @@ export function createVideoModeController(
return;
}
// Check if any camera is active
let anyCameraOn = voice.localCamera;
if (!anyCameraOn) {
// Check if any camera or screenshare is active
let anyVideoOn = voice.localCamera || voice.localScreenshare;
if (!anyVideoOn) {
for (const user of channelUsers.values()) {
if (user.camera) {
anyCameraOn = true;
if (user.camera || user.screenshare) {
anyVideoOn = true;
break;
}
}
}
if (anyCameraOn && !videoMode) {
showVideoGrid();
} else if (!anyCameraOn && videoMode) {
// Auto-close video grid when no streams remain
if (!anyVideoOn && videoMode) {
showChat();
}
// Manage local self-view tile
// Manage local self-view tile — only add once, skip if already showing
const currentUserId = getCurrentUserId();
if (voice.localCamera) {
const localStream = getLocalCameraStream();
if (localStream !== null) {
const me = channelUsers.get(currentUserId);
videoGrid.addStream(
currentUserId,
me?.username ? `${me.username} (You)` : "You",
localStream,
);
if (!localTileAdded) {
const localStream = getLocalCameraStream();
if (localStream !== null) {
const me = channelUsers.get(currentUserId);
videoGrid.addStream(
currentUserId,
me?.username ? `${me.username} (You)` : "You",
localStream,
{ isSelf: true, audioUserId: currentUserId, isScreenshare: false },
);
localTileAdded = true;
}
}
} else {
videoGrid.removeStream(currentUserId);
localTileAdded = false;
}
// Remove remote video tiles for users who turned off their camera
// Manage local screenshare self-view tile
const screenshareUserId = currentUserId + SCREENSHARE_TILE_ID_OFFSET;
if (voice.localScreenshare) {
if (!localScreenshareTileAdded) {
const localStream = getLocalScreenshareStream();
if (localStream !== null) {
const me = channelUsers.get(currentUserId);
videoGrid.addStream(
screenshareUserId,
me?.username ? `${me.username} (Screen)` : "Your Screen",
localStream,
{ isSelf: true, audioUserId: currentUserId, isScreenshare: true },
);
localScreenshareTileAdded = true;
}
}
} else {
videoGrid.removeStream(screenshareUserId);
localScreenshareTileAdded = false;
}
// Remove remote video tiles for users who turned off their camera or screenshare
if (channelUsers) {
for (const user of channelUsers.values()) {
if (!user.camera && user.userId !== currentUserId) {
if (!user.camera && !user.screenshare && user.userId !== currentUserId) {
videoGrid.removeStream(user.userId);
}
}
@@ -124,8 +161,20 @@ export function createVideoModeController(
return videoMode;
}
function setFocus(tileId: number): void {
focusedTileId = tileId;
videoGrid.setFocusedTile(tileId);
}
function getFocusedTileId(): number | null {
return focusedTileId;
}
function destroy(): void {
if (videoMode) showChat();
focusedTileId = null;
localTileAdded = false;
localScreenshareTileAdded = false;
}
return {
@@ -133,6 +182,8 @@ export function createVideoModeController(
showChat,
showVideoGrid,
isVideoMode: isVideoModeActive,
setFocus,
getFocusedTileId,
destroy,
};
}
@@ -9,7 +9,6 @@ import {
voiceStore,
joinVoiceChannel,
leaveVoiceChannel,
setLocalScreenshare,
} from "@stores/voice.store";
import {
leaveVoice as voiceSessionLeave,
@@ -17,7 +16,9 @@ import {
setDeafened as voiceSessionSetDeafened,
enableCamera,
disableCamera,
} from "@lib/voiceSession";
enableScreenshare,
disableScreenshare,
} from "@lib/livekitSession";
const log = createLogger("voice-callbacks");
@@ -106,8 +107,14 @@ export function createVoiceWidgetCallbacks(
onScreenshareToggle: () => {
if (!limiters.voiceVideo.tryConsume()) return;
const next = !voiceStore.getState().localScreenshare;
setLocalScreenshare(next);
ws.send({ type: "voice_screenshare", payload: { enabled: next } });
const handleScreenshareError = (err: unknown) => {
log.error("Screenshare toggle failed", { error: String(err) });
};
if (next) {
enableScreenshare().catch(handleScreenshareError);
} else {
disableScreenshare().catch(handleScreenshareError);
}
},
};
}
+1 -1
View File
@@ -6,7 +6,7 @@
import { createStore } from "@lib/store";
import type { UserWithRole } from "@lib/types";
import { resetVoiceStore } from "@stores/voice.store";
import { leaveVoice } from "@lib/voiceSession";
import { leaveVoice } from "@lib/livekitSession";
export interface AuthState {
readonly token: string | null;
@@ -6,6 +6,7 @@
import { createStore } from "@lib/store";
import type {
ReadyChannel,
ReadyRole,
ChannelCreatePayload,
ChannelUpdatePayload,
ChannelType,
@@ -24,11 +25,13 @@ export interface Channel {
export interface ChannelsState {
readonly channels: ReadonlyMap<number, Channel>;
readonly activeChannelId: number | null;
readonly roles: readonly ReadyRole[];
}
const INITIAL_STATE: ChannelsState = {
channels: new Map(),
activeChannelId: null,
roles: [],
};
export const channelsStore = createStore<ChannelsState>(INITIAL_STATE);
@@ -53,6 +56,18 @@ export function setChannels(channels: readonly ReadyChannel[]): void {
}));
}
/** Bulk set roles from the ready payload. */
export function setRoles(roles: readonly ReadyRole[]): void {
channelsStore.setState((prev) => ({ ...prev, roles }));
}
/** Look up a role ID by name (case-insensitive). Returns undefined if not found. */
export function getRoleIdByName(name: string): number | undefined {
const roles = channelsStore.getState().roles;
const match = roles.find((r) => r.name.toLowerCase() === name.toLowerCase());
return match?.id;
}
/** Add a single channel from a channel_create event. */
export function addChannel(channel: ChannelCreatePayload): void {
channelsStore.setState((prev) => {
@@ -147,6 +162,8 @@ export function getChannelsByCategory(): Map<string | null, Channel[]> {
return channelsStore.select((s) => {
const grouped = new Map<string | null, Channel[]>();
for (const channel of s.channels.values()) {
// DM channels are shown in the DM sidebar, not the channel list
if (channel.type === "dm") continue;
const existing = grouped.get(channel.category);
if (existing !== undefined) {
existing.push(channel);
+102
View File
@@ -0,0 +1,102 @@
/**
* DM store — holds direct message channel list and unread state.
* Immutable state updates only.
*/
import { createStore } from "@lib/store";
export interface DmUser {
readonly id: number;
readonly username: string;
readonly avatar: string;
readonly status: string;
}
export interface DmChannel {
readonly channelId: number;
readonly recipient: DmUser;
readonly lastMessageId: number | null;
readonly lastMessage: string;
readonly lastMessageAt: string;
readonly unreadCount: number;
}
export interface DmState {
readonly channels: readonly DmChannel[];
}
const INITIAL: DmState = { channels: [] };
export const dmStore = createStore<DmState>(INITIAL);
/** Bulk-set DM channels from ready payload. */
export function setDmChannels(channels: readonly DmChannel[]): void {
dmStore.setState(() => ({ channels }));
}
/** Add or update a single DM channel (from dm_channel_open event). */
export function addDmChannel(channel: DmChannel): void {
dmStore.setState((prev) => {
const filtered = prev.channels.filter((c) => c.channelId !== channel.channelId);
return { channels: [channel, ...filtered] };
});
}
/** Remove a DM channel from the list (from dm_channel_close event). */
export function removeDmChannel(channelId: number): void {
dmStore.setState((prev) => ({
channels: prev.channels.filter((c) => c.channelId !== channelId),
}));
}
/** Update last message info for a DM channel (on new message) and increment unread.
* Moves the channel to the top of the list so new messages are always visible. */
export function updateDmLastMessage(
channelId: number,
messageId: number,
content: string,
timestamp: string,
): void {
dmStore.setState((prev) => {
const updated = prev.channels.find((c) => c.channelId === channelId);
if (updated === undefined) return prev;
const rest = prev.channels.filter((c) => c.channelId !== channelId);
return {
channels: [
{ ...updated, lastMessageId: messageId, lastMessage: content, lastMessageAt: timestamp, unreadCount: updated.unreadCount + 1 },
...rest,
],
};
});
}
/** Update last message preview for a DM channel without incrementing unread count.
* Used for own messages and messages in the currently focused DM.
* Moves the channel to the top of the list so active conversations stay visible. */
export function updateDmLastMessagePreview(
channelId: number,
messageId: number,
content: string,
timestamp: string,
): void {
dmStore.setState((prev) => {
const updated = prev.channels.find((c) => c.channelId === channelId);
if (updated === undefined) return prev;
const rest = prev.channels.filter((c) => c.channelId !== channelId);
return {
channels: [
{ ...updated, lastMessageId: messageId, lastMessage: content, lastMessageAt: timestamp },
...rest,
],
};
});
}
/** Clear unread count for a DM channel. */
export function clearDmUnread(channelId: number): void {
dmStore.setState((prev) => ({
channels: prev.channels.map((c) =>
c.channelId === channelId ? { ...c, unreadCount: 0 } : c,
),
}));
}
@@ -37,7 +37,9 @@ function typingKey(channelId: number, userId: number): string {
return `${channelId}:${userId}`;
}
/** Bulk set members from the ready payload. */
/** Bulk set members from the ready payload.
* Also clears typing state and timers — a fresh ready means all typing
* indicators from the previous session are stale. */
export function setMembers(members: readonly ReadyMember[]): void {
const map = new Map<number, Member>();
for (const m of members) {
@@ -49,9 +51,14 @@ export function setMembers(members: readonly ReadyMember[]): void {
status: m.status,
});
}
membersStore.setState((prev) => ({
...prev,
// Clear all outstanding typing timers
for (const timer of typingTimers.values()) {
clearTimeout(timer);
}
typingTimers.clear();
membersStore.setState(() => ({
members: map,
typingUsers: new Map(),
}));
}

Some files were not shown because too many files have changed in this diff Show More