43f4ceb45d
Phase C.1 of the protocol-gaps roadmap, gate-green (693 tests). Generalizes the Element Call widget host into a general room-widget feature: - StateEvent.Widget + widgetsPanelAtom + useRoomWidgets (WidgetParser). - RoomWidgetView: sandboxed-iframe host via ClientWidgetApi with a conservative GeneralWidgetDriver (approves only benign display caps — no room-event send/read/to-device). Blocks same-origin widget URLs (sandbox breakout guard). - WidgetsPanel: list / open / add / remove, PL-gated on im.vector.modular.widgets, https + non-same-origin URL validation. Mounted like the media gallery (header toggle + 3-way content-panel exclusivity + mobile full-screen overlay). - Tested URL/capability/id helpers. Requires the prod CSP frame-src widening (matrix repo) for external widgets. v1 cuts (capability consent prompt, Jitsi/sticker types, user widgets) noted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
277 lines
9.2 KiB
TypeScript
277 lines
9.2 KiB
TypeScript
import React, { FormEventHandler, useState } from 'react';
|
||
import {
|
||
Box,
|
||
Button,
|
||
Header,
|
||
Icon,
|
||
IconButton,
|
||
Icons,
|
||
Input,
|
||
Scroll,
|
||
Spinner,
|
||
Text,
|
||
Tooltip,
|
||
TooltipProvider,
|
||
color,
|
||
config,
|
||
} from 'folds';
|
||
import { Room } from 'matrix-js-sdk';
|
||
import classNames from 'classnames';
|
||
import * as css from './WidgetsPanel.css';
|
||
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
||
import { RoomWidgetView } from './RoomWidgetView';
|
||
import { useRoomWidgets } from './useRoomWidgets';
|
||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||
import { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
|
||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||
import { StateEvent } from '../../../../types/matrix/room';
|
||
import { generateWidgetId, validateWidgetUrl, WidgetUrlError } from './widgetUtils';
|
||
|
||
const urlErrorMessage = (err: WidgetUrlError): string => {
|
||
switch (err) {
|
||
case 'empty':
|
||
return 'Enter a widget URL.';
|
||
case 'not-https':
|
||
return 'Widget URLs must use https.';
|
||
case 'same-origin':
|
||
return 'That URL is not allowed (it is on this app’s own origin).';
|
||
default:
|
||
return 'That is not a valid URL.';
|
||
}
|
||
};
|
||
|
||
type WidgetsPanelProps = {
|
||
room: Room;
|
||
requestClose: () => void;
|
||
};
|
||
export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
|
||
const mx = useMatrixClient();
|
||
const widgets = useRoomWidgets(room);
|
||
const powerLevels = usePowerLevelsContext();
|
||
const creators = useRoomCreators(room);
|
||
const permissions = useRoomPermissions(creators, powerLevels);
|
||
const canModify = permissions.stateEvent(StateEvent.Widget, mx.getSafeUserId());
|
||
|
||
const [viewingId, setViewingId] = useState<string | null>(null);
|
||
const [adding, setAdding] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string>();
|
||
|
||
const viewing = widgets.find((w) => w.id === viewingId) ?? null;
|
||
|
||
const handleAdd: FormEventHandler<HTMLFormElement> = async (evt) => {
|
||
evt.preventDefault();
|
||
const target = evt.target as HTMLFormElement;
|
||
const nameInput = target.elements.namedItem('widgetName') as HTMLInputElement | null;
|
||
const urlInput = target.elements.namedItem('widgetUrl') as HTMLInputElement | null;
|
||
if (!urlInput) return;
|
||
const urlErr = validateWidgetUrl(urlInput.value, window.location.origin);
|
||
if (urlErr) {
|
||
setError(urlErrorMessage(urlErr));
|
||
return;
|
||
}
|
||
setError(undefined);
|
||
setSaving(true);
|
||
const id = generateWidgetId();
|
||
const content = {
|
||
id,
|
||
type: 'm.custom',
|
||
url: urlInput.value.trim(),
|
||
name: nameInput?.value.trim() || 'Widget',
|
||
creatorUserId: mx.getSafeUserId(),
|
||
data: {},
|
||
};
|
||
try {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
await mx.sendStateEvent(room.roomId, StateEvent.Widget as any, content as any, id);
|
||
setAdding(false);
|
||
} catch (e) {
|
||
setError((e as Error).message);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleRemove = (id: string) => {
|
||
if (viewingId === id) setViewingId(null);
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
mx.sendStateEvent(room.roomId, StateEvent.Widget as any, {} as any, id).catch(() => undefined);
|
||
};
|
||
|
||
return (
|
||
<Box
|
||
shrink="No"
|
||
className={classNames(css.WidgetsPanel, ContainerColor({ variant: 'Surface' }))}
|
||
direction="Column"
|
||
>
|
||
<Header className={css.WidgetsPanelHeader} variant="Background" size="600">
|
||
<Box grow="Yes" alignItems="Center" gap="200">
|
||
<Box grow="Yes" direction="Column">
|
||
<Text size="H5" truncate>
|
||
Widgets
|
||
</Text>
|
||
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
||
{room.name}
|
||
</Text>
|
||
</Box>
|
||
<Box shrink="No">
|
||
<TooltipProvider
|
||
position="Bottom"
|
||
align="End"
|
||
offset={4}
|
||
tooltip={
|
||
<Tooltip>
|
||
<Text>Close</Text>
|
||
</Tooltip>
|
||
}
|
||
>
|
||
{(triggerRef) => (
|
||
<IconButton
|
||
ref={triggerRef}
|
||
variant="Background"
|
||
aria-label="Close widgets"
|
||
onClick={requestClose}
|
||
>
|
||
<Icon src={Icons.Cross} />
|
||
</IconButton>
|
||
)}
|
||
</TooltipProvider>
|
||
</Box>
|
||
</Box>
|
||
</Header>
|
||
|
||
<Box grow="Yes" className={css.WidgetsPanelContent}>
|
||
{viewing ? (
|
||
<Box grow="Yes" direction="Column">
|
||
<Box shrink="No" style={{ padding: config.space.S200 }}>
|
||
<Button
|
||
variant="Secondary"
|
||
fill="Soft"
|
||
size="300"
|
||
radii="300"
|
||
onClick={() => setViewingId(null)}
|
||
before={<Icon size="100" src={Icons.ArrowLeft} />}
|
||
>
|
||
<Text size="B300" truncate>
|
||
{viewing.name || 'Widget'}
|
||
</Text>
|
||
</Button>
|
||
</Box>
|
||
<RoomWidgetView room={room} widget={viewing} />
|
||
</Box>
|
||
) : (
|
||
<Scroll hideTrack visibility="Hover">
|
||
<Box direction="Column" gap="200" style={{ padding: config.space.S300 }}>
|
||
{widgets.length === 0 && (
|
||
<Text size="T200" style={{ opacity: 0.65 }}>
|
||
No widgets in this room yet.
|
||
</Text>
|
||
)}
|
||
{widgets.map((widget) => (
|
||
<Box key={widget.id} alignItems="Center" gap="200">
|
||
<Box
|
||
as="button"
|
||
type="button"
|
||
grow="Yes"
|
||
alignItems="Center"
|
||
gap="200"
|
||
onClick={() => setViewingId(widget.id)}
|
||
style={{ cursor: 'pointer', minWidth: 0 }}
|
||
>
|
||
<Icon size="100" src={Icons.Category} />
|
||
<Text size="T300" truncate>
|
||
{widget.name || widget.templateUrl}
|
||
</Text>
|
||
</Box>
|
||
{canModify && (
|
||
<IconButton
|
||
size="300"
|
||
radii="300"
|
||
variant="Background"
|
||
aria-label={`Remove ${widget.name || 'widget'}`}
|
||
onClick={() => handleRemove(widget.id)}
|
||
>
|
||
<Icon size="100" src={Icons.Delete} />
|
||
</IconButton>
|
||
)}
|
||
</Box>
|
||
))}
|
||
|
||
{canModify &&
|
||
(adding ? (
|
||
<Box
|
||
as="form"
|
||
direction="Column"
|
||
gap="200"
|
||
onSubmit={handleAdd}
|
||
style={{ marginTop: config.space.S200 }}
|
||
>
|
||
<Input
|
||
name="widgetName"
|
||
placeholder="Name (optional)"
|
||
variant="Secondary"
|
||
radii="300"
|
||
/>
|
||
<Input
|
||
name="widgetUrl"
|
||
placeholder="https://…"
|
||
variant="Secondary"
|
||
radii="300"
|
||
required
|
||
/>
|
||
<Box gap="200">
|
||
<Button
|
||
type="submit"
|
||
size="300"
|
||
variant="Primary"
|
||
fill="Solid"
|
||
radii="300"
|
||
disabled={saving}
|
||
before={
|
||
saving ? <Spinner size="100" variant="Primary" fill="Solid" /> : undefined
|
||
}
|
||
>
|
||
<Text size="B300">Add</Text>
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="300"
|
||
variant="Secondary"
|
||
fill="Soft"
|
||
radii="300"
|
||
onClick={() => {
|
||
setAdding(false);
|
||
setError(undefined);
|
||
}}
|
||
>
|
||
<Text size="B300">Cancel</Text>
|
||
</Button>
|
||
</Box>
|
||
</Box>
|
||
) : (
|
||
<Button
|
||
variant="Secondary"
|
||
fill="Soft"
|
||
size="300"
|
||
radii="300"
|
||
onClick={() => setAdding(true)}
|
||
before={<Icon size="100" src={Icons.Plus} />}
|
||
style={{ marginTop: config.space.S200 }}
|
||
>
|
||
<Text size="B300">Add Widget</Text>
|
||
</Button>
|
||
))}
|
||
{error && (
|
||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||
{error}
|
||
</Text>
|
||
)}
|
||
</Box>
|
||
</Scroll>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
);
|
||
}
|