1450 lines
49 KiB
TypeScript
1450 lines
49 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|