From 7ca5f385ec8287304899303f5873e3900cf8cf10 Mon Sep 17 00:00:00 2001 From: TastelessVoid Date: Tue, 24 Feb 2026 14:33:30 +0100 Subject: [PATCH 1/2] Added widget support --- src/app/features/room/Room.tsx | 8 + src/app/features/room/RoomViewHeader.tsx | 43 +++ .../features/widgets/GenericWidgetDriver.ts | 284 ++++++++++++++++ .../widgets/IntegrationManager.css.ts | 28 ++ .../features/widgets/IntegrationManager.tsx | 154 +++++++++ src/app/features/widgets/WidgetIframe.tsx | 150 +++++++++ src/app/features/widgets/WidgetsDrawer.css.ts | 30 ++ src/app/features/widgets/WidgetsDrawer.tsx | 305 ++++++++++++++++++ src/app/hooks/useIntegrationManager.ts | 151 +++++++++ src/app/hooks/useRoomWidgets.ts | 74 +++++ src/app/state/settings.ts | 2 + src/types/matrix/room.ts | 3 + 12 files changed, 1232 insertions(+) create mode 100644 src/app/features/widgets/GenericWidgetDriver.ts create mode 100644 src/app/features/widgets/IntegrationManager.css.ts create mode 100644 src/app/features/widgets/IntegrationManager.tsx create mode 100644 src/app/features/widgets/WidgetIframe.tsx create mode 100644 src/app/features/widgets/WidgetsDrawer.css.ts create mode 100644 src/app/features/widgets/WidgetsDrawer.tsx create mode 100644 src/app/hooks/useIntegrationManager.ts create mode 100644 src/app/hooks/useRoomWidgets.ts diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index caad789e5f..be51d73c75 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -16,6 +16,7 @@ import { useRoomMembers } from '../../hooks/useRoomMembers'; import { CallView } from '../call/CallView'; import { RoomViewHeader } from './RoomViewHeader'; import { useCallState } from '../../pages/client/call/CallProvider'; +import { WidgetsDrawer } from '../widgets/WidgetsDrawer'; export function Room() { const { eventId } = useParams(); @@ -23,6 +24,7 @@ export function Room() { const mx = useMatrixClient(); const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); + const [isWidgetDrawerOpen] = useSetting(settingsAtom, 'isWidgetDrawer'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const screenSize = useScreenSizeContext(); const powerLevels = usePowerLevels(room); @@ -60,6 +62,12 @@ export function Room() { )} + {screenSize === ScreenSize.Desktop && isWidgetDrawerOpen && ( + <> + + + + )} ); diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index a8dc00e994..9adad0b129 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -69,6 +69,7 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; import { useCallState } from '../../pages/client/call/CallProvider'; import { ContainerColor } from '../../styles/ContainerColor.css'; +import { useRoomWidgets } from '../../hooks/useRoomWidgets'; type RoomMenuProps = { room: Room; @@ -277,6 +278,8 @@ export function RoomViewHeader() { : undefined; const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); + const [widgetDrawer, setWidgetDrawer] = useSetting(settingsAtom, 'isWidgetDrawer'); + const widgets = useRoomWidgets(room); const handleSearchClick = () => { const searchParams: _SearchPathSearchParams = { @@ -450,6 +453,46 @@ export function RoomViewHeader() { )} + {screenSize === ScreenSize.Desktop && ( + + {widgetDrawer ? 'Hide Widgets' : 'Show Widgets'} + + } + > + {(triggerRef) => ( + setWidgetDrawer((d) => !d)} + style={{ position: 'relative' }} + > + {widgets.length > 0 && ( + + + {widgets.length} + + + )} + + + )} + + )} + {screenSize === ScreenSize.Desktop && ( +) => Promise>; + +// Unlike SmallWidgetDriver which auto-grants all capabilities for Element Call, +// this driver provides a capability approval mechanism for untrusted widgets. +export class GenericWidgetDriver extends WidgetDriver { + private readonly mxClient: MatrixClient; + private readonly approveCapabilities: CapabilityApprovalCallback; + + public constructor( + mx: MatrixClient, + private forWidget: Widget, + private forWidgetKind: WidgetKind, + private inRoomId?: string, + approveCapabilities?: CapabilityApprovalCallback + ) { + super(); + this.mxClient = mx; + this.approveCapabilities = approveCapabilities ?? (async (caps) => caps); + } + + public async validateCapabilities(requested: Set): Promise> { + return this.approveCapabilities(requested); + } + + public async sendEvent( + eventType: K, + content: StateEvents[K], + stateKey: string | null, + targetRoomId: string | null + ): Promise; + + public async sendEvent( + eventType: K, + content: TimelineEvents[K], + stateKey: null, + targetRoomId: string | null + ): Promise; + + public async sendEvent( + eventType: string, + content: IContent, + stateKey: string | null = null, + targetRoomId: string | null = null + ): Promise { + const client = this.mxClient; + const roomId = targetRoomId || this.inRoomId; + if (!client || !roomId) throw new Error('Not in a room or not attached to a client'); + + let r: { event_id: string } | null; + if (stateKey !== null) { + r = await client.sendStateEvent( + roomId, + eventType as keyof StateEvents, + content as StateEvents[keyof StateEvents], + stateKey + ); + } else if (eventType === EventType.RoomRedaction) { + r = await client.redactEvent(roomId, content.redacts); + } else { + r = await client.sendEvent( + roomId, + eventType as keyof TimelineEvents, + content as TimelineEvents[keyof TimelineEvents] + ); + } + return { roomId, eventId: r!.event_id }; + } + + public async sendDelayedEvent( + delay: number | null, + parentDelayId: string | null, + eventType: K, + content: StateEvents[K], + stateKey: string | null, + targetRoomId: string | null + ): Promise; + + public async sendDelayedEvent( + delay: number | null, + parentDelayId: string | null, + eventType: K, + content: TimelineEvents[K], + stateKey: null, + targetRoomId: string | null + ): Promise; + + public async sendDelayedEvent( + delay: number | null, + parentDelayId: string | null, + eventType: string, + content: IContent, + stateKey: string | null = null, + targetRoomId: string | null = null + ): Promise { + const client = this.mxClient; + const roomId = targetRoomId || this.inRoomId; + if (!client || !roomId) throw new Error('Not in a room or not attached to a client'); + + let delayOpts; + if (delay !== null) { + delayOpts = { delay, ...(parentDelayId !== null && { parent_delay_id: parentDelayId }) }; + } else if (parentDelayId !== null) { + delayOpts = { parent_delay_id: parentDelayId }; + } else { + throw new Error('Must provide at least one of delay or parentDelayId'); + } + + let r: SendDelayedEventResponse | null; + if (stateKey !== null) { + r = await client._unstable_sendDelayedStateEvent( + roomId, delayOpts, + eventType as keyof StateEvents, + content as StateEvents[keyof StateEvents], + stateKey + ); + } else { + r = await client._unstable_sendDelayedEvent( + roomId, delayOpts, null, + eventType as keyof TimelineEvents, + content as TimelineEvents[keyof TimelineEvents] + ); + } + return { roomId, delayId: r.delay_id }; + } + + public async updateDelayedEvent(delayId: string, action: UpdateDelayedEventAction): Promise { + await this.mxClient._unstable_updateDelayedEvent(delayId, action); + } + + public async sendToDevice( + eventType: string, + encrypted: boolean, + contentMap: { [userId: string]: { [deviceId: string]: object } } + ): Promise { + const client = this.mxClient; + if (encrypted) { + const crypto = client.getCrypto(); + if (!crypto) throw new Error('E2EE not enabled'); + const invertedContentMap: { [content: string]: { userId: string; deviceId: string }[] } = {}; + for (const userId of Object.keys(contentMap)) { + for (const deviceId of Object.keys(contentMap[userId])) { + const key = JSON.stringify(contentMap[userId][deviceId]); + invertedContentMap[key] = invertedContentMap[key] || []; + invertedContentMap[key].push({ userId, deviceId }); + } + } + await Promise.all( + Object.entries(invertedContentMap).map(async ([str, recipients]) => { + const batch = await crypto.encryptToDeviceMessages(eventType, recipients, JSON.parse(str)); + await client.queueToDevice(batch); + }) + ); + } else { + await client.queueToDevice({ + eventType, + batch: Object.entries(contentMap).flatMap(([userId, userContentMap]) => + Object.entries(userContentMap).map(([deviceId, content]) => ({ + userId, deviceId, payload: content, + })) + ), + }); + } + } + + public async readRoomTimeline( + roomId: string, eventType: string, msgtype: string | undefined, + stateKey: string | undefined, limit: number, since: string | undefined + ): Promise { + limit = limit > 0 ? Math.min(limit, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; + const room = this.mxClient.getRoom(roomId); + if (!room) return []; + const results: MatrixEvent[] = []; + const events = room.getLiveTimeline().getEvents(); + for (let i = events.length - 1; i >= 0; i--) { + const ev = events[i]; + if (results.length >= limit) break; + if (since !== undefined && ev.getId() === since) break; + if (ev.getType() !== eventType || ev.isState()) continue; + if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent().msgtype) continue; + if (ev.getStateKey() !== undefined && stateKey !== undefined && ev.getStateKey() !== stateKey) continue; + results.push(ev); + } + return results.map((e) => e.getEffectiveEvent() as IRoomEvent); + } + + public async askOpenID(observer: SimpleObservable): Promise { + return observer.update({ + state: OpenIDRequestState.Allowed, + token: await this.mxClient.getOpenIdToken(), + }); + } + + public async readRoomState( + roomId: string, eventType: string, stateKey: string | undefined + ): Promise { + const room = this.mxClient.getRoom(roomId); + if (!room) return []; + const state = room.getLiveTimeline().getState(Direction.Forward); + if (!state) return []; + if (stateKey === undefined) + return state.getStateEvents(eventType).map((e: MatrixEvent) => e.getEffectiveEvent() as IRoomEvent); + const event = state.getStateEvents(eventType, stateKey); + return event === null ? [] : [event.getEffectiveEvent() as IRoomEvent]; + } + + public async readEventRelations( + eventId: string, roomId?: string, relationType?: string, eventType?: string, + from?: string, to?: string, limit?: number, direction?: 'f' | 'b' + ): Promise { + roomId = roomId ?? this.inRoomId ?? undefined; + if (typeof roomId !== 'string') throw new Error('Error while reading the current room'); + const { events, nextBatch, prevBatch } = await this.mxClient.relations( + roomId, eventId, relationType ?? null, eventType ?? null, + { from, to, limit, dir: direction as Direction } + ); + return { + chunk: events.map((e: MatrixEvent) => e.getEffectiveEvent() as IRoomEvent), + nextBatch: nextBatch ?? undefined, + prevBatch: prevBatch ?? undefined, + }; + } + + public async searchUserDirectory(searchTerm: string, limit?: number): Promise { + const { limited, results } = await this.mxClient.searchUserDirectory({ term: searchTerm, limit }); + return { + limited, + results: results.map((r: any) => ({ + userId: r.user_id, displayName: r.display_name, avatarUrl: r.avatar_url, + })), + }; + } + + public async getMediaConfig(): Promise { + return await this.mxClient.getMediaConfig(); + } + + public async uploadFile(file: XMLHttpRequestBodyInit): Promise<{ contentUri: string }> { + const uploadResult = await this.mxClient.uploadContent(file); + return { contentUri: uploadResult.content_uri }; + } + + public getKnownRooms(): string[] { + return this.mxClient.getVisibleRooms().map((r: Room) => r.roomId); + } + + public processError(error: unknown): IWidgetApiErrorResponseDataDetails | undefined { + return error instanceof MatrixError + ? { matrix_api_error: (error as any).asWidgetApiErrorData() } + : undefined; + } +} + diff --git a/src/app/features/widgets/IntegrationManager.css.ts b/src/app/features/widgets/IntegrationManager.css.ts new file mode 100644 index 0000000000..928bd6133e --- /dev/null +++ b/src/app/features/widgets/IntegrationManager.css.ts @@ -0,0 +1,28 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +export const IntegrationManagerOverlay = style({ + width: '80vw', + height: '80vh', + maxWidth: toRem(960), + maxHeight: toRem(720), + backgroundColor: color.Background.Container, + borderRadius: config.radii.R400, + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', +}); + +export const IntegrationManagerHeader = style({ + flexShrink: 0, + padding: `0 ${config.space.S200} 0 ${config.space.S300}`, + borderBottomWidth: config.borderWidth.B300, +}); + +export const IntegrationManagerIframe = style({ + flexGrow: 1, + width: '100%', + border: 'none', + display: 'block', +}); + diff --git a/src/app/features/widgets/IntegrationManager.tsx b/src/app/features/widgets/IntegrationManager.tsx new file mode 100644 index 0000000000..f805bd55eb --- /dev/null +++ b/src/app/features/widgets/IntegrationManager.tsx @@ -0,0 +1,154 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + Box, + Header, + Icon, + IconButton, + Icons, + Overlay, + OverlayBackdrop, + OverlayCenter, + Spinner, + Text, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { Room } from 'matrix-js-sdk'; + +import * as css from './IntegrationManager.css'; +import { + useIntegrationManager, + buildIntegrationManagerUrl, +} from '../../hooks/useIntegrationManager'; + +interface IntegrationManagerProps { + room: Room; + open: boolean; + onClose: () => void; +} + +export function IntegrationManager({ room, open, onClose }: IntegrationManagerProps) { + const { managers, scalarToken, loading, error } = useIntegrationManager(); + const iframeRef = useRef(null); + const [iframeLoaded, setIframeLoaded] = useState(false); + + const manager = managers[0]; + + const iframeSrc = manager + ? buildIntegrationManagerUrl(manager.uiUrl, scalarToken, room.roomId) + : null; + + useEffect(() => { + if (!open) return undefined; + + const handleMessage = (event: MessageEvent) => { + if (!manager) return; + + try { + const managerOrigin = new URL(manager.uiUrl).origin; + if (event.origin !== managerOrigin) return; + } catch { + return; + } + + if (event.data?.action === 'close_scalar' || event.data?.action === 'close') { + onClose(); + } + }; + + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [open, manager, onClose]); + + useEffect(() => { + if (!open) setIframeLoaded(false); + }, [open]); + + return ( + }> + + + +
+ + + + Integration Manager + + + + + + + + +
+ + + {loading && ( + + + + Connecting to integration manager... + + + )} + + {error && ( + + + Failed to connect: {error} + + + )} + + {!loading && !error && !manager && ( + + + No integration manager available for this homeserver. + + + )} + + {!loading && !error && iframeSrc && ( + <> + {!iframeLoaded && ( + + + + Loading... + + + )} +