Files
cinny/src/app/components/remove-inaccessible-prompt/RemoveInaccessiblePrompt.tsx

319 lines
11 KiB
TypeScript

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<void> {
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<InaccessibleScanResult> {
let hierarchySummaries = new Map<string, { room_id: string }>();
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<string>();
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<void> {
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<InaccessibleScanResult | null>(null);
const [scanState, scan] = useAsyncCallback<InaccessibleScanResult, MatrixError | Error, []>(
useCallback(async () => collectInaccessibleSpaceChildren(mx, roomId), [mx, roomId])
);
const children = scanResult?.inaccessible ?? null;
const [removeState, removeChildren] = useAsyncCallback<undefined, MatrixError | Error, []>(
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 (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onCancel,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<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">Remove Inaccessible</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300" disabled={isRemoving}>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="200">
{isScanning && (
<Box alignItems="Center" gap="200">
<Spinner size="200" />
<Text priority="400">Scanning space hierarchy for inaccessible rooms</Text>
</Box>
)}
{scanState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
Failed to scan space. {scanState.error.message}
</Text>
)}
{scanState.status === AsyncStatus.Success && count === 0 && (
<Box direction="Column" gap="100">
<Text priority="400">No inaccessible rooms found in this space.</Text>
{scanResult && (
<Text size="T200" priority="300">
Scanned {scanResult.childCount} child
{scanResult.childCount === 1 ? '' : 'ren'}: {scanResult.joinedCount} joined,{' '}
{scanResult.joinableCount} joinable via hierarchy.
</Text>
)}
</Box>
)}
{scanState.status === AsyncStatus.Success && count > 0 && (
<>
<Text priority="400">
Remove {count} inaccessible room{count === 1 ? '' : 's'} from this space? This
only unlinks them from the space; it does not leave or delete rooms.
</Text>
<Box
direction="Column"
gap="100"
style={{
maxHeight: toRem(200),
overflow: 'auto',
padding: config.space.S200,
backgroundColor: color.SurfaceVariant.Container,
borderRadius: config.radii.R400,
}}
>
{previewIds.map((id) => (
<Text key={id} size="T200" style={{ wordBreak: 'break-all' }}>
{id}
</Text>
))}
{count > PREVIEW_LIMIT && (
<Text size="T200" priority="300">
and {count - PREVIEW_LIMIT} more
</Text>
)}
</Box>
</>
)}
{removeState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
Failed to remove some rooms. {removeState.error.message}
</Text>
)}
</Box>
<Button
type="submit"
variant="Critical"
onClick={() => removeChildren()}
before={
isRemoving || isScanning ? (
<Spinner fill="Solid" variant="Critical" size="200" />
) : undefined
}
disabled={
isScanning ||
isRemoving ||
count === 0 ||
removeState.status === AsyncStatus.Success
}
>
<Text size="B400">{getButtonText()}</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}