diff --git a/capacitor.config.ts b/capacitor.config.ts
index 45e73227..8a677246 100644
--- a/capacitor.config.ts
+++ b/capacitor.config.ts
@@ -4,6 +4,16 @@ const config: CapacitorConfig = {
appId: 'chat.vojo.app',
appName: 'Vojo',
webDir: 'dist',
+ // Bot widgets (docs/plans/bots_tab.md Phase 2): on Android, Capacitor's
+ // BridgeWebViewClient.shouldOverrideUrlLoading does NOT check
+ // request.isForMainFrame(), so any cross-origin iframe URL not in
+ // appAllowNavigationMask is hijacked into an external Intent.ACTION_VIEW
+ // and the iframe stays blank. Same-origin /widgets/... is safe (resolves
+ // to https://localhost under Capacitor). For HTTPS-hosted bot widgets,
+ // uncomment and edit the host list below BEFORE shipping a config.json
+ // that points at them:
+ //
+ // server: { allowNavigation: ['app.vojo.chat', '*.vojo.chat'] },
android: {
// Keep default: resolveServiceWorkerRequests = true
// SW is critical for authenticated Matrix media (MSC3916 / spec v1.11+)
diff --git a/package-lock.json b/package-lock.json
index 3d57ef4f..13c30b11 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -53,7 +53,7 @@
"linkify-react": "4.3.2",
"linkifyjs": "4.3.2",
"matrix-js-sdk": "38.2.0",
- "matrix-widget-api": "1.13.0",
+ "matrix-widget-api": "1.17.0",
"millify": "6.1.0",
"pdfjs-dist": "4.2.67",
"prismjs": "1.30.0",
@@ -10679,9 +10679,9 @@
}
},
"node_modules/matrix-widget-api": {
- "version": "1.13.0",
- "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.0.tgz",
- "integrity": "sha512-+LrvwkR1izL4h2euX8PDrvG/3PZZDEd6As+lmnR3jAVwbFJtU5iTnwmZGnCca9ddngCvXvAHkcpJBEPyPTZneQ==",
+ "version": "1.17.0",
+ "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.17.0.tgz",
+ "integrity": "sha512-5FHoo3iEP3Bdlv5jsYPWOqj+pGdFQNLWnJLiB0V7Ygne7bb+Gsj3ibyFyHWC6BVw+Z+tSW4ljHpO17I9TwStwQ==",
"license": "Apache-2.0",
"dependencies": {
"@types/events": "^3.0.0",
diff --git a/package.json b/package.json
index 73c1a7af..e75a0492 100644
--- a/package.json
+++ b/package.json
@@ -85,7 +85,7 @@
"linkify-react": "4.3.2",
"linkifyjs": "4.3.2",
"matrix-js-sdk": "38.2.0",
- "matrix-widget-api": "1.13.0",
+ "matrix-widget-api": "1.17.0",
"millify": "6.1.0",
"pdfjs-dist": "4.2.67",
"prismjs": "1.30.0",
diff --git a/public/locales/en.json b/public/locales/en.json
index f99fb1c2..1079008d 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -937,6 +937,9 @@
"unsafe_title": "This robot chat is not private",
"unsafe_description": "The robot is blocked because another active member is present in the chat.",
"open_chat": "Open chat",
+ "show_chat": "Show chat",
+ "show_widget": "Show robot",
+ "retry_widget": "Retry robot",
"unknown_title": "Robot not found",
"unknown_description": "This robot is not in the Vojo catalog."
}
diff --git a/public/locales/ru.json b/public/locales/ru.json
index 35d197ae..02a88abb 100644
--- a/public/locales/ru.json
+++ b/public/locales/ru.json
@@ -941,6 +941,9 @@
"unsafe_title": "Чат с роботом не приватный",
"unsafe_description": "Робот заблокирован: в чате присутствует посторонний участник.",
"open_chat": "Открыть чат",
+ "show_chat": "Показать чат",
+ "show_widget": "Показать робота",
+ "retry_widget": "Повторить",
"unknown_title": "Робот не найден",
"unknown_description": "Этого робота нет в каталоге Vojo."
}
diff --git a/src/app/features/bots/BotExperienceSlot.tsx b/src/app/features/bots/BotExperienceSlot.tsx
new file mode 100644
index 00000000..5e2b2d46
--- /dev/null
+++ b/src/app/features/bots/BotExperienceSlot.tsx
@@ -0,0 +1,79 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { Room } from 'matrix-js-sdk';
+import { Box, Button, Text } from 'folds';
+import { useTranslation } from 'react-i18next';
+import { RoomView } from '../room/RoomView';
+import type { BotPreset } from './catalog';
+import { BotWidgetHost } from './BotWidgetHost';
+import * as css from './BotWidgetHost.css';
+
+type BotExperienceSlotProps = {
+ preset: BotPreset;
+ room: Room;
+ eventId?: string;
+};
+
+export function BotExperienceSlot({ preset, room, eventId }: BotExperienceSlotProps) {
+ const { t } = useTranslation();
+ const [showChat, setShowChat] = useState(false);
+ const [failed, setFailed] = useState(false);
+ // Bumped on every retry so BotWidgetHost gets a fresh `key` and remounts
+ // even when preset/room/url are unchanged — without this, a retry after
+ // failure would reuse the same React element and the iframe/embed lifecycle
+ // would not re-run.
+ const [retryCount, setRetryCount] = useState(0);
+
+ const experienceUrl = preset.experience?.url;
+ const hasWidget = preset.experience?.type === 'matrix-widget';
+ const showRawChat = showChat || failed;
+
+ useEffect(() => {
+ setShowChat(false);
+ setFailed(false);
+ setRetryCount(0);
+ }, [preset.id, room.roomId, experienceUrl]);
+
+ const handleShowChat = useCallback(() => {
+ setShowChat(true);
+ }, []);
+
+ const handleWidgetError = useCallback(() => {
+ setFailed(true);
+ }, []);
+
+ const handleShowWidget = useCallback(() => {
+ setShowChat(false);
+ setFailed(false);
+ setRetryCount((c) => c + 1);
+ }, []);
+
+ if (!hasWidget) {
+ return ;
+ }
+
+ if (showRawChat) {
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/features/bots/BotWidgetDriver.ts b/src/app/features/bots/BotWidgetDriver.ts
new file mode 100644
index 00000000..cb695be3
--- /dev/null
+++ b/src/app/features/bots/BotWidgetDriver.ts
@@ -0,0 +1,228 @@
+import {
+ EventDirection,
+ type Capability,
+ type IOpenIDUpdate,
+ type IRoomEvent,
+ type ISendEventDetails,
+ type SimpleObservable,
+ WidgetDriver,
+ WidgetEventCapability,
+ OpenIDRequestState,
+} from 'matrix-widget-api';
+import {
+ EventTimeline,
+ EventType,
+ type IContent,
+ MatrixClient,
+ MatrixError,
+ MatrixEvent,
+ MsgType,
+ Room,
+} from 'matrix-js-sdk';
+import { Membership } from '../../../types/matrix/room';
+import { isPortalRoomForOtherBridge } from './room';
+import type { BotPreset } from './catalog';
+
+const BOT_WIDGET_TIMELINE_LIMIT = 100;
+
+const isObject = (value: unknown): value is Record =>
+ typeof value === 'object' && value !== null && !Array.isArray(value);
+
+const isAllowedMessageType = (msgtype: unknown): msgtype is 'm.text' | 'm.notice' =>
+ msgtype === 'm.text' || msgtype === 'm.notice';
+
+export const isSafeBotWidgetRoom = (
+ mx: MatrixClient,
+ room: Room | null | undefined,
+ preset: BotPreset
+): room is Room => {
+ if (!room) return false;
+ if (isPortalRoomForOtherBridge(room, preset.mxid)) return false;
+
+ const myUserId = mx.getSafeUserId();
+ if (room.getMember(myUserId)?.membership !== Membership.Join) return false;
+ if (room.getMember(preset.mxid)?.membership !== Membership.Join) return false;
+
+ const activeMembers = room
+ .getMembers()
+ .filter((m) => m.membership === Membership.Join || m.membership === Membership.Invite);
+ const allowedMembers = new Set([myUserId, preset.mxid]);
+
+ return (
+ activeMembers.length === allowedMembers.size &&
+ activeMembers.every((m) => allowedMembers.has(m.userId))
+ );
+};
+
+export const sanitizeBotWidgetMessageEvent = (rawEvent: IRoomEvent): IRoomEvent | undefined => {
+ if (rawEvent.type !== EventType.RoomMessage) return undefined;
+ if (rawEvent.state_key !== undefined) return undefined;
+ if (!isObject(rawEvent.content)) return undefined;
+ if (typeof rawEvent.event_id !== 'string' || rawEvent.event_id.length === 0) return undefined;
+ if (typeof rawEvent.room_id !== 'string' || rawEvent.room_id.length === 0) return undefined;
+ if (typeof rawEvent.sender !== 'string') return undefined;
+ if (typeof rawEvent.origin_server_ts !== 'number') return undefined;
+
+ const { msgtype, body } = rawEvent.content;
+ if (!isAllowedMessageType(msgtype) || typeof body !== 'string') return undefined;
+
+ // Rebuild the outgoing event from primitives instead of spreading rawEvent —
+ // a future SDK upgrade could attach extra top-level keys (decryption hints,
+ // local-echo flags, custom org.matrix.* fields) and we never want those to
+ // ride out to a widget that only asked for {msgtype, body}.
+ return {
+ type: EventType.RoomMessage,
+ event_id: rawEvent.event_id,
+ room_id: rawEvent.room_id,
+ sender: rawEvent.sender,
+ origin_server_ts: rawEvent.origin_server_ts,
+ content: { msgtype, body },
+ unsigned: {},
+ };
+};
+
+export const getBotWidgetCapabilities = (roomId: string): Set =>
+ new Set([
+ `org.matrix.msc2762.timeline:${roomId}`,
+ WidgetEventCapability.forRoomMessageEvent(EventDirection.Send, 'm.text').raw,
+ WidgetEventCapability.forRoomMessageEvent(EventDirection.Receive, 'm.text').raw,
+ WidgetEventCapability.forRoomMessageEvent(EventDirection.Receive, 'm.notice').raw,
+ WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomMember).raw,
+ ]);
+
+export class BotWidgetDriver extends WidgetDriver {
+ private readonly allowedCapabilities: Set;
+
+ public constructor(
+ private readonly mx: MatrixClient,
+ private readonly roomId: string,
+ private readonly preset: BotPreset
+ ) {
+ super();
+ this.allowedCapabilities = getBotWidgetCapabilities(roomId);
+ }
+
+ private getSafeRoom(): Room | undefined {
+ const room = this.mx.getRoom(this.roomId);
+ return isSafeBotWidgetRoom(this.mx, room, this.preset) ? room : undefined;
+ }
+
+ private isAllowedTargetRoom(targetRoomId?: string | null): boolean {
+ return !targetRoomId || targetRoomId === this.roomId;
+ }
+
+ public async validateCapabilities(requested: Set): Promise> {
+ if (!this.getSafeRoom()) return new Set();
+
+ return new Set(Array.from(requested).filter((cap) => this.allowedCapabilities.has(cap)));
+ }
+
+ public async sendEvent(
+ eventType: string,
+ content: IContent,
+ stateKey: string | null = null,
+ targetRoomId: string | null = null
+ ): Promise {
+ const room = this.getSafeRoom();
+ if (!room || !this.isAllowedTargetRoom(targetRoomId)) {
+ throw new Error('Bot widget cannot send to this room');
+ }
+ if (stateKey !== null) throw new Error('Bot widget cannot send state events');
+ if (eventType !== EventType.RoomMessage) throw new Error('Bot widget cannot send this event');
+ if (!isObject(content) || content.msgtype !== 'm.text' || typeof content.body !== 'string') {
+ throw new Error('Bot widget can only send m.text messages');
+ }
+
+ const result = await this.mx.sendEvent(room.roomId, EventType.RoomMessage, {
+ msgtype: MsgType.Text,
+ body: content.body,
+ });
+
+ return { roomId: room.roomId, eventId: result.event_id };
+ }
+
+ public async readRoomTimeline(
+ targetRoomId: string,
+ eventType: string,
+ msgtype: string | undefined,
+ stateKey: string | undefined,
+ limit: number,
+ since: string | undefined
+ ): Promise {
+ const room = this.getSafeRoom();
+ if (!room || !this.isAllowedTargetRoom(targetRoomId)) return [];
+ if (eventType === EventType.RoomMember) {
+ return this.readRoomState(targetRoomId, eventType, stateKey);
+ }
+ if (eventType !== EventType.RoomMessage) return [];
+ if (msgtype !== undefined && !isAllowedMessageType(msgtype)) return [];
+
+ const safeLimit =
+ limit > 0 ? Math.min(limit, BOT_WIDGET_TIMELINE_LIMIT) : BOT_WIDGET_TIMELINE_LIMIT;
+ const events: MatrixEvent[] = [];
+
+ const timelineEvents = room.getLiveTimeline().getEvents();
+ for (let i = timelineEvents.length - 1; i >= 0; i -= 1) {
+ if (events.length >= safeLimit) break;
+
+ const ev = timelineEvents[i];
+ if (since !== undefined && ev.getId() === since) break;
+ if (!ev.isState() && ev.getType() === EventType.RoomMessage) {
+ const content = ev.getContent();
+ if (
+ isAllowedMessageType(content.msgtype) &&
+ (msgtype === undefined || content.msgtype === msgtype)
+ ) {
+ events.push(ev);
+ }
+ }
+ }
+
+ return events.flatMap((ev) => {
+ const sanitized = sanitizeBotWidgetMessageEvent(ev.getEffectiveEvent() as IRoomEvent);
+ return sanitized ? [sanitized] : [];
+ });
+ }
+
+ public async readRoomState(
+ targetRoomId: string,
+ eventType: string,
+ stateKey: string | undefined
+ ): Promise {
+ const room = this.getSafeRoom();
+ if (!room || !this.isAllowedTargetRoom(targetRoomId)) return [];
+ if (eventType !== EventType.RoomMember) return [];
+ if (
+ stateKey !== undefined &&
+ stateKey !== this.mx.getSafeUserId() &&
+ stateKey !== this.preset.mxid
+ ) {
+ return [];
+ }
+
+ const state = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
+ if (!state) return [];
+
+ const stateKeys = stateKey ? [stateKey] : [this.mx.getSafeUserId(), this.preset.mxid];
+ return stateKeys.flatMap((key) => {
+ const ev = state.getStateEvents(EventType.RoomMember, key);
+ return ev ? [ev.getEffectiveEvent() as IRoomEvent] : [];
+ });
+ }
+
+ public getKnownRooms(): string[] {
+ return this.getSafeRoom() ? [this.roomId] : [];
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ public askOpenID(observer: SimpleObservable): void {
+ observer.update({ state: OpenIDRequestState.Blocked });
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ public processError(error: unknown) {
+ return error instanceof MatrixError
+ ? { matrix_api_error: error.asWidgetApiErrorData() }
+ : undefined;
+ }
+}
diff --git a/src/app/features/bots/BotWidgetEmbed.ts b/src/app/features/bots/BotWidgetEmbed.ts
new file mode 100644
index 00000000..4c8ada47
--- /dev/null
+++ b/src/app/features/bots/BotWidgetEmbed.ts
@@ -0,0 +1,302 @@
+import {
+ MatrixClient,
+ MatrixEvent,
+ MatrixEventEvent,
+ EventType,
+ Room,
+ RoomEvent,
+ RoomStateEvent,
+ type RoomEventHandlerMap,
+} from 'matrix-js-sdk';
+import {
+ ClientWidgetApi,
+ type IRoomEvent,
+ MatrixWidgetType,
+ Widget,
+ WidgetApiToWidgetAction,
+ type WidgetDriver,
+} from 'matrix-widget-api';
+import { Theme } from '../../hooks/useTheme';
+import type { BotPreset } from './catalog';
+import { BotWidgetDriver, sanitizeBotWidgetMessageEvent } from './BotWidgetDriver';
+
+export type BotWidgetEmbedOptions = {
+ mx: MatrixClient;
+ room: Room;
+ preset: BotPreset;
+ container: HTMLElement;
+ theme: Theme;
+ language: string;
+ onError: (error: Error) => void;
+};
+
+const getBotWidgetId = (preset: BotPreset): string => `vojo-bot-${preset.id}`;
+
+const getBotWidgetUrl = (
+ preset: BotPreset,
+ room: Room,
+ mx: MatrixClient,
+ theme: Theme,
+ language: string,
+ widgetId: string
+): string => {
+ if (!preset.experience) throw new Error('Bot widget experience is not configured');
+
+ const url = new URL(preset.experience.url, window.location.origin);
+ url.searchParams.set('widgetId', widgetId);
+ url.searchParams.set('parentUrl', window.location.origin);
+ url.searchParams.set('roomId', room.roomId);
+ url.searchParams.set('userId', mx.getSafeUserId());
+ url.searchParams.set('botId', preset.id);
+ url.searchParams.set('botMxid', preset.mxid);
+ url.searchParams.set('theme', theme.kind);
+ url.searchParams.set('clientLanguage', language);
+ url.searchParams.set('baseUrl', mx.baseUrl);
+ const deviceId = mx.getDeviceId();
+ if (deviceId) url.searchParams.set('deviceId', deviceId);
+
+ return url.toString();
+};
+
+const createBotWidget = (
+ preset: BotPreset,
+ room: Room,
+ mx: MatrixClient,
+ theme: Theme,
+ language: string
+): Widget => {
+ const widgetId = getBotWidgetId(preset);
+ return new Widget({
+ id: widgetId,
+ creatorUserId: mx.getSafeUserId(),
+ type: MatrixWidgetType.Custom,
+ name: preset.name,
+ url: getBotWidgetUrl(preset, room, mx, theme, language, widgetId),
+ waitForIframeLoad: true,
+ data: {
+ title: `${preset.name} Bot`,
+ },
+ });
+};
+
+// Build the iframe with all attributes EXCEPT `src`. The caller must assign
+// `iframe.src` only AFTER `new ClientWidgetApi(widget, iframe, driver)` has
+// attached its internal `load` listener — same-origin static widget HTML can
+// finish loading before the listener is wired, and `beginCapabilities()` then
+// never fires. This is the canonical Element-Web ordering.
+const createBotIframe = (preset: BotPreset): HTMLIFrameElement => {
+ const iframe = document.createElement('iframe');
+
+ iframe.title = `${preset.name} Bot`;
+ // Sandbox aligns with docs/plans/bots_tab.md M8 minimum: scripts + forms +
+ // same-origin only. Add allow-popups / allow-popups-to-escape-sandbox /
+ // allow-downloads only when a specific widget requires them (e.g. an OAuth
+ // login flow), as a per-preset opt-in — not as a default. Element-Web's
+ // wider default exists because their widget set includes Element Call;
+ // Phase 2 bot widgets are text-protocol management surfaces.
+ iframe.setAttribute('sandbox', 'allow-scripts allow-forms allow-same-origin');
+ iframe.allow = 'clipboard-write';
+ iframe.referrerPolicy = 'no-referrer';
+ iframe.style.width = '100%';
+ iframe.style.height = '100%';
+ iframe.style.border = 'none';
+
+ return iframe;
+};
+
+export class BotWidgetEmbed {
+ public readonly api: ClientWidgetApi;
+
+ public readonly iframe: HTMLIFrameElement;
+
+ private disposed = false;
+
+ // Gate live event/state feeding on the widget completing its capability
+ // handshake. Without this, an initial-sync burst from matrix-js-sdk can
+ // call api.feedEvent / api.feedStateUpdate while the postMessage transport
+ // has no widget on the other end yet — pending sends can timeout/reject and
+ // demote the widget into the chat fallback even though the widget is fine.
+ private isReady = false;
+
+ private readonly disposables: Array<() => void> = [];
+
+ // Dedup events that have already been forwarded to the widget. Encrypted DMs
+ // hit feedEvent twice in succession (once from RoomEvent.Timeline with the
+ // ciphertext, once from MatrixEventEvent.Decrypted with the plaintext); pure
+ // chat rooms can also re-emit Timeline on store-rehydrate. Track by event_id
+ // so the widget never receives the same message twice.
+ private readonly fedEventIds = new Set();
+
+ private readonly onTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = (
+ ev,
+ timelineRoom,
+ toStartOfTimeline,
+ _removed,
+ data
+ ) => {
+ if (timelineRoom?.roomId !== this.room.roomId) return;
+ // Back-paginated history must not replay into a fresh widget session —
+ // the widget asks for history explicitly via readRoomTimeline.
+ if (toStartOfTimeline) return;
+ if (data?.liveEvent === false) return;
+ this.mx.decryptEventIfNeeded(ev);
+ this.feedEvent(ev);
+ };
+
+ private readonly onEventDecrypted = (ev: MatrixEvent) => {
+ if (ev.getRoomId() === this.room.roomId) this.feedEvent(ev);
+ };
+
+ private readonly onStateUpdate = (ev: MatrixEvent) => {
+ if (ev.getRoomId() !== this.room.roomId) return;
+ this.feedStateUpdate(ev);
+ };
+
+ public constructor(private readonly options: BotWidgetEmbedOptions) {
+ const { mx, room, preset, container, theme, language } = options;
+ const widget = createBotWidget(preset, room, mx, theme, language);
+ const widgetUrl = widget.getCompleteUrl({
+ currentUserId: mx.getSafeUserId(),
+ clientTheme: theme.kind,
+ clientLanguage: language,
+ widgetRoomId: room.roomId,
+ });
+
+ // Strict ordering — DO NOT reorder:
+ // 1. Build iframe with NO src.
+ // 2. Append iframe to DOM (creates contentWindow).
+ // 3. Construct ClientWidgetApi (attaches the internal `load` listener
+ // that triggers beginCapabilities()).
+ // 4. Only now assign iframe.src.
+ // If we set src in step 1 or 2, a same-origin static page can finish
+ // loading before step 3 wires the listener — beginCapabilities() never
+ // fires, no Capabilities request is sent to-widget, no `ready` event,
+ // and the widget hangs forever on a blank handshake.
+ const iframe = createBotIframe(preset);
+ container.append(iframe);
+
+ const driver: WidgetDriver = new BotWidgetDriver(mx, room.roomId, preset);
+ const api = new ClientWidgetApi(widget, iframe, driver);
+
+ this.api = api;
+ this.iframe = iframe;
+
+ api.setViewedRoomId(room.roomId);
+
+ iframe.src = widgetUrl;
+
+ const onReady = () => {
+ this.isReady = true;
+ // eslint-disable-next-line no-console
+ console.info('[BotWidget] handshake complete');
+ };
+ // Element-Web treats `error:preparing` as informational — it never tears
+ // down the widget. Vojo follows the same rule: log the upstream cause and
+ // leave the iframe mounted. The user can fall back to the chat manually
+ // via the «Show chat» toolbar; transient handshake hiccups (slow widget
+ // boot, dev hot-reload, mid-launch theme race) no longer demote a working
+ // session into chat. Real teardown still happens for: iframe network
+ // failure, room becomes unsafe, and host unmount.
+ const onPreparingError = (error: unknown) => {
+ // eslint-disable-next-line no-console
+ console.warn('[BotWidget] error:preparing (left mounted)', error);
+ };
+ const onIframeError = (event: Event) => {
+ // eslint-disable-next-line no-console
+ console.error('[BotWidget] iframe error', event);
+ this.fail(new Error('Bot widget iframe failed to load'));
+ };
+
+ api.on('ready', onReady);
+ api.on('error:preparing', onPreparingError);
+ iframe.addEventListener('error', onIframeError);
+ room.on(RoomEvent.Timeline, this.onTimelineEvent);
+ mx.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
+ mx.on(RoomStateEvent.Events, this.onStateUpdate);
+
+ this.disposables.push(
+ () => api.off('ready', onReady),
+ () => api.off('error:preparing', onPreparingError),
+ () => iframe.removeEventListener('error', onIframeError),
+ () => room.removeListener(RoomEvent.Timeline, this.onTimelineEvent),
+ () => mx.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted),
+ () => mx.removeListener(RoomStateEvent.Events, this.onStateUpdate)
+ );
+ }
+
+ private get mx(): MatrixClient {
+ return this.options.mx;
+ }
+
+ private get room(): Room {
+ return this.options.room;
+ }
+
+ private fail(error: Error): void {
+ if (this.disposed) return;
+ // eslint-disable-next-line no-console
+ console.error('[BotWidget] fail:', error.message, error);
+ this.options.onError(error);
+ this.destroy();
+ }
+
+ // No per-event isSafeBotWidgetRoom re-check on the feed paths. Initial sync
+ // can transiently report "unsafe" before all members hydrate, and the per-
+ // event fail() that used to live here would tear the widget down on flap.
+ // Membership-driven teardown is the responsibility of `useBotRoom` upstream:
+ // when the room transitions out of `ready`, BotExperienceHost dispatches to
+ // a different state branch and unmounts the widget through React. The
+ // driver still applies the safety gate on every capability/sendEvent/read*
+ // call (BotWidgetDriver.getSafeRoom) so a hostile widget can't escalate.
+ private feedEvent(ev: MatrixEvent): void {
+ if (this.disposed || !this.isReady) return;
+ if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
+
+ const raw = ev.getEffectiveEvent() as Partial;
+ if (raw.room_id !== this.room.roomId) return;
+ if (typeof raw.event_id !== 'string' || raw.event_id.length === 0) return;
+ if (this.fedEventIds.has(raw.event_id)) return;
+
+ const sanitized = sanitizeBotWidgetMessageEvent(raw as IRoomEvent);
+ if (!sanitized) return;
+
+ this.fedEventIds.add(raw.event_id);
+ this.api.feedEvent(sanitized).catch((error) => {
+ // eslint-disable-next-line no-console
+ console.warn('[BotWidget] feedEvent rejected', error);
+ });
+ }
+
+ private feedStateUpdate(ev: MatrixEvent): void {
+ if (this.disposed || !this.isReady) return;
+ if (ev.getType() !== EventType.RoomMember) return;
+ const stateKey = ev.getStateKey();
+ if (stateKey !== this.mx.getSafeUserId() && stateKey !== this.options.preset.mxid) return;
+
+ this.api.feedStateUpdate(ev.getEffectiveEvent() as IRoomEvent).catch((error) => {
+ // eslint-disable-next-line no-console
+ console.warn('[BotWidget] feedStateUpdate rejected', error);
+ });
+ }
+
+ // Push a runtime theme change. No-op until the widget completes the
+ // capability handshake — the initial theme is already in the URL params, so
+ // sending it pre-handshake would just queue a postMessage request that times
+ // out after 10 s in the transport (matrix-widget-api default).
+ public setTheme(theme: Theme): Promise {
+ if (this.disposed || !this.isReady) return Promise.resolve();
+ return this.api.transport
+ .send(WidgetApiToWidgetAction.ThemeChange, { name: theme.kind })
+ .then(() => undefined);
+ }
+
+ public destroy(): void {
+ if (this.disposed) return;
+
+ this.disposed = true;
+ this.disposables.forEach((dispose) => dispose());
+ this.api.stop();
+ this.iframe.remove();
+ this.fedEventIds.clear();
+ }
+}
diff --git a/src/app/features/bots/BotWidgetHost.css.ts b/src/app/features/bots/BotWidgetHost.css.ts
new file mode 100644
index 00000000..b1a8a61e
--- /dev/null
+++ b/src/app/features/bots/BotWidgetHost.css.ts
@@ -0,0 +1,28 @@
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, color, config, toRem } from 'folds';
+
+export const Host = style([
+ DefaultReset,
+ {
+ position: 'relative',
+ width: '100%',
+ height: '100%',
+ backgroundColor: color.Background.Container,
+ },
+]);
+
+export const FrameMount = style({
+ width: '100%',
+ height: '100%',
+});
+
+export const Toolbar = style([
+ DefaultReset,
+ {
+ position: 'absolute',
+ top: config.space.S300,
+ right: config.space.S300,
+ zIndex: 1,
+ maxWidth: `calc(100% - ${toRem(24)})`,
+ },
+]);
diff --git a/src/app/features/bots/BotWidgetHost.tsx b/src/app/features/bots/BotWidgetHost.tsx
new file mode 100644
index 00000000..acfa0c2b
--- /dev/null
+++ b/src/app/features/bots/BotWidgetHost.tsx
@@ -0,0 +1,18 @@
+import React, { useRef } from 'react';
+import { Room } from 'matrix-js-sdk';
+import type { BotPreset } from './catalog';
+import { useBotWidgetEmbed } from './useBotWidgetEmbed';
+import * as css from './BotWidgetHost.css';
+
+type BotWidgetHostProps = {
+ preset: BotPreset;
+ room: Room;
+ onError: () => void;
+};
+
+export function BotWidgetHost({ preset, room, onError }: BotWidgetHostProps) {
+ const containerRef = useRef(null);
+ useBotWidgetEmbed({ containerRef, preset, room, onError });
+
+ return ;
+}
diff --git a/src/app/features/bots/catalog.ts b/src/app/features/bots/catalog.ts
index c11f2850..8fda997b 100644
--- a/src/app/features/bots/catalog.ts
+++ b/src/app/features/bots/catalog.ts
@@ -2,12 +2,18 @@ import { useMemo } from 'react';
import { useClientConfig } from '../../hooks/useClientConfig';
import type { BotConfig, ClientConfig } from '../../hooks/useClientConfig';
+export type BotExperience = {
+ type: 'matrix-widget';
+ url: string;
+};
+
export type BotPreset = {
/** Stable URL slug — `/bots/`. Never reuse across bots. */
id: string;
/** Bot user mxid. The DM with this user IS the bot's control room. */
mxid: string;
name: string;
+ experience?: BotExperience;
};
const BOT_ID_RE = /^[A-Za-z0-9_-]+$/;
@@ -21,6 +27,50 @@ const isValidBotPreset = (preset: BotConfig | undefined): preset is BotPreset =>
typeof preset.name === 'string' &&
preset.name.trim().length > 0;
+const normalizeBotExperience = (experience: BotConfig['experience']): BotExperience | undefined => {
+ const type = experience?.type;
+ const url = experience?.url?.trim();
+ if (type !== 'matrix-widget' || !url) return undefined;
+ if (url.startsWith('//')) return undefined;
+
+ if (url.startsWith('/')) {
+ // Resolve once so `/widgets/../admin` collapses before the prefix check —
+ // a relative `/widgets/...` survives `new URL(url, base)` only if it does
+ // not escape its own segment. Same-origin widgets share the parent's
+ // localStorage/cookies under `allow-same-origin`, so /widgets/ is the only
+ // path the operator config is allowed to mount.
+ //
+ // Path MUST resolve to a concrete file: the last segment must contain a
+ // dot (e.g. /widgets/foo/index.html, /widgets/foo.js). Bare directory
+ // paths or extensionless paths (/widgets/foo, /widgets/foo/) get caught
+ // by SPA fallback in Vite dev and any history-fallback static server,
+ // which would then serve the parent app's index.html into the iframe —
+ // recursive Vojo-in-Vojo sharing localStorage and the matrix client,
+ // and the two copies infinite-loop on sync events. The dot-in-last-
+ // segment heuristic keeps the validator simple while catching the
+ // realistic accident class.
+ try {
+ const resolved = new URL(url, 'https://vojo.invalid');
+ if (resolved.username || resolved.password) return undefined;
+ if (!resolved.pathname.startsWith('/widgets/')) return undefined;
+ const lastSegment = resolved.pathname.split('/').pop() ?? '';
+ if (!lastSegment.includes('.')) return undefined;
+ return { type, url: `${resolved.pathname}${resolved.search}${resolved.hash}` };
+ } catch {
+ return undefined;
+ }
+ }
+
+ try {
+ const parsed = new URL(url);
+ if (parsed.protocol !== 'https:') return undefined;
+ if (parsed.username || parsed.password) return undefined;
+ return { type, url: parsed.toString() };
+ } catch {
+ return undefined;
+ }
+};
+
export const getBotPresets = (clientConfig: ClientConfig): BotPreset[] => {
const seenIds = new Set();
const seenMxids = new Set();
@@ -36,6 +86,7 @@ export const getBotPresets = (clientConfig: ClientConfig): BotPreset[] => {
id: preset.id,
mxid: preset.mxid,
name: preset.name.trim(),
+ experience: normalizeBotExperience(preset.experience),
});
});
diff --git a/src/app/features/bots/room.ts b/src/app/features/bots/room.ts
index 650f55c4..efeba6c4 100644
--- a/src/app/features/bots/room.ts
+++ b/src/app/features/bots/room.ts
@@ -1,6 +1,6 @@
import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
import { Membership, StateEvent } from '../../../types/matrix/room';
-import { isBridgedRoom, isRoom } from '../../utils/room';
+import { getStateEvents, isRoom } from '../../utils/room';
import type { BotPreset } from './catalog';
const ACTIVE_MEMBERSHIPS = new Set([Membership.Join, Membership.Invite]);
@@ -10,8 +10,25 @@ export const isBridgeStateEvent = (ev: MatrixEvent): boolean => {
return type === StateEvent.RoomBridge || type === StateEvent.RoomBridgeUnstable;
};
+// A room is a "portal" (Telegram/Signal/etc. mirror chat) if it carries an
+// m.bridge / uk.half-shot.bridge state event whose state_key is NOT our
+// control bot. Bridge BOTS write m.bridge into their OWN control DMs too,
+// keyed by the bot mxid — those are management rooms, not portals, and the
+// Robots tab must own them. The plain `isBridgedRoom` helper in utils/room
+// can't make this distinction (it has no concept of "our bot"); it stays
+// correct for DmCallButton's call-button suppression but is too coarse for
+// the bot ownership question. Single source of truth — both useBotRoom's
+// classifyRoom and BotWidgetDriver.isSafeBotWidgetRoom delegate here.
+export const isPortalRoomForOtherBridge = (room: Room, controlBotMxid: string): boolean => {
+ const events = [
+ ...getStateEvents(room, StateEvent.RoomBridge),
+ ...getStateEvents(room, StateEvent.RoomBridgeUnstable),
+ ];
+ return events.some((ev) => ev.getStateKey() !== controlBotMxid);
+};
+
export const isBotControlRoom = (mx: MatrixClient, room: Room, preset: BotPreset): boolean => {
- if (!isRoom(room) || isBridgedRoom(room)) return false;
+ if (!isRoom(room) || isPortalRoomForOtherBridge(room, preset.mxid)) return false;
const myUserId = mx.getUserId();
if (!myUserId) return false;
diff --git a/src/app/features/bots/useBotRoom.ts b/src/app/features/bots/useBotRoom.ts
index 0c5595a5..d12a3cf9 100644
--- a/src/app/features/bots/useBotRoom.ts
+++ b/src/app/features/bots/useBotRoom.ts
@@ -11,8 +11,7 @@ import {
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { Membership } from '../../../types/matrix/room';
import { removeRoomIdFromMDirect } from '../../utils/matrix';
-import { isBridgedRoom } from '../../utils/room';
-import { isBotControlRoom, isBridgeStateEvent } from './room';
+import { isBotControlRoom, isBridgeStateEvent, isPortalRoomForOtherBridge } from './room';
import type { BotPreset } from './catalog';
// Discriminated union over the lifecycle of a bot DM. Mount-eligible only at
@@ -38,7 +37,7 @@ const classifyRoom = (
room: Room,
preset: BotPreset
): BotRoomState | undefined => {
- if (isBridgedRoom(room)) return undefined;
+ if (isPortalRoomForOtherBridge(room, preset.mxid)) return undefined;
const my = room.getMyMembership();
const bot = room.getMember(preset.mxid)?.membership;
diff --git a/src/app/features/bots/useBotWidgetEmbed.ts b/src/app/features/bots/useBotWidgetEmbed.ts
new file mode 100644
index 00000000..7da29655
--- /dev/null
+++ b/src/app/features/bots/useBotWidgetEmbed.ts
@@ -0,0 +1,99 @@
+import { RefObject, useEffect, useRef } from 'react';
+import { Room } from 'matrix-js-sdk';
+import { useTranslation } from 'react-i18next';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { Theme, useTheme } from '../../hooks/useTheme';
+import type { BotPreset } from './catalog';
+import { BotWidgetEmbed } from './BotWidgetEmbed';
+
+type UseBotWidgetEmbedOptions = {
+ containerRef: RefObject;
+ preset: BotPreset;
+ room: Room;
+ onError: () => void;
+};
+
+// Encapsulates the imperative BotWidgetEmbed lifecycle so the host React
+// component stays small. Mirrors useCallEmbed's split: the hook owns the
+// effect, the component owns the DOM ref + visual chrome.
+//
+// Returns a ref to the live embed instance; the host can use it to push
+// runtime updates (e.g. theme) without forcing an iframe remount.
+export const useBotWidgetEmbed = ({
+ containerRef,
+ preset,
+ room,
+ onError,
+}: UseBotWidgetEmbedOptions): RefObject => {
+ const { i18n } = useTranslation();
+ const mx = useMatrixClient();
+ const theme = useTheme();
+ const embedRef = useRef();
+
+ // Capture mount-time values in refs so the lifecycle effect doesn't re-run
+ // when locale/theme change — those are propagated separately via setTheme.
+ const themeRef = useRef(theme);
+ themeRef.current = theme;
+ const languageRef = useRef(i18n.language);
+ languageRef.current = i18n.language;
+
+ // Depend on primitive identity for the embed lifecycle — using `preset`
+ // directly would remount the iframe (and re-handshake with the widget)
+ // every time `useBotPresets` returns a fresh memo, e.g. after a runtime
+ // config refresh.
+ const { id: presetId, mxid: presetMxid, name: presetName, experience } = preset;
+ const experienceUrl = experience?.url;
+ const experienceType = experience?.type;
+ const { roomId } = room;
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return undefined;
+
+ let embed: BotWidgetEmbed | undefined;
+ try {
+ embed = new BotWidgetEmbed({
+ mx,
+ room,
+ preset,
+ container,
+ theme: themeRef.current,
+ language: languageRef.current,
+ onError,
+ });
+ embedRef.current = embed;
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error('[BotWidget] constructor threw', error);
+ onError();
+ }
+
+ return () => {
+ embed?.destroy();
+ if (embedRef.current === embed) {
+ embedRef.current = undefined;
+ }
+ };
+ // theme/language read above are intentionally not in deps — they are
+ // captured via refs and propagated by the separate effect below.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [
+ mx,
+ containerRef,
+ roomId,
+ presetId,
+ presetMxid,
+ presetName,
+ experienceUrl,
+ experienceType,
+ onError,
+ ]);
+
+ // Runtime theme propagation — embed.setTheme is a no-op until the widget
+ // handshake completes, so calling it on every theme tick is safe.
+ useEffect(() => {
+ embedRef.current?.setTheme(theme).catch(() => undefined);
+ }, [theme]);
+
+ return embedRef;
+};
diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx
index 6455ac0c..5a453cda 100644
--- a/src/app/features/room/Room.tsx
+++ b/src/app/features/room/Room.tsx
@@ -19,7 +19,11 @@ import { RoomViewHeader } from './RoomViewHeader';
import { callChatAtom } from '../../state/callEmbed';
import { CallChatView } from './CallChatView';
-export function Room() {
+type RoomProps = {
+ renderRoomView?: (props: { eventId?: string }) => React.ReactNode;
+};
+
+export function Room({ renderRoomView }: RoomProps) {
const { eventId } = useParams();
const room = useRoom();
const mx = useMatrixClient();
@@ -65,9 +69,7 @@ export function Room() {
{!callView && (
-
-
-
+ {renderRoomView?.({ eventId }) ?? }
)}
diff --git a/src/app/hooks/router/useSelectedRoom.ts b/src/app/hooks/router/useSelectedRoom.ts
index 6d0ee4f1..85b41f2f 100644
--- a/src/app/hooks/router/useSelectedRoom.ts
+++ b/src/app/hooks/router/useSelectedRoom.ts
@@ -1,15 +1,22 @@
import { useParams } from 'react-router-dom';
+import { useAtomValue } from 'jotai';
import { getCanonicalAliasRoomId, isRoomAlias } from '../../utils/matrix';
import { useMatrixClient } from '../useMatrixClient';
+import { viewedRoomIdAtom } from '../../state/viewedRoom';
+// Returns the id of the room the user is currently looking at. Resolution
+// order: (1) URL param `roomIdOrAlias` for the canonical /direct, /home,
+// /:spaceId/* routes; (2) `viewedRoomIdAtom` for routes whose URL key isn't
+// the room id itself (e.g. /bots/:botId, where BotExperienceHost writes the
+// derived control-room id into the atom on mount).
export const useSelectedRoom = (): string | undefined => {
const mx = useMatrixClient();
+ const viewedRoomId = useAtomValue(viewedRoomIdAtom);
const { roomIdOrAlias } = useParams();
- const roomId =
- roomIdOrAlias && isRoomAlias(roomIdOrAlias)
- ? getCanonicalAliasRoomId(mx, roomIdOrAlias)
- : roomIdOrAlias;
+ if (roomIdOrAlias) {
+ return isRoomAlias(roomIdOrAlias) ? getCanonicalAliasRoomId(mx, roomIdOrAlias) : roomIdOrAlias;
+ }
- return roomId;
+ return viewedRoomId;
};
diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts
index 3c3371d6..f7b8dc50 100644
--- a/src/app/hooks/useClientConfig.ts
+++ b/src/app/hooks/useClientConfig.ts
@@ -16,6 +16,10 @@ export type BotConfig = {
id?: string;
mxid?: string;
name?: string;
+ experience?: {
+ type?: string;
+ url?: string;
+ };
};
export type ClientConfig = {
diff --git a/src/app/pages/client/bots/BotExperienceHost.tsx b/src/app/pages/client/bots/BotExperienceHost.tsx
index 42dfebd6..777c9c34 100644
--- a/src/app/pages/client/bots/BotExperienceHost.tsx
+++ b/src/app/pages/client/bots/BotExperienceHost.tsx
@@ -3,6 +3,7 @@ import { useParams } from 'react-router-dom';
import { Icon, Icons } from 'folds';
import { useTranslation } from 'react-i18next';
import { findBotPresetById, useBotPresets } from '../../../features/bots/catalog';
+import { BotExperienceSlot } from '../../../features/bots/BotExperienceSlot';
import { useBotRoom } from '../../../features/bots/useBotRoom';
import { Room } from '../../../features/room';
import { BotInvitePending } from './BotInvitePending';
@@ -53,7 +54,11 @@ export function BotExperienceHost() {
return (
-
+ (
+
+ )}
+ />
);
}
diff --git a/src/app/pages/client/bots/BotRoomProvider.tsx b/src/app/pages/client/bots/BotRoomProvider.tsx
index a0635be2..64d38a5c 100644
--- a/src/app/pages/client/bots/BotRoomProvider.tsx
+++ b/src/app/pages/client/bots/BotRoomProvider.tsx
@@ -1,7 +1,9 @@
-import React, { ReactNode } from 'react';
+import React, { ReactNode, useEffect } from 'react';
import { Room as MatrixRoom } from 'matrix-js-sdk';
+import { useSetAtom } from 'jotai';
import { IsOneOnOneProvider, RoomProvider } from '../../../hooks/useRoom';
import { useIsOneOnOneRoom } from '../../../hooks/useIsOneOnOneRoom';
+import { viewedRoomIdAtom } from '../../../state/viewedRoom';
type BotRoomProviderProps = {
room: MatrixRoom;
@@ -10,6 +12,17 @@ type BotRoomProviderProps = {
export function BotRoomProvider({ room, children }: BotRoomProviderProps) {
const isOneOnOne = useIsOneOnOneRoom(room);
+ const setViewedRoomId = useSetAtom(viewedRoomIdAtom);
+
+ // /bots/:botId routes don't carry the control room id in the URL — write
+ // the resolved id into a global atom so consumers like ClientNonUIFeatures
+ // (notification suppression) and CallStatusRenderer can recognise that
+ // this room is on screen. Cleared on unmount to avoid leaking the bot DM
+ // id to other routes.
+ useEffect(() => {
+ setViewedRoomId(room.roomId);
+ return () => setViewedRoomId(undefined);
+ }, [room.roomId, setViewedRoomId]);
return (
diff --git a/src/app/pages/client/direct/useDirectRooms.ts b/src/app/pages/client/direct/useDirectRooms.ts
index 69481bd2..52b87adb 100644
--- a/src/app/pages/client/direct/useDirectRooms.ts
+++ b/src/app/pages/client/direct/useDirectRooms.ts
@@ -42,7 +42,7 @@ export const useDirectRooms = (): string[] => {
() => new Set([...orphanRooms, ...directs]),
[orphanRooms, directs]
);
- const [, setRoomStateTick] = useState(0);
+ const [roomStateTick, setRoomStateTick] = useState(0);
useEffect(() => {
const bumpIfCandidate = (roomId: string | undefined) => {
@@ -66,25 +66,40 @@ export const useDirectRooms = (): string[] => {
};
}, [mx, directCandidates]);
- const seen = new Set();
- const out: string[] = [];
- const isVisibleDirect = (id: string): boolean => {
- const room = mx.getRoom(id);
- return !room || !isCatalogBotControlRoom(mx, room, bots);
- };
+ // The output MUST be a stable reference across renders that don't change
+ // input identity — `useRoomsUnread` (state/hooks/unread.ts:37) feeds this
+ // array into a `useCallback([rooms])` whose result is then handed to
+ // `selectAtom(...)` on every render. If `rooms` identity flips each render,
+ // `selectAtom` produces a fresh atom subscription which triggers another
+ // render — that's the React-detected "Maximum update depth exceeded" loop
+ // observed in DirectTab. `roomStateTick` is in deps so member/bridge state
+ // updates still recompute the visible set.
+ return useMemo(() => {
+ const seen = new Set();
+ const out: string[] = [];
+ const isVisibleDirect = (id: string): boolean => {
+ const room = mx.getRoom(id);
+ return !room || !isCatalogBotControlRoom(mx, room, bots);
+ };
- orphanRooms.forEach((id) => {
- if (!seen.has(id) && isVisibleDirect(id)) {
- seen.add(id);
- out.push(id);
- }
- });
- directs.forEach((id) => {
- if (!seen.has(id) && isVisibleDirect(id)) {
- seen.add(id);
- out.push(id);
- }
- });
+ orphanRooms.forEach((id) => {
+ if (!seen.has(id) && isVisibleDirect(id)) {
+ seen.add(id);
+ out.push(id);
+ }
+ });
+ directs.forEach((id) => {
+ if (!seen.has(id) && isVisibleDirect(id)) {
+ seen.add(id);
+ out.push(id);
+ }
+ });
- return out;
+ return out;
+ // roomStateTick is intentional: the listener bumps it on member/bridge
+ // state events to force re-evaluation, even though the value isn't read
+ // inside the body. Removing it would make the visible-direct set go
+ // stale until one of the input arrays' identities changes.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [mx, orphanRooms, directs, bots, roomStateTick]);
};
diff --git a/src/app/state/viewedRoom.ts b/src/app/state/viewedRoom.ts
new file mode 100644
index 00000000..dfb43f4f
--- /dev/null
+++ b/src/app/state/viewedRoom.ts
@@ -0,0 +1,13 @@
+import { atom } from 'jotai';
+
+// Cross-route source of truth for "the room the user is currently looking at",
+// independent of URL `roomIdOrAlias` params. Routes that don't carry the room
+// id in the URL (e.g. /bots/:botId — the route key is a preset id, the room
+// id is derived) write here so that consumers like ClientNonUIFeatures'
+// notification suppression know which room is on screen.
+//
+// Existing /direct, /home, /:spaceId/* routes do NOT need to write here —
+// `useSelectedRoom()` resolves their roomId from URL params already, and the
+// atom is consulted only as a fallback. Keep writers narrow to avoid having
+// two sources of truth racing for routes the URL already covers.
+export const viewedRoomIdAtom = atom(undefined);