From 43f4ceb45d30830d62d0eda8626c41a2c96cd96f Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 3 Jul 2026 13:27:23 -0400 Subject: [PATCH] feat(rooms): Room Widgets (MSC1236 im.vector.modular.widgets) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- LOTUS_TESTING.md | 2 + LOTUS_TODO.md | 6 +- src/app/features/room/Room.tsx | 48 ++- src/app/features/room/RoomViewHeader.tsx | 25 ++ .../room/widgets/GeneralWidgetDriver.ts | 15 + .../features/room/widgets/RoomWidgetView.tsx | 78 +++++ .../features/room/widgets/WidgetsPanel.css.ts | 25 ++ .../features/room/widgets/WidgetsPanel.tsx | 276 ++++++++++++++++++ .../features/room/widgets/useRoomWidgets.ts | 21 ++ .../features/room/widgets/widgetUtils.test.ts | 49 ++++ src/app/features/room/widgets/widgetUtils.ts | 45 +++ src/app/state/widgetsPanel.ts | 4 + src/types/matrix/room.ts | 3 + 13 files changed, 584 insertions(+), 13 deletions(-) create mode 100644 src/app/features/room/widgets/GeneralWidgetDriver.ts create mode 100644 src/app/features/room/widgets/RoomWidgetView.tsx create mode 100644 src/app/features/room/widgets/WidgetsPanel.css.ts create mode 100644 src/app/features/room/widgets/WidgetsPanel.tsx create mode 100644 src/app/features/room/widgets/useRoomWidgets.ts create mode 100644 src/app/features/room/widgets/widgetUtils.test.ts create mode 100644 src/app/features/room/widgets/widgetUtils.ts create mode 100644 src/app/state/widgetsPanel.ts diff --git a/LOTUS_TESTING.md b/LOTUS_TESTING.md index 0c41a5323..d7bb08219 100644 --- a/LOTUS_TESTING.md +++ b/LOTUS_TESTING.md @@ -675,6 +675,8 @@ Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view, ## 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). **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. diff --git a/LOTUS_TODO.md b/LOTUS_TODO.md index 89119fba8..2c3c0d07c 100644 --- a/LOTUS_TODO.md +++ b/LOTUS_TODO.md @@ -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] **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.** +**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). --- diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index d6f5021f0..8aba0051d 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -7,6 +7,8 @@ import { RoomView } from './RoomView'; import { MembersDrawer } from './MembersDrawer'; import { MediaGallery } from './MediaGallery'; import { mediaGalleryAtom } from '../../state/mediaGallery'; +import { WidgetsPanel } from './widgets/WidgetsPanel'; +import { widgetsPanelAtom } from '../../state/widgetsPanel'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; @@ -39,6 +41,8 @@ export function Room() { const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId)); const galleryOpen = useAtomValue(mediaGalleryAtom); const setGalleryOpen = useSetAtom(mediaGalleryAtom); + const widgetsOpen = useAtomValue(widgetsPanelAtom); + const setWidgetsOpen = useSetAtom(widgetsPanelAtom); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const screenSize = useScreenSizeContext(); const powerLevels = usePowerLevels(room); @@ -64,30 +68,40 @@ export function Room() { const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0; - // Thread panel and media gallery are mutually exclusive on every screen size: - // opening one closes the other. Detect the just-opened transition so whichever - // was opened most recently wins. + // The content panels (thread / media gallery / widgets) are mutually exclusive + // on every screen size: opening one closes the others. Detect the just-opened + // transition so whichever was opened most recently wins. const prevThreadRef = useRef(activeThreadId); const prevGalleryRef = useRef(galleryOpen); + const prevWidgetsRef = useRef(widgetsOpen); useEffect(() => { const threadJustOpened = Boolean(activeThreadId) && !prevThreadRef.current; const galleryJustOpened = galleryOpen && !prevGalleryRef.current; - if (threadJustOpened && galleryOpen) { - setGalleryOpen(false); - } else if (galleryJustOpened && activeThreadId) { - setActiveThreadId(null); + const widgetsJustOpened = widgetsOpen && !prevWidgetsRef.current; + if (threadJustOpened) { + if (galleryOpen) setGalleryOpen(false); + 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; 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 - // thread > gallery > members. On desktop thread + members may coexist while - // thread + gallery stay mutually exclusive (via the effect above). + // thread > gallery > widgets > members. On desktop thread + members may coexist + // while the content panels stay mutually exclusive (via the effect above). const isDesktop = screenSize === ScreenSize.Desktop; const showThreadPanel = !callView && Boolean(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 ( @@ -125,6 +139,18 @@ export function Room() { setGalleryOpen(false)} /> )} + {showWidgets && ( + <> + {screenSize === ScreenSize.Desktop && ( + + )} + setWidgetsOpen(false)} + /> + + )} {showThreadPanel && activeThreadId && ( <> {screenSize === ScreenSize.Desktop && ( diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index e3e0f77f2..3fa9ef645 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -74,6 +74,7 @@ import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed'; import { useLivekitSupport } from '../../hooks/useLivekitSupport'; import { webRTCSupported } from '../../utils/rtc'; import { mediaGalleryAtom } from '../../state/mediaGallery'; +import { widgetsPanelAtom } from '../../state/widgetsPanel'; import { usePendingKnocks } from '../../hooks/usePendingKnocks'; import { bookmarksPanelAtom } from '../../state/bookmarksPanel'; @@ -489,6 +490,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); const [galleryOpen, setGalleryOpen] = useAtom(mediaGalleryAtom); + const [widgetsOpen, setWidgetsOpen] = useAtom(widgetsPanelAtom); const pendingKnocks = usePendingKnocks(room); const handleSearchClick = () => { @@ -725,6 +727,29 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { )} )} + {screenSize === ScreenSize.Desktop && ( + + {widgetsOpen ? 'Hide Widgets' : 'Widgets'} + + } + > + {(triggerRef) => ( + setWidgetsOpen(!widgetsOpen)} + aria-label="Toggle widgets" + aria-pressed={widgetsOpen} + > + + + )} + + )} {screenSize === ScreenSize.Desktop && ( ): Promise> { + return filterWidgetCapabilities(requested); + } +} diff --git a/src/app/features/room/widgets/RoomWidgetView.tsx b/src/app/features/room/widgets/RoomWidgetView.tsx new file mode 100644 index 000000000..fdc60e370 --- /dev/null +++ b/src/app/features/room/widgets/RoomWidgetView.tsx @@ -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(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 ( + + + + This widget can't be loaded because its URL is on this app's own origin. + + + ); + } + + return ; +} diff --git a/src/app/features/room/widgets/WidgetsPanel.css.ts b/src/app/features/room/widgets/WidgetsPanel.css.ts new file mode 100644 index 000000000..63240072d --- /dev/null +++ b/src/app/features/room/widgets/WidgetsPanel.css.ts @@ -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', +}); diff --git a/src/app/features/room/widgets/WidgetsPanel.tsx b/src/app/features/room/widgets/WidgetsPanel.tsx new file mode 100644 index 000000000..d51ac373b --- /dev/null +++ b/src/app/features/room/widgets/WidgetsPanel.tsx @@ -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(null); + const [adding, setAdding] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(); + + const viewing = widgets.find((w) => w.id === viewingId) ?? null; + + const handleAdd: FormEventHandler = 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 ( + +
+ + + + Widgets + + + {room.name} + + + + + Close + + } + > + {(triggerRef) => ( + + + + )} + + + +
+ + + {viewing ? ( + + + + + + + ) : ( + + + {widgets.length === 0 && ( + + No widgets in this room yet. + + )} + {widgets.map((widget) => ( + + setViewingId(widget.id)} + style={{ cursor: 'pointer', minWidth: 0 }} + > + + + {widget.name || widget.templateUrl} + + + {canModify && ( + handleRemove(widget.id)} + > + + + )} + + ))} + + {canModify && + (adding ? ( + + + + + + + + + ) : ( + + ))} + {error && ( + + {error} + + )} + + + )} + +
+ ); +} diff --git a/src/app/features/room/widgets/useRoomWidgets.ts b/src/app/features/room/widgets/useRoomWidgets.ts new file mode 100644 index 000000000..5541ec5ed --- /dev/null +++ b/src/app/features/room/widgets/useRoomWidgets.ts @@ -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]); +}; diff --git a/src/app/features/room/widgets/widgetUtils.test.ts b/src/app/features/room/widgets/widgetUtils.test.ts new file mode 100644 index 000000000..9d5a9f812 --- /dev/null +++ b/src/app/features/room/widgets/widgetUtils.test.ts @@ -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([ + 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); +}); diff --git a/src/app/features/room/widgets/widgetUtils.ts b/src/app/features/room/widgets/widgetUtils.ts new file mode 100644 index 000000000..86bf3e861 --- /dev/null +++ b/src/app/features/room/widgets/widgetUtils.ts @@ -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 = new Set([ + MatrixCapabilities.AlwaysOnScreen, + MatrixCapabilities.RequiresClient, + MatrixCapabilities.Screenshots, +]); + +export const filterWidgetCapabilities = (requested: Set): Set => + 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)}`; diff --git a/src/app/state/widgetsPanel.ts b/src/app/state/widgetsPanel.ts new file mode 100644 index 000000000..3b2f5ead9 --- /dev/null +++ b/src/app/state/widgetsPanel.ts @@ -0,0 +1,4 @@ +import { atom } from 'jotai'; + +// Whether the room's Widgets side-panel is open (mirrors mediaGalleryAtom). +export const widgetsPanelAtom = atom(false); diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index ab2b1d8e5..2fb7113ac 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -27,6 +27,9 @@ export enum StateEvent { RoomTopic = 'm.room.topic', RoomAvatar = 'm.room.avatar', 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', RoomHistoryVisibility = 'm.room.history_visibility', // [MSC1763] Per-room message retention policy (disappearing messages).