import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import ReactDOM from 'react-dom'; import { useVoiceStore } from '../../stores/voiceStore'; import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore'; import { wsSend } from '../../hooks/useWebSocket'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; import { getActiveRoom, setCameraSubscription } from '../../hooks/useLiveKit'; interface VoiceModMenuItemsProps { targetUserId: string; channelId: string; onAction: () => void; } /** * Headless moderation menu items (mute/deafen/move buttons). * Renders nothing if the current user has no moderation permissions. * Use inside any container — no portal or positioning logic. */ export function VoiceModMenuItems({ targetUserId, channelId, onAction }: VoiceModMenuItemsProps) { const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds); const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds); const spacePermissions = useSpaceStore((s) => s.spacePermissions); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const channels = useSpaceStore((s) => s.channels); const myPerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined; const canMuteMembers = hasPermissionBit(myPerms, PermissionBits.MUTE_MEMBERS); const canDeafenMembers = hasPermissionBit(myPerms, PermissionBits.DEAFEN_MEMBERS); const canMoveMembers = hasPermissionBit(myPerms, PermissionBits.MOVE_MEMBERS); const canDisconnectMembers = hasPermissionBit(myPerms, PermissionBits.DISCONNECT_MEMBERS); const otherVoiceChannels = channels.filter( (c) => (c.type === 'voice' || c.type === 'video') && c.id !== channelId, ); const voiceOrigin = getChannelOrigin(channelId); const spaceId = useSpaceStore((s) => s.channelToSpaceMap.get(channelId)); const isServerMuted = serverMutedUserIds.has(`${spaceId}:${targetUserId}`); const isServerDeafened = serverDeafenedUserIds.has(`${spaceId}:${targetUserId}`); if (!canMuteMembers && !canDeafenMembers && !canMoveMembers && !canDisconnectMembers) return null; const handleServerMute = () => { wsSend({ type: 'voice_server_mute', userId: targetUserId, muted: !isServerMuted }, voiceOrigin); onAction(); }; const handleServerDeafen = () => { wsSend({ type: 'voice_server_deafen', userId: targetUserId, deafened: !isServerDeafened }, voiceOrigin); onAction(); }; const handleMove = (targetChannelId: string) => { wsSend({ type: 'voice_move', userId: targetUserId, targetChannelId }, voiceOrigin); onAction(); }; const handleDisconnect = () => { wsSend({ type: 'voice_disconnect', userId: targetUserId }, voiceOrigin); onAction(); }; const btnClass = 'w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 text-txt-secondary hover:bg-accent-primary hover:text-white'; const btnStyle = { width: 'calc(100% - 12px)' }; return ( <> {canMuteMembers && ( )} {canDeafenMembers && ( )} {canDisconnectMembers && ( <>
)} {canMoveMembers && otherVoiceChannels.length > 0 && ( )} ); } // ─── "Move to" hover flyout submenu ────────────────────────────────────────── interface MoveToSubmenuProps { channels: { id: string; name: string }[]; onMove: (channelId: string) => void; btnClass: string; btnStyle: React.CSSProperties; } function MoveToSubmenu({ channels, onMove, btnClass, btnStyle }: MoveToSubmenuProps) { const [open, setOpen] = useState(false); const triggerRef = useRef(null); const flyoutRef = useRef(null); const closeTimer = useRef | null>(null); const startCloseTimer = useCallback(() => { closeTimer.current = setTimeout(() => setOpen(false), 150); }, []); const cancelCloseTimer = useCallback(() => { if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; } }, []); useEffect(() => { return () => { if (closeTimer.current) clearTimeout(closeTimer.current); }; }, []); // Position the flyout relative to the trigger useLayoutEffect(() => { const flyout = flyoutRef.current; const trigger = triggerRef.current; if (!open || !flyout || !trigger) return; const tRect = trigger.getBoundingClientRect(); const fRect = flyout.getBoundingClientRect(); const gap = 4; // Horizontal: prefer right, flip left if overflowing let left = tRect.right + gap; if (left + fRect.width > window.innerWidth) { left = tRect.left - fRect.width - gap; } if (left < 8) left = 8; // Vertical: align top with trigger, clamp to viewport let top = tRect.top; if (top + fRect.height > window.innerHeight - 8) { top = window.innerHeight - fRect.height - 8; } if (top < 8) top = 8; flyout.style.left = `${left}px`; flyout.style.top = `${top}px`; const availableHeight = window.innerHeight - top - 8; const maxHeight = Math.max(availableHeight, 120); flyout.style.maxHeight = `${maxHeight}px`; }, [open]); return ( <>
{open && ReactDOM.createPortal(
e.stopPropagation()} onClick={(e) => e.stopPropagation()} > {channels.map((ch) => ( ))}
, document.body, )} ); } // ─── Standalone portalled context menu ───────────────────────────────────────── interface VoiceUserContextMenuProps { targetUserId: string; channelId: string; position: { x: number; y: number }; onClose: () => void; isLocal: boolean; } /** * Unified voice user context menu rendered via createPortal to document.body. * Shows moderation items (if perms) + volume slider (always, for remote users). * Includes viewport-aware positioning and click-outside dismissal. */ export function VoiceUserContextMenu({ targetUserId, channelId, position, onClose, isLocal }: VoiceUserContextMenuProps) { const menuRef = useRef(null); const spacePermissions = useSpaceStore((s) => s.spacePermissions); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const myPerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined; const canMuteMembers = hasPermissionBit(myPerms, PermissionBits.MUTE_MEMBERS); const canDeafenMembers = hasPermissionBit(myPerms, PermissionBits.DEAFEN_MEMBERS); const canMoveMembers = hasPermissionBit(myPerms, PermissionBits.MOVE_MEMBERS); const canDisconnectMembers = hasPermissionBit(myPerms, PermissionBits.DISCONNECT_MEMBERS); const hasModPerms = canMuteMembers || canDeafenMembers || canMoveMembers || canDisconnectMembers; const participantVolumes = useVoiceStore((s) => s.participantVolumes); const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume); const perUserVolume = participantVolumes.get(targetUserId) ?? 100; const participantMutes = useVoiceStore((s) => s.participantMutes); const setParticipantMute = useVoiceStore((s) => s.setParticipantMute); const isUserMuted = participantMutes.get(targetUserId) ?? false; const unwatchedCameras = useVoiceStore((s) => s.unwatchedCameras); const participants = useVoiceStore((s) => s.participants); const targetParticipant = participants.find((p) => p.userId === targetUserId); const targetHasCamera = targetParticipant?.isCameraOn ?? false; const isCameraUnwatched = unwatchedCameras.has(targetUserId); // Click-outside dismissal useEffect(() => { const handler = (e: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) { onClose(); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [onClose]); // Viewport-aware positioning — direct DOM mutation, no extra state/render useLayoutEffect(() => { const el = menuRef.current; if (!el) return; const rect = el.getBoundingClientRect(); let x = position.x; let y = position.y; if (rect.right > window.innerWidth) x = window.innerWidth - rect.width - 8; if (rect.bottom > window.innerHeight) y = window.innerHeight - rect.height - 8; if (x < 8) x = 8; if (y < 8) y = 8; el.style.left = `${x}px`; el.style.top = `${y}px`; }, [position]); if (isLocal) return null; return ReactDOM.createPortal(
e.stopPropagation()} onClick={(e) => e.stopPropagation()} > {hasModPerms && ( <>
)} {targetHasCamera && ( <>
)}
User Volume
setParticipantVolume(targetUserId, parseInt(e.target.value))} className="flex-1 accent-accent-primary h-1" /> {perUserVolume}%
, document.body, ); }