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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user