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:
2026-07-03 13:27:23 -04:00
parent 17bd50cc4e
commit 43f4ceb45d
13 changed files with 584 additions and 13 deletions
+2
View File
@@ -675,6 +675,8 @@ Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view,
## Outstanding verification backlog ## Outstanding verification backlog
**Room Widgets (MSC1236, 2026-07 — needs the CSP `frame-src` widening + `nginx -s reload` first):** In a room, the header **Widgets** button (grid icon, desktop) opens a right-side panel. As an admin (PL to modify widgets): **Add Widget** with a name + an https URL (e.g. an Etherpad `https://…` or any embeddable page) → it appears in the list; click it → it renders in a sandboxed iframe in the panel; **Remove** clears it. A non-admin sees the list + can open widgets but has no Add/Remove. Check: a non-https or same-origin URL is rejected on Add with a clear message; the panel is a full-screen overlay on mobile and is mutually exclusive with the Thread/Gallery/Members panels; if a widget stays blank, the prod CSP `frame-src` still needs widening. Widgets get only benign display capabilities (they can't send/read room events in v1).
**QR Device Verification (2026-07):** With two logged-in Lotus sessions (or Lotus + Element), start a device verification. On the **Ready** step you now see your own QR code plus a **"Scan their QR code"** button and a **"Verify with emoji instead"** fallback. Have one device **scan** the other's code (grant camera permission) → the showing device asks you to **Confirm**, and both reach **verified**. Check: emoji-SAS still works unchanged; denying camera shows a graceful "verify with emojis instead" message; a deliberately-wrong scan cancels cleanly. Desktop (WebView2) auto-grants the camera; web needs the Permissions-Policy camera allowance (already set). **QR Device Verification (2026-07):** With two logged-in Lotus sessions (or Lotus + Element), start a device verification. On the **Ready** step you now see your own QR code plus a **"Scan their QR code"** button and a **"Verify with emoji instead"** fallback. Have one device **scan** the other's code (grant camera permission) → the showing device asks you to **Confirm**, and both reach **verified**. Check: emoji-SAS still works unchanged; denying camera shows a graceful "verify with emojis instead" message; a deliberately-wrong scan cancels cleanly. Desktop (WebView2) auto-grants the camera; web needs the Permissions-Policy camera allowance (already set).
**Disappearing Messages (MSC1763 `m.room.retention`, 2026-07):** In Room Settings → General → **Message Retention**, an admin picks Off / 1 Day / 1 Week / 1 Month (non-admins see the buttons disabled). After setting e.g. 1 Day, messages older than a day **vanish from the timeline** for everyone in Lotus (toggle Settings → General → **Show Hidden Events** to reveal them again). Setting back to **Off** restores them. Separately, each user can enable Settings → General → **Enforce Message Retention** (default OFF) → their OWN expired messages then get **permanently redacted** within ~30 s (verify: OTHER people's messages are NEVER redacted by this; only your own). Note true server-side purge also needs Synapse `retention:` configured. **Disappearing Messages (MSC1763 `m.room.retention`, 2026-07):** In Room Settings → General → **Message Retention**, an admin picks Off / 1 Day / 1 Week / 1 Month (non-admins see the buttons disabled). After setting e.g. 1 Day, messages older than a day **vanish from the timeline** for everyone in Lotus (toggle Settings → General → **Show Hidden Events** to reveal them again). Setting back to **Off** restores them. Separately, each user can enable Settings → General → **Enforce Message Retention** (default OFF) → their OWN expired messages then get **permanently redacted** within ~30 s (verify: OTHER people's messages are NEVER redacted by this; only your own). Note true server-side purge also needs Synapse `retention:` configured.
+4 -2
View File
@@ -105,11 +105,13 @@ Genuine Matrix client-spec / MSC features Lotus does **not** yet implement (audi
- [x] **Disappearing Messages — MSC1763 `m.room.retention`.** PL-gated room-settings `SettingTile` to set `{ max_lifetime }`; retention badge; a client-side sweep hides/self-redacts own expired events (pattern like the mute-timer restore in `ClientNonUIFeatures.tsx`). True server deletion also wants Synapse `retention:` (LXC 151). - [x] **Disappearing Messages — MSC1763 `m.room.retention`.** PL-gated room-settings `SettingTile` to set `{ max_lifetime }`; retention badge; a client-side sweep hides/self-redacts own expired events (pattern like the mute-timer restore in `ClientNonUIFeatures.tsx`). True server deletion also wants Synapse `retention:` (LXC 151).
- [x] **QR Device Verification — reciprocate QR.** Add the QR path beside emoji-SAS in `components/DeviceVerification.tsx`: render with `qrcode.react` (already a dep), scan via `BarcodeDetector` (fallback `jsQR`); uses the SDK `VerificationRequest` QR/reciprocate support. - [x] **QR Device Verification — reciprocate QR.** Add the QR path beside emoji-SAS in `components/DeviceVerification.tsx`: render with `qrcode.react` (already a dep), scan via `BarcodeDetector` (fallback `jsQR`); uses the SDK `VerificationRequest` QR/reciprocate support.
**Phase C (large — each its own planning session):** **Phase C (Room Widgets ✅ 2026-07; Sliding Sync pending — its own session):**
- [ ] **Room Widgets — MSC1236 + widget API.** No general widget UI exists (only the PL entry `im.vector.modular.widgets`; the EC call widget is hardcoded). Read `im.vector.modular.widgets`/`m.widget` state, add an Add/Manage panel + sandboxed iframe renderer via `matrix-widget-api`**extend the existing EC widget plumbing** (`plugins/call/CallEmbed.ts`). Enables Etherpad/notes/dashboards/integrations. - [x] **Room Widgets — MSC1236 + widget API.** No general widget UI exists (only the PL entry `im.vector.modular.widgets`; the EC call widget is hardcoded). Read `im.vector.modular.widgets`/`m.widget` state, add an Add/Manage panel + sandboxed iframe renderer via `matrix-widget-api`**extend the existing EC widget plumbing** (`plugins/call/CallEmbed.ts`). Enables Etherpad/notes/dashboards/integrations.
- [ ] **Sliding Sync — MSC3575 / simplified MSC4186.** Lotus is on **legacy full `/sync`** though the server advertises `simplified_msc3575`. matrix-js-sdk ships `SlidingSync`; migration → near-instant cold start + low memory + huge-account scale. Touches the sync/room-list/spaces/unread core — behind a feature flag with a legacy fallback. **Plan separately before touching.** - [ ] **Sliding Sync — MSC3575 / simplified MSC4186.** Lotus is on **legacy full `/sync`** though the server advertises `simplified_msc3575`. matrix-js-sdk ships `SlidingSync`; migration → near-instant cold start + low memory + huge-account scale. Touches the sync/room-list/spaces/unread core — behind a feature flag with a legacy fallback. **Plan separately before touching.**
**Room Widgets v1 follow-ups:** capability-approval consent prompt (let widgets request send/read room events); Jitsi/stickerpicker special types; account-data (user/sticker) widgets; per-widget popout / always-on-screen. Requires the prod CSP `frame-src` widening (done in `matrix/cinny/nginx.conf`**`nginx -s reload`**) or external widgets are blocked.
**Server-gated / advanced (capture, don't build yet):** QR sign-in for a new device (**MSC4108** rendezvous — needs an HS-side endpoint); dehydrated devices (**MSC3814** — offline key delivery, also helps the E2EE KE cluster); E2EE history key sharing on invite (**MSC3061** `shared_history`, niche); voice broadcast (Element MSC3888, low value — skip). **Server-gated / advanced (capture, don't build yet):** QR sign-in for a new device (**MSC4108** rendezvous — needs an HS-side endpoint); dehydrated devices (**MSC3814** — offline key delivery, also helps the E2EE KE cluster); E2EE history key sharing on invite (**MSC3061** `shared_history`, niche); voice broadcast (Element MSC3888, low value — skip).
--- ---
+37 -11
View File
@@ -7,6 +7,8 @@ import { RoomView } from './RoomView';
import { MembersDrawer } from './MembersDrawer'; import { MembersDrawer } from './MembersDrawer';
import { MediaGallery } from './MediaGallery'; import { MediaGallery } from './MediaGallery';
import { mediaGalleryAtom } from '../../state/mediaGallery'; import { mediaGalleryAtom } from '../../state/mediaGallery';
import { WidgetsPanel } from './widgets/WidgetsPanel';
import { widgetsPanelAtom } from '../../state/widgetsPanel';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
@@ -39,6 +41,8 @@ export function Room() {
const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId)); const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId));
const galleryOpen = useAtomValue(mediaGalleryAtom); const galleryOpen = useAtomValue(mediaGalleryAtom);
const setGalleryOpen = useSetAtom(mediaGalleryAtom); const setGalleryOpen = useSetAtom(mediaGalleryAtom);
const widgetsOpen = useAtomValue(widgetsPanelAtom);
const setWidgetsOpen = useSetAtom(widgetsPanelAtom);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
const powerLevels = usePowerLevels(room); const powerLevels = usePowerLevels(room);
@@ -64,30 +68,40 @@ export function Room() {
const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0; const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0;
// Thread panel and media gallery are mutually exclusive on every screen size: // The content panels (thread / media gallery / widgets) are mutually exclusive
// opening one closes the other. Detect the just-opened transition so whichever // on every screen size: opening one closes the others. Detect the just-opened
// was opened most recently wins. // transition so whichever was opened most recently wins.
const prevThreadRef = useRef(activeThreadId); const prevThreadRef = useRef(activeThreadId);
const prevGalleryRef = useRef(galleryOpen); const prevGalleryRef = useRef(galleryOpen);
const prevWidgetsRef = useRef(widgetsOpen);
useEffect(() => { useEffect(() => {
const threadJustOpened = Boolean(activeThreadId) && !prevThreadRef.current; const threadJustOpened = Boolean(activeThreadId) && !prevThreadRef.current;
const galleryJustOpened = galleryOpen && !prevGalleryRef.current; const galleryJustOpened = galleryOpen && !prevGalleryRef.current;
if (threadJustOpened && galleryOpen) { const widgetsJustOpened = widgetsOpen && !prevWidgetsRef.current;
setGalleryOpen(false); if (threadJustOpened) {
} else if (galleryJustOpened && activeThreadId) { if (galleryOpen) setGalleryOpen(false);
setActiveThreadId(null); if (widgetsOpen) setWidgetsOpen(false);
} else if (galleryJustOpened) {
if (activeThreadId) setActiveThreadId(null);
if (widgetsOpen) setWidgetsOpen(false);
} else if (widgetsJustOpened) {
if (activeThreadId) setActiveThreadId(null);
if (galleryOpen) setGalleryOpen(false);
} }
prevThreadRef.current = activeThreadId; prevThreadRef.current = activeThreadId;
prevGalleryRef.current = galleryOpen; prevGalleryRef.current = galleryOpen;
}, [activeThreadId, galleryOpen, setGalleryOpen, setActiveThreadId]); prevWidgetsRef.current = widgetsOpen;
}, [activeThreadId, galleryOpen, widgetsOpen, setGalleryOpen, setActiveThreadId, setWidgetsOpen]);
// On non-desktop screens at most one right-side panel may show, priority // On non-desktop screens at most one right-side panel may show, priority
// thread > gallery > members. On desktop thread + members may coexist while // thread > gallery > widgets > members. On desktop thread + members may coexist
// thread + gallery stay mutually exclusive (via the effect above). // while the content panels stay mutually exclusive (via the effect above).
const isDesktop = screenSize === ScreenSize.Desktop; const isDesktop = screenSize === ScreenSize.Desktop;
const showThreadPanel = !callView && Boolean(activeThreadId); const showThreadPanel = !callView && Boolean(activeThreadId);
const showGallery = !callView && galleryOpen && (isDesktop || !activeThreadId); const showGallery = !callView && galleryOpen && (isDesktop || !activeThreadId);
const showMembers = !callView && isDrawer && (isDesktop || (!activeThreadId && !galleryOpen)); const showWidgets = !callView && widgetsOpen && (isDesktop || (!activeThreadId && !galleryOpen));
const showMembers =
!callView && isDrawer && (isDesktop || (!activeThreadId && !galleryOpen && !widgetsOpen));
return ( return (
<PowerLevelsContextProvider value={powerLevels}> <PowerLevelsContextProvider value={powerLevels}>
@@ -125,6 +139,18 @@ export function Room() {
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} /> <MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
</> </>
)} )}
{showWidgets && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<WidgetsPanel
key={room.roomId}
room={room}
requestClose={() => setWidgetsOpen(false)}
/>
</>
)}
{showThreadPanel && activeThreadId && ( {showThreadPanel && activeThreadId && (
<> <>
{screenSize === ScreenSize.Desktop && ( {screenSize === ScreenSize.Desktop && (
+25
View File
@@ -74,6 +74,7 @@ import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { useLivekitSupport } from '../../hooks/useLivekitSupport'; import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc'; import { webRTCSupported } from '../../utils/rtc';
import { mediaGalleryAtom } from '../../state/mediaGallery'; import { mediaGalleryAtom } from '../../state/mediaGallery';
import { widgetsPanelAtom } from '../../state/widgetsPanel';
import { usePendingKnocks } from '../../hooks/usePendingKnocks'; import { usePendingKnocks } from '../../hooks/usePendingKnocks';
import { bookmarksPanelAtom } from '../../state/bookmarksPanel'; import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
@@ -489,6 +490,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [galleryOpen, setGalleryOpen] = useAtom(mediaGalleryAtom); const [galleryOpen, setGalleryOpen] = useAtom(mediaGalleryAtom);
const [widgetsOpen, setWidgetsOpen] = useAtom(widgetsPanelAtom);
const pendingKnocks = usePendingKnocks(room); const pendingKnocks = usePendingKnocks(room);
const handleSearchClick = () => { const handleSearchClick = () => {
@@ -725,6 +727,29 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
)} )}
</TooltipProvider> </TooltipProvider>
)} )}
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>{widgetsOpen ? 'Hide Widgets' : 'Widgets'}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
ref={triggerRef}
onClick={() => setWidgetsOpen(!widgetsOpen)}
aria-label="Toggle widgets"
aria-pressed={widgetsOpen}
>
<Icon size="400" src={Icons.Category} filled={widgetsOpen} />
</IconButton>
)}
</TooltipProvider>
)}
{screenSize === ScreenSize.Desktop && ( {screenSize === ScreenSize.Desktop && (
<TooltipProvider <TooltipProvider
position="Bottom" position="Bottom"
@@ -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&apos;t be loaded because its URL is on this app&apos;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 apps 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)}`;
+4
View File
@@ -0,0 +1,4 @@
import { atom } from 'jotai';
// Whether the room's Widgets side-panel is open (mirrors mediaGalleryAtom).
export const widgetsPanelAtom = atom<boolean>(false);
+3
View File
@@ -27,6 +27,9 @@ export enum StateEvent {
RoomTopic = 'm.room.topic', RoomTopic = 'm.room.topic',
RoomAvatar = 'm.room.avatar', RoomAvatar = 'm.room.avatar',
RoomPinnedEvents = 'm.room.pinned_events', RoomPinnedEvents = 'm.room.pinned_events',
// [MSC1236] Room widgets (embedded apps). One state event per widget,
// state_key = widget id; content is a matrix-widget-api IWidget.
Widget = 'im.vector.modular.widgets',
RoomEncryption = 'm.room.encryption', RoomEncryption = 'm.room.encryption',
RoomHistoryVisibility = 'm.room.history_visibility', RoomHistoryVisibility = 'm.room.history_visibility',
// [MSC1763] Per-room message retention policy (disappearing messages). // [MSC1763] Per-room message retention policy (disappearing messages).