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

@@ -27,6 +27,27 @@ const handlers: Map<string, EventHandler[]> = new Map();
let reconnectAttempt = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
/**
* True once a stream has opened at least once this session. Distinguishes "first
* connect" from "reconnect" now that `lastEventTime` is seeded at ticket-mint rather
* than in `onopen` (see `connectSse`) — without it every boot would fire a pointless
* zero-width delta.
*/
let streamEverOpened = false;
/**
* `Date.now()` of the last thing we actually received on the live stream.
*
* Keep-alives are deliberately NOT observable here: the backend sends them as SSE
* COMMENTS (`KeepAlive::new().text("ping")` emits `:ping`), and the EventSource parser
* discards comments without dispatching anything at all. There is no browser API that
* exposes them. So liveness cannot be decided by a plain silence timer — it could not
* tell a dead socket from a genuinely quiet half hour, and would churn reconnects for
* every guest through every lull. The backstop poll below decides it on evidence
* instead: the server had news that this stream never delivered.
*/
let lastStreamActivity = 0;
/**
* SSE event names emitted by the backend. Add new ones here as `state.sse_tx.send`
* call sites grow — every entry becomes a relay registration below.
@@ -91,29 +112,40 @@ export function connectSse(): void {
scheduleReconnect();
return;
}
// Auth flow may have torn things down while we were awaiting the ticket.
if (!getToken() || eventSource) return;
// Seed the reconnect cursor the moment we have a server clock — NOT in `onopen`,
// which is where it used to live. `onopen` never fires behind a captive portal or
// any proxy that buffers `text/event-stream`, so on exactly the networks where the
// stream fails the cursor stayed `null` forever and the backstop poll below had no
// `since` to fetch from. Still the server clock and never `new Date()`: a skewed
// browser clock would shift the window and silently drop uploads.
if (!lastEventTime) lastEventTime = serverTime;
// Auth flow may have torn things down while we were awaiting the ticket — and the
// phone may have gone to sleep during that round-trip. Opening a stream while
// hidden is worse than not opening one: iOS reaps a backgrounded socket without
// ever firing `onerror`, so `eventSource` stays non-null and the guard at the top
// of this function then treats the corpse as a live connection for the rest of the
// evening. The visibility handler reconnects us when the screen comes back.
if (!getToken() || eventSource || (typeof document !== 'undefined' && document.hidden)) return;
eventSource = new EventSource(`/api/v1/stream?ticket=${encodeURIComponent(ticket)}`);
eventSource.onopen = () => {
// Successful connection — reset the backoff counter.
reconnectAttempt = 0;
// If we have a previous timestamp this is a reconnect — fetch the gap. The
// delta advances `lastEventTime` from the SERVER clock it returns.
const since = lastEventTime;
if (since) {
void deltaFetchAndFan(since);
} else {
// First connect: seed the cursor from the server clock at ticket-mint time,
// never `new Date()` — a skewed browser clock would otherwise shift the very
// first reconnect window and could drop uploads.
lastEventTime = serverTime;
}
noteStreamActivity();
// A reconnect has a gap to close; the very first open of a session does not
// (the cursor was just seeded from this ticket's server clock). The delta
// advances `lastEventTime` from the SERVER clock it returns.
if (streamEverOpened && lastEventTime) void deltaFetchAndFan(lastEventTime);
streamEverOpened = true;
};
for (const eventName of KNOWN_EVENTS) {
eventSource.addEventListener(eventName, (e) => dispatch(eventName, (e as MessageEvent).data));
eventSource.addEventListener(eventName, (e) => {
noteStreamActivity();
dispatch(eventName, (e as MessageEvent).data);
});
}
// `resync` is emitted by the server when our broadcast subscription fell
@@ -123,6 +155,7 @@ export function connectSse(): void {
// own listener — not via `dispatch` — so reading `lastEventTime` as the gap
// start isn't clobbered by dispatch bumping it to "now".
eventSource.addEventListener('resync', () => {
noteStreamActivity();
const since = lastEventTime;
if (since) void deltaFetchAndFan(since);
});
@@ -141,7 +174,14 @@ export function connectSse(): void {
function scheduleReconnect(): void {
reconnectAttempt++;
const delay = Math.min(60_000, 1_000 * 2 ** (reconnectAttempt - 1));
const jitter = Math.random() * 500;
// Jitter must SCALE WITH the backoff, not be a flat 500ms. Every client that dropped
// together shares the same `reconnectAttempt`, so they compute an identical `delay` —
// a fixed 500ms window spreads 100 phones over half a second no matter how long the
// backoff grew, which is the thundering herd the backoff exists to prevent. Each
// reconnect costs a ticket POST + stream GET + feed-delta fetch, so the herd lands on
// the DB pool three times over. Scaling the jitter to the delay (floored at 1s so the
// first, most synchronised retry is spread too) turns that into a smooth ramp.
const jitter = Math.random() * Math.max(delay, 1_000);
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(connectSse, delay + jitter);
}
@@ -157,6 +197,91 @@ export function disconnectSse(): void {
}
}
function noteStreamActivity(): void {
lastStreamActivity = Date.now();
}
// ── Stream backstop ────────────────────────────────────────────────────────────────
//
// EVERY feed update is triggered by an SSE event — there is no periodic refetch — so a
// stream that stops delivering freezes a guest's gallery for the rest of the evening.
// And a venue produces exactly the two failures that the reconnect path cannot see:
//
// • a captive portal or any proxy that buffers `text/event-stream` never forwards a
// byte, so `onopen` may never fire and neither does `onerror`;
// • a phone that roams between APs leaves a HALF-OPEN socket — the connection is gone
// but `readyState` still reads OPEN, no error is raised, and `connectSse` early-
// returns on its non-null `eventSource`, so nothing ever reconnects.
//
// Neither surfaces anything to react to, which is why the only honest backstop is to
// ask the server. `/feed/delta` is idempotent and answers with an empty payload when
// nothing changed, and one request per 60120s is ~1/60th of the per-user feed limit —
// cheap insurance against a guest staring at a frozen feed all night. The diashow uses
// the same reconcile-on-a-timer for the same reason (`RECONCILE_INTERVAL_MS` there).
const BACKSTOP_MIN_MS = 60_000;
const BACKSTOP_MAX_MS = 120_000;
let backstopTimer: ReturnType<typeof setTimeout> | null = null;
let backstopEnabled = false;
/**
* Start the poll-based liveness/completeness backstop. Opt-in per page (the feed is the
* consumer that needs it; the export page's SSE use is a status ping and the diashow
* runs its own full reconcile), and idempotent.
*/
export function startStreamBackstop(): void {
if (typeof document === 'undefined' || backstopEnabled) return;
backstopEnabled = true;
scheduleBackstop();
}
export function stopStreamBackstop(): void {
backstopEnabled = false;
if (backstopTimer) {
clearTimeout(backstopTimer);
backstopTimer = null;
}
}
function scheduleBackstop(): void {
if (backstopTimer) clearTimeout(backstopTimer);
// Jittered for the same reason the reconnect backoff is: ~100 phones that joined
// within the same few minutes would otherwise poll in permanent lockstep.
const delay = BACKSTOP_MIN_MS + Math.random() * (BACKSTOP_MAX_MS - BACKSTOP_MIN_MS);
backstopTimer = setTimeout(() => void runBackstop(), delay);
}
async function runBackstop(): Promise<void> {
backstopTimer = null;
try {
// A hidden tab has no stream (the visibility handler closed it) and cannot show a
// result anyway; the reopen path already fetches the gap.
if (document.hidden || !getToken()) return;
// No stream, or one the browser has admitted is closed, while we are visible: there
// is no other way back, because `connectSse`'s `eventSource` guard cannot tell a
// corpse from a live connection.
if (!eventSource || eventSource.readyState === EventSource.CLOSED) {
disconnectSse();
reconnectAttempt = 0;
connectSse();
return;
}
const since = lastEventTime;
if (!since) return;
const activityBefore = lastStreamActivity;
const carried = await deltaFetchAndFan(since);
// The server had news that this stream never delivered. That is the evidence a
// half-open socket cannot otherwise give us — reconnect, or every remaining update
// tonight arrives at poll latency instead of instantly.
if (carried && lastStreamActivity === activityBefore) {
disconnectSse();
reconnectAttempt = 0;
connectSse();
}
} finally {
if (backstopEnabled) scheduleBackstop();
}
}
export function getLastEventTime(): string | null {
return lastEventTime;
}
@@ -196,14 +321,23 @@ function extractCreatedAt(data: string): string | undefined {
* event. Subscribers (typically the feed page) merge the result into their
* in-memory list. Swallows errors — a failed delta is non-fatal; the next live
* SSE event will keep the feed moving.
*
* Resolves to whether the delta actually CARRIED something. `runBackstop` uses that as
* its liveness signal: content the poll found but the stream never pushed means the
* stream is dead in the way the browser will not report.
*/
async function deltaFetchAndFan(since: string, attempt = 0): Promise<void> {
async function deltaFetchAndFan(since: string, attempt = 0): Promise<boolean> {
try {
const response = await api.get<DeltaResponse>(`/feed/delta?since=${encodeURIComponent(since)}`);
// Advance the cursor to the server clock this delta was computed at, so the next
// reconnect resumes exactly where the server left off (no browser-clock skew).
lastEventTime = response.server_time;
dispatch('feed-delta', JSON.stringify(response));
return (
response.uploads.length > 0 ||
response.deleted_ids.length > 0 ||
response.hidden_user_ids.length > 0
);
} catch (e) {
// A throttled delta (429) must NOT be silently dropped: live events keep advancing
// `lastEventTime`, so the next reconnect would resume PAST this un-fetched gap and
@@ -211,10 +345,15 @@ async function deltaFetchAndFan(since: string, attempt = 0): Promise<void> {
// covered regardless of how the live cursor moves in the meantime. Bounded, and only
// reachable by a rapidly flapping EventSource hitting the per-user delta limit.
if (e instanceof ApiError && e.status === 429 && attempt < MAX_DELTA_RETRIES) {
const delayMs = DELTA_RETRY_BASE_MS * 2 ** attempt;
setTimeout(() => void deltaFetchAndFan(since, attempt + 1), delayMs);
// Jittered for the same reason `scheduleReconnect` is: the clients that hit this
// 429 are the ones that just reconnected together after a venue-wide wifi blip,
// so they share `attempt` and would otherwise retry in lockstep at exactly 2s,
// 4s, 8s — re-tripping the same per-user limit in a synchronised wave.
const base = DELTA_RETRY_BASE_MS * 2 ** attempt;
setTimeout(() => void deltaFetchAndFan(since, attempt + 1), base + Math.random() * base);
}
// Other errors are non-fatal — the next live SSE event keeps the feed moving.
return false;
}
}
@@ -228,6 +367,12 @@ function handleVisibilityChange() {
if (document.hidden) {
disconnectSse();
} else {
// Tear down UNCONDITIONALLY before reconnecting rather than leaning on
// `connectSse`'s `eventSource` guard. iOS can reap a backgrounded socket without
// ever firing `onerror`, which leaves a non-null but permanently dead EventSource —
// and the guard would then read that as "already connected" and never reconnect,
// for the rest of the evening. Closing an already-closed EventSource is a no-op.
disconnectSse();
// User-initiated reconnect — clear backoff so we don't wait out a long
// retry delay that was scheduled from a prior background error.
reconnectAttempt = 0;