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

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

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Dialog,
@@ -16,10 +16,85 @@ import {
Button,
Spinner,
} from 'folds';
import { MatrixError } from 'matrix-js-sdk';
import { MatrixError, Room } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { stopPropagation } from '../../utils/keyboard';
import { getSpaceChildren, isSpace } from '../../utils/room';
import { mDirectAtom } from '../../state/mDirectList';
import { Membership } from '../../../types/matrix/room';
const LEAVE_DELAY_MS = 300;
/**
* Delays execution for a specified time
*/
function delay(ms: number): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
/**
* Leaves multiple rooms sequentially with rate limiting
*/
async function leaveRoomsSequentially(
mx: ReturnType<typeof useMatrixClient>,
roomIds: string[],
onProgress?: (current: number, total: number) => void
): Promise<void> {
const total = roomIds.length;
for (let i = 0; i < roomIds.length; i++) {
const roomId = roomIds[i];
onProgress?.(i, total);
try {
await mx.leave(roomId);
await delay(LEAVE_DELAY_MS);
} catch {
// Continue leaving other rooms even if one fails
}
}
onProgress?.(total, total);
}
/**
* Gets direct child room IDs from a space that can be left (excluding DMs and subspaces)
*/
function getDirectChildRoomsToLeave(
mx: ReturnType<typeof useMatrixClient>,
spaceRoom: Room,
mDirects: Set<string>
): string[] {
const childIds = getSpaceChildren(spaceRoom);
return childIds.filter((childId) => {
// Skip DMs
if (mDirects.has(childId)) {
return false;
}
const childRoom = mx.getRoom(childId);
if (!childRoom) {
return false;
}
// Skip subspaces (only leave rooms, not nested spaces)
if (isSpace(childRoom)) {
return false;
}
// Only include rooms we're actually joined to
if (childRoom.getMyMembership() !== Membership.Join) {
return false;
}
return true;
});
}
type LeaveSpacePromptProps = {
roomId: string;
@@ -28,11 +103,29 @@ type LeaveSpacePromptProps = {
};
export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptProps) {
const mx = useMatrixClient();
const mDirects = useAtomValue(mDirectAtom);
const [progress, setProgress] = useState<{ current: number; total: number } | null>(null);
const [leaveState, leaveRoom] = useAsyncCallback<undefined, MatrixError, []>(
useCallback(async () => {
mx.leave(roomId);
}, [mx, roomId])
const spaceRoom = mx.getRoom(roomId);
if (spaceRoom) {
// Get direct child rooms to leave (excluding DMs and subspaces)
const childRoomsToLeave = getDirectChildRoomsToLeave(mx, spaceRoom, mDirects);
if (childRoomsToLeave.length > 0) {
// Leave child rooms first
await leaveRoomsSequentially(mx, childRoomsToLeave, (current, total) => {
setProgress({ current, total });
});
}
}
// Clear progress and leave the space itself
setProgress(null);
await mx.leave(roomId);
}, [mx, roomId, mDirects])
);
const handleLeave = () => {
@@ -45,6 +138,13 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
}
}, [leaveState, onDone]);
const isLoading = leaveState.status === AsyncStatus.Loading;
const getButtonText = () => {
if (!isLoading) return 'Leave';
if (progress) return `Leaving Rooms: ${progress.current}/${progress.total}`;
return 'Leaving Space...';
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
@@ -74,7 +174,10 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="200">
<Text priority="400">Are you sure you want to leave this space?</Text>
<Text priority="400">
Are you sure you want to leave this space? You will also leave all rooms directly
in this space (except DMs).
</Text>
{leaveState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
Failed to leave space! {leaveState.error.message}
@@ -86,18 +189,11 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
variant="Critical"
onClick={handleLeave}
before={
leaveState.status === AsyncStatus.Loading ? (
<Spinner fill="Solid" variant="Critical" size="200" />
) : undefined
}
aria-disabled={
leaveState.status === AsyncStatus.Loading ||
leaveState.status === AsyncStatus.Success
isLoading ? <Spinner fill="Solid" variant="Critical" size="200" /> : undefined
}
aria-disabled={isLoading || leaveState.status === AsyncStatus.Success}
>
<Text size="B400">
{leaveState.status === AsyncStatus.Loading ? 'Leaving...' : 'Leave'}
</Text>
<Text size="B400">{getButtonText()}</Text>
</Button>
</Box>
</Dialog>