319 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|