Merge pull request #82 from J3vb/feature/livekit-migration
feat: LiveKit voice migration, DMs, themes, and remote voice support
@@ -42,3 +42,6 @@ Client/publish-release/
|
||||
Client/login-mockup.html
|
||||
Client/ui-mockup.html
|
||||
.gstack/
|
||||
.claude-flow/
|
||||
.mcp.json
|
||||
.superpowers/
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 361 B After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 858 B After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 105 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 127 B After Width: | Height: | Size: 57 KiB |
|
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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||