Files
cinny/src/app/components/manage-rooms-prompt/ManageRoomsPrompt.tsx

428 lines
13 KiB
TypeScript

import React, { useMemo, useState, useCallback, useRef, useEffect, MouseEvent } from 'react';
import FocusTrap from 'focus-trap-react';
import { Dialog, Overlay, OverlayCenter, OverlayBackdrop, Header, config, Box, Text, IconButton, color, Button, Spinner, Scroll, Checkbox, Badge, Line, Avatar } from 'folds';
import { Icon, Icons } from '../icons';
import { Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import { RoomAvatar } from '../room-avatar';
import { nameInitials } from '../../utils/common';
import { getMxIdLocalPart } from '../../utils/matrix';
import { getRoomAvatarUrl } from '../../utils/room';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
const BASE_DELAY_MS = 1000;
const MAX_RETRIES = 3;
/**
* Delays execution for a specified time
*/
function delay(ms: number): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
/**
* Gets the retry delay from a rate limit error, or returns a default backoff
*/
function getRetryDelay(error: unknown, attempt: number): number {
// Check for Matrix rate limit error with retry_after_ms
if (
error &&
typeof error === 'object' &&
'data' in error &&
error.data &&
typeof error.data === 'object' &&
'retry_after_ms' in error.data &&
typeof error.data.retry_after_ms === 'number'
) {
return error.data.retry_after_ms + 100; // Add small buffer
}
// Exponential backoff: 2s, 4s, 8s...
return Math.min(2000 * 2 ** attempt, 30000);
}
type RoomItemProps = {
room: Room;
selected: boolean;
onToggle: () => void;
onShiftClick: () => void;
disabled?: boolean;
};
/**
* Individual room item component with checkbox
*/
function RoomItem({ room, selected, onToggle, onShiftClick, disabled }: RoomItemProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const avatarUrl = getRoomAvatarUrl(mx, room, 32, useAuthentication);
const roomName = room.name || getMxIdLocalPart(room.roomId) || room.roomId;
const memberCount = room.getJoinedMemberCount();
const handleClick = (evt: MouseEvent<HTMLLabelElement>) => {
if (disabled) return;
if (evt.shiftKey) {
evt.preventDefault();
onShiftClick();
}
};
return (
<Box
as="label"
gap="300"
alignItems="Center"
onClick={handleClick}
style={{
padding: config.space.S200,
borderRadius: config.radii.R300,
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.6 : 1,
background: selected ? 'var(--bg-surface-active)' : undefined,
}}
>
<Checkbox
size="300"
variant="Primary"
checked={selected}
onClick={disabled ? undefined : onToggle}
disabled={disabled}
/>
<Avatar size="200">
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={roomName}
renderFallback={() => (
<Text size="H6">{nameInitials(roomName, 2)}</Text>
)}
/>
</Avatar>
<Box direction="Column" grow="Yes" style={{ minWidth: 0 }}>
<Text size="T300" truncate>
{roomName}
</Text>
<Text size="T200" style={{ opacity: 0.7 }} truncate>
{memberCount} {memberCount === 1 ? 'member' : 'members'}
</Text>
</Box>
</Box>
);
}
type LeavePhase = 'selecting' | 'leaving' | 'done';
type ManageRoomsPromptProps = {
rooms: Room[];
onDone: () => void;
onCancel: () => void;
};
/**
* Dialog for managing multiple rooms with multi-select and mass leave functionality
*/
export function ManageRoomsPrompt({ rooms, onDone, onCancel }: ManageRoomsPromptProps) {
const mx = useMatrixClient();
const [selectedRoomIds, setSelectedRoomIds] = useState<Set<string>>(new Set());
const [phase, setPhase] = useState<LeavePhase>('selecting');
const [leaveProgress, setLeaveProgress] = useState<{ current: number; total: number } | null>(
null
);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const lastSelectedIndex = useRef<number | null>(null);
const sortedRooms = useMemo(
() =>
[...rooms].sort((a, b) => {
const nameA = a.name?.toLowerCase() || '';
const nameB = b.name?.toLowerCase() || '';
return nameA.localeCompare(nameB);
}),
[rooms]
);
/**
* Handles toggling individual room selection
*/
const handleToggle = useCallback((roomId: string, index: number) => {
setSelectedRoomIds((prev) => {
const newSet = new Set(prev);
if (newSet.has(roomId)) {
newSet.delete(roomId);
} else {
newSet.add(roomId);
}
return newSet;
});
lastSelectedIndex.current = index;
}, []);
/**
* Handles shift-click for range selection
*/
const handleShiftClick = useCallback(
(roomId: string, index: number) => {
if (lastSelectedIndex.current === null) {
handleToggle(roomId, index);
return;
}
const start = Math.min(lastSelectedIndex.current, index);
const end = Math.max(lastSelectedIndex.current, index);
setSelectedRoomIds((prev) => {
const newSet = new Set(prev);
sortedRooms.slice(start, end + 1).forEach((r) => newSet.add(r.roomId));
return newSet;
});
lastSelectedIndex.current = index;
},
[handleToggle, sortedRooms]
);
/**
* Select all rooms
*/
const handleSelectAll = useCallback(() => {
setSelectedRoomIds(new Set(sortedRooms.map((r) => r.roomId)));
}, [sortedRooms]);
/**
* Deselect all rooms
*/
const handleDeselectAll = useCallback(() => {
setSelectedRoomIds(new Set());
lastSelectedIndex.current = null;
}, []);
/**
* Leaves a single room with retry on rate limit
*/
const leaveRoom = useCallback(
async (roomId: string, attempt = 0): Promise<boolean> => {
try {
await mx.leave(roomId);
return true;
} catch (error) {
// Check if it's a rate limit error (429)
const isRateLimited =
error &&
typeof error === 'object' &&
'httpStatus' in error &&
error.httpStatus === 429;
if (isRateLimited && attempt < MAX_RETRIES) {
const retryDelay = getRetryDelay(error, attempt);
await delay(retryDelay);
return leaveRoom(roomId, attempt + 1);
}
return false;
}
},
[mx]
);
/**
* Performs mass leave operation
*/
const handleMassLeave = useCallback(async () => {
if (selectedRoomIds.size === 0) return;
setPhase('leaving');
setErrorMessage(null);
const roomsToLeave = Array.from(selectedRoomIds);
const total = roomsToLeave.length;
let failedCount = 0;
// Process rooms sequentially with rate limiting
const processRoom = async (index: number): Promise<void> => {
if (index >= roomsToLeave.length) {
setPhase('done');
if (failedCount > 0) {
setErrorMessage(`Failed to leave ${failedCount} room(s).`);
}
return;
}
const roomId = roomsToLeave[index];
setLeaveProgress({ current: index + 1, total });
const success = await leaveRoom(roomId);
if (!success) {
failedCount += 1;
}
// Rate limiting delay before next room
if (index < roomsToLeave.length - 1) {
await delay(BASE_DELAY_MS);
}
await processRoom(index + 1);
};
await processRoom(0);
}, [leaveRoom, selectedRoomIds]);
useEffect(() => {
if (phase === 'done' && !errorMessage) {
// Auto close on success after short delay
const timer = setTimeout(onDone, 500);
return () => clearTimeout(timer);
}
return undefined;
}, [phase, errorMessage, onDone]);
const renderContent = () => {
if (phase === 'leaving') {
return (
<Box
style={{ padding: config.space.S400, minHeight: '200px' }}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="400"
>
<Spinner size="600" variant="Secondary" />
<Text>
Leaving rooms... {leaveProgress?.current} / {leaveProgress?.total}
</Text>
</Box>
);
}
if (phase === 'done') {
return (
<Box
style={{ padding: config.space.S400, minHeight: '200px' }}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="400"
>
{errorMessage ? (
<>
<Icon src={Icons.Warning} size="600" style={{ color: color.Warning.Main }} />
<Text style={{ color: color.Warning.Main }}>{errorMessage}</Text>
<Button variant="Secondary" onClick={onDone}>
<Text size="B300">Close</Text>
</Button>
</>
) : (
<>
<Icon src={Icons.Check} size="600" style={{ color: color.Success.Main }} />
<Text style={{ color: color.Success.Main }}>Successfully left all selected rooms!</Text>
</>
)}
</Box>
);
}
// Selection phase
return (
<>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="300">
<Box justifyContent="SpaceBetween" alignItems="Center">
<Text size="T300" style={{ opacity: 0.8 }}>
{selectedRoomIds.size} of {sortedRooms.length} rooms selected
</Text>
<Box gap="200">
<Button variant="Secondary" size="300" onClick={handleSelectAll}>
<Text size="B300">Select All</Text>
</Button>
<Button
variant="Secondary"
size="300"
onClick={handleDeselectAll}
disabled={selectedRoomIds.size === 0}
>
<Text size="B300">Deselect All</Text>
</Button>
</Box>
</Box>
<Text size="T200" style={{ opacity: 0.6 }}>
Tip: Hold Shift and click to select a range of rooms
</Text>
</Box>
<Line variant="Surface" size="300" />
<Box style={{ flexGrow: 1, minHeight: 0 }}>
<Scroll hideTrack visibility="Hover" style={{ maxHeight: '400px' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
{sortedRooms.map((room, index) => (
<RoomItem
key={room.roomId}
room={room}
selected={selectedRoomIds.has(room.roomId)}
onToggle={() => handleToggle(room.roomId, index)}
onShiftClick={() => handleShiftClick(room.roomId, index)}
/>
))}
</Box>
</Scroll>
</Box>
<Line variant="Surface" size="300" />
<Box
style={{ padding: config.space.S400 }}
justifyContent="SpaceBetween"
alignItems="Center"
gap="300"
>
<Button variant="Secondary" fill="Soft" onClick={onCancel}>
<Text size="B300">Cancel</Text>
</Button>
<Button
variant="Critical"
onClick={handleMassLeave}
disabled={selectedRoomIds.size === 0}
>
<Icon src={Icons.ArrowGoRight} size="200" />
<Text size="B300">Leave Selected ({selectedRoomIds.size})</Text>
</Button>
</Box>
</>
);
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: phase === 'selecting' ? onCancel : undefined,
clickOutsideDeactivates: phase === 'selecting',
escapeDeactivates: phase === 'selecting' ? stopPropagation : false,
}}
>
<Dialog variant="Surface" style={{ width: '500px', maxWidth: '90vw' }}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H4">Manage Rooms</Text>
{selectedRoomIds.size > 0 && (
<Badge variant="Secondary" radii="Pill">
<Text size="L400">{selectedRoomIds.size}</Text>
</Badge>
)}
</Box>
{phase === 'selecting' && (
<IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
)}
</Header>
{renderContent()}
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}