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,15 @@
|
||||
import { type Capability, WidgetDriver } from 'matrix-widget-api';
|
||||
import { filterWidgetCapabilities } from './widgetUtils';
|
||||
|
||||
// A minimal, conservative WidgetDriver for general room widgets. It only narrows
|
||||
// the capabilities a widget may hold (to a benign display-only subset — see
|
||||
// widgetUtils). All data-access methods (sendEvent / readRoomState / sendToDevice
|
||||
// / uploads …) are inherited from the base WidgetDriver and are never reached,
|
||||
// because the capabilities that would gate them are denied here. A richer,
|
||||
// consent-prompt-driven driver is a follow-up.
|
||||
export class GeneralWidgetDriver extends WidgetDriver {
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> {
|
||||
return filterWidgetCapabilities(requested);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Box, Icon, Icons, Text, color } from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { ClientWidgetApi, Widget } from 'matrix-widget-api';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { GeneralWidgetDriver } from './GeneralWidgetDriver';
|
||||
import { isWidgetUrlSafe } from './widgetUtils';
|
||||
|
||||
type RoomWidgetViewProps = {
|
||||
room: Room;
|
||||
widget: Widget;
|
||||
};
|
||||
|
||||
// Hosts one room widget in a sandboxed iframe via ClientWidgetApi (so widgets
|
||||
// that wait on the client handshake load), with a conservative capability driver.
|
||||
// Re-mounts only when the widget id or its (template) URL changes — not on every
|
||||
// unrelated room-state update — so viewing a widget doesn't reload constantly.
|
||||
export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
||||
const mx = useMatrixClient();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const widgetRef = useRef(widget);
|
||||
widgetRef.current = widget;
|
||||
const [blocked, setBlocked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return undefined;
|
||||
const current = widgetRef.current;
|
||||
|
||||
const completeUrl = current.getCompleteUrl({
|
||||
currentUserId: mx.getSafeUserId(),
|
||||
widgetRoomId: room.roomId,
|
||||
deviceId: mx.getDeviceId() ?? undefined,
|
||||
baseUrl: mx.baseUrl,
|
||||
});
|
||||
|
||||
// Security: never render a same-origin widget with allow-same-origin (a
|
||||
// same-origin frame could break out of the sandbox against our own origin).
|
||||
if (!isWidgetUrlSafe(completeUrl, window.location.origin)) {
|
||||
setBlocked(true);
|
||||
return undefined;
|
||||
}
|
||||
setBlocked(false);
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.title = current.name || 'Widget';
|
||||
iframe.sandbox.value =
|
||||
'allow-forms allow-scripts allow-same-origin allow-popups allow-downloads';
|
||||
iframe.allow = 'autoplay; clipboard-write;';
|
||||
iframe.src = completeUrl;
|
||||
iframe.style.width = '100%';
|
||||
iframe.style.height = '100%';
|
||||
iframe.style.border = 'none';
|
||||
container.append(iframe);
|
||||
|
||||
const clientApi = new ClientWidgetApi(current, iframe, new GeneralWidgetDriver());
|
||||
clientApi.setViewedRoomId(room.roomId);
|
||||
|
||||
return () => {
|
||||
clientApi.stop();
|
||||
iframe.remove();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mx, room.roomId, widget.id, widget.templateUrl]);
|
||||
|
||||
if (blocked) {
|
||||
return (
|
||||
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
|
||||
<Icon size="400" src={Icons.Warning} style={{ color: color.Warning.Main }} />
|
||||
<Text size="T300" align="Center">
|
||||
This widget can't be loaded because its URL is on this app's own origin.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <Box ref={containerRef} grow="Yes" style={{ height: '100%', minHeight: 0 }} />;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { config, toRem } from 'folds';
|
||||
|
||||
export const WidgetsPanel = style({
|
||||
width: toRem(360),
|
||||
'@media': {
|
||||
'(max-width: 750px)': {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
zIndex: 500,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const WidgetsPanelHeader = style({
|
||||
flexShrink: 0,
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
});
|
||||
|
||||
export const WidgetsPanelContent = style({
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useMemo } from 'react';
|
||||
import { Widget, WidgetParser, IStateEvent } from 'matrix-widget-api';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { useRoomState } from '../../../hooks/useRoomState';
|
||||
|
||||
/**
|
||||
* All valid `im.vector.modular.widgets` room widgets, reactive on room state.
|
||||
* `WidgetParser` drops empty/removed (`{}`) and malformed entries.
|
||||
*/
|
||||
export const useRoomWidgets = (room: Room): Widget[] => {
|
||||
const state = useRoomState(room);
|
||||
return useMemo(() => {
|
||||
const widgetEvents = state.get(StateEvent.Widget);
|
||||
if (!widgetEvents) return [];
|
||||
const stateEvents = Array.from(widgetEvents.values()).map(
|
||||
(event) => event.getEffectiveEvent() as unknown as IStateEvent,
|
||||
);
|
||||
return WidgetParser.parseWidgetsFromRoomState(stateEvents);
|
||||
}, [state]);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MatrixCapabilities, Capability } from 'matrix-widget-api';
|
||||
import {
|
||||
validateWidgetUrl,
|
||||
isWidgetUrlSafe,
|
||||
filterWidgetCapabilities,
|
||||
generateWidgetId,
|
||||
} from './widgetUtils';
|
||||
|
||||
const APP = 'https://chat.lotusguild.org';
|
||||
|
||||
test('validateWidgetUrl accepts a cross-origin https url', () => {
|
||||
assert.equal(validateWidgetUrl('https://pad.example.org/p/room', APP), undefined);
|
||||
});
|
||||
|
||||
test('validateWidgetUrl rejects empty / invalid / http / same-origin', () => {
|
||||
assert.equal(validateWidgetUrl(' ', APP), 'empty');
|
||||
assert.equal(validateWidgetUrl('not a url', APP), 'invalid');
|
||||
assert.equal(validateWidgetUrl('http://example.org', APP), 'not-https');
|
||||
assert.equal(validateWidgetUrl('https://chat.lotusguild.org/evil', APP), 'same-origin');
|
||||
});
|
||||
|
||||
test('isWidgetUrlSafe rejects same-origin + garbage, accepts cross-origin', () => {
|
||||
assert.equal(isWidgetUrlSafe('https://chat.lotusguild.org/x', APP), false);
|
||||
assert.equal(isWidgetUrlSafe('https://other.example/x', APP), true);
|
||||
assert.equal(isWidgetUrlSafe('garbage', APP), false);
|
||||
});
|
||||
|
||||
test('filterWidgetCapabilities keeps only the benign allowlist', () => {
|
||||
const requested = new Set<Capability>([
|
||||
MatrixCapabilities.AlwaysOnScreen,
|
||||
'm.send.event:m.room.message',
|
||||
'org.matrix.msc2762.receive.state_event:m.room.member',
|
||||
MatrixCapabilities.Screenshots,
|
||||
]);
|
||||
const allowed = filterWidgetCapabilities(requested);
|
||||
assert.ok(allowed.has(MatrixCapabilities.AlwaysOnScreen));
|
||||
assert.ok(allowed.has(MatrixCapabilities.Screenshots));
|
||||
assert.equal(allowed.has('m.send.event:m.room.message'), false);
|
||||
assert.equal(allowed.size, 2);
|
||||
});
|
||||
|
||||
test('generateWidgetId is prefixed and unique across calls', () => {
|
||||
const a = generateWidgetId();
|
||||
const b = generateWidgetId();
|
||||
assert.match(a, /^lotus_/);
|
||||
assert.notEqual(a, b);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Capability, MatrixCapabilities } from 'matrix-widget-api';
|
||||
|
||||
// Conservative v1 capability policy: approve only benign display capabilities.
|
||||
// Everything else (room-event send/receive, to-device, uploads, user-directory,
|
||||
// delayed events, TURN servers) is denied — a random widget must not be able to
|
||||
// act as the user or read room data without an explicit consent flow (follow-up).
|
||||
export const ALLOWED_WIDGET_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
|
||||
MatrixCapabilities.AlwaysOnScreen,
|
||||
MatrixCapabilities.RequiresClient,
|
||||
MatrixCapabilities.Screenshots,
|
||||
]);
|
||||
|
||||
export const filterWidgetCapabilities = (requested: Set<Capability>): Set<Capability> =>
|
||||
new Set([...requested].filter((cap) => ALLOWED_WIDGET_CAPABILITIES.has(cap)));
|
||||
|
||||
export type WidgetUrlError = 'empty' | 'invalid' | 'not-https' | 'same-origin';
|
||||
|
||||
// A widget URL to ADD must be https and NOT our own origin: a same-origin frame
|
||||
// with allow-same-origin + allow-scripts can break out of the sandbox against us.
|
||||
export const validateWidgetUrl = (raw: string, appOrigin: string): WidgetUrlError | undefined => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return 'empty';
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(trimmed);
|
||||
} catch {
|
||||
return 'invalid';
|
||||
}
|
||||
if (url.protocol !== 'https:') return 'not-https';
|
||||
if (url.origin === appOrigin) return 'same-origin';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Is an already-resolved (complete) widget URL safe to render in a sandboxed
|
||||
// iframe that carries allow-same-origin? Rejects same-origin URLs (breakout).
|
||||
export const isWidgetUrlSafe = (completeUrl: string, appOrigin: string): boolean => {
|
||||
try {
|
||||
return new URL(completeUrl).origin !== appOrigin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const generateWidgetId = (): string =>
|
||||
`lotus_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
Reference in New Issue
Block a user