diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml
new file mode 100644
index 0000000000..5f1e0bcfcb
--- /dev/null
+++ b/.github/workflows/ios.yml
@@ -0,0 +1,55 @@
+name: iOS Build
+
+on:
+ workflow_dispatch:
+ push:
+ tags:
+ - 'v*'
+
+jobs:
+ build-ios:
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: aarch64-apple-ios
+
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: src-tauri -> target
+
+ - run: npm ci
+
+ - run: npx tauri ios init
+
+ - name: Build unsigned iOS .app
+ run: |
+ npx tauri ios build -- \
+ CODE_SIGNING_ALLOWED=NO \
+ CODE_SIGNING_REQUIRED=NO \
+ CODE_SIGN_IDENTITY=""
+
+ - name: Package .ipa
+ run: |
+ APP=$(find src-tauri/gen/apple/build -name "*.app" -type d | head -1)
+ if [ -z "$APP" ]; then
+ echo "No .app found"
+ exit 1
+ fi
+ mkdir -p Payload
+ cp -r "$APP" Payload/
+ zip -qr nullptr-unsigned.ipa Payload
+ rm -rf Payload
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: nullptr-ios-unsigned
+ path: nullptr-unsigned.ipa
+
diff --git a/scripts/build-release.sh b/scripts/build-release.sh
index f0dacb2c60..f38bb72325 100755
--- a/scripts/build-release.sh
+++ b/scripts/build-release.sh
@@ -26,8 +26,9 @@ Usage: $(basename "$0") [OPTIONS] [VERSION]
Build nullptr release artifacts for all platforms.
Options:
- --desktop-only Skip Android build
- --android-only Skip desktop build
+ --desktop-only Skip Android and iOS builds
+ --android-only Skip desktop and iOS builds
+ --ios-only Skip desktop and Android builds
--target TARGET Build only a specific Rust target (e.g. x86_64-unknown-linux-gnu)
--sign Sign Android APK (requires ANDROID_SIGNING_* env vars)
--clean Clean Rust target directory before building
@@ -43,6 +44,7 @@ Examples:
./scripts/build-release.sh # Build everything
./scripts/build-release.sh 1.0.0 # Build with version 1.0.0
./scripts/build-release.sh --desktop-only # Desktop only
+ ./scripts/build-release.sh --ios-only # iOS only (requires macOS + Xcode)
./scripts/build-release.sh --target x86_64-unknown-linux-gnu
EOF
exit 0
@@ -50,6 +52,7 @@ EOF
DESKTOP=true
ANDROID=true
+IOS=true
SPECIFIC_TARGET=""
SIGN_ANDROID=true
CLEAN=false
@@ -57,8 +60,9 @@ VERSION=""
while [[ $# -gt 0 ]]; do
case "$1" in
- --desktop-only) ANDROID=false; shift ;;
- --android-only) DESKTOP=false; shift ;;
+ --desktop-only) ANDROID=false; IOS=false; shift ;;
+ --android-only) DESKTOP=false; IOS=false; shift ;;
+ --ios-only) DESKTOP=false; ANDROID=false; shift ;;
--target) SPECIFIC_TARGET="$2"; shift 2 ;;
--sign) SIGN_ANDROID=true; shift ;;
--clean) CLEAN=true; shift ;;
@@ -229,6 +233,59 @@ if [[ "$ANDROID" == true ]]; then
echo ""
fi
+# ── iOS build ──
+
+if [[ "$IOS" == true ]]; then
+ log "═══ iOS Build ═══"
+ echo ""
+
+ if [[ "$(uname -s)" != "Darwin" ]]; then
+ warn "iOS builds require macOS with Xcode — skipping"
+ IOS=false
+ elif ! command -v xcodebuild &>/dev/null; then
+ warn "Xcode not found — skipping iOS build"
+ IOS=false
+ fi
+
+ if [[ "$IOS" == true ]]; then
+ if ! rustup target list --installed | grep -q "aarch64-apple-ios"; then
+ rustup target add aarch64-apple-ios
+ fi
+
+ if [[ ! -d "src-tauri/gen/apple" ]]; then
+ log "Initializing Tauri iOS project..."
+ npx tauri ios init
+ fi
+
+ log "Building unsigned iOS .app (development export)..."
+ npx tauri ios build -- -allowProvisioningUpdates \
+ CODE_SIGNING_ALLOWED=NO \
+ CODE_SIGNING_REQUIRED=NO \
+ CODE_SIGN_IDENTITY="" \
+ 2>&1 | tail -10
+
+ APP_DIR="src-tauri/gen/apple/build"
+ if [[ -d "$APP_DIR" ]]; then
+ mkdir -p "$OUTPUT_DIR/ios"
+ find "$APP_DIR" -name "*.app" -type d | while read -r app; do
+ app_name="$(basename "$app")"
+ cp -r "$app" "$OUTPUT_DIR/ios/$app_name"
+
+ # Package into an unsigned .ipa for distribution
+ PAYLOAD_DIR=$(mktemp -d)
+ mkdir -p "$PAYLOAD_DIR/Payload"
+ cp -r "$app" "$PAYLOAD_DIR/Payload/"
+ (cd "$PAYLOAD_DIR" && zip -qr "${OLDPWD}/${OUTPUT_DIR}/ios/nullptr-${VERSION}-unsigned.ipa" Payload)
+ rm -rf "$PAYLOAD_DIR"
+ done
+ ok "iOS .app → ${OUTPUT_DIR}/ios/"
+ else
+ warn "No iOS build output found"
+ fi
+ fi
+ echo ""
+fi
+
# ── Summary ──
echo ""
@@ -241,7 +298,7 @@ echo ""
if [[ -d "$OUTPUT_DIR" ]]; then
find "$OUTPUT_DIR" -type f \( -name "*.deb" -o -name "*.AppImage" -o -name "*.rpm" \
-o -name "*.dmg" -o -name "*.app" -o -name "*.msi" -o -name "*.exe" \
- -o -name "*.apk" \) -exec sh -c '
+ -o -name "*.apk" -o -name "*.ipa" \) -exec sh -c '
for f; do
size=$(du -h "$f" | cut -f1)
echo " ${size} $(basename "$f")"
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index febc5b9322..9fd9c1f284 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -48,6 +48,9 @@
"appimage": {
"bundleMediaFramework": false
}
+ },
+ "iOS": {
+ "developmentTeam": ""
}
}
}
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...
+
+
+ )}
+
+
+
+
+
+ );
+}
+
diff --git a/src/app/features/widgets/WidgetIframe.tsx b/src/app/features/widgets/WidgetIframe.tsx
new file mode 100644
index 0000000000..960c34a2ae
--- /dev/null
+++ b/src/app/features/widgets/WidgetIframe.tsx
@@ -0,0 +1,150 @@
+import React, { useEffect, useRef, useState } from 'react';
+import {
+ ClientWidgetApi,
+ IWidget,
+ IRoomEvent,
+ Widget,
+ WidgetKind,
+} from 'matrix-widget-api';
+import {
+ ClientEvent,
+ Direction,
+ IEvent,
+ MatrixClient,
+ MatrixEvent,
+ MatrixEventEvent,
+} from 'matrix-js-sdk';
+import { GenericWidgetDriver, CapabilityApprovalCallback } from './GenericWidgetDriver';
+import { resolveWidgetUrl } from '../../hooks/useRoomWidgets';
+
+interface WidgetIframeProps {
+ widget: IWidget;
+ roomId: string;
+ mx: MatrixClient;
+ onCapabilityRequest?: CapabilityApprovalCallback;
+}
+
+export function WidgetIframe({ widget, roomId, mx, onCapabilityRequest }: WidgetIframeProps) {
+ const iframeRef = useRef(null);
+ const messagingRef = useRef(null);
+ const [, setReady] = useState(false);
+
+ const userId = mx.getUserId() || '';
+ const displayName = mx.getUser(userId)?.displayName || userId;
+ const avatarUrl = mx.getUser(userId)?.avatarUrl || '';
+ const resolvedUrl = resolveWidgetUrl(
+ widget.url || '',
+ roomId,
+ userId,
+ displayName,
+ avatarUrl,
+ widget.id
+ );
+
+ useEffect(() => {
+ const iframe = iframeRef.current;
+ if (!iframe) return undefined;
+
+ const mockWidget = new Widget(widget);
+ const driver = new GenericWidgetDriver(
+ mx,
+ mockWidget,
+ WidgetKind.Room,
+ roomId,
+ onCapabilityRequest
+ );
+
+ const messaging = new ClientWidgetApi(mockWidget, iframe, driver);
+ messagingRef.current = messaging;
+ messaging.setViewedRoomId(roomId);
+
+ messaging.once('ready', () => setReady(true));
+
+ messaging.on('action:org.matrix.msc2876.read_events', (ev: CustomEvent) => {
+ const room = mx.getRoom(roomId);
+ const events: Partial[] = [];
+ const { type } = ev.detail.data;
+ ev.preventDefault();
+ if (room === null) {
+ messaging.transport.reply(ev.detail, { events });
+ return;
+ }
+ const state = room.getLiveTimeline().getState(Direction.Forward);
+ if (state === undefined) {
+ messaging.transport.reply(ev.detail, { events });
+ return;
+ }
+ const stateEvents = state.events?.get(type);
+ Array.from(stateEvents?.values() ?? []).forEach((eventObject: MatrixEvent) => {
+ events.push(eventObject.event);
+ });
+ messaging.transport.reply(ev.detail, { events });
+ });
+
+ const readUpToMap: { [rId: string]: string } = {};
+ mx.getRooms().forEach((room) => {
+ const roomEvents = room.getLiveTimeline()?.getEvents() || [];
+ const last = roomEvents[roomEvents.length - 1];
+ if (last) {
+ const id = last.getId();
+ if (id) readUpToMap[room.roomId] = id;
+ }
+ });
+
+ const feedEvent = (ev: MatrixEvent): void => {
+ if (!messagingRef.current) return;
+ if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
+ const raw = ev.getEffectiveEvent();
+ messagingRef.current.feedEvent(raw as IRoomEvent).catch(() => null);
+ };
+
+ const onEvent = (ev: MatrixEvent): void => {
+ mx.decryptEventIfNeeded(ev);
+ feedEvent(ev);
+ };
+
+ const onDecrypted = (ev: MatrixEvent): void => {
+ feedEvent(ev);
+ };
+
+ const onToDevice = async (ev: MatrixEvent): Promise => {
+ await mx.decryptEventIfNeeded(ev);
+ if (ev.isDecryptionFailure()) return;
+ await messagingRef.current?.feedToDevice(
+ ev.getEffectiveEvent() as IRoomEvent,
+ ev.isEncrypted()
+ );
+ };
+
+ mx.on(ClientEvent.Event, onEvent);
+ mx.on(MatrixEventEvent.Decrypted, onDecrypted);
+ mx.on(ClientEvent.ToDeviceEvent, onToDevice);
+
+ return () => {
+ mx.removeListener(ClientEvent.Event, onEvent);
+ mx.removeListener(MatrixEventEvent.Decrypted, onDecrypted);
+ mx.removeListener(ClientEvent.ToDeviceEvent, onToDevice);
+ messaging.stop();
+ messaging.removeAllListeners();
+ messagingRef.current = null;
+ setReady(false);
+ };
+ }, [widget.id, roomId, mx]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ return (
+
+ );
+}
diff --git a/src/app/features/widgets/WidgetsDrawer.css.ts b/src/app/features/widgets/WidgetsDrawer.css.ts
new file mode 100644
index 0000000000..171fa49044
--- /dev/null
+++ b/src/app/features/widgets/WidgetsDrawer.css.ts
@@ -0,0 +1,30 @@
+import { style } from '@vanilla-extract/css';
+import { config, toRem } from 'folds';
+
+export const WidgetsDrawer = style({
+ width: toRem(420),
+ maxWidth: '100vw',
+});
+
+export const WidgetsDrawerHeader = style({
+ flexShrink: 0,
+ padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
+ borderBottomWidth: config.borderWidth.B300,
+});
+
+
+export const WidgetIframeContainer = style({
+ flexGrow: 1,
+ position: 'relative',
+ overflow: 'hidden',
+ minHeight: 0,
+});
+
+export const AddWidgetForm = style({
+ padding: config.space.S300,
+});
+
+export const AddWidgetInput = style({
+ width: '100%',
+});
+
diff --git a/src/app/features/widgets/WidgetsDrawer.tsx b/src/app/features/widgets/WidgetsDrawer.tsx
new file mode 100644
index 0000000000..448abb9f1d
--- /dev/null
+++ b/src/app/features/widgets/WidgetsDrawer.tsx
@@ -0,0 +1,305 @@
+import React, { ChangeEventHandler, FormEventHandler, MouseEventHandler, useState } from 'react';
+import {
+ Box,
+ Header,
+ Icon,
+ IconButton,
+ Icons,
+ Input,
+ MenuItem,
+ Scroll,
+ Text,
+ Tooltip,
+ TooltipProvider,
+ config,
+ Button,
+ Line,
+} from 'folds';
+import { Room } from 'matrix-js-sdk';
+import { IWidget } from 'matrix-widget-api';
+
+import * as css from './WidgetsDrawer.css';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useRoomWidgets, RoomWidget } from '../../hooks/useRoomWidgets';
+import { WidgetIframe } from './WidgetIframe';
+import { useSetSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
+import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
+import { useRoomCreators } from '../../hooks/useRoomCreators';
+import { useRoomPermissions } from '../../hooks/useRoomPermissions';
+import { ContainerColor } from '../../styles/ContainerColor.css';
+import { StateEvent } from '../../../types/matrix/room';
+import { IntegrationManager } from './IntegrationManager';
+
+type WidgetsDrawerHeaderProps = {
+ room: Room;
+ activeWidget: RoomWidget | null;
+ onBack: () => void;
+};
+
+function WidgetDrawerHeader({ room, activeWidget, onBack }: WidgetsDrawerHeaderProps) {
+ const setWidgetDrawer = useSetSetting(settingsAtom, 'isWidgetDrawer');
+
+ return (
+
+
+ {activeWidget && (
+
+
+
+
+
+ )}
+
+
+ {activeWidget ? activeWidget.name || 'Widget' : 'Widgets'}
+
+
+
+
+ Close
+
+ }
+ >
+ {(triggerRef) => (
+ setWidgetDrawer(false)}
+ >
+
+
+ )}
+
+
+
+
+ );
+}
+
+type AddWidgetFormProps = {
+ room: Room;
+ onAdded: () => void;
+};
+
+function AddWidgetForm({ room, onAdded }: AddWidgetFormProps) {
+ const mx = useMatrixClient();
+ const [name, setName] = useState('');
+ const [url, setUrl] = useState('');
+ const [adding, setAdding] = useState(false);
+
+ const handleSubmit: FormEventHandler = async (e) => {
+ e.preventDefault();
+ if (!name.trim() || !url.trim()) return;
+
+ setAdding(true);
+ try {
+ const widgetId = `${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
+ await mx.sendStateEvent(room.roomId, StateEvent.RoomWidget as any, {
+ type: 'm.custom',
+ url: url.trim(),
+ name: name.trim(),
+ id: widgetId,
+ creatorUserId: mx.getUserId(),
+ } as any, widgetId);
+ setName('');
+ setUrl('');
+ onAdded();
+ } catch (err) {
+ console.error('Failed to add widget:', err);
+ } finally {
+ setAdding(false);
+ }
+ };
+
+ return (
+
+ );
+}
+
+type WidgetListItemProps = {
+ widget: RoomWidget;
+ onSelect: (widget: RoomWidget) => void;
+ onRemove: (widget: RoomWidget) => void;
+ canRemove: boolean;
+};
+
+function WidgetListItemView({ widget, onSelect, onRemove, canRemove }: WidgetListItemProps) {
+ const handleRemove: MouseEventHandler = (e) => {
+ e.stopPropagation();
+ onRemove(widget);
+ };
+
+ return (
+
+ );
+}
+
+type WidgetsDrawerProps = {
+ room: Room;
+};
+
+export function WidgetsDrawer({ room }: WidgetsDrawerProps) {
+ const mx = useMatrixClient();
+ const widgets = useRoomWidgets(room);
+ const [activeWidget, setActiveWidget] = useState(null);
+ const [showAddForm, setShowAddForm] = useState(false);
+ const [showIntegrationManager, setShowIntegrationManager] = useState(false);
+
+ const powerLevels = usePowerLevelsContext();
+ const creators = useRoomCreators(room);
+ const permissions = useRoomPermissions(creators, powerLevels);
+ const canManageWidgets = permissions.stateEvent(StateEvent.RoomWidget, mx.getSafeUserId());
+
+ const handleRemoveWidget = async (widget: RoomWidget) => {
+ try {
+ await mx.sendStateEvent(room.roomId, StateEvent.RoomWidget as any, {} as any, widget.id);
+ if (activeWidget?.id === widget.id) {
+ setActiveWidget(null);
+ }
+ } catch (err) {
+ console.error('Failed to remove widget:', err);
+ }
+ };
+
+ const handleBack = () => setActiveWidget(null);
+
+ return (
+
+
+ {activeWidget ? (
+
+
+
+ ) : (
+
+
+ {widgets.length === 0 && !showAddForm && (
+
+
+ No widgets in this room.
+
+
+ )}
+ {widgets.map((widget) => (
+
+ ))}
+ {canManageWidgets && (
+ <>
+
+ {showAddForm ? (
+ setShowAddForm(false)} />
+ ) : (
+
+
+
+
+ )}
+ >
+ )}
+
+
+ )}
+ setShowIntegrationManager(false)}
+ />
+
+ );
+}
+
diff --git a/src/app/hooks/useIntegrationManager.ts b/src/app/hooks/useIntegrationManager.ts
new file mode 100644
index 0000000000..e0472655ae
--- /dev/null
+++ b/src/app/hooks/useIntegrationManager.ts
@@ -0,0 +1,151 @@
+import { useCallback, useEffect, useState } from 'react';
+import { MatrixClient } from 'matrix-js-sdk';
+import { useMatrixClient } from './useMatrixClient';
+
+export interface IntegrationManager {
+ apiUrl: string;
+ uiUrl: string;
+}
+
+const DEFAULT_MANAGERS: IntegrationManager[] = [
+ {
+ apiUrl: 'https://scalar.vector.im/api',
+ uiUrl: 'https://scalar.vector.im',
+ },
+];
+
+async function discoverManagers(mx: MatrixClient): Promise {
+ const managers: IntegrationManager[] = [];
+
+ try {
+ const baseUrl = mx.getHomeserverUrl();
+ const url = new URL(baseUrl);
+ const wellKnownUrl = `https://${url.hostname}/.well-known/matrix/client`;
+
+ const resp = await fetch(wellKnownUrl, { method: 'GET' });
+ if (resp.ok) {
+ const data = await resp.json();
+ const integrations = data?.['m.integrations'];
+ if (integrations?.managers && Array.isArray(integrations.managers)) {
+ integrations.managers.forEach((mgr: { api_url?: string; ui_url?: string }) => {
+ if (mgr.api_url && mgr.ui_url) {
+ managers.push({ apiUrl: mgr.api_url, uiUrl: mgr.ui_url });
+ }
+ });
+ }
+ }
+ } catch {
+ }
+
+ try {
+ const widgetsEvent = mx.getAccountData('m.widgets');
+ if (widgetsEvent) {
+ const content = widgetsEvent.getContent();
+ Object.values(content).forEach((widget: any) => {
+ if (widget?.type === 'm.integration_manager' && widget?.url) {
+ const existing = managers.some((m) => m.uiUrl === widget.url);
+ if (!existing) {
+ managers.push({
+ apiUrl: widget.data?.api_url || widget.url,
+ uiUrl: widget.url,
+ });
+ }
+ }
+ });
+ }
+ } catch {
+ }
+
+ if (managers.length === 0) {
+ return DEFAULT_MANAGERS;
+ }
+
+ return managers;
+}
+
+async function getScalarToken(mx: MatrixClient, apiUrl: string): Promise {
+ try {
+ const openIdToken = await mx.getOpenIdToken();
+
+ const resp = await fetch(`${apiUrl}/register`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(openIdToken),
+ });
+
+ if (!resp.ok) return null;
+
+ const data = await resp.json();
+ return data?.scalar_token ?? null;
+ } catch {
+ return null;
+ }
+}
+
+export function buildIntegrationManagerUrl(
+ uiUrl: string,
+ scalarToken: string | null,
+ roomId: string,
+ screen?: string,
+ integId?: string
+): string {
+ const url = new URL(uiUrl);
+ url.pathname = '/index.html';
+ if (scalarToken) url.searchParams.set('scalar_token', scalarToken);
+ url.searchParams.set('room_id', roomId);
+ if (screen) url.searchParams.set('screen', screen);
+ if (integId) url.searchParams.set('integ_id', integId);
+ return url.toString();
+}
+
+export type IntegrationManagerState = {
+ managers: IntegrationManager[];
+ scalarToken: string | null;
+ loading: boolean;
+ error: string | null;
+};
+
+export function useIntegrationManager(): IntegrationManagerState & {
+ refresh: () => void;
+} {
+ const mx = useMatrixClient();
+ const [state, setState] = useState({
+ managers: [],
+ scalarToken: null,
+ loading: true,
+ error: null,
+ });
+
+ const loadManagers = useCallback(async () => {
+ setState((s) => ({ ...s, loading: true, error: null }));
+ try {
+ const managers = await discoverManagers(mx);
+ let scalarToken: string | null = null;
+
+ if (managers.length > 0) {
+ scalarToken = await getScalarToken(mx, managers[0].apiUrl);
+ }
+
+ setState({
+ managers,
+ scalarToken,
+ loading: false,
+ error: null,
+ });
+ } catch (err) {
+ setState({
+ managers: [],
+ scalarToken: null,
+ loading: false,
+ error: err instanceof Error ? err.message : 'Failed to load integration managers',
+ });
+ }
+ }, [mx]);
+
+ useEffect(() => {
+ loadManagers();
+ }, [loadManagers]);
+
+ return { ...state, refresh: loadManagers };
+}
+
diff --git a/src/app/hooks/useRoomWidgets.ts b/src/app/hooks/useRoomWidgets.ts
new file mode 100644
index 0000000000..21cbad5575
--- /dev/null
+++ b/src/app/hooks/useRoomWidgets.ts
@@ -0,0 +1,74 @@
+import { Room, MatrixEvent } from 'matrix-js-sdk';
+import { useCallback, useMemo } from 'react';
+import { IWidget } from 'matrix-widget-api';
+import { useStateEventCallback } from './useStateEventCallback';
+import { useForceUpdate } from './useForceUpdate';
+import { getStateEvents } from '../utils/room';
+import { StateEvent } from '../../types/matrix/room';
+
+export interface RoomWidget extends IWidget {
+ eventId?: string;
+ sender?: string;
+}
+
+export const resolveWidgetUrl = (
+ url: string,
+ roomId: string,
+ userId: string,
+ displayName: string,
+ avatarUrl: string,
+ widgetId: string
+): string =>
+ url
+ .replace(/\$matrix_user_id/g, encodeURIComponent(userId))
+ .replace(/\$matrix_room_id/g, encodeURIComponent(roomId))
+ .replace(/\$matrix_display_name/g, encodeURIComponent(displayName))
+ .replace(/\$matrix_avatar_url/g, encodeURIComponent(avatarUrl))
+ .replace(/\$matrix_widget_id/g, encodeURIComponent(widgetId));
+
+export const useRoomWidgets = (room: Room): RoomWidget[] => {
+ const [updateCount, forceUpdate] = useForceUpdate();
+
+ useStateEventCallback(
+ room.client,
+ useCallback(
+ (event) => {
+ if (
+ event.getRoomId() === room.roomId &&
+ event.getType() === StateEvent.RoomWidget
+ ) {
+ forceUpdate();
+ }
+ },
+ [room.roomId, forceUpdate]
+ )
+ );
+
+ return useMemo(() => {
+ const events: MatrixEvent[] = getStateEvents(room, StateEvent.RoomWidget);
+
+ return events.reduce((widgets, event) => {
+ const content = event.getContent();
+ if (!content || !content.url || Object.keys(content).length === 0) return widgets;
+
+ const stateKey = event.getStateKey();
+ if (!stateKey) return widgets;
+
+ widgets.push({
+ id: content.id || stateKey,
+ creatorUserId: content.creatorUserId || event.getSender() || '',
+ type: content.type || 'm.custom',
+ url: content.url,
+ name: content.name || 'Widget',
+ data: content.data || {},
+ waitForIframeLoad: content.waitForIframeLoad ?? true,
+ eventId: event.getId(),
+ sender: event.getSender() || undefined,
+ });
+
+ return widgets;
+ }, []);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [room, updateCount]);
+};
+
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index ef3a856345..a6dea7548e 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -29,6 +29,7 @@ export interface Settings {
hideActivity: boolean;
isPeopleDrawer: boolean;
+ isWidgetDrawer: boolean;
memberSortFilterIndex: number;
enterForNewline: boolean;
messageLayout: MessageLayout;
@@ -73,6 +74,7 @@ const defaultSettings: Settings = {
hideActivity: false,
isPeopleDrawer: true,
+ isWidgetDrawer: false,
memberSortFilterIndex: 0,
enterForNewline: false,
messageLayout: 0,
diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts
index 0ef53fc567..60454d7a91 100644
--- a/src/types/matrix/room.ts
+++ b/src/types/matrix/room.ts
@@ -42,6 +42,9 @@ export enum StateEvent {
PoniesRoomEmotes = 'im.ponies.room_emotes',
PowerLevelTags = 'in.cinny.room.power_level_tags',
+ // Widget state events
+ RoomWidget = 'im.vector.modular.widgets',
+
// nullptr state events
RoomCosmeticsColor = 'moe.nullptr.room.cosmetics.color',
RoomCosmeticsFont = 'moe.nullptr.room.cosmetics.font',