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,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 }} />;
|
||||
}
|
||||
Reference in New Issue
Block a user