Files
cinny/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx

191 lines
5.7 KiB
TypeScript

import React, { useCallback, useEffect, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { Dialog, Overlay, OverlayCenter, OverlayBackdrop, Header, config, Box, Text, IconButton, color, Button, Spinner } from 'folds';
import { Icon, Icons } from '../icons';
import { MatrixError, Room } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { stopPropagation } from '../../utils/keyboard';
import { getSpaceChildren, isSpace } from '../../utils/room';
import { mDirectAtom } from '../../state/mDirectList';
import { Membership } from '../../../types/matrix/room';
const LEAVE_DELAY_MS = 300;
/**
* Delays execution for a specified time
*/
function delay(ms: number): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
/**
* Leaves multiple rooms sequentially with rate limiting
*/
async function leaveRoomsSequentially(
mx: ReturnType<typeof useMatrixClient>,
roomIds: string[],
onProgress?: (current: number, total: number) => void
): Promise<void> {
const total = roomIds.length;
for (let i = 0; i < roomIds.length; i++) {
const roomId = roomIds[i];
onProgress?.(i, total);
try {
await mx.leave(roomId);
await delay(LEAVE_DELAY_MS);
} catch {
// Continue leaving other rooms even if one fails
}
}
onProgress?.(total, total);
}
/**
* Gets direct child room IDs from a space that can be left (excluding DMs and subspaces)
*/
function getDirectChildRoomsToLeave(
mx: ReturnType<typeof useMatrixClient>,
spaceRoom: Room,
mDirects: Set<string>
): string[] {
const childIds = getSpaceChildren(spaceRoom);
return childIds.filter((childId) => {
// Skip DMs
if (mDirects.has(childId)) {
return false;
}
const childRoom = mx.getRoom(childId);
if (!childRoom) {
return false;
}
// Skip subspaces (only leave rooms, not nested spaces)
if (isSpace(childRoom)) {
return false;
}
// Only include rooms we're actually joined to
if (childRoom.getMyMembership() !== Membership.Join) {
return false;
}
return true;
});
}
type LeaveSpacePromptProps = {
roomId: string;
onDone: () => void;
onCancel: () => void;
};
export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptProps) {
const mx = useMatrixClient();
const mDirects = useAtomValue(mDirectAtom);
const [progress, setProgress] = useState<{ current: number; total: number } | null>(null);
const [leaveState, leaveRoom] = useAsyncCallback<undefined, MatrixError, []>(
useCallback(async () => {
const spaceRoom = mx.getRoom(roomId);
if (spaceRoom) {
// Get direct child rooms to leave (excluding DMs and subspaces)
const childRoomsToLeave = getDirectChildRoomsToLeave(mx, spaceRoom, mDirects);
if (childRoomsToLeave.length > 0) {
// Leave child rooms first
await leaveRoomsSequentially(mx, childRoomsToLeave, (current, total) => {
setProgress({ current, total });
});
}
}
// Clear progress and leave the space itself
setProgress(null);
await mx.leave(roomId);
}, [mx, roomId, mDirects])
);
const handleLeave = () => {
leaveRoom();
};
useEffect(() => {
if (leaveState.status === AsyncStatus.Success) {
onDone();
}
}, [leaveState, onDone]);
const isLoading = leaveState.status === AsyncStatus.Loading;
const getButtonText = () => {
if (!isLoading) return 'Leave';
if (progress) return `Leaving Rooms: ${progress.current}/${progress.total}`;
return 'Leaving Space...';
};
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">Leave Space</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="200">
<Text priority="400">
Are you sure you want to leave this space? You will also leave all rooms directly
in this space (except DMs).
</Text>
{leaveState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
Failed to leave space! {leaveState.error.message}
</Text>
)}
</Box>
<Button
type="submit"
variant="Critical"
onClick={handleLeave}
before={
isLoading ? <Spinner fill="Solid" variant="Critical" size="200" /> : undefined
}
aria-disabled={isLoading || leaveState.status === AsyncStatus.Success}
>
<Text size="B400">{getButtonText()}</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}