fix(feed): survive a bad network, and let the lightbox actually browse

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 `<img>` 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 `<body>`. 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 <noreply@anthropic.com>
This commit is contained in:
Fabian Hamm (Privat)
2026-08-03 18:36:05 +02:00
parent 87d01a8a26
commit 51e55b1ace
11 changed files with 1025 additions and 243 deletions

View File

@@ -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 <img>.
// 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 <img> 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-<img>, 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<Record<string, TileError>>({});
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)}
<div class="flex h-full items-center justify-center bg-gray-800">
{#if tileUrl(upload)}
{#if tileSrc(upload)}
<img
src={tileUrl(upload)}
src={tileSrc(upload)}
alt=""
class="h-full w-full object-cover"
loading="lazy"
decoding="async"
onerror={() => handleTileError(upload)}
/>
{/if}
<div class="absolute inset-0 flex items-center justify-center">
@@ -227,13 +290,14 @@
</svg>
</div>
</div>
{:else if tileUrl(upload)}
{:else if tileSrc(upload)}
<img
src={tileUrl(upload)}
src={tileSrc(upload)}
alt=""
class="h-full w-full object-cover"
loading="lazy"
decoding="async"
onerror={() => handleTileError(upload)}
/>
{:else}
<div class="flex h-full items-center justify-center text-gray-400">