vojo/src/app/features/bots/useBotWidgetEmbed.ts

125 lines
4.5 KiB
TypeScript

import { RefObject, useEffect, useRef, useState } 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 { MatrixToRoom } from '../../plugins/matrix-to';
import type { BotPreset } from './catalog';
import { BotWidgetEmbed } from './BotWidgetEmbed';
type UseBotWidgetEmbedOptions = {
containerRef: RefObject<HTMLElement>;
preset: BotPreset;
room: Room;
onError: () => void;
// Forwarded into the embed. Plumbed from `BotWidgetMount` where the
// react-router context is available — the hook stays unaware of routing.
onOpenMatrixToRoom?: (target: MatrixToRoom) => void;
};
type UseBotWidgetEmbedResult = {
// True once the widget's `ready` event has fired (capability handshake
// completed). Resets to false whenever the embed is recreated (room or
// preset change). Drives the loading-bar overlay in BotWidgetMount.
ready: boolean;
};
// 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. Live embed
// instance is held in a private ref for runtime updates (theme
// propagation below); not exposed because no caller needs it today.
export const useBotWidgetEmbed = ({
containerRef,
preset,
room,
onError,
onOpenMatrixToRoom,
}: UseBotWidgetEmbedOptions): UseBotWidgetEmbedResult => {
const { i18n } = useTranslation();
const mx = useMatrixClient();
const theme = useTheme();
const embedRef = useRef<BotWidgetEmbed>();
const [ready, setReady] = useState(false);
// 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;
// Same indirection for `onOpenMatrixToRoom`: the callback identity
// typically changes per render (closes over `navigate`/`mx`), and we do
// NOT want that to remount the embed. The ref carries the latest fn; the
// embed only sees a stable shim that re-reads it.
const onOpenMatrixToRoomRef = useRef(onOpenMatrixToRoom);
onOpenMatrixToRoomRef.current = onOpenMatrixToRoom;
// 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;
// Reset readiness whenever the embed is (re)created. Subsequent
// `ready` event from the new ClientWidgetApi flips this back to true.
setReady(false);
let embed: BotWidgetEmbed | undefined;
try {
embed = new BotWidgetEmbed({
mx,
room,
preset,
container,
theme: themeRef.current,
language: languageRef.current,
onError,
onReady: () => setReady(true),
// Indirection so the embed lifecycle doesn't reset when the
// navigate-callback closes over a new render's `mx`/`navigate`.
onOpenMatrixToRoom: (target) => onOpenMatrixToRoomRef.current?.(target),
});
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 { ready };
};