Compare commits

...
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 0746e80941 feat(widgets): keep a widget on screen while you navigate away (#205)
CI / Build & Quality Checks (pull_request) Successful in 3m36s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 18s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m40s
MSC2931: a widget with m.always_on_screen (auto-granted) can send
set_always_on_screen; the host now answers it, and a widget that asked
keeps running in a small floating window (title, Back to room, close)
when the Widgets panel closes or you switch rooms. Back to room returns
to it with the panel open on that widget, without reloading it.

Moving an iframe reloads it, so widget frames now live in an app-level
layer from the start (widgetSessions.ts) and are positioned over the
panel's placeholder while docked. A widget that did not ask is torn
down as before. One floating widget at a time; it also goes when the
widget withdraws, is removed from the room, or on logout. A permission
request while floating grants nothing extra.

Refs #205

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-20 21:05:31 -04:00
jared 6300e4170e Merge pull request 'feat(widgets): open a widget in its own window (#205)' (#441) from feat/widget-popout into lotus
CI / Build & Quality Checks (push) Successful in 4m5s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 14s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-20 21:05:12 -04:00
jared 7250322977 Merge pull request 'fix(touch): harden long-press for iOS (#199)' (#440) from fix/ios-long-press into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-20 20:47:19 -04:00
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
jared 8784a8158c Merge pull request 'chore(types): fewer any casts; eslint ratchet 36 → 29 (#210)' (#439) from chore/any-cleanup into lotus
CI / Build & Quality Checks (push) Successful in 3m13s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 15s
CI / Trigger Desktop Build (push) Successful in 8s
CI / Playwright smoke (e2e) (push) Canceled after 14s
2026-10-20 20:17:01 -04:00
Lotus CIandClaude Opus 5.5 42040b6412 chore(types): fewer any casts; eslint warning ratchet 36 -> 29 (#210)
CI / Build & Quality Checks (pull_request) Successful in 3m23s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 17s
CI / Playwright smoke (e2e) (pull_request) Successful in 18m6s
- Developer tools (send event, state event editor, room account data) and
  setMarkedUnread go through the typed helpers in utils/room and
  utils/accountData (new setRoomAccountData) instead of casting at the
  call site; sendStateEvent accepts a developer-chosen type string.
- SecretStorage passes the key description as the SDK's
  SecretStorageKeyDescription, CallEmbed replies with an IWidgetApiRequest,
  and fillMissingPowers writes through a Record instead of `as any`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-20 19:49:15 -04:00
19 changed files with 585 additions and 143 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"build": "vite build",
"preview": "vite preview",
"lint": "npm run check:eslint && npm run check:prettier",
"check:eslint": "eslint src/* --max-warnings 36",
"check:eslint": "eslint src/* --max-warnings 29",
"check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit",
+9 -2
View File
@@ -1,6 +1,7 @@
import React, { FormEventHandler, useCallback } from 'react';
import { Box, Text, Button, Spinner, color } from 'folds';
import { decodeRecoveryKey, deriveRecoveryKeyFromPassphrase } from 'matrix-js-sdk/lib/crypto-api';
import type { SecretStorageKeyDescription } from 'matrix-js-sdk/lib/secret-storage';
import { PasswordInput } from './password-input';
import {
SecretStorageKeyContent,
@@ -39,7 +40,10 @@ export function SecretStorageRecoveryPassphrase({
bits,
);
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
const match = await mx.secretStorage.checkKey(
decodedRecoveryKey,
keyContent as SecretStorageKeyDescription,
);
if (!match) {
throw new Error('Invalid recovery passphrase.');
@@ -132,7 +136,10 @@ export function SecretStorageRecoveryKey({
async (recoveryKey) => {
const decodedRecoveryKey = decodeRecoveryKey(recoveryKey);
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
const match = await mx.secretStorage.checkKey(
decodedRecoveryKey,
keyContent as SecretStorageKeyDescription,
);
if (!match) {
throw new Error('Invalid recovery key.');
@@ -30,6 +30,7 @@ import {
AccountDataSubmitCallback,
} from '../../../components/AccountDataEditor';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { setRoomAccountData } from '../../../utils/accountData';
type DeveloperToolsProps = {
requestClose: () => void;
@@ -58,8 +59,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
const submitAccountData: AccountDataSubmitCallback = useCallback(
async (type, content) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await mx.setRoomAccountData(room.roomId, type as any, content);
await setRoomAccountData(mx, room.roomId, type, content);
},
[mx, room.roomId],
);
@@ -1,5 +1,5 @@
import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react';
import { MatrixError } from 'matrix-js-sdk';
import { IContent, MatrixError } from 'matrix-js-sdk';
import {
Box,
Chip,
@@ -22,6 +22,7 @@ import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { syntaxErrorPosition } from '../../../utils/dom';
import { Cursor } from '../../../plugins/text-area';
import { sendRoomEvent, sendStateEvent } from '../../../utils/room';
const EDITOR_INTENT_SPACE_COUNT = 2;
@@ -51,9 +52,9 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro
useCallback(
(evtType, evtStateKey, evtContent) => {
if (typeof evtStateKey === 'string') {
return mx.sendStateEvent(room.roomId, evtType as any, evtContent, evtStateKey);
return sendStateEvent(mx, room.roomId, evtType, evtContent, evtStateKey);
}
return mx.sendEvent(room.roomId, evtType as any, evtContent);
return sendRoomEvent(mx, room.roomId, null, evtType, evtContent as IContent);
},
[mx, room],
),
@@ -31,6 +31,7 @@ import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { sendStateEvent } from '../../../utils/room';
const EDITOR_INTENT_SPACE_COUNT = 2;
@@ -59,7 +60,7 @@ function StateEventEdit({ type, stateKey, content, requestClose }: StateEventEdi
const [submitState, submit] = useAsyncCallback<object, MatrixError, [object]>(
useCallback(
(c) => mx.sendStateEvent(room.roomId, type as any, c, stateKey),
(c) => sendStateEvent(mx, room.roomId, type, c, stateKey),
[mx, room, type, stateKey],
),
);
@@ -0,0 +1,76 @@
import React, { useEffect, useSyncExternalStore } from 'react';
import { Box, color, config, Icon, IconButton, Icons, Text } from 'folds';
import { useSetAtom } from 'jotai';
import {
destroyAllWidgetSessions,
destroyWidgetSession,
FLOATING_WIDGET,
floatingWidgetSessions,
subscribeWidgetSessions,
widgetSessionsVersion,
} from './widgetSessions';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { widgetViewingAtom, widgetsPanelAtom } from '../../../state/widgetsPanel';
import { zIndices } from '../../../styles/zIndex';
/**
* [Gitea #205] Title bar for a widget kept on screen (MSC2931) after its room
* or the Widgets panel was left. The widget's own frame is positioned by
* widgetSessions.ts right below this bar. Mounted once for the signed-in
* client; unmounting (logout) stops every widget.
*/
export function FloatingWidget() {
useSyncExternalStore(subscribeWidgetSessions, widgetSessionsVersion);
const { navigateRoom } = useRoomNavigate();
const setPanel = useSetAtom(widgetsPanelAtom);
const setViewing = useSetAtom(widgetViewingAtom);
useEffect(() => () => destroyAllWidgetSessions(), []);
const session = floatingWidgetSessions()[0];
if (!session) return null;
const f = FLOATING_WIDGET;
const back = () => {
setViewing((prev) => ({ ...prev, [session.roomId]: session.widgetId }));
setPanel(true);
navigateRoom(session.roomId);
};
return (
<Box
role="region"
aria-label={`${session.name} (kept on screen)`}
alignItems="Center"
gap="100"
style={{
position: 'fixed',
right: f.right,
bottom: f.bottom + f.height,
width: f.width,
height: f.bar,
zIndex: zIndices.floatingWidget,
padding: `0 ${config.space.S100} 0 ${config.space.S300}`,
background: color.Surface.Container,
color: color.Surface.OnContainer,
borderRadius: '8px 8px 0 0',
boxShadow: '0 8px 24px rgba(0,0,0,0.35)',
}}
>
<Icon size="100" src={Icons.Category} />
<Text size="T300" truncate style={{ flexGrow: 1 }}>
{session.name}
</Text>
<IconButton size="300" radii="300" variant="Surface" aria-label="Back to room" onClick={back}>
<Icon size="100" src={Icons.ArrowGoLeft} />
</IconButton>
<IconButton
size="300"
radii="300"
variant="Surface"
aria-label={`Close ${session.name}`}
onClick={() => destroyWidgetSession(session.key)}
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</Box>
);
}
+34 -124
View File
@@ -1,138 +1,48 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef, useSyncExternalStore } from 'react';
import { Box, Icon, Icons, Text, color } from 'folds';
import {
MatrixEvent,
MatrixEventEvent,
Room,
RoomEvent,
RoomEventHandlerMap,
RoomStateEvent,
} from 'matrix-js-sdk';
import { Capability, ClientWidgetApi, IRoomEvent, Widget } from 'matrix-widget-api';
import { Room } from 'matrix-js-sdk';
import { Widget } from 'matrix-widget-api';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { GeneralWidgetDriver } from './GeneralWidgetDriver';
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
import { isFromFilteredTimelineSet } from '../../../utils/room';
type PendingAsk = {
requests: WidgetPermissionRequest[];
origin: string;
resolve: (allowed: Set<Capability>, remember: boolean) => void;
};
import {
attachWidgetSession,
detachWidgetSession,
getWidgetSession,
subscribeWidgetSessions,
widgetSessionKey,
widgetSessionsVersion,
} from './widgetSessions';
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). Display-only access is granted
// silently; reading/sending in this room is asked for (WidgetPermissionPrompt)
// and, once granted, the room's live events are fed to the widget.
// 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.
// Shows one room widget in the Widgets panel. The widget itself runs in a
// session (widgetSessions.ts) whose sandboxed iframe lives in an app-level
// layer and is positioned over this placeholder, so a widget that asked to
// stay on screen can keep running in a floating window after the panel closes.
// Display-only access is granted silently; reading/sending in this room is
// asked for (WidgetPermissionPrompt) and, once granted, the room's live events
// are fed to the widget. Re-attaches only when the widget id or its (template)
// URL changes, so viewing a widget doesn't reload it on unrelated state updates.
export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
const mx = useMatrixClient();
const containerRef = useRef<HTMLDivElement>(null);
const slotRef = useRef<HTMLDivElement>(null);
const widgetRef = useRef(widget);
widgetRef.current = widget;
const [blocked, setBlocked] = useState(false);
const [pendingAsk, setPendingAsk] = useState<PendingAsk>();
const key = widgetSessionKey(room.roomId, widget.id);
useSyncExternalStore(subscribeWidgetSessions, widgetSessionsVersion);
const session = getWidgetSession(key);
useEffect(() => {
const container = containerRef.current;
if (!container) return undefined;
const current = widgetRef.current;
const slot = slotRef.current;
if (!slot) return undefined;
attachWidgetSession(mx, room, widgetRef.current, slot);
return () => detachWidgetSession(key, slot);
}, [mx, room, key, widget.templateUrl]);
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);
let origin = completeUrl;
try {
origin = new URL(completeUrl).host;
} catch {
// isWidgetUrlSafe already rejected unparsable URLs
}
let cancelAsk: (() => void) | undefined;
const driver = new GeneralWidgetDriver(
mx,
room.roomId,
{ id: current.id, url: current.templateUrl },
(requests) =>
new Promise((resolve) => {
let settled = false;
const done = (allowed: Set<Capability>, remember: boolean) => {
if (settled) return;
settled = true;
cancelAsk = undefined;
setPendingAsk(undefined);
resolve({ allowed, remember });
};
cancelAsk = () => done(new Set(), false);
setPendingAsk({ requests, origin, resolve: done });
}),
);
const clientApi = new ClientWidgetApi(current, iframe, driver);
// Only messages from this widget's own frame and origin are handled, so a
// widget can't impersonate another one (or the call).
restrictWidgetMessages(clientApi, iframe, current.origin);
clientApi.setViewedRoomId(room.roomId);
// Live room events for widgets that were allowed to see them
// (ClientWidgetApi drops anything the widget has no capability for).
const feed = (ev: MatrixEvent) => {
if (ev.getRoomId() !== room.roomId) return;
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
clientApi.feedEvent(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
};
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
if (toStart || removed || !data?.liveEvent) return;
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] don't feed widgets each event twice
if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed);
else feed(ev);
};
const onState = (ev: MatrixEvent) => {
if (ev.getRoomId() !== room.roomId) return;
clientApi.feedStateUpdate(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
};
mx.on(RoomEvent.Timeline, onTimeline);
mx.on(RoomStateEvent.Events, onState);
return () => {
mx.off(RoomEvent.Timeline, onTimeline);
mx.off(RoomStateEvent.Events, onState);
cancelAsk?.();
clientApi.stop();
iframe.remove();
};
}, [mx, room.roomId, widget.id, widget.templateUrl]);
if (blocked) {
if (session?.blocked) {
return (
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
<Icon size="400" src={Icons.Warning} style={{ color: color.Warning.Main }} />
@@ -149,14 +59,14 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
return (
<>
<Box ref={containerRef} grow="Yes" style={{ height: '100%', minHeight: 0 }} />
{pendingAsk && (
<Box ref={slotRef} grow="Yes" style={{ height: '100%', minHeight: 0 }} />
{session?.pendingAsk && (
<WidgetPermissionPrompt
widgetName={widget.name || 'Widget'}
origin={pendingAsk.origin}
origin={session.pendingAsk.origin}
addedBy={addedBy}
requests={pendingAsk.requests}
onDone={pendingAsk.resolve}
requests={session.pendingAsk.requests}
onDone={session.pendingAsk.resolve}
/>
)}
</>
+50 -3
View File
@@ -16,7 +16,9 @@ import {
config,
} from 'folds';
import { Room } from 'matrix-js-sdk';
import { useAtom } from 'jotai';
import classNames from 'classnames';
import { widgetViewingAtom } from '../../../state/widgetsPanel';
import * as css from './WidgetsPanel.css';
import { ContainerColor } from '../../../styles/ContainerColor.css';
import { RoomWidgetView } from './RoomWidgetView';
@@ -27,7 +29,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) {
@@ -54,12 +62,23 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
const permissions = useRoomPermissions(creators, powerLevels);
const canModify = permissions.stateEvent(StateEvent.Widget, mx.getSafeUserId());
const [viewingId, setViewingId] = useState<string | null>(null);
const [viewingByRoom, setViewingByRoom] = useAtom(widgetViewingAtom);
const viewingId = viewingByRoom[room.roomId] ?? null;
const setViewingId = (id: string | null) =>
setViewingByRoom((prev) => ({ ...prev, [room.roomId]: id }));
const [adding, setAdding] = useState(false);
const [saving, setSaving] = useState(false);
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 +163,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 +182,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 +220,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"
@@ -0,0 +1,341 @@
import {
MatrixClient,
MatrixEvent,
MatrixEventEvent,
Room,
RoomEvent,
RoomEventHandlerMap,
RoomStateEvent,
} from 'matrix-js-sdk';
import {
Capability,
ClientWidgetApi,
IRoomEvent,
IWidgetApiRequest,
MatrixCapabilities,
Widget,
WidgetApiFromWidgetAction,
} from 'matrix-widget-api';
import { GeneralWidgetDriver } from './GeneralWidgetDriver';
import { WidgetPermissionRequest, isWidgetUrlSafe, widgetCompleteUrl } from './widgetUtils';
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
import { isFromFilteredTimelineSet } from '../../../utils/room';
import { zIndices } from '../../../styles/zIndex';
/**
* [Gitea #205] Running room widgets, kept outside React so a widget that asked
* to stay on screen (MSC2931 `set_always_on_screen`) survives closing the
* Widgets panel or switching rooms.
*
* Moving an iframe in the DOM reloads it, so every widget frame lives from the
* start in one app-level fixed layer. While the panel shows the widget, the
* frame is positioned over the panel's placeholder (`attach`), so it looks and
* behaves as before. When the placeholder goes away (`detach`) the widget is
* torn down, unless it is always-on-screen: then the same live frame moves to
* a small floating window (see FloatingWidget) until it is closed, the widget
* says it no longer needs to be on screen, or it is removed from the room.
*/
export type PendingAsk = {
requests: WidgetPermissionRequest[];
origin: string;
resolve: (allowed: Set<Capability>, remember: boolean) => void;
};
export type WidgetSession = {
readonly key: string;
readonly roomId: string;
readonly widgetId: string;
readonly name: string;
readonly templateUrl: string;
/** The URL is on this app's own origin: never rendered. */
readonly blocked: boolean;
alwaysOnScreen: boolean;
floating: boolean;
pendingAsk?: PendingAsk;
};
type Internal = WidgetSession & {
frame: HTMLDivElement;
slot: HTMLElement | null;
raf: number | undefined;
stop: () => void;
};
/** The floating window's geometry (FloatingWidget draws its title bar above). */
export const FLOATING_WIDGET = { right: 16, bottom: 96, width: 360, height: 240, bar: 36 };
const DOCKED_Z = 600; // just above the side panel (500 on phones), below popovers/modals
const sessions = new Map<string, Internal>();
const listeners = new Set<() => void>();
let version = 0;
const notify = () => {
version += 1;
listeners.forEach((l) => l());
};
export const subscribeWidgetSessions = (l: () => void): (() => void) => {
listeners.add(l);
return () => listeners.delete(l);
};
export const widgetSessionsVersion = (): number => version;
export const widgetSessionKey = (roomId: string, widgetId: string): string =>
`${roomId}|${widgetId}`;
export const getWidgetSession = (key: string): WidgetSession | undefined => sessions.get(key);
export const floatingWidgetSessions = (): WidgetSession[] =>
[...sessions.values()].filter((s) => s.floating);
let layerEl: HTMLDivElement | undefined;
const layer = (): HTMLDivElement => {
if (!layerEl || !layerEl.isConnected) {
layerEl = document.createElement('div');
layerEl.id = 'lotus-widget-layer';
// The layer itself is click-through; only the frames inside take input.
Object.assign(layerEl.style, { position: 'fixed', inset: '0', pointerEvents: 'none' });
document.body.append(layerEl);
}
return layerEl;
};
const hideFrame = (s: Internal) => {
s.frame.style.visibility = 'hidden';
};
const placeDocked = (s: Internal) => {
const slot = s.slot;
if (!slot) return;
const r = slot.getBoundingClientRect();
if (r.width === 0 || r.height === 0) {
hideFrame(s);
} else {
Object.assign(s.frame.style, {
visibility: 'visible',
left: `${r.left}px`,
top: `${r.top}px`,
width: `${r.width}px`,
height: `${r.height}px`,
right: '',
bottom: '',
borderRadius: '0',
boxShadow: 'none',
zIndex: String(DOCKED_Z),
});
}
s.raf = requestAnimationFrame(() => placeDocked(s));
};
const placeFloating = (s: Internal) => {
const f = FLOATING_WIDGET;
Object.assign(s.frame.style, {
visibility: 'visible',
left: '',
top: '',
right: `${f.right}px`,
bottom: `${f.bottom}px`,
width: `${f.width}px`,
height: `${f.height}px`,
borderRadius: '0 0 8px 8px',
boxShadow: '0 8px 24px rgba(0,0,0,0.35)',
zIndex: String(zIndices.floatingWidget),
});
};
const stopPlacing = (s: Internal) => {
if (s.raf !== undefined) cancelAnimationFrame(s.raf);
s.raf = undefined;
};
export const destroyWidgetSession = (key: string): void => {
const s = sessions.get(key);
if (!s) return;
sessions.delete(key);
stopPlacing(s);
s.stop();
s.frame.remove();
notify();
};
export const destroyAllWidgetSessions = (): void => {
[...sessions.keys()].forEach(destroyWidgetSession);
};
/** Start (or reuse) the widget's session and dock its frame over `slot`. */
export function attachWidgetSession(
mx: MatrixClient,
room: Room,
widget: Widget,
slot: HTMLElement,
): WidgetSession {
const key = widgetSessionKey(room.roomId, widget.id);
let s = sessions.get(key);
if (s && s.templateUrl !== widget.templateUrl) {
destroyWidgetSession(key);
s = undefined;
}
if (!s) s = startSession(mx, room, widget, key);
stopPlacing(s);
s.slot = slot;
s.floating = false;
if (!s.blocked) placeDocked(s);
notify();
return s;
}
/** The panel stopped showing the widget: float it, or tear it down. */
export function detachWidgetSession(key: string, slot: HTMLElement): void {
const s = sessions.get(key);
if (!s || s.slot !== slot) return;
s.slot = null;
stopPlacing(s);
if (!s.alwaysOnScreen || s.blocked) {
destroyWidgetSession(key);
return;
}
// One floating widget at a time: a newer one replaces an older one.
floatingWidgetSessions()
.filter((o) => o.key !== key)
.forEach((o) => destroyWidgetSession(o.key));
// Nobody is there to answer a permission prompt any more.
s.pendingAsk?.resolve(new Set(), false);
s.floating = true;
placeFloating(s);
notify();
}
function startSession(mx: MatrixClient, room: Room, widget: Widget, key: string): Internal {
const frame = document.createElement('div');
Object.assign(frame.style, {
position: 'fixed',
pointerEvents: 'auto',
overflow: 'hidden',
visibility: 'hidden',
});
const completeUrl = widgetCompleteUrl(widget, mx, room.roomId);
const blocked = !isWidgetUrlSafe(completeUrl, window.location.origin);
const s: Internal = {
key,
roomId: room.roomId,
widgetId: widget.id,
name: widget.name || 'Widget',
templateUrl: widget.templateUrl,
blocked,
alwaysOnScreen: false,
floating: false,
frame,
slot: null,
raf: undefined,
stop: () => undefined,
};
sessions.set(key, s);
if (blocked) return s;
// 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).
const iframe = document.createElement('iframe');
iframe.title = widget.name || 'Widget';
iframe.sandbox.value = 'allow-forms allow-scripts allow-same-origin allow-popups allow-downloads';
iframe.allow = 'autoplay; clipboard-write;';
iframe.src = completeUrl;
Object.assign(iframe.style, { width: '100%', height: '100%', border: 'none' });
frame.append(iframe);
layer().append(frame);
let origin = completeUrl;
try {
origin = new URL(completeUrl).host;
} catch {
// isWidgetUrlSafe already rejected unparsable URLs
}
let cancelAsk: (() => void) | undefined;
const driver = new GeneralWidgetDriver(
mx,
room.roomId,
{ id: widget.id, url: widget.templateUrl },
(requests) =>
new Promise((resolve) => {
let settled = false;
const done = (allowed: Set<Capability>, remember: boolean) => {
if (settled) return;
settled = true;
cancelAsk = undefined;
s.pendingAsk = undefined;
notify();
resolve({ allowed, remember });
};
cancelAsk = () => done(new Set(), false);
// A floating widget has nobody to ask: grant nothing extra.
if (s.floating) {
done(new Set(), false);
return;
}
s.pendingAsk = { requests, origin, resolve: done };
notify();
}),
);
const clientApi = new ClientWidgetApi(widget, iframe, driver);
// Only messages from this widget's own frame and origin are handled, so a
// widget can't impersonate another one (or the call).
restrictWidgetMessages(clientApi, iframe, widget.origin);
clientApi.setViewedRoomId(room.roomId);
// MSC2931: the widget asks to stay visible when the user navigates away.
const onAlwaysOnScreen = (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
if (!clientApi.hasCapability(MatrixCapabilities.AlwaysOnScreen)) {
clientApi.transport.reply(ev.detail, {
error: { message: 'Missing capability' },
});
return;
}
const value = (ev.detail.data as { value?: unknown } | undefined)?.value === true;
s.alwaysOnScreen = value;
clientApi.transport.reply(ev.detail, {});
// Floating only because it asked: once it no longer needs to be, go.
if (!value && s.floating) destroyWidgetSession(key);
else notify();
};
clientApi.on(`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`, onAlwaysOnScreen);
// Live room events for widgets that were allowed to see them
// (ClientWidgetApi drops anything the widget has no capability for).
const feed = (ev: MatrixEvent) => {
if (ev.getRoomId() !== room.roomId) return;
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
clientApi.feedEvent(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
};
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
if (toStart || removed || !data?.liveEvent) return;
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] don't feed widgets each event twice
if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed);
else feed(ev);
};
const onState = (ev: MatrixEvent) => {
if (ev.getRoomId() !== room.roomId) return;
// The widget was removed from the room while it was floating.
if (
s.floating &&
(ev.getType() === 'im.vector.modular.widgets' || ev.getType() === 'm.widget') &&
ev.getStateKey() === widget.id &&
!ev.getContent()?.url
) {
destroyWidgetSession(key);
return;
}
clientApi.feedStateUpdate(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
};
mx.on(RoomEvent.Timeline, onTimeline);
mx.on(RoomStateEvent.Events, onState);
s.stop = () => {
mx.off(RoomEvent.Timeline, onTimeline);
mx.off(RoomStateEvent.Events, onState);
clientApi.off(`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`, onAlwaysOnScreen);
cancelAsk?.();
clientApi.stop();
};
return s;
}
@@ -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)}`;
+1 -1
View File
@@ -46,7 +46,7 @@ const fillMissingPowers = (powerLevels: IPowerLevels): IPowerLevels =>
const keys = Object.keys(DEFAULT_POWER_LEVELS) as unknown as (keyof IPowerLevels)[];
keys.forEach((key) => {
if (draftPl[key] === undefined) {
draftPl[key] = DEFAULT_POWER_LEVELS[key] as any;
(draftPl as Record<string, unknown>)[key] = DEFAULT_POWER_LEVELS[key];
}
});
if (draftPl.notifications && typeof draftPl.notifications.room !== 'number') {
+2
View File
@@ -68,6 +68,7 @@ import { SHARE_PAGE_PATH } from '../../swShare';
import { RouteError } from './RouteError';
import { CallStatusRenderer } from './CallStatusRenderer';
import { CallEmbedProvider } from '../components/CallEmbedProvider';
import { FloatingWidget } from '../features/room/widgets/FloatingWidget';
const Share = React.lazy(() => import('./client/share/Share').then((m) => ({ default: m.Share })));
@@ -197,6 +198,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Outlet />
</ClientLayout>
<CallStatusRenderer />
<FloatingWidget />
</CallEmbedProvider>
<React.Suspense fallback={null}>
<SearchModalRenderer />
+2 -1
View File
@@ -15,6 +15,7 @@ import {
WidgetApiFromWidgetAction,
WidgetApiToWidgetAction,
WidgetDriver,
IWidgetApiRequest,
} from 'matrix-widget-api';
import { CallWidgetDriver } from './CallWidgetDriver';
import { trimTrailingSlash } from '../../utils/common';
@@ -796,7 +797,7 @@ export class CallEmbed {
const wrapped = (ev: CustomEvent<T>) => {
ev.preventDefault();
// Reply with success so the widget transport doesn't time out waiting for a response
this.call.transport.reply(ev.detail as any, {});
this.call.transport.reply(ev.detail as unknown as IWidgetApiRequest, {});
callback(ev);
};
return this.listenEvent(`action:${type}`, wrapped);
+3 -4
View File
@@ -2,6 +2,7 @@ import { atom, useSetAtom } from 'jotai';
import { MatrixClient, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { useEffect } from 'react';
import { AccountDataEvent } from '../../../types/matrix/accountData';
import { setRoomAccountData } from '../../utils/accountData';
// MSC2867 — "mark a room as unread". A per-room account-data flag `{ unread }`.
// Stable type `m.marked_unread`; servers/clients predating the stabilization use
@@ -25,11 +26,9 @@ export const setMarkedUnread = (
unread: boolean,
): Promise<unknown> =>
Promise.all([
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mx.setRoomAccountData(roomId, AccountDataEvent.MarkedUnread as any, { unread }),
setRoomAccountData(mx, roomId, AccountDataEvent.MarkedUnread, { unread }),
// Best-effort mirror for older servers; never fail the primary write on it.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mx.setRoomAccountData(roomId, UNSTABLE_MARKED_UNREAD as any, { unread }).catch(() => undefined),
setRoomAccountData(mx, roomId, UNSTABLE_MARKED_UNREAD, { unread }).catch(() => undefined),
]);
export const receiptIsMine = (event: MatrixEvent, userId: string): boolean => {
+4
View File
@@ -2,3 +2,7 @@ import { atom } from 'jotai';
// Whether the room's Widgets side-panel is open (mirrors mediaGalleryAtom).
export const widgetsPanelAtom = atom<boolean>(false);
// [Gitea #205] Which widget the panel is showing, per room, so a floating
// widget's "Back to room" can open the panel on it.
export const widgetViewingAtom = atom<Record<string, string | null>>({});
+2
View File
@@ -5,6 +5,8 @@
* z-index values and is intentionally not listed here.
*/
export const zIndices = {
/** [Gitea #205] A room widget kept on screen after leaving its room. */
floatingWidget: 9980,
/** In-call incoming-call banner — below seasonal/night-light/modals. */
inCallBanner: 9990,
/** Seasonal particle effect — below the night-light tint so particles tint. */
+11
View File
@@ -18,6 +18,17 @@ export function getAccountData<T>(
return event?.getContent() as T | undefined;
}
/** Room account data under a custom or developer-chosen type (same cast). */
export function setRoomAccountData<T>(
mx: MatrixClient,
roomId: string,
eventType: string,
content: T,
): Promise<unknown> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (mx as any).setRoomAccountData(roomId, eventType, content);
}
export function setAccountData<T>(
mx: MatrixClient,
eventType: AccountDataEvent | string,
+1 -1
View File
@@ -60,7 +60,7 @@ export const getStateEvents = (room: Room, eventType: StateEvent): MatrixEvent[]
export function sendStateEvent<T extends object>(
mx: MatrixClient,
roomId: string,
eventType: StateEvent,
eventType: StateEvent | string,
content: T,
stateKey = '',
): Promise<ISendEventResponse> {