import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { useConnectivity, type ConnectivityStatus } from "@/lib/connectivity"; /** Trouble must last this long before the banner appears, so one blip never flickers. */ export const BANNER_SHOW_DELAY_MS = 2_000; /** How long "Back online" stays after a recovery the user saw. */ export const BACK_ONLINE_VISIBLE_MS = 3_000; export type ConnectionBannerView = "hidden" | "reconnecting" | "offline" | "back_online"; /** * Which banner to show. Reconnecting/offline only after `BANNER_SHOW_DELAY_MS` * of continuous trouble; "Back online" only after an outage the banner showed. */ export function connectionBannerView(input: { status: ConnectivityStatus; troubleSince: number | null; recoveredAt: number | null; shownForOutage: boolean; now: number; }): ConnectionBannerView { if (input.status !== "online") { const troubleFor = input.troubleSince === null ? 0 : input.now - input.troubleSince; if (troubleFor < BANNER_SHOW_DELAY_MS) return "hidden"; return input.status; } if (input.shownForOutage && input.recoveredAt !== null && input.now - input.recoveredAt < BACK_ONLINE_VISIBLE_MS) { return "back_online"; } return "hidden"; } export function pendingWritesCopy(count: number): string | null { if (count <= 0) return null; return count === 1 ? "1 change will send when you’re back online." : `${count} changes will send when you’re back online.`; } // Full-page reconnect screens (CloudAccessGate before the board opens) already // say the same thing; the banner stays hidden while one is mounted. let suppressors = 0; const suppressionListeners = new Set<() => void>(); function setSuppressors(next: number) { suppressors = next; for (const listener of [...suppressionListeners]) listener(); } function subscribeSuppression(listener: () => void) { suppressionListeners.add(listener); return () => { suppressionListeners.delete(listener); }; } /** Hide the banner while the calling component is mounted. */ export function useSuppressConnectionBanner(active = true) { useEffect(() => { if (!active) return; setSuppressors(suppressors + 1); return () => setSuppressors(suppressors - 1); }, [active]); } /** * The single app-level connection indicator. Replaces per-surface * outage errors: while the server is unreachable, loaded content stays on * screen and this banner explains why nothing is updating. */ export function ConnectionStatusBanner() { const { status, troubleSince, recoveredAt, pendingWrites } = useConnectivity(); const [now, setNow] = useState(() => Date.now()); const shownForOutage = useRef(false); const suppressed = useSyncExternalStore(subscribeSuppression, () => suppressors > 0, () => false); const view = connectionBannerView({ status, troubleSince, recoveredAt, shownForOutage: shownForOutage.current, now }); if (!suppressed && (view === "reconnecting" || view === "offline")) shownForOutage.current = true; // Re-render when the view can next change: when the show delay elapses, or // when "Back online" should disappear. useEffect(() => { let wakeAt: number | null = null; if (status !== "online" && troubleSince !== null && view === "hidden") { wakeAt = troubleSince + BANNER_SHOW_DELAY_MS; } else if (view === "back_online" && recoveredAt !== null) { wakeAt = recoveredAt + BACK_ONLINE_VISIBLE_MS; } if (wakeAt === null) return; const timer = window.setTimeout(() => setNow(Date.now()), Math.max(0, wakeAt - Date.now())); return () => window.clearTimeout(timer); }, [status, troubleSince, recoveredAt, view]); // Keep `now` current when the connectivity state changes. useEffect(() => { setNow(Date.now()); }, [status, troubleSince, recoveredAt]); useEffect(() => { if (view === "hidden" && status === "online") shownForOutage.current = false; }, [view, status]); if (view === "hidden" || suppressed) return null; return ; } /** The banner's presentation, for a view that is not hidden. */ export function ConnectionStatusMessage({ view, pendingWrites = 0, }: { view: Exclude; pendingWrites?: number; }) { const pending = view === "back_online" ? null : pendingWritesCopy(pendingWrites); const message = view === "back_online" ? "Back online." : view === "offline" ? "You’re offline. Reconnecting when your network is back…" : "Connection interrupted. Reconnecting automatically…"; // Fixed, not in flow: the desktop shell is exactly `h-dvh` with clipped // overflow, so an in-flow banner would push the board's bottom off screen. // The top offset includes the safe-area inset so a notch cannot hide it. return (
{message} {pending ? <> {pending} : null}
); }