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}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { config, DefaultReset, color } from 'folds';
|
||||
import { config, DefaultReset, color, toRem } from 'folds';
|
||||
|
||||
export const CallOverlayContainer = style([
|
||||
DefaultReset,
|
||||
@@ -25,6 +25,53 @@ export const CallOverlayDragging = style({
|
||||
opacity: 0.95,
|
||||
});
|
||||
|
||||
export const CallOverlayDocked = style({
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: toRem(266),
|
||||
maxWidth: toRem(266),
|
||||
minWidth: toRem(266),
|
||||
borderRadius: 0,
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transform: 'none !important',
|
||||
borderLeft: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
|
||||
});
|
||||
|
||||
export const DockIndicator = style({
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: toRem(266),
|
||||
backgroundColor: color.Primary.Container,
|
||||
opacity: 0.5,
|
||||
zIndex: 9998,
|
||||
pointerEvents: 'none',
|
||||
borderLeft: `2px dashed ${color.Primary.Main}`,
|
||||
});
|
||||
|
||||
export const DockedCallPanelLayout = style({
|
||||
width: toRem(266),
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: color.Background.Container,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const DockedCallPanelContent = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
padding: config.space.S300,
|
||||
backgroundColor: color.Surface.Container,
|
||||
color: color.Surface.OnContainer,
|
||||
});
|
||||
|
||||
export const CallOverlayFullscreen = style({
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
@@ -134,6 +181,20 @@ export const ParticipantsSection = style({
|
||||
borderRadius: config.radii.R300,
|
||||
backgroundColor: color.Surface.ContainerActive,
|
||||
overflow: 'hidden',
|
||||
selectors: {
|
||||
[`${CallOverlayDocked} &`]: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'auto',
|
||||
},
|
||||
[`${CallOverlayFullscreen} &`]: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'auto',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const ParticipantsHeader = style({
|
||||
@@ -214,3 +275,39 @@ export const ParticipantAvatar = style({
|
||||
export const ParticipantSpeaking = style({
|
||||
boxShadow: `0 0 0 2px ${color.Success.Main}`,
|
||||
});
|
||||
|
||||
export const RemoteCursorOverlay = style({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
overflow: 'hidden',
|
||||
zIndex: 10,
|
||||
});
|
||||
|
||||
export const RemoteCursorDot = style({
|
||||
position: 'absolute',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 0 4px rgba(0,0,0,0.4)',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
transition: 'left 0.05s linear, top 0.05s linear, opacity 0.2s ease',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
|
||||
export const RemoteCursorLabel = style({
|
||||
position: 'absolute',
|
||||
transform: 'translate(-50%, 8px)',
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
color: 'white',
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
borderRadius: '4px',
|
||||
padding: '1px 5px',
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider, color } from 'folds';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useCall } from './useCall';
|
||||
import { useRoomCallMembers } from './useRoomCallMembers';
|
||||
import { CallState, CallType } from './types';
|
||||
@@ -10,6 +11,13 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { UnreadBadge } from '../../components/unread-badge';
|
||||
import { useRoomUnread } from '../../state/hooks/unread';
|
||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { pendingUndockDragAtom, PendingDragState } from './pendingDragState';
|
||||
import { RemoteCursorOverlay, useScreenShareCursor } from './RemoteCursorOverlay';
|
||||
import * as css from './CallOverlay.css';
|
||||
|
||||
/** Drag position state */
|
||||
@@ -18,13 +26,67 @@ interface DragPosition {
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** Custom hook for draggable functionality with viewport constraints */
|
||||
function useDraggable() {
|
||||
/** Threshold in pixels for docking detection */
|
||||
const DOCK_THRESHOLD = 50;
|
||||
|
||||
/** Custom hook for draggable functionality with viewport constraints and docking detection */
|
||||
function useDraggable(
|
||||
onDockRequest?: () => void,
|
||||
onUndockRequest?: () => void,
|
||||
isDocked?: boolean,
|
||||
pendingDrag?: PendingDragState | null,
|
||||
clearPendingDrag?: () => void
|
||||
) {
|
||||
const [position, setPosition] = useState<DragPosition>({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isNearDockZone, setIsNearDockZone] = useState(false);
|
||||
const dragRef = useRef<HTMLDivElement>(null);
|
||||
const dragStartRef = useRef<{ x: number; y: number; startX: number; startY: number } | null>(null);
|
||||
|
||||
// Check for pending drag state from undocking
|
||||
useEffect(() => {
|
||||
if (pendingDrag && Date.now() - pendingDrag.timestamp < 500) {
|
||||
// Calculate where the floating panel should be positioned
|
||||
// so the mouse is at the same relative position as when dragging started
|
||||
const panelWidth = dragRef.current?.offsetWidth ?? 400;
|
||||
const panelHeight = dragRef.current?.offsetHeight ?? 300;
|
||||
|
||||
// The floating panel renders centered (transform: translate(-50%, -50%))
|
||||
// from right: 20px, bottom: 20px by default (CSS position)
|
||||
// We need to calculate the position offset to put the panel under the mouse
|
||||
// at the same offset as when the drag started
|
||||
|
||||
// Target: mouse should be at offsetX from left edge, offsetY from top edge
|
||||
// Panel default position: right: 20px, bottom: 20px (centered via transform)
|
||||
// Default center: (window.innerWidth - 20 - panelWidth/2, window.innerHeight - 20 - panelHeight/2)
|
||||
|
||||
const defaultCenterX = window.innerWidth - 20 - panelWidth / 2;
|
||||
const defaultCenterY = window.innerHeight - 20 - panelHeight / 2;
|
||||
|
||||
// Where we want the panel's top-left corner to be:
|
||||
const targetLeft = pendingDrag.startX - pendingDrag.offsetX;
|
||||
const targetTop = pendingDrag.startY - pendingDrag.offsetY;
|
||||
|
||||
// Where the panel's top-left corner would be by default:
|
||||
const defaultLeft = defaultCenterX - panelWidth / 2;
|
||||
const defaultTop = defaultCenterY - panelHeight / 2;
|
||||
|
||||
// Calculate the offset needed
|
||||
const offsetX = targetLeft - defaultLeft;
|
||||
const offsetY = targetTop - defaultTop;
|
||||
|
||||
setPosition({ x: offsetX, y: offsetY });
|
||||
setIsDragging(true);
|
||||
dragStartRef.current = {
|
||||
x: offsetX,
|
||||
y: offsetY,
|
||||
startX: pendingDrag.startX,
|
||||
startY: pendingDrag.startY,
|
||||
};
|
||||
clearPendingDrag?.();
|
||||
}
|
||||
}, [pendingDrag, clearPendingDrag]);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
e.preventDefault();
|
||||
@@ -43,30 +105,22 @@ function useDraggable() {
|
||||
const rect = dragRef.current.getBoundingClientRect();
|
||||
const padding = 16;
|
||||
|
||||
// The element is positioned with right: S400 (16px) and top: 60px in CSS
|
||||
// Transform moves it from that base position
|
||||
// We need to ensure the element stays fully visible
|
||||
|
||||
let newX = position.x;
|
||||
let newY = position.y;
|
||||
let needsUpdate = false;
|
||||
|
||||
// Check if left edge is off screen (rect.left < padding)
|
||||
if (rect.left < padding) {
|
||||
newX = position.x + (padding - rect.left);
|
||||
needsUpdate = true;
|
||||
}
|
||||
// Check if right edge is off screen
|
||||
if (rect.right > window.innerWidth - padding) {
|
||||
newX = position.x - (rect.right - window.innerWidth + padding);
|
||||
needsUpdate = true;
|
||||
}
|
||||
// Check if top edge is off screen
|
||||
if (rect.top < padding) {
|
||||
newY = position.y + (padding - rect.top);
|
||||
needsUpdate = true;
|
||||
}
|
||||
// Check if bottom edge is off screen
|
||||
if (rect.bottom > window.innerHeight - padding) {
|
||||
newY = position.y - (rect.bottom - window.innerHeight + padding);
|
||||
needsUpdate = true;
|
||||
@@ -77,7 +131,6 @@ function useDraggable() {
|
||||
}
|
||||
}, [position]);
|
||||
|
||||
// Listen for window resize to keep element in bounds
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
clampToViewport();
|
||||
@@ -91,24 +144,36 @@ function useDraggable() {
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!dragStartRef.current || !dragRef.current) return;
|
||||
|
||||
// Check if near right edge for docking
|
||||
const nearRightEdge = e.clientX > window.innerWidth - DOCK_THRESHOLD;
|
||||
setIsNearDockZone(nearRightEdge && !isDocked);
|
||||
|
||||
// If docked and dragging away from edge, trigger undock
|
||||
if (isDocked) {
|
||||
const dragDistance = Math.abs(e.clientX - dragStartRef.current.startX);
|
||||
if (dragDistance > DOCK_THRESHOLD) {
|
||||
onUndockRequest?.();
|
||||
setIsDragging(false);
|
||||
dragStartRef.current = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const deltaX = e.clientX - dragStartRef.current.startX;
|
||||
const deltaY = e.clientY - dragStartRef.current.startY;
|
||||
|
||||
// Get element dimensions for bounds checking
|
||||
const rect = dragRef.current.getBoundingClientRect();
|
||||
const padding = 16;
|
||||
|
||||
// Calculate new position
|
||||
let newX = dragStartRef.current.x + deltaX;
|
||||
let newY = dragStartRef.current.y + deltaY;
|
||||
|
||||
// Calculate where the element would be with the new transform
|
||||
const newLeft = rect.left - position.x + newX;
|
||||
const newRight = rect.right - position.x + newX;
|
||||
const newTop = rect.top - position.y + newY;
|
||||
const newBottom = rect.bottom - position.y + newY;
|
||||
|
||||
// Clamp to keep fully on screen
|
||||
if (newLeft < padding) {
|
||||
newX += padding - newLeft;
|
||||
}
|
||||
@@ -125,8 +190,13 @@ function useDraggable() {
|
||||
setPosition({ x: newX, y: newY });
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
// Check if should dock on mouse release
|
||||
if (!isDocked && e.clientX > window.innerWidth - DOCK_THRESHOLD) {
|
||||
onDockRequest?.();
|
||||
}
|
||||
setIsDragging(false);
|
||||
setIsNearDockZone(false);
|
||||
dragStartRef.current = null;
|
||||
};
|
||||
|
||||
@@ -137,9 +207,9 @@ function useDraggable() {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, position]);
|
||||
}, [isDragging, position, isDocked, onDockRequest, onUndockRequest]);
|
||||
|
||||
return { position, isDragging, handleMouseDown, dragRef };
|
||||
return { position, isDragging, isNearDockZone, handleMouseDown, dragRef };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,17 +273,26 @@ function formatDuration(seconds: number): string {
|
||||
}
|
||||
|
||||
/** Screen share entry type */
|
||||
interface ScreenShareEntry {
|
||||
export interface ScreenShareEntry {
|
||||
/** Unique identifier for the screen share */
|
||||
id: string;
|
||||
/** Whether this is the local user's screen share */
|
||||
isLocal: boolean;
|
||||
/** Matrix user ID of the participant sharing */
|
||||
participantId: string;
|
||||
/** Room ID the call is in (for cursor resolution) */
|
||||
roomId: string;
|
||||
/** LiveKit participant identity */
|
||||
livekitId?: string;
|
||||
/** The underlying track (LiveKit RemoteTrack) */
|
||||
track?: unknown;
|
||||
/** The MediaStream for the screen share */
|
||||
stream?: MediaStream;
|
||||
}
|
||||
|
||||
/** Props for ScreenShareItem component */
|
||||
interface ScreenShareItemProps {
|
||||
/** The screen share entry to display */
|
||||
share: ScreenShareEntry;
|
||||
}
|
||||
|
||||
@@ -221,8 +300,9 @@ interface ScreenShareItemProps {
|
||||
* Individual screen share display component
|
||||
* Handles attaching the video stream/track properly
|
||||
*/
|
||||
function ScreenShareItem({ share }: ScreenShareItemProps) {
|
||||
export function ScreenShareItem({ share }: ScreenShareItemProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { handleMouseMove, handleMouseLeave } = useScreenShareCursor(share.participantId);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -281,7 +361,6 @@ function ScreenShareItem({ share }: ScreenShareItemProps) {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// Find the video element inside the container
|
||||
const videoEl = container.querySelector('video');
|
||||
if (!videoEl) return;
|
||||
|
||||
@@ -297,14 +376,17 @@ function ScreenShareItem({ share }: ScreenShareItemProps) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={css.ScreenShareContainer}>
|
||||
<div className={css.ScreenShareContainer} style={{ position: 'relative' }}>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={css.ScreenShareVideoContainer}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
<RemoteCursorOverlay shareId={share.participantId} roomId={share.roomId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -326,9 +408,37 @@ export function CallOverlay() {
|
||||
const localVideoRef = useRef<HTMLVideoElement>(null);
|
||||
const remoteVideoRef = useRef<HTMLVideoElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { position, isDragging, handleMouseDown, dragRef } = useDraggable();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
// Docking state
|
||||
const [isDocked, setIsDocked] = useSetting(settingsAtom, 'isCallPanelDocked');
|
||||
|
||||
// Pending drag state from undocking
|
||||
const [pendingDrag, setPendingDrag] = useAtom(pendingUndockDragAtom);
|
||||
|
||||
const clearPendingDrag = useCallback(() => {
|
||||
setPendingDrag(null);
|
||||
}, [setPendingDrag]);
|
||||
|
||||
const handleDockRequest = useCallback(() => {
|
||||
setIsDocked(true);
|
||||
}, [setIsDocked]);
|
||||
|
||||
const handleUndockRequest = useCallback(() => {
|
||||
setIsDocked(false);
|
||||
}, [setIsDocked]);
|
||||
|
||||
const { position, isDragging, isNearDockZone, handleMouseDown, dragRef } = useDraggable(
|
||||
handleDockRequest,
|
||||
handleUndockRequest,
|
||||
isDocked,
|
||||
pendingDrag,
|
||||
clearPendingDrag
|
||||
);
|
||||
|
||||
// Get unread notifications for the call room
|
||||
const unread = useRoomUnread(activeCall?.roomId ?? '', roomToUnreadAtom);
|
||||
|
||||
// Get call members from Matrix state events (provides real Matrix user IDs)
|
||||
const { callMembers } = useRoomCallMembers(activeCall?.roomId ?? '');
|
||||
const myUserId = mx.getUserId() ?? '';
|
||||
@@ -343,6 +453,7 @@ export function CallOverlay() {
|
||||
id: 'local',
|
||||
isLocal: true,
|
||||
participantId: myUserId,
|
||||
roomId: activeCall.roomId,
|
||||
stream: activeCall.localScreenStream,
|
||||
});
|
||||
}
|
||||
@@ -366,6 +477,7 @@ export function CallOverlay() {
|
||||
id: livekitId,
|
||||
isLocal: false,
|
||||
participantId: matrixUserId,
|
||||
roomId: activeCall?.roomId ?? '',
|
||||
livekitId,
|
||||
track: trackInfo.track,
|
||||
stream: trackInfo.stream,
|
||||
@@ -373,7 +485,7 @@ export function CallOverlay() {
|
||||
});
|
||||
|
||||
return shares;
|
||||
}, [activeCall?.isScreenSharing, activeCall?.localScreenStream, activeCall?.remoteScreenTracks, callMembers, myUserId]);
|
||||
}, [activeCall?.isScreenSharing, activeCall?.localScreenStream, activeCall?.remoteScreenTracks, activeCall?.roomId, callMembers, myUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeCall || activeCall.state !== CallState.Connected) {
|
||||
@@ -482,6 +594,11 @@ export function CallOverlay() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If docked and not fullscreen, don't render as overlay - it's rendered in the layout instead
|
||||
if (isDocked && !isFullscreen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isConnecting = activeCall.state === CallState.Connecting;
|
||||
const isVideoCall = activeCall.callType === CallType.Video;
|
||||
|
||||
@@ -583,15 +700,26 @@ export function CallOverlay() {
|
||||
}
|
||||
};
|
||||
|
||||
// Determine container class based on state
|
||||
const containerClass = [
|
||||
css.CallOverlayContainer,
|
||||
isFullscreen && css.CallOverlayFullscreen,
|
||||
isDocked && !isFullscreen && css.CallOverlayDocked,
|
||||
isDragging && css.CallOverlayDragging,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setContainerRef}
|
||||
className={`${css.CallOverlayContainer} ${isFullscreen ? css.CallOverlayFullscreen : ''} ${isDragging ? css.CallOverlayDragging : ''}`}
|
||||
style={!isFullscreen ? {
|
||||
transform: `translate(${position.x}px, ${position.y}px)`,
|
||||
} : undefined}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<>
|
||||
{/* Dock indicator shown when dragging near the right edge */}
|
||||
{isNearDockZone && !isDocked && <div className={css.DockIndicator} />}
|
||||
<div
|
||||
ref={setContainerRef}
|
||||
className={containerClass}
|
||||
style={!isFullscreen && !isDocked ? {
|
||||
transform: `translate(${position.x}px, ${position.y}px)`,
|
||||
} : undefined}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className={css.CallOverlayHeader}
|
||||
onMouseDown={handleMouseDown}
|
||||
@@ -621,6 +749,9 @@ export function CallOverlay() {
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</Text>
|
||||
{unread && unread.total > 0 && (
|
||||
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
|
||||
)}
|
||||
{selectedRoomId !== activeCall.roomId && (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
@@ -931,5 +1062,6 @@ export function CallOverlay() {
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
455
src/app/features/call/DockedCallContent.tsx
Normal file
455
src/app/features/call/DockedCallContent.tsx
Normal file
@@ -0,0 +1,455 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider, Scroll, color } from 'folds';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useCall } from './useCall';
|
||||
import { ScreenShareItem, ScreenShareEntry } from './CallOverlay';
|
||||
import { useRoomCallMembers } from './useRoomCallMembers';
|
||||
import { CallState, CallType } from './types';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { getMemberDisplayName, getMemberAvatarMxc } from '../../utils/room';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { UnreadBadge } from '../../components/unread-badge';
|
||||
import { useRoomUnread } from '../../state/hooks/unread';
|
||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { pendingUndockDragAtom } from './pendingDragState';
|
||||
import * as css from './CallOverlay.css';
|
||||
|
||||
/**
|
||||
* Formats call duration in MM:SS format
|
||||
*/
|
||||
function formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Docked call panel content - renders call UI in the layout flow
|
||||
* This is used when the call panel is docked to the right side
|
||||
*/
|
||||
/**
|
||||
* Custom Screen share icon SVG (Phosphor-style)
|
||||
*/
|
||||
function ScreenShareIcon() {
|
||||
return (
|
||||
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 16V8a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h5v2H8v2h8v-2h-2v-2h5a2 2 0 0 0 2-2zm-2 0H5V8h14v8z" />
|
||||
<path d="M12 9l-4 4h3v3h2v-3h3l-4-4z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom Screen share off icon SVG (Phosphor-style)
|
||||
*/
|
||||
function ScreenShareOffIcon() {
|
||||
return (
|
||||
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 16V8a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h5v2H8v2h8v-2h-2v-2h5a2 2 0 0 0 2-2zm-2 0H5V8h14v8z" />
|
||||
<path d="M2 4l20 16" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DockedCallContent() {
|
||||
const { activeCall, endCall, toggleMute, toggleDeafen, toggleScreenShare } = useCall();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const selectedRoomId = useSelectedRoom();
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isTogglingScreenShare, setIsTogglingScreenShare] = useState(false);
|
||||
const [showParticipants, setShowParticipants] = useState(true);
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const [, setIsDocked] = useSetting(settingsAtom, 'isCallPanelDocked');
|
||||
const setPendingDrag = useSetAtom(pendingUndockDragAtom);
|
||||
|
||||
const unread = useRoomUnread(activeCall?.roomId ?? '', roomToUnreadAtom);
|
||||
const { callMembers } = useRoomCallMembers(activeCall?.roomId ?? '');
|
||||
const myUserId = mx.getUserId() ?? '';
|
||||
|
||||
/** Memoized list of active screen shares (local + remote) */
|
||||
const allScreenShares = useMemo((): ScreenShareEntry[] => {
|
||||
const shares: ScreenShareEntry[] = [];
|
||||
|
||||
if (activeCall?.isScreenSharing && activeCall?.localScreenStream) {
|
||||
shares.push({
|
||||
id: 'local',
|
||||
isLocal: true,
|
||||
participantId: myUserId,
|
||||
roomId: activeCall.roomId,
|
||||
stream: activeCall.localScreenStream,
|
||||
});
|
||||
}
|
||||
|
||||
const remoteScreenTracks = activeCall?.remoteScreenTracks
|
||||
? Array.from(activeCall.remoteScreenTracks.values())
|
||||
: [];
|
||||
|
||||
remoteScreenTracks.forEach((trackInfo) => {
|
||||
const livekitId = trackInfo.participantId;
|
||||
const underscoreIndex = livekitId.lastIndexOf('_');
|
||||
const possibleDeviceId = underscoreIndex > 0 ? livekitId.substring(0, underscoreIndex) : livekitId;
|
||||
const matchingMember = callMembers.find((m) => m.deviceId === possibleDeviceId);
|
||||
const matrixUserId = matchingMember?.userId ?? livekitId;
|
||||
|
||||
shares.push({
|
||||
id: livekitId,
|
||||
isLocal: false,
|
||||
participantId: matrixUserId,
|
||||
roomId: activeCall?.roomId ?? '',
|
||||
livekitId,
|
||||
track: trackInfo.track,
|
||||
stream: trackInfo.stream,
|
||||
});
|
||||
});
|
||||
|
||||
return shares;
|
||||
}, [activeCall?.isScreenSharing, activeCall?.localScreenStream, activeCall?.remoteScreenTracks, activeCall?.roomId, callMembers, myUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeCall || activeCall.state !== CallState.Connected) {
|
||||
setDuration(0);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { startTime } = activeCall;
|
||||
const updateDuration = () => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
setDuration(elapsed);
|
||||
};
|
||||
|
||||
updateDuration();
|
||||
const interval = setInterval(updateDuration, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [activeCall]);
|
||||
|
||||
/**
|
||||
* Checks if a participant is currently speaking
|
||||
*/
|
||||
const isParticipantSpeaking = (userId: string): boolean => {
|
||||
if (!activeCall) return false;
|
||||
if (activeCall.activeSpeakers.has(userId)) return true;
|
||||
|
||||
const member = callMembers.find((m) => m.userId === userId);
|
||||
if (!member) return false;
|
||||
|
||||
return Array.from(activeCall.activeSpeakers).some((speakerId) => {
|
||||
const underscoreIndex = speakerId.lastIndexOf('_');
|
||||
const deviceId = underscoreIndex > 0 ? speakerId.substring(0, underscoreIndex) : speakerId;
|
||||
return deviceId === member.deviceId;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a participant is currently muted
|
||||
*/
|
||||
const isParticipantMuted = (userId: string): boolean => {
|
||||
if (!activeCall) return false;
|
||||
if (userId === myUserId) return activeCall.isMuted;
|
||||
|
||||
const member = callMembers.find((m) => m.userId === userId);
|
||||
if (!member) return false;
|
||||
|
||||
return Array.from(activeCall.mutedParticipants).some((mutedId) => {
|
||||
const underscoreIndex = mutedId.lastIndexOf('_');
|
||||
const deviceId = underscoreIndex > 0 ? mutedId.substring(0, underscoreIndex) : mutedId;
|
||||
return deviceId === member.deviceId;
|
||||
});
|
||||
};
|
||||
|
||||
const handleEndCall = () => {
|
||||
endCall();
|
||||
};
|
||||
|
||||
const handleToggleMute = () => {
|
||||
toggleMute();
|
||||
};
|
||||
|
||||
const handleToggleDeafen = () => {
|
||||
toggleDeafen();
|
||||
};
|
||||
|
||||
const handleToggleScreenShare = async () => {
|
||||
if (isTogglingScreenShare) return;
|
||||
setIsTogglingScreenShare(true);
|
||||
try {
|
||||
await toggleScreenShare();
|
||||
} finally {
|
||||
setIsTogglingScreenShare(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle mousedown on header to start drag-to-undock
|
||||
* Sets pending drag state so the floating overlay can continue the drag
|
||||
*/
|
||||
const handleHeaderMouseDown = async (e: React.MouseEvent) => {
|
||||
// Don't start drag if clicking a button
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
|
||||
// Exit browser fullscreen if active before undocking
|
||||
if (document.fullscreenElement) {
|
||||
try {
|
||||
await document.exitFullscreen();
|
||||
} catch {
|
||||
// Fullscreen exit may fail
|
||||
}
|
||||
}
|
||||
|
||||
// Get the header element to calculate offset
|
||||
const header = e.currentTarget as HTMLElement;
|
||||
const rect = header.getBoundingClientRect();
|
||||
|
||||
// Store the drag start position with mouse offset from panel edges
|
||||
setPendingDrag({
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
offsetX: e.clientX - rect.left,
|
||||
offsetY: e.clientY - rect.top,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Undock immediately - the floating overlay will pick up the drag
|
||||
setIsDocked(false);
|
||||
};
|
||||
|
||||
if (!activeCall || activeCall.state === CallState.Idle || activeCall.state === CallState.Ended) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isConnecting = activeCall.state === CallState.Connecting;
|
||||
const isVideoCall = activeCall.callType === CallType.Video;
|
||||
const room = mx.getRoom(activeCall.roomId);
|
||||
const allParticipants = callMembers.map((m) => m.userId);
|
||||
const participantCount = allParticipants.length;
|
||||
|
||||
return (
|
||||
<div className={css.DockedCallPanelContent}>
|
||||
{/* Header - drag to undock */}
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className={css.CallOverlayHeader}
|
||||
onMouseDown={handleHeaderMouseDown}
|
||||
style={{ cursor: 'grab' }}
|
||||
>
|
||||
<Box alignItems="Center" gap="200" style={{ minWidth: 0, flex: 1 }}>
|
||||
<Icon
|
||||
size="200"
|
||||
src={isVideoCall ? Icons.VideoCamera : Icons.Phone}
|
||||
/>
|
||||
<Text size="T300" truncate>
|
||||
{isConnecting ? 'Connecting...' : room?.name ?? 'Unknown Room'}
|
||||
{selectedRoomId === activeCall.roomId && (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
offset={4}
|
||||
tooltip={<Tooltip><Text>Current Room</Text></Tooltip>}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<Icon
|
||||
ref={triggerRef}
|
||||
size="100"
|
||||
src={Icons.Check}
|
||||
style={{ marginLeft: '4px', opacity: 0.7, verticalAlign: 'middle' }}
|
||||
/>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</Text>
|
||||
{unread && unread.total > 0 && (
|
||||
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
|
||||
)}
|
||||
</Box>
|
||||
<Box alignItems="Center" gap="100">
|
||||
<Text size="T200" className={css.CallDuration}>
|
||||
{formatDuration(duration)}
|
||||
</Text>
|
||||
{selectedRoomId !== activeCall.roomId && (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
offset={4}
|
||||
tooltip={<Tooltip><Text>Go to Room</Text></Tooltip>}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
onClick={() => navigateRoom(activeCall.roomId)}
|
||||
variant="Secondary"
|
||||
size="300"
|
||||
>
|
||||
<Icon size="100" src={Icons.Link} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
{/* Participants */}
|
||||
<Box direction="Column" grow="Yes" style={{ overflow: 'hidden' }}>
|
||||
<div className={css.ParticipantsSection} style={{ flex: 1 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.ParticipantsHeader}
|
||||
onClick={() => setShowParticipants(!showParticipants)}
|
||||
>
|
||||
<Text size="T200" className={css.ParticipantsHeaderText}>
|
||||
Participants ({participantCount})
|
||||
</Text>
|
||||
<Icon
|
||||
size="100"
|
||||
src={showParticipants ? Icons.ChevronTop : Icons.ChevronBottom}
|
||||
style={{ opacity: 0.7 }}
|
||||
/>
|
||||
</button>
|
||||
{showParticipants && room && (
|
||||
<Scroll hideTrack visibility="Hover" style={{ maxHeight: '100%' }}>
|
||||
<div className={css.ParticipantsList}>
|
||||
{allParticipants.map((participantId) => {
|
||||
const avatarMxc = getMemberAvatarMxc(room, participantId);
|
||||
const avatarUrl = avatarMxc
|
||||
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 32, 32, 'crop') ?? undefined
|
||||
: undefined;
|
||||
const displayName = getMemberDisplayName(room, participantId)
|
||||
?? getMxIdLocalPart(participantId)
|
||||
?? participantId;
|
||||
const isMe = participantId === myUserId;
|
||||
const isMuted = isParticipantMuted(participantId);
|
||||
const isSpeaking = isParticipantSpeaking(participantId);
|
||||
|
||||
return (
|
||||
<div key={participantId} className={css.ParticipantItem}>
|
||||
<div
|
||||
className={`${css.ParticipantAvatarWrapper} ${isSpeaking ? css.ParticipantSpeaking : ''}`}
|
||||
>
|
||||
<UserAvatar
|
||||
className={css.ParticipantAvatar}
|
||||
userId={participantId}
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
renderFallback={() => (
|
||||
<Text size="H6" style={{ color: color.Surface.Container }}>
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Box direction="Column" style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="T300" className={css.ParticipantName} truncate>
|
||||
{displayName}
|
||||
{isMe && <span className={css.YouBadge}> (You)</span>}
|
||||
</Text>
|
||||
</Box>
|
||||
{isMuted && (
|
||||
<Icon
|
||||
size="100"
|
||||
src={Icons.MicMute}
|
||||
className={css.ParticipantMutedIcon}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Scroll>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{/* Screen shares */}
|
||||
{allScreenShares.length > 0 && (
|
||||
<Box direction="Column" gap="200" style={{ padding: '0 8px 8px' }}>
|
||||
{allScreenShares.map((share) => (
|
||||
<ScreenShareItem key={share.id} share={share} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div className={css.CallOverlayControls}>
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
offset={4}
|
||||
tooltip={<Tooltip><Text>{activeCall.isMuted ? 'Unmute' : 'Mute'}</Text></Tooltip>}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
onClick={handleToggleMute}
|
||||
variant={activeCall.isMuted ? 'Critical' : 'Secondary'}
|
||||
>
|
||||
<Icon size="300" src={activeCall.isMuted ? Icons.MicMute : Icons.Mic} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
offset={4}
|
||||
tooltip={<Tooltip><Text>{activeCall.isDeafened ? 'Undeafen' : 'Deafen'}</Text></Tooltip>}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
onClick={handleToggleDeafen}
|
||||
variant={activeCall.isDeafened ? 'Critical' : 'Secondary'}
|
||||
>
|
||||
<Icon size="300" src={activeCall.isDeafened ? Icons.VolumeMute : Icons.VolumeHigh} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
offset={4}
|
||||
tooltip={<Tooltip><Text>{activeCall.isScreenSharing ? 'Stop Screen Share' : 'Share Screen'}</Text></Tooltip>}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
onClick={handleToggleScreenShare}
|
||||
variant={activeCall.isScreenSharing ? 'Primary' : 'Secondary'}
|
||||
disabled={isTogglingScreenShare}
|
||||
>
|
||||
<Box
|
||||
as="span"
|
||||
style={{
|
||||
width: '1.5rem',
|
||||
height: '1.5rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{activeCall.isScreenSharing ? <ScreenShareOffIcon /> : <ScreenShareIcon />}
|
||||
</Box>
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
offset={4}
|
||||
tooltip={<Tooltip><Text>End Call</Text></Tooltip>}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
onClick={handleEndCall}
|
||||
variant="Critical"
|
||||
>
|
||||
<Icon size="300" src={Icons.Phone} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
src/app/features/call/DockedCallPanel.tsx
Normal file
26
src/app/features/call/DockedCallPanel.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { Box, Line } from 'folds';
|
||||
import { useIsCallDocked } from './useDockedCall';
|
||||
import { DockedCallContent } from './DockedCallContent';
|
||||
import * as css from './CallOverlay.css';
|
||||
|
||||
/**
|
||||
* Component that renders the docked call panel in the layout flow
|
||||
* Only renders when there's an active call AND docking is enabled
|
||||
*/
|
||||
export function DockedCallPanel() {
|
||||
const isCallDocked = useIsCallDocked();
|
||||
|
||||
if (!isCallDocked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
<Box shrink="No" className={css.DockedCallPanelLayout}>
|
||||
<DockedCallContent />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
132
src/app/features/call/RemoteCursorOverlay.tsx
Normal file
132
src/app/features/call/RemoteCursorOverlay.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { useRoomCallMembers } from './useRoomCallMembers';
|
||||
import { useCall } from './useCall';
|
||||
import { useSendCursor, useReceiveCursors, RemoteCursorState } from './useRemoteCursor';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import * as css from './CallOverlay.css';
|
||||
|
||||
/** Props for RemoteCursorOverlay */
|
||||
interface RemoteCursorOverlayProps {
|
||||
/** The screen share ID to track cursors for */
|
||||
shareId: string;
|
||||
/** The room ID of the active call */
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a LiveKit participant identity to a Matrix user ID
|
||||
* LiveKit identities are typically in the format "deviceId_timestamp"
|
||||
*/
|
||||
function resolveParticipantUserId(
|
||||
livekitIdentity: string,
|
||||
callMembers: { userId: string; deviceId: string }[]
|
||||
): string {
|
||||
const underscoreIndex = livekitIdentity.lastIndexOf('_');
|
||||
const possibleDeviceId =
|
||||
underscoreIndex > 0 ? livekitIdentity.substring(0, underscoreIndex) : livekitIdentity;
|
||||
|
||||
const match = callMembers.find((m) => m.deviceId === possibleDeviceId);
|
||||
return match?.userId ?? livekitIdentity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single remote cursor dot with label
|
||||
*/
|
||||
function CursorDot({
|
||||
cursor,
|
||||
displayName,
|
||||
cursorColor,
|
||||
}: {
|
||||
cursor: RemoteCursorState;
|
||||
displayName: string;
|
||||
cursorColor: string;
|
||||
}) {
|
||||
if (!cursor.visible) return null;
|
||||
|
||||
const left = `${cursor.x * 100}%`;
|
||||
const top = `${cursor.y * 100}%`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={css.RemoteCursorDot}
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
backgroundColor: cursorColor,
|
||||
opacity: cursor.visible ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={css.RemoteCursorLabel}
|
||||
style={{ left, top, color: 'white' }}
|
||||
>
|
||||
{displayName}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay that renders remote cursor positions on top of a screen share
|
||||
* Also provides mouse event handlers for sending local cursor position
|
||||
*/
|
||||
export function RemoteCursorOverlay({ shareId, roomId }: RemoteCursorOverlayProps) {
|
||||
const mx = useMatrixClient();
|
||||
const { callMembers } = useRoomCallMembers(roomId);
|
||||
const { activeCall } = useCall();
|
||||
const cursors = useReceiveCursors(shareId);
|
||||
const room = mx.getRoom(roomId);
|
||||
const myUserId = mx.getUserId() ?? '';
|
||||
|
||||
/**
|
||||
* Gets a display name for a LiveKit participant
|
||||
*/
|
||||
const getDisplayName = (livekitIdentity: string): string => {
|
||||
const userId = resolveParticipantUserId(livekitIdentity, callMembers);
|
||||
if (!room) return getMxIdLocalPart(userId) ?? userId;
|
||||
return getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||
};
|
||||
|
||||
// Filter out our own cursor and only show visible cursors
|
||||
const myIdentity = activeCall?.participants.find((p) => {
|
||||
const underscoreIndex = p.lastIndexOf('_');
|
||||
const deviceId = underscoreIndex > 0 ? p.substring(0, underscoreIndex) : p;
|
||||
const member = callMembers.find((m) => m.deviceId === deviceId);
|
||||
return member?.userId === myUserId;
|
||||
});
|
||||
|
||||
const remoteCursors = Array.from(cursors.entries()).filter(
|
||||
([participantId]) => participantId !== myIdentity
|
||||
);
|
||||
|
||||
if (remoteCursors.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={css.RemoteCursorOverlay}>
|
||||
{remoteCursors.map(([participantId, cursor]) => {
|
||||
const userId = resolveParticipantUserId(participantId, callMembers);
|
||||
return (
|
||||
<CursorDot
|
||||
key={participantId}
|
||||
cursor={cursor}
|
||||
displayName={getDisplayName(participantId)}
|
||||
cursorColor={colorMXID(userId)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper that provides mouse event handlers for cursor sending on a screenshare
|
||||
* @param shareId - The screen share ID
|
||||
* @returns Mouse event handlers to attach to the container
|
||||
*/
|
||||
export function useScreenShareCursor(shareId: string) {
|
||||
return useSendCursor(shareId);
|
||||
}
|
||||
@@ -7,3 +7,6 @@ export * from './CallProviderWrapper';
|
||||
export * from './CallOverlay';
|
||||
export * from './IncomingCallNotification';
|
||||
export * from './useRoomCallMembers';
|
||||
export * from './useDockedCall';
|
||||
export * from './DockedCallPanel';
|
||||
export * from './DockedCallContent';
|
||||
|
||||
21
src/app/features/call/pendingDragState.ts
Normal file
21
src/app/features/call/pendingDragState.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
/**
|
||||
* Atom to store pending drag state when transitioning from docked to floating
|
||||
* When the user starts dragging the docked panel header, we store the mouse position
|
||||
* so the floating overlay can continue the drag seamlessly
|
||||
*/
|
||||
export interface PendingDragState {
|
||||
/** Mouse X position when drag started */
|
||||
startX: number;
|
||||
/** Mouse Y position when drag started */
|
||||
startY: number;
|
||||
/** Offset from panel left edge to mouse X */
|
||||
offsetX: number;
|
||||
/** Offset from panel top edge to mouse Y */
|
||||
offsetY: number;
|
||||
/** Timestamp to prevent stale drag states */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export const pendingUndockDragAtom = atom<PendingDragState | null>(null);
|
||||
18
src/app/features/call/useDockedCall.ts
Normal file
18
src/app/features/call/useDockedCall.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useCall } from './useCall';
|
||||
import { CallState } from './types';
|
||||
|
||||
/**
|
||||
* Hook to determine if the call panel should be rendered docked
|
||||
* Returns true if there's an active call AND the panel is set to docked mode
|
||||
*/
|
||||
export function useIsCallDocked(): boolean {
|
||||
const { activeCall } = useCall();
|
||||
const [isDocked] = useSetting(settingsAtom, 'isCallPanelDocked');
|
||||
|
||||
const hasActiveCall = activeCall &&
|
||||
(activeCall.state === CallState.Connecting || activeCall.state === CallState.Connected);
|
||||
|
||||
return Boolean(hasActiveCall && isDocked);
|
||||
}
|
||||
187
src/app/features/call/useRemoteCursor.ts
Normal file
187
src/app/features/call/useRemoteCursor.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { RoomEvent } from 'livekit-client';
|
||||
import { useCall } from './useCall';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
|
||||
/** Data topic for cursor position messages */
|
||||
const CURSOR_TOPIC = 'remote-cursor';
|
||||
|
||||
/** How often to send cursor updates (ms) */
|
||||
const CURSOR_SEND_INTERVAL = 50;
|
||||
|
||||
/** How long before a cursor is considered stale and hidden (ms) */
|
||||
const CURSOR_STALE_TIMEOUT = 3000;
|
||||
|
||||
/** Cursor position data sent over the data channel */
|
||||
export interface CursorData {
|
||||
/** Message type identifier */
|
||||
type: 'cursor';
|
||||
/** Normalized X position (0–1) */
|
||||
x: number;
|
||||
/** Normalized Y position (0–1) */
|
||||
y: number;
|
||||
/** Whether the cursor is visible (false = user moved mouse away) */
|
||||
visible: boolean;
|
||||
/** Screen share participant ID (Matrix user ID) this cursor relates to */
|
||||
shareId: string;
|
||||
}
|
||||
|
||||
/** Remote cursor state for rendering */
|
||||
export interface RemoteCursorState {
|
||||
/** LiveKit participant identity */
|
||||
participantId: string;
|
||||
/** Normalized X position (0–1) */
|
||||
x: number;
|
||||
/** Normalized Y position (0–1) */
|
||||
y: number;
|
||||
/** Whether the cursor is currently visible */
|
||||
visible: boolean;
|
||||
/** Timestamp of last update */
|
||||
lastUpdate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to send local cursor position over the LiveKit data channel
|
||||
* Tracks mouse movement over a screenshare container and broadcasts position
|
||||
* @param shareId - The screen share ID to associate cursor data with
|
||||
* @returns Object with onMouseMove/onMouseLeave handlers to attach to the container
|
||||
*/
|
||||
export function useSendCursor(shareId: string) {
|
||||
const { callService } = useCall();
|
||||
const [showRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor');
|
||||
const lastSendRef = useRef(0);
|
||||
const encoder = useRef(new TextEncoder());
|
||||
|
||||
const sendCursorData = useCallback(
|
||||
(data: Omit<CursorData, 'type'>) => {
|
||||
const room = callService?.getLiveKitRoom();
|
||||
if (!room?.localParticipant) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (!data.visible || now - lastSendRef.current >= CURSOR_SEND_INTERVAL) {
|
||||
lastSendRef.current = now;
|
||||
const message: CursorData = { ...data, type: 'cursor' };
|
||||
const payload = encoder.current.encode(JSON.stringify(message));
|
||||
room.localParticipant
|
||||
.publishData(payload, { reliable: false, topic: CURSOR_TOPIC })
|
||||
.catch(() => {
|
||||
// Data publish can fail silently
|
||||
});
|
||||
}
|
||||
},
|
||||
[callService]
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle mouse movement over a screenshare container
|
||||
* Calculates normalized position and sends to remote participants
|
||||
*/
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!showRemoteCursor) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const y = (e.clientY - rect.top) / rect.height;
|
||||
|
||||
sendCursorData({ x, y, visible: true, shareId });
|
||||
},
|
||||
[showRemoteCursor, sendCursorData, shareId]
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle mouse leaving the screenshare container
|
||||
* Sends a hidden cursor update so remote stops showing cursor
|
||||
*/
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (!showRemoteCursor) return;
|
||||
sendCursorData({ x: 0, y: 0, visible: false, shareId });
|
||||
}, [showRemoteCursor, sendCursorData, shareId]);
|
||||
|
||||
return { handleMouseMove, handleMouseLeave };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to receive remote cursor positions from the LiveKit data channel
|
||||
* Listens for cursor data from other participants and provides state for rendering
|
||||
* @param shareId - The screen share ID to filter cursor data for
|
||||
* @returns Map of participant IDs to their cursor states
|
||||
*/
|
||||
export function useReceiveCursors(shareId: string): Map<string, RemoteCursorState> {
|
||||
const { callService } = useCall();
|
||||
const [showRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor');
|
||||
const [cursors, setCursors] = useState<Map<string, RemoteCursorState>>(new Map());
|
||||
const decoder = useRef(new TextDecoder());
|
||||
|
||||
useEffect(() => {
|
||||
if (!showRemoteCursor) {
|
||||
setCursors(new Map());
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const room = callService?.getLiveKitRoom();
|
||||
if (!room) return undefined;
|
||||
|
||||
const handleDataReceived = (
|
||||
payload: Uint8Array,
|
||||
participant?: { identity: string },
|
||||
_kind?: unknown,
|
||||
topic?: string
|
||||
) => {
|
||||
if (!participant) return;
|
||||
// Only process cursor messages by topic
|
||||
if (topic !== undefined && topic !== CURSOR_TOPIC) return;
|
||||
|
||||
try {
|
||||
const text = decoder.current.decode(payload);
|
||||
const data = JSON.parse(text) as CursorData;
|
||||
|
||||
// Validate this is actually a cursor message
|
||||
if (data.type !== 'cursor') return;
|
||||
if (data.shareId !== shareId) return;
|
||||
|
||||
setCursors((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(participant.identity, {
|
||||
participantId: participant.identity,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
visible: data.visible,
|
||||
lastUpdate: Date.now(),
|
||||
});
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// Ignore malformed data
|
||||
}
|
||||
};
|
||||
|
||||
room.on(RoomEvent.DataReceived, handleDataReceived);
|
||||
|
||||
// Periodically clean up stale cursors
|
||||
const cleanupInterval = setInterval(() => {
|
||||
setCursors((prev) => {
|
||||
const now = Date.now();
|
||||
let changed = false;
|
||||
const next = new Map(prev);
|
||||
|
||||
next.forEach((cursor, key) => {
|
||||
if (now - cursor.lastUpdate > CURSOR_STALE_TIMEOUT) {
|
||||
next.delete(key);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
room.off(RoomEvent.DataReceived, handleDataReceived);
|
||||
clearInterval(cleanupInterval);
|
||||
};
|
||||
}, [callService, showRemoteCursor, shareId]);
|
||||
|
||||
return cursors;
|
||||
}
|
||||
315
src/app/features/common-settings/general/EmbedFilters.tsx
Normal file
315
src/app/features/common-settings/general/EmbedFilters.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import React, { FormEventHandler, useCallback, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
color,
|
||||
config,
|
||||
Icon,
|
||||
Icons,
|
||||
Input,
|
||||
Spinner,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
} from 'folds';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import {
|
||||
useRoomEmbedFilters,
|
||||
useRoomWideEmbedFilters,
|
||||
useSetRoomWideEmbedFilters,
|
||||
EmbedFiltersContent,
|
||||
} from '../../../hooks/useRoomEmbedFilters';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
|
||||
/**
|
||||
* Validates that a string is a valid regex pattern.
|
||||
* @param pattern - The pattern to validate
|
||||
* @returns True if the pattern is valid
|
||||
*/
|
||||
const isValidPattern = (pattern: string): boolean => {
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new RegExp(pattern);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Props for the pattern list editor component.
|
||||
*/
|
||||
interface PatternListEditorProps {
|
||||
patterns: string[];
|
||||
onAdd: (pattern: string) => Promise<void>;
|
||||
onRemove: (pattern: string) => Promise<void>;
|
||||
disabled?: boolean;
|
||||
saving: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable component for adding/removing patterns.
|
||||
*/
|
||||
function PatternListEditor({
|
||||
patterns,
|
||||
onAdd,
|
||||
onRemove,
|
||||
disabled,
|
||||
saving,
|
||||
error,
|
||||
}: PatternListEditorProps) {
|
||||
const [newPattern, setNewPattern] = useState('');
|
||||
const [patternError, setPatternError] = useState<string>();
|
||||
|
||||
const handleAddPattern: FormEventHandler<HTMLFormElement> = async (evt) => {
|
||||
evt.preventDefault();
|
||||
const pattern = newPattern.trim();
|
||||
|
||||
if (!pattern) {
|
||||
setPatternError('Pattern cannot be empty');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidPattern(pattern)) {
|
||||
setPatternError('Invalid regex pattern');
|
||||
return;
|
||||
}
|
||||
|
||||
if (patterns.includes(pattern)) {
|
||||
setPatternError('Pattern already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
setPatternError(undefined);
|
||||
await onAdd(pattern);
|
||||
setNewPattern('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="300">
|
||||
<Box as="form" onSubmit={handleAddPattern} gap="200" alignItems="Start">
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Input
|
||||
value={newPattern}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNewPattern(e.target.value);
|
||||
setPatternError(undefined);
|
||||
}}
|
||||
placeholder="e.g. ^https?://example\.com"
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
disabled={disabled || saving}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
{patternError && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{patternError}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
type="submit"
|
||||
size="300"
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
disabled={disabled || saving || !newPattern.trim()}
|
||||
before={saving && <Spinner size="100" variant="Primary" fill="Solid" />}
|
||||
>
|
||||
<Text size="B300">Add</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{patterns.length > 0 && (
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="T200" priority="300">
|
||||
Active patterns ({patterns.length}):
|
||||
</Text>
|
||||
<Box gap="200" wrap="Wrap">
|
||||
{patterns.map((pattern) => (
|
||||
<TooltipProvider
|
||||
key={pattern}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text size="T300">{pattern}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<Chip
|
||||
ref={triggerRef}
|
||||
variant="Secondary"
|
||||
radii="Pill"
|
||||
onClick={() => onRemove(pattern)}
|
||||
after={!disabled && <Icon size="50" src={Icons.Cross} />}
|
||||
disabled={disabled || saving}
|
||||
>
|
||||
<Text
|
||||
size="T200"
|
||||
truncate
|
||||
style={{ maxWidth: config.space.S500, fontFamily: 'monospace' }}
|
||||
>
|
||||
{pattern}
|
||||
</Text>
|
||||
</Chip>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
Failed to save: {error}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for managing personal URL embed filter patterns for a room.
|
||||
* These patterns are stored per-user per-room and filter out URL previews
|
||||
* that match the patterns.
|
||||
*/
|
||||
export function PersonalEmbedFilters() {
|
||||
const room = useRoom();
|
||||
const [filters, setFilters] = useRoomEmbedFilters(room);
|
||||
|
||||
const [saveState, saveFilters] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (newFilters: EmbedFiltersContent) => {
|
||||
await setFilters(newFilters);
|
||||
},
|
||||
[setFilters]
|
||||
)
|
||||
);
|
||||
|
||||
const saving = saveState.status === AsyncStatus.Loading;
|
||||
|
||||
const handleAdd = async (pattern: string) => {
|
||||
const newFilters: EmbedFiltersContent = {
|
||||
disabledPatterns: [...filters.disabledPatterns, pattern],
|
||||
};
|
||||
await saveFilters(newFilters);
|
||||
};
|
||||
|
||||
const handleRemove = async (pattern: string) => {
|
||||
const newFilters: EmbedFiltersContent = {
|
||||
disabledPatterns: filters.disabledPatterns.filter((p) => p !== pattern),
|
||||
};
|
||||
await saveFilters(newFilters);
|
||||
};
|
||||
|
||||
return (
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Personal Embed Filters"
|
||||
description="Add regex patterns for URLs that should not show embeds for you only. Patterns are case-insensitive."
|
||||
/>
|
||||
<PatternListEditor
|
||||
patterns={filters.disabledPatterns}
|
||||
onAdd={handleAdd}
|
||||
onRemove={handleRemove}
|
||||
saving={saving}
|
||||
error={saveState.status === AsyncStatus.Error ? String(saveState.error) : undefined}
|
||||
/>
|
||||
</SequenceCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for managing room-wide URL embed filter patterns.
|
||||
* These patterns are set by room admins and apply to all users.
|
||||
*/
|
||||
export function RoomWideEmbedFilters() {
|
||||
const room = useRoom();
|
||||
const mx = useMatrixClient();
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const creators = useRoomCreators(room);
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
|
||||
const canEdit = permissions.stateEvent(StateEvent.RoomEmbedFilters, mx.getSafeUserId());
|
||||
const filters = useRoomWideEmbedFilters(room);
|
||||
const setRoomWideFilters = useSetRoomWideEmbedFilters(room);
|
||||
|
||||
const [saveState, saveFilters] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (newFilters: EmbedFiltersContent) => {
|
||||
await setRoomWideFilters(newFilters);
|
||||
},
|
||||
[setRoomWideFilters]
|
||||
)
|
||||
);
|
||||
|
||||
const saving = saveState.status === AsyncStatus.Loading;
|
||||
|
||||
const handleAdd = async (pattern: string) => {
|
||||
const newFilters: EmbedFiltersContent = {
|
||||
disabledPatterns: [...filters.disabledPatterns, pattern],
|
||||
};
|
||||
await saveFilters(newFilters);
|
||||
};
|
||||
|
||||
const handleRemove = async (pattern: string) => {
|
||||
const newFilters: EmbedFiltersContent = {
|
||||
disabledPatterns: filters.disabledPatterns.filter((p) => p !== pattern),
|
||||
};
|
||||
await saveFilters(newFilters);
|
||||
};
|
||||
|
||||
return (
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Room-Wide Embed Filters"
|
||||
description={
|
||||
canEdit
|
||||
? 'Add regex patterns that apply to all users in this room. Patterns are case-insensitive.'
|
||||
: 'Embed filter patterns set by room admins. You need moderator permissions to edit.'
|
||||
}
|
||||
/>
|
||||
<PatternListEditor
|
||||
patterns={filters.disabledPatterns}
|
||||
onAdd={handleAdd}
|
||||
onRemove={handleRemove}
|
||||
disabled={!canEdit}
|
||||
saving={saving}
|
||||
error={saveState.status === AsyncStatus.Error ? String(saveState.error) : undefined}
|
||||
/>
|
||||
</SequenceCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined component showing both personal and room-wide embed filters.
|
||||
*/
|
||||
export function EmbedFilters() {
|
||||
return (
|
||||
<Box direction="Column" gap="400">
|
||||
<RoomWideEmbedFilters />
|
||||
<PersonalEmbedFilters />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './EmbedFilters';
|
||||
export * from './RoomAddress';
|
||||
export * from './RoomEncryption';
|
||||
export * from './RoomHistoryVisibility';
|
||||
|
||||
@@ -56,6 +56,7 @@ import { useGetRoom } from '../../hooks/useGetRoom';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
|
||||
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
|
||||
import { joiningProgressAtom } from '../../state/joiningProgress';
|
||||
|
||||
const useCanDropLobbyItem = (
|
||||
space: Room,
|
||||
@@ -158,6 +159,8 @@ export function Lobby() {
|
||||
const spacePowerLevels = usePowerLevels(space);
|
||||
const lex = useMemo(() => new ASCIILexicalTable(' '.charCodeAt(0), '~'.charCodeAt(0), 6), []);
|
||||
const members = useRoomMembers(mx, space.roomId);
|
||||
const joiningProgress = useAtomValue(joiningProgressAtom);
|
||||
const currentJoiningProgress = joiningProgress.get(space.roomId);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const heroSectionRef = useRef<HTMLDivElement>(null);
|
||||
@@ -530,6 +533,33 @@ export function Lobby() {
|
||||
</Chip>
|
||||
</Box>
|
||||
)}
|
||||
{currentJoiningProgress && (
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: reordering
|
||||
? `calc(${config.space.S400} + 40px)`
|
||||
: config.space.S400,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 2,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
justifyContent="Center"
|
||||
>
|
||||
<Chip
|
||||
variant="Primary"
|
||||
outlined
|
||||
radii="Pill"
|
||||
before={<Spinner variant="Primary" fill="Soft" size="100" />}
|
||||
>
|
||||
<Text size="L400">
|
||||
Joining Channels: {currentJoiningProgress.current}/
|
||||
{currentJoiningProgress.total}
|
||||
</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
)}
|
||||
</PageContentCenter>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
|
||||
@@ -181,7 +181,14 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
||||
{(promptLeave, setPromptLeave) => (
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={() => setPromptLeave(true)}
|
||||
onClick={(evt) => {
|
||||
if (evt.shiftKey) {
|
||||
mx.leave(room.roomId);
|
||||
requestClose();
|
||||
} else {
|
||||
setPromptLeave(true);
|
||||
}
|
||||
}}
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
size="300"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import {
|
||||
EmbedFilters,
|
||||
RoomProfile,
|
||||
RoomEncryption,
|
||||
RoomHistoryVisibility,
|
||||
@@ -53,6 +54,10 @@ export function General({ requestClose }: GeneralProps) {
|
||||
<RoomEncryption permissions={permissions} />
|
||||
<RoomPublish permissions={permissions} />
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Link Previews</Text>
|
||||
<EmbedFilters />
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Addresses</Text>
|
||||
<RoomPublishedAddresses permissions={permissions} />
|
||||
|
||||
@@ -174,6 +174,19 @@ export const usePermissionGroups = (): PermissionGroup[] => {
|
||||
],
|
||||
};
|
||||
|
||||
const callsGroup: PermissionGroup = {
|
||||
name: 'Calls',
|
||||
items: [
|
||||
{
|
||||
location: {
|
||||
state: true,
|
||||
key: StateEvent.CallMember,
|
||||
},
|
||||
name: 'Join/Start Calls',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const otherSettingsGroup: PermissionGroup = {
|
||||
name: 'Other',
|
||||
items: [
|
||||
@@ -199,6 +212,7 @@ export const usePermissionGroups = (): PermissionGroup[] => {
|
||||
moderationGroup,
|
||||
roomOverviewGroup,
|
||||
roomSettingsGroup,
|
||||
callsGroup,
|
||||
otherSettingsGroup,
|
||||
];
|
||||
}, []);
|
||||
|
||||
@@ -126,6 +126,11 @@ import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/u
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||
import {
|
||||
useRoomEmbedFilters,
|
||||
useRoomWideEmbedFilters,
|
||||
combineEmbedFilters,
|
||||
} from '../../hooks/useRoomEmbedFilters';
|
||||
|
||||
/** Information about a call member event for grouping */
|
||||
interface CallEventInfo {
|
||||
@@ -455,6 +460,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
||||
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
||||
const [personalEmbedFilters] = useRoomEmbedFilters(room);
|
||||
const roomWideEmbedFilters = useRoomWideEmbedFilters(room);
|
||||
const combinedEmbedFilters = useMemo(
|
||||
() => combineEmbedFilters(personalEmbedFilters, roomWideEmbedFilters),
|
||||
[personalEmbedFilters, roomWideEmbedFilters]
|
||||
);
|
||||
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
||||
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||
|
||||
@@ -1119,6 +1130,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
disabledEmbedPatterns={combinedEmbedFilters}
|
||||
/>
|
||||
)}
|
||||
</Message>
|
||||
@@ -1225,6 +1237,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
disabledEmbedPatterns={combinedEmbedFilters}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -509,7 +509,17 @@ export const MessageDeleteItem = as<
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Delete} />}
|
||||
radii="300"
|
||||
onClick={() => setOpen(true)}
|
||||
onClick={(evt) => {
|
||||
if (evt.shiftKey) {
|
||||
const eventId = mEvent.getId();
|
||||
if (eventId) {
|
||||
mx.redactEvent(room.roomId, eventId);
|
||||
onClose?.();
|
||||
}
|
||||
} else {
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
aria-pressed={open}
|
||||
{...props}
|
||||
ref={ref}
|
||||
|
||||
@@ -23,7 +23,10 @@ import {
|
||||
Header,
|
||||
config,
|
||||
Spinner,
|
||||
color,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { HexColorPicker } from 'react-colorful';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
@@ -43,6 +46,8 @@ import { ModalWide } from '../../../styles/Modal.css';
|
||||
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
||||
import { useCapabilities } from '../../../hooks/useCapabilities';
|
||||
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
|
||||
import { useUserColor } from '../../../hooks/useUserColor';
|
||||
|
||||
type ProfileProps = {
|
||||
profile: UserProfile;
|
||||
@@ -303,6 +308,126 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Color picker component for user's profile color
|
||||
* Stored in avatar image metadata, visible to other Paarrot users
|
||||
*/
|
||||
function ProfileColor() {
|
||||
const [userColor, setUserColor, loading] = useUserColor();
|
||||
const [localColor, setLocalColor] = useState(userColor ?? '#3b82f6');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (userColor) {
|
||||
setLocalColor(userColor);
|
||||
}
|
||||
}, [userColor]);
|
||||
|
||||
const handleColorChange = (newColor: string) => {
|
||||
setLocalColor(newColor);
|
||||
setError(undefined);
|
||||
};
|
||||
|
||||
const handleSaveColor = async () => {
|
||||
setSaving(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
await setUserColor(localColor);
|
||||
} catch (e) {
|
||||
setError('Failed to save. Make sure you have an avatar set.');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleRemoveColor = async () => {
|
||||
setSaving(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
await setUserColor(undefined);
|
||||
setLocalColor('#3b82f6');
|
||||
} catch (e) {
|
||||
setError('Failed to remove color.');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title={
|
||||
<Text as="span" size="L400">
|
||||
Profile Color
|
||||
</Text>
|
||||
}
|
||||
description="Embedded in your avatar. Other Paarrot users will see this color."
|
||||
after={
|
||||
<Box alignItems="Center" gap="200">
|
||||
{loading ? (
|
||||
<Text size="T300" style={{ opacity: 0.7 }}>Loading...</Text>
|
||||
) : (
|
||||
<HexColorPickerPopOut
|
||||
picker={
|
||||
<Box direction="Column" gap="200">
|
||||
<HexColorPicker color={localColor} onChange={handleColorChange} />
|
||||
<Box gap="100" alignItems="Center">
|
||||
<Input
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
style={{ width: toRem(100) }}
|
||||
value={localColor}
|
||||
onChange={(e) => {
|
||||
setLocalColor(e.target.value);
|
||||
setError(undefined);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
onClick={handleSaveColor}
|
||||
disabled={saving}
|
||||
>
|
||||
<Text size="B300">{saving ? 'Saving...' : 'Save'}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
{error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>{error}</Text>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
onRemove={userColor ? handleRemoveColor : undefined}
|
||||
>
|
||||
{(onOpen) => (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={onOpen}
|
||||
disabled={saving}
|
||||
before={
|
||||
<Box
|
||||
style={{
|
||||
width: toRem(16),
|
||||
height: toRem(16),
|
||||
borderRadius: toRem(4),
|
||||
backgroundColor: userColor ?? localColor,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Text size="T300">{userColor ? 'Change' : 'Choose'}</Text>
|
||||
</Button>
|
||||
)}
|
||||
</HexColorPickerPopOut>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Profile() {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getUserId()!;
|
||||
@@ -318,6 +443,7 @@ export function Profile() {
|
||||
gap="400"
|
||||
>
|
||||
<ProfileAvatar userId={userId} profile={profile} />
|
||||
<ProfileColor />
|
||||
<ProfileDisplayName userId={userId} profile={profile} />
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
|
||||
@@ -23,6 +23,8 @@ import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
|
||||
/** Represents an audio device (microphone or speaker) */
|
||||
interface AudioDevice {
|
||||
@@ -447,6 +449,7 @@ export function Audio({ requestClose }: AudioProps) {
|
||||
const [speakers, setSpeakers] = useState<AudioDevice[]>([]);
|
||||
const [settings, setSettings] = useState<AudioSettings>(loadAudioSettings);
|
||||
const [permissionStatus, setPermissionStatus] = useState<'granted' | 'denied' | 'prompt'>('prompt');
|
||||
const [showRemoteCursor, setShowRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor');
|
||||
|
||||
useEffect(() => {
|
||||
const loadDevices = async () => {
|
||||
@@ -797,6 +800,17 @@ export function Audio({ requestClose }: AudioProps) {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Show Remote Cursor"
|
||||
description="Display other participants' cursors on shared screens. Both users must have this enabled."
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
value={showRemoteCursor}
|
||||
onChange={setShowRemoteCursor}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard
|
||||
|
||||
@@ -52,8 +52,6 @@ import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
|
||||
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
||||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
|
||||
import { useUserColor } from '../../../hooks/useUserColor';
|
||||
|
||||
type ThemeSelectorProps = {
|
||||
themeNames: Record<string, string>;
|
||||
@@ -431,131 +429,10 @@ function Appearance() {
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile title="Page Zoom" after={<PageZoomInput />} />
|
||||
</SequenceCard>
|
||||
|
||||
<UserColorPicker />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Color picker component for user's profile color
|
||||
* Stored in avatar PNG metadata, visible to other Paarrot users
|
||||
*/
|
||||
function UserColorPicker() {
|
||||
const [userColor, setUserColor, loading] = useUserColor();
|
||||
const [localColor, setLocalColor] = useState(userColor ?? '#3b82f6');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
// Update local color when avatar color changes
|
||||
useEffect(() => {
|
||||
if (userColor) {
|
||||
setLocalColor(userColor);
|
||||
}
|
||||
}, [userColor]);
|
||||
|
||||
const handleColorChange = (newColor: string) => {
|
||||
setLocalColor(newColor);
|
||||
setError(undefined);
|
||||
};
|
||||
|
||||
const handleSaveColor = async () => {
|
||||
setSaving(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
await setUserColor(localColor);
|
||||
} catch (e) {
|
||||
setError('Failed to save. Make sure you have an avatar set.');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleRemoveColor = async () => {
|
||||
setSaving(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
await setUserColor(undefined);
|
||||
setLocalColor('#3b82f6');
|
||||
} catch (e) {
|
||||
setError('Failed to remove color.');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Profile Color"
|
||||
description="Embedded in your avatar. Other Paarrot users will see this color."
|
||||
after={
|
||||
<Box alignItems="Center" gap="200">
|
||||
{loading ? (
|
||||
<Text size="T300" style={{ opacity: 0.7 }}>Loading...</Text>
|
||||
) : (
|
||||
<HexColorPickerPopOut
|
||||
picker={
|
||||
<Box direction="Column" gap="200">
|
||||
<HexColorPicker color={localColor} onChange={handleColorChange} />
|
||||
<Box gap="100" alignItems="Center">
|
||||
<Input
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
style={{ width: toRem(100) }}
|
||||
value={localColor}
|
||||
onChange={(e) => {
|
||||
setLocalColor(e.target.value);
|
||||
setError(undefined);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
onClick={handleSaveColor}
|
||||
disabled={saving}
|
||||
>
|
||||
<Text size="B300">{saving ? 'Saving...' : 'Save'}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
{error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>{error}</Text>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
onRemove={userColor ? handleRemoveColor : undefined}
|
||||
>
|
||||
{(onOpen) => (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={onOpen}
|
||||
disabled={saving}
|
||||
before={
|
||||
<Box
|
||||
style={{
|
||||
width: toRem(16),
|
||||
height: toRem(16),
|
||||
borderRadius: toRem(4),
|
||||
backgroundColor: userColor ?? localColor,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Text size="T300">{userColor ? 'Change' : 'Choose'}</Text>
|
||||
</Button>
|
||||
)}
|
||||
</HexColorPickerPopOut>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
);
|
||||
}
|
||||
|
||||
type DateHintProps = {
|
||||
hasChanges: boolean;
|
||||
handleReset: () => void;
|
||||
@@ -943,6 +820,32 @@ function Editor() {
|
||||
);
|
||||
}
|
||||
|
||||
function Spaces() {
|
||||
const [autoJoinSpaceRooms, setAutoJoinSpaceRooms] = useSetting(
|
||||
settingsAtom,
|
||||
'autoJoinSpaceRooms'
|
||||
);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Spaces</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Auto-Join Space Rooms"
|
||||
description="Automatically join all accessible rooms when joining a space, with notifications set to Mentions & Keywords."
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
value={autoJoinSpaceRooms}
|
||||
onChange={setAutoJoinSpaceRooms}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectMessageLayout() {
|
||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||
const [messageLayout, setMessageLayout] = useSetting(settingsAtom, 'messageLayout');
|
||||
@@ -1207,6 +1110,7 @@ export function General({ requestClose }: GeneralProps) {
|
||||
<Appearance />
|
||||
<DateAndTime />
|
||||
<Editor />
|
||||
<Spaces />
|
||||
<Messages />
|
||||
</Box>
|
||||
</PageContent>
|
||||
|
||||
247
src/app/hooks/useAutoJoinSpaceRooms.ts
Normal file
247
src/app/hooks/useAutoJoinSpaceRooms.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { MatrixClient, Room, RoomEvent } from 'matrix-js-sdk';
|
||||
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { Membership } from '../../types/matrix/room';
|
||||
import { isSpace } from '../utils/room';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import {
|
||||
setRoomNotificationPreference,
|
||||
RoomNotificationMode,
|
||||
} from './useRoomsNotificationPreferences';
|
||||
import { updateJoiningProgressAtom } from '../state/joiningProgress';
|
||||
|
||||
const PER_PAGE_COUNT = 100;
|
||||
const MAX_PAGES = 50;
|
||||
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
|
||||
* @param roomId - The room ID (e.g., "!abc123:matrix.org")
|
||||
* @returns The server name (e.g., "matrix.org")
|
||||
*/
|
||||
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
|
||||
* @param hierarchyRoom - The room info from the space hierarchy
|
||||
* @returns Whether the room/space should be auto-joined
|
||||
*/
|
||||
function canAutoJoin(hierarchyRoom: IHierarchyRoom): boolean {
|
||||
const { join_rule: joinRule } = hierarchyRoom;
|
||||
|
||||
// Only join public or restricted rooms/spaces
|
||||
// Restricted ones are joinable if you're a member of the parent space
|
||||
if (!joinRule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return AUTO_JOINABLE_RULES.includes(joinRule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a hierarchy item is a space
|
||||
* @param hierarchyRoom - The room info from the space hierarchy
|
||||
* @returns Whether the item is a space
|
||||
*/
|
||||
function isHierarchySpace(hierarchyRoom: IHierarchyRoom): boolean {
|
||||
return hierarchyRoom.room_type === 'm.space';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all rooms from a space hierarchy
|
||||
* @param mx - Matrix client
|
||||
* @param spaceId - The space room ID
|
||||
* @returns Array of hierarchy rooms
|
||||
*/
|
||||
async function fetchSpaceHierarchy(
|
||||
mx: MatrixClient,
|
||||
spaceId: string
|
||||
): Promise<IHierarchyRoom[]> {
|
||||
const allRooms: IHierarchyRoom[] = [];
|
||||
let nextBatch: string | undefined;
|
||||
let pageCount = 0;
|
||||
|
||||
while (pageCount < MAX_PAGES) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const result = await mx.getRoomHierarchy(spaceId, PER_PAGE_COUNT, undefined, false, nextBatch);
|
||||
allRooms.push(...result.rooms);
|
||||
nextBatch = result.next_batch;
|
||||
pageCount += 1;
|
||||
|
||||
if (!nextBatch) {
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return allRooms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delays execution for a specified time
|
||||
* @param ms - Milliseconds to delay
|
||||
*/
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
/** Callback for reporting join progress */
|
||||
type ProgressCallback = (current: number, total: number) => void;
|
||||
|
||||
/**
|
||||
* Attempts to join rooms/spaces with rate limiting and sets notification to Mentions & Keywords for rooms
|
||||
* @param mx - Matrix client
|
||||
* @param rooms - Array of hierarchy rooms to join
|
||||
* @param spaceId - The parent space ID (for via servers)
|
||||
* @param onProgress - Callback to report progress
|
||||
*/
|
||||
async function joinRoomsSequentially(
|
||||
mx: MatrixClient,
|
||||
rooms: IHierarchyRoom[],
|
||||
spaceId: string,
|
||||
onProgress?: ProgressCallback
|
||||
): Promise<void> {
|
||||
const spaceServer = getServerFromRoomId(spaceId);
|
||||
|
||||
const itemsToJoin = rooms.filter((room) => {
|
||||
// Check if already joined
|
||||
const existingRoom = mx.getRoom(room.room_id);
|
||||
if (existingRoom?.getMyMembership() === Membership.Join) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if can auto-join
|
||||
return canAutoJoin(room);
|
||||
});
|
||||
|
||||
// Separate spaces and rooms - join spaces first (rooms may depend on space membership)
|
||||
const spacesToJoin = itemsToJoin.filter(isHierarchySpace);
|
||||
const roomsToJoin = itemsToJoin.filter((r) => !isHierarchySpace(r));
|
||||
|
||||
const total = itemsToJoin.length;
|
||||
let current = 0;
|
||||
|
||||
// Report initial progress
|
||||
if (total > 0) {
|
||||
onProgress?.(current, total);
|
||||
}
|
||||
|
||||
// Process item (space or room)
|
||||
const joinItem = async (item: IHierarchyRoom, isItemSpace: boolean): Promise<void> => {
|
||||
try {
|
||||
const itemServer = getServerFromRoomId(item.room_id);
|
||||
const viaServers = [itemServer, spaceServer].filter(Boolean);
|
||||
|
||||
await mx.joinRoom(item.room_id, { viaServers });
|
||||
|
||||
// Only set notification preferences for rooms, not spaces
|
||||
if (!isItemSpace) {
|
||||
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;
|
||||
onProgress?.(current, total);
|
||||
}
|
||||
};
|
||||
|
||||
// Join spaces first, then rooms
|
||||
await spacesToJoin.reduce(
|
||||
(promise, space) => promise.then(() => joinItem(space, true)),
|
||||
Promise.resolve()
|
||||
);
|
||||
|
||||
await roomsToJoin.reduce(
|
||||
(promise, room) => promise.then(() => joinItem(room, false)),
|
||||
Promise.resolve()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that automatically joins all accessible rooms when joining a space
|
||||
* @param mx - Matrix client instance
|
||||
*/
|
||||
export function useAutoJoinSpaceRooms(mx: MatrixClient): void {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const setJoiningProgress = useSetAtom(updateJoiningProgressAtom);
|
||||
const processingSpaces = useRef<Set<string>>(new Set());
|
||||
|
||||
const handleSpaceJoin = useCallback(
|
||||
async (room: Room) => {
|
||||
if (!settings.autoJoinSpaceRooms) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only process spaces
|
||||
if (!isSpace(room)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only process when joining (not invites or leaves)
|
||||
if (room.getMyMembership() !== Membership.Join) {
|
||||
return;
|
||||
}
|
||||
|
||||
const spaceId = room.roomId;
|
||||
|
||||
// Prevent duplicate processing
|
||||
if (processingSpaces.current.has(spaceId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
processingSpaces.current.add(spaceId);
|
||||
|
||||
try {
|
||||
// Fetch the space hierarchy
|
||||
const hierarchyRooms = await fetchSpaceHierarchy(mx, spaceId);
|
||||
|
||||
// Filter out the space itself
|
||||
const childRooms = hierarchyRooms.filter((r) => r.room_id !== spaceId);
|
||||
|
||||
// Join all accessible rooms with progress tracking
|
||||
await joinRoomsSequentially(mx, childRooms, spaceId, (current, total) => {
|
||||
setJoiningProgress({ spaceId, progress: { current, total } });
|
||||
});
|
||||
} finally {
|
||||
processingSpaces.current.delete(spaceId);
|
||||
// Clear progress when done
|
||||
setJoiningProgress({ spaceId, progress: null });
|
||||
}
|
||||
},
|
||||
[mx, settings.autoJoinSpaceRooms, setJoiningProgress]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onMembershipChange = (room: Room, membership: string) => {
|
||||
if (membership === Membership.Join) {
|
||||
handleSpaceJoin(room);
|
||||
}
|
||||
};
|
||||
|
||||
mx.on(RoomEvent.MyMembership, onMembershipChange);
|
||||
|
||||
return () => {
|
||||
mx.removeListener(RoomEvent.MyMembership, onMembershipChange);
|
||||
};
|
||||
}, [mx, handleSpaceJoin]);
|
||||
}
|
||||
175
src/app/hooks/useRoomEmbedFilters.ts
Normal file
175
src/app/hooks/useRoomEmbedFilters.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useStateEvent } from './useStateEvent';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
|
||||
/**
|
||||
* The room account data type for storing personal embed filter patterns.
|
||||
*/
|
||||
export const EMBED_FILTERS_ACCOUNT_DATA_TYPE = 'im.paarrot.embed_filters';
|
||||
|
||||
/**
|
||||
* Content structure for embed filter data.
|
||||
*/
|
||||
export interface EmbedFiltersContent {
|
||||
/**
|
||||
* Array of regex pattern strings to match URLs that should not show embeds.
|
||||
* Each pattern will be tested against URLs using RegExp.test().
|
||||
*/
|
||||
disabledPatterns: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default empty embed filters content.
|
||||
*/
|
||||
const DEFAULT_EMBED_FILTERS: EmbedFiltersContent = {
|
||||
disabledPatterns: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the personal embed filters content from room account data.
|
||||
* @param room - The Matrix room to get filters for
|
||||
* @returns The embed filters content
|
||||
*/
|
||||
const getPersonalEmbedFiltersFromRoom = (room: Room): EmbedFiltersContent => {
|
||||
const accountDataEvent = room.getAccountData(EMBED_FILTERS_ACCOUNT_DATA_TYPE);
|
||||
if (!accountDataEvent) {
|
||||
return DEFAULT_EMBED_FILTERS;
|
||||
}
|
||||
|
||||
const content = accountDataEvent.getContent() as Partial<EmbedFiltersContent>;
|
||||
return {
|
||||
disabledPatterns: Array.isArray(content.disabledPatterns) ? content.disabledPatterns : [],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get and set personal embed filter patterns for a room.
|
||||
* These patterns are stored in room account data (per-user, per-room).
|
||||
* @param room - The Matrix room
|
||||
* @returns A tuple of [filters, setFilters]
|
||||
*/
|
||||
export const useRoomEmbedFilters = (
|
||||
room: Room
|
||||
): [EmbedFiltersContent, (filters: EmbedFiltersContent) => Promise<void>] => {
|
||||
const mx = useMatrixClient();
|
||||
const [filters, setFiltersState] = useState<EmbedFiltersContent>(() =>
|
||||
getPersonalEmbedFiltersFromRoom(room)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleAccountData: RoomEventHandlerMap[RoomEvent.AccountData] = (event) => {
|
||||
if (event.getType() === EMBED_FILTERS_ACCOUNT_DATA_TYPE) {
|
||||
setFiltersState(getPersonalEmbedFiltersFromRoom(room));
|
||||
}
|
||||
};
|
||||
|
||||
room.on(RoomEvent.AccountData, handleAccountData);
|
||||
return () => {
|
||||
room.removeListener(RoomEvent.AccountData, handleAccountData);
|
||||
};
|
||||
}, [room]);
|
||||
|
||||
const setFilters = useCallback(
|
||||
async (newFilters: EmbedFiltersContent): Promise<void> => {
|
||||
await mx.setRoomAccountData(room.roomId, EMBED_FILTERS_ACCOUNT_DATA_TYPE, newFilters);
|
||||
},
|
||||
[mx, room.roomId]
|
||||
);
|
||||
|
||||
return [filters, setFilters];
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get room-wide embed filter patterns set by room admins.
|
||||
* These patterns are stored as room state and apply to all users.
|
||||
* @param room - The Matrix room
|
||||
* @returns The room-wide embed filters content
|
||||
*/
|
||||
export const useRoomWideEmbedFilters = (room: Room): EmbedFiltersContent => {
|
||||
const stateEvent = useStateEvent(room, StateEvent.RoomEmbedFilters);
|
||||
if (!stateEvent) {
|
||||
return DEFAULT_EMBED_FILTERS;
|
||||
}
|
||||
|
||||
const content = stateEvent.getContent() as Partial<EmbedFiltersContent>;
|
||||
return {
|
||||
disabledPatterns: Array.isArray(content.disabledPatterns) ? content.disabledPatterns : [],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to set room-wide embed filter patterns.
|
||||
* Requires appropriate power level to send state events.
|
||||
* @param room - The Matrix room
|
||||
* @returns Function to save room-wide filters
|
||||
*/
|
||||
export const useSetRoomWideEmbedFilters = (
|
||||
room: Room
|
||||
): ((filters: EmbedFiltersContent) => Promise<void>) => {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const setFilters = useCallback(
|
||||
async (newFilters: EmbedFiltersContent): Promise<void> => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.RoomEmbedFilters as any, newFilters);
|
||||
},
|
||||
[mx, room.roomId]
|
||||
);
|
||||
|
||||
return setFilters;
|
||||
};
|
||||
|
||||
/**
|
||||
* Combines personal and room-wide embed filter patterns.
|
||||
* @param personalFilters - Personal (per-user) filters
|
||||
* @param roomWideFilters - Room-wide (admin-set) filters
|
||||
* @returns Combined unique patterns
|
||||
*/
|
||||
export const combineEmbedFilters = (
|
||||
personalFilters: EmbedFiltersContent,
|
||||
roomWideFilters: EmbedFiltersContent
|
||||
): string[] => {
|
||||
const combined = new Set<string>([
|
||||
...personalFilters.disabledPatterns,
|
||||
...roomWideFilters.disabledPatterns,
|
||||
]);
|
||||
return Array.from(combined);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests if a URL should have its embed disabled based on the filter patterns.
|
||||
* @param url - The URL to test
|
||||
* @param disabledPatterns - Array of regex pattern strings
|
||||
* @returns True if the URL matches any disabled pattern
|
||||
*/
|
||||
export const isUrlEmbedDisabled = (url: string, disabledPatterns: string[]): boolean => {
|
||||
if (!disabledPatterns || disabledPatterns.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return disabledPatterns.some((pattern) => {
|
||||
try {
|
||||
const regex = new RegExp(pattern, 'i');
|
||||
return regex.test(url);
|
||||
} catch {
|
||||
// Invalid regex pattern, skip it
|
||||
return false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters an array of URLs, removing those that match any disabled pattern.
|
||||
* @param urls - Array of URLs to filter
|
||||
* @param disabledPatterns - Array of regex pattern strings
|
||||
* @returns Array of URLs that don't match any disabled pattern
|
||||
*/
|
||||
export const filterDisabledUrls = (urls: string[], disabledPatterns: string[]): string[] => {
|
||||
if (!disabledPatterns || disabledPatterns.length === 0) {
|
||||
return urls;
|
||||
}
|
||||
|
||||
return urls.filter((url) => !isUrlEmbedDisabled(url, disabledPatterns));
|
||||
};
|
||||
@@ -101,7 +101,6 @@ export const useRoomNotificationPreference = (
|
||||
|
||||
export const getRoomNotificationModeIcon = (mode?: RoomNotificationMode): IconSrc => {
|
||||
if (mode === RoomNotificationMode.Mute) return Icons.BellMute;
|
||||
if (mode === RoomNotificationMode.SpecialMessages) return Icons.BellPing;
|
||||
if (mode === RoomNotificationMode.AllMessages) return Icons.BellRing;
|
||||
|
||||
return Icons.Bell;
|
||||
|
||||
@@ -17,6 +17,7 @@ export type SidebarItems = Array<TSidebarItem>;
|
||||
export type InCinnySpacesContent = {
|
||||
shortcut?: string[];
|
||||
sidebar?: SidebarItems;
|
||||
hidden?: string[];
|
||||
};
|
||||
|
||||
export const parseSidebar = (
|
||||
@@ -25,12 +26,14 @@ export const parseSidebar = (
|
||||
content?: InCinnySpacesContent
|
||||
) => {
|
||||
const sidebar = content?.sidebar ?? content?.shortcut ?? [];
|
||||
const hiddenSet = new Set(content?.hidden ?? []);
|
||||
const orphans = new Set(orphanSpaces);
|
||||
|
||||
const items: SidebarItems = [];
|
||||
|
||||
const safeToAdd = (spaceId: string): boolean => {
|
||||
if (typeof spaceId !== 'string') return false;
|
||||
if (hiddenSet.has(spaceId)) return false;
|
||||
const space = mx.getRoom(spaceId);
|
||||
if (space?.getMyMembership() !== Membership.Join) return false;
|
||||
return isSpace(space);
|
||||
@@ -52,17 +55,39 @@ export const parseSidebar = (
|
||||
) {
|
||||
const safeContent = item.content.filter(safeToAdd);
|
||||
safeContent.forEach((i) => orphans.delete(i));
|
||||
items.push({
|
||||
...item,
|
||||
content: Array.from(new Set(safeContent)),
|
||||
});
|
||||
if (safeContent.length > 0) {
|
||||
items.push({
|
||||
...item,
|
||||
content: Array.from(new Set(safeContent)),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
orphans.forEach((spaceId) => items.push(spaceId));
|
||||
orphans.forEach((spaceId) => {
|
||||
if (!hiddenSet.has(spaceId)) {
|
||||
items.push(spaceId);
|
||||
}
|
||||
});
|
||||
return items;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse hidden spaces from account data
|
||||
*/
|
||||
export const parseHiddenSpaces = (
|
||||
mx: MatrixClient,
|
||||
content?: InCinnySpacesContent
|
||||
): string[] => {
|
||||
const hidden = content?.hidden ?? [];
|
||||
return hidden.filter((spaceId) => {
|
||||
if (typeof spaceId !== 'string') return false;
|
||||
const space = mx.getRoom(spaceId);
|
||||
if (space?.getMyMembership() !== Membership.Join) return false;
|
||||
return isSpace(space);
|
||||
});
|
||||
};
|
||||
|
||||
export const useSidebarItems = (
|
||||
orphanSpaces: string[]
|
||||
): [SidebarItems, Dispatch<SetStateAction<SidebarItems>>] => {
|
||||
@@ -136,3 +161,80 @@ export const makeCinnySpacesContent = (
|
||||
|
||||
return newSpacesContent;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get and manage hidden spaces
|
||||
*/
|
||||
export const useHiddenSpaces = (): [string[], Dispatch<SetStateAction<string[]>>] => {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const [hiddenSpaces, setHiddenSpaces] = useState(() => {
|
||||
const content = getAccountData(
|
||||
mx,
|
||||
AccountDataEvent.CinnySpaces
|
||||
)?.getContent<InCinnySpacesContent>();
|
||||
return parseHiddenSpaces(mx, content);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const content = getAccountData(
|
||||
mx,
|
||||
AccountDataEvent.CinnySpaces
|
||||
)?.getContent<InCinnySpacesContent>();
|
||||
setHiddenSpaces(parseHiddenSpaces(mx, content));
|
||||
}, [mx]);
|
||||
|
||||
useAccountDataCallback(
|
||||
mx,
|
||||
useCallback(
|
||||
(mEvent) => {
|
||||
if (mEvent.getType() === AccountDataEvent.CinnySpaces) {
|
||||
const newContent = mEvent.getContent<InCinnySpacesContent>();
|
||||
setHiddenSpaces(parseHiddenSpaces(mx, newContent));
|
||||
}
|
||||
},
|
||||
[mx]
|
||||
)
|
||||
);
|
||||
|
||||
return [hiddenSpaces, setHiddenSpaces];
|
||||
};
|
||||
|
||||
/**
|
||||
* Create content for hiding a space
|
||||
*/
|
||||
export const makeHideSpaceContent = (
|
||||
mx: MatrixClient,
|
||||
spaceId: string
|
||||
): InCinnySpacesContent => {
|
||||
const currentContent =
|
||||
getAccountData(mx, AccountDataEvent.CinnySpaces)?.getContent<InCinnySpacesContent>() ?? {};
|
||||
|
||||
const currentHidden = currentContent.hidden ?? [];
|
||||
if (currentHidden.includes(spaceId)) {
|
||||
return currentContent;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentContent,
|
||||
hidden: [...currentHidden, spaceId],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create content for unhiding a space
|
||||
*/
|
||||
export const makeUnhideSpaceContent = (
|
||||
mx: MatrixClient,
|
||||
spaceId: string
|
||||
): InCinnySpacesContent => {
|
||||
const currentContent =
|
||||
getAccountData(mx, AccountDataEvent.CinnySpaces)?.getContent<InCinnySpacesContent>() ?? {};
|
||||
|
||||
const currentHidden = currentContent.hidden ?? [];
|
||||
|
||||
return {
|
||||
...currentContent,
|
||||
hidden: currentHidden.filter((id) => id !== spaceId),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Provider as JotaiProvider } from 'jotai';
|
||||
import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
|
||||
import { ClientConfigLoader } from '../components/ClientConfigLoader';
|
||||
import { ClientConfigProvider } from '../hooks/useClientConfig';
|
||||
@@ -39,7 +38,6 @@ function App() {
|
||||
<JotaiProvider>
|
||||
<RouterProvider router={createRouter(clientConfig, screenSize)} />
|
||||
</JotaiProvider>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</ClientConfigProvider>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from 'folds';
|
||||
import { DockedCallPanel } from '../../features/call/DockedCallPanel';
|
||||
|
||||
type ClientLayoutProps = {
|
||||
nav: ReactNode;
|
||||
@@ -10,6 +11,7 @@ export function ClientLayout({ nav, children }: ClientLayoutProps) {
|
||||
<Box grow="Yes">
|
||||
<Box shrink="No">{nav}</Box>
|
||||
<Box grow="Yes">{children}</Box>
|
||||
<DockedCallPanel />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SearchTab,
|
||||
} from './sidebar';
|
||||
import { CreateTab } from './sidebar/CreateTab';
|
||||
import { HiddenSpacesTabs } from './sidebar/SpaceTabs';
|
||||
|
||||
export function SidebarNav() {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
@@ -37,6 +38,7 @@ export function SidebarNav() {
|
||||
<ExploreTab />
|
||||
<CreateTab />
|
||||
</SidebarStack>
|
||||
<HiddenSpacesTabs />
|
||||
</Scroll>
|
||||
}
|
||||
sticky={
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort';
|
||||
import {
|
||||
NavButton,
|
||||
@@ -64,44 +65,73 @@ import {
|
||||
} from '../../../hooks/useRoomsNotificationPreferences';
|
||||
import { UseStateProvider } from '../../../components/UseStateProvider';
|
||||
import { JoinAddressPrompt } from '../../../components/join-address-prompt';
|
||||
import { ManageRoomsPrompt } from '../../../components/manage-rooms-prompt';
|
||||
import { _RoomSearchParams } from '../../paths';
|
||||
|
||||
type HomeMenuProps = {
|
||||
requestClose: () => void;
|
||||
onManageRooms: () => void;
|
||||
};
|
||||
const HomeMenu = forwardRef<HTMLDivElement, HomeMenuProps>(({ requestClose }, ref) => {
|
||||
const orphanRooms = useHomeRooms();
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
|
||||
const mx = useMatrixClient();
|
||||
const HomeMenu = forwardRef<HTMLDivElement, HomeMenuProps>(
|
||||
({ requestClose, onManageRooms }, ref) => {
|
||||
const orphanRooms = useHomeRooms();
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const handleMarkAsRead = () => {
|
||||
if (!unread) return;
|
||||
orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity));
|
||||
requestClose();
|
||||
};
|
||||
const handleMarkAsRead = () => {
|
||||
if (!unread) return;
|
||||
orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity));
|
||||
requestClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
onClick={handleMarkAsRead}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.CheckTwice} />}
|
||||
radii="300"
|
||||
aria-disabled={!unread}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Mark as Read
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
</Menu>
|
||||
);
|
||||
});
|
||||
const handleManageRooms = () => {
|
||||
requestClose();
|
||||
onManageRooms();
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
onClick={handleMarkAsRead}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.CheckTwice} />}
|
||||
radii="300"
|
||||
aria-disabled={!unread}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Mark as Read
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={handleManageRooms}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Setting} />}
|
||||
radii="300"
|
||||
aria-disabled={orphanRooms.length === 0}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Manage Rooms
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
function HomeHeader() {
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [showManageRooms, setShowManageRooms] = useState(false);
|
||||
const mx = useMatrixClient();
|
||||
const orphanRooms = useHomeRooms();
|
||||
|
||||
const rooms = useMemo(() => {
|
||||
return orphanRooms
|
||||
.map((roomId) => mx.getRoom(roomId))
|
||||
.filter((room): room is Room => room !== null);
|
||||
}, [mx, orphanRooms]);
|
||||
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
const cords = evt.currentTarget.getBoundingClientRect();
|
||||
@@ -144,10 +174,20 @@ function HomeHeader() {
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<HomeMenu requestClose={() => setMenuAnchor(undefined)} />
|
||||
<HomeMenu
|
||||
requestClose={() => setMenuAnchor(undefined)}
|
||||
onManageRooms={() => setShowManageRooms(true)}
|
||||
/>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
{showManageRooms && (
|
||||
<ManageRoomsPrompt
|
||||
rooms={rooms}
|
||||
onDone={() => setShowManageRooms(false)}
|
||||
onCancel={() => setShowManageRooms(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,8 +69,12 @@ import {
|
||||
SidebarItems,
|
||||
TSidebarItem,
|
||||
makeCinnySpacesContent,
|
||||
makeHideSpaceContent,
|
||||
makeUnhideSpaceContent,
|
||||
parseHiddenSpaces,
|
||||
parseSidebar,
|
||||
sidebarItemWithout,
|
||||
useHiddenSpaces,
|
||||
useSidebarItems,
|
||||
} from '../../../hooks/useSidebarItems';
|
||||
import { AccountDataEvent } from '../../../../types/matrix/accountData';
|
||||
@@ -93,14 +97,17 @@ import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||
import { InviteUserPrompt } from '../../../components/invite-user-prompt';
|
||||
import { UseStateProvider } from '../../../components/UseStateProvider';
|
||||
import { LeaveSpacePrompt } from '../../../components/leave-space-prompt';
|
||||
|
||||
type SpaceMenuProps = {
|
||||
room: Room;
|
||||
requestClose: () => void;
|
||||
onUnpin?: (roomId: string) => void;
|
||||
onHide?: (roomId: string) => void;
|
||||
};
|
||||
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
|
||||
({ room, requestClose, onUnpin }, ref) => {
|
||||
({ room, requestClose, onUnpin, onHide }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
@@ -146,6 +153,11 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
|
||||
requestClose();
|
||||
};
|
||||
|
||||
const handleHide = () => {
|
||||
onHide?.(room.roomId);
|
||||
requestClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
||||
{invitePrompt && room && (
|
||||
@@ -181,6 +193,18 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
{onHide && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={handleHide}
|
||||
after={<Icon size="100" src={Icons.EyeBlind} />}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Hide Space
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Box>
|
||||
<Line variant="Surface" size="300" />
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
@@ -219,6 +243,42 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
<Line variant="Surface" size="300" />
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<UseStateProvider initial={false}>
|
||||
{(promptLeave, setPromptLeave) => (
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={(evt) => {
|
||||
if (evt.shiftKey) {
|
||||
mx.leave(room.roomId);
|
||||
requestClose();
|
||||
} else {
|
||||
setPromptLeave(true);
|
||||
}
|
||||
}}
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
|
||||
radii="300"
|
||||
aria-pressed={promptLeave}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Leave Space
|
||||
</Text>
|
||||
</MenuItem>
|
||||
{promptLeave && (
|
||||
<LeaveSpacePrompt
|
||||
roomId={room.roomId}
|
||||
onDone={requestClose}
|
||||
onCancel={() => setPromptLeave(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
</Box>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -390,6 +450,7 @@ type SpaceTabProps = {
|
||||
onDragging: (dragItem?: SidebarDraggable) => void;
|
||||
disabled?: boolean;
|
||||
onUnpin?: (roomId: string) => void;
|
||||
onHide?: (roomId: string) => void;
|
||||
};
|
||||
function SpaceTab({
|
||||
space,
|
||||
@@ -399,6 +460,7 @@ function SpaceTab({
|
||||
onDragging,
|
||||
disabled,
|
||||
onUnpin,
|
||||
onHide,
|
||||
}: SpaceTabProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
@@ -489,6 +551,7 @@ function SpaceTab({
|
||||
room={space}
|
||||
requestClose={() => setMenuAnchor(undefined)}
|
||||
onUnpin={onUnpin}
|
||||
onHide={onHide}
|
||||
/>
|
||||
</FocusTrap>
|
||||
}
|
||||
@@ -605,6 +668,176 @@ function ClosedSpaceFolder({
|
||||
);
|
||||
}
|
||||
|
||||
type HiddenSpacesFolderProps = {
|
||||
hiddenSpaces: string[];
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
onSpaceClick: MouseEventHandler<HTMLButtonElement>;
|
||||
onUnhide: (roomId: string) => void;
|
||||
selectedSpaceId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A special folder that displays hidden spaces
|
||||
*/
|
||||
function HiddenSpacesFolder({
|
||||
hiddenSpaces,
|
||||
open,
|
||||
onToggle,
|
||||
onSpaceClick,
|
||||
onUnhide,
|
||||
selectedSpaceId,
|
||||
}: HiddenSpacesFolderProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [menuSpaceId, setMenuSpaceId] = useState<string>();
|
||||
|
||||
const handleContextMenu = (spaceId: string): MouseEventHandler<HTMLButtonElement> => (evt) => {
|
||||
evt.preventDefault();
|
||||
const cords = evt.currentTarget.getBoundingClientRect();
|
||||
setMenuSpaceId(spaceId);
|
||||
setMenuAnchor((currentState) => {
|
||||
if (currentState) return undefined;
|
||||
return cords;
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnhideClick = () => {
|
||||
if (menuSpaceId) {
|
||||
onUnhide(menuSpaceId);
|
||||
}
|
||||
setMenuAnchor(undefined);
|
||||
};
|
||||
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
// Check if any hidden space has unreads
|
||||
const hasUnreads = hiddenSpaces.some((spaceId) => {
|
||||
const space = mx.getRoom(spaceId);
|
||||
return space && space.getUnreadNotificationCount() > 0;
|
||||
});
|
||||
|
||||
if (!open) {
|
||||
// Only show when hovered, has unreads, or a hidden space is selected
|
||||
const isVisible = hovered || hasUnreads || (!!selectedSpaceId && hiddenSpaces.includes(selectedSpaceId));
|
||||
|
||||
return (
|
||||
<RoomsUnreadProvider rooms={hiddenSpaces}>
|
||||
{(unread) => (
|
||||
<SidebarItem
|
||||
active={!!selectedSpaceId && hiddenSpaces.includes(selectedSpaceId)}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<SidebarItemTooltip tooltip="Hidden Spaces">
|
||||
{(tooltipRef) => (
|
||||
<SidebarAvatar as="button" ref={tooltipRef} outlined onClick={onToggle}>
|
||||
<Icon src={Icons.EyeBlind} />
|
||||
</SidebarAvatar>
|
||||
)}
|
||||
</SidebarItemTooltip>
|
||||
{unread && (
|
||||
<SidebarItemBadge hasCount={unread.total > 0}>
|
||||
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
|
||||
</SidebarItemBadge>
|
||||
)}
|
||||
</SidebarItem>
|
||||
)}
|
||||
</RoomsUnreadProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarFolder state="Open">
|
||||
<SidebarAvatar size="300">
|
||||
<IconButton size="300" variant="Background" onClick={onToggle}>
|
||||
<Icon size="400" src={Icons.ChevronTop} filled />
|
||||
</IconButton>
|
||||
</SidebarAvatar>
|
||||
{hiddenSpaces.map((spaceId) => {
|
||||
const space = mx.getRoom(spaceId);
|
||||
if (!space) return null;
|
||||
const selected = space.roomId === selectedSpaceId;
|
||||
|
||||
return (
|
||||
<RoomUnreadProvider key={space.roomId} roomId={space.roomId}>
|
||||
{(unread) => (
|
||||
<SidebarItem active={selected} data-inside-folder>
|
||||
<SidebarItemTooltip tooltip={space.name}>
|
||||
{(triggerRef) => (
|
||||
<SidebarAvatar
|
||||
as="button"
|
||||
data-id={space.roomId}
|
||||
ref={triggerRef}
|
||||
size="300"
|
||||
onClick={onSpaceClick}
|
||||
onContextMenu={handleContextMenu(space.roomId)}
|
||||
>
|
||||
<RoomAvatar
|
||||
roomId={space.roomId}
|
||||
src={getRoomAvatarUrl(mx, space, 96, useAuthentication) ?? undefined}
|
||||
alt={space.name}
|
||||
renderFallback={() => (
|
||||
<Text size="H6">{nameInitials(space.name, 2)}</Text>
|
||||
)}
|
||||
/>
|
||||
</SidebarAvatar>
|
||||
)}
|
||||
</SidebarItemTooltip>
|
||||
{unread && (
|
||||
<SidebarItemBadge hasCount={unread.total > 0}>
|
||||
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
|
||||
</SidebarItemBadge>
|
||||
)}
|
||||
</SidebarItem>
|
||||
)}
|
||||
</RoomUnreadProvider>
|
||||
);
|
||||
})}
|
||||
{menuAnchor && (
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
position="Right"
|
||||
align="Start"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu style={{ maxWidth: toRem(160), width: '100vw' }}>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={handleUnhideClick}
|
||||
after={<Icon size="100" src={Icons.Eye} />}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Unhide Space
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SidebarFolder>
|
||||
);
|
||||
}
|
||||
|
||||
type SpaceTabsProps = {
|
||||
scrollRef: RefObject<HTMLDivElement>;
|
||||
};
|
||||
@@ -615,6 +848,7 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
|
||||
const [sidebarItems, localEchoSidebarItem] = useSidebarItems(orphanSpaces);
|
||||
const [, localEchoHiddenSpaces] = useHiddenSpaces();
|
||||
const navToActivePath = useAtomValue(useNavToActivePathAtom());
|
||||
const [openedFolder, setOpenedFolder] = useAtom(useOpenedSidebarFolderAtom());
|
||||
const [draggingItem, setDraggingItem] = useState<SidebarDraggable>();
|
||||
@@ -797,6 +1031,16 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
|
||||
[mx, sidebarItems, orphanSpaces, localEchoSidebarItem]
|
||||
);
|
||||
|
||||
const handleHide = useCallback(
|
||||
(roomId: string) => {
|
||||
const newContent = makeHideSpaceContent(mx, roomId);
|
||||
localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newContent));
|
||||
localEchoHiddenSpaces(parseHiddenSpaces(mx, newContent));
|
||||
mx.setAccountData(AccountDataEvent.CinnySpaces, newContent);
|
||||
},
|
||||
[mx, orphanSpaces, localEchoSidebarItem, localEchoHiddenSpaces]
|
||||
);
|
||||
|
||||
if (sidebarItems.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
@@ -824,6 +1068,7 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
|
||||
: false
|
||||
}
|
||||
onUnpin={orphanSpaces.includes(space.roomId) ? undefined : handleUnpin}
|
||||
onHide={handleHide}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -857,6 +1102,7 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
|
||||
onDragging={setDraggingItem}
|
||||
disabled={typeof draggingItem === 'string' ? draggingItem === space.roomId : false}
|
||||
onUnpin={orphanSpaces.includes(space.roomId) ? undefined : handleUnpin}
|
||||
onHide={handleHide}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -864,3 +1110,67 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Separate component for hidden spaces that can be rendered after other sidebar items
|
||||
*/
|
||||
export function HiddenSpacesTabs() {
|
||||
const navigate = useNavigate();
|
||||
const mx = useMatrixClient();
|
||||
const screenSize = useScreenSizeContext();
|
||||
const navToActivePath = useAtomValue(useNavToActivePathAtom());
|
||||
const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, useAtomValue(roomToParentsAtom));
|
||||
const [hiddenSpaces, localEchoHiddenSpaces] = useHiddenSpaces();
|
||||
const [, localEchoSidebarItem] = useSidebarItems(orphanSpaces);
|
||||
const [hiddenFolderOpen, setHiddenFolderOpen] = useState(false);
|
||||
|
||||
const selectedSpaceId = useSelectedSpace();
|
||||
|
||||
const handleSpaceClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
const target = evt.currentTarget;
|
||||
const targetSpaceId = target.getAttribute('data-id');
|
||||
if (!targetSpaceId) return;
|
||||
|
||||
const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId));
|
||||
if (screenSize === ScreenSize.Mobile) {
|
||||
navigate(spacePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const activePath = navToActivePath.get(targetSpaceId);
|
||||
if (activePath && activePath.pathname.startsWith(spacePath)) {
|
||||
navigate(joinPathComponent(activePath));
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, targetSpaceId)));
|
||||
};
|
||||
|
||||
const handleUnhide = useCallback(
|
||||
(roomId: string) => {
|
||||
const newContent = makeUnhideSpaceContent(mx, roomId);
|
||||
localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newContent));
|
||||
localEchoHiddenSpaces(parseHiddenSpaces(mx, newContent));
|
||||
mx.setAccountData(AccountDataEvent.CinnySpaces, newContent);
|
||||
},
|
||||
[mx, orphanSpaces, localEchoSidebarItem, localEchoHiddenSpaces]
|
||||
);
|
||||
|
||||
if (hiddenSpaces.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarStackSeparator />
|
||||
<SidebarStack>
|
||||
<HiddenSpacesFolder
|
||||
hiddenSpaces={hiddenSpaces}
|
||||
open={hiddenFolderOpen}
|
||||
onToggle={() => setHiddenFolderOpen(!hiddenFolderOpen)}
|
||||
onSpaceClick={handleSpaceClick}
|
||||
onUnhide={handleUnhide}
|
||||
selectedSpaceId={selectedSpaceId}
|
||||
/>
|
||||
</SidebarStack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { mDirectAtom, useBindMDirectAtom } from '../mDirectList';
|
||||
import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../room/roomToUnread';
|
||||
import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
|
||||
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
|
||||
import { useAutoJoinSpaceRooms } from '../../hooks/useAutoJoinSpaceRooms';
|
||||
|
||||
export const useBindAtoms = (mx: MatrixClient) => {
|
||||
useBindMDirectAtom(mx, mDirectAtom);
|
||||
@@ -14,4 +15,5 @@ export const useBindAtoms = (mx: MatrixClient) => {
|
||||
useBindRoomToUnreadAtom(mx, roomToUnreadAtom);
|
||||
|
||||
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
|
||||
useAutoJoinSpaceRooms(mx);
|
||||
};
|
||||
|
||||
37
src/app/state/joiningProgress.ts
Normal file
37
src/app/state/joiningProgress.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
/**
|
||||
* Represents the progress of auto-joining rooms in a space
|
||||
*/
|
||||
export interface JoiningProgress {
|
||||
/** Total number of rooms/spaces to join */
|
||||
total: number;
|
||||
/** Number of rooms/spaces already processed */
|
||||
current: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atom that tracks the progress of auto-joining rooms per space.
|
||||
* Key is the space ID, value is the progress.
|
||||
* When joining is complete, the entry is removed.
|
||||
*/
|
||||
export const joiningProgressAtom = atom<Map<string, JoiningProgress>>(new Map());
|
||||
|
||||
/**
|
||||
* Writable atom to update joining progress for a specific space
|
||||
*/
|
||||
export const updateJoiningProgressAtom = atom(
|
||||
null,
|
||||
(get, set, update: { spaceId: string; progress: JoiningProgress | null }) => {
|
||||
const current = get(joiningProgressAtom);
|
||||
const newMap = new Map(current);
|
||||
|
||||
if (update.progress === null) {
|
||||
newMap.delete(update.spaceId);
|
||||
} else {
|
||||
newMap.set(update.spaceId, update.progress);
|
||||
}
|
||||
|
||||
set(joiningProgressAtom, newMap);
|
||||
}
|
||||
);
|
||||
@@ -28,6 +28,7 @@ export interface Settings {
|
||||
hideActivity: boolean;
|
||||
|
||||
isPeopleDrawer: boolean;
|
||||
isCallPanelDocked: boolean;
|
||||
memberSortFilterIndex: number;
|
||||
enterForNewline: boolean;
|
||||
messageLayout: MessageLayout;
|
||||
@@ -43,10 +44,14 @@ export interface Settings {
|
||||
showNotifications: boolean;
|
||||
isNotificationSounds: boolean;
|
||||
|
||||
showRemoteCursor: boolean;
|
||||
|
||||
hour24Clock: boolean;
|
||||
dateFormatString: string;
|
||||
|
||||
developerTools: boolean;
|
||||
|
||||
autoJoinSpaceRooms: boolean;
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
@@ -62,6 +67,7 @@ const defaultSettings: Settings = {
|
||||
hideActivity: false,
|
||||
|
||||
isPeopleDrawer: true,
|
||||
isCallPanelDocked: false,
|
||||
memberSortFilterIndex: 0,
|
||||
enterForNewline: false,
|
||||
messageLayout: 0,
|
||||
@@ -77,10 +83,14 @@ const defaultSettings: Settings = {
|
||||
showNotifications: true,
|
||||
isNotificationSounds: true,
|
||||
|
||||
showRemoteCursor: true,
|
||||
|
||||
hour24Clock: false,
|
||||
dateFormatString: 'D MMM YYYY',
|
||||
|
||||
developerTools: false,
|
||||
|
||||
autoJoinSpaceRooms: true,
|
||||
};
|
||||
|
||||
export const getSettings = () => {
|
||||
|
||||
@@ -39,6 +39,9 @@ export enum StateEvent {
|
||||
PoniesRoomEmotes = 'im.ponies.room_emotes',
|
||||
PowerLevelTags = 'in.cinny.room.power_level_tags',
|
||||
|
||||
/** Room-wide link embed filter patterns (admin-controlled) */
|
||||
RoomEmbedFilters = 'im.paarrot.room.embed_filters',
|
||||
|
||||
/** Matrix RTC call membership (MSC3401) */
|
||||
CallMember = 'org.matrix.msc3401.call.member',
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ export default defineConfig({
|
||||
injectionPoint: undefined,
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
type: 'module'
|
||||
}
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user