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:
Fabian Hamm (Privat)
2026-08-03 18:36:30 +02:00
parent 51e55b1ace
commit fffa2d556c
8 changed files with 915 additions and 121 deletions

View File

@@ -1,6 +1,8 @@
import { writable } from 'svelte/store';
import { api } from './api';
import { setRole } from './role-store';
import { privacyNote } from './privacy-note-store';
import { isBanned } from './ban-store';
import type { MeContextDto } from './types';
/**
@@ -35,6 +37,13 @@ export async function refreshEventState(): Promise<void> {
// The role is unaffected by the close/reopen race guarded below, so apply it
// unconditionally — this is one of the refreshes that used to drop it.
setRole(ctx.role);
// Same reasoning, same bug shape: these two fields come back in the SAME payload and
// were simply dropped on the floor here. `privacy_note` mattered most on the join
// → feed path, where the layout's token-gated boot fetch never runs, so a first-time
// guest's onboarding never learned the note existed. `is_banned` drives the read-only
// banner, which has to appear the moment a ban lands, not on the next cold start.
privacyNote.set(ctx.privacy_note);
isBanned.set(ctx.is_banned);
// A close/reopen landed while this was in flight — its result is now authoritative;
// don't overwrite it with our possibly-stale snapshot.
if (seq !== stateSeq) return;