mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
chore: ESLint config, 61 lint fixes across 22 client files, CLAUDE.md update
- Add ESLint v9 flat config with typescript-eslint - Fix no-floating-promises, no-unused-vars, consistent-return across client - Refactor livekitSession: delegate entirely to AudioPipeline (1438→1171 lines) - Add 7 delete-account UI tests in settings-overlay.test.ts - Update CLAUDE.md with latest features and project structure - Update .gitignore
This commit is contained in:
@@ -5,6 +5,10 @@ Server/.claude/
|
||||
|
||||
# Claude Code skills
|
||||
skills/
|
||||
\
|
||||
|
||||
#github copilot
|
||||
.github/
|
||||
|
||||
# AI-specific / internal planning docs
|
||||
Obsidian-Brain/
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
);
|
||||
Generated
+1280
-18
File diff suppressed because it is too large
Load Diff
@@ -16,14 +16,19 @@
|
||||
"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",
|
||||
"lint": "eslint src/",
|
||||
"lint:fix": "eslint src/ --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@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"
|
||||
},
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
import {
|
||||
createElement,
|
||||
setText,
|
||||
clearChildren,
|
||||
appendChildren,
|
||||
} from "@lib/dom";
|
||||
import { createIcon } from "@lib/icons";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -459,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;
|
||||
|
||||
@@ -519,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";
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -129,7 +129,7 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
|
||||
if (statsPoller !== null) return;
|
||||
statsPoller = createConnectionStatsPoller(() => getRoomForStats());
|
||||
statsUnlisten = statsPoller.onUpdate(updateSignalIcon);
|
||||
qualityUnlisten = statsPoller.onQualityChanged((quality, prevQuality) => {
|
||||
qualityUnlisten = statsPoller.onQualityChanged((quality, _prevQuality) => {
|
||||
// Auto-expand stats pane when quality degrades
|
||||
if ((quality === "poor" || quality === "bad") && statsPane !== null) {
|
||||
statsPane.classList.add("visible");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,8 +87,9 @@ 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;
|
||||
|
||||
/** Strict pattern for YouTube video IDs (alphanumeric, hyphens, underscores). */
|
||||
const YOUTUBE_ID_RE = /^[\w-]{1,20}$/;
|
||||
@@ -127,10 +128,18 @@ export function renderYouTubeEmbed(videoId: string, originalUrl: string): HTMLDi
|
||||
.then((res) => (res.ok ? (res.json() as Promise<{ title?: string } | null>) : null))
|
||||
.then((data) => {
|
||||
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 (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");
|
||||
});
|
||||
@@ -208,7 +217,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);
|
||||
@@ -304,9 +313,7 @@ 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;
|
||||
}
|
||||
|
||||
@@ -367,8 +374,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();
|
||||
@@ -391,7 +400,7 @@ 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);
|
||||
|
||||
@@ -69,7 +69,7 @@ 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 });
|
||||
|
||||
@@ -134,7 +134,24 @@ export function buildAdvancedTab(signal: AbortSignal): HTMLDivElement {
|
||||
"Clear & Restart",
|
||||
signal,
|
||||
async (btn) => {
|
||||
if (!confirm("This will clear all cached data and restart the app. Continue?")) return;
|
||||
// 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 {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// after a device switch so the new source track flows through the GainNode.
|
||||
// Monitors navigator.mediaDevices.ondevicechange for hot-swap (unplug/plug).
|
||||
|
||||
import { Room, type LocalAudioTrack, Track } from "livekit-client";
|
||||
import { Room } from "livekit-client";
|
||||
import { loadPref, savePref } from "@components/settings/helpers";
|
||||
import { createLogger } from "@lib/logger";
|
||||
import type { AudioPipeline } from "@lib/audioPipeline";
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type RemoteTrackPublication,
|
||||
type RemoteParticipant,
|
||||
type Participant,
|
||||
type LocalAudioTrack,
|
||||
type LocalVideoTrack,
|
||||
type LocalTrack,
|
||||
type LocalTrackPublication,
|
||||
@@ -30,9 +29,8 @@ import {
|
||||
leaveVoiceChannel,
|
||||
setListenOnly,
|
||||
} from "@stores/voice.store";
|
||||
import { loadPref, savePref } from "@components/settings/helpers";
|
||||
import { loadPref } from "@components/settings/helpers";
|
||||
import { createLogger } from "@lib/logger";
|
||||
import { createRNNoiseProcessor } from "@lib/noise-suppression";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { AudioPipeline } from "@lib/audioPipeline";
|
||||
import { AudioElements } from "@lib/audioElements";
|
||||
@@ -139,43 +137,6 @@ export class LiveKitSession {
|
||||
private manualCameraTrack: LocalVideoTrack | null = null;
|
||||
private manualScreenTracks: LocalTrack[] = [];
|
||||
|
||||
// --- Unified audio pipeline: input volume + VAD gating ---
|
||||
// Pipeline: rawMicTrack → source → analyser (VAD reads here)
|
||||
// → gainNode (volume × vadGate) → dest → WebRTC sender
|
||||
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;
|
||||
|
||||
// --- RNNoise processor (LiveKit TrackProcessor API) ---
|
||||
|
||||
/** Attach RNNoise processor to the local mic track. Safe to call if already attached. */
|
||||
private 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. */
|
||||
private 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");
|
||||
}
|
||||
|
||||
// --- Room factory ---
|
||||
|
||||
private createRoom(): Room {
|
||||
@@ -356,7 +317,7 @@ export class LiveKitSession {
|
||||
const channelId = this.currentChannelId;
|
||||
const directUrl = this.lastDirectUrl;
|
||||
// Clean up current room without sending WS leave (we're reconnecting, not leaving).
|
||||
this.teardownAudioPipeline();
|
||||
this._audioPipeline.teardownAudioPipeline();
|
||||
this.removeAutoplayUnlock();
|
||||
this.clearTokenRefreshTimer();
|
||||
// Clear stale remote audio elements so reconnect doesn't leak DOM nodes.
|
||||
@@ -400,7 +361,7 @@ export class LiveKitSession {
|
||||
this.logIceConnectionInfo();
|
||||
this.room.startAudio().catch((err) => log.debug("Failed to start audio after reconnect", err));
|
||||
await this.restoreLocalVoiceState("reconnect");
|
||||
this.setupAudioPipeline();
|
||||
this._audioPipeline.setupAudioPipeline();
|
||||
this.reapplyMuteGain();
|
||||
this.startTokenRefreshTimer();
|
||||
// Clear the abort controller after all post-connect work is done so
|
||||
@@ -552,7 +513,7 @@ export class LiveKitSession {
|
||||
? "Published mic via LiveKit native capture"
|
||||
: "Auto-reconnect restored live microphone");
|
||||
if (loadPref<boolean>("enhancedNoiseSuppression", false)) {
|
||||
await this.applyNoiseSuppressor();
|
||||
await this._audioPipeline.applyNoiseSuppressor();
|
||||
}
|
||||
}
|
||||
setListenOnly(false); // Mic acquired successfully
|
||||
@@ -702,7 +663,7 @@ export class LiveKitSession {
|
||||
}
|
||||
// Set up unified audio pipeline (input volume + VAD gating via GainNode).
|
||||
// VAD polling only starts if saved sensitivity < 100.
|
||||
this.setupAudioPipeline();
|
||||
this._audioPipeline.setupAudioPipeline();
|
||||
this.reapplyMuteGain();
|
||||
this.startTokenRefreshTimer();
|
||||
log.info("Voice session active", { channelId });
|
||||
@@ -739,9 +700,9 @@ export class LiveKitSession {
|
||||
setLocalMuted(false);
|
||||
log.info("Microphone permission granted — exited listen-only mode");
|
||||
// Set up audio pipeline for the new mic track
|
||||
this.setupAudioPipeline();
|
||||
this._audioPipeline.setupAudioPipeline();
|
||||
if (loadPref<boolean>("enhancedNoiseSuppression", false)) {
|
||||
await this.applyNoiseSuppressor();
|
||||
await this._audioPipeline.applyNoiseSuppressor();
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn("Microphone retry failed — still in listen-only mode", err);
|
||||
@@ -756,7 +717,7 @@ export class LiveKitSession {
|
||||
this.reconnectAc = null;
|
||||
}
|
||||
this.clearTokenRefreshTimer();
|
||||
this.teardownAudioPipeline();
|
||||
this._audioPipeline.teardownAudioPipeline();
|
||||
this.removeAutoplayUnlock();
|
||||
this.pendingJoin = null;
|
||||
// Clean up manually published tracks.
|
||||
@@ -817,7 +778,7 @@ export class LiveKitSession {
|
||||
if (this.room === null) return;
|
||||
if (muted) {
|
||||
// Tear down pipeline first so it doesn't hold refs to the track
|
||||
this.teardownAudioPipeline();
|
||||
this._audioPipeline.teardownAudioPipeline();
|
||||
// Fully disable the mic — this unpublishes the track from the SFU
|
||||
await this.room.localParticipant.setMicrophoneEnabled(false);
|
||||
log.debug("Mic fully unpublished (muted)");
|
||||
@@ -825,7 +786,7 @@ export class LiveKitSession {
|
||||
// Re-enable mic — this re-publishes the track to the SFU
|
||||
await this.room.localParticipant.setMicrophoneEnabled(true);
|
||||
// Rebuild the audio pipeline on the fresh track
|
||||
this.setupAudioPipeline();
|
||||
this._audioPipeline.setupAudioPipeline();
|
||||
log.debug("Mic re-published (unmuted)");
|
||||
}
|
||||
}
|
||||
@@ -858,7 +819,7 @@ export class LiveKitSession {
|
||||
this.ws.send({ type: "voice_camera", payload: { enabled: true } });
|
||||
// Re-apply audio pipeline — publishing a new track can trigger WebRTC
|
||||
// renegotiation which resets the mic sender, bypassing our GainNode mute.
|
||||
this.setupAudioPipeline();
|
||||
this._audioPipeline.setupAudioPipeline();
|
||||
this.reapplyMuteGain();
|
||||
log.info("Camera enabled", { quality, maxBitrate: CAMERA_PUBLISH_BITRATES[quality] });
|
||||
} catch (err) {
|
||||
@@ -885,7 +846,6 @@ export class LiveKitSession {
|
||||
} finally {
|
||||
setLocalCamera(false);
|
||||
if (this.ws !== null) this.ws.send({ type: "voice_camera", payload: { enabled: false } });
|
||||
this.onErrorCallback?.("Camera off");
|
||||
log.info("Camera disabled");
|
||||
}
|
||||
}
|
||||
@@ -895,7 +855,7 @@ export class LiveKitSession {
|
||||
const track = this.manualCameraTrack;
|
||||
this.manualCameraTrack = null;
|
||||
try {
|
||||
this.room.localParticipant.unpublishTrack(track.mediaStreamTrack);
|
||||
void this.room.localParticipant.unpublishTrack(track.mediaStreamTrack);
|
||||
} catch { /* already unpublished */ }
|
||||
track.stop();
|
||||
}
|
||||
@@ -927,7 +887,7 @@ export class LiveKitSession {
|
||||
}
|
||||
this.ws.send({ type: "voice_screenshare", payload: { enabled: true } });
|
||||
// Re-apply audio pipeline — same renegotiation risk as camera.
|
||||
this.setupAudioPipeline();
|
||||
this._audioPipeline.setupAudioPipeline();
|
||||
this.reapplyMuteGain();
|
||||
log.info("Screenshare enabled", { quality, maxBitrate: SCREENSHARE_PUBLISH_BITRATES[quality] });
|
||||
} catch (err) {
|
||||
@@ -950,7 +910,6 @@ export class LiveKitSession {
|
||||
} finally {
|
||||
setLocalScreenshare(false);
|
||||
if (this.ws !== null) this.ws.send({ type: "voice_screenshare", payload: { enabled: false } });
|
||||
this.onErrorCallback?.("Screen share ended");
|
||||
log.info("Screenshare disabled");
|
||||
}
|
||||
}
|
||||
@@ -961,7 +920,7 @@ export class LiveKitSession {
|
||||
this.manualScreenTracks = [];
|
||||
for (const track of tracks) {
|
||||
try {
|
||||
this.room.localParticipant.unpublishTrack(track.mediaStreamTrack);
|
||||
void this.room.localParticipant.unpublishTrack(track.mediaStreamTrack);
|
||||
} catch { /* already unpublished */ }
|
||||
track.stop();
|
||||
}
|
||||
@@ -997,97 +956,7 @@ export class LiveKitSession {
|
||||
return this._audioElements.getScreenshareAudioMuted(userId);
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
|
||||
/** Build or rebuild the audio pipeline on the current mic track. */
|
||||
private 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. */
|
||||
private 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. */
|
||||
private 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);
|
||||
}
|
||||
// --- Audio pipeline delegates (all state lives in AudioPipeline) ---
|
||||
|
||||
/** Re-apply mute/deafen state after events that may reset the audio pipeline. */
|
||||
private reapplyMuteGain(): void {
|
||||
@@ -1098,156 +967,19 @@ export class LiveKitSession {
|
||||
}
|
||||
|
||||
setInputVolume(volume: number): void {
|
||||
const clamped = Math.max(0, Math.min(200, volume));
|
||||
savePref("inputVolume", clamped);
|
||||
this.currentInputGain = clamped / 100;
|
||||
this.updatePipelineGain();
|
||||
this._audioPipeline.setInputVolume(volume);
|
||||
}
|
||||
|
||||
setOutputVolume(volume: number): void {
|
||||
this._audioElements.setOutputVolume(volume);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 });
|
||||
this._audioPipeline.setVoiceSensitivity(sensitivity);
|
||||
}
|
||||
|
||||
/** Start VAD polling loop — reads from the pipeline's analyser. */
|
||||
private startVadPolling(): void {
|
||||
this.stopVadPolling();
|
||||
if (this.audioPipelineAnalyser === null) return;
|
||||
|
||||
const sensitivity = loadPref<number>("voiceSensitivity", 50);
|
||||
if (sensitivity >= 100) return;
|
||||
|
||||
// Convert sensitivity to an RMS threshold (time-domain):
|
||||
// sensitivity 0 → threshold ~0.10, sensitivity 50 → ~0.05, sensitivity 99 → ~0.001
|
||||
const threshold = ((100 - sensitivity) / 100) * 0.10;
|
||||
const analyser = this.audioPipelineAnalyser;
|
||||
const dataArray = new Float32Array(analyser.fftSize);
|
||||
|
||||
let silentFrames = 0;
|
||||
let speechFrames = 0;
|
||||
const GATE_ON_FRAMES = 12; // ~200ms of silence before gating
|
||||
const GATE_OFF_FRAMES = 2; // ~33ms of speech before ungating
|
||||
// Grace period: don't gate for the first ~500ms to let audio settle
|
||||
let startupFrames = 0;
|
||||
const STARTUP_GRACE = 30;
|
||||
|
||||
// setTimeout instead of requestAnimationFrame: rAF pauses when the Tauri
|
||||
// window is minimized/backgrounded, which freezes the VAD gate in whatever
|
||||
// state it was in. setTimeout continues firing (throttled to ~1 Hz by some
|
||||
// engines when hidden), which is still fast enough for VAD gate timing
|
||||
// (200ms gate-on, 100ms gate-off). The CPU cost is negligible since
|
||||
// getFloatTimeDomainData is a cheap memcpy from the audio thread.
|
||||
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);
|
||||
|
||||
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(); // gain → 0
|
||||
}
|
||||
} else {
|
||||
silentFrames = 0;
|
||||
speechFrames++;
|
||||
if (this.vadGated && speechFrames >= GATE_OFF_FRAMES) {
|
||||
this.vadGated = false;
|
||||
this.updatePipelineGain(); // gain → inputVolume
|
||||
}
|
||||
}
|
||||
|
||||
this.vadTimer = setTimeout(poll, 16);
|
||||
};
|
||||
this.vadTimer = setTimeout(poll, 16);
|
||||
log.info("VAD polling started", { sensitivity, threshold });
|
||||
}
|
||||
|
||||
/** Stop VAD polling loop (pipeline stays intact). */
|
||||
private stopVadPolling(): void {
|
||||
if (this.vadTimer !== null) {
|
||||
clearTimeout(this.vadTimer);
|
||||
this.vadTimer = null;
|
||||
}
|
||||
// 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(): 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);
|
||||
this.onErrorCallback?.("Failed to update audio settings");
|
||||
}
|
||||
return this._audioPipeline.reapplyAudioProcessing(this.onErrorCallback ?? undefined);
|
||||
}
|
||||
|
||||
getLocalCameraStream(): MediaStream | null {
|
||||
@@ -1293,11 +1025,11 @@ export class LiveKitSession {
|
||||
hasRNNoiseProcessor: this.room.localParticipant.getTrackPublication(Track.Source.Microphone)?.track?.getProcessor() !== undefined,
|
||||
currentChannelId: this.currentChannelId,
|
||||
outputVolumeMultiplier: this.outputVolumeMultiplier,
|
||||
audioPipelineActive: this.audioPipelineGain !== null,
|
||||
audioPipelineGain: this.audioPipelineGain?.gain.value ?? null,
|
||||
audioPipelineCtxState: this.audioPipelineCtx?.state ?? null,
|
||||
vadGated: this.vadGated,
|
||||
currentInputGain: this.currentInputGain,
|
||||
audioPipelineActive: this._audioPipeline.isActive,
|
||||
audioPipelineGain: this._audioPipeline.gainValue,
|
||||
audioPipelineCtxState: this._audioPipeline.ctxState,
|
||||
vadGated: this._audioPipeline.isVadGated,
|
||||
currentInputGain: this._audioPipeline.inputGain,
|
||||
localParticipant: this.room.localParticipant.identity, localTracks,
|
||||
remoteParticipants,
|
||||
iceConnectionState: this.getIceConnectionState(),
|
||||
@@ -1310,12 +1042,14 @@ export class LiveKitSession {
|
||||
// Access the underlying RTCPeerConnection via LiveKit's engine.
|
||||
// LiveKit exposes the PeerConnection via room.engine.subscriber/publisher.
|
||||
try {
|
||||
const engine = (this.room as any).engine;
|
||||
const engine = (this.room as unknown as Record<string, unknown>).engine as Record<string, unknown> | undefined;
|
||||
if (!engine) return;
|
||||
|
||||
const subscriber = engine.subscriber as Record<string, unknown> | undefined;
|
||||
const publisher = engine.publisher as Record<string, unknown> | undefined;
|
||||
const pcs: Array<{ label: string; pc: RTCPeerConnection }> = [];
|
||||
if (engine.subscriber?.pc) pcs.push({ label: "subscriber", pc: engine.subscriber.pc });
|
||||
if (engine.publisher?.pc) pcs.push({ label: "publisher", pc: engine.publisher.pc });
|
||||
if (subscriber?.pc) pcs.push({ label: "subscriber", pc: subscriber.pc as RTCPeerConnection });
|
||||
if (publisher?.pc) pcs.push({ label: "publisher", pc: publisher.pc as RTCPeerConnection });
|
||||
|
||||
for (const { label, pc } of pcs) {
|
||||
log.info(`ICE ${label} connection state`, {
|
||||
@@ -1365,19 +1099,23 @@ export class LiveKitSession {
|
||||
private getIceConnectionState(): Record<string, unknown> | null {
|
||||
if (this.room === null) return null;
|
||||
try {
|
||||
const engine = (this.room as any).engine;
|
||||
const engine = (this.room as unknown as Record<string, unknown>).engine as Record<string, unknown> | undefined;
|
||||
if (!engine) return null;
|
||||
const subscriber = engine.subscriber as Record<string, unknown> | undefined;
|
||||
const publisher = engine.publisher as Record<string, unknown> | undefined;
|
||||
const result: Record<string, unknown> = {};
|
||||
if (engine.subscriber?.pc) {
|
||||
if (subscriber?.pc) {
|
||||
const pc = subscriber.pc as RTCPeerConnection;
|
||||
result.subscriber = {
|
||||
iceConnectionState: engine.subscriber.pc.iceConnectionState,
|
||||
connectionState: engine.subscriber.pc.connectionState,
|
||||
iceConnectionState: pc.iceConnectionState,
|
||||
connectionState: pc.connectionState,
|
||||
};
|
||||
}
|
||||
if (engine.publisher?.pc) {
|
||||
if (publisher?.pc) {
|
||||
const pc = publisher.pc as RTCPeerConnection;
|
||||
result.publisher = {
|
||||
iceConnectionState: engine.publisher.pc.iceConnectionState,
|
||||
connectionState: engine.publisher.pc.connectionState,
|
||||
iceConnectionState: pc.iceConnectionState,
|
||||
connectionState: pc.connectionState,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -49,7 +49,7 @@ async function flushBuffer(): Promise<void> {
|
||||
buffer = [];
|
||||
|
||||
try {
|
||||
const filePath = logFilePath(logDir, currentDate!);
|
||||
const filePath = logFilePath(logDir, currentDate);
|
||||
await writeTextFile(filePath, lines, { append: true });
|
||||
} catch (err) {
|
||||
// Log persistence failure shouldn't crash the app.
|
||||
@@ -62,7 +62,7 @@ function scheduleFlush(): void {
|
||||
if (flushTimer !== null) return;
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
flushBuffer();
|
||||
void flushBuffer();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ async function rotateOldFiles(): Promise<void> {
|
||||
(e) =>
|
||||
e.name?.endsWith(".jsonl") && !e.isDirectory,
|
||||
)
|
||||
.map((e) => e.name!)
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
|
||||
if (jsonlFiles.length > MAX_LOG_FILES) {
|
||||
@@ -174,7 +174,7 @@ export async function readAllPersistedLogs(): Promise<string> {
|
||||
(e) =>
|
||||
e.name?.endsWith(".jsonl") && !e.isDirectory,
|
||||
)
|
||||
.map((e) => e.name!)
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
@@ -110,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;
|
||||
|
||||
@@ -147,7 +147,7 @@ export function createWsClient() {
|
||||
setState("reconnecting");
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectAttempt++;
|
||||
connect(config!);
|
||||
void connect(config!);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
@@ -244,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) {
|
||||
@@ -280,7 +280,8 @@ export function createWsClient() {
|
||||
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", {
|
||||
|
||||
@@ -11,7 +11,7 @@ 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";
|
||||
@@ -51,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);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ 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,
|
||||
@@ -30,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";
|
||||
@@ -279,9 +277,9 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
channelCtrl = createChannelController({
|
||||
ws,
|
||||
api,
|
||||
msgCtrl: msgCtrl!,
|
||||
msgCtrl: msgCtrl,
|
||||
pendingDeleteManager,
|
||||
reactionCtrl: reactionCtrl!,
|
||||
reactionCtrl: reactionCtrl,
|
||||
typingLimiter: limiters.typing,
|
||||
showToast: (msg, type) => showToast(msg, type as "success" | "error" | "info"),
|
||||
getCurrentUserId,
|
||||
@@ -369,7 +367,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
if (active.type === "text") {
|
||||
videoModeCtrl?.showChat();
|
||||
}
|
||||
channelCtrl!.mountChannel(active.id, resolveChannelName(active.id, active.name, active.type), active.type);
|
||||
channelCtrl?.mountChannel(active.id, resolveChannelName(active.id, active.name, active.type), active.type);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("Channel mount failed", err);
|
||||
@@ -380,7 +378,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
|
||||
const active = getActiveChannel();
|
||||
if (active !== null) {
|
||||
channelCtrl!.mountChannel(active.id, resolveChannelName(active.id, active.name, active.type), active.type);
|
||||
channelCtrl?.mountChannel(active.id, resolveChannelName(active.id, active.name, active.type), active.type);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,13 +93,9 @@ 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 autoConnectOverlay: HTMLDivElement;
|
||||
let autoConnectServerName: HTMLSpanElement;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -321,7 +317,7 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
|
||||
(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleTotpSubmit();
|
||||
void handleTotpSubmit();
|
||||
}
|
||||
},
|
||||
{ signal },
|
||||
@@ -358,6 +354,23 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -563,23 +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();
|
||||
|
||||
// Auto-connect overlay (hidden by default)
|
||||
autoConnectOverlay = buildAutoConnectOverlay();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -296,7 +296,7 @@ export function createServerPanel(
|
||||
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)) {
|
||||
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();
|
||||
@@ -319,7 +319,7 @@ 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
|
||||
|
||||
@@ -59,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> = [];
|
||||
|
||||
@@ -53,7 +53,7 @@ export function buildChatHeader(
|
||||
const onFocus = opts.onSearchFocus;
|
||||
searchInput.addEventListener("focus", () => {
|
||||
onFocus();
|
||||
(searchInput as HTMLInputElement).blur();
|
||||
(searchInput).blur();
|
||||
});
|
||||
}
|
||||
appendChildren(tools, searchInput, pinBtn);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -819,7 +819,7 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
|
||||
|
||||
// Load profiles asynchronously, then show overlay
|
||||
void (async () => {
|
||||
let profiles: readonly QuickSwitchProfile[] = [];
|
||||
let profiles: readonly QuickSwitchProfile[];
|
||||
|
||||
try {
|
||||
if (profileManager === null) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { voiceStore } from "@stores/voice.store";
|
||||
import { getLocalCameraStream, getLocalScreenshareStream } from "@lib/livekitSession";
|
||||
import type { VideoGridComponent, TileConfig } from "@components/VideoGrid";
|
||||
import type { VideoGridComponent } from "@components/VideoGrid";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
|
||||
@@ -141,7 +141,7 @@ export function loadCollapsedCategories(serverHost: string): void {
|
||||
uiStore.setState((prev) => ({ ...prev, collapsedCategories: new Set() }));
|
||||
return;
|
||||
}
|
||||
const loaded: ReadonlySet<string> = new Set(parsed as string[]);
|
||||
const loaded: ReadonlySet<string> = new Set(parsed);
|
||||
uiStore.setState((prev) => ({ ...prev, collapsedCategories: loaded }));
|
||||
} catch {
|
||||
uiStore.setState((prev) => ({ ...prev, collapsedCategories: new Set() }));
|
||||
|
||||
@@ -365,6 +365,150 @@ describe("SettingsOverlay", () => {
|
||||
overlay.destroy?.();
|
||||
});
|
||||
|
||||
// --- Delete account tests ---
|
||||
|
||||
it("shows confirmation area when Delete Account is clicked", () => {
|
||||
const overlay = createSettingsOverlay(defaultOptions);
|
||||
overlay.mount(container);
|
||||
|
||||
const triggerBtn = container.querySelector("[data-testid='delete-account-trigger']") as HTMLElement;
|
||||
expect(triggerBtn).not.toBeNull();
|
||||
|
||||
const confirmArea = container.querySelector("[data-testid='delete-account-confirm-area']") as HTMLElement;
|
||||
expect(confirmArea.style.display).toBe("none");
|
||||
|
||||
triggerBtn.click();
|
||||
|
||||
expect(confirmArea.style.display).toBe("block");
|
||||
expect(triggerBtn.style.display).toBe("none");
|
||||
|
||||
overlay.destroy?.();
|
||||
});
|
||||
|
||||
it("hides confirmation area when Cancel is clicked", () => {
|
||||
const overlay = createSettingsOverlay(defaultOptions);
|
||||
overlay.mount(container);
|
||||
|
||||
const triggerBtn = container.querySelector("[data-testid='delete-account-trigger']") as HTMLElement;
|
||||
triggerBtn.click();
|
||||
|
||||
const confirmArea = container.querySelector("[data-testid='delete-account-confirm-area']") as HTMLElement;
|
||||
expect(confirmArea.style.display).toBe("block");
|
||||
|
||||
const cancelBtn = confirmArea.querySelector("button:not(.account-delete-btn)") as HTMLElement;
|
||||
cancelBtn.click();
|
||||
|
||||
expect(confirmArea.style.display).toBe("none");
|
||||
expect(triggerBtn.style.display).toBe("");
|
||||
|
||||
overlay.destroy?.();
|
||||
});
|
||||
|
||||
it("shows error when confirming delete without password", () => {
|
||||
const overlay = createSettingsOverlay(defaultOptions);
|
||||
overlay.mount(container);
|
||||
|
||||
const triggerBtn = container.querySelector("[data-testid='delete-account-trigger']") as HTMLElement;
|
||||
triggerBtn.click();
|
||||
|
||||
const confirmBtn = container.querySelector("[data-testid='delete-account-confirm']") as HTMLElement;
|
||||
confirmBtn.click();
|
||||
|
||||
const errorEl = container.querySelector("[data-testid='delete-account-error']") as HTMLElement;
|
||||
expect(errorEl.textContent).toBe("Password is required.");
|
||||
expect(defaultOptions.onDeleteAccount).not.toHaveBeenCalled();
|
||||
|
||||
overlay.destroy?.();
|
||||
});
|
||||
|
||||
it("calls onDeleteAccount with password on confirm", () => {
|
||||
const overlay = createSettingsOverlay(defaultOptions);
|
||||
overlay.mount(container);
|
||||
|
||||
const triggerBtn = container.querySelector("[data-testid='delete-account-trigger']") as HTMLElement;
|
||||
triggerBtn.click();
|
||||
|
||||
const passwordInput = container.querySelector("[data-testid='delete-account-password']") as HTMLInputElement;
|
||||
passwordInput.value = "mypassword123";
|
||||
|
||||
const confirmBtn = container.querySelector("[data-testid='delete-account-confirm']") as HTMLButtonElement;
|
||||
confirmBtn.click();
|
||||
|
||||
expect(defaultOptions.onDeleteAccount).toHaveBeenCalledWith("mypassword123");
|
||||
|
||||
overlay.destroy?.();
|
||||
});
|
||||
|
||||
it("disables confirm button and shows 'Deleting...' during delete", () => {
|
||||
const overlay = createSettingsOverlay(defaultOptions);
|
||||
overlay.mount(container);
|
||||
|
||||
const triggerBtn = container.querySelector("[data-testid='delete-account-trigger']") as HTMLElement;
|
||||
triggerBtn.click();
|
||||
|
||||
const passwordInput = container.querySelector("[data-testid='delete-account-password']") as HTMLInputElement;
|
||||
passwordInput.value = "mypassword123";
|
||||
|
||||
const confirmBtn = container.querySelector("[data-testid='delete-account-confirm']") as HTMLButtonElement;
|
||||
confirmBtn.click();
|
||||
|
||||
expect(confirmBtn.disabled).toBe(true);
|
||||
expect(confirmBtn.textContent).toBe("Deleting...");
|
||||
|
||||
overlay.destroy?.();
|
||||
});
|
||||
|
||||
it("shows error and re-enables button on delete failure", async () => {
|
||||
const failOptions = {
|
||||
...defaultOptions,
|
||||
onDeleteAccount: vi.fn().mockRejectedValue(new Error("Wrong password")),
|
||||
};
|
||||
|
||||
const overlay = createSettingsOverlay(failOptions);
|
||||
overlay.mount(container);
|
||||
|
||||
const triggerBtn = container.querySelector("[data-testid='delete-account-trigger']") as HTMLElement;
|
||||
triggerBtn.click();
|
||||
|
||||
const passwordInput = container.querySelector("[data-testid='delete-account-password']") as HTMLInputElement;
|
||||
passwordInput.value = "wrongpassword";
|
||||
|
||||
const confirmBtn = container.querySelector("[data-testid='delete-account-confirm']") as HTMLButtonElement;
|
||||
confirmBtn.click();
|
||||
|
||||
// Wait for the rejected promise to settle
|
||||
await vi.waitFor(() => {
|
||||
expect(confirmBtn.disabled).toBe(false);
|
||||
});
|
||||
|
||||
const errorEl = container.querySelector("[data-testid='delete-account-error']") as HTMLElement;
|
||||
expect(errorEl.textContent).toBe("Wrong password");
|
||||
expect(confirmBtn.textContent).toBe("Confirm Delete");
|
||||
|
||||
overlay.destroy?.();
|
||||
});
|
||||
|
||||
it("clears password input when reopening confirmation area", () => {
|
||||
const overlay = createSettingsOverlay(defaultOptions);
|
||||
overlay.mount(container);
|
||||
|
||||
const triggerBtn = container.querySelector("[data-testid='delete-account-trigger']") as HTMLElement;
|
||||
triggerBtn.click();
|
||||
|
||||
const passwordInput = container.querySelector("[data-testid='delete-account-password']") as HTMLInputElement;
|
||||
passwordInput.value = "typed-something";
|
||||
|
||||
// Cancel and reopen
|
||||
const confirmArea = container.querySelector("[data-testid='delete-account-confirm-area']") as HTMLElement;
|
||||
const cancelBtn = confirmArea.querySelector("button:not(.account-delete-btn)") as HTMLElement;
|
||||
cancelBtn.click();
|
||||
triggerBtn.click();
|
||||
|
||||
expect(passwordInput.value).toBe("");
|
||||
|
||||
overlay.destroy?.();
|
||||
});
|
||||
|
||||
// --- Open/Close ---
|
||||
|
||||
it("open() adds .open class, close() removes it", () => {
|
||||
|
||||
Reference in New Issue
Block a user