428 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|