fix(upload): stop the queue from wedging, and tell the guest when it fails
A STALLED UPLOAD BLOCKED EVERYTHING, FOREVER. The XHR set no timeout and had no stall detection, so a half-open connection from an AP roam left the item `uploading` indefinitely — which kept `processQueue`'s `processing` flag set, so the whole rest of the queue stopped draining. The UI offered no control at all for an `uploading` item. The guest saw "Wird hochgeladen 43%" all evening with four photos stuck behind it and no button to press; the only escape was force-quitting the PWA, which nobody guesses. Now: a watchdog aborts when no bytes move for 90s, disarmed on `loadend` so the server may take its time storing a file it already has; a size-scaled timeout as a generous backstop that will not kill slow-but-progressing LTE; and a cancel button. FAILURES WERE INVISIBLE. `handleSubmit` navigates to /feed immediately, and the queue component is mounted only on /upload — so a 5xx, a captive-portal error or an uploads-locked 403 wrote a German message into an item that nothing ever rendered. The guest believed the photo was uploading; it never appeared. Same for the documented rate-limit countdown banner, which lives in that same unreachable component and is now also rendered from the layout. RETRIES WERE UNCAPPED. `requeueRetriable` flipped every errored item back to pending on the `online` event AND on every `feed-delta` — i.e. every SSE reconnect — with no attempt counter and no backoff. On a flapping network a large failing video was re-uploaded from byte zero all evening, saturating the AP for everyone. Now a persisted attempt count, exponential backoff and a cap of five. INDEXEDDB COULD STRAND THE COMPOSER. `openDB` had no `blocked` handler, so a second tab holding an older version made it never settle, and it rejects outright on iOS private mode; `handleSubmit` had no try/catch and never reset `submitting`, so both buttons stayed disabled reading "Wird hochgeladen…" permanently, with no error and nothing queued. There is now a `blocked` handler plus a settle timeout, an in-memory fallback so uploading still works when persistence is unavailable, and a `finally`. A 401 during a background upload cleared the session without redirecting — and api.ts documents exactly why that strands a guest: the nav and FAB are gated on `isAuthenticated` so they vanish, route guards only run on mount, and a standalone PWA has no URL bar. Three early-return paths wrote status only to memory and never to IndexedDB, leaving blob-less error rows that could never be evicted and held the red FAB badge lit all night. A banned guest was offered the entire upload flow — FAB, camera, staging — and only the POST 403'd, while the new read-only banner told them uploading was disabled. The sheet now consults the ban, the layout subscribes to `user-hidden` so a live ban reaches the UI instead of arriving as a stream of 403 toasts, and the banner clears `env(safe-area-inset-bottom)` so the bottom nav stops covering it on notched iPhones. The /upload submit bar gets the same inset — it sat in the home-indicator zone, where the system swallows the first tap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,42 @@
|
||||
|
||||
const MAX_CAPTION_LENGTH = 2000;
|
||||
|
||||
// Mirrors MAX_UPLOAD_BYTES in backend/src/main.rs — the axum body limit, which is a
|
||||
// BOOT CONSTANT rather than an admin-tunable value, so checking it here cannot drift
|
||||
// out of sync with the dashboard the way max_image_size_mb / max_video_size_mb would.
|
||||
// Anything above this is refused by the server no matter how the event is configured.
|
||||
const HARD_MAX_UPLOAD_BYTES = 576 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Reject files the server is certain to refuse, BEFORE any bytes leave the phone.
|
||||
*
|
||||
* Without this a guest pushes the whole file over the venue wifi — saturating the AP
|
||||
* for everyone else — only to get a German error at the end. Two cases are worth
|
||||
* catching, and only two: both are certain rejections, so this can never over-reject
|
||||
* a file the server would have taken.
|
||||
*
|
||||
* HEIC/HEIF: deliberately excluded from the backend allowlist (neither the `image`
|
||||
* crate nor the bundled ffmpeg can decode them). iOS transcodes HEIC→JPEG when a photo
|
||||
* comes from the Photos picker, but NOT via the Files app or a third-party share sheet,
|
||||
* so this path is reachable in normal guest use.
|
||||
*/
|
||||
function uploadRejectReason(file: File): string | null {
|
||||
const type = file.type.toLowerCase();
|
||||
// Trust the MIME type. The filename is only consulted when the browser supplied no
|
||||
// MIME at all: iOS transcodes HEIC→JPEG for the Photos picker but can hand back a
|
||||
// File still NAMED *.HEIC, so an extension-based rule would reject good JPEGs — the
|
||||
// one thing this function must never do.
|
||||
const heicByName = type === '' && /\.hei[cf]$/.test(file.name.toLowerCase());
|
||||
if (type === 'image/heic' || type === 'image/heif' || heicByName) {
|
||||
return `„${file.name}“ ist ein HEIC-Bild und kann nicht verarbeitet werden. Bitte teile es aus der Fotos-App (dann wandelt iOS es automatisch in JPEG um).`;
|
||||
}
|
||||
if (file.size > HARD_MAX_UPLOAD_BYTES) {
|
||||
const mb = Math.round(file.size / (1024 * 1024));
|
||||
return `„${file.name}“ ist mit ${mb} MB zu groß. Bitte nimm einen kürzeren Clip auf.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The storage widget is staff-only (host/admin). Guests never see server-derived
|
||||
// storage figures — the backend also zeroes the raw-disk fields for non-staff, so
|
||||
// this is UI-consistency on top of an API guarantee, not the security boundary.
|
||||
@@ -105,20 +141,42 @@
|
||||
vibrate(10);
|
||||
const hashtagsString = captionTags.join(',');
|
||||
let full = 0;
|
||||
for (const sf of stagedFiles) {
|
||||
const result = await addToQueue(sf.file, caption, hashtagsString);
|
||||
if (result === 'full') full++;
|
||||
}
|
||||
// Don't let a full queue silently swallow photos the user thinks were queued.
|
||||
if (full > 0) {
|
||||
// `addToQueue` touches IndexedDB, which can reject outright (iOS private mode, a denied
|
||||
// quota) — and an unhandled throw here left BOTH buttons stuck on "Wird hochgeladen…"
|
||||
// with no error and no way forward, because `submitting` was never reset. Whatever
|
||||
// happens, the composer has to come back to life and say something in German.
|
||||
try {
|
||||
for (const sf of stagedFiles) {
|
||||
// Refuse certain-rejects before a single byte is sent (see uploadRejectReason).
|
||||
const reason = uploadRejectReason(sf.file);
|
||||
if (reason) {
|
||||
toast(reason, 'error', 8000);
|
||||
continue;
|
||||
}
|
||||
const result = await addToQueue(sf.file, caption, hashtagsString);
|
||||
if (result === 'full') full++;
|
||||
}
|
||||
// Don't let a full queue silently swallow photos the user thinks were queued.
|
||||
if (full > 0) {
|
||||
toast(
|
||||
`Warteschlange voll – ${full} ${full === 1 ? 'Foto' : 'Fotos'} nicht hinzugefügt. Bitte warte, bis laufende Uploads fertig sind.`,
|
||||
'error',
|
||||
6000
|
||||
);
|
||||
}
|
||||
clearPending();
|
||||
goto('/feed');
|
||||
} catch {
|
||||
// Staged files are deliberately NOT cleared: the guest stays in the composer with
|
||||
// their selection intact so a second tap can succeed.
|
||||
toast(
|
||||
`Warteschlange voll – ${full} ${full === 1 ? 'Foto' : 'Fotos'} nicht hinzugefügt. Bitte warte, bis laufende Uploads fertig sind.`,
|
||||
'Der Upload konnte nicht gestartet werden. Bitte versuch es noch einmal.',
|
||||
'error',
|
||||
6000
|
||||
);
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
clearPending();
|
||||
goto('/feed');
|
||||
}
|
||||
|
||||
function isVideo(file: File): boolean {
|
||||
@@ -224,7 +282,7 @@
|
||||
<div>
|
||||
<p class="font-medium text-gray-500 dark:text-gray-400">Keine Dateien ausgewählt</p>
|
||||
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">
|
||||
Geh zurück und tippe auf den Plus-Button.
|
||||
Geh zurück und tippe auf den Kamera-Button.
|
||||
</p>
|
||||
</div>
|
||||
<button onclick={cancel} class="btn btn-secondary btn-sm"> Zurück </button>
|
||||
@@ -313,8 +371,13 @@
|
||||
onCancel={() => (discardConfirmOpen = false)}
|
||||
/>
|
||||
|
||||
<!-- Sticky submit button at bottom (mobile-primary) -->
|
||||
<div class="border-t border-gray-100 px-4 py-3 dark:border-gray-800">
|
||||
<!-- Sticky submit button at bottom (mobile-primary). This page suppresses the bottom nav, so
|
||||
this bar IS the bottom of the layout and has to carry the safe-area inset itself — with
|
||||
viewport-fit=cover the lower ~22px of the button otherwise sits in the iPhone home
|
||||
indicator's gesture zone, where the first tap is swallowed by the system. -->
|
||||
<div
|
||||
class="border-t border-gray-100 px-4 py-3 pb-[calc(env(safe-area-inset-bottom)+0.75rem)] dark:border-gray-800"
|
||||
>
|
||||
<button
|
||||
onclick={handleSubmit}
|
||||
disabled={stagedFiles.length === 0 || submitting}
|
||||
|
||||
Reference in New Issue
Block a user