SkateSpots/src/lib/localModeration.ts
SkateSpots e44f8b81a7 feat(moderation): reversible verdicts (undo) + community photos as a signal
Undo (latest verdict wins):
- MODERATION_LABEL_CLEARED = 'cleared' — the neutral undo verdict.
- useAdminModeration no longer UNIONS labels (which made a hide permanent in the
  UI); it keeps the NEWEST verdict per target (created_at, with device-local
  verdicts overlaid by timestamp), so 'cleared' supersedes an earlier
  'hidden'/'verified'. useAdminClear() publishes it.
- Local mirror is now a verdict map (skatespots_local_admin_verdicts_v1: key ->
  {verdict, at}) with migration from the legacy hidden/confirmed arrays.
- useSkateSpots(limit, { includeHidden: true }) — ADMIN TOOLING ONLY: without it
  a hidden spot leaves the list, so a verdict could never be undone. The review
  queue uses it, keeps actioned rows (Hidden/Confirmed badge + Undo), and sorts
  un-actioned first.

Community photos as a signal:
- useBatchSpotPhotos: ONE #a query for every spot (the single-spot hook already
  queried by #a, so batching is free), counting only photos from people OTHER
  than the mapper. Folded into the 'no community signals' flag.

Verified by seeding local verdicts: none -> flags + Confirm/Hide; hidden ->
Undo; verified -> Undo; cleared -> back to neutral. tsc/eslint clean, 101 tests,
smoke PASS.
2026-09-28 01:06:04 +07:00

111 lines
3.9 KiB
TypeScript

/**
* 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<string, LocalVerdictEntry>;
/** One key per target: the addressable coord, or `e:<id>` 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<string, LocalVerdictEntry> {
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');
}