diff --git a/AGENTS.md b/AGENTS.md index bbd4ae6..0cad225 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1621,6 +1621,26 @@ session with **no other memory of this project**. So: functions with a free draft (`netlify deploy --dir=dist`), not a prod deploy. Verified end-to-end on a draft (2026-09-28): claim → resolves → a second key gets 409 → release → gone. +- **Deleted content flashed back from cache (fixed 2026-10-09):** on refresh, a + just-deleted spot (e.g. the Africa test pins) appeared for a few seconds and + then vanished. Two cache layers render before "current truth": the persisted + React Query cache (`skate-spots`/`skate-shops` are allow-listed in + `queryPersister.ts`) and the Dexie spot cache (`offlineCache.ts`). A NIP-09 + kind-5 delete is honoured **only by relays** and is never applied locally, and + some relays ignore it — so cached copies kept resurfacing until the fresh query + (or the `useAdminModeration` hidden-label query) filtered them out. + Fix: **`src/lib/localDeletions.ts`** — a device-local tombstone + (`skatespots_local_deletions_v1`) keyed by BOTH coord (`kind:pubkey:d`) and + `e:`, written optimistically in `useDeleteEntity` on the owner path (rolled + back if signing/publish fails). The shared **`filterAdminHidden` / + `filterAdminHiddenMixed`** now also drop tombstoned items, so every list hook + (spots/shops/events/tours/crews/marketplace/skaters/coaches/lab) filters them + from the first render — no per-hook changes. `useSkateSpot` returns null for a + tombstoned spot. `cacheSpots`/`cacheSpot` refuse to re-cache tombstoned rows and + evict existing ones. ⚠️ Do NOT prune the Dexie cache by "not in the fresh + result": callers use limits from 8 to 2000, so a small-limit query would wipe + valid cached spots — prune by tombstone only. Admin `hide` was already instant + via `localModeration`'s local verdicts; this closes the NIP-09 self-delete gap. - **Offline / dead-relay behaviour (2026-09-28):** the app is a utility, so a relay outage must degrade, not blank. ⚠️ `querySkateContent` used to swallow **every** relay error and return `[]`, so a dead relay looked like "no spots" diff --git a/src/hooks/useAdminModeration.ts b/src/hooks/useAdminModeration.ts index 78c4224..d69b5a2 100644 --- a/src/hooks/useAdminModeration.ts +++ b/src/hooks/useAdminModeration.ts @@ -8,6 +8,7 @@ import { MODERATION_LABEL_CLEARED, } from '@/lib/types'; import { getLocalVerdicts, type LocalVerdict } from '@/lib/localModeration'; +import { getLocalDeletions } from '@/lib/localDeletions'; /** * App-wide admin moderation layer. @@ -56,11 +57,16 @@ const EMPTY: AdminHiddenSet = { export function filterAdminHidden< T extends { id: string; pubkey: string; identifier: string }, >(items: T[], kind: number, hidden: AdminHiddenSet): T[] { - if (hidden.coords.size === 0 && hidden.ids.size === 0) return items; - return items.filter( - (it) => - !hidden.has({ coord: `${kind}:${it.pubkey}:${it.identifier}`, id: it.id }), - ); + const deleted = getLocalDeletions(); + if (hidden.coords.size === 0 && hidden.ids.size === 0 && deleted.size === 0) return items; + return items.filter((it) => { + const coord = `${kind}:${it.pubkey}:${it.identifier}`; + if (hidden.has({ coord, id: it.id })) return false; + // Locally-deleted (NIP-09 pending): drop immediately so a deleted item can + // never flash back from the persistence/offline cache on refresh. + if (deleted.has(coord) || deleted.has(`e:${it.id}`)) return false; + return true; + }); } /** @@ -76,14 +82,14 @@ export function filterAdminHiddenMixed< event: { kind: number }; }, >(items: T[], hidden: AdminHiddenSet): T[] { - if (hidden.coords.size === 0 && hidden.ids.size === 0) return items; - return items.filter( - (it) => - !hidden.has({ - coord: `${it.event.kind}:${it.pubkey}:${it.identifier}`, - id: it.id, - }), - ); + const deleted = getLocalDeletions(); + if (hidden.coords.size === 0 && hidden.ids.size === 0 && deleted.size === 0) return items; + return items.filter((it) => { + const coord = `${it.event.kind}:${it.pubkey}:${it.identifier}`; + if (hidden.has({ coord, id: it.id })) return false; + if (deleted.has(coord) || deleted.has(`e:${it.id}`)) return false; + return true; + }); } export function useAdminModeration() { diff --git a/src/hooks/useDeleteEntity.ts b/src/hooks/useDeleteEntity.ts index 54e9078..ac0ebd0 100644 --- a/src/hooks/useDeleteEntity.ts +++ b/src/hooks/useDeleteEntity.ts @@ -3,6 +3,7 @@ import { useNostr } from '@nostrify/react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { isAdminPubkey } from '@/lib/admins'; import { useAdminHide } from '@/hooks/useAdminHide'; +import { addLocalDeletion, removeLocalDeletion } from '@/lib/localDeletions'; import { toast } from '@/hooks/useToast'; import { useTranslation } from '@/contexts/language'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -102,6 +103,12 @@ export function useDeleteEntity() { created_at: Math.floor(Date.now() / 1000), }; + // Record a local tombstone NOW so the deleted item cannot flash back from + // the persistence/offline cache while the kind 5 propagates (or if some + // relay never honours it). Rolled back below if the delete does not go + // through, so a cancelled/failed delete cannot silently hide real content. + addLocalDeletion({ coord, id: event.id }); + // ── PHASE 1: SIGN ──────────────────────────────────────────────────────── // With the Amethyst-style foreground signer, Amber pops up its Approve/Reject // screen directly — no more silent ContentProvider stall. The signer has its @@ -124,6 +131,8 @@ export function useDeleteEntity() { ); } catch (e) { const msg = e instanceof Error ? e.message : String(e); + // The delete did not go through — undo the optimistic tombstone. + removeLocalDeletion({ coord, id: event.id }); // Distinguish a deliberate rejection from a real failure. if (/reject/i.test(msg)) { toast({ title: t('toastsUi.deleteCancelled'), description: t('toastsUi.deleteCancelledDesc') }); @@ -151,6 +160,9 @@ export function useDeleteEntity() { ); } catch (e) { const msg = e instanceof Error ? e.message : String(e); + // Signed fine but the relay refused/failed — undo the tombstone so the + // content is not hidden locally when nothing was actually deleted. + removeLocalDeletion({ coord, id: event.id }); // The event was SIGNED fine — the relay is the slow/failing part. toast({ title: t('toastsUi.deleteStuck'), diff --git a/src/hooks/useSkateSpots.ts b/src/hooks/useSkateSpots.ts index c3e5ed5..d4e2e16 100644 --- a/src/hooks/useSkateSpots.ts +++ b/src/hooks/useSkateSpots.ts @@ -3,6 +3,7 @@ import { SKATE_SPOT_KIND, parseSkateSpot, type SkateSpot } from '@/lib/types'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration'; +import { hasLocalDeletion } from '@/lib/localDeletions'; import { cacheSpots, getAllCachedSpots, isOffline } from '@/lib/offlineCache'; // Spots change infrequently — cache for 5 minutes, keep for 30 minutes @@ -93,7 +94,11 @@ export function useSkateSpot(identifier: string, pubkey?: string) { queryKey: ['skate-spot', identifier, pubkey, getQueryKeySuffix()], select: (spot: SkateSpot | null) => { if (!spot) return null; - if (!hidden.has({ coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`, id: spot.id })) { + const coord = `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`; + // Locally-deleted (NIP-09 pending): treat as gone immediately so a + // stale cached copy can't resurface on a direct visit either. + if (hasLocalDeletion({ coord, id: spot.id })) return null; + if (!hidden.has({ coord, id: spot.id })) { return spot; } // A hidden spot stays reachable for its OWN AUTHOR — otherwise their spot diff --git a/src/lib/localDeletions.ts b/src/lib/localDeletions.ts new file mode 100644 index 0000000..2facd39 --- /dev/null +++ b/src/lib/localDeletions.ts @@ -0,0 +1,91 @@ +/** + * Local (optimistic) content-deletion tombstones. + * + * WHY THIS EXISTS + * --------------- + * A NIP-09 deletion request (kind 5) is only honoured by relays, and the client + * never applies it locally. Until the relays have processed it — and some relays + * never do — a deleted event can still come back from the React Query + * persistence cache or the Dexie offline cache, so a deleted spot flashes on the + * map for a few seconds on refresh before the fresh relay result removes it. + * + * Recording a tombstone the moment the author deletes something lets every list + * filter drop it from the very first render, independent of relay timing. The + * tombstone is device-local and best-effort; it is NOT a substitute for the + * kind-5 event (that is what removes the content for everyone else). + * + * KEYING + * ------ + * A target is keyed BOTH by its addressable coordinate (`kind:pubkey:d`) and by + * its concrete event id (`e:`) when both are known, so a list filter matches + * whichever form it has. Entries carry a timestamp so the store can be pruned + * later if needed. + */ + +export interface LocalDeletionEntry { + /** Unix seconds. */ + at: number; +} + +const STORAGE_KEY = 'skatespots_local_deletions_v1'; + +type Store = Record; + +function read(): Store { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? (parsed as Store) : {}; + } catch { + return {}; + } +} + +function write(store: Store): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); + } catch { + /* storage full / unavailable — the optimistic tombstone is best-effort */ + } +} + +/** Record that a target was deleted by its author on this device. */ +export function addLocalDeletion(opts: { coord?: string; id?: string }): void { + const { coord, id } = opts; + if (!coord && !id) return; + const store = read(); + const at = Math.floor(Date.now() / 1000); + if (coord) store[coord] = { at }; + if (id) store[`e:${id}`] = { at }; + write(store); +} + +/** Undo a tombstone (e.g. the deletion was cancelled or failed to publish). */ +export function removeLocalDeletion(opts: { coord?: string; id?: string }): void { + const { coord, id } = opts; + const store = read(); + let changed = false; + if (coord && coord in store) { + delete store[coord]; + changed = true; + } + if (id && `e:${id}` in store) { + delete store[`e:${id}`]; + changed = true; + } + if (changed) write(store); +} + +/** Every locally-recorded deletion, keyed by coord or `e:`. */ +export function getLocalDeletions(): Map { + return new Map(Object.entries(read())); +} + +/** True when this target carries a local deletion tombstone. */ +export function hasLocalDeletion(opts: { coord?: string; id?: string }): boolean { + const store = read(); + if (opts.coord && opts.coord in store) return true; + if (opts.id && `e:${opts.id}` in store) return true; + return false; +} diff --git a/src/lib/offlineCache.ts b/src/lib/offlineCache.ts index a742ee1..ff817f5 100644 --- a/src/lib/offlineCache.ts +++ b/src/lib/offlineCache.ts @@ -1,6 +1,7 @@ import Dexie, { type Table } from 'dexie'; import type { NostrEvent } from '@nostrify/nostrify'; -import type { SkateSpot } from '@/lib/types'; +import { SKATE_SPOT_KIND, type SkateSpot } from '@/lib/types'; +import { getLocalDeletions } from '@/lib/localDeletions'; // Define the database schema interface CachedSpot { @@ -95,6 +96,16 @@ export async function cacheSpot(event: NostrEvent, spot: SkateSpot): Promise): Promise { const db = getDB(); const now = Date.now(); + const deleted = getLocalDeletions(); - await db.spots.bulkPut( - spotsWithEvents.map(({ event, spot }) => ({ - id: event.id, - pubkey: spot.pubkey, - identifier: spot.identifier, - event, - spot, - cachedAt: now, - viewedAt: now, - })) - ); + const isTombstoned = (event: NostrEvent, spot: SkateSpot) => + deleted.has(`${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`) || + deleted.has(`e:${event.id}`); + + const keep = + deleted.size > 0 + ? spotsWithEvents.filter(({ event, spot }) => !isTombstoned(event, spot)) + : spotsWithEvents; + + if (keep.length > 0) { + await db.spots.bulkPut( + keep.map(({ event, spot }) => ({ + id: event.id, + pubkey: spot.pubkey, + identifier: spot.identifier, + event, + spot, + cachedAt: now, + viewedAt: now, + })) + ); + } + + // Evict previously-cached rows that now carry a tombstone (deleted after they + // were cached), so the offline map can never resurrect a deleted spot. + if (deleted.size > 0) { + const stale = (await db.spots.toArray()) + .filter( + (c) => + deleted.has(`${SKATE_SPOT_KIND}:${c.pubkey}:${c.identifier}`) || + deleted.has(`e:${c.id}`), + ) + .map((c) => c.id); + if (stale.length > 0) await db.spots.bulkDelete(stale); + } } /**