125 lines
4.5 KiB
TypeScript
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 };
|
|
};
|