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. */