feat(bots): add bot-widget loading bar with cycle-complete hide and sync-state deference, plus matching cycle-complete polish on SyncIndicator
This commit is contained in:
parent
061608558a
commit
6fd6844611
5 changed files with 335 additions and 43 deletions
|
|
@ -33,6 +33,7 @@ export type BotWidgetEmbedOptions = {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
language: string;
|
language: string;
|
||||||
onError: (error: Error) => void;
|
onError: (error: Error) => void;
|
||||||
|
onReady?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBotWidgetId = (preset: BotPreset): string => `vojo-bot-${preset.id}`;
|
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.
|
// demote the widget into the chat fallback even though the widget is fine.
|
||||||
private isReady = false;
|
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> = [];
|
private readonly disposables: Array<() => void> = [];
|
||||||
|
|
||||||
// Expected origin of the widget iframe — captured once at construction
|
// Expected origin of the widget iframe — captured once at construction
|
||||||
|
|
@ -284,6 +297,26 @@ export class BotWidgetEmbed {
|
||||||
const iframe = createBotIframe(preset);
|
const iframe = createBotIframe(preset);
|
||||||
container.append(iframe);
|
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 driver: WidgetDriver = new BotWidgetDriver(mx, room.roomId, preset);
|
||||||
const api = new ClientWidgetApi(widget, iframe, driver);
|
const api = new ClientWidgetApi(widget, iframe, driver);
|
||||||
|
|
||||||
|
|
@ -296,8 +329,10 @@ export class BotWidgetEmbed {
|
||||||
|
|
||||||
const onReady = () => {
|
const onReady = () => {
|
||||||
this.isReady = true;
|
this.isReady = true;
|
||||||
|
this.handshakeReady = true;
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.info('[BotWidget] handshake complete');
|
console.info('[BotWidget] handshake complete');
|
||||||
|
fireExternalReady();
|
||||||
};
|
};
|
||||||
// Element-Web treats `error:preparing` as informational — it never tears
|
// Element-Web treats `error:preparing` as informational — it never tears
|
||||||
// down the widget. Vojo follows the same rule: log the upstream cause and
|
// 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);
|
window.addEventListener('message', this.onWidgetMessage);
|
||||||
|
|
||||||
this.disposables.push(
|
this.disposables.push(
|
||||||
|
() => iframe.removeEventListener('load', onIframeLoad),
|
||||||
() => api.off('ready', onReady),
|
() => api.off('ready', onReady),
|
||||||
() => api.off('error:preparing', onPreparingError),
|
() => api.off('error:preparing', onPreparingError),
|
||||||
() => iframe.removeEventListener('error', onIframeError),
|
() => iframe.removeEventListener('error', onIframeError),
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,65 @@
|
||||||
import { style } from '@vanilla-extract/css';
|
import { keyframes, 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,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const FrameMount = style({
|
export const FrameMount = style({
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Toolbar = style([
|
// Loading bar overlay shown while the widget iframe boots and capability
|
||||||
DefaultReset,
|
// handshake completes. Mirrors `SyncIndicator` (sweep + radial fade + glow)
|
||||||
{
|
// but pinned to the TOP of the iframe frame (under the BotShell hero) and
|
||||||
position: 'absolute',
|
// painted in the brand fleet-violet `Primary.Main` (#9580ff) instead of the
|
||||||
top: config.space.S300,
|
// sync-state green. Resolves the «empty page on bot click» complaint:
|
||||||
right: config.space.S300,
|
// before this, the iframe took ~200-800ms to first paint and the user saw
|
||||||
zIndex: 1,
|
// only the hero on a flat dark surface. Hex is hardcoded for the same
|
||||||
maxWidth: `calc(100% - ${toRem(24)})`,
|
// 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',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,27 @@
|
||||||
import React, { useRef } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room, SyncState } from 'matrix-js-sdk';
|
||||||
import type { BotPreset } from './catalog';
|
import type { BotPreset } from './catalog';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useSyncState } from '../../hooks/useSyncState';
|
||||||
import { useBotWidgetEmbed } from './useBotWidgetEmbed';
|
import { useBotWidgetEmbed } from './useBotWidgetEmbed';
|
||||||
import * as css from './BotWidgetMount.css';
|
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
|
// Renders the iframe-mount target and wires up `useBotWidgetEmbed` to
|
||||||
// drive the iframe lifecycle. Renamed from BotWidgetHost in the BotShell
|
// drive the iframe lifecycle. Renamed from BotWidgetHost in the BotShell
|
||||||
// refactor — «Host» was overloaded with BotExperienceHost (page-level
|
// refactor — «Host» was overloaded with BotExperienceHost (page-level
|
||||||
|
|
@ -16,7 +34,130 @@ type BotWidgetMountProps = {
|
||||||
|
|
||||||
export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
|
export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
useBotWidgetEmbed({ containerRef, preset, room, onError });
|
const { ready } = useBotWidgetEmbed({ containerRef, preset, room, onError });
|
||||||
|
|
||||||
return <div ref={containerRef} className={css.FrameMount} />;
|
// 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<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { RefObject, useEffect, useRef } from 'react';
|
import { RefObject, useEffect, useRef, useState } from 'react';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
|
@ -13,22 +13,29 @@ type UseBotWidgetEmbedOptions = {
|
||||||
onError: () => void;
|
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
|
// Encapsulates the imperative BotWidgetEmbed lifecycle so the host React
|
||||||
// component stays small. Mirrors useCallEmbed's split: the hook owns the
|
// component stays small. Mirrors useCallEmbed's split: the hook owns the
|
||||||
// effect, the component owns the DOM ref + visual chrome.
|
// effect, the component owns the DOM ref + visual chrome. Live embed
|
||||||
//
|
// instance is held in a private ref for runtime updates (theme
|
||||||
// Returns a ref to the live embed instance; the host can use it to push
|
// propagation below); not exposed because no caller needs it today.
|
||||||
// runtime updates (e.g. theme) without forcing an iframe remount.
|
|
||||||
export const useBotWidgetEmbed = ({
|
export const useBotWidgetEmbed = ({
|
||||||
containerRef,
|
containerRef,
|
||||||
preset,
|
preset,
|
||||||
room,
|
room,
|
||||||
onError,
|
onError,
|
||||||
}: UseBotWidgetEmbedOptions): RefObject<BotWidgetEmbed | undefined> => {
|
}: UseBotWidgetEmbedOptions): UseBotWidgetEmbedResult => {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const embedRef = useRef<BotWidgetEmbed>();
|
const embedRef = useRef<BotWidgetEmbed>();
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
// Capture mount-time values in refs so the lifecycle effect doesn't re-run
|
// Capture mount-time values in refs so the lifecycle effect doesn't re-run
|
||||||
// when locale/theme change — those are propagated separately via setTheme.
|
// when locale/theme change — those are propagated separately via setTheme.
|
||||||
|
|
@ -50,6 +57,10 @@ export const useBotWidgetEmbed = ({
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return undefined;
|
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;
|
let embed: BotWidgetEmbed | undefined;
|
||||||
try {
|
try {
|
||||||
embed = new BotWidgetEmbed({
|
embed = new BotWidgetEmbed({
|
||||||
|
|
@ -60,6 +71,7 @@ export const useBotWidgetEmbed = ({
|
||||||
theme: themeRef.current,
|
theme: themeRef.current,
|
||||||
language: languageRef.current,
|
language: languageRef.current,
|
||||||
onError,
|
onError,
|
||||||
|
onReady: () => setReady(true),
|
||||||
});
|
});
|
||||||
embedRef.current = embed;
|
embedRef.current = embed;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -95,5 +107,5 @@ export const useBotWidgetEmbed = ({
|
||||||
embedRef.current?.setTheme(theme).catch(() => undefined);
|
embedRef.current?.setTheme(theme).catch(() => undefined);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
return embedRef;
|
return { ready };
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,23 @@ import { ClientEvent, MatrixClient, SyncState } from 'matrix-js-sdk';
|
||||||
import { isAndroidPlatform } from '../../utils/capacitor';
|
import { isAndroidPlatform } from '../../utils/capacitor';
|
||||||
import * as css from './SyncIndicator.css';
|
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
|
// (e.g. one /sync request blips Reconnecting then immediately recovers to
|
||||||
// Syncing — without the delay the bar would briefly appear at whatever
|
// Syncing — without the delay the bar would briefly appear at whatever
|
||||||
// position the slide animation happened to be at).
|
// position the slide animation happened to be at). 100ms keeps the bar
|
||||||
const PROGRESS_FADE_IN_DELAY_MS = 150;
|
// 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
|
// 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
|
// 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 =
|
const visual: Visual =
|
||||||
resumeGraceUntil > 0 && baseVisual === 'error' ? 'progress' : baseVisual;
|
resumeGraceUntil > 0 && baseVisual === 'error' ? 'progress' : baseVisual;
|
||||||
|
|
||||||
// Anti-flicker debounce: the green layer only becomes visible if the
|
// Three logical phases for the green progress bar (mirrors the bot
|
||||||
// progress visual is sustained for at least 150ms.
|
// 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 [showProgress, setShowProgress] = useState(false);
|
||||||
|
const [pendingProgressHide, setPendingProgressHide] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visual !== 'progress') {
|
if (visual === 'progress') {
|
||||||
setShowProgress(false);
|
setPendingProgressHide(false);
|
||||||
return undefined;
|
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);
|
if (visual === 'hidden' && showProgress) {
|
||||||
return () => clearTimeout(timer);
|
// Reduced-motion: animation is off, no iteration will land,
|
||||||
}, [visual]);
|
// 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';
|
const showError = visual === 'error';
|
||||||
|
|
||||||
|
|
@ -160,6 +215,15 @@ export function SyncIndicator({ mx }: SyncIndicatorProps) {
|
||||||
// 250ms transition (set on barBase). `animationPlayState: 'paused'` on
|
// 250ms transition (set on barBase). `animationPlayState: 'paused'` on
|
||||||
// the green layer when invisible avoids continuous compositor work —
|
// the green layer when invisible avoids continuous compositor work —
|
||||||
// `opacity: 0` does NOT pause CSS animations in any major browser.
|
// `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 (
|
return (
|
||||||
<div className={css.root} aria-hidden>
|
<div className={css.root} aria-hidden>
|
||||||
<div
|
<div
|
||||||
|
|
@ -167,7 +231,9 @@ export function SyncIndicator({ mx }: SyncIndicatorProps) {
|
||||||
style={{
|
style={{
|
||||||
opacity: showProgress ? 1 : 0,
|
opacity: showProgress ? 1 : 0,
|
||||||
animationPlayState: showProgress ? 'running' : 'paused',
|
animationPlayState: showProgress ? 'running' : 'paused',
|
||||||
|
transition: showError ? 'none' : undefined,
|
||||||
}}
|
}}
|
||||||
|
onAnimationIteration={handleProgressIteration}
|
||||||
/>
|
/>
|
||||||
<div className={css.barError} style={{ opacity: showError ? 1 : 0 }} />
|
<div className={css.barError} style={{ opacity: showError ? 1 : 0 }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue