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; 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(); 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); themeRef.current = theme; const languageRef = useRef(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 }; };