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',
|
appId: 'chat.vojo.app',
|
||||||
appName: 'Vojo',
|
appName: 'Vojo',
|
||||||
webDir: 'dist',
|
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: {
|
android: {
|
||||||
// Keep default: resolveServiceWorkerRequests = true
|
// Keep default: resolveServiceWorkerRequests = true
|
||||||
// SW is critical for authenticated Matrix media (MSC3916 / spec v1.11+)
|
// 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",
|
"linkify-react": "4.3.2",
|
||||||
"linkifyjs": "4.3.2",
|
"linkifyjs": "4.3.2",
|
||||||
"matrix-js-sdk": "38.2.0",
|
"matrix-js-sdk": "38.2.0",
|
||||||
"matrix-widget-api": "1.13.0",
|
"matrix-widget-api": "1.17.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
"pdfjs-dist": "4.2.67",
|
"pdfjs-dist": "4.2.67",
|
||||||
"prismjs": "1.30.0",
|
"prismjs": "1.30.0",
|
||||||
|
|
@ -10679,9 +10679,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/matrix-widget-api": {
|
"node_modules/matrix-widget-api": {
|
||||||
"version": "1.13.0",
|
"version": "1.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.17.0.tgz",
|
||||||
"integrity": "sha512-+LrvwkR1izL4h2euX8PDrvG/3PZZDEd6As+lmnR3jAVwbFJtU5iTnwmZGnCca9ddngCvXvAHkcpJBEPyPTZneQ==",
|
"integrity": "sha512-5FHoo3iEP3Bdlv5jsYPWOqj+pGdFQNLWnJLiB0V7Ygne7bb+Gsj3ibyFyHWC6BVw+Z+tSW4ljHpO17I9TwStwQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/events": "^3.0.0",
|
"@types/events": "^3.0.0",
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@
|
||||||
"linkify-react": "4.3.2",
|
"linkify-react": "4.3.2",
|
||||||
"linkifyjs": "4.3.2",
|
"linkifyjs": "4.3.2",
|
||||||
"matrix-js-sdk": "38.2.0",
|
"matrix-js-sdk": "38.2.0",
|
||||||
"matrix-widget-api": "1.13.0",
|
"matrix-widget-api": "1.17.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
"pdfjs-dist": "4.2.67",
|
"pdfjs-dist": "4.2.67",
|
||||||
"prismjs": "1.30.0",
|
"prismjs": "1.30.0",
|
||||||
|
|
|
||||||
|
|
@ -937,6 +937,9 @@
|
||||||
"unsafe_title": "This robot chat is not private",
|
"unsafe_title": "This robot chat is not private",
|
||||||
"unsafe_description": "The robot is blocked because another active member is present in the chat.",
|
"unsafe_description": "The robot is blocked because another active member is present in the chat.",
|
||||||
"open_chat": "Open chat",
|
"open_chat": "Open chat",
|
||||||
|
"show_chat": "Show chat",
|
||||||
|
"show_widget": "Show robot",
|
||||||
|
"retry_widget": "Retry robot",
|
||||||
"unknown_title": "Robot not found",
|
"unknown_title": "Robot not found",
|
||||||
"unknown_description": "This robot is not in the Vojo catalog."
|
"unknown_description": "This robot is not in the Vojo catalog."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -941,6 +941,9 @@
|
||||||
"unsafe_title": "Чат с роботом не приватный",
|
"unsafe_title": "Чат с роботом не приватный",
|
||||||
"unsafe_description": "Робот заблокирован: в чате присутствует посторонний участник.",
|
"unsafe_description": "Робот заблокирован: в чате присутствует посторонний участник.",
|
||||||
"open_chat": "Открыть чат",
|
"open_chat": "Открыть чат",
|
||||||
|
"show_chat": "Показать чат",
|
||||||
|
"show_widget": "Показать робота",
|
||||||
|
"retry_widget": "Повторить",
|
||||||
"unknown_title": "Робот не найден",
|
"unknown_title": "Робот не найден",
|
||||||
"unknown_description": "Этого робота нет в каталоге Vojo."
|
"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 { useClientConfig } from '../../hooks/useClientConfig';
|
||||||
import type { BotConfig, ClientConfig } from '../../hooks/useClientConfig';
|
import type { BotConfig, ClientConfig } from '../../hooks/useClientConfig';
|
||||||
|
|
||||||
|
export type BotExperience = {
|
||||||
|
type: 'matrix-widget';
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type BotPreset = {
|
export type BotPreset = {
|
||||||
/** Stable URL slug — `/bots/<id>`. Never reuse across bots. */
|
/** Stable URL slug — `/bots/<id>`. Never reuse across bots. */
|
||||||
id: string;
|
id: string;
|
||||||
/** Bot user mxid. The DM with this user IS the bot's control room. */
|
/** Bot user mxid. The DM with this user IS the bot's control room. */
|
||||||
mxid: string;
|
mxid: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
experience?: BotExperience;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BOT_ID_RE = /^[A-Za-z0-9_-]+$/;
|
const BOT_ID_RE = /^[A-Za-z0-9_-]+$/;
|
||||||
|
|
@ -21,6 +27,50 @@ const isValidBotPreset = (preset: BotConfig | undefined): preset is BotPreset =>
|
||||||
typeof preset.name === 'string' &&
|
typeof preset.name === 'string' &&
|
||||||
preset.name.trim().length > 0;
|
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[] => {
|
export const getBotPresets = (clientConfig: ClientConfig): BotPreset[] => {
|
||||||
const seenIds = new Set<string>();
|
const seenIds = new Set<string>();
|
||||||
const seenMxids = new Set<string>();
|
const seenMxids = new Set<string>();
|
||||||
|
|
@ -36,6 +86,7 @@ export const getBotPresets = (clientConfig: ClientConfig): BotPreset[] => {
|
||||||
id: preset.id,
|
id: preset.id,
|
||||||
mxid: preset.mxid,
|
mxid: preset.mxid,
|
||||||
name: preset.name.trim(),
|
name: preset.name.trim(),
|
||||||
|
experience: normalizeBotExperience(preset.experience),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
|
import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
|
||||||
import { Membership, StateEvent } from '../../../types/matrix/room';
|
import { Membership, StateEvent } from '../../../types/matrix/room';
|
||||||
import { isBridgedRoom, isRoom } from '../../utils/room';
|
import { getStateEvents, isRoom } from '../../utils/room';
|
||||||
import type { BotPreset } from './catalog';
|
import type { BotPreset } from './catalog';
|
||||||
|
|
||||||
const ACTIVE_MEMBERSHIPS = new Set<string>([Membership.Join, Membership.Invite]);
|
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;
|
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 => {
|
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();
|
const myUserId = mx.getUserId();
|
||||||
if (!myUserId) return false;
|
if (!myUserId) return false;
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,7 @@ import {
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { Membership } from '../../../types/matrix/room';
|
import { Membership } from '../../../types/matrix/room';
|
||||||
import { removeRoomIdFromMDirect } from '../../utils/matrix';
|
import { removeRoomIdFromMDirect } from '../../utils/matrix';
|
||||||
import { isBridgedRoom } from '../../utils/room';
|
import { isBotControlRoom, isBridgeStateEvent, isPortalRoomForOtherBridge } from './room';
|
||||||
import { isBotControlRoom, isBridgeStateEvent } from './room';
|
|
||||||
import type { BotPreset } from './catalog';
|
import type { BotPreset } from './catalog';
|
||||||
|
|
||||||
// Discriminated union over the lifecycle of a bot DM. Mount-eligible only at
|
// Discriminated union over the lifecycle of a bot DM. Mount-eligible only at
|
||||||
|
|
@ -38,7 +37,7 @@ const classifyRoom = (
|
||||||
room: Room,
|
room: Room,
|
||||||
preset: BotPreset
|
preset: BotPreset
|
||||||
): BotRoomState | undefined => {
|
): BotRoomState | undefined => {
|
||||||
if (isBridgedRoom(room)) return undefined;
|
if (isPortalRoomForOtherBridge(room, preset.mxid)) return undefined;
|
||||||
|
|
||||||
const my = room.getMyMembership();
|
const my = room.getMyMembership();
|
||||||
const bot = room.getMember(preset.mxid)?.membership;
|
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 { callChatAtom } from '../../state/callEmbed';
|
||||||
import { CallChatView } from './CallChatView';
|
import { CallChatView } from './CallChatView';
|
||||||
|
|
||||||
export function Room() {
|
type RoomProps = {
|
||||||
|
renderRoomView?: (props: { eventId?: string }) => React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Room({ renderRoomView }: RoomProps) {
|
||||||
const { eventId } = useParams();
|
const { eventId } = useParams();
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
@ -65,9 +69,7 @@ export function Room() {
|
||||||
{!callView && (
|
{!callView && (
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
<RoomViewHeader />
|
<RoomViewHeader />
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">{renderRoomView?.({ eventId }) ?? <RoomView eventId={eventId} />}</Box>
|
||||||
<RoomView eventId={eventId} />
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,22 @@
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
import { getCanonicalAliasRoomId, isRoomAlias } from '../../utils/matrix';
|
import { getCanonicalAliasRoomId, isRoomAlias } from '../../utils/matrix';
|
||||||
import { useMatrixClient } from '../useMatrixClient';
|
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 => {
|
export const useSelectedRoom = (): string | undefined => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
const viewedRoomId = useAtomValue(viewedRoomIdAtom);
|
||||||
|
|
||||||
const { roomIdOrAlias } = useParams();
|
const { roomIdOrAlias } = useParams();
|
||||||
const roomId =
|
if (roomIdOrAlias) {
|
||||||
roomIdOrAlias && isRoomAlias(roomIdOrAlias)
|
return isRoomAlias(roomIdOrAlias) ? getCanonicalAliasRoomId(mx, roomIdOrAlias) : roomIdOrAlias;
|
||||||
? getCanonicalAliasRoomId(mx, roomIdOrAlias)
|
}
|
||||||
: roomIdOrAlias;
|
|
||||||
|
|
||||||
return roomId;
|
return viewedRoomId;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,10 @@ export type BotConfig = {
|
||||||
id?: string;
|
id?: string;
|
||||||
mxid?: string;
|
mxid?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
experience?: {
|
||||||
|
type?: string;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ClientConfig = {
|
export type ClientConfig = {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useParams } from 'react-router-dom';
|
||||||
import { Icon, Icons } from 'folds';
|
import { Icon, Icons } from 'folds';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { findBotPresetById, useBotPresets } from '../../../features/bots/catalog';
|
import { findBotPresetById, useBotPresets } from '../../../features/bots/catalog';
|
||||||
|
import { BotExperienceSlot } from '../../../features/bots/BotExperienceSlot';
|
||||||
import { useBotRoom } from '../../../features/bots/useBotRoom';
|
import { useBotRoom } from '../../../features/bots/useBotRoom';
|
||||||
import { Room } from '../../../features/room';
|
import { Room } from '../../../features/room';
|
||||||
import { BotInvitePending } from './BotInvitePending';
|
import { BotInvitePending } from './BotInvitePending';
|
||||||
|
|
@ -53,7 +54,11 @@ export function BotExperienceHost() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BotRoomProvider room={room}>
|
<BotRoomProvider room={room}>
|
||||||
<Room />
|
<Room
|
||||||
|
renderRoomView={({ eventId }) => (
|
||||||
|
<BotExperienceSlot preset={preset} room={room} eventId={eventId} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</BotRoomProvider>
|
</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 { Room as MatrixRoom } from 'matrix-js-sdk';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
import { IsOneOnOneProvider, RoomProvider } from '../../../hooks/useRoom';
|
import { IsOneOnOneProvider, RoomProvider } from '../../../hooks/useRoom';
|
||||||
import { useIsOneOnOneRoom } from '../../../hooks/useIsOneOnOneRoom';
|
import { useIsOneOnOneRoom } from '../../../hooks/useIsOneOnOneRoom';
|
||||||
|
import { viewedRoomIdAtom } from '../../../state/viewedRoom';
|
||||||
|
|
||||||
type BotRoomProviderProps = {
|
type BotRoomProviderProps = {
|
||||||
room: MatrixRoom;
|
room: MatrixRoom;
|
||||||
|
|
@ -10,6 +12,17 @@ type BotRoomProviderProps = {
|
||||||
|
|
||||||
export function BotRoomProvider({ room, children }: BotRoomProviderProps) {
|
export function BotRoomProvider({ room, children }: BotRoomProviderProps) {
|
||||||
const isOneOnOne = useIsOneOnOneRoom(room);
|
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 (
|
return (
|
||||||
<RoomProvider key={room.roomId} value={room}>
|
<RoomProvider key={room.roomId} value={room}>
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export const useDirectRooms = (): string[] => {
|
||||||
() => new Set([...orphanRooms, ...directs]),
|
() => new Set([...orphanRooms, ...directs]),
|
||||||
[orphanRooms, directs]
|
[orphanRooms, directs]
|
||||||
);
|
);
|
||||||
const [, setRoomStateTick] = useState(0);
|
const [roomStateTick, setRoomStateTick] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const bumpIfCandidate = (roomId: string | undefined) => {
|
const bumpIfCandidate = (roomId: string | undefined) => {
|
||||||
|
|
@ -66,25 +66,40 @@ export const useDirectRooms = (): string[] => {
|
||||||
};
|
};
|
||||||
}, [mx, directCandidates]);
|
}, [mx, directCandidates]);
|
||||||
|
|
||||||
const seen = new Set<string>();
|
// The output MUST be a stable reference across renders that don't change
|
||||||
const out: string[] = [];
|
// input identity — `useRoomsUnread` (state/hooks/unread.ts:37) feeds this
|
||||||
const isVisibleDirect = (id: string): boolean => {
|
// array into a `useCallback([rooms])` whose result is then handed to
|
||||||
const room = mx.getRoom(id);
|
// `selectAtom(...)` on every render. If `rooms` identity flips each render,
|
||||||
return !room || !isCatalogBotControlRoom(mx, room, bots);
|
// `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) => {
|
orphanRooms.forEach((id) => {
|
||||||
if (!seen.has(id) && isVisibleDirect(id)) {
|
if (!seen.has(id) && isVisibleDirect(id)) {
|
||||||
seen.add(id);
|
seen.add(id);
|
||||||
out.push(id);
|
out.push(id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
directs.forEach((id) => {
|
directs.forEach((id) => {
|
||||||
if (!seen.has(id) && isVisibleDirect(id)) {
|
if (!seen.has(id) && isVisibleDirect(id)) {
|
||||||
seen.add(id);
|
seen.add(id);
|
||||||
out.push(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