feat(call): add docked call panel and remote cursor overlay

- Implemented DockedCallPanel component to render the docked call interface when an active call is present.
- Created RemoteCursorOverlay component to display remote cursor positions during screen sharing.
- Added hooks for managing docked call state and remote cursor functionality.
- Introduced pending drag state management for seamless transitions between docked and floating states.
- Developed embed filter management components for personal and room-wide settings, allowing users to customize URL embed visibility.
- Implemented auto-joining functionality for rooms within spaces, enhancing user experience during space navigation.
- Added state management for tracking joining progress of rooms in spaces.
This commit is contained in:
2026-02-19 17:49:48 +11:00
parent bfbdf98468
commit 3f6f2134ad
42 changed files with 3801 additions and 219 deletions

View File

@@ -1,5 +1,6 @@
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';
@@ -10,6 +11,13 @@ 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 */
@@ -18,13 +26,67 @@ interface DragPosition {
y: number;
}
/** Custom hook for draggable functionality with viewport constraints */
function useDraggable() {
/** Threshold in pixels for docking detection */
const DOCK_THRESHOLD = 50;
/** Custom hook for draggable functionality with viewport constraints and docking detection */
function useDraggable(
onDockRequest?: () => 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 [isNearDockZone, setIsNearDockZone] = useState(false);
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();
@@ -43,30 +105,22 @@ function useDraggable() {
const rect = dragRef.current.getBoundingClientRect();
const padding = 16;
// The element is positioned with right: S400 (16px) and top: 60px in CSS
// Transform moves it from that base position
// We need to ensure the element stays fully visible
let newX = position.x;
let newY = position.y;
let needsUpdate = false;
// Check if left edge is off screen (rect.left < padding)
if (rect.left < padding) {
newX = position.x + (padding - rect.left);
needsUpdate = true;
}
// Check if right edge is off screen
if (rect.right > window.innerWidth - padding) {
newX = position.x - (rect.right - window.innerWidth + padding);
needsUpdate = true;
}
// Check if top edge is off screen
if (rect.top < padding) {
newY = position.y + (padding - rect.top);
needsUpdate = true;
}
// Check if bottom edge is off screen
if (rect.bottom > window.innerHeight - padding) {
newY = position.y - (rect.bottom - window.innerHeight + padding);
needsUpdate = true;
@@ -77,7 +131,6 @@ function useDraggable() {
}
}, [position]);
// Listen for window resize to keep element in bounds
useEffect(() => {
const handleResize = () => {
clampToViewport();
@@ -91,24 +144,36 @@ function useDraggable() {
const handleMouseMove = (e: MouseEvent) => {
if (!dragStartRef.current || !dragRef.current) return;
// Check if near right edge for docking
const nearRightEdge = e.clientX > window.innerWidth - DOCK_THRESHOLD;
setIsNearDockZone(nearRightEdge && !isDocked);
// 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;
// Get element dimensions for bounds checking
const rect = dragRef.current.getBoundingClientRect();
const padding = 16;
// Calculate new position
let newX = dragStartRef.current.x + deltaX;
let newY = dragStartRef.current.y + deltaY;
// Calculate where the element would be with the new transform
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;
// Clamp to keep fully on screen
if (newLeft < padding) {
newX += padding - newLeft;
}
@@ -125,8 +190,13 @@ function useDraggable() {
setPosition({ x: newX, y: newY });
};
const handleMouseUp = () => {
const handleMouseUp = (e: MouseEvent) => {
// Check if should dock on mouse release
if (!isDocked && e.clientX > window.innerWidth - DOCK_THRESHOLD) {
onDockRequest?.();
}
setIsDragging(false);
setIsNearDockZone(false);
dragStartRef.current = null;
};
@@ -137,9 +207,9 @@ function useDraggable() {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, position]);
}, [isDragging, position, isDocked, onDockRequest, onUndockRequest]);
return { position, isDragging, handleMouseDown, dragRef };
return { position, isDragging, isNearDockZone, handleMouseDown, dragRef };
}
/**
@@ -203,17 +273,26 @@ function formatDuration(seconds: number): string {
}
/** Screen share entry type */
interface ScreenShareEntry {
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;
}
@@ -221,8 +300,9 @@ interface ScreenShareItemProps {
* Individual screen share display component
* Handles attaching the video stream/track properly
*/
function ScreenShareItem({ share }: ScreenShareItemProps) {
export function ScreenShareItem({ share }: ScreenShareItemProps) {
const containerRef = useRef<HTMLDivElement>(null);
const { handleMouseMove, handleMouseLeave } = useScreenShareCursor(share.participantId);
useEffect(() => {
const container = containerRef.current;
@@ -281,7 +361,6 @@ function ScreenShareItem({ share }: ScreenShareItemProps) {
const container = containerRef.current;
if (!container) return;
// Find the video element inside the container
const videoEl = container.querySelector('video');
if (!videoEl) return;
@@ -297,14 +376,17 @@ function ScreenShareItem({ share }: ScreenShareItemProps) {
}, []);
return (
<div className={css.ScreenShareContainer}>
<div className={css.ScreenShareContainer} style={{ position: 'relative' }}>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
ref={containerRef}
className={css.ScreenShareVideoContainer}
onDoubleClick={handleDoubleClick}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
style={{ cursor: 'pointer' }}
/>
<RemoteCursorOverlay shareId={share.participantId} roomId={share.roomId} />
</div>
);
}
@@ -326,9 +408,37 @@ export function CallOverlay() {
const localVideoRef = useRef<HTMLVideoElement>(null);
const remoteVideoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const { position, isDragging, handleMouseDown, dragRef } = useDraggable();
const { navigateRoom } = useRoomNavigate();
// Docking state
const [isDocked, setIsDocked] = useSetting(settingsAtom, 'isCallPanelDocked');
// Pending drag state from undocking
const [pendingDrag, setPendingDrag] = useAtom(pendingUndockDragAtom);
const clearPendingDrag = useCallback(() => {
setPendingDrag(null);
}, [setPendingDrag]);
const handleDockRequest = useCallback(() => {
setIsDocked(true);
}, [setIsDocked]);
const handleUndockRequest = useCallback(() => {
setIsDocked(false);
}, [setIsDocked]);
const { position, isDragging, isNearDockZone, handleMouseDown, dragRef } = useDraggable(
handleDockRequest,
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() ?? '';
@@ -343,6 +453,7 @@ export function CallOverlay() {
id: 'local',
isLocal: true,
participantId: myUserId,
roomId: activeCall.roomId,
stream: activeCall.localScreenStream,
});
}
@@ -366,6 +477,7 @@ export function CallOverlay() {
id: livekitId,
isLocal: false,
participantId: matrixUserId,
roomId: activeCall?.roomId ?? '',
livekitId,
track: trackInfo.track,
stream: trackInfo.stream,
@@ -373,7 +485,7 @@ export function CallOverlay() {
});
return shares;
}, [activeCall?.isScreenSharing, activeCall?.localScreenStream, activeCall?.remoteScreenTracks, callMembers, myUserId]);
}, [activeCall?.isScreenSharing, activeCall?.localScreenStream, activeCall?.remoteScreenTracks, activeCall?.roomId, callMembers, myUserId]);
useEffect(() => {
if (!activeCall || activeCall.state !== CallState.Connected) {
@@ -482,6 +594,11 @@ export function CallOverlay() {
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;
@@ -583,15 +700,26 @@ export function CallOverlay() {
}
};
// Determine container class based on state
const containerClass = [
css.CallOverlayContainer,
isFullscreen && css.CallOverlayFullscreen,
isDocked && !isFullscreen && css.CallOverlayDocked,
isDragging && css.CallOverlayDragging,
].filter(Boolean).join(' ');
return (
<div
ref={setContainerRef}
className={`${css.CallOverlayContainer} ${isFullscreen ? css.CallOverlayFullscreen : ''} ${isDragging ? css.CallOverlayDragging : ''}`}
style={!isFullscreen ? {
transform: `translate(${position.x}px, ${position.y}px)`,
} : undefined}
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<>
{/* Dock indicator shown when dragging near the right edge */}
{isNearDockZone && !isDocked && <div className={css.DockIndicator} />}
<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}
@@ -621,6 +749,9 @@ export function CallOverlay() {
</TooltipProvider>
)}
</Text>
{unread && unread.total > 0 && (
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
)}
{selectedRoomId !== activeCall.roomId && (
<TooltipProvider
position="Top"
@@ -931,5 +1062,6 @@ export function CallOverlay() {
</TooltipProvider>
</div>
</div>
</>
);
}