diff --git a/src/app/features/bots/BotWidgetEmbed.ts b/src/app/features/bots/BotWidgetEmbed.ts index f29e987e..7b3ed8c2 100644 --- a/src/app/features/bots/BotWidgetEmbed.ts +++ b/src/app/features/bots/BotWidgetEmbed.ts @@ -33,6 +33,7 @@ export type BotWidgetEmbedOptions = { theme: Theme; language: string; onError: (error: Error) => void; + onReady?: () => void; }; const getBotWidgetId = (preset: BotPreset): string => `vojo-bot-${preset.id}`; @@ -157,6 +158,18 @@ export class BotWidgetEmbed { // demote the widget into the chat fallback even though the widget is fine. private isReady = false; + // Two halves of «widget is fully booted». `iframeLoaded` flips on the + // iframe's `load` event (document complete + same-origin assets ready); + // `handshakeReady` flips on the ClientWidgetApi `ready` event (host + // capability handshake complete). The external `onReady` callback only + // fires once BOTH have happened — defensive coupling so a same-origin + // synchronous handshake (cached bundle) can't beat the document-complete + // signal and trigger a premature loading-bar fade. In practice load + // tends to fire first, but the order is not guaranteed by the spec. + private iframeLoaded = false; + + private handshakeReady = false; + private readonly disposables: Array<() => void> = []; // Expected origin of the widget iframe — captured once at construction @@ -284,6 +297,26 @@ export class BotWidgetEmbed { const iframe = createBotIframe(preset); container.append(iframe); + // External onReady fires only when BOTH the iframe document is fully + // loaded AND the ClientWidgetApi handshake is complete — see + // `iframeLoaded` / `handshakeReady` field comments. The host gate this + // serves is the loading-bar overlay in BotWidgetMount. + const fireExternalReady = () => { + if (this.iframeLoaded && this.handshakeReady) { + this.options.onReady?.(); + } + }; + + // Attach the iframe load listener BEFORE the src assignment for the + // same reason ClientWidgetApi attaches its internal listener early — + // a cached same-origin document can finish loading before src returns + // control here, and a late listener would miss the load event. + const onIframeLoad = () => { + this.iframeLoaded = true; + fireExternalReady(); + }; + iframe.addEventListener('load', onIframeLoad); + const driver: WidgetDriver = new BotWidgetDriver(mx, room.roomId, preset); const api = new ClientWidgetApi(widget, iframe, driver); @@ -296,8 +329,10 @@ export class BotWidgetEmbed { const onReady = () => { this.isReady = true; + this.handshakeReady = true; // eslint-disable-next-line no-console console.info('[BotWidget] handshake complete'); + fireExternalReady(); }; // Element-Web treats `error:preparing` as informational — it never tears // down the widget. Vojo follows the same rule: log the upstream cause and @@ -325,6 +360,7 @@ export class BotWidgetEmbed { window.addEventListener('message', this.onWidgetMessage); this.disposables.push( + () => iframe.removeEventListener('load', onIframeLoad), () => api.off('ready', onReady), () => api.off('error:preparing', onPreparingError), () => iframe.removeEventListener('error', onIframeError), diff --git a/src/app/features/bots/BotWidgetMount.css.ts b/src/app/features/bots/BotWidgetMount.css.ts index b1a8a61e..1074678a 100644 --- a/src/app/features/bots/BotWidgetMount.css.ts +++ b/src/app/features/bots/BotWidgetMount.css.ts @@ -1,28 +1,65 @@ -import { style } from '@vanilla-extract/css'; -import { DefaultReset, color, config, toRem } from 'folds'; - -export const Host = style([ - DefaultReset, - { - position: 'relative', - width: '100%', - height: '100%', - backgroundColor: color.Background.Container, - }, -]); +import { keyframes, style } from '@vanilla-extract/css'; export const FrameMount = style({ width: '100%', height: '100%', }); -export const Toolbar = style([ - DefaultReset, - { - position: 'absolute', - top: config.space.S300, - right: config.space.S300, - zIndex: 1, - maxWidth: `calc(100% - ${toRem(24)})`, +// Loading bar overlay shown while the widget iframe boots and capability +// handshake completes. Mirrors `SyncIndicator` (sweep + radial fade + glow) +// but pinned to the TOP of the iframe frame (under the BotShell hero) and +// painted in the brand fleet-violet `Primary.Main` (#9580ff) instead of the +// sync-state green. Resolves the «empty page on bot click» complaint: +// before this, the iframe took ~200-800ms to first paint and the user saw +// only the hero on a flat dark surface. Hex is hardcoded for the same +// reason `BotShell.HeroAvatar` hardcodes it: keep the bot accent stable +// across folds palette swaps. +const slide = keyframes({ + '0%': { transform: 'translateX(-100%)' }, + '100%': { transform: 'translateX(100%)' }, +}); + +// Container clips horizontally so the slide-keyframe wraps invisibly off- +// screen at the edges. Vertically the upper drop-shadow halo (-6px..0) IS +// intentionally clipped — the bar sits flush against the BotShell hero's +// bottom border and a halo glowing UP would visually bleed into the hero +// chrome. Only the lower halo (0..8px) renders, glowing DOWN into the +// iframe — mirror of SyncIndicator's bottom-anchored bar where the halo +// renders UP into the page. Don't bump `top` to surface the upper halo; +// that would float the bar away from the hero edge and break the seam. +export const LoadingBarRoot = style({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: '14px', + pointerEvents: 'none', + // Above the iframe, but no need for the global Z400 — local stacking + // context inside Frame is enough. + zIndex: 2, + overflow: 'hidden', +}); + +export const LoadingBar = style({ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '2px', + background: + 'radial-gradient(ellipse 50% 100% at center, #9580ff 0%, rgba(149, 128, 255, 0.45) 35%, rgba(149, 128, 255, 0) 70%)', + filter: 'drop-shadow(0 0 6px rgba(149, 128, 255, 0.75))', + animation: `${slide} 1.6s linear infinite`, + willChange: 'transform', + // 250ms opacity transition matches SyncIndicator's crossfade so the bar + // doesn't pop in/out abruptly when ready flips. animationPlayState is + // toggled inline at the call site so the compositor doesn't keep + // sweeping while the bar is invisible (opacity:0 alone doesn't pause + // CSS animations — verified across browsers). + transition: 'opacity 250ms ease', + '@media': { + '(prefers-reduced-motion: reduce)': { + animation: 'none', + }, }, -]); +}); diff --git a/src/app/features/bots/BotWidgetMount.tsx b/src/app/features/bots/BotWidgetMount.tsx index 6638caf3..78ff0fa9 100644 --- a/src/app/features/bots/BotWidgetMount.tsx +++ b/src/app/features/bots/BotWidgetMount.tsx @@ -1,9 +1,27 @@ -import React, { useRef } from 'react'; -import { Room } from 'matrix-js-sdk'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Room, SyncState } from 'matrix-js-sdk'; import type { BotPreset } from './catalog'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useSyncState } from '../../hooks/useSyncState'; 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 @@ -16,7 +34,130 @@ type BotWidgetMountProps = { export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) { const containerRef = useRef(null); - useBotWidgetEmbed({ containerRef, preset, room, onError }); + const { ready } = useBotWidgetEmbed({ containerRef, preset, room, onError }); - return
; + // 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. + const mx = useMatrixClient(); + 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 ( + <> +
+
+
+
+ + ); } diff --git a/src/app/features/bots/useBotWidgetEmbed.ts b/src/app/features/bots/useBotWidgetEmbed.ts index 7da29655..0dba6b3d 100644 --- a/src/app/features/bots/useBotWidgetEmbed.ts +++ b/src/app/features/bots/useBotWidgetEmbed.ts @@ -1,4 +1,4 @@ -import { RefObject, useEffect, useRef } from 'react'; +import { RefObject, useEffect, useRef, useState } from 'react'; import { Room } from 'matrix-js-sdk'; import { useTranslation } from 'react-i18next'; import { useMatrixClient } from '../../hooks/useMatrixClient'; @@ -13,22 +13,29 @@ type UseBotWidgetEmbedOptions = { onError: () => 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. -// -// Returns a ref to the live embed instance; the host can use it to push -// runtime updates (e.g. theme) without forcing an iframe remount. +// 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, -}: UseBotWidgetEmbedOptions): RefObject => { +}: 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. @@ -50,6 +57,10 @@ export const useBotWidgetEmbed = ({ 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({ @@ -60,6 +71,7 @@ export const useBotWidgetEmbed = ({ theme: themeRef.current, language: languageRef.current, onError, + onReady: () => setReady(true), }); embedRef.current = embed; } catch (error) { @@ -95,5 +107,5 @@ export const useBotWidgetEmbed = ({ embedRef.current?.setTheme(theme).catch(() => undefined); }, [theme]); - return embedRef; + return { ready }; }; diff --git a/src/app/pages/client/SyncIndicator.tsx b/src/app/pages/client/SyncIndicator.tsx index 2570b81c..5ae198c6 100644 --- a/src/app/pages/client/SyncIndicator.tsx +++ b/src/app/pages/client/SyncIndicator.tsx @@ -4,11 +4,23 @@ import { ClientEvent, MatrixClient, SyncState } from 'matrix-js-sdk'; import { isAndroidPlatform } from '../../utils/capacitor'; import * as css from './SyncIndicator.css'; -// Suppress 0-150ms green flashes during fast transient state cycles +// Suppress sub-100ms green flashes during fast transient state cycles // (e.g. one /sync request blips Reconnecting then immediately recovers to // Syncing — without the delay the bar would briefly appear at whatever -// position the slide animation happened to be at). -const PROGRESS_FADE_IN_DELAY_MS = 150; +// position the slide animation happened to be at). 100ms keeps the bar +// responsive enough that real transitions land before the user gives up. +// +// Trade-off: combined with the cycle-complete hide below, transients in +// the 100-1800ms window now block the bar for a full slide cycle (~2s +// of visible bar) instead of cutting at the actual recovery moment. +// Deliberate choice — graceful animation > strict timing accuracy. +const PROGRESS_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). 2100ms gives ~300ms slack over the +// 1.8s slide-cycle. The reduced-motion path skips this branch entirely. +const PROGRESS_HIDE_FALLBACK_MS = 2100; // On detected resume, hold the visual at "progress" (green) for this long if // the SDK is in `Error`. Worst-case keep-alive backoff in matrix-js-sdk is @@ -142,17 +154,60 @@ export function SyncIndicator({ mx }: SyncIndicatorProps) { const visual: Visual = resumeGraceUntil > 0 && baseVisual === 'error' ? 'progress' : baseVisual; - // Anti-flicker debounce: the green layer only becomes visible if the - // progress visual is sustained for at least 150ms. + // Three logical phases for the green progress bar (mirrors the bot + // widget loading bar): + // - hidden (showProgress=false, pendingProgressHide=false) + // - visible (showProgress=true, pendingProgressHide=false) + // - closing (showProgress=true, pendingProgressHide=true) → + // progress→hidden landed mid-sweep, waiting for the + // animationiteration boundary to land before snapping + // invisible so the user always sees a complete left-to- + // right sweep, never a mid-stroke cut. + // Cycle-complete is only applied for progress→hidden. progress→error + // skips it because we want the red layer to take over without a half- + // faded green sweep visually mixing on top of it. const [showProgress, setShowProgress] = useState(false); + const [pendingProgressHide, setPendingProgressHide] = useState(false); + useEffect(() => { - if (visual !== 'progress') { - setShowProgress(false); - return undefined; + if (visual === 'progress') { + setPendingProgressHide(false); + if (showProgress) return undefined; + const timer = setTimeout(() => setShowProgress(true), PROGRESS_FADE_IN_DELAY_MS); + return () => clearTimeout(timer); } - const timer = setTimeout(() => setShowProgress(true), PROGRESS_FADE_IN_DELAY_MS); - return () => clearTimeout(timer); - }, [visual]); + if (visual === 'hidden' && showProgress) { + // Reduced-motion: animation is off, no iteration will land, + // parking a static green bar for ~2s isn't graceful — it's just + // a stuck bar. Snap invisible immediately. + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + setShowProgress(false); + setPendingProgressHide(false); + return undefined; + } + // Sync recovered while bar was mid-sweep — defer the hide to the + // next iteration boundary. Fallback force-hides if the event + // never lands (compositor freeze on backgrounded tab). + setPendingProgressHide(true); + const fallback = setTimeout(() => { + setShowProgress(false); + setPendingProgressHide(false); + }, PROGRESS_HIDE_FALLBACK_MS); + return () => clearTimeout(fallback); + } + // Either visual === 'error' (red layer takes over, drop green now) + // or visual === 'hidden' && !showProgress (already hidden, no-op). + setShowProgress(false); + setPendingProgressHide(false); + return undefined; + }, [visual, showProgress]); + + const handleProgressIteration = () => { + if (pendingProgressHide) { + setShowProgress(false); + setPendingProgressHide(false); + } + }; const showError = visual === 'error'; @@ -160,6 +215,15 @@ export function SyncIndicator({ mx }: SyncIndicatorProps) { // 250ms transition (set on barBase). `animationPlayState: 'paused'` on // the green layer when invisible avoids continuous compositor work — // `opacity: 0` does NOT pause CSS animations in any major browser. + // + // On progress→error we snap the green to opacity:0 with no transition + // (`transition: 'none'`). The default 250ms fade-out would otherwise + // cross-fade with the red layer's 250ms fade-in — both stack at the + // same bottom edge, both at intermediate alpha, producing a muddy + // green-on-red blend at the exact moment we're trying to alarm the + // user. The `paused` animation also freezes the green at whatever + // mid-cycle position it was at, so the cross-fade looks like a static + // green smear bleeding into a static red bar. Snap-hide kills it. return (