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:
557
src/app/components/join-space-prompt/JoinSpacePrompt.tsx
Normal file
557
src/app/components/join-space-prompt/JoinSpacePrompt.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user