mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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();
|
||||
|
||||
@@ -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<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) => ({
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user