fix: close the last three guest-facing dead ends (items 7-9)

C1 — a failed page-append silently ended infinite scroll

`loadMore`'s catch showed a toast and changed no state, unlike every sibling error
path in the file. `nextCursor` survived so the feed was still technically paginable,
but the IntersectionObserver only fires on a CHANGE: after a failed append nothing
scrolls and no rows are added, so it never re-fires. One 429 or wifi blip and the
guest concluded the gallery was 20 photos. Now leaves a retry control at the sentinel
— a toast that fades in 5s is not an affordance — resuming from the untouched cursor.

C2 — the export page reported downloads that never happened

`downloadFile` toasted 'Download gestartet' the instant it assigned the iframe's src,
before a single byte existed. Since the iframe swallows errors BY DESIGN (a top-level
navigation to a 404 would unload the PWA), a failure produced a green success message,
a consumed single-use ticket, and one of only three daily slots spent — repeatable
until the day's allowance was gone, on the screen that is the whole point of the app.

Root cause is two sources of truth: `export_status` reports `done` from `export_job`
and enables the button, while the download resolves through `export_current.file_path`
plus a `Path::exists()`. They can legitimately disagree. `export_ticket` now takes a
`kind` and calls the existing `resolve_export_file` BEFORE charging the rate slot, so
a missing archive fails honestly on a plain fetch that `toastError` already renders.
Not the HEAD probe ruled out elsewhere: it reads the same indexed row the download
will read and touches no ticket, so it cannot consume anything. The parameter is
optional, so an older client degrades to today's behaviour rather than breaking.

C3 — the WhatsApp journey could dead-end with no error at all

The join link travels through guest group chats, and a link tapped inside one opens in
that app's browser, where the file picker and getUserMedia both depend on the host app
having wired them up. When they aren't, the buttons do nothing — no error, nothing to
act on. Two targeted changes rather than a UI rebuild: the camera error panel now
offers "Aus Galerie wählen" (its advice to change "Browsereinstellungen" refers to
settings that do not exist in a webview, so retrying could never help those guests),
and the sheet carries a standing one-line hint to open the link in Safari or Chrome.

Deliberately no user-agent sniffing: a sniff list is wrong for every browser it has
not heard of, while a quiet standing hint costs one line and is never wrong. The hint
lives in UploadSheet rather than the root layout because both layout banners are gated
on `$showBottomNav`, which `/upload` turns off — one there would never render on the
composer.

Verified: 151/151 backend tests against a live Postgres, clippy clean, 58/58 vitest,
svelte-check 0 errors, eslint clean, both builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-08-08 22:38:07 +02:00
parent 05063694d2
commit 5b705317ef
5 changed files with 113 additions and 8 deletions

View File

@@ -335,8 +335,17 @@ pub struct DownloadQuery {
/// carry an `Authorization` header, so the client exchanges its Bearer token for /// carry an `Authorization` header, so the client exchanges its Bearer token for
/// an opaque ticket here, then hits `/export/zip?ticket=...`. Reuses the same /// an opaque ticket here, then hits `/export/zip?ticket=...`. Reuses the same
/// single-use, 30s-TTL store as the SSE stream. /// single-use, 30s-TTL store as the SSE stream.
#[derive(serde::Deserialize)]
pub struct ExportTicketQuery {
/// Which archive the ticket is for — `zip` or `html`. Optional so an older client that
/// doesn't send it keeps working; it simply skips the pre-check it doesn't know to ask for.
#[serde(default)]
pub kind: Option<String>,
}
pub async fn export_ticket( pub async fn export_ticket(
State(state): State<AppState>, State(state): State<AppState>,
axum::extract::Query(q): axum::extract::Query<ExportTicketQuery>,
auth: crate::auth::middleware::AuthUser, auth: crate::auth::middleware::AuthUser,
) -> Result<Json<serde_json::Value>, AppError> { ) -> Result<Json<serde_json::Value>, AppError> {
// NOTE: intentionally NOT gated on `is_banned`. A banned user keeps *read* access // NOTE: intentionally NOT gated on `is_banned`. A banned user keeps *read* access
@@ -353,6 +362,39 @@ pub async fn export_ticket(
// //
// Moving it does not weaken the limit: tickets are single-use with a 30s TTL and can only be // Moving it does not weaken the limit: tickets are single-use with a 30s TTL and can only be
// obtained from this authenticated endpoint, so one mint is at most one download. // obtained from this authenticated endpoint, so one mint is at most one download.
// Confirm the archive actually EXISTS before spending anything on it.
//
// `export_status` — which is what enables the Download button — reports `done` from
// `export_job`, while the download resolves through `export_current.file_path` plus a
// `Path::exists()`. Those are different sources of truth and can legitimately disagree: a
// row can say done while the file is gone, or an epoch bump can retire it between the page
// rendering and the guest tapping. When they disagreed the guest got the worst possible
// shape of failure — a green "Download gestartet" toast, a consumed single-use ticket, one
// of only three daily slots spent, and nothing in their Downloads folder, repeatable until
// the day's allowance was gone.
//
// Checking here, before `enforce_export_rate`, turns that into an honest error on a plain
// `fetch` that the existing `toastError` path already renders. This is NOT the HEAD probe
// ruled out elsewhere: it reads the same indexed row the download will read and touches no
// ticket, so it cannot consume anything.
if let Some(kind) = q.kind.as_deref() {
let export_type = match kind {
"zip" => "zip",
"html" => "html",
other => {
return Err(AppError::BadRequest(format!(
"Unbekannter Export-Typ: {other}"
)));
}
};
let msg = if export_type == "zip" {
"Der ZIP-Export ist noch nicht verfügbar."
} else {
"Der HTML-Export ist noch nicht verfügbar."
};
resolve_export_file(&state, export_type, msg).await?;
}
enforce_export_rate(&state, auth.user_id).await?; enforce_export_rate(&state, auth.user_id).await?;
let ticket = state.sse_tickets.issue(auth.token_hash); let ticket = state.sse_tickets.issue(auth.token_hash);

View File

@@ -14,9 +14,17 @@
/// returns the guest to where they were instead of dropping them somewhere they never /// returns the guest to where they were instead of dropping them somewhere they never
/// asked to be. /// asked to be.
onready?: () => void; onready?: () => void;
/// Fired when the guest gives up on the camera and wants the file picker instead.
///
/// The error panel used to offer only "Erneut versuchen" and "Schließen", which dead-ends
/// the whole upload journey for anyone whose `getUserMedia` cannot succeed: an in-app
/// browser (the WhatsApp/Instagram webview a shared QR link opens in) may never grant it,
/// and the panel's advice to change "Browsereinstellungen" refers to settings that do not
/// exist there. Retrying cannot help those guests; picking a photo can.
onpickfile?: () => void;
} }
let { oncapture, onclose, onready }: Props = $props(); let { oncapture, onclose, onready, onpickfile }: Props = $props();
/// `onready` is a one-shot. `startCamera` re-runs on every lens flip and photo/video switch, /// `onready` is a one-shot. `startCamera` re-runs on every lens flip and photo/video switch,
/// and re-announcing "the camera is ready" mid-session would ask the caller to redo a /// and re-announcing "the camera is ready" mid-session would ask the caller to redo a
@@ -204,13 +212,21 @@
/> />
</svg> </svg>
<p class="text-sm text-white">{error}</p> <p class="text-sm text-white">{error}</p>
<div class="mt-4 flex justify-center gap-2"> <div class="mt-4 flex flex-wrap justify-center gap-2">
<button <button
onclick={startCamera} onclick={startCamera}
class="rounded-lg bg-white px-4 py-2 text-sm font-medium text-gray-900" class="rounded-lg bg-white px-4 py-2 text-sm font-medium text-gray-900"
> >
Erneut versuchen Erneut versuchen
</button> </button>
{#if onpickfile}
<button
onclick={onpickfile}
class="rounded-lg bg-white px-4 py-2 text-sm font-medium text-gray-900"
>
Aus Galerie wählen
</button>
{/if}
<button onclick={onclose} class="rounded-lg bg-white/20 px-4 py-2 text-sm text-white"> <button onclick={onclose} class="rounded-lg bg-white/20 px-4 py-2 text-sm text-white">
Schließen Schließen
</button> </button>

View File

@@ -156,6 +156,12 @@
oncapture={handleCapture} oncapture={handleCapture}
onclose={handleCameraClose} onclose={handleCameraClose}
onready={handleCameraReady} onready={handleCameraReady}
onpickfile={() => {
// Close the camera first, then open the OS picker — the sheet is still mounted
// behind it, so this returns the guest to a working path instead of a dead end.
showCamera = false;
openGallery();
}}
/> />
{/if} {/if}
@@ -288,6 +294,20 @@
<p class="text-sm text-gray-500 dark:text-gray-400">Jetzt aufnehmen</p> <p class="text-sm text-gray-500 dark:text-gray-400">Jetzt aufnehmen</p>
</div> </div>
</button> </button>
<!-- The in-app-browser escape hatch.
The join link travels through WhatsApp groups, and a link tapped inside one opens
in that app's own browser rather than Safari or Chrome. There, the file picker and
`getUserMedia` both depend on the host app having wired them up, and when they are
not wired up the buttons above simply do nothing — no error, nothing to act on,
with no operator to ask. Deliberately NOT user-agent sniffing: a sniff list is
wrong for browsers it has never heard of, whereas a quiet standing hint costs a
line of text and is never wrong. It lives here rather than in the root layout
because both layout banners are gated on `$showBottomNav`, which `/upload` turns
off — a banner there would never render on the composer. -->
<p class="px-1 pt-1 text-center text-xs text-gray-500 dark:text-gray-400">
Nichts passiert beim Tippen? Öffne den Link in Safari oder Chrome.
</p>
{/if} {/if}
<!-- Queue access. The FAB badge is the ONLY signal a guest gets that an upload is <!-- Queue access. The FAB badge is the ONLY signal a guest gets that an upload is

View File

@@ -115,11 +115,20 @@
// the GET route, so a probe would consume the ticket and burn a rate-limit slot. // the GET route, so a probe would consume the ticket and burn a rate-limit slot.
let downloadFrame: HTMLIFrameElement; let downloadFrame: HTMLIFrameElement;
async function downloadFile(endpoint: string) { async function downloadFile(endpoint: string, kind: 'zip' | 'html') {
if (downloading) return; if (downloading) return;
downloading = true; downloading = true;
try { try {
const { ticket } = await api.post<{ ticket: string }>('/export/ticket'); // `kind` makes the mint check that THIS archive exists before charging a slot.
// `export_status` reports readiness from `export_job`, while the download resolves
// through `export_current` + the filesystem — different sources of truth that can
// legitimately disagree (a retired epoch, a file swept from under a done row). When
// they did, the guest got a green success toast, a consumed single-use ticket, one
// of three daily slots gone, and nothing in their Downloads. Now the mint fails
// honestly and lands in the `catch` below.
const { ticket } = await api.post<{ ticket: string }>(
`/export/ticket?kind=${kind}`
);
downloadFrame.src = `${endpoint}?ticket=${encodeURIComponent(ticket)}`; downloadFrame.src = `${endpoint}?ticket=${encodeURIComponent(ticket)}`;
// An iframe download produces NO visible change: no spinner, no navigation, and // An iframe download produces NO visible change: no spinner, no navigation, and
// on mobile often no browser chrome either. Without a word here the guest cannot // on mobile often no browser chrome either. Without a word here the guest cannot
@@ -142,12 +151,12 @@
} }
function downloadZip() { function downloadZip() {
downloadFile('/api/v1/export/zip'); downloadFile('/api/v1/export/zip', 'zip');
} }
function downloadHtml() { function downloadHtml() {
if (localStorage.getItem(HTML_GUIDE_KEY)) { if (localStorage.getItem(HTML_GUIDE_KEY)) {
downloadFile('/api/v1/export/html'); downloadFile('/api/v1/export/html', 'html');
} else { } else {
showHtmlGuide = true; showHtmlGuide = true;
} }
@@ -156,7 +165,7 @@
function confirmHtmlDownload() { function confirmHtmlDownload() {
localStorage.setItem(HTML_GUIDE_KEY, '1'); localStorage.setItem(HTML_GUIDE_KEY, '1');
showHtmlGuide = false; showHtmlGuide = false;
downloadFile('/api/v1/export/html'); downloadFile('/api/v1/export/html', 'html');
} }
</script> </script>

View File

@@ -31,6 +31,11 @@
let selectedHashtag = $state<string | null>(null); let selectedHashtag = $state<string | null>(null);
let nextCursor = $state<string | null>(null); let nextCursor = $state<string | null>(null);
let loadingMore = $state(false); let loadingMore = $state(false);
// Set when a page-append fails. Without it the feed silently stops paginating: the
// IntersectionObserver only fires on a CHANGE, and after a failed append nothing scrolls and
// no rows are added, so it never re-fires. A single 429 or wifi blip ended infinite scroll for
// the session and the guest concluded the gallery was 20 photos.
let loadMoreError = $state(false);
let initialLoading = $state(true); let initialLoading = $state(true);
// Set when a load left us with NOTHING to show. Without it the template fell straight // Set when a load left us with NOTHING to show. Without it the template fell straight
// through to "Noch keine Fotos" — so on the venue WiFi the most likely first thing a // through to "Noch keine Fotos" — so on the venue WiFi the most likely first thing a
@@ -639,7 +644,11 @@
const present = new Set(uploads.map((u) => u.id)); const present = new Set(uploads.map((u) => u.id));
uploads = [...uploads, ...res.uploads.filter((u) => !present.has(u.id))]; uploads = [...uploads, ...res.uploads.filter((u) => !present.has(u.id))];
nextCursor = res.next_cursor; nextCursor = res.next_cursor;
loadMoreError = false;
} catch (e) { } catch (e) {
// Leave a CONTROL on screen, not just a toast that fades in 5s. `nextCursor` is
// deliberately untouched so the retry resumes from exactly where this failed.
loadMoreError = true;
toastError(e); toastError(e);
} finally { } finally {
loadingMore = false; loadingMore = false;
@@ -1295,7 +1304,16 @@
<!-- Infinite scroll sentinel --> <!-- Infinite scroll sentinel -->
<div class="mx-auto max-w-2xl"> <div class="mx-auto max-w-2xl">
<div bind:this={sentinel} class="h-4"></div> <div bind:this={sentinel} class="h-4"></div>
{#if loadingMore} {#if loadMoreError && !loadingMore}
<div class="py-6 text-center" data-testid="feed-load-more-error">
<p class="text-sm text-gray-500 dark:text-gray-400">
Weitere Fotos konnten nicht geladen werden.
</p>
<button onclick={() => void loadMore()} class="btn btn-primary btn-sm mt-3">
Erneut laden
</button>
</div>
{:else if loadingMore}
<div class="py-4 text-center"> <div class="py-4 text-center">
<div <div
class="inline-block h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600 dark:border-gray-700 dark:border-t-blue-400" class="inline-block h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600 dark:border-gray-700 dark:border-t-blue-400"