feat(widgets): keep a widget on screen while you navigate away (#205) #443
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { Box, Icon, Icons, Text, color } from 'folds';
|
||||||
import {
|
import { Room } from 'matrix-js-sdk';
|
||||||
MatrixEvent,
|
import { Widget } from 'matrix-widget-api';
|
||||||
MatrixEventEvent,
|
|
||||||
Room,
|
|
||||||
RoomEvent,
|
|
||||||
RoomEventHandlerMap,
|
|
||||||
RoomStateEvent,
|
|
||||||
} from 'matrix-js-sdk';
|
|
||||||
import { Capability, ClientWidgetApi, IRoomEvent, Widget } from 'matrix-widget-api';
|
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
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 { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
|
||||||
import { isFromFilteredTimelineSet } from '../../../utils/room';
|
import {
|
||||||
|
attachWidgetSession,
|
||||||
type PendingAsk = {
|
detachWidgetSession,
|
||||||
requests: WidgetPermissionRequest[];
|
getWidgetSession,
|
||||||
origin: string;
|
subscribeWidgetSessions,
|
||||||
resolve: (allowed: Set<Capability>, remember: boolean) => void;
|
widgetSessionKey,
|
||||||
};
|
widgetSessionsVersion,
|
||||||
|
} from './widgetSessions';
|
||||||
|
|
||||||
type RoomWidgetViewProps = {
|
type RoomWidgetViewProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
widget: Widget;
|
widget: Widget;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hosts one room widget in a sandboxed iframe via ClientWidgetApi (so widgets
|
// Shows one room widget in the Widgets panel. The widget itself runs in a
|
||||||
// that wait on the client handshake load). Display-only access is granted
|
// session (widgetSessions.ts) whose sandboxed iframe lives in an app-level
|
||||||
// silently; reading/sending in this room is asked for (WidgetPermissionPrompt)
|
// layer and is positioned over this placeholder, so a widget that asked to
|
||||||
// and, once granted, the room's live events are fed to the widget.
|
// stay on screen can keep running in a floating window after the panel closes.
|
||||||
// Re-mounts only when the widget id or its (template) URL changes — not on every
|
// Display-only access is granted silently; reading/sending in this room is
|
||||||
// unrelated room-state update — so viewing a widget doesn't reload constantly.
|
// 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) {
|
export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const slotRef = useRef<HTMLDivElement>(null);
|
||||||
const widgetRef = useRef(widget);
|
const widgetRef = useRef(widget);
|
||||||
widgetRef.current = widget;
|
widgetRef.current = widget;
|
||||||
const [blocked, setBlocked] = useState(false);
|
const key = widgetSessionKey(room.roomId, widget.id);
|
||||||
const [pendingAsk, setPendingAsk] = useState<PendingAsk>();
|
useSyncExternalStore(subscribeWidgetSessions, widgetSessionsVersion);
|
||||||
|
const session = getWidgetSession(key);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const slot = slotRef.current;
|
||||||
if (!container) return undefined;
|
if (!slot) return undefined;
|
||||||
const current = widgetRef.current;
|
attachWidgetSession(mx, room, widgetRef.current, slot);
|
||||||
|
return () => detachWidgetSession(key, slot);
|
||||||
|
}, [mx, room, key, widget.templateUrl]);
|
||||||
|
|
||||||
const completeUrl = widgetCompleteUrl(current, mx, room.roomId);
|
if (session?.blocked) {
|
||||||
|
|
||||||
// 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) {
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
|
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
|
||||||
<Icon size="400" src={Icons.Warning} style={{ color: color.Warning.Main }} />
|
<Icon size="400" src={Icons.Warning} style={{ color: color.Warning.Main }} />
|
||||||
@@ -144,14 +59,14 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box ref={containerRef} grow="Yes" style={{ height: '100%', minHeight: 0 }} />
|
<Box ref={slotRef} grow="Yes" style={{ height: '100%', minHeight: 0 }} />
|
||||||
{pendingAsk && (
|
{session?.pendingAsk && (
|
||||||
<WidgetPermissionPrompt
|
<WidgetPermissionPrompt
|
||||||
widgetName={widget.name || 'Widget'}
|
widgetName={widget.name || 'Widget'}
|
||||||
origin={pendingAsk.origin}
|
origin={session.pendingAsk.origin}
|
||||||
addedBy={addedBy}
|
addedBy={addedBy}
|
||||||
requests={pendingAsk.requests}
|
requests={session.pendingAsk.requests}
|
||||||
onDone={pendingAsk.resolve}
|
onDone={session.pendingAsk.resolve}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import {
|
|||||||
config,
|
config,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { widgetViewingAtom } from '../../../state/widgetsPanel';
|
||||||
import * as css from './WidgetsPanel.css';
|
import * as css from './WidgetsPanel.css';
|
||||||
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
||||||
import { RoomWidgetView } from './RoomWidgetView';
|
import { RoomWidgetView } from './RoomWidgetView';
|
||||||
@@ -60,7 +62,10 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
|
|||||||
const permissions = useRoomPermissions(creators, powerLevels);
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
const canModify = permissions.stateEvent(StateEvent.Widget, mx.getSafeUserId());
|
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 [adding, setAdding] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -68,6 +68,7 @@ import { SHARE_PAGE_PATH } from '../../swShare';
|
|||||||
import { RouteError } from './RouteError';
|
import { RouteError } from './RouteError';
|
||||||
import { CallStatusRenderer } from './CallStatusRenderer';
|
import { CallStatusRenderer } from './CallStatusRenderer';
|
||||||
import { CallEmbedProvider } from '../components/CallEmbedProvider';
|
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 })));
|
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 />
|
<Outlet />
|
||||||
</ClientLayout>
|
</ClientLayout>
|
||||||
<CallStatusRenderer />
|
<CallStatusRenderer />
|
||||||
|
<FloatingWidget />
|
||||||
</CallEmbedProvider>
|
</CallEmbedProvider>
|
||||||
<React.Suspense fallback={null}>
|
<React.Suspense fallback={null}>
|
||||||
<SearchModalRenderer />
|
<SearchModalRenderer />
|
||||||
|
|||||||
@@ -2,3 +2,7 @@ import { atom } from 'jotai';
|
|||||||
|
|
||||||
// Whether the room's Widgets side-panel is open (mirrors mediaGalleryAtom).
|
// Whether the room's Widgets side-panel is open (mirrors mediaGalleryAtom).
|
||||||
export const widgetsPanelAtom = atom<boolean>(false);
|
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>>({});
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* z-index values and is intentionally not listed here.
|
* z-index values and is intentionally not listed here.
|
||||||
*/
|
*/
|
||||||
export const zIndices = {
|
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. */
|
/** In-call incoming-call banner — below seasonal/night-light/modals. */
|
||||||
inCallBanner: 9990,
|
inCallBanner: 9990,
|
||||||
/** Seasonal particle effect — below the night-light tint so particles tint. */
|
/** Seasonal particle effect — below the night-light tint so particles tint. */
|
||||||
|
|||||||
Reference in New Issue
Block a user