fix: Sprachnotizen nach Browserprüfung (Wiedergabe, Fokus, Warteschlange)
- 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:
1 parent
e0be687b7c
commit
92323197a7
16 files changed
+314
-119
No files matched your search
@@ -63,6 +63,9 @@ vi.mock("@/components/voice/voice-recorder", () => ({
|
|||||||
<button type="button" onClick={() => props.onBusyChange?.(true)}>
|
<button type="button" onClick={() => props.onBusyChange?.(true)}>
|
||||||
Sperren (Test)
|
Sperren (Test)
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" onClick={() => props.onReset?.()}>
|
||||||
|
Zurücksetzen (Test)
|
||||||
|
</button>
|
||||||
<button type="button" onClick={() => props.onSaved?.({ kind: "sent" })}>
|
<button type="button" onClick={() => props.onSaved?.({ kind: "sent" })}>
|
||||||
Aufnahme speichern (Test)
|
Aufnahme speichern (Test)
|
||||||
</button>
|
</button>
|
||||||
@@ -144,6 +147,28 @@ describe("NewVoiceNotePage", () => {
|
|||||||
expect(screen.getByRole("combobox", { name: /Volk/ })).toBeDisabled();
|
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 () => {
|
it("lists what to talk about", async () => {
|
||||||
query = new URLSearchParams({ colonyId: "c-1" });
|
query = new URLSearchParams({ colonyId: "c-1" });
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ function VoiceCaptureForm({ initialColonyId, initialDate, roundApiaryId }: Captu
|
|||||||
const [date, setDate] = useState(initialDate);
|
const [date, setDate] = useState(initialDate);
|
||||||
const [editing, setEditing] = useState(initialColonyId === "");
|
const [editing, setEditing] = useState(initialColonyId === "");
|
||||||
const [busy, setBusy] = useState(false);
|
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 [savedRound, setSavedRound] = useState<StandRound | null>(null);
|
||||||
const stand = useStandRound(roundApiaryId, colonyId);
|
const stand = useStandRound(roundApiaryId, colonyId);
|
||||||
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
|
const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
|
||||||
@@ -124,7 +127,7 @@ function VoiceCaptureForm({ initialColonyId, initialDate, roundApiaryId }: Captu
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-5 p-4 sm:p-6">
|
<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." />
|
<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">
|
<section aria-label="Volk und Datum" className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
@@ -134,7 +137,7 @@ function VoiceCaptureForm({ initialColonyId, initialDate, roundApiaryId }: Captu
|
|||||||
size="touch"
|
size="touch"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="min-h-14"
|
className="min-h-14"
|
||||||
disabled={busy}
|
disabled={locked}
|
||||||
aria-expanded={editing}
|
aria-expanded={editing}
|
||||||
onClick={() => setEditing((open) => !open)}
|
onClick={() => setEditing((open) => !open)}
|
||||||
>
|
>
|
||||||
@@ -144,7 +147,7 @@ function VoiceCaptureForm({ initialColonyId, initialDate, roundApiaryId }: Captu
|
|||||||
{editing ? (
|
{editing ? (
|
||||||
// Colony and date are fixed while audio is being recorded or waits to be saved.
|
// Colony and date are fixed while audio is being recorded or waits to be saved.
|
||||||
<fieldset
|
<fieldset
|
||||||
disabled={busy}
|
disabled={locked}
|
||||||
className="flex min-w-0 flex-col gap-3 rounded-2xl border border-border bg-card p-4"
|
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>
|
<legend className="sr-only">Volk und Datum ändern</legend>
|
||||||
@@ -177,7 +180,9 @@ function VoiceCaptureForm({ initialColonyId, initialDate, roundApiaryId }: Captu
|
|||||||
typeInsteadHref={inspectionHref(colonyId, roundApiaryId)}
|
typeInsteadHref={inspectionHref(colonyId, roundApiaryId)}
|
||||||
blockedReason={colonyId ? null : NO_COLONY_REASON}
|
blockedReason={colonyId ? null : NO_COLONY_REASON}
|
||||||
onBusyChange={setBusy}
|
onBusyChange={setBusy}
|
||||||
|
onReset={() => setSaved(false)}
|
||||||
onSaved={(outcome) => {
|
onSaved={(outcome) => {
|
||||||
|
setSaved(true);
|
||||||
if (outcome.kind !== "rejected" && stand.round) {
|
if (outcome.kind !== "rejected" && stand.round) {
|
||||||
setSavedRound(stand.markDone(colonyId));
|
setSavedRound(stand.markDone(colonyId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,12 +299,37 @@ describe("VoiceListPage", () => {
|
|||||||
|
|
||||||
expect(await screen.findByText(/Du bist offline/)).toBeInTheDocument();
|
expect(await screen.findByText(/Du bist offline/)).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/konnten nicht geladen werden/)).not.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 {
|
} finally {
|
||||||
onlineManager.setOnline(true);
|
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 () => {
|
it("reads a transcribed note as Text liegt vor when the evaluation is off", async () => {
|
||||||
vi.mocked(fetchVoiceConfig).mockResolvedValue({ ...CONFIG, extractionEnabled: false });
|
vi.mocked(fetchVoiceConfig).mockResolvedValue({ ...CONFIG, extractionEnabled: false });
|
||||||
vi.mocked(fetchVoiceCaptures).mockResolvedValue([item({ status: "TRANSCRIBED" })]);
|
vi.mocked(fetchVoiceCaptures).mockResolvedValue([item({ status: "TRANSCRIBED" })]);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useRef } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Mic } from "lucide-react";
|
import { Mic } from "lucide-react";
|
||||||
@@ -111,6 +112,10 @@ function VoiceNotes() {
|
|||||||
});
|
});
|
||||||
const coloniesQuery = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
|
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({
|
const retry = useMutation({
|
||||||
mutationFn: (id: string) => retryVoiceCapture(id),
|
mutationFn: (id: string) => retryVoiceCapture(id),
|
||||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: voiceKeys.all }),
|
onSuccess: () => void queryClient.invalidateQueries({ queryKey: voiceKeys.all }),
|
||||||
@@ -118,12 +123,18 @@ function VoiceNotes() {
|
|||||||
});
|
});
|
||||||
const discard = useMutation({
|
const discard = useMutation({
|
||||||
mutationFn: (id: string) => discardVoiceCapture(id),
|
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."),
|
onError: () => toast.error("Die Sprachnotiz konnte nicht verworfen werden."),
|
||||||
});
|
});
|
||||||
const discardLocal = useMutation({
|
const discardLocal = useMutation({
|
||||||
mutationFn: (id: string) => discardPendingVoice(id, userId ?? ""),
|
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."),
|
onError: () => toast.error("Die Sprachnotiz konnte nicht verworfen werden."),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,66 +145,60 @@ function VoiceNotes() {
|
|||||||
isListedVoiceStatus(item.status),
|
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.
|
// No answer because there is no network: not an error, the notes appear with the network.
|
||||||
const serverOffline =
|
const serverOffline =
|
||||||
serverQuery.data === undefined &&
|
serverQuery.data === undefined &&
|
||||||
(serverQuery.fetchStatus === "paused" || isNetworkError(serverQuery.error));
|
(serverQuery.fetchStatus === "paused" || isNetworkError(serverQuery.error));
|
||||||
const serverFailed = serverQuery.data === undefined && !serverQuery.isLoading && !serverOffline;
|
const serverFailed = serverQuery.data === undefined && !serverQuery.isLoading && !serverOffline;
|
||||||
if (local.length === 0 && listed.length === 0) {
|
const isLoading = serverQuery.isLoading && local.length === 0;
|
||||||
if (serverOffline) {
|
const isEmpty = local.length === 0 && listed.length === 0;
|
||||||
return <OfflineNote />;
|
|
||||||
}
|
|
||||||
return serverFailed ? (
|
|
||||||
<ErrorState
|
|
||||||
what="Sprachnotizen"
|
|
||||||
onRetry={() => void serverQuery.refetch()}
|
|
||||||
isRetrying={serverQuery.isFetching}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<EmptyState
|
|
||||||
icon={Mic}
|
|
||||||
title="Noch keine Sprachnotizen."
|
|
||||||
description="Nimm beim Volk eine Sprachnotiz auf. Sie erscheint hier, bis du sie geprüft hast."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{serverOffline ? <OfflineNote /> : null}
|
<h2 ref={listHeading} tabIndex={-1} className="sr-only">
|
||||||
{serverFailed ? (
|
Liste der Sprachnotizen
|
||||||
|
</h2>
|
||||||
|
{isLoading ? <LoadingState what="Sprachnotizen" /> : null}
|
||||||
|
{!isLoading && serverOffline ? <OfflineNote /> : null}
|
||||||
|
{!isLoading && serverFailed ? (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
what="Sprachnotizen vom Server"
|
what={isEmpty ? "Sprachnotizen" : "Sprachnotizen vom Server"}
|
||||||
onRetry={() => void serverQuery.refetch()}
|
onRetry={() => void serverQuery.refetch()}
|
||||||
isRetrying={serverQuery.isFetching}
|
isRetrying={serverQuery.isFetching}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<ul className="flex flex-col gap-3" aria-label="Sprachnotizen">
|
{!isLoading && isEmpty && !serverFailed ? (
|
||||||
{local.map((note) => (
|
<EmptyState
|
||||||
<li key={note.id}>
|
icon={Mic}
|
||||||
<LocalVoiceCard
|
title="Noch keine Sprachnotizen."
|
||||||
note={note}
|
description="Nimm beim Volk eine Sprachnotiz auf. Sie erscheint hier, bis du sie geprüft hast."
|
||||||
colonyName={colonyName(note.colonyId)}
|
/>
|
||||||
online={online}
|
) : null}
|
||||||
onDiscard={() => discardLocal.mutate(note.id)}
|
{isEmpty ? null : (
|
||||||
/>
|
<ul className="flex flex-col gap-3" aria-label="Sprachnotizen">
|
||||||
</li>
|
{local.map((note) => (
|
||||||
))}
|
<li key={note.id}>
|
||||||
{listed.map((item) => (
|
<LocalVoiceCard
|
||||||
<li key={item.id}>
|
note={note}
|
||||||
<VoiceCaptureCard
|
colonyName={colonyName(note.colonyId)}
|
||||||
item={item}
|
online={online}
|
||||||
extractionEnabled={extractionEnabled}
|
onDiscard={() => discardLocal.mutate(note.id)}
|
||||||
busy={retry.isPending || discard.isPending}
|
/>
|
||||||
onRetry={() => retry.mutate(item.id)}
|
</li>
|
||||||
onDiscard={() => discard.mutate(item.id)}
|
))}
|
||||||
/>
|
{listed.map((item) => (
|
||||||
</li>
|
<li key={item.id}>
|
||||||
))}
|
<VoiceCaptureCard
|
||||||
</ul>
|
item={item}
|
||||||
|
extractionEnabled={extractionEnabled}
|
||||||
|
busy={retry.isPending || discard.isPending}
|
||||||
|
onRetry={() => retry.mutate(item.id)}
|
||||||
|
onDiscard={() => discard.mutate(item.id)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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 userEvent from "@testing-library/user-event";
|
||||||
import { reportRequestOutcome } from "@/lib/offline/connectivity";
|
import { reportRequestOutcome } from "@/lib/offline/connectivity";
|
||||||
import { summarizeQueue } from "@/lib/offline/db";
|
import { summarizeQueue } from "@/lib/offline/db";
|
||||||
@@ -66,6 +66,18 @@ describe("LogoutConfirmDialog", () => {
|
|||||||
expect(screen.getByRole("button", { name: "Abbrechen" })).toBeVisible();
|
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 () => {
|
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 });
|
vi.mocked(summarizeQueue).mockResolvedValue({ own: 3, failed: 0, foreign: 0, voice: 2 });
|
||||||
|
|
||||||
|
|||||||
@@ -152,13 +152,20 @@ export function LogoutConfirmDialog({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="touch"
|
size="touch"
|
||||||
|
className="min-h-14"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
>
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</Button>
|
</Button>
|
||||||
{hasEntries && isOnline ? (
|
{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
|
Erst senden
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -166,6 +173,7 @@ export function LogoutConfirmDialog({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="touch"
|
size="touch"
|
||||||
|
className="min-h-14"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onClick={() => void signOut(hasEntries)}
|
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 () => {
|
it("asks before leaving and flushes the draft", async () => {
|
||||||
const flush = vi.fn();
|
const flush = vi.fn();
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -21,6 +22,8 @@ export interface FormBackLinkProps {
|
|||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
/** The form's draft; flushed so leaving does not race the autosave delay. */
|
/** The form's draft; flushed so leaving does not race the autosave delay. */
|
||||||
draft?: { flush: () => void };
|
draft?: { flush: () => void };
|
||||||
|
/** Extra classes, e.g. a bigger target on screens used with gloves. */
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NOT_PLAIN_CLICK = (event: MouseEvent) =>
|
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
|
* untouched; once something was typed it asks first, saying honestly whether
|
||||||
* the input stays as a draft on this device or is lost.
|
* 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 router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
@@ -53,7 +56,10 @@ export function FormBackLink({ href, label, dirty, draft }: FormBackLinkProps) {
|
|||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
onClick={handleClick}
|
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" />
|
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -143,6 +143,25 @@ describe("OfflineBand", () => {
|
|||||||
expect(screen.getByRole("status")).toHaveTextContent("davon 1 Sprachnotiz");
|
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 () => {
|
it("says „1 Eintrag wartet\" for a single entry", async () => {
|
||||||
setOnLine(false);
|
setOnLine(false);
|
||||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
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 />
|
<AlertTriangle aria-hidden />
|
||||||
{`${entriesRejected(queue.failed)} – ansehen`}
|
{`${entriesRejected(queue.failed)} – ansehen`}
|
||||||
</SheetTrigger>
|
</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
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ function DialogContent({
|
|||||||
render={
|
render={
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="absolute top-2 right-2"
|
className="absolute top-1 right-1 size-11"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { MicOff } from "lucide-react";
|
import { MicOff } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -20,6 +21,8 @@ const STEPS: readonly { browser: string; text: string }[] = [
|
|||||||
interface MicrophoneHelpProps {
|
interface MicrophoneHelpProps {
|
||||||
reason: "denied" | "no-microphone" | "unsupported";
|
reason: "denied" | "no-microphone" | "unsupported";
|
||||||
typeInsteadHref: string;
|
typeInsteadHref: string;
|
||||||
|
/** The user just tried to record: the heading takes the focus. */
|
||||||
|
focusOnShow?: boolean;
|
||||||
onRetry?(): void;
|
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. */
|
/** 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 (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-labelledby="voice-mic-help"
|
aria-labelledby="voice-mic-help"
|
||||||
className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4"
|
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 />
|
<MicOff className="size-5" aria-hidden />
|
||||||
{TITLES[reason]}
|
{TITLES[reason]}
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -139,8 +139,7 @@ describe("VoiceRecorder", () => {
|
|||||||
|
|
||||||
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
|
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
|
||||||
|
|
||||||
expect(await screen.findByRole("heading", { name: /Aufnahme fertig · 3:00/ })).toBeInTheDocument();
|
expect(await screen.findByText(/^Gespeichert/)).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Senden" })).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the denied-state with a way to type instead", async () => {
|
it("shows the denied-state with a way to type instead", async () => {
|
||||||
@@ -360,9 +359,11 @@ describe("VoiceRecorder", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("a recording the limit stopped", () => {
|
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();
|
const user = userEvent.setup();
|
||||||
renderRecorder();
|
renderRecorder({ onSaved });
|
||||||
await startAndRecord(user);
|
await startAndRecord(user);
|
||||||
|
|
||||||
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
|
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
|
||||||
@@ -370,15 +371,19 @@ describe("VoiceRecorder", () => {
|
|||||||
expect(
|
expect(
|
||||||
await screen.findByText("Gespeichert. Wird gesendet, sobald Netz da ist."),
|
await screen.findByText("Gespeichert. Wird gesendet, sobald Netz da ist."),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
const { listPendingVoice } = await import("@/lib/voice/pending-voice");
|
|
||||||
expect(await listPendingVoice("user-1")).toHaveLength(1);
|
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 () => {
|
it("says Gesendet when the sync took it at once", async () => {
|
||||||
const { listPendingVoice, discardAllPendingVoice } = await import(
|
const { discardAllPendingVoice } = await import("@/lib/voice/pending-voice");
|
||||||
"@/lib/voice/pending-voice"
|
|
||||||
);
|
|
||||||
syncPendingEntries.mockImplementation(async () => {
|
syncPendingEntries.mockImplementation(async () => {
|
||||||
await discardAllPendingVoice("user-1");
|
await discardAllPendingVoice("user-1");
|
||||||
return { synced: 1, failed: 0, skippedForeign: 0 };
|
return { synced: 1, failed: 0, skippedForeign: 0 };
|
||||||
@@ -386,30 +391,61 @@ describe("VoiceRecorder", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderRecorder();
|
renderRecorder();
|
||||||
await startAndRecord(user);
|
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(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();
|
const user = userEvent.setup();
|
||||||
renderRecorder();
|
renderRecorder();
|
||||||
await startAndRecord(user);
|
await startAndRecord(user);
|
||||||
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
|
act(() => options.onAutoStop({ ...RECORDING, durationSec: 180 }));
|
||||||
await screen.findByText(/^Gespeichert/);
|
await screen.findByText(/^Gespeichert/);
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Neu aufnehmen" }));
|
await user.click(screen.getByRole("button", { name: "Noch eine Aufnahme" }));
|
||||||
const dialog = await screen.findByRole("alertdialog");
|
|
||||||
await user.click(within(dialog).getByRole("button", { name: "Neu aufnehmen" }));
|
|
||||||
|
|
||||||
await screen.findByRole("button", { name: "Aufnahme starten" });
|
await screen.findByRole("button", { name: "Aufnahme starten" });
|
||||||
const { listPendingVoice } = await import("@/lib/voice/pending-voice");
|
expect(await listPendingVoice("user-1")).toHaveLength(1);
|
||||||
await waitFor(async () => expect(await listPendingVoice("user-1")).toHaveLength(0));
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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. */
|
/** True while audio exists that is not on the device yet: the page locks colony and date. */
|
||||||
onBusyChange?(busy: boolean): void;
|
onBusyChange?(busy: boolean): void;
|
||||||
onSaved?(outcome: SaveVoiceOutcome): void;
|
onSaved?(outcome: SaveVoiceOutcome): void;
|
||||||
|
/** The user cleared the saved result to record anew. */
|
||||||
|
onReset?(): void;
|
||||||
/** Rendered after saving, e.g. "Weiter mit B4". */
|
/** Rendered after saving, e.g. "Weiter mit B4". */
|
||||||
savedActions?: ReactNode;
|
savedActions?: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -71,6 +73,7 @@ export function VoiceRecorder({
|
|||||||
blockedReason = null,
|
blockedReason = null,
|
||||||
onBusyChange,
|
onBusyChange,
|
||||||
onSaved,
|
onSaved,
|
||||||
|
onReset,
|
||||||
savedActions,
|
savedActions,
|
||||||
}: VoiceRecorderProps) {
|
}: VoiceRecorderProps) {
|
||||||
const userId = useCurrentUserId();
|
const userId = useCurrentUserId();
|
||||||
@@ -87,27 +90,40 @@ export function VoiceRecorder({
|
|||||||
const resultHeading = useRef<HTMLHeadingElement>(null);
|
const resultHeading = useRef<HTMLHeadingElement>(null);
|
||||||
const mainButton = useRef<HTMLButtonElement>(null);
|
const mainButton = useRef<HTMLButtonElement>(null);
|
||||||
const focusMainWhenIdle = useRef(false);
|
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() {
|
function afterQueueChange() {
|
||||||
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
|
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A recording the limit ended must not depend on the user pressing "Speichern". */
|
/** Puts the recording on the device and, online, sends it - for Senden and for a limit stop alike. */
|
||||||
function queueAtOnce(recording: Recording) {
|
async function persist(finished: Recording) {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
saveVoiceNote({
|
setSaving(true);
|
||||||
userId,
|
setSaveError(null);
|
||||||
recording,
|
try {
|
||||||
colonyId,
|
const result = await saveVoiceNote({
|
||||||
inspectionDate,
|
userId,
|
||||||
online: false,
|
recording: finished,
|
||||||
onQueued: setQueuedNoteId,
|
colonyId,
|
||||||
}).then(afterQueueChange, (error: unknown) =>
|
inspectionDate,
|
||||||
setSaveError(error instanceof VoiceStorageFullError ? error.message : SAVE_FAILED_TEXT),
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const recorder = useVoiceRecorder({ onAutoStop: queueAtOnce });
|
const recorder = useVoiceRecorder({ onAutoStop: (finished) => void persist(finished) });
|
||||||
const { phase, sec, recording } = recorder;
|
const { phase, sec, recording } = recorder;
|
||||||
|
|
||||||
const unsaved =
|
const unsaved =
|
||||||
@@ -145,6 +161,7 @@ export function VoiceRecorder({
|
|||||||
|
|
||||||
function pressMainButton() {
|
function pressMainButton() {
|
||||||
if (phase === "starting") return;
|
if (phase === "starting") return;
|
||||||
|
setTried(true);
|
||||||
if (phase === "recording") {
|
if (phase === "recording") {
|
||||||
recorder.stop();
|
recorder.stop();
|
||||||
return;
|
return;
|
||||||
@@ -177,6 +194,7 @@ export function VoiceRecorder({
|
|||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
setQueuedNoteId(null);
|
setQueuedNoteId(null);
|
||||||
recorder.reset();
|
recorder.reset();
|
||||||
|
onReset?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The user gives this recording up: a note it already has on the device goes too. */
|
/** The user gives this recording up: a note it already has on the device goes too. */
|
||||||
@@ -198,29 +216,8 @@ export function VoiceRecorder({
|
|||||||
startOver();
|
startOver();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
function save() {
|
||||||
if (!recording || !userId || saving) return;
|
if (recording && !saving) void persist(recording);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const liveRegion = (
|
const liveRegion = (
|
||||||
@@ -236,6 +233,7 @@ export function VoiceRecorder({
|
|||||||
<MicrophoneHelp
|
<MicrophoneHelp
|
||||||
reason={phase}
|
reason={phase}
|
||||||
typeInsteadHref={typeInsteadHref}
|
typeInsteadHref={typeInsteadHref}
|
||||||
|
focusOnShow={tried}
|
||||||
onRetry={phase === "unsupported" ? undefined : recorder.reset}
|
onRetry={phase === "unsupported" ? undefined : recorder.reset}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -320,7 +318,7 @@ export function VoiceRecorder({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{recording.url ? (
|
{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}
|
) : null}
|
||||||
{saveError ? (
|
{saveError ? (
|
||||||
<p role="alert" className="text-sm text-destructive">
|
<p role="alert" className="text-sm text-destructive">
|
||||||
@@ -332,7 +330,7 @@ export function VoiceRecorder({
|
|||||||
size="touch"
|
size="touch"
|
||||||
className="min-h-14 text-base"
|
className="min-h-14 text-base"
|
||||||
disabled={saving || !userId}
|
disabled={saving || !userId}
|
||||||
onClick={() => void save()}
|
onClick={save}
|
||||||
>
|
>
|
||||||
{saving ? "Wird gespeichert…" : online ? "Senden" : "Speichern"}
|
{saving ? "Wird gespeichert…" : online ? "Senden" : "Speichern"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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'");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,6 +37,7 @@ function cspHeader(): string {
|
|||||||
script-src 'self' 'unsafe-inline';
|
script-src 'self' 'unsafe-inline';
|
||||||
style-src 'self' 'unsafe-inline';
|
style-src 'self' 'unsafe-inline';
|
||||||
img-src 'self' blob: data:;
|
img-src 'self' blob: data:;
|
||||||
|
media-src 'self' blob:;
|
||||||
font-src 'self';
|
font-src 'self';
|
||||||
connect-src 'self' ${apiOrigin()};
|
connect-src 'self' ${apiOrigin()};
|
||||||
object-src 'none';
|
object-src 'none';
|
||||||
|
|||||||
Reference in new issue
Block a user