/** * Local (optimistic) admin-moderation store. * * WHY THIS EXISTS * --------------- * An admin verdict publishes a kind:1985 label so it applies across devices (see * `useAdminModeration`). But publishing requires an Amber approval, which can be * slow. We must not make the admin UI wait on it, so every verdict is ALSO * mirrored here and merged in, so it takes effect on this device instantly. * * VERDICTS (latest wins) * ---------------------- * A target has ONE current verdict, and the newest one wins — both here and on * the relay side. That is what makes moderation reversible: * • `hidden` — out of the app; * • `verified` — admin-vouched (badge, and cleared from the review queue); * • `cleared` — UNDO: back to neutral, as if nobody had said anything. * * Entries carry a timestamp so a device-local verdict (just now) can be compared * against a relay label (created_at) instead of blindly winning forever. */ /** A moderation verdict about one target. */ export type LocalVerdict = 'hidden' | 'verified' | 'cleared'; export interface LocalVerdictEntry { verdict: LocalVerdict; /** Unix seconds. */ at: number; } const STORAGE_KEY = 'skatespots_local_admin_verdicts_v1'; // Older versions stored hidden and confirmed as two separate arrays. const LEGACY_HIDDEN_KEY = 'skatespots_local_admin_hidden_v1'; const LEGACY_CONFIRMED_KEY = 'skatespots_local_admin_confirmed_v1'; type Store = Record; /** One key per target: the addressable coord, or `e:` for a concrete event. */ export function targetKey(opts: { coord?: string; id?: string }): string | null { if (opts.coord) return opts.coord; if (opts.id) return `e:${opts.id}`; return null; } function readLegacy(key: string): string[] { try { const raw = localStorage.getItem(key); if (!raw) return []; const parsed = JSON.parse(raw) as { coords?: unknown; ids?: unknown }; const coords = Array.isArray(parsed.coords) ? (parsed.coords as string[]) : []; const ids = Array.isArray(parsed.ids) ? (parsed.ids as string[]) : []; return [...coords, ...ids.map((id) => `e:${id}`)]; } catch { return []; } } function read(): Store { try { const raw = localStorage.getItem(STORAGE_KEY); if (raw) return JSON.parse(raw) as Store; // Migrate the legacy two-array format. `at: 0` means "no idea when", so any // real relay label supersedes it — but it still applies while the label // hasn't propagated. const migrated: Store = {}; for (const key of readLegacy(LEGACY_HIDDEN_KEY)) migrated[key] = { verdict: 'hidden', at: 0 }; for (const key of readLegacy(LEGACY_CONFIRMED_KEY)) migrated[key] = { verdict: 'verified', at: 0 }; if (Object.keys(migrated).length > 0) write(migrated); return migrated; } catch { return {}; } } function write(store: Store): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); } catch { /* storage full / unavailable — the optimistic mirror is best-effort */ } } /** Record the admin's verdict for a target (device-local, newest wins). */ export function setLocalVerdict( opts: { coord?: string; id?: string }, verdict: LocalVerdict, ): void { const key = targetKey(opts); if (!key) return; const store = read(); store[key] = { verdict, at: Math.floor(Date.now() / 1000) }; write(store); } /** Read every locally-recorded verdict, keyed by `targetKey`. */ export function getLocalVerdicts(): Map { return new Map(Object.entries(read())); } // ─── Thin wrappers, kept for existing callers ──────────────────────────────── /** @see setLocalVerdict */ export function addLocalHidden(opts: { coord?: string; id?: string }): void { setLocalVerdict(opts, 'hidden'); } /** @see setLocalVerdict */ export function addLocalConfirmed(opts: { coord?: string; id?: string }): void { setLocalVerdict(opts, 'verified'); }