feat(voice): surface voice-session + E2EE status and freeze controls on WS reconnect

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 <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-07-20 09:31:17 +02:00
co-authored by Claude Fable 5
parent 5de92b9510
commit 827eea77ed
5 changed files with 140 additions and 14 deletions
@@ -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) => ({
+9
View File
@@ -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);