fix: Sprachnotizen nach Browserprüfung (Wiedergabe, Fokus, Warteschlange)
Backend Image / build-and-push (push) Successful in 6m2s
Frontend Image / build-and-push (push) Failing after 12m25s
Manifests / validate (push) Successful in 16s

- 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]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 12:22:47 +02:00
1 parent e0be687b7c
commit 92323197a7
16 files changed
+285 -90

No files matched your search

@@ -63,6 +63,9 @@ vi.mock("@/components/voice/voice-recorder", () => ({
<button type="button" onClick={() => props.onBusyChange?.(true)}>
Sperren (Test)
</button>
<button type="button" onClick={() => props.onReset?.()}>
Zurücksetzen (Test)
</button>
<button type="button" onClick={() => props.onSaved?.({ kind: "sent" })}>
Aufnahme speichern (Test)
</button>
@@ -144,6 +147,28 @@ describe("NewVoiceNotePage", () => {
expect(screen.getByRole("combobox", { name: /Volk/ })).toBeDisabled();
});
it("keeps colony and date fixed to the saved note until the user records anew", async () => {
query = new URLSearchParams({ colonyId: "c-1" });
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole("button", { name: "Ändern" }));
await user.click(screen.getByRole("button", { name: "Aufnahme speichern (Test)" }));
expect(screen.getByRole("button", { name: "Ändern" })).toBeDisabled();
expect(screen.getByLabelText(/Datum/, { selector: "input" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Zurücksetzen (Test)" }));
expect(screen.getByRole("button", { name: "Ändern" })).toBeEnabled();
expect(screen.getByLabelText(/Datum/, { selector: "input" })).toBeEnabled();
});
it("has a 56 px back link", async () => {
query = new URLSearchParams({ colonyId: "c-1" });
renderPage();
expect(await screen.findByRole("link", { name: /Zum Volk/ })).toHaveClass("min-h-14");
});
it("lists what to talk about", async () => {
query = new URLSearchParams({ colonyId: "c-1" });
renderPage();
@@ -114,6 +114,9 @@ function VoiceCaptureForm({ initialColonyId, initialDate, roundApiaryId }: Captu
const [date, setDate] = useState(initialDate);
const [editing, setEditing] = useState(initialColonyId === "");
const [busy, setBusy] = useState(false);
// A saved note belongs to its colony and day: changing them needs a new recording.
const [saved, setSaved] = useState(false);
const locked = busy || saved;
const [savedRound, setSavedRound] = useState<StandRound | null>(null);
const stand = useStandRound(roundApiaryId, colonyId);
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
@@ -124,7 +127,7 @@ function VoiceCaptureForm({ initialColonyId, initialDate, roundApiaryId }: Captu
return (
<main className="flex flex-col gap-5 p-4 sm:p-6">
<FormBackLink href={target.href} label={target.label} dirty={busy} />
<FormBackLink href={target.href} label={target.label} dirty={busy} className="min-h-14" />
<PageHeader icon={Mic} title="Sprachnotiz" description="Sprich in ganzen Sätzen, nur du." />
<section aria-label="Volk und Datum" className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
@@ -134,7 +137,7 @@ function VoiceCaptureForm({ initialColonyId, initialDate, roundApiaryId }: Captu
size="touch"
variant="outline"
className="min-h-14"
disabled={busy}
disabled={locked}
aria-expanded={editing}
onClick={() => setEditing((open) => !open)}
>
@@ -144,7 +147,7 @@ function VoiceCaptureForm({ initialColonyId, initialDate, roundApiaryId }: Captu
{editing ? (
// Colony and date are fixed while audio is being recorded or waits to be saved.
<fieldset
disabled={busy}
disabled={locked}
className="flex min-w-0 flex-col gap-3 rounded-2xl border border-border bg-card p-4"
>
<legend className="sr-only">Volk und Datum ändern</legend>
@@ -177,7 +180,9 @@ function VoiceCaptureForm({ initialColonyId, initialDate, roundApiaryId }: Captu
typeInsteadHref={inspectionHref(colonyId, roundApiaryId)}
blockedReason={colonyId ? null : NO_COLONY_REASON}
onBusyChange={setBusy}
onReset={() => setSaved(false)}
onSaved={(outcome) => {
setSaved(true);
if (outcome.kind !== "rejected" && stand.round) {
setSavedRound(stand.markDone(colonyId));
}
@@ -299,12 +299,37 @@ describe("VoiceListPage", () => {
expect(await screen.findByText(/Du bist offline/)).toBeInTheDocument();
expect(screen.queryByText(/konnten nicht geladen werden/)).not.toBeInTheDocument();
expect(screen.queryByText("Noch keine Sprachnotizen.")).not.toBeInTheDocument();
// Nothing on the device either: the empty state stays, with the offline hint.
expect(screen.getByText("Noch keine Sprachnotizen.")).toBeInTheDocument();
} finally {
onlineManager.setOnline(true);
}
});
it("moves focus to the list heading after the last device note is discarded", async () => {
connectivity = "offline";
await queueVoiceNote({
ownerUserId: "user-1",
blob: new Blob(["a"]),
mime: "audio/webm",
durationSec: 20,
colonyId: "c-2",
inspectionDate: "2026-10-12",
});
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole("button", { name: /Mehr zu B4/ }));
await user.click(await screen.findByRole("menuitem", { name: "Verwerfen" }));
await user.click(
within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Verwerfen" }),
);
const heading = await screen.findByRole("heading", { name: "Liste der Sprachnotizen" });
await waitFor(() => expect(document.activeElement).toBe(heading));
expect(await screen.findByText("Noch keine Sprachnotizen.")).toBeInTheDocument();
});
it("reads a transcribed note as Text liegt vor when the evaluation is off", async () => {
vi.mocked(fetchVoiceConfig).mockResolvedValue({ ...CONFIG, extractionEnabled: false });
vi.mocked(fetchVoiceCaptures).mockResolvedValue([item({ status: "TRANSCRIBED" })]);
+29 -24
View File
@@ -1,5 +1,6 @@
"use client";
import { useRef } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Mic } from "lucide-react";
@@ -111,6 +112,10 @@ function VoiceNotes() {
});
const coloniesQuery = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
const listHeading = useRef<HTMLHeadingElement>(null);
// The card with the focus is gone after a discard: the focus goes to the list's heading.
const focusList = () => listHeading.current?.focus();
const retry = useMutation({
mutationFn: (id: string) => retryVoiceCapture(id),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: voiceKeys.all }),
@@ -118,12 +123,18 @@ function VoiceNotes() {
});
const discard = useMutation({
mutationFn: (id: string) => discardVoiceCapture(id),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: voiceKeys.all }),
onSuccess: () => {
focusList();
void queryClient.invalidateQueries({ queryKey: voiceKeys.all });
},
onError: () => toast.error("Die Sprachnotiz konnte nicht verworfen werden."),
});
const discardLocal = useMutation({
mutationFn: (id: string) => discardPendingVoice(id, userId ?? ""),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY }),
onSuccess: () => {
focusList();
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Die Sprachnotiz konnte nicht verworfen werden."),
});
@@ -134,43 +145,36 @@ function VoiceNotes() {
isListedVoiceStatus(item.status),
);
if (serverQuery.isLoading && local.length === 0) {
return <LoadingState what="Sprachnotizen" />;
}
// No answer because there is no network: not an error, the notes appear with the network.
const serverOffline =
serverQuery.data === undefined &&
(serverQuery.fetchStatus === "paused" || isNetworkError(serverQuery.error));
const serverFailed = serverQuery.data === undefined && !serverQuery.isLoading && !serverOffline;
if (local.length === 0 && listed.length === 0) {
if (serverOffline) {
return <OfflineNote />;
}
return serverFailed ? (
const isLoading = serverQuery.isLoading && local.length === 0;
const isEmpty = local.length === 0 && listed.length === 0;
return (
<div className="flex flex-col gap-3">
<h2 ref={listHeading} tabIndex={-1} className="sr-only">
Liste der Sprachnotizen
</h2>
{isLoading ? <LoadingState what="Sprachnotizen" /> : null}
{!isLoading && serverOffline ? <OfflineNote /> : null}
{!isLoading && serverFailed ? (
<ErrorState
what="Sprachnotizen"
what={isEmpty ? "Sprachnotizen" : "Sprachnotizen vom Server"}
onRetry={() => void serverQuery.refetch()}
isRetrying={serverQuery.isFetching}
/>
) : (
) : null}
{!isLoading && isEmpty && !serverFailed ? (
<EmptyState
icon={Mic}
title="Noch keine Sprachnotizen."
description="Nimm beim Volk eine Sprachnotiz auf. Sie erscheint hier, bis du sie geprüft hast."
/>
);
}
return (
<div className="flex flex-col gap-3">
{serverOffline ? <OfflineNote /> : null}
{serverFailed ? (
<ErrorState
what="Sprachnotizen vom Server"
onRetry={() => void serverQuery.refetch()}
isRetrying={serverQuery.isFetching}
/>
) : null}
{isEmpty ? null : (
<ul className="flex flex-col gap-3" aria-label="Sprachnotizen">
{local.map((note) => (
<li key={note.id}>
@@ -194,6 +198,7 @@ function VoiceNotes() {
</li>
))}
</ul>
)}
</div>
);
}
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import { summarizeQueue } from "@/lib/offline/db";
@@ -66,6 +66,18 @@ describe("LogoutConfirmDialog", () => {
expect(screen.getByRole("button", { name: "Abbrechen" })).toBeVisible();
});
it("has 56 px buttons and a 44 px close target", async () => {
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
renderDialog();
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
for (const name of ["Abbrechen", "Erst senden", "Abmelden und Einträge löschen"]) {
expect(screen.getByRole("button", { name })).toHaveClass("min-h-14");
}
expect(within(dialog).getByRole("button", { name: "Close" })).toHaveClass("size-11");
});
it("asks to discard waiting voice notes and says they cannot be restored", async () => {
vi.mocked(summarizeQueue).mockResolvedValue({ own: 3, failed: 0, foreign: 0, voice: 2 });
@@ -152,13 +152,20 @@ export function LogoutConfirmDialog({
type="button"
variant="outline"
size="touch"
className="min-h-14"
disabled={isBusy}
onClick={() => onOpenChange(false)}
>
Abbrechen
</Button>
{hasEntries && isOnline ? (
<Button type="button" size="touch" disabled={isBusy} onClick={() => void sendFirst()}>
<Button
type="button"
size="touch"
className="min-h-14"
disabled={isBusy}
onClick={() => void sendFirst()}
>
Erst senden
</Button>
) : null}
@@ -166,6 +173,7 @@ export function LogoutConfirmDialog({
type="button"
variant="destructive"
size="touch"
className="min-h-14"
disabled={isBusy}
onClick={() => void signOut(hasEntries)}
>
@@ -20,6 +20,12 @@ describe("FormBackLink", () => {
);
});
it("takes extra classes for a bigger target", () => {
render(<FormBackLink href="/voice" label="Zum Volk" dirty={false} className="min-h-14" />);
expect(screen.getByRole("link", { name: /Zum Volk/ })).toHaveClass("min-h-14");
});
it("asks before leaving and flushes the draft", async () => {
const flush = vi.fn();
render(
@@ -5,6 +5,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
Dialog,
DialogContent,
@@ -21,6 +22,8 @@ export interface FormBackLinkProps {
dirty: boolean;
/** The form's draft; flushed so leaving does not race the autosave delay. */
draft?: { flush: () => void };
/** Extra classes, e.g. a bigger target on screens used with gloves. */
className?: string;
}
const NOT_PLAIN_CLICK = (event: MouseEvent) =>
@@ -31,7 +34,7 @@ const NOT_PLAIN_CLICK = (event: MouseEvent) =>
* untouched; once something was typed it asks first, saying honestly whether
* the input stays as a draft on this device or is lost.
*/
export function FormBackLink({ href, label, dirty, draft }: FormBackLinkProps) {
export function FormBackLink({ href, label, dirty, draft, className }: FormBackLinkProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
@@ -53,7 +56,10 @@ export function FormBackLink({ href, label, dirty, draft }: FormBackLinkProps) {
<Link
href={href}
onClick={handleClick}
className="-ml-2 inline-flex min-h-11 w-fit items-center gap-1.5 rounded-lg px-2 text-sm font-medium text-muted-foreground hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
className={cn(
"-ml-2 inline-flex min-h-11 w-fit items-center gap-1.5 rounded-lg px-2 text-sm font-medium text-muted-foreground hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none",
className,
)}
>
<ArrowLeft className="size-4" aria-hidden="true" />
{label}
@@ -143,6 +143,25 @@ describe("OfflineBand", () => {
expect(screen.getByRole("status")).toHaveTextContent("davon 1 Sprachnotiz");
});
it("opens the queue from the band when voice notes wait, offline too", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0, voice: 1, voiceWaiting: 1 });
renderBand();
expect(await screen.findByRole("button", { name: "Warteschlange ansehen" })).toBeVisible();
});
it("offers no queue button when nothing voice-related waits", async () => {
setOnLine(true);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
renderBand();
await screen.findByRole("button", { name: "2 Einträge warten – Jetzt senden" });
expect(screen.queryByRole("button", { name: "Warteschlange ansehen" })).not.toBeInTheDocument();
});
it("says „1 Eintrag wartet\" for a single entry", async () => {
setOnLine(false);
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
@@ -188,6 +188,20 @@ export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: Offl
<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
}
/>
@@ -65,7 +65,7 @@ function DialogContent({
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
className="absolute top-1 right-1 size-11"
size="icon-sm"
/>
}
@@ -1,3 +1,4 @@
import { useEffect, useRef } from "react";
import Link from "next/link";
import { MicOff } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -20,6 +21,8 @@ const STEPS: readonly { browser: string; text: string }[] = [
interface MicrophoneHelpProps {
reason: "denied" | "no-microphone" | "unsupported";
typeInsteadHref: string;
/** The user just tried to record: the heading takes the focus. */
focusOnShow?: boolean;
onRetry?(): void;
}
@@ -30,13 +33,26 @@ const TITLES: Record<MicrophoneHelpProps["reason"], string> = {
};
/** Mikrofon verweigert: what to do per browser, and a way to type instead. */
export function MicrophoneHelp({ reason, typeInsteadHref, onRetry }: MicrophoneHelpProps) {
export function MicrophoneHelp({
reason,
typeInsteadHref,
focusOnShow = false,
onRetry,
}: MicrophoneHelpProps) {
const heading = useRef<HTMLHeadingElement>(null);
useEffect(() => {
if (focusOnShow) heading.current?.focus();
}, [focusOnShow]);
return (
<section
aria-labelledby="voice-mic-help"
className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4"
>
<h2 id="voice-mic-help" className="flex items-center gap-2 font-heading text-lg font-semibold">
<h2
id="voice-mic-help"
ref={heading}
tabIndex={-1}
className="flex items-center gap-2 font-heading text-lg font-semibold">
<MicOff className="size-5" aria-hidden />
{TITLES[reason]}
</h2>
@@ -139,8 +139,7 @@ describe("VoiceRecorder", () => {
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
expect(await screen.findByRole("heading", { name: /Aufnahme fertig · 3:00/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Senden" })).toBeInTheDocument();
expect(await screen.findByText(/^Gespeichert/)).toBeInTheDocument();
});
it("shows the denied-state with a way to type instead", async () => {
@@ -360,9 +359,11 @@ describe("VoiceRecorder", () => {
});
describe("a recording the limit stopped", () => {
it("is on the device at once and shows the saved state", async () => {
it("is saved like a press on Senden and then offers no second save", async () => {
const { listPendingVoice } = await import("@/lib/voice/pending-voice");
const onSaved = vi.fn();
const user = userEvent.setup();
renderRecorder();
renderRecorder({ onSaved });
await startAndRecord(user);
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
@@ -370,15 +371,19 @@ describe("VoiceRecorder", () => {
expect(
await screen.findByText("Gespeichert. Wird gesendet, sobald Netz da ist."),
).toBeInTheDocument();
const { listPendingVoice } = await import("@/lib/voice/pending-voice");
expect(await listPendingVoice("user-1")).toHaveLength(1);
expect(syncPendingEntries).not.toHaveBeenCalled();
expect(syncPendingEntries).toHaveBeenCalledTimes(1);
expect(onSaved).toHaveBeenCalledWith({ kind: "queued" });
expect(screen.queryByRole("button", { name: /^(Senden|Speichern)$/ })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Noch eine Aufnahme" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Zu den Sprachnotizen" })).toHaveAttribute(
"href",
"/voice",
);
});
it("is only synced, not queued twice, when Senden is pressed", async () => {
const { listPendingVoice, discardAllPendingVoice } = await import(
"@/lib/voice/pending-voice"
);
it("says Gesendet when the sync took it at once", async () => {
const { discardAllPendingVoice } = await import("@/lib/voice/pending-voice");
syncPendingEntries.mockImplementation(async () => {
await discardAllPendingVoice("user-1");
return { synced: 1, failed: 0, skippedForeign: 0 };
@@ -386,30 +391,61 @@ describe("VoiceRecorder", () => {
const user = userEvent.setup();
renderRecorder();
await startAndRecord(user);
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
await screen.findByText(/^Gespeichert/);
await user.click(screen.getByRole("button", { name: "Senden" }));
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
expect(await screen.findByText(/^Gesendet/)).toBeInTheDocument();
expect(syncPendingEntries).toHaveBeenCalledTimes(1);
expect(await listPendingVoice("user-1")).toHaveLength(0);
});
it("is removed from the device when the user records anew", async () => {
it("stays offline on the device only and does not sync", async () => {
connectivity = "offline";
const user = userEvent.setup();
renderRecorder();
await startAndRecord(user);
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
await screen.findByText(/^Gespeichert/);
expect(syncPendingEntries).not.toHaveBeenCalled();
});
it("keeps the note on the device when the user records anew", async () => {
const { listPendingVoice } = await import("@/lib/voice/pending-voice");
const user = userEvent.setup();
renderRecorder();
await startAndRecord(user);
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
await screen.findByText(/^Gespeichert/);
await user.click(screen.getByRole("button", { name: "Neu aufnehmen" }));
const dialog = await screen.findByRole("alertdialog");
await user.click(within(dialog).getByRole("button", { name: "Neu aufnehmen" }));
await user.click(screen.getByRole("button", { name: "Noch eine Aufnahme" }));
await screen.findByRole("button", { name: "Aufnahme starten" });
const { listPendingVoice } = await import("@/lib/voice/pending-voice");
await waitFor(async () => expect(await listPendingVoice("user-1")).toHaveLength(0));
expect(await listPendingVoice("user-1")).toHaveLength(1);
});
});
it("shows the recorder's own duration, not the media element's", async () => {
const user = userEvent.setup();
renderRecorder();
await startAndRecord(user);
await user.click(screen.getByRole("button", { name: "Aufnahme beenden" }));
const heading = await screen.findByRole("heading", { name: /Aufnahme fertig · 0:42/ });
expect(heading).toBeInTheDocument();
const audio = document.querySelector("audio");
expect(audio).toHaveAttribute("aria-label", "Aufnahme anhören, Dauer 0:42");
});
describe("when the microphone cannot be used", () => {
it("moves focus to the help heading after a refused start", async () => {
startRecording.mockRejectedValue(new DOMException("denied", "NotAllowedError"));
const user = userEvent.setup();
renderRecorder();
await user.click(screen.getByRole("button", { name: "Aufnahme starten" }));
const heading = await screen.findByRole("heading", { name: "Das Mikrofon ist nicht erlaubt." });
await waitFor(() => expect(document.activeElement).toBe(heading));
});
});
@@ -52,6 +52,8 @@ export interface VoiceRecorderProps {
/** True while audio exists that is not on the device yet: the page locks colony and date. */
onBusyChange?(busy: boolean): void;
onSaved?(outcome: SaveVoiceOutcome): void;
/** The user cleared the saved result to record anew. */
onReset?(): void;
/** Rendered after saving, e.g. "Weiter mit B4". */
savedActions?: ReactNode;
}
@@ -71,6 +73,7 @@ export function VoiceRecorder({
blockedReason = null,
onBusyChange,
onSaved,
onReset,
savedActions,
}: VoiceRecorderProps) {
const userId = useCurrentUserId();
@@ -87,27 +90,40 @@ export function VoiceRecorder({
const resultHeading = useRef<HTMLHeadingElement>(null);
const mainButton = useRef<HTMLButtonElement>(null);
const focusMainWhenIdle = useRef(false);
// The help for a refused microphone takes the focus only after the user tried.
const [tried, setTried] = useState(false);
function afterQueueChange() {
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
}
/** A recording the limit ended must not depend on the user pressing "Speichern". */
function queueAtOnce(recording: Recording) {
/** Puts the recording on the device and, online, sends it - for Senden and for a limit stop alike. */
async function persist(finished: Recording) {
if (!userId) return;
saveVoiceNote({
setSaving(true);
setSaveError(null);
try {
const result = await saveVoiceNote({
userId,
recording,
recording: finished,
colonyId,
inspectionDate,
online: false,
online,
queuedNoteId,
onQueued: setQueuedNoteId,
}).then(afterQueueChange, (error: unknown) =>
setSaveError(error instanceof VoiceStorageFullError ? error.message : SAVE_FAILED_TEXT),
);
});
setOutcome(result);
afterQueueChange();
void queryClient.invalidateQueries({ queryKey: voiceKeys.all });
onSaved?.(result);
} catch (error) {
setSaveError(error instanceof VoiceStorageFullError ? error.message : SAVE_FAILED_TEXT);
} finally {
setSaving(false);
}
}
const recorder = useVoiceRecorder({ onAutoStop: queueAtOnce });
const recorder = useVoiceRecorder({ onAutoStop: (finished) => void persist(finished) });
const { phase, sec, recording } = recorder;
const unsaved =
@@ -145,6 +161,7 @@ export function VoiceRecorder({
function pressMainButton() {
if (phase === "starting") return;
setTried(true);
if (phase === "recording") {
recorder.stop();
return;
@@ -177,6 +194,7 @@ export function VoiceRecorder({
setSaveError(null);
setQueuedNoteId(null);
recorder.reset();
onReset?.();
}
/** The user gives this recording up: a note it already has on the device goes too. */
@@ -198,29 +216,8 @@ export function VoiceRecorder({
startOver();
}
async function save() {
if (!recording || !userId || saving) return;
setSaving(true);
setSaveError(null);
try {
const result = await saveVoiceNote({
userId,
recording,
colonyId,
inspectionDate,
online,
queuedNoteId,
onQueued: setQueuedNoteId,
});
setOutcome(result);
afterQueueChange();
void queryClient.invalidateQueries({ queryKey: voiceKeys.all });
onSaved?.(result);
} catch (error) {
setSaveError(error instanceof VoiceStorageFullError ? error.message : SAVE_FAILED_TEXT);
} finally {
setSaving(false);
}
function save() {
if (recording && !saving) void persist(recording);
}
const liveRegion = (
@@ -236,6 +233,7 @@ export function VoiceRecorder({
<MicrophoneHelp
reason={phase}
typeInsteadHref={typeInsteadHref}
focusOnShow={tried}
onRetry={phase === "unsupported" ? undefined : recorder.reset}
/>
</>
@@ -320,7 +318,7 @@ export function VoiceRecorder({
</p>
) : null}
{recording.url ? (
<audio controls src={recording.url} className="w-full" aria-label="Aufnahme anhören" />
<audio controls src={recording.url} className="w-full" aria-label={`Aufnahme anhören, Dauer ${formatClock(recording.durationSec)}`} />
) : null}
{saveError ? (
<p role="alert" className="text-sm text-destructive">
@@ -332,7 +330,7 @@ export function VoiceRecorder({
size="touch"
className="min-h-14 text-base"
disabled={saving || !userId}
onClick={() => void save()}
onClick={save}
>
{saving ? "Wird gespeichert…" : online ? "Senden" : "Speichern"}
</Button>
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { proxy } from "./proxy";
function csp(): string {
return proxy().headers.get("Content-Security-Policy") ?? "";
}
describe("proxy security headers", () => {
it("lets the page play its own voice recordings (blob:) and nothing broader", () => {
const directives = csp().split(";").map((part) => part.trim());
expect(directives).toContain("media-src 'self' blob:");
expect(directives.filter((part) => part.startsWith("media-src"))).toHaveLength(1);
});
it("keeps the default of the same origin only", () => {
expect(csp()).toContain("default-src 'self'");
});
});
+1
View File
@@ -37,6 +37,7 @@ function cspHeader(): string {
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data:;
media-src 'self' blob:;
font-src 'self';
connect-src 'self' ${apiOrigin()};
object-src 'none';