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:
@@ -31,6 +31,7 @@ import { PdfViewer } from './Pdf-viewer';
|
||||
import { TextViewer } from './text-viewer';
|
||||
import { testMatrixTo } from '../plugins/matrix-to';
|
||||
import { IImageContent } from '../../types/matrix/common';
|
||||
import { filterDisabledUrls } from '../hooks/useRoomEmbedFilters';
|
||||
|
||||
type RenderMessageContentProps = {
|
||||
displayName: string;
|
||||
@@ -44,6 +45,7 @@ type RenderMessageContentProps = {
|
||||
htmlReactParserOptions: HTMLReactParserOptions;
|
||||
linkifyOpts: Opts;
|
||||
outlineAttachment?: boolean;
|
||||
disabledEmbedPatterns?: string[];
|
||||
};
|
||||
export function RenderMessageContent({
|
||||
displayName,
|
||||
@@ -57,9 +59,11 @@ export function RenderMessageContent({
|
||||
htmlReactParserOptions,
|
||||
linkifyOpts,
|
||||
outlineAttachment,
|
||||
disabledEmbedPatterns,
|
||||
}: RenderMessageContentProps) {
|
||||
const renderUrlsPreview = (urls: string[]) => {
|
||||
const filteredUrls = urls.filter((url) => !testMatrixTo(url));
|
||||
let filteredUrls = urls.filter((url) => !testMatrixTo(url));
|
||||
filteredUrls = filterDisabledUrls(filteredUrls, disabledEmbedPatterns ?? []);
|
||||
if (filteredUrls.length === 0) return undefined;
|
||||
return (
|
||||
<UrlPreviewHolder>
|
||||
|
||||
557
src/app/components/join-space-prompt/JoinSpacePrompt.tsx
Normal file
557
src/app/components/join-space-prompt/JoinSpacePrompt.tsx
Normal file
@@ -0,0 +1,557 @@
|
||||
import React, { useMemo, useState } 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,
|
||||
Avatar,
|
||||
Badge,
|
||||
} from 'folds';
|
||||
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { RoomType, Membership } from '../../../types/matrix/room';
|
||||
import { RoomAvatar } from '../room-avatar';
|
||||
import { nameInitials } from '../../utils/common';
|
||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import {
|
||||
setRoomNotificationPreference,
|
||||
RoomNotificationMode,
|
||||
} from '../../hooks/useRoomsNotificationPreferences';
|
||||
|
||||
const PER_PAGE_COUNT = 100;
|
||||
const MAX_PAGES = 5;
|
||||
const HIERARCHY_TIMEOUT_MS = 10000;
|
||||
const JOIN_DELAY_MS = 500;
|
||||
|
||||
/** Join rules that allow automatic joining */
|
||||
const AUTO_JOINABLE_RULES = ['public', 'restricted', 'knock_restricted'];
|
||||
|
||||
/**
|
||||
* Extracts the server name from a Matrix room ID
|
||||
*/
|
||||
function getServerFromRoomId(roomId: string): string {
|
||||
const parts = roomId.split(':');
|
||||
return parts.length > 1 ? parts[1] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a room/space can be automatically joined based on its join rule
|
||||
*/
|
||||
function canAutoJoin(hierarchyRoom: IHierarchyRoom): boolean {
|
||||
const { join_rule: joinRule } = hierarchyRoom;
|
||||
if (!joinRule) return false;
|
||||
return AUTO_JOINABLE_RULES.includes(joinRule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delays execution for a specified time
|
||||
*/
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
type HierarchyRoomItemProps = {
|
||||
room: IHierarchyRoom;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
useAuthentication: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
function HierarchyRoomItem({
|
||||
room,
|
||||
selected,
|
||||
onToggle,
|
||||
useAuthentication,
|
||||
disabled,
|
||||
}: HierarchyRoomItemProps) {
|
||||
const mx = useMatrixClient();
|
||||
const roomIsSpace = room.room_type === RoomType.Space;
|
||||
const avatarUrl = room.avatar_url
|
||||
? mxcUrlToHttp(mx, room.avatar_url, useAuthentication, 48, 48, 'crop')
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="label"
|
||||
gap="300"
|
||||
alignItems="Center"
|
||||
style={{
|
||||
padding: config.space.S200,
|
||||
borderRadius: config.radii.R300,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
size="300"
|
||||
variant="Primary"
|
||||
checked={selected}
|
||||
onClick={disabled ? undefined : onToggle}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Avatar size="200">
|
||||
<RoomAvatar
|
||||
roomId={room.room_id}
|
||||
src={avatarUrl ?? undefined}
|
||||
alt={room.name || room.room_id}
|
||||
renderFallback={() => (
|
||||
<Text as="span" size="T200">
|
||||
{nameInitials(room.name || room.room_id)}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
<Box direction="Column" grow="Yes" style={{ minWidth: 0 }}>
|
||||
<Text size="T300" truncate>
|
||||
{room.name || room.room_id}
|
||||
</Text>
|
||||
{room.topic && (
|
||||
<Text size="T200" priority="400" truncate>
|
||||
{room.topic}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{roomIsSpace && (
|
||||
<Badge variant="Secondary" fill="Soft" outlined>
|
||||
<Text size="L400">Space</Text>
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type JoinSpacePromptProps = {
|
||||
roomIdOrAlias: string;
|
||||
viaServers?: string[];
|
||||
onDone: (roomId: string) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
type JoinPhase = 'initial' | 'joining-space' | 'selecting-rooms' | 'joining-rooms';
|
||||
|
||||
export function JoinSpacePrompt({
|
||||
roomIdOrAlias,
|
||||
viaServers,
|
||||
onDone,
|
||||
onCancel,
|
||||
}: JoinSpacePromptProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const [phase, setPhase] = useState<JoinPhase>('initial');
|
||||
const [spaceId, setSpaceId] = useState<string | null>(null);
|
||||
const [hierarchyRooms, setHierarchyRooms] = useState<IHierarchyRoom[]>([]);
|
||||
const [selectedRoomIds, setSelectedRoomIds] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<{ current: number; total: number } | null>(null);
|
||||
const [canSkipLoading, setCanSkipLoading] = useState(false);
|
||||
|
||||
/**
|
||||
* Skips to the space (used when loading takes too long)
|
||||
*/
|
||||
const handleSkipToSpace = () => {
|
||||
if (spaceId) {
|
||||
onDone(spaceId);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Joins the space first
|
||||
*/
|
||||
const handleJoinSpace = async () => {
|
||||
setPhase('joining-space');
|
||||
setError(null);
|
||||
setCanSkipLoading(false);
|
||||
|
||||
let joinedSpaceId: string;
|
||||
|
||||
try {
|
||||
const spaceRoom = await mx.joinRoom(roomIdOrAlias, { viaServers });
|
||||
joinedSpaceId = spaceRoom.roomId;
|
||||
setSpaceId(joinedSpaceId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to join space');
|
||||
setPhase('initial');
|
||||
return;
|
||||
}
|
||||
|
||||
// Space is joined - allow skipping from here
|
||||
setCanSkipLoading(true);
|
||||
|
||||
// Now fetch the hierarchy with a timeout
|
||||
setLoading(true);
|
||||
|
||||
const fetchWithTimeout = async (): Promise<IHierarchyRoom[]> => {
|
||||
const allRooms: IHierarchyRoom[] = [];
|
||||
let nextBatch: string | undefined;
|
||||
let pageCount = 0;
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Timeout')), HIERARCHY_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
while (pageCount < MAX_PAGES) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const result = await Promise.race([
|
||||
mx.getRoomHierarchy(joinedSpaceId, PER_PAGE_COUNT, undefined, false, nextBatch),
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
allRooms.push(...result.rooms);
|
||||
nextBatch = result.next_batch;
|
||||
pageCount += 1;
|
||||
|
||||
if (!nextBatch) break;
|
||||
}
|
||||
|
||||
return allRooms;
|
||||
};
|
||||
|
||||
try {
|
||||
const allRooms = await fetchWithTimeout();
|
||||
|
||||
// Filter out items we can't join (excluding the space itself)
|
||||
const joinableRooms = allRooms.filter((room) => {
|
||||
// Skip the space itself
|
||||
if (room.room_id === joinedSpaceId) return false;
|
||||
// Check if already joined
|
||||
const existingRoom = mx.getRoom(room.room_id);
|
||||
if (existingRoom?.getMyMembership() === Membership.Join) {
|
||||
return false;
|
||||
}
|
||||
return canAutoJoin(room);
|
||||
});
|
||||
|
||||
setHierarchyRooms(joinableRooms);
|
||||
// Select all by default
|
||||
setSelectedRoomIds(new Set(joinableRooms.map((r) => r.room_id)));
|
||||
} catch {
|
||||
// Hierarchy fetch failed or timed out - show empty selection
|
||||
setHierarchyRooms([]);
|
||||
setSelectedRoomIds(new Set());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setPhase('selecting-rooms');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Joins selected rooms
|
||||
*/
|
||||
const handleJoinSelectedRooms = async () => {
|
||||
if (!spaceId) return;
|
||||
|
||||
setPhase('joining-rooms');
|
||||
setError(null);
|
||||
|
||||
const spaceServer = getServerFromRoomId(spaceId);
|
||||
|
||||
// Get selected rooms
|
||||
const roomsToJoin = hierarchyRooms.filter((room) => selectedRoomIds.has(room.room_id));
|
||||
|
||||
// Separate spaces and rooms - join spaces first
|
||||
const spacesToJoin = roomsToJoin.filter((r) => r.room_type === RoomType.Space);
|
||||
const regularRoomsToJoin = roomsToJoin.filter((r) => r.room_type !== RoomType.Space);
|
||||
|
||||
const total = roomsToJoin.length;
|
||||
let current = 0;
|
||||
|
||||
if (total > 0) {
|
||||
setProgress({ current, total });
|
||||
}
|
||||
|
||||
const joinItem = async (item: IHierarchyRoom, isSpace: boolean): Promise<void> => {
|
||||
try {
|
||||
const itemServer = getServerFromRoomId(item.room_id);
|
||||
const itemViaServers = [itemServer, spaceServer].filter(Boolean);
|
||||
|
||||
await mx.joinRoom(item.room_id, { viaServers: itemViaServers });
|
||||
|
||||
// Set notification preferences for rooms (not spaces)
|
||||
if (!isSpace) {
|
||||
await setRoomNotificationPreference(
|
||||
mx,
|
||||
item.room_id,
|
||||
RoomNotificationMode.SpecialMessages,
|
||||
RoomNotificationMode.Unset
|
||||
);
|
||||
}
|
||||
|
||||
await delay(JOIN_DELAY_MS);
|
||||
} catch {
|
||||
// Continue with other items even if one fails
|
||||
} finally {
|
||||
current += 1;
|
||||
setProgress({ current, total });
|
||||
}
|
||||
};
|
||||
|
||||
// Join spaces first, then rooms
|
||||
await spacesToJoin.reduce(
|
||||
(promise, space) => promise.then(() => joinItem(space, true)),
|
||||
Promise.resolve()
|
||||
);
|
||||
|
||||
await regularRoomsToJoin.reduce(
|
||||
(promise, room) => promise.then(() => joinItem(room, false)),
|
||||
Promise.resolve()
|
||||
);
|
||||
|
||||
setProgress(null);
|
||||
onDone(spaceId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Skip room selection and just finish
|
||||
*/
|
||||
const handleSkip = () => {
|
||||
if (spaceId) {
|
||||
onDone(spaceId);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRoom = (roomId: string) => {
|
||||
setSelectedRoomIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(roomId)) {
|
||||
newSet.delete(roomId);
|
||||
} else {
|
||||
newSet.add(roomId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedRoomIds(new Set(hierarchyRooms.map((r) => r.room_id)));
|
||||
};
|
||||
|
||||
const selectNone = () => {
|
||||
setSelectedRoomIds(new Set());
|
||||
};
|
||||
|
||||
const isJoining = phase === 'joining-space' || phase === 'joining-rooms';
|
||||
const spaces = useMemo(
|
||||
() => hierarchyRooms.filter((r) => r.room_type === RoomType.Space),
|
||||
[hierarchyRooms]
|
||||
);
|
||||
const rooms = useMemo(
|
||||
() => hierarchyRooms.filter((r) => r.room_type !== RoomType.Space),
|
||||
[hierarchyRooms]
|
||||
);
|
||||
|
||||
const getButtonText = () => {
|
||||
if (phase === 'initial') return 'Join Space';
|
||||
if (phase === 'joining-space') return loading ? 'Loading Rooms...' : 'Joining Space...';
|
||||
if (phase === 'selecting-rooms') {
|
||||
const selectedCount = selectedRoomIds.size;
|
||||
return selectedCount > 0 ? `Join ${selectedCount} Rooms` : 'Done';
|
||||
}
|
||||
if (progress) return `Joining: ${progress.current}/${progress.total}`;
|
||||
return 'Joining...';
|
||||
};
|
||||
|
||||
const handlePrimaryAction = () => {
|
||||
if (phase === 'initial') {
|
||||
handleJoinSpace();
|
||||
} else if (phase === 'selecting-rooms') {
|
||||
if (selectedRoomIds.size > 0) {
|
||||
handleJoinSelectedRooms();
|
||||
} else {
|
||||
handleSkip();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const canCancel = phase === 'initial' || phase === 'selecting-rooms' || canSkipLoading;
|
||||
|
||||
const getDeactivateHandler = () => {
|
||||
if (!canCancel) return undefined;
|
||||
if (canSkipLoading) return handleSkipToSpace;
|
||||
return onCancel;
|
||||
};
|
||||
|
||||
const getCloseHandler = () => {
|
||||
if (phase === 'selecting-rooms') return handleSkip;
|
||||
if (canSkipLoading) return handleSkipToSpace;
|
||||
return onCancel;
|
||||
};
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: getDeactivateHandler(),
|
||||
clickOutsideDeactivates: canCancel,
|
||||
escapeDeactivates: canCancel ? stopPropagation : false,
|
||||
}}
|
||||
>
|
||||
<Dialog variant="Surface" style={{ maxWidth: '500px', width: '90vw' }}>
|
||||
<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">
|
||||
{phase === 'selecting-rooms' ? 'Select Rooms to Join' : 'Join Space'}
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="300"
|
||||
onClick={getCloseHandler()}
|
||||
radii="300"
|
||||
disabled={phase === 'joining-space' && !canSkipLoading}
|
||||
>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
{phase === 'initial' && (
|
||||
<Text priority="400" size="T300">
|
||||
Join this space to see and select which rooms to join.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{phase === 'joining-space' && (
|
||||
<Box direction="Column" gap="200" alignItems="Center">
|
||||
<Box justifyContent="Center" alignItems="Center" gap="200">
|
||||
<Spinner size="200" />
|
||||
<Text size="T300">
|
||||
{loading ? 'Loading space contents...' : 'Joining space...'}
|
||||
</Text>
|
||||
</Box>
|
||||
{canSkipLoading && (
|
||||
<Button size="300" variant="Secondary" fill="Soft" onClick={handleSkipToSpace}>
|
||||
<Text size="B300">Skip to Space</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{phase === 'selecting-rooms' && hierarchyRooms.length > 0 && (
|
||||
<>
|
||||
<Box direction="Column" gap="200">
|
||||
<Text priority="400" size="T300">
|
||||
Select which rooms and spaces to join:
|
||||
</Text>
|
||||
<Box gap="200">
|
||||
<Button size="300" variant="Secondary" fill="Soft" onClick={selectAll}>
|
||||
<Text size="B300">Select All</Text>
|
||||
</Button>
|
||||
<Button size="300" variant="Secondary" fill="Soft" onClick={selectNone}>
|
||||
<Text size="B300">Select None</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Scroll
|
||||
hideTrack
|
||||
visibility="Hover"
|
||||
style={{ maxHeight: '300px', flexGrow: 1 }}
|
||||
>
|
||||
<Box direction="Column" gap="200">
|
||||
{spaces.length > 0 && (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400" style={{ color: color.Secondary.Main }}>
|
||||
Spaces ({spaces.length})
|
||||
</Text>
|
||||
{spaces.map((room) => (
|
||||
<HierarchyRoomItem
|
||||
key={room.room_id}
|
||||
room={room}
|
||||
selected={selectedRoomIds.has(room.room_id)}
|
||||
onToggle={() => toggleRoom(room.room_id)}
|
||||
useAuthentication={useAuthentication}
|
||||
disabled={isJoining}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{rooms.length > 0 && (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400" style={{ color: color.Secondary.Main }}>
|
||||
Rooms ({rooms.length})
|
||||
</Text>
|
||||
{rooms.map((room) => (
|
||||
<HierarchyRoomItem
|
||||
key={room.room_id}
|
||||
room={room}
|
||||
selected={selectedRoomIds.has(room.room_id)}
|
||||
onToggle={() => toggleRoom(room.room_id)}
|
||||
useAuthentication={useAuthentication}
|
||||
disabled={isJoining}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Scroll>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'selecting-rooms' && hierarchyRooms.length === 0 && (
|
||||
<Text priority="400" size="T300">
|
||||
This space has no additional rooms to join.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{phase === 'joining-rooms' && (
|
||||
<Box justifyContent="Center" alignItems="Center" gap="200">
|
||||
<Spinner size="200" />
|
||||
<Text size="T300">
|
||||
{progress
|
||||
? `Joining rooms: ${progress.current}/${progress.total}`
|
||||
: 'Joining rooms...'}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="Primary"
|
||||
onClick={handlePrimaryAction}
|
||||
disabled={isJoining}
|
||||
before={isJoining ? <Spinner fill="Solid" variant="Primary" size="200" /> : undefined}
|
||||
aria-disabled={isJoining}
|
||||
>
|
||||
<Text size="B400">{getButtonText()}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
1
src/app/components/join-space-prompt/index.ts
Normal file
1
src/app/components/join-space-prompt/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './JoinSpacePrompt';
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -16,10 +16,85 @@ import {
|
||||
Button,
|
||||
Spinner,
|
||||
} from 'folds';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
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;
|
||||
@@ -28,11 +103,29 @@ type LeaveSpacePromptProps = {
|
||||
};
|
||||
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 () => {
|
||||
mx.leave(roomId);
|
||||
}, [mx, roomId])
|
||||
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 = () => {
|
||||
@@ -45,6 +138,13 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
|
||||
}
|
||||
}, [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>
|
||||
@@ -74,7 +174,10 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
|
||||
</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?</Text>
|
||||
<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}
|
||||
@@ -86,18 +189,11 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
|
||||
variant="Critical"
|
||||
onClick={handleLeave}
|
||||
before={
|
||||
leaveState.status === AsyncStatus.Loading ? (
|
||||
<Spinner fill="Solid" variant="Critical" size="200" />
|
||||
) : undefined
|
||||
}
|
||||
aria-disabled={
|
||||
leaveState.status === AsyncStatus.Loading ||
|
||||
leaveState.status === AsyncStatus.Success
|
||||
isLoading ? <Spinner fill="Solid" variant="Critical" size="200" /> : undefined
|
||||
}
|
||||
aria-disabled={isLoading || leaveState.status === AsyncStatus.Success}
|
||||
>
|
||||
<Text size="B400">
|
||||
{leaveState.status === AsyncStatus.Loading ? 'Leaving...' : 'Leave'}
|
||||
</Text>
|
||||
<Text size="B400">{getButtonText()}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Dialog>
|
||||
|
||||
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';
|
||||
@@ -33,6 +33,7 @@ import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
|
||||
import { getRoomAvatarUrl, getStateEvent } from '../../utils/room';
|
||||
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { JoinSpacePrompt } from '../join-space-prompt';
|
||||
|
||||
type GridColumnCount = '1' | '2' | '3';
|
||||
const getGridColumnCount = (gridWidth: number): GridColumnCount => {
|
||||
@@ -207,8 +208,32 @@ export const RoomCard = as<'div', RoomCardProps>(
|
||||
const closeTopic = () => setViewTopic(false);
|
||||
const openTopic = () => setViewTopic(true);
|
||||
|
||||
const [showSpaceJoinPrompt, setShowSpaceJoinPrompt] = useState(false);
|
||||
const isSpace = roomType === RoomType.Space || joinedRoom?.isSpaceRoom();
|
||||
|
||||
const handleJoinClick = () => {
|
||||
if (isSpace) {
|
||||
setShowSpaceJoinPrompt(true);
|
||||
} else {
|
||||
join();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSpaceJoinDone = (roomId: string) => {
|
||||
setShowSpaceJoinPrompt(false);
|
||||
onView?.(roomId);
|
||||
};
|
||||
|
||||
return (
|
||||
<RoomCardBase {...props} ref={ref}>
|
||||
{showSpaceJoinPrompt && (
|
||||
<JoinSpacePrompt
|
||||
roomIdOrAlias={roomIdOrAlias}
|
||||
viaServers={viaServers}
|
||||
onDone={handleSpaceJoinDone}
|
||||
onCancel={() => setShowSpaceJoinPrompt(false)}
|
||||
/>
|
||||
)}
|
||||
<Box gap="200" justifyContent="SpaceBetween">
|
||||
<Avatar size="500">
|
||||
<RoomAvatar
|
||||
@@ -222,7 +247,7 @@ export const RoomCard = as<'div', RoomCardProps>(
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
{(roomType === RoomType.Space || joinedRoom?.isSpaceRoom()) && (
|
||||
{isSpace && (
|
||||
<Badge variant="Secondary" fill="Soft" outlined>
|
||||
<Text size="L400">Space</Text>
|
||||
</Badge>
|
||||
@@ -269,10 +294,10 @@ export const RoomCard = as<'div', RoomCardProps>(
|
||||
)}
|
||||
{typeof joinedRoomId !== 'string' && joinState.status !== AsyncStatus.Error && (
|
||||
<Button
|
||||
onClick={join}
|
||||
onClick={handleJoinClick}
|
||||
variant="Secondary"
|
||||
size="300"
|
||||
disabled={joining}
|
||||
disabled={joining || showSpaceJoinPrompt}
|
||||
before={joining && <Spinner size="50" variant="Secondary" fill="Soft" />}
|
||||
>
|
||||
<Text size="B300" truncate>
|
||||
@@ -283,7 +308,7 @@ export const RoomCard = as<'div', RoomCardProps>(
|
||||
{typeof joinedRoomId !== 'string' && joinState.status === AsyncStatus.Error && (
|
||||
<Box gap="200">
|
||||
<Button
|
||||
onClick={join}
|
||||
onClick={handleJoinClick}
|
||||
className={css.ActionButton}
|
||||
variant="Critical"
|
||||
fill="Solid"
|
||||
|
||||
@@ -42,11 +42,26 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
if (previewStatus.status === AsyncStatus.Error) return null;
|
||||
|
||||
const renderContent = (prev: IPreviewUrlResponse) => {
|
||||
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication, 256, 256, 'scale', false);
|
||||
const ogImage = prev['og:image'] || '';
|
||||
// SVGs can't be thumbnailed by Matrix servers, load them directly
|
||||
// Also check og:image:type if available
|
||||
const isSvg =
|
||||
ogImage.toLowerCase().endsWith('.svg') ||
|
||||
prev['og:image:type'] === 'image/svg+xml';
|
||||
const imgUrl = isSvg
|
||||
? mxcUrlToHttp(mx, ogImage, useAuthentication)
|
||||
: mxcUrlToHttp(mx, ogImage, useAuthentication, 256, 256, 'scale', false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{imgUrl && <UrlPreviewImg src={imgUrl} alt={prev['og:title']} title={prev['og:title']} />}
|
||||
{imgUrl && (
|
||||
<UrlPreviewImg
|
||||
src={imgUrl}
|
||||
alt={prev['og:title']}
|
||||
title={prev['og:title']}
|
||||
style={isSvg ? { padding: '10px' } : undefined}
|
||||
/>
|
||||
)}
|
||||
<UrlPreviewContent>
|
||||
<Text
|
||||
style={linkStyles}
|
||||
|
||||
Reference in New Issue
Block a user