diff --git a/frontend/src/lib/sse.ts b/frontend/src/lib/sse.ts index b0bc777..da5408b 100644 --- a/frontend/src/lib/sse.ts +++ b/frontend/src/lib/sse.ts @@ -367,11 +367,18 @@ function noteDelivered(eventName: string, data: string): void { // Mirrors the three clauses in `carried`: uploads by upload id, deletions by upload id, // ban-hides by user id. const relevant = - eventName === 'new-upload' || eventName === 'upload-processed' + eventName === 'new-upload' ? p.id - : eventName === 'upload-deleted' + : // `upload-processed` carries `upload_id`, not `id` (see compression.rs) — reading + // `p.id` recorded nothing at all, so this branch quietly did the opposite of what + // the comment above claims. Harmless today only because the delta cursor is + // anchored by `new-upload`, which is not a property worth depending on. + eventName === 'upload-processed' || eventName === 'upload-deleted' ? (p.upload_id ?? p.id) - : eventName === 'user-hidden' || eventName === 'user-shown' + : // Only `user-hidden` has a matching clause in `carried` (`hidden_user_ids` comes + // from `uploads_hidden = TRUE`). Recording an UNBAN's user id could only ever + // suppress a later genuine signal, so it is deliberately not recorded. + eventName === 'user-hidden' ? p.user_id : undefined; if (typeof relevant === 'string') rememberDelivered(relevant); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 36c6d56..229dca7 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -19,7 +19,7 @@ import { privacyNote } from '$lib/privacy-note-store'; import { refreshQuota } from '$lib/quota-store'; import { onSseEvent } from '$lib/sse'; - import { api } from '$lib/api'; + import { api, ApiError } from '$lib/api'; import type { MeContextDto } from '$lib/types'; import { eventState, markClosed, markOpened, refreshEventState } from '$lib/event-state-store'; import { setRole } from '$lib/role-store'; @@ -104,9 +104,44 @@ banned: ctx.is_banned, uploadsOpen: !ctx.uploads_locked && !ctx.gallery_released }); - } catch { + } catch (err) { // Cross-cutting hydration on boot — failure is non-fatal; users without // a session land on /join anyway, and the per-page mount will retry. + // + // But ONE consequence is not recoverable by a per-page mount: the park release + // above. A parked photo's other two release paths are the live `user-shown` / + // `event-opened` SSE events, and the routes that open a stream are /feed, /diashow, + // /export, /host and /admin — NOT /upload, which is exactly where the toast sends + // the guest to watch their queue. So on venue wifi, the condition this branch + // exists for, one failed request could strand the photo for the whole session with + // the queue row still reading "Du bist gesperrt.". Retry once, briefly. + // + // NOT on a 401. `api.get` already answered that one by calling `clearAuth()` and + // redirecting to /join, so there is no session left to hydrate and a second attempt + // can only fire a SECOND redirect — two seconds later, by which time the guest may + // have navigated somewhere else. Retry the transient case this exists for (offline, + // 5xx, a dropped request) and nothing else. + // + // A guard, not an early `return`: everything below this block — `refreshQuota` + // and, outside it, every SSE listener registration — still has to run. + const worthRetrying = !(err instanceof ApiError && err.status === 401); + if (worthRetrying) { + try { + await new Promise((r) => setTimeout(r, 2000)); + const ctx = await api.get('/me/context'); + isBanned.set(ctx.is_banned); + eventState.set({ + uploadsLocked: ctx.uploads_locked, + galleryReleased: ctx.gallery_released + }); + void releaseResolvedParks({ + banned: ctx.is_banned, + uploadsOpen: !ctx.uploads_locked && !ctx.gallery_released + }); + } catch { + // Still down. The "Erneut" button on the parked row remains the way back. + } + } } void refreshQuota(); }