feat(call): add docked call panel and remote cursor overlay
- Implemented DockedCallPanel component to render the docked call interface when an active call is present. - Created RemoteCursorOverlay component to display remote cursor positions during screen sharing. - Added hooks for managing docked call state and remote cursor functionality. - Introduced pending drag state management for seamless transitions between docked and floating states. - Developed embed filter management components for personal and room-wide settings, allowing users to customize URL embed visibility. - Implemented auto-joining functionality for rooms within spaces, enhancing user experience during space navigation. - Added state management for tracking joining progress of rooms in spaces.
This commit is contained in:
446
src/app/components/manage-rooms-prompt/ManageRoomsPrompt.tsx
Normal file
446
src/app/components/manage-rooms-prompt/ManageRoomsPrompt.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
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<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>
|
||||
);
|
||||
}
|
||||
1
src/app/components/manage-rooms-prompt/index.ts
Normal file
1
src/app/components/manage-rooms-prompt/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ManageRoomsPrompt';
|
||||
Reference in New Issue
Block a user