import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Box, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider, color } from 'folds'; import { useAtom } from 'jotai'; import { useCall } from './useCall'; import { useRoomCallMembers } from './useRoomCallMembers'; import { CallState, CallType } from './types'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { getMemberDisplayName, getMemberAvatarMxc } from '../../utils/room'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { UserAvatar } from '../../components/user-avatar'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { UnreadBadge } from '../../components/unread-badge'; import { useRoomUnread } from '../../state/hooks/unread'; import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { pendingUndockDragAtom, PendingDragState } from './pendingDragState'; import { RemoteCursorOverlay, useScreenShareCursor } from './RemoteCursorOverlay'; import * as css from './CallOverlay.css'; /** Drag position state */ interface DragPosition { x: number; y: number; } /** Threshold in pixels for docking detection */ const DOCK_THRESHOLD = 50; /** Combined width of space panel (68px) + room list panel (256px) for dock detection */ const ROOM_LIST_EDGE = 324; /** Dock zone type */ type DockZone = 'none' | 'right' | 'sidebar'; /** Custom hook for draggable functionality with viewport constraints and docking detection */ function useDraggable( onDockRight?: () => void, onDockSidebar?: () => void, onUndockRequest?: () => void, isDocked?: boolean, pendingDrag?: PendingDragState | null, clearPendingDrag?: () => void ) { const [position, setPosition] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); const [nearDockZone, setNearDockZone] = useState('none'); const dragRef = useRef(null); const dragStartRef = useRef<{ x: number; y: number; startX: number; startY: number } | null>(null); // Check for pending drag state from undocking useEffect(() => { if (pendingDrag && Date.now() - pendingDrag.timestamp < 500) { // Calculate where the floating panel should be positioned // so the mouse is at the same relative position as when dragging started const panelWidth = dragRef.current?.offsetWidth ?? 400; const panelHeight = dragRef.current?.offsetHeight ?? 300; // The floating panel renders centered (transform: translate(-50%, -50%)) // from right: 20px, bottom: 20px by default (CSS position) // We need to calculate the position offset to put the panel under the mouse // at the same offset as when the drag started // Target: mouse should be at offsetX from left edge, offsetY from top edge // Panel default position: right: 20px, bottom: 20px (centered via transform) // Default center: (window.innerWidth - 20 - panelWidth/2, window.innerHeight - 20 - panelHeight/2) const defaultCenterX = window.innerWidth - 20 - panelWidth / 2; const defaultCenterY = window.innerHeight - 20 - panelHeight / 2; // Where we want the panel's top-left corner to be: const targetLeft = pendingDrag.startX - pendingDrag.offsetX; const targetTop = pendingDrag.startY - pendingDrag.offsetY; // Where the panel's top-left corner would be by default: const defaultLeft = defaultCenterX - panelWidth / 2; const defaultTop = defaultCenterY - panelHeight / 2; // Calculate the offset needed const offsetX = targetLeft - defaultLeft; const offsetY = targetTop - defaultTop; setPosition({ x: offsetX, y: offsetY }); setIsDragging(true); dragStartRef.current = { x: offsetX, y: offsetY, startX: pendingDrag.startX, startY: pendingDrag.startY, }; clearPendingDrag?.(); } }, [pendingDrag, clearPendingDrag]); const handleMouseDown = useCallback((e: React.MouseEvent) => { if ((e.target as HTMLElement).closest('button')) return; e.preventDefault(); setIsDragging(true); dragStartRef.current = { x: position.x, y: position.y, startX: e.clientX, startY: e.clientY, }; }, [position]); // Clamp position to viewport bounds const clampToViewport = useCallback(() => { if (!dragRef.current) return; const rect = dragRef.current.getBoundingClientRect(); const padding = 16; let newX = position.x; let newY = position.y; let needsUpdate = false; if (rect.left < padding) { newX = position.x + (padding - rect.left); needsUpdate = true; } if (rect.right > window.innerWidth - padding) { newX = position.x - (rect.right - window.innerWidth + padding); needsUpdate = true; } if (rect.top < padding) { newY = position.y + (padding - rect.top); needsUpdate = true; } if (rect.bottom > window.innerHeight - padding) { newY = position.y - (rect.bottom - window.innerHeight + padding); needsUpdate = true; } if (needsUpdate) { setPosition({ x: newX, y: newY }); } }, [position]); useEffect(() => { const handleResize = () => { clampToViewport(); }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, [clampToViewport]); useEffect(() => { if (!isDragging) return undefined; const handleMouseMove = (e: MouseEvent) => { if (!dragStartRef.current || !dragRef.current) return; // Check dock zones const nearRightEdge = e.clientX > window.innerWidth - DOCK_THRESHOLD; const nearSidebarZone = e.clientX < ROOM_LIST_EDGE + DOCK_THRESHOLD && e.clientY > window.innerHeight - 150; if (!isDocked) { if (nearSidebarZone) { setNearDockZone('sidebar'); } else if (nearRightEdge) { setNearDockZone('right'); } else { setNearDockZone('none'); } } // If docked and dragging away from edge, trigger undock if (isDocked) { const dragDistance = Math.abs(e.clientX - dragStartRef.current.startX); if (dragDistance > DOCK_THRESHOLD) { onUndockRequest?.(); setIsDragging(false); dragStartRef.current = null; return; } } const deltaX = e.clientX - dragStartRef.current.startX; const deltaY = e.clientY - dragStartRef.current.startY; const rect = dragRef.current.getBoundingClientRect(); const padding = 16; let newX = dragStartRef.current.x + deltaX; let newY = dragStartRef.current.y + deltaY; const newLeft = rect.left - position.x + newX; const newRight = rect.right - position.x + newX; const newTop = rect.top - position.y + newY; const newBottom = rect.bottom - position.y + newY; if (newLeft < padding) { newX += padding - newLeft; } if (newRight > window.innerWidth - padding) { newX -= newRight - window.innerWidth + padding; } if (newTop < padding) { newY += padding - newTop; } if (newBottom > window.innerHeight - padding) { newY -= newBottom - window.innerHeight + padding; } setPosition({ x: newX, y: newY }); }; const handleMouseUp = (e: MouseEvent) => { // Check which dock zone to use const nearRightEdge = e.clientX > window.innerWidth - DOCK_THRESHOLD; const nearSidebarZone = e.clientX < ROOM_LIST_EDGE + DOCK_THRESHOLD && e.clientY > window.innerHeight - 150; if (!isDocked) { if (nearSidebarZone) { onDockSidebar?.(); } else if (nearRightEdge) { onDockRight?.(); } } setIsDragging(false); setNearDockZone('none'); dragStartRef.current = null; }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, [isDragging, position, isDocked, onDockRight, onDockSidebar, onUndockRequest]); return { position, isDragging, nearDockZone, handleMouseDown, dragRef }; } /** * Custom Screen share icon SVG (Phosphor-style) */ function ScreenShareIcon() { return ( ); } /** * Custom Screen share off icon SVG (Phosphor-style) */ function ScreenShareOffIcon() { return ( ); } /** Fullscreen icon */ function FullscreenIcon() { return ( ); } /** Exit fullscreen icon */ function ExitFullscreenIcon() { return ( ); } /** Picture-in-Picture icon */ function PipIcon() { return ( ); } /** * Formats call duration in MM:SS format */ function formatDuration(seconds: number): string { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } /** Screen share entry type */ export interface ScreenShareEntry { /** Unique identifier for the screen share */ id: string; /** Whether this is the local user's screen share */ isLocal: boolean; /** Matrix user ID of the participant sharing */ participantId: string; /** Room ID the call is in (for cursor resolution) */ roomId: string; /** LiveKit participant identity */ livekitId?: string; /** The underlying track (LiveKit RemoteTrack) */ track?: unknown; /** The MediaStream for the screen share */ stream?: MediaStream; } /** Props for ScreenShareItem component */ interface ScreenShareItemProps { /** The screen share entry to display */ share: ScreenShareEntry; } /** * Individual screen share display component * Handles attaching the video stream/track properly */ export function ScreenShareItem({ share }: ScreenShareItemProps) { const videoContainerRef = useRef(null); const [showControls, setShowControls] = useState(false); const [isPoppedOut, setIsPoppedOut] = useState(false); const hideTimeoutRef = useRef | null>(null); const { handleMouseMove, handleMouseLeave } = useScreenShareCursor(share.participantId); useEffect(() => { const container = videoContainerRef.current; if (!container) return undefined; // Always render video in main window (even when popped out - just hidden) // This keeps the track alive // Handle local screenshare (MediaStream) if (share.isLocal && share.stream) { const videoEl = document.createElement('video'); videoEl.className = css.ScreenShareVideo; videoEl.autoplay = true; videoEl.playsInline = true; videoEl.muted = true; videoEl.srcObject = share.stream; container.innerHTML = ''; container.appendChild(videoEl); return () => { videoEl.srcObject = null; if (videoEl.parentNode) { videoEl.parentNode.removeChild(videoEl); } }; } // Handle remote screenshare (LiveKit track) if (!share.isLocal && share.track) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const track = share.track as any; if (typeof track.attach === 'function') { const videoEl = track.attach() as HTMLVideoElement; videoEl.className = css.ScreenShareVideo; container.innerHTML = ''; container.appendChild(videoEl); return () => { if (typeof track.detach === 'function') { track.detach(videoEl); } if (videoEl.parentNode) { videoEl.parentNode.removeChild(videoEl); } }; } } return undefined; }, [share.isLocal, share.stream, share.track]); const handlePopOut = useCallback(() => { const popoutWindow = window.open( '', '_blank', 'width=1200,height=800,frame=false,titleBarStyle=hidden' ); if (!popoutWindow) return; popoutWindow.document.write(` Screen Share
`); popoutWindow.document.close(); // Wait for document to be ready, attach video FIRST (while still attached to main), THEN hide main setTimeout(() => { const videoContainer = popoutWindow.document.getElementById('videoContainer'); if (!videoContainer) return; // For local screenshare, create new video with the MediaStream if (share.isLocal && share.stream) { const videoEl = popoutWindow.document.createElement('video'); videoEl.autoplay = true; videoEl.playsInline = true; videoEl.muted = true; videoEl.srcObject = share.stream; videoContainer.appendChild(videoEl); videoEl.play().catch(() => {}); // Now that pop-out has the stream, hide main window setIsPoppedOut(true); } // For remote screenshare, use LiveKit track's attach method (creates fresh video) if (!share.isLocal && share.track) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const track = share.track as any; if (typeof track.attach === 'function') { // track.attach() creates a brand new video element attached to the track // Track is now attached to BOTH main window and pop-out const videoEl = track.attach() as HTMLVideoElement; videoEl.style.width = '100%'; videoEl.style.height = '100%'; videoEl.style.objectFit = 'contain'; videoEl.style.display = 'block'; videoEl.style.cursor = 'move'; // @ts-expect-error webkit property videoEl.style.webkitAppRegion = 'drag'; videoContainer.appendChild(videoEl); // Now that pop-out has the track, hide main window (which will detach from main) setIsPoppedOut(true); } } }, 100); // Show preview again when pop-out window is closed popoutWindow.addEventListener('beforeunload', () => { // For remote tracks, detach from pop-out if (!share.isLocal && share.track) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const track = share.track as any; if (typeof track.detach === 'function') { const videoContainer = popoutWindow.document.getElementById('videoContainer'); if (videoContainer) { const videoEl = videoContainer.querySelector('video'); if (videoEl) { track.detach(videoEl); } } } } setIsPoppedOut(false); }); }, [share.isLocal, share.stream, share.track]); const handleMouseMoveWrapper = useCallback((e: React.MouseEvent) => { // Clear any pending hide timeout if (hideTimeoutRef.current) { clearTimeout(hideTimeoutRef.current); hideTimeoutRef.current = null; } setShowControls(true); handleMouseMove(e); }, [handleMouseMove]); const handleMouseLeaveWrapper = useCallback((e: React.MouseEvent) => { // Delay hiding controls to prevent flickering hideTimeoutRef.current = setTimeout(() => { setShowControls(false); }, 300); handleMouseLeave(e); }, [handleMouseLeave]); // Cleanup timeout on unmount useEffect(() => { return () => { if (hideTimeoutRef.current) { clearTimeout(hideTimeoutRef.current); } }; }, []); return (
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
{/* Control buttons overlay */} {showControls && ( { // Keep controls visible when hovering over them if (hideTimeoutRef.current) { clearTimeout(hideTimeoutRef.current); hideTimeoutRef.current = null; } setShowControls(true); }} onMouseLeave={() => { // Hide controls after a delay when leaving the buttons hideTimeoutRef.current = setTimeout(() => { setShowControls(false); }, 300); }} > Pop Out } position="Bottom" align="Center" > {(triggerRef) => ( )} )}
); } /** * Component that displays active call controls and video streams */ export function CallOverlay() { const { activeCall, endCall, toggleMute, toggleDeafen, toggleVideo, toggleScreenShare } = useCall(); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const selectedRoomId = useSelectedRoom(); const [duration, setDuration] = useState(0); const [isTogglingScreenShare, setIsTogglingScreenShare] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [isPip, setIsPip] = useState(false); const [showParticipants, setShowParticipants] = useState(true); const [collapsedScreenShares, setCollapsedScreenShares] = useState>(new Set()); const localVideoRef = useRef(null); const remoteVideoRef = useRef(null); const containerRef = useRef(null); const { navigateRoom } = useRoomNavigate(); // Docking state const [isDocked, setIsDocked] = useSetting(settingsAtom, 'isCallPanelDocked'); const [, setDockPosition] = useSetting(settingsAtom, 'callPanelDockPosition'); // Pending drag state from undocking const [pendingDrag, setPendingDrag] = useAtom(pendingUndockDragAtom); const clearPendingDrag = useCallback(() => { setPendingDrag(null); }, [setPendingDrag]); const handleDockRight = useCallback(() => { setDockPosition('right'); setIsDocked(true); }, [setIsDocked, setDockPosition]); const handleDockSidebar = useCallback(() => { setDockPosition('sidebar'); setIsDocked(true); }, [setIsDocked, setDockPosition]); const handleUndockRequest = useCallback(() => { setIsDocked(false); }, [setIsDocked]); const { position, isDragging, nearDockZone, handleMouseDown, dragRef } = useDraggable( handleDockRight, handleDockSidebar, handleUndockRequest, isDocked, pendingDrag, clearPendingDrag ); // Get unread notifications for the call room const unread = useRoomUnread(activeCall?.roomId ?? '', roomToUnreadAtom); // Get call members from Matrix state events (provides real Matrix user IDs) const { callMembers } = useRoomCallMembers(activeCall?.roomId ?? ''); const myUserId = mx.getUserId() ?? ''; // Memoize the screenshare list to prevent re-renders causing flickering const allScreenShares = useMemo((): ScreenShareEntry[] => { const shares: ScreenShareEntry[] = []; // Add local screenshare first if active if (activeCall?.isScreenSharing && activeCall?.localScreenStream) { shares.push({ id: 'local', isLocal: true, participantId: myUserId, roomId: activeCall.roomId, stream: activeCall.localScreenStream, }); } // Add remote screenshares const remoteScreenTracks = activeCall?.remoteScreenTracks ? Array.from(activeCall.remoteScreenTracks.values()) : []; remoteScreenTracks.forEach((trackInfo) => { // Try to extract deviceId from LiveKit identity (format: "deviceId_timestamp") const livekitId = trackInfo.participantId; const underscoreIndex = livekitId.lastIndexOf('_'); const possibleDeviceId = underscoreIndex > 0 ? livekitId.substring(0, underscoreIndex) : livekitId; // Look up Matrix user ID by matching device ID from call members const matchingMember = callMembers.find((m) => m.deviceId === possibleDeviceId); const matrixUserId = matchingMember?.userId ?? livekitId; shares.push({ id: livekitId, isLocal: false, participantId: matrixUserId, roomId: activeCall?.roomId ?? '', livekitId, track: trackInfo.track, stream: trackInfo.stream, }); }); return shares; }, [activeCall?.isScreenSharing, activeCall?.localScreenStream, activeCall?.remoteScreenTracks, activeCall?.roomId, callMembers, myUserId]); useEffect(() => { if (!activeCall || activeCall.state !== CallState.Connected) { setDuration(0); return undefined; } const { startTime } = activeCall; const updateDuration = () => { const elapsed = Math.floor((Date.now() - startTime) / 1000); setDuration(elapsed); }; updateDuration(); const interval = setInterval(updateDuration, 1000); return () => clearInterval(interval); }, [activeCall]); useEffect(() => { if (localVideoRef.current && activeCall?.localStream) { localVideoRef.current.srcObject = activeCall.localStream; } }, [activeCall?.localStream]); useEffect(() => { if (remoteVideoRef.current && activeCall?.remoteStreams.size) { const [firstStream] = activeCall.remoteStreams.values(); if (firstStream) { remoteVideoRef.current.srcObject = firstStream; } } }, [activeCall?.remoteStreams]); // Fullscreen toggle handler const handleToggleFullscreen = useCallback(async () => { if (!containerRef.current) return; try { if (!isFullscreen) { await containerRef.current.requestFullscreen(); setIsFullscreen(true); } else if (document.fullscreenElement) { await document.exitFullscreen(); setIsFullscreen(false); } } catch (err) { // eslint-disable-next-line no-console console.error('Fullscreen error:', err); } }, [isFullscreen]); // Pop-out window handler const handlePopOut = useCallback(() => { if (!activeCall) return; // Get room inside callback to avoid initialization order issues const callRoom = mx.getRoom(activeCall.roomId); const popoutWindow = window.open( '', '_blank', 'width=1200,height=800,menubar=no,toolbar=no,location=no,status=no' ); if (popoutWindow && activeCall.remoteStreams.size > 0) { const [firstStream] = activeCall.remoteStreams.values(); popoutWindow.document.write(` Video Call - ${callRoom?.name ?? 'Call'} `); popoutWindow.document.close(); // Wait for window to load, then set the video stream setTimeout(() => { const videoEl = popoutWindow.document.getElementById('remoteVideo'); if (videoEl) { videoEl.srcObject = firstStream; } }, 100); } }, [activeCall, mx]); // PiP toggle handler const handleTogglePip = useCallback(async () => { const videoEl = remoteVideoRef.current; if (!videoEl) return; try { if (!isPip && document.pictureInPictureEnabled) { await videoEl.requestPictureInPicture(); setIsPip(true); } else if (document.pictureInPictureElement) { await document.exitPictureInPicture(); setIsPip(false); } } catch (err) { // eslint-disable-next-line no-console console.error('PiP error:', err); } }, [isPip]); // Listen for fullscreen change events (only for the call overlay itself, not screenshares) useEffect(() => { const handleFullscreenChange = () => { // Only set fullscreen state if the call overlay container is fullscreened setIsFullscreen(document.fullscreenElement === containerRef.current); }; document.addEventListener('fullscreenchange', handleFullscreenChange); return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); }, []); // Listen for PiP change events useEffect(() => { const videoEl = remoteVideoRef.current; if (!videoEl) return undefined; const handlePipEnter = () => setIsPip(true); const handlePipLeave = () => setIsPip(false); videoEl.addEventListener('enterpictureinpicture', handlePipEnter); videoEl.addEventListener('leavepictureinpicture', handlePipLeave); return () => { videoEl.removeEventListener('enterpictureinpicture', handlePipEnter); videoEl.removeEventListener('leavepictureinpicture', handlePipLeave); }; }, [activeCall]); // Assign both refs to the container - must be before early return const setContainerRef = useCallback((el: HTMLDivElement | null) => { (containerRef as React.MutableRefObject).current = el; (dragRef as React.MutableRefObject).current = el; }, [dragRef]); if (!activeCall || activeCall.state === CallState.Idle || activeCall.state === CallState.Ended) { return null; } // If docked and not fullscreen, don't render as overlay - it's rendered in the layout instead if (isDocked && !isFullscreen) { return null; } const isConnecting = activeCall.state === CallState.Connecting; const isVideoCall = activeCall.callType === CallType.Video; // Get room for participant info const room = mx.getRoom(activeCall.roomId); // Build participant list from Matrix state events (provides real user IDs) // callMembers has the actual Matrix user IDs from org.matrix.msc3401.call.member events const allParticipants = callMembers.map((m) => m.userId); const participantCount = allParticipants.length; /** * Gets avatar URL for a participant */ const getParticipantAvatar = (participantId: string): string | undefined => { if (!room) return undefined; const mxcUrl = getMemberAvatarMxc(room, participantId); if (!mxcUrl) return undefined; return mxcUrlToHttp(mx, mxcUrl, useAuthentication, 48, 48, 'crop') ?? undefined; }; /** * Gets display name for a participant */ const getParticipantName = (participantId: string): string => { if (!room) return getMxIdLocalPart(participantId) ?? participantId; return getMemberDisplayName(room, participantId) ?? getMxIdLocalPart(participantId) ?? participantId; }; /** * Checks if a participant is currently speaking * Handles mapping between Matrix user IDs and LiveKit identities */ const isParticipantSpeaking = (userId: string): boolean => { if (!activeCall) return false; // Direct check for Matrix user ID (works for local user) if (activeCall.activeSpeakers.has(userId)) return true; // For remote users, activeSpeakers contains LiveKit identities (deviceId_timestamp) // We need to find the deviceId for this user and check if any matching identity is speaking const member = callMembers.find((m) => m.userId === userId); if (!member) return false; // Check if any active speaker has this user's deviceId return Array.from(activeCall.activeSpeakers).some((speakerId) => { const underscoreIndex = speakerId.lastIndexOf('_'); const deviceId = underscoreIndex > 0 ? speakerId.substring(0, underscoreIndex) : speakerId; return deviceId === member.deviceId; }); }; /** * Checks if a participant is currently muted * Handles mapping between Matrix user IDs and LiveKit identities */ const isParticipantMuted = (userId: string): boolean => { if (!activeCall) return false; // For local user, check activeCall.isMuted if (userId === myUserId) return activeCall.isMuted; // For remote users, mutedParticipants contains LiveKit identities (deviceId_timestamp) // We need to find the deviceId for this user and check if any matching identity is muted const member = callMembers.find((m) => m.userId === userId); if (!member) return false; // Check if any muted participant has this user's deviceId return Array.from(activeCall.mutedParticipants).some((mutedId) => { const underscoreIndex = mutedId.lastIndexOf('_'); const deviceId = underscoreIndex > 0 ? mutedId.substring(0, underscoreIndex) : mutedId; return deviceId === member.deviceId; }); }; const handleEndCall = () => { endCall(); }; const handleToggleMute = () => { toggleMute(); }; const handleToggleDeafen = () => { toggleDeafen(); }; const handleToggleVideo = () => { toggleVideo(); }; const handleToggleScreenShare = async () => { if (isTogglingScreenShare) return; setIsTogglingScreenShare(true); try { await toggleScreenShare(); } finally { setIsTogglingScreenShare(false); } }; // Determine container class based on state const containerClass = [ css.CallOverlayContainer, isFullscreen && css.CallOverlayFullscreen, isDocked && !isFullscreen && css.CallOverlayDocked, isDragging && css.CallOverlayDragging, ].filter(Boolean).join(' '); return ( <> {/* Dock indicators shown when dragging near dock zones */} {nearDockZone === 'right' && !isDocked &&
} {nearDockZone === 'sidebar' && !isDocked &&
}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
{isConnecting ? 'Connecting...' : `In Call - ${room?.name ?? 'Unknown Room'}`} {selectedRoomId === activeCall.roomId && ( Current Room} > {(triggerRef) => ( )} )} {unread && unread.total > 0 && ( 0} count={unread.total} /> )} {selectedRoomId !== activeCall.roomId && ( Go to Room} > {(triggerRef) => ( navigateRoom(activeCall.roomId)} variant="Secondary" size="300" style={{ padding: '2px', marginLeft: '4px' }} > )} )} {formatDuration(duration)} {isVideoCall && ( <> Pop Out} > {(triggerRef) => ( )} {isPip ? 'Exit PiP' : 'Picture-in-Picture'}} > {(triggerRef) => ( )} {isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}} > {(triggerRef) => ( {isFullscreen ? : } )} )}
{/* Participants Section */}
{showParticipants && (
{allParticipants.map((participantId) => { const isMe = participantId === myUserId; const displayName = getParticipantName(participantId); const avatarUrl = getParticipantAvatar(participantId); const isSpeaking = isParticipantSpeaking(participantId); const isMuted = isParticipantMuted(participantId); // Get screenshares for this participant const participantScreenShares = allScreenShares.filter( (share) => share.participantId === participantId ); return (
( {displayName.charAt(0).toUpperCase()} )} />
{displayName} {isMe && (You)} {participantId} {isMuted && ( )} {participantScreenShares.length > 0 && ( {collapsedScreenShares.has(participantScreenShares[0].id) ? 'Show Screen' : 'Hide Screen'}} > {(triggerRef) => ( { setCollapsedScreenShares((prev) => { const next = new Set(prev); // Toggle all screenshares for this participant participantScreenShares.forEach((share) => { if (next.has(share.id)) { next.delete(share.id); } else { next.add(share.id); } }); return next; }); }} variant="Secondary" size="300" style={{ padding: '2px' }} > )} )}
); })}
)}
{/* Screen shares rendered outside participant list */} {allScreenShares .filter((share) => !collapsedScreenShares.has(share.id)) .map((share) => ( ))} {isVideoCall && (
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
)}
{activeCall.isMuted ? 'Unmute' : 'Mute'} } > {(triggerRef) => ( )} {activeCall.isDeafened ? 'Undeafen' : 'Deafen'} } > {(triggerRef) => ( )} {isVideoCall && ( {activeCall.isVideoEnabled ? 'Turn Off Camera' : 'Turn On Camera'} } > {(triggerRef) => ( )} )} {activeCall.isScreenSharing ? 'Stop Screen Share' : 'Share Screen'} } > {(triggerRef) => ( {activeCall.isScreenSharing ? : } )} End Call } > {(triggerRef) => ( )}
); }