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 { return new Promise((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 ( ( {nameInitials(room.name || room.room_id)} )} /> {room.name || room.room_id} {room.topic && ( {room.topic} )} {roomIsSpace && ( Space )} ); } 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('initial'); const [spaceId, setSpaceId] = useState(null); const [hierarchyRooms, setHierarchyRooms] = useState([]); const [selectedRoomIds, setSelectedRoomIds] = useState>(new Set()); const [loading, setLoading] = useState(false); const [error, setError] = useState(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 => { const allRooms: IHierarchyRoom[] = []; let nextBatch: string | undefined; let pageCount = 0; const timeoutPromise = new Promise((_, 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 => { 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 ( }>
{phase === 'selecting-rooms' ? 'Select Rooms to Join' : 'Join Space'}
{phase === 'initial' && ( Join this space to see and select which rooms to join. )} {phase === 'joining-space' && ( {loading ? 'Loading space contents...' : 'Joining space...'} {canSkipLoading && ( )} )} {phase === 'selecting-rooms' && hierarchyRooms.length > 0 && ( <> Select which rooms and spaces to join: {spaces.length > 0 && ( Spaces ({spaces.length}) {spaces.map((room) => ( toggleRoom(room.room_id)} useAuthentication={useAuthentication} disabled={isJoining} /> ))} )} {rooms.length > 0 && ( Rooms ({rooms.length}) {rooms.map((room) => ( toggleRoom(room.room_id)} useAuthentication={useAuthentication} disabled={isJoining} /> ))} )} )} {phase === 'selecting-rooms' && hierarchyRooms.length === 0 && ( This space has no additional rooms to join. )} {phase === 'joining-rooms' && ( {progress ? `Joining rooms: ${progress.current}/${progress.total}` : 'Joining rooms...'} )} {error && ( {error} )}
); }