fix(frontend): park uploads that cannot succeed, and stop two false signals
The upload queue gains `parkedFor`, so a photo rejected for a reason that cannot change on its own stops re-pushing itself. A ban used to come back as a generic `forbidden`, which purged the blob and moved the row to `blocked` — a terminal state with no retry button — so lifting a ban restored everything except the photo actually in flight. Ban and release are now distinct codes that keep the blob, charge no attempt, and tell the guest what has to happen. `releaseResolvedParks` drains them at boot from /me/context, because the live `user-shown` / `event-opened` events only reach a tab that was open when the host acted, and the usual sequence is the other way round. Two signals were firing on nothing. A filtered feed set `feedStale` on EVERY delta without deduping — and the delta cursor boundary is inclusive while sse.ts deliberately rewinds `lastEventTime`, so deltas routinely re-return rows already delivered. With the backstop polling every 60-120s, a guest who tapped a hashtag got a "Neue Beiträge" pill they could never clear, each tap costing a full filtered refetch. It now dedupes in both branches. The SSE liveness backstop had the mirror problem: `noteDelivered` harvested id, upload_id AND user_id from every payload, so by the time anything was deleted or anyone banned, their ids were already marked delivered from ordinary traffic about live content. The `deleted_ids` and `hidden_user_ids` clauses were false essentially always, leaving a half-open socket undetected while a host moderated into a feed nobody was listening to. Each event now records only the id its own clause tests. Also: /admin no longer bounces to /join on a cleared session — AUTH_ROUTES had the `/admin` prefix, which suppressed clearAuth() on the dashboard and let the login guard bounce back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -308,7 +308,11 @@
|
||||
unsubscribers.push(
|
||||
onSseEvent('new-upload', (data) => {
|
||||
try {
|
||||
const upload: FeedUpload = JSON.parse(data);
|
||||
// The `new-upload` payload is the backend's `UploadDto`, which carries `hashtags`
|
||||
// on top of what `FeedUpload` declares — needed for the filter check below.
|
||||
// Typed explicitly rather than widening `FeedUpload`, because the feed's own
|
||||
// rows (from `/feed`) genuinely do not include them.
|
||||
const upload: FeedUpload & { hashtags?: string[] } = JSON.parse(data);
|
||||
// GRID view must NOT prepend live. Its rows are POSITIONAL windows
|
||||
// (`uploads.slice(i * COLS, …)` in VirtualFeed), so inserting at the head shifts
|
||||
// every tile by one slot: each row's keyed `{#each}` then sees a different set of
|
||||
@@ -329,6 +333,12 @@
|
||||
// row, and a duplicate id in a keyed `{#each}` is a thrown error, not a
|
||||
// cosmetic glitch.
|
||||
if (uploads.some((u) => u.id === upload.id)) return;
|
||||
// Respect the active filter (H13). `uploads` IS the filtered set — the server
|
||||
// applied `filterParams()` — but this handler prepended unconditionally, so a
|
||||
// guest who filtered to #tanzflaeche had that filter quietly destroyed within
|
||||
// minutes by everyone else's uploads, with no indication and no way back short
|
||||
// of toggling the chip. The payload carries `hashtags`, so we can just check.
|
||||
if (selectedHashtag && !upload.hashtags?.includes(selectedHashtag)) return;
|
||||
uploads = [upload, ...uploads];
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -372,6 +382,17 @@
|
||||
/* ignore */
|
||||
}
|
||||
}),
|
||||
// The ban was lifted — their cards must come back. Unlike a hide we cannot do this in
|
||||
// place: the cards were filtered out of `uploads`, so there is nothing left to restore
|
||||
// from. Flag the feed stale and let the existing tap-to-refresh pill resync, which is
|
||||
// the same treatment a truncated delta gets and keeps the guest's scroll position.
|
||||
//
|
||||
// Without this the unban was invisible to every open feed and to the unattended
|
||||
// projector until somebody reloaded by hand — while the host's confirm copy promised
|
||||
// the photos were back.
|
||||
onSseEvent('user-shown', () => {
|
||||
feedStale = true;
|
||||
}),
|
||||
// Patch the single affected card in place from the SSE payload instead of
|
||||
// refetching page 1 — a busy event fires these constantly and a full reload
|
||||
// would yank every scrolled-down user back to the top on each reaction.
|
||||
@@ -406,9 +427,39 @@
|
||||
return;
|
||||
}
|
||||
if (delta.uploads.length) {
|
||||
// `/feed/delta` takes NO filter parameters, so its rows are the unfiltered
|
||||
// event. Merging them into a filtered view is the same defect as the
|
||||
// `new-upload` prepend above (H13) — and here we cannot check hashtags,
|
||||
// because the delta rows do not carry them.
|
||||
//
|
||||
// So when a filter is active, don't merge: flag the feed stale and let the
|
||||
// existing tap-to-refresh pill re-run page 1 under `filterParams()`. Same
|
||||
// treatment a truncated delta gets, and it keeps the guest's scroll.
|
||||
// Dedupe FIRST, in both branches. `delta.uploads.length > 0` is not
|
||||
// evidence that anything NEW arrived: the delta cursor boundary is
|
||||
// inclusive and `sse.ts` deliberately rewinds `lastEventTime` to an
|
||||
// upload's `created_at`, so a delta routinely re-returns rows the
|
||||
// stream already delivered. The filtered branch skipped this check
|
||||
// entirely and set `feedStale` on EVERY delta — and the backstop
|
||||
// polls every 60-120s, so a guest who tapped a hashtag got a "Neue
|
||||
// Beiträge" pill they could never clear, each tap costing a full
|
||||
// filtered refetch. That trains guests to ignore the one control
|
||||
// that means something. It also fired for any other guest's
|
||||
// non-matching upload, which by construction is never in the
|
||||
// filtered `uploads`.
|
||||
const seen = new Set(uploads.map((u) => u.id));
|
||||
const fresh = delta.uploads.filter((u) => !seen.has(u.id));
|
||||
if (fresh.length) uploads = [...fresh, ...uploads];
|
||||
if (fresh.length) {
|
||||
if (selectedHashtag || activeFilters.length) {
|
||||
// Still cannot MERGE under a filter — `/feed/delta` takes no
|
||||
// filter params and its rows carry no hashtags, so we cannot
|
||||
// tell which belong in this view. Flagging stale is right;
|
||||
// doing it for rows already on screen was not.
|
||||
feedStale = true;
|
||||
} else {
|
||||
uploads = [...fresh, ...uploads];
|
||||
}
|
||||
}
|
||||
}
|
||||
// A delta reconciles new uploads and deletions, but not like/comment
|
||||
// counts that changed on already-visible cards while we were
|
||||
|
||||
Reference in New Issue
Block a user