"use client"; import { RefObject, useEffect, useRef, useState } from "react"; import { AlertTriangle, CloudOff, Send } from "lucide-react"; import { toast } from "sonner"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { SheetTrigger } from "@/components/ui/sheet"; import { cn } from "@/lib/utils"; import { useConnectivity } from "@/lib/offline/connectivity"; import { QueueSummary, summarizeQueue } from "@/lib/offline/db"; import { entriesRejected, entriesSynced, entriesWaiting, voiceNotesAmong, } from "@/lib/offline/queue-texts"; import { resumeSync, syncPendingEntries } from "@/lib/offline/sync"; import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation"; import { PendingQueueSheet } from "./pending-queue-sheet"; const COUNT_INTERVAL_MS = 15_000; const EMPTY_QUEUE: QueueSummary = { own: 0, failed: 0, foreign: 0 }; // Both auto-sync triggers can fire together; they share one sync run // (syncPendingEntries), even one already running - they change nothing that // is to be sent - and one toast id keeps it to one message. const SYNC_TOAST_ID = "offline-sync"; // Wraps on narrow phones instead of overflowing; never below 44 px. const BAND_BUTTON_CLASS = "h-auto min-h-11 whitespace-normal text-left"; interface OfflineBandProps { userId: string; /** Focus target when the queue sheet closes and the band shows nothing. */ pageContentRef: RefObject; /** * False in an offline session (opened from the cached identity, no access * token): the band then shows what waits but neither opens the sync gate * nor sends - the server would only answer 401. */ syncEnabled?: boolean; } /** * One line under the header that says whether input is only stored on this * device, how much still waits to be sent and what the server rejected. * Sends by itself as soon as the connection is back. */ export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: OfflineBandProps) { const queryClient = useQueryClient(); const connectivity = useConnectivity(); const statusRef = useRef(null); const [sheetOpen, setSheetOpen] = useState(false); const { data: queue = EMPTY_QUEUE } = useQuery({ queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "summary"], queryFn: () => summarizeQueue(userId), refetchInterval: COUNT_INTERVAL_MS, // Reads this device's storage, never the network. networkMode: "always", }); const sync = useMutation({ mutationFn: () => syncPendingEntries(userId), networkMode: "always", onSuccess: (result) => { if (result.synced > 0) { toast.success(entriesSynced(result.synced), { id: SYNC_TOAST_ID }); // New records on the server: every list and count may be stale now. void queryClient.invalidateQueries(); return; } void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY }); }, onError: () => toast.error("Synchronisierung fehlgeschlagen - wird beim nächsten Versuch erneut versucht", { id: SYNC_TOAST_ID, }), }); const { mutate: startSync } = sync; // The band only exists for a signed-in user, and the auth context sets that // user's access token before the user: the moment to reopen sync after a // logout's cancelSync(). Not without a token (offline session): the gate // stays closed until the session is online. Declared first, so it runs // before the sync below. useEffect(() => { if (syncEnabled) { resumeSync(); } }, [userId, syncEnabled]); // Mount, and every switch back to online (a request reached the server again). useEffect(() => { if (syncEnabled && connectivity === "online") { startSync(); } }, [connectivity, startSync, userId, syncEnabled]); // The browser's own `online` event: try even while the last request failed. useEffect(() => { if (!syncEnabled) { return; } const handleOnline = () => startSync(); window.addEventListener("online", handleOnline); return () => window.removeEventListener("online", handleOnline); }, [startSync, syncEnabled]); // The sheet stays open while the user still has entries in it (waiting // ones too, after the last rejected one was discarded), and closes once // there is nothing left to show. const hasRejected = queue.failed > 0; if (sheetOpen && queue.own === 0 && !hasRejected) { setSheetOpen(false); } // An offline session cannot send, whatever the network says. const isOffline = connectivity === "offline" || !syncEnabled; // „(davon 2 Sprachnotizen)“: voice notes are part of the counts above. const voiceSuffix = queue.voiceWaiting ? ` (${voiceNotesAmong(queue.voiceWaiting)})` : ""; const hasContent = isOffline || queue.own > 0 || hasRejected; // Back to the trigger while it exists; otherwise to the band's message, or // to the page content when the band shows nothing. function sheetFinalFocus(): true | HTMLElement | null { if (hasRejected) { return true; } return hasContent ? statusRef.current : pageContentRef.current; } // The live region is always there, so its first message is announced too. return (
{isOffline ? (

{queue.own > 0 ? `Offline – ${entriesWaiting(queue.own)}${voiceSuffix}` : "Offline – Erfassungen werden gespeichert und später gesendet. Vorschläge und Aufgaben brauchen eine Verbindung."}

) : null} {!isOffline && queue.own > 0 ? ( ) : null} {!isOffline && queue.own > 0 && queue.voiceWaiting ? ( {voiceSuffix.trim()} ) : null} } > {`${entriesRejected(queue.failed)} – ansehen`} ) : queue.voiceWaiting ? ( // Waiting voice notes cannot be seen anywhere else but the list: the sheet opens from here too. } > Warteschlange ansehen ) : null } />
); }