diff --git a/src/app/features/room/widgets/FloatingWidget.tsx b/src/app/features/room/widgets/FloatingWidget.tsx new file mode 100644 index 000000000..3595b593a --- /dev/null +++ b/src/app/features/room/widgets/FloatingWidget.tsx @@ -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 ( + + + + {session.name} + + + + + destroyWidgetSession(session.key)} + > + + + + ); +} diff --git a/src/app/features/room/widgets/RoomWidgetView.tsx b/src/app/features/room/widgets/RoomWidgetView.tsx index 3f3decc42..72faa192c 100644 --- a/src/app/features/room/widgets/RoomWidgetView.tsx +++ b/src/app/features/room/widgets/RoomWidgetView.tsx @@ -1,133 +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, widgetCompleteUrl } 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, 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(null); + const slotRef = useRef(null); const widgetRef = useRef(widget); widgetRef.current = widget; - const [blocked, setBlocked] = useState(false); - const [pendingAsk, setPendingAsk] = useState(); + 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 = 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). - 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, 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 ( @@ -144,14 +59,14 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) { return ( <> - - {pendingAsk && ( + + {session?.pendingAsk && ( )} diff --git a/src/app/features/room/widgets/WidgetsPanel.tsx b/src/app/features/room/widgets/WidgetsPanel.tsx index 3edd40bdb..eed5b30ca 100644 --- a/src/app/features/room/widgets/WidgetsPanel.tsx +++ b/src/app/features/room/widgets/WidgetsPanel.tsx @@ -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'; @@ -60,7 +62,10 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) { const permissions = useRoomPermissions(creators, powerLevels); const canModify = permissions.stateEvent(StateEvent.Widget, mx.getSafeUserId()); - const [viewingId, setViewingId] = useState(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(); diff --git a/src/app/features/room/widgets/widgetSessions.ts b/src/app/features/room/widgets/widgetSessions.ts new file mode 100644 index 000000000..96f42d583 --- /dev/null +++ b/src/app/features/room/widgets/widgetSessions.ts @@ -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, 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(); +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, 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) => { + 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; +} diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 8503238c0..6e4b917b4 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -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) + diff --git a/src/app/state/widgetsPanel.ts b/src/app/state/widgetsPanel.ts index 3b2f5ead9..d9da03ac4 100644 --- a/src/app/state/widgetsPanel.ts +++ b/src/app/state/widgetsPanel.ts @@ -2,3 +2,7 @@ import { atom } from 'jotai'; // Whether the room's Widgets side-panel is open (mirrors mediaGalleryAtom). export const widgetsPanelAtom = atom(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>({}); diff --git a/src/app/styles/zIndex.ts b/src/app/styles/zIndex.ts index 9501c690f..1234c1667 100644 --- a/src/app/styles/zIndex.ts +++ b/src/app/styles/zIndex.ts @@ -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. */