From 51e55b1ace9bf527988dfef178fa691068d86e67 Mon Sep 17 00:00:00 2001 From: "Fabian Hamm (Privat)" Date: Mon, 3 Aug 2026 18:36:05 +0200 Subject: [PATCH] fix(feed): survive a bad network, and let the lightbox actually browse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things a guest on congested venue wifi would have hit, and one they would have hit immediately. A FAILED FEED LOAD CLAIMED THE GALLERY WAS EMPTY. `loadFeed` caught, toasted for five seconds and left `uploads` empty, so the page fell through to "Noch keine Fotos. Tippe auf den Kamera-Button unten!" — the most likely first impression at the party, and a lie. There is now a distinct error state with "Erneut laden". Refreshes suppressed the toast entirely, so pull-to-refresh and the "Neue Beiträge" pill failed in total silence; they now report, and the pill survives its own failure instead of clearing before the request. THE FILTER-EMPTY STATE WAS DEAD CODE. With filtering server-side `displayUploads` is a plain alias of `uploads`, so the grid's "Keine Treffer für die gewählten Filter." plus its reset button sat behind an identical earlier branch and could never render — a guest tapping a chip with no matches was told to go take a photo. SSE COULD FREEZE THE FEED FOR THE WHOLE EVENING. Nothing in the feed ever refetched on a timer; every update path was triggered exclusively by a stream event. Behind a proxy that buffers `text/event-stream` `onopen` never fires, so the guest saw only the photos that were on screen when they arrived; and a socket left half-open by an AP roam is worse, because `connectSse` early-returns on a non-null EventSource and nothing ever reconnects. A pure silence timer is not implementable — the backend sends keep-alives as SSE comments, which the EventSource parser discards without dispatching — so liveness is established on evidence instead: a jittered 60-120s `/feed/delta` backstop that reconnects when a poll returns content the stream never delivered. The ticket round-trip also seeds the delta cursor before the EventSource is created, so the backstop has a `since` even if `onopen` never fires. THE PILL COLLAPSED A DEEPLY-SCROLLED FEED to 20 items and dumped the guest at an arbitrary scroll position — the exact yank the pill exists to avoid. It merges now. The refresh debounce was 800ms + jitter, which during a burst is roughly one feed query per client every two seconds; at 100 guests that approaches the 60/min per-user limit, and the resulting 429s were swallowed by a bare `catch {}`, so the feed would simply stop updating with no signal. Now 8s + jitter, coalescing, and skipped entirely while the page is hidden. Not one `` in the app had an `onerror`. `pickMediaUrl` falls back to the original whenever preview and thumbnail are null — i.e. for everything still compressing, which during a burst is the top of the feed — so a 404 there rendered an empty grey box with `alt=""`, not even a message. Each now retries once, then shows the placeholder. The lightbox had no swipe, no prev/next and no arrow keys, so browsing 300 photos meant closing and reopening the modal for every one — while FEATURES.md and USER_JOURNEYS both claimed swipe shipped. It now has chevrons (44px, German aria-labels, hidden at the ends), arrow keys, and horizontal swipe, with focus handed to the surviving control so a disappearing chevron can't drop focus to ``. Comment deletion was a ~14px `✕` four pixels from the text that deleted permanently on one tap, while deleting a POST two components away goes through a ConfirmSheet; it now matches. `feed-filter.ts` and its test are deleted — with the server filtering, they were dead. Co-Authored-By: Claude Opus 5 --- frontend/src/lib/api.ts | 30 ++ .../src/lib/components/FeedListCard.svelte | 59 ++- .../src/lib/components/LightboxModal.svelte | 313 +++++++++++- .../src/lib/components/VirtualFeed.svelte | 84 ++- frontend/src/lib/event-config-store.ts | 19 +- frontend/src/lib/feed-filter.test.ts | 59 --- frontend/src/lib/feed-filter.ts | 35 -- frontend/src/lib/quota-store.ts | 7 + frontend/src/lib/sse.ts | 181 ++++++- frontend/src/lib/types.ts | 2 + frontend/src/routes/feed/+page.svelte | 479 ++++++++++++++---- 11 files changed, 1025 insertions(+), 243 deletions(-) delete mode 100644 frontend/src/lib/feed-filter.test.ts delete mode 100644 frontend/src/lib/feed-filter.ts diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index bdf73ad..87d7e6a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -15,6 +15,25 @@ export class ApiError extends Error { const TIMEOUT_MS = 20_000; +/** Pages that ARE the recovery flow — redirecting from them would loop. */ +const AUTH_ROUTES = ['/join', '/recover']; + +/** + * Send a guest whose session died back to the join screen. + * + * Deliberately uses `window.location` rather than SvelteKit's `goto`: `toast-store` already + * imports `ApiError` from this module, so pulling a store or `$app/navigation` in here would + * create an import cycle. A full document load is also the more correct behaviour after a + * session loss — it resets every module-level store, which is exactly what we want, and the + * queued upload blobs live in IndexedDB so they survive it. + */ +function redirectToJoin(): void { + if (typeof window === 'undefined') return; + const path = window.location.pathname; + if (AUTH_ROUTES.some((r) => path === r || path.startsWith(`${r}/`))) return; + window.location.assign('/join'); +} + async function request(method: string, path: string, body?: unknown): Promise { const headers: Record = {}; const token = getToken(); @@ -69,6 +88,17 @@ async function request(method: string, path: string, body?: unknown): Promise // simply get a 403 "gesperrt" toast on writes. if (res.status === 401) { clearAuth(); + // Clearing auth alone leaves the guest stranded: the bottom nav and FAB are + // gated on `isAuthenticated` so they simply vanish, route guards only run in + // onMount (which does not re-run), and a standalone PWA has no URL bar — so + // there is no way back to /join. Real triggers mid-event are a host PIN reset + // (which revokes that guest's sessions) and a redeployed JWT_SECRET. + // Queued upload blobs survive in IndexedDB and are picked up again after + // re-joining — via the `onSetAuth` hook in upload-queue.ts, NOT the boot-time + // call in +layout.svelte: this redirect lands on /join with no token, so the + // layout's `if (getToken())` skips it, and the subsequent recover navigates + // with `goto()`, which never re-runs `onMount`. + redirectToJoin(); } const d = (data ?? {}) as { error?: string; message?: string }; throw new ApiError( diff --git a/frontend/src/lib/components/FeedListCard.svelte b/frontend/src/lib/components/FeedListCard.svelte index dc8aa7a..18fb122 100644 --- a/frontend/src/lib/components/FeedListCard.svelte +++ b/frontend/src/lib/components/FeedListCard.svelte @@ -27,6 +27,53 @@ const mediaSrc = $derived(pickMediaUrl($dataMode, upload)); + // Video cards show a poster if one exists; `pickMediaUrl` is not used here because it is + // mime-agnostic and would hand back the raw MP4 for an . + const posterSrc = $derived(upload.thumbnail_url ?? upload.preview_url ?? ''); + + // ── Media fallback ─────────────────────────────────────────────────────────────── + // + // A failed rendered as an empty grey box with `alt=""` — no icon, no message, + // nothing to tap. And the failure is routine rather than exotic: `pickMediaUrl` falls + // back to `/original` whenever preview AND thumbnail are still null, i.e. for everything + // still compressing, which in a newest-first feed is the card at the top during every + // burst. That also makes the cause usually TRANSIENT, so one delayed retry of the same + // URL recovers most of them; the nonce exists because the browser would otherwise replay + // its cached failure rather than re-request. Two strikes and we fall through to the + // placeholder this card already draws for "no derivative yet". + const MEDIA_RETRY_MS = 4000; + let mediaRetryNonce = $state(0); + let mediaFailed = $state(false); + let mediaRetryTimer: ReturnType | null = null; + + function withNonce(url: string): string { + if (!url || !mediaRetryNonce) return url; + return `${url}${url.includes('?') ? '&' : '?'}r=${mediaRetryNonce}`; + } + + const displaySrc = $derived(withNonce(isVideo(upload.mime_type) ? posterSrc : mediaSrc)); + + // A url change — the SSE `upload-processed` swapping the original for a real preview is + // the common one — deserves a clean attempt rather than inheriting the old verdict. + $effect(() => { + void mediaSrc; + void posterSrc; + mediaRetryNonce = 0; + mediaFailed = false; + }); + + function handleMediaError() { + if (mediaRetryNonce) { + mediaFailed = true; + return; + } + if (mediaRetryTimer) clearTimeout(mediaRetryTimer); + mediaRetryTimer = setTimeout(() => { + mediaRetryTimer = null; + mediaRetryNonce = Date.now(); + }, MEDIA_RETRY_MS); + } + function relativeTime(iso: string, nowMs: number): string { const diff = nowMs - new Date(iso).getTime(); const mins = Math.floor(diff / 60000); @@ -61,6 +108,7 @@ // card mid-animation) so it can't fire against a stale component. onDestroy(() => { if (burstTimer) clearTimeout(burstTimer); + if (mediaRetryTimer) clearTimeout(mediaRetryTimer); }); @@ -124,13 +172,14 @@ {#if isVideo(upload.mime_type)}
- {#if upload.thumbnail_url || upload.preview_url} + {#if posterSrc && !mediaFailed} {/if}
@@ -143,17 +192,18 @@
- {:else if mediaSrc} + {:else if mediaSrc && !mediaFailed}
{:else} @@ -209,6 +259,7 @@ {#if $commentsEnabled}
-
+ + + {#if hasPrev} + + {/if} + {#if hasNext} + + {/if} + + +
(touchTracking = false)} + > {#if isVideo(upload.mime_type)} + {:else if mediaFailed} + +
+ + + +

Bild konnte nicht geladen werden.

+
{:else} {/if} @@ -211,18 +447,20 @@
-
-
- + +
+ {upload.uploader_name} - {formatTime(upload.created_at)}
{#if comment.user_id === userId || $isStaff} + @@ -335,3 +583,16 @@
+ + + (pendingCommentDelete = null)} +/> diff --git a/frontend/src/lib/components/VirtualFeed.svelte b/frontend/src/lib/components/VirtualFeed.svelte index 0c64199..64cf988 100644 --- a/frontend/src/lib/components/VirtualFeed.svelte +++ b/frontend/src/lib/components/VirtualFeed.svelte @@ -21,7 +21,7 @@ import { get } from 'svelte/store'; import { browser } from '$app/environment'; import type { FeedUpload } from '$lib/types'; - import { dataMode } from '$lib/data-mode-store'; + import { dataMode, pickMediaUrl } from '$lib/data-mode-store'; import { commentsEnabled } from '$lib/event-config-store'; import { longpress } from '$lib/actions/longpress'; import FeedListCard from './FeedListCard.svelte'; @@ -62,12 +62,74 @@ return mime.startsWith('video/'); } - // Grid tiles always use the small thumbnail — full media is one tap away in the - // lightbox where the data-mode picker decides for real. + // Grid tiles prefer the SMALLEST derivative — three across, so a thumbnail is plenty and + // the full file is one tap away in the lightbox. + // + // Everything past that preference defers to `pickMediaUrl`, the centralised rule the card + // and the lightbox already follow (saver → preview → thumbnail → original). This used to + // return '' when no derivative existed yet, which rendered the grey "broken image" tile + // for every upload still in the compression queue. Because the feed is newest-first, that + // was precisely the top of the grid during a burst — the photos people had just taken — + // while the same items opened fine in the lightbox, which has always had the fallback. + // An upload with no derivatives is a TRANSIENT state (SSE `upload-processed` swaps in the + // preview as soon as the worker finishes), so the original is only ever fetched for the + // short window before compression catches up. function tileUrl(upload: FeedUpload): string { - if (upload.thumbnail_url) return upload.thumbnail_url; - if (upload.preview_url) return upload.preview_url; - return $dataMode === 'original' ? `/api/v1/upload/${upload.id}/original` : ''; + // VIDEOS: a poster image or nothing. `pickMediaUrl` is deliberately mime-agnostic and + // bottoms out at `/original` — for a video that is the raw MP4, and this is an . + // Videos also never get a `preview_url` (compression.rs gives them a thumbnail only, + // and only when a poster frame was actually extracted), so falling through would put a + // multi-hundred-MB file behind three broken-image tiles per row. Returning '' keeps the + // `{#if tileUrl(...)}` guard meaningful and leaves the play icon on its own. This is the + // same carve-out LightboxModal documents for the same reason. + if (isVideo(upload.mime_type)) return upload.thumbnail_url ?? ''; + if ($dataMode === 'saver' && upload.thumbnail_url) return upload.thumbnail_url; + return pickMediaUrl($dataMode, upload); + } + + // ── Tile media fallback ────────────────────────────────────────────────────────── + // + // A tile whose 404s used to render as an empty grey box with `alt=""` — no icon, + // no message, nothing to tap. And it is not an exotic case: `tileUrl` bottoms out at + // `/original` while an upload is still compressing, which in a newest-first grid is the + // top-left corner during every burst. That also makes the cause usually TRANSIENT, so + // one delayed retry recovers most of them; the nonce is required because the browser + // would otherwise replay its cached failure instead of re-requesting. + // + // Keyed by upload id rather than per-, because the virtualizer recycles rows: a + // tile that scrolls out and back must not restart the whole dance, and must not lose a + // verdict we already reached. The recorded `url` is what makes that safe to keep — an + // `upload-processed` that swaps the original for a real preview is a DIFFERENT url and + // gets a clean attempt. + const MEDIA_RETRY_MS = 4000; + type TileError = { url: string; phase: 'retrying' | 'failed'; nonce: number }; + let tileErrors = $state>({}); + + function tileSrc(upload: FeedUpload): string { + const base = tileUrl(upload); + if (!base) return ''; + const e = tileErrors[upload.id]; + if (!e || e.url !== base) return base; + if (e.phase === 'failed') return ''; + return e.nonce ? `${base}${base.includes('?') ? '&' : '?'}r=${e.nonce}` : base; + } + + function handleTileError(upload: FeedUpload) { + const base = tileUrl(upload); + if (!base) return; + const e = tileErrors[upload.id]; + if (e && e.url === base) { + // The retry failed too — show the placeholder rather than a blank tile. + tileErrors[upload.id] = { ...e, phase: 'failed' }; + return; + } + tileErrors[upload.id] = { url: base, phase: 'retrying', nonce: 0 }; + setTimeout(() => { + const cur = tileErrors[upload.id]; + if (cur && cur.url === base && cur.phase === 'retrying') { + tileErrors[upload.id] = { ...cur, nonce: Date.now() }; + } + }, MEDIA_RETRY_MS); } // STABLE option callbacks — created once, never swapped. They read the live @@ -208,13 +270,14 @@ > {#if isVideo(upload.mime_type)}
- {#if tileUrl(upload)} + {#if tileSrc(upload)} handleTileError(upload)} /> {/if}
@@ -227,13 +290,14 @@
- {:else if tileUrl(upload)} + {:else if tileSrc(upload)} handleTileError(upload)} /> {:else}
diff --git a/frontend/src/lib/event-config-store.ts b/frontend/src/lib/event-config-store.ts index a086b7d..d04686c 100644 --- a/frontend/src/lib/event-config-store.ts +++ b/frontend/src/lib/event-config-store.ts @@ -8,6 +8,7 @@ * paint the right colours before the JS bundle loads (no flash of the default palette). */ import { writable } from 'svelte/store'; +import { api } from './api'; import { buildPaletteCss, applyPaletteCss, type ThemeConfig } from './theme/palette'; export const PALETTE_CACHE_KEY = 'eventsnap_palette_css'; @@ -23,12 +24,24 @@ export const eventConfig = writable(null); /** Convenience flag for the comment UI. Optimistic `true` until /event resolves. */ export const commentsEnabled = writable(true); +type PublicEventDto = { + name: string; + slug: string; + comments_enabled?: boolean; + theme_preset?: string; + theme_primary?: string; + theme_accent?: string; +}; + /** Fetch /event, apply the theme, and cache the resolved CSS for the next boot. */ export async function loadEventConfig(): Promise { try { - const res = await fetch('/api/v1/event'); - if (!res.ok) return; - const b = await res.json(); + // Through `api` rather than a bare `fetch`: this was the ONE request in the app with + // no AbortController deadline. On a congested venue WiFi a socket that never answers + // left the promise pending indefinitely, so the palette never reconciled with the + // server — and since `event-updated` re-calls this, those zombie requests stack up. + // `api` aborts at 20s and turns it into an ApiError the catch below already handles. + const b = await api.get('/event'); const theme: ThemeConfig = { preset: b.theme_preset ?? 'champagne-gold', primary: b.theme_primary ?? '#8a6a2b', diff --git a/frontend/src/lib/feed-filter.test.ts b/frontend/src/lib/feed-filter.test.ts deleted file mode 100644 index 3c93d0f..0000000 --- a/frontend/src/lib/feed-filter.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { filterUploads, type FeedFilter } from './feed-filter'; - -// filterUploads only reads `caption` and `uploader_name`, so a partial shape suffices. -const u = (id: string, uploader_name: string, caption: string | null) => - ({ id, uploader_name, caption }) as any; - -const uploads = [ - u('1', 'Alice', 'pic #wedding'), - u('2', 'Bob', 'party #party'), - u('3', 'Alice', 'more #wedding #party'), - u('4', 'Carol', 'no tags here'), - u('5', 'Bob', null) // no caption -]; - -const ids = (r: any[]) => r.map((x) => x.id); -const tag = (value: string): FeedFilter => ({ type: 'tag', value }); -const user = (value: string): FeedFilter => ({ type: 'user', value }); - -describe('filterUploads', () => { - it('no filters → returns all uploads unchanged', () => { - expect(filterUploads(uploads, [])).toBe(uploads); - }); - - it('a single tag matches uploads whose caption contains it', () => { - expect(ids(filterUploads(uploads, [tag('wedding')]))).toEqual(['1', '3']); - }); - - it('two tags combine with OR', () => { - expect(ids(filterUploads(uploads, [tag('wedding'), tag('party')]))).toEqual(['1', '2', '3']); - }); - - it('a single user matches only that uploader', () => { - expect(ids(filterUploads(uploads, [user('Alice')]))).toEqual(['1', '3']); - }); - - it('two users combine with OR', () => { - expect(ids(filterUploads(uploads, [user('Alice'), user('Bob')]))).toEqual(['1', '2', '3', '5']); - }); - - it('a user chip and a tag chip combine with AND', () => { - // Alice AND #wedding → only Alice's wedding uploads (not Bob's #wedding, not Alice's #party-only) - expect(ids(filterUploads(uploads, [user('Alice'), tag('wedding')]))).toEqual(['1', '3']); - }); - - it('AND excludes an uploader-match that lacks the tag', () => { - // Bob AND #wedding → none (Bob has #party and a null caption, no #wedding) - expect(filterUploads(uploads, [user('Bob'), tag('wedding')])).toHaveLength(0); - }); - - it('tag matching is case-insensitive against the caption', () => { - expect(filterUploads([u('9', 'X', 'PIC #WeDDing')], [tag('wedding')])).toHaveLength(1); - }); - - it('a null caption never matches a tag but can match a user', () => { - expect(filterUploads([u('5', 'Bob', null)], [tag('party')])).toHaveLength(0); - expect(filterUploads([u('5', 'Bob', null)], [user('Bob')])).toHaveLength(1); - }); -}); diff --git a/frontend/src/lib/feed-filter.ts b/frontend/src/lib/feed-filter.ts deleted file mode 100644 index ebac89b..0000000 --- a/frontend/src/lib/feed-filter.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Grid-view feed filtering. Extracted from feed/+page.svelte so the OR/AND -// combination rules are unit-testable without mounting the page. - -import type { FeedUpload } from './types'; - -export interface FeedFilter { - type: 'tag' | 'user'; - value: string; -} - -/** - * Apply the active grid filters to a list of uploads. - * - * Combination rules (mirroring the chip UI): - * - Tags combine with **OR**: a card passes the tag group if its caption contains - * ANY selected `#tag`. - * - Users combine with **OR** within the user group (uploader is one of the - * selected names). - * - The tag group and the user group combine with **AND**: a card must satisfy - * both groups. An empty group is a pass-through. - * - * Tags are matched against the caption text (the autocomplete source), so `value` - * is expected lowercase (as produced by the tag suggestions). - */ -export function filterUploads(uploads: FeedUpload[], filters: FeedFilter[]): FeedUpload[] { - if (filters.length === 0) return uploads; - const tags = filters.filter((f) => f.type === 'tag').map((f) => f.value); - const users = filters.filter((f) => f.type === 'user').map((f) => f.value); - return uploads.filter((u) => { - const cap = (u.caption ?? '').toLowerCase(); - const passTag = !tags.length || tags.some((t) => cap.includes('#' + t)); - const passUser = !users.length || users.includes(u.uploader_name); - return passTag && passUser; - }); -} diff --git a/frontend/src/lib/quota-store.ts b/frontend/src/lib/quota-store.ts index 8bcee98..164bad1 100644 --- a/frontend/src/lib/quota-store.ts +++ b/frontend/src/lib/quota-store.ts @@ -7,6 +7,7 @@ import { writable } from 'svelte/store'; import { api } from './api'; +import { onClearAuth } from './auth'; export interface QuotaSnapshot { enabled: boolean; @@ -26,6 +27,12 @@ const empty: QuotaSnapshot = { export const quotaStore = writable(empty); +// Reset on logout, the same way role-store / ban-store / upload-queue do. A phone handed +// round a party is one browser profile: `goto()` is a client-side navigation, so no module +// re-imports and no `onMount` re-runs, and without this the next guest saw the PREVIOUS +// guest's storage usage — their data, on their screen — until the first refresh landed. +onClearAuth(() => quotaStore.set(empty)); + /** Refresh from the server. Swallows errors so a transient network blip doesn't * break the account page; the previous snapshot just stays in place. */ export async function refreshQuota(): Promise { diff --git a/frontend/src/lib/sse.ts b/frontend/src/lib/sse.ts index b9688b8..3a2c2a3 100644 --- a/frontend/src/lib/sse.ts +++ b/frontend/src/lib/sse.ts @@ -27,6 +27,27 @@ const handlers: Map = new Map(); let reconnectAttempt = 0; let reconnectTimer: ReturnType | null = null; +/** + * True once a stream has opened at least once this session. Distinguishes "first + * connect" from "reconnect" now that `lastEventTime` is seeded at ticket-mint rather + * than in `onopen` (see `connectSse`) — without it every boot would fire a pointless + * zero-width delta. + */ +let streamEverOpened = false; + +/** + * `Date.now()` of the last thing we actually received on the live stream. + * + * Keep-alives are deliberately NOT observable here: the backend sends them as SSE + * COMMENTS (`KeepAlive::new().text("ping")` emits `:ping`), and the EventSource parser + * discards comments without dispatching anything at all. There is no browser API that + * exposes them. So liveness cannot be decided by a plain silence timer — it could not + * tell a dead socket from a genuinely quiet half hour, and would churn reconnects for + * every guest through every lull. The backstop poll below decides it on evidence + * instead: the server had news that this stream never delivered. + */ +let lastStreamActivity = 0; + /** * SSE event names emitted by the backend. Add new ones here as `state.sse_tx.send` * call sites grow — every entry becomes a relay registration below. @@ -91,29 +112,40 @@ export function connectSse(): void { scheduleReconnect(); return; } - // Auth flow may have torn things down while we were awaiting the ticket. - if (!getToken() || eventSource) return; + // Seed the reconnect cursor the moment we have a server clock — NOT in `onopen`, + // which is where it used to live. `onopen` never fires behind a captive portal or + // any proxy that buffers `text/event-stream`, so on exactly the networks where the + // stream fails the cursor stayed `null` forever and the backstop poll below had no + // `since` to fetch from. Still the server clock and never `new Date()`: a skewed + // browser clock would shift the window and silently drop uploads. + if (!lastEventTime) lastEventTime = serverTime; + + // Auth flow may have torn things down while we were awaiting the ticket — and the + // phone may have gone to sleep during that round-trip. Opening a stream while + // hidden is worse than not opening one: iOS reaps a backgrounded socket without + // ever firing `onerror`, so `eventSource` stays non-null and the guard at the top + // of this function then treats the corpse as a live connection for the rest of the + // evening. The visibility handler reconnects us when the screen comes back. + if (!getToken() || eventSource || (typeof document !== 'undefined' && document.hidden)) return; eventSource = new EventSource(`/api/v1/stream?ticket=${encodeURIComponent(ticket)}`); eventSource.onopen = () => { // Successful connection — reset the backoff counter. reconnectAttempt = 0; - // If we have a previous timestamp this is a reconnect — fetch the gap. The - // delta advances `lastEventTime` from the SERVER clock it returns. - const since = lastEventTime; - if (since) { - void deltaFetchAndFan(since); - } else { - // First connect: seed the cursor from the server clock at ticket-mint time, - // never `new Date()` — a skewed browser clock would otherwise shift the very - // first reconnect window and could drop uploads. - lastEventTime = serverTime; - } + noteStreamActivity(); + // A reconnect has a gap to close; the very first open of a session does not + // (the cursor was just seeded from this ticket's server clock). The delta + // advances `lastEventTime` from the SERVER clock it returns. + if (streamEverOpened && lastEventTime) void deltaFetchAndFan(lastEventTime); + streamEverOpened = true; }; for (const eventName of KNOWN_EVENTS) { - eventSource.addEventListener(eventName, (e) => dispatch(eventName, (e as MessageEvent).data)); + eventSource.addEventListener(eventName, (e) => { + noteStreamActivity(); + dispatch(eventName, (e as MessageEvent).data); + }); } // `resync` is emitted by the server when our broadcast subscription fell @@ -123,6 +155,7 @@ export function connectSse(): void { // own listener — not via `dispatch` — so reading `lastEventTime` as the gap // start isn't clobbered by dispatch bumping it to "now". eventSource.addEventListener('resync', () => { + noteStreamActivity(); const since = lastEventTime; if (since) void deltaFetchAndFan(since); }); @@ -141,7 +174,14 @@ export function connectSse(): void { function scheduleReconnect(): void { reconnectAttempt++; const delay = Math.min(60_000, 1_000 * 2 ** (reconnectAttempt - 1)); - const jitter = Math.random() * 500; + // Jitter must SCALE WITH the backoff, not be a flat 500ms. Every client that dropped + // together shares the same `reconnectAttempt`, so they compute an identical `delay` — + // a fixed 500ms window spreads 100 phones over half a second no matter how long the + // backoff grew, which is the thundering herd the backoff exists to prevent. Each + // reconnect costs a ticket POST + stream GET + feed-delta fetch, so the herd lands on + // the DB pool three times over. Scaling the jitter to the delay (floored at 1s so the + // first, most synchronised retry is spread too) turns that into a smooth ramp. + const jitter = Math.random() * Math.max(delay, 1_000); if (reconnectTimer) clearTimeout(reconnectTimer); reconnectTimer = setTimeout(connectSse, delay + jitter); } @@ -157,6 +197,91 @@ export function disconnectSse(): void { } } +function noteStreamActivity(): void { + lastStreamActivity = Date.now(); +} + +// ── Stream backstop ──────────────────────────────────────────────────────────────── +// +// EVERY feed update is triggered by an SSE event — there is no periodic refetch — so a +// stream that stops delivering freezes a guest's gallery for the rest of the evening. +// And a venue produces exactly the two failures that the reconnect path cannot see: +// +// • a captive portal or any proxy that buffers `text/event-stream` never forwards a +// byte, so `onopen` may never fire and neither does `onerror`; +// • a phone that roams between APs leaves a HALF-OPEN socket — the connection is gone +// but `readyState` still reads OPEN, no error is raised, and `connectSse` early- +// returns on its non-null `eventSource`, so nothing ever reconnects. +// +// Neither surfaces anything to react to, which is why the only honest backstop is to +// ask the server. `/feed/delta` is idempotent and answers with an empty payload when +// nothing changed, and one request per 60–120s is ~1/60th of the per-user feed limit — +// cheap insurance against a guest staring at a frozen feed all night. The diashow uses +// the same reconcile-on-a-timer for the same reason (`RECONCILE_INTERVAL_MS` there). +const BACKSTOP_MIN_MS = 60_000; +const BACKSTOP_MAX_MS = 120_000; +let backstopTimer: ReturnType | null = null; +let backstopEnabled = false; + +/** + * Start the poll-based liveness/completeness backstop. Opt-in per page (the feed is the + * consumer that needs it; the export page's SSE use is a status ping and the diashow + * runs its own full reconcile), and idempotent. + */ +export function startStreamBackstop(): void { + if (typeof document === 'undefined' || backstopEnabled) return; + backstopEnabled = true; + scheduleBackstop(); +} + +export function stopStreamBackstop(): void { + backstopEnabled = false; + if (backstopTimer) { + clearTimeout(backstopTimer); + backstopTimer = null; + } +} + +function scheduleBackstop(): void { + if (backstopTimer) clearTimeout(backstopTimer); + // Jittered for the same reason the reconnect backoff is: ~100 phones that joined + // within the same few minutes would otherwise poll in permanent lockstep. + const delay = BACKSTOP_MIN_MS + Math.random() * (BACKSTOP_MAX_MS - BACKSTOP_MIN_MS); + backstopTimer = setTimeout(() => void runBackstop(), delay); +} + +async function runBackstop(): Promise { + backstopTimer = null; + try { + // A hidden tab has no stream (the visibility handler closed it) and cannot show a + // result anyway; the reopen path already fetches the gap. + if (document.hidden || !getToken()) return; + // No stream, or one the browser has admitted is closed, while we are visible: there + // is no other way back, because `connectSse`'s `eventSource` guard cannot tell a + // corpse from a live connection. + if (!eventSource || eventSource.readyState === EventSource.CLOSED) { + disconnectSse(); + reconnectAttempt = 0; + connectSse(); + return; + } + const since = lastEventTime; + if (!since) return; + const activityBefore = lastStreamActivity; + const carried = await deltaFetchAndFan(since); + // The server had news that this stream never delivered. That is the evidence a + // half-open socket cannot otherwise give us — reconnect, or every remaining update + // tonight arrives at poll latency instead of instantly. + if (carried && lastStreamActivity === activityBefore) { + disconnectSse(); + reconnectAttempt = 0; + connectSse(); + } + } finally { + if (backstopEnabled) scheduleBackstop(); + } +} + export function getLastEventTime(): string | null { return lastEventTime; } @@ -196,14 +321,23 @@ function extractCreatedAt(data: string): string | undefined { * event. Subscribers (typically the feed page) merge the result into their * in-memory list. Swallows errors — a failed delta is non-fatal; the next live * SSE event will keep the feed moving. + * + * Resolves to whether the delta actually CARRIED something. `runBackstop` uses that as + * its liveness signal: content the poll found but the stream never pushed means the + * stream is dead in the way the browser will not report. */ -async function deltaFetchAndFan(since: string, attempt = 0): Promise { +async function deltaFetchAndFan(since: string, attempt = 0): Promise { try { const response = await api.get(`/feed/delta?since=${encodeURIComponent(since)}`); // Advance the cursor to the server clock this delta was computed at, so the next // reconnect resumes exactly where the server left off (no browser-clock skew). lastEventTime = response.server_time; dispatch('feed-delta', JSON.stringify(response)); + return ( + response.uploads.length > 0 || + response.deleted_ids.length > 0 || + response.hidden_user_ids.length > 0 + ); } catch (e) { // A throttled delta (429) must NOT be silently dropped: live events keep advancing // `lastEventTime`, so the next reconnect would resume PAST this un-fetched gap and @@ -211,10 +345,15 @@ async function deltaFetchAndFan(since: string, attempt = 0): Promise { // covered regardless of how the live cursor moves in the meantime. Bounded, and only // reachable by a rapidly flapping EventSource hitting the per-user delta limit. if (e instanceof ApiError && e.status === 429 && attempt < MAX_DELTA_RETRIES) { - const delayMs = DELTA_RETRY_BASE_MS * 2 ** attempt; - setTimeout(() => void deltaFetchAndFan(since, attempt + 1), delayMs); + // Jittered for the same reason `scheduleReconnect` is: the clients that hit this + // 429 are the ones that just reconnected together after a venue-wide wifi blip, + // so they share `attempt` and would otherwise retry in lockstep at exactly 2s, + // 4s, 8s — re-tripping the same per-user limit in a synchronised wave. + const base = DELTA_RETRY_BASE_MS * 2 ** attempt; + setTimeout(() => void deltaFetchAndFan(since, attempt + 1), base + Math.random() * base); } // Other errors are non-fatal — the next live SSE event keeps the feed moving. + return false; } } @@ -228,6 +367,12 @@ function handleVisibilityChange() { if (document.hidden) { disconnectSse(); } else { + // Tear down UNCONDITIONALLY before reconnecting rather than leaning on + // `connectSse`'s `eventSource` guard. iOS can reap a backgrounded socket without + // ever firing `onerror`, which leaves a non-null but permanently dead EventSource — + // and the guard would then read that as "already connected" and never reconnect, + // for the rest of the evening. Closing an already-closed EventSource is a no-op. + disconnectSse(); // User-initiated reconnect — clear backoff so we don't wait out a long // retry delay that was scheduled from a prior background error. reconnectAttempt = 0; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index d836f48..d5536f6 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -67,6 +67,8 @@ export interface MeContextDto { storage_quota_enabled: boolean; uploads_locked: boolean; gallery_released: boolean; + /** Read-only ban: the feed and the keepsake stay available, every write is refused. */ + is_banned: boolean; } // mirrors backend/src/handlers/host.rs::PinResetResponse diff --git a/frontend/src/routes/feed/+page.svelte b/frontend/src/routes/feed/+page.svelte index b663aed..723b810 100644 --- a/frontend/src/routes/feed/+page.svelte +++ b/frontend/src/routes/feed/+page.svelte @@ -3,7 +3,13 @@ import { getToken, getUserId } from '$lib/auth'; import { isStaff } from '$lib/role-store'; import { api } from '$lib/api'; - import { connectSse, disconnectSse, onSseEvent } from '$lib/sse'; + import { + connectSse, + disconnectSse, + onSseEvent, + startStreamBackstop, + stopStreamBackstop + } from '$lib/sse'; import { onMount, onDestroy } from 'svelte'; import VirtualFeed from '$lib/components/VirtualFeed.svelte'; import HashtagChips from '$lib/components/HashtagChips.svelte'; @@ -17,7 +23,6 @@ import { toast, toastError } from '$lib/toast-store'; import { pullToRefresh } from '$lib/actions/pull-to-refresh'; import { vibrate } from '$lib/haptics'; - import { filterUploads } from '$lib/feed-filter'; import { refreshEventState } from '$lib/event-state-store'; import type { FeedUpload, FeedResponse, HashtagCount, DeltaResponse } from '$lib/types'; @@ -27,6 +32,11 @@ let nextCursor = $state(null); let loadingMore = $state(false); let initialLoading = $state(true); + // Set when a load left us with NOTHING to show. Without it the template fell straight + // through to "Noch keine Fotos" — so on the venue WiFi the most likely first thing a + // guest ever saw was the app confidently telling them the gallery was empty, with no + // error, no retry, and nobody around to ask. + let loadError = $state(false); let refreshing = $state(false); let pullProgress = $state(0); // 0–1+ during the drag, 0 when idle let selectedUpload = $state(null); @@ -36,6 +46,10 @@ let sentinel: HTMLDivElement; let feedObserver: IntersectionObserver | null = null; let inPlaceRefreshTimer: ReturnType | null = null; + // Latest moment the coalescing window below is allowed to push the reconcile to, and + // a "an event arrived while hidden" flag — see `scheduleInPlaceRefresh`. + let inPlaceRefreshDeadline = 0; + let inPlaceRefreshDeferred = false; // `asHost` picks the endpoint AND the copy: removing someone else's photo is a // moderation action, not "delete my post", and it hits the host route. let pendingDelete = $state<{ id: string; asHost: boolean } | null>(null); @@ -49,11 +63,20 @@ if (typeof document === 'undefined') return; const prev = document.documentElement.style.overscrollBehaviorY; document.documentElement.style.overscrollBehaviorY = 'contain'; + document.addEventListener('visibilitychange', handleVisibility); return () => { document.documentElement.style.overscrollBehaviorY = prev; + document.removeEventListener('visibilitychange', handleVisibility); }; }); + /** Run the reconcile that was deferred while the tab was hidden (see `scheduleInPlaceRefresh`). */ + function handleVisibility() { + if (document.hidden || !inPlaceRefreshDeferred) return; + inPlaceRefreshDeferred = false; + scheduleInPlaceRefresh(); + } + // View mode let viewMode = $state<'list' | 'grid'>('list'); @@ -132,20 +155,42 @@ } } - // ── Autocomplete derived from loaded uploads (no extra API calls) ──────── + // ── Autocomplete sources ───────────────────────────────────────────────── + // Tags come from the SERVER's hashtag index (`/hashtags`, already loaded for the list + // view's chips and ordered by count) — not just the captions of the uploads currently + // in memory. Page 1 is 20 items, so deriving tags only from loaded captions meant a tag + // used further down the feed was simply absent from the picker until the user happened + // to scroll past the photo carrying it. That is the "sometimes I can't select a tag" + // case: the tag was never offered, so no amount of typing surfaced it. + // + // Captions of loaded uploads are unioned in afterwards so a tag from a photo that landed + // since the last `/hashtags` refresh is still offered. The regex mirrors the backend rule + // exactly — ASCII alphanumerics and `_`, stopping at the first other character (see + // backend `models/hashtag.rs::extract_hashtags`) — so both sources agree on what a tag is. let allTags = $derived.by(() => { - // eslint-disable-next-line svelte/prefer-svelte-reactivity -- local throwaway counter inside a $derived.by; never stored in $state, so no reactivity is involved. - const freq = new Map(); - for (const u of uploads) { - for (const m of (u.caption ?? '').matchAll(/#(\w+)/g)) { - const t = m[1].toLowerCase(); - freq.set(t, (freq.get(t) ?? 0) + 1); + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- local dedupe set inside a $derived.by; never stored in $state, so no reactivity is involved. + const seen = new Set(); + const out: string[] = []; + const add = (raw: string) => { + const t = raw.toLowerCase(); + if (t && !seen.has(t)) { + seen.add(t); + out.push(t); } + }; + for (const h of hashtags) add(h.tag); + for (const u of uploads) { + for (const m of (u.caption ?? '').matchAll(/#(\w+)/g)) add(m[1]); } - return [...freq.entries()].sort((a, b) => b[1] - a[1]).map(([t]) => t); + return out; }); - let allUploaders = $derived([...new Set(uploads.map((u) => u.uploader_name))].sort()); + // Uploaders come from `/uploaders` for the same reason tags come from `/hashtags`: deriving + // them from the uploads currently in memory meant typing a guest's name found nothing + // whenever their photos sat below page 1, which reads as "search is broken". The endpoint + // reads v_feed, so banned and hidden uploaders are already excluded. + let uploaderNames = $state([]); + let allUploaders = $derived(uploaderNames); // The suggestion SOURCE is frozen for as long as the dropdown is open. // @@ -186,31 +231,36 @@ ...frozenTags.slice(0, 3).map((t) => ({ type: 'tag' as const, value: t })) ]; } + // Once the user has TYPED, every match is offered — no `.slice()`. The old caps (8 for + // a `#` query, 4 tags + 4 users otherwise) silently dropped matches, so a tag that + // existed and matched what was typed still could not be selected, with nothing on + // screen to say more existed. The dropdown scrolls instead (see `max-h-72` below), + // which bounds the UI without bounding the choices. if (q.startsWith('#')) { const prefix = q.slice(1).toLowerCase(); return frozenTags .filter((t) => t.startsWith(prefix)) - .slice(0, 8) .map((t) => ({ type: 'tag' as const, value: t })); } const lower = q.toLowerCase(); return [ ...frozenUploaders .filter((u) => u.toLowerCase().includes(lower)) - .slice(0, 4) .map((u) => ({ type: 'user' as const, value: u })), ...frozenTags .filter((t) => t.includes(lower)) - .slice(0, 4) .map((t) => ({ type: 'tag' as const, value: t })) ]; }); - // ── Filtered uploads for grid view ─────────────────────────────────────── - let displayUploads = $derived.by(() => { - if (viewMode === 'list' || activeFilters.length === 0) return uploads; - return filterUploads(uploads, activeFilters); - }); + // `uploads` IS the filtered set — the server applied the filters (see `filterParams`), so + // there is nothing left to narrow client-side. The previous client-side pass could only + // ever see the pages already loaded, so a tag whose photos sat past page 1 rendered a + // near-empty grid that looked complete, and it matched a caption SUBSTRING rather than the + // tag itself (`#tanz` also matched `#tanzflaeche`) — so list and grid disagreed about the + // same chip. Kept as an alias so the template and the infinite-scroll sentinel read the + // same way in both views. + let displayUploads = $derived(uploads); // ───────────────────────────────────────────────────────────────────────── // onMount B — auth gate, recovery toast, data load, SSE subscriptions, @@ -243,8 +293,12 @@ // state in the same request. void refreshEventState(); - await Promise.all([loadFeed(), loadHashtags()]); + await Promise.all([loadFeed(), loadHashtags(), loadUploaders()]); connectSse(); + // Nothing in this page refetches on a timer — every update path below hangs off an + // SSE event. The backstop is what keeps that from meaning "one bad socket and the + // gallery is frozen until you force-reload", which is not a thing a guest will do. + startStreamBackstop(); unsubscribers.push( onSseEvent('new-upload', (data) => { @@ -371,6 +425,7 @@ onDestroy(() => { disconnectSse(); + stopStreamBackstop(); for (const unsub of unsubscribers) unsub(); feedObserver?.disconnect(); if (inPlaceRefreshTimer) clearTimeout(inPlaceRefreshTimer); @@ -396,64 +451,164 @@ } } - // Debounced page-1 fetch that *merges* (updates existing cards in place, prepends - // genuinely new ones) rather than replacing the array — preserves scroll and any - // pages already loaded below the fold. + // Coalescing window for the SSE-driven reconcile. The old floor was `800 + random*2000`, + // which during a burst (a bulk upload fires one `upload-processed` PER FILE) meant + // roughly one feed query per client every ~2s. At 100 guests that walks straight into + // the backend's 60/min per-user feed limit — and `refreshFeedInPlace`'s bare `catch {}` + // swallowed the resulting 429s, so the feed simply stopped updating with nothing on + // screen to say why. A reconcile is a background nicety; seconds of latency cost the + // guest nothing, while the request budget is the thing that actually runs out. + const REFRESH_DEBOUNCE_MS = 8_000; + // Spread on top of the floor. A fixed delay would make every client that saw the same + // broadcast fetch in the same window — 100 feed queries landing together, on top of the + // reconnect burst that often triggered them. The spread makes it a ramp. + const REFRESH_SPREAD_MS = 7_000; + // Ceiling on the coalescing, so a party that never stops posting still reconciles. + const REFRESH_MAX_WAIT_MS = 30_000; + // How much of the loaded feed a reconcile re-reads, and at what page size. The server + // caps `limit` at 100. + const RECONCILE_PAGE = 100; + const RECONCILE_MAX_PAGES = 3; + + // Debounced fetch that *merges* (updates existing cards in place, prepends genuinely + // new ones) rather than replacing the array — preserves scroll and any pages already + // loaded below the fold. function scheduleInPlaceRefresh() { - if (inPlaceRefreshTimer) return; + // A hidden tab cannot show the result, and iOS clamps its timers into a clump that + // all fires at once on wake. Defer to the visibility change, where exactly one runs. + if (typeof document !== 'undefined' && document.hidden) { + inPlaceRefreshDeferred = true; + return; + } + const nowMs = Date.now(); + if (!inPlaceRefreshTimer) inPlaceRefreshDeadline = nowMs + REFRESH_MAX_WAIT_MS; + // Push the reconcile out again on every further event, so a burst of thirty uploads + // costs ONE feed query once it settles rather than one per event — but never past + // the deadline above. + const delay = Math.min( + REFRESH_DEBOUNCE_MS + Math.random() * REFRESH_SPREAD_MS, + Math.max(0, inPlaceRefreshDeadline - nowMs) + ); + if (inPlaceRefreshTimer) clearTimeout(inPlaceRefreshTimer); inPlaceRefreshTimer = setTimeout(() => { inPlaceRefreshTimer = null; - void refreshFeedInPlace(); - }, 800); + void refreshFeedInPlace().catch(() => { + // Background reconcile — quiet by design. The next event, the next visibility + // change or a pull-to-refresh retries; if this was a 429 we are already over + // budget and retrying immediately is the worst possible response. + }); + }, delay); } - async function refreshFeedInPlace() { - try { + /** + * Merge the server's current view of the LOADED WINDOW into the in-memory list. + * + * Reconciling only page 1 (what this used to do) meant like/comment counts on items + * 21..N moved solely via live `like-update` / `new-comment` — so for a guest who had + * scrolled through a few hundred photos, everything that happened while their phone + * was asleep or their stream was down was lost permanently. Paging at the server's + * 100 cap and stopping after `RECONCILE_MAX_PAGES` keeps that at 1–3 requests instead + * of one per 20 items, which at a party's event rate would be its own little DDoS. + * + * Throws: callers decide whether the failure is worth showing (the pill's tap is, a + * background event is not). + */ + async function refreshFeedInPlace(): Promise { + const base = filterParams(); + base.set('limit', String(RECONCILE_PAGE)); + const known = new Set(uploads.map((u) => u.id)); + // How much of the loaded window we set out to re-read for fresh counts. + const windowPages = Math.min( + RECONCILE_MAX_PAGES, + Math.max(1, Math.ceil(uploads.length / RECONCILE_PAGE)) + ); + const fetched: FeedUpload[] = []; + let cursor: string | null = null; + for (let page = 0; page < RECONCILE_MAX_PAGES; page++) { // eslint-disable-next-line svelte/prefer-svelte-reactivity -- local query-string builder for a fetch; not reactive state. - const params = new URLSearchParams(); - if (selectedHashtag) params.set('hashtag', selectedHashtag); - params.set('limit', '20'); + const params = new URLSearchParams(base); + if (cursor) params.set('cursor', cursor); const res = await api.get(`/feed?${params}`); - const byId = new Map(res.uploads.map((u) => [u.id, u])); - const known = new Set(uploads.map((u) => u.id)); - uploads = uploads.map((u) => byId.get(u.id) ?? u); - const fresh = res.uploads.filter((u) => !known.has(u.id)); - if (fresh.length) uploads = [...fresh, ...uploads]; - } catch { - // Background refresh — stay quiet, the next event or pull-to-refresh retries. + fetched.push(...res.uploads); + cursor = res.next_cursor; + if (!cursor) break; + // Nothing fetched so far overlaps what we hold, so the head we are prepending is + // not yet CONTIGUOUS with the old list — there are still unseen uploads in between. + // Keep paging until the two reconverge, or the merge would silently leave a hole + // in the middle of the feed (the truncated-delta pill is exactly this case: more + // than the backend's 200-row delta cap arrived while the guest was away). + const bridged = known.size === 0 || fetched.some((u) => known.has(u.id)); + if (page + 1 >= windowPages && bridged) break; + } + const byId = new Map(fetched.map((u) => [u.id, u])); + uploads = uploads.map((u) => byId.get(u.id) ?? u); + const fresh = fetched.filter((u) => !known.has(u.id)); + if (fresh.length) uploads = [...fresh, ...uploads]; + } + + /** + * The "Neue Beiträge" pill's action. MERGES page 1 into the head of what is already + * loaded instead of replacing the array with it. + * + * Replacing collapsed the virtualizer's total size from however many rows were loaded + * (400+ after an evening of scrolling) down to 20, so the browser dropped the reader at + * an arbitrary offset in a feed that had just shrunk under them — the exact yank the + * pill exists to prevent (see the `feedStale` comment at the top of this file). + * `nextCursor` is deliberately left alone for the same reason: the tail below the fold + * is still loaded and still paginating from where it was. + */ + async function refreshStale() { + try { + await refreshFeedInPlace(); + feedStale = false; + } catch (e) { + // Keep the pill up so the tap can be retried. Clearing it before the request (as + // this used to) left a guest whose tap failed with no signal AND no control. + toastError(e); } } async function loadFeed(refresh = false) { - // Any full refresh (pill tap, pull-to-refresh, filter change) resyncs page 1, - // so the "new posts" pill is no longer relevant — clear it here rather than - // only in the pill's own onclick, or a pull-to-refresh leaves it stranded. - if (refresh) feedStale = false; try { - // eslint-disable-next-line svelte/prefer-svelte-reactivity -- local query-string builder for a fetch; not reactive state. - const params = new URLSearchParams(); + const params = filterParams(); if (!refresh && nextCursor) params.set('cursor', nextCursor); - if (selectedHashtag) params.set('hashtag', selectedHashtag); params.set('limit', '20'); const res = await api.get(`/feed?${params}`); uploads = res.uploads; nextCursor = res.next_cursor; + loadError = false; + // A full refresh (pull-to-refresh, filter change) has resynced page 1, so the + // "new posts" pill is no longer relevant. Cleared only on SUCCESS: clearing it up + // front meant a failed pull-to-refresh silently ate the one affordance the guest + // had for getting the new photos. + if (refresh) feedStale = false; } catch (e) { - // Initial / user-triggered refresh is worth surfacing — background SSE refetches are noisier and silenced below. - if (!refresh) toastError(e); + // Every path through here is user-triggered (first load, pull-to-refresh, filter + // change) — silencing the refresh ones, as this used to, made pull-to-refresh fail + // completely invisibly. + toastError(e); + // Only the case where we have nothing left on screen earns the full error view; + // a failed refresh over an already-populated feed keeps the feed. + if (uploads.length === 0) loadError = true; } finally { initialLoading = false; } } + /** "Erneut laden" from the error state — a clean re-run of everything onMount loads. */ + async function retryInitialLoad() { + loadError = false; + initialLoading = true; + nextCursor = null; + await Promise.all([loadFeed(true), loadHashtags(), loadUploaders()]); + } + async function loadMore() { if (!nextCursor || loadingMore) return; loadingMore = true; try { - // eslint-disable-next-line svelte/prefer-svelte-reactivity -- local query-string builder for a fetch; not reactive state. - const params = new URLSearchParams(); + const params = filterParams(); params.set('cursor', nextCursor); - if (selectedHashtag) params.set('hashtag', selectedHashtag); params.set('limit', '20'); const res = await api.get(`/feed?${params}`); uploads = [...uploads, ...res.uploads]; @@ -473,6 +628,14 @@ } } + async function loadUploaders() { + try { + uploaderNames = await api.get('/uploaders'); + } catch { + // Same as the hashtag index: the picker degrades, the feed keeps working. + } + } + async function pullRefresh() { if (refreshing) return; refreshing = true; @@ -480,16 +643,69 @@ vibrate(10); try { nextCursor = null; - await Promise.all([loadFeed(true), loadHashtags()]); + await Promise.all([loadFeed(true), loadHashtags(), loadUploaders()]); } finally { refreshing = false; } } + // ── Filter state ───────────────────────────────────────────────────────── + // + // BOTH views filter server-side now; the two states below are just the two UIs for it. + // `selectedHashtag` is the list's single chip, `activeFilters` the grid's chip row, and + // `filterParams()` is the one place either is turned into a request. + // + // Previously the list filtered server-side while the grid filtered the loaded array + // client-side, and the two never synced — so a tag picked in the list kept filtering the + // grid (the fetch still carried `?hashtag=`) while the grid's chip row rendered the empty + // `activeFilters`: active, invisible, unclearable. The grid also matched a caption + // substring rather than the tag, so `#tanz` matched `#tanzflaeche` in one view and not the + // other, and it could only ever see the pages already loaded. + // + // The helpers below keep the two chip UIs in sync when switching views. + + /** The tag currently shown as a grid chip, if any. Grid supports several; list has one. */ + function firstTagFilter(): string | null { + return activeFilters.find((f) => f.type === 'tag')?.value ?? null; + } + + /** + * The active filter as query params — the SINGLE place that translates UI state into a + * request, used by every fetch path (initial load, pagination, pull-to-refresh, and the + * SSE-driven in-place refresh). + * + * Those four used to build their own params, and each only knew about `selectedHashtag` — + * so the grid's chips were never sent at all and were applied client-side over whatever + * pages happened to be loaded. + */ + function filterParams(): URLSearchParams { + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- local query-string builder for a fetch; not reactive state. + const params = new URLSearchParams(); + if (viewMode === 'list') { + if (selectedHashtag) params.set('hashtag', selectedHashtag); + return params; + } + const tags = activeFilters.filter((f) => f.type === 'tag').map((f) => f.value); + if (tags.length) params.set('hashtags', tags.join(',')); + const user = activeFilters.find((f) => f.type === 'user'); + if (user) params.set('uploader', user.value); + return params; + } + + /** Re-run page 1 under the current filters. Any filter change resets pagination. */ + function reloadForFilters() { + nextCursor = null; + loadFeed(true); + } + function selectHashtag(tag: string | null) { selectedHashtag = tag; - nextCursor = null; - loadFeed(); + // Mirror into the grid chips so switching views SHOWS the active filter. Tag chips are + // replaced rather than appended: the list's model is one tag. User chips are grid-only, + // so they survive untouched. + const users = activeFilters.filter((f) => f.type === 'user'); + activeFilters = tag ? [{ type: 'tag', value: tag }, ...users] : users; + reloadForFilters(); } async function handleLike(id: string) { @@ -527,28 +743,85 @@ } function selectSuggestion(item: Filter) { - if (!activeFilters.some((f) => f.type === item.type && f.value === item.value)) { + if (item.type === 'user') { + // Exactly ONE uploader at a time. The server takes a single `uploader` param + // rather than a list because display names may contain a comma, and a CSV would + // silently split such a name into two filters that match nobody. Tags are safe to + // CSV — the backend restricts them to ASCII alphanumerics and `_`. + activeFilters = [...activeFilters.filter((f) => f.type !== 'user'), item]; + } else if (!activeFilters.some((f) => f.type === item.type && f.value === item.value)) { activeFilters = [...activeFilters, item]; } searchQuery = ''; showAutocomplete = false; + reloadForFilters(); } function removeFilter(item: Filter) { activeFilters = activeFilters.filter((f) => !(f.type === item.type && f.value === item.value)); + reloadForFilters(); } + // `selectedHashtag` is cleared too, not just the grid chips: the two are mirrors of one + // server-side filter (see `selectHashtag`), so dropping only `activeFilters` left the + // list's tag armed and invisible — switching back to the list silently re-applied it. function clearFilters() { activeFilters = []; + selectedHashtag = null; searchQuery = ''; + reloadForFilters(); + } + + /** + * Whether the empty feed we are looking at is "no matches" rather than "no photos yet". + * Both chip UIs count, because the list and the grid express the same server-side + * filter differently. + */ + const hasActiveFilters = $derived( + viewMode === 'list' ? selectedHashtag !== null : activeFilters.length > 0 + ); + + // ── Lightbox stepping ──────────────────────────────────────────────────────────── + // + // Prev/next walk THE SAME array the feed renders — already server-filtered and + // server-ordered — so "next" in the lightbox is the photo the guest would have + // scrolled to. It stops at the end of what is LOADED rather than paging: infinite + // scroll owns pagination, and having the modal extend the list would grow the + // virtualizer behind it while it is the thing holding focus. + const lightboxIndex = $derived( + selectedUpload ? uploads.findIndex((u) => u.id === selectedUpload!.id) : -1 + ); + + function stepLightbox(delta: -1 | 1) { + if (lightboxIndex < 0) return; + const next = uploads[lightboxIndex + delta]; + if (next) selectedUpload = next; } function switchView(mode: 'list' | 'grid') { - viewMode = mode; - if (mode === 'list') { + const before = filterParams().toString(); + if (mode === 'grid') { + // Carry the list's tag into the grid's chip row so it is visible — and therefore + // removable — there. + if ( + selectedHashtag && + !activeFilters.some((f) => f.type === 'tag' && f.value === selectedHashtag) + ) { + activeFilters = [{ type: 'tag', value: selectedHashtag }, ...activeFilters]; + } + } else { searchQuery = ''; showAutocomplete = false; + // The list expresses exactly one tag, so carry the first chip across. A second tag + // or an uploader chip cannot be represented here; they stay on `activeFilters` and + // come back when the user returns to the grid. + selectedHashtag = firstTagFilter(); } + viewMode = mode; + // Both views send their filters to the server, but they express them differently + // (`hashtag` vs `hashtags`+`uploader`), so refetch only when the effective query + // actually changed — switching views with no filter must not reset pagination. + if (filterParams().toString() !== before) reloadForFilters(); } @@ -604,10 +877,7 @@ @@ -781,7 +1051,7 @@ element self-destructing is harmless, and press-then-slide-off aborts like a button should. -->
e.preventDefault()} role="listbox" tabindex="-1" @@ -918,10 +1188,51 @@
{/if}
+ {:else if loadError} + +
+ +

+ Galerie konnte nicht geladen werden. +

+

+ Das WLAN ist gerade voll. Versuch es gleich noch einmal. +

+ +
+ {:else if uploads.length === 0 && hasActiveFilters} + +
+

+ Keine Treffer für die gewählten Filter. +

+ +
{:else if uploads.length === 0}

Noch keine Fotos.

-

Tippe auf den Plus-Button unten!

+

+ Tippe auf den Kamera-Button unten! +

{:else if viewMode === 'list'} @@ -937,33 +1248,21 @@ />
{:else} - +
- {#if displayUploads.length === 0} -
-

- Keine Treffer für die gewählten Filter. -

- {#if nextCursor} -

- Es sind noch nicht alle Beiträge geladen — scrolle weiter, um mehr zu durchsuchen. -

- {/if} - -
- {:else} - (selectedUpload = u)} - oncontextmenu={openContextSheet} - /> - {/if} + (selectedUpload = u)} + oncontextmenu={openContextSheet} + />
{/if} @@ -986,6 +1285,10 @@ upload={selectedUpload} onclose={() => (selectedUpload = null)} onlike={handleLike} + hasPrev={lightboxIndex > 0} + hasNext={lightboxIndex >= 0 && lightboxIndex < uploads.length - 1} + onprev={() => stepLightbox(-1)} + onnext={() => stepLightbox(1)} /> {/if}