feat(rooms): Room Widgets (MSC1236 im.vector.modular.widgets)
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>
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user