From c3f897b6d11a2c99d78a135a7db81e359b4a9386 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Tue, 20 Oct 2026 20:17:52 -0400 Subject: [PATCH] feat(widgets): open a widget in its own window (#205) Each widget in the Widgets panel (and the open widget's header) gets an "Open in a new window" button, so a pad or board can sit beside the chat. It opens the same filled-in URL the frame uses (widgetCompleteUrl, now shared) with noopener,noreferrer, standalone as in Element. Only cross-origin https URLs get the button: room state can carry anything, e.g. a script URL from another client. Refs #205 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- .../features/room/widgets/RoomWidgetView.tsx | 9 +--- .../features/room/widgets/WidgetsPanel.tsx | 46 ++++++++++++++++++- .../features/room/widgets/widgetUtils.test.ts | 15 ++++++ src/app/features/room/widgets/widgetUtils.ts | 25 ++++++++++ 4 files changed, 86 insertions(+), 9 deletions(-) diff --git a/src/app/features/room/widgets/RoomWidgetView.tsx b/src/app/features/room/widgets/RoomWidgetView.tsx index 99c460cd8..3f3decc42 100644 --- a/src/app/features/room/widgets/RoomWidgetView.tsx +++ b/src/app/features/room/widgets/RoomWidgetView.tsx @@ -11,7 +11,7 @@ import { import { Capability, ClientWidgetApi, IRoomEvent, Widget } from 'matrix-widget-api'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { GeneralWidgetDriver } from './GeneralWidgetDriver'; -import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils'; +import { WidgetPermissionRequest, isWidgetUrlSafe, widgetCompleteUrl } from './widgetUtils'; import { restrictWidgetMessages } from '../../../plugins/widgetTransport'; import { WidgetPermissionPrompt } from './WidgetPermissionPrompt'; import { isFromFilteredTimelineSet } from '../../../utils/room'; @@ -46,12 +46,7 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) { 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, - }); + const completeUrl = widgetCompleteUrl(current, mx, room.roomId); // 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). diff --git a/src/app/features/room/widgets/WidgetsPanel.tsx b/src/app/features/room/widgets/WidgetsPanel.tsx index 7bbd6d0dc..3edd40bdb 100644 --- a/src/app/features/room/widgets/WidgetsPanel.tsx +++ b/src/app/features/room/widgets/WidgetsPanel.tsx @@ -27,7 +27,13 @@ import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; import { StateEvent } from '../../../../types/matrix/room'; import { sendStateEvent } from '../../../utils/room'; -import { generateWidgetId, validateWidgetUrl, WidgetUrlError } from './widgetUtils'; +import { + generateWidgetId, + validateWidgetUrl, + widgetCompleteUrl, + widgetPopoutUrl, + WidgetUrlError, +} from './widgetUtils'; const urlErrorMessage = (err: WidgetUrlError): string => { switch (err) { @@ -60,6 +66,14 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) { const [error, setError] = useState(); const viewing = widgets.find((w) => w.id === viewingId) ?? null; + // [Gitea #205] Open a widget in its own window, beside the chat. It runs + // standalone there (no widget API), as in Element's popout. + const popoutUrl = (widget: (typeof widgets)[number]) => + widgetPopoutUrl(widgetCompleteUrl(widget, mx, room.roomId), window.location.origin); + const popout = (widget: (typeof widgets)[number]) => { + const url = popoutUrl(widget); + if (url) window.open(url, '_blank', 'noopener,noreferrer'); + }; const handleAdd: FormEventHandler = async (evt) => { evt.preventDefault(); @@ -144,7 +158,13 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) { {viewing ? ( - + + {popoutUrl(viewing) && ( + popout(viewing)} + > + + + )} @@ -184,6 +215,17 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) { {widget.name || widget.templateUrl} + {popoutUrl(widget) && ( + popout(widget)} + > + + + )} {canModify && ( { + // Built at runtime so the no-script-url lint rule doesn't flag the test data. + const scriptUrl = ['javascript', 'alert(1)'].join(':'); + assert.equal( + widgetPopoutUrl('https://pad.example.org/p/room', APP), + 'https://pad.example.org/p/room', + ); + assert.equal(widgetPopoutUrl('https://chat.lotusguild.org/evil', APP), null, 'same origin'); + assert.equal(widgetPopoutUrl('http://pad.example.org/', APP), null, 'not https'); + assert.equal(widgetPopoutUrl(scriptUrl, APP), null, 'script URL'); + assert.equal(widgetPopoutUrl('data:text/html,x', APP), null, 'data:'); + assert.equal(widgetPopoutUrl('not a url', APP), null); +}); diff --git a/src/app/features/room/widgets/widgetUtils.ts b/src/app/features/room/widgets/widgetUtils.ts index 09cf85267..dc8aef0fd 100644 --- a/src/app/features/room/widgets/widgetUtils.ts +++ b/src/app/features/room/widgets/widgetUtils.ts @@ -1,8 +1,10 @@ +import type { MatrixClient } from 'matrix-js-sdk'; import { Capability, EventDirection, EventKind, MatrixCapabilities, + type Widget, WidgetEventCapability, } from 'matrix-widget-api'; @@ -48,6 +50,29 @@ export const isWidgetUrlSafe = (completeUrl: string, appOrigin: string): boolean } }; +/** The widget's URL with its template variables filled in for this viewer. */ +export const widgetCompleteUrl = (widget: Widget, mx: MatrixClient, roomId: string): string => + widget.getCompleteUrl({ + currentUserId: mx.getSafeUserId(), + widgetRoomId: roomId, + deviceId: mx.getDeviceId() ?? undefined, + baseUrl: mx.baseUrl, + }); + +/** + * [Gitea #205] The URL to open a widget in its own window, or null when it + * mustn't be opened: only cross-origin https (room state can carry anything, + * e.g. a `javascript:` URL from another client, whose origin is "null"). + */ +export const widgetPopoutUrl = (completeUrl: string, appOrigin: string): string | null => { + try { + const u = new URL(completeUrl); + return u.protocol === 'https:' && u.origin !== appOrigin ? u.href : null; + } catch { + return null; + } +}; + export const generateWidgetId = (): string => `lotus_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;