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:
@@ -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 <img>.
|
||||
const posterSrc = $derived(upload.thumbnail_url ?? upload.preview_url ?? '');
|
||||
|
||||
// ── Media fallback ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A failed <img> 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<typeof setTimeout> | 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);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -124,13 +172,14 @@
|
||||
<HeartBurst active={heartBurst} />
|
||||
{#if isVideo(upload.mime_type)}
|
||||
<div class="relative aspect-video w-full bg-gray-900">
|
||||
{#if upload.thumbnail_url || upload.preview_url}
|
||||
{#if posterSrc && !mediaFailed}
|
||||
<img
|
||||
src={upload.thumbnail_url ?? upload.preview_url ?? ''}
|
||||
src={displaySrc}
|
||||
alt=""
|
||||
class="h-full w-full object-cover opacity-80"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onerror={handleMediaError}
|
||||
/>
|
||||
{/if}
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
@@ -143,17 +192,18 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if mediaSrc}
|
||||
{:else if mediaSrc && !mediaFailed}
|
||||
<!-- Reserve the same 4/5 box the skeleton uses so the card doesn't collapse
|
||||
to height 0 and reflow as images stream in. The uncropped original is one
|
||||
tap away in the lightbox. -->
|
||||
<div class="aspect-[4/5] w-full bg-gray-100 dark:bg-gray-800">
|
||||
<img
|
||||
src={mediaSrc}
|
||||
src={displaySrc}
|
||||
alt=""
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onerror={handleMediaError}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -209,6 +259,7 @@
|
||||
{#if $commentsEnabled}
|
||||
<button
|
||||
onclick={() => oncomment(upload.id)}
|
||||
aria-label="Kommentare anzeigen"
|
||||
class="flex items-center gap-1.5 text-sm font-medium text-gray-500 transition-colors hover:text-blue-500 active:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 dark:active:text-blue-400"
|
||||
>
|
||||
<svg
|
||||
|
||||
Reference in New Issue
Block a user