feat: Sprachnotizen in Synchronisierung, Warteschlange und Abmelden (T7 L5 Task 18)
Wartende Sprachnotizen werden nach Einträgen und Fotos hochgeladen, je Notiz ein fester Idempotency-Key; endgültige 4xx markieren die Notiz mit festem deutschem Text, 401/408/429/5xx/Netz warten auf den nächsten Lauf, „wird bearbeitet“ wird übersprungen. Warteschlangen-Sheet mit Abschnitt „Sprachnotizen“ (Verwerfen je Notiz nur für den Besitzer, alle verwerfen mit Rückfrage, Hinweis bei zu vielen offenen Notizen), Offline-Band zählt nur wartende Notizen, Abmelden fragt bei wartenden Notizen nach. Zählen liest keine Audiodaten. Gates: Frontend lint, typecheck, 2886 Tests. Refs #27 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
0c7147f626
commit
e614ded967
18 files changed
+1201
-30
No files matched your search
@@ -66,6 +66,32 @@ describe("LogoutConfirmDialog", () => {
|
||||
expect(screen.getByRole("button", { name: "Abbrechen" })).toBeVisible();
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
renderDialog();
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
|
||||
expect(dialog).toHaveTextContent("3 Einträge sind noch nicht gesendet.");
|
||||
expect(dialog).toHaveTextContent("Darunter 2 Sprachnotizen");
|
||||
expect(dialog).toHaveTextContent("nicht wiederherstellen");
|
||||
expect(logoutMock).not.toHaveBeenCalled();
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Abmelden und Einträge löschen" }),
|
||||
);
|
||||
await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: true }));
|
||||
});
|
||||
|
||||
it("does not mention recordings when there are none", async () => {
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
||||
|
||||
renderDialog();
|
||||
|
||||
expect(await screen.findByRole("dialog", { name: "Abmelden?" })).not.toHaveTextContent(
|
||||
"Sprachnotiz",
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes the entries only after the destructive button was pressed", async () => {
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
||||
renderDialog();
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { useConnectivity } from "@/lib/offline/connectivity";
|
||||
import { summarizeQueue } from "@/lib/offline/db";
|
||||
import { entriesNotSent } from "@/lib/offline/queue-texts";
|
||||
import { entriesNotSent, voiceNotesLostOnLogout } from "@/lib/offline/queue-texts";
|
||||
import { syncPendingEntries } from "@/lib/offline/sync";
|
||||
|
||||
interface LogoutConfirmDialogProps {
|
||||
@@ -24,14 +24,20 @@ interface LogoutConfirmDialogProps {
|
||||
onLoggedOut: () => void;
|
||||
}
|
||||
|
||||
interface Unsent {
|
||||
entries: number;
|
||||
/** Of the entries: the voice recordings, which cannot be recreated. */
|
||||
voice: number;
|
||||
}
|
||||
|
||||
/** Waiting and rejected entries of the person; owner-less legacy ones count as theirs. */
|
||||
async function countUnsent(userId: string): Promise<number> {
|
||||
async function countUnsent(userId: string): Promise<Unsent> {
|
||||
try {
|
||||
const { own, failed } = await summarizeQueue(userId);
|
||||
return own + failed;
|
||||
const { own, failed, voice = 0 } = await summarizeQueue(userId);
|
||||
return { entries: own + failed, voice };
|
||||
} catch {
|
||||
// The queue cannot be read, so it cannot be lost either.
|
||||
return 0;
|
||||
return { entries: 0, voice: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +55,7 @@ export function LogoutConfirmDialog({
|
||||
}: LogoutConfirmDialogProps) {
|
||||
const { logout, sessionMode } = useAuth();
|
||||
const connectivity = useConnectivity();
|
||||
const [unsent, setUnsent] = useState<number | null>(null);
|
||||
const [unsent, setUnsent] = useState<Unsent | null>(null);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [wasOpen, setWasOpen] = useState(open);
|
||||
@@ -81,14 +87,14 @@ export function LogoutConfirmDialog({
|
||||
return;
|
||||
}
|
||||
let isCurrent = true;
|
||||
void countUnsent(userId).then((count) => {
|
||||
void countUnsent(userId).then((counted) => {
|
||||
if (!isCurrent) {
|
||||
return;
|
||||
}
|
||||
if (count === 0) {
|
||||
if (counted.entries === 0) {
|
||||
void signOut(false);
|
||||
} else {
|
||||
setUnsent(count);
|
||||
setUnsent(counted);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
@@ -107,7 +113,7 @@ export function LogoutConfirmDialog({
|
||||
// Whatever could not be sent is counted below.
|
||||
}
|
||||
const remaining = await countUnsent(userId);
|
||||
if (remaining === 0) {
|
||||
if (remaining.entries === 0) {
|
||||
await signOut(false);
|
||||
return;
|
||||
}
|
||||
@@ -117,7 +123,7 @@ export function LogoutConfirmDialog({
|
||||
|
||||
// An offline session has no token: nothing can be sent, whatever the network says.
|
||||
const isOnline = connectivity === "online" && sessionMode !== "offline";
|
||||
const hasEntries = unsent !== null && unsent > 0;
|
||||
const hasEntries = unsent !== null && unsent.entries > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open && (hasEntries || error !== null)} onOpenChange={onOpenChange}>
|
||||
@@ -126,7 +132,9 @@ export function LogoutConfirmDialog({
|
||||
<DialogTitle>Abmelden?</DialogTitle>
|
||||
<DialogDescription>
|
||||
{hasEntries
|
||||
? `${entriesNotSent(unsent)} ${
|
||||
? `${entriesNotSent(unsent.entries)} ${
|
||||
unsent.voice > 0 ? `${voiceNotesLostOnLogout(unsent.voice)} ` : ""
|
||||
}${
|
||||
isOnline
|
||||
? "Sende sie zuerst, oder melde dich ab und lösche sie."
|
||||
: "Ohne Netz kannst du sie nicht senden. Beim Abmelden werden sie gelöscht."
|
||||
|
||||
@@ -99,6 +99,50 @@ describe("OfflineBand", () => {
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("counts voice notes in the waiting entries", async () => {
|
||||
setOnLine(false);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 3, failed: 0, foreign: 0, voice: 2, voiceWaiting: 2 });
|
||||
|
||||
renderBand();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"Offline – 3 Einträge warten (davon 2 Sprachnotizen)",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not count a rejected voice note as waiting", async () => {
|
||||
setOnLine(false);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({
|
||||
own: 1,
|
||||
failed: 1,
|
||||
foreign: 0,
|
||||
voice: 2,
|
||||
voiceWaiting: 1,
|
||||
});
|
||||
|
||||
renderBand();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"Offline – 1 Eintrag wartet (davon 1 Sprachnotiz)",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("names the voice notes next to „Jetzt senden\" when online", async () => {
|
||||
setOnLine(true);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0, voice: 1, voiceWaiting: 1 });
|
||||
|
||||
renderBand();
|
||||
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "2 Einträge warten – Jetzt senden" }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("davon 1 Sprachnotiz");
|
||||
});
|
||||
|
||||
it("says „1 Eintrag wartet\" for a single entry", async () => {
|
||||
setOnLine(false);
|
||||
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
|
||||
|
||||
@@ -9,7 +9,12 @@ 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 } from "@/lib/offline/queue-texts";
|
||||
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";
|
||||
@@ -111,6 +116,8 @@ export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: Offl
|
||||
|
||||
// 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
|
||||
@@ -140,7 +147,7 @@ export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: Offl
|
||||
<p className="flex items-center gap-2 font-medium">
|
||||
<CloudOff className="size-4 shrink-0" aria-hidden />
|
||||
{queue.own > 0
|
||||
? `Offline – ${entriesWaiting(queue.own)}`
|
||||
? `Offline – ${entriesWaiting(queue.own)}${voiceSuffix}`
|
||||
: "Offline – Erfassungen werden gespeichert und später gesendet. Vorschläge und Aufgaben brauchen eine Verbindung."}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -157,6 +164,9 @@ export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: Offl
|
||||
{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}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type StoredPhoto,
|
||||
} from "@/lib/offline/db";
|
||||
import { syncPendingEntries } from "@/lib/offline/sync";
|
||||
import { discardAllPendingVoice, discardPendingVoice } from "@/lib/voice/pending-voice";
|
||||
import { discardReadyPhoto, listPendingPhotos, resetPhotoError } from "@/lib/photos/store";
|
||||
import { PendingQueueSheet } from "./pending-queue-sheet";
|
||||
|
||||
@@ -20,6 +21,10 @@ vi.mock("@/lib/offline/db", () => ({
|
||||
retryBulkWithout: vi.fn(),
|
||||
retryInspectionWithoutHiveChange: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/voice/pending-voice", () => ({
|
||||
discardPendingVoice: vi.fn(),
|
||||
discardAllPendingVoice: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
|
||||
vi.mock("@/lib/photos/store", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/photos/store")>()),
|
||||
@@ -97,6 +102,8 @@ describe("PendingQueueSheet", () => {
|
||||
vi.mocked(retryBulkWithout).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(retryInspectionWithoutHiveChange).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(listPendingPhotos).mockReset().mockResolvedValue([]);
|
||||
vi.mocked(discardPendingVoice).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(discardAllPendingVoice).mockReset().mockResolvedValue(2);
|
||||
vi.mocked(discardReadyPhoto).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(resetPhotoError).mockReset().mockResolvedValue(undefined);
|
||||
vi.mocked(syncPendingEntries)
|
||||
@@ -337,4 +344,147 @@ describe("PendingQueueSheet", () => {
|
||||
expect(await within(sheet).findByText(text)).toBeInTheDocument();
|
||||
expect(within(sheet).queryByRole("button", { name: /Verwerfen/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("voice notes", () => {
|
||||
const VOICE_WAITING: QueueItem = {
|
||||
store: "pending-voice",
|
||||
id: "voice-1",
|
||||
label: "Sprachnotiz",
|
||||
queuedAt: "2026-10-16T08:00:00.000Z",
|
||||
voice: { colonyId: "colony-1", inspectionDate: "2026-10-16", durationSec: 75 },
|
||||
};
|
||||
const VOICE_REJECTED: QueueItem = {
|
||||
...VOICE_WAITING,
|
||||
id: "voice-2",
|
||||
queuedAt: "2026-10-16T08:05:00.000Z",
|
||||
lastError: {
|
||||
status: 415,
|
||||
message: "Unsupported",
|
||||
details: { code: "UNSUPPORTED_AUDIO" },
|
||||
at: "2026-10-16T08:06:00.000Z",
|
||||
},
|
||||
};
|
||||
|
||||
it("shows a voice note with Erneut aufnehmen and Verwerfen", async () => {
|
||||
vi.mocked(listQueue).mockResolvedValue([VOICE_REJECTED]);
|
||||
renderSheet();
|
||||
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
|
||||
|
||||
expect(await within(sheet).findByText("Sprachnotiz")).toBeVisible();
|
||||
expect(within(sheet).getByText(/Dauer 1:15/)).toBeVisible();
|
||||
expect(
|
||||
within(sheet).getByText(/Dieses Audioformat wird nicht unterstützt/),
|
||||
).toBeVisible();
|
||||
const again = within(sheet).getByRole("link", { name: /^Erneut aufnehmen/ });
|
||||
expect(again).toHaveAttribute("href", "/voice/new?colonyId=colony-1");
|
||||
expect(within(sheet).getByRole("button", { name: /^Verwerfen: Sprachnotiz vom/ })).toBeVisible();
|
||||
});
|
||||
|
||||
it("offers Erneut aufnehmen only for a rejected note", async () => {
|
||||
vi.mocked(listQueue).mockResolvedValue([VOICE_WAITING]);
|
||||
renderSheet();
|
||||
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
|
||||
|
||||
expect(await within(sheet).findByText("Wartet auf Senden")).toBeVisible();
|
||||
expect(within(sheet).queryByRole("link", { name: /Erneut aufnehmen/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("discards one note owner-scoped after confirmation", async () => {
|
||||
vi.mocked(listQueue).mockResolvedValue([VOICE_WAITING]);
|
||||
renderSheet();
|
||||
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
|
||||
|
||||
await userEvent.click(
|
||||
await within(sheet).findByRole("button", { name: /^Verwerfen: Sprachnotiz vom/ }),
|
||||
);
|
||||
await userEvent.click(
|
||||
within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Verwerfen" }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(discardPendingVoice).toHaveBeenCalledWith("voice-1", "user-1"));
|
||||
expect(discardQueueItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards all recordings of the user at once", async () => {
|
||||
vi.mocked(listQueue).mockResolvedValue([VOICE_WAITING, VOICE_REJECTED]);
|
||||
renderSheet();
|
||||
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
|
||||
|
||||
await userEvent.click(
|
||||
await within(sheet).findByRole("button", { name: "Alle Sprachnotizen verwerfen" }),
|
||||
);
|
||||
await userEvent.click(
|
||||
within(await screen.findByRole("alertdialog")).getByRole("button", {
|
||||
name: "Alle verwerfen",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(discardAllPendingVoice).toHaveBeenCalledWith("user-1"));
|
||||
});
|
||||
|
||||
it("does not delete the refused note on a modified click", async () => {
|
||||
vi.mocked(listQueue).mockResolvedValue([VOICE_REJECTED]);
|
||||
renderSheet();
|
||||
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
|
||||
const link = await within(sheet).findByRole("link", { name: /^Erneut aufnehmen/ });
|
||||
document.addEventListener("click", (event) => event.preventDefault(), { once: true, capture: false });
|
||||
|
||||
fireEvent.click(link, { ctrlKey: true });
|
||||
fireEvent.click(link, { metaKey: true });
|
||||
fireEvent.click(link, { shiftKey: true });
|
||||
fireEvent.click(link, { button: 1 });
|
||||
|
||||
expect(discardPendingVoice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes the refused note on a plain click", async () => {
|
||||
vi.mocked(listQueue).mockResolvedValue([VOICE_REJECTED]);
|
||||
renderSheet();
|
||||
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
|
||||
const link = await within(sheet).findByRole("link", { name: /^Erneut aufnehmen/ });
|
||||
document.addEventListener("click", (event) => event.preventDefault(), { once: true, capture: false });
|
||||
|
||||
await userEvent.click(link);
|
||||
|
||||
await waitFor(() => expect(discardPendingVoice).toHaveBeenCalledWith("voice-2", "user-1"));
|
||||
});
|
||||
|
||||
it("moves focus to the Sprachnotizen heading after a single discard", async () => {
|
||||
vi.mocked(listQueue).mockResolvedValue([VOICE_WAITING, VOICE_REJECTED]);
|
||||
renderSheet();
|
||||
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
|
||||
|
||||
await userEvent.click(
|
||||
(await within(sheet).findAllByRole("button", { name: /^Verwerfen: Sprachnotiz vom/ }))[0],
|
||||
);
|
||||
await userEvent.click(
|
||||
within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Verwerfen" }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(within(sheet).getByRole("heading", { name: "Sprachnotizen" })).toHaveFocus(),
|
||||
);
|
||||
});
|
||||
|
||||
it("tells without a toast that too many open notes will be sent later", async () => {
|
||||
vi.mocked(listQueue).mockResolvedValue([
|
||||
{ ...VOICE_WAITING, voice: { ...VOICE_WAITING.voice!, blockedBy: "TOO_MANY_OPEN" } },
|
||||
]);
|
||||
renderSheet();
|
||||
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
|
||||
|
||||
expect(
|
||||
await within(sheet).findByText("Zu viele offene Sprachnotizen – sie werden später gesendet."),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("shows only the count of another account's notes", async () => {
|
||||
vi.mocked(listQueue).mockResolvedValue([VOICE_WAITING]);
|
||||
renderSheet(2);
|
||||
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
|
||||
|
||||
expect(await within(sheet).findByText("2 Einträge eines anderen Kontos warten")).toBeVisible();
|
||||
expect(within(sheet).getAllByText("Sprachnotiz")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { AlertTriangle, Camera, Clock, RotateCw } from "lucide-react";
|
||||
import { useRef, type ComponentProps, type MouseEvent, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { AlertTriangle, Camera, Clock, Mic, RotateCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { NavSheet } from "@/components/nav/nav-sheet";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { PhotoThumbnail } from "@/components/photos/photo-capture-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
discardQueueItem,
|
||||
listQueue,
|
||||
@@ -21,9 +22,12 @@ import {
|
||||
foreignEntriesWaiting,
|
||||
photosAttached,
|
||||
queueRejectionText,
|
||||
VOICE_TOO_MANY_OPEN_HINT,
|
||||
voiceRejectionText,
|
||||
} from "@/lib/offline/queue-texts";
|
||||
import { syncPendingEntries } from "@/lib/offline/sync";
|
||||
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
|
||||
import { discardAllPendingVoice, discardPendingVoice } from "@/lib/voice/pending-voice";
|
||||
import { discardReadyPhoto, listPendingPhotos, resetPhotoError } from "@/lib/photos/store";
|
||||
|
||||
interface PendingQueueSheetProps {
|
||||
@@ -44,6 +48,30 @@ interface Named {
|
||||
}
|
||||
|
||||
const PHOTO_LABEL = "Foto";
|
||||
const VOICE_STORE = "pending-voice";
|
||||
|
||||
/** 75 seconds as "1:15". */
|
||||
function durationLabel(totalSec: number): string {
|
||||
const seconds = Math.round(totalSec);
|
||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** True for a plain left click; ctrl, meta, shift, alt and middle click open a link elsewhere. */
|
||||
function isPlainLeftClick(event: MouseEvent): boolean {
|
||||
return (
|
||||
event.button === 0 &&
|
||||
!event.defaultPrevented &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey
|
||||
);
|
||||
}
|
||||
|
||||
/** The recording page, for the colony of a rejected note. */
|
||||
function recordAgainHref(item: QueueItem): string {
|
||||
return `/voice/new?${new URLSearchParams({ colonyId: item.voice?.colonyId ?? "" }).toString()}`;
|
||||
}
|
||||
|
||||
function queuedAtLabel(queuedAt: string): string {
|
||||
return new Date(queuedAt).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
|
||||
@@ -81,14 +109,19 @@ function discardDescription(item: QueueItem): string {
|
||||
function QueueStatus({
|
||||
lastError,
|
||||
changesHive = false,
|
||||
isVoice = false,
|
||||
}: {
|
||||
lastError?: QueueError;
|
||||
changesHive?: boolean;
|
||||
isVoice?: boolean;
|
||||
}) {
|
||||
return lastError ? (
|
||||
<p className="flex items-start gap-2 text-sm text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
<span>Abgelehnt: {queueRejectionText(lastError, changesHive)}</span>
|
||||
<span>
|
||||
Abgelehnt:{" "}
|
||||
{isVoice ? voiceRejectionText(lastError) : queueRejectionText(lastError, changesHive)}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
@@ -164,6 +197,29 @@ export function PendingQueueSheet({
|
||||
onError: () => toast.error("Eintrag konnte nicht verworfen werden."),
|
||||
});
|
||||
|
||||
const voiceHeadingRef = useRef<HTMLHeadingElement>(null);
|
||||
const discardVoice = useMutation({
|
||||
mutationFn: (noteId: string) => discardPendingVoice(noteId, userId),
|
||||
networkMode: "always",
|
||||
onSuccess: () => {
|
||||
// The discard button is gone: keep the focus inside the list.
|
||||
voiceHeadingRef.current?.focus();
|
||||
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
|
||||
toast.success("Sprachnotiz verworfen");
|
||||
},
|
||||
onError: () => toast.error("Sprachnotiz konnte nicht verworfen werden."),
|
||||
});
|
||||
|
||||
const discardAllVoice = useMutation({
|
||||
mutationFn: () => discardAllPendingVoice(userId),
|
||||
networkMode: "always",
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
|
||||
toast.success("Sprachnotizen verworfen");
|
||||
},
|
||||
onError: () => toast.error("Sprachnotizen konnten nicht verworfen werden."),
|
||||
});
|
||||
|
||||
const discardPhoto = useMutation({
|
||||
mutationFn: (photoId: string) => discardReadyPhoto(photoId),
|
||||
networkMode: "always",
|
||||
@@ -233,7 +289,10 @@ export function PendingQueueSheet({
|
||||
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
|
||||
});
|
||||
|
||||
const names = entryNames(items);
|
||||
const records = items.filter((item) => item.store !== VOICE_STORE);
|
||||
const voiceNotes = items.filter((item) => item.store === VOICE_STORE);
|
||||
const names = entryNames(records);
|
||||
const voiceNames = entryNames(voiceNotes);
|
||||
const photoNames = entryNames(
|
||||
photos.map((photo) => ({ label: PHOTO_LABEL, queuedAt: photo.createdAt })),
|
||||
);
|
||||
@@ -251,7 +310,7 @@ export function PendingQueueSheet({
|
||||
<p className="text-sm text-muted-foreground">Von dir wartet nichts mehr.</p>
|
||||
) : null}
|
||||
<ul className="flex flex-col gap-2">
|
||||
{items.map((item, index) => (
|
||||
{records.map((item, index) => (
|
||||
<li
|
||||
key={`${item.store}-${item.id}`}
|
||||
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
|
||||
@@ -357,6 +416,87 @@ export function PendingQueueSheet({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{voiceNotes.length > 0 ? (
|
||||
<section aria-labelledby="queue-voice-heading" className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3
|
||||
id="queue-voice-heading"
|
||||
ref={voiceHeadingRef}
|
||||
tabIndex={-1}
|
||||
className="font-medium outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
>
|
||||
Sprachnotizen
|
||||
</h3>
|
||||
{voiceNotes.length > 1 ? (
|
||||
<ConfirmDialog
|
||||
triggerLabel="Alle Sprachnotizen verwerfen"
|
||||
triggerVariant="ghost"
|
||||
triggerSize="touch"
|
||||
title="Alle Sprachnotizen verwerfen?"
|
||||
description="Die Sprachnotizen werden von diesem Gerät gelöscht und nicht mehr gesendet."
|
||||
confirmLabel="Alle verwerfen"
|
||||
onConfirm={() => discardAllVoice.mutate()}
|
||||
isPending={discardAllVoice.isPending}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{voiceNotes.some((item) => item.voice?.blockedBy === "TOO_MANY_OPEN") ? (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
{VOICE_TOO_MANY_OPEN_HINT}
|
||||
</p>
|
||||
) : null}
|
||||
<ul className="flex flex-col gap-2">
|
||||
{voiceNotes.map((item, index) => (
|
||||
<li
|
||||
key={`voice-${item.id}`}
|
||||
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Mic className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{item.label}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Aufgenommen am {queuedAtLabel(item.queuedAt)}
|
||||
{item.voice ? ` · Dauer ${durationLabel(item.voice.durationSec)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<QueueStatus lastError={item.lastError} isVoice />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.lastError ? (
|
||||
<Link
|
||||
href={recordAgainHref(item)}
|
||||
className={buttonVariants({ variant: "outline", size: "touch" })}
|
||||
aria-label={`Erneut aufnehmen: ${voiceNames[index]}`}
|
||||
onClick={(event) => {
|
||||
// Only a plain click stays in this tab; the refused audio is of no
|
||||
// use any more and the new recording replaces it.
|
||||
if (!isPlainLeftClick(event)) return;
|
||||
discardVoice.mutate(String(item.id));
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<RotateCw aria-hidden />
|
||||
Erneut aufnehmen
|
||||
</Link>
|
||||
) : null}
|
||||
<ConfirmDialog
|
||||
triggerLabel="Verwerfen"
|
||||
triggerAriaLabel={`Verwerfen: ${voiceNames[index]}`}
|
||||
triggerVariant="ghost"
|
||||
triggerSize="touch"
|
||||
title={`${voiceNames[index]} verwerfen?`}
|
||||
description="Die Sprachnotiz wird von diesem Gerät gelöscht und nicht mehr gesendet."
|
||||
confirmLabel="Verwerfen"
|
||||
onConfirm={() => discardVoice.mutate(String(item.id))}
|
||||
isPending={discardVoice.isPending}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
{foreignCount > 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{foreignEntriesWaiting(foreignCount)}</p>
|
||||
) : null}
|
||||
|
||||
Reference in new issue
Block a user