feat(bots): land Phase 2 widget host/driver with retry UX and route-aware notifications
This commit is contained in:
parent
83e246da1f
commit
d961dddfbc
21 changed files with 937 additions and 41 deletions
|
|
@ -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+)
|
||||
|
|
|
|||
8
package-lock.json
generated
8
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -941,6 +941,9 @@
|
|||
"unsafe_title": "Чат с роботом не приватный",
|
||||
"unsafe_description": "Робот заблокирован: в чате присутствует посторонний участник.",
|
||||
"open_chat": "Открыть чат",
|
||||
"show_chat": "Показать чат",
|
||||
"show_widget": "Показать робота",
|
||||
"retry_widget": "Повторить",
|
||||
"unknown_title": "Робот не найден",
|
||||
"unknown_description": "Этого робота нет в каталоге Vojo."
|
||||
}
|
||||
|
|
|
|||
79
src/app/features/bots/BotExperienceSlot.tsx
Normal file
79
src/app/features/bots/BotExperienceSlot.tsx
Normal file
|
|
@ -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 <RoomView eventId={eventId} />;
|
||||
}
|
||||
|
||||
if (showRawChat) {
|
||||
return (
|
||||
<Box className={css.Host}>
|
||||
<Box className={css.FrameMount}>
|
||||
<RoomView eventId={eventId} />
|
||||
</Box>
|
||||
<Box className={css.Toolbar}>
|
||||
<Button variant="Secondary" fill="Soft" size="300" radii="300" onClick={handleShowWidget}>
|
||||
<Text size="B300">{failed ? t('Bots.retry_widget') : t('Bots.show_widget')}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={css.Host}>
|
||||
<BotWidgetHost key={retryCount} preset={preset} room={room} onError={handleWidgetError} />
|
||||
<Box className={css.Toolbar}>
|
||||
<Button variant="Secondary" fill="Soft" size="300" radii="300" onClick={handleShowChat}>
|
||||
<Text size="B300">{t('Bots.show_chat')}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
228
src/app/features/bots/BotWidgetDriver.ts
Normal file
228
src/app/features/bots/BotWidgetDriver.ts
Normal file
|
|
@ -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<string, unknown> =>
|
||||
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<Capability> =>
|
||||
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<Capability>;
|
||||
|
||||
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<Capability>): Promise<Set<Capability>> {
|
||||
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<ISendEventDetails> {
|
||||
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<IRoomEvent[]> {
|
||||
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<IRoomEvent[]> {
|
||||
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<IOpenIDUpdate>): 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;
|
||||
}
|
||||
}
|
||||
302
src/app/features/bots/BotWidgetEmbed.ts
Normal file
302
src/app/features/bots/BotWidgetEmbed.ts
Normal file
|
|
@ -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<string>();
|
||||
|
||||
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<IRoomEvent>;
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
28
src/app/features/bots/BotWidgetHost.css.ts
Normal file
28
src/app/features/bots/BotWidgetHost.css.ts
Normal file
|
|
@ -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)})`,
|
||||
},
|
||||
]);
|
||||
18
src/app/features/bots/BotWidgetHost.tsx
Normal file
18
src/app/features/bots/BotWidgetHost.tsx
Normal file
|
|
@ -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<HTMLDivElement>(null);
|
||||
useBotWidgetEmbed({ containerRef, preset, room, onError });
|
||||
|
||||
return <div ref={containerRef} className={css.FrameMount} />;
|
||||
}
|
||||
|
|
@ -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/<id>`. 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<string>();
|
||||
const seenMxids = new Set<string>();
|
||||
|
|
@ -36,6 +86,7 @@ export const getBotPresets = (clientConfig: ClientConfig): BotPreset[] => {
|
|||
id: preset.id,
|
||||
mxid: preset.mxid,
|
||||
name: preset.name.trim(),
|
||||
experience: normalizeBotExperience(preset.experience),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string>([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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
99
src/app/features/bots/useBotWidgetEmbed.ts
Normal file
99
src/app/features/bots/useBotWidgetEmbed.ts
Normal file
|
|
@ -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<HTMLElement>;
|
||||
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<BotWidgetEmbed | undefined> => {
|
||||
const { i18n } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const theme = useTheme();
|
||||
const embedRef = useRef<BotWidgetEmbed>();
|
||||
|
||||
// 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>(theme);
|
||||
themeRef.current = theme;
|
||||
const languageRef = useRef<string>(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;
|
||||
};
|
||||
|
|
@ -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 && (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomViewHeader />
|
||||
<Box grow="Yes">
|
||||
<RoomView eventId={eventId} />
|
||||
</Box>
|
||||
<Box grow="Yes">{renderRoomView?.({ eventId }) ?? <RoomView eventId={eventId} />}</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ export type BotConfig = {
|
|||
id?: string;
|
||||
mxid?: string;
|
||||
name?: string;
|
||||
experience?: {
|
||||
type?: string;
|
||||
url?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ClientConfig = {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<BotRoomProvider room={room}>
|
||||
<Room />
|
||||
<Room
|
||||
renderRoomView={({ eventId }) => (
|
||||
<BotExperienceSlot preset={preset} room={room} eventId={eventId} />
|
||||
)}
|
||||
/>
|
||||
</BotRoomProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<RoomProvider key={room.roomId} value={room}>
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
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<string>();
|
||||
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]);
|
||||
};
|
||||
|
|
|
|||
13
src/app/state/viewedRoom.ts
Normal file
13
src/app/state/viewedRoom.ts
Normal file
|
|
@ -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<string | undefined>(undefined);
|
||||
Loading…
Add table
Reference in a new issue