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:
2026-02-19 17:49:48 +11:00
parent bfbdf98468
commit 3f6f2134ad
42 changed files with 3801 additions and 219 deletions

View 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>
);
}

View File

@@ -1,3 +1,4 @@
export * from './EmbedFilters';
export * from './RoomAddress';
export * from './RoomEncryption';
export * from './RoomHistoryVisibility';