540 lines
17 KiB
TypeScript
540 lines
17 KiB
TypeScript
import React, { useMemo, useState } from 'react';
|
|
import FocusTrap from 'focus-trap-react';
|
|
import { Dialog, Overlay, OverlayCenter, OverlayBackdrop, Header, config, Box, Text, IconButton, color, Button, Spinner, Scroll, Checkbox, Avatar, Badge } from 'folds';
|
|
import { Icon, Icons } from '../icons';
|
|
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>
|
|
);
|
|
}
|