import React, { useEffect, useMemo, useState } from 'react'; import { Box, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider, Scroll, color } from 'folds'; import { useSetAtom } from 'jotai'; import { useCall } from './useCall'; import { ScreenShareItem, ScreenShareEntry } from './CallOverlay'; 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 } from './pendingDragState'; import * as css from './CallOverlay.css'; /** * Formats call duration in MM:SS format */ function formatDuration(seconds: number): string { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins}:${secs.toString().padStart(2, '0')}`; } /** * Docked call panel content - renders call UI in the layout flow * This is used when the call panel is docked to the right side */ /** * Custom Screen share icon SVG (Phosphor-style) */ function ScreenShareIcon() { return ( ); } /** * Custom Screen share off icon SVG (Phosphor-style) */ function ScreenShareOffIcon() { return ( ); } export function DockedCallContent() { const { activeCall, endCall, toggleMute, toggleDeafen, toggleScreenShare } = useCall(); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const selectedRoomId = useSelectedRoom(); const [duration, setDuration] = useState(0); const [isTogglingScreenShare, setIsTogglingScreenShare] = useState(false); const [showParticipants, setShowParticipants] = useState(true); const { navigateRoom } = useRoomNavigate(); const [, setIsDocked] = useSetting(settingsAtom, 'isCallPanelDocked'); const setPendingDrag = useSetAtom(pendingUndockDragAtom); const unread = useRoomUnread(activeCall?.roomId ?? '', roomToUnreadAtom); const { callMembers } = useRoomCallMembers(activeCall?.roomId ?? ''); const myUserId = mx.getUserId() ?? ''; /** Memoized list of active screen shares (local + remote) */ const allScreenShares = useMemo((): ScreenShareEntry[] => { const shares: ScreenShareEntry[] = []; if (activeCall?.isScreenSharing && activeCall?.localScreenStream) { shares.push({ id: 'local', isLocal: true, participantId: myUserId, roomId: activeCall.roomId, stream: activeCall.localScreenStream, }); } const remoteScreenTracks = activeCall?.remoteScreenTracks ? Array.from(activeCall.remoteScreenTracks.values()) : []; remoteScreenTracks.forEach((trackInfo) => { const livekitId = trackInfo.participantId; const underscoreIndex = livekitId.lastIndexOf('_'); const possibleDeviceId = underscoreIndex > 0 ? livekitId.substring(0, underscoreIndex) : livekitId; 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]); /** * Checks if a participant is currently speaking */ const isParticipantSpeaking = (userId: string): boolean => { if (!activeCall) return false; if (activeCall.activeSpeakers.has(userId)) return true; const member = callMembers.find((m) => m.userId === userId); if (!member) return false; 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 */ const isParticipantMuted = (userId: string): boolean => { if (!activeCall) return false; if (userId === myUserId) return activeCall.isMuted; const member = callMembers.find((m) => m.userId === userId); if (!member) return false; 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 handleToggleScreenShare = async () => { if (isTogglingScreenShare) return; setIsTogglingScreenShare(true); try { await toggleScreenShare(); } finally { setIsTogglingScreenShare(false); } }; /** * Handle mousedown on header to start drag-to-undock * Sets pending drag state so the floating overlay can continue the drag */ const handleHeaderMouseDown = async (e: React.MouseEvent) => { // Don't start drag if clicking a button if ((e.target as HTMLElement).closest('button')) return; // Exit browser fullscreen if active before undocking if (document.fullscreenElement) { try { await document.exitFullscreen(); } catch { // Fullscreen exit may fail } } // Get the header element to calculate offset const header = e.currentTarget as HTMLElement; const rect = header.getBoundingClientRect(); // Store the drag start position with mouse offset from panel edges setPendingDrag({ startX: e.clientX, startY: e.clientY, offsetX: e.clientX - rect.left, offsetY: e.clientY - rect.top, timestamp: Date.now(), }); // Undock immediately - the floating overlay will pick up the drag setIsDocked(false); }; if (!activeCall || activeCall.state === CallState.Idle || activeCall.state === CallState.Ended) { return null; } const isConnecting = activeCall.state === CallState.Connecting; const isVideoCall = activeCall.callType === CallType.Video; const room = mx.getRoom(activeCall.roomId); const allParticipants = callMembers.map((m) => m.userId); const participantCount = allParticipants.length; return (
{/* Header - drag to undock */} {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
{isConnecting ? 'Connecting...' : room?.name ?? 'Unknown Room'} {selectedRoomId === activeCall.roomId && ( Current Room} > {(triggerRef) => ( )} )} {unread && unread.total > 0 && ( 0} count={unread.total} /> )} {formatDuration(duration)} {selectedRoomId !== activeCall.roomId && ( Go to Room} > {(triggerRef) => ( navigateRoom(activeCall.roomId)} variant="Secondary" size="300" > )} )}
{/* Participants */}
{showParticipants && room && (
{allParticipants.map((participantId) => { const avatarMxc = getMemberAvatarMxc(room, participantId); const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 32, 32, 'crop') ?? undefined : undefined; const displayName = getMemberDisplayName(room, participantId) ?? getMxIdLocalPart(participantId) ?? participantId; const isMe = participantId === myUserId; const isMuted = isParticipantMuted(participantId); const isSpeaking = isParticipantSpeaking(participantId); return (
( {displayName.charAt(0).toUpperCase()} )} />
{displayName} {isMe && (You)} {isMuted && ( )}
); })}
)}
{/* Screen shares */} {allScreenShares.length > 0 && ( {allScreenShares.map((share) => ( ))} )} {/* Controls */}
{activeCall.isMuted ? 'Unmute' : 'Mute'}} > {(triggerRef) => ( )} {activeCall.isDeafened ? 'Undeafen' : 'Deafen'}} > {(triggerRef) => ( )} {activeCall.isScreenSharing ? 'Stop Screen Share' : 'Share Screen'}} > {(triggerRef) => ( {activeCall.isScreenSharing ? : } )} End Call} > {(triggerRef) => ( )}
); }