The upload queue gains `parkedFor`, so a photo rejected for a reason that cannot change on its own stops re-pushing itself. A ban used to come back as a generic `forbidden`, which purged the blob and moved the row to `blocked` — a terminal state with no retry button — so lifting a ban restored everything except the photo actually in flight. Ban and release are now distinct codes that keep the blob, charge no attempt, and tell the guest what has to happen. `releaseResolvedParks` drains them at boot from /me/context, because the live `user-shown` / `event-opened` events only reach a tab that was open when the host acted, and the usual sequence is the other way round. Two signals were firing on nothing. A filtered feed set `feedStale` on EVERY delta without deduping — and the delta cursor boundary is inclusive while sse.ts deliberately rewinds `lastEventTime`, so deltas routinely re-return rows already delivered. With the backstop polling every 60-120s, a guest who tapped a hashtag got a "Neue Beiträge" pill they could never clear, each tap costing a full filtered refetch. It now dedupes in both branches. The SSE liveness backstop had the mirror problem: `noteDelivered` harvested id, upload_id AND user_id from every payload, so by the time anything was deleted or anyone banned, their ids were already marked delivered from ordinary traffic about live content. The `deleted_ids` and `hidden_user_ids` clauses were false essentially always, leaving a half-open socket undetected while a host moderated into a feed nobody was listening to. Each event now records only the id its own clause tests. Also: /admin no longer bounces to /join on a cleared session — AUTH_ROUTES had the `/admin` prefix, which suppressed clearAuth() on the dashboard and let the login guard bounce back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1486 lines
63 KiB
TypeScript
1486 lines
63 KiB
TypeScript
import { openDB, type IDBPDatabase } from 'idb';
|
|
import { writable, get } from 'svelte/store';
|
|
import { getToken, getUserId, clearAuth, onClearAuth, onSetAuth } from './auth';
|
|
import { onSseEvent } from './sse';
|
|
import { refreshQuota } from './quota-store';
|
|
import { toast } from './toast-store';
|
|
|
|
export interface QueueItem {
|
|
id: string;
|
|
userId: string;
|
|
fileName: string;
|
|
fileSize: number;
|
|
/** Source file's last-modified ms; part of the dedup key so two distinct photos that
|
|
* happen to share a name and byte length don't collapse into one. */
|
|
lastModified?: number;
|
|
mimeType: string;
|
|
caption: string;
|
|
hashtags: string;
|
|
// 'error' is retryable (network / 5xx); 'blocked' is TERMINAL (a 4xx the server will
|
|
// keep rejecting — locked event, banned user, released gallery, quota full). Blocked
|
|
// items have had their blob purged from IndexedDB and offer no retry.
|
|
status: 'pending' | 'uploading' | 'done' | 'error' | 'blocked';
|
|
progress: number;
|
|
error?: string;
|
|
}
|
|
|
|
// Store does NOT hold file blobs — those stay in IndexedDB only
|
|
export const queueItems = writable<QueueItem[]>([]);
|
|
export const isProcessing = writable(false);
|
|
|
|
/** Set to the timestamp (ms) at which the rate-limit lifts, or null when clear. */
|
|
export const rateLimitRetryAt = writable<number | null>(null);
|
|
|
|
const DB_NAME = 'eventsnap-uploads';
|
|
const STORE_NAME = 'queue';
|
|
|
|
/** Hard cap on queued items per device — bounds IndexedDB growth from stuck blobs. */
|
|
const MAX_QUEUE_ITEMS = 100;
|
|
|
|
/**
|
|
* 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;
|
|
|
|
/**
|
|
* Quiet time after which an item's automatic-retry budget refills.
|
|
*
|
|
* The cap above is a rate limiter, and a rate limiter needs a window or it is a lifetime quota.
|
|
* Five attempts on a 5/10/20/40s ladder is ~75 seconds, so ANY outage longer than that — a venue
|
|
* AP brownout, a captive portal re-arming, an `app` container restart — permanently parked every
|
|
* in-flight photo behind a per-row button three taps deep that no guest will find.
|
|
*
|
|
* 10 minutes is chosen against the thing being protected: the concern is a hot loop re-sending a
|
|
* 200 MB video over a shared uplink, and one re-send per item per 10 minutes is not that. It is
|
|
* also comfortably longer than every outage the queue can ride out on its own.
|
|
*/
|
|
const RETRY_BUDGET_WINDOW_MS = 10 * 60_000;
|
|
|
|
/** 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;
|
|
|
|
/**
|
|
* Ceiling for the window AFTER the last byte is sent.
|
|
*
|
|
* `upload.progress` is silent there BY DEFINITION — the server is sniffing the magic bytes,
|
|
* committing the row and writing the response — so the bytes-moved signal above has nothing
|
|
* to measure and the watchdog used to simply switch off at `loadend`. That left the wall-clock
|
|
* `xhr.timeout` as the only remaining bound: 5 minutes for a photo, up to 60 for a video. A
|
|
* half-open socket in that window (phone roams wifi→LTE while the server is still writing a
|
|
* 500 MB video) pinned `processing` for that entire time, so NOTHING else in the guest's queue
|
|
* drained either.
|
|
*
|
|
* Sized above the backend's worst-case commit path, not above compression — compression is
|
|
* spawned after the response and does not hold it open.
|
|
*/
|
|
const RESPONSE_TIMEOUT_MS = 120_000;
|
|
|
|
/**
|
|
* Pure predicate behind the watchdog, extracted so the policy is unit-testable without
|
|
* standing up an XHR harness.
|
|
*/
|
|
export function shouldAbortForStall(
|
|
lastActivityAt: number,
|
|
now: number,
|
|
bodySent: boolean
|
|
): boolean {
|
|
const ceiling = bodySent ? RESPONSE_TIMEOUT_MS : STALL_TIMEOUT_MS;
|
|
return now - lastActivityAt > ceiling;
|
|
}
|
|
|
|
/**
|
|
* Normal timer jitter/throttling budget. A tick later than `interval + this` did not run
|
|
* because the page was suspended, not because it was merely late.
|
|
*/
|
|
const SUSPEND_TOLERANCE_MS = 2_000;
|
|
|
|
/**
|
|
* Total suspension credit a single silent stretch may ever be granted.
|
|
*
|
|
* A tick gap cannot distinguish "the page was frozen" from "the page is being throttled to one
|
|
* tick per minute while the network stack keeps running" — and in the second case the socket can
|
|
* be genuinely dead. Crediting every late tick meant the observed silence grew by only one
|
|
* interval per real minute, so a dead upload took ~18 minutes to detect while holding the
|
|
* queue's `processing` latch and blocking every other photo the guest had queued.
|
|
*
|
|
* Capping the total at one stall window bounds detection at roughly 2x the ceiling (~3 min)
|
|
* whatever the throttling pattern, while still absorbing the pocket-length freezes this exists
|
|
* for. Crucially the budget RESETS on real progress (see the `progress` handler), so a long
|
|
* upload that is actually moving survives any number of screen locks — only an upload that is
|
|
* silent AND suspended spends it, and that is exactly the case where being wrong is cheap: one
|
|
* re-send, bounded by MAX_AUTO_ATTEMPTS.
|
|
*/
|
|
export const MAX_SUSPEND_CREDIT_MS = STALL_TIMEOUT_MS;
|
|
|
|
/**
|
|
* Wall-clock the watchdog interval FAILED to cover because the page was suspended.
|
|
*
|
|
* `Date.now()` keeps advancing while a backgrounded phone is frozen, but `setInterval` does
|
|
* not run. So the first tick after a screen lock saw the entire sleep as "no bytes moved" and
|
|
* aborted a connection that was very possibly healthy — restarting a 200 MB video from byte
|
|
* zero, burning one of five PERMANENT auto-attempts (`chargeAttempt`), and breaking the drain
|
|
* loop for every other queued photo. A phone in a pocket between shots is the common case at a
|
|
* party, not an edge case.
|
|
*
|
|
* The interval is its own suspension detector: a tick scheduled 5s out that arrives 130s late
|
|
* means the page was frozen for ~125s, and that is exactly the window the watchdog had no
|
|
* right to measure. Deliberately chosen over a `visibilitychange` listener, which only covers
|
|
* the causes that happen to fire that event — a throttled-but-visible tab, a closed laptop lid
|
|
* and an occluded window all freeze timers without one. It also needs no listener, no
|
|
* module-level state, no SSR guard and no teardown.
|
|
*
|
|
* `performance.now()` was rejected as the clock source: Safari pauses it across system sleep
|
|
* on some paths while Chrome does not, which is precisely the non-uniformity that makes it
|
|
* unusable as the sole signal.
|
|
*
|
|
* Returns 0 for a normal tick, and 0 if the clock jumps BACKWARDS (an NTP correction) — that
|
|
* fails open, and the wall-clock `xhr.timeout` still bounds the request.
|
|
*/
|
|
export function suspendedSinceLastTick(
|
|
lastTickAt: number,
|
|
now: number,
|
|
intervalMs: number = STALL_CHECK_INTERVAL_MS
|
|
): number {
|
|
const overshoot = now - lastTickAt - intervalMs;
|
|
return overshoot > SUSPEND_TOLERANCE_MS ? overshoot : 0;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
/** When the most recent attempt failed. Lets the budget refill after a quiet spell. */
|
|
lastFailureAt?: number;
|
|
/**
|
|
* The guest stopped this transfer themselves (the ✕ on an in-flight row).
|
|
*
|
|
* `requeueRetriable` requeues any blob-bearing `error` item that is under budget and past its
|
|
* backoff — and a cancel deliberately charges NO attempt and sets NO backoff, so without this
|
|
* flag it matched on both counts and the upload restarted from byte zero within ~120s (an
|
|
* `online` event, or the SSE backstop's `feed-delta` poll). It then restarted forever, because
|
|
* a path that never charges an attempt can never exhaust the budget that would stop it. The row
|
|
* said "Abgebrochen. Tippe auf „Erneut“." the whole time, on a shared venue uplink.
|
|
*
|
|
* Cleared by `retryItem` — an explicit tap is the guest changing their mind.
|
|
*/
|
|
cancelled?: boolean;
|
|
/**
|
|
* Parked waiting for a specific host action, with the blob intact.
|
|
*
|
|
* Both values are reversible 403s whose answer cannot change without somebody deciding to
|
|
* change it, which makes automatic retries pure waste: they re-pushed the whole photo over
|
|
* cellular on every budget refill for the rest of the night while the guest was told to tap
|
|
* a camera button that 403s.
|
|
*
|
|
* - `'reopen'` — the gallery was released (`gallery_released`). Cleared by `event-opened`.
|
|
* - `'unban'` — the uploader is banned (`user_banned`). Cleared by `user-shown`.
|
|
*
|
|
* An explicit `retryItem` clears either: a deliberate tap is the guest asking us to try
|
|
* anyway, and if the condition still holds the next response simply re-parks it.
|
|
*/
|
|
parkedFor?: 'reopen' | 'unban';
|
|
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
|
|
// a reconnect only resumes if the user manually re-stages a file.
|
|
let onlineBound = false;
|
|
function bindOnline(): void {
|
|
if (onlineBound || typeof window === 'undefined') return;
|
|
window.addEventListener('online', () => {
|
|
void (async () => {
|
|
await requeueRetriable();
|
|
await processQueue();
|
|
})();
|
|
});
|
|
onlineBound = true;
|
|
}
|
|
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.
|
|
// We resume on TWO signals:
|
|
// - `event-opened`: the live reopen, when the SSE happens to be connected.
|
|
// - `feed-delta`: fired on every SSE (re)connect (see sse.ts `deltaFetchAndFan`). Because
|
|
// `event-opened` has no server-side replay and the SSE is disconnected on non-feed pages
|
|
// / backgrounded tabs, a reopen is often MISSED live — this catches up on the next
|
|
// reconnect. Retrying while still locked just re-parks the item (bounded, one per event).
|
|
// One-way import (sse.ts never imports this module).
|
|
let sseBound = false;
|
|
function bindSse(): void {
|
|
if (sseBound || typeof window === 'undefined') return;
|
|
const resume = (options: { resetAttempts?: boolean; release?: 'reopen' | 'unban' } = {}) => {
|
|
void (async () => {
|
|
await requeueRetriable(options);
|
|
await processQueue();
|
|
})();
|
|
};
|
|
// 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, release: 'reopen' }));
|
|
// An unban is the same kind of evidence, for the guest it names. `user-shown` is broadcast to
|
|
// everyone (every feed needs to un-hide that user's photos), so check it is actually us
|
|
// before resuming — otherwise one guest's unban would resume every OTHER banned guest's
|
|
// queue straight into another 403.
|
|
onSseEvent('user-shown', (payload) => {
|
|
let userId: unknown;
|
|
try {
|
|
userId = (JSON.parse(String(payload)) as { user_id?: unknown }).user_id;
|
|
} catch {
|
|
return;
|
|
}
|
|
if (userId && userId === getUserId()) resume({ resetAttempts: true, release: 'unban' });
|
|
});
|
|
onSseEvent('feed-delta', () => resume());
|
|
sseBound = true;
|
|
}
|
|
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.
|
|
*
|
|
* `release` names the host action that just happened, and un-parks only the items that were
|
|
* waiting for exactly that (`parkedFor`). A reopen must not resume a banned guest's queue, and
|
|
* an unban must not resume uploads into a released gallery — both would just 403 again.
|
|
*/
|
|
async function requeueRetriable(
|
|
options: { resetAttempts?: boolean; release?: 'reopen' | 'unban' } = {}
|
|
): Promise<void> {
|
|
const myUserId = getUserId();
|
|
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) continue;
|
|
// A cancel is the guest's decision, not a transient failure — never undo it automatically,
|
|
// not even on `resetAttempts` (the host reopening the event says nothing about whether
|
|
// this guest still wants this photo sent). Only `retryItem` clears it.
|
|
if (entry.cancelled) continue;
|
|
// Parked waiting on a host action. Only the matching signal releases it, so a plain
|
|
// reconnect leaves it alone instead of re-pushing the photo at a server whose answer
|
|
// cannot have changed. A manual "Erneut" bypasses this via `retryItem`.
|
|
if (entry.parkedFor) {
|
|
if (entry.parkedFor !== options.release) continue;
|
|
entry.parkedFor = undefined;
|
|
}
|
|
if (options.resetAttempts) {
|
|
entry.attempts = 0;
|
|
entry.nextAttemptAt = undefined;
|
|
}
|
|
// The budget is a RATE, not a lifetime allowance.
|
|
//
|
|
// Five attempts with a 5/10/20/40s ladder is ~75 seconds of failure end to end. A venue
|
|
// AP brownout, a re-armed captive portal or a backend restart lasting two minutes — with
|
|
// `navigator.onLine` still true the whole time, so none of it takes the offline path —
|
|
// therefore exhausted every automatic attempt and parked the item until the guest went
|
|
// FAB → sheet → "Warteschlange" → per-row "Erneut". Nobody does that; the photo simply
|
|
// never arrives. Refilling after a quiet spell keeps the bound that matters (no hot
|
|
// retry loop against a server that is genuinely down) while letting the evening recover
|
|
// from a blip on its own.
|
|
const lastFailureAt = entry.lastFailureAt ?? 0;
|
|
if (now - lastFailureAt > RETRY_BUDGET_WINDOW_MS) {
|
|
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;
|
|
entry.nextAttemptAt = undefined;
|
|
await storePut(entry);
|
|
requeued.add(entry.id);
|
|
}
|
|
if (soonest !== null) scheduleRetrySweep(soonest - now);
|
|
queueItems.update((items) =>
|
|
items.map((item) =>
|
|
requeued.has(item.id)
|
|
? { ...item, status: 'pending' as const, progress: 0, error: undefined }
|
|
: item
|
|
)
|
|
);
|
|
}
|
|
|
|
// 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;
|
|
entry.lastFailureAt = Date.now();
|
|
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.
|
|
// Version 3 self-heals installs corrupted by a shipped v1→v2 bug: that upgrade
|
|
// opened a *new* transaction inside the callback, which throws InvalidStateError
|
|
// ("A version change transaction is running") and aborts the whole upgrade —
|
|
// leaving some browsers at version 2 with NO 'queue' object store (so every queue
|
|
// 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.
|
|
const opening = openDB(DB_NAME, 3, {
|
|
upgrade(database, oldVersion, _newVersion, transaction) {
|
|
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
|
database.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
|
} else if (oldVersion < 2) {
|
|
// Existing v1 store: its entries predate the `userId` field, so drop them
|
|
// rather than misattribute them to whoever is signed in now. Reuse the
|
|
// active version-change transaction (never open a new one here — see above).
|
|
// 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
|
|
);
|
|
}
|
|
});
|
|
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 */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wipe every queue entry — both IndexedDB rows and the in-memory store. Called on
|
|
* explicit logout so a second guest using the same device doesn't inherit (or be
|
|
* blamed for) the previous guest's pending uploads.
|
|
*/
|
|
export async function clearQueue(): Promise<void> {
|
|
await storeClear();
|
|
queueItems.set([]);
|
|
rateLimitRetryAt.set(null);
|
|
}
|
|
|
|
class RateLimitError extends Error {
|
|
retryAfterSecs: number;
|
|
constructor(secs: number) {
|
|
super('rate_limited');
|
|
this.retryAfterSecs = secs;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A permanent, non-retryable failure — the server returned a 4xx (other than 429) that
|
|
* will never succeed on retry: uploads locked, user banned, gallery already released, or
|
|
* per-user quota full. The item is moved to the terminal `blocked` state and its blob is
|
|
* dropped from IndexedDB (no point keeping bytes we'll never send).
|
|
*/
|
|
class TerminalError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A connectivity failure — the request never reached the server (offline, DNS, dropped
|
|
* connection). The item stays `pending` (not `error`) so the `online` listener and the
|
|
* next `processQueue` pick it up automatically. This is what makes "the queue flushes
|
|
* when you're back online" actually true for a file staged with no signal.
|
|
*/
|
|
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
|
|
* instant their sliding session lapsed or a host reset their PIN, exactly when auto-resume
|
|
* fires). We mark the item retryable and route to re-auth; once the user signs back in with
|
|
* the same identity (via /recover), `loadQueue` re-associates these entries by userId and
|
|
* they resume. Distinct from `TerminalError` so the 4xx branch can't purge the blob.
|
|
*/
|
|
class AuthError extends Error {}
|
|
|
|
/**
|
|
* A REVERSIBLE 403 — the event is closed or the gallery was released, but a host can reopen
|
|
* it. Backend tags these with code `uploads_locked` (distinct from a permanent `forbidden`
|
|
* like a banned user). The blob is KEPT and the item parks as retryable; the `event-opened`
|
|
* SSE (or a manual retry) resumes it. Without this, a photo staged during a lock was purged
|
|
* as terminal and lost the moment the host reopened.
|
|
*/
|
|
class LockedError extends Error {}
|
|
|
|
/**
|
|
* The gallery has been RELEASED — `gallery_released`. A subclass of `LockedError` so every
|
|
* blob-preserving code path below keeps treating it as a reversible lock (the host *can* still
|
|
* reopen, and losing a photo is the worst outcome).
|
|
*
|
|
* What differs is the retry policy. A closed event is a pause the host means to undo, so
|
|
* auto-resuming on reconnect is right. A released gallery is the end of the event, and in the
|
|
* normal flow nobody reopens it — so auto-retrying re-pushes a multi-megabyte photo over
|
|
* cellular on every budget refill, forever, for an answer that will not change, while the guest
|
|
* is told to tap a camera button that 403s. Items parked this way sit still (see
|
|
* `awaitingReopen` in `requeueRetriable`) until a real `event-opened` arrives or the guest
|
|
* retries by hand.
|
|
*/
|
|
class ReleasedError extends LockedError {}
|
|
|
|
/**
|
|
* The uploader is banned — `user_banned`. Also a `LockedError` subclass, so the blob survives:
|
|
* `unban_user` exists and the host's confirm copy promises the photos come back, which the old
|
|
* generic-`forbidden` classification made impossible for anything mid-flight (blob purged, row
|
|
* moved to `blocked`, and `blocked` has no retry button).
|
|
*
|
|
* Parks still like `ReleasedError` and waits for `user-shown`.
|
|
*/
|
|
class BannedError extends LockedError {}
|
|
|
|
/** Retry policy for an upload response status. */
|
|
export type UploadOutcome = 'success' | 'rate_limit' | 'auth' | 'transient' | 'terminal';
|
|
|
|
/**
|
|
* Classify an upload HTTP status into a retry policy. Pure + exported so the
|
|
* data-loss-critical rules are unit-testable without an XHR/IndexedDB harness:
|
|
* - 401 → `auth` (session gone: KEEP the blob, re-auth — NEVER purge it)
|
|
* - 408 → `transient` (request timeout: retryable)
|
|
* - 429 → `rate_limit`(back off, auto-resume)
|
|
* - other 4xx → `terminal` (locked/banned/released/quota — the only case that purges)
|
|
* - 2xx → `success`; 5xx/other → `transient`
|
|
* The one rule that must never regress: a 401 is `auth`, not `terminal`.
|
|
*/
|
|
export function classifyUploadStatus(status: number): UploadOutcome {
|
|
if (status >= 200 && status < 300) return 'success';
|
|
if (status === 429) return 'rate_limit';
|
|
if (status === 401) return 'auth';
|
|
if (status === 408) return 'transient';
|
|
if (status >= 400 && status < 500) return 'terminal';
|
|
return 'transient';
|
|
}
|
|
|
|
/**
|
|
* Within the `terminal` bucket, decide whether a 4xx is a REVERSIBLE lock (keep the blob,
|
|
* park retryable for a host reopen) rather than a permanent rejection (purge the blob).
|
|
* Pure + exported so this data-loss-critical rule is unit-testable without an XHR harness.
|
|
*
|
|
* Reversible when:
|
|
* - the backend tagged it `uploads_locked` (event closed / gallery released — a host can reopen), OR
|
|
* - it's `quota_exceeded` (413). The per-user ceiling is `free_disk * tolerance / uploaders`,
|
|
* which MOVES: the numerator falls and the denominator rises all evening, so a guest who was
|
|
* comfortably under it at 20:00 is over it at 22:00 through nobody's action, and a host
|
|
* deleting content or the hourly reclaim can put them back under it just as passively. It is
|
|
* the textbook reversible lock, and treating it as permanent meant a 400 MB video was pushed
|
|
* across cellular in full and THEN deleted from IndexedDB — gone on both sides, unrecoverable
|
|
* without re-picking from the camera roll (impossible for an in-app camera capture). OR
|
|
* - it's ANY 403 we can't positively identify as a permanent ban (`forbidden`). An unparseable
|
|
* 403 body (proxy/WAF/captive portal) must NOT purge the blob — losing a photo is the worst
|
|
* outcome, and 403 is the reversible-lock status here.
|
|
* A `forbidden` 403 (banned) and every other 4xx (too large, wrong type) are permanent → purge.
|
|
*/
|
|
export function isReversibleLock(status: number, errorCode: unknown): boolean {
|
|
return (
|
|
errorCode === 'uploads_locked' ||
|
|
errorCode === 'gallery_released' ||
|
|
// A ban is lifted by `unban_user`, and the host UI promises the photos come back. Purging
|
|
// the blob here made that promise impossible to keep for anything mid-flight.
|
|
errorCode === 'user_banned' ||
|
|
errorCode === 'quota_exceeded' ||
|
|
(status === 403 && errorCode !== 'forbidden')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Within the reversible-lock bucket, is this the END of the event rather than a pause?
|
|
*
|
|
* `gallery_released` means the keepsake has been snapshotted. The blob is still kept (a host
|
|
* reopen is possible), but the item must stop auto-retrying — see `ReleasedError`. Pure +
|
|
* exported for the same reason as `isReversibleLock`: it decides whether a guest's photo gets
|
|
* re-pushed over cellular all night.
|
|
*/
|
|
export function isGalleryReleased(errorCode: unknown): boolean {
|
|
return errorCode === 'gallery_released';
|
|
}
|
|
|
|
/**
|
|
* Is this a ban (`user_banned`)? Reversible, blob kept — but like a release it will not lift on
|
|
* its own, so the item parks still and waits for the `user-shown` SSE rather than re-pushing the
|
|
* photo on every reconnect at a guest who is currently not allowed to upload.
|
|
*/
|
|
export function isUserBanned(errorCode: unknown): boolean {
|
|
return errorCode === 'user_banned';
|
|
}
|
|
|
|
/**
|
|
* Rehydrate a persisted IndexedDB entry into an in-memory `QueueItem`. Pure + exported so the
|
|
* field-mapping is unit-testable. The rule that must not regress: `lastModified` MUST be carried
|
|
* across — addToQueue's dedup keys on it, so an item restored from IndexedDB (page reload / PWA
|
|
* relaunch) with an undefined lastModified would fail to match a re-selection of the same file
|
|
* and silently queue it twice. `uploading` is downgraded to `pending` (an interrupted in-flight
|
|
* upload must resume, not stay stuck spinning).
|
|
*/
|
|
export function entryToQueueItem(entry: {
|
|
id: string;
|
|
userId: string;
|
|
fileName: string;
|
|
fileSize: number;
|
|
lastModified?: number;
|
|
mimeType: string;
|
|
caption?: string;
|
|
hashtags?: string;
|
|
status: QueueItem['status'] | 'uploading';
|
|
error?: string;
|
|
}): QueueItem {
|
|
return {
|
|
id: entry.id,
|
|
userId: entry.userId,
|
|
fileName: entry.fileName,
|
|
fileSize: entry.fileSize,
|
|
lastModified: entry.lastModified,
|
|
mimeType: entry.mimeType,
|
|
caption: entry.caption ?? '',
|
|
hashtags: entry.hashtags ?? '',
|
|
status: entry.status === 'uploading' ? 'pending' : entry.status,
|
|
progress: entry.status === 'done' ? 100 : 0,
|
|
error: entry.error
|
|
};
|
|
}
|
|
|
|
export async function loadQueue(): Promise<void> {
|
|
const myUserId = getUserId();
|
|
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`).
|
|
const items: QueueItem[] = all
|
|
.filter((entry) => entry.userId && entry.userId === myUserId)
|
|
.map(entryToQueueItem);
|
|
queueItems.set(items);
|
|
// Staged-but-unsent items from a prior session (queued offline, tab closed before
|
|
// reconnect) must resume now — otherwise the "queue flushes when you're back online"
|
|
// promise only holds if the user manually re-stages a file. Reclaim transient errors
|
|
// (a network drop from a prior session) so they retry instead of stalling.
|
|
void (async () => {
|
|
await requeueRetriable();
|
|
await processQueue();
|
|
})();
|
|
}
|
|
|
|
/**
|
|
* Release parked items whose blocking condition is already over, using the authoritative state
|
|
* the app fetches at boot.
|
|
*
|
|
* `parkedFor` is persisted to IndexedDB, but the only things that cleared it were the LIVE
|
|
* `event-opened` / `user-shown` SSE events. Those only reach a tab that is open at the moment the
|
|
* host acts — and the realistic sequence is the opposite one: the guest's photo is parked, they
|
|
* close the app at the end of the night, and the host lifts the ban or reopens uploads the next
|
|
* morning. Nothing then ever un-parked the item, so it sat in the queue forever while the toast
|
|
* had promised "wird gesendet, sobald die Sperre aufgehoben ist".
|
|
*
|
|
* Called once per boot with what `/me/context` and the event state actually say, so a park can
|
|
* never outlive the condition it was waiting on. Cheap: a no-op unless something is parked.
|
|
*/
|
|
export async function releaseResolvedParks(state: {
|
|
banned: boolean;
|
|
uploadsOpen: boolean;
|
|
}): Promise<void> {
|
|
// Each release is scoped to its own signal, exactly as the SSE path is: being unbanned says
|
|
// nothing about whether the gallery reopened, and vice versa.
|
|
if (!state.banned) {
|
|
await requeueRetriable({ resetAttempts: true, release: 'unban' });
|
|
}
|
|
if (state.uploadsOpen) {
|
|
await requeueRetriable({ resetAttempts: true, release: 'reopen' });
|
|
}
|
|
await processQueue();
|
|
}
|
|
|
|
/** Outcome of an `addToQueue` call, so the caller can tell the user when a file was NOT
|
|
* actually queued (deduped, or the queue is full of un-evictable in-flight items). */
|
|
export type EnqueueResult = 'queued' | 'duplicate' | 'full';
|
|
|
|
export async function addToQueue(
|
|
file: File,
|
|
caption: string,
|
|
hashtags: string
|
|
): Promise<EnqueueResult> {
|
|
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
|
|
// unreachable: the upload view sits behind a token guard.
|
|
if (!userId) return 'duplicate';
|
|
|
|
// Dedup: don't queue the same file twice while an identical one is still unsent
|
|
// (double-tap, re-added after a flaky reconnect). Matches on name+size+lastModified+user
|
|
// — including lastModified so two genuinely different photos sharing a name and byte
|
|
// length aren't silently collapsed into one.
|
|
const mine = get(queueItems).filter((i) => i.userId === userId);
|
|
const dup = mine.some(
|
|
(i) =>
|
|
i.fileName === file.name &&
|
|
i.fileSize === file.size &&
|
|
(i.lastModified ?? 0) === file.lastModified &&
|
|
(i.status === 'pending' || i.status === 'uploading')
|
|
);
|
|
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 — `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 = await findEvictable(userId);
|
|
if (evictable) {
|
|
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: QueueEntry = {
|
|
id,
|
|
userId,
|
|
fileName: file.name,
|
|
fileSize: file.size,
|
|
lastModified: file.lastModified,
|
|
mimeType: file.type,
|
|
caption,
|
|
hashtags,
|
|
status: 'pending',
|
|
blob: file
|
|
};
|
|
await storePut(entry);
|
|
|
|
queueItems.update((items) => [
|
|
...items,
|
|
{
|
|
id,
|
|
userId,
|
|
fileName: file.name,
|
|
fileSize: file.size,
|
|
lastModified: file.lastModified,
|
|
mimeType: file.type,
|
|
caption,
|
|
hashtags,
|
|
status: 'pending',
|
|
progress: 0
|
|
}
|
|
]);
|
|
|
|
processQueue();
|
|
return 'queued';
|
|
}
|
|
|
|
export async function retryItem(id: string): Promise<void> {
|
|
const entry = await storeGet(id);
|
|
if (!entry) return;
|
|
|
|
entry.status = 'pending';
|
|
entry.error = undefined;
|
|
// 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;
|
|
// And it is the one thing that un-cancels: tapping "Erneut" on a row the guest stopped
|
|
// themselves is them changing their mind.
|
|
entry.cancelled = false;
|
|
// Same for a parked item: an explicit tap is the guest asking us to try anyway (the host may
|
|
// have reopened or unbanned without this device seeing the SSE). If the condition still
|
|
// holds the next response re-parks it, so this cannot become a loop.
|
|
entry.parkedFor = undefined;
|
|
await storePut(entry);
|
|
|
|
queueItems.update((items) =>
|
|
items.map((item) =>
|
|
item.id === id ? { ...item, status: 'pending' as const, progress: 0, error: undefined } : item
|
|
)
|
|
);
|
|
|
|
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> {
|
|
// 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 items = get(queueItems);
|
|
for (const item of items) {
|
|
if (item.status === 'done') {
|
|
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;
|
|
isProcessing.set(true);
|
|
|
|
try {
|
|
while (true) {
|
|
// Offline: leave items 'pending' rather than burning through them into 'error'.
|
|
// The `online` listener re-enters here the moment connectivity returns.
|
|
if (typeof navigator !== 'undefined' && navigator.onLine === false) break;
|
|
|
|
const items = get(queueItems);
|
|
const next = items.find((item) => item.status === 'pending');
|
|
if (!next) break;
|
|
|
|
try {
|
|
await uploadItem(next.id);
|
|
} catch (e) {
|
|
if (e instanceof RateLimitError) {
|
|
// Keep all pending items as-is; schedule queue resume when limit lifts
|
|
const retryAt = Date.now() + e.retryAfterSecs * 1000;
|
|
rateLimitRetryAt.set(retryAt);
|
|
setTimeout(() => {
|
|
rateLimitRetryAt.set(null);
|
|
processQueue();
|
|
}, e.retryAfterSecs * 1000);
|
|
break;
|
|
}
|
|
if (e instanceof AuthError) {
|
|
// Dead session — stop the batch (every further item would 401 too). Items
|
|
// stay retryable with blobs intact; the re-auth flow (clearAuth) takes over.
|
|
break;
|
|
}
|
|
if (e instanceof LockedError) {
|
|
// Event locked — stop the batch (every item would hit the same lock). Items
|
|
// 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
|
|
// online it's now a retryable 'error'. Either way, stop hammering here.
|
|
break;
|
|
}
|
|
// Other errors are already handled inside uploadItem (marked 'error'/'blocked')
|
|
}
|
|
}
|
|
} finally {
|
|
processing = false;
|
|
isProcessing.set(false);
|
|
}
|
|
}
|
|
|
|
async function uploadItem(id: string): Promise<void> {
|
|
const entry = await storeGet(id);
|
|
if (!entry || !entry.blob) {
|
|
// 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) {
|
|
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) {
|
|
await failEarly(id, entry, 'Anderer Nutzer angemeldet.');
|
|
return;
|
|
}
|
|
|
|
updateItemStatus(id, 'uploading');
|
|
|
|
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);
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
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 lastTickAt = Date.now();
|
|
let creditSpent = 0;
|
|
let bodySent = false;
|
|
let stalled = false;
|
|
const stallTimer = setInterval(() => {
|
|
// Never fire twice. `xhr.abort()` on a request already in `readyState === DONE`
|
|
// emits NO `abort` event, so `settle()` would never run: the interval would keep
|
|
// running forever, re-aborting every 5s, and `activeUploads` would keep a stale
|
|
// entry so the guest's ✕ button silently did nothing.
|
|
if (stalled) return;
|
|
const now = Date.now();
|
|
// Credit back the window the page was frozen. The watchdog measures SILENCE, and
|
|
// a period in which it could not observe anything is not evidence of silence.
|
|
// Clamped to `now` so a progress event delivered right at resume cannot push the
|
|
// timestamp into the future.
|
|
// Bounded: see MAX_SUSPEND_CREDIT_MS. Without the cap, sustained background
|
|
// throttling credited back all but one interval on every tick, so a dead socket
|
|
// took ~18 minutes to notice while the queue's latch stayed held.
|
|
const credit = Math.min(
|
|
suspendedSinceLastTick(lastTickAt, now),
|
|
Math.max(0, MAX_SUSPEND_CREDIT_MS - creditSpent)
|
|
);
|
|
creditSpent += credit;
|
|
lastProgressAt = Math.min(now, lastProgressAt + credit);
|
|
lastTickAt = now;
|
|
if (!shouldAbortForStall(lastProgressAt, now, bodySent)) return;
|
|
stalled = true;
|
|
clearInterval(stallTimer);
|
|
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();
|
|
// Bytes moved: this stretch of silence is over, so the suspension budget refills.
|
|
creditSpent = 0;
|
|
if (e.lengthComputable) {
|
|
const pct = Math.round((e.loaded / e.total) * 100);
|
|
queueItems.update((items) =>
|
|
items.map((item) => (item.id === id ? { ...item, progress: pct } : item))
|
|
);
|
|
}
|
|
});
|
|
// 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 on the 90s no-progress ceiling there
|
|
// would re-send a whole video the server had already accepted.
|
|
//
|
|
// But switching the watchdog OFF here (which is what this used to do) handed the
|
|
// window to `xhr.timeout` alone — 5 to 60 minutes, during which a half-open socket
|
|
// holds `processing` and the guest's whole queue stops draining. So instead of
|
|
// disarming, widen: `shouldAbortForStall` switches to RESPONSE_TIMEOUT_MS once
|
|
// `bodySent` flips, which bounds the wedge at 2 minutes without ever firing on a
|
|
// server that is legitimately still working.
|
|
xhr.upload.addEventListener('loadend', () => {
|
|
bodySent = true;
|
|
lastProgressAt = Date.now();
|
|
});
|
|
|
|
xhr.addEventListener('load', () => {
|
|
const body = (() => {
|
|
try {
|
|
return JSON.parse(xhr.responseText);
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
switch (classifyUploadStatus(xhr.status)) {
|
|
case 'success':
|
|
// 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;
|
|
settle(() => reject(new RateLimitError(secs)));
|
|
break;
|
|
}
|
|
case 'auth':
|
|
// Session expired/revoked. NOT terminal — keep the blob and re-auth. Lumping
|
|
// 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.
|
|
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.
|
|
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
|
|
// `uploads_locked` by the backend — keep the blob and park it retryable so
|
|
// a host reopen resumes it, instead of purging it like a permanent 4xx.
|
|
// Also treat ANY 403 we can't positively identify as permanent (`forbidden`
|
|
// = banned) as reversible: an unparseable 403 body (proxy/WAF/captive
|
|
// 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)) {
|
|
const msg = body?.message || 'Event ist geschlossen.';
|
|
settle(() =>
|
|
reject(
|
|
isGalleryReleased(body?.error)
|
|
? new ReleasedError(msg)
|
|
: isUserBanned(body?.error)
|
|
? new BannedError(msg)
|
|
: new LockedError(msg)
|
|
)
|
|
);
|
|
break;
|
|
}
|
|
// Any other 4xx the server will keep rejecting (banned, too large, wrong
|
|
// type). Quota is NOT here any more — it moves with free disk and the
|
|
// uploader count, so it is a reversible lock handled above.
|
|
let msg = body?.message || 'Upload nicht möglich.';
|
|
if (!body?.message && xhr.status === 413) msg = 'Speicher-Limit erreicht.';
|
|
settle(() => reject(new TerminalError(msg)));
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
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'));
|
|
})
|
|
);
|
|
// `send` can throw SYNCHRONOUSLY — most plausibly on a phone whose OS purged the
|
|
// backing store for the blob, leaving a neutered File. The executor would turn that
|
|
// into a rejection and `uploadItem` would recover, but `settle()` never runs: the
|
|
// stall interval leaks and `activeUploads` keeps a stale entry, so the ✕ button on
|
|
// that item stops working for the rest of the session.
|
|
try {
|
|
xhr.send(formData);
|
|
} catch {
|
|
settle(() => reject(new NetworkError('Netzwerkfehler')));
|
|
}
|
|
});
|
|
|
|
// Success — remove blob from IndexedDB, mark done
|
|
entry.status = 'done';
|
|
entry.error = undefined;
|
|
entry.attempts = 0;
|
|
entry.nextAttemptAt = undefined;
|
|
delete entry.blob;
|
|
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.
|
|
void refreshQuota();
|
|
} catch (e) {
|
|
if (e instanceof RateLimitError) {
|
|
// Reset to pending so it will be retried when the queue resumes
|
|
entry.status = 'pending';
|
|
await storePut(entry);
|
|
updateItemStatus(id, 'pending');
|
|
throw e; // Propagate to processQueue for scheduling
|
|
}
|
|
if (e instanceof LockedError) {
|
|
// A condition that can lift without the guest doing anything: the event is closed or
|
|
// the gallery released (a host can reopen), or the storage quota is currently
|
|
// exceeded (it moves with free disk and the uploader count, and a host takedown or
|
|
// the hourly media reclaim puts them back under it). KEEP the blob and park the item
|
|
// as retryable so it survives until then. `event-opened` and the `feed-delta`
|
|
// reconnect both auto-resume it; a manual "Erneut" also works. Never purge here.
|
|
// Neither a release nor a ban lifts on its own, so charging an attempt — and with it
|
|
// the backoff ladder and budget refill that drive automatic re-pushes — buys nothing
|
|
// but bandwidth. Park those still and wait for the host action.
|
|
const parkedFor: 'reopen' | 'unban' | undefined =
|
|
e instanceof ReleasedError ? 'reopen' : e instanceof BannedError ? 'unban' : undefined;
|
|
const exhausted = parkedFor ? false : chargeAttempt(entry);
|
|
entry.status = 'error';
|
|
entry.parkedFor = parkedFor;
|
|
entry.error = parkedFor ? e.message : 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.
|
|
//
|
|
// For a release, be explicit that the photo is NOT lost and NOT coming back on its
|
|
// own — that is the whole difference the guest needs to act on.
|
|
const parkedHint =
|
|
parkedFor === 'reopen'
|
|
? ' Dein Foto bleibt auf diesem Gerät gespeichert — frag die Gastgeber, ob sie die Galerie noch einmal öffnen.'
|
|
: parkedFor === 'unban'
|
|
? ' Dein Foto bleibt auf diesem Gerät gespeichert und wird gesendet, sobald die Sperre aufgehoben ist.'
|
|
: ' Du findest den Upload über den Kamera-Button.';
|
|
toast(`${entry.fileName}: ${e.message}${parkedHint}`, 'warning', parkedFor ? 9000 : 6000);
|
|
throw e;
|
|
}
|
|
if (e instanceof AuthError) {
|
|
// Dead session — KEEP the blob (never purge on auth failure) and mark retryable so
|
|
// the file survives re-auth. Route through the app's single de-auth path (matches
|
|
// api.ts's 401 handling). The blob persists in IndexedDB keyed by userId; once the
|
|
// user signs back in with the SAME identity (via /recover), the next time the upload
|
|
// view loads `loadQueue` re-associates this entry and it can be retried/resumed.
|
|
entry.status = 'error';
|
|
entry.error = e.message;
|
|
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.
|
|
// `cancelled` is what keeps the automatic path from immediately undoing that; see the
|
|
// field's docstring. Without it the ✕ was purely cosmetic.
|
|
entry.status = 'error';
|
|
entry.cancelled = true;
|
|
entry.error = 'Abgebrochen. Tippe auf „Erneut“.';
|
|
await storePut(entry);
|
|
updateItemStatus(id, 'error', entry.error);
|
|
throw e;
|
|
}
|
|
if (e instanceof NetworkError) {
|
|
const offline = typeof navigator !== 'undefined' && navigator.onLine === false;
|
|
if (offline) {
|
|
// Genuinely offline — keep the item pending; the `online` listener resumes it
|
|
// automatically with no user action.
|
|
entry.status = 'pending';
|
|
await storePut(entry);
|
|
updateItemStatus(id, 'pending');
|
|
} else {
|
|
// Network-level failure while the OS still reports online (server down,
|
|
// 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 = 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;
|
|
}
|
|
if (e instanceof TerminalError) {
|
|
// Permanent rejection — drop the blob (we'll never resend it) and mark blocked
|
|
// so the UI shows a clear reason and offers no retry.
|
|
delete entry.blob;
|
|
entry.status = 'blocked';
|
|
entry.error = e.message;
|
|
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
|
|
// upload was silently swallowed: the blob is gone, the FAB badge drops exactly
|
|
// as if it had succeeded, and the photo simply never appears.
|
|
toast(`${entry.fileName}: ${e.message}`, 'error');
|
|
return;
|
|
}
|
|
// 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 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) =>
|
|
items.map((item) =>
|
|
item.id === id
|
|
? {
|
|
...item,
|
|
status,
|
|
progress: status === 'done' ? 100 : status === 'pending' ? 0 : item.progress,
|
|
error
|
|
}
|
|
: item
|
|
)
|
|
);
|
|
}
|