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

@@ -0,0 +1,557 @@
import React, { useMemo, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Dialog,
Overlay,
OverlayCenter,
OverlayBackdrop,
Header,
config,
Box,
Text,
IconButton,
Icon,
Icons,
color,
Button,
Spinner,
Scroll,
Checkbox,
Avatar,
Badge,
} from 'folds';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import { RoomType, Membership } from '../../../types/matrix/room';
import { RoomAvatar } from '../room-avatar';
import { nameInitials } from '../../utils/common';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import {
setRoomNotificationPreference,
RoomNotificationMode,
} from '../../hooks/useRoomsNotificationPreferences';
const PER_PAGE_COUNT = 100;
const MAX_PAGES = 5;
const HIERARCHY_TIMEOUT_MS = 10000;
const JOIN_DELAY_MS = 500;
/** Join rules that allow automatic joining */
const AUTO_JOINABLE_RULES = ['public', 'restricted', 'knock_restricted'];
/**
* Extracts the server name from a Matrix room ID
*/
function getServerFromRoomId(roomId: string): string {
const parts = roomId.split(':');
return parts.length > 1 ? parts[1] : '';
}
/**
* Checks if a room/space can be automatically joined based on its join rule
*/
function canAutoJoin(hierarchyRoom: IHierarchyRoom): boolean {
const { join_rule: joinRule } = hierarchyRoom;
if (!joinRule) return false;
return AUTO_JOINABLE_RULES.includes(joinRule);
}
/**
* Delays execution for a specified time
*/
function delay(ms: number): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
type HierarchyRoomItemProps = {
room: IHierarchyRoom;
selected: boolean;
onToggle: () => void;
useAuthentication: boolean;
disabled?: boolean;
};
function HierarchyRoomItem({
room,
selected,
onToggle,
useAuthentication,
disabled,
}: HierarchyRoomItemProps) {
const mx = useMatrixClient();
const roomIsSpace = room.room_type === RoomType.Space;
const avatarUrl = room.avatar_url
? mxcUrlToHttp(mx, room.avatar_url, useAuthentication, 48, 48, 'crop')
: undefined;
return (
<Box
as="label"
gap="300"
alignItems="Center"
style={{
padding: config.space.S200,
borderRadius: config.radii.R300,
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.6 : 1,
}}
>
<Checkbox
size="300"
variant="Primary"
checked={selected}
onClick={disabled ? undefined : onToggle}
disabled={disabled}
/>
<Avatar size="200">
<RoomAvatar
roomId={room.room_id}
src={avatarUrl ?? undefined}
alt={room.name || room.room_id}
renderFallback={() => (
<Text as="span" size="T200">
{nameInitials(room.name || room.room_id)}
</Text>
)}
/>
</Avatar>
<Box direction="Column" grow="Yes" style={{ minWidth: 0 }}>
<Text size="T300" truncate>
{room.name || room.room_id}
</Text>
{room.topic && (
<Text size="T200" priority="400" truncate>
{room.topic}
</Text>
)}
</Box>
{roomIsSpace && (
<Badge variant="Secondary" fill="Soft" outlined>
<Text size="L400">Space</Text>
</Badge>
)}
</Box>
);
}
type JoinSpacePromptProps = {
roomIdOrAlias: string;
viaServers?: string[];
onDone: (roomId: string) => void;
onCancel: () => void;
};
type JoinPhase = 'initial' | 'joining-space' | 'selecting-rooms' | 'joining-rooms';
export function JoinSpacePrompt({
roomIdOrAlias,
viaServers,
onDone,
onCancel,
}: JoinSpacePromptProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [phase, setPhase] = useState<JoinPhase>('initial');
const [spaceId, setSpaceId] = useState<string | null>(null);
const [hierarchyRooms, setHierarchyRooms] = useState<IHierarchyRoom[]>([]);
const [selectedRoomIds, setSelectedRoomIds] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [progress, setProgress] = useState<{ current: number; total: number } | null>(null);
const [canSkipLoading, setCanSkipLoading] = useState(false);
/**
* Skips to the space (used when loading takes too long)
*/
const handleSkipToSpace = () => {
if (spaceId) {
onDone(spaceId);
}
};
/**
* Joins the space first
*/
const handleJoinSpace = async () => {
setPhase('joining-space');
setError(null);
setCanSkipLoading(false);
let joinedSpaceId: string;
try {
const spaceRoom = await mx.joinRoom(roomIdOrAlias, { viaServers });
joinedSpaceId = spaceRoom.roomId;
setSpaceId(joinedSpaceId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to join space');
setPhase('initial');
return;
}
// Space is joined - allow skipping from here
setCanSkipLoading(true);
// Now fetch the hierarchy with a timeout
setLoading(true);
const fetchWithTimeout = async (): Promise<IHierarchyRoom[]> => {
const allRooms: IHierarchyRoom[] = [];
let nextBatch: string | undefined;
let pageCount = 0;
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Timeout')), HIERARCHY_TIMEOUT_MS);
});
while (pageCount < MAX_PAGES) {
// eslint-disable-next-line no-await-in-loop
const result = await Promise.race([
mx.getRoomHierarchy(joinedSpaceId, PER_PAGE_COUNT, undefined, false, nextBatch),
timeoutPromise,
]);
allRooms.push(...result.rooms);
nextBatch = result.next_batch;
pageCount += 1;
if (!nextBatch) break;
}
return allRooms;
};
try {
const allRooms = await fetchWithTimeout();
// Filter out items we can't join (excluding the space itself)
const joinableRooms = allRooms.filter((room) => {
// Skip the space itself
if (room.room_id === joinedSpaceId) return false;
// Check if already joined
const existingRoom = mx.getRoom(room.room_id);
if (existingRoom?.getMyMembership() === Membership.Join) {
return false;
}
return canAutoJoin(room);
});
setHierarchyRooms(joinableRooms);
// Select all by default
setSelectedRoomIds(new Set(joinableRooms.map((r) => r.room_id)));
} catch {
// Hierarchy fetch failed or timed out - show empty selection
setHierarchyRooms([]);
setSelectedRoomIds(new Set());
} finally {
setLoading(false);
setPhase('selecting-rooms');
}
};
/**
* Joins selected rooms
*/
const handleJoinSelectedRooms = async () => {
if (!spaceId) return;
setPhase('joining-rooms');
setError(null);
const spaceServer = getServerFromRoomId(spaceId);
// Get selected rooms
const roomsToJoin = hierarchyRooms.filter((room) => selectedRoomIds.has(room.room_id));
// Separate spaces and rooms - join spaces first
const spacesToJoin = roomsToJoin.filter((r) => r.room_type === RoomType.Space);
const regularRoomsToJoin = roomsToJoin.filter((r) => r.room_type !== RoomType.Space);
const total = roomsToJoin.length;
let current = 0;
if (total > 0) {
setProgress({ current, total });
}
const joinItem = async (item: IHierarchyRoom, isSpace: boolean): Promise<void> => {
try {
const itemServer = getServerFromRoomId(item.room_id);
const itemViaServers = [itemServer, spaceServer].filter(Boolean);
await mx.joinRoom(item.room_id, { viaServers: itemViaServers });
// Set notification preferences for rooms (not spaces)
if (!isSpace) {
await setRoomNotificationPreference(
mx,
item.room_id,
RoomNotificationMode.SpecialMessages,
RoomNotificationMode.Unset
);
}
await delay(JOIN_DELAY_MS);
} catch {
// Continue with other items even if one fails
} finally {
current += 1;
setProgress({ current, total });
}
};
// Join spaces first, then rooms
await spacesToJoin.reduce(
(promise, space) => promise.then(() => joinItem(space, true)),
Promise.resolve()
);
await regularRoomsToJoin.reduce(
(promise, room) => promise.then(() => joinItem(room, false)),
Promise.resolve()
);
setProgress(null);
onDone(spaceId);
};
/**
* Skip room selection and just finish
*/
const handleSkip = () => {
if (spaceId) {
onDone(spaceId);
}
};
const toggleRoom = (roomId: string) => {
setSelectedRoomIds((prev) => {
const newSet = new Set(prev);
if (newSet.has(roomId)) {
newSet.delete(roomId);
} else {
newSet.add(roomId);
}
return newSet;
});
};
const selectAll = () => {
setSelectedRoomIds(new Set(hierarchyRooms.map((r) => r.room_id)));
};
const selectNone = () => {
setSelectedRoomIds(new Set());
};
const isJoining = phase === 'joining-space' || phase === 'joining-rooms';
const spaces = useMemo(
() => hierarchyRooms.filter((r) => r.room_type === RoomType.Space),
[hierarchyRooms]
);
const rooms = useMemo(
() => hierarchyRooms.filter((r) => r.room_type !== RoomType.Space),
[hierarchyRooms]
);
const getButtonText = () => {
if (phase === 'initial') return 'Join Space';
if (phase === 'joining-space') return loading ? 'Loading Rooms...' : 'Joining Space...';
if (phase === 'selecting-rooms') {
const selectedCount = selectedRoomIds.size;
return selectedCount > 0 ? `Join ${selectedCount} Rooms` : 'Done';
}
if (progress) return `Joining: ${progress.current}/${progress.total}`;
return 'Joining...';
};
const handlePrimaryAction = () => {
if (phase === 'initial') {
handleJoinSpace();
} else if (phase === 'selecting-rooms') {
if (selectedRoomIds.size > 0) {
handleJoinSelectedRooms();
} else {
handleSkip();
}
}
};
const canCancel = phase === 'initial' || phase === 'selecting-rooms' || canSkipLoading;
const getDeactivateHandler = () => {
if (!canCancel) return undefined;
if (canSkipLoading) return handleSkipToSpace;
return onCancel;
};
const getCloseHandler = () => {
if (phase === 'selecting-rooms') return handleSkip;
if (canSkipLoading) return handleSkipToSpace;
return onCancel;
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: getDeactivateHandler(),
clickOutsideDeactivates: canCancel,
escapeDeactivates: canCancel ? stopPropagation : false,
}}
>
<Dialog variant="Surface" style={{ maxWidth: '500px', width: '90vw' }}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">
{phase === 'selecting-rooms' ? 'Select Rooms to Join' : 'Join Space'}
</Text>
</Box>
<IconButton
size="300"
onClick={getCloseHandler()}
radii="300"
disabled={phase === 'joining-space' && !canSkipLoading}
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
{phase === 'initial' && (
<Text priority="400" size="T300">
Join this space to see and select which rooms to join.
</Text>
)}
{phase === 'joining-space' && (
<Box direction="Column" gap="200" alignItems="Center">
<Box justifyContent="Center" alignItems="Center" gap="200">
<Spinner size="200" />
<Text size="T300">
{loading ? 'Loading space contents...' : 'Joining space...'}
</Text>
</Box>
{canSkipLoading && (
<Button size="300" variant="Secondary" fill="Soft" onClick={handleSkipToSpace}>
<Text size="B300">Skip to Space</Text>
</Button>
)}
</Box>
)}
{phase === 'selecting-rooms' && hierarchyRooms.length > 0 && (
<>
<Box direction="Column" gap="200">
<Text priority="400" size="T300">
Select which rooms and spaces to join:
</Text>
<Box gap="200">
<Button size="300" variant="Secondary" fill="Soft" onClick={selectAll}>
<Text size="B300">Select All</Text>
</Button>
<Button size="300" variant="Secondary" fill="Soft" onClick={selectNone}>
<Text size="B300">Select None</Text>
</Button>
</Box>
</Box>
<Scroll
hideTrack
visibility="Hover"
style={{ maxHeight: '300px', flexGrow: 1 }}
>
<Box direction="Column" gap="200">
{spaces.length > 0 && (
<Box direction="Column" gap="100">
<Text size="L400" style={{ color: color.Secondary.Main }}>
Spaces ({spaces.length})
</Text>
{spaces.map((room) => (
<HierarchyRoomItem
key={room.room_id}
room={room}
selected={selectedRoomIds.has(room.room_id)}
onToggle={() => toggleRoom(room.room_id)}
useAuthentication={useAuthentication}
disabled={isJoining}
/>
))}
</Box>
)}
{rooms.length > 0 && (
<Box direction="Column" gap="100">
<Text size="L400" style={{ color: color.Secondary.Main }}>
Rooms ({rooms.length})
</Text>
{rooms.map((room) => (
<HierarchyRoomItem
key={room.room_id}
room={room}
selected={selectedRoomIds.has(room.room_id)}
onToggle={() => toggleRoom(room.room_id)}
useAuthentication={useAuthentication}
disabled={isJoining}
/>
))}
</Box>
)}
</Box>
</Scroll>
</>
)}
{phase === 'selecting-rooms' && hierarchyRooms.length === 0 && (
<Text priority="400" size="T300">
This space has no additional rooms to join.
</Text>
)}
{phase === 'joining-rooms' && (
<Box justifyContent="Center" alignItems="Center" gap="200">
<Spinner size="200" />
<Text size="T300">
{progress
? `Joining rooms: ${progress.current}/${progress.total}`
: 'Joining rooms...'}
</Text>
</Box>
)}
{error && (
<Text style={{ color: color.Critical.Main }} size="T300">
{error}
</Text>
)}
<Button
type="submit"
variant="Primary"
onClick={handlePrimaryAction}
disabled={isJoining}
before={isJoining ? <Spinner fill="Solid" variant="Primary" size="200" /> : undefined}
aria-disabled={isJoining}
>
<Text size="B400">{getButtonText()}</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}