Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 c3f897b6d1 feat(widgets): open a widget in its own window (#205)
CI / Build & Quality Checks (pull_request) Successful in 3m34s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 17m37s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-20 20:17:52 -04:00
6 changed files with 91 additions and 49 deletions
+1 -12
View File
@@ -1,4 +1,4 @@
import { globalStyle, style } from '@vanilla-extract/css';
import { style } from '@vanilla-extract/css';
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
export const MessageBase = style({
@@ -77,14 +77,3 @@ export const ReactionsMore = style([
export const ReactionsTooltipText = style({
wordBreak: 'break-word',
});
// [Gitea #199] Long-press opens the message action sheet on touch screens. On
// iOS a long-press otherwise belongs to the system: a link/image callout, or
// the text-selection loupe, both of which race (and win against) the 450 ms
// press. No callout on message rows, and no selection while a finger is
// down; copying text is in the sheet.
globalStyle('[data-touch-actions]', { WebkitTouchCallout: 'none' });
globalStyle('[data-touch-actions][data-pressing], [data-touch-actions][data-pressing] *', {
userSelect: 'none',
WebkitUserSelect: 'none',
});
@@ -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).
+44 -2
View File
@@ -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<string>();
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<HTMLFormElement> = async (evt) => {
evt.preventDefault();
@@ -144,7 +158,13 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
<Box grow="Yes" className={css.WidgetsPanelContent}>
{viewing ? (
<Box grow="Yes" direction="Column">
<Box shrink="No" style={{ padding: config.space.S200 }}>
<Box
shrink="No"
alignItems="Center"
justifyContent="SpaceBetween"
gap="200"
style={{ padding: config.space.S200 }}
>
<Button
variant="Secondary"
fill="Soft"
@@ -157,6 +177,17 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
{viewing.name || 'Widget'}
</Text>
</Button>
{popoutUrl(viewing) && (
<IconButton
size="300"
radii="300"
variant="Background"
aria-label={`Open ${viewing.name || 'widget'} in a new window`}
onClick={() => popout(viewing)}
>
<Icon size="100" src={Icons.External} />
</IconButton>
)}
</Box>
<RoomWidgetView room={room} widget={viewing} />
</Box>
@@ -184,6 +215,17 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
{widget.name || widget.templateUrl}
</Text>
</Box>
{popoutUrl(widget) && (
<IconButton
size="300"
radii="300"
variant="Background"
aria-label={`Open ${widget.name || 'widget'} in a new window`}
onClick={() => popout(widget)}
>
<Icon size="100" src={Icons.External} />
</IconButton>
)}
{canModify && (
<IconButton
size="300"
@@ -8,6 +8,7 @@ import {
generateWidgetId,
classifyWidgetCapabilities,
loadWidgetConsent,
widgetPopoutUrl,
} from './widgetUtils';
const APP = 'https://chat.lotusguild.org';
@@ -122,3 +123,17 @@ test('[#266] space structure, call membership and Lotus room policy can never be
assert.equal(auto.size, 0);
assert.deepEqual(ask, []);
});
test('[#205] widgetPopoutUrl: only cross-origin https opens in a new window', () => {
// 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,<b>x</b>', APP), null, 'data:');
assert.equal(widgetPopoutUrl('not a url', APP), null);
});
@@ -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)}`;
+4 -28
View File
@@ -17,37 +17,22 @@ export function useLongPress(onLongPress: (x: number, y: number) => void) {
const timer = useRef<number | undefined>(undefined);
const start = useRef<{ x: number; y: number } | null>(null);
const fired = useRef(false);
// [Gitea #199] The element being pressed carries `data-pressing` (set on the
// DOM directly, no re-render) so iOS can't start a text selection under
// the finger; see `[data-touch-actions]` in room/message/styles.css.ts.
const pressed = useRef<HTMLElement | null>(null);
const stopTimer = useCallback(() => {
const clear = useCallback(() => {
if (timer.current !== undefined) window.clearTimeout(timer.current);
timer.current = undefined;
start.current = null;
}, []);
// The finger is up (or the gesture was cancelled): only now drop
// `data-pressing`, so the system selection can't start under a finger that
// is still down after the sheet opened.
const clear = useCallback(() => {
stopTimer();
pressed.current?.removeAttribute('data-pressing');
pressed.current = null;
}, [stopTimer]);
const onTouchStart = useCallback(
(evt: React.TouchEvent) => {
if (!coarse || evt.touches.length !== 1) return;
const t = evt.touches[0];
start.current = { x: t.clientX, y: t.clientY };
fired.current = false;
pressed.current = evt.currentTarget as HTMLElement;
pressed.current.setAttribute('data-pressing', '');
timer.current = window.setTimeout(() => {
const s = start.current;
stopTimer();
clear();
if (!s) return;
fired.current = true;
window.setTimeout(() => {
@@ -56,7 +41,7 @@ export function useLongPress(onLongPress: (x: number, y: number) => void) {
onLongPress(s.x, s.y);
}, LONG_PRESS_MS);
},
[coarse, stopTimer, onLongPress],
[coarse, clear, onLongPress],
);
const onTouchMove = useCallback(
@@ -87,16 +72,7 @@ export function useLongPress(onLongPress: (x: number, y: number) => void) {
return {
coarse,
handlers: coarse
? {
// [Gitea #199] Marks the element for the iOS hardening CSS.
'data-touch-actions': '',
onTouchStart,
onTouchMove,
onTouchEnd,
onTouchCancel: clear,
}
: {},
handlers: coarse ? { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel: clear } : {},
/** True for ~1 s after a long-press fired (drop the Android contextmenu echo). */
suppressContextMenu: fired,
};