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