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).
91 lines
3 KiB
TypeScript
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;
|
|
}
|