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, Icon, Icons, color, Button, Spinner, Scroll, Checkbox, Badge, Line, Avatar, } from 'folds'; 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 { return new Promise((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) => { if (disabled) return; if (evt.shiftKey) { evt.preventDefault(); onShiftClick(); } }; return ( ( {nameInitials(roomName, 2)} )} /> {roomName} {memberCount} {memberCount === 1 ? 'member' : 'members'} ); } 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>(new Set()); const [phase, setPhase] = useState('selecting'); const [leaveProgress, setLeaveProgress] = useState<{ current: number; total: number } | null>( null ); const [errorMessage, setErrorMessage] = useState(null); const lastSelectedIndex = useRef(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 => { 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 => { 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 ( Leaving rooms... {leaveProgress?.current} / {leaveProgress?.total} ); } if (phase === 'done') { return ( {errorMessage ? ( <> {errorMessage} ) : ( <> Successfully left all selected rooms! )} ); } // Selection phase return ( <> {selectedRoomIds.size} of {sortedRooms.length} rooms selected Tip: Hold Shift and click to select a range of rooms {sortedRooms.map((room, index) => ( handleToggle(room.roomId, index)} onShiftClick={() => handleShiftClick(room.roomId, index)} /> ))} ); }; return ( }>
Manage Rooms {selectedRoomIds.size > 0 && ( {selectedRoomIds.size} )} {phase === 'selecting' && ( )}
{renderContent()}
); }