import React, { useCallback, useEffect, useMemo, useState } from 'react'; import FocusTrap from 'focus-trap-react'; import { Dialog, Overlay, OverlayCenter, OverlayBackdrop, Header, config, Box, Text, IconButton, color, Button, Spinner, toRem, } from 'folds'; import { MatrixClient, MatrixError, Room } from 'matrix-js-sdk'; import { Icon, Icons } from '../icons'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { stopPropagation } from '../../utils/keyboard'; import { getSpaceChildren, getStateEvent, isSpace } from '../../utils/room'; import { Membership, StateEvent } from '../../../types/matrix/room'; import { fetchAllHierarchyRooms } from '../../hooks/useSpaceHierarchy'; import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators'; import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions'; import { IPowerLevels } from '../../hooks/usePowerLevels'; const REMOVE_DELAY_MS = 100; const PREVIEW_LIMIT = 20; export type InaccessibleSpaceChild = { parentId: string; roomId: string; }; function delay(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); } function canEditSpaceChildren(mx: MatrixClient, spaceRoom: Room): boolean { const creators = getRoomCreatorsForRoomId(mx, spaceRoom.roomId); const powerLevelsEvent = getStateEvent(spaceRoom, StateEvent.RoomPowerLevels); const powerLevels = (powerLevelsEvent?.getContent() ?? {}) as IPowerLevels; const permissions = getRoomPermissionsAPI(creators, powerLevels); return permissions.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()); } export type InaccessibleScanResult = { inaccessible: InaccessibleSpaceChild[]; /** Total valid m.space.child entries walked (editable spaces only). */ childCount: number; /** Children skipped because the user is joined to them. */ joinedCount: number; /** Unjoined children that hierarchy can still summarize (Joinable / not Inaccessible). */ joinableCount: number; }; /** * Collects space children that appear as Unknown/Inaccessible in the lobby. * Matches lobby getRoom (joined-only): not joined + no hierarchy summary. * Walks nested subspaces the user can edit. */ export async function collectInaccessibleSpaceChildren( mx: MatrixClient, rootSpaceId: string ): Promise { let hierarchySummaries = new Map(); try { const hierarchyRooms = await fetchAllHierarchyRooms(mx, rootSpaceId, 3); hierarchySummaries = new Map(hierarchyRooms.map((room) => [room.room_id, room])); } catch { // Hierarchy often fails for restricted spaces; treat missing summaries as inaccessible. hierarchySummaries = new Map(); } const inaccessible: InaccessibleSpaceChild[] = []; const visited = new Set(); let childCount = 0; let joinedCount = 0; let joinableCount = 0; const walk = (spaceId: string) => { if (visited.has(spaceId)) return; visited.add(spaceId); const spaceRoom = mx.getRoom(spaceId); if (!spaceRoom || spaceRoom.getMyMembership() !== Membership.Join) return; if (!canEditSpaceChildren(mx, spaceRoom)) return; for (const childId of getSpaceChildren(spaceRoom)) { childCount += 1; const childRoom = mx.getRoom(childId); // Lobby uses joined-only getRoom; left/invite rooms still render as Inaccessible. const joined = childRoom?.getMyMembership() === Membership.Join; if (joined && childRoom && isSpace(childRoom)) { joinedCount += 1; walk(childId); continue; } if (joined) { joinedCount += 1; continue; } const hasHierarchySummary = hierarchySummaries.has(childId); if (hasHierarchySummary) { joinableCount += 1; continue; } inaccessible.push({ parentId: spaceId, roomId: childId }); } }; walk(rootSpaceId); return { inaccessible, childCount, joinedCount, joinableCount }; } async function removeSpaceChildrenSequentially( mx: MatrixClient, children: InaccessibleSpaceChild[], onProgress?: (current: number, total: number) => void ): Promise { const total = children.length; for (let i = 0; i < children.length; i += 1) { const { parentId, roomId } = children[i]; onProgress?.(i, total); try { await mx.sendStateEvent(parentId, StateEvent.SpaceChild as any, {}, roomId); await delay(REMOVE_DELAY_MS); } catch { // Continue removing others even if one fails } } onProgress?.(total, total); } type RemoveInaccessiblePromptProps = { roomId: string; onDone: () => void; onCancel: () => void; }; export function RemoveInaccessiblePrompt({ roomId, onDone, onCancel, }: RemoveInaccessiblePromptProps) { const mx = useMatrixClient(); const [progress, setProgress] = useState<{ current: number; total: number } | null>(null); const [scanResult, setScanResult] = useState(null); const [scanState, scan] = useAsyncCallback( useCallback(async () => collectInaccessibleSpaceChildren(mx, roomId), [mx, roomId]) ); const children = scanResult?.inaccessible ?? null; const [removeState, removeChildren] = useAsyncCallback( useCallback(async () => { if (!children || children.length === 0) return; await removeSpaceChildrenSequentially(mx, children, (current, total) => { setProgress({ current, total }); }); setProgress(null); }, [mx, children]) ); useEffect(() => { scan(); }, [scan]); useEffect(() => { if (scanState.status === AsyncStatus.Success) { setScanResult(scanState.data); } }, [scanState]); useEffect(() => { if (removeState.status === AsyncStatus.Success) { onDone(); } }, [removeState, onDone]); const previewIds = useMemo(() => (children ?? []).slice(0, PREVIEW_LIMIT).map((c) => c.roomId), [children]); const isScanning = scanState.status === AsyncStatus.Loading || scanState.status === AsyncStatus.Idle; const isRemoving = removeState.status === AsyncStatus.Loading; const count = children?.length ?? 0; const getButtonText = () => { if (isRemoving && progress) return `Removing: ${progress.current}/${progress.total}`; if (isRemoving) return 'Removing...'; if (count === 0) return 'Nothing to Remove'; return `Remove ${count} Room${count === 1 ? '' : 's'}`; }; return ( }>
Remove Inaccessible
{isScanning && ( Scanning space hierarchy for inaccessible rooms… )} {scanState.status === AsyncStatus.Error && ( Failed to scan space. {scanState.error.message} )} {scanState.status === AsyncStatus.Success && count === 0 && ( No inaccessible rooms found in this space. {scanResult && ( Scanned {scanResult.childCount} child {scanResult.childCount === 1 ? '' : 'ren'}: {scanResult.joinedCount} joined,{' '} {scanResult.joinableCount} joinable via hierarchy. )} )} {scanState.status === AsyncStatus.Success && count > 0 && ( <> Remove {count} inaccessible room{count === 1 ? '' : 's'} from this space? This only unlinks them from the space; it does not leave or delete rooms. {previewIds.map((id) => ( {id} ))} {count > PREVIEW_LIMIT && ( …and {count - PREVIEW_LIMIT} more )} )} {removeState.status === AsyncStatus.Error && ( Failed to remove some rooms. {removeState.error.message} )}
); }