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>
381 lines
16 KiB
Svelte
381 lines
16 KiB
Svelte
<script lang="ts">
|
|
// DOM-windowing for the feed. Only the cards/rows inside (and a small overscan
|
|
// around) the viewport are kept in the DOM — at ~1000 uploads this is the
|
|
// difference between ~1000 heavy cards (each with its own image, HeartBurst,
|
|
// long-press + double-tap listeners) and ~10-15.
|
|
//
|
|
// We use TanStack's *window* virtualizer (not an inner scroll container) on
|
|
// purpose: the feed scrolls the document, and the page's sticky header,
|
|
// pull-to-refresh, infinite-scroll sentinel and bottom nav all rely on that.
|
|
// The window virtualizer measures against `window` scroll, so every one of
|
|
// those keeps working untouched.
|
|
//
|
|
// Two layouts share one mechanism:
|
|
// list — one full-width FeedListCard per row, heights *measured* (captions
|
|
// make them variable). Keyed by upload id + `anchorTo:'start'` so an
|
|
// SSE prepend (new upload) doesn't yank a scrolled-down reader.
|
|
// grid — three square tiles per row, uniform height; still measured to shrug
|
|
// off sub-pixel drift over hundreds of rows.
|
|
import { createWindowVirtualizer } from '@tanstack/svelte-virtual';
|
|
import { untrack } from 'svelte';
|
|
import { get } from 'svelte/store';
|
|
import { browser } from '$app/environment';
|
|
import type { FeedUpload } from '$lib/types';
|
|
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';
|
|
|
|
interface Props {
|
|
uploads: FeedUpload[];
|
|
mode: 'list' | 'grid';
|
|
myUserId: string | null;
|
|
onlike: (id: string) => void;
|
|
oncomment: (id: string) => void;
|
|
onselect: (upload: FeedUpload) => void;
|
|
oncontextmenu?: (upload: FeedUpload) => void;
|
|
}
|
|
|
|
let { uploads, mode, myUserId, onlike, oncomment, onselect, oncontextmenu }: Props = $props();
|
|
|
|
const COLS = 3;
|
|
const GRID_GAP = 2; // px — matches the `gap-0.5` the non-virtual grid used.
|
|
const LIST_ESTIMATE = 700; // px — first-paint guess; real heights replace it on measure.
|
|
|
|
let listEl = $state<HTMLDivElement>();
|
|
let containerWidth = $state(0);
|
|
|
|
// Distance from the top of the document to the list container, i.e. the height
|
|
// of everything above it (sticky header + chips/search). Items' `start` values
|
|
// are document-absolute (they include this margin), so we feed it back as
|
|
// `scrollMargin` and subtract it again when positioning. Read live from layout
|
|
// (getBoundingClientRect + scrollY is scroll-independent) so it self-corrects
|
|
// when the header grows — e.g. grid filter chips appear.
|
|
let scrollMargin = $state(0);
|
|
|
|
const rowCount = $derived(mode === 'grid' ? Math.ceil(uploads.length / COLS) : uploads.length);
|
|
const colWidth = $derived(
|
|
containerWidth > 0 ? (containerWidth - (COLS - 1) * GRID_GAP) / COLS : 120
|
|
);
|
|
|
|
function isVideo(mime: string): boolean {
|
|
return mime.startsWith('video/');
|
|
}
|
|
|
|
// 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 {
|
|
// 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
|
|
// reactive values (`colWidth`, `uploads`, `mode`) at *call* time, so they stay
|
|
// current without needing a new function reference. This matters: `getItemKey`
|
|
// is a dependency of virtual-core's measurements memo, so handing it a fresh
|
|
// closure on every render would force an O(n) recompute. List keys by upload id
|
|
// (so an SSE prepend keeps measured heights attached to the right card); grid
|
|
// keys by row index (uniform rows, nothing to preserve).
|
|
const estimateSize = (_i: number) => (mode === 'grid' ? colWidth : LIST_ESTIMATE);
|
|
const getItemKey = (i: number) => (mode === 'list' ? (uploads[i]?.id ?? i) : i);
|
|
|
|
// `mode` is fixed for the lifetime of an instance (list and grid are rendered as
|
|
// separate <VirtualFeed> elements in the parent's {#if} branches, so toggling
|
|
// remounts rather than mutating this prop). Snapshot it without a reactive read
|
|
// to set the layout-constant options once.
|
|
const isGrid = untrack(() => mode === 'grid');
|
|
|
|
// Created with static placeholder count/margin; `applyOptions` pushes those.
|
|
const virtualizer = createWindowVirtualizer<HTMLDivElement>({
|
|
count: 0,
|
|
estimateSize,
|
|
getItemKey,
|
|
overscan: isGrid ? 4 : 3,
|
|
gap: isGrid ? GRID_GAP : 0,
|
|
anchorTo: 'start',
|
|
scrollMargin: 0
|
|
});
|
|
|
|
// Only `count` (load-more / prepend / delete) and `scrollMargin` (header height
|
|
// shifts) actually need to be pushed into the virtualizer. Like/comment SSE
|
|
// patches reassign `uploads` without changing its length — those must NOT
|
|
// trigger a setOptions (and its getBoundingClientRect reflow + store churn); the
|
|
// affected card re-renders through normal reactivity instead. We read the raw
|
|
// instance via `get()` rather than `$virtualizer` so writing never re-triggers
|
|
// this effect (that would loop).
|
|
let appliedCount = -1;
|
|
let appliedMargin = Number.NaN;
|
|
let appliedWidth = Number.NaN;
|
|
|
|
function applyOptions() {
|
|
const count = rowCount;
|
|
const width = containerWidth;
|
|
const margin = listEl ? listEl.getBoundingClientRect().top + window.scrollY : 0;
|
|
scrollMargin = margin; // drives the template transforms
|
|
// A width change (rotation, or the first 0→real clientWidth landing) changes
|
|
// every card's / tile-row's real height, so the heights cached from the old
|
|
// width are stale even when count and margin are unchanged. Invalidate the
|
|
// measurement cache so getTotalSize + positions recompute (rendered rows then
|
|
// re-measure immediately via their ResizeObserver) instead of trusting them.
|
|
const widthChanged = width > 0 && Math.abs(width - appliedWidth) > 0.5;
|
|
if (count === appliedCount && Math.abs(margin - appliedMargin) < 0.5 && !widthChanged) return;
|
|
appliedCount = count;
|
|
appliedMargin = margin;
|
|
appliedWidth = width;
|
|
get(virtualizer).setOptions({ count, scrollMargin: margin });
|
|
if (widthChanged) get(virtualizer).measure();
|
|
}
|
|
|
|
// Re-apply when the row count or container width changes (load-more, prepend,
|
|
// delete, filter, rotate). `containerWidth`/`rowCount` are the only tracked
|
|
// reads, so a length-stable like/comment patch doesn't re-run this.
|
|
$effect(() => {
|
|
void rowCount;
|
|
void containerWidth;
|
|
applyOptions();
|
|
});
|
|
|
|
// Header offset can also shift on resize without a count change (orientation,
|
|
// on-screen keyboard, font scaling).
|
|
$effect(() => {
|
|
if (!browser) return;
|
|
const onResize = () => applyOptions();
|
|
window.addEventListener('resize', onResize);
|
|
return () => window.removeEventListener('resize', onResize);
|
|
});
|
|
|
|
// Hand each rendered row to the virtualizer's ResizeObserver (it reads the row's
|
|
// `data-index` and caches the real height by item key). On `destroy` we call
|
|
// `measureElement(null)`, which sweeps now-disconnected nodes out of the
|
|
// internal cache + ResizeObserver — without it, every card that scrolls out of
|
|
// the window stays observed and retained, defeating the point of virtualizing.
|
|
function measure(node: HTMLDivElement) {
|
|
get(virtualizer).measureElement(node);
|
|
return {
|
|
update() {
|
|
get(virtualizer).measureElement(node);
|
|
},
|
|
destroy() {
|
|
get(virtualizer).measureElement(null);
|
|
}
|
|
};
|
|
}
|
|
</script>
|
|
|
|
<div bind:this={listEl} bind:clientWidth={containerWidth} class="w-full">
|
|
{#if browser}
|
|
<div style="position: relative; width: 100%; height: {$virtualizer.getTotalSize()}px;">
|
|
{#each $virtualizer.getVirtualItems() as item (item.key)}
|
|
{#if mode === 'list'}
|
|
{@const upload = uploads[item.index]}
|
|
<div
|
|
data-index={item.index}
|
|
use:measure
|
|
style="position: absolute; top: 0; left: 0; width: 100%; transform: translateY({item.start -
|
|
scrollMargin}px);"
|
|
>
|
|
{#if upload}
|
|
<FeedListCard
|
|
{upload}
|
|
isOwn={upload.user_id === myUserId}
|
|
{onlike}
|
|
{oncomment}
|
|
{onselect}
|
|
{oncontextmenu}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<div
|
|
data-index={item.index}
|
|
use:measure
|
|
style="position: absolute; top: 0; left: 0; width: 100%; transform: translateY({item.start -
|
|
scrollMargin}px);"
|
|
>
|
|
<div class="grid grid-cols-3 gap-0.5">
|
|
{#each uploads.slice(item.index * COLS, item.index * COLS + COLS) as upload (upload.id)}
|
|
<!-- Tile — mirrors the markup the old FeedGrid used. -->
|
|
<div
|
|
class="group relative aspect-square cursor-pointer overflow-hidden rounded-lg bg-gray-100 dark:bg-gray-800"
|
|
use:longpress={{ duration: 500 }}
|
|
onlongpress={() => oncontextmenu?.(upload)}
|
|
>
|
|
<button
|
|
onclick={() => onselect(upload)}
|
|
class="block h-full w-full"
|
|
aria-label="Upload anzeigen"
|
|
>
|
|
{#if isVideo(upload.mime_type)}
|
|
<div class="flex h-full items-center justify-center bg-gray-800">
|
|
{#if tileSrc(upload)}
|
|
<img
|
|
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">
|
|
<svg
|
|
class="h-10 w-10 text-white/80"
|
|
fill="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path d="M8 5v14l11-7z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
{:else if tileSrc(upload)}
|
|
<img
|
|
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">
|
|
<svg class="h-8 w-8" 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>
|
|
|
|
<div
|
|
class="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-2"
|
|
>
|
|
<p class="truncate text-xs font-medium text-white">{upload.uploader_name}</p>
|
|
<div class="mt-0.5 flex items-center gap-3 text-xs text-white/80">
|
|
<button
|
|
class="pointer-events-auto -m-1 flex items-center gap-0.5 p-1"
|
|
onclick={(e) => {
|
|
e.stopPropagation();
|
|
onlike(upload.id);
|
|
}}
|
|
aria-pressed={upload.liked_by_me}
|
|
aria-label={upload.liked_by_me ? 'Gefällt mir nicht mehr' : 'Gefällt mir'}
|
|
>
|
|
<svg
|
|
class="h-4 w-4 {upload.liked_by_me ? 'fill-red-400 text-red-400' : ''}"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
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
|
|
class="pointer-events-auto -m-1 flex items-center gap-0.5 p-1"
|
|
onclick={(e) => {
|
|
e.stopPropagation();
|
|
oncomment(upload.id);
|
|
}}
|
|
aria-label="Kommentare anzeigen"
|
|
>
|
|
<svg
|
|
class="h-4 w-4"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
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}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|