Files
EventSnap/frontend/src/lib/event-state-store.ts
Fabian Hamm (Privat) fffa2d556c 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>
2026-08-03 18:36:30 +02:00

70 lines
2.9 KiB
TypeScript

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';
/**
* Live event lock/release state, so the composer can reflect a close/reopen the *instant*
* it happens instead of a guest discovering the lock via a rejected upload. Seeded from
* `/me/context` on boot and kept current by the `event-closed`/`event-opened` SSE events
* (see the root layout, which subscribes once).
*/
export interface EventState {
uploadsLocked: boolean;
galleryReleased: boolean;
}
export const eventState = writable<EventState>({ uploadsLocked: false, galleryReleased: false });
// Monotonic sequence bumped by every synchronous state transition (markClosed/markOpened).
// `refreshEventState` captures it before its async fetch and only applies the result if no
// newer transition happened meanwhile — so a slow `/me/context` reflecting a pre-reopen
// snapshot can't clobber a subsequent `event-opened` with stale `locked=true`.
let stateSeq = 0;
/** True when uploads are closed for any reason (event locked or gallery released). */
export function uploadsClosed(s: EventState): boolean {
return s.uploadsLocked || s.galleryReleased;
}
/** Refresh from the server. Non-fatal on failure — the next SSE event reconciles. */
export async function refreshEventState(): Promise<void> {
const seq = stateSeq;
try {
const ctx = await api.get<MeContextDto>('/me/context');
// 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;
eventState.set({
uploadsLocked: ctx.uploads_locked,
galleryReleased: ctx.gallery_released
});
} catch {
// non-fatal
}
}
/** Apply an `event-closed` SSE event (uploads just locked). */
export function markClosed(): void {
stateSeq++;
eventState.update((s) => ({ ...s, uploadsLocked: true }));
}
/** Apply an `event-opened` SSE event (uploads reopened → release also cleared). */
export function markOpened(): void {
stateSeq++;
eventState.set({ uploadsLocked: false, galleryReleased: false });
}