diff --git a/packages/web/src/components/ui/ContextMenu.tsx b/packages/web/src/components/ui/ContextMenu.tsx deleted file mode 100644 index 2ffbd811..00000000 --- a/packages/web/src/components/ui/ContextMenu.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { useUIStore } from '../../stores/uiStore'; - -interface ContextMenuItem { - label: string; - onClick: () => void; - danger?: boolean; - icon?: React.ReactNode; -} - -interface ContextMenuProps { - items: ContextMenuItem[]; - children: React.ReactNode; -} - -export function ContextMenu({ items, children }: ContextMenuProps) { - const [isOpen, setIsOpen] = useState(false); - const [position, setPosition] = useState({ x: 0, y: 0 }); - const menuRef = useRef(null); - const isMobile = useUIStore((s) => s.isMobile); - - const handleContextMenu = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setPosition({ x: e.clientX, y: e.clientY }); - setIsOpen(true); - }; - - useEffect(() => { - const handleClick = () => setIsOpen(false); - const handleScroll = () => setIsOpen(false); - - if (isOpen) { - document.addEventListener('click', handleClick); - document.addEventListener('scroll', handleScroll, true); - return () => { - document.removeEventListener('click', handleClick); - document.removeEventListener('scroll', handleScroll, true); - }; - } - }, [isOpen]); - - // Adjust position to keep menu in viewport - useEffect(() => { - if (isOpen && menuRef.current) { - const rect = menuRef.current.getBoundingClientRect(); - const newPosition = { ...position }; - - if (rect.right > window.innerWidth) { - newPosition.x = window.innerWidth - rect.width - 8; - } - if (rect.bottom > window.innerHeight) { - newPosition.y = window.innerHeight - rect.height - 8; - } - if (newPosition.x < 8) newPosition.x = 8; - if (newPosition.y < 8) newPosition.y = 8; - - if (newPosition.x !== position.x || newPosition.y !== position.y) { - setPosition(newPosition); - } - } - }, [isOpen, position]); - - return ( - <> -
{children}
- {isOpen && isMobile && ( - <> - {/* Backdrop */} -
setIsOpen(false)} /> - {/* Bottom sheet menu */} -
-
-
- {items.map((item, i) => ( - - ))} -
-
- - )} - {isOpen && !isMobile && ( -
- {items.map((item, i) => ( - - ))} -
- )} - - ); -} diff --git a/packages/web/src/components/voice/StreamContextMenu.tsx b/packages/web/src/components/voice/StreamContextMenu.tsx deleted file mode 100644 index 937bde84..00000000 --- a/packages/web/src/components/voice/StreamContextMenu.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; -import ReactDOM from 'react-dom'; -import { useVoiceStore } from '../../stores/voiceStore'; -import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit'; -import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover'; -import { stopScreenShare, changeScreenShare } from '../../utils/screenShare'; - -interface StreamContextMenuProps { - userId: string; - identity: string; - isLocal: boolean; - position: { x: number; y: number }; - onClose: () => void; -} - -export function StreamContextMenu({ userId, identity, isLocal, position, onClose }: StreamContextMenuProps) { - const menuRef = useRef(null); - - const [qualityPopoverOpen, setQualityPopoverOpen] = useState(false); - const qualityBtnRef = useRef(null); - - const streamVolumes = useVoiceStore((s) => s.streamVolumes); - const streamMutes = useVoiceStore((s) => s.streamMutes); - const watchingStreams = useVoiceStore((s) => s.watchingStreams); - const streamAttenuationEnabled = useVoiceStore((s) => s.streamAttenuationEnabled); - const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength); - const setStreamVolumeAction = useVoiceStore((s) => s.setStreamVolume); - const setStreamMuteAction = useVoiceStore((s) => s.setStreamMute); - const setAttenuationEnabled = useVoiceStore((s) => s.setStreamAttenuationEnabled); - const setAttenuationStrength = useVoiceStore((s) => s.setStreamAttenuationStrength); - - const isWatching = watchingStreams.has(userId); - const streamVolume = streamVolumes.get(userId) ?? 100; - const isStreamMuted = streamMutes.get(userId) ?? false; - - const handleWatch = useCallback(() => { - useVoiceStore.getState().watchStream(userId); - setStreamSubscription(getActiveRoom(), identity, true); - }, [userId, identity]); - - const handleUnwatch = useCallback(() => { - useVoiceStore.getState().unwatchStream(userId); - setStreamSubscription(getActiveRoom(), identity, false); - }, [userId, identity]); - - const handleStopStreaming = useCallback(async () => { - const room = getActiveRoom(); - if (room) { - await stopScreenShare(room); - } - }, []); - - const handleChangeStream = useCallback(async () => { - const room = getActiveRoom(); - if (room) { - await changeScreenShare(room); - } - }, []); - - // Click-outside dismissal (guards nested popover) - useEffect(() => { - const handler = (e: MouseEvent) => { - if (qualityPopoverOpen) return; - if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - onClose(); - } - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, [onClose, qualityPopoverOpen]); - - // Viewport-aware positioning - 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]); - - 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: React.CSSProperties = { width: 'calc(100% - 12px)' }; - - return ReactDOM.createPortal( -
e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - > - {isLocal ? ( - <> -
- - -
-
-
-
- Stream Quality -
-
- - {qualityPopoverOpen && ( - setQualityPopoverOpen(false)} - anchorRef={qualityBtnRef} - /> - )} -
-
- - ) : ( - <> -
- {isWatching ? ( - - ) : ( - - )} -
-
-
- -
-
-
- Stream Volume -
-
- - - - setStreamVolumeAction(userId, parseInt(e.target.value))} - className="flex-1 accent-accent-primary h-1" - /> - - {streamVolume}% - -
-
-
-
- -
- {streamAttenuationEnabled && ( -
-
- Attenuation Strength -
-
- setAttenuationStrength(parseInt(e.target.value))} - className="flex-1 accent-accent-primary h-1" - /> - - {streamAttenuationStrength}% - -
-
- )} - - )} -
, - document.body, - ); -} diff --git a/packages/web/src/components/voice/VoiceUserContextMenu.tsx b/packages/web/src/components/voice/VoiceUserContextMenu.tsx deleted file mode 100644 index 712b22d3..00000000 --- a/packages/web/src/components/voice/VoiceUserContextMenu.tsx +++ /dev/null @@ -1,399 +0,0 @@ -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 spaceMutedUserIds = useVoiceStore((s) => s.spaceMutedUserIds); - const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds); - - 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.id !== channelId, - ); - - const voiceOrigin = getChannelOrigin(channelId); - const spaceId = useSpaceStore((s) => s.channelToSpaceMap.get(channelId)); - - const isSpaceMuted = spaceMutedUserIds.has(`${spaceId}:${targetUserId}`); - const isSpaceDeafened = spaceDeafenedUserIds.has(`${spaceId}:${targetUserId}`); - - if (!canMuteMembers && !canDeafenMembers && !canMoveMembers && !canDisconnectMembers) return null; - - const handleSpaceMute = () => { - wsSend({ type: 'voice_space_mute', userId: targetUserId, muted: !isSpaceMuted }, voiceOrigin); - onAction(); - }; - - const handleSpaceDeafen = () => { - wsSend({ type: 'voice_space_deafen', userId: targetUserId, deafened: !isSpaceDeafened }, 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, - ); -}