Files
cinny/src/app/features/call/CallOverlay.tsx

1450 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<DragPosition>({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [nearDockZone, setNearDockZone] = useState<DockZone>('none');
const dragRef = useRef<HTMLDivElement>(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 (
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 16V8a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h5v2H8v2h8v-2h-2v-2h5a2 2 0 0 0 2-2zm-2 0H5V8h14v8z" />
<path d="M12 9l-4 4h3v3h2v-3h3l-4-4z" />
</svg>
);
}
/**
* Custom Screen share off icon SVG (Phosphor-style)
*/
function ScreenShareOffIcon() {
return (
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 16V8a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h5v2H8v2h8v-2h-2v-2h5a2 2 0 0 0 2-2zm-2 0H5V8h14v8z" />
<path d="M2 4l20 16" stroke="currentColor" strokeWidth="2" />
</svg>
);
}
/** Fullscreen icon */
function FullscreenIcon() {
return (
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
</svg>
);
}
/** Exit fullscreen icon */
function ExitFullscreenIcon() {
return (
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" />
</svg>
);
}
/** Picture-in-Picture icon */
function PipIcon() {
return (
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14z" />
</svg>
);
}
/**
* 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<HTMLDivElement>(null);
const [showControls, setShowControls] = useState(false);
const [isPoppedOut, setIsPoppedOut] = useState(false);
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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(`
<!DOCTYPE html>
<html>
<head>
<title>Screen Share</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
user-select: none;
position: relative;
}
#videoContainer {
width: 100%;
height: 100%;
}
#videoContainer video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
-webkit-app-region: drag;
cursor: move;
}
#closeButton {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
background: rgba(0, 0, 0, 0.7);
border: none;
border-radius: 8px;
color: #fff;
font-size: 20px;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
-webkit-app-region: no-drag;
transition: background 0.2s;
}
#closeButton:hover {
background: rgba(255, 0, 0, 0.8);
}
#closeButton.visible {
display: flex;
}
</style>
</head>
<body>
<div id="videoContainer"></div>
<button id="closeButton" title="Close">×</button>
<script>
let hideTimeout = null;
const closeButton = document.getElementById('closeButton');
function showCloseButton() {
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
closeButton.classList.add('visible');
hideTimeout = setTimeout(() => {
closeButton.classList.remove('visible');
hideTimeout = null;
}, 2000);
}
// Show on any mouse movement
document.addEventListener('mousemove', showCloseButton, true);
// Keep visible when hovering over button
closeButton.addEventListener('mouseenter', () => {
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
closeButton.classList.add('visible');
});
// Start hide timer when leaving button
closeButton.addEventListener('mouseleave', () => {
hideTimeout = setTimeout(() => {
closeButton.classList.remove('visible');
hideTimeout = null;
}, 2000);
});
// Close window on click
closeButton.addEventListener('click', () => {
window.close();
});
// Keep video playing - prevent pause from visibility changes or focus loss
function keepVideoPlaying() {
const video = document.querySelector('video');
if (video && video.paused && video.srcObject) {
video.play().catch(() => {});
}
}
// Check every 500ms if video got paused
setInterval(keepVideoPlaying, 500);
// Also resume on visibility change
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
keepVideoPlaying();
}
});
// Resume on window focus
window.addEventListener('focus', keepVideoPlaying);
</script>
</body>
</html>
`);
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 (
<div
className={css.ScreenShareContainer}
style={{
position: isPoppedOut ? 'absolute' : 'relative',
width: isPoppedOut ? '1px' : undefined,
height: isPoppedOut ? '1px' : undefined,
opacity: isPoppedOut ? 0 : 1,
pointerEvents: isPoppedOut ? 'none' : 'auto',
overflow: isPoppedOut ? 'hidden' : 'visible',
left: isPoppedOut ? '-9999px' : undefined,
}}
>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
ref={videoContainerRef}
className={css.ScreenShareVideoContainer}
onMouseMove={handleMouseMoveWrapper}
onMouseLeave={handleMouseLeaveWrapper}
style={{ cursor: 'pointer' }}
/>
{/* Control buttons overlay */}
{showControls && (
<Box
style={{
position: 'absolute',
top: '12px',
right: '12px',
display: 'flex',
gap: '8px',
zIndex: 10,
pointerEvents: 'auto',
}}
onMouseEnter={() => {
// 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);
}}
>
<TooltipProvider
tooltip={
<Tooltip variant="Secondary">
<Text size="T200">Pop Out</Text>
</Tooltip>
}
position="Bottom"
align="Center"
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="Secondary"
size="300"
radii="300"
onClick={handlePopOut}
aria-label="Open in new window"
>
<Icon size="200" src={Icons.External} />
</IconButton>
)}
</TooltipProvider>
</Box>
)}
<RemoteCursorOverlay shareId={share.participantId} roomId={share.roomId} />
</div>
);
}
/**
* 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<Set<string>>(new Set());
const localVideoRef = useRef<HTMLVideoElement>(null);
const remoteVideoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(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(`
<!DOCTYPE html>
<html>
<head>
<title>Video Call - ${callRoom?.name ?? 'Call'}</title>
<style>
body {
margin: 0;
padding: 0;
background: #000;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
overflow: hidden;
}
video {
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
object-fit: contain;
}
</style>
</head>
<body>
<video id="remoteVideo" autoplay playsinline></video>
<script>
const video = document.getElementById('remoteVideo');
// Stream will be set from parent window
</script>
</body>
</html>
`);
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<HTMLDivElement | null>).current = el;
(dragRef as React.MutableRefObject<HTMLDivElement | null>).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 && <div className={css.DockIndicator} />}
{nearDockZone === 'sidebar' && !isDocked && <div className={css.SidebarDockIndicator} />}
<div
ref={setContainerRef}
className={containerClass}
style={!isFullscreen && !isDocked ? {
transform: `translate(${position.x}px, ${position.y}px)`,
} : undefined}
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className={css.CallOverlayHeader}
onMouseDown={handleMouseDown}
style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
>
<Box alignItems="Center" gap="200" style={{ minWidth: 0, flex: 1 }}>
<Icon
size="200"
src={isVideoCall ? Icons.VideoCamera : Icons.Phone}
/>
<Text size="T300" truncate>
{isConnecting ? 'Connecting...' : `In Call - ${room?.name ?? 'Unknown Room'}`}
{selectedRoomId === activeCall.roomId && (
<TooltipProvider
position="Top"
offset={4}
tooltip={<Tooltip><Text>Current Room</Text></Tooltip>}
>
{(triggerRef) => (
<Icon
ref={triggerRef}
size="100"
src={Icons.Check}
style={{ marginLeft: '4px', opacity: 0.7, verticalAlign: 'middle' }}
/>
)}
</TooltipProvider>
)}
</Text>
{unread && unread.total > 0 && (
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
)}
{selectedRoomId !== activeCall.roomId && (
<TooltipProvider
position="Top"
offset={4}
tooltip={<Tooltip><Text>Go to Room</Text></Tooltip>}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={() => navigateRoom(activeCall.roomId)}
variant="Secondary"
size="300"
style={{ padding: '2px', marginLeft: '4px' }}
>
<Icon size="100" src={Icons.Link} />
</IconButton>
)}
</TooltipProvider>
)}
</Box>
<Box alignItems="Center" gap="100">
<Text size="T200" className={css.CallDuration}>
{formatDuration(duration)}
</Text>
{isVideoCall && (
<>
<TooltipProvider
position="Top"
offset={4}
tooltip={<Tooltip><Text>Pop Out</Text></Tooltip>}
>
{(triggerRef) => (
<IconButton ref={triggerRef} onClick={handlePopOut} variant="Secondary" size="300">
<Icon size="200" src={Icons.External} />
</IconButton>
)}
</TooltipProvider>
<TooltipProvider
position="Top"
offset={4}
tooltip={<Tooltip><Text>{isPip ? 'Exit PiP' : 'Picture-in-Picture'}</Text></Tooltip>}
>
{(triggerRef) => (
<IconButton ref={triggerRef} onClick={handleTogglePip} variant="Secondary" size="300">
<Box as="span" style={{ width: '1rem', height: '1rem', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<PipIcon />
</Box>
</IconButton>
)}
</TooltipProvider>
<TooltipProvider
position="Top"
offset={4}
tooltip={<Tooltip><Text>{isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}</Text></Tooltip>}
>
{(triggerRef) => (
<IconButton ref={triggerRef} onClick={handleToggleFullscreen} variant="Secondary" size="300">
<Box as="span" style={{ width: '1rem', height: '1rem', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{isFullscreen ? <ExitFullscreenIcon /> : <FullscreenIcon />}
</Box>
</IconButton>
)}
</TooltipProvider>
</>
)}
</Box>
</div>
{/* Participants Section */}
<div className={css.ParticipantsSection}>
<button
type="button"
className={css.ParticipantsHeader}
onClick={() => setShowParticipants(!showParticipants)}
>
<Box alignItems="Center" gap="200">
<Icon size="100" src={Icons.User} />
<Text size="T200" className={css.ParticipantsHeaderText}>
{participantCount} {participantCount === 1 ? 'Participant' : 'Participants'}
</Text>
</Box>
<Icon
size="100"
src={showParticipants ? Icons.ChevronTop : Icons.ChevronBottom}
/>
</button>
{showParticipants && (
<div className={css.ParticipantsList}>
{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 (
<div key={participantId} className={css.ParticipantItem}>
<div className={`${css.ParticipantAvatarWrapper} ${isSpeaking ? css.ParticipantSpeaking : ''}`}>
<UserAvatar
className={css.ParticipantAvatar}
userId={participantId}
src={avatarUrl}
alt={displayName}
renderFallback={() => (
<Text size="H6" style={{ color: color.Surface.Container }}>
{displayName.charAt(0).toUpperCase()}
</Text>
)}
/>
</div>
<Box direction="Column" grow="Yes" style={{ minWidth: 0 }}>
<Text size="T200" className={css.ParticipantName} truncate>
{displayName}
{isMe && <span className={css.YouBadge}> (You)</span>}
</Text>
<Text size="T200" className={css.ParticipantId} truncate>
{participantId}
</Text>
</Box>
{isMuted && (
<Icon size="100" src={Icons.MicMute} className={css.ParticipantMutedIcon} />
)}
{participantScreenShares.length > 0 && (
<TooltipProvider
position="Top"
offset={4}
tooltip={<Tooltip><Text>{collapsedScreenShares.has(participantScreenShares[0].id) ? 'Show Screen' : 'Hide Screen'}</Text></Tooltip>}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={() => {
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' }}
>
<ScreenShareIcon />
</IconButton>
)}
</TooltipProvider>
)}
</div>
);
})}
</div>
)}
</div>
{/* Screen shares rendered outside participant list */}
{allScreenShares
.filter((share) => !collapsedScreenShares.has(share.id))
.map((share) => (
<ScreenShareItem key={share.id} share={share} />
))}
{isVideoCall && (
<div className={css.VideoContainer}>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={remoteVideoRef}
autoPlay
playsInline
controls
className={css.VideoElement}
/>
<div className={css.LocalVideo}>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={localVideoRef}
autoPlay
playsInline
muted
controls
className={css.VideoElement}
/>
</div>
</div>
)}
<div className={css.CallOverlayControls}>
<TooltipProvider
position="Top"
offset={4}
tooltip={
<Tooltip>
<Text>{activeCall.isMuted ? 'Unmute' : 'Mute'}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={handleToggleMute}
variant={activeCall.isMuted ? 'Critical' : 'Secondary'}
>
<Icon
size="300"
src={activeCall.isMuted ? Icons.MicMute : Icons.Mic}
/>
</IconButton>
)}
</TooltipProvider>
<TooltipProvider
position="Top"
offset={4}
tooltip={
<Tooltip>
<Text>{activeCall.isDeafened ? 'Undeafen' : 'Deafen'}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={handleToggleDeafen}
variant={activeCall.isDeafened ? 'Critical' : 'Secondary'}
>
<Icon
size="300"
src={activeCall.isDeafened ? Icons.VolumeMute : Icons.VolumeHigh}
/>
</IconButton>
)}
</TooltipProvider>
{isVideoCall && (
<TooltipProvider
position="Top"
offset={4}
tooltip={
<Tooltip>
<Text>{activeCall.isVideoEnabled ? 'Turn Off Camera' : 'Turn On Camera'}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={handleToggleVideo}
variant={activeCall.isVideoEnabled ? 'Secondary' : 'Critical'}
>
<Icon
size="300"
src={activeCall.isVideoEnabled ? Icons.VideoCamera : Icons.VideoCameraMute}
/>
</IconButton>
)}
</TooltipProvider>
)}
<TooltipProvider
position="Top"
offset={4}
tooltip={
<Tooltip>
<Text>{activeCall.isScreenSharing ? 'Stop Screen Share' : 'Share Screen'}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={handleToggleScreenShare}
variant={activeCall.isScreenSharing ? 'Primary' : 'Secondary'}
disabled={isTogglingScreenShare}
>
<Box
as="span"
style={{
width: '1.5rem',
height: '1.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
{activeCall.isScreenSharing ? <ScreenShareOffIcon /> : <ScreenShareIcon />}
</Box>
</IconButton>
)}
</TooltipProvider>
<TooltipProvider
position="Top"
offset={4}
tooltip={
<Tooltip>
<Text>End Call</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
onClick={handleEndCall}
variant="Critical"
>
<Icon size="300" src={Icons.Phone} />
</IconButton>
)}
</TooltipProvider>
</div>
</div>
</>
);
}