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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user