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 && ( + { + e.stopPropagation(); + 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