import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Room, SyncState } from 'matrix-js-sdk'; import type { BotPreset } from './catalog'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useSyncState } from '../../hooks/useSyncState'; import { getCanonicalAliasOrRoomId, getCanonicalAliasRoomId, isRoomAlias, } from '../../utils/matrix'; import { getChannelsSpacePath } from '../../pages/pathUtils'; import type { MatrixToRoom } from '../../plugins/matrix-to'; import { useBotWidgetEmbed } from './useBotWidgetEmbed'; import * as css from './BotWidgetMount.css'; // Anti-flicker debounce — same rationale as `SyncIndicator`'s // `PROGRESS_FADE_IN_DELAY_MS`. On a warm/cached widget the iframe can // boot in <100ms; without the delay the bar would briefly appear and // fade out, reading as a flash. 100ms is just above the typical instant- // load envelope yet below the perception threshold for «I clicked and // nothing happened». const LOADING_BAR_FADE_IN_DELAY_MS = 100; // Fallback hide deadline if `animationiteration` never fires — covers // any compositor freeze that drops the event (e.g. tab backgrounded // mid-cycle and just foregrounded). 2000ms gives ~400ms slack over the // 1.6s slide-cycle: a fast 60Hz display fires iteration ~16-32ms after // cycle end, but a backgrounded compositor can be 100+ms late. The // reduced-motion path skips this branch entirely. const LOADING_BAR_HIDE_FALLBACK_MS = 2000; // Renders the iframe-mount target and wires up `useBotWidgetEmbed` to // drive the iframe lifecycle. Renamed from BotWidgetHost in the BotShell // refactor — «Host» was overloaded with BotExperienceHost (page-level // dispatcher); «Mount» reads as the literal DOM mount point this is. type BotWidgetMountProps = { preset: BotPreset; room: Room; onError: () => void; }; export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) { const containerRef = useRef(null); const navigate = useNavigate(); const mx = useMatrixClient(); // Generic «navigate cinny to a matrix.to room/alias». Bot-agnostic: any // widget that posts `{action: 'open-matrix-to', data: {url}}` on the // `io.vojo.bot-widget` side-channel reaches this. The embed has already // validated the URL via `parseMatrixToRoom` so `target` is well-formed. // For an alias we resolve to the canonical room id first — the channels // path expects an id-or-alias either way, but joined-room lookup needs // the id form for the via-server hint to be effective. `viaServers` are // currently dropped (the channels view doesn't propagate them); add a // dedicated «join-via» path if a future widget needs to surface a room // the user hasn't joined yet. const handleOpenMatrixToRoom = useCallback( (target: MatrixToRoom) => { const { roomIdOrAlias } = target; const idOrAlias = isRoomAlias(roomIdOrAlias) ? getCanonicalAliasRoomId(mx, roomIdOrAlias) ?? roomIdOrAlias : roomIdOrAlias; const canonical = getCanonicalAliasOrRoomId(mx, idOrAlias); navigate(getChannelsSpacePath(canonical)); }, [mx, navigate] ); const { ready } = useBotWidgetEmbed({ containerRef, preset, room, onError, onOpenMatrixToRoom: handleOpenMatrixToRoom, }); // Track Matrix sync state so the bot loading bar yields to the global // SyncIndicator when the connection is unhealthy. Without this, on a // dropped network the user would see TWO sweeping bars at once — the // bot bar at top stuck in «still loading» plus the SyncIndicator at // bottom in transient/error state. The bottom bar is the canonical // connection-state surface; the top one defers. Reuses `mx` from the // navigate-callback block above — single hook call per render. const [syncState, setSyncState] = useState(() => mx.getSyncState()); useSyncState( mx, useCallback((state: SyncState) => { setSyncState(state); }, []) ); useEffect(() => { // Close the render-to-effect gap (same pattern as SyncIndicator) so // a Sync event fired between useState init and listener attach // doesn't get dropped. setSyncState(mx.getSyncState()); }, [mx]); // Healthy = SyncIndicator's «hidden» branch (`Syncing` or `Stopped`). // Anything else (`Reconnecting`, `Catchup`, `Error`, initial null) // means the bottom bar will be visible, so we should not also show // ours. Mirrors `stateToVisual` in SyncIndicator.tsx. const syncHealthy = syncState === SyncState.Syncing || syncState === SyncState.Stopped; // Three logical phases for the loading bar: // - hidden (visible=false, pendingHide=false) → initial / post-hide // - visible (visible=true, pendingHide=false) → animating actively // - closing (visible=true, pendingHide=true) → ready fired mid- // cycle, waiting for // animationiteration // to land at the // cycle boundary // before snapping // invisible // The closing → hidden transition is driven by the iteration event so // the user always sees a complete left-to-right sweep and never a // mid-stroke cut. A fallback timer covers the case where the event // can't fire (compositor freeze on a backgrounded tab). Reduced-motion // and sync-unhealthy paths skip cycle-complete entirely — for the // first there's no animation cycle to honour, for the second the // bottom SyncIndicator should take over without UI overlap. const [visible, setVisible] = useState(false); const [pendingHide, setPendingHide] = useState(false); // Reasons the bar should NOT be shown: // - 'ready' — widget loaded normally, hide gracefully (cycle-complete) // - 'sync' — connection unhealthy, snap-hide so the SyncIndicator's // bar is the only visible chrome (no double-bar UX) // - null — bar should be visible // Order matters: `ready` takes precedence so a successfully-loaded // widget that then drops its connection still gets the polished cycle // hide on the original ready event rather than being re-classified as // a sync-issue snap. let hideReason: 'ready' | 'sync' | null = null; if (ready) hideReason = 'ready'; else if (!syncHealthy) hideReason = 'sync'; useEffect(() => { if (hideReason !== null) { // Bar wasn't shown yet — cached fast load (or a handshake that // landed during the debounce window) won the race. Nothing to do; // bar stays hidden, no flash. if (!visible) return undefined; // Sync-issue snap: jump straight to invisible so the bottom // SyncIndicator can take over without two bars overlapping. // Reduced-motion: animation is off (no iterations ever land), so // parking a static stripe for ~2s isn't graceful, just stuck. if (hideReason === 'sync' || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { setVisible(false); setPendingHide(false); return undefined; } // hideReason === 'ready' with motion enabled — bar is mid-sweep. // Defer the actual hide to the next cycle boundary. Fallback // force-hides if the event never lands. setPendingHide(true); const fallback = setTimeout(() => { setVisible(false); setPendingHide(false); }, LOADING_BAR_HIDE_FALLBACK_MS); return () => clearTimeout(fallback); } // Bar should show — clear any leftover hide request from a previous // ready-then-not-ready blip and (if not yet visible) schedule the // debounced fade-in. setPendingHide(false); if (visible) return undefined; const fadeIn = setTimeout(() => setVisible(true), LOADING_BAR_FADE_IN_DELAY_MS); return () => clearTimeout(fadeIn); }, [hideReason, visible]); const handleIteration = () => { if (pendingHide) { setVisible(false); setPendingHide(false); } }; // Loading bar stays mounted; opacity carries the visible/hidden // crossfade. animationPlayState is paused while invisible so the // compositor doesn't keep sweeping a 0-alpha element forever. // pointer-events: none on the root so it never intercepts iframe // clicks even mid-fade. return ( <>
); }