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:
18
frontend/src/lib/ban-store.ts
Normal file
18
frontend/src/lib/ban-store.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Whether the CURRENT guest is banned.
|
||||
//
|
||||
// The ban is deliberately read-only (see backend `handlers/host.rs`): the guest keeps the feed
|
||||
// and the released keepsake, but every write — upload, like, comment, delete — is refused with
|
||||
// 403 "Du bist gesperrt.". Until this store existed the client had no idea, so all of those
|
||||
// controls rendered fully enabled and the guest discovered the ban one error toast at a time,
|
||||
// with nobody at the party to ask. The event-lock case has always been surfaced live for
|
||||
// exactly this reason (`event-state-store`); a ban simply never was.
|
||||
//
|
||||
// Seeded from `/me/context` (root layout on boot, and `refreshEventState` afterwards). Reset on
|
||||
// identity change so a shared device does not carry one guest's ban to the next.
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
import { onClearAuth } from './auth';
|
||||
|
||||
export const isBanned = writable(false);
|
||||
|
||||
onClearAuth(() => isBanned.set(false));
|
||||
@@ -4,6 +4,7 @@
|
||||
isProcessing,
|
||||
retryItem,
|
||||
removeItem,
|
||||
cancelItem,
|
||||
clearCompleted,
|
||||
rateLimitRetryAt
|
||||
} from '$lib/upload-queue';
|
||||
@@ -121,6 +122,27 @@
|
||||
Erneut
|
||||
</button>
|
||||
{/if}
|
||||
<!-- An in-flight item gets a cancel, not a remove. Until this existed an
|
||||
upload that stalled on a half-open connection had NO control at all:
|
||||
it holds the queue's only slot, so nothing else drains either, and
|
||||
the guest's only escape was force-quitting the app. Cancelling parks
|
||||
the item as retryable with its file intact. -->
|
||||
{#if item.status === 'uploading'}
|
||||
<button
|
||||
onclick={() => cancelItem(item.id)}
|
||||
class="inline-flex h-9 w-9 items-center justify-center text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||
aria-label="Upload abbrechen"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{#if item.status === 'done' || item.status === 'error' || item.status === 'blocked'}
|
||||
<button
|
||||
onclick={() => removeItem(item.id)}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { uploadSheetOpen } from '$lib/ui-store';
|
||||
import { uploadSheetOpen, uploadBadgeCount } from '$lib/ui-store';
|
||||
import { pendingFiles } from '$lib/pending-upload-store';
|
||||
import { scrollLock } from '$lib/actions/scroll-lock';
|
||||
import CameraCapture from '$lib/components/CameraCapture.svelte';
|
||||
import type { PendingFile } from '$lib/pending-upload-store';
|
||||
import { eventState, uploadsClosed } from '$lib/event-state-store';
|
||||
import { commentsEnabled } from '$lib/event-config-store';
|
||||
import { isBanned } from '$lib/ban-store';
|
||||
|
||||
// A ban closes uploads just as hard as an event lock does — the backend refuses every
|
||||
// write from a banned user. Without this the read-only banner said uploading was off while
|
||||
// the FAB still opened the sheet, the camera still opened, files still staged, and only the
|
||||
// final POST 403'd: the guest burns their photo and their upload allowance on a rejection
|
||||
// nobody is around to explain.
|
||||
let banned = $derived($isBanned);
|
||||
// Uploads closed (event locked or gallery released) — show a lock notice instead of
|
||||
// the capture options, so a guest can't stage a photo that would just be rejected.
|
||||
let closed = $derived(uploadsClosed($eventState));
|
||||
let closed = $derived(banned || uploadsClosed($eventState));
|
||||
|
||||
let showCamera = $state(false);
|
||||
let fileInput: HTMLInputElement;
|
||||
@@ -77,6 +84,13 @@
|
||||
showCamera = true;
|
||||
}
|
||||
|
||||
// Close the sheet before navigating: it stays mounted for its translate-y animation,
|
||||
// so leaving it open would keep the backdrop and scroll lock over /upload.
|
||||
function openQueue() {
|
||||
close();
|
||||
void goto('/upload');
|
||||
}
|
||||
|
||||
async function handleFiles() {
|
||||
const files = fileInput?.files;
|
||||
if (!files || files.length === 0) return;
|
||||
@@ -160,16 +174,26 @@
|
||||
|
||||
<div class="space-y-3 px-4 pb-4 pt-2">
|
||||
{#if closed}
|
||||
<!-- Uploads closed: no capture options, just an explanation + dismiss. -->
|
||||
<!-- Uploads closed: no capture options, just an explanation + dismiss. A ban and an
|
||||
event lock both land here, but they need different copy — a banned guest keeps
|
||||
read access only, so promising them likes and comments would just set up the
|
||||
next 403. -->
|
||||
<div class="rounded-xl bg-amber-50 px-5 py-4 text-center dark:bg-amber-950/30">
|
||||
{#if banned}
|
||||
<p class="font-semibold text-amber-800 dark:text-amber-300">Nur-Lese-Modus</p>
|
||||
<p class="mt-1 text-sm text-amber-700 dark:text-amber-400">
|
||||
Hochladen ist für dich deaktiviert. Du kannst weiterhin alle Fotos ansehen und die
|
||||
Galerie später herunterladen.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="font-semibold text-amber-800 dark:text-amber-300">Uploads geschlossen</p>
|
||||
<p class="mt-1 text-sm text-amber-700 dark:text-amber-400">
|
||||
Der Host hat die Uploads für dieses Event beendet. Du kannst weiterhin Fotos ansehen{$commentsEnabled
|
||||
? ', liken und kommentieren'
|
||||
: ' und liken'}.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button onclick={close} class="btn btn-secondary btn-block"> Schließen </button>
|
||||
{:else}
|
||||
<!-- Gallery option -->
|
||||
<button
|
||||
@@ -231,9 +255,49 @@
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Jetzt aufnehmen</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Cancel -->
|
||||
<button onclick={close} class="btn btn-secondary btn-block"> Abbrechen </button>
|
||||
{/if}
|
||||
|
||||
<!-- Queue access. The FAB badge is the ONLY signal a guest gets that an upload is
|
||||
pending or failed, and tapping it opens this sheet — not the queue. Without this
|
||||
entry the queue view (and with it the only retry button in the app) is reachable
|
||||
only by staging a NEW file, so a guest with a failed upload sees a red badge,
|
||||
three capture options, and no way to act on it. Shown in the closed/banned state
|
||||
too: that guest is the MOST likely to have items parked in the queue, and it is
|
||||
the only place they can clear the badge. -->
|
||||
{#if $uploadBadgeCount > 0}
|
||||
<button
|
||||
onclick={openQueue}
|
||||
class="flex w-full items-center gap-4 rounded-xl bg-gray-50 px-5 py-4 text-left transition hover:bg-gray-100 active:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 dark:active:bg-gray-600"
|
||||
>
|
||||
<span
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full bg-amber-100 text-amber-600 dark:bg-amber-900/40 dark:text-amber-300"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<p class="font-semibold text-gray-900 dark:text-gray-100">Warteschlange</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{$uploadBadgeCount}
|
||||
{$uploadBadgeCount === 1 ? 'Foto wartet' : 'Fotos warten'}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button onclick={close} class="btn btn-secondary btn-block">
|
||||
{closed ? 'Schließen' : 'Abbrechen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
45
frontend/src/lib/onboarding.ts
Normal file
45
frontend/src/lib/onboarding.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// Onboarding-guide "already seen" state.
|
||||
//
|
||||
// Extracted from OnboardingGuide.svelte so the AUTH FLOWS can mark it too. The guide is a
|
||||
// first-run explainer, and the only thing that used to suppress it was this localStorage key
|
||||
// being present on the device — which meant a returning guest who recovered their account
|
||||
// (new phone, cleared site data, second browser) was shown the whole guide again as if they
|
||||
// had never used the app.
|
||||
//
|
||||
// `/join` cannot distinguish them: its `is_new` field is hardcoded `true` in the backend,
|
||||
// because a name that already exists 409s before that response is built. So `/recover` — both
|
||||
// the dedicated page and the inline "name taken, enter your PIN" path on /join — is the
|
||||
// authoritative "this person already has an account" signal, and it marks the guide seen.
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
const GUIDE_SEEN_KEY = 'eventsnap_guide_seen';
|
||||
|
||||
/** Has this device already been shown (and dismissed) the onboarding guide? */
|
||||
export function hasSeenGuide(): boolean {
|
||||
if (!browser) return false;
|
||||
try {
|
||||
return localStorage.getItem(GUIDE_SEEN_KEY) !== null;
|
||||
} catch {
|
||||
// localStorage can throw in Safari private mode. Treating that as "not seen" would
|
||||
// re-show the guide on every navigation, so fail the quiet way instead.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress the guide from now on.
|
||||
*
|
||||
* Called when the user dismisses it, and when they authenticate as an EXISTING user via
|
||||
* recover — someone who already has an account has already been through onboarding, and
|
||||
* re-running it on a replacement device is the bug this exists to prevent.
|
||||
*/
|
||||
export function markGuideSeen(): void {
|
||||
if (!browser) return;
|
||||
try {
|
||||
localStorage.setItem(GUIDE_SEEN_KEY, '1');
|
||||
} catch {
|
||||
// Private mode / quota. Nothing to do: the guide is cosmetic, and the worst case is
|
||||
// that it shows again on this device.
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { openDB, type IDBPDatabase } from 'idb';
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { getToken, getUserId, clearAuth } from './auth';
|
||||
import { getToken, getUserId, clearAuth, onClearAuth, onSetAuth } from './auth';
|
||||
import { onSseEvent } from './sse';
|
||||
import { refreshQuota } from './quota-store';
|
||||
import { toast } from './toast-store';
|
||||
@@ -37,7 +37,71 @@ const STORE_NAME = 'queue';
|
||||
/** Hard cap on queued items per device — bounds IndexedDB growth from stuck blobs. */
|
||||
const MAX_QUEUE_ITEMS = 100;
|
||||
|
||||
let db: IDBPDatabase | null = null;
|
||||
/**
|
||||
* How many times an item may be re-sent by an AUTOMATIC resume (`online`, SSE reconnect,
|
||||
* backoff timer) before it parks and waits for the guest to tap "Erneut".
|
||||
*
|
||||
* Without a cap, `requeueRetriable` flips every failed item back to `pending` on every
|
||||
* `online` event and every SSE reconnect — on a congested venue AP those fire constantly,
|
||||
* so one failing 200 MB video re-uploads from byte zero all evening and starves the ~100
|
||||
* other guests sharing the uplink. A manual retry resets the counter: an explicit tap is
|
||||
* evidence the guest wants to spend the bandwidth.
|
||||
*/
|
||||
const MAX_AUTO_ATTEMPTS = 5;
|
||||
|
||||
/** Exponential backoff between automatic attempts: 5s, 10s, 20s, 40s, … capped below. */
|
||||
const RETRY_BASE_DELAY_MS = 5_000;
|
||||
const MAX_RETRY_DELAY_MS = 5 * 60_000;
|
||||
|
||||
/**
|
||||
* Abort an upload that has not moved a single byte for this long.
|
||||
*
|
||||
* A phone roaming between APs leaves a half-open TCP connection: the XHR neither errors nor
|
||||
* completes, so the item sits in `uploading` forever and `processQueue`'s `processing` flag
|
||||
* never clears — the ENTIRE queue stops draining, with no way out short of force-quitting
|
||||
* the app. Bytes-moved (not elapsed time) is the right signal: it never punishes a slow but
|
||||
* healthy LTE upload, which is why the wall-clock `xhr.timeout` below is only a backstop.
|
||||
*/
|
||||
const STALL_TIMEOUT_MS = 90_000;
|
||||
const STALL_CHECK_INTERVAL_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Wall-clock cap for one attempt, scaled by file size assuming a floor of ~8 kB/s — a
|
||||
* deliberately pessimistic rate, because killing a slow-but-progressing upload would lose
|
||||
* exactly the videos that are hardest to re-take. The stall watchdog is what actually
|
||||
* catches a dead connection; this only bounds the pathological case where bytes trickle
|
||||
* fast enough to reset the watchdog but the upload would never finish.
|
||||
*/
|
||||
const MIN_UPLOAD_TIMEOUT_MS = 5 * 60_000;
|
||||
const MAX_UPLOAD_TIMEOUT_MS = 60 * 60_000;
|
||||
const ASSUMED_MIN_BYTES_PER_SEC = 8_000;
|
||||
|
||||
/**
|
||||
* How long to wait for IndexedDB to open before giving up and running from memory.
|
||||
* `openDB` does not settle at all while another tab pins an older version (the `blocked`
|
||||
* callback fires but the promise stays pending), and every queue operation awaits it — so
|
||||
* without this bound a second open tab silently disables uploading device-wide.
|
||||
*/
|
||||
const DB_OPEN_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** The persisted shape of a queue row. `blob` is the only field that never reaches the store. */
|
||||
interface QueueEntry {
|
||||
id: string;
|
||||
userId: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
lastModified?: number;
|
||||
mimeType: string;
|
||||
caption: string;
|
||||
hashtags: string;
|
||||
status: QueueItem['status'];
|
||||
error?: string;
|
||||
/** Consecutive failed attempts; gates + delays automatic resumes. Reset by a manual retry. */
|
||||
attempts?: number;
|
||||
/** Earliest ms timestamp at which an automatic resume may re-send this item. */
|
||||
nextAttemptAt?: number;
|
||||
blob?: Blob;
|
||||
}
|
||||
|
||||
// Resume the queue as soon as connectivity returns. Registered once, guarded for SSR.
|
||||
// This is the other half of the "flushes when you're back online" promise — without it
|
||||
@@ -55,6 +119,25 @@ function bindOnline(): void {
|
||||
}
|
||||
bindOnline();
|
||||
|
||||
// Rehydrate on every IDENTITY change, not just on a cold boot.
|
||||
//
|
||||
// `+layout.svelte` calls `loadQueue()` on mount, but only `if (getToken())` — and the boot
|
||||
// that matters most has no token: a mid-event 401 (host PIN reset, redeployed JWT_SECRET)
|
||||
// clears auth and hard-navigates to /join, so the layout skips it. The guest then recovers,
|
||||
// which finishes with `goto('/feed')` — a client-side navigation, so `onMount` never runs
|
||||
// again and `queueItems` stays the empty array it was initialised to. Their queued photos
|
||||
// sit in IndexedDB with nothing reading them, and the FAB badge reads 0 — exactly the
|
||||
// silent-loss failure the boot-time call was added to prevent, one route over.
|
||||
//
|
||||
// `onSetAuth` is the mechanism the role store already uses for this same reason.
|
||||
onSetAuth(() => void loadQueue());
|
||||
|
||||
// Drop the in-memory view when an identity goes away, so the next user of a shared device
|
||||
// does not see the previous guest's file names in the queue. This deliberately does NOT
|
||||
// touch IndexedDB: a 401 is often transient, the blobs are user-scoped on every read, and
|
||||
// destroying them here would lose photos the AuthError path goes out of its way to keep.
|
||||
onClearAuth(() => queueItems.set([]));
|
||||
|
||||
// Resume the queue when the host reopens the event. A queued upload that hit a locked/
|
||||
// released event kept its blob and parked as a retryable `error` (LockedError); flipping it
|
||||
// back to pending and re-draining recovers it so a photo staged during a lock isn't lost.
|
||||
@@ -68,14 +151,17 @@ bindOnline();
|
||||
let sseBound = false;
|
||||
function bindSse(): void {
|
||||
if (sseBound || typeof window === 'undefined') return;
|
||||
const resume = () => {
|
||||
const resume = (options: { resetAttempts?: boolean } = {}) => {
|
||||
void (async () => {
|
||||
await requeueRetriable();
|
||||
await requeueRetriable(options);
|
||||
await processQueue();
|
||||
})();
|
||||
};
|
||||
onSseEvent('event-opened', resume);
|
||||
onSseEvent('feed-delta', resume);
|
||||
// A reopen is a deliberate host action that changes the server's answer, so it's fair to
|
||||
// give parked items a fresh retry budget. A plain reconnect is not — that's the signal
|
||||
// that fires over and over on a flapping AP.
|
||||
onSseEvent('event-opened', () => resume({ resetAttempts: true }));
|
||||
onSseEvent('feed-delta', () => resume());
|
||||
sseBound = true;
|
||||
}
|
||||
bindSse();
|
||||
@@ -84,29 +170,129 @@ bindSse();
|
||||
* Flip transient `error` items (5xx / a network drop that got marked before we could
|
||||
* reclassify it) back to `pending` so a resume actually retries them. Terminal `blocked`
|
||||
* items (403/413) are left alone — retrying those never succeeds.
|
||||
*
|
||||
* This is the ONLY automatic retry path, and it is driven by events that fire constantly on
|
||||
* a bad network (`online`, and `feed-delta` on every SSE reconnect), so it must be the thing
|
||||
* that enforces the budget: an item is only requeued while it is under `MAX_AUTO_ATTEMPTS`
|
||||
* and past its backoff deadline. Beyond that it stays parked until the guest taps "Erneut".
|
||||
*
|
||||
* `resetAttempts` is for signals that are positive evidence the blocking condition changed
|
||||
* (the host reopening the event), where starting the budget over is warranted.
|
||||
*/
|
||||
async function requeueRetriable(): Promise<void> {
|
||||
const database = await getDb();
|
||||
async function requeueRetriable(options: { resetAttempts?: boolean } = {}): Promise<void> {
|
||||
const myUserId = getUserId();
|
||||
const all = await database.getAll(STORE_NAME);
|
||||
const all = await storeGetAll();
|
||||
const now = Date.now();
|
||||
const requeued = new Set<string>();
|
||||
let soonest: number | null = null;
|
||||
for (const entry of all) {
|
||||
if (entry.userId === myUserId && entry.status === 'error' && entry.blob) {
|
||||
if (entry.userId !== myUserId || entry.status !== 'error' || !entry.blob) continue;
|
||||
if (options.resetAttempts) {
|
||||
entry.attempts = 0;
|
||||
entry.nextAttemptAt = undefined;
|
||||
}
|
||||
if ((entry.attempts ?? 0) >= MAX_AUTO_ATTEMPTS) continue;
|
||||
if (entry.nextAttemptAt && entry.nextAttemptAt > now) {
|
||||
// Still cooling down — remember the earliest deadline so the sweep below can
|
||||
// come back for it without waiting for another `online`/reconnect to happen by.
|
||||
soonest = soonest === null ? entry.nextAttemptAt : Math.min(soonest, entry.nextAttemptAt);
|
||||
continue;
|
||||
}
|
||||
entry.status = 'pending';
|
||||
entry.error = undefined;
|
||||
await database.put(STORE_NAME, entry);
|
||||
}
|
||||
entry.nextAttemptAt = undefined;
|
||||
await storePut(entry);
|
||||
requeued.add(entry.id);
|
||||
}
|
||||
if (soonest !== null) scheduleRetrySweep(soonest - now);
|
||||
queueItems.update((items) =>
|
||||
items.map((item) =>
|
||||
item.status === 'error'
|
||||
requeued.has(item.id)
|
||||
? { ...item, status: 'pending' as const, progress: 0, error: undefined }
|
||||
: item
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function getDb(): Promise<IDBPDatabase> {
|
||||
if (db) return db;
|
||||
// A single pending wake-up for the earliest backoff deadline. Without it an item that fails
|
||||
// while the network is nominally fine would sit until the next `online` event or SSE
|
||||
// reconnect — which on a stable-but-broken connection (captive portal, dead upstream) may
|
||||
// never come, leaving the guest with a red badge and no visible progress.
|
||||
let retrySweepTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let retrySweepAt = 0;
|
||||
|
||||
function scheduleRetrySweep(delayMs: number): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const at = Date.now() + Math.max(0, delayMs);
|
||||
if (retrySweepTimer && retrySweepAt <= at) return;
|
||||
if (retrySweepTimer) clearTimeout(retrySweepTimer);
|
||||
retrySweepAt = at;
|
||||
retrySweepTimer = setTimeout(
|
||||
() => {
|
||||
retrySweepTimer = null;
|
||||
void (async () => {
|
||||
await requeueRetriable();
|
||||
await processQueue();
|
||||
})();
|
||||
},
|
||||
Math.max(0, delayMs)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failed attempt on an entry and compute when the next automatic one may run.
|
||||
* Returns true once the budget is spent, so the caller can say so in the item's error text —
|
||||
* the queue row is the only place a guest can learn that nothing is retrying any more.
|
||||
*/
|
||||
function chargeAttempt(entry: QueueEntry): boolean {
|
||||
const attempts = (entry.attempts ?? 0) + 1;
|
||||
entry.attempts = attempts;
|
||||
if (attempts >= MAX_AUTO_ATTEMPTS) {
|
||||
entry.nextAttemptAt = undefined;
|
||||
return true;
|
||||
}
|
||||
const delay = Math.min(RETRY_BASE_DELAY_MS * 2 ** (attempts - 1), MAX_RETRY_DELAY_MS);
|
||||
entry.nextAttemptAt = Date.now() + delay;
|
||||
scheduleRetrySweep(delay);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Append the "auto-retry has stopped" hint once the budget is spent. */
|
||||
function withRetryHint(message: string, exhausted: boolean): string {
|
||||
return exhausted ? `${message} Tippe auf „Erneut“.` : message;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Persistence layer.
|
||||
//
|
||||
// IndexedDB is NOT guaranteed to be there: iOS private mode denies it outright, a storage
|
||||
// quota can be refused mid-event, and a second open tab holding an older DB version leaves
|
||||
// `openDB` pending forever. Every one of those used to reject or hang the very first `await`
|
||||
// of `addToQueue`, which stranded the composer with both buttons stuck on "Wird hochgeladen…".
|
||||
//
|
||||
// So the store degrades instead of failing: entries that cannot be persisted live in an
|
||||
// in-memory overlay, and the guest is told ONCE, in German, that their photos won't survive
|
||||
// closing the app. An upload that works but isn't crash-proof beats no upload at all.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
let dbPromise: Promise<IDBPDatabase | null> | null = null;
|
||||
const memoryEntries = new Map<string, QueueEntry>();
|
||||
let warnedNoPersistence = false;
|
||||
|
||||
function warnNoPersistence(): void {
|
||||
if (warnedNoPersistence) return;
|
||||
warnedNoPersistence = true;
|
||||
toast(
|
||||
'Fotos können auf diesem Gerät nicht zwischengespeichert werden. Die Uploads laufen weiter — lass die App bitte offen, bis sie fertig sind.',
|
||||
'warning',
|
||||
9000
|
||||
);
|
||||
}
|
||||
|
||||
async function openQueueDb(): Promise<IDBPDatabase | null> {
|
||||
if (dbPromise) return dbPromise;
|
||||
dbPromise = (async () => {
|
||||
try {
|
||||
// v1 → v2: add `userId` index so each guest's queue is isolated on shared devices.
|
||||
// Pre-existing entries (no userId) are dropped on upgrade; nothing useful was ever
|
||||
// persisted across logouts before this version.
|
||||
@@ -117,7 +303,7 @@ async function getDb(): Promise<IDBPDatabase> {
|
||||
// write failed and no upload ever fired). Bumping to 3 re-runs this upgrade for
|
||||
// those installs; the contains() guard recreates the missing store instead of
|
||||
// assuming createObjectStore only ever runs on a brand-new DB.
|
||||
db = await openDB(DB_NAME, 3, {
|
||||
const opening = openDB(DB_NAME, 3, {
|
||||
upgrade(database, oldVersion, _newVersion, transaction) {
|
||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||
database.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
@@ -128,9 +314,102 @@ async function getDb(): Promise<IDBPDatabase> {
|
||||
// Skipped when we just created the store, which is already empty.
|
||||
transaction.objectStore(STORE_NAME).clear();
|
||||
}
|
||||
},
|
||||
blocked() {
|
||||
// Another tab still holds an older version, so the upgrade cannot start. There
|
||||
// is nothing we can do from here except tell the guest which action unblocks it
|
||||
// — the timeout below keeps the queue usable meanwhile.
|
||||
toast(
|
||||
'EventSnap ist noch in einem anderen Tab geöffnet. Bitte schließe die anderen Tabs.',
|
||||
'warning',
|
||||
9000
|
||||
);
|
||||
}
|
||||
});
|
||||
return db;
|
||||
const database = await Promise.race([
|
||||
opening,
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), DB_OPEN_TIMEOUT_MS))
|
||||
]);
|
||||
if (!database) {
|
||||
warnNoPersistence();
|
||||
return null;
|
||||
}
|
||||
return database;
|
||||
} catch {
|
||||
// Private mode / quota denial / corrupted profile — fall back to memory.
|
||||
warnNoPersistence();
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
async function storeGet(id: string): Promise<QueueEntry | undefined> {
|
||||
const overlay = memoryEntries.get(id);
|
||||
if (overlay) return overlay;
|
||||
const database = await openQueueDb();
|
||||
if (!database) return undefined;
|
||||
try {
|
||||
return await database.get(STORE_NAME, id);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function storeGetAll(): Promise<QueueEntry[]> {
|
||||
const database = await openQueueDb();
|
||||
let persisted: QueueEntry[] = [];
|
||||
if (database) {
|
||||
try {
|
||||
persisted = await database.getAll(STORE_NAME);
|
||||
} catch {
|
||||
persisted = [];
|
||||
}
|
||||
}
|
||||
// The overlay shadows the persisted copy: once a row fails to write, memory holds the
|
||||
// newer state and the stale IndexedDB row must not resurrect it.
|
||||
return [...memoryEntries.values(), ...persisted.filter((entry) => !memoryEntries.has(entry.id))];
|
||||
}
|
||||
|
||||
async function storePut(entry: QueueEntry): Promise<void> {
|
||||
if (memoryEntries.has(entry.id)) {
|
||||
memoryEntries.set(entry.id, entry);
|
||||
return;
|
||||
}
|
||||
const database = await openQueueDb();
|
||||
if (database) {
|
||||
try {
|
||||
await database.put(STORE_NAME, entry);
|
||||
return;
|
||||
} catch {
|
||||
// Most often QuotaExceededError on a phone with a full photo library. Keep going
|
||||
// from memory rather than throwing out of addToQueue/uploadItem.
|
||||
}
|
||||
}
|
||||
memoryEntries.set(entry.id, entry);
|
||||
warnNoPersistence();
|
||||
}
|
||||
|
||||
async function storeDelete(id: string): Promise<void> {
|
||||
memoryEntries.delete(id);
|
||||
const database = await openQueueDb();
|
||||
if (!database) return;
|
||||
try {
|
||||
await database.delete(STORE_NAME, id);
|
||||
} catch {
|
||||
/* nothing to recover — the in-memory view is already authoritative */
|
||||
}
|
||||
}
|
||||
|
||||
async function storeClear(): Promise<void> {
|
||||
memoryEntries.clear();
|
||||
const database = await openQueueDb();
|
||||
if (!database) return;
|
||||
try {
|
||||
await database.clear(STORE_NAME);
|
||||
} catch {
|
||||
/* see storeDelete */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,8 +418,7 @@ async function getDb(): Promise<IDBPDatabase> {
|
||||
* blamed for) the previous guest's pending uploads.
|
||||
*/
|
||||
export async function clearQueue(): Promise<void> {
|
||||
const database = await getDb();
|
||||
await database.clear(STORE_NAME);
|
||||
await storeClear();
|
||||
queueItems.set([]);
|
||||
rateLimitRetryAt.set(null);
|
||||
}
|
||||
@@ -173,6 +451,13 @@ class TerminalError extends Error {
|
||||
*/
|
||||
class NetworkError extends Error {}
|
||||
|
||||
/**
|
||||
* The guest aborted this upload themselves (the ✕ on an in-flight row). A NetworkError
|
||||
* subclass because the transport outcome is identical — but it must NOT stop the batch or
|
||||
* count against the retry budget, since it says nothing about the connection.
|
||||
*/
|
||||
class CancelledError extends NetworkError {}
|
||||
|
||||
/**
|
||||
* The session is gone/expired (HTTP 401). This is emphatically NOT terminal: the blob is
|
||||
* KEPT (deleting it — the old behavior for any 4xx — destroyed a guest's staged photos the
|
||||
@@ -266,9 +551,8 @@ export function entryToQueueItem(entry: {
|
||||
}
|
||||
|
||||
export async function loadQueue(): Promise<void> {
|
||||
const database = await getDb();
|
||||
const myUserId = getUserId();
|
||||
const all = await database.getAll(STORE_NAME);
|
||||
const all = await storeGetAll();
|
||||
// Only surface entries that belong to the current user. Entries from a previous
|
||||
// guest on this device are filtered out (and would also be wiped on their next
|
||||
// explicit logout via `clearQueue`).
|
||||
@@ -295,7 +579,6 @@ export async function addToQueue(
|
||||
caption: string,
|
||||
hashtags: string
|
||||
): Promise<EnqueueResult> {
|
||||
const database = await getDb();
|
||||
const userId = getUserId();
|
||||
// Not authenticated — nothing to queue. Return the silent 'duplicate' outcome rather than
|
||||
// 'full' so the caller doesn't show a misleading "queue full" toast. Practically
|
||||
@@ -317,26 +600,27 @@ export async function addToQueue(
|
||||
if (dup) return 'duplicate';
|
||||
|
||||
// Cap the queue so stuck/blocked blobs can't grow IndexedDB without bound. When full,
|
||||
// evict the oldest item whose blob is already gone or unrecoverable — ONLY `done` (blob
|
||||
// deleted on success) or `blocked` (blob purged, terminal). NEVER evict `error`: those are
|
||||
// retryable and still hold a live blob (a network blip, or a locked/released upload that
|
||||
// resumes on reopen) — evicting one would silently lose a photo the fix deliberately kept.
|
||||
// If nothing is evictable, refuse and tell the caller so it can surface a "queue full"
|
||||
// message rather than dropping a photo the user believed was queued.
|
||||
// evict the oldest item whose blob is already gone or unrecoverable — `done` (blob deleted
|
||||
// on success), `blocked` (blob purged, terminal), or an `error` whose blob is missing.
|
||||
// An `error` item that STILL HOLDS a blob is never evicted: it is retryable (a network
|
||||
// blip, or a locked/released upload that resumes on reopen) and dropping it would silently
|
||||
// lose a photo the retry paths deliberately kept. If nothing is evictable, refuse and tell
|
||||
// the caller so it can surface a "queue full" message rather than dropping a photo the
|
||||
// user believed was queued.
|
||||
if (mine.length >= MAX_QUEUE_ITEMS) {
|
||||
const evictable = get(queueItems).find(
|
||||
(i) => i.userId === userId && (i.status === 'done' || i.status === 'blocked')
|
||||
);
|
||||
const evictable = await findEvictable(userId);
|
||||
if (evictable) {
|
||||
await database.delete(STORE_NAME, evictable.id);
|
||||
queueItems.update((items) => items.filter((it) => it.id !== evictable.id));
|
||||
await storeDelete(evictable);
|
||||
queueItems.update((items) => items.filter((it) => it.id !== evictable));
|
||||
} else {
|
||||
return 'full';
|
||||
}
|
||||
}
|
||||
|
||||
// This id is also the server-side idempotency key (`client_upload_id`), so it is minted
|
||||
// exactly ONCE per file here and reused by every retry — see uploadItem.
|
||||
const id = crypto.randomUUID();
|
||||
const entry = {
|
||||
const entry: QueueEntry = {
|
||||
id,
|
||||
userId,
|
||||
fileName: file.name,
|
||||
@@ -348,7 +632,7 @@ export async function addToQueue(
|
||||
status: 'pending',
|
||||
blob: file
|
||||
};
|
||||
await database.put(STORE_NAME, entry);
|
||||
await storePut(entry);
|
||||
|
||||
queueItems.update((items) => [
|
||||
...items,
|
||||
@@ -371,13 +655,16 @@ export async function addToQueue(
|
||||
}
|
||||
|
||||
export async function retryItem(id: string): Promise<void> {
|
||||
const database = await getDb();
|
||||
const entry = await database.get(STORE_NAME, id);
|
||||
const entry = await storeGet(id);
|
||||
if (!entry) return;
|
||||
|
||||
entry.status = 'pending';
|
||||
entry.error = undefined;
|
||||
await database.put(STORE_NAME, entry);
|
||||
// A deliberate tap outranks the automatic budget: reset the counter and clear any backoff
|
||||
// so a guest who watched their photo fail five times can still get it sent right now.
|
||||
entry.attempts = 0;
|
||||
entry.nextAttemptAt = undefined;
|
||||
await storePut(entry);
|
||||
|
||||
queueItems.update((items) =>
|
||||
items.map((item) =>
|
||||
@@ -388,25 +675,83 @@ export async function retryItem(id: string): Promise<void> {
|
||||
processQueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort an in-flight upload on the guest's command. The only escape hatch from an upload that
|
||||
* is technically alive but going nowhere — without it the queue's single slot stays occupied
|
||||
* until the stall watchdog fires (or the app is force-quit). The item parks as retryable with
|
||||
* its blob intact, so "Erneut" still works afterwards.
|
||||
*/
|
||||
export function cancelItem(id: string): void {
|
||||
const xhr = activeUploads.get(id);
|
||||
if (!xhr) return;
|
||||
cancelledUploads.add(id);
|
||||
xhr.abort();
|
||||
}
|
||||
|
||||
export async function removeItem(id: string): Promise<void> {
|
||||
const database = await getDb();
|
||||
await database.delete(STORE_NAME, id);
|
||||
// An item can be removed while its request is still on the wire — stop the transfer, or it
|
||||
// keeps pushing bytes for a row that no longer exists. `removedUploads` tells the abort
|
||||
// handler that the row is gone, so it doesn't race the delete below and resurrect it.
|
||||
if (activeUploads.has(id)) {
|
||||
removedUploads.add(id);
|
||||
cancelItem(id);
|
||||
}
|
||||
await storeDelete(id);
|
||||
queueItems.update((items) => items.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
export async function clearCompleted(): Promise<void> {
|
||||
const database = await getDb();
|
||||
const items = get(queueItems);
|
||||
for (const item of items) {
|
||||
if (item.status === 'done') {
|
||||
await database.delete(STORE_NAME, item.id);
|
||||
await storeDelete(item.id);
|
||||
}
|
||||
}
|
||||
queueItems.update((items) => items.filter((item) => item.status !== 'done'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the id of an item whose slot can be reclaimed: its blob is gone (`done`, `blocked`) or
|
||||
* was never there to begin with. The blob-less `error` case matters because several early
|
||||
* returns in `uploadItem` produce exactly that — an item with no bytes left to send, which can
|
||||
* never succeed on retry, yet used to hold a queue slot and a red FAB badge forever.
|
||||
*/
|
||||
async function findEvictable(userId: string): Promise<string | null> {
|
||||
for (const item of get(queueItems)) {
|
||||
if (item.userId !== userId) continue;
|
||||
if (item.status === 'done' || item.status === 'blocked') return item.id;
|
||||
if (item.status !== 'error') continue;
|
||||
const entry = await storeGet(item.id);
|
||||
if (!entry?.blob) return item.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let processing = false;
|
||||
|
||||
/** In-flight requests by item id, so a stuck upload can be aborted from the UI. */
|
||||
const activeUploads = new Map<string, XMLHttpRequest>();
|
||||
/** Ids whose abort was requested by the guest, to tell a ✕ apart from a dropped connection. */
|
||||
const cancelledUploads = new Set<string>();
|
||||
/** Ids aborted because the row itself is being deleted — their failure must persist nothing. */
|
||||
const removedUploads = new Set<string>();
|
||||
|
||||
/**
|
||||
* Send a guest whose session died back to /join.
|
||||
*
|
||||
* Duplicated from api.ts (where it is module-private) rather than imported: api.ts owns the
|
||||
* foreground half of the same rule and deliberately uses `window.location` over `goto`, since
|
||||
* a full document load resets every module-level store — the correct behaviour after a session
|
||||
* loss, and safe here because the queued blobs live in IndexedDB and survive it.
|
||||
*/
|
||||
function redirectToJoin(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const path = window.location.pathname;
|
||||
// Already on an auth screen — re-navigating would throw away half-typed recovery input.
|
||||
if (['/join', '/recover'].some((r) => path === r || path.startsWith(`${r}/`))) return;
|
||||
window.location.assign('/join');
|
||||
}
|
||||
|
||||
async function processQueue(): Promise<void> {
|
||||
if (processing) return;
|
||||
processing = true;
|
||||
@@ -445,6 +790,12 @@ async function processQueue(): Promise<void> {
|
||||
// stay retryable with blobs intact; the `event-opened` SSE resumes them.
|
||||
break;
|
||||
}
|
||||
if (e instanceof CancelledError) {
|
||||
// The guest cancelled THIS item, which says nothing about the connection —
|
||||
// keep draining so the rest of their photos still go out. (Checked before
|
||||
// NetworkError, which it extends.)
|
||||
continue;
|
||||
}
|
||||
if (e instanceof NetworkError) {
|
||||
// Connectivity dropped mid-flight. If offline the item is back to 'pending'
|
||||
// and the `online` listener resumes it; if the failure hit while nominally
|
||||
@@ -461,24 +812,26 @@ async function processQueue(): Promise<void> {
|
||||
}
|
||||
|
||||
async function uploadItem(id: string): Promise<void> {
|
||||
const database = await getDb();
|
||||
const entry = await database.get(STORE_NAME, id);
|
||||
const entry = await storeGet(id);
|
||||
if (!entry || !entry.blob) {
|
||||
updateItemStatus(id, 'error', 'Datei nicht gefunden.');
|
||||
// Persist the status, don't just paint it. `updateItemStatus` alone writes the store
|
||||
// only, so the row on disk still said `pending` — leaving a blob-less `error` item that
|
||||
// re-entered the drain loop on every resume, and which nothing could ever evict.
|
||||
await failEarly(id, entry, 'Datei nicht gefunden.');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
const currentUserId = getUserId();
|
||||
if (!token || !currentUserId) {
|
||||
updateItemStatus(id, 'error', 'Nicht angemeldet.');
|
||||
await failEarly(id, entry, 'Nicht angemeldet.');
|
||||
return;
|
||||
}
|
||||
// Defense-in-depth: if the device's signed-in user changed since this entry was
|
||||
// queued, refuse to upload it under the new identity. `loadQueue` already filters
|
||||
// by user; this guards the in-memory store path too.
|
||||
if (entry.userId && entry.userId !== currentUserId) {
|
||||
updateItemStatus(id, 'error', 'Anderer Nutzer angemeldet.');
|
||||
await failEarly(id, entry, 'Anderer Nutzer angemeldet.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -486,6 +839,13 @@ async function uploadItem(id: string): Promise<void> {
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
// Idempotency key, sent FIRST so the server can read it before it starts streaming the
|
||||
// body (multipart fields arrive in order). The queue item id is stable across every
|
||||
// retry, so a reply lost on the way back — the classic congested-wifi failure — makes
|
||||
// the server return the ORIGINAL upload instead of committing the photo a second time
|
||||
// and charging the guest's quota twice. Both 200 (deduped) and 201 (created) are
|
||||
// success; `classifyUploadStatus` already treats the whole 2xx range that way.
|
||||
formData.append('client_upload_id', entry.id);
|
||||
formData.append('file', entry.blob, entry.fileName);
|
||||
if (entry.caption) formData.append('caption', entry.caption);
|
||||
if (entry.hashtags) formData.append('hashtags', entry.hashtags);
|
||||
@@ -494,8 +854,32 @@ async function uploadItem(id: string): Promise<void> {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/v1/upload');
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
// Wall-clock backstop only — generous enough that a slow-but-alive LTE upload is
|
||||
// never killed by it. See MIN/MAX_UPLOAD_TIMEOUT_MS.
|
||||
xhr.timeout = Math.min(
|
||||
MAX_UPLOAD_TIMEOUT_MS,
|
||||
Math.max(MIN_UPLOAD_TIMEOUT_MS, (entry.fileSize / ASSUMED_MIN_BYTES_PER_SEC) * 1000)
|
||||
);
|
||||
|
||||
// Stall watchdog: a phone that roams between APs mid-upload leaves a half-open
|
||||
// connection that never errors and never completes. Only "no bytes moved" catches
|
||||
// that without also punishing a healthy slow link.
|
||||
let lastProgressAt = Date.now();
|
||||
let stalled = false;
|
||||
const stallTimer = setInterval(() => {
|
||||
if (Date.now() - lastProgressAt < STALL_TIMEOUT_MS) return;
|
||||
stalled = true;
|
||||
xhr.abort();
|
||||
}, STALL_CHECK_INTERVAL_MS);
|
||||
const settle = (fn: () => void) => {
|
||||
clearInterval(stallTimer);
|
||||
activeUploads.delete(id);
|
||||
fn();
|
||||
};
|
||||
activeUploads.set(id, xhr);
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
lastProgressAt = Date.now();
|
||||
if (e.lengthComputable) {
|
||||
const pct = Math.round((e.loaded / e.total) * 100);
|
||||
queueItems.update((items) =>
|
||||
@@ -503,6 +887,11 @@ async function uploadItem(id: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
});
|
||||
// Once the last byte is out the watchdog has nothing left to measure: the server may
|
||||
// legitimately sit on the request while it validates and stores the file, and no
|
||||
// progress event fires in that window. Aborting there would re-send a whole video
|
||||
// the server had already accepted, so hand over to `xhr.timeout` instead.
|
||||
xhr.upload.addEventListener('loadend', () => clearInterval(stallTimer));
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
const body = (() => {
|
||||
@@ -514,13 +903,15 @@ async function uploadItem(id: string): Promise<void> {
|
||||
})();
|
||||
switch (classifyUploadStatus(xhr.status)) {
|
||||
case 'success':
|
||||
resolve();
|
||||
// 201 = created, 200 = the server recognised `client_upload_id` and replayed
|
||||
// the original upload. Identical outcome for us: the photo is on the server.
|
||||
settle(resolve);
|
||||
break;
|
||||
case 'rate_limit': {
|
||||
// Back off and auto-resume when the window lifts (quota-full is a distinct
|
||||
// 413, classified 'terminal' below).
|
||||
const secs = typeof body?.retry_after_secs === 'number' ? body.retry_after_secs : 60;
|
||||
reject(new RateLimitError(secs));
|
||||
settle(() => reject(new RateLimitError(secs)));
|
||||
break;
|
||||
}
|
||||
case 'auth':
|
||||
@@ -528,13 +919,16 @@ async function uploadItem(id: string): Promise<void> {
|
||||
// this into the terminal bucket (which purges the blob) irrecoverably
|
||||
// destroyed queued photos whenever a sliding session lapsed or a host reset
|
||||
// the PIN — precisely when the `online` auto-resume kicks in.
|
||||
reject(new AuthError('Sitzung abgelaufen. Bitte melde dich erneut an.'));
|
||||
settle(() => reject(new AuthError('Sitzung abgelaufen. Bitte melde dich erneut an.')));
|
||||
break;
|
||||
case 'transient':
|
||||
// 408 (request timeout) behaves like a network blip so it stays retryable;
|
||||
// 5xx is a generic retryable error. Neither purges the blob.
|
||||
if (xhr.status === 408) reject(new NetworkError('Zeitüberschreitung'));
|
||||
else reject(new Error(body?.message || `HTTP ${xhr.status}`));
|
||||
settle(() =>
|
||||
xhr.status === 408
|
||||
? reject(new NetworkError('Zeitüberschreitung'))
|
||||
: reject(new Error(body?.message || `HTTP ${xhr.status}`))
|
||||
);
|
||||
break;
|
||||
case 'terminal': {
|
||||
// A REVERSIBLE lock (event closed / gallery released) is tagged
|
||||
@@ -545,27 +939,42 @@ async function uploadItem(id: string): Promise<void> {
|
||||
// portal) must NOT purge the blob — losing a photo is the worst outcome, and
|
||||
// 403 is the reversible-lock status here.
|
||||
if (isReversibleLock(xhr.status, body?.error)) {
|
||||
reject(new LockedError(body?.message || 'Event ist geschlossen.'));
|
||||
settle(() => reject(new LockedError(body?.message || 'Event ist geschlossen.')));
|
||||
break;
|
||||
}
|
||||
// Any other 4xx the server will keep rejecting (banned / quota).
|
||||
let msg = body?.message || 'Upload nicht möglich.';
|
||||
if (!body?.message && xhr.status === 413) msg = 'Speicher-Limit erreicht.';
|
||||
reject(new TerminalError(msg));
|
||||
settle(() => reject(new TerminalError(msg)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => reject(new NetworkError('Netzwerkfehler')));
|
||||
xhr.addEventListener('abort', () => reject(new NetworkError('Abgebrochen')));
|
||||
xhr.addEventListener('error', () => settle(() => reject(new NetworkError('Netzwerkfehler'))));
|
||||
xhr.addEventListener('timeout', () =>
|
||||
settle(() => reject(new NetworkError('Zeitüberschreitung')))
|
||||
);
|
||||
xhr.addEventListener('abort', () =>
|
||||
settle(() => {
|
||||
// Three ways to land here, and they need different answers: the guest tapped ✕
|
||||
// (don't stop the batch, don't spend retry budget), the watchdog killed a dead
|
||||
// connection, or the browser aborted on its own.
|
||||
if (cancelledUploads.delete(id)) reject(new CancelledError('Abgebrochen'));
|
||||
else if (stalled) reject(new NetworkError('Verbindung eingeschlafen'));
|
||||
else reject(new NetworkError('Abgebrochen'));
|
||||
})
|
||||
);
|
||||
xhr.send(formData);
|
||||
});
|
||||
|
||||
// Success — remove blob from IndexedDB, mark done
|
||||
entry.status = 'done';
|
||||
entry.error = undefined;
|
||||
entry.attempts = 0;
|
||||
entry.nextAttemptAt = undefined;
|
||||
delete entry.blob;
|
||||
await database.put(STORE_NAME, entry);
|
||||
await storePut(entry);
|
||||
updateItemStatus(id, 'done');
|
||||
// Refresh the per-user quota snapshot so the My Account widget reflects this
|
||||
// upload's bytes without a manual reload.
|
||||
@@ -574,7 +983,7 @@ async function uploadItem(id: string): Promise<void> {
|
||||
if (e instanceof RateLimitError) {
|
||||
// Reset to pending so it will be retried when the queue resumes
|
||||
entry.status = 'pending';
|
||||
await database.put(STORE_NAME, entry);
|
||||
await storePut(entry);
|
||||
updateItemStatus(id, 'pending');
|
||||
throw e; // Propagate to processQueue for scheduling
|
||||
}
|
||||
@@ -582,10 +991,20 @@ async function uploadItem(id: string): Promise<void> {
|
||||
// Event closed / gallery released, but a host can reopen — KEEP the blob and park
|
||||
// the item as retryable so it survives until reopen. The `event-opened` SSE
|
||||
// (bindSse) auto-resumes it; a manual "Erneut" also works. Never purge here.
|
||||
const exhausted = chargeAttempt(entry);
|
||||
entry.status = 'error';
|
||||
entry.error = e.message;
|
||||
await database.put(STORE_NAME, entry);
|
||||
updateItemStatus(id, 'error', e.message);
|
||||
entry.error = withRetryHint(e.message, exhausted);
|
||||
await storePut(entry);
|
||||
updateItemStatus(id, 'error', entry.error);
|
||||
// Say it out loud. The queue list is only mounted on /upload and the composer sends
|
||||
// the guest straight to /feed, so this message otherwise lands in a store that
|
||||
// nothing on screen renders — the photo just never appears and the guest, with no
|
||||
// operator to ask, assumes it worked.
|
||||
toast(
|
||||
`${entry.fileName}: ${e.message} Du findest den Upload über den Kamera-Button.`,
|
||||
'warning',
|
||||
6000
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
if (e instanceof AuthError) {
|
||||
@@ -596,9 +1015,27 @@ async function uploadItem(id: string): Promise<void> {
|
||||
// view loads `loadQueue` re-associates this entry and it can be retried/resumed.
|
||||
entry.status = 'error';
|
||||
entry.error = e.message;
|
||||
await database.put(STORE_NAME, entry);
|
||||
await storePut(entry);
|
||||
updateItemStatus(id, 'error', e.message);
|
||||
clearAuth();
|
||||
// And actually TAKE them to /join. `clearAuth` alone hides the bottom nav and the
|
||||
// FAB (both gated on `isAuthenticated`), route guards only run in onMount, and a
|
||||
// standalone PWA has no URL bar — so a 401 that arrived from a background upload
|
||||
// left the guest on a dead screen with no control that leads anywhere. api.ts does
|
||||
// this for every foreground request; a background one is no different.
|
||||
redirectToJoin();
|
||||
throw e;
|
||||
}
|
||||
if (e instanceof CancelledError) {
|
||||
// Aborted because the row is being deleted — writing the entry back here would
|
||||
// undo `removeItem`'s delete and the item would reappear on the next reload.
|
||||
if (removedUploads.delete(id)) throw e;
|
||||
// The guest's own ✕. Park it retryable with the blob intact and spend no retry
|
||||
// budget — they asked for the transfer to stop, not for the photo to be dropped.
|
||||
entry.status = 'error';
|
||||
entry.error = 'Abgebrochen. Tippe auf „Erneut“.';
|
||||
await storePut(entry);
|
||||
updateItemStatus(id, 'error', entry.error);
|
||||
throw e;
|
||||
}
|
||||
if (e instanceof NetworkError) {
|
||||
@@ -607,18 +1044,30 @@ async function uploadItem(id: string): Promise<void> {
|
||||
// Genuinely offline — keep the item pending; the `online` listener resumes it
|
||||
// automatically with no user action.
|
||||
entry.status = 'pending';
|
||||
await database.put(STORE_NAME, entry);
|
||||
await storePut(entry);
|
||||
updateItemStatus(id, 'pending');
|
||||
} else {
|
||||
// Network-level failure while the OS still reports online (server down,
|
||||
// connection refused, TLS error, captive portal). The `online` event will
|
||||
// NEVER fire in this case, so leaving it 'pending' would strand the item with
|
||||
// no retry path and no spinner. Mark it retryable 'error' so the user gets a
|
||||
// working "Erneut" button and a later real reconnect requeues it.
|
||||
// connection refused, TLS error, captive portal, or our stall watchdog firing on
|
||||
// a half-open connection). The `online` event will NEVER fire in this case, so
|
||||
// leaving it 'pending' would strand the item with no retry path and no spinner.
|
||||
// Mark it retryable 'error' so the user gets a working "Erneut" button, and let
|
||||
// the backoff sweep pick it up.
|
||||
const exhausted = chargeAttempt(entry);
|
||||
const msg = withRetryHint(`${e.message}. Erneut versuchen.`, exhausted);
|
||||
entry.status = 'error';
|
||||
entry.error = 'Netzwerkfehler. Erneut versuchen.';
|
||||
await database.put(STORE_NAME, entry);
|
||||
updateItemStatus(id, 'error', 'Netzwerkfehler. Erneut versuchen.');
|
||||
entry.error = msg;
|
||||
await storePut(entry);
|
||||
updateItemStatus(id, 'error', msg);
|
||||
// Only shout once the automatic attempts are used up: a single blip self-heals
|
||||
// seconds later and a toast for each one would just train guests to ignore them.
|
||||
if (exhausted) {
|
||||
toast(
|
||||
`${entry.fileName} konnte nicht hochgeladen werden. Tippe auf den Kamera-Button, um es erneut zu versuchen.`,
|
||||
'error',
|
||||
7000
|
||||
);
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
@@ -628,7 +1077,7 @@ async function uploadItem(id: string): Promise<void> {
|
||||
delete entry.blob;
|
||||
entry.status = 'blocked';
|
||||
entry.error = e.message;
|
||||
await database.put(STORE_NAME, entry);
|
||||
await storePut(entry);
|
||||
updateItemStatus(id, 'blocked', e.message);
|
||||
// Tell the user NOW. The queue list only lives on /upload, and the flow sends
|
||||
// them straight to /feed after staging a photo — so without this a rejected
|
||||
@@ -637,13 +1086,42 @@ async function uploadItem(id: string): Promise<void> {
|
||||
toast(`${entry.fileName}: ${e.message}`, 'error');
|
||||
return;
|
||||
}
|
||||
const msg = e instanceof Error ? e.message : 'Upload fehlgeschlagen.';
|
||||
// Everything else is a retryable server-side failure (5xx, an unparseable response).
|
||||
const exhausted = chargeAttempt(entry);
|
||||
const msg = withRetryHint(e instanceof Error ? e.message : 'Upload fehlgeschlagen.', exhausted);
|
||||
entry.status = 'error';
|
||||
entry.error = msg;
|
||||
await database.put(STORE_NAME, entry);
|
||||
await storePut(entry);
|
||||
updateItemStatus(id, 'error', msg);
|
||||
// Same reasoning as the network branch: nothing renders this message where the guest
|
||||
// is standing, so a 5xx would otherwise be completely invisible to them.
|
||||
if (exhausted) {
|
||||
toast(
|
||||
`${entry.fileName} konnte nicht hochgeladen werden. Tippe auf den Kamera-Button, um es erneut zu versuchen.`,
|
||||
'error',
|
||||
7000
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an item failed on BOTH sides of the queue before a single byte is sent. The in-memory
|
||||
* `updateItemStatus` on its own left the persisted row saying `pending`, so the next resume
|
||||
* picked the item up again — forever, since these paths produce items with no blob to send.
|
||||
*/
|
||||
async function failEarly(
|
||||
id: string,
|
||||
entry: QueueEntry | undefined,
|
||||
message: string
|
||||
): Promise<void> {
|
||||
if (entry) {
|
||||
entry.status = 'error';
|
||||
entry.error = message;
|
||||
await storePut(entry);
|
||||
}
|
||||
updateItemStatus(id, 'error', message);
|
||||
}
|
||||
|
||||
function updateItemStatus(id: string, status: QueueItem['status'], error?: string): void {
|
||||
queueItems.update((items) =>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import Toaster from '$lib/components/Toaster.svelte';
|
||||
import { showBottomNav } from '$lib/ui-store';
|
||||
import { isAuthenticated } from '$lib/auth';
|
||||
import { queueItems, isProcessing } from '$lib/upload-queue';
|
||||
import { queueItems, isProcessing, loadQueue, rateLimitRetryAt } from '$lib/upload-queue';
|
||||
import { privacyNote } from '$lib/privacy-note-store';
|
||||
import { refreshQuota } from '$lib/quota-store';
|
||||
import { onSseEvent } from '$lib/sse';
|
||||
@@ -17,7 +17,8 @@
|
||||
import type { MeContextDto } from '$lib/types';
|
||||
import { eventState, markClosed, markOpened, refreshEventState } from '$lib/event-state-store';
|
||||
import { setRole } from '$lib/role-store';
|
||||
import { loadEventConfig } from '$lib/event-config-store';
|
||||
import { loadEventConfig, commentsEnabled } from '$lib/event-config-store';
|
||||
import { isBanned } from '$lib/ban-store';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -31,6 +32,27 @@
|
||||
return Math.round((done / total) * 100);
|
||||
});
|
||||
|
||||
// Rate-limit countdown, mirrored from UploadQueue.svelte. That component is mounted ONLY on
|
||||
// /upload, but the composer sends the guest straight to /feed after staging — which is where
|
||||
// they invariably are when the 429 lands. The documented "Wird in Xs automatisch fortgesetzt"
|
||||
// reassurance was therefore unreachable in the exact situation it exists for: all the guest
|
||||
// saw was a stuck badge and a queue that appeared to have died.
|
||||
let rateLimitCountdown = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
const retryAt = $rateLimitRetryAt;
|
||||
if (!retryAt) {
|
||||
rateLimitCountdown = 0;
|
||||
return;
|
||||
}
|
||||
rateLimitCountdown = Math.ceil((retryAt - Date.now()) / 1000);
|
||||
const interval = setInterval(() => {
|
||||
rateLimitCountdown = Math.ceil((retryAt - Date.now()) / 1000);
|
||||
if (rateLimitCountdown <= 0) clearInterval(interval);
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
// With `ssr = false` the server ships an empty shell; `app.html` paints a boot
|
||||
// spinner to cover the JS-load gap. The app has now mounted and painted, so drop it.
|
||||
@@ -46,6 +68,15 @@
|
||||
// Hydrate cross-cutting stores once on boot if the user is already authenticated.
|
||||
// Page-level mounts will refresh again as needed.
|
||||
if (getToken()) {
|
||||
// Rehydrate the persisted upload queue from IndexedDB on EVERY boot, not just
|
||||
// when /upload happens to mount. `queueItems` is a module-level store that starts
|
||||
// empty, and the drain loop reads only that store — so without this, a guest whose
|
||||
// PWA is evicted mid-upload (iOS does this aggressively) and who reopens onto /feed
|
||||
// has pending blobs sitting in IndexedDB that nothing ever reads. The badge shows
|
||||
// 0, the photos never upload, and there is no symptom to act on. This is also what
|
||||
// arms the other resume paths below (`online`, `event-opened`, `feed-delta`), all
|
||||
// of which call processQueue() against the same store.
|
||||
void loadQueue();
|
||||
try {
|
||||
const ctx = await api.get<MeContextDto>('/me/context');
|
||||
privacyNote.set(ctx.privacy_note);
|
||||
@@ -56,6 +87,7 @@
|
||||
uploadsLocked: ctx.uploads_locked,
|
||||
galleryReleased: ctx.gallery_released
|
||||
});
|
||||
isBanned.set(ctx.is_banned);
|
||||
} catch {
|
||||
// Cross-cutting hydration on boot — failure is non-fatal; users without
|
||||
// a session land on /join anyway, and the per-page mount will retry.
|
||||
@@ -79,6 +111,20 @@
|
||||
// Malformed payload — discard; nothing actionable for the user.
|
||||
}
|
||||
}),
|
||||
// A host banned someone. Same contract as `pin-reset`: `data` is a JSON string of
|
||||
// `{ user_id: UUID }`, broadcast to everyone (it also evicts the banned user's cards
|
||||
// from every feed), so only OUR id means us. Without this, `isBanned` was seeded once
|
||||
// on boot and never moved — a guest banned mid-party kept the full UI and learned
|
||||
// about it one 403 toast at a time, which reads as the app being broken. The ban is
|
||||
// one-way here on purpose: an unban has no SSE, and the next `/me/context` clears it.
|
||||
onSseEvent('user-hidden', (data) => {
|
||||
try {
|
||||
const payload = JSON.parse(data) as { user_id: string };
|
||||
if (payload.user_id === getUserId()) isBanned.set(true);
|
||||
} catch {
|
||||
// Malformed payload — discard; nothing actionable for the user.
|
||||
}
|
||||
}),
|
||||
// Reflect a host closing/reopening uploads live, so the composer switches to a
|
||||
// locked state immediately instead of a guest finding out via a rejected upload.
|
||||
// `event-closed` fires for BOTH a plain lock and a gallery release (release ⇒ lock),
|
||||
@@ -117,6 +163,55 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Rate-limit countdown, rendered next to the progress bar for the same reason: the queue's
|
||||
own banner lives on /upload, which is not where the guest is standing when the 429 hits.
|
||||
Suppressed while the bottom nav is (i.e. on /upload), where UploadQueue already shows it
|
||||
in place and this would duplicate it over the sticky submit bar — and for a banned guest,
|
||||
whose read-only banner occupies the same slot and is the more relevant message. -->
|
||||
{#if $rateLimitRetryAt && rateLimitCountdown > 0 && $isAuthenticated && $showBottomNav && !$isBanned}
|
||||
<div
|
||||
role="status"
|
||||
class="fixed inset-x-0 z-40 mx-auto max-w-2xl px-4 pb-2"
|
||||
style="bottom: calc(3.5rem + env(safe-area-inset-bottom))"
|
||||
>
|
||||
<div
|
||||
class="rounded-xl bg-amber-50 px-4 py-2 text-center text-sm text-amber-800 shadow-lg ring-1 ring-amber-200 dark:bg-amber-950/90 dark:text-amber-300 dark:ring-amber-900"
|
||||
>
|
||||
Upload-Limit erreicht. Wird in {rateLimitCountdown} Sek. automatisch fortgesetzt.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Read-only notice for a banned guest. Rendered in the layout so it follows them across
|
||||
every route, and above the bottom nav so it is not hidden behind it. A ban blocks every
|
||||
write server-side but leaves the feed and the keepsake readable, so the guest needs to be
|
||||
told once — otherwise the upload, like and delete controls all look available and answer
|
||||
with a 403 toast each time, which reads as the app being broken.
|
||||
|
||||
The offset must be computed, not the fixed `bottom-16` it used to be: BottomNav is h-14
|
||||
(3.5rem) PLUS env(safe-area-inset-bottom), so on a notched iPhone the nav is ~90px tall and
|
||||
paints over the lower part of this banner (same z-40, later in the DOM). And it only renders
|
||||
alongside the nav — on /upload the nav is suppressed and this would float over the sticky
|
||||
submit bar; a banned guest can no longer open the composer anyway (see UploadSheet). -->
|
||||
{#if $isBanned && $isAuthenticated && $showBottomNav}
|
||||
<div
|
||||
role="status"
|
||||
class="fixed inset-x-0 z-40 mx-auto max-w-2xl px-4 pb-2"
|
||||
style="bottom: calc(3.5rem + env(safe-area-inset-bottom))"
|
||||
>
|
||||
<div
|
||||
class="rounded-xl bg-amber-50 px-4 py-3 text-center shadow-lg ring-1 ring-amber-200 dark:bg-amber-950/90 dark:ring-amber-900"
|
||||
>
|
||||
<p class="text-sm font-semibold text-amber-800 dark:text-amber-300">Nur-Lese-Modus</p>
|
||||
<p class="mt-0.5 text-xs text-amber-700 dark:text-amber-400">
|
||||
Du kannst alle Fotos ansehen und die Galerie später herunterladen. Hochladen, Liken{$commentsEnabled
|
||||
? ' und Kommentieren'
|
||||
: ''} sind für dich deaktiviert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- UploadSheet is always mounted for smooth enter/exit animation -->
|
||||
<UploadSheet />
|
||||
|
||||
|
||||
@@ -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,7 +141,18 @@
|
||||
vibrate(10);
|
||||
const hashtagsString = captionTags.join(',');
|
||||
let 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++;
|
||||
}
|
||||
@@ -119,6 +166,17 @@
|
||||
}
|
||||
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(
|
||||
'Der Upload konnte nicht gestartet werden. Bitte versuch es noch einmal.',
|
||||
'error',
|
||||
6000
|
||||
);
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
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