From 0746e809417cfa1c61924e6d13ed5dd39e9a447c Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Tue, 20 Oct 2026 20:32:45 -0400 Subject: [PATCH] feat(widgets): keep a widget on screen while you navigate away (#205) 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 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- .../features/room/widgets/FloatingWidget.tsx | 76 ++++ .../features/room/widgets/RoomWidgetView.tsx | 153 ++------ .../features/room/widgets/WidgetsPanel.tsx | 7 +- .../features/room/widgets/widgetSessions.ts | 341 ++++++++++++++++++ src/app/pages/Router.tsx | 2 + src/app/state/widgetsPanel.ts | 4 + src/app/styles/zIndex.ts | 2 + 7 files changed, 465 insertions(+), 120 deletions(-) create mode 100644 src/app/features/room/widgets/FloatingWidget.tsx create mode 100644 src/app/features/room/widgets/widgetSessions.ts 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. */