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.
111 lines
3.9 KiB
TypeScript
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');
|
|
}
|