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