From 827eea77ed17903333015f11c4ea818b36dbbd6e Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:31:17 +0200 Subject: [PATCH 1/5] feat(voice): surface voice-session + E2EE status and freeze controls on WS reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a store-backed voice.store.voiceStatus (idle | joining | securing | connected | reconnecting), written as the single source of truth from livekitSession at each lifecycle transition: joining at connectAndSetup start, securing when ECDH key exchange begins, connected on the connected transition (initial join and auto-reconnect), reconnecting when the room drops, idle on leaveVoice. joinVoiceChannel seeds joining optimistically on click. VoiceWidget renders the phase in its header: 'Connecting…' / 'Securing…' (amber) and a persistent '🔒 Secured' badge once the room key is ready, replacing the log-line-only E2EE feedback. While ui.store.connectionStatus is not 'connected', the widget disables its controls with a 'Reconnecting…' / 'Not connected' reason, and the VoiceCallbacks join/leave paths refuse to send over a down socket. LiveKit's own reconnection machinery is untouched. Co-Authored-By: Claude Fable 5 --- .../src/components/VoiceWidget.ts | 87 ++++++++++++++++--- Client/tauri-client/src/lib/livekitSession.ts | 12 +++ .../src/pages/main-page/VoiceCallbacks.ts | 12 +++ Client/tauri-client/src/stores/voice.store.ts | 34 +++++++- Client/tauri-client/src/styles/app.css | 9 ++ 5 files changed, 140 insertions(+), 14 deletions(-) diff --git a/Client/tauri-client/src/components/VoiceWidget.ts b/Client/tauri-client/src/components/VoiceWidget.ts index f73ec764..6abbcaf0 100644 --- a/Client/tauri-client/src/components/VoiceWidget.ts +++ b/Client/tauri-client/src/components/VoiceWidget.ts @@ -9,8 +9,9 @@ import { createElement, appendChildren, setText } from "@lib/dom"; import { createIcon, createSignalIcon } from "@lib/icons"; import type { IconName } from "@lib/icons"; import type { MountableComponent } from "@lib/safe-render"; -import { voiceStore } from "@stores/voice.store"; +import { voiceStore, type VoiceStatus } from "@stores/voice.store"; import { channelsStore } from "@stores/channels.store"; +import { uiStore } from "@stores/ui.store"; import { createConnectionStatsPoller, formatBytes, @@ -44,6 +45,16 @@ const QUALITY_BARS: Record = { bad: 1, }; +/** Header status text per voice-session lifecycle state + * (docs/architecture/ux/voice-and-e2ee.md §2). */ +const STATUS_LABELS: Record = { + idle: "Voice Connected", + joining: "Connecting…", + securing: "Securing…", + connected: "Voice Connected", + reconnecting: "Reconnecting voice…", +}; + /** Format milliseconds elapsed into HH:MM:SS or MM:SS. */ function formatElapsed(ms: number): string { const totalSec = Math.floor(ms / 1000); @@ -59,10 +70,14 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone const ac = new AbortController(); let root: HTMLDivElement | null = null; let channelNameEl: HTMLSpanElement | null = null; + let statusLabel: HTMLSpanElement | null = null; + let securedBadge: HTMLSpanElement | null = null; + let controlsRow: HTMLDivElement | null = null; let muteBtn: HTMLButtonElement | null = null; let deafenBtn: HTMLButtonElement | null = null; let cameraBtn: HTMLButtonElement | null = null; let shareBtn: HTMLButtonElement | null = null; + let disconnectBtn: HTMLButtonElement | null = null; // Listen-only mode: "Grant Microphone" button let grantMicBtn: HTMLButtonElement | null = null; @@ -168,6 +183,33 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone if (timerEl !== null) setText(timerEl, "00:00"); } + /** Header E2EE status: dynamic label + a persistent "secured" lock once the + * room key is ready (docs/architecture/ux/voice-and-e2ee.md §2). */ + function updateStatus(status: VoiceStatus): void { + if (statusLabel !== null) { + setText(statusLabel, STATUS_LABELS[status]); + statusLabel.classList.toggle("vw-securing", status === "securing"); + statusLabel.classList.toggle("vw-reconnecting", status === "reconnecting"); + } + if (securedBadge !== null) { + securedBadge.style.display = status === "connected" ? "inline-flex" : "none"; + } + } + + /** Freeze voice controls while the WS socket is not live — the controls would + * otherwise send over a down socket (docs/architecture/ux/README.md §3). + * LiveKit's own reconnect keeps retrying underneath; we only gate the UI. */ + function updateFrozen(status: "connected" | "reconnecting" | "disconnected"): void { + const frozen = status !== "connected"; + const reason = status === "reconnecting" ? "Reconnecting…" : "Not connected"; + controlsRow?.classList.toggle("vw-controls--frozen", frozen); + for (const btn of [muteBtn, deafenBtn, cameraBtn, shareBtn, disconnectBtn, grantMicBtn]) { + if (btn === null) continue; + btn.disabled = frozen; + btn.title = frozen ? reason : ""; + } + } + function render(): void { if (root === null || channelNameEl === null) return; @@ -185,6 +227,8 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone root.classList.add("visible"); startStatsPoller(); startElapsedTimer(); + updateStatus(voice.voiceStatus); + updateFrozen(uiStore.getState().connectionStatus); // Channel name const channel = channelsStore.getState().channels.get(channelId); @@ -244,9 +288,21 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone function mount(container: Element): void { root = createElement("div", { class: "voice-widget", "data-testid": "voice-widget" }); - // Header row: "Voice Connected" + channel name + signal icon + // Header row: lifecycle status + secured lock + channel name + signal icon const header = createElement("div", { class: "vw-header" }); - const connLabel = createElement("span", { class: "vw-connected" }, "Voice Connected"); + statusLabel = createElement("span", { + class: "vw-connected", + "data-testid": "vw-status", + }); + setText(statusLabel, STATUS_LABELS.connected); + // Persistent E2EE affirmation, shown only once the room key is ready. + securedBadge = createElement("span", { + class: "vw-secured", + "data-testid": "vw-secured", + title: "End-to-end encrypted", + }); + setText(securedBadge, "🔒 Secured"); + securedBadge.style.display = "none"; timerEl = createElement("span", { class: "vw-timer" }, "00:00"); channelNameEl = createElement("span", { class: "vw-channel" }, "Voice Channel"); @@ -263,7 +319,7 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone { signal: ac.signal }, ); - appendChildren(header, connLabel, timerEl, channelNameEl, signalWrap); + appendChildren(header, statusLabel, securedBadge, timerEl, channelNameEl, signalWrap); // Expanded stats pane (hidden by default) statsPane = createElement("div", { class: "vw-stats" }); @@ -326,6 +382,7 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone // Controls row const controls = createElement("div", { class: "vw-controls" }); + controlsRow = controls; muteBtn = createControlButton("Mute", "mic", options.onMuteToggle); deafenBtn = createControlButton("Deafen", "headphones", options.onDeafenToggle); cameraBtn = createControlButton("Camera", "camera", options.onCameraToggle); @@ -338,12 +395,7 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone const shareLabelSpan = createElement("span", { class: "vw-share-label" }); shareLabelSpan.style.display = "none"; shareBtn.appendChild(shareLabelSpan); - const disconnectBtn = createControlButton( - "Disconnect", - "phone", - options.onDisconnect, - "disconnect", - ); + disconnectBtn = createControlButton("Disconnect", "phone", options.onDisconnect, "disconnect"); appendChildren(controls, muteBtn, deafenBtn, cameraBtn, shareBtn, disconnectBtn); // "Grant Microphone" button for listen-only mode @@ -386,6 +438,7 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone camera: s.localCamera, screenshare: s.localScreenshare, listenOnly: s.listenOnly, + voiceStatus: s.voiceStatus, }), () => render(), (a, b) => @@ -394,7 +447,15 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone a.deafened === b.deafened && a.camera === b.camera && a.screenshare === b.screenshare && - a.listenOnly === b.listenOnly, + a.listenOnly === b.listenOnly && + a.voiceStatus === b.voiceStatus, + ), + ); + // Freeze controls reactively when the WS socket drops (§3 connection status). + unsubs.push( + uiStore.subscribeSelector( + (s) => s.connectionStatus, + () => render(), ), ); unsubs.push( @@ -418,10 +479,14 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone root?.remove(); root = null; channelNameEl = null; + statusLabel = null; + securedBadge = null; + controlsRow = null; muteBtn = null; deafenBtn = null; cameraBtn = null; shareBtn = null; + disconnectBtn = null; grantMicBtn = null; signalWrap = null; pingLabel = null; diff --git a/Client/tauri-client/src/lib/livekitSession.ts b/Client/tauri-client/src/lib/livekitSession.ts index 669ed9f9..3af6682d 100644 --- a/Client/tauri-client/src/lib/livekitSession.ts +++ b/Client/tauri-client/src/lib/livekitSession.ts @@ -9,6 +9,7 @@ import { setLocalScreenshare, leaveVoiceChannel, setListenOnly, + setVoiceStatus, } from "@stores/voice.store"; import { authStore } from "@stores/auth.store"; import { loadPref } from "@components/settings/helpers"; @@ -292,6 +293,7 @@ export class LiveKitSession { lastDirectUrl, ac, }); + setVoiceStatus("reconnecting"); } // ac === null: reconnect succeeded — connectAndSetup already set "connected". // No transition needed; just discard stale pending fields if any. @@ -486,6 +488,7 @@ export class LiveKitSession { lastUrl: url, lastDirectUrl: directUrl, }); + setVoiceStatus("connected"); this._deviceManager.setOnError(this.onErrorCallback); this._deviceManager.setOnToast(this.onErrorCallback); logIceConnectionInfo(newRoom); @@ -803,6 +806,8 @@ export class LiveKitSession { const prevGeneration = prevState.type === "connecting" ? prevState.joinGeneration : 0; const myGeneration = prevGeneration + 1; this.setState({ type: "connecting", pendingJoin: null, joinGeneration: myGeneration }); + // "joining" = connecting to the room; the E2EE "securing" phase is set below. + setVoiceStatus("joining"); let resolvedUrl = ""; // Track the room being built in this attempt so we can disconnect it on // supersession without touching the shared state (which may already have @@ -832,6 +837,10 @@ export class LiveKitSession { const RETRY_DELAY_MS = 2000; // ── Client-side E2EE key exchange (ECDH) ────────────────────────── + // "securing" — until the room key is ready the call is not yet private. + // Non-key-holders block here waiting for the key holder's offer (up to + // ~15s); key holders pass through near-instantly. + setVoiceStatus("securing"); // Generate a fresh ECDH keypair for this session. this._ecdhKeyPair = await generateECDHKeyPair(); this._peerPublicKeys.clear(); @@ -999,6 +1008,8 @@ export class LiveKitSession { lastUrl: url, lastDirectUrl: directUrl, }); + // Room connected and E2EE key ready — the call is now secured. + setVoiceStatus("connected"); // Optimistic startAudio — may succeed if the join was triggered by a // recent user gesture. If not, the AudioPlaybackStatusChanged handler // will register a click-to-unlock fallback. @@ -1500,6 +1511,7 @@ export class LiveKitSession { // pendingJoin, and the joinGeneration (idle has none). Any in-flight // connectAndSetup() will detect the state type change at its next checkpoint. this.setState({ type: "idle" }); + setVoiceStatus("idle"); this.syncModuleRooms(); setLocalCamera(false); setLocalScreenshare(false); diff --git a/Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts b/Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts index 1899253a..4f80887b 100644 --- a/Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts +++ b/Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts @@ -6,6 +6,7 @@ import { createLogger } from "@lib/logger"; import type { WsClient } from "@lib/ws"; import { voiceStore, joinVoiceChannel, leaveVoiceChannel } from "@stores/voice.store"; +import { uiStore } from "@stores/ui.store"; import { leaveVoice as voiceSessionLeave, setMuted as voiceSessionSetMuted, @@ -18,6 +19,14 @@ import { const log = createLogger("voice-callbacks"); +/** Voice join/leave send over the WS socket; refuse when it's not live so we + * never fire voice_join/voice_leave into a down socket. The VoiceWidget freezes + * its controls with a visible reason (docs/architecture/ux/README.md §3); this + * is the defensive backstop for the sidebar join/leave path. */ +function socketLive(): boolean { + return uiStore.getState().connectionStatus === "connected"; +} + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -51,6 +60,7 @@ export function createVoiceWidgetCallbacks( return { onDisconnect: () => { if (voiceStore.getState().currentChannelId === null) return; + if (!socketLive()) return; log.info("Leaving voice channel (widget disconnect)"); voiceSessionLeave(false); leaveVoiceChannel(); @@ -122,11 +132,13 @@ export function createVoiceWidgetCallbacks( export function createSidebarVoiceCallbacks(ws: WsClient): SidebarVoiceCallbacks { return { onVoiceJoin: (channelId: number) => { + if (!socketLive()) return; log.info("Joining voice channel", { channelId }); joinVoiceChannel(channelId); ws.send({ type: "voice_join", payload: { channel_id: channelId } }); }, onVoiceLeave: () => { + if (!socketLive()) return; log.info("Leaving voice channel"); voiceSessionLeave(false); leaveVoiceChannel(); diff --git a/Client/tauri-client/src/stores/voice.store.ts b/Client/tauri-client/src/stores/voice.store.ts index 7e816e99..2dbb28f4 100644 --- a/Client/tauri-client/src/stores/voice.store.ts +++ b/Client/tauri-client/src/stores/voice.store.ts @@ -24,6 +24,12 @@ export interface VoiceUser { readonly screenshare: boolean; } +/** Observable voice-session lifecycle status, surfaced so the UI can + * distinguish "connecting to the room" (joining) from "securing E2EE" + * (securing) from a live, encrypted call (connected). Written from + * livekitSession.ts. See docs/architecture/ux/voice-and-e2ee.md §1–2. */ +export type VoiceStatus = "idle" | "joining" | "securing" | "connected" | "reconnecting"; + export interface VoiceConfig { readonly quality: string; readonly bitrate: number; @@ -45,6 +51,9 @@ export interface VoiceState { readonly joinedAt: number | null; /** True when joined in listen-only mode (mic permission denied or no mic found). */ readonly listenOnly: boolean; + /** Voice-session lifecycle status (drives the widget's connecting/securing/ + * secured indicators). Written from livekitSession.ts. */ + readonly voiceStatus: VoiceStatus; } const INITIAL_STATE: VoiceState = { @@ -57,6 +66,7 @@ const INITIAL_STATE: VoiceState = { localScreenshare: false, joinedAt: null, listenOnly: false, + voiceStatus: "idle", }; export const voiceStore = createStore(INITIAL_STATE); @@ -73,6 +83,7 @@ export function resetVoiceStore(): void { localScreenshare: false, joinedAt: null, listenOnly: false, + voiceStatus: "idle", })); } @@ -173,6 +184,9 @@ export function joinVoiceChannel(channelId: number): void { ...prev, currentChannelId: channelId, joinedAt: Date.now(), + // Optimistic: the widget shows "Connecting…" the moment the user clicks, + // before the voice_token round-trip. livekitSession advances it from here. + voiceStatus: "joining", }; }); } @@ -183,11 +197,11 @@ export function leaveVoiceChannel(): void { voiceStore.setState((prev) => { const channelId = prev.currentChannelId; if (channelId === null || currentUserId === 0) { - return { ...prev, currentChannelId: null, joinedAt: null }; + return { ...prev, currentChannelId: null, joinedAt: null, voiceStatus: "idle" }; } const existingChannel = prev.voiceUsers.get(channelId); if (!existingChannel || !existingChannel.has(currentUserId)) { - return { ...prev, currentChannelId: null, joinedAt: null }; + return { ...prev, currentChannelId: null, joinedAt: null, voiceStatus: "idle" }; } const nextChannels = new Map(prev.voiceUsers); const nextUsers = new Map(existingChannel); @@ -197,10 +211,24 @@ export function leaveVoiceChannel(): void { } else { nextChannels.set(channelId, nextUsers); } - return { ...prev, currentChannelId: null, joinedAt: null, voiceUsers: nextChannels }; + return { + ...prev, + currentChannelId: null, + joinedAt: null, + voiceStatus: "idle", + voiceUsers: nextChannels, + }; }); } +/** Set the voice-session lifecycle status. Single-writer from livekitSession.ts + * at each connection-lifecycle transition. */ +export function setVoiceStatus(status: VoiceStatus): void { + voiceStore.setState((prev) => + prev.voiceStatus === status ? prev : { ...prev, voiceStatus: status }, + ); +} + /** Toggle local mute state. */ export function setLocalMuted(muted: boolean): void { voiceStore.setState((prev) => ({ diff --git a/Client/tauri-client/src/styles/app.css b/Client/tauri-client/src/styles/app.css index 36d42cc0..6fa844e5 100644 --- a/Client/tauri-client/src/styles/app.css +++ b/Client/tauri-client/src/styles/app.css @@ -217,8 +217,17 @@ .voice-widget.visible { display: block; } .vw-header { display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 12px; } .vw-connected { color: var(--green); font-weight: 700; } +/* In-progress lifecycle states use amber; the call is not yet secured/live. */ +.vw-connected.vw-securing, .vw-connected.vw-reconnecting { color: var(--yellow, #f0b232); } +.vw-secured { + display: inline-flex; align-items: center; gap: 3px; + color: var(--green); font-size: 11px; font-weight: 600; +} .vw-timer { color: var(--green); font-size: 11px; opacity: 0.8; font-variant-numeric: tabular-nums; } .vw-channel { color: var(--text-muted); } +/* Frozen while the WS socket is down: controls are visibly non-interactive. */ +.vw-controls--frozen button { opacity: 0.5; cursor: not-allowed; } +.vw-controls--frozen button:hover { background: var(--bg-active); color: var(--text-muted); } .vw-controls { display: flex; gap: 4px; padding: 4px 4px 0; } .vw-controls button { flex: 1; height: 32px; border-radius: var(--radius-sm); From ff55da71616aa32a3001d84e9f83f090da7dfcd6 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:31:27 +0200 Subject: [PATCH 2/5] test(voice): pin voiceStatus transitions, widget indicators, and control freeze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - livekit-session: assert joining→securing→connected on join, idle on leave, connected after auto-reconnect - voice-widget: assert each status label + secured badge visibility, and controls disabled with reason while the socket is down, re-enabled on reconnect - voice-callbacks: assert join/leave/disconnect do not send over a down socket - voice.store: assert join seeds joining, leave resets idle, setter writes status - thread voiceStatus through existing full-state fixtures; keep an active-call socket live in widget fixtures so control-click tests still operate enabled Co-Authored-By: Claude Fable 5 --- .../tauri-client/tests/helpers/test-utils.ts | 1 + .../tests/integration/stores.test.ts | 1 + .../tests/unit/channel-sidebar.test.ts | 6 + .../tests/unit/dispatcher.test.ts | 1 + .../tests/unit/livekit-session.test.ts | 55 ++++++ .../tests/unit/screen-share-button.test.ts | 4 + .../tests/unit/sidebar-area.test.ts | 1 + .../tests/unit/voice-callbacks.test.ts | 41 ++++ .../tests/unit/voice-channel.test.ts | 1 + .../tests/unit/voice-disconnect.test.ts | 1 + .../tests/unit/voice-widget.test.ts | 178 +++++++++++++++++- .../tests/unit/voice.store.test.ts | 24 +++ 12 files changed, 313 insertions(+), 1 deletion(-) diff --git a/Client/tauri-client/tests/helpers/test-utils.ts b/Client/tauri-client/tests/helpers/test-utils.ts index b1841a77..07673da5 100644 --- a/Client/tauri-client/tests/helpers/test-utils.ts +++ b/Client/tauri-client/tests/helpers/test-utils.ts @@ -59,6 +59,7 @@ const VOICE_INITIAL: VoiceState = { localScreenshare: false, joinedAt: null, listenOnly: false, + voiceStatus: "idle", }; const UI_INITIAL: UiState = { diff --git a/Client/tauri-client/tests/integration/stores.test.ts b/Client/tauri-client/tests/integration/stores.test.ts index 35bccf08..cdb9444f 100644 --- a/Client/tauri-client/tests/integration/stores.test.ts +++ b/Client/tauri-client/tests/integration/stores.test.ts @@ -134,6 +134,7 @@ function resetAllStores(): void { localScreenshare: false, joinedAt: null, listenOnly: false, + voiceStatus: "idle", })); authStore.setState(() => ({ token: null, diff --git a/Client/tauri-client/tests/unit/channel-sidebar.test.ts b/Client/tauri-client/tests/unit/channel-sidebar.test.ts index ef66febb..e1d9f3b8 100644 --- a/Client/tauri-client/tests/unit/channel-sidebar.test.ts +++ b/Client/tauri-client/tests/unit/channel-sidebar.test.ts @@ -59,6 +59,7 @@ function resetStores(): void { localScreenshare: false, joinedAt: null, listenOnly: false, + voiceStatus: "idle", })); membersStore.setState(() => ({ members: new Map(), @@ -1072,6 +1073,7 @@ describe("ChannelSidebar", () => { localScreenshare: false, joinedAt: null, listenOnly: false, + voiceStatus: "idle", })); sidebarWithWatch.mount(container); @@ -1116,6 +1118,7 @@ describe("ChannelSidebar", () => { localScreenshare: false, joinedAt: null, listenOnly: false, + voiceStatus: "idle", })); sidebarWithWatch.mount(container); @@ -1166,6 +1169,7 @@ describe("ChannelSidebar", () => { localScreenshare: false, joinedAt: null, listenOnly: false, + voiceStatus: "idle", })); sidebar.mount(container); @@ -1207,6 +1211,7 @@ describe("ChannelSidebar", () => { localScreenshare: false, joinedAt: null, listenOnly: false, + voiceStatus: "idle", })); sidebarWithWatch.mount(container); @@ -1248,6 +1253,7 @@ describe("ChannelSidebar", () => { localScreenshare: false, joinedAt: null, listenOnly: false, + voiceStatus: "idle", })); sidebar.mount(container); diff --git a/Client/tauri-client/tests/unit/dispatcher.test.ts b/Client/tauri-client/tests/unit/dispatcher.test.ts index d38e86d9..bf05bb71 100644 --- a/Client/tauri-client/tests/unit/dispatcher.test.ts +++ b/Client/tauri-client/tests/unit/dispatcher.test.ts @@ -130,6 +130,7 @@ describe("WS Dispatcher", () => { localScreenshare: false, joinedAt: null, listenOnly: false, + voiceStatus: "idle", })); dmStore.setState(() => ({ channels: [] })); uiStore.setState((prev) => ({ ...prev, transientError: null })); diff --git a/Client/tauri-client/tests/unit/livekit-session.test.ts b/Client/tauri-client/tests/unit/livekit-session.test.ts index 3bb345b7..f6b43069 100644 --- a/Client/tauri-client/tests/unit/livekit-session.test.ts +++ b/Client/tauri-client/tests/unit/livekit-session.test.ts @@ -85,6 +85,7 @@ vi.mock("@stores/voice.store", () => ({ setSpeakers: vi.fn(), leaveVoiceChannel: vi.fn(), setListenOnly: vi.fn(), + setVoiceStatus: vi.fn(), })); const mockInvoke = vi.hoisted(() => @@ -149,6 +150,7 @@ import { setLocalScreenshare, setListenOnly, leaveVoiceChannel, + setVoiceStatus, } from "@stores/voice.store"; import { isVoiceConnected, @@ -651,6 +653,59 @@ describe("LiveKitSession", () => { }); }); + describe("voiceStatus transitions (voice-and-e2ee.md §1–2)", () => { + function statusCalls(): string[] { + return (setVoiceStatus as any).mock.calls.map((c: unknown[]) => c[0] as string); + } + + it("writes joining → securing → connected on a successful join", async () => { + session.setServerHost("localhost:7880"); + session.setWsClient({ send: vi.fn() } as any); + (setVoiceStatus as any).mockClear(); + + await session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880", true); + + const calls = statusCalls(); + expect(calls).toContain("joining"); + expect(calls).toContain("securing"); + expect(calls).toContain("connected"); + // Ordering: joining before securing before connected. + expect(calls.indexOf("joining")).toBeLessThan(calls.indexOf("securing")); + expect(calls.indexOf("securing")).toBeLessThan(calls.indexOf("connected")); + }); + + it("writes idle on leaveVoice", () => { + (setVoiceStatus as any).mockClear(); + session.leaveVoice(false); + expect(setVoiceStatus).toHaveBeenCalledWith("idle"); + }); + + it("writes connected after a successful auto-reconnect", async () => { + (session as any)._state = { + type: "reconnecting", + channelId: 7, + latestToken: "reconnect-token", + lastUrl: "/livekit", + lastDirectUrl: "ws://localhost:7880", + ac: new AbortController(), + }; + (setVoiceStatus as any).mockClear(); + + const ac = new AbortController(); + const reconnectPromise = (session as any).attemptAutoReconnect( + "reconnect-token", + "/livekit", + 7, + "ws://localhost:7880", + ac.signal, + ); + await vi.advanceTimersByTimeAsync(3100); + await reconnectPromise; + + expect(setVoiceStatus).toHaveBeenCalledWith("connected"); + }); + }); + describe("handleVoiceTokenRefresh", () => { it("stores the token and restarts the timer", () => { session.handleVoiceTokenRefresh("new-token"); diff --git a/Client/tauri-client/tests/unit/screen-share-button.test.ts b/Client/tauri-client/tests/unit/screen-share-button.test.ts index 51548122..a0661fc0 100644 --- a/Client/tauri-client/tests/unit/screen-share-button.test.ts +++ b/Client/tauri-client/tests/unit/screen-share-button.test.ts @@ -36,6 +36,7 @@ import { createVoiceWidget } from "@components/VoiceWidget"; import { voiceStore } from "@stores/voice.store"; import { channelsStore } from "@stores/channels.store"; import { membersStore } from "@stores/members.store"; +import { setConnectionStatus } from "@stores/ui.store"; function resetStores(): void { voiceStore.setState(() => ({ @@ -48,6 +49,7 @@ function resetStores(): void { localScreenshare: false, joinedAt: null, listenOnly: false, + voiceStatus: "idle", })); channelsStore.setState(() => ({ channels: new Map(), @@ -58,6 +60,8 @@ function resetStores(): void { members: new Map(), typingUsers: new Map(), })); + // Voice controls freeze while the socket is down; an active call is live. + setConnectionStatus("connected"); } function setVoiceConnected(screenshare = false): void { diff --git a/Client/tauri-client/tests/unit/sidebar-area.test.ts b/Client/tauri-client/tests/unit/sidebar-area.test.ts index 2d7c5a78..c78275f5 100644 --- a/Client/tauri-client/tests/unit/sidebar-area.test.ts +++ b/Client/tauri-client/tests/unit/sidebar-area.test.ts @@ -215,6 +215,7 @@ function resetStores(): void { localScreenshare: false, joinedAt: null, listenOnly: false, + voiceStatus: "idle", })); localStorage.removeItem("owncord:member-list-height"); localStorage.removeItem("owncord:member-list-collapsed"); diff --git a/Client/tauri-client/tests/unit/voice-callbacks.test.ts b/Client/tauri-client/tests/unit/voice-callbacks.test.ts index 7577854c..12c53794 100644 --- a/Client/tauri-client/tests/unit/voice-callbacks.test.ts +++ b/Client/tauri-client/tests/unit/voice-callbacks.test.ts @@ -15,6 +15,7 @@ const { mockDisableCamera, mockEnableScreenshare, mockDisableScreenshare, + mockUiGetState, } = vi.hoisted(() => ({ mockVoiceStoreGetState: vi.fn(), mockJoinVoiceChannel: vi.fn(), @@ -26,6 +27,7 @@ const { mockDisableCamera: vi.fn(() => Promise.resolve()), mockEnableScreenshare: vi.fn(() => Promise.resolve()), mockDisableScreenshare: vi.fn(() => Promise.resolve()), + mockUiGetState: vi.fn(() => ({ connectionStatus: "connected" })), })); vi.mock("@lib/logger", () => ({ @@ -43,6 +45,10 @@ vi.mock("@stores/voice.store", () => ({ leaveVoiceChannel: mockLeaveVoiceChannel, })); +vi.mock("@stores/ui.store", () => ({ + uiStore: { getState: mockUiGetState }, +})); + vi.mock("@lib/livekitSession", () => ({ leaveVoice: mockVoiceSessionLeave, setMuted: mockSetMuted, @@ -105,6 +111,7 @@ describe("createVoiceWidgetCallbacks", () => { beforeEach(() => { vi.clearAllMocks(); mockVoiceStoreGetState.mockReturnValue(makeVoiceState()); + mockUiGetState.mockReturnValue({ connectionStatus: "connected" }); }); describe("onDisconnect", () => { @@ -128,6 +135,17 @@ describe("createVoiceWidgetCallbacks", () => { expect(ws.send).not.toHaveBeenCalled(); }); + + it("does not send over a down socket while reconnecting", () => { + mockUiGetState.mockReturnValue({ connectionStatus: "reconnecting" }); + const ws = makeWs(); + const cbs = createVoiceWidgetCallbacks(ws, makeLimiters()); + + cbs.onDisconnect(); + + expect(mockVoiceSessionLeave).not.toHaveBeenCalled(); + expect(ws.send).not.toHaveBeenCalled(); + }); }); describe("onMuteToggle", () => { @@ -276,6 +294,7 @@ describe("createVoiceWidgetCallbacks", () => { describe("createSidebarVoiceCallbacks", () => { beforeEach(() => { vi.clearAllMocks(); + mockUiGetState.mockReturnValue({ connectionStatus: "connected" }); }); it("onVoiceJoin sends voice_join and updates store", () => { @@ -301,4 +320,26 @@ describe("createSidebarVoiceCallbacks", () => { expect(mockLeaveVoiceChannel).toHaveBeenCalled(); expect(ws.send).toHaveBeenCalledWith({ type: "voice_leave", payload: {} }); }); + + it("onVoiceJoin does not send over a down socket", () => { + mockUiGetState.mockReturnValue({ connectionStatus: "disconnected" }); + const ws = makeWs(); + const cbs = createSidebarVoiceCallbacks(ws); + + cbs.onVoiceJoin(42); + + expect(mockJoinVoiceChannel).not.toHaveBeenCalled(); + expect(ws.send).not.toHaveBeenCalled(); + }); + + it("onVoiceLeave does not send over a down socket", () => { + mockUiGetState.mockReturnValue({ connectionStatus: "reconnecting" }); + const ws = makeWs(); + const cbs = createSidebarVoiceCallbacks(ws); + + cbs.onVoiceLeave(); + + expect(mockVoiceSessionLeave).not.toHaveBeenCalled(); + expect(ws.send).not.toHaveBeenCalled(); + }); }); diff --git a/Client/tauri-client/tests/unit/voice-channel.test.ts b/Client/tauri-client/tests/unit/voice-channel.test.ts index f62de44d..209409c6 100644 --- a/Client/tauri-client/tests/unit/voice-channel.test.ts +++ b/Client/tauri-client/tests/unit/voice-channel.test.ts @@ -33,6 +33,7 @@ function resetStores(): void { localScreenshare: false, joinedAt: null, listenOnly: false, + voiceStatus: "idle", })); membersStore.setState(() => ({ members: new Map(), diff --git a/Client/tauri-client/tests/unit/voice-disconnect.test.ts b/Client/tauri-client/tests/unit/voice-disconnect.test.ts index 44d40a10..8fcb4a7a 100644 --- a/Client/tauri-client/tests/unit/voice-disconnect.test.ts +++ b/Client/tauri-client/tests/unit/voice-disconnect.test.ts @@ -23,6 +23,7 @@ function resetStores(): void { localScreenshare: false, joinedAt: null, listenOnly: false, + voiceStatus: "idle", })); authStore.setState(() => ({ token: null, diff --git a/Client/tauri-client/tests/unit/voice-widget.test.ts b/Client/tauri-client/tests/unit/voice-widget.test.ts index 32a15be9..1ccc7631 100644 --- a/Client/tauri-client/tests/unit/voice-widget.test.ts +++ b/Client/tauri-client/tests/unit/voice-widget.test.ts @@ -34,9 +34,10 @@ vi.mock("@lib/connectionStats", () => ({ })); import { createVoiceWidget } from "../../src/components/VoiceWidget"; -import { voiceStore } from "../../src/stores/voice.store"; +import { voiceStore, type VoiceStatus } from "../../src/stores/voice.store"; import { channelsStore } from "../../src/stores/channels.store"; import { membersStore } from "../../src/stores/members.store"; +import { uiStore, setConnectionStatus } from "../../src/stores/ui.store"; import type { VoiceUser } from "../../src/stores/voice.store"; function resetStores(): void { @@ -50,6 +51,7 @@ function resetStores(): void { localScreenshare: false, joinedAt: null, listenOnly: false, + voiceStatus: "idle", })); channelsStore.setState(() => ({ channels: new Map(), @@ -60,6 +62,14 @@ function resetStores(): void { members: new Map(), typingUsers: new Map(), })); + // Voice controls freeze when the socket is down; keep it live by default so + // the interaction tests below operate on enabled controls. + setConnectionStatus("connected"); +} + +function setVoiceStatus(status: VoiceStatus): void { + voiceStore.setState((prev) => ({ ...prev, voiceStatus: status })); + voiceStore.flush(); } function setVoiceChannel(channelId: number, users: VoiceUser[]): void { @@ -624,4 +634,170 @@ describe("VoiceWidget", () => { widget.destroy?.(); }); + + // --- E2EE / voice-session status (docs/architecture/ux/voice-and-e2ee.md §2) --- + + it("shows 'Connecting…' during the joining phase, no secured badge", () => { + setVoiceChannel(1, []); + + const widget = createVoiceWidget({ + onDisconnect: vi.fn(), + onMuteToggle: vi.fn(), + onDeafenToggle: vi.fn(), + onCameraToggle: vi.fn(), + onScreenshareToggle: vi.fn(), + }); + widget.mount(container); + + setVoiceStatus("joining"); + + const status = container.querySelector('[data-testid="vw-status"]'); + const secured = container.querySelector('[data-testid="vw-secured"]') as HTMLElement; + expect(status?.textContent).toBe("Connecting…"); + expect(secured.style.display).toBe("none"); + + widget.destroy?.(); + }); + + it("shows 'Securing…' while E2EE key exchange runs, no secured badge yet", () => { + setVoiceChannel(1, []); + + const widget = createVoiceWidget({ + onDisconnect: vi.fn(), + onMuteToggle: vi.fn(), + onDeafenToggle: vi.fn(), + onCameraToggle: vi.fn(), + onScreenshareToggle: vi.fn(), + }); + widget.mount(container); + + setVoiceStatus("securing"); + + const status = container.querySelector('[data-testid="vw-status"]') as HTMLElement; + const secured = container.querySelector('[data-testid="vw-secured"]') as HTMLElement; + expect(status.textContent).toBe("Securing…"); + expect(status.classList.contains("vw-securing")).toBe(true); + expect(secured.style.display).toBe("none"); + + widget.destroy?.(); + }); + + it("shows a persistent secured indicator once connected", () => { + setVoiceChannel(1, []); + + const widget = createVoiceWidget({ + onDisconnect: vi.fn(), + onMuteToggle: vi.fn(), + onDeafenToggle: vi.fn(), + onCameraToggle: vi.fn(), + onScreenshareToggle: vi.fn(), + }); + widget.mount(container); + + setVoiceStatus("connected"); + + const status = container.querySelector('[data-testid="vw-status"]'); + const secured = container.querySelector('[data-testid="vw-secured"]') as HTMLElement; + expect(status?.textContent).toBe("Voice Connected"); + expect(secured.style.display).toBe("inline-flex"); + expect(secured.textContent).toContain("Secured"); + + widget.destroy?.(); + }); + + it("shows 'Reconnecting voice…' during a voice reconnect", () => { + setVoiceChannel(1, []); + + const widget = createVoiceWidget({ + onDisconnect: vi.fn(), + onMuteToggle: vi.fn(), + onDeafenToggle: vi.fn(), + onCameraToggle: vi.fn(), + onScreenshareToggle: vi.fn(), + }); + widget.mount(container); + + setVoiceStatus("reconnecting"); + + const status = container.querySelector('[data-testid="vw-status"]') as HTMLElement; + const secured = container.querySelector('[data-testid="vw-secured"]') as HTMLElement; + expect(status.textContent).toBe("Reconnecting voice…"); + expect(status.classList.contains("vw-reconnecting")).toBe(true); + expect(secured.style.display).toBe("none"); + + widget.destroy?.(); + }); + + // --- Freeze controls during WS reconnect (docs/architecture/ux/README.md §3) --- + + it("disables voice controls with a reason while the WS socket is reconnecting", () => { + setVoiceChannel(1, []); + + const widget = createVoiceWidget({ + onDisconnect: vi.fn(), + onMuteToggle: vi.fn(), + onDeafenToggle: vi.fn(), + onCameraToggle: vi.fn(), + onScreenshareToggle: vi.fn(), + }); + widget.mount(container); + + setConnectionStatus("reconnecting"); + uiStore.flush(); + + const muteBtn = container.querySelector('[aria-label="Mute"]') as HTMLButtonElement; + const disconnectBtn = container.querySelector('[aria-label="Disconnect"]') as HTMLButtonElement; + expect(muteBtn.disabled).toBe(true); + expect(muteBtn.title).toBe("Reconnecting…"); + expect(disconnectBtn.disabled).toBe(true); + + widget.destroy?.(); + }); + + it("shows 'Not connected' reason while the WS socket is disconnected", () => { + setVoiceChannel(1, []); + + const widget = createVoiceWidget({ + onDisconnect: vi.fn(), + onMuteToggle: vi.fn(), + onDeafenToggle: vi.fn(), + onCameraToggle: vi.fn(), + onScreenshareToggle: vi.fn(), + }); + widget.mount(container); + + setConnectionStatus("disconnected"); + uiStore.flush(); + + const muteBtn = container.querySelector('[aria-label="Mute"]') as HTMLButtonElement; + expect(muteBtn.disabled).toBe(true); + expect(muteBtn.title).toBe("Not connected"); + + widget.destroy?.(); + }); + + it("re-enables voice controls when the WS socket reconnects", () => { + setVoiceChannel(1, []); + + const widget = createVoiceWidget({ + onDisconnect: vi.fn(), + onMuteToggle: vi.fn(), + onDeafenToggle: vi.fn(), + onCameraToggle: vi.fn(), + onScreenshareToggle: vi.fn(), + }); + widget.mount(container); + + setConnectionStatus("reconnecting"); + uiStore.flush(); + const muteBtn = container.querySelector('[aria-label="Mute"]') as HTMLButtonElement; + expect(muteBtn.disabled).toBe(true); + + setConnectionStatus("connected"); + uiStore.flush(); + expect(muteBtn.disabled).toBe(false); + expect(muteBtn.title).toBe(""); + + widget.destroy?.(); + }); }); diff --git a/Client/tauri-client/tests/unit/voice.store.test.ts b/Client/tauri-client/tests/unit/voice.store.test.ts index 61fe6e10..0f22daf0 100644 --- a/Client/tauri-client/tests/unit/voice.store.test.ts +++ b/Client/tauri-client/tests/unit/voice.store.test.ts @@ -16,6 +16,7 @@ import { setSpeakers, setVoiceConfig, getChannelVoiceUsers, + setVoiceStatus, } from "../../src/stores/voice.store"; import type { ReadyVoiceState, VoiceStatePayload, VoiceLeavePayload } from "../../src/lib/types"; import { authStore } from "../../src/stores/auth.store"; @@ -31,6 +32,7 @@ function resetStore(): void { localScreenshare: false, joinedAt: null, listenOnly: false, + voiceStatus: "idle", })); } @@ -476,6 +478,28 @@ describe("voice store", () => { }); }); + describe("voiceStatus", () => { + it("seeds 'joining' optimistically on a fresh join", () => { + expect(voiceStore.getState().voiceStatus).toBe("idle"); + joinVoiceChannel(42); + expect(voiceStore.getState().voiceStatus).toBe("joining"); + }); + + it("resets to 'idle' on leaveVoiceChannel", () => { + joinVoiceChannel(42); + setVoiceStatus("connected"); + leaveVoiceChannel(); + expect(voiceStore.getState().voiceStatus).toBe("idle"); + }); + + it("setVoiceStatus writes the given status", () => { + setVoiceStatus("securing"); + expect(voiceStore.getState().voiceStatus).toBe("securing"); + setVoiceStatus("reconnecting"); + expect(voiceStore.getState().voiceStatus).toBe("reconnecting"); + }); + }); + describe("leaveVoiceChannel — clears user from voiceUsers", () => { it("removes current user from the channel's voiceUsers map", () => { authStore.setState(() => ({ From 23bf34bbae3853874ca1043667116af06d12d622 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:31:40 +0200 Subject: [PATCH 3/5] docs(ux): close the voice/E2EE status gap callouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mark the voice-and-e2ee.md §1 (store-backed voiceStatus) and §2 (visible E2EE securing/secured indicator) gaps as implemented, noting the client runs the ECDH key exchange before the media connect. Close the remaining voice column of the README.md §3 connection-status table: voice controls now freeze on WS reconnect. Co-Authored-By: Claude Fable 5 --- docs/architecture/ux/README.md | 9 ++++-- docs/architecture/ux/voice-and-e2ee.md | 39 +++++++++++++++++--------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/docs/architecture/ux/README.md b/docs/architecture/ux/README.md index 6d464252..07c6196f 100644 --- a/docs/architecture/ux/README.md +++ b/docs/architecture/ux/README.md @@ -92,9 +92,12 @@ source of truth in `ui.store.connectionStatus` > `SidebarArea` never passed it a `ws`; it now gates on the store and receives > the `ws` send path). The one-shot connected-overlay wiring in `main.ts` stays > on `ws.onStateChange` deliberately — it needs the exact internal transition. -> **Remaining gap:** the table's voice column. Voice controls are not yet -> frozen during a WS reconnect — LiveKit reconnection retries underneath, but -> join/leave controls stay enabled and would send over the down socket. +> The voice column is now wired too: the VoiceWidget freezes its controls +> (disabled + a "Reconnecting…" / "Not connected" reason) while +> `connectionStatus !== "connected"`, and the join/leave callbacks +> (`VoiceCallbacks.ts`) refuse to fire `voice_join`/`voice_leave` over a down +> socket. LiveKit's own reconnection keeps retrying underneath — only the UI is +> gated, never LiveKit's machinery. | Status | Composer / send | Voice controls | Presence picker | Reconnect banner | |--------|-----------------|----------------|-----------------|------------------| diff --git a/docs/architecture/ux/voice-and-e2ee.md b/docs/architecture/ux/voice-and-e2ee.md index 88445f9a..3bb7fc5d 100644 --- a/docs/architecture/ux/voice-and-e2ee.md +++ b/docs/architecture/ux/voice-and-e2ee.md @@ -24,14 +24,21 @@ Plus the user-facing booleans in `voice.store` (`localMuted`, `localDeafened`, roster (`voiceUsers` with per-user `speaking/muted/deafened/camera/screenshare`). **Target:** expose the voice session as one observable `voiceStatus` the widgets -read — `idle | joining | securing | connected | reconnecting | failed` — rather -than inferring it from `isVoiceConnected()` alone. +read — `idle | joining | securing | connected | reconnecting` — rather than +inferring it from `isVoiceConnected()` alone. -> **⚠ Current gap.** The voice session FSM is internal; the only UI-observable -> connection signal is `isVoiceConnected()` (`livekitSession.ts:1713`, true only -> in `connected`). There is **no** store-backed `joining`/`securing`/`reconnecting` -> indicator, so the UI can't distinguish "connecting to the room" from "securing -> the encryption" from "reconnecting". Target adds an explicit status field. +> **✓ Implemented (2026-07).** `voice.store.voiceStatus` +> (`idle | joining | securing | connected | reconnecting`) is now the observable +> voice-session status. `livekitSession.ts` is the single writer: `joining` at the +> start of `connectAndSetup`, `securing` when the ECDH key exchange begins, +> `connected` on the atomic `connected` transition (both the initial join and a +> successful auto-reconnect), `reconnecting` when the room drops and the reconnect +> loop forms its state, and `idle` on `leaveVoice`. `joinVoiceChannel` seeds +> `joining` optimistically on click so the widget reacts before the `voice_token` +> round-trip. The VoiceWidget reads it to distinguish "connecting to the room" +> from "securing the encryption" from "reconnecting". `failed` is not a persisted +> status: an E2EE-timeout / connection error auto-leaves to `idle` and surfaces a +> toast via `onErrorCallback` (§2). --- @@ -66,13 +73,17 @@ stateDiagram-v2 - Leaving is immediate and local (`leaveVoice`): tear down tracks, clear E2EE state, reset camera/screenshare, `idle`. -> **⚠ Current gap — E2EE has no visible indicator.** Key exchange produces only -> log lines; the sole user-facing effects are (a) the join *blocking* while the -> key is fetched and (b) an `"e2ee_timeout"` error string on failure -> (`livekitSession.ts:893`). There is no "securing" state and no persistent -> "secured 🔒" affirmation once connected. Target: a `voiceStatus: "securing"` -> phase + a secured indicator on the connected widget, so users can *see* the -> call is end-to-end encrypted (and see when it isn't yet). +> **✓ Implemented (2026-07).** The VoiceWidget header now renders the E2EE phase +> from `voiceStatus`: a "Securing…" label (amber) while the key exchange runs and +> a persistent "🔒 Secured" badge once the room key is ready and the room is +> connected — replacing the log-line-only feedback. `joining` shows "Connecting…" +> and `reconnecting` shows "Reconnecting voice…", neither showing the secured +> badge. An E2EE-timeout still surfaces its `"e2ee_timeout"` toast and auto-leaves +> (`livekitSession.ts` `connectAndSetup`). **Code vs. diagram note:** the client +> actually runs the ECDH key exchange *before* `room.connect()`, so `securing` +> spans the key wait and the media connect; the state diagram below draws them in +> the reverse order for readability. The distinction users see is unchanged: +> non-key-holders sit in `securing` until a room key arrives. --- From 544360642ebd374d2b944208c8c860449a9a62f5 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:53:48 +0200 Subject: [PATCH 4/5] fix(voice): freeze sidebar voice join/leave row on WS reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WS-reconnect control freeze (spec item 6) only reached the VoiceWidget's in-call controls. The actual join affordance — clicking a voice-channel row in ChannelSidebar — stayed a plain clickable div with no disabled state, so a click while the socket was reconnecting/disconnected was a silent no-op (only the VoiceCallbacks socketLive() backstop stopped the send). Gate renderVoiceChannelItem on ui.store.connectionStatus using the same disabled-with-reason pattern as VoiceWidget: apply a .disabled class, aria-disabled, and a "Reconnecting…" / "Not connected" title while not connected, and make the click a no-op. Subscribe the sidebar to connectionStatus so the row freezes/unfreezes reactively (mirrors the existing collapsedCategories selector). Docs: README.md §3 callout now notes the sidebar join affordance takes the disabled-with-reason state too; voice-and-e2ee.md lists ChannelSidebar.ts as a source of truth for the freeze. Co-Authored-By: Claude Fable 5 --- .../src/components/ChannelSidebar.ts | 25 ++++++- Client/tauri-client/src/styles/app.css | 3 + .../tests/unit/channel-sidebar.test.ts | 68 ++++++++++++++++++- docs/architecture/ux/README.md | 14 ++-- docs/architecture/ux/voice-and-e2ee.md | 1 + 5 files changed, 104 insertions(+), 7 deletions(-) diff --git a/Client/tauri-client/src/components/ChannelSidebar.ts b/Client/tauri-client/src/components/ChannelSidebar.ts index 450e65ff..734b73ac 100644 --- a/Client/tauri-client/src/components/ChannelSidebar.ts +++ b/Client/tauri-client/src/components/ChannelSidebar.ts @@ -105,13 +105,26 @@ function renderVoiceChannelItem( ): HTMLDivElement { const voiceState = voiceStore.getState(); const isJoined = voiceState.currentChannelId === channel.id; + // Freeze the join/leave affordance while the WS socket is not live — the same + // disabled-with-reason pattern the VoiceWidget uses for its in-call controls + // (docs/architecture/ux/README.md §3). LiveKit keeps retrying underneath; we + // only gate the UI so the click isn't a silent no-op. + const connectionStatus = uiStore.getState().connectionStatus; + const frozen = connectionStatus !== "connected"; + const frozenReason = connectionStatus === "reconnecting" ? "Reconnecting…" : "Not connected"; const wrapper = createElement("div", {}); - const classes = ["channel-item", "voice", isJoined ? "active" : ""].filter(Boolean).join(" "); + const classes = ["channel-item", "voice", isJoined ? "active" : "", frozen ? "disabled" : ""] + .filter(Boolean) + .join(" "); const item = createElement("div", { class: classes, "data-testid": `channel-${channel.id}` }); item.dataset.channelId = String(channel.id); + if (frozen) { + item.title = frozenReason; + item.setAttribute("aria-disabled", "true"); + } const prefix = createElement("span", { class: "ch-icon" }); prefix.appendChild(createIcon("volume-2", 16)); @@ -122,6 +135,8 @@ function renderVoiceChannelItem( item.addEventListener( "click", () => { + // Frozen while the WS socket is down — no-op; the reason is shown via title. + if (uiStore.getState().connectionStatus !== "connected") return; if (isJoined) { onVoiceLeave(); } else { @@ -498,6 +513,14 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC ); unsubscribers.push(unsubUi); + // Re-render voice rows when the WS connection status flips so the join/leave + // affordance freezes/unfreezes with a visible reason (§3 connection status). + const unsubConnStatus = uiStore.subscribeSelector( + (s) => s.connectionStatus, + () => renderChannels(), + ); + unsubscribers.push(unsubConnStatus); + // Subscribe to voice store — only full re-render when users join/leave // or mute/deafen/camera changes. Speaking state is patched in-place via // CSS class toggle to avoid destroying DOM elements (which kills hover). diff --git a/Client/tauri-client/src/styles/app.css b/Client/tauri-client/src/styles/app.css index 6fa844e5..894d98ef 100644 --- a/Client/tauri-client/src/styles/app.css +++ b/Client/tauri-client/src/styles/app.css @@ -87,6 +87,9 @@ } .channel-item:hover { background: var(--bg-hover); color: var(--text-normal); } .channel-item.active { background: var(--bg-active); color: white; } +/* Voice join/leave frozen while the WS socket is down (docs/architecture/ux/README.md §3). */ +.channel-item.voice.disabled { opacity: 0.5; cursor: not-allowed; } +.channel-item.voice.disabled:hover { background: transparent; color: var(--text-muted); } .channel-draggable { cursor: grab; } .channel-draggable:active { cursor: grabbing; } .channel-reordering { user-select: none; cursor: grabbing !important; } diff --git a/Client/tauri-client/tests/unit/channel-sidebar.test.ts b/Client/tauri-client/tests/unit/channel-sidebar.test.ts index e1d9f3b8..e1197e5c 100644 --- a/Client/tauri-client/tests/unit/channel-sidebar.test.ts +++ b/Client/tauri-client/tests/unit/channel-sidebar.test.ts @@ -42,7 +42,9 @@ function resetStores(): void { settingsOpen: false, activeModal: null, theme: "dark" as const, - connectionStatus: "disconnected" as const, + // Default to a live socket: the voice join/leave affordance is only usable + // when connected. Frozen-state behavior is exercised explicitly below. + connectionStatus: "connected" as const, transientError: null, persistentError: null, collapsedCategories: new Set(), @@ -291,6 +293,70 @@ describe("ChannelSidebar", () => { expect(onVoiceJoin).not.toHaveBeenCalled(); }); + // ── Voice join/leave freeze while the WS socket is not connected (§3) ── + + it("disables voice channel join with a 'Reconnecting…' reason while reconnecting", () => { + setChannels(testChannels); + uiStore.setState((prev) => ({ ...prev, connectionStatus: "reconnecting" })); + sidebar.mount(container); + + const voiceItem = container.querySelector('[data-channel-id="3"]') as HTMLElement; + expect(voiceItem.classList.contains("disabled")).toBe(true); + expect(voiceItem.getAttribute("aria-disabled")).toBe("true"); + expect(voiceItem.title).toBe("Reconnecting…"); + + // Frozen: the click must not fire the join callback. + voiceItem.click(); + expect(onVoiceJoin).not.toHaveBeenCalled(); + }); + + it("disables voice channel join with a 'Not connected' reason while disconnected", () => { + setChannels(testChannels); + uiStore.setState((prev) => ({ ...prev, connectionStatus: "disconnected" })); + sidebar.mount(container); + + const voiceItem = container.querySelector('[data-channel-id="3"]') as HTMLElement; + expect(voiceItem.classList.contains("disabled")).toBe(true); + expect(voiceItem.title).toBe("Not connected"); + + voiceItem.click(); + expect(onVoiceJoin).not.toHaveBeenCalled(); + }); + + it("frozen voice channel does not fire onVoiceLeave even when joined", () => { + setChannels(testChannels); + voiceStore.setState((prev) => ({ ...prev, currentChannelId: 3 })); + uiStore.setState((prev) => ({ ...prev, connectionStatus: "reconnecting" })); + sidebar.mount(container); + + const voiceItem = container.querySelector('[data-channel-id="3"]') as HTMLElement; + expect(voiceItem.classList.contains("disabled")).toBe(true); + + voiceItem.click(); + expect(onVoiceLeave).not.toHaveBeenCalled(); + }); + + it("re-enables voice channel join when the connection returns to connected", () => { + setChannels(testChannels); + uiStore.setState((prev) => ({ ...prev, connectionStatus: "reconnecting" })); + sidebar.mount(container); + + // Initially frozen. + let voiceItem = container.querySelector('[data-channel-id="3"]') as HTMLElement; + expect(voiceItem.classList.contains("disabled")).toBe(true); + + // Connection restored — the sidebar re-renders and unfreezes the row. + uiStore.setState((prev) => ({ ...prev, connectionStatus: "connected" })); + uiStore.flush(); + + voiceItem = container.querySelector('[data-channel-id="3"]') as HTMLElement; + expect(voiceItem.classList.contains("disabled")).toBe(false); + expect(voiceItem.hasAttribute("aria-disabled")).toBe(false); + + voiceItem.click(); + expect(onVoiceJoin).toHaveBeenCalledWith(3); + }); + it("shows connected voice users under voice channel", () => { setChannels(testChannels); // Add a member so username resolves diff --git a/docs/architecture/ux/README.md b/docs/architecture/ux/README.md index 07c6196f..15882644 100644 --- a/docs/architecture/ux/README.md +++ b/docs/architecture/ux/README.md @@ -92,12 +92,16 @@ source of truth in `ui.store.connectionStatus` > `SidebarArea` never passed it a `ws`; it now gates on the store and receives > the `ws` send path). The one-shot connected-overlay wiring in `main.ts` stays > on `ws.onStateChange` deliberately — it needs the exact internal transition. -> The voice column is now wired too: the VoiceWidget freezes its controls +> The voice column is now wired too: the VoiceWidget freezes its in-call controls > (disabled + a "Reconnecting…" / "Not connected" reason) while -> `connectionStatus !== "connected"`, and the join/leave callbacks -> (`VoiceCallbacks.ts`) refuse to fire `voice_join`/`voice_leave` over a down -> socket. LiveKit's own reconnection keeps retrying underneath — only the UI is -> gated, never LiveKit's machinery. +> `connectionStatus !== "connected"`, **and** the join affordance itself — the +> voice-channel row in the `ChannelSidebar` — takes the same disabled-with-reason +> state (`.disabled` class, `aria-disabled`, a "Reconnecting…" / "Not connected" +> title) and its click becomes a no-op, so joining/leaving is visibly gated, not a +> silent dead click. The join/leave callbacks (`VoiceCallbacks.ts`) still refuse to +> fire `voice_join`/`voice_leave` over a down socket as a defensive backstop. +> LiveKit's own reconnection keeps retrying underneath — only the UI is gated, +> never LiveKit's machinery. | Status | Composer / send | Voice controls | Presence picker | Reconnect banner | |--------|-----------------|----------------|-----------------|------------------| diff --git a/docs/architecture/ux/voice-and-e2ee.md b/docs/architecture/ux/voice-and-e2ee.md index 3bb7fc5d..baf855fa 100644 --- a/docs/architecture/ux/voice-and-e2ee.md +++ b/docs/architecture/ux/voice-and-e2ee.md @@ -158,6 +158,7 @@ forward-secrecy keypair rotation on reconnect are mechanics the user never sees. `src/lib/livekitSession.ts`, `src/stores/voice.store.ts`, `src/lib/screenShare.ts`, `src/lib/ptt.ts`, `src/lib/roomEventHandlers.ts`, `src/components/VoiceWidget.ts`, +`src/components/ChannelSidebar.ts` (voice-row join freeze on WS reconnect), `VoiceChannel.ts`, `VideoGrid.ts`, `src-tauri/src/livekit_proxy.rs`, `src-tauri/src/ptt.rs`, `src/lib/e2eeCrypto.ts`; and the structural map in [../voice-e2ee.md](../voice-e2ee.md). From b8ad9db489730784c93f8b2e833e7b612c9c69d0 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:56:43 +0200 Subject: [PATCH 5/5] test(voice): pin the reconnecting status write on unexpected room drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setVoiceStatus("reconnecting") is written from the setReconnectAc callback fired via roomEventHandlers.handleDisconnected on an unexpected room drop — the primary reconnecting write, previously untested (the reconnect suite only asserted the subsequent "connected"). Add a test that connects, captures the Disconnected handler, fires it with a non-CLIENT_INITIATED reason, and asserts both the "reconnecting" session state and the setVoiceStatus("reconnecting") write. Co-Authored-By: Claude Fable 5 --- .../tests/unit/livekit-session.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Client/tauri-client/tests/unit/livekit-session.test.ts b/Client/tauri-client/tests/unit/livekit-session.test.ts index f6b43069..e370ba15 100644 --- a/Client/tauri-client/tests/unit/livekit-session.test.ts +++ b/Client/tauri-client/tests/unit/livekit-session.test.ts @@ -680,6 +680,32 @@ describe("LiveKitSession", () => { expect(setVoiceStatus).toHaveBeenCalledWith("idle"); }); + it("writes reconnecting when the room drops unexpectedly", async () => { + session.setServerHost("localhost:7880"); + session.setWsClient({ send: vi.fn() } as any); + + // Capture the Disconnected handler registered during room creation. + let disconnectedHandler: ((reason?: number) => void) | undefined; + mockRoom.on.mockImplementation((event: string, handler: any) => { + if (event === "disconnected") disconnectedHandler = handler; + return mockRoom; + }); + + await session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880", true); + expect(disconnectedHandler).toBeDefined(); + expect((session as any)._state.type).toBe("connected"); + + // Isolate the write triggered purely by the unexpected room drop. + (setVoiceStatus as any).mockClear(); + + // Fire an unexpected disconnect (non-CLIENT_INITIATED) — this is the primary + // reconnecting write, from setReconnectAc via handleDisconnected. + disconnectedHandler!(/* SERVER_SHUTDOWN */ 1); + + expect((session as any)._state.type).toBe("reconnecting"); + expect(setVoiceStatus).toHaveBeenCalledWith("reconnecting"); + }); + it("writes connected after a successful auto-reconnect", async () => { (session as any)._state = { type: "reconnecting",