199 lines
8.8 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|