Files
EventSnap/frontend/src/lib/components/FeedListCard.svelte
Fabian Hamm (Privat) 51e55b1ace 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>
2026-08-03 18:36:05 +02:00

294 lines
9.9 KiB
Svelte

<script lang="ts">
import { onDestroy } from 'svelte';
import type { FeedUpload } from '$lib/types';
import { dataMode, pickMediaUrl } from '$lib/data-mode-store';
import { longpress } from '$lib/actions/longpress';
import { doubletap } from '$lib/actions/doubletap';
import { avatarPalette, initials } from '$lib/avatar';
import { vibrate } from '$lib/haptics';
import { now } from '$lib/now';
import { commentsEnabled } from '$lib/event-config-store';
import HeartBurst from './HeartBurst.svelte';
interface Props {
upload: FeedUpload;
isOwn?: boolean;
onlike: (id: string) => void;
oncomment: (id: string) => void;
onselect: (upload: FeedUpload) => void;
oncontextmenu?: (upload: FeedUpload) => void;
}
let { upload, isOwn = false, onlike, oncomment, onselect, oncontextmenu }: Props = $props();
function isVideo(mime: string): boolean {
return mime.startsWith('video/');
}
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);
if (mins < 1) return 'gerade eben';
if (mins < 60) return `vor ${mins} Min.`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `vor ${hrs} Std.`;
const days = Math.floor(hrs / 24);
return `vor ${days} Tag${days === 1 ? '' : 'en'}`;
}
// Re-derives off the shared 60s clock so "vor 5 Min." actually advances.
const relTime = $derived(relativeTime(upload.created_at, $now));
function openContext() {
oncontextmenu?.(upload);
}
// Inline heart-burst on double-tap (consistent with the lightbox).
let heartBurst = $state(false);
let burstTimer: ReturnType<typeof setTimeout> | null = null;
function handleDoubleTap() {
heartBurst = true;
vibrate(10);
onlike(upload.id);
if (burstTimer) clearTimeout(burstTimer);
burstTimer = setTimeout(() => (heartBurst = false), 700);
}
// Clear the burst timer on unmount (a feed-delta SSE event can remove this
// card mid-animation) so it can't fire against a stale component.
onDestroy(() => {
if (burstTimer) clearTimeout(burstTimer);
if (mediaRetryTimer) clearTimeout(mediaRetryTimer);
});
</script>
<!-- Off-screen cards are removed from the DOM entirely by the parent VirtualFeed
window-virtualizer, so this card no longer needs content-visibility — and must
not use it, since the virtualizer measures each card's real rendered height. -->
<article class="px-3 py-2" use:longpress={{ duration: 500 }} onlongpress={openContext}>
<div
class="overflow-hidden rounded-2xl border border-gray-200/70 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<!-- Uploader row -->
<div class="flex items-center justify-between gap-3 px-4 py-3">
<div class="flex min-w-0 items-center gap-3">
<div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-sm font-bold
{avatarPalette(upload.uploader_name)}"
>
{initials(upload.uploader_name)}
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
{upload.uploader_name}
</p>
<p class="text-xs text-gray-400 dark:text-gray-500">{relTime}</p>
</div>
</div>
{#if oncontextmenu}
<!-- Desktop kebab — same actions as the mobile long-press context sheet. -->
<button
type="button"
onclick={(e) => {
e.stopPropagation();
openContext();
}}
class="rounded-full p-1 text-gray-400 hover:bg-gray-100 active:bg-gray-200 hover:text-gray-700 dark:text-gray-500 dark:hover:bg-gray-800 dark:active:bg-gray-800 dark:hover:text-gray-200"
aria-label="Mehr Aktionen"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"
><circle cx="5" cy="12" r="2" /><circle cx="12" cy="12" r="2" /><circle
cx="19"
cy="12"
r="2"
/></svg
>
</button>
{/if}
</div>
<!-- Media -->
<button
class="relative block w-full"
use:doubletap
onsingletap={() => onselect(upload)}
ondoubletap={handleDoubleTap}
onclick={(e) => {
if (e.detail === 0) onselect(upload);
}}
aria-label="Bild vergrößern"
>
<HeartBurst active={heartBurst} />
{#if isVideo(upload.mime_type)}
<div class="relative aspect-video w-full bg-gray-900">
{#if posterSrc && !mediaFailed}
<img
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">
<span
class="flex h-14 w-14 items-center justify-center rounded-full bg-black/50 text-white"
>
<svg class="h-7 w-7 pl-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</span>
</div>
</div>
{: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={displaySrc}
alt=""
class="h-full w-full object-cover"
loading="lazy"
decoding="async"
onerror={handleMediaError}
/>
</div>
{:else}
<div
class="flex aspect-square w-full items-center justify-center bg-gray-100 dark:bg-gray-800"
>
<svg
class="h-12 w-12 text-gray-300 dark:text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
{/if}
</button>
<!-- Actions row -->
<div class="flex items-center gap-4 px-4 py-2">
<button
onclick={() => {
vibrate(10);
onlike(upload.id);
}}
aria-pressed={upload.liked_by_me}
aria-label={upload.liked_by_me ? 'Gefällt mir nicht mehr' : 'Gefällt mir'}
class="flex items-center gap-1.5 text-sm font-medium transition-colors
{upload.liked_by_me
? 'text-red-500 dark:text-red-400'
: 'text-gray-500 hover:text-red-400 active:text-red-400 dark:text-gray-400 dark:hover:text-red-400 dark:active:text-red-400'}"
>
<svg
class="h-5 w-5 {upload.liked_by_me ? 'fill-red-500' : ''}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
{upload.like_count}
</button>
{#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
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
{upload.comment_count}
</button>
{/if}
{#if isOwn}
<span class="ml-auto text-xs text-gray-400 dark:text-gray-500">Eigener Beitrag</span>
{/if}
</div>
<!-- Caption -->
{#if upload.caption}
<p class="px-4 pb-3 text-sm text-gray-800 [overflow-wrap:anywhere] dark:text-gray-200">
{upload.caption}
</p>
{/if}
</div>
</article>