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:
jevb
2026-03-29 19:40:11 +02:00
parent 6b6a6fbea8
commit cdb56f1619
33 changed files with 1643 additions and 394 deletions
+4
View File
@@ -5,6 +5,10 @@ Server/.claude/
# Claude Code skills
skills/
\
#github copilot
.github/
# AI-specific / internal planning docs
Obsidian-Brain/
+80
View File
@@ -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",
],
},
);
+1280 -18
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -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 {
+1 -1
View File
@@ -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";
+39 -301
View File
@@ -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[] = [];
+1 -4
View File
@@ -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;
+5 -4
View File
@@ -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", {
+3 -3
View File
@@ -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);
}
+4 -6
View File
@@ -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
+1 -1
View File
@@ -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", () => {