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>
70 lines
2.9 KiB
TypeScript
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 });
|
|
}
|