Merge pull request #84 from J3vb/dev
v1.0.0 — OwnCord first public release
@@ -2,23 +2,16 @@
|
||||
.claude/
|
||||
Client/.claude/
|
||||
Server/.claude/
|
||||
CLAUDE.md
|
||||
|
||||
# Claude Code skills
|
||||
skills/
|
||||
# GitHub Copilot instructions (internal)
|
||||
.github/copilot-instructions.md
|
||||
.github/instructions/
|
||||
|
||||
# AI-specific / internal planning docs
|
||||
Obsidian-Brain/
|
||||
IMPROVEMENTS.md
|
||||
STOAT-RESEARCH.md
|
||||
PROMPTS.md
|
||||
SKILL.md
|
||||
AUDIT.md
|
||||
LANGUAGE-REVIEW.md
|
||||
MIGRATION-PLAN.md
|
||||
TESTING-STRATEGY.md
|
||||
CLIENT-ARCHITECTURE.md
|
||||
docs/superpowers/
|
||||
docs/brain/
|
||||
docs/
|
||||
skills/
|
||||
|
||||
# Server runtime artifacts
|
||||
Server/chatserver.exe
|
||||
Server/chatserver.exe~
|
||||
@@ -41,4 +34,19 @@ Client/publish-release/
|
||||
# HTML mockups (large design reference files)
|
||||
Client/login-mockup.html
|
||||
Client/ui-mockup.html
|
||||
|
||||
# Node modules
|
||||
node_modules/
|
||||
|
||||
# AI tooling
|
||||
.gstack/
|
||||
.claude-flow/
|
||||
.mcp.json
|
||||
.superpowers/
|
||||
|
||||
# Internal dev files
|
||||
SKILL.md
|
||||
TODOS.md
|
||||
CLAUDE.md
|
||||
DESIGN.md
|
||||
Client/CLIENT-REVIEW.md
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# Client Code Review Findings
|
||||
|
||||
Date: 2026-03-16
|
||||
|
||||
Scope: `Client/tauri-client/src`
|
||||
|
||||
## High
|
||||
|
||||
- Auth token is never set in `authStore` after login. The `auth_ok` handler calls `setAuth(authStore.getState().token ?? "", ...)`, so the store token becomes an empty string. Any future logic that relies on `authStore.token` (re-auth, API helpers, telemetry) will be wrong. File: `Client/tauri-client/src/lib/dispatcher.ts`.
|
||||
- If Tauri APIs are unavailable, `ws.connect` logs an error and returns early but leaves the connection state as `connecting` and never schedules a retry or notifies the UI. This can hang the client in a pseudo-connecting state in browser/test contexts. File: `Client/tauri-client/src/lib/ws.ts`.
|
||||
|
||||
## Medium
|
||||
|
||||
- Server-driven voice disconnects do not clear `currentChannelId`. The dispatcher handles `voice_leave` by removing users only; it does not call `leaveVoiceChannel()` when the current user is removed, so the voice widget can stay visible after kicks/disconnects. Files: `Client/tauri-client/src/lib/dispatcher.ts`, `Client/tauri-client/src/stores/voice.store.ts`, `Client/tauri-client/src/components/VoiceWidget.ts`.
|
||||
- Theme/font-size/compact-mode preferences are applied only when the Settings overlay is opened. On app start, stored preferences are not applied, causing UI to render in default theme until the user opens Settings. File: `Client/tauri-client/src/components/SettingsOverlay.ts`.
|
||||
|
||||
## Low
|
||||
|
||||
- Infinite scroll throttling in the message list uses a fixed `500ms` timeout to reset `loadingOlder`, independent of the fetch completion. On slow responses this can trigger overlapping loads or repeated requests. File: `Client/tauri-client/src/components/MessageList.ts`.
|
||||
@@ -0,0 +1,80 @@
|
||||
import eslint from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// --- Key rules from T-191 ---
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"consistent-return": "error",
|
||||
|
||||
// --- Relax rules that conflict with project style ---
|
||||
// Project uses `any` sparingly with eslint-disable comments
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
// Project uses non-null assertions intentionally
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
// Empty functions are used for no-op callbacks
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
// Project uses void for fire-and-forget promises intentionally
|
||||
"@typescript-eslint/no-misused-promises": [
|
||||
"error",
|
||||
{ checksVoidReturn: false },
|
||||
],
|
||||
// Allow require() in config files
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
// Unbound methods used in singleton export pattern (bind at export)
|
||||
"@typescript-eslint/unbound-method": "off",
|
||||
// Allow unsafe member access on `any` — project narrows manually
|
||||
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||
"@typescript-eslint/no-unsafe-argument": "off",
|
||||
"@typescript-eslint/no-unsafe-call": "off",
|
||||
"@typescript-eslint/no-unsafe-return": "off",
|
||||
// Redundant type constituents show up in union types with branded types
|
||||
"@typescript-eslint/no-redundant-type-constituents": "off",
|
||||
// Permissions use number bitmasks compared with enum values — intentional
|
||||
"@typescript-eslint/no-unsafe-enum-comparison": "off",
|
||||
// Interface-conforming async methods don't always need await
|
||||
"@typescript-eslint/require-await": "off",
|
||||
// Re-throwing with different message is a project pattern
|
||||
"preserve-caught-error": "off",
|
||||
// Promise rejection with string literals is used in some UI code
|
||||
"@typescript-eslint/prefer-promise-reject-errors": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
// Test files get relaxed rules
|
||||
files: ["tests/**/*.ts"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-floating-promises": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"consistent-return": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
"dist/",
|
||||
"src-tauri/",
|
||||
"node_modules/",
|
||||
"public/",
|
||||
"*.js",
|
||||
"*.cjs",
|
||||
],
|
||||
},
|
||||
);
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "owncord-client",
|
||||
"private": true,
|
||||
"version": "1.3.0",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build": "tsc -p tsconfig.build.json && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"test": "vitest run",
|
||||
@@ -16,14 +16,21 @@
|
||||
"test:e2e:native": "playwright test --config playwright.config.native.ts",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:build": "tsc -p tsconfig.build.json --noEmit",
|
||||
"lint": "eslint src/",
|
||||
"lint:fix": "eslint src/ --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@playwright/test": "^1",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@vitest/coverage-v8": "^3",
|
||||
"eslint": "^9.39.4",
|
||||
"jsdom": "^29.0.0",
|
||||
"typescript": "^5.7",
|
||||
"typescript-eslint": "^8.57.2",
|
||||
"vite": "^6",
|
||||
"vitest": "^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,
|
||||
timeout: 120_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
timeout: 15_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,
|
||||
@@ -29,13 +40,31 @@ export default defineConfig({
|
||||
: "html",
|
||||
|
||||
use: {
|
||||
actionTimeout: 15_000,
|
||||
navigationTimeout: 30_000,
|
||||
actionTimeout: 30_000,
|
||||
navigationTimeout: 45_000,
|
||||
screenshot: "only-on-failure",
|
||||
trace: "on-first-retry",
|
||||
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);
|
||||
@@ -0,0 +1,97 @@
|
||||
// =============================================================================
|
||||
// VAD (Voice Activity Detection) AudioWorklet Processor
|
||||
//
|
||||
// Runs on the audio rendering thread. Computes RMS energy per audio frame and
|
||||
// sends gating decisions to the main thread via MessagePort. This replaces
|
||||
// setTimeout-based polling which pauses when the app is backgrounded.
|
||||
//
|
||||
// Protocol:
|
||||
// Main → Worklet: { type: "config", threshold: number, gateOnFrames: number, gateOffFrames: number }
|
||||
// Main → Worklet: { type: "stop" }
|
||||
// Worklet → Main: { type: "gate", gated: boolean }
|
||||
// Worklet → Main: { type: "rms", value: number } (optional, for VAD indicator)
|
||||
// =============================================================================
|
||||
|
||||
class VadProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this._threshold = 0.05;
|
||||
this._gateOnFrames = 12; // ~200ms of silence before gating
|
||||
this._gateOffFrames = 2; // ~33ms of speech before ungating
|
||||
this._silentFrames = 0;
|
||||
this._speechFrames = 0;
|
||||
this._gated = false;
|
||||
this._active = true;
|
||||
this._startupFrames = 0;
|
||||
this._startupGrace = 30; // ~500ms grace period
|
||||
this._frameCounter = 0; // for throttled RMS updates
|
||||
|
||||
this.port.onmessage = (event) => {
|
||||
if (event.data.type === "config") {
|
||||
this._threshold = event.data.threshold;
|
||||
if (event.data.gateOnFrames !== undefined) this._gateOnFrames = event.data.gateOnFrames;
|
||||
if (event.data.gateOffFrames !== undefined) this._gateOffFrames = event.data.gateOffFrames;
|
||||
// Reset state on config change
|
||||
this._silentFrames = 0;
|
||||
this._speechFrames = 0;
|
||||
this._startupFrames = 0;
|
||||
if (this._gated) {
|
||||
this._gated = false;
|
||||
this.port.postMessage({ type: "gate", gated: false });
|
||||
}
|
||||
} else if (event.data.type === "stop") {
|
||||
this._active = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
process(inputs) {
|
||||
if (!this._active) return false; // Returning false stops the processor
|
||||
|
||||
const input = inputs[0];
|
||||
if (input === undefined || input.length === 0 || input[0] === undefined) return true;
|
||||
|
||||
const samples = input[0];
|
||||
let sum = 0;
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const v = samples[i];
|
||||
sum += v * v;
|
||||
}
|
||||
const rms = Math.sqrt(sum / samples.length);
|
||||
|
||||
// Grace period: don't gate for the first ~500ms to let audio settle
|
||||
if (this._startupFrames < this._startupGrace) {
|
||||
this._startupFrames++;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Send RMS value to main thread every ~6 frames (~50ms at 128 samples/frame @ 48kHz)
|
||||
// This is used for the VAD indicator bar in the UI
|
||||
this._frameCounter++;
|
||||
if (this._frameCounter >= 6) {
|
||||
this._frameCounter = 0;
|
||||
this.port.postMessage({ type: "rms", value: rms });
|
||||
}
|
||||
|
||||
// Gate logic (identical to the setTimeout version)
|
||||
if (rms < this._threshold) {
|
||||
this._speechFrames = 0;
|
||||
this._silentFrames++;
|
||||
if (!this._gated && this._silentFrames >= this._gateOnFrames) {
|
||||
this._gated = true;
|
||||
this.port.postMessage({ type: "gate", gated: true });
|
||||
}
|
||||
} else {
|
||||
this._silentFrames = 0;
|
||||
this._speechFrames++;
|
||||
if (this._gated && this._speechFrames >= this._gateOffFrames) {
|
||||
this._gated = false;
|
||||
this.port.postMessage({ type: "gate", gated: false });
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor("vad-processor", VadProcessor);
|
||||
@@ -41,6 +41,56 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
@@ -471,6 +521,12 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -980,6 +1036,29 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef"
|
||||
dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.11.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"env_filter",
|
||||
"jiff",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -1977,6 +2056,12 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -2006,6 +2091,30 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
|
||||
dependencies = [
|
||||
"jiff-static",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
@@ -2544,6 +2653,12 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.3"
|
||||
@@ -2594,9 +2709,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "owncord-client"
|
||||
version = "1.2.0"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"futures-util",
|
||||
"log",
|
||||
"ring",
|
||||
"rustls",
|
||||
"serde",
|
||||
@@ -2613,6 +2730,7 @@ dependencies = [
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-updater",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-tungstenite",
|
||||
"url",
|
||||
"windows 0.58.0",
|
||||
@@ -2946,6 +3064,21 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
@@ -5232,6 +5365,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.22.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "owncord-client"
|
||||
version = "1.3.0"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
description = "OwnCord Desktop Client"
|
||||
|
||||
@@ -31,9 +31,12 @@ 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"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.58", features = ["Win32_Security_Credentials", "Win32_Foundation", "Win32_UI_Input_KeyboardAndMouse"] }
|
||||
|
||||
@@ -75,6 +75,54 @@
|
||||
"path": "**"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-write-text-file",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$APPLOG/**"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-mkdir",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$APPLOG/**"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-exists",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$APPLOG/**"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-read-dir",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$APPLOG/**"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-remove",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$APPLOG/**"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-read-text-file",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$APPLOG/**"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
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 |
@@ -75,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());
|
||||
}
|
||||
@@ -82,7 +85,7 @@ 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)
|
||||
// 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());
|
||||
}
|
||||
@@ -143,3 +146,73 @@ pub fn open_devtools(_window: tauri::WebviewWindow) {
|
||||
_window.open_devtools();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn allowed_key_owncord_prefix() {
|
||||
assert!(is_settings_key_allowed("owncord:profiles"));
|
||||
assert!(is_settings_key_allowed("owncord:settings:theme"));
|
||||
assert!(is_settings_key_allowed("owncord:recent-emoji"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_key_user_volume_prefix() {
|
||||
assert!(is_settings_key_allowed("userVolume_42"));
|
||||
assert!(is_settings_key_allowed("userVolume_0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_key_exact_match() {
|
||||
assert!(is_settings_key_allowed("windowState"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_key_empty() {
|
||||
assert!(!is_settings_key_allowed(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_key_too_long() {
|
||||
let long_key = "owncord:".to_owned() + &"x".repeat(MAX_SETTINGS_KEY_LEN);
|
||||
assert!(!is_settings_key_allowed(&long_key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_key_unknown_prefix() {
|
||||
assert!(!is_settings_key_allowed("unknown:key"));
|
||||
assert!(!is_settings_key_allowed("admin:secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_key_partial_prefix_match() {
|
||||
// "owncord" without colon should not match "owncord:" prefix
|
||||
assert!(!is_settings_key_allowed("owncordNOCOLON"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_validation_accepts_valid() {
|
||||
let valid = "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99";
|
||||
assert_eq!(valid.len(), 95);
|
||||
// Validation logic: length 95, hex digits at non-colon positions, colons at every 3rd
|
||||
for (i, ch) in valid.chars().enumerate() {
|
||||
if i % 3 == 2 {
|
||||
assert_eq!(ch, ':');
|
||||
} else {
|
||||
assert!(ch.is_ascii_hexdigit());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_validation_rejects_wrong_length() {
|
||||
let short = "aa:bb:cc";
|
||||
assert_ne!(short.len(), 95);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,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() {
|
||||
@@ -124,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())
|
||||
.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());
|
||||
|
||||
// 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.
|
||||
@@ -188,3 +195,58 @@ pub fn delete_credential(host: String) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn target_name_encodes_host_as_utf16() {
|
||||
let result = target_name("localhost:8443");
|
||||
let expected: Vec<u16> = "OwnCord/localhost:8443"
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_name_empty_host() {
|
||||
let result = target_name("");
|
||||
let expected: Vec<u16> = "OwnCord/"
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_wide_ascii() {
|
||||
let result = to_wide("hello");
|
||||
let expected: Vec<u16> = "hello"
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
assert_eq!(result, expected);
|
||||
// Last element must be null terminator
|
||||
assert_eq!(*result.last().unwrap(), 0u16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_wide_empty_string() {
|
||||
let result = to_wide("");
|
||||
assert_eq!(result, vec![0u16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_wide_unicode() {
|
||||
let result = to_wide("日本語");
|
||||
assert_eq!(*result.last().unwrap(), 0u16);
|
||||
// 3 CJK chars + null terminator = 4 elements
|
||||
assert_eq!(result.len(), 4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 +40,19 @@ 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| {
|
||||
// Initialize Rust logging (controlled by RUST_LOG env var, defaults to info).
|
||||
// try_init avoids panic if another logger (e.g. a Tauri plugin) registered first.
|
||||
let _ = env_logger::Builder::from_env(
|
||||
env_logger::Env::default().default_filter_or("info"),
|
||||
)
|
||||
.format_timestamp_millis()
|
||||
.try_init();
|
||||
|
||||
tray::create_tray(app.handle())?;
|
||||
Ok(())
|
||||
})
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
// 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 log::{debug, error, info, warn};
|
||||
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;
|
||||
|
||||
info!("[livekit_proxy] start requested for {}", remote_host);
|
||||
|
||||
// Reuse existing proxy for same host.
|
||||
if let Some(port) = inner.port {
|
||||
if inner.remote_host == remote_host {
|
||||
debug!("[livekit_proxy] reusing existing proxy on port {} for {}", port, remote_host);
|
||||
return Ok(port);
|
||||
}
|
||||
// Different host — tear down old proxy.
|
||||
info!("[livekit_proxy] stopping old proxy for {} (switching to {})", inner.remote_host, remote_host);
|
||||
if let Some(tx) = inner.shutdown_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
inner.port = None;
|
||||
}
|
||||
|
||||
// Load the TOFU-pinned fingerprint from the cert store. The ws_proxy must
|
||||
// have connected first (establishing the TOFU trust), so the fingerprint
|
||||
// should already be stored. If not, reject — we refuse to connect without
|
||||
// a pinned cert.
|
||||
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));
|
||||
|
||||
info!("[livekit_proxy] proxy started on 127.0.0.1:{} → {}", port, remote_host);
|
||||
|
||||
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, addr)) => {
|
||||
consecutive_errors = 0;
|
||||
let host = remote_host.clone();
|
||||
let fp = pinned_fingerprint.clone();
|
||||
debug!("[livekit_proxy] accepted connection from {}", addr);
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(stream, &host, &fp).await {
|
||||
warn!("[livekit_proxy] connection to {} failed: {}", host, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
consecutive_errors += 1;
|
||||
error!(
|
||||
"[livekit_proxy] accept error ({}/{}): {}",
|
||||
consecutive_errors, MAX_CONSECUTIVE_ACCEPT_ERRORS, e
|
||||
);
|
||||
if consecutive_errors >= MAX_CONSECUTIVE_ACCEPT_ERRORS {
|
||||
error!(
|
||||
"[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}"))?
|
||||
};
|
||||
|
||||
debug!("[livekit_proxy] connecting TCP to {}", remote_host);
|
||||
let tcp = TcpStream::connect(remote_host).await?;
|
||||
debug!("[livekit_proxy] starting TLS handshake with {}", remote_host);
|
||||
let mut tls = connector.connect(server_name, tcp).await?;
|
||||
debug!("[livekit_proxy] TLS handshake complete, forwarding traffic");
|
||||
|
||||
// ── 4. Forward request + bidirectional copy ──────────────────────────
|
||||
tls.write_all(modified.as_bytes()).await?;
|
||||
let result = io::copy_bidirectional(&mut local, &mut tls).await;
|
||||
match result {
|
||||
Ok((to_remote, from_remote)) => {
|
||||
debug!("[livekit_proxy] connection closed: {}B sent, {}B received", to_remote, from_remote);
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("[livekit_proxy] bidirectional copy ended: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cert_store_key_strips_default_port() {
|
||||
assert_eq!(cert_store_key("example.com:443"), "example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_store_key_keeps_non_default_port() {
|
||||
assert_eq!(cert_store_key("example.com:8443"), "example.com:8443");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_store_key_no_port() {
|
||||
assert_eq!(cert_store_key("example.com"), "example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_store_key_ipv4_default_port() {
|
||||
assert_eq!(cert_store_key("192.168.1.1:443"), "192.168.1.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_store_key_ipv4_custom_port() {
|
||||
assert_eq!(cert_store_key("192.168.1.1:7880"), "192.168.1.1:7880");
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
// - If the fingerprint changes, the connection is rejected (potential MitM).
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use log::{debug, error, info, warn};
|
||||
use ring::digest::{digest, SHA256};
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
@@ -189,14 +190,20 @@ pub async fn ws_connect<R: Runtime>(
|
||||
state: tauri::State<'_, WsState>,
|
||||
url: String,
|
||||
) -> Result<(), String> {
|
||||
info!("[ws_proxy] connecting to {}", url);
|
||||
|
||||
// Drop any existing connection
|
||||
{
|
||||
let mut tx_lock = state.tx.lock().await;
|
||||
if tx_lock.is_some() {
|
||||
debug!("[ws_proxy] dropping existing connection");
|
||||
}
|
||||
*tx_lock = None;
|
||||
}
|
||||
|
||||
// Only allow secure WebSocket connections
|
||||
if !url.starts_with("wss://") {
|
||||
warn!("[ws_proxy] rejected non-wss URL: {}", url);
|
||||
return Err("Only wss:// connections are permitted".into());
|
||||
}
|
||||
|
||||
@@ -222,8 +229,16 @@ pub async fn ws_connect<R: Runtime>(
|
||||
|
||||
let (ws_stream, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_future)
|
||||
.await
|
||||
.map_err(|_| format!("ws connect timed out after {}s", CONNECT_TIMEOUT.as_secs()))?
|
||||
.map_err(|e| format!("ws connect failed: {e}"))?;
|
||||
.map_err(|_| {
|
||||
error!("[ws_proxy] connect timed out after {}s to {}", CONNECT_TIMEOUT.as_secs(), url);
|
||||
format!("ws connect timed out after {}s", CONNECT_TIMEOUT.as_secs())
|
||||
})?
|
||||
.map_err(|e| {
|
||||
error!("[ws_proxy] connect failed to {}: {}", url, e);
|
||||
format!("ws connect failed: {e}")
|
||||
})?;
|
||||
|
||||
debug!("[ws_proxy] WebSocket handshake complete");
|
||||
|
||||
// ── TOFU check ───────────────────────────────────────────────────────
|
||||
let host = extract_host(&url);
|
||||
@@ -239,6 +254,7 @@ pub async fn ws_connect<R: Runtime>(
|
||||
|
||||
match tofu_check(&app, &host, &fingerprint) {
|
||||
Ok(status) => {
|
||||
info!("[ws_proxy] TOFU check passed for {}: {}", host, status);
|
||||
let _ = app.emit(
|
||||
"cert-tofu",
|
||||
serde_json::json!({
|
||||
@@ -249,6 +265,8 @@ pub async fn ws_connect<R: Runtime>(
|
||||
);
|
||||
}
|
||||
Err(mismatch_msg) => {
|
||||
warn!("[ws_proxy] TOFU check FAILED for {} — certificate fingerprint mismatch", host);
|
||||
debug!("[ws_proxy] TOFU detail: {}", mismatch_msg);
|
||||
let _ = app.emit(
|
||||
"cert-tofu",
|
||||
serde_json::json!({
|
||||
@@ -264,6 +282,7 @@ pub async fn ws_connect<R: Runtime>(
|
||||
}
|
||||
// ── End TOFU check ───────────────────────────────────────────────────
|
||||
|
||||
info!("[ws_proxy] connected to {}", host);
|
||||
let _ = app.emit("ws-state", "open");
|
||||
|
||||
let (mut sink, mut stream) = ws_stream.split();
|
||||
@@ -285,8 +304,12 @@ pub async fn ws_connect<R: Runtime>(
|
||||
Ok(Message::Text(text)) => {
|
||||
let _ = app_read.emit("ws-message", text.to_string());
|
||||
}
|
||||
Ok(Message::Close(_)) => break,
|
||||
Ok(Message::Close(frame)) => {
|
||||
debug!("[ws_proxy] server sent Close frame: {:?}", frame);
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("[ws_proxy] read error: {}", e);
|
||||
let _ = app_read.emit("ws-error", format!("{e}"));
|
||||
break;
|
||||
}
|
||||
@@ -307,9 +330,16 @@ pub async fn ws_connect<R: Runtime>(
|
||||
// When either task ends, abort sibling and emit closed
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = &mut read_task => { write_task.abort(); }
|
||||
_ = &mut write_task => { read_task.abort(); }
|
||||
_ = &mut read_task => {
|
||||
debug!("[ws_proxy] read task ended, aborting write task");
|
||||
write_task.abort();
|
||||
}
|
||||
_ = &mut write_task => {
|
||||
debug!("[ws_proxy] write task ended, aborting read task");
|
||||
read_task.abort();
|
||||
}
|
||||
}
|
||||
info!("[ws_proxy] connection closed");
|
||||
let _ = app_state.emit("ws-state", "closed");
|
||||
});
|
||||
|
||||
@@ -373,3 +403,42 @@ pub fn accept_cert_fingerprint<R: Runtime>(
|
||||
.map_err(|e| format!("failed to persist cert fingerprint: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_host_basic_wss_url() {
|
||||
assert_eq!(extract_host("wss://example.com/chat"), "example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_host_with_port() {
|
||||
assert_eq!(extract_host("wss://example.com:8443/chat"), "example.com:8443");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_host_no_path() {
|
||||
assert_eq!(extract_host("wss://example.com"), "example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_host_no_scheme() {
|
||||
assert_eq!(extract_host("example.com/path"), "example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_host_empty() {
|
||||
assert_eq!(extract_host(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_host_with_port_and_deep_path() {
|
||||
assert_eq!(extract_host("wss://myhost:9443/api/v1/ws"), "myhost:9443");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "OwnCord",
|
||||
"version": "1.3.0",
|
||||
"version": "1.0.0",
|
||||
"identifier": "com.owncord.client",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -18,7 +18,8 @@
|
||||
"minHeight": 500,
|
||||
"decorations": true,
|
||||
"resizable": true,
|
||||
"center": true
|
||||
"center": true,
|
||||
"additionalBrowserArgs": "--autoplay-policy=no-user-gesture-required"
|
||||
}
|
||||
],
|
||||
"withGlobalTauri": true,
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
} from "@stores/ui.store";
|
||||
import { voiceStore, getChannelVoiceUsers } from "@stores/voice.store";
|
||||
import { setUserVolume, getUserVolume } from "@lib/livekitSession";
|
||||
import { SCREENSHARE_TILE_ID_OFFSET } from "@lib/constants";
|
||||
import { attachStreamPreview, attachScrollCollapse } from "@lib/streamPreview";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-user volume context menu (right-click on voice user row)
|
||||
@@ -103,6 +105,7 @@ function showUserVolumeMenu(
|
||||
// Close on click outside
|
||||
const dismissAc = new AbortController();
|
||||
setTimeout(() => {
|
||||
if (dismissAc.signal.aborted) return;
|
||||
document.addEventListener("mousedown", (e: MouseEvent) => {
|
||||
if (!menu.contains(e.target as Node)) {
|
||||
menu.remove();
|
||||
@@ -134,6 +137,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 +208,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;
|
||||
@@ -266,6 +272,15 @@ function renderVoiceChannelItem(
|
||||
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 mic-off and headphones-off
|
||||
const muteIcon = createElement("span", { class: "vu-muted" });
|
||||
@@ -291,8 +306,45 @@ 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();
|
||||
const tileId = user.screenshare
|
||||
? user.userId + SCREENSHARE_TILE_ID_OFFSET
|
||||
: user.userId;
|
||||
onWatchStream(tileId);
|
||||
}, { signal });
|
||||
row.style.cursor = "pointer";
|
||||
}
|
||||
|
||||
// Hover/focus preview for remote users with video
|
||||
if ((currentUser === null || currentUser.id !== user.userId)
|
||||
&& (user.camera || user.screenshare)) {
|
||||
const tileId = user.screenshare
|
||||
? user.userId + SCREENSHARE_TILE_ID_OFFSET
|
||||
: user.userId;
|
||||
attachStreamPreview(
|
||||
row,
|
||||
user.userId,
|
||||
user.username || "Unknown",
|
||||
user.screenshare,
|
||||
user.camera,
|
||||
signal,
|
||||
() => {
|
||||
// Placeholder click: join voice channel and watch stream
|
||||
onVoiceJoin(channel.id);
|
||||
if (onWatchStream !== undefined) onWatchStream(tileId);
|
||||
},
|
||||
onWatchStream !== undefined ? () => onWatchStream(tileId) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
usersContainer.appendChild(row);
|
||||
}
|
||||
attachScrollCollapse(usersContainer, signal);
|
||||
wrapper.appendChild(usersContainer);
|
||||
}
|
||||
|
||||
@@ -385,14 +437,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) {
|
||||
@@ -415,7 +471,7 @@ function ensureGlobalDragListeners(): void {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}, { signal: globalDragAc.signal });
|
||||
|
||||
document.addEventListener("mouseup", (e) => {
|
||||
if (activeDrag === null) {
|
||||
@@ -480,7 +536,7 @@ function ensureGlobalDragListeners(): void {
|
||||
if (reorders.length > 0) {
|
||||
drag.onReorder(reorders);
|
||||
}
|
||||
});
|
||||
}, { signal: globalDragAc.signal });
|
||||
}
|
||||
|
||||
/** Make a channel element draggable via mousedown (admin/owner only). */
|
||||
@@ -566,10 +622,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);
|
||||
}
|
||||
@@ -591,6 +648,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", {});
|
||||
|
||||
@@ -644,7 +702,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);
|
||||
@@ -654,7 +712,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);
|
||||
@@ -664,7 +722,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;
|
||||
@@ -692,7 +750,7 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -760,7 +818,7 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
|
||||
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" : ""}`;
|
||||
structSig += `:${uid}${u.muted ? "m" : ""}${u.deafened ? "d" : ""}${u.camera ? "c" : ""}${u.screenshare ? "s" : ""}`;
|
||||
}
|
||||
}
|
||||
if (structSig !== prevVoiceStructureSig) {
|
||||
@@ -785,6 +843,11 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export function createCreateChannelModal(
|
||||
type: "text",
|
||||
placeholder: isVoiceCategory(category) ? "lounge" : "general",
|
||||
"data-testid": "channel-name-input",
|
||||
}) as HTMLInputElement;
|
||||
});
|
||||
appendChildren(nameGroup, nameLabel, nameInput);
|
||||
|
||||
// Channel type
|
||||
@@ -101,7 +101,7 @@ export function createCreateChannelModal(
|
||||
const typeSelect = createElement("select", {
|
||||
class: "form-input",
|
||||
"data-testid": "channel-type-select",
|
||||
}) as HTMLSelectElement;
|
||||
});
|
||||
|
||||
for (const t of allowedTypes) {
|
||||
const opt = createElement(
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
import {
|
||||
createElement,
|
||||
setText,
|
||||
clearChildren,
|
||||
appendChildren,
|
||||
} from "@lib/dom";
|
||||
import { createIcon } from "@lib/icons";
|
||||
@@ -36,6 +35,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> = {
|
||||
@@ -132,6 +133,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", {
|
||||
|
||||
@@ -68,7 +68,7 @@ export function createEditChannelModal(
|
||||
type: "text",
|
||||
value: channelName,
|
||||
"data-testid": "edit-channel-name-input",
|
||||
}) as HTMLInputElement;
|
||||
});
|
||||
nameInput.value = channelName;
|
||||
appendChildren(nameGroup, nameLabel, nameInput);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// EmojiPicker — grid-based emoji selector with search and scrollable categories.
|
||||
// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
|
||||
|
||||
import { createElement, setText, appendChildren, clearChildren } from "@lib/dom";
|
||||
import { createElement, setText, clearChildren } from "@lib/dom";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
|
||||
@@ -93,20 +93,20 @@ export function createFileUpload(options: FileUploadOptions): FileUploadComponen
|
||||
dropzone = createElement("div", { class: "file-upload__dropzone file-upload__dropzone--hidden" });
|
||||
appendChildren(dropzone, createElement("span", { class: "file-upload__droptext" }, "Drop files here"));
|
||||
|
||||
fileInput = createElement("input", { class: "file-upload__input", type: "file" }) as HTMLInputElement;
|
||||
fileInput = createElement("input", { class: "file-upload__input", type: "file" });
|
||||
fileInput.style.display = "none";
|
||||
|
||||
preview = createElement("div", { class: "file-upload__preview file-upload__preview--hidden" });
|
||||
thumb = createElement("img", { class: "file-upload__thumb" }) as HTMLImageElement;
|
||||
thumb = createElement("img", { class: "file-upload__thumb" });
|
||||
thumb.style.display = "none";
|
||||
thumb.alt = "";
|
||||
nameSpan = createElement("span", { class: "file-upload__name" }) as HTMLSpanElement;
|
||||
sizeSpan = createElement("span", { class: "file-upload__size" }) as HTMLSpanElement;
|
||||
nameSpan = createElement("span", { class: "file-upload__name" });
|
||||
sizeSpan = createElement("span", { class: "file-upload__size" });
|
||||
const progressContainer = createElement("div", { class: "file-upload__progress" });
|
||||
progressBar = createElement("div", { class: "file-upload__progress-bar" });
|
||||
progressBar.style.width = "0%";
|
||||
appendChildren(progressContainer, progressBar);
|
||||
cancelBtn = createElement("button", { class: "file-upload__cancel", type: "button" }) as HTMLButtonElement;
|
||||
cancelBtn = createElement("button", { class: "file-upload__cancel", type: "button" });
|
||||
cancelBtn.appendChild(createIcon("x", 14));
|
||||
appendChildren(preview, thumb, nameSpan, sizeSpan, progressContainer, cancelBtn);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// GifPicker — searchable GIF selector powered by Tenor API.
|
||||
// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
|
||||
|
||||
import { createElement, setText, appendChildren, clearChildren } from "@lib/dom";
|
||||
import { createElement, setText, clearChildren } from "@lib/dom";
|
||||
import { searchGifs, getTrendingGifs } from "@lib/tenor";
|
||||
import type { TenorGif } from "@lib/tenor";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Create, copy, and revoke invite codes.
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren, clearChildren, setText } from "@lib/dom";
|
||||
import { createElement, appendChildren, clearChildren } from "@lib/dom";
|
||||
import { createIcon } from "@lib/icons";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ function statusPriority(status: UserStatus): number {
|
||||
case "idle": return 1;
|
||||
case "dnd": return 2;
|
||||
case "offline": return 3;
|
||||
default: return 99;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +49,7 @@ function statusColor(status: UserStatus): string {
|
||||
case "idle": return "var(--yellow)";
|
||||
case "dnd": return "var(--red)";
|
||||
case "offline": return "var(--text-micro)";
|
||||
default: return "#747f8d";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,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,
|
||||
@@ -48,6 +59,12 @@ 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;
|
||||
/** Timer IDs for cleanup on destroy. */
|
||||
const activeTimers: Set<ReturnType<typeof setTimeout>> = new Set();
|
||||
|
||||
function showReplyBar(username: string): void {
|
||||
if (replyBar === null || replyText === null) return;
|
||||
@@ -83,12 +100,28 @@ export function createMessageInput(
|
||||
}
|
||||
}
|
||||
|
||||
function showUploadError(message: string): void {
|
||||
if (attachmentPreviewBar === null) return;
|
||||
const errEl = createElement("div", {
|
||||
class: "attachment-upload-error",
|
||||
}, message);
|
||||
attachmentPreviewBar.appendChild(errEl);
|
||||
const t = setTimeout(() => { activeTimers.delete(t); errEl.remove(); }, 4000);
|
||||
activeTimers.add(t);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -144,6 +177,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/");
|
||||
|
||||
@@ -156,7 +201,7 @@ export function createMessageInput(
|
||||
const img = createElement("img", {
|
||||
class: "attachment-preview-img",
|
||||
alt: file.name,
|
||||
}) as HTMLImageElement;
|
||||
});
|
||||
item.appendChild(img);
|
||||
readFileAsDataUrl(file).then((dataUrl) => {
|
||||
img.src = dataUrl;
|
||||
@@ -192,6 +237,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
|
||||
@@ -206,12 +252,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--;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +322,7 @@ export function createMessageInput(
|
||||
type: "file",
|
||||
style: "display: none;",
|
||||
accept: "image/*,video/*,audio/*,.pdf,.txt,.zip,.rar,.7z",
|
||||
}) as HTMLInputElement;
|
||||
});
|
||||
fileInput.addEventListener("change", () => {
|
||||
const file = fileInput.files?.[0];
|
||||
if (file != null) {
|
||||
@@ -383,9 +426,11 @@ export function createMessageInput(
|
||||
});
|
||||
root?.appendChild(emojiPicker.element);
|
||||
// Defer so this click doesn't immediately close it
|
||||
setTimeout(() => {
|
||||
const t1 = setTimeout(() => {
|
||||
activeTimers.delete(t1);
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}, 0);
|
||||
activeTimers.add(t1);
|
||||
}
|
||||
|
||||
emojiBtn.addEventListener("click", toggleEmojiPicker, { signal });
|
||||
@@ -430,13 +475,18 @@ export function createMessageInput(
|
||||
},
|
||||
});
|
||||
root?.appendChild(gifPicker.element);
|
||||
setTimeout(() => {
|
||||
const t2 = setTimeout(() => {
|
||||
activeTimers.delete(t2);
|
||||
document.addEventListener("mousedown", handleGifClickOutside);
|
||||
}, 0);
|
||||
activeTimers.add(t2);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -444,14 +494,15 @@ export function createMessageInput(
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
// Close any open pickers and their document listeners before aborting
|
||||
cleanupPickers?.();
|
||||
cleanupPickers = null;
|
||||
// Clear all pending timers
|
||||
for (const t of activeTimers) clearTimeout(t);
|
||||
activeTimers.clear();
|
||||
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;
|
||||
|
||||
@@ -24,6 +24,7 @@ import { FenwickTree } from "./message-list/fenwick";
|
||||
export interface MessageListOptions {
|
||||
readonly channelId: number;
|
||||
readonly channelName: string;
|
||||
readonly channelType?: string;
|
||||
readonly currentUserId: number;
|
||||
readonly onScrollTop: () => void;
|
||||
readonly onReplyClick: (messageId: number) => void;
|
||||
@@ -112,15 +113,21 @@ function buildVirtualItems(messages: readonly Message[]): readonly VirtualItem[]
|
||||
|
||||
// -- Empty state --------------------------------------------------------------
|
||||
|
||||
function renderEmptyState(channelName: string): HTMLDivElement {
|
||||
function renderEmptyState(channelName: string, channelType?: string): HTMLDivElement {
|
||||
const isDm = channelType === "dm";
|
||||
|
||||
const icon = createElement("div", { class: "channel-welcome-icon" });
|
||||
icon.textContent = "#";
|
||||
icon.textContent = isDm ? "@" : "#";
|
||||
|
||||
const title = createElement("h2", { class: "channel-welcome-title" });
|
||||
title.textContent = `Welcome to #${channelName}!`;
|
||||
title.textContent = isDm
|
||||
? channelName
|
||||
: `Welcome to #${channelName}!`;
|
||||
|
||||
const text = createElement("p", { class: "channel-welcome-text" });
|
||||
text.textContent = `This is the start of the #${channelName} channel.`;
|
||||
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);
|
||||
@@ -277,7 +284,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
|
||||
if (virtualItems.length === 0) {
|
||||
clearChildren(contentContainer);
|
||||
contentContainer.appendChild(renderEmptyState(options.channelName));
|
||||
contentContainer.appendChild(renderEmptyState(options.channelName, options.channelType));
|
||||
topSpacer.style.height = "0px";
|
||||
bottomSpacer.style.height = "0px";
|
||||
renderedStart = 0;
|
||||
@@ -302,7 +309,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
// Scroll-driven spacer updates are cheap and don't need limiting.
|
||||
renderWindowCount++;
|
||||
if (renderWindowCount > 30) {
|
||||
console.error("[MessageList] renderWindow REBUILD called >30 times in 2s — breaking loop");
|
||||
log.error("[MessageList] renderWindow REBUILD called >30 times in 2s — breaking loop");
|
||||
return;
|
||||
}
|
||||
if (renderWindowResetTimer === 0) {
|
||||
@@ -379,7 +386,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
// within 2 seconds, something is wrong — bail out to prevent freeze.
|
||||
renderAllCount++;
|
||||
if (renderAllCount > 20) {
|
||||
console.error("[MessageList] renderAll called >20 times in 2s — breaking loop");
|
||||
log.error("[MessageList] renderAll called >20 times in 2s — breaking loop");
|
||||
return;
|
||||
}
|
||||
if (renderAllResetTimer === 0) {
|
||||
@@ -452,7 +459,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
|
||||
let scrollRafId = 0;
|
||||
let resizeRafId = 0;
|
||||
let resizeDirty = false;
|
||||
// resizeDirty tracking removed — resize observer batches via RAF directly
|
||||
function handleScroll(): void {
|
||||
if (root === null) return;
|
||||
|
||||
@@ -490,7 +497,7 @@ 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 = createElement("button", { class: "scroll-to-bottom-btn" });
|
||||
scrollToBottomBtn.textContent = "↓";
|
||||
scrollToBottomBtn.addEventListener("click", () => {
|
||||
scrollToBottom();
|
||||
@@ -512,12 +519,10 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
|
||||
// Batched via RAF with anchor-based scroll preservation.
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (root === null || contentContainer === null) return;
|
||||
resizeDirty = true;
|
||||
if (resizeRafId !== 0) return;
|
||||
|
||||
resizeRafId = requestAnimationFrame(() => {
|
||||
resizeRafId = 0;
|
||||
resizeDirty = false;
|
||||
if (root === null || contentContainer === null) return;
|
||||
|
||||
const atBottom = isNearBottom();
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
import {
|
||||
createElement,
|
||||
clearChildren,
|
||||
appendChildren,
|
||||
} from "@lib/dom";
|
||||
import { createIcon } from "@lib/icons";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -155,7 +155,7 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
|
||||
class: "quick-switcher__input",
|
||||
type: "text",
|
||||
placeholder: "Where do you want to go?",
|
||||
}) as HTMLInputElement;
|
||||
});
|
||||
|
||||
// Results list
|
||||
resultsDiv = createElement("div", { class: "quick-switcher__results" });
|
||||
|
||||
@@ -187,7 +187,7 @@ export function createSearchOverlay(options: SearchOverlayOptions): MountableCom
|
||||
placeholder: "Search messages...",
|
||||
"aria-label": "Search messages",
|
||||
"data-testid": "search-overlay-input",
|
||||
}) as HTMLInputElement;
|
||||
});
|
||||
|
||||
statusEl = createElement("div", {
|
||||
class: "search-overlay-status",
|
||||
|
||||
@@ -11,8 +11,9 @@ 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 { loadPref, applyTheme, THEMES } from "./settings/helpers";
|
||||
import type { ThemeName } from "./settings/helpers";
|
||||
import { getActiveThemeName, restoreTheme } from "@lib/themes";
|
||||
import { syncOsMotionListener } from "@lib/os-motion";
|
||||
import { buildAccountTab } from "./settings/AccountTab";
|
||||
import { buildAppearanceTab } from "./settings/AppearanceTab";
|
||||
@@ -33,7 +34,13 @@ 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;
|
||||
onEnableTotp(password: string): Promise<{ qr_uri: string; backup_codes: string[] }>;
|
||||
onConfirmTotp(password: string, code: string): Promise<void>;
|
||||
onDisableTotp(password: string): Promise<void>;
|
||||
/** When false, the Account tab is hidden (e.g. on the connect page). Defaults to true. */
|
||||
isAuthenticated?: boolean;
|
||||
}
|
||||
|
||||
export type TabName = "Account" | "Appearance" | "Notifications" | "Text & Images" | "Accessibility" | "Voice & Audio" | "Keybinds" | "Advanced" | "Logs";
|
||||
@@ -59,7 +66,24 @@ const TAB_ICONS: Record<TabName, IconName> = {
|
||||
* Call at app startup so the UI doesn't flash default styles.
|
||||
*/
|
||||
export function applyStoredAppearance(): void {
|
||||
applyTheme(loadPref<ThemeName>("theme", "dark"));
|
||||
const activeThemeName = getActiveThemeName();
|
||||
if (activeThemeName in THEMES) {
|
||||
applyTheme(activeThemeName as ThemeName);
|
||||
} else {
|
||||
restoreTheme();
|
||||
}
|
||||
try {
|
||||
const rawAccent = localStorage.getItem("owncord:settings:accentColor");
|
||||
if (rawAccent !== null) {
|
||||
const accent = JSON.parse(rawAccent);
|
||||
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 — keep the theme default accent.
|
||||
}
|
||||
document.documentElement.style.setProperty(
|
||||
"--font-size",
|
||||
`${loadPref<number>("fontSize", 16)}px`,
|
||||
@@ -71,10 +95,6 @@ export function applyStoredAppearance(): void {
|
||||
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));
|
||||
document.documentElement.style.setProperty(
|
||||
"--accent",
|
||||
loadPref<string>("accentColor", "#5865f2"),
|
||||
);
|
||||
|
||||
syncOsMotionListener(loadPref<boolean>("syncOsMotion", false));
|
||||
}
|
||||
@@ -87,10 +107,11 @@ export function createSettingsOverlay(
|
||||
options: SettingsOverlayOptions,
|
||||
): MountableComponent & { open(): void; close(): void } {
|
||||
const ac = new AbortController();
|
||||
const authenticated = options.isAuthenticated !== false;
|
||||
let root: HTMLDivElement | null = null;
|
||||
let contentArea: HTMLDivElement | null = null;
|
||||
let pageTitle: HTMLHeadingElement | null = null;
|
||||
let activeTab: TabName = "Account";
|
||||
let activeTab: TabName = authenticated ? "Account" : "Appearance";
|
||||
const tabButtons = new Map<TabName, HTMLButtonElement>();
|
||||
let unsubUi: (() => void) | null = null;
|
||||
|
||||
@@ -163,25 +184,31 @@ export function createSettingsOverlay(
|
||||
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 });
|
||||
if (authenticated) {
|
||||
editProfileLink.addEventListener("click", () => setActiveTab("Account"), { signal: ac.signal });
|
||||
} else {
|
||||
editProfileLink.style.display = "none";
|
||||
}
|
||||
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);
|
||||
// "User Settings" category — only Account belongs here (hidden when not authenticated)
|
||||
if (authenticated) {
|
||||
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);
|
||||
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");
|
||||
@@ -201,13 +228,15 @@ export function createSettingsOverlay(
|
||||
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);
|
||||
if (authenticated) {
|
||||
// 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);
|
||||
@@ -232,7 +261,16 @@ export function createSettingsOverlay(
|
||||
}
|
||||
}, { signal: ac.signal });
|
||||
|
||||
appendChildren(root, sidebar, contentArea, closeWrap);
|
||||
// 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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Uses mockup's .typing-bar and .typing-dots classes.
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren, setText, clearChildren } from "@lib/dom";
|
||||
import { createElement, appendChildren, clearChildren } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import { Disposable } from "@lib/disposable";
|
||||
import { membersStore, getTypingUsers } from "@stores/members.store";
|
||||
|
||||
@@ -27,7 +27,7 @@ export function createUpdateNotifier(options: UpdateNotifierOptions): MountableC
|
||||
showBanner(result.version, result.body ?? "");
|
||||
}
|
||||
|
||||
function showBanner(version: string, notes: string): void {
|
||||
function showBanner(version: string, _notes: string): void {
|
||||
if (container === null || banner !== null) return;
|
||||
|
||||
banner = createElement("div", { class: "update-banner" });
|
||||
|
||||
@@ -10,7 +10,9 @@ 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 disposable = new Disposable();
|
||||
@@ -72,6 +74,20 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
@@ -4,37 +4,200 @@
|
||||
*/
|
||||
|
||||
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 VideoGridComponent extends MountableComponent {
|
||||
addStream(userId: number, username: string, stream: MediaStream): void;
|
||||
removeStream(userId: number): void;
|
||||
hasStreams(): boolean;
|
||||
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;
|
||||
}
|
||||
|
||||
function computeGridColumns(count: number): string {
|
||||
if (count <= 1) return "1fr";
|
||||
if (count <= 4) return "1fr 1fr";
|
||||
if (count <= 9) return "1fr 1fr 1fr";
|
||||
return "1fr 1fr 1fr 1fr";
|
||||
export interface VideoGridComponent extends MountableComponent {
|
||||
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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout calculator — Discord-style tile sizing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GridLayout {
|
||||
readonly cols: number;
|
||||
readonly rows: number;
|
||||
readonly tileW: number;
|
||||
readonly tileH: number;
|
||||
}
|
||||
|
||||
const GRID_GAP = 4;
|
||||
const GRID_PAD = 8;
|
||||
const ASPECT = 16 / 9;
|
||||
|
||||
/**
|
||||
* Compute optimal tile arrangement that maximises tile area while fitting
|
||||
* all tiles inside the container. Tries every possible column count and
|
||||
* picks the one whose tiles are largest.
|
||||
*/
|
||||
export function computeGridLayout(
|
||||
containerW: number,
|
||||
containerH: number,
|
||||
tileCount: number,
|
||||
): GridLayout {
|
||||
if (tileCount <= 0) return { cols: 1, rows: 1, tileW: 0, tileH: 0 };
|
||||
|
||||
let best: GridLayout = { cols: 1, rows: tileCount, tileW: 0, tileH: 0 };
|
||||
|
||||
for (let cols = 1; cols <= tileCount; cols++) {
|
||||
const rows = Math.ceil(tileCount / cols);
|
||||
const availW = containerW - GRID_PAD * 2 - GRID_GAP * (cols - 1);
|
||||
const availH = containerH - GRID_PAD * 2 - GRID_GAP * (rows - 1);
|
||||
if (availW <= 0 || availH <= 0) continue;
|
||||
|
||||
let tileW = availW / cols;
|
||||
let tileH = tileW / ASPECT;
|
||||
|
||||
// Shrink if total row height exceeds available height
|
||||
if (tileH * rows > availH) {
|
||||
tileH = availH / rows;
|
||||
tileW = tileH * ASPECT;
|
||||
}
|
||||
|
||||
// Floor width first, then derive height to preserve exact 16:9
|
||||
const floorW = Math.floor(tileW);
|
||||
const floorH = Math.floor(floorW / ASPECT);
|
||||
|
||||
if (floorW * floorH > best.tileW * best.tileH) {
|
||||
best = { cols, rows, tileW: floorW, tileH: floorH };
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
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;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let resizeRafId = 0;
|
||||
|
||||
/** Apply JS-calculated tile sizes to all grid-mode cells. */
|
||||
function applyGridSizes(): void {
|
||||
if (root === null || focusedTileId !== null || cells.size === 0) return;
|
||||
|
||||
const { width: cw, height: ch } = root.getBoundingClientRect();
|
||||
if (cw === 0 || ch === 0) return;
|
||||
|
||||
const layout = computeGridLayout(cw, ch, cells.size);
|
||||
|
||||
for (const entry of cells.values()) {
|
||||
entry.el.style.width = `${layout.tileW}px`;
|
||||
entry.el.style.height = `${layout.tileH}px`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Schedule a layout recalculation on the next animation frame. */
|
||||
function scheduleResize(): void {
|
||||
if (resizeRafId !== 0) cancelAnimationFrame(resizeRafId);
|
||||
resizeRafId = requestAnimationFrame(() => {
|
||||
resizeRafId = 0;
|
||||
applyGridSizes();
|
||||
});
|
||||
}
|
||||
|
||||
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 flex-wrap layout
|
||||
root.classList.remove("focus-mode");
|
||||
for (const entry of cells.values()) {
|
||||
entry.el.classList.remove("focused", "thumb");
|
||||
root.appendChild(entry.el);
|
||||
}
|
||||
applyGridSizes();
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add("focus-mode");
|
||||
|
||||
// Clear inline sizes on cells (focus mode uses CSS flex sizing)
|
||||
for (const entry of cells.values()) {
|
||||
entry.el.style.width = "";
|
||||
entry.el.style.height = "";
|
||||
}
|
||||
|
||||
// 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;
|
||||
root.style.gridTemplateColumns = computeGridColumns(cells.size);
|
||||
if (focusedTileId !== null) {
|
||||
rebuildFocusLayout();
|
||||
return;
|
||||
}
|
||||
applyGridSizes();
|
||||
}
|
||||
|
||||
function addStream(userId: number, username: string, stream: MediaStream): void {
|
||||
function addStream(userId: number, username: string, stream: MediaStream, config?: TileConfig): void {
|
||||
if (root === null) return;
|
||||
|
||||
// If a cell already exists for this user, update it in place
|
||||
const existing = cells.get(userId);
|
||||
if (existing) {
|
||||
const video = existing.querySelector("video");
|
||||
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() ?? [];
|
||||
@@ -47,7 +210,7 @@ export function createVideoGrid(): VideoGridComponent {
|
||||
}
|
||||
}
|
||||
// Update username label in case it changed
|
||||
const label = existing.querySelector(".video-username");
|
||||
const label = existing.el.querySelector(".video-username");
|
||||
if (label !== null) {
|
||||
label.textContent = username;
|
||||
}
|
||||
@@ -69,23 +232,114 @@ export function createVideoGrid(): VideoGridComponent {
|
||||
});
|
||||
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",
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
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 {
|
||||
@@ -98,16 +352,27 @@ export function createVideoGrid(): VideoGridComponent {
|
||||
"data-testid": "video-grid",
|
||||
});
|
||||
container.appendChild(root);
|
||||
|
||||
// Observe container size changes to recalculate tile layout
|
||||
resizeObserver = new ResizeObserver(() => { scheduleResize(); });
|
||||
resizeObserver.observe(root);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
for (const [, cell] of cells) {
|
||||
const video = cell.querySelector("video");
|
||||
if (video !== null) {
|
||||
video.srcObject = null;
|
||||
}
|
||||
if (resizeRafId !== 0) cancelAnimationFrame(resizeRafId);
|
||||
resizeRafId = 0;
|
||||
|
||||
if (resizeObserver !== null) {
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver = 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();
|
||||
@@ -115,5 +380,5 @@ export function createVideoGrid(): VideoGridComponent {
|
||||
}
|
||||
}
|
||||
|
||||
return { mount, destroy, addStream, removeStream, hasStreams };
|
||||
return { mount, destroy, addStream, removeStream, hasStreams, setFocusedTile, getFocusedTileId: getFocusedTileIdFn };
|
||||
}
|
||||
|
||||
@@ -141,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();
|
||||
|
||||
@@ -6,11 +6,21 @@
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren, setText } from "@lib/dom";
|
||||
import { createIcon } from "@lib/icons";
|
||||
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,
|
||||
formatBitrate,
|
||||
type ConnectionStats,
|
||||
type ConnectionStatsPoller,
|
||||
type QualityLevel,
|
||||
} from "@lib/connectionStats";
|
||||
import { getRoomForStats, retryMicPermission } from "@lib/livekitSession";
|
||||
|
||||
export interface VoiceWidgetOptions {
|
||||
onDisconnect(): void;
|
||||
@@ -20,6 +30,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;
|
||||
@@ -27,6 +62,30 @@ 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;
|
||||
|
||||
// Listen-only mode: "Grant Microphone" button
|
||||
let grantMicBtn: 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> = [];
|
||||
|
||||
@@ -36,6 +95,78 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
|
||||
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)} (${formatBitrate(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)} (${formatBitrate(stats.inRate)})`);
|
||||
if (inPacketsEl) setText(inPacketsEl, String(stats.inPackets));
|
||||
if (totalUpEl) setText(totalUpEl, formatBytes(stats.totalUp));
|
||||
if (totalDownEl) setText(totalDownEl, formatBytes(stats.totalDown));
|
||||
}
|
||||
|
||||
let qualityUnlisten: (() => void) | null = null;
|
||||
|
||||
function startStatsPoller(): void {
|
||||
if (statsPoller !== null) return;
|
||||
statsPoller = createConnectionStatsPoller(() => getRoomForStats());
|
||||
statsUnlisten = statsPoller.onUpdate(updateSignalIcon);
|
||||
qualityUnlisten = statsPoller.onQualityChanged((quality, _prevQuality) => {
|
||||
// Auto-expand stats pane when quality degrades
|
||||
if ((quality === "poor" || quality === "bad") && statsPane !== null) {
|
||||
statsPane.classList.add("visible");
|
||||
}
|
||||
});
|
||||
statsPoller.start();
|
||||
}
|
||||
|
||||
function stopStatsPoller(): void {
|
||||
statsUnlisten?.();
|
||||
statsUnlisten = null;
|
||||
qualityUnlisten?.();
|
||||
qualityUnlisten = 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;
|
||||
|
||||
@@ -44,10 +175,15 @@ 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);
|
||||
@@ -61,6 +197,13 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
|
||||
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)); }
|
||||
|
||||
// Show/hide "Grant Microphone" button based on listen-only state
|
||||
if (grantMicBtn) {
|
||||
grantMicBtn.style.display = voice.listenOnly ? "block" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
function createControlButton(
|
||||
@@ -81,22 +224,106 @@ 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", "mic", options.onMuteToggle);
|
||||
deafenBtn = createControlButton("Deafen", "headphones", options.onDeafenToggle);
|
||||
cameraBtn = createControlButton("Camera", "camera", options.onCameraToggle);
|
||||
const shareBtn = createControlButton("Screenshare", "monitor", options.onScreenshareToggle);
|
||||
shareBtn = createControlButton("Screenshare", "monitor", options.onScreenshareToggle);
|
||||
const disconnectBtn = createControlButton(
|
||||
"Disconnect", "phone", options.onDisconnect, "disconnect",
|
||||
);
|
||||
appendChildren(controls, muteBtn, deafenBtn, cameraBtn, shareBtn, disconnectBtn);
|
||||
|
||||
appendChildren(root, header, controls);
|
||||
// "Grant Microphone" button for listen-only mode
|
||||
grantMicBtn = createElement("button", {
|
||||
class: "vw-grant-mic",
|
||||
"aria-label": "Grant microphone permission",
|
||||
}, "Grant Microphone");
|
||||
grantMicBtn.style.display = "none";
|
||||
grantMicBtn.addEventListener("click", () => {
|
||||
if (grantMicBtn) {
|
||||
grantMicBtn.disabled = true;
|
||||
setText(grantMicBtn, "Requesting...");
|
||||
}
|
||||
void retryMicPermission().finally(() => {
|
||||
if (grantMicBtn) {
|
||||
grantMicBtn.disabled = false;
|
||||
setText(grantMicBtn, "Grant Microphone");
|
||||
}
|
||||
});
|
||||
}, { signal: ac.signal });
|
||||
|
||||
appendChildren(root, header, statsPane, grantMicBtn, controls);
|
||||
|
||||
render();
|
||||
|
||||
@@ -106,13 +333,17 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
|
||||
muted: s.localMuted,
|
||||
deafened: s.localDeafened,
|
||||
camera: s.localCamera,
|
||||
screenshare: s.localScreenshare,
|
||||
listenOnly: s.listenOnly,
|
||||
}),
|
||||
() => render(),
|
||||
(a, b) =>
|
||||
a.channelId === b.channelId &&
|
||||
a.muted === b.muted &&
|
||||
a.deafened === b.deafened &&
|
||||
a.camera === b.camera,
|
||||
a.camera === b.camera &&
|
||||
a.screenshare === b.screenshare &&
|
||||
a.listenOnly === b.listenOnly,
|
||||
));
|
||||
unsubs.push(channelsStore.subscribeSelector(
|
||||
(s) => s.channels,
|
||||
@@ -123,6 +354,8 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
stopStatsPoller();
|
||||
stopElapsedTimer();
|
||||
ac.abort();
|
||||
for (const unsub of unsubs) {
|
||||
unsub();
|
||||
@@ -134,6 +367,19 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
|
||||
muteBtn = null;
|
||||
deafenBtn = null;
|
||||
cameraBtn = null;
|
||||
shareBtn = null;
|
||||
grantMicBtn = 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 };
|
||||
|
||||
@@ -5,14 +5,16 @@
|
||||
|
||||
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";
|
||||
@@ -24,7 +26,7 @@ let _serverHost: string | null = null;
|
||||
|
||||
/** Set the server host (called once from MainPage on connect). */
|
||||
export function setServerHost(host: string): void {
|
||||
_serverHost = host;
|
||||
_serverHost = host.toLowerCase();
|
||||
}
|
||||
|
||||
/** Resolve a potentially relative URL to a full URL using the server host. */
|
||||
@@ -63,8 +65,45 @@ export function isSafeUrl(url: string): boolean {
|
||||
// Image cache: memory + IndexedDB for persistence across restarts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** In-memory cache for instant re-render. */
|
||||
/** In-memory cache for instant re-render (LRU eviction at CACHE_MAX). */
|
||||
const memoryCache = new Map<string, string>();
|
||||
const CACHE_MAX = 200;
|
||||
let attachmentCacheGeneration = 0;
|
||||
|
||||
export function clearAttachmentCaches(): void {
|
||||
attachmentCacheGeneration += 1;
|
||||
memoryCache.clear();
|
||||
inFlight.clear();
|
||||
}
|
||||
|
||||
/** Safe MIME types allowed in data: URIs — blocks script injection via crafted Content-Type. */
|
||||
const SAFE_MIME_TYPES = new Set([
|
||||
"image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml",
|
||||
"image/avif", "image/bmp", "video/mp4", "video/webm", "audio/mpeg",
|
||||
"audio/ogg", "audio/wav", "application/pdf",
|
||||
]);
|
||||
|
||||
/** Sanitize a Content-Type header value for use in a data: URI. */
|
||||
function sanitizeContentType(raw: string): string {
|
||||
const mime = raw.split(";")[0]?.trim() ?? "";
|
||||
return SAFE_MIME_TYPES.has(mime) ? raw : "application/octet-stream";
|
||||
}
|
||||
|
||||
/** Check if a URL points to the configured OwnCord server. */
|
||||
function isServerUrl(url: string): boolean {
|
||||
if (_serverHost === null) return false;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.host === _serverHost;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Report whether a URL targets the configured OwnCord server host. */
|
||||
export function isTrustedServerUrl(url: string): boolean {
|
||||
return isServerUrl(url);
|
||||
}
|
||||
|
||||
/** In-flight fetch promises to prevent duplicate concurrent requests. */
|
||||
const inFlight = new Map<string, Promise<string | null>>();
|
||||
@@ -93,6 +132,13 @@ export function openCacheDb(): Promise<IDBDatabase | null> {
|
||||
});
|
||||
}
|
||||
|
||||
function closeDbAfterTransaction(tx: IDBTransaction, db: IDBDatabase): void {
|
||||
const close = (): void => db.close();
|
||||
tx.oncomplete = close;
|
||||
tx.onabort = close;
|
||||
tx.onerror = close;
|
||||
}
|
||||
|
||||
/** Read a cached data URL from IndexedDB. */
|
||||
async function idbGet(url: string): Promise<string | null> {
|
||||
const db = await openCacheDb();
|
||||
@@ -100,11 +146,13 @@ async function idbGet(url: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const tx = db.transaction(IDB_STORE, "readonly");
|
||||
closeDbAfterTransaction(tx, db);
|
||||
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 {
|
||||
db.close();
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
@@ -116,8 +164,10 @@ async function idbPut(url: string, dataUrl: string): Promise<void> {
|
||||
if (db === null) return;
|
||||
try {
|
||||
const tx = db.transaction(IDB_STORE, "readwrite");
|
||||
closeDbAfterTransaction(tx, db);
|
||||
tx.objectStore(IDB_STORE).put(dataUrl, url);
|
||||
} catch {
|
||||
db.close();
|
||||
// IndexedDB full or unavailable — ignore
|
||||
}
|
||||
}
|
||||
@@ -136,6 +186,8 @@ export function uint8ToBase64(bytes: Uint8Array): string {
|
||||
|
||||
/** Fetch an image and return a data: URI. Uses memory → IndexedDB → network. */
|
||||
export function fetchImageAsDataUrl(url: string): Promise<string | null> {
|
||||
const generation = attachmentCacheGeneration;
|
||||
|
||||
// 1. Memory cache (instant)
|
||||
const cached = memoryCache.get(url);
|
||||
if (cached !== undefined) return Promise.resolve(cached);
|
||||
@@ -148,35 +200,60 @@ export function fetchImageAsDataUrl(url: string): Promise<string | null> {
|
||||
// 3. IndexedDB cache (persists across restarts)
|
||||
const idbCached = await idbGet(url);
|
||||
if (idbCached !== null) {
|
||||
if (generation !== attachmentCacheGeneration) return null;
|
||||
if (memoryCache.size >= CACHE_MAX) {
|
||||
const firstKey = memoryCache.keys().next().value;
|
||||
if (firstKey !== undefined) memoryCache.delete(firstKey);
|
||||
}
|
||||
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);
|
||||
const useInsecure = isServerUrl(url);
|
||||
const fetchOpts: RequestInit = useInsecure
|
||||
? { danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false } } as RequestInit
|
||||
: {};
|
||||
const res = await tauriFetch(url, fetchOpts);
|
||||
if (!res.ok) return null;
|
||||
|
||||
const contentType = res.headers.get("content-type") ?? "image/png";
|
||||
const rawCt = res.headers.get("content-type") ?? "";
|
||||
const contentType = sanitizeContentType(rawCt);
|
||||
const buffer = await res.arrayBuffer();
|
||||
const base64 = uint8ToBase64(new Uint8Array(buffer));
|
||||
const dataUrl = `data:${contentType};base64,${base64}`;
|
||||
|
||||
// Store in both caches
|
||||
if (generation !== attachmentCacheGeneration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Store in both caches (LRU eviction)
|
||||
if (memoryCache.size >= CACHE_MAX) {
|
||||
const firstKey = memoryCache.keys().next().value;
|
||||
if (firstKey !== undefined) memoryCache.delete(firstKey);
|
||||
}
|
||||
memoryCache.set(url, dataUrl);
|
||||
void idbPut(url, dataUrl);
|
||||
|
||||
return dataUrl;
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch attachment image:", url, err);
|
||||
log.error("Failed to fetch attachment image", { url, error: String(err) });
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
inFlight.set(url, promise);
|
||||
void promise.finally(() => inFlight.delete(url));
|
||||
void promise.finally(() => {
|
||||
if (inFlight.get(url) === promise) {
|
||||
inFlight.delete(url);
|
||||
}
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
@@ -228,7 +305,7 @@ export function renderAttachment(att: Attachment): HTMLDivElement {
|
||||
const img = createElement("img", {
|
||||
src: cached,
|
||||
alt: att.filename,
|
||||
}) as HTMLImageElement;
|
||||
});
|
||||
attachLightbox(img);
|
||||
img.addEventListener("load", () => {
|
||||
clearReservation();
|
||||
@@ -245,13 +322,15 @@ export function renderAttachment(att: Attachment): HTMLDivElement {
|
||||
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);
|
||||
} else {
|
||||
placeholder.classList.remove("loading");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -282,22 +361,27 @@ export function renderAttachment(att: Attachment): HTMLDivElement {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** Download a file via Tauri HTTP plugin and save to disk with native dialog. */
|
||||
/** Download a file via Tauri HTTP plugin and save to disk with native dialog.
|
||||
* NOTE: This requires fs:allow-write-file with path "**" in capabilities because
|
||||
* the user chooses the save location via the native OS dialog — the destination is
|
||||
* not under our control. The dialog itself is the security boundary. */
|
||||
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);
|
||||
// Fetch file data — only accept invalid certs for the OwnCord server
|
||||
const useInsecure = isServerUrl(url);
|
||||
const fetchOpts: RequestInit = useInsecure
|
||||
? { danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false } } as RequestInit
|
||||
: {};
|
||||
const res = await tauriFetch(url, fetchOpts);
|
||||
if (!res.ok) return;
|
||||
|
||||
const buffer = await res.arrayBuffer();
|
||||
await writeFile(filePath, new Uint8Array(buffer));
|
||||
} catch (err) {
|
||||
console.error("Download failed:", err);
|
||||
log.error("Download failed", { filename, error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,43 +92,53 @@ export function renderMentionSegment(text: string): DocumentFragment {
|
||||
|
||||
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);
|
||||
|
||||
// Split on triple-backtick boundaries to avoid ReDoS from greedy regex.
|
||||
// Odd-indexed segments are code block contents; even-indexed are prose.
|
||||
const parts = content.split("```");
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const segment = parts[i]!;
|
||||
if (i % 2 === 0) {
|
||||
// Prose segment
|
||||
const trimmed = i === 0 ? segment : (i === parts.length - 1 ? segment.trim() : segment);
|
||||
if (trimmed.length > 0) {
|
||||
const text = createElement("div", { class: "msg-text" });
|
||||
text.appendChild(renderInlineContent(trimmed));
|
||||
fragment.appendChild(text);
|
||||
}
|
||||
} else {
|
||||
// Code block segment
|
||||
const codeContent = segment.trim();
|
||||
const codeWrap = createElement("div", { class: "msg-codeblock-wrap" });
|
||||
const codeBlock = createElement("div", { class: "msg-codeblock" });
|
||||
setText(codeBlock, codeContent);
|
||||
const copyBtn = createElement("button", { class: "msg-codeblock-copy" });
|
||||
setText(copyBtn, "Copy");
|
||||
copyBtn.addEventListener("click", () => {
|
||||
void navigator.clipboard.writeText(codeContent).then(() => {
|
||||
setText(copyBtn, "Copied!");
|
||||
setTimeout(() => setText(copyBtn, "Copy"), 2000);
|
||||
}).catch(() => {
|
||||
setText(copyBtn, "Failed");
|
||||
setTimeout(() => setText(copyBtn, "Copy"), 2000);
|
||||
});
|
||||
});
|
||||
});
|
||||
codeWrap.appendChild(codeBlock);
|
||||
codeWrap.appendChild(copyBtn);
|
||||
fragment.appendChild(codeWrap);
|
||||
lastIndex = idx + match[0].length;
|
||||
codeWrap.appendChild(codeBlock);
|
||||
codeWrap.appendChild(copyBtn);
|
||||
fragment.appendChild(codeWrap);
|
||||
}
|
||||
}
|
||||
if (lastIndex === 0) {
|
||||
|
||||
// If there were no code blocks at all, ensure at least one text node
|
||||
if (parts.length === 1) {
|
||||
const text = createElement("div", { class: "msg-text" });
|
||||
text.appendChild(renderInlineContent(content));
|
||||
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));
|
||||
// Replace the fragment content (it already has the same, but handle empty edge case)
|
||||
if (fragment.childNodes.length === 0) {
|
||||
fragment.appendChild(text);
|
||||
}
|
||||
}
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
} from "@lib/dom";
|
||||
import { observeMedia } from "@lib/media-visibility";
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||
import { isSafeUrl } from "./attachments";
|
||||
import { createLogger } from "@lib/logger";
|
||||
import { isSafeUrl, isTrustedServerUrl } from "./attachments";
|
||||
|
||||
const log = createLogger("embeds");
|
||||
|
||||
// -- OG metadata types --------------------------------------------------------
|
||||
|
||||
@@ -25,18 +28,31 @@ export interface OgMeta {
|
||||
|
||||
/** Cache for OG metadata to avoid re-fetching on re-render. */
|
||||
const ogCache = new Map<string, OgMeta>();
|
||||
/** URLs currently being fetched (prevents duplicate requests). */
|
||||
const ogInFlight = new Set<string>();
|
||||
/** In-flight fetch promises keyed by URL — concurrent callers share the same promise. */
|
||||
const ogInFlight = new Map<string, Promise<OgMeta>>();
|
||||
let embedCacheGeneration = 0;
|
||||
|
||||
export function clearEmbedCaches(): void {
|
||||
embedCacheGeneration += 1;
|
||||
ogCache.clear();
|
||||
ogInFlight.clear();
|
||||
}
|
||||
|
||||
// -- 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)=["']${property}["'][^>]*content=["']([^"']*)["']` +
|
||||
`|<meta[^>]*content=["']([^"']*)["'][^>]*(?:property|name)=["']${property}["']`,
|
||||
`<meta[^>]*(?:property|name)=["']${escaped}["'][^>]*content=["']([^"']*)["']` +
|
||||
`|<meta[^>]*content=["']([^"']*)["'][^>]*(?:property|name)=["']${escaped}["']`,
|
||||
"i",
|
||||
);
|
||||
const match = html.match(regex);
|
||||
@@ -69,56 +85,154 @@ export function parseOgTags(html: string): OgMeta {
|
||||
};
|
||||
}
|
||||
|
||||
// -- 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 parseIPv4Literal(hostname: string): readonly [number, number, number, number] | null {
|
||||
const parts = hostname.split(".");
|
||||
if (parts.length !== 4) return null;
|
||||
|
||||
const octets = parts.map((part) => {
|
||||
if (!/^\d+$/.test(part)) return NaN;
|
||||
return Number(part);
|
||||
});
|
||||
if (octets.some((octet) => Number.isNaN(octet) || octet < 0 || octet > 255)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [octets[0]!, octets[1]!, octets[2]!, octets[3]!];
|
||||
}
|
||||
|
||||
function isPrivateHost(hostname: string): boolean {
|
||||
const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
const isIPv6Literal = h.includes(":");
|
||||
const ipv4 = parseIPv4Literal(h);
|
||||
|
||||
// Block localhost variants and unspecified address
|
||||
if (h === "localhost") return true;
|
||||
|
||||
if (isIPv6Literal) {
|
||||
if (h === "::" || h === "::1") return true;
|
||||
// IPv6 private ranges: fc00::/7 (fc.. and fd..), link-local fe80::/10.
|
||||
if (h.startsWith("fc") || h.startsWith("fd") || /^fe[89ab]/.test(h)) return true;
|
||||
if (h.startsWith("ff")) return true;
|
||||
if (h.startsWith("2001:db8")) return true;
|
||||
// IPv4-mapped IPv6 addresses (::ffff:x.x.x.x).
|
||||
if (h.startsWith("::ffff:")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ipv4 !== null) {
|
||||
const [first, second] = ipv4;
|
||||
// Block loopback, unspecified, RFC1918, link-local, CGNAT, and benchmarking ranges.
|
||||
if (first === 0 || first === 10 || first === 127) return true;
|
||||
if (first === 169 && second === 254) return true;
|
||||
if (first === 172 && second >= 16 && second <= 31) return true;
|
||||
if (first === 192 && second === 168) return true;
|
||||
if (first === 192 && second === 0) return true;
|
||||
if (first === 192 && second === 0 && ipv4[2] === 2) return true;
|
||||
if (first === 100 && second >= 64 && second <= 127) return true;
|
||||
if (first === 198 && (second === 18 || second === 19)) return true;
|
||||
if (first === 198 && second === 51 && ipv4[2] === 100) return true;
|
||||
if (first === 203 && second === 0 && ipv4[2] === 113) return true;
|
||||
if (first >= 224) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isBlockedForPreview(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (isTrustedServerUrl(parsed.toString())) {
|
||||
return false;
|
||||
}
|
||||
return isPrivateHost(parsed.hostname);
|
||||
} catch {
|
||||
return true; // Malformed URLs are blocked
|
||||
}
|
||||
}
|
||||
|
||||
// -- OG fetch -----------------------------------------------------------------
|
||||
|
||||
/** Fetch OG metadata for a URL using the Tauri native HTTP client (no CORS). */
|
||||
async function fetchOgMeta(url: string): Promise<OgMeta> {
|
||||
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 generation = embedCacheGeneration;
|
||||
const cached = ogCache.get(url);
|
||||
if (cached !== undefined) return cached;
|
||||
if (cached !== undefined) return Promise.resolve(cached);
|
||||
|
||||
// Return empty while in-flight to avoid duplicate requests
|
||||
if (ogInFlight.has(url)) {
|
||||
return { title: null, description: null, image: null, siteName: null };
|
||||
// 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);
|
||||
}
|
||||
|
||||
console.log("[embeds] fetchOgMeta START", url.slice(0, 100));
|
||||
ogInFlight.add(url);
|
||||
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);
|
||||
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 fetchOpts: RequestInit = {
|
||||
signal: controller.signal,
|
||||
headers: { "User-Agent": "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)" },
|
||||
};
|
||||
if (isTrustedServerUrl(url)) {
|
||||
(fetchOpts as RequestInit & { danger?: { acceptInvalidCerts: boolean; acceptInvalidHostnames: boolean } }).danger = { acceptInvalidCerts: true, acceptInvalidHostnames: false };
|
||||
}
|
||||
const res = await tauriFetch(url, fetchOpts);
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!res.ok) {
|
||||
const empty: OgMeta = { title: null, description: null, image: null, siteName: null };
|
||||
ogCache.set(url, empty);
|
||||
return empty;
|
||||
if (!res.ok) {
|
||||
if (generation !== embedCacheGeneration) {
|
||||
return EMPTY_OG;
|
||||
}
|
||||
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")) {
|
||||
if (generation !== embedCacheGeneration) {
|
||||
return EMPTY_OG;
|
||||
}
|
||||
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));
|
||||
if (generation !== embedCacheGeneration) {
|
||||
return EMPTY_OG;
|
||||
}
|
||||
ogCache.set(url, meta);
|
||||
return meta;
|
||||
} catch {
|
||||
if (generation !== embedCacheGeneration) {
|
||||
return EMPTY_OG;
|
||||
}
|
||||
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")) {
|
||||
const empty: OgMeta = { title: null, description: null, image: null, siteName: null };
|
||||
ogCache.set(url, empty);
|
||||
return empty;
|
||||
ogInFlight.set(url, promise);
|
||||
void promise.finally(() => {
|
||||
if (ogInFlight.get(url) === promise) {
|
||||
ogInFlight.delete(url);
|
||||
}
|
||||
|
||||
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 {
|
||||
const empty: OgMeta = { title: null, description: null, image: null, siteName: null };
|
||||
ogCache.set(url, empty);
|
||||
return empty;
|
||||
} finally {
|
||||
ogInFlight.delete(url);
|
||||
}
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
// -- Link preview rendering ---------------------------------------------------
|
||||
@@ -204,7 +318,7 @@ export function applyOgMeta(
|
||||
imgSrc = `${base.origin}${imgSrc}`;
|
||||
} catch { /* keep as-is */ }
|
||||
}
|
||||
if (isSafeUrl(imgSrc)) {
|
||||
if (isSafeUrl(imgSrc) && !isBlockedForPreview(imgSrc)) {
|
||||
const isGif = imgSrc.toLowerCase().endsWith(".gif");
|
||||
const attrs: Record<string, string> = {
|
||||
class: "msg-embed-link-img",
|
||||
@@ -220,8 +334,8 @@ export function applyOgMeta(
|
||||
imageWrap.style.display = "none";
|
||||
});
|
||||
if (isGif) {
|
||||
(img as HTMLImageElement).addEventListener("load", () => {
|
||||
observeMedia(img as HTMLImageElement, imgSrc, imageWrap);
|
||||
(img).addEventListener("load", () => {
|
||||
observeMedia(img, imgSrc, imageWrap);
|
||||
}, { once: true });
|
||||
}
|
||||
imageWrap.appendChild(img);
|
||||
|
||||
@@ -20,7 +20,7 @@ export class FenwickTree {
|
||||
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;
|
||||
(this.tree)[x] = (this.tree[x] as number) + delta;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ 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";
|
||||
@@ -26,6 +27,7 @@ const log = createLogger("media");
|
||||
*/
|
||||
const imageHeightCache = new Map<string, number>();
|
||||
const MAX_IMAGE_HEIGHT_CACHE = 500;
|
||||
let mediaCacheGeneration = 0;
|
||||
|
||||
function cacheImageHeight(url: string, h: number): void {
|
||||
if (imageHeightCache.size >= MAX_IMAGE_HEIGHT_CACHE) {
|
||||
@@ -86,8 +88,15 @@ export function extractYouTubeId(url: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Cache for YouTube video titles to avoid re-fetching on every re-render. */
|
||||
/** Cache for YouTube video titles to avoid re-fetching on every re-render (LRU at 200). */
|
||||
const ytTitleCache = new Map<string, string>();
|
||||
const YT_TITLE_CACHE_MAX = 200;
|
||||
|
||||
export function clearMediaCaches(): void {
|
||||
mediaCacheGeneration += 1;
|
||||
imageHeightCache.clear();
|
||||
ytTitleCache.clear();
|
||||
}
|
||||
|
||||
/** Strict pattern for YouTube video IDs (alphanumeric, hyphens, underscores). */
|
||||
const YOUTUBE_ID_RE = /^[\w-]{1,20}$/;
|
||||
@@ -119,15 +128,34 @@ export function renderYouTubeEmbed(videoId: string, originalUrl: string): HTMLDi
|
||||
setText(titleLink, cached);
|
||||
} else {
|
||||
setText(titleLink, "Loading...");
|
||||
const generation = mediaCacheGeneration;
|
||||
const oembedUrl = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${encodeURIComponent(videoId)}&format=json`;
|
||||
fetch(oembedUrl, { signal: AbortSignal.timeout(5000) })
|
||||
tauriFetch(oembedUrl, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
} as RequestInit)
|
||||
.then((res) => (res.ok ? (res.json() as Promise<{ title?: string } | null>) : null))
|
||||
.then((data) => {
|
||||
if (generation !== mediaCacheGeneration) {
|
||||
setText(titleLink, "YouTube Video");
|
||||
return;
|
||||
}
|
||||
const title = data?.title ?? "YouTube Video";
|
||||
if (ytTitleCache.size >= YT_TITLE_CACHE_MAX) {
|
||||
const firstKey = ytTitleCache.keys().next().value;
|
||||
if (firstKey !== undefined) ytTitleCache.delete(firstKey);
|
||||
}
|
||||
ytTitleCache.set(videoId, title);
|
||||
setText(titleLink, title);
|
||||
})
|
||||
.catch(() => {
|
||||
if (generation !== mediaCacheGeneration) {
|
||||
setText(titleLink, "YouTube Video");
|
||||
return;
|
||||
}
|
||||
if (ytTitleCache.size >= YT_TITLE_CACHE_MAX) {
|
||||
const firstKey = ytTitleCache.keys().next().value;
|
||||
if (firstKey !== undefined) ytTitleCache.delete(firstKey);
|
||||
}
|
||||
ytTitleCache.set(videoId, "YouTube Video");
|
||||
setText(titleLink, "YouTube Video");
|
||||
});
|
||||
@@ -205,7 +233,7 @@ export function renderInlineImage(url: string): HTMLDivElement {
|
||||
// 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 });
|
||||
log.info("Image loaded", { url: url.slice(0, 80), naturalW: (img).naturalWidth, naturalH: (img).naturalHeight });
|
||||
wrap.style.minHeight = "";
|
||||
const h = wrap.offsetHeight;
|
||||
if (h > 0) cacheImageHeight(url, h);
|
||||
@@ -241,15 +269,22 @@ export function renderInlineImage(url: string): HTMLDivElement {
|
||||
|
||||
// -- 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 to prevent stacking on rapid clicks
|
||||
document.querySelector(".image-lightbox")?.remove();
|
||||
// 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;
|
||||
const img = createElement("img", { src, alt });
|
||||
imgWrap.appendChild(img);
|
||||
overlay.appendChild(imgWrap);
|
||||
|
||||
@@ -294,9 +329,8 @@ export function openImageLightbox(src: string, alt: string): void {
|
||||
|
||||
function close(): void {
|
||||
overlay.remove();
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
ac.abort();
|
||||
if (activeLightboxClose === close) activeLightboxClose = null;
|
||||
}
|
||||
|
||||
// Mouse wheel zoom
|
||||
@@ -356,8 +390,10 @@ export function openImageLightbox(src: string, alt: string): void {
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
// Use AbortController for cleanup of document-level listeners to prevent leaks
|
||||
const ac = new AbortController();
|
||||
document.addEventListener("mousemove", onMove, { signal: ac.signal });
|
||||
document.addEventListener("mouseup", onUp, { signal: ac.signal });
|
||||
|
||||
closeBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -380,8 +416,9 @@ export function openImageLightbox(src: string, alt: string): void {
|
||||
}
|
||||
if (e.key === "0") resetZoom();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
document.addEventListener("keydown", onKey, { signal: ac.signal });
|
||||
|
||||
activeLightboxClose = close;
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
|
||||
@@ -211,19 +211,28 @@ export function renderMessage(
|
||||
if (!msg.deleted) {
|
||||
const actionsBar = createElement("div", { class: "msg-actions-bar" });
|
||||
|
||||
const reactBtn = createElement("button", { "data-testid": `msg-react-${msg.id}` });
|
||||
const reactBtn = createElement("button", {
|
||||
"data-testid": `msg-react-${msg.id}`,
|
||||
"aria-label": "React",
|
||||
});
|
||||
reactBtn.appendChild(createIcon("smile", 16));
|
||||
reactBtn.title = "React";
|
||||
reactBtn.addEventListener("click", () => opts.onReactionClick(msg.id, ""), { signal });
|
||||
actionsBar.appendChild(reactBtn);
|
||||
|
||||
const replyBtn = createElement("button", { "data-testid": `msg-reply-${msg.id}` });
|
||||
const replyBtn = createElement("button", {
|
||||
"data-testid": `msg-reply-${msg.id}`,
|
||||
"aria-label": "Reply",
|
||||
});
|
||||
replyBtn.appendChild(createIcon("reply", 16));
|
||||
replyBtn.title = "Reply";
|
||||
replyBtn.addEventListener("click", () => opts.onReplyClick(msg.id), { signal });
|
||||
actionsBar.appendChild(replyBtn);
|
||||
|
||||
const pinBtn = createElement("button", { "data-testid": `msg-pin-${msg.id}` });
|
||||
const pinBtn = createElement("button", {
|
||||
"data-testid": `msg-pin-${msg.id}`,
|
||||
"aria-label": msg.pinned ? "Unpin" : "Pin",
|
||||
});
|
||||
pinBtn.appendChild(createIcon(msg.pinned ? "pin-off" : "pin", 16));
|
||||
pinBtn.title = msg.pinned ? "Unpin" : "Pin";
|
||||
pinBtn.addEventListener(
|
||||
@@ -234,7 +243,10 @@ export function renderMessage(
|
||||
actionsBar.appendChild(pinBtn);
|
||||
|
||||
if (msg.user.id === opts.currentUserId) {
|
||||
const editBtn = createElement("button", { "data-testid": `msg-edit-${msg.id}` });
|
||||
const editBtn = createElement("button", {
|
||||
"data-testid": `msg-edit-${msg.id}`,
|
||||
"aria-label": "Edit",
|
||||
});
|
||||
editBtn.appendChild(createIcon("pencil", 16));
|
||||
editBtn.title = "Edit";
|
||||
editBtn.addEventListener("click", () => opts.onEditClick(msg.id), { signal });
|
||||
@@ -242,7 +254,10 @@ export function renderMessage(
|
||||
}
|
||||
|
||||
if (msg.user.id === opts.currentUserId) {
|
||||
const deleteBtn = createElement("button", { "data-testid": `msg-delete-${msg.id}` });
|
||||
const deleteBtn = createElement("button", {
|
||||
"data-testid": `msg-delete-${msg.id}`,
|
||||
"aria-label": "Delete",
|
||||
});
|
||||
deleteBtn.appendChild(createIcon("trash-2", 16));
|
||||
deleteBtn.title = "Delete";
|
||||
deleteBtn.addEventListener("click", () => opts.onDeleteClick(msg.id), { signal });
|
||||
@@ -250,7 +265,10 @@ export function renderMessage(
|
||||
}
|
||||
|
||||
if (developerModeEnabled) {
|
||||
const copyIdBtn = createElement("button", { "data-testid": `msg-copy-id-${msg.id}` });
|
||||
const copyIdBtn = createElement("button", {
|
||||
"data-testid": `msg-copy-id-${msg.id}`,
|
||||
"aria-label": "Copy ID",
|
||||
});
|
||||
copyIdBtn.appendChild(createIcon("hash", 16));
|
||||
copyIdBtn.title = "Copy ID";
|
||||
copyIdBtn.addEventListener("click", () => {
|
||||
|
||||
@@ -124,6 +124,285 @@ function buildPasswordSection(
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TOTP section builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildTotpEnrollForm(
|
||||
options: SettingsOverlayOptions,
|
||||
signal: AbortSignal,
|
||||
onEnrolled: () => void,
|
||||
): HTMLDivElement {
|
||||
const wrapper = createElement("div", {});
|
||||
|
||||
const description = createElement("div", {
|
||||
style: "color:var(--text-muted);font-size:13px;margin-bottom:12px",
|
||||
}, "Add an extra layer of security to your account.");
|
||||
|
||||
const enableBtn = createElement("button", {
|
||||
class: "ac-btn",
|
||||
"data-testid": "totp-enable-btn",
|
||||
}, "Enable 2FA");
|
||||
|
||||
const formArea = createElement("div", { style: "display:none" });
|
||||
const pwInput = createElement("input", {
|
||||
class: "form-input", type: "password",
|
||||
placeholder: "Enter your password", style: "margin-bottom:12px",
|
||||
"data-testid": "totp-password-input",
|
||||
});
|
||||
const errorEl = createElement("div", {
|
||||
style: "color:var(--red);font-size:13px;margin-bottom:8px",
|
||||
"data-testid": "totp-error",
|
||||
});
|
||||
const submitBtn = createElement("button", { class: "ac-btn" }, "Submit");
|
||||
|
||||
appendChildren(formArea, pwInput, errorEl, submitBtn);
|
||||
|
||||
const enrollArea = createElement("div", { style: "display:none" });
|
||||
|
||||
enableBtn.addEventListener("click", () => {
|
||||
enableBtn.style.display = "none";
|
||||
formArea.style.display = "block";
|
||||
pwInput.value = "";
|
||||
setText(errorEl, "");
|
||||
pwInput.focus();
|
||||
}, { signal });
|
||||
|
||||
submitBtn.addEventListener("click", () => {
|
||||
const pw = pwInput.value;
|
||||
if (pw.length === 0) {
|
||||
setText(errorEl, "Password is required.");
|
||||
return;
|
||||
}
|
||||
setText(errorEl, "");
|
||||
submitBtn.disabled = true;
|
||||
setText(submitBtn, "Requesting...");
|
||||
|
||||
void options.onEnableTotp(pw).then((result) => {
|
||||
formArea.style.display = "none";
|
||||
buildTotpConfirmArea(enrollArea, options, pw, result, signal, onEnrolled);
|
||||
enrollArea.style.display = "block";
|
||||
submitBtn.disabled = false;
|
||||
setText(submitBtn, "Submit");
|
||||
}).catch((err: unknown) => {
|
||||
setText(errorEl, err instanceof Error ? err.message : "Failed to enable 2FA.");
|
||||
submitBtn.disabled = false;
|
||||
setText(submitBtn, "Submit");
|
||||
});
|
||||
}, { signal });
|
||||
|
||||
appendChildren(wrapper, description, enableBtn, formArea, enrollArea);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function buildTotpConfirmArea(
|
||||
container: HTMLDivElement,
|
||||
options: SettingsOverlayOptions,
|
||||
password: string,
|
||||
result: { qr_uri: string; backup_codes: string[] },
|
||||
signal: AbortSignal,
|
||||
onEnrolled: () => void,
|
||||
): void {
|
||||
// Clear previous content immutably (remove children)
|
||||
while (container.firstChild) {
|
||||
container.removeChild(container.firstChild);
|
||||
}
|
||||
|
||||
const qrLabel = createElement("div", {
|
||||
style: "color:var(--text-muted);font-size:13px;margin-bottom:8px",
|
||||
}, "Scan this URI with your authenticator app, or copy it manually:");
|
||||
|
||||
const qrUri = createElement("code", {
|
||||
style: "display:block;background:var(--bg-active);padding:8px 12px;border-radius:6px;" +
|
||||
"font-family:monospace;font-size:12px;word-break:break-all;margin-bottom:12px;" +
|
||||
"color:var(--text-primary);user-select:all",
|
||||
"data-testid": "totp-qr-uri",
|
||||
}, result.qr_uri);
|
||||
|
||||
const elements: HTMLElement[] = [qrLabel, qrUri];
|
||||
|
||||
if (result.backup_codes.length > 0) {
|
||||
const backupLabel = createElement("div", {
|
||||
style: "color:var(--text-muted);font-size:13px;margin-bottom:8px",
|
||||
}, "Save these backup codes in a safe place:");
|
||||
const backupList = createElement("code", {
|
||||
style: "display:block;background:var(--bg-active);padding:8px 12px;border-radius:6px;" +
|
||||
"font-family:monospace;font-size:12px;white-space:pre-wrap;margin-bottom:12px;" +
|
||||
"color:var(--text-primary);user-select:all",
|
||||
}, result.backup_codes.join("\n"));
|
||||
elements.push(backupLabel, backupList);
|
||||
}
|
||||
|
||||
const codeInput = createElement("input", {
|
||||
class: "form-input", type: "text",
|
||||
placeholder: "6-digit code", maxlength: "6",
|
||||
style: "margin-bottom:12px",
|
||||
"data-testid": "totp-code-input",
|
||||
});
|
||||
|
||||
const confirmError = createElement("div", {
|
||||
style: "color:var(--red);font-size:13px;margin-bottom:8px",
|
||||
"data-testid": "totp-error",
|
||||
});
|
||||
|
||||
const confirmBtn = createElement("button", {
|
||||
class: "ac-btn",
|
||||
"data-testid": "totp-confirm-btn",
|
||||
}, "Verify & Activate");
|
||||
|
||||
confirmBtn.addEventListener("click", () => {
|
||||
const code = codeInput.value.trim();
|
||||
if (code.length === 0) {
|
||||
setText(confirmError, "Please enter the 6-digit code.");
|
||||
return;
|
||||
}
|
||||
setText(confirmError, "");
|
||||
confirmBtn.disabled = true;
|
||||
setText(confirmBtn, "Verifying...");
|
||||
|
||||
void options.onConfirmTotp(password, code).then(() => {
|
||||
onEnrolled();
|
||||
}).catch((err: unknown) => {
|
||||
setText(confirmError, err instanceof Error ? err.message : "Invalid verification code.");
|
||||
confirmBtn.disabled = false;
|
||||
setText(confirmBtn, "Verify & Activate");
|
||||
});
|
||||
}, { signal });
|
||||
|
||||
elements.push(codeInput, confirmError, confirmBtn);
|
||||
appendChildren(container, ...elements);
|
||||
}
|
||||
|
||||
function buildTotpDisableView(
|
||||
options: SettingsOverlayOptions,
|
||||
signal: AbortSignal,
|
||||
onDisabled: () => void,
|
||||
): HTMLDivElement {
|
||||
const wrapper = createElement("div", {});
|
||||
|
||||
const description = createElement("div", {
|
||||
style: "color:var(--text-muted);font-size:13px;margin-bottom:12px",
|
||||
}, "Your account is protected with 2FA.");
|
||||
|
||||
const disableBtn = createElement("button", {
|
||||
class: "ac-btn account-delete-btn",
|
||||
"data-testid": "totp-disable-btn",
|
||||
}, "Disable 2FA");
|
||||
|
||||
const confirmArea = createElement("div", { style: "display:none" });
|
||||
const pwInput = createElement("input", {
|
||||
class: "form-input", type: "password",
|
||||
placeholder: "Enter your password", style: "margin-bottom:12px",
|
||||
"data-testid": "totp-password-input",
|
||||
});
|
||||
const errorEl = createElement("div", {
|
||||
style: "color:var(--red);font-size:13px;margin-bottom:8px",
|
||||
"data-testid": "totp-error",
|
||||
});
|
||||
const btnRow = createElement("div", { style: "display:flex;gap:8px" });
|
||||
const confirmBtn = createElement("button", { class: "ac-btn account-delete-btn" }, "Confirm Disable");
|
||||
const cancelBtn = createElement("button", {
|
||||
class: "ac-btn", style: "background:var(--bg-active)",
|
||||
}, "Cancel");
|
||||
appendChildren(btnRow, confirmBtn, cancelBtn);
|
||||
appendChildren(confirmArea, pwInput, errorEl, btnRow);
|
||||
|
||||
disableBtn.addEventListener("click", () => {
|
||||
disableBtn.style.display = "none";
|
||||
confirmArea.style.display = "block";
|
||||
pwInput.value = "";
|
||||
setText(errorEl, "");
|
||||
pwInput.focus();
|
||||
}, { signal });
|
||||
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
confirmArea.style.display = "none";
|
||||
disableBtn.style.display = "";
|
||||
pwInput.value = "";
|
||||
setText(errorEl, "");
|
||||
}, { signal });
|
||||
|
||||
confirmBtn.addEventListener("click", () => {
|
||||
const pw = pwInput.value;
|
||||
if (pw.length === 0) {
|
||||
setText(errorEl, "Password is required.");
|
||||
return;
|
||||
}
|
||||
setText(errorEl, "");
|
||||
confirmBtn.disabled = true;
|
||||
setText(confirmBtn, "Disabling...");
|
||||
|
||||
void options.onDisableTotp(pw).then(() => {
|
||||
onDisabled();
|
||||
}).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : "Failed to disable 2FA.";
|
||||
const is403Required = msg.toLowerCase().includes("required");
|
||||
setText(errorEl, is403Required
|
||||
? "2FA is required by this server and cannot be disabled"
|
||||
: msg);
|
||||
confirmBtn.disabled = false;
|
||||
setText(confirmBtn, "Confirm Disable");
|
||||
});
|
||||
}, { signal });
|
||||
|
||||
appendChildren(wrapper, description, disableBtn, confirmArea);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function buildTotpSection(
|
||||
options: SettingsOverlayOptions,
|
||||
signal: AbortSignal,
|
||||
): HTMLDivElement {
|
||||
const wrapper = createElement("div", { "data-testid": "totp-section" });
|
||||
|
||||
const separator = createElement("div", { class: "settings-separator" });
|
||||
const headerRow = createElement("div", {
|
||||
style: "display:flex;align-items:center;gap:8px;margin-bottom:4px",
|
||||
});
|
||||
const header = createElement("div", {
|
||||
class: "settings-section-title",
|
||||
style: "margin-bottom:0",
|
||||
}, "Two-Factor Authentication");
|
||||
|
||||
const statusBadge = createElement("span", {
|
||||
"data-testid": "totp-status-badge",
|
||||
style: "font-size:12px;padding:2px 8px;border-radius:4px;font-weight:600",
|
||||
});
|
||||
|
||||
appendChildren(headerRow, header, statusBadge);
|
||||
|
||||
const contentArea = createElement("div", {});
|
||||
|
||||
function render(): void {
|
||||
const enabled = authStore.getState().user?.totp_enabled === true;
|
||||
|
||||
if (enabled) {
|
||||
statusBadge.textContent = "Enabled";
|
||||
statusBadge.style.background = "var(--green, #3ba55d)";
|
||||
statusBadge.style.color = "#fff";
|
||||
} else {
|
||||
statusBadge.textContent = "Disabled";
|
||||
statusBadge.style.background = "var(--bg-active)";
|
||||
statusBadge.style.color = "var(--text-muted)";
|
||||
}
|
||||
|
||||
while (contentArea.firstChild) {
|
||||
contentArea.removeChild(contentArea.firstChild);
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
contentArea.appendChild(buildTotpDisableView(options, signal, render));
|
||||
} else {
|
||||
contentArea.appendChild(buildTotpEnrollForm(options, signal, render));
|
||||
}
|
||||
}
|
||||
|
||||
render();
|
||||
|
||||
appendChildren(wrapper, separator, headerRow, contentArea);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status selector builder
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -139,7 +418,7 @@ 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" },
|
||||
{ value: "offline", label: "Offline", description: "You will appear offline but still have full access", color: "#747f8d" },
|
||||
];
|
||||
|
||||
function buildStatusSelector(
|
||||
@@ -155,8 +434,12 @@ function buildStatusSelector(
|
||||
const rowElements = new Map<UserStatus, HTMLDivElement>();
|
||||
|
||||
for (const opt of STATUS_OPTIONS) {
|
||||
const isActive = opt.value === currentStatus;
|
||||
const row = createElement("div", {
|
||||
class: `settings-status-option${opt.value === currentStatus ? " active" : ""}`,
|
||||
class: `settings-status-option${isActive ? " active" : ""}`,
|
||||
role: "button",
|
||||
tabindex: "0",
|
||||
"aria-pressed": isActive ? "true" : "false",
|
||||
});
|
||||
|
||||
const dot = createElement("div", { class: "settings-status-dot" });
|
||||
@@ -172,13 +455,23 @@ function buildStatusSelector(
|
||||
|
||||
appendChildren(row, dot, labelWrap);
|
||||
|
||||
row.addEventListener("click", () => {
|
||||
const selectStatus = (): void => {
|
||||
for (const [, el] of rowElements) {
|
||||
el.classList.remove("active");
|
||||
el.setAttribute("aria-pressed", "false");
|
||||
}
|
||||
row.classList.add("active");
|
||||
row.setAttribute("aria-pressed", "true");
|
||||
savePref("userStatus", opt.value);
|
||||
options.onStatusChange(opt.value);
|
||||
};
|
||||
|
||||
row.addEventListener("click", selectStatus, { signal });
|
||||
row.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
selectStatus();
|
||||
}
|
||||
}, { signal });
|
||||
|
||||
rowElements.set(opt.value, row);
|
||||
@@ -189,6 +482,109 @@ function buildStatusSelector(
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -237,8 +633,8 @@ export function buildAccountTab(
|
||||
|
||||
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.`);
|
||||
if (newName.length < 2 || newName.length > MAX_USERNAME_LEN) {
|
||||
setText(usernameError, `Username must be 2\u2013${MAX_USERNAME_LEN} characters.`);
|
||||
return;
|
||||
}
|
||||
setText(usernameError, "");
|
||||
@@ -256,5 +652,11 @@ export function buildAccountTab(
|
||||
// Password section
|
||||
section.appendChild(buildPasswordSection(options, signal));
|
||||
|
||||
// Two-factor authentication section
|
||||
section.appendChild(buildTotpSection(options, signal));
|
||||
|
||||
// Delete account (danger zone)
|
||||
section.appendChild(buildDeleteAccountSection(options, signal));
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
/**
|
||||
* Advanced settings tab — developer mode, hardware acceleration, and debug tools.
|
||||
* Advanced settings tab — developer mode, hardware acceleration, debug tools,
|
||||
* and cache management.
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren } from "@lib/dom";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { appLogDir, join } from "@tauri-apps/api/path";
|
||||
import { readDir, remove } from "@tauri-apps/plugin-fs";
|
||||
import { createLogger } from "@lib/logger";
|
||||
import { clearPendingPersistedLogs } from "@lib/logPersistence";
|
||||
import { clearAttachmentCaches } from "@components/message-list/attachments";
|
||||
import { clearEmbedCaches } from "@components/message-list/embeds";
|
||||
import { clearMediaCaches } from "@components/message-list/media";
|
||||
import { loadPref, savePref, createToggle } from "./helpers";
|
||||
|
||||
const log = createLogger("AdvancedTab");
|
||||
const IMAGE_CACHE_DELETE_BLOCK_TIMEOUT_MS = 1000;
|
||||
|
||||
export function buildAdvancedTab(signal: AbortSignal): HTMLDivElement {
|
||||
const section = createElement("div", { class: "settings-pane active" });
|
||||
|
||||
@@ -63,12 +74,209 @@ export function buildAdvancedTab(signal: AbortSignal): HTMLDivElement {
|
||||
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);
|
||||
log.warn("DevTools not available", { error: err instanceof Error ? err.message : String(err) });
|
||||
});
|
||||
}, { signal });
|
||||
|
||||
appendChildren(devtoolsRow, devtoolsInfo, devtoolsBtn);
|
||||
section.appendChild(devtoolsRow);
|
||||
|
||||
// ---- Storage & Cache section ------------------------------------------------
|
||||
|
||||
const cacheSep = createElement("div", { class: "settings-separator" });
|
||||
section.appendChild(cacheSep);
|
||||
|
||||
const cacheTitle = createElement("div", { class: "settings-section-title" }, "Storage & Cache");
|
||||
section.appendChild(cacheTitle);
|
||||
|
||||
// Clear Image Cache
|
||||
section.appendChild(buildCacheRow(
|
||||
"Clear Image Cache",
|
||||
"Remove cached images and link previews. They will be re-downloaded as needed.",
|
||||
"Clear",
|
||||
signal,
|
||||
async (btn) => {
|
||||
btn.textContent = "Clearing...";
|
||||
btn.setAttribute("disabled", "");
|
||||
try {
|
||||
await clearImageCache();
|
||||
btn.textContent = "Cleared!";
|
||||
setTimeout(() => { btn.textContent = "Clear"; btn.removeAttribute("disabled"); }, 2000);
|
||||
} catch (err) {
|
||||
log.error("Failed to clear image cache", err);
|
||||
btn.textContent = "Failed";
|
||||
setTimeout(() => { btn.textContent = "Clear"; btn.removeAttribute("disabled"); }, 2000);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
// Clear Log Files
|
||||
section.appendChild(buildCacheRow(
|
||||
"Clear Log Files",
|
||||
"Remove persisted client log files from disk.",
|
||||
"Clear",
|
||||
signal,
|
||||
async (btn) => {
|
||||
btn.textContent = "Clearing...";
|
||||
btn.setAttribute("disabled", "");
|
||||
try {
|
||||
await clearLogFiles();
|
||||
btn.textContent = "Cleared!";
|
||||
setTimeout(() => { btn.textContent = "Clear"; btn.removeAttribute("disabled"); }, 2000);
|
||||
} catch (err) {
|
||||
log.error("Failed to clear log files", err);
|
||||
btn.textContent = "Failed";
|
||||
setTimeout(() => { btn.textContent = "Clear"; btn.removeAttribute("disabled"); }, 2000);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
// Clear All Cache (nuclear option)
|
||||
section.appendChild(buildCacheRow(
|
||||
"Clear All Cache & Restart",
|
||||
"Remove all cached data (images, logs, WebView storage) and restart the app. "
|
||||
+ "Server profiles and credentials are preserved.",
|
||||
"Clear & Restart",
|
||||
signal,
|
||||
async (btn) => {
|
||||
// Two-step confirmation: first click shows warning, second click confirms
|
||||
if (btn.dataset.confirmPending !== "true") {
|
||||
btn.dataset.confirmPending = "true";
|
||||
btn.textContent = "Are you sure? Click again";
|
||||
btn.classList.add("ac-btn-danger");
|
||||
const resetTimer = setTimeout(() => {
|
||||
btn.dataset.confirmPending = "";
|
||||
btn.textContent = "Clear & Restart";
|
||||
btn.classList.remove("ac-btn-danger");
|
||||
}, 3000);
|
||||
// Store timer ID so it can be cleared if the button is clicked again
|
||||
btn.dataset.resetTimer = String(resetTimer);
|
||||
return;
|
||||
}
|
||||
// Second click — clear the pending state and proceed
|
||||
const pendingTimer = btn.dataset.resetTimer;
|
||||
if (pendingTimer) clearTimeout(Number(pendingTimer));
|
||||
btn.dataset.confirmPending = "";
|
||||
btn.textContent = "Clearing...";
|
||||
btn.setAttribute("disabled", "");
|
||||
try {
|
||||
await clearImageCache();
|
||||
await clearLogFiles();
|
||||
clearLocalStoragePreservingUserData();
|
||||
sessionStorage.clear();
|
||||
log.info("All cache cleared, restarting app");
|
||||
const { relaunch } = await import("@tauri-apps/plugin-process");
|
||||
await relaunch();
|
||||
} catch (err) {
|
||||
log.error("Failed to clear all cache", err);
|
||||
btn.textContent = "Failed";
|
||||
setTimeout(() => { btn.textContent = "Clear & Restart"; btn.removeAttribute("disabled"); }, 2000);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildCacheRow(
|
||||
label: string,
|
||||
desc: string,
|
||||
btnText: string,
|
||||
signal: AbortSignal,
|
||||
onClick: (btn: HTMLButtonElement) => void,
|
||||
): HTMLDivElement {
|
||||
const row = createElement("div", { class: "setting-row" });
|
||||
const info = createElement("div", {});
|
||||
const labelEl = createElement("div", { class: "setting-label" }, label);
|
||||
const descEl = createElement("div", { class: "setting-desc" }, desc);
|
||||
appendChildren(info, labelEl, descEl);
|
||||
|
||||
const btn = createElement("button", { class: "ac-btn" }, btnText);
|
||||
btn.addEventListener("click", () => { onClick(btn); }, { signal });
|
||||
|
||||
appendChildren(row, info, btn);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Delete the IndexedDB image cache database. */
|
||||
async function clearImageCache(): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = indexedDB.deleteDatabase("owncord-image-cache");
|
||||
let settled = false;
|
||||
let blockedTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function finish(callback: () => void): void {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (blockedTimer !== null) {
|
||||
clearTimeout(blockedTimer);
|
||||
}
|
||||
callback();
|
||||
}
|
||||
|
||||
req.onsuccess = () => finish(resolve);
|
||||
req.onerror = () => finish(() => reject(req.error));
|
||||
req.onblocked = () => {
|
||||
if (blockedTimer !== null) return;
|
||||
blockedTimer = setTimeout(() => {
|
||||
finish(() => reject(new Error("Image cache is still in use. Close active media and try again.")));
|
||||
}, IMAGE_CACHE_DELETE_BLOCK_TIMEOUT_MS);
|
||||
};
|
||||
});
|
||||
|
||||
clearAttachmentCaches();
|
||||
clearEmbedCaches();
|
||||
clearMediaCaches();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear localStorage but preserve user-critical data: server profiles,
|
||||
* saved credentials, active theme selection, and custom themes.
|
||||
*/
|
||||
function clearLocalStoragePreservingUserData(): void {
|
||||
const PRESERVE_PREFIXES = [
|
||||
"owncord:profiles",
|
||||
"owncord:credential:",
|
||||
"owncord:theme:active",
|
||||
"owncord:theme:custom:",
|
||||
];
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key !== null && !PRESERVE_PREFIXES.some((p) => key.startsWith(p))) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
for (const key of keysToRemove) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete all JSONL log files from the app log directory. */
|
||||
async function clearLogFiles(): Promise<void> {
|
||||
try {
|
||||
await clearPendingPersistedLogs();
|
||||
const baseDir = await appLogDir();
|
||||
const logDir = await join(baseDir, "client-logs");
|
||||
const entries = await readDir(logDir);
|
||||
for (const entry of entries) {
|
||||
if (entry.name?.endsWith(".jsonl") && !entry.isDirectory) {
|
||||
await remove(`${logDir}/${entry.name}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMissingPathError(err)) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingPathError(err: unknown): boolean {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return /not found|no such file|cannot find the path|os error 2|enoent/i.test(message);
|
||||
}
|
||||
|
||||
@@ -6,28 +6,65 @@ import { createElement, appendChildren, setText } from "@lib/dom";
|
||||
import { loadPref, savePref, applyTheme, THEMES, createToggle } from "./helpers";
|
||||
import type { ThemeName } from "./helpers";
|
||||
import { setTheme } from "@stores/ui.store";
|
||||
import { getActiveThemeName, loadCustomTheme, restoreTheme } from "@lib/themes";
|
||||
|
||||
const FALLBACK_ACCENT = "#5865f2";
|
||||
|
||||
function getDefaultAccent(themeName: string): string {
|
||||
if (themeName === "neon-glow") return "#00c8ff";
|
||||
if (themeName in THEMES) return FALLBACK_ACCENT;
|
||||
|
||||
const customTheme = loadCustomTheme(themeName);
|
||||
const accent = customTheme?.colors["--accent"];
|
||||
return typeof accent === "string" && /^#[\da-fA-F]{3,8}$/.test(accent)
|
||||
? accent
|
||||
: FALLBACK_ACCENT;
|
||||
}
|
||||
|
||||
export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement {
|
||||
const section = createElement("div", { class: "settings-pane active" });
|
||||
const currentTheme = loadPref<ThemeName>("theme", "dark");
|
||||
const activeThemeName = getActiveThemeName();
|
||||
const currentTheme = activeThemeName in THEMES
|
||||
? activeThemeName as ThemeName
|
||||
: null;
|
||||
const currentFontSize = loadPref<number>("fontSize", 16);
|
||||
const currentCompact = loadPref<boolean>("compactMode", false);
|
||||
let hasStoredAccent = localStorage.getItem("owncord:settings:accentColor") !== null;
|
||||
const defaultAccent = getDefaultAccent(activeThemeName);
|
||||
|
||||
// Theme selector
|
||||
const themeHeader = createElement("h3", {}, "Theme");
|
||||
const themeRow = createElement("div", { class: "theme-options" });
|
||||
const themeRow = createElement("div", { class: "theme-options", role: "radiogroup" });
|
||||
for (const name of Object.keys(THEMES) as ThemeName[]) {
|
||||
const btn = createElement("div", {
|
||||
class: `theme-opt ${name}${name === currentTheme ? " active" : ""}`,
|
||||
const isActive = name === currentTheme;
|
||||
const btn = createElement("button", {
|
||||
class: `theme-opt ${name}${isActive ? " active" : ""}`,
|
||||
role: "radio",
|
||||
tabindex: "0",
|
||||
"aria-checked": isActive ? "true" : "false",
|
||||
"aria-label": name.charAt(0).toUpperCase() + name.slice(1),
|
||||
}, name.charAt(0).toUpperCase() + name.slice(1));
|
||||
|
||||
btn.addEventListener("click", () => {
|
||||
const activateTheme = (): void => {
|
||||
applyTheme(name);
|
||||
savePref("theme", name);
|
||||
setTheme(name);
|
||||
const prev = themeRow.querySelector(".theme-opt.active");
|
||||
if (prev) prev.classList.remove("active");
|
||||
for (const child of themeRow.children) {
|
||||
child.classList.remove("active");
|
||||
child.setAttribute("aria-checked", "false");
|
||||
}
|
||||
btn.classList.add("active");
|
||||
btn.setAttribute("aria-checked", "true");
|
||||
if (!hasStoredAccent) {
|
||||
syncDisplayedAccent(getDefaultAccent(name));
|
||||
}
|
||||
};
|
||||
|
||||
btn.addEventListener("click", activateTheme, { signal });
|
||||
btn.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
activateTheme();
|
||||
}
|
||||
}, { signal });
|
||||
|
||||
themeRow.appendChild(btn);
|
||||
@@ -69,7 +106,7 @@ export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement {
|
||||
|
||||
// Accent color picker
|
||||
const ACCENT_PRESETS: readonly string[] = [
|
||||
"#5865f2", // Discord blurple (default)
|
||||
"#00c8ff", // OwnCord neon cyan
|
||||
"#57f287", // green
|
||||
"#fee75c", // yellow
|
||||
"#eb459e", // fuchsia/pink
|
||||
@@ -77,17 +114,21 @@ export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement {
|
||||
"#f47b67", // salmon
|
||||
"#e78b38", // orange
|
||||
"#3ba55d", // dark green
|
||||
"#45ddff", // cyan
|
||||
"#5865f2", // blurple
|
||||
"#b9bbbe", // grey
|
||||
];
|
||||
|
||||
const currentAccent = loadPref<string>("accentColor", "#5865f2");
|
||||
const currentAccent = loadPref<string>("accentColor", defaultAccent);
|
||||
|
||||
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 {
|
||||
hasStoredAccent = true;
|
||||
savePref("accentColor", color);
|
||||
applyAccent(color);
|
||||
}
|
||||
@@ -102,10 +143,20 @@ export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement {
|
||||
class: "form-input",
|
||||
type: "text",
|
||||
maxlength: "6",
|
||||
placeholder: "5865f2",
|
||||
placeholder: defaultAccent.replace("#", ""),
|
||||
value: currentAccent.replace("#", ""),
|
||||
style: "width:120px",
|
||||
}) as HTMLInputElement;
|
||||
});
|
||||
|
||||
function syncDisplayedAccent(color: string): void {
|
||||
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");
|
||||
}
|
||||
hexInput.value = color.replace("#", "");
|
||||
hexInput.placeholder = color.replace("#", "");
|
||||
}
|
||||
|
||||
for (const color of ACCENT_PRESETS) {
|
||||
const swatch = createElement("div", {
|
||||
@@ -122,14 +173,7 @@ export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement {
|
||||
|
||||
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("#", "");
|
||||
syncDisplayedAccent(color);
|
||||
};
|
||||
|
||||
swatch.addEventListener("click", activateSwatch, { signal });
|
||||
@@ -149,12 +193,7 @@ export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement {
|
||||
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");
|
||||
}
|
||||
syncDisplayedAccent(color);
|
||||
}
|
||||
}, { signal });
|
||||
|
||||
@@ -162,10 +201,16 @@ export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement {
|
||||
appendChildren(section, accentHeader, swatchesRow, hexInputRow);
|
||||
|
||||
// Apply stored preferences on render
|
||||
applyTheme(currentTheme);
|
||||
if (currentTheme === null) {
|
||||
restoreTheme();
|
||||
} else {
|
||||
applyTheme(currentTheme);
|
||||
}
|
||||
document.documentElement.style.setProperty("--font-size", `${currentFontSize}px`);
|
||||
document.documentElement.classList.toggle("compact-mode", currentCompact);
|
||||
applyAccent(currentAccent);
|
||||
if (hasStoredAccent) {
|
||||
applyAccent(currentAccent);
|
||||
}
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
@@ -14,10 +14,11 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
|
||||
const pttRow = createElement("div", { class: "keybind-row" });
|
||||
const pttLabel = createElement("span", { class: "setting-label" }, "Push to Talk");
|
||||
let currentVk = loadPref<number>("pttVk", 0);
|
||||
const pttValue = createElement("span", {
|
||||
const pttValue = createElement("button", {
|
||||
class: "kbd",
|
||||
style: "cursor: pointer; min-width: 80px; text-align: center;",
|
||||
title: "Click to set keybind",
|
||||
"aria-label": "Push to Talk keybind — click to capture",
|
||||
}, currentVk !== 0 ? vkName(currentVk) : "Not set");
|
||||
const pttClear = createElement("button", {
|
||||
class: "ac-btn",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getLogBuffer, clearLogBuffer, addLogListener, setLogLevel } from "@lib/
|
||||
import type { LogEntry, LogLevel } from "@lib/logger";
|
||||
import type { TabName } from "../SettingsOverlay";
|
||||
import { getSessionDebugInfo } from "@lib/livekitSession";
|
||||
import { loadPref, savePref } from "./helpers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -19,6 +20,9 @@ const LOG_LEVEL_COLORS: Record<LogLevel, string> = {
|
||||
error: "#ed4245",
|
||||
};
|
||||
|
||||
const LOG_FILTER_LEVELS = ["all", "debug", "info", "warn", "error"] as const;
|
||||
const LOG_MIN_LEVELS = ["debug", "info", "warn", "error"] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -47,6 +51,40 @@ function formatLogEntry(entry: LogEntry): HTMLDivElement {
|
||||
return row;
|
||||
}
|
||||
|
||||
function readMigratedStringPref<T extends string>(
|
||||
key: string,
|
||||
fallback: T,
|
||||
allowedValues: readonly T[],
|
||||
): T {
|
||||
const currentRaw = localStorage.getItem(`owncord:settings:${key}`);
|
||||
if (currentRaw !== null) {
|
||||
try {
|
||||
const currentValue: unknown = JSON.parse(currentRaw);
|
||||
if (typeof currentValue === "string" && allowedValues.includes(currentValue as T)) {
|
||||
return currentValue as T;
|
||||
}
|
||||
} catch {
|
||||
// Ignore corrupted current storage and fall back below.
|
||||
}
|
||||
}
|
||||
|
||||
const legacyRaw = localStorage.getItem(key);
|
||||
if (legacyRaw !== null) {
|
||||
let legacyValue: unknown = legacyRaw;
|
||||
try {
|
||||
legacyValue = JSON.parse(legacyRaw);
|
||||
} catch {
|
||||
// Legacy values were previously stored as raw strings.
|
||||
}
|
||||
if (typeof legacyValue === "string" && allowedValues.includes(legacyValue as T)) {
|
||||
savePref(key, legacyValue);
|
||||
return legacyValue as T;
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -61,7 +99,7 @@ export function createLogsTab(
|
||||
signal: AbortSignal,
|
||||
): LogsTabHandle {
|
||||
let logListEl: HTMLDivElement | null = null;
|
||||
let logFilterLevel: LogLevel | "all" = (localStorage.getItem("logs_filter_level") as LogLevel | "all") ?? "all";
|
||||
let logFilterLevel: LogLevel | "all" = readMigratedStringPref("logs_filter_level", "all", LOG_FILTER_LEVELS);
|
||||
let unsubLogListener: (() => void) | null = null;
|
||||
|
||||
function renderLogEntries(): void {
|
||||
@@ -100,8 +138,7 @@ export function createLogsTab(
|
||||
const filterSelect = createElement("select", {
|
||||
style: "background: var(--bg-tertiary); color: var(--text-normal); border: 1px solid var(--bg-active); border-radius: 4px; padding: 4px 8px; font-size: 13px;",
|
||||
});
|
||||
const levels: Array<LogLevel | "all"> = ["all", "debug", "info", "warn", "error"];
|
||||
for (const lvl of levels) {
|
||||
for (const lvl of LOG_FILTER_LEVELS) {
|
||||
const opt = createElement("option", { value: lvl }, lvl.toUpperCase());
|
||||
if (lvl === logFilterLevel) opt.setAttribute("selected", "");
|
||||
filterSelect.appendChild(opt);
|
||||
@@ -109,7 +146,7 @@ export function createLogsTab(
|
||||
filterSelect.value = logFilterLevel;
|
||||
filterSelect.addEventListener("change", () => {
|
||||
logFilterLevel = filterSelect.value as LogLevel | "all";
|
||||
localStorage.setItem("logs_filter_level", logFilterLevel);
|
||||
savePref("logs_filter_level", logFilterLevel);
|
||||
renderLogEntries();
|
||||
}, { signal });
|
||||
|
||||
@@ -118,20 +155,19 @@ export function createLogsTab(
|
||||
const levelSelect = createElement("select", {
|
||||
style: "background: var(--bg-tertiary); color: var(--text-normal); border: 1px solid var(--bg-active); border-radius: 4px; padding: 4px 8px; font-size: 13px;",
|
||||
});
|
||||
const minLevels: LogLevel[] = ["debug", "info", "warn", "error"];
|
||||
for (const lvl of minLevels) {
|
||||
for (const lvl of LOG_MIN_LEVELS) {
|
||||
const opt = createElement("option", { value: lvl }, lvl.toUpperCase());
|
||||
levelSelect.appendChild(opt);
|
||||
}
|
||||
const savedMinLevel = localStorage.getItem("logs_min_level") as LogLevel | null;
|
||||
if (savedMinLevel !== null) {
|
||||
const savedMinLevel = readMigratedStringPref<LogLevel | "">("logs_min_level", "", ["", ...LOG_MIN_LEVELS]);
|
||||
if (savedMinLevel !== "") {
|
||||
levelSelect.value = savedMinLevel;
|
||||
setLogLevel(savedMinLevel);
|
||||
}
|
||||
levelSelect.addEventListener("change", () => {
|
||||
const level = levelSelect.value as LogLevel;
|
||||
setLogLevel(level);
|
||||
localStorage.setItem("logs_min_level", level);
|
||||
savePref("logs_min_level", level);
|
||||
}, { signal });
|
||||
|
||||
// Copy All button
|
||||
@@ -155,6 +191,9 @@ export function createLogsTab(
|
||||
void navigator.clipboard.writeText(text).then(() => {
|
||||
copyBtn.textContent = "Copied!";
|
||||
setTimeout(() => { copyBtn.textContent = "Copy All"; }, 1500);
|
||||
}).catch(() => {
|
||||
copyBtn.textContent = "Failed to copy";
|
||||
setTimeout(() => { copyBtn.textContent = "Copy All"; }, 1500);
|
||||
});
|
||||
}, { signal });
|
||||
|
||||
@@ -194,6 +233,9 @@ export function createLogsTab(
|
||||
void navigator.clipboard.writeText(diagPanel.textContent ?? "").then(() => {
|
||||
diagCopy.textContent = "Copied!";
|
||||
setTimeout(() => { diagCopy.textContent = "Copy Diagnostics"; }, 1500);
|
||||
}).catch(() => {
|
||||
diagCopy.textContent = "Failed to copy";
|
||||
setTimeout(() => { diagCopy.textContent = "Copy Diagnostics"; }, 1500);
|
||||
});
|
||||
}, { signal });
|
||||
|
||||
|
||||
@@ -33,18 +33,6 @@ export function buildTextImagesTab(signal: AbortSignal): HTMLDivElement {
|
||||
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) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { createElement, appendChildren, setText } from "@lib/dom";
|
||||
import { loadPref, savePref, createToggle } from "./helpers";
|
||||
import { switchInputDevice, switchOutputDevice, setVoiceSensitivity, setInputVolume, setOutputVolume } from "@lib/livekitSession";
|
||||
import { switchInputDevice, switchOutputDevice, setVoiceSensitivity, setInputVolume, setOutputVolume, reapplyAudioProcessing } from "@lib/livekitSession";
|
||||
|
||||
export interface VoiceAudioTabHandle {
|
||||
build(): HTMLDivElement;
|
||||
@@ -16,9 +16,11 @@ export function createVoiceAudioTab(signal: AbortSignal): VoiceAudioTabHandle {
|
||||
let micAudioCtx: AudioContext | null = null;
|
||||
let micAnimFrame: number | null = null;
|
||||
let cameraPreviewStream: MediaStream | null = null;
|
||||
let invalidateCameraPreviewRequest: (() => void) | null = null;
|
||||
|
||||
function cleanupMic(): void {
|
||||
if (micAnimFrame !== null) { cancelAnimationFrame(micAnimFrame); micAnimFrame = null; }
|
||||
invalidateCameraPreviewRequest?.();
|
||||
if (micStream !== null) {
|
||||
for (const track of micStream.getTracks()) track.stop();
|
||||
micStream = null;
|
||||
@@ -44,6 +46,8 @@ export function createVoiceAudioTab(signal: AbortSignal): VoiceAudioTabHandle {
|
||||
for (const track of cameraPreviewStream.getTracks()) track.stop();
|
||||
}
|
||||
cameraPreviewStream = stream;
|
||||
}, (invalidate) => {
|
||||
invalidateCameraPreviewRequest = invalidate;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,8 +63,14 @@ export function createVoiceAudioTab(signal: AbortSignal): VoiceAudioTabHandle {
|
||||
|
||||
type MicRegistrar = (stream: MediaStream, ctx: AudioContext, frame: number) => void;
|
||||
type CameraRegistrar = (stream: MediaStream | null) => void;
|
||||
type CameraInvalidationRegistrar = (invalidate: () => void) => void;
|
||||
|
||||
function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar, registerCamera: CameraRegistrar): HTMLDivElement {
|
||||
function buildVoiceAudioTabInner(
|
||||
signal: AbortSignal,
|
||||
registerMic: MicRegistrar,
|
||||
registerCamera: CameraRegistrar,
|
||||
registerCameraInvalidation: CameraInvalidationRegistrar,
|
||||
): HTMLDivElement {
|
||||
const section = createElement("div", { class: "settings-pane active" });
|
||||
|
||||
// Input device selector
|
||||
@@ -96,6 +106,63 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar,
|
||||
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", {
|
||||
@@ -129,6 +196,35 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar,
|
||||
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",
|
||||
});
|
||||
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,7 +298,14 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar,
|
||||
void switchOutputDevice(outputSelect.value);
|
||||
}, { signal });
|
||||
|
||||
// Race guard: prevent stale getUserMedia results from overwriting a newer request
|
||||
let cameraRequestId = 0;
|
||||
registerCameraInvalidation(() => {
|
||||
cameraRequestId += 1;
|
||||
});
|
||||
|
||||
function stopCameraPreview(): void {
|
||||
cameraRequestId += 1;
|
||||
registerCamera(null);
|
||||
previewVideo.srcObject = null;
|
||||
}
|
||||
@@ -219,6 +322,7 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar,
|
||||
function startCameraPreview(deviceId: string): void {
|
||||
stopCameraPreview();
|
||||
clearPreviewError();
|
||||
const thisRequest = ++cameraRequestId;
|
||||
void (async () => {
|
||||
try {
|
||||
const constraints: MediaStreamConstraints = {
|
||||
@@ -228,11 +332,17 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar,
|
||||
audio: false,
|
||||
};
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
// Race guard: if a newer request was issued while we awaited, discard this result
|
||||
if (signal.aborted || thisRequest !== cameraRequestId) {
|
||||
for (const track of stream.getTracks()) track.stop();
|
||||
return;
|
||||
}
|
||||
registerCamera(stream);
|
||||
previewVideo.srcObject = stream;
|
||||
} catch (err) {
|
||||
if (signal.aborted || thisRequest !== cameraRequestId) return;
|
||||
const msg = err instanceof Error ? err.message : "Camera unavailable";
|
||||
previewErrorEl = createElement("div", { class: "setting-desc" }, msg) as HTMLDivElement;
|
||||
previewErrorEl = createElement("div", { class: "setting-desc" }, msg);
|
||||
previewWrap.appendChild(previewErrorEl);
|
||||
}
|
||||
})();
|
||||
@@ -253,49 +363,6 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar,
|
||||
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 — matches slider direction:
|
||||
// slider left (low sensitivity) = indicator left, slider right = indicator right
|
||||
function updateThresholdIndicator(sensitivity: number): void {
|
||||
meterThreshold.style.left = `${sensitivity}%`;
|
||||
}
|
||||
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 {
|
||||
@@ -330,7 +397,7 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar,
|
||||
meterLevel.style.width = `${visual * 100}%`;
|
||||
|
||||
// Color: green if above threshold, yellow/red if below
|
||||
const threshold = ((100 - Number(sensitivitySlider.value)) / 100) * 0.15;
|
||||
const threshold = ((100 - currentSensitivity) / 100) * 0.15;
|
||||
if (rms >= threshold) {
|
||||
meterLevel.style.background = "#43b581"; // green — voice detected
|
||||
} else {
|
||||
@@ -367,8 +434,8 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar,
|
||||
signal,
|
||||
onChange: (nowOn) => {
|
||||
savePref(item.key, nowOn);
|
||||
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;
|
||||
}
|
||||
@@ -80,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 ─────────────────────────────────────────────
|
||||
@@ -276,16 +270,16 @@ export function createApiClient(
|
||||
);
|
||||
},
|
||||
|
||||
enableTotp(signal?: AbortSignal): Promise<{ qr_uri: string; backup_codes: string[] }> {
|
||||
return request("POST", "/users/me/totp/enable", undefined, signal);
|
||||
enableTotp(password: string, signal?: AbortSignal): Promise<{ qr_uri: string; backup_codes: string[] }> {
|
||||
return request("POST", "/users/me/totp/enable", { password }, signal);
|
||||
},
|
||||
|
||||
confirmTotp(code: string, signal?: AbortSignal): Promise<void> {
|
||||
return request<void>("POST", "/users/me/totp/confirm", { code }, signal);
|
||||
confirmTotp(password: string, code: string, signal?: AbortSignal): Promise<void> {
|
||||
return request<void>("POST", "/users/me/totp/confirm", { password, code }, signal);
|
||||
},
|
||||
|
||||
disableTotp(signal?: AbortSignal): Promise<void> {
|
||||
return request<void>("DELETE", "/users/me/totp", undefined, signal);
|
||||
disableTotp(password: string, signal?: AbortSignal): Promise<void> {
|
||||
return request<void>("DELETE", "/users/me/totp", { password }, signal);
|
||||
},
|
||||
|
||||
getSessions(signal?: AbortSignal): Promise<SessionResponse[]> {
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
// AudioElements — manages remote audio elements (mic + screenshare)
|
||||
//
|
||||
// Handles HTMLAudioElement lifecycle for remote participants' audio tracks,
|
||||
// per-user volume, screenshare audio volume/mute, and output device routing.
|
||||
|
||||
import {
|
||||
Track,
|
||||
type Room,
|
||||
type RemoteTrack,
|
||||
type RemoteTrackPublication,
|
||||
type RemoteParticipant,
|
||||
} from "livekit-client";
|
||||
import { loadPref, savePref } from "@components/settings/helpers";
|
||||
import { createLogger } from "@lib/logger";
|
||||
import { parseUserId } from "@lib/livekitSession";
|
||||
|
||||
const log = createLogger("audioElements");
|
||||
|
||||
/** Get saved per-user volume (0-200 range, default 100). Applied via LiveKit's GainNode-backed setVolume(). */
|
||||
function getSavedUserVolume(userId: number): number {
|
||||
return loadPref<number>(`userVolume_${userId}`, 100);
|
||||
}
|
||||
|
||||
export class AudioElements {
|
||||
private room: Room | null = null;
|
||||
|
||||
/** Remote microphone audio elements keyed by track SID for cleanup on disconnect. */
|
||||
private remoteMicAudioElements = new Map<string, HTMLAudioElement>();
|
||||
/** Screenshare audio elements keyed by userId — separate from mic audio pipeline. */
|
||||
private screenshareAudioElements = new Map<number, Set<HTMLAudioElement>>();
|
||||
/** Persisted mute state for screenshare audio so replacement tracks inherit UI state. */
|
||||
private screenshareAudioMutedByUser = new Map<number, boolean>();
|
||||
|
||||
/** Master output volume multiplier (0-2.0). Per-user volumes are scaled by this. */
|
||||
private outputVolumeMultiplier: number;
|
||||
|
||||
constructor() {
|
||||
this.outputVolumeMultiplier = loadPref<number>("outputVolume", 100) / 100;
|
||||
}
|
||||
|
||||
setRoom(room: Room | null): void {
|
||||
this.room = room;
|
||||
}
|
||||
|
||||
/** Get the current output volume multiplier. */
|
||||
getOutputVolumeMultiplier(): number {
|
||||
return this.outputVolumeMultiplier;
|
||||
}
|
||||
|
||||
/** Compute the effective volume for a participant: per-user volume * master output. */
|
||||
getEffectiveVolume(userId: number): number {
|
||||
const userVol = userId > 0 ? getSavedUserVolume(userId) : 100;
|
||||
return (userVol / 100) * this.outputVolumeMultiplier;
|
||||
}
|
||||
|
||||
private getScreenshareOutputVolume(): number {
|
||||
return Math.max(0, Math.min(1, this.outputVolumeMultiplier));
|
||||
}
|
||||
|
||||
// --- Track subscription handlers ---
|
||||
|
||||
handleTrackSubscribedAudio(
|
||||
track: RemoteTrack,
|
||||
publication: RemoteTrackPublication,
|
||||
participant: RemoteParticipant,
|
||||
): void {
|
||||
const userId = parseUserId(participant.identity);
|
||||
if (publication.source === Track.Source.ScreenShareAudio) {
|
||||
// Screenshare audio: manage via HTMLAudioElement volume (not participant.setVolume)
|
||||
for (const el of track.detach()) el.remove();
|
||||
const audioEl = track.attach();
|
||||
audioEl.style.display = "none";
|
||||
document.body.appendChild(audioEl);
|
||||
audioEl.volume = this.getScreenshareOutputVolume();
|
||||
audioEl.muted = this.screenshareAudioMutedByUser.get(userId) ?? false;
|
||||
let audioEls = this.screenshareAudioElements.get(userId);
|
||||
if (audioEls === undefined) {
|
||||
audioEls = new Set();
|
||||
this.screenshareAudioElements.set(userId, audioEls);
|
||||
}
|
||||
audioEls.add(audioEl);
|
||||
const savedOutput = loadPref<string>("audioOutputDevice", "");
|
||||
if (savedOutput !== "" && typeof audioEl.setSinkId === "function") {
|
||||
audioEl.setSinkId(savedOutput).catch((err) => {
|
||||
log.warn("Failed to set output device on screenshare audio", err);
|
||||
});
|
||||
}
|
||||
log.debug("Screenshare audio track subscribed and attached", { userId, trackSid: track.sid });
|
||||
} else {
|
||||
// Microphone audio: use LiveKit's GainNode-backed setVolume
|
||||
// Detach any previous <audio> elements to prevent duplicate playback
|
||||
// on fast reconnects (new subscription fires before old unsubscription)
|
||||
for (const el of track.detach()) el.remove();
|
||||
const audioEl = track.attach();
|
||||
audioEl.style.display = "none";
|
||||
document.body.appendChild(audioEl);
|
||||
// Track mic audio elements for cleanup on abnormal disconnect
|
||||
if (track.sid !== undefined) {
|
||||
this.remoteMicAudioElements.set(track.sid, audioEl);
|
||||
}
|
||||
// Apply saved per-user volume via LiveKit's setVolume (supports 0-2.0 range)
|
||||
participant.setVolume(this.getEffectiveVolume(userId));
|
||||
const savedOutput = loadPref<string>("audioOutputDevice", "");
|
||||
if (savedOutput !== "" && typeof audioEl.setSinkId === "function") {
|
||||
audioEl.setSinkId(savedOutput).catch((err) => {
|
||||
log.warn("Failed to set output device on remote audio", err);
|
||||
});
|
||||
}
|
||||
log.debug("Remote audio track subscribed and attached", { userId, trackSid: track.sid });
|
||||
}
|
||||
}
|
||||
|
||||
handleTrackUnsubscribedAudio(
|
||||
track: RemoteTrack,
|
||||
publication: RemoteTrackPublication,
|
||||
participant: RemoteParticipant,
|
||||
): void {
|
||||
const userId = parseUserId(participant.identity);
|
||||
if (publication.source === Track.Source.ScreenShareAudio) {
|
||||
const detachedEls = track.detach() as HTMLAudioElement[];
|
||||
for (const el of detachedEls) el.remove();
|
||||
const audioEls = this.screenshareAudioElements.get(userId);
|
||||
if (audioEls !== undefined) {
|
||||
for (const el of detachedEls) audioEls.delete(el);
|
||||
if (audioEls.size === 0) this.screenshareAudioElements.delete(userId);
|
||||
}
|
||||
log.debug("Screenshare audio track unsubscribed and detached", { userId, trackSid: track.sid });
|
||||
} else {
|
||||
for (const el of track.detach()) el.remove();
|
||||
if (track.sid !== undefined) this.remoteMicAudioElements.delete(track.sid);
|
||||
log.debug("Remote audio track unsubscribed and detached", { userId, trackSid: track.sid });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Remote audio subscription state (deafen) ---
|
||||
|
||||
applyRemoteAudioSubscriptionState(deafened: boolean): void {
|
||||
if (this.room === null) return;
|
||||
for (const participant of this.room.remoteParticipants.values()) {
|
||||
for (const publication of participant.audioTrackPublications.values()) {
|
||||
publication.setSubscribed(!deafened);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Volume control ---
|
||||
|
||||
/** Apply effective volume to all remote participants. */
|
||||
applyAllVolumes(): void {
|
||||
if (this.room === null) return;
|
||||
for (const participant of this.room.remoteParticipants.values()) {
|
||||
const userId = parseUserId(participant.identity);
|
||||
participant.setVolume(this.getEffectiveVolume(userId));
|
||||
}
|
||||
}
|
||||
|
||||
setUserVolume(userId: number, volume: number): void {
|
||||
const clamped = Math.max(0, Math.min(200, volume));
|
||||
savePref(`userVolume_${userId}`, clamped);
|
||||
if (this.room !== null) {
|
||||
for (const participant of this.room.remoteParticipants.values()) {
|
||||
if (parseUserId(participant.identity) === userId) {
|
||||
participant.setVolume((clamped / 100) * this.outputVolumeMultiplier);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getUserVolume(userId: number): number { return getSavedUserVolume(userId); }
|
||||
|
||||
setOutputVolume(volume: number): void {
|
||||
const clamped = Math.max(0, Math.min(200, volume));
|
||||
savePref("outputVolume", clamped);
|
||||
this.outputVolumeMultiplier = clamped / 100;
|
||||
this.applyAllVolumes();
|
||||
const screenshareVolume = this.getScreenshareOutputVolume();
|
||||
for (const audioEls of this.screenshareAudioElements.values()) {
|
||||
for (const audioEl of audioEls) {
|
||||
audioEl.volume = screenshareVolume;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Screenshare audio ---
|
||||
|
||||
setScreenshareAudioVolume(userId: number, volume: number): void {
|
||||
const audioEls = this.screenshareAudioElements.get(userId);
|
||||
if (audioEls === undefined) return;
|
||||
const clamped = Math.max(0, Math.min(1, volume));
|
||||
for (const el of audioEls) el.volume = clamped;
|
||||
}
|
||||
|
||||
muteScreenshareAudio(userId: number, muted: boolean): void {
|
||||
this.screenshareAudioMutedByUser.set(userId, muted);
|
||||
const audioEls = this.screenshareAudioElements.get(userId);
|
||||
if (audioEls === undefined) return;
|
||||
for (const el of audioEls) el.muted = muted;
|
||||
}
|
||||
|
||||
getScreenshareAudioMuted(userId: number): boolean {
|
||||
const storedMuted = this.screenshareAudioMutedByUser.get(userId);
|
||||
if (storedMuted !== undefined) return storedMuted;
|
||||
const audioEls = this.screenshareAudioElements.get(userId);
|
||||
if (audioEls === undefined) return false;
|
||||
for (const el of audioEls) return el.muted;
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Cleanup ---
|
||||
|
||||
/** Remove all remote audio elements from the DOM and clear tracking maps. */
|
||||
cleanupAllAudioElements(): void {
|
||||
for (const el of this.remoteMicAudioElements.values()) el.remove();
|
||||
this.remoteMicAudioElements.clear();
|
||||
for (const audioEls of this.screenshareAudioElements.values()) {
|
||||
for (const el of audioEls) el.remove();
|
||||
}
|
||||
this.screenshareAudioElements.clear();
|
||||
this.screenshareAudioMutedByUser.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
// AudioPipeline — unified audio pipeline: input volume + VAD gating
|
||||
//
|
||||
// Architecture:
|
||||
// rawMicTrack → AudioContext source
|
||||
// ├──→ AnalyserNode (VAD reads raw audio here — always sees real signal)
|
||||
// └──→ GainNode (inputVolume × vadGate) → MediaStreamDestination → WebRTC sender
|
||||
//
|
||||
// The pipeline is always active while in a voice session. This avoids
|
||||
// creating/destroying it when volume changes, and gives the VAD a stable
|
||||
// analyser that's independent of LiveKit's track lifecycle.
|
||||
|
||||
import { Track, type Room, type LocalAudioTrack } from "livekit-client";
|
||||
import { loadPref, savePref } from "@components/settings/helpers";
|
||||
import { createLogger } from "@lib/logger";
|
||||
import { createRNNoiseProcessor } from "@lib/noise-suppression";
|
||||
|
||||
const log = createLogger("audioPipeline");
|
||||
|
||||
export class AudioPipeline {
|
||||
private room: Room | null = null;
|
||||
|
||||
// Pipeline nodes
|
||||
private audioPipelineCtx: AudioContext | null = null;
|
||||
private audioPipelineGain: GainNode | null = null;
|
||||
private audioPipelineAnalyser: AnalyserNode | null = null;
|
||||
private audioPipelineDest: MediaStreamAudioDestinationNode | null = null;
|
||||
private vadTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** When true, mic is currently gated (muted by VAD — gain set to 0). */
|
||||
private vadGated = false;
|
||||
/** The user's input volume gain (0-2.0). VAD multiplies this by 0 or 1. */
|
||||
private currentInputGain = 1.0;
|
||||
|
||||
setRoom(room: Room | null): void {
|
||||
this.room = room;
|
||||
}
|
||||
|
||||
/** Whether the audio pipeline is currently active (has a GainNode). */
|
||||
get isActive(): boolean {
|
||||
return this.audioPipelineGain !== null;
|
||||
}
|
||||
|
||||
/** Current gain value from the pipeline GainNode, or null if inactive. */
|
||||
get gainValue(): number | null {
|
||||
return this.audioPipelineGain?.gain.value ?? null;
|
||||
}
|
||||
|
||||
/** Current AudioContext state, or null if inactive. */
|
||||
get ctxState(): string | null {
|
||||
return this.audioPipelineCtx?.state ?? null;
|
||||
}
|
||||
|
||||
/** Whether VAD is currently gating audio. */
|
||||
get isVadGated(): boolean {
|
||||
return this.vadGated;
|
||||
}
|
||||
|
||||
/** Current input gain multiplier. */
|
||||
get inputGain(): number {
|
||||
return this.currentInputGain;
|
||||
}
|
||||
|
||||
// --- RNNoise processor (LiveKit TrackProcessor API) ---
|
||||
|
||||
/** Attach RNNoise processor to the local mic track. Safe to call if already attached. */
|
||||
async applyNoiseSuppressor(): Promise<void> {
|
||||
if (this.room === null) return;
|
||||
const micPub = this.room.localParticipant.getTrackPublication(Track.Source.Microphone);
|
||||
if (micPub?.track === undefined) return;
|
||||
if (micPub.track.getProcessor() !== undefined) return;
|
||||
const processor = createRNNoiseProcessor();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- LocalTrack.setProcessor uses wide generic, but AudioProcessorOptions is guaranteed at runtime with webAudioMix
|
||||
await micPub.track.setProcessor(processor as any);
|
||||
log.info("RNNoise processor attached to mic track");
|
||||
}
|
||||
|
||||
/** Remove RNNoise processor from the local mic track. Safe to call if none attached. */
|
||||
async removeNoiseSuppressor(): Promise<void> {
|
||||
if (this.room === null) return;
|
||||
const micPub = this.room.localParticipant.getTrackPublication(Track.Source.Microphone);
|
||||
if (micPub?.track === undefined) return;
|
||||
if (micPub.track.getProcessor() === undefined) return;
|
||||
await micPub.track.stopProcessor();
|
||||
log.info("RNNoise processor removed from mic track");
|
||||
}
|
||||
|
||||
// --- Pipeline setup/teardown ---
|
||||
|
||||
/** Build or rebuild the audio pipeline on the current mic track. */
|
||||
setupAudioPipeline(): void {
|
||||
this.teardownAudioPipeline();
|
||||
if (this.room === null) return;
|
||||
const micPub = this.room.localParticipant.getTrackPublication(Track.Source.Microphone);
|
||||
if (micPub?.track === undefined) return;
|
||||
|
||||
try {
|
||||
const mediaTrack = micPub.track.mediaStreamTrack;
|
||||
const ctx = new AudioContext({ sampleRate: 48000 });
|
||||
void ctx.resume(); // Ensure not suspended (WebView2 autoplay policy)
|
||||
|
||||
const source = ctx.createMediaStreamSource(new MediaStream([mediaTrack]));
|
||||
|
||||
// Analyser: VAD reads time-domain data from here (always real audio)
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 2048;
|
||||
analyser.smoothingTimeConstant = 0.3;
|
||||
|
||||
// GainNode: controls both input volume and VAD gating
|
||||
const gainNode = ctx.createGain();
|
||||
this.currentInputGain = loadPref<number>("inputVolume", 100) / 100;
|
||||
gainNode.gain.setValueAtTime(this.currentInputGain, ctx.currentTime);
|
||||
|
||||
const dest = ctx.createMediaStreamDestination();
|
||||
|
||||
// Wire: source → analyser (tap) and source → gain → dest
|
||||
source.connect(analyser);
|
||||
source.connect(gainNode);
|
||||
gainNode.connect(dest);
|
||||
|
||||
this.audioPipelineCtx = ctx;
|
||||
this.audioPipelineGain = gainNode;
|
||||
this.audioPipelineAnalyser = analyser;
|
||||
this.audioPipelineDest = dest;
|
||||
|
||||
// Replace the WebRTC sender's track with the pipeline output
|
||||
const adjustedTrack = dest.stream.getAudioTracks()[0];
|
||||
if (adjustedTrack !== undefined && micPub.track.sender) {
|
||||
void micPub.track.sender.replaceTrack(adjustedTrack).catch((err) => {
|
||||
log.warn("Failed to replace sender track with pipeline output", err);
|
||||
});
|
||||
}
|
||||
|
||||
log.info("Audio pipeline created", { inputGain: this.currentInputGain });
|
||||
|
||||
// Start VAD polling if sensitivity < 100
|
||||
this.startVadPolling();
|
||||
} catch (err) {
|
||||
log.warn("Failed to set up audio pipeline", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Tear down the audio pipeline and restore the original sender track. */
|
||||
teardownAudioPipeline(): void {
|
||||
this.stopVadPolling();
|
||||
|
||||
// Restore original mic track on the WebRTC sender
|
||||
if (this.room !== null) {
|
||||
const micPub = this.room.localParticipant.getTrackPublication(Track.Source.Microphone);
|
||||
if (micPub?.track?.sender !== undefined) {
|
||||
const originalTrack = micPub.track.mediaStreamTrack;
|
||||
void micPub.track.sender.replaceTrack(originalTrack).catch((err) => log.debug("Failed to replace track during teardown", err));
|
||||
}
|
||||
}
|
||||
|
||||
if (this.audioPipelineGain !== null) { this.audioPipelineGain.disconnect(); this.audioPipelineGain = null; }
|
||||
if (this.audioPipelineAnalyser !== null) { this.audioPipelineAnalyser.disconnect(); this.audioPipelineAnalyser = null; }
|
||||
if (this.audioPipelineDest !== null) { this.audioPipelineDest.disconnect(); this.audioPipelineDest = null; }
|
||||
if (this.audioPipelineCtx !== null) { void this.audioPipelineCtx.close(); this.audioPipelineCtx = null; }
|
||||
this.vadGated = false;
|
||||
}
|
||||
|
||||
/** Update the effective gain on the pipeline (inputVolume × vadGate).
|
||||
* The pipeline only exists when unmuted — muting tears it down entirely. */
|
||||
updatePipelineGain(): void {
|
||||
if (this.audioPipelineGain === null || this.audioPipelineCtx === null) return;
|
||||
const effectiveGain = this.vadGated ? 0 : this.currentInputGain;
|
||||
this.audioPipelineGain.gain.setTargetAtTime(effectiveGain, this.audioPipelineCtx.currentTime, 0.015);
|
||||
}
|
||||
|
||||
// --- Volume/sensitivity ---
|
||||
|
||||
setInputVolume(volume: number): void {
|
||||
const clamped = Math.max(0, Math.min(200, volume));
|
||||
savePref("inputVolume", clamped);
|
||||
this.currentInputGain = clamped / 100;
|
||||
this.updatePipelineGain();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply voice sensitivity as a client-side VAD gate.
|
||||
* Sensitivity 0 = gate everything (threshold impossibly high).
|
||||
* Sensitivity 100 = gate nothing (no VAD polling).
|
||||
* VAD sets gain to 0 when gated, restores inputVolume when ungated.
|
||||
*/
|
||||
setVoiceSensitivity(sensitivity: number): void {
|
||||
const clamped = Math.max(0, Math.min(100, sensitivity));
|
||||
savePref("voiceSensitivity", clamped);
|
||||
// Restart VAD polling with the new threshold (pipeline stays intact)
|
||||
this.stopVadPolling();
|
||||
if (clamped >= 100) {
|
||||
// Ensure ungated
|
||||
if (this.vadGated) { this.vadGated = false; this.updatePipelineGain(); }
|
||||
} else {
|
||||
this.startVadPolling();
|
||||
}
|
||||
log.debug("Voice sensitivity updated", { sensitivity: clamped });
|
||||
}
|
||||
|
||||
// --- VAD (Voice Activity Detection) ---
|
||||
//
|
||||
// Primary: AudioWorklet (vad-worklet.js) — runs on audio thread, works when
|
||||
// app is backgrounded, zero main-thread CPU.
|
||||
// Fallback: setTimeout polling — used if AudioWorklet fails to load.
|
||||
|
||||
private vadWorkletNode: AudioWorkletNode | null = null;
|
||||
/** Latest RMS value from VAD worklet, used for UI indicator. */
|
||||
private _lastVadRms = 0;
|
||||
private _vadUsingWorklet = false;
|
||||
|
||||
/** Latest RMS value from VAD (for UI indicator bar). */
|
||||
get lastVadRms(): number { return this._lastVadRms; }
|
||||
/** Whether VAD is using AudioWorklet (true) or setTimeout fallback (false). */
|
||||
get vadUsingWorklet(): boolean { return this._vadUsingWorklet; }
|
||||
|
||||
/** Start VAD — tries AudioWorklet first, falls back to setTimeout polling. */
|
||||
startVadPolling(): void {
|
||||
this.stopVadPolling();
|
||||
if (this.audioPipelineCtx === null || this.audioPipelineAnalyser === null) return;
|
||||
|
||||
const sensitivity = loadPref<number>("voiceSensitivity", 50);
|
||||
if (sensitivity >= 100) return;
|
||||
|
||||
const threshold = ((100 - sensitivity) / 100) * 0.10;
|
||||
|
||||
// Try AudioWorklet first
|
||||
this.audioPipelineCtx.audioWorklet.addModule("/vad-worklet.js").then(() => {
|
||||
if (this.audioPipelineCtx === null) return; // Torn down while loading
|
||||
this.startVadWorklet(threshold);
|
||||
}).catch((err) => {
|
||||
log.warn("AudioWorklet unavailable, falling back to setTimeout VAD", err);
|
||||
this.startVadFallback(threshold);
|
||||
});
|
||||
}
|
||||
|
||||
/** Start VAD via AudioWorklet (preferred — runs on audio thread). */
|
||||
private startVadWorklet(threshold: number): void {
|
||||
if (this.audioPipelineCtx === null) return;
|
||||
|
||||
try {
|
||||
const workletNode = new AudioWorkletNode(this.audioPipelineCtx, "vad-processor");
|
||||
|
||||
// Wire: source → analyser → workletNode (workletNode receives audio directly)
|
||||
// We connect to the analyser's output so both the analyser and worklet see audio
|
||||
if (this.audioPipelineAnalyser !== null) {
|
||||
this.audioPipelineAnalyser.connect(workletNode);
|
||||
}
|
||||
// Don't connect workletNode output to anything — it's analysis-only
|
||||
|
||||
workletNode.port.postMessage({ type: "config", threshold });
|
||||
|
||||
workletNode.port.onmessage = (event: MessageEvent) => {
|
||||
if (event.data.type === "gate") {
|
||||
const gated = event.data.gated as boolean;
|
||||
if (gated !== this.vadGated) {
|
||||
this.vadGated = gated;
|
||||
this.updatePipelineGain();
|
||||
}
|
||||
} else if (event.data.type === "rms") {
|
||||
this._lastVadRms = event.data.value as number;
|
||||
}
|
||||
};
|
||||
|
||||
this.vadWorkletNode = workletNode;
|
||||
this._vadUsingWorklet = true;
|
||||
log.info("VAD AudioWorklet started", { threshold });
|
||||
} catch (err) {
|
||||
log.warn("Failed to create VAD AudioWorkletNode, falling back", err);
|
||||
this.startVadFallback(threshold);
|
||||
}
|
||||
}
|
||||
|
||||
/** Start VAD via setTimeout polling (fallback — works when AudioWorklet unavailable).
|
||||
* setTimeout instead of rAF: rAF pauses when the Tauri window is backgrounded,
|
||||
* which freezes the VAD gate. setTimeout continues firing (throttled ~1Hz when
|
||||
* hidden), still fast enough for VAD gate timing (200ms on, 100ms off). */
|
||||
private startVadFallback(threshold: number): void {
|
||||
if (this.audioPipelineAnalyser === null) return;
|
||||
|
||||
const analyser = this.audioPipelineAnalyser;
|
||||
const dataArray = new Float32Array(analyser.fftSize);
|
||||
let silentFrames = 0;
|
||||
let speechFrames = 0;
|
||||
const GATE_ON_FRAMES = 12;
|
||||
const GATE_OFF_FRAMES = 2;
|
||||
let startupFrames = 0;
|
||||
const STARTUP_GRACE = 30;
|
||||
let frameCounter = 0;
|
||||
|
||||
const poll = (): void => {
|
||||
if (this.audioPipelineAnalyser === null) return;
|
||||
|
||||
analyser.getFloatTimeDomainData(dataArray);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
const v = dataArray[i] ?? 0;
|
||||
sum += v * v;
|
||||
}
|
||||
const rms = Math.sqrt(sum / dataArray.length);
|
||||
|
||||
// Send RMS for UI indicator (~50ms interval)
|
||||
frameCounter++;
|
||||
if (frameCounter >= 3) {
|
||||
frameCounter = 0;
|
||||
this._lastVadRms = rms;
|
||||
}
|
||||
|
||||
if (startupFrames < STARTUP_GRACE) {
|
||||
startupFrames++;
|
||||
this.vadTimer = setTimeout(poll, 16);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rms < threshold) {
|
||||
speechFrames = 0;
|
||||
silentFrames++;
|
||||
if (!this.vadGated && silentFrames >= GATE_ON_FRAMES) {
|
||||
this.vadGated = true;
|
||||
this.updatePipelineGain();
|
||||
}
|
||||
} else {
|
||||
silentFrames = 0;
|
||||
speechFrames++;
|
||||
if (this.vadGated && speechFrames >= GATE_OFF_FRAMES) {
|
||||
this.vadGated = false;
|
||||
this.updatePipelineGain();
|
||||
}
|
||||
}
|
||||
|
||||
this.vadTimer = setTimeout(poll, 16);
|
||||
};
|
||||
this.vadTimer = setTimeout(poll, 16);
|
||||
this._vadUsingWorklet = false;
|
||||
log.info("VAD setTimeout fallback started", { threshold });
|
||||
}
|
||||
|
||||
/** Stop VAD (both worklet and fallback). Pipeline stays intact. */
|
||||
stopVadPolling(): void {
|
||||
// Stop setTimeout fallback
|
||||
if (this.vadTimer !== null) {
|
||||
clearTimeout(this.vadTimer);
|
||||
this.vadTimer = null;
|
||||
}
|
||||
// Stop AudioWorklet
|
||||
if (this.vadWorkletNode !== null) {
|
||||
this.vadWorkletNode.port.postMessage({ type: "stop" });
|
||||
this.vadWorkletNode.disconnect();
|
||||
this.vadWorkletNode = null;
|
||||
}
|
||||
this._vadUsingWorklet = false;
|
||||
this._lastVadRms = 0;
|
||||
// Ungate if was gated
|
||||
if (this.vadGated) {
|
||||
this.vadGated = false;
|
||||
this.updatePipelineGain();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-apply audio processing settings (echo cancellation, noise suppression, AGC)
|
||||
* to the live mic track by restarting it with updated constraints.
|
||||
*/
|
||||
async reapplyAudioProcessing(onError?: (message: string) => void): Promise<void> {
|
||||
if (this.room === null) {
|
||||
log.debug("Skipping audio processing reapply — no active voice session");
|
||||
return;
|
||||
}
|
||||
const micPub = this.room.localParticipant.getTrackPublication(Track.Source.Microphone);
|
||||
if (micPub?.track === undefined) {
|
||||
log.debug("Skipping audio processing reapply — no mic track");
|
||||
return;
|
||||
}
|
||||
|
||||
const captureOptions = {
|
||||
echoCancellation: loadPref("echoCancellation", true),
|
||||
noiseSuppression: loadPref("noiseSuppression", true),
|
||||
autoGainControl: loadPref("autoGainControl", true),
|
||||
};
|
||||
|
||||
try {
|
||||
// restartTrack re-acquires the mic with new constraints without unpublishing
|
||||
await (micPub.track as LocalAudioTrack).restartTrack(captureOptions);
|
||||
log.info("Audio processing reapplied via restartTrack", captureOptions);
|
||||
|
||||
// Rebuild audio pipeline (underlying track changed)
|
||||
this.setupAudioPipeline();
|
||||
|
||||
// Re-apply or remove RNNoise processor
|
||||
const enhancedNS = loadPref<boolean>("enhancedNoiseSuppression", false);
|
||||
if (enhancedNS) {
|
||||
await this.applyNoiseSuppressor();
|
||||
} else {
|
||||
await this.removeNoiseSuppressor();
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("Failed to reapply audio processing", err);
|
||||
onError?.("Failed to update audio settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// 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;
|
||||
onQualityChanged(cb: (quality: QualityLevel, prevQuality: QualityLevel) => 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>();
|
||||
const qualityChangeListeners = new Set<(quality: QualityLevel, prevQuality: QualityLevel) => void>();
|
||||
let lastQuality: QualityLevel = "excellent";
|
||||
let qualityDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const QUALITY_DEBOUNCE_MS = 3000;
|
||||
|
||||
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));
|
||||
|
||||
// Debounced quality change notification (prevents toast spam on flapping)
|
||||
const newQuality = current.quality;
|
||||
if (newQuality !== lastQuality) {
|
||||
if (qualityDebounceTimer !== null) clearTimeout(qualityDebounceTimer);
|
||||
qualityDebounceTimer = setTimeout(() => {
|
||||
if (current.quality !== lastQuality) {
|
||||
const prev = lastQuality;
|
||||
lastQuality = current.quality;
|
||||
qualityChangeListeners.forEach((cb) => cb(current.quality, prev));
|
||||
}
|
||||
}, QUALITY_DEBOUNCE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
}
|
||||
|
||||
function onQualityChanged(cb: (quality: QualityLevel, prevQuality: QualityLevel) => void): () => void {
|
||||
qualityChangeListeners.add(cb);
|
||||
return () => { qualityChangeListeners.delete(cb); };
|
||||
}
|
||||
|
||||
return { start, stop, getStats, onUpdate, onQualityChanged };
|
||||
}
|
||||
|
||||
// --- 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`;
|
||||
}
|
||||
|
||||
/** Format bytes/sec as human-readable Mbps (for bandwidth display). */
|
||||
export function formatBitrate(bytesPerSec: number): string {
|
||||
const mbps = (bytesPerSec * 8) / 1_000_000;
|
||||
if (mbps < 0.01) return "0 Mbps";
|
||||
if (mbps < 1) return `${(mbps * 1000).toFixed(0)} Kbps`;
|
||||
return `${mbps.toFixed(1)} Mbps`;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Offset added to userId to produce a unique tile ID for screenshare tiles in the video grid. */
|
||||
export const SCREENSHARE_TILE_ID_OFFSET = 1_000_000;
|
||||
@@ -57,6 +57,7 @@ export function showContextMenu(opts: ContextMenuOptions): void {
|
||||
"click",
|
||||
() => {
|
||||
menu.remove();
|
||||
dismissAc.abort();
|
||||
item.onClick();
|
||||
},
|
||||
{ signal },
|
||||
@@ -70,6 +71,7 @@ export function showContextMenu(opts: ContextMenuOptions): void {
|
||||
// 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) => {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// DeviceManager — audio input/output device switching + hot-swap detection
|
||||
//
|
||||
// Delegates to Room.switchActiveDevice and rebuilds the audio pipeline
|
||||
// after a device switch so the new source track flows through the GainNode.
|
||||
// Monitors navigator.mediaDevices.ondevicechange for hot-swap (unplug/plug).
|
||||
|
||||
import { Room } from "livekit-client";
|
||||
import { loadPref, savePref } from "@components/settings/helpers";
|
||||
import { createLogger } from "@lib/logger";
|
||||
import type { AudioPipeline } from "@lib/audioPipeline";
|
||||
|
||||
const log = createLogger("deviceManager");
|
||||
|
||||
/** Debounce interval for device change events (ms). */
|
||||
const DEVICE_CHANGE_DEBOUNCE_MS = 500;
|
||||
|
||||
export class DeviceManager {
|
||||
private room: Room | null = null;
|
||||
private audioPipeline: AudioPipeline | null = null;
|
||||
private onErrorCallback: ((message: string) => void) | null = null;
|
||||
private onToast: ((message: string) => void) | null = null;
|
||||
private deviceChangeHandler: (() => void) | null = null;
|
||||
private deviceChangeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
setRoom(room: Room | null): void {
|
||||
this.room = room;
|
||||
if (room !== null) {
|
||||
this.startDeviceChangeListener();
|
||||
} else {
|
||||
this.stopDeviceChangeListener();
|
||||
}
|
||||
}
|
||||
|
||||
setAudioPipeline(pipeline: AudioPipeline | null): void {
|
||||
this.audioPipeline = pipeline;
|
||||
}
|
||||
|
||||
setOnError(cb: ((message: string) => void) | null): void {
|
||||
this.onErrorCallback = cb;
|
||||
}
|
||||
|
||||
setOnToast(cb: ((message: string) => void) | null): void {
|
||||
this.onToast = cb;
|
||||
}
|
||||
|
||||
// --- Device change detection (hot-swap) ---
|
||||
|
||||
private startDeviceChangeListener(): void {
|
||||
this.stopDeviceChangeListener();
|
||||
this.deviceChangeHandler = () => {
|
||||
// Debounce: device change events often fire in bursts
|
||||
if (this.deviceChangeTimer !== null) clearTimeout(this.deviceChangeTimer);
|
||||
this.deviceChangeTimer = setTimeout(() => {
|
||||
void this.handleDeviceChange();
|
||||
}, DEVICE_CHANGE_DEBOUNCE_MS);
|
||||
};
|
||||
navigator.mediaDevices?.addEventListener("devicechange", this.deviceChangeHandler);
|
||||
log.debug("Device change listener started");
|
||||
}
|
||||
|
||||
private stopDeviceChangeListener(): void {
|
||||
if (this.deviceChangeHandler !== null) {
|
||||
navigator.mediaDevices?.removeEventListener("devicechange", this.deviceChangeHandler);
|
||||
this.deviceChangeHandler = null;
|
||||
}
|
||||
if (this.deviceChangeTimer !== null) {
|
||||
clearTimeout(this.deviceChangeTimer);
|
||||
this.deviceChangeTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDeviceChange(): Promise<void> {
|
||||
if (this.room === null) return;
|
||||
log.info("Device change detected");
|
||||
|
||||
try {
|
||||
const devices = await Room.getLocalDevices("audioinput");
|
||||
const savedInput = loadPref<string>("audioInputDevice", "");
|
||||
|
||||
// Check if the saved input device was removed
|
||||
if (savedInput !== "" && !devices.some(d => d.deviceId === savedInput)) {
|
||||
log.warn("Saved audio input device removed — falling back to default", { savedInput });
|
||||
// Reset to default
|
||||
savePref("audioInputDevice", "");
|
||||
// Switch to default device
|
||||
try {
|
||||
await this.room.localParticipant.setMicrophoneEnabled(false);
|
||||
await this.room.localParticipant.setMicrophoneEnabled(true);
|
||||
try {
|
||||
this.audioPipeline?.setupAudioPipeline();
|
||||
} catch (pipelineErr) {
|
||||
log.warn("Audio pipeline setup failed after device fallback", pipelineErr);
|
||||
this.onToast?.("Audio pipeline error after device switch");
|
||||
}
|
||||
this.onToast?.("Audio device disconnected — switched to default");
|
||||
} catch (err) {
|
||||
log.error("Failed to fallback to default input device", err);
|
||||
this.onErrorCallback?.("No audio input device available");
|
||||
}
|
||||
}
|
||||
|
||||
// Check output device
|
||||
const outputDevices = await Room.getLocalDevices("audiooutput");
|
||||
const savedOutput = loadPref<string>("audioOutputDevice", "");
|
||||
if (savedOutput !== "" && !outputDevices.some(d => d.deviceId === savedOutput)) {
|
||||
log.warn("Saved audio output device removed — falling back to default", { savedOutput });
|
||||
savePref("audioOutputDevice", "");
|
||||
this.onToast?.("Audio output device disconnected — switched to default");
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn("Failed to enumerate devices after change", err);
|
||||
}
|
||||
}
|
||||
|
||||
async switchInputDevice(deviceId: string): Promise<void> {
|
||||
if (this.room === null) {
|
||||
log.debug("Skipping input device switch — no active voice session");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (deviceId) {
|
||||
await this.room.switchActiveDevice("audioinput", deviceId);
|
||||
} else {
|
||||
await this.room.localParticipant.setMicrophoneEnabled(false);
|
||||
await this.room.localParticipant.setMicrophoneEnabled(true);
|
||||
}
|
||||
// Rebuild audio pipeline (source track changed after device switch)
|
||||
try {
|
||||
this.audioPipeline?.setupAudioPipeline();
|
||||
} catch (pipelineErr) {
|
||||
log.warn("Audio pipeline setup failed after input device switch", pipelineErr);
|
||||
this.onToast?.("Audio pipeline error after device switch");
|
||||
}
|
||||
// Re-apply or remove RNNoise processor based on current setting
|
||||
const enhancedNS = loadPref<boolean>("enhancedNoiseSuppression", false);
|
||||
if (enhancedNS) {
|
||||
await this.audioPipeline?.applyNoiseSuppressor();
|
||||
} else {
|
||||
await this.audioPipeline?.removeNoiseSuppressor();
|
||||
}
|
||||
log.info("Switched input device", { deviceId });
|
||||
} catch (err) {
|
||||
log.error("Failed to switch input device", err);
|
||||
this.onErrorCallback?.("Failed to switch microphone");
|
||||
}
|
||||
}
|
||||
|
||||
async switchOutputDevice(deviceId: string): Promise<void> {
|
||||
if (this.room !== null) await this.room.switchActiveDevice("audiooutput", deviceId);
|
||||
log.info("Switched output device", { deviceId });
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { authStore, setAuth, clearAuth } from "@stores/auth.store";
|
||||
import { setTransientError } from "@stores/ui.store";
|
||||
import {
|
||||
setChannels,
|
||||
setRoles,
|
||||
setActiveChannel,
|
||||
addChannel,
|
||||
updateChannel,
|
||||
@@ -38,12 +39,40 @@ import {
|
||||
joinVoiceChannel,
|
||||
leaveVoiceChannel,
|
||||
} from "@stores/voice.store";
|
||||
import {
|
||||
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";
|
||||
import { ServerMessageType as S } from "./protocolTypes";
|
||||
|
||||
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;
|
||||
|
||||
@@ -57,7 +86,7 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
|
||||
// ── Auth ──────────────────────────────────────────────
|
||||
|
||||
unsubs.push(
|
||||
ws.on("auth_ok", (payload) => {
|
||||
ws.on(S.AUTH_OK, (payload) => {
|
||||
setAuth(
|
||||
authStore.getState().token ?? "",
|
||||
payload.user,
|
||||
@@ -68,7 +97,7 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("auth_error", (payload) => {
|
||||
ws.on(S.AUTH_ERROR, (payload) => {
|
||||
log.error("Auth failed", { message: payload.message });
|
||||
setTransientError(payload.message);
|
||||
clearAuth();
|
||||
@@ -78,8 +107,9 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
|
||||
// ── Ready (initial state dump) ────────────────────────
|
||||
|
||||
unsubs.push(
|
||||
ws.on("ready", (payload) => {
|
||||
ws.on(S.READY, (payload) => {
|
||||
setChannels(payload.channels);
|
||||
setRoles(payload.roles ?? []);
|
||||
setMembers(payload.members);
|
||||
setVoiceStates(payload.voice_states);
|
||||
|
||||
@@ -92,50 +122,108 @@ 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(S.DM_CHANNEL_OPEN, (payload) => {
|
||||
log.info("DM channel opened", { channelId: payload.channel_id });
|
||||
addDmChannel(mapDmPayload(payload));
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on(S.DM_CHANNEL_CLOSE, (payload) => {
|
||||
log.info("DM channel closed", { channelId: payload.channel_id });
|
||||
removeDmChannel(payload.channel_id);
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Chat Messages ─────────────────────────────────────
|
||||
|
||||
unsubs.push(
|
||||
ws.on("chat_message", (payload) => {
|
||||
ws.on(S.CHAT_MESSAGE, (payload) => {
|
||||
log.debug("chat_message received", {
|
||||
id: payload.id,
|
||||
channelId: payload.channel_id,
|
||||
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.
|
||||
// Skip during reconnection replay to avoid inflating counts — the
|
||||
// server's ready payload already contains accurate unread_count values.
|
||||
// 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 && !ws.isReplaying()) {
|
||||
incrementUnread(payload.channel_id);
|
||||
}
|
||||
|
||||
// Update DM store last message if this message belongs to a DM channel.
|
||||
// Skip unread increment for own messages, currently focused DM, and replay.
|
||||
if (isDm) {
|
||||
const isDmActive = payload.channel_id === activeId;
|
||||
if (isOwnMessage || isDmActive || ws.isReplaying()) {
|
||||
// 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);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("chat_edited", (payload) => {
|
||||
ws.on(S.CHAT_EDITED, (payload) => {
|
||||
editMessage(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("chat_deleted", (payload) => {
|
||||
ws.on(S.CHAT_DELETED, (payload) => {
|
||||
deleteMessage(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("chat_send_ok", (payload, id) => {
|
||||
ws.on(S.CHAT_SEND_OK, (payload, id) => {
|
||||
if (id) {
|
||||
confirmSend(id, payload.message_id, payload.timestamp);
|
||||
}
|
||||
@@ -145,7 +233,7 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
|
||||
// ── Reactions ───────────────────────────────────────────
|
||||
|
||||
unsubs.push(
|
||||
ws.on("reaction_update", (payload) => {
|
||||
ws.on(S.REACTION_UPDATE, (payload) => {
|
||||
const userId = authStore.getState().user?.id ?? 0;
|
||||
updateReaction(payload, userId);
|
||||
}),
|
||||
@@ -154,7 +242,7 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
|
||||
// ── Typing ────────────────────────────────────────────
|
||||
|
||||
unsubs.push(
|
||||
ws.on("typing", (payload) => {
|
||||
ws.on(S.TYPING, (payload) => {
|
||||
setTyping(payload.channel_id, payload.user_id);
|
||||
}),
|
||||
);
|
||||
@@ -162,7 +250,7 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
|
||||
// ── Presence ──────────────────────────────────────────
|
||||
|
||||
unsubs.push(
|
||||
ws.on("presence", (payload) => {
|
||||
ws.on(S.PRESENCE, (payload) => {
|
||||
updatePresence(payload.user_id, payload.status);
|
||||
}),
|
||||
);
|
||||
@@ -170,19 +258,19 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
|
||||
// ── Channels ──────────────────────────────────────────
|
||||
|
||||
unsubs.push(
|
||||
ws.on("channel_create", (payload) => {
|
||||
ws.on(S.CHANNEL_CREATE, (payload) => {
|
||||
addChannel(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("channel_update", (payload) => {
|
||||
ws.on(S.CHANNEL_UPDATE, (payload) => {
|
||||
updateChannel(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("channel_delete", (payload) => {
|
||||
ws.on(S.CHANNEL_DELETE, (payload) => {
|
||||
// If the deleted channel is the active one, redirect to the first text channel.
|
||||
const activeId = channelsStore.select((s) => s.activeChannelId);
|
||||
removeChannel(payload.id);
|
||||
@@ -201,28 +289,28 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
|
||||
// ── Members ───────────────────────────────────────────
|
||||
|
||||
unsubs.push(
|
||||
ws.on("member_join", (payload) => {
|
||||
ws.on(S.MEMBER_JOIN, (payload) => {
|
||||
log.info("Member joined", { userId: payload.user.id, username: payload.user.username });
|
||||
addMember(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("member_leave", (payload) => {
|
||||
ws.on(S.MEMBER_LEAVE, (payload) => {
|
||||
log.info("Member left", { userId: payload.user_id });
|
||||
removeMember(payload.user_id);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("member_ban", (payload) => {
|
||||
ws.on(S.MEMBER_BAN, (payload) => {
|
||||
log.info("Member banned", { userId: payload.user_id });
|
||||
removeMember(payload.user_id);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("member_update", (payload) => {
|
||||
ws.on(S.MEMBER_UPDATE, (payload) => {
|
||||
log.info("Member role updated", { userId: payload.user_id, role: payload.role });
|
||||
updateMemberRole(payload.user_id, payload.role);
|
||||
}),
|
||||
@@ -231,7 +319,7 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
|
||||
// ── Voice ─────────────────────────────────────────────
|
||||
|
||||
unsubs.push(
|
||||
ws.on("voice_state", (payload) => {
|
||||
ws.on(S.VOICE_STATE, (payload) => {
|
||||
updateVoiceState(payload);
|
||||
// Auto-join voice channel if the event is for the current user
|
||||
const currentUserId = authStore.getState().user?.id ?? 0;
|
||||
@@ -242,7 +330,7 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("voice_leave", (payload) => {
|
||||
ws.on(S.VOICE_LEAVE, (payload) => {
|
||||
removeVoiceUser(payload);
|
||||
// Clear local voice state if the current user was removed (kick/disconnect)
|
||||
const currentUserId = authStore.getState().user?.id ?? 0;
|
||||
@@ -253,19 +341,19 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("voice_config", (payload) => {
|
||||
ws.on(S.VOICE_CONFIG, (payload) => {
|
||||
setVoiceConfig(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("voice_speakers", (payload) => {
|
||||
ws.on(S.VOICE_SPEAKERS, (payload) => {
|
||||
setSpeakers(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("voice_token", (payload) => {
|
||||
ws.on(S.VOICE_TOKEN, (payload) => {
|
||||
void handleVoiceToken(payload.token, payload.url, payload.channel_id, payload.direct_url);
|
||||
}),
|
||||
);
|
||||
@@ -273,20 +361,30 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
|
||||
// ── Server Events ─────────────────────────────────────
|
||||
|
||||
unsubs.push(
|
||||
ws.on("server_restart", (payload) => {
|
||||
ws.on(S.SERVER_RESTART, (payload) => {
|
||||
log.warn("Server restarting", {
|
||||
reason: payload.reason,
|
||||
delaySeconds: payload.delay_seconds,
|
||||
});
|
||||
setTransientError(`Server is restarting: ${payload.reason ?? "maintenance"}`);
|
||||
}),
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
ws.on("error", (payload) => {
|
||||
ws.on(S.ERROR, (payload) => {
|
||||
log.error("Server error", {
|
||||
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");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -57,7 +57,10 @@ export type IconName =
|
||||
| "bell"
|
||||
| "keyboard"
|
||||
| "scroll-text"
|
||||
| "image";
|
||||
| "image"
|
||||
| "signal"
|
||||
| "log-out"
|
||||
| "zap";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SVG inner content (innerHTML) — Lucide 0.x path data
|
||||
@@ -190,6 +193,15 @@ const ICON_PATHS: Record<IconName, string> = {
|
||||
|
||||
// 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"/>`,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -226,3 +238,40 @@ export function createIcon(name: IconName, size = 24): SVGSVGElement {
|
||||
|
||||
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,213 @@
|
||||
// Log persistence — writes client logs to rotating JSONL files on disk.
|
||||
//
|
||||
// Uses Tauri's FS plugin to write to the app log directory.
|
||||
// Files: {appLogDir}/client-logs/YYYY-MM-DD.jsonl
|
||||
// Rotation: keeps the most recent MAX_LOG_FILES days of logs.
|
||||
|
||||
import { appLogDir, join } from "@tauri-apps/api/path";
|
||||
import {
|
||||
mkdir,
|
||||
writeTextFile,
|
||||
readDir,
|
||||
remove,
|
||||
exists,
|
||||
readTextFile,
|
||||
} from "@tauri-apps/plugin-fs";
|
||||
import { type LogEntry, addLogListener, createLogger } from "./logger";
|
||||
|
||||
const log = createLogger("logPersistence");
|
||||
const MAX_LOG_FILES = 5;
|
||||
const LOG_SUBDIR = "client-logs";
|
||||
|
||||
let logDir: string | null = null;
|
||||
let currentDate: string | null = null;
|
||||
let buffer: string[] = [];
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let initialized = false;
|
||||
let activeFlush: Promise<void> | null = null;
|
||||
|
||||
export async function clearPendingPersistedLogs(): Promise<void> {
|
||||
buffer = [];
|
||||
if (flushTimer !== null) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
if (activeFlush !== null) {
|
||||
await activeFlush;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get today's date as YYYY-MM-DD. */
|
||||
function today(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Resolve the full path for a given date's log file. */
|
||||
function logFilePath(dir: string, date: string): string {
|
||||
return `${dir}/${date}.jsonl`;
|
||||
}
|
||||
|
||||
/** Flush buffered log lines to disk. */
|
||||
async function flushBuffer(): Promise<void> {
|
||||
if (buffer.length === 0 || !logDir) return;
|
||||
|
||||
const date = today();
|
||||
if (date !== currentDate) {
|
||||
currentDate = date;
|
||||
await rotateOldFiles();
|
||||
}
|
||||
|
||||
const lines = buffer.join("\n") + "\n";
|
||||
buffer = [];
|
||||
|
||||
const flushPromise = (async () => {
|
||||
try {
|
||||
const filePath = logFilePath(logDir, currentDate);
|
||||
await writeTextFile(filePath, lines, { append: true });
|
||||
} catch (err) {
|
||||
// Log persistence failure shouldn't crash the app.
|
||||
log.error("flush failed", err);
|
||||
}
|
||||
})();
|
||||
|
||||
activeFlush = flushPromise;
|
||||
try {
|
||||
await flushPromise;
|
||||
} finally {
|
||||
if (activeFlush === flushPromise) {
|
||||
activeFlush = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Schedule a flush after a short debounce. */
|
||||
function scheduleFlush(): void {
|
||||
if (flushTimer !== null) return;
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
void flushBuffer();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/** Remove log files older than MAX_LOG_FILES days. */
|
||||
async function rotateOldFiles(): Promise<void> {
|
||||
if (!logDir) return;
|
||||
try {
|
||||
const entries = await readDir(logDir);
|
||||
const jsonlFiles = entries
|
||||
.filter(
|
||||
(e) =>
|
||||
e.name?.endsWith(".jsonl") && !e.isDirectory,
|
||||
)
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
|
||||
if (jsonlFiles.length > MAX_LOG_FILES) {
|
||||
const toRemove = jsonlFiles.slice(
|
||||
0,
|
||||
jsonlFiles.length - MAX_LOG_FILES,
|
||||
);
|
||||
for (const file of toRemove) {
|
||||
await remove(`${logDir}/${file}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn("rotation failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle a log entry by serializing it and buffering for disk write. */
|
||||
function onLogEntry(entry: LogEntry): void {
|
||||
if (!initialized) return;
|
||||
buffer.push(JSON.stringify(entry));
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize log persistence. Call once at app startup.
|
||||
* Sets up a listener on the logger that writes entries to disk.
|
||||
* Returns a cleanup function to remove the listener.
|
||||
*/
|
||||
export async function initLogPersistence(): Promise<() => void> {
|
||||
if (initialized) return () => {};
|
||||
|
||||
try {
|
||||
const baseDir = await appLogDir();
|
||||
logDir = await join(baseDir, LOG_SUBDIR);
|
||||
|
||||
const dirExists = await exists(logDir);
|
||||
if (!dirExists) {
|
||||
await mkdir(logDir, { recursive: true });
|
||||
}
|
||||
|
||||
currentDate = today();
|
||||
initialized = true;
|
||||
|
||||
const removeListener = addLogListener(onLogEntry);
|
||||
|
||||
return () => {
|
||||
removeListener(); // stop receiving new entries first
|
||||
initialized = false;
|
||||
if (flushTimer !== null) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
// Final flush — best-effort. Log a warning if it fails.
|
||||
flushBuffer().catch((err) => {
|
||||
log.warn("Final flush failed during cleanup", err);
|
||||
});
|
||||
};
|
||||
} catch (err) {
|
||||
log.error("init failed", err);
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force an immediate flush of any buffered log entries.
|
||||
* Best-effort — may not complete if called during window teardown
|
||||
* since Tauri IPC is async and the WebView may be destroyed first.
|
||||
*/
|
||||
export async function flushLogs(): Promise<void> {
|
||||
if (flushTimer !== null) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
await flushBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the log directory path (for use in debug bundle export).
|
||||
* Returns null if persistence hasn't been initialized.
|
||||
*/
|
||||
export function getLogDir(): string | null {
|
||||
return logDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all persisted log files and return their combined content.
|
||||
* Intended for on-demand export only (reads all files into memory).
|
||||
*/
|
||||
export async function readAllPersistedLogs(): Promise<string> {
|
||||
if (!logDir) return "";
|
||||
try {
|
||||
const entries = await readDir(logDir);
|
||||
const jsonlFiles = entries
|
||||
.filter(
|
||||
(e) =>
|
||||
e.name?.endsWith(".jsonl") && !e.isDirectory,
|
||||
)
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const file of jsonlFiles) {
|
||||
const content = await readTextFile(`${logDir}/${file}`);
|
||||
parts.push(content);
|
||||
}
|
||||
return parts.join("");
|
||||
} catch (err) {
|
||||
log.warn("readAllPersistedLogs failed", err);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -267,6 +267,13 @@ export function unobserveMedia(img: HTMLImageElement): void {
|
||||
}
|
||||
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). */
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Shared modal overlay factory.
|
||||
* Creates a modal with backdrop, optional click-outside and Escape key
|
||||
* dismissal, and clean lifecycle management via AbortController.
|
||||
*
|
||||
* CSS classes match the existing project convention:
|
||||
* - div.modal-overlay.visible (backdrop)
|
||||
* - div.modal (content container)
|
||||
*/
|
||||
|
||||
import { createElement } from "./dom";
|
||||
|
||||
export interface ModalOptions {
|
||||
/** The content element to place inside the modal container. */
|
||||
readonly content: HTMLElement;
|
||||
/** Called when the modal is closed (backdrop click, Escape, or programmatic). */
|
||||
readonly onClose?: () => void;
|
||||
/** Close when the backdrop is clicked. Default: true. */
|
||||
readonly closeOnBackdrop?: boolean;
|
||||
/** Close when the Escape key is pressed. Default: true. */
|
||||
readonly closeOnEscape?: boolean;
|
||||
/** Additional CSS class on the .modal container (e.g. "dm-member-picker-modal"). */
|
||||
readonly className?: string;
|
||||
/** Additional attributes on the overlay element (e.g. data-testid). */
|
||||
readonly overlayAttrs?: Readonly<Record<string, string>>;
|
||||
/** AbortSignal for automatic cleanup when the parent component is destroyed. */
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ModalInstance {
|
||||
/** The overlay element (outermost). */
|
||||
readonly overlay: HTMLElement;
|
||||
/** The modal container element (inner). */
|
||||
readonly modal: HTMLElement;
|
||||
/** Hide the modal (removes visible class). */
|
||||
close(): void;
|
||||
/** Remove the modal from the DOM and clean up all listeners. */
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and append a modal overlay to the given container (default: document.body).
|
||||
* Returns a ModalInstance for lifecycle control.
|
||||
*/
|
||||
export function createModal(
|
||||
options: ModalOptions,
|
||||
container: Element = document.body,
|
||||
): ModalInstance {
|
||||
const {
|
||||
content,
|
||||
onClose,
|
||||
closeOnBackdrop = true,
|
||||
closeOnEscape = true,
|
||||
className,
|
||||
overlayAttrs,
|
||||
signal,
|
||||
} = options;
|
||||
|
||||
const ac = new AbortController();
|
||||
|
||||
// Build overlay
|
||||
const overlayBaseAttrs: Record<string, string> = {
|
||||
class: "modal-overlay visible",
|
||||
};
|
||||
if (overlayAttrs !== undefined) {
|
||||
Object.assign(overlayBaseAttrs, overlayAttrs);
|
||||
}
|
||||
const overlay = createElement("div", overlayBaseAttrs);
|
||||
|
||||
// Build modal container
|
||||
const modalClass = className !== undefined
|
||||
? `modal ${className}`
|
||||
: "modal";
|
||||
const modal = createElement("div", { class: modalClass });
|
||||
modal.appendChild(content);
|
||||
overlay.appendChild(modal);
|
||||
|
||||
let closed = false;
|
||||
|
||||
function handleClose(): void {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
overlay.remove();
|
||||
ac.abort();
|
||||
if (onClose !== undefined) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
// Backdrop click
|
||||
if (closeOnBackdrop) {
|
||||
overlay.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
if (e.target === overlay) {
|
||||
handleClose();
|
||||
}
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
}
|
||||
|
||||
// Escape key
|
||||
if (closeOnEscape) {
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
handleClose();
|
||||
}
|
||||
},
|
||||
{ signal: ac.signal },
|
||||
);
|
||||
}
|
||||
|
||||
// If an external signal is provided, clean up when it aborts
|
||||
if (signal !== undefined) {
|
||||
signal.addEventListener("abort", () => {
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
overlay.remove();
|
||||
onClose?.();
|
||||
if (!ac.signal.aborted) {
|
||||
ac.abort();
|
||||
}
|
||||
}
|
||||
}, { signal: ac.signal });
|
||||
}
|
||||
|
||||
container.appendChild(overlay);
|
||||
|
||||
return {
|
||||
overlay,
|
||||
modal,
|
||||
close: handleClose,
|
||||
destroy: handleClose,
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,17 @@ export function syncOsMotionListener(enabled: boolean): void {
|
||||
ac.abort();
|
||||
ac = null;
|
||||
}
|
||||
if (!enabled) return;
|
||||
if (!enabled) {
|
||||
// Restore the user's manual reducedMotion preference from settings.
|
||||
// Value is stored via JSON.stringify by savePref, so parse it safely.
|
||||
const raw = localStorage.getItem("owncord:settings:reducedMotion");
|
||||
let manual = false;
|
||||
if (raw !== null) {
|
||||
try { manual = JSON.parse(raw) === true; } catch { /* corrupted — default false */ }
|
||||
}
|
||||
document.documentElement.classList.toggle("reduced-motion", manual);
|
||||
return;
|
||||
}
|
||||
|
||||
ac = new AbortController();
|
||||
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Preference persistence helpers.
|
||||
*
|
||||
* Moved here from `@components/settings/helpers` so that `lib/` modules can
|
||||
* depend on these utilities without importing from the component layer.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const STORAGE_PREFIX = "owncord:settings:";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preference helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function loadPref<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_PREFIX + key);
|
||||
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 (parsed === null || typeof parsed !== typeof fallback) return fallback;
|
||||
return parsed as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function savePref(key: string, value: unknown): void {
|
||||
try {
|
||||
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 } }));
|
||||
} catch {
|
||||
// localStorage may throw on quota exceeded or when storage is disabled.
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -109,10 +110,7 @@ export function createTauriBackend(): PersistenceBackend {
|
||||
return {
|
||||
async load(): Promise<StoredData | null> {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const settings = (await invoke("get_settings")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const settings = await invoke<Record<string, unknown>>("get_settings");
|
||||
const raw = settings[STORAGE_KEY];
|
||||
if (raw === undefined || raw === null) return null;
|
||||
if (isValidStoredData(raw)) return raw;
|
||||
@@ -157,6 +155,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;
|
||||
|
||||
@@ -229,16 +234,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 +307,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 +343,7 @@ export function createProfileManager(
|
||||
status: "offline",
|
||||
latencyMs: null,
|
||||
version: null,
|
||||
onlineUsers: null,
|
||||
};
|
||||
return offline;
|
||||
}
|
||||
@@ -329,6 +352,7 @@ export function createProfileManager(
|
||||
status: "checking",
|
||||
latencyMs: null,
|
||||
version: null,
|
||||
onlineUsers: null,
|
||||
});
|
||||
|
||||
const result = await pingHost(profile.host);
|
||||
@@ -345,6 +369,7 @@ export function createProfileManager(
|
||||
status: "checking",
|
||||
latencyMs: null,
|
||||
version: null,
|
||||
onlineUsers: null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Shared WebSocket protocol message type constants.
|
||||
// Generated from docs/protocol-schema.json — single source of truth for
|
||||
// both Server (Go) and Client (TypeScript).
|
||||
//
|
||||
// Usage: import { MessageType } from "@lib/protocolTypes";
|
||||
// ws.send({ type: MessageType.CHAT_SEND, payload: { ... } });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server → Client message types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ServerMessageType = {
|
||||
AUTH_OK: "auth_ok",
|
||||
AUTH_ERROR: "auth_error",
|
||||
READY: "ready",
|
||||
CHAT_MESSAGE: "chat_message",
|
||||
CHAT_SEND_OK: "chat_send_ok",
|
||||
CHAT_EDITED: "chat_edited",
|
||||
CHAT_DELETED: "chat_deleted",
|
||||
REACTION_UPDATE: "reaction_update",
|
||||
TYPING: "typing",
|
||||
PRESENCE: "presence",
|
||||
CHANNEL_CREATE: "channel_create",
|
||||
CHANNEL_UPDATE: "channel_update",
|
||||
CHANNEL_DELETE: "channel_delete",
|
||||
VOICE_STATE: "voice_state",
|
||||
VOICE_LEAVE: "voice_leave",
|
||||
VOICE_CONFIG: "voice_config",
|
||||
VOICE_TOKEN: "voice_token",
|
||||
VOICE_SPEAKERS: "voice_speakers",
|
||||
MEMBER_JOIN: "member_join",
|
||||
MEMBER_LEAVE: "member_leave",
|
||||
MEMBER_UPDATE: "member_update",
|
||||
MEMBER_BAN: "member_ban",
|
||||
SERVER_RESTART: "server_restart",
|
||||
ERROR: "error",
|
||||
// Extensions (not in protocol-schema.json but used in practice)
|
||||
PONG: "pong",
|
||||
DM_CHANNEL_OPEN: "dm_channel_open",
|
||||
DM_CHANNEL_CLOSE: "dm_channel_close",
|
||||
} as const;
|
||||
|
||||
export type ServerMessageTypeValue =
|
||||
(typeof ServerMessageType)[keyof typeof ServerMessageType];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client → Server message types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ClientMessageType = {
|
||||
AUTH: "auth",
|
||||
CHAT_SEND: "chat_send",
|
||||
CHAT_EDIT: "chat_edit",
|
||||
CHAT_DELETE: "chat_delete",
|
||||
REACTION_ADD: "reaction_add",
|
||||
REACTION_REMOVE: "reaction_remove",
|
||||
TYPING_START: "typing_start",
|
||||
CHANNEL_FOCUS: "channel_focus",
|
||||
PRESENCE_UPDATE: "presence_update",
|
||||
VOICE_JOIN: "voice_join",
|
||||
VOICE_LEAVE: "voice_leave",
|
||||
VOICE_MUTE: "voice_mute",
|
||||
VOICE_DEAFEN: "voice_deafen",
|
||||
VOICE_CAMERA: "voice_camera",
|
||||
VOICE_SCREENSHARE: "voice_screenshare",
|
||||
PING: "ping",
|
||||
// Extension (not in protocol-schema.json but used in practice)
|
||||
VOICE_TOKEN_REFRESH: "voice_token_refresh",
|
||||
} as const;
|
||||
|
||||
export type ClientMessageTypeValue =
|
||||
(typeof ClientMessageType)[keyof typeof ClientMessageType];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified MessageType — all message types in one object for convenience
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const MessageType = {
|
||||
...ServerMessageType,
|
||||
...ClientMessageType,
|
||||
} as const;
|
||||
|
||||
export type MessageTypeValue =
|
||||
(typeof MessageType)[keyof typeof MessageType];
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Stream preview — hover/focus to see a live video preview of a remote
|
||||
* participant's camera or screenshare in the voice channel sidebar.
|
||||
*
|
||||
* Lifecycle:
|
||||
* mouseenter/focusin → 300ms debounce → create <video> or placeholder
|
||||
* mouseleave/focusout/scroll → animate collapse → remove DOM → cleanup
|
||||
*
|
||||
* All timers and listeners are cleaned up via AbortSignal on sidebar teardown.
|
||||
*/
|
||||
|
||||
import { createElement } from "@lib/dom";
|
||||
import { createIcon } from "@lib/icons";
|
||||
import { getRemoteVideoStream } from "@lib/livekitSession";
|
||||
|
||||
/** Internal state tracked per voice-user-item row for cleanup. */
|
||||
interface PreviewState {
|
||||
readonly debounce: number;
|
||||
animation: number;
|
||||
trackCleanup: (() => void) | null;
|
||||
}
|
||||
|
||||
const previewTimers = new WeakMap<HTMLElement, PreviewState>();
|
||||
|
||||
/** Height the preview expands to. Set dynamically after DOM insertion. */
|
||||
/** Debounce delay before showing the preview. */
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
function clearPreviewState(row: HTMLElement): void {
|
||||
const state = previewTimers.get(row);
|
||||
if (state === undefined) return;
|
||||
clearTimeout(state.debounce);
|
||||
clearTimeout(state.animation);
|
||||
if (state.trackCleanup !== null) state.trackCleanup();
|
||||
previewTimers.delete(row);
|
||||
}
|
||||
|
||||
function removePreviewDom(row: HTMLElement): void {
|
||||
// Preview is inserted as sibling after the row, not inside it
|
||||
const existing = row.nextElementSibling;
|
||||
if (existing !== null && existing.classList.contains("vu-preview")) {
|
||||
const video = existing.querySelector("video");
|
||||
if (video !== null) video.srcObject = null;
|
||||
existing.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function showPreview(
|
||||
row: HTMLElement,
|
||||
userId: number,
|
||||
username: string,
|
||||
hasScreenshare: boolean,
|
||||
hasCamera: boolean,
|
||||
onClickJoin?: () => void,
|
||||
onClickWatch?: () => void,
|
||||
): void {
|
||||
if (!document.contains(row)) return;
|
||||
|
||||
// Try screenshare first, then camera
|
||||
let stream: MediaStream | null = null;
|
||||
let isScreen = false;
|
||||
if (hasScreenshare) {
|
||||
stream = getRemoteVideoStream(userId, "screenshare");
|
||||
if (stream !== null) isScreen = true;
|
||||
}
|
||||
if (stream === null && hasCamera) {
|
||||
stream = getRemoteVideoStream(userId, "camera");
|
||||
isScreen = false;
|
||||
}
|
||||
|
||||
const previewDiv = createElement("div", { class: "vu-preview" });
|
||||
|
||||
if (stream !== null) {
|
||||
const video = document.createElement("video");
|
||||
video.autoplay = true;
|
||||
video.playsInline = true;
|
||||
video.muted = true;
|
||||
video.className = isScreen ? "preview-screen" : "preview-camera";
|
||||
video.setAttribute("aria-label", `Stream preview for ${username}`);
|
||||
video.srcObject = stream;
|
||||
|
||||
// Handle autoplay failure — swap to placeholder
|
||||
video.play().catch(() => {
|
||||
if (!document.contains(row)) return;
|
||||
video.srcObject = null;
|
||||
previewDiv.textContent = "";
|
||||
previewDiv.appendChild(createPlaceholder(onClickJoin));
|
||||
});
|
||||
|
||||
// Track renegotiation: detect ended/mute and swap to placeholder
|
||||
const track = stream.getVideoTracks()[0];
|
||||
if (track !== undefined) {
|
||||
const onTrackDead = (): void => {
|
||||
if (!document.contains(row)) return;
|
||||
video.srcObject = null;
|
||||
previewDiv.textContent = "";
|
||||
previewDiv.appendChild(createPlaceholder(onClickJoin));
|
||||
};
|
||||
track.addEventListener("ended", onTrackDead);
|
||||
track.addEventListener("mute", onTrackDead);
|
||||
|
||||
// Store cleanup function
|
||||
const state = previewTimers.get(row);
|
||||
if (state !== undefined) {
|
||||
state.trackCleanup = () => {
|
||||
track.removeEventListener("ended", onTrackDead);
|
||||
track.removeEventListener("mute", onTrackDead);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
video.style.cursor = "pointer";
|
||||
if (onClickWatch !== undefined) {
|
||||
video.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
onClickWatch();
|
||||
});
|
||||
}
|
||||
previewDiv.appendChild(video);
|
||||
} else {
|
||||
previewDiv.appendChild(createPlaceholder(onClickJoin));
|
||||
}
|
||||
|
||||
// Screen reader announcement
|
||||
const announcement = createElement("span", {
|
||||
role: "status",
|
||||
"aria-live": "polite",
|
||||
class: "sr-only",
|
||||
}, `Showing stream preview for ${username}`);
|
||||
previewDiv.appendChild(announcement);
|
||||
|
||||
// Close when mouse leaves the preview div (but not if moving back to row)
|
||||
previewDiv.addEventListener("mouseleave", () => {
|
||||
if (row.matches(":hover")) return; // Moving back to row — keep open
|
||||
hidePreview(row);
|
||||
});
|
||||
|
||||
// Insert as sibling after the row (not inside it — row is display:flex)
|
||||
row.after(previewDiv);
|
||||
|
||||
// Animate open — measure actual content height
|
||||
requestAnimationFrame(() => {
|
||||
if (!document.contains(previewDiv)) return;
|
||||
previewDiv.style.height = `${previewDiv.scrollHeight}px`;
|
||||
});
|
||||
}
|
||||
|
||||
function createPlaceholder(onClickJoin?: () => void): HTMLElement {
|
||||
const placeholder = createElement("div", {
|
||||
class: "vu-preview-placeholder",
|
||||
role: "button",
|
||||
"aria-label": "Join channel to preview stream",
|
||||
});
|
||||
const icon = createIcon("monitor", 14);
|
||||
icon.style.color = "var(--text-faint)";
|
||||
placeholder.appendChild(icon);
|
||||
const text = createElement("span", {}, "Join to preview");
|
||||
placeholder.appendChild(text);
|
||||
if (onClickJoin !== undefined) {
|
||||
placeholder.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
onClickJoin();
|
||||
});
|
||||
}
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
function hidePreview(row: HTMLElement): void {
|
||||
const state = previewTimers.get(row);
|
||||
if (state !== undefined) {
|
||||
clearTimeout(state.debounce);
|
||||
if (state.trackCleanup !== null) {
|
||||
state.trackCleanup();
|
||||
state.trackCleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Preview is a sibling after the row
|
||||
const next = row.nextElementSibling;
|
||||
const previewDiv = (next !== null && next.classList.contains("vu-preview")) ? next as HTMLElement : null;
|
||||
if (previewDiv === null) {
|
||||
previewTimers.delete(row);
|
||||
return;
|
||||
}
|
||||
|
||||
// Animate close
|
||||
previewDiv.style.height = "0";
|
||||
const animTimer = window.setTimeout(() => {
|
||||
removePreviewDom(row);
|
||||
previewTimers.delete(row);
|
||||
}, 200);
|
||||
|
||||
if (state !== undefined) {
|
||||
state.animation = animTimer;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach stream preview behavior to a voice-user-item row.
|
||||
* Call once per row during render. Cleanup is automatic via the AbortSignal.
|
||||
*/
|
||||
export function attachStreamPreview(
|
||||
row: HTMLElement,
|
||||
userId: number,
|
||||
username: string,
|
||||
hasScreenshare: boolean,
|
||||
hasCamera: boolean,
|
||||
signal: AbortSignal,
|
||||
onClickJoin?: () => void,
|
||||
onClickWatch?: () => void,
|
||||
): void {
|
||||
const startPreview = (): void => {
|
||||
clearPreviewState(row);
|
||||
removePreviewDom(row);
|
||||
|
||||
const debounceTimer = window.setTimeout(() => {
|
||||
showPreview(row, userId, username, hasScreenshare, hasCamera, onClickJoin, onClickWatch);
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
previewTimers.set(row, {
|
||||
debounce: debounceTimer,
|
||||
animation: 0,
|
||||
trackCleanup: null,
|
||||
});
|
||||
};
|
||||
|
||||
const stopPreview = (): void => {
|
||||
hidePreview(row);
|
||||
};
|
||||
|
||||
// Delayed stop — gives the user time to move mouse to the preview div
|
||||
const stopPreviewDelayed = (): void => {
|
||||
const state = previewTimers.get(row);
|
||||
if (state !== undefined) {
|
||||
clearTimeout(state.animation);
|
||||
state.animation = window.setTimeout(() => {
|
||||
// Check if mouse is now over the preview sibling
|
||||
const preview = row.nextElementSibling;
|
||||
if (preview !== null && preview.classList.contains("vu-preview") && preview.matches(":hover")) {
|
||||
return; // Mouse moved to preview — keep it open
|
||||
}
|
||||
hidePreview(row);
|
||||
}, 150);
|
||||
} else {
|
||||
hidePreview(row);
|
||||
}
|
||||
};
|
||||
|
||||
// Mouse handlers
|
||||
row.addEventListener("mouseenter", startPreview, { signal });
|
||||
row.addEventListener("mouseleave", stopPreviewDelayed, { signal });
|
||||
|
||||
// Keyboard accessibility: focus mirrors hover
|
||||
row.addEventListener("focusin", startPreview, { signal });
|
||||
row.addEventListener("focusout", stopPreview, { signal });
|
||||
|
||||
// Cleanup on abort (sidebar teardown)
|
||||
signal.addEventListener("abort", () => {
|
||||
clearPreviewState(row);
|
||||
removePreviewDom(row);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach scroll listener to a voice-users-list container to collapse
|
||||
* any open previews when the user scrolls. WebView2 doesn't always
|
||||
* fire mouseleave on scroll.
|
||||
*/
|
||||
export function attachScrollCollapse(
|
||||
container: HTMLElement,
|
||||
signal: AbortSignal,
|
||||
): void {
|
||||
container.addEventListener("scroll", () => {
|
||||
const openPreviews = container.querySelectorAll<HTMLElement>(".vu-preview");
|
||||
for (const preview of openPreviews) {
|
||||
// Preview is a sibling after the row — get the preceding voice-user-item
|
||||
const row = preview.previousElementSibling;
|
||||
if (row !== null && row.classList.contains("voice-user-item")) {
|
||||
hidePreview(row as HTMLElement);
|
||||
}
|
||||
}
|
||||
}, { signal, passive: true });
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 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_LEGACY = "owncord:settings:theme";
|
||||
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"];
|
||||
|
||||
function isKnownThemeName(name: string): boolean {
|
||||
return BUILT_IN_THEMES.includes(name) || loadCustomTheme(name) !== null;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
// Reject any value containing ( or ) — no CSS functions allowed.
|
||||
// Also reject { and } to block any injection attempts.
|
||||
if (/[(){}]/.test(value)) continue;
|
||||
// Allowlist: only permit characters found in typical CSS color/sizing values.
|
||||
// No parentheses — colors must use #hex format, not rgb()/hsl().
|
||||
if (!/^[\w\s#.,%+\-/]+$/.test(value)) continue;
|
||||
// Deny-list: block dangerous CSS keywords that slip through the allowlist.
|
||||
if (/\b(url|expression|import|image|cross-fade|element)\b/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 {
|
||||
const active = localStorage.getItem(STORAGE_KEY_ACTIVE);
|
||||
if (active !== null && active.length > 0 && isKnownThemeName(active)) {
|
||||
return active;
|
||||
}
|
||||
|
||||
if (active !== null) {
|
||||
localStorage.removeItem(STORAGE_KEY_ACTIVE);
|
||||
}
|
||||
|
||||
try {
|
||||
const legacyRaw = localStorage.getItem(STORAGE_KEY_LEGACY);
|
||||
if (legacyRaw !== null) {
|
||||
const legacyName: unknown = JSON.parse(legacyRaw);
|
||||
if (
|
||||
typeof legacyName === "string" &&
|
||||
legacyName.length > 0 &&
|
||||
isKnownThemeName(legacyName)
|
||||
) {
|
||||
localStorage.setItem(STORAGE_KEY_ACTIVE, legacyName);
|
||||
return legacyName;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore corrupted legacy settings and fall back to the default theme.
|
||||
}
|
||||
|
||||
return "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 {
|
||||
const wasActive = getActiveThemeName() === name;
|
||||
localStorage.removeItem(STORAGE_KEY_CUSTOM_PREFIX + name);
|
||||
if (wasActive) {
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Global toast helper — eliminates verbose `toast?.show()` plumbing.
|
||||
*
|
||||
* Call `initToast(container)` once at app startup (MainPage mount).
|
||||
* Then import `showToast` anywhere to display notifications.
|
||||
*/
|
||||
|
||||
import type { ToastContainer, ToastType } from "@components/Toast";
|
||||
|
||||
let instance: ToastContainer | null = null;
|
||||
|
||||
/**
|
||||
* Register the app-wide ToastContainer. Called once during MainPage mount.
|
||||
* Subsequent calls replace the previous instance (for hot-reload safety).
|
||||
*/
|
||||
export function initToast(container: ToastContainer): void {
|
||||
instance = container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the registered instance (called on MainPage destroy).
|
||||
*/
|
||||
export function teardownToast(): void {
|
||||
instance = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a toast notification globally. No-ops silently if the toast
|
||||
* container has not been initialized yet.
|
||||
*/
|
||||
export function showToast(
|
||||
message: string,
|
||||
type: ToastType = "info",
|
||||
durationMs?: number,
|
||||
): void {
|
||||
instance?.show(message, type, durationMs);
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
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";
|
||||
@@ -22,6 +22,7 @@ export type ReactionAction = "add" | "remove";
|
||||
|
||||
/** WebSocket error codes returned by the server. */
|
||||
export type WsErrorCode =
|
||||
| "BANNED"
|
||||
| "FORBIDDEN"
|
||||
| "NOT_FOUND"
|
||||
| "RATE_LIMITED"
|
||||
@@ -57,6 +58,7 @@ export interface MessageUser {
|
||||
/** User object with role, used in auth_ok and member_join. */
|
||||
export interface UserWithRole extends MessageUser {
|
||||
readonly role: string;
|
||||
readonly totp_enabled?: boolean;
|
||||
}
|
||||
|
||||
/** Attachment on a chat message. */
|
||||
@@ -173,6 +175,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 {
|
||||
@@ -297,6 +300,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;
|
||||
@@ -408,6 +446,8 @@ export type ServerMessage =
|
||||
| (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" });
|
||||
|
||||
@@ -456,6 +496,7 @@ export interface HealthResponse {
|
||||
readonly status: string;
|
||||
readonly version: string;
|
||||
readonly uptime: number;
|
||||
readonly online_users: number;
|
||||
}
|
||||
|
||||
/** Single channel object from REST API. */
|
||||
@@ -566,6 +607,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;
|
||||
|
||||
@@ -80,6 +80,11 @@ export function createWsClient() {
|
||||
let proxyOpen = false;
|
||||
let lastSeq = 0;
|
||||
|
||||
// Deduplication cache for reconnection replay.
|
||||
// Active when reconnecting (reconnectAttempt > 0) until auth_ok.
|
||||
let replayDedup: Set<string> | null = null;
|
||||
const MAX_DEDUP_SIZE = 1000;
|
||||
|
||||
// Tauri event unsubscribe functions
|
||||
const eventUnsubs: Array<() => void> = [];
|
||||
|
||||
@@ -133,11 +138,16 @@ export function createWsClient() {
|
||||
function scheduleReconnect(): void {
|
||||
if (intentionalClose || certMismatchBlock || !config) return;
|
||||
const delay = getReconnectDelay();
|
||||
log.info(`Reconnecting in ${delay}ms (attempt ${reconnectAttempt + 1})`);
|
||||
log.info("WebSocket reconnecting", {
|
||||
delayMs: delay,
|
||||
attempt: reconnectAttempt + 1,
|
||||
host: config?.host ?? "unknown",
|
||||
lastSeq,
|
||||
});
|
||||
setState("reconnecting");
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectAttempt++;
|
||||
connect(config!);
|
||||
void connect(config!);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
@@ -182,6 +192,21 @@ export function createWsClient() {
|
||||
|
||||
log.debug("WS ←", { type: msg.type, id: msg.id });
|
||||
|
||||
// Deduplication during reconnection replay
|
||||
if (replayDedup !== null && msg.type !== "auth_ok" && msg.type !== "auth_error" && msg.type !== "ready") {
|
||||
const dedupKey = msg.id ?? `${msg.type}:${seq}`;
|
||||
if (replayDedup.has(dedupKey)) {
|
||||
log.debug("Dedup: skipping duplicate message", { type: msg.type, key: dedupKey });
|
||||
return;
|
||||
}
|
||||
replayDedup.add(dedupKey);
|
||||
// Evict oldest entries if set is too large
|
||||
if (replayDedup.size > MAX_DEDUP_SIZE) {
|
||||
const first = replayDedup.values().next().value;
|
||||
if (first !== undefined) replayDedup.delete(first);
|
||||
}
|
||||
}
|
||||
|
||||
// auth_error — non-recoverable
|
||||
if (msg.type === "auth_error") {
|
||||
log.error("Authentication failed", { message: msg.payload.message });
|
||||
@@ -194,6 +219,15 @@ export function createWsClient() {
|
||||
|
||||
// auth_ok — mark as connected
|
||||
if (msg.type === "auth_ok") {
|
||||
if (reconnectAttempt > 0) {
|
||||
log.info("WebSocket reconnected successfully", {
|
||||
afterAttempts: reconnectAttempt,
|
||||
host: config?.host ?? "unknown",
|
||||
lastSeq,
|
||||
});
|
||||
}
|
||||
// Clear dedup cache — replay is complete
|
||||
replayDedup = null;
|
||||
setState("connected");
|
||||
reconnectAttempt = 0;
|
||||
startHeartbeat();
|
||||
@@ -210,8 +244,8 @@ export function createWsClient() {
|
||||
}
|
||||
for (const listener of typeListeners) {
|
||||
try {
|
||||
(listener as WsListener<typeof msg.type>)(
|
||||
msg.payload as Extract<ServerMessage, { type: typeof msg.type }>["payload"],
|
||||
(listener)(
|
||||
msg.payload,
|
||||
msg.id,
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -236,12 +270,25 @@ export function createWsClient() {
|
||||
|
||||
if (rustState === "open") {
|
||||
proxyOpen = true;
|
||||
log.info("WebSocket open, sending auth");
|
||||
log.info("WebSocket open, sending auth", {
|
||||
host: config?.host ?? "unknown",
|
||||
isReconnect: reconnectAttempt > 0,
|
||||
lastSeq,
|
||||
});
|
||||
// Enable dedup during reconnection replay
|
||||
if (reconnectAttempt > 0 && lastSeq > 0) {
|
||||
replayDedup = new Set();
|
||||
}
|
||||
setState("authenticating");
|
||||
send({ type: "auth", payload: { token: config!.token, last_seq: lastSeq } });
|
||||
if (config === null) return;
|
||||
send({ type: "auth", payload: { token: config.token, last_seq: lastSeq } });
|
||||
} else if (rustState === "closed") {
|
||||
proxyOpen = false;
|
||||
log.info("WebSocket closed (proxy)");
|
||||
log.info("WebSocket closed", {
|
||||
host: config?.host ?? "unknown",
|
||||
intentional: intentionalClose,
|
||||
certBlocked: certMismatchBlock,
|
||||
});
|
||||
stopHeartbeat();
|
||||
if (!intentionalClose) {
|
||||
scheduleReconnect();
|
||||
@@ -314,7 +361,11 @@ export function createWsClient() {
|
||||
}
|
||||
|
||||
const wsUrl = `wss://${cfg.host}/api/v1/ws`;
|
||||
log.info("Connecting to", { url: wsUrl });
|
||||
log.info("WebSocket connecting", {
|
||||
url: wsUrl,
|
||||
isReconnect: reconnectAttempt > 0,
|
||||
attempt: reconnectAttempt,
|
||||
});
|
||||
|
||||
// Set up event listeners before connecting
|
||||
cleanupEventListeners();
|
||||
@@ -364,13 +415,17 @@ export function createWsClient() {
|
||||
|
||||
function disconnect(): void {
|
||||
intentionalClose = true;
|
||||
log.info("WebSocket disconnecting (intentional)", { host: config?.host ?? "unknown" });
|
||||
certMismatchBlock = false;
|
||||
lastSeq = 0;
|
||||
cancelReconnect();
|
||||
stopHeartbeat();
|
||||
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 {
|
||||
@@ -428,6 +483,11 @@ export function createWsClient() {
|
||||
return state;
|
||||
},
|
||||
|
||||
/** True while processing reconnection replay messages (dedup active). */
|
||||
isReplaying(): boolean {
|
||||
return replayDedup !== null;
|
||||
},
|
||||
|
||||
/** @internal for testing */
|
||||
_getWs(): WebSocket | null {
|
||||
return null;
|
||||
|
||||
@@ -4,23 +4,27 @@ 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";
|
||||
import { createApiClient } from "@lib/api";
|
||||
import { createWsClient } from "@lib/ws";
|
||||
import { wireDispatcher } from "@lib/dispatcher";
|
||||
import { authStore, setAuth, clearAuth } from "@stores/auth.store";
|
||||
import { authStore, clearAuth } from "@stores/auth.store";
|
||||
import { setTransientError } from "@stores/ui.store";
|
||||
import { voiceStore, leaveVoiceChannel } from "@stores/voice.store";
|
||||
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 { initLogPersistence, flushLogs } from "@lib/logPersistence";
|
||||
import { saveCredential, loadCredential, deleteCredential } from "@lib/credentials";
|
||||
import { initWindowState } from "@lib/window-state";
|
||||
import { createCertMismatchModal } from "@components/CertMismatchModal";
|
||||
import { createProfileManager, createTauriBackend } from "@lib/profiles";
|
||||
@@ -47,10 +51,10 @@ document.addEventListener("keydown", (e) => {
|
||||
|
||||
// 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;
|
||||
const link = (e.target as HTMLElement).closest("a[target='_blank']");
|
||||
if (link === null) return;
|
||||
e.preventDefault();
|
||||
const href = link.href;
|
||||
const href = (link as HTMLAnchorElement).href;
|
||||
if (href && (href.startsWith("http://") || href.startsWith("https://"))) {
|
||||
void openUrl(href);
|
||||
}
|
||||
@@ -62,6 +66,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();
|
||||
|
||||
@@ -123,7 +130,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) {
|
||||
@@ -133,6 +140,7 @@ function runHealthChecks(
|
||||
status: "checking",
|
||||
latencyMs: null,
|
||||
version: null,
|
||||
onlineUsers: null,
|
||||
});
|
||||
const start = performance.now();
|
||||
const health = await api.getHealth(profile.host, 3000);
|
||||
@@ -141,12 +149,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,
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -173,8 +183,15 @@ function renderPage(pageId: "connect" | "main"): void {
|
||||
dispatcherCleanup = wireDispatcher(ws);
|
||||
log.info("Dispatcher wired, connecting WS");
|
||||
|
||||
// Save credential for auto-reconnect (fire-and-forget)
|
||||
void saveCredential(host, username, token, password);
|
||||
// Save credential for auto-reconnect. Warn user if it fails.
|
||||
saveCredential(host, username, token, password).then((ok) => {
|
||||
if (!ok) {
|
||||
log.warn("Credential save failed — auto-login will not work for this server");
|
||||
setTransientError("Could not save credentials — auto-login won't work");
|
||||
}
|
||||
}).catch(() => {
|
||||
// saveCredential already catches internally; this is defence-in-depth
|
||||
});
|
||||
|
||||
const unsubState = ws.onStateChange((wsState) => {
|
||||
log.debug("WS state change", { state: wsState });
|
||||
@@ -217,11 +234,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({
|
||||
@@ -229,7 +246,7 @@ function renderPage(pageId: "connect" | "main"): void {
|
||||
host,
|
||||
username,
|
||||
autoConnect: false,
|
||||
rememberPassword: false,
|
||||
rememberPassword,
|
||||
color: "#5865F2",
|
||||
});
|
||||
profileManager.setLastConnected(created.id);
|
||||
@@ -249,16 +266,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) {
|
||||
@@ -268,8 +287,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);
|
||||
}
|
||||
},
|
||||
@@ -292,10 +312,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 () => {
|
||||
@@ -308,6 +350,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 });
|
||||
@@ -353,6 +447,8 @@ window.addEventListener("beforeunload", () => {
|
||||
voiceSessionLeave(false); // false: we send voice_leave below
|
||||
ws.send({ type: "voice_leave", payload: {} });
|
||||
}
|
||||
// Flush any buffered log entries to disk before the window closes.
|
||||
void flushLogs();
|
||||
});
|
||||
|
||||
// Initial render
|
||||
@@ -361,4 +457,7 @@ renderPage(router.getCurrentPage());
|
||||
// Initialize window state persistence (fire-and-forget)
|
||||
void initWindowState();
|
||||
|
||||
// Initialize log persistence to disk (fire-and-forget)
|
||||
void initLogPersistence();
|
||||
|
||||
log.info("OwnCord client initialized");
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createSettingsOverlay } from "@components/SettingsOverlay";
|
||||
import type { HealthStatus } from "@lib/profiles";
|
||||
import { createServerPanel } from "./connect-page/ServerPanel";
|
||||
import { createLoginForm } from "./connect-page/LoginForm";
|
||||
import { loadCredential } from "@lib/credentials";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Re-exports (public API must not change)
|
||||
@@ -30,6 +31,8 @@ export interface ConnectPageCallbacks {
|
||||
onTotpSubmit(code: string): Promise<void>;
|
||||
onAddProfile?(name: string, host: string): void;
|
||||
onDeleteProfile?(profileId: string): void;
|
||||
onToggleAutoLogin?(profileId: string, enabled: boolean): void;
|
||||
onAutoLoginCancel?(): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -50,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;
|
||||
@@ -57,6 +61,8 @@ 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;
|
||||
} {
|
||||
let container: Element | null = null;
|
||||
let root: HTMLDivElement;
|
||||
@@ -73,6 +79,7 @@ export function createConnectPage(
|
||||
onRegister: callbacks.onRegister,
|
||||
onTotpSubmit: callbacks.onTotpSubmit,
|
||||
onSettingsOpen: () => openSettings(),
|
||||
onAutoLoginCancel: callbacks.onAutoLoginCancel,
|
||||
});
|
||||
|
||||
const serverPanel = createServerPanel(
|
||||
@@ -92,6 +99,7 @@ export function createConnectPage(
|
||||
},
|
||||
onAddProfile: callbacks.onAddProfile,
|
||||
onDeleteProfile: callbacks.onDeleteProfile,
|
||||
onToggleAutoLogin: callbacks.onToggleAutoLogin,
|
||||
},
|
||||
initialProfiles,
|
||||
);
|
||||
@@ -103,6 +111,84 @@ export function createConnectPage(
|
||||
function buildRoot(): HTMLDivElement {
|
||||
root = createElement("div", { class: "connect-page" });
|
||||
|
||||
// OC Logo branding — prepended to server panel
|
||||
const branding = createElement("div", { class: "server-branding" });
|
||||
|
||||
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");
|
||||
|
||||
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);
|
||||
|
||||
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
|
||||
@@ -111,6 +197,9 @@ export function createConnectPage(
|
||||
// TOTP overlay
|
||||
root.appendChild(loginForm.totpOverlayElement);
|
||||
|
||||
// Auto-connect overlay
|
||||
root.appendChild(loginForm.autoConnectOverlayElement);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -125,13 +214,18 @@ export function createConnectPage(
|
||||
const rootEl = buildRoot();
|
||||
container.appendChild(rootEl);
|
||||
|
||||
// Mount settings overlay on the connect page
|
||||
// Mount settings overlay on the connect page (unauthenticated — account actions are no-ops)
|
||||
settingsOverlay = createSettingsOverlay({
|
||||
isAuthenticated: false,
|
||||
onClose: () => closeSettings(),
|
||||
onChangePassword: async () => { /* no-op on connect page */ },
|
||||
onUpdateProfile: async () => { /* no-op on connect page */ },
|
||||
onLogout: () => { /* no-op on connect page */ },
|
||||
onStatusChange: () => { /* no-op on connect page */ },
|
||||
onChangePassword: () => Promise.resolve(),
|
||||
onUpdateProfile: () => Promise.resolve(),
|
||||
onLogout: () => {},
|
||||
onDeleteAccount: () => Promise.resolve(),
|
||||
onStatusChange: () => {},
|
||||
onEnableTotp: () => Promise.reject(new Error("Not authenticated")),
|
||||
onConfirmTotp: () => Promise.reject(new Error("Not authenticated")),
|
||||
onDisableTotp: () => Promise.reject(new Error("Not authenticated")),
|
||||
});
|
||||
settingsOverlay.mount(rootEl);
|
||||
|
||||
@@ -163,6 +257,7 @@ export function createConnectPage(
|
||||
destroy,
|
||||
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) =>
|
||||
@@ -172,6 +267,23 @@ export function createConnectPage(
|
||||
refreshProfiles(profiles: readonly SimpleProfile[]): void {
|
||||
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
|
||||
}
|
||||
})();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,13 +14,14 @@ import type { ServerBannerControl } from "@components/ServerBanner";
|
||||
import { createSettingsOverlay } from "@components/SettingsOverlay";
|
||||
import { createToastContainer } from "@components/Toast";
|
||||
import type { ToastContainer } from "@components/Toast";
|
||||
import { initToast, teardownToast, showToast } from "@lib/toast";
|
||||
import { authStore, clearAuth, updateUser } from "@stores/auth.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 {
|
||||
leaveVoice as voiceSessionLeave,
|
||||
cleanupAll as voiceCleanupAll,
|
||||
setOnRemoteVideo,
|
||||
setOnRemoteVideoRemoved,
|
||||
@@ -28,7 +29,6 @@ import {
|
||||
setWsClient,
|
||||
setServerHost as setLiveKitServerHost,
|
||||
setOnError as setVoiceOnError,
|
||||
clearOnError as clearVoiceOnError,
|
||||
} from "@lib/livekitSession";
|
||||
import { setServerHost } from "@components/message-list/renderers";
|
||||
import { createQuickSwitcherManager } from "./main-page/OverlayManagers";
|
||||
@@ -46,6 +46,7 @@ import type { ChannelController } from "./main-page/ChannelController";
|
||||
import { createUpdateNotifier } from "@components/UpdateNotifier";
|
||||
import { createSidebarArea } from "./main-page/SidebarArea";
|
||||
import { createChatArea } from "./main-page/ChatArea";
|
||||
import { SCREENSHARE_TILE_ID_OFFSET } from "@lib/constants";
|
||||
|
||||
const log = createLogger("main-page");
|
||||
|
||||
@@ -110,6 +111,15 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
return authStore.getState().user?.id ?? 0;
|
||||
}
|
||||
|
||||
/** 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -163,11 +173,16 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
limiters,
|
||||
getRoot: () => root,
|
||||
getToast: () => toast,
|
||||
onWatchStream: (userId) => {
|
||||
if (videoModeCtrl === null) return;
|
||||
videoModeCtrl.showVideoGrid();
|
||||
videoModeCtrl.setFocus(userId);
|
||||
},
|
||||
});
|
||||
children.push(...sidebar.children);
|
||||
unsubscribers.push(...sidebar.unsubscribers);
|
||||
|
||||
// --- Chat area + member list ---
|
||||
// --- Chat area ---
|
||||
const chatAreaResult = createChatArea({
|
||||
api,
|
||||
getRoot: () => root,
|
||||
@@ -187,10 +202,8 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
|
||||
appendChildren(
|
||||
app,
|
||||
sidebar.serverStripSlot,
|
||||
sidebar.sidebarWrapper,
|
||||
chatAreaResult.chatArea,
|
||||
chatAreaResult.memberListSlot,
|
||||
);
|
||||
root.appendChild(app);
|
||||
|
||||
@@ -200,10 +213,10 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
onChangePassword: async (oldPassword, newPassword) => {
|
||||
try {
|
||||
await api.changePassword(oldPassword, newPassword);
|
||||
toast?.show("Password changed successfully", "success");
|
||||
showToast("Password changed successfully", "success");
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to change password";
|
||||
toast?.show(msg, "error");
|
||||
showToast(msg, "error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
@@ -211,20 +224,58 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
try {
|
||||
const updated = await api.updateProfile({ username });
|
||||
updateUser({ username: updated.username });
|
||||
toast?.show("Profile updated", "success");
|
||||
showToast("Profile updated", "success");
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to update profile";
|
||||
toast?.show(msg, "error");
|
||||
showToast(msg, "error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onLogout: () => clearAuth(),
|
||||
onDeleteAccount: async (password) => {
|
||||
await api.deleteAccount(password);
|
||||
clearAuth();
|
||||
showToast("Account deleted successfully", "success");
|
||||
},
|
||||
onEnableTotp: async (password) => {
|
||||
try {
|
||||
return await api.enableTotp(password);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to enable 2FA";
|
||||
showToast(msg, "error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onConfirmTotp: async (password, code) => {
|
||||
try {
|
||||
await api.confirmTotp(password, code);
|
||||
updateUser({ totp_enabled: true });
|
||||
showToast("Two-factor authentication enabled", "success");
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to confirm 2FA";
|
||||
showToast(msg, "error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onDisableTotp: async (password) => {
|
||||
try {
|
||||
await api.disableTotp(password);
|
||||
updateUser({ totp_enabled: false });
|
||||
showToast("Two-factor authentication disabled", "success");
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to disable 2FA";
|
||||
showToast(msg, "error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onStatusChange: (status) => {
|
||||
const userId = getCurrentUserId();
|
||||
if (userId !== 0) {
|
||||
updatePresence(userId, status);
|
||||
}
|
||||
ws.send({ type: "presence_update", payload: { status } });
|
||||
if (limiters.presence.tryConsume()) {
|
||||
ws.send({ type: "presence_update", payload: { status } });
|
||||
}
|
||||
},
|
||||
});
|
||||
settingsOverlay.mount(root);
|
||||
@@ -238,11 +289,12 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
toast = createToastContainer();
|
||||
toast.mount(root);
|
||||
children.push(toast);
|
||||
initToast(toast);
|
||||
|
||||
// Message loading controller
|
||||
msgCtrl = createMessageController({
|
||||
api,
|
||||
showError: (msg) => toast?.show(msg, "error"),
|
||||
showError: (msg) => showToast(msg, "error"),
|
||||
});
|
||||
|
||||
// Reaction controller
|
||||
@@ -250,18 +302,18 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
ws,
|
||||
reactionsLimiter: limiters.reactions,
|
||||
getChannelId: () => channelCtrl?.currentChannelId ?? 0,
|
||||
showError: (msg) => toast?.show(msg, "error"),
|
||||
showError: (msg) => showToast(msg, "error"),
|
||||
});
|
||||
|
||||
// Channel controller (mount/destroy MessageList, TypingIndicator, MessageInput per channel)
|
||||
channelCtrl = createChannelController({
|
||||
ws,
|
||||
api,
|
||||
msgCtrl: msgCtrl!,
|
||||
msgCtrl: msgCtrl,
|
||||
pendingDeleteManager,
|
||||
reactionCtrl: reactionCtrl!,
|
||||
reactionCtrl: reactionCtrl,
|
||||
typingLimiter: limiters.typing,
|
||||
showToast: (msg, type) => toast?.show(msg, type as "success" | "error" | "info"),
|
||||
showToast: (msg, type) => showToast(msg, type as "success" | "error" | "info"),
|
||||
getCurrentUserId,
|
||||
slots: {
|
||||
messagesSlot: chatAreaResult.slots.messagesSlot,
|
||||
@@ -269,48 +321,56 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
inputSlot: chatAreaResult.slots.inputSlot,
|
||||
},
|
||||
chatHeaderName: chatAreaResult.chatHeaderName,
|
||||
chatHeaderRefs: chatAreaResult.chatHeaderRefs,
|
||||
});
|
||||
|
||||
// Wire voice error callback to toast
|
||||
setVoiceOnError((msg) => toast?.show(msg, "error"));
|
||||
setVoiceOnError((msg) => showToast(msg, "error"));
|
||||
|
||||
// Wire remote video callbacks to video grid
|
||||
setOnRemoteVideo((userId, stream) => {
|
||||
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 only (not speaking ticks)
|
||||
let prevLocalCamera = voiceStore.getState().localCamera;
|
||||
let prevCameraSignature = "";
|
||||
// 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 camera-relevant state
|
||||
let sig = state.localCamera ? "1" : "0";
|
||||
// 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 += `:${uid}`;
|
||||
if (u.camera) sig += `:c${uid}`;
|
||||
if (u.screenshare) sig += `:s${uid}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sig !== prevCameraSignature || state.localCamera !== prevLocalCamera) {
|
||||
prevCameraSignature = sig;
|
||||
prevLocalCamera = state.localCamera;
|
||||
if (sig !== prevVideoSignature) {
|
||||
prevVideoSignature = sig;
|
||||
videoModeCtrl?.checkVideoMode();
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -332,9 +392,16 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
const unsubChannels = channelsStore.subscribeSelector(
|
||||
(s) => s.activeChannelId,
|
||||
() => {
|
||||
const active = getActiveChannel();
|
||||
if (active !== null) {
|
||||
channelCtrl!.mountChannel(active.id, active.name);
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -342,13 +409,14 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
|
||||
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");
|
||||
try {
|
||||
teardownToast();
|
||||
// Full voice cleanup — tears down room, callbacks, ws ref, serverHost.
|
||||
// Prevents stale module-level state persisting across logout/reconnect cycles.
|
||||
voiceCleanupAll();
|
||||
|
||||
@@ -14,7 +14,7 @@ import { createIcon } from "@lib/icons";
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Form state machine states. */
|
||||
export type FormState = "idle" | "loading" | "totp" | "connecting" | "error";
|
||||
export type FormState = "idle" | "loading" | "totp" | "connecting" | "error" | "auto-connecting";
|
||||
|
||||
/** Form mode: login or register. */
|
||||
export type FormMode = "login" | "register";
|
||||
@@ -40,6 +40,7 @@ export interface LoginFormOptions {
|
||||
) => Promise<void>;
|
||||
readonly onTotpSubmit: (code: string) => Promise<void>;
|
||||
readonly onSettingsOpen: () => void;
|
||||
readonly onAutoLoginCancel?: () => void;
|
||||
}
|
||||
|
||||
export interface LoginFormApi {
|
||||
@@ -49,8 +50,11 @@ export interface LoginFormApi {
|
||||
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;
|
||||
@@ -71,7 +75,7 @@ export interface LoginFormApi {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
|
||||
const { signal, onLogin, onRegister, onTotpSubmit, onSettingsOpen } = opts;
|
||||
const { signal, onLogin, onRegister, onTotpSubmit, onSettingsOpen, onAutoLoginCancel } = opts;
|
||||
|
||||
// --- internal state ---
|
||||
let formState: FormState = "idle";
|
||||
@@ -89,12 +93,10 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
|
||||
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 autoConnectServerName: HTMLSpanElement;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM construction
|
||||
@@ -116,12 +118,48 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
|
||||
// Form container
|
||||
const formContainer = createElement("div", { class: "form-container" });
|
||||
|
||||
// Logo section
|
||||
// Logo section — OC neon glow SVG
|
||||
const formLogo = createElement("div", { class: "form-logo" });
|
||||
const logoMark = createElement("div", { class: "form-logo-mark" }, "OC");
|
||||
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, logoMark, logoTitle, logoSubtitle);
|
||||
appendChildren(formLogo, logoSvg, logoTitle, logoSubtitle);
|
||||
|
||||
// Form title
|
||||
formTitle = createElement("h1", {}, "Login");
|
||||
@@ -138,15 +176,15 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
|
||||
|
||||
// Host
|
||||
const hostGroup = buildFormGroup("host", "Server Address", "text", "localhost:8443");
|
||||
hostInput = qs("input", hostGroup) as HTMLInputElement;
|
||||
hostInput = qs("input", hostGroup)!;
|
||||
|
||||
// Username
|
||||
const usernameGroup = buildFormGroup("username", "Username", "text", "");
|
||||
usernameInput = qs("input", usernameGroup) as HTMLInputElement;
|
||||
usernameInput = qs("input", usernameGroup)!;
|
||||
|
||||
// Password
|
||||
const passwordGroup = buildFormGroup("password", "Password", "password", "");
|
||||
passwordInput = qs("input", passwordGroup) as HTMLInputElement;
|
||||
passwordInput = qs("input", passwordGroup)!;
|
||||
|
||||
// Remember password checkbox
|
||||
const rememberGroup = createElement("div", { class: "form-group remember-password-group" });
|
||||
@@ -163,7 +201,7 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
|
||||
// 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;
|
||||
inviteInput = qs("input", inviteGroup)!;
|
||||
|
||||
// Submit button
|
||||
submitBtn = createElement("button", {
|
||||
@@ -178,7 +216,7 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
|
||||
|
||||
// Toggle mode link
|
||||
const formSwitch = createElement("div", { class: "form-switch" });
|
||||
toggleModeBtn = createElement("a", {}, "Need an account? Register") as HTMLAnchorElement;
|
||||
toggleModeBtn = createElement("a", {}, "Need an account? Register");
|
||||
formSwitch.appendChild(toggleModeBtn);
|
||||
|
||||
appendChildren(form, hostGroup, usernameGroup, passwordGroup, rememberGroup, inviteGroup, submitBtn, formSwitch);
|
||||
@@ -279,7 +317,7 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
|
||||
(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleTotpSubmit();
|
||||
void handleTotpSubmit();
|
||||
}
|
||||
},
|
||||
{ signal },
|
||||
@@ -290,6 +328,49 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build elements (before state transition functions that reference them)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const panelEl = buildFormPanel();
|
||||
|
||||
// Status bar (hidden by default, shown with .visible class)
|
||||
const statusBar = createElement("div", { class: "status-bar" });
|
||||
const statusBarFill = createElement("div", { class: "status-bar-fill" });
|
||||
statusBar.appendChild(statusBarFill);
|
||||
|
||||
// TOTP overlay (hidden by default)
|
||||
const totpOverlay = buildTotpOverlay();
|
||||
|
||||
// Auto-connect overlay (hidden by default)
|
||||
const autoConnectOverlay = buildAutoConnectOverlay();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State transitions
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -303,15 +384,16 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
|
||||
updateErrorBanner();
|
||||
updateStatusBar();
|
||||
updateTotpOverlay();
|
||||
updateAutoConnectOverlay();
|
||||
updateFormInputsDisabled();
|
||||
}
|
||||
|
||||
function updateSubmitButton(): void {
|
||||
const isLoading = formState === "loading" || formState === "connecting";
|
||||
const isLoading = formState === "loading" || formState === "connecting" || formState === "auto-connecting";
|
||||
submitBtn.disabled = isLoading;
|
||||
submitBtn.classList.toggle("loading", isLoading);
|
||||
|
||||
if (formState === "connecting") {
|
||||
if (formState === "connecting" || formState === "auto-connecting") {
|
||||
setText(submitBtnText, "Connecting\u2026");
|
||||
} else if (formState === "loading") {
|
||||
setText(submitBtnText, formMode === "login" ? "Logging in\u2026" : "Registering\u2026");
|
||||
@@ -338,20 +420,15 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
|
||||
function updateStatusBar(): void {
|
||||
switch (formState) {
|
||||
case "idle":
|
||||
case "totp":
|
||||
case "error":
|
||||
statusBar.classList.remove("visible", "indeterminate");
|
||||
break;
|
||||
case "loading":
|
||||
statusBar.classList.add("visible", "indeterminate");
|
||||
break;
|
||||
case "totp":
|
||||
statusBar.classList.remove("visible", "indeterminate");
|
||||
break;
|
||||
case "connecting":
|
||||
case "auto-connecting":
|
||||
statusBar.classList.add("visible", "indeterminate");
|
||||
break;
|
||||
case "error":
|
||||
statusBar.classList.remove("visible", "indeterminate");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,8 +442,16 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
const disable = formState === "loading" || formState === "connecting" || formState === "auto-connecting";
|
||||
hostInput.disabled = disable;
|
||||
usernameInput.disabled = disable;
|
||||
passwordInput.disabled = disable;
|
||||
@@ -491,20 +576,6 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
|
||||
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();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -513,6 +584,7 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
|
||||
element: panelEl,
|
||||
statusBarElement: statusBar,
|
||||
totpOverlayElement: totpOverlay,
|
||||
autoConnectOverlayElement: autoConnectOverlay,
|
||||
|
||||
showTotp(): void {
|
||||
transitionTo("totp");
|
||||
@@ -522,6 +594,11 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
|
||||
transitionTo("connecting");
|
||||
},
|
||||
|
||||
showAutoConnecting(serverName: string): void {
|
||||
setText(autoConnectServerName, serverName);
|
||||
transitionTo("auto-connecting");
|
||||
},
|
||||
|
||||
showError(message: string): void {
|
||||
transitionTo("error", message);
|
||||
},
|
||||
|
||||
@@ -36,7 +36,7 @@ function getIconColor(name: string): string {
|
||||
}
|
||||
|
||||
function getIconInitials(name: string): string {
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
return name.charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -51,6 +51,8 @@ export interface ServerPanelOptions {
|
||||
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 {
|
||||
@@ -68,10 +70,10 @@ export function createServerPanel(
|
||||
opts: ServerPanelOptions,
|
||||
initialProfiles: readonly SimpleProfile[],
|
||||
): ServerPanelApi {
|
||||
const { signal, onServerClick, onCredentialLoaded, onAddProfile, onDeleteProfile } = opts;
|
||||
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 }>();
|
||||
const healthElements = new Map<string, { dot: HTMLDivElement; latency: HTMLSpanElement; onlineUsers: HTMLSpanElement }>();
|
||||
|
||||
// Cached DOM references
|
||||
let serverListEl: HTMLDivElement;
|
||||
@@ -120,16 +122,16 @@ export function createServerPanel(
|
||||
});
|
||||
setText(icon, getIconInitials(profile.name));
|
||||
|
||||
// Health status dot on the icon
|
||||
// Health status dot — placed as sibling after info, not inside 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);
|
||||
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>;
|
||||
@@ -140,10 +142,34 @@ export function createServerPanel(
|
||||
|
||||
appendChildren(info, name, meta);
|
||||
|
||||
healthElements.set(profile.host, { dot: statusDot, latency });
|
||||
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)
|
||||
const actions = createElement("div", { class: "srv-actions" });
|
||||
if (fullProfile.id && onDeleteProfile) {
|
||||
const deleteBtn = createElement("button", {
|
||||
class: "srv-btn danger",
|
||||
@@ -163,7 +189,7 @@ export function createServerPanel(
|
||||
actions.appendChild(deleteBtn);
|
||||
}
|
||||
|
||||
appendChildren(item, icon, info, actions);
|
||||
appendChildren(item, icon, info, statusDot, actions);
|
||||
|
||||
item.addEventListener(
|
||||
"click",
|
||||
@@ -202,6 +228,15 @@ export function createServerPanel(
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -257,9 +292,17 @@ export function createServerPanel(
|
||||
}
|
||||
|
||||
function handleSave(): void {
|
||||
const name = (nameInput as HTMLInputElement).value.trim();
|
||||
const addr = (hostAddrInput as HTMLInputElement).value.trim();
|
||||
const name = nameInput.value.trim();
|
||||
const addr = hostAddrInput.value.trim();
|
||||
if (!name || !addr) return;
|
||||
// Validate address: must be a valid hostname:port — no paths, no special chars
|
||||
if (!/^[\w.-]+(:\d+)?$/.test(addr)) {
|
||||
// Show inline validation error via the host input
|
||||
hostAddrInput.setCustomValidity("Invalid server address (expected host or host:port)");
|
||||
hostAddrInput.reportValidity();
|
||||
return;
|
||||
}
|
||||
hostAddrInput.setCustomValidity("");
|
||||
onAddProfile!(name, addr);
|
||||
closeModal();
|
||||
}
|
||||
@@ -276,13 +319,13 @@ export function createServerPanel(
|
||||
|
||||
// Enter key submits
|
||||
hostAddrInput.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") handleSave();
|
||||
if ((e).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();
|
||||
nameInput.focus();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -9,6 +9,7 @@ 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";
|
||||
@@ -18,6 +19,10 @@ 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();
|
||||
@@ -129,6 +136,7 @@ export function createChannelController(
|
||||
messageList = createMessageList({
|
||||
channelId,
|
||||
channelName,
|
||||
channelType,
|
||||
currentUserId: userId,
|
||||
onScrollTop: () => {
|
||||
if (channelAbort !== null) {
|
||||
@@ -257,7 +265,22 @@ export function createChannelController(
|
||||
}, { 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ChatArea — chat column DOM construction and overlay/video wiring.
|
||||
* Composes ChatHeader, message/typing/input slots, VideoGrid, pinned panel,
|
||||
* search overlay, and MemberList. Extracted from MainPage to reduce orchestrator size.
|
||||
* and search overlay. Extracted from MainPage to reduce orchestrator size.
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren } from "@lib/dom";
|
||||
@@ -10,10 +10,8 @@ import type { ApiClient } from "@lib/api";
|
||||
import type { ToastContainer } from "@components/Toast";
|
||||
import { createVideoGrid } from "@components/VideoGrid";
|
||||
import type { VideoGridComponent } from "@components/VideoGrid";
|
||||
import { createMemberList } from "@components/MemberList";
|
||||
import { authStore } from "@stores/auth.store";
|
||||
import { toggleMemberList, uiStore } from "@stores/ui.store";
|
||||
import { buildChatHeader } from "./ChatHeader";
|
||||
import type { ChatHeaderRefs } from "./ChatHeader";
|
||||
import {
|
||||
createPinnedPanelController,
|
||||
createSearchOverlayController,
|
||||
@@ -35,8 +33,6 @@ export interface ChatAreaOptions {
|
||||
export interface ChatAreaResult {
|
||||
/** The chat area element (center column). */
|
||||
readonly chatArea: HTMLDivElement;
|
||||
/** The member list slot element (right column). */
|
||||
readonly memberListSlot: HTMLDivElement;
|
||||
/** Message/typing/input/videoGrid slots for ChannelController and VideoModeController. */
|
||||
readonly slots: {
|
||||
readonly messagesSlot: HTMLDivElement;
|
||||
@@ -48,6 +44,8 @@ export interface ChatAreaResult {
|
||||
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. */
|
||||
@@ -61,7 +59,7 @@ export interface ChatAreaResult {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createChatArea(opts: ChatAreaOptions): ChatAreaResult {
|
||||
const { api, getRoot, getToast, getChannelCtrl } = opts;
|
||||
const { api, getRoot, getChannelCtrl } = opts;
|
||||
|
||||
const children: MountableComponent[] = [];
|
||||
const unsubscribers: Array<() => void> = [];
|
||||
@@ -70,7 +68,6 @@ export function createChatArea(opts: ChatAreaOptions): ChatAreaResult {
|
||||
const pinnedCtrl = createPinnedPanelController({
|
||||
api,
|
||||
getRoot,
|
||||
getToast,
|
||||
getCurrentChannelId: () => getChannelCtrl()?.currentChannelId ?? null,
|
||||
onJumpToMessage: (msgId: number) => {
|
||||
const ctrl = getChannelCtrl();
|
||||
@@ -83,7 +80,6 @@ export function createChatArea(opts: ChatAreaOptions): ChatAreaResult {
|
||||
const searchCtrl = createSearchOverlayController({
|
||||
api,
|
||||
getRoot,
|
||||
getToast,
|
||||
getCurrentChannelId: () => getChannelCtrl()?.currentChannelId ?? null,
|
||||
onJumpToMessage: (_channelId: number, msgId: number) => {
|
||||
const ctrl = getChannelCtrl();
|
||||
@@ -96,7 +92,6 @@ export function createChatArea(opts: ChatAreaOptions): ChatAreaResult {
|
||||
// --- Chat header ---
|
||||
const chatHeader = buildChatHeader({
|
||||
onTogglePins: () => { void pinnedCtrl.toggle(); },
|
||||
onToggleMembers: () => toggleMemberList(),
|
||||
onSearchFocus: () => { searchCtrl.open(); },
|
||||
});
|
||||
const chatHeaderName = chatHeader.refs.nameEl;
|
||||
@@ -105,27 +100,27 @@ export function createChatArea(opts: ChatAreaOptions): ChatAreaResult {
|
||||
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();
|
||||
@@ -134,61 +129,12 @@ export function createChatArea(opts: ChatAreaOptions): ChatAreaResult {
|
||||
|
||||
appendChildren(chatArea, messagesSlot, typingSlot, inputSlot, videoGridSlot);
|
||||
|
||||
// --- Member list ---
|
||||
const memberListSlot = createElement("div", {}) as HTMLDivElement;
|
||||
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 roleNameToId: Record<string, number> = { owner: 1, admin: 2, moderator: 3, member: 4 };
|
||||
const roleId = roleNameToId[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(memberListSlot);
|
||||
children.push(memberList);
|
||||
|
||||
const memberListEl = memberListSlot.querySelector(".member-list");
|
||||
const unsubMemberList = uiStore.subscribeSelector(
|
||||
(s) => s.memberListVisible,
|
||||
(visible) => {
|
||||
if (memberListEl !== null) {
|
||||
memberListEl.classList.toggle("hidden", !visible);
|
||||
}
|
||||
},
|
||||
);
|
||||
unsubscribers.push(unsubMemberList);
|
||||
|
||||
return {
|
||||
chatArea,
|
||||
memberListSlot,
|
||||
slots: { messagesSlot, typingSlot, inputSlot, videoGridSlot },
|
||||
videoGrid,
|
||||
chatHeaderName,
|
||||
chatHeaderRefs: chatHeader.refs,
|
||||
searchCtrl,
|
||||
children,
|
||||
unsubscribers,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -11,13 +10,13 @@ import { createIcon } from "@lib/icons";
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ChatHeaderRefs {
|
||||
readonly hashEl: HTMLSpanElement;
|
||||
readonly nameEl: HTMLSpanElement;
|
||||
readonly topicEl: HTMLSpanElement;
|
||||
}
|
||||
|
||||
export interface ChatHeaderOptions {
|
||||
readonly onTogglePins: () => void;
|
||||
readonly onToggleMembers: () => void;
|
||||
readonly onSearchFocus?: () => void;
|
||||
}
|
||||
|
||||
@@ -54,18 +53,28 @@ export function buildChatHeader(
|
||||
const onFocus = opts.onSearchFocus;
|
||||
searchInput.addEventListener("focus", () => {
|
||||
onFocus();
|
||||
(searchInput as HTMLInputElement).blur();
|
||||
(searchInput).blur();
|
||||
});
|
||||
}
|
||||
const membersToggle = createElement("button", {
|
||||
type: "button",
|
||||
"aria-label": "Toggle member list",
|
||||
"data-testid": "members-toggle",
|
||||
});
|
||||
membersToggle.appendChild(createIcon("users", 18));
|
||||
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, "#");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* MemberPickerModal — a simple modal that lists server members for starting
|
||||
* a new DM conversation. Uses the shared modal factory for overlay behavior.
|
||||
*/
|
||||
|
||||
import { createElement, setText, appendChildren } from "@lib/dom";
|
||||
import { createModal } from "@lib/modalFactory";
|
||||
import type { ModalInstance } from "@lib/modalFactory";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import { membersStore } from "@stores/members.store";
|
||||
import { authStore } from "@stores/auth.store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MemberPickerOptions {
|
||||
/** Called when the user selects a member. Receives the member's user ID. */
|
||||
readonly onSelect: (userId: number) => void;
|
||||
/** Called when the modal is dismissed (cancel or overlay click). */
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createMemberPickerModal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create and mount a member picker modal. Returns a MountableComponent for
|
||||
* lifecycle management by the caller.
|
||||
*/
|
||||
export function createMemberPickerModal(opts: MemberPickerOptions): MountableComponent {
|
||||
let modalInstance: ModalInstance | null = null;
|
||||
|
||||
function mount(container: Element): void {
|
||||
const members = membersStore.getState().members;
|
||||
const currentUserId = authStore.getState().user?.id ?? 0;
|
||||
|
||||
// Build the content that goes inside the modal
|
||||
const content = createElement("div", { 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", () => {
|
||||
if (modalInstance !== null) {
|
||||
modalInstance.close();
|
||||
}
|
||||
opts.onSelect(member.id);
|
||||
});
|
||||
listContainer.appendChild(item);
|
||||
}
|
||||
|
||||
const cancelBtn = createElement("button", {
|
||||
class: "btn btn-secondary",
|
||||
style: "margin-top:12px;width:100%;",
|
||||
}, "Cancel");
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
if (modalInstance !== null) {
|
||||
modalInstance.close();
|
||||
}
|
||||
});
|
||||
|
||||
appendChildren(content, title, subtitle, listContainer, cancelBtn);
|
||||
|
||||
modalInstance = createModal(
|
||||
{
|
||||
content,
|
||||
onClose: opts.onClose,
|
||||
className: "dm-member-picker-modal",
|
||||
},
|
||||
container,
|
||||
);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
if (modalInstance !== null) {
|
||||
modalInstance.destroy();
|
||||
modalInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { mount, destroy };
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import type { InviteResponse } from "@lib/types";
|
||||
import { createPinnedMessages } from "@components/PinnedMessages";
|
||||
import type { PinnedMessage } from "@components/PinnedMessages";
|
||||
import { createSearchOverlay } from "@components/SearchOverlay";
|
||||
import type { ToastContainer } from "@components/Toast";
|
||||
import { showToast } from "@lib/toast";
|
||||
import { setActiveChannel } from "@stores/channels.store";
|
||||
|
||||
const log = createLogger("overlays");
|
||||
@@ -29,7 +29,7 @@ export function mapInviteResponse(r: InviteResponse): InviteItem {
|
||||
? (extra["created_by"] as { username?: string }).username ?? "unknown"
|
||||
: "unknown";
|
||||
const uses = r.use_count
|
||||
?? (typeof extra["uses"] === "number" ? (extra["uses"] as number) : 0);
|
||||
?? (typeof extra["uses"] === "number" ? (extra["uses"]) : 0);
|
||||
return {
|
||||
code: r.code,
|
||||
createdBy,
|
||||
@@ -135,7 +135,7 @@ export interface InviteManagerController {
|
||||
export function createInviteManagerController(opts: {
|
||||
readonly api: ApiClient;
|
||||
readonly getRoot: () => HTMLDivElement | null;
|
||||
readonly getToast: () => ToastContainer | null;
|
||||
|
||||
}): InviteManagerController {
|
||||
let instance: MountableComponent | null = null;
|
||||
|
||||
@@ -176,7 +176,7 @@ export function createInviteManagerController(opts: {
|
||||
onClose: close,
|
||||
onError: (message: string) => {
|
||||
log.error(message);
|
||||
opts.getToast()?.show(message, "error");
|
||||
showToast(message, "error");
|
||||
},
|
||||
});
|
||||
if (root !== null) {
|
||||
@@ -184,7 +184,7 @@ export function createInviteManagerController(opts: {
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("Failed to open invite manager", { error: String(err) });
|
||||
opts.getToast()?.show("Failed to load invites", "error");
|
||||
showToast("Failed to load invites", "error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ export interface PinnedPanelController {
|
||||
export function createPinnedPanelController(opts: {
|
||||
readonly api: ApiClient;
|
||||
readonly getRoot: () => HTMLDivElement | null;
|
||||
readonly getToast: () => ToastContainer | null;
|
||||
|
||||
readonly getCurrentChannelId: () => number | null;
|
||||
readonly onJumpToMessage?: (messageId: number) => boolean;
|
||||
}): PinnedPanelController {
|
||||
@@ -236,7 +236,7 @@ export function createPinnedPanelController(opts: {
|
||||
if (found) {
|
||||
close();
|
||||
} else {
|
||||
opts.getToast()?.show("Message not in loaded window", "info");
|
||||
showToast("Message not in loaded window", "info");
|
||||
}
|
||||
} else {
|
||||
close();
|
||||
@@ -247,7 +247,7 @@ export function createPinnedPanelController(opts: {
|
||||
close();
|
||||
}).catch((err: unknown) => {
|
||||
log.error("Failed to unpin message", { msgId, error: String(err) });
|
||||
opts.getToast()?.show("Failed to unpin message", "error");
|
||||
showToast("Failed to unpin message", "error");
|
||||
});
|
||||
},
|
||||
onClose: close,
|
||||
@@ -257,7 +257,7 @@ export function createPinnedPanelController(opts: {
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("Failed to load pinned messages", { error: String(err) });
|
||||
opts.getToast()?.show("Failed to load pinned messages", "error");
|
||||
showToast("Failed to load pinned messages", "error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +276,7 @@ export interface SearchOverlayController {
|
||||
export function createSearchOverlayController(opts: {
|
||||
readonly api: ApiClient;
|
||||
readonly getRoot: () => HTMLDivElement | null;
|
||||
readonly getToast: () => ToastContainer | null;
|
||||
|
||||
readonly getCurrentChannelId: () => number | null;
|
||||
readonly onJumpToMessage?: (channelId: number, messageId: number) => boolean;
|
||||
}): SearchOverlayController {
|
||||
@@ -304,7 +304,7 @@ export function createSearchOverlayController(opts: {
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") throw err;
|
||||
log.error("Search failed", { query, error: String(err) });
|
||||
opts.getToast()?.show("Search failed", "error");
|
||||
showToast("Search failed", "error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
@@ -315,7 +315,7 @@ export function createSearchOverlayController(opts: {
|
||||
requestAnimationFrame(() => {
|
||||
const found = opts.onJumpToMessage!(result.channel_id, result.message_id);
|
||||
if (!found) {
|
||||
opts.getToast()?.show("Message not in loaded history", "info");
|
||||
showToast("Message not in loaded history", "info");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* SidebarDmHelpers — DM-related business logic helpers used by both the
|
||||
* embedded DM section (channels mode) and the full DM sidebar (dms mode).
|
||||
*/
|
||||
|
||||
import type { ApiClient } from "@lib/api";
|
||||
import type { ToastContainer } from "@components/Toast";
|
||||
import type { DmConversation } from "@components/DmSidebar";
|
||||
import { setSidebarMode, setActiveDmUser } from "@stores/ui.store";
|
||||
import { channelsStore, setActiveChannel } from "@stores/channels.store";
|
||||
import type { Channel } from "@stores/channels.store";
|
||||
import { dmStore, clearDmUnread, addDmChannel } from "@stores/dm.store";
|
||||
import type { DmChannel } from "@stores/dm.store";
|
||||
import { membersStore } from "@stores/members.store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DmHelperDeps {
|
||||
readonly api: ApiClient;
|
||||
readonly getToast: () => ToastContainer | null;
|
||||
readonly getChannelBeforeDm: () => number | null;
|
||||
readonly setChannelBeforeDm: (id: number | null) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// selectDmConversation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Switch the UI to a specific DM conversation. Saves the current non-DM
|
||||
* channel so it can be restored when the user navigates back.
|
||||
*/
|
||||
export function selectDmConversation(
|
||||
dmChannel: DmChannel,
|
||||
deps: DmHelperDeps,
|
||||
): 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") {
|
||||
deps.setChannelBeforeDm(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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// addDmToChannelsStore
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Ensure a DM channel exists in channelsStore so ChannelController can switch to it. */
|
||||
export 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 };
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handleCreateDm
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Create a DM with a user via the API and switch to it. */
|
||||
export async function handleCreateDm(
|
||||
recipientId: number,
|
||||
deps: DmHelperDeps,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await deps.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, deps);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to create DM";
|
||||
deps.getToast()?.show(msg, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildDmConversations — helper for DM sidebar mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Build a readonly DmConversation array from DM store state. */
|
||||
export function buildDmConversations(activeDmUserId: number | null): readonly DmConversation[] {
|
||||
const dmChannels = dmStore.getState().channels;
|
||||
return 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,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* SidebarDmSection — the embedded DM preview section that sits above channels
|
||||
* in "channels" mode. Shows the top 3 DM conversations, an unread badge,
|
||||
* a "View all messages" button, and collapse toggle.
|
||||
*/
|
||||
|
||||
import { createElement, setText, clearChildren, appendChildren } from "@lib/dom";
|
||||
import { dmStore } from "@stores/dm.store";
|
||||
import type { DmChannel } from "@stores/dm.store";
|
||||
import { setSidebarMode } from "@stores/ui.store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SidebarDmSectionOptions {
|
||||
/** Called when the user clicks a DM entry to open that conversation. */
|
||||
readonly onSelectDm: (dmChannel: DmChannel) => void;
|
||||
/** Called when the user clicks the "+" button to create a new DM. */
|
||||
readonly onNewDm: () => void;
|
||||
}
|
||||
|
||||
export interface SidebarDmSectionResult {
|
||||
/** The root element to insert into the DOM. */
|
||||
readonly element: HTMLDivElement;
|
||||
/** Re-render the DM list from current store state. */
|
||||
readonly update: () => void;
|
||||
/** Clean up store subscriptions. */
|
||||
readonly destroy: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createSidebarDmSection(opts: SidebarDmSectionOptions): SidebarDmSectionResult {
|
||||
const unsubs: Array<() => void> = [];
|
||||
|
||||
// --- Root container ---
|
||||
const dmSection = createElement("div", { class: "sidebar-dm-section" });
|
||||
|
||||
// --- Header ---
|
||||
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);
|
||||
|
||||
// --- DM list ---
|
||||
let dmCollapsed = false;
|
||||
const dmList = createElement("div", { class: "category-channels sidebar-dm-list" });
|
||||
|
||||
// --- "View All" button ---
|
||||
const viewAllBtn = createElement("button", {
|
||||
class: "sidebar-dm-view-all",
|
||||
}, "View all messages");
|
||||
|
||||
viewAllBtn.addEventListener("click", () => {
|
||||
setSidebarMode("dms");
|
||||
});
|
||||
|
||||
// --- Render logic ---
|
||||
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", () => {
|
||||
opts.onSelectDm(dm);
|
||||
});
|
||||
dmList.appendChild(dmItem);
|
||||
}
|
||||
|
||||
// Show/hide "View All" button based on DM count (respect collapsed state)
|
||||
if (dmChannels.length > 3) {
|
||||
setText(viewAllBtn, `View all messages (${dmChannels.length})`);
|
||||
viewAllBtn.style.display = dmCollapsed ? "none" : "";
|
||||
} 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);
|
||||
|
||||
// --- Store subscription ---
|
||||
const unsubDmSection = dmStore.subscribeSelector(
|
||||
(s) => s.channels,
|
||||
() => { renderDmListItems(); },
|
||||
);
|
||||
unsubs.push(unsubDmSection);
|
||||
|
||||
// --- Collapse toggle ---
|
||||
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 > 3 ? "" : "none");
|
||||
});
|
||||
|
||||
// --- Add DM button ---
|
||||
dmAddBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
opts.onNewDm();
|
||||
});
|
||||
|
||||
return {
|
||||
element: dmSection,
|
||||
update: renderDmListItems,
|
||||
destroy: () => {
|
||||
for (const unsub of unsubs) {
|
||||
unsub();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* SidebarMemberSection — the collapsible member list panel that sits below
|
||||
* channels in "channels" mode. Supports drag-to-resize and persists
|
||||
* collapsed state and height to localStorage.
|
||||
*/
|
||||
|
||||
import { createElement, appendChildren } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import { createMemberList } from "@components/MemberList";
|
||||
import { authStore } from "@stores/auth.store";
|
||||
import { getRoleIdByName } from "@stores/roles.store";
|
||||
import type { ApiClient } from "@lib/api";
|
||||
import type { ToastContainer } from "@components/Toast";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LS_KEY_HEIGHT = "owncord:member-list-height";
|
||||
const LS_KEY_COLLAPSED = "owncord:member-list-collapsed";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SidebarMemberSectionOptions {
|
||||
readonly api: ApiClient;
|
||||
readonly getToast: () => ToastContainer | null;
|
||||
}
|
||||
|
||||
export interface SidebarMemberSectionResult {
|
||||
/** The root element to insert into the DOM. */
|
||||
readonly element: HTMLDivElement;
|
||||
/** The member list MountableComponent (for external cleanup tracking). */
|
||||
readonly memberListComponent: MountableComponent;
|
||||
/** Clean up event listeners and abort controller. */
|
||||
readonly destroy: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createSidebarMemberSection(opts: SidebarMemberSectionOptions): SidebarMemberSectionResult {
|
||||
const { api, getToast } = opts;
|
||||
const unsubs: Array<() => void> = [];
|
||||
|
||||
// --- Container ---
|
||||
const memberListContainer = createElement("div", {
|
||||
class: "sidebar-members-section",
|
||||
"data-testid": "sidebar-members",
|
||||
});
|
||||
|
||||
// --- Header ---
|
||||
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(LS_KEY_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(LS_KEY_HEIGHT, String(memberListContainer.offsetHeight));
|
||||
}, { signal: resizeAbort.signal });
|
||||
|
||||
unsubs.push(() => { resizeAbort.abort(); });
|
||||
|
||||
// --- Collapse state ---
|
||||
const savedCollapsed = localStorage.getItem(LS_KEY_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(LS_KEY_HEIGHT);
|
||||
if (h !== null) {
|
||||
memberListContainer.style.height = `${h}px`;
|
||||
} else {
|
||||
memberListContainer.style.height = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply initial state
|
||||
applyMembersCollapsed();
|
||||
|
||||
memberHeader.addEventListener("click", () => {
|
||||
membersCollapsed = !membersCollapsed;
|
||||
localStorage.setItem(LS_KEY_COLLAPSED, String(membersCollapsed));
|
||||
applyMembersCollapsed();
|
||||
});
|
||||
|
||||
// --- Member list component ---
|
||||
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);
|
||||
|
||||
return {
|
||||
element: memberListContainer,
|
||||
memberListComponent: memberList,
|
||||
destroy: () => {
|
||||
for (const unsub of unsubs) {
|
||||
unsub();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
*/
|
||||
|
||||
import { voiceStore } from "@stores/voice.store";
|
||||
import { getLocalCameraStream } from "@lib/livekitSession";
|
||||
import { getLocalCameraStream, getLocalScreenshareStream } from "@lib/livekitSession";
|
||||
import { SCREENSHARE_TILE_ID_OFFSET } from "@lib/constants";
|
||||
import type { VideoGridComponent } from "@components/VideoGrid";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -33,6 +34,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;
|
||||
}
|
||||
@@ -48,6 +53,8 @@ export function createVideoModeController(
|
||||
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;
|
||||
|
||||
function showVideoGrid(): void {
|
||||
if (videoMode) return;
|
||||
@@ -61,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 = "";
|
||||
@@ -80,19 +90,18 @@ 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();
|
||||
}
|
||||
|
||||
@@ -107,6 +116,7 @@ export function createVideoModeController(
|
||||
currentUserId,
|
||||
me?.username ? `${me.username} (You)` : "You",
|
||||
localStream,
|
||||
{ isSelf: true, audioUserId: currentUserId, isScreenshare: false },
|
||||
);
|
||||
localTileAdded = true;
|
||||
}
|
||||
@@ -116,10 +126,31 @@ export function createVideoModeController(
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -130,9 +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 {
|
||||
@@ -140,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,6 +16,8 @@ import {
|
||||
setDeafened as voiceSessionSetDeafened,
|
||||
enableCamera,
|
||||
disableCamera,
|
||||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||