Files
cinny/src/app/features/room/widgets/RoomWidgetView.tsx
T
Lotus CIandClaude Opus 5.5 0746e80941
CI / Build & Quality Checks (pull_request) Successful in 3m36s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 18s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m40s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-20 21:05:31 -04:00

75 lines
2.7 KiB
TypeScript

import React, { useEffect, useRef, useSyncExternalStore } from 'react';
import { Box, Icon, Icons, Text, color } from 'folds';
import { Room } from 'matrix-js-sdk';
import { Widget } from 'matrix-widget-api';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
import {
attachWidgetSession,
detachWidgetSession,
getWidgetSession,
subscribeWidgetSessions,
widgetSessionKey,
widgetSessionsVersion,
} from './widgetSessions';
type RoomWidgetViewProps = {
room: Room;
widget: Widget;
};
// 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 slotRef = useRef<HTMLDivElement>(null);
const widgetRef = useRef(widget);
widgetRef.current = widget;
const key = widgetSessionKey(room.roomId, widget.id);
useSyncExternalStore(subscribeWidgetSessions, widgetSessionsVersion);
const session = getWidgetSession(key);
useEffect(() => {
const slot = slotRef.current;
if (!slot) return undefined;
attachWidgetSession(mx, room, widgetRef.current, slot);
return () => detachWidgetSession(key, slot);
}, [mx, room, key, widget.templateUrl]);
if (session?.blocked) {
return (
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
<Icon size="400" src={Icons.Warning} style={{ color: color.Warning.Main }} />
<Text size="T300" align="Center">
This widget can&apos;t be loaded because its URL is on this app&apos;s own origin.
</Text>
</Box>
);
}
const addedBy = widget.creatorUserId
? (room.getMember(widget.creatorUserId)?.rawDisplayName ?? widget.creatorUserId)
: undefined;
return (
<>
<Box ref={slotRef} grow="Yes" style={{ height: '100%', minHeight: 0 }} />
{session?.pendingAsk && (
<WidgetPermissionPrompt
widgetName={widget.name || 'Widget'}
origin={session.pendingAsk.origin}
addedBy={addedBy}
requests={session.pendingAsk.requests}
onDone={session.pendingAsk.resolve}
/>
)}
</>
);
}