- CSP erlaubt media-src 'self' blob:, damit die eigene Aufnahme abspielbar ist; Dauer kommt vom Recorder statt aus den Metadaten - Fokus nach gesperrtem Mikrofon auf der Hilfe, nach Verwerfen in /voice auf der Liste - automatisch beendete Aufnahme läuft über denselben Speicherweg wie Senden, kein zweites Speichern; Volk und Datum bleiben bis „Noch eine Aufnahme“ gesperrt - Offline-Band öffnet die Warteschlange auch ohne Ablehnung, wenn Sprachnotizen warten; leere Liste offline mit Text - Abmelde-Dialog und Zurück-Link mit 56 px, Schließen-Knopf der Dialoge 44 px Gates: Frontend lint, typecheck, 2991 Tests, build. Refs #27 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
211 lines
7.6 KiB
TypeScript
211 lines
7.6 KiB
TypeScript
"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<HTMLElement | null>;
|
||
/**
|
||
* 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<HTMLDivElement>(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 (
|
||
<div
|
||
ref={statusRef}
|
||
role="status"
|
||
aria-live="polite"
|
||
tabIndex={-1}
|
||
className={cn(
|
||
"outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
|
||
hasContent &&
|
||
"flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border/60 px-4 py-2 text-sm sm:px-6",
|
||
hasContent && (isOffline ? "bg-warning/15" : "bg-muted/60"),
|
||
)}
|
||
>
|
||
{isOffline ? (
|
||
<p className="flex items-center gap-2 font-medium">
|
||
<CloudOff className="size-4 shrink-0" aria-hidden />
|
||
{queue.own > 0
|
||
? `Offline – ${entriesWaiting(queue.own)}${voiceSuffix}`
|
||
: "Offline – Erfassungen werden gespeichert und später gesendet. Vorschläge und Aufgaben brauchen eine Verbindung."}
|
||
</p>
|
||
) : null}
|
||
{!isOffline && queue.own > 0 ? (
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="touch"
|
||
className={BAND_BUTTON_CLASS}
|
||
disabled={sync.isPending}
|
||
onClick={() => startSync()}
|
||
>
|
||
<Send aria-hidden />
|
||
{sync.isPending ? "Wird gesendet…" : `${entriesWaiting(queue.own)} – Jetzt senden`}
|
||
</Button>
|
||
) : null}
|
||
{!isOffline && queue.own > 0 && queue.voiceWaiting ? (
|
||
<span>{voiceSuffix.trim()}</span>
|
||
) : null}
|
||
<PendingQueueSheet
|
||
userId={userId}
|
||
foreignCount={queue.foreign}
|
||
open={sheetOpen}
|
||
onOpenChange={setSheetOpen}
|
||
finalFocus={sheetFinalFocus}
|
||
trigger={
|
||
hasRejected ? (
|
||
<SheetTrigger
|
||
render={
|
||
<Button
|
||
type="button"
|
||
variant="destructive"
|
||
size="touch"
|
||
className={BAND_BUTTON_CLASS}
|
||
/>
|
||
}
|
||
>
|
||
<AlertTriangle aria-hidden />
|
||
{`${entriesRejected(queue.failed)} – ansehen`}
|
||
</SheetTrigger>
|
||
) : queue.voiceWaiting ? (
|
||
// Waiting voice notes cannot be seen anywhere else but the list: the sheet opens from here too.
|
||
<SheetTrigger
|
||
render={
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="touch"
|
||
className={BAND_BUTTON_CLASS}
|
||
/>
|
||
}
|
||
>
|
||
Warteschlange ansehen
|
||
</SheetTrigger>
|
||
) : null
|
||
}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|