SkateSpots/src/lib/localDeletions.ts
SkateSpots ef01c3a5ed fix(map): stop deleted spots flashing back from cache
A NIP-09 kind-5 delete is only honoured by relays and was never applied
locally, so on refresh the persisted React Query cache and the Dexie spot
cache re-rendered a just-deleted spot for a few seconds until fresh data
(or the admin hidden-label query) filtered it out.

Add src/lib/localDeletions.ts: a device-local tombstone keyed by coord
and event id, written optimistically by useDeleteEntity on the owner path
(rolled back if signing/publishing fails). The shared filterAdminHidden/
filterAdminHiddenMixed now also drop tombstoned items, so every list hook
filters them from first render with no per-hook changes. useSkateSpot
returns null for a tombstoned spot, and cacheSpots/cacheSpot refuse and
evict tombstoned rows. Prune by tombstone only (callers use limits 8-2000,
so pruning by 'not in fresh result' would wipe valid cached spots).
2026-10-09 09:03:16 +07:00

91 lines
3 KiB
TypeScript

/**
* 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:<id>`) 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<string, LocalDeletionEntry>;
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:<id>`. */
export function getLocalDeletions(): Map<string, LocalDeletionEntry> {
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;
}