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,5 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { config, DefaultReset, color } from 'folds';
|
||||
import { config, DefaultReset, color, toRem } from 'folds';
|
||||
|
||||
export const CallOverlayContainer = style([
|
||||
DefaultReset,
|
||||
@@ -25,6 +25,53 @@ export const CallOverlayDragging = style({
|
||||
opacity: 0.95,
|
||||
});
|
||||
|
||||
export const CallOverlayDocked = style({
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: toRem(266),
|
||||
maxWidth: toRem(266),
|
||||
minWidth: toRem(266),
|
||||
borderRadius: 0,
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transform: 'none !important',
|
||||
borderLeft: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
|
||||
});
|
||||
|
||||
export const DockIndicator = style({
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: toRem(266),
|
||||
backgroundColor: color.Primary.Container,
|
||||
opacity: 0.5,
|
||||
zIndex: 9998,
|
||||
pointerEvents: 'none',
|
||||
borderLeft: `2px dashed ${color.Primary.Main}`,
|
||||
});
|
||||
|
||||
export const DockedCallPanelLayout = style({
|
||||
width: toRem(266),
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: color.Background.Container,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const DockedCallPanelContent = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
padding: config.space.S300,
|
||||
backgroundColor: color.Surface.Container,
|
||||
color: color.Surface.OnContainer,
|
||||
});
|
||||
|
||||
export const CallOverlayFullscreen = style({
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
@@ -134,6 +181,20 @@ export const ParticipantsSection = style({
|
||||
borderRadius: config.radii.R300,
|
||||
backgroundColor: color.Surface.ContainerActive,
|
||||
overflow: 'hidden',
|
||||
selectors: {
|
||||
[`${CallOverlayDocked} &`]: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'auto',
|
||||
},
|
||||
[`${CallOverlayFullscreen} &`]: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'auto',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const ParticipantsHeader = style({
|
||||
@@ -214,3 +275,39 @@ export const ParticipantAvatar = style({
|
||||
export const ParticipantSpeaking = style({
|
||||
boxShadow: `0 0 0 2px ${color.Success.Main}`,
|
||||
});
|
||||
|
||||
export const RemoteCursorOverlay = style({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
overflow: 'hidden',
|
||||
zIndex: 10,
|
||||
});
|
||||
|
||||
export const RemoteCursorDot = style({
|
||||
position: 'absolute',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 0 4px rgba(0,0,0,0.4)',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
transition: 'left 0.05s linear, top 0.05s linear, opacity 0.2s ease',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
|
||||
export const RemoteCursorLabel = style({
|
||||
position: 'absolute',
|
||||
transform: 'translate(-50%, 8px)',
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
color: 'white',
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
borderRadius: '4px',
|
||||
padding: '1px 5px',
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
455
src/app/features/call/DockedCallContent.tsx
Normal file
455
src/app/features/call/DockedCallContent.tsx
Normal file
@@ -0,0 +1,455 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider, Scroll, color } from 'folds';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useCall } from './useCall';
|
||||
import { ScreenShareItem, ScreenShareEntry } from './CallOverlay';
|
||||
import { useRoomCallMembers } from './useRoomCallMembers';
|
||||
import { CallState, CallType } from './types';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { getMemberDisplayName, getMemberAvatarMxc } from '../../utils/room';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { UnreadBadge } from '../../components/unread-badge';
|
||||
import { useRoomUnread } from '../../state/hooks/unread';
|
||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { pendingUndockDragAtom } from './pendingDragState';
|
||||
import * as css from './CallOverlay.css';
|
||||
|
||||
/**
|
||||
* Formats call duration in MM:SS format
|
||||
*/
|
||||
function formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Docked call panel content - renders call UI in the layout flow
|
||||
* This is used when the call panel is docked to the right side
|
||||
*/
|
||||
/**
|
||||
* Custom Screen share icon SVG (Phosphor-style)
|
||||
*/
|
||||
function ScreenShareIcon() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export function DockedCallContent() {
|
||||
const { activeCall, endCall, toggleMute, toggleDeafen, toggleScreenShare } = useCall();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const selectedRoomId = useSelectedRoom();
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isTogglingScreenShare, setIsTogglingScreenShare] = useState(false);
|
||||
const [showParticipants, setShowParticipants] = useState(true);
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const [, setIsDocked] = useSetting(settingsAtom, 'isCallPanelDocked');
|
||||
const setPendingDrag = useSetAtom(pendingUndockDragAtom);
|
||||
|
||||
const unread = useRoomUnread(activeCall?.roomId ?? '', roomToUnreadAtom);
|
||||
const { callMembers } = useRoomCallMembers(activeCall?.roomId ?? '');
|
||||
const myUserId = mx.getUserId() ?? '';
|
||||
|
||||
/** Memoized list of active screen shares (local + remote) */
|
||||
const allScreenShares = useMemo((): ScreenShareEntry[] => {
|
||||
const shares: ScreenShareEntry[] = [];
|
||||
|
||||
if (activeCall?.isScreenSharing && activeCall?.localScreenStream) {
|
||||
shares.push({
|
||||
id: 'local',
|
||||
isLocal: true,
|
||||
participantId: myUserId,
|
||||
roomId: activeCall.roomId,
|
||||
stream: activeCall.localScreenStream,
|
||||
});
|
||||
}
|
||||
|
||||
const remoteScreenTracks = activeCall?.remoteScreenTracks
|
||||
? Array.from(activeCall.remoteScreenTracks.values())
|
||||
: [];
|
||||
|
||||
remoteScreenTracks.forEach((trackInfo) => {
|
||||
const livekitId = trackInfo.participantId;
|
||||
const underscoreIndex = livekitId.lastIndexOf('_');
|
||||
const possibleDeviceId = underscoreIndex > 0 ? livekitId.substring(0, underscoreIndex) : livekitId;
|
||||
const matchingMember = callMembers.find((m) => m.deviceId === possibleDeviceId);
|
||||
const matrixUserId = matchingMember?.userId ?? livekitId;
|
||||
|
||||
shares.push({
|
||||
id: livekitId,
|
||||
isLocal: false,
|
||||
participantId: matrixUserId,
|
||||
roomId: activeCall?.roomId ?? '',
|
||||
livekitId,
|
||||
track: trackInfo.track,
|
||||
stream: trackInfo.stream,
|
||||
});
|
||||
});
|
||||
|
||||
return shares;
|
||||
}, [activeCall?.isScreenSharing, activeCall?.localScreenStream, activeCall?.remoteScreenTracks, activeCall?.roomId, callMembers, myUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeCall || activeCall.state !== CallState.Connected) {
|
||||
setDuration(0);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { startTime } = activeCall;
|
||||
const updateDuration = () => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
setDuration(elapsed);
|
||||
};
|
||||
|
||||
updateDuration();
|
||||
const interval = setInterval(updateDuration, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [activeCall]);
|
||||
|
||||
/**
|
||||
* Checks if a participant is currently speaking
|
||||
*/
|
||||
const isParticipantSpeaking = (userId: string): boolean => {
|
||||
if (!activeCall) return false;
|
||||
if (activeCall.activeSpeakers.has(userId)) return true;
|
||||
|
||||
const member = callMembers.find((m) => m.userId === userId);
|
||||
if (!member) return false;
|
||||
|
||||
return Array.from(activeCall.activeSpeakers).some((speakerId) => {
|
||||
const underscoreIndex = speakerId.lastIndexOf('_');
|
||||
const deviceId = underscoreIndex > 0 ? speakerId.substring(0, underscoreIndex) : speakerId;
|
||||
return deviceId === member.deviceId;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a participant is currently muted
|
||||
*/
|
||||
const isParticipantMuted = (userId: string): boolean => {
|
||||
if (!activeCall) return false;
|
||||
if (userId === myUserId) return activeCall.isMuted;
|
||||
|
||||
const member = callMembers.find((m) => m.userId === userId);
|
||||
if (!member) return false;
|
||||
|
||||
return Array.from(activeCall.mutedParticipants).some((mutedId) => {
|
||||
const underscoreIndex = mutedId.lastIndexOf('_');
|
||||
const deviceId = underscoreIndex > 0 ? mutedId.substring(0, underscoreIndex) : mutedId;
|
||||
return deviceId === member.deviceId;
|
||||
});
|
||||
};
|
||||
|
||||
const handleEndCall = () => {
|
||||
endCall();
|
||||
};
|
||||
|
||||
const handleToggleMute = () => {
|
||||
toggleMute();
|
||||
};
|
||||
|
||||
const handleToggleDeafen = () => {
|
||||
toggleDeafen();
|
||||
};
|
||||
|
||||
const handleToggleScreenShare = async () => {
|
||||
if (isTogglingScreenShare) return;
|
||||
setIsTogglingScreenShare(true);
|
||||
try {
|
||||
await toggleScreenShare();
|
||||
} finally {
|
||||
setIsTogglingScreenShare(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle mousedown on header to start drag-to-undock
|
||||
* Sets pending drag state so the floating overlay can continue the drag
|
||||
*/
|
||||
const handleHeaderMouseDown = async (e: React.MouseEvent) => {
|
||||
// Don't start drag if clicking a button
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
|
||||
// Exit browser fullscreen if active before undocking
|
||||
if (document.fullscreenElement) {
|
||||
try {
|
||||
await document.exitFullscreen();
|
||||
} catch {
|
||||
// Fullscreen exit may fail
|
||||
}
|
||||
}
|
||||
|
||||
// Get the header element to calculate offset
|
||||
const header = e.currentTarget as HTMLElement;
|
||||
const rect = header.getBoundingClientRect();
|
||||
|
||||
// Store the drag start position with mouse offset from panel edges
|
||||
setPendingDrag({
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
offsetX: e.clientX - rect.left,
|
||||
offsetY: e.clientY - rect.top,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Undock immediately - the floating overlay will pick up the drag
|
||||
setIsDocked(false);
|
||||
};
|
||||
|
||||
if (!activeCall || activeCall.state === CallState.Idle || activeCall.state === CallState.Ended) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isConnecting = activeCall.state === CallState.Connecting;
|
||||
const isVideoCall = activeCall.callType === CallType.Video;
|
||||
const room = mx.getRoom(activeCall.roomId);
|
||||
const allParticipants = callMembers.map((m) => m.userId);
|
||||
const participantCount = allParticipants.length;
|
||||
|
||||
return (
|
||||
<div className={css.DockedCallPanelContent}>
|
||||
{/* Header - drag to undock */}
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className={css.CallOverlayHeader}
|
||||
onMouseDown={handleHeaderMouseDown}
|
||||
style={{ cursor: '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...' : 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} />
|
||||
)}
|
||||
</Box>
|
||||
<Box alignItems="Center" gap="100">
|
||||
<Text size="T200" className={css.CallDuration}>
|
||||
{formatDuration(duration)}
|
||||
</Text>
|
||||
{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"
|
||||
>
|
||||
<Icon size="100" src={Icons.Link} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
{/* Participants */}
|
||||
<Box direction="Column" grow="Yes" style={{ overflow: 'hidden' }}>
|
||||
<div className={css.ParticipantsSection} style={{ flex: 1 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.ParticipantsHeader}
|
||||
onClick={() => setShowParticipants(!showParticipants)}
|
||||
>
|
||||
<Text size="T200" className={css.ParticipantsHeaderText}>
|
||||
Participants ({participantCount})
|
||||
</Text>
|
||||
<Icon
|
||||
size="100"
|
||||
src={showParticipants ? Icons.ChevronTop : Icons.ChevronBottom}
|
||||
style={{ opacity: 0.7 }}
|
||||
/>
|
||||
</button>
|
||||
{showParticipants && room && (
|
||||
<Scroll hideTrack visibility="Hover" style={{ maxHeight: '100%' }}>
|
||||
<div className={css.ParticipantsList}>
|
||||
{allParticipants.map((participantId) => {
|
||||
const avatarMxc = getMemberAvatarMxc(room, participantId);
|
||||
const avatarUrl = avatarMxc
|
||||
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 32, 32, 'crop') ?? undefined
|
||||
: undefined;
|
||||
const displayName = getMemberDisplayName(room, participantId)
|
||||
?? getMxIdLocalPart(participantId)
|
||||
?? participantId;
|
||||
const isMe = participantId === myUserId;
|
||||
const isMuted = isParticipantMuted(participantId);
|
||||
const isSpeaking = isParticipantSpeaking(participantId);
|
||||
|
||||
return (
|
||||
<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" style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="T300" className={css.ParticipantName} truncate>
|
||||
{displayName}
|
||||
{isMe && <span className={css.YouBadge}> (You)</span>}
|
||||
</Text>
|
||||
</Box>
|
||||
{isMuted && (
|
||||
<Icon
|
||||
size="100"
|
||||
src={Icons.MicMute}
|
||||
className={css.ParticipantMutedIcon}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Scroll>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{/* Screen shares */}
|
||||
{allScreenShares.length > 0 && (
|
||||
<Box direction="Column" gap="200" style={{ padding: '0 8px 8px' }}>
|
||||
{allScreenShares.map((share) => (
|
||||
<ScreenShareItem key={share.id} share={share} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
26
src/app/features/call/DockedCallPanel.tsx
Normal file
26
src/app/features/call/DockedCallPanel.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { Box, Line } from 'folds';
|
||||
import { useIsCallDocked } from './useDockedCall';
|
||||
import { DockedCallContent } from './DockedCallContent';
|
||||
import * as css from './CallOverlay.css';
|
||||
|
||||
/**
|
||||
* Component that renders the docked call panel in the layout flow
|
||||
* Only renders when there's an active call AND docking is enabled
|
||||
*/
|
||||
export function DockedCallPanel() {
|
||||
const isCallDocked = useIsCallDocked();
|
||||
|
||||
if (!isCallDocked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
<Box shrink="No" className={css.DockedCallPanelLayout}>
|
||||
<DockedCallContent />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
132
src/app/features/call/RemoteCursorOverlay.tsx
Normal file
132
src/app/features/call/RemoteCursorOverlay.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { useRoomCallMembers } from './useRoomCallMembers';
|
||||
import { useCall } from './useCall';
|
||||
import { useSendCursor, useReceiveCursors, RemoteCursorState } from './useRemoteCursor';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import * as css from './CallOverlay.css';
|
||||
|
||||
/** Props for RemoteCursorOverlay */
|
||||
interface RemoteCursorOverlayProps {
|
||||
/** The screen share ID to track cursors for */
|
||||
shareId: string;
|
||||
/** The room ID of the active call */
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a LiveKit participant identity to a Matrix user ID
|
||||
* LiveKit identities are typically in the format "deviceId_timestamp"
|
||||
*/
|
||||
function resolveParticipantUserId(
|
||||
livekitIdentity: string,
|
||||
callMembers: { userId: string; deviceId: string }[]
|
||||
): string {
|
||||
const underscoreIndex = livekitIdentity.lastIndexOf('_');
|
||||
const possibleDeviceId =
|
||||
underscoreIndex > 0 ? livekitIdentity.substring(0, underscoreIndex) : livekitIdentity;
|
||||
|
||||
const match = callMembers.find((m) => m.deviceId === possibleDeviceId);
|
||||
return match?.userId ?? livekitIdentity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single remote cursor dot with label
|
||||
*/
|
||||
function CursorDot({
|
||||
cursor,
|
||||
displayName,
|
||||
cursorColor,
|
||||
}: {
|
||||
cursor: RemoteCursorState;
|
||||
displayName: string;
|
||||
cursorColor: string;
|
||||
}) {
|
||||
if (!cursor.visible) return null;
|
||||
|
||||
const left = `${cursor.x * 100}%`;
|
||||
const top = `${cursor.y * 100}%`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={css.RemoteCursorDot}
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
backgroundColor: cursorColor,
|
||||
opacity: cursor.visible ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={css.RemoteCursorLabel}
|
||||
style={{ left, top, color: 'white' }}
|
||||
>
|
||||
{displayName}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay that renders remote cursor positions on top of a screen share
|
||||
* Also provides mouse event handlers for sending local cursor position
|
||||
*/
|
||||
export function RemoteCursorOverlay({ shareId, roomId }: RemoteCursorOverlayProps) {
|
||||
const mx = useMatrixClient();
|
||||
const { callMembers } = useRoomCallMembers(roomId);
|
||||
const { activeCall } = useCall();
|
||||
const cursors = useReceiveCursors(shareId);
|
||||
const room = mx.getRoom(roomId);
|
||||
const myUserId = mx.getUserId() ?? '';
|
||||
|
||||
/**
|
||||
* Gets a display name for a LiveKit participant
|
||||
*/
|
||||
const getDisplayName = (livekitIdentity: string): string => {
|
||||
const userId = resolveParticipantUserId(livekitIdentity, callMembers);
|
||||
if (!room) return getMxIdLocalPart(userId) ?? userId;
|
||||
return getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||
};
|
||||
|
||||
// Filter out our own cursor and only show visible cursors
|
||||
const myIdentity = activeCall?.participants.find((p) => {
|
||||
const underscoreIndex = p.lastIndexOf('_');
|
||||
const deviceId = underscoreIndex > 0 ? p.substring(0, underscoreIndex) : p;
|
||||
const member = callMembers.find((m) => m.deviceId === deviceId);
|
||||
return member?.userId === myUserId;
|
||||
});
|
||||
|
||||
const remoteCursors = Array.from(cursors.entries()).filter(
|
||||
([participantId]) => participantId !== myIdentity
|
||||
);
|
||||
|
||||
if (remoteCursors.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={css.RemoteCursorOverlay}>
|
||||
{remoteCursors.map(([participantId, cursor]) => {
|
||||
const userId = resolveParticipantUserId(participantId, callMembers);
|
||||
return (
|
||||
<CursorDot
|
||||
key={participantId}
|
||||
cursor={cursor}
|
||||
displayName={getDisplayName(participantId)}
|
||||
cursorColor={colorMXID(userId)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper that provides mouse event handlers for cursor sending on a screenshare
|
||||
* @param shareId - The screen share ID
|
||||
* @returns Mouse event handlers to attach to the container
|
||||
*/
|
||||
export function useScreenShareCursor(shareId: string) {
|
||||
return useSendCursor(shareId);
|
||||
}
|
||||
@@ -7,3 +7,6 @@ export * from './CallProviderWrapper';
|
||||
export * from './CallOverlay';
|
||||
export * from './IncomingCallNotification';
|
||||
export * from './useRoomCallMembers';
|
||||
export * from './useDockedCall';
|
||||
export * from './DockedCallPanel';
|
||||
export * from './DockedCallContent';
|
||||
|
||||
21
src/app/features/call/pendingDragState.ts
Normal file
21
src/app/features/call/pendingDragState.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
/**
|
||||
* Atom to store pending drag state when transitioning from docked to floating
|
||||
* When the user starts dragging the docked panel header, we store the mouse position
|
||||
* so the floating overlay can continue the drag seamlessly
|
||||
*/
|
||||
export interface PendingDragState {
|
||||
/** Mouse X position when drag started */
|
||||
startX: number;
|
||||
/** Mouse Y position when drag started */
|
||||
startY: number;
|
||||
/** Offset from panel left edge to mouse X */
|
||||
offsetX: number;
|
||||
/** Offset from panel top edge to mouse Y */
|
||||
offsetY: number;
|
||||
/** Timestamp to prevent stale drag states */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export const pendingUndockDragAtom = atom<PendingDragState | null>(null);
|
||||
18
src/app/features/call/useDockedCall.ts
Normal file
18
src/app/features/call/useDockedCall.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useCall } from './useCall';
|
||||
import { CallState } from './types';
|
||||
|
||||
/**
|
||||
* Hook to determine if the call panel should be rendered docked
|
||||
* Returns true if there's an active call AND the panel is set to docked mode
|
||||
*/
|
||||
export function useIsCallDocked(): boolean {
|
||||
const { activeCall } = useCall();
|
||||
const [isDocked] = useSetting(settingsAtom, 'isCallPanelDocked');
|
||||
|
||||
const hasActiveCall = activeCall &&
|
||||
(activeCall.state === CallState.Connecting || activeCall.state === CallState.Connected);
|
||||
|
||||
return Boolean(hasActiveCall && isDocked);
|
||||
}
|
||||
187
src/app/features/call/useRemoteCursor.ts
Normal file
187
src/app/features/call/useRemoteCursor.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { RoomEvent } from 'livekit-client';
|
||||
import { useCall } from './useCall';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
|
||||
/** Data topic for cursor position messages */
|
||||
const CURSOR_TOPIC = 'remote-cursor';
|
||||
|
||||
/** How often to send cursor updates (ms) */
|
||||
const CURSOR_SEND_INTERVAL = 50;
|
||||
|
||||
/** How long before a cursor is considered stale and hidden (ms) */
|
||||
const CURSOR_STALE_TIMEOUT = 3000;
|
||||
|
||||
/** Cursor position data sent over the data channel */
|
||||
export interface CursorData {
|
||||
/** Message type identifier */
|
||||
type: 'cursor';
|
||||
/** Normalized X position (0–1) */
|
||||
x: number;
|
||||
/** Normalized Y position (0–1) */
|
||||
y: number;
|
||||
/** Whether the cursor is visible (false = user moved mouse away) */
|
||||
visible: boolean;
|
||||
/** Screen share participant ID (Matrix user ID) this cursor relates to */
|
||||
shareId: string;
|
||||
}
|
||||
|
||||
/** Remote cursor state for rendering */
|
||||
export interface RemoteCursorState {
|
||||
/** LiveKit participant identity */
|
||||
participantId: string;
|
||||
/** Normalized X position (0–1) */
|
||||
x: number;
|
||||
/** Normalized Y position (0–1) */
|
||||
y: number;
|
||||
/** Whether the cursor is currently visible */
|
||||
visible: boolean;
|
||||
/** Timestamp of last update */
|
||||
lastUpdate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to send local cursor position over the LiveKit data channel
|
||||
* Tracks mouse movement over a screenshare container and broadcasts position
|
||||
* @param shareId - The screen share ID to associate cursor data with
|
||||
* @returns Object with onMouseMove/onMouseLeave handlers to attach to the container
|
||||
*/
|
||||
export function useSendCursor(shareId: string) {
|
||||
const { callService } = useCall();
|
||||
const [showRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor');
|
||||
const lastSendRef = useRef(0);
|
||||
const encoder = useRef(new TextEncoder());
|
||||
|
||||
const sendCursorData = useCallback(
|
||||
(data: Omit<CursorData, 'type'>) => {
|
||||
const room = callService?.getLiveKitRoom();
|
||||
if (!room?.localParticipant) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (!data.visible || now - lastSendRef.current >= CURSOR_SEND_INTERVAL) {
|
||||
lastSendRef.current = now;
|
||||
const message: CursorData = { ...data, type: 'cursor' };
|
||||
const payload = encoder.current.encode(JSON.stringify(message));
|
||||
room.localParticipant
|
||||
.publishData(payload, { reliable: false, topic: CURSOR_TOPIC })
|
||||
.catch(() => {
|
||||
// Data publish can fail silently
|
||||
});
|
||||
}
|
||||
},
|
||||
[callService]
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle mouse movement over a screenshare container
|
||||
* Calculates normalized position and sends to remote participants
|
||||
*/
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!showRemoteCursor) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const y = (e.clientY - rect.top) / rect.height;
|
||||
|
||||
sendCursorData({ x, y, visible: true, shareId });
|
||||
},
|
||||
[showRemoteCursor, sendCursorData, shareId]
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle mouse leaving the screenshare container
|
||||
* Sends a hidden cursor update so remote stops showing cursor
|
||||
*/
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (!showRemoteCursor) return;
|
||||
sendCursorData({ x: 0, y: 0, visible: false, shareId });
|
||||
}, [showRemoteCursor, sendCursorData, shareId]);
|
||||
|
||||
return { handleMouseMove, handleMouseLeave };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to receive remote cursor positions from the LiveKit data channel
|
||||
* Listens for cursor data from other participants and provides state for rendering
|
||||
* @param shareId - The screen share ID to filter cursor data for
|
||||
* @returns Map of participant IDs to their cursor states
|
||||
*/
|
||||
export function useReceiveCursors(shareId: string): Map<string, RemoteCursorState> {
|
||||
const { callService } = useCall();
|
||||
const [showRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor');
|
||||
const [cursors, setCursors] = useState<Map<string, RemoteCursorState>>(new Map());
|
||||
const decoder = useRef(new TextDecoder());
|
||||
|
||||
useEffect(() => {
|
||||
if (!showRemoteCursor) {
|
||||
setCursors(new Map());
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const room = callService?.getLiveKitRoom();
|
||||
if (!room) return undefined;
|
||||
|
||||
const handleDataReceived = (
|
||||
payload: Uint8Array,
|
||||
participant?: { identity: string },
|
||||
_kind?: unknown,
|
||||
topic?: string
|
||||
) => {
|
||||
if (!participant) return;
|
||||
// Only process cursor messages by topic
|
||||
if (topic !== undefined && topic !== CURSOR_TOPIC) return;
|
||||
|
||||
try {
|
||||
const text = decoder.current.decode(payload);
|
||||
const data = JSON.parse(text) as CursorData;
|
||||
|
||||
// Validate this is actually a cursor message
|
||||
if (data.type !== 'cursor') return;
|
||||
if (data.shareId !== shareId) return;
|
||||
|
||||
setCursors((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(participant.identity, {
|
||||
participantId: participant.identity,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
visible: data.visible,
|
||||
lastUpdate: Date.now(),
|
||||
});
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// Ignore malformed data
|
||||
}
|
||||
};
|
||||
|
||||
room.on(RoomEvent.DataReceived, handleDataReceived);
|
||||
|
||||
// Periodically clean up stale cursors
|
||||
const cleanupInterval = setInterval(() => {
|
||||
setCursors((prev) => {
|
||||
const now = Date.now();
|
||||
let changed = false;
|
||||
const next = new Map(prev);
|
||||
|
||||
next.forEach((cursor, key) => {
|
||||
if (now - cursor.lastUpdate > CURSOR_STALE_TIMEOUT) {
|
||||
next.delete(key);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
room.off(RoomEvent.DataReceived, handleDataReceived);
|
||||
clearInterval(cleanupInterval);
|
||||
};
|
||||
}, [callService, showRemoteCursor, shareId]);
|
||||
|
||||
return cursors;
|
||||
}
|
||||
Reference in New Issue
Block a user