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