import React, { useState, useCallback } from 'react'; import { useVoiceStore } from '../../stores/voiceStore'; import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore'; import { useAuthStore } from '../../stores/authStore'; import { Avatar } from '../ui/Avatar'; import { VoiceUserContextMenu } from './VoiceUserContextMenu'; import { wsSend } from '../../hooks/useWebSocket'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; const EMPTY_VOICE_USERS: string[] = []; interface VoiceChannelDragState { userId: string; fromChannelId: string; } interface VoiceChannelProps { channelId: string; channelName: string; onClick: () => void; locked?: boolean; 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; const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId); const participants = useVoiceStore((s) => s.participants); const localIsDeafened = useVoiceStore((s) => s.isDeafened); const localIsMuted = useVoiceStore((s) => s.isMuted); const voiceUserStates = useVoiceStore((s) => s.voiceUserStates); const spaceMutedUserIds = useVoiceStore((s) => s.spaceMutedUserIds); const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds); const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds); const participantMutes = useVoiceStore((s) => s.participantMutes); const unwatchedCameras = useVoiceStore((s) => s.unwatchedCameras); const currentUserId = useVoiceStore((s) => { const local = s.participants.find(p => p.isLocal); return local?.userId ?? null; }); const members = useSpaceStore((s) => s.members); const channelToSpaceMap = useSpaceStore((s) => s.channelToSpaceMap); const myUser = useAuthStore((s) => s.user); const isActive = currentVoiceChannel === channelId; // Drag-and-drop permission check const spacePermissions = useSpaceStore((s) => s.spacePermissions); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const myPerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined; const canMoveMembers = hasPermissionBit(myPerms, PermissionBits.MOVE_MEMBERS); // Drop target highlight state const [isDragOver, setIsDragOver] = useState(false); const isValidDropTarget = dragState !== null && dragState !== undefined && dragState.fromChannelId !== channelId; // Context menu state const [contextMenu, setContextMenu] = useState<{ x: number; y: number; userId: string } | null>(null); const handleContextMenu = useCallback( (e: React.MouseEvent, userId: string) => { if (userId === myUser?.id) return; e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, userId }); }, [myUser?.id], ); return (
{ if (isValidDropTarget) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setIsDragOver(true); } }} onDragEnter={(e) => { if (isValidDropTarget) { e.preventDefault(); setIsDragOver(true); } }} onDragLeave={(e) => { // Only clear when leaving the container (not entering a child) if (!e.currentTarget.contains(e.relatedTarget as Node)) { setIsDragOver(false); } }} onDrop={(e) => { e.preventDefault(); setIsDragOver(false); if (dragState && dragState.fromChannelId !== channelId) { const voiceOrigin = getChannelOrigin(dragState.fromChannelId); wsSend({ type: 'voice_move', userId: dragState.userId, targetChannelId: channelId }, voiceOrigin); onDragEnd?.(); } }} className={isDragOver && isValidDropTarget ? 'rounded-[8px] ring-1 ring-accent-mint/40' : ''} > {/* Connected users */} {voiceUsers.length > 0 && (
{voiceUsers.map((userId) => { const member = members.find(m => m.userId === userId); const participant = participants.find(p => p.userId === userId); const displayName = member?.user.displayName ?? member?.user.username ?? participant?.username ?? userId; const avatar = member?.user.avatar ?? null; const status = member?.user.status; const wsStatus = voiceUserStates.get(userId); const isParticipantDeafened = userId === currentUserId ? localIsDeafened : (participant?.isDeafened ?? wsStatus?.isDeafened ?? false); const isMuted = userId === currentUserId ? localIsMuted : (participant?.isMuted ?? wsStatus?.isMuted ?? false); const hasCamera = participant?.isCameraOn ?? wsStatus?.isCameraOn ?? false; const isScreenSharing = participant?.isScreenSharing ?? wsStatus?.isScreenSharing ?? false; const spaceId = channelToSpaceMap.get(channelId); const isSpaceMuted = spaceMutedUserIds.has(`${spaceId}:${userId}`); const isSpaceDeafened = spaceDeafenedUserIds.has(`${spaceId}:${userId}`); const isPermissionMuted = permissionMutedUserIds.has(`${spaceId}:${userId}`); const isDraggable = canMoveMembers && userId !== myUser?.id; const isBeingDragged = dragState?.userId === userId && dragState?.fromChannelId === channelId; return (
{ if (!isDraggable) return; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', userId); onDragStart?.(userId); }} onDragEnd={() => onDragEnd?.()} onContextMenu={(e) => handleContextMenu(e, userId)} > {displayName} {/* Status badges */}
{(isSpaceMuted || isSpaceDeafened || isPermissionMuted) && ( )} {isSpaceDeafened && ( )} {!isSpaceMuted && !isSpaceDeafened && !isPermissionMuted && isMuted && ( )} {!isSpaceDeafened && isParticipantDeafened && ( )} {hasCamera && ( {userId !== myUser?.id && unwatchedCameras.has(userId) && ( )} )} {isScreenSharing && ( LIVE )} {userId !== myUser?.id && participantMutes.get(userId) && ( )}
); })}
)} {/* Voice moderation context menu (portalled to body) */} {contextMenu && ( setContextMenu(null)} isLocal={false} /> )}
); }