"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { useConvexConnectionState } from "convex/react"; import { cn } from "@/lib/utils"; import { toast, toastDuration } from "@/lib/toast"; import { msg } from "@/lib/toast-messages"; type BannerState = "hidden" | "reconnecting" | "disconnected" | "reconnected"; const RECONNECTED_HIDE_DELAY_MS = 1800; export default function ConnectionBanner() { const connectionState = useConvexConnectionState(); const previousConnectedRef = useRef(connectionState.isWebSocketConnected); const disconnectToastIdRef = useRef(undefined); const [showReconnected, setShowReconnected] = useState(false); const [isBrowserOnline, setIsBrowserOnline] = useState( typeof navigator === "undefined" ? true : navigator.onLine, ); useEffect(() => { const handleOnline = () => setIsBrowserOnline(true); const handleOffline = () => setIsBrowserOnline(false); window.addEventListener("online", handleOnline); window.addEventListener("offline", handleOffline); return () => { window.removeEventListener("online", handleOnline); window.removeEventListener("offline", handleOffline); }; }, []); useEffect(() => { const wasConnected = previousConnectedRef.current; const isConnected = connectionState.isWebSocketConnected; const didReconnect = !wasConnected && isConnected && connectionState.connectionCount > 1; if (didReconnect) { queueMicrotask(() => { setShowReconnected(true); }); } if (!isConnected) { queueMicrotask(() => { setShowReconnected(false); }); } previousConnectedRef.current = isConnected; }, [connectionState.connectionCount, connectionState.isWebSocketConnected]); useEffect(() => { if (!showReconnected) { return; } const timeoutId = window.setTimeout(() => { setShowReconnected(false); }, RECONNECTED_HIDE_DELAY_MS); return () => window.clearTimeout(timeoutId); }, [showReconnected]); useEffect(() => { const connected = connectionState.isWebSocketConnected; const shouldAlertDisconnect = !connected && (!isBrowserOnline || connectionState.hasEverConnected || connectionState.connectionRetries > 0); if (shouldAlertDisconnect) { if (disconnectToastIdRef.current === undefined) { disconnectToastIdRef.current = toast.error( msg.system.connectionLost.title, msg.system.connectionLost.desc, { duration: Number.POSITIVE_INFINITY }, ); } return; } if (connected && disconnectToastIdRef.current !== undefined) { toast.dismiss(disconnectToastIdRef.current); disconnectToastIdRef.current = undefined; toast.success(msg.system.reconnected.title, undefined, { duration: toastDuration.successShort, }); } }, [ connectionState.connectionRetries, connectionState.hasEverConnected, connectionState.isWebSocketConnected, isBrowserOnline, ]); const bannerState = useMemo(() => { if (connectionState.isWebSocketConnected) { return showReconnected ? "reconnected" : "hidden"; } // Streng `=== false`, damit kein undefined/SSR-Artefakt wie „offline“ wird. if (isBrowserOnline === false) { return "disconnected"; } if (connectionState.hasEverConnected || connectionState.connectionRetries > 0) { return "reconnecting"; } return "hidden"; }, [ connectionState.connectionRetries, connectionState.hasEverConnected, connectionState.isWebSocketConnected, isBrowserOnline, showReconnected, ]); // WebSocket/Convex-Verbindung gibt es im Browser; SSR soll keinen Banner rendern, // sonst weicht die Geschwister-Reihenfolge vom ersten Client-Render ab (Hydration). if (typeof window === "undefined") { return null; } if (bannerState === "hidden") { return null; } const contentByState: Record, { dotClass: string; text: string }> = { reconnecting: { dotClass: "bg-amber-500", text: "Verbindung wird wiederhergestellt…", }, disconnected: { dotClass: "bg-destructive", text: "Keine Verbindung. Wir verbinden uns automatisch erneut.", }, reconnected: { dotClass: "bg-emerald-500", text: "Verbindung wiederhergestellt", }, }; const content = contentByState[bannerState]; return (
); }