From bfecb41c6679afad7b819dd8012e5c38b10918d7 Mon Sep 17 00:00:00 2001
From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com>
Date: Mon, 16 Mar 2026 22:10:53 +0100
Subject: [PATCH] fix: derive voice sidebar from LiveKit participants to
eliminate desync
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The channel sidebar voice user list was maintained by a separate
voiceUsers Map (fed by WS events + fragile hydration code) that diverged
from reality after server restarts — users shown in wrong channels,
duplicated across channels. The VoiceGrid was always correct because it
reads LiveKit participants directly.
Now VoiceChannel.tsx derives its user list from LiveKit participants for
the connected channel (single source of truth) and only falls back to
server-provided voiceUsers for channels the user is not connected to.
Removed all hydration band-aids that tried to sync the two systems:
- useLiveKit ParticipantDisconnected → removeVoiceUser
- useLiveKit ConnectionStateChanged → addVoiceUser hydration loop
- useWebSocket ready handler → dynamic import LiveKit hydration
Also includes: voice channel settings gear icon on hover, persist
per-user volume/mute prefs across sessions, default screen share
audio off on Electron (no system audio capture support).
---
.../src/components/layout/ChannelSidebar.tsx | 2 ++
.../web/src/components/voice/VoiceChannel.tsx | 33 +++++++++++++++++--
packages/web/src/hooks/useLiveKit.ts | 13 +++-----
packages/web/src/stores/voiceStore.ts | 24 ++++++++++----
4 files changed, 53 insertions(+), 19 deletions(-)
diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx
index 379f5682..eaf1da38 100644
--- a/packages/web/src/components/layout/ChannelSidebar.tsx
+++ b/packages/web/src/components/layout/ChannelSidebar.tsx
@@ -1334,6 +1334,8 @@ function ChannelItem({
channelName={channel.name}
onClick={() => canConnect && handleVoiceJoin(channel.id)}
locked={!canConnect}
+ canManage={canManage}
+ onSettingsClick={onSettingsClick}
dragState={voiceDragState}
onDragStart={onVoiceDragStart}
onDragEnd={onVoiceDragEnd}
diff --git a/packages/web/src/components/voice/VoiceChannel.tsx b/packages/web/src/components/voice/VoiceChannel.tsx
index 11022ab0..c8837eed 100644
--- a/packages/web/src/components/voice/VoiceChannel.tsx
+++ b/packages/web/src/components/voice/VoiceChannel.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useCallback } from 'react';
+import React, { useState, useCallback, useMemo } from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
import { useAuthStore } from '../../stores/authStore';
@@ -19,15 +19,27 @@ interface VoiceChannelProps {
channelName: string;
onClick: () => void;
locked?: boolean;
+ canManage?: boolean;
+ onSettingsClick?: () => void;
dragState?: VoiceChannelDragState | null;
onDragStart?: (userId: string) => void;
onDragEnd?: () => void;
}
-export function VoiceChannel({ channelId, channelName, onClick, locked, dragState, onDragStart, onDragEnd }: VoiceChannelProps) {
- const voiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS;
+export function VoiceChannel({ channelId, channelName, onClick, locked, canManage, onSettingsClick, dragState, onDragStart, onDragEnd }: VoiceChannelProps) {
+ const serverVoiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS;
const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId);
const participants = useVoiceStore((s) => s.participants);
+ const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
+
+ // For OUR channel: LiveKit participants are the single source of truth.
+ // For other channels: use server-provided voiceUsers (only available source).
+ const voiceUsers = useMemo(() => {
+ if (currentVoiceChannel === channelId && isLiveKitConnected && participants.length > 0) {
+ return [...new Set(participants.map(p => p.userId))];
+ }
+ return serverVoiceUsers;
+ }, [currentVoiceChannel, channelId, isLiveKitConnected, participants, serverVoiceUsers]);
const localIsDeafened = useVoiceStore((s) => s.isDeafened);
const localIsMuted = useVoiceStore((s) => s.isMuted);
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
@@ -127,6 +139,21 @@ export function VoiceChannel({ channelId, channelName, onClick, locked, dragStat
)}
{channelName}
+ {canManage && onSettingsClick && (
+
+ )}
{/* Connected users */}
diff --git a/packages/web/src/hooks/useLiveKit.ts b/packages/web/src/hooks/useLiveKit.ts
index 26b36aa0..3a7500ba 100644
--- a/packages/web/src/hooks/useLiveKit.ts
+++ b/packages/web/src/hooks/useLiveKit.ts
@@ -122,7 +122,7 @@ export function setCameraSubscription(room: Room | null, targetIdentity: string,
});
}
-function parseIdentity(identity: string): { userId: string; username: string } {
+export function parseIdentity(identity: string): { userId: string; username: string } {
const parts = identity.split(':');
return { userId: parts[0] ?? identity, username: parts[1] ?? identity };
}
@@ -414,14 +414,9 @@ export function useLiveKit() {
});
newRoom.on(RoomEvent.ParticipantDisconnected, (participant: RemoteParticipant) => {
guardedUpdate();
- // Sync voiceUsers so channel sidebar updates immediately
- // (don't wait for server's 5s grace-period WS event)
- const chId = connectedChannelRef.current;
- if (chId && !chId.startsWith('dm-')) {
- const { userId } = parseIdentity(participant.identity);
- useVoiceStore.getState().removeVoiceUser(chId, userId);
- useVoiceStore.getState().clearVoiceUserStatus(userId);
- }
+ // Clean up stale WS-based voice status for the departed participant
+ const { userId } = parseIdentity(participant.identity);
+ useVoiceStore.getState().clearVoiceUserStatus(userId);
});
newRoom.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
// LiveKit auto-attaches a hidden