vojo/src/app/features/bots/BotWidgetMount.tsx

199 lines
8.8 KiB
TypeScript

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<HTMLDivElement>(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<SyncState | null>(() => 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 (
<>
<div ref={containerRef} className={css.FrameMount} />
<div className={css.LoadingBarRoot} aria-hidden>
<div
className={css.LoadingBar}
style={{
opacity: visible ? 1 : 0,
animationPlayState: visible ? 'running' : 'paused',
}}
onAnimationIteration={handleIteration}
/>
</div>
</>
);
}