Merge pull request #1193 from J3vb/feat/voice-e2ee-status

feat(voice): surface voice-session + E2EE status and freeze controls on WS reconnect
This commit is contained in:
J3vb
2026-07-20 12:39:11 +02:00
committed by GitHub
20 changed files with 609 additions and 34 deletions
@@ -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).
@@ -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<QualityLevel, number> = {
bad: 1,
};
/** Header status text per voice-session lifecycle state
* (docs/architecture/ux/voice-and-e2ee.md §2). */
const STATUS_LABELS: Record<VoiceStatus, string> = {
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;
@@ -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);
@@ -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();
+31 -3
View File
@@ -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 §12. */
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<VoiceState>(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) => ({
+12
View File
@@ -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; }
@@ -217,8 +220,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);
@@ -59,6 +59,7 @@ const VOICE_INITIAL: VoiceState = {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
};
const UI_INITIAL: UiState = {
@@ -134,6 +134,7 @@ function resetAllStores(): void {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
authStore.setState(() => ({
token: null,
@@ -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<string>(),
@@ -59,6 +61,7 @@ function resetStores(): void {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
membersStore.setState(() => ({
members: new Map(),
@@ -290,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
@@ -1072,6 +1139,7 @@ describe("ChannelSidebar", () => {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
sidebarWithWatch.mount(container);
@@ -1116,6 +1184,7 @@ describe("ChannelSidebar", () => {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
sidebarWithWatch.mount(container);
@@ -1166,6 +1235,7 @@ describe("ChannelSidebar", () => {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
sidebar.mount(container);
@@ -1207,6 +1277,7 @@ describe("ChannelSidebar", () => {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
sidebarWithWatch.mount(container);
@@ -1248,6 +1319,7 @@ describe("ChannelSidebar", () => {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
sidebar.mount(container);
@@ -130,6 +130,7 @@ describe("WS Dispatcher", () => {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
dmStore.setState(() => ({ channels: [] }));
uiStore.setState((prev) => ({ ...prev, transientError: null }));
@@ -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,85 @@ describe("LiveKitSession", () => {
});
});
describe("voiceStatus transitions (voice-and-e2ee.md §12)", () => {
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 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",
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");
@@ -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 {
@@ -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");
@@ -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();
});
});
@@ -33,6 +33,7 @@ function resetStores(): void {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
membersStore.setState(() => ({
members: new Map(),
@@ -23,6 +23,7 @@ function resetStores(): void {
localScreenshare: false,
joinedAt: null,
listenOnly: false,
voiceStatus: "idle",
}));
authStore.setState(() => ({
token: null,
@@ -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?.();
});
});
@@ -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(() => ({
+10 -3
View File
@@ -92,9 +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.
> **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 in-call controls
> (disabled + a "Reconnecting…" / "Not connected" reason) while
> `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 |
|--------|-----------------|----------------|-----------------|------------------|
+26 -14
View File
@@ -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.
---
@@ -147,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).