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

@@ -31,6 +31,7 @@ import { PdfViewer } from './Pdf-viewer';
import { TextViewer } from './text-viewer';
import { testMatrixTo } from '../plugins/matrix-to';
import { IImageContent } from '../../types/matrix/common';
import { filterDisabledUrls } from '../hooks/useRoomEmbedFilters';
type RenderMessageContentProps = {
displayName: string;
@@ -44,6 +45,7 @@ type RenderMessageContentProps = {
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
outlineAttachment?: boolean;
disabledEmbedPatterns?: string[];
};
export function RenderMessageContent({
displayName,
@@ -57,9 +59,11 @@ export function RenderMessageContent({
htmlReactParserOptions,
linkifyOpts,
outlineAttachment,
disabledEmbedPatterns,
}: RenderMessageContentProps) {
const renderUrlsPreview = (urls: string[]) => {
const filteredUrls = urls.filter((url) => !testMatrixTo(url));
let filteredUrls = urls.filter((url) => !testMatrixTo(url));
filteredUrls = filterDisabledUrls(filteredUrls, disabledEmbedPatterns ?? []);
if (filteredUrls.length === 0) return undefined;
return (
<UrlPreviewHolder>

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>
);
}

View File

@@ -0,0 +1 @@
export * from './JoinSpacePrompt';

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>

View File

@@ -0,0 +1,446 @@
import React, { useMemo, useState, useCallback, useRef, useEffect, MouseEvent } 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,
Badge,
Line,
Avatar,
} from 'folds';
import { Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import { RoomAvatar } from '../room-avatar';
import { nameInitials } from '../../utils/common';
import { getMxIdLocalPart } from '../../utils/matrix';
import { getRoomAvatarUrl } from '../../utils/room';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
const BASE_DELAY_MS = 1000;
const MAX_RETRIES = 3;
/**
* Delays execution for a specified time
*/
function delay(ms: number): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
/**
* Gets the retry delay from a rate limit error, or returns a default backoff
*/
function getRetryDelay(error: unknown, attempt: number): number {
// Check for Matrix rate limit error with retry_after_ms
if (
error &&
typeof error === 'object' &&
'data' in error &&
error.data &&
typeof error.data === 'object' &&
'retry_after_ms' in error.data &&
typeof error.data.retry_after_ms === 'number'
) {
return error.data.retry_after_ms + 100; // Add small buffer
}
// Exponential backoff: 2s, 4s, 8s...
return Math.min(2000 * 2 ** attempt, 30000);
}
type RoomItemProps = {
room: Room;
selected: boolean;
onToggle: () => void;
onShiftClick: () => void;
disabled?: boolean;
};
/**
* Individual room item component with checkbox
*/
function RoomItem({ room, selected, onToggle, onShiftClick, disabled }: RoomItemProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const avatarUrl = getRoomAvatarUrl(mx, room, 32, useAuthentication);
const roomName = room.name || getMxIdLocalPart(room.roomId) || room.roomId;
const memberCount = room.getJoinedMemberCount();
const handleClick = (evt: MouseEvent<HTMLLabelElement>) => {
if (disabled) return;
if (evt.shiftKey) {
evt.preventDefault();
onShiftClick();
}
};
return (
<Box
as="label"
gap="300"
alignItems="Center"
onClick={handleClick}
style={{
padding: config.space.S200,
borderRadius: config.radii.R300,
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.6 : 1,
background: selected ? 'var(--bg-surface-active)' : undefined,
}}
>
<Checkbox
size="300"
variant="Primary"
checked={selected}
onClick={disabled ? undefined : onToggle}
disabled={disabled}
/>
<Avatar size="200">
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={roomName}
renderFallback={() => (
<Text size="H6">{nameInitials(roomName, 2)}</Text>
)}
/>
</Avatar>
<Box direction="Column" grow="Yes" style={{ minWidth: 0 }}>
<Text size="T300" truncate>
{roomName}
</Text>
<Text size="T200" style={{ opacity: 0.7 }} truncate>
{memberCount} {memberCount === 1 ? 'member' : 'members'}
</Text>
</Box>
</Box>
);
}
type LeavePhase = 'selecting' | 'leaving' | 'done';
type ManageRoomsPromptProps = {
rooms: Room[];
onDone: () => void;
onCancel: () => void;
};
/**
* Dialog for managing multiple rooms with multi-select and mass leave functionality
*/
export function ManageRoomsPrompt({ rooms, onDone, onCancel }: ManageRoomsPromptProps) {
const mx = useMatrixClient();
const [selectedRoomIds, setSelectedRoomIds] = useState<Set<string>>(new Set());
const [phase, setPhase] = useState<LeavePhase>('selecting');
const [leaveProgress, setLeaveProgress] = useState<{ current: number; total: number } | null>(
null
);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const lastSelectedIndex = useRef<number | null>(null);
const sortedRooms = useMemo(
() =>
[...rooms].sort((a, b) => {
const nameA = a.name?.toLowerCase() || '';
const nameB = b.name?.toLowerCase() || '';
return nameA.localeCompare(nameB);
}),
[rooms]
);
/**
* Handles toggling individual room selection
*/
const handleToggle = useCallback((roomId: string, index: number) => {
setSelectedRoomIds((prev) => {
const newSet = new Set(prev);
if (newSet.has(roomId)) {
newSet.delete(roomId);
} else {
newSet.add(roomId);
}
return newSet;
});
lastSelectedIndex.current = index;
}, []);
/**
* Handles shift-click for range selection
*/
const handleShiftClick = useCallback(
(roomId: string, index: number) => {
if (lastSelectedIndex.current === null) {
handleToggle(roomId, index);
return;
}
const start = Math.min(lastSelectedIndex.current, index);
const end = Math.max(lastSelectedIndex.current, index);
setSelectedRoomIds((prev) => {
const newSet = new Set(prev);
sortedRooms.slice(start, end + 1).forEach((r) => newSet.add(r.roomId));
return newSet;
});
lastSelectedIndex.current = index;
},
[handleToggle, sortedRooms]
);
/**
* Select all rooms
*/
const handleSelectAll = useCallback(() => {
setSelectedRoomIds(new Set(sortedRooms.map((r) => r.roomId)));
}, [sortedRooms]);
/**
* Deselect all rooms
*/
const handleDeselectAll = useCallback(() => {
setSelectedRoomIds(new Set());
lastSelectedIndex.current = null;
}, []);
/**
* Leaves a single room with retry on rate limit
*/
const leaveRoom = useCallback(
async (roomId: string, attempt = 0): Promise<boolean> => {
try {
await mx.leave(roomId);
return true;
} catch (error) {
// Check if it's a rate limit error (429)
const isRateLimited =
error &&
typeof error === 'object' &&
'httpStatus' in error &&
error.httpStatus === 429;
if (isRateLimited && attempt < MAX_RETRIES) {
const retryDelay = getRetryDelay(error, attempt);
await delay(retryDelay);
return leaveRoom(roomId, attempt + 1);
}
return false;
}
},
[mx]
);
/**
* Performs mass leave operation
*/
const handleMassLeave = useCallback(async () => {
if (selectedRoomIds.size === 0) return;
setPhase('leaving');
setErrorMessage(null);
const roomsToLeave = Array.from(selectedRoomIds);
const total = roomsToLeave.length;
let failedCount = 0;
// Process rooms sequentially with rate limiting
const processRoom = async (index: number): Promise<void> => {
if (index >= roomsToLeave.length) {
setPhase('done');
if (failedCount > 0) {
setErrorMessage(`Failed to leave ${failedCount} room(s).`);
}
return;
}
const roomId = roomsToLeave[index];
setLeaveProgress({ current: index + 1, total });
const success = await leaveRoom(roomId);
if (!success) {
failedCount += 1;
}
// Rate limiting delay before next room
if (index < roomsToLeave.length - 1) {
await delay(BASE_DELAY_MS);
}
await processRoom(index + 1);
};
await processRoom(0);
}, [leaveRoom, selectedRoomIds]);
useEffect(() => {
if (phase === 'done' && !errorMessage) {
// Auto close on success after short delay
const timer = setTimeout(onDone, 500);
return () => clearTimeout(timer);
}
return undefined;
}, [phase, errorMessage, onDone]);
const renderContent = () => {
if (phase === 'leaving') {
return (
<Box
style={{ padding: config.space.S400, minHeight: '200px' }}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="400"
>
<Spinner size="600" variant="Secondary" />
<Text>
Leaving rooms... {leaveProgress?.current} / {leaveProgress?.total}
</Text>
</Box>
);
}
if (phase === 'done') {
return (
<Box
style={{ padding: config.space.S400, minHeight: '200px' }}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="400"
>
{errorMessage ? (
<>
<Icon src={Icons.Warning} size="600" style={{ color: color.Warning.Main }} />
<Text style={{ color: color.Warning.Main }}>{errorMessage}</Text>
<Button variant="Secondary" onClick={onDone}>
<Text size="B300">Close</Text>
</Button>
</>
) : (
<>
<Icon src={Icons.Check} size="600" style={{ color: color.Success.Main }} />
<Text style={{ color: color.Success.Main }}>Successfully left all selected rooms!</Text>
</>
)}
</Box>
);
}
// Selection phase
return (
<>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="300">
<Box justifyContent="SpaceBetween" alignItems="Center">
<Text size="T300" style={{ opacity: 0.8 }}>
{selectedRoomIds.size} of {sortedRooms.length} rooms selected
</Text>
<Box gap="200">
<Button variant="Secondary" size="300" onClick={handleSelectAll}>
<Text size="B300">Select All</Text>
</Button>
<Button
variant="Secondary"
size="300"
onClick={handleDeselectAll}
disabled={selectedRoomIds.size === 0}
>
<Text size="B300">Deselect All</Text>
</Button>
</Box>
</Box>
<Text size="T200" style={{ opacity: 0.6 }}>
Tip: Hold Shift and click to select a range of rooms
</Text>
</Box>
<Line variant="Surface" size="300" />
<Box style={{ flexGrow: 1, minHeight: 0 }}>
<Scroll hideTrack visibility="Hover" style={{ maxHeight: '400px' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
{sortedRooms.map((room, index) => (
<RoomItem
key={room.roomId}
room={room}
selected={selectedRoomIds.has(room.roomId)}
onToggle={() => handleToggle(room.roomId, index)}
onShiftClick={() => handleShiftClick(room.roomId, index)}
/>
))}
</Box>
</Scroll>
</Box>
<Line variant="Surface" size="300" />
<Box
style={{ padding: config.space.S400 }}
justifyContent="SpaceBetween"
alignItems="Center"
gap="300"
>
<Button variant="Secondary" fill="Soft" onClick={onCancel}>
<Text size="B300">Cancel</Text>
</Button>
<Button
variant="Critical"
onClick={handleMassLeave}
disabled={selectedRoomIds.size === 0}
>
<Icon src={Icons.ArrowGoRight} size="200" />
<Text size="B300">Leave Selected ({selectedRoomIds.size})</Text>
</Button>
</Box>
</>
);
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: phase === 'selecting' ? onCancel : undefined,
clickOutsideDeactivates: phase === 'selecting',
escapeDeactivates: phase === 'selecting' ? stopPropagation : false,
}}
>
<Dialog variant="Surface" style={{ width: '500px', maxWidth: '90vw' }}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H4">Manage Rooms</Text>
{selectedRoomIds.size > 0 && (
<Badge variant="Secondary" radii="Pill">
<Text size="L400">{selectedRoomIds.size}</Text>
</Badge>
)}
</Box>
{phase === 'selecting' && (
<IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
)}
</Header>
{renderContent()}
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}

View File

@@ -0,0 +1 @@
export * from './ManageRoomsPrompt';

View File

@@ -33,6 +33,7 @@ import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
import { getRoomAvatarUrl, getStateEvent } from '../../utils/room';
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { JoinSpacePrompt } from '../join-space-prompt';
type GridColumnCount = '1' | '2' | '3';
const getGridColumnCount = (gridWidth: number): GridColumnCount => {
@@ -207,8 +208,32 @@ export const RoomCard = as<'div', RoomCardProps>(
const closeTopic = () => setViewTopic(false);
const openTopic = () => setViewTopic(true);
const [showSpaceJoinPrompt, setShowSpaceJoinPrompt] = useState(false);
const isSpace = roomType === RoomType.Space || joinedRoom?.isSpaceRoom();
const handleJoinClick = () => {
if (isSpace) {
setShowSpaceJoinPrompt(true);
} else {
join();
}
};
const handleSpaceJoinDone = (roomId: string) => {
setShowSpaceJoinPrompt(false);
onView?.(roomId);
};
return (
<RoomCardBase {...props} ref={ref}>
{showSpaceJoinPrompt && (
<JoinSpacePrompt
roomIdOrAlias={roomIdOrAlias}
viaServers={viaServers}
onDone={handleSpaceJoinDone}
onCancel={() => setShowSpaceJoinPrompt(false)}
/>
)}
<Box gap="200" justifyContent="SpaceBetween">
<Avatar size="500">
<RoomAvatar
@@ -222,7 +247,7 @@ export const RoomCard = as<'div', RoomCardProps>(
)}
/>
</Avatar>
{(roomType === RoomType.Space || joinedRoom?.isSpaceRoom()) && (
{isSpace && (
<Badge variant="Secondary" fill="Soft" outlined>
<Text size="L400">Space</Text>
</Badge>
@@ -269,10 +294,10 @@ export const RoomCard = as<'div', RoomCardProps>(
)}
{typeof joinedRoomId !== 'string' && joinState.status !== AsyncStatus.Error && (
<Button
onClick={join}
onClick={handleJoinClick}
variant="Secondary"
size="300"
disabled={joining}
disabled={joining || showSpaceJoinPrompt}
before={joining && <Spinner size="50" variant="Secondary" fill="Soft" />}
>
<Text size="B300" truncate>
@@ -283,7 +308,7 @@ export const RoomCard = as<'div', RoomCardProps>(
{typeof joinedRoomId !== 'string' && joinState.status === AsyncStatus.Error && (
<Box gap="200">
<Button
onClick={join}
onClick={handleJoinClick}
className={css.ActionButton}
variant="Critical"
fill="Solid"

View File

@@ -42,11 +42,26 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
if (previewStatus.status === AsyncStatus.Error) return null;
const renderContent = (prev: IPreviewUrlResponse) => {
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication, 256, 256, 'scale', false);
const ogImage = prev['og:image'] || '';
// SVGs can't be thumbnailed by Matrix servers, load them directly
// Also check og:image:type if available
const isSvg =
ogImage.toLowerCase().endsWith('.svg') ||
prev['og:image:type'] === 'image/svg+xml';
const imgUrl = isSvg
? mxcUrlToHttp(mx, ogImage, useAuthentication)
: mxcUrlToHttp(mx, ogImage, useAuthentication, 256, 256, 'scale', false);
return (
<>
{imgUrl && <UrlPreviewImg src={imgUrl} alt={prev['og:title']} title={prev['og:title']} />}
{imgUrl && (
<UrlPreviewImg
src={imgUrl}
alt={prev['og:title']}
title={prev['og:title']}
style={isSvg ? { padding: '10px' } : undefined}
/>
)}
<UrlPreviewContent>
<Text
style={linkStyles}