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.
This commit is contained in:
parent
0419c18810
commit
e44f8b81a7
9 changed files with 358 additions and 117 deletions
15
AGENTS.md
15
AGENTS.md
|
|
@ -403,7 +403,20 @@ session with **no other memory of this project**. So:
|
|||
The flag logic is shared in **`src/lib/spotQuality.ts`** (used by both the page
|
||||
and `AdminReviewCard`, so they can't drift) and is deliberately heuristic —
|
||||
'no community signals' only fires alongside another flag, so a brand-new real
|
||||
spot isn't punished for being new.
|
||||
spot isn't punished for being new. Community signals now include **other
|
||||
skaters' photos**: `useBatchSpotPhotos` (`useSpotContributions.ts`) does ONE
|
||||
`#a` query for every spot and ignores the mapper's own photos.
|
||||
- **Verdicts are reversible — latest wins (2026-09-28):** `MODERATION_LABEL_CLEARED`
|
||||
('cleared') is the neutral undo verdict. `useAdminModeration` no longer unions
|
||||
labels; it keeps the **newest verdict per target** (by `created_at`, with
|
||||
device-local verdicts overlaid by timestamp), so publishing `cleared`
|
||||
supersedes an earlier `hidden`/`verified`. `useAdminClear()` publishes it.
|
||||
The local mirror is now a **verdict map** (`skatespots_local_admin_verdicts_v1`,
|
||||
key = coord or `e:<id>`, value `{verdict, at}`), migrating the legacy
|
||||
hidden/confirmed arrays on read. `useSkateSpots(limit, { includeHidden: true })`
|
||||
is **admin-tooling only** — without it a hidden spot leaves the list and can
|
||||
never be un-hidden. The review queue keeps actioned rows (badge + **Undo**)
|
||||
so both verbiage and verdicts are reversible in the UI.
|
||||
- **Mints directory**: `/mints` page lists Cashu mints from the community
|
||||
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
|
||||
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@ v1.5.4
|
|||
verdict on whether a place is a real spot.
|
||||
- One-tap "Not a skate spot" for off-topic pins (test entries, airports, malls,
|
||||
private land), plus an admin review queue that surfaces them for a human.
|
||||
- The queue also weighs mapper trust and whether any skater has actually
|
||||
reported on the spot, and a review can now **Confirm** a spot as well as hide
|
||||
it — a staff-vouched spot gets a badge and drops off the queue.
|
||||
- The queue also weighs mapper trust, whether any skater has reported on the
|
||||
spot, and whether anyone has added a community photo — and a review can now
|
||||
**Confirm** a spot as well as hide it (a staff-vouched spot gets a badge).
|
||||
- Moderation is **reversible**: Hide and Confirm can both be undone, returning the
|
||||
spot to neutral without hiding or vouching for it.
|
||||
|
||||
🛍 App Shop — a new section (empty on purpose, with free printables)
|
||||
- Free to print, no sats needed: a 55 × 86 mm "DO YOU EVEN SKATE BRO?" sticker
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { addLocalHidden, addLocalConfirmed } from '@/lib/localModeration';
|
||||
import { setLocalVerdict } from '@/lib/localModeration';
|
||||
import {
|
||||
MODERATION_LABEL_NAMESPACE,
|
||||
MODERATION_LABEL_HIDDEN,
|
||||
MODERATION_LABEL_VERIFIED,
|
||||
MODERATION_LABEL_CLEARED,
|
||||
} from '@/lib/types';
|
||||
|
||||
/**
|
||||
|
|
@ -18,7 +19,8 @@ import {
|
|||
*
|
||||
* Two symmetric verdicts share one code path:
|
||||
* • `hidden` — take it out of the app (soft, reversible);
|
||||
* • `verified` — vouch for it, which also clears it from the review queue.
|
||||
* • `verified` — vouch for it, which also clears it from the review queue;
|
||||
* • `cleared` — UNDO: retract a verdict, back to neutral (newest wins).
|
||||
*
|
||||
* The label is public and authored by an admin key, so every SkateSpots client
|
||||
* filters/boosts accordingly. Nothing is ever deleted from relays — that is the
|
||||
|
|
@ -58,9 +60,14 @@ function usePublishModerationLabel(label: string) {
|
|||
|
||||
await publish({ kind: 1985, content: reason ?? '', tags });
|
||||
|
||||
// Optimistic, this device.
|
||||
if (label === MODERATION_LABEL_HIDDEN) addLocalHidden({ coord, id });
|
||||
else addLocalConfirmed({ coord, id });
|
||||
// Optimistic, this device — newest verdict wins on merge.
|
||||
const verdict =
|
||||
label === MODERATION_LABEL_HIDDEN
|
||||
? 'hidden'
|
||||
: label === MODERATION_LABEL_VERIFIED
|
||||
? 'verified'
|
||||
: 'cleared';
|
||||
setLocalVerdict({ coord, id }, verdict);
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-moderation'] });
|
||||
},
|
||||
});
|
||||
|
|
@ -79,3 +86,13 @@ export function useAdminVerify() {
|
|||
const { run, isPending } = usePublishModerationLabel(MODERATION_LABEL_VERIFIED);
|
||||
return { confirmContent: run, isPending };
|
||||
}
|
||||
|
||||
/**
|
||||
* UNDO: retract whatever verdict was on the target so it is merely neutral
|
||||
* again — not hidden, not vouched. The spot stays exactly where it was; only the
|
||||
* verdict goes away.
|
||||
*/
|
||||
export function useAdminClear() {
|
||||
const { run, isPending } = usePublishModerationLabel(MODERATION_LABEL_CLEARED);
|
||||
return { clearVerdict: run, isPending };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ import {
|
|||
MODERATION_LABEL_NAMESPACE,
|
||||
MODERATION_LABEL_HIDDEN,
|
||||
MODERATION_LABEL_VERIFIED,
|
||||
MODERATION_LABEL_CLEARED,
|
||||
} from '@/lib/types';
|
||||
import { getLocalHidden, getLocalConfirmed } from '@/lib/localModeration';
|
||||
import { getLocalVerdicts, type LocalVerdict } from '@/lib/localModeration';
|
||||
|
||||
/**
|
||||
* App-wide admin moderation layer.
|
||||
|
|
@ -107,10 +108,12 @@ export function useAdminModeration() {
|
|||
);
|
||||
|
||||
const adminSet = new Set(ADMIN_PUBKEYS.map((p) => p.toLowerCase()));
|
||||
const coords = new Set<string>();
|
||||
const ids = new Set<string>();
|
||||
const verifiedCoords = new Set<string>();
|
||||
const verifiedIds = new Set<string>();
|
||||
|
||||
// NEWEST VERDICT PER TARGET WINS. Kind 1985 is a regular event, so there
|
||||
// can be several labels for one target; the latest one is the current
|
||||
// truth. That is what makes a verdict reversible: an undo publishes
|
||||
// `cleared`, which supersedes an earlier `hidden`/`verified`.
|
||||
const verdicts = new Map<string, { verdict: LocalVerdict; at: number }>();
|
||||
|
||||
for (const ev of events) {
|
||||
// Defense in depth: re-verify author + namespace + label client-side.
|
||||
|
|
@ -119,28 +122,47 @@ export function useAdminModeration() {
|
|||
([n, v]) => n === 'L' && v === MODERATION_LABEL_NAMESPACE,
|
||||
);
|
||||
if (!hasNamespace) continue;
|
||||
const isHidden = ev.tags.some(
|
||||
([n, v]) => n === 'l' && v === MODERATION_LABEL_HIDDEN,
|
||||
);
|
||||
const isVerified = ev.tags.some(
|
||||
([n, v]) => n === 'l' && v === MODERATION_LABEL_VERIFIED,
|
||||
);
|
||||
if (!isHidden && !isVerified) continue;
|
||||
|
||||
const labelOf = (want: string) =>
|
||||
ev.tags.some(([n, v]) => n === 'l' && v === want);
|
||||
const verdict: LocalVerdict | null = labelOf(MODERATION_LABEL_HIDDEN)
|
||||
? 'hidden'
|
||||
: labelOf(MODERATION_LABEL_VERIFIED)
|
||||
? 'verified'
|
||||
: labelOf(MODERATION_LABEL_CLEARED)
|
||||
? 'cleared'
|
||||
: null;
|
||||
if (!verdict) continue;
|
||||
|
||||
for (const [n, v] of ev.tags) {
|
||||
if (n === 'a' && v) (isHidden ? coords : verifiedCoords).add(v);
|
||||
if (n === 'e' && v) (isHidden ? ids : verifiedIds).add(v);
|
||||
const key = n === 'a' ? v : n === 'e' ? `e:${v}` : null;
|
||||
if (!key) continue;
|
||||
const current = verdicts.get(key);
|
||||
if (!current || ev.created_at > current.at) {
|
||||
verdicts.set(key, { verdict, at: ev.created_at });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge in this device's OPTIMISTIC local hides so admin-removed content
|
||||
// disappears instantly, before the background kind:1985 label propagates.
|
||||
const local = getLocalHidden();
|
||||
for (const c of local.coords) coords.add(c);
|
||||
for (const i of local.ids) ids.add(i);
|
||||
const localConfirmed = getLocalConfirmed();
|
||||
for (const c of localConfirmed.coords) verifiedCoords.add(c);
|
||||
for (const i of localConfirmed.ids) verifiedIds.add(i);
|
||||
// Overlay this device's OPTIMISTIC verdicts (just-made ones win until the
|
||||
// relay catches up, and an undo written locally replaces the old verdict).
|
||||
for (const [key, entry] of getLocalVerdicts()) {
|
||||
const current = verdicts.get(key);
|
||||
if (!current || entry.at >= current.at) verdicts.set(key, entry);
|
||||
}
|
||||
|
||||
const coords = new Set<string>();
|
||||
const ids = new Set<string>();
|
||||
const verifiedCoords = new Set<string>();
|
||||
const verifiedIds = new Set<string>();
|
||||
|
||||
for (const [key, { verdict }] of verdicts) {
|
||||
const isId = key.startsWith('e:');
|
||||
const value = isId ? key.slice(2) : key;
|
||||
if (verdict === 'hidden') (isId ? ids : coords).add(value);
|
||||
else if (verdict === 'verified') (isId ? verifiedIds : verifiedCoords).add(value);
|
||||
// `cleared` adds nothing — the target is simply neutral again.
|
||||
}
|
||||
|
||||
return {
|
||||
coords,
|
||||
|
|
@ -161,26 +183,31 @@ export function useAdminModeration() {
|
|||
// Even if the relay query is still loading/empty, honor local optimistic
|
||||
// hides so the takedown is reflected immediately on this device.
|
||||
if (!query.data) {
|
||||
const local = getLocalHidden();
|
||||
const localConfirmed = getLocalConfirmed();
|
||||
if (
|
||||
local.coords.size === 0 &&
|
||||
local.ids.size === 0 &&
|
||||
localConfirmed.coords.size === 0 &&
|
||||
localConfirmed.ids.size === 0
|
||||
) {
|
||||
const coords = new Set<string>();
|
||||
const ids = new Set<string>();
|
||||
const verifiedCoords = new Set<string>();
|
||||
const verifiedIds = new Set<string>();
|
||||
|
||||
for (const [key, entry] of getLocalVerdicts()) {
|
||||
const isId = key.startsWith('e:');
|
||||
const value = isId ? key.slice(2) : key;
|
||||
if (entry.verdict === 'hidden') (isId ? ids : coords).add(value);
|
||||
else if (entry.verdict === 'verified') (isId ? verifiedIds : verifiedCoords).add(value);
|
||||
}
|
||||
|
||||
if (coords.size === 0 && ids.size === 0 && verifiedCoords.size === 0 && verifiedIds.size === 0) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return {
|
||||
coords: local.coords,
|
||||
ids: local.ids,
|
||||
coords,
|
||||
ids,
|
||||
has: ({ coord, id }) =>
|
||||
(!!coord && local.coords.has(coord)) || (!!id && local.ids.has(id)),
|
||||
verifiedCoords: localConfirmed.coords,
|
||||
verifiedIds: localConfirmed.ids,
|
||||
(!!coord && coords.has(coord)) || (!!id && ids.has(id)),
|
||||
verifiedCoords,
|
||||
verifiedIds,
|
||||
isVerified: ({ coord, id }) =>
|
||||
(!!coord && localConfirmed.coords.has(coord)) ||
|
||||
(!!id && localConfirmed.ids.has(id)),
|
||||
(!!coord && verifiedCoords.has(coord)) || (!!id && verifiedIds.has(id)),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,13 +9,22 @@ const SPOTS_STALE_TIME = 5 * 60 * 1000; // 5 min
|
|||
const SPOTS_GC_TIME = 30 * 60 * 1000; // 30 min
|
||||
const QUERY_TIMEOUT = 15000; // 15s — mobile cold-start needs more time
|
||||
|
||||
export function useSkateSpots(limit = 100) {
|
||||
/**
|
||||
* Spots, newest first.
|
||||
*
|
||||
* `includeHidden` is for ADMIN TOOLING ONLY (the review queue): without it a spot
|
||||
* you just hid vanishes from the list, so there is no way to undo the verdict.
|
||||
* Normal callers must leave it off so hidden spots stay hidden.
|
||||
*/
|
||||
export function useSkateSpots(limit = 100, options: { includeHidden?: boolean } = {}) {
|
||||
const { includeHidden = false } = options;
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const hidden = useAdminModeration();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['skate-spots', limit, getQueryKeySuffix()],
|
||||
select: (spots: SkateSpot[]) => filterAdminHidden(spots, SKATE_SPOT_KIND, hidden),
|
||||
queryKey: ['skate-spots', limit, includeHidden, getQueryKeySuffix()],
|
||||
select: (spots: SkateSpot[]) =>
|
||||
includeHidden ? spots : filterAdminHidden(spots, SKATE_SPOT_KIND, hidden),
|
||||
queryFn: async () => {
|
||||
// If offline, return cached spots
|
||||
if (isOffline()) {
|
||||
|
|
|
|||
|
|
@ -173,3 +173,64 @@ export function useAggregatedSpotTags(spotPubkey: string, spotIdentifier: string
|
|||
.sort((a, b) => b.count - a.count);
|
||||
}, [tagContributions]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Community photo counts for MANY spots in one query.
|
||||
*
|
||||
* `useSpotPhotos` already queries by the `a` tag, so the batch is the same query
|
||||
* with every ref at once (relays index `a`, so this is one request) — that is
|
||||
* what makes it cheap enough for the review queue, where a per-spot query would
|
||||
* mean hundreds of round-trips.
|
||||
*
|
||||
* Only photos from people OTHER than the mapper count: the mapper supplying their
|
||||
* own picture is expected, not a community signal.
|
||||
*/
|
||||
export function useBatchSpotPhotos(spots: Array<{ pubkey: string; identifier: string }>) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
const refs = useMemo(
|
||||
() => spots.map((s) => `${SKATE_SPOT_KIND}:${s.pubkey}:${s.identifier}`),
|
||||
[spots],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['batch-spot-photos', refs.slice().sort().join(',')],
|
||||
enabled: refs.length > 0,
|
||||
staleTime: 60_000,
|
||||
queryFn: async (c) => {
|
||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [PHOTO_CONTRIBUTION_KIND], '#a': refs, limit: 1000 }],
|
||||
{ signal },
|
||||
);
|
||||
return events
|
||||
.map(parsePhotoContribution)
|
||||
.filter((p): p is PhotoContribution => p !== null);
|
||||
},
|
||||
});
|
||||
|
||||
const mapperByRef = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const s of spots) map.set(`${SKATE_SPOT_KIND}:${s.pubkey}:${s.identifier}`, s.pubkey);
|
||||
return map;
|
||||
}, [spots]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const photo of query.data ?? []) {
|
||||
// The contribution event carries the spot's `a` coordinate.
|
||||
const ref = photo.event.tags.find(([n]) => n === 'a')?.[1];
|
||||
if (!ref) continue;
|
||||
const mapper = mapperByRef.get(ref);
|
||||
if (mapper && photo.pubkey === mapper) continue; // mapper's own photo
|
||||
map.set(ref, (map.get(ref) ?? 0) + 1);
|
||||
}
|
||||
return map;
|
||||
}, [query.data, mapperByRef]);
|
||||
|
||||
return {
|
||||
isLoading: query.isLoading,
|
||||
getCommunityPhotoCount: (pubkey: string, identifier: string) =>
|
||||
counts.get(`${SKATE_SPOT_KIND}:${pubkey}:${identifier}`) ?? 0,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,82 +3,109 @@
|
|||
*
|
||||
* WHY THIS EXISTS
|
||||
* ---------------
|
||||
* An admin takedown publishes a kind:1985 `hidden` label so the content is
|
||||
* hidden across devices (see useAdminModeration). But publishing that label
|
||||
* requires a SECOND Amber approval, which on mobile can be slow or require a
|
||||
* second app switch. We must NOT make the delete UI wait on it.
|
||||
* 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.
|
||||
*
|
||||
* So the moment the required kind:5 deletion succeeds, we record the target in
|
||||
* this local store. useAdminModeration merges it, so the content disappears
|
||||
* from the app immediately on THIS device. The kind:1985 label still publishes
|
||||
* in the background for cross-device / other-user persistence.
|
||||
* 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.
|
||||
*
|
||||
* Persisted to localStorage so an optimistic hide survives a reload even if the
|
||||
* background label publish hasn't propagated yet.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'skatespots_local_admin_hidden_v1';
|
||||
/** Same idea, for the POSITIVE action: an admin vouching for a spot. */
|
||||
const CONFIRMED_KEY = 'skatespots_local_admin_confirmed_v1';
|
||||
/** A moderation verdict about one target. */
|
||||
export type LocalVerdict = 'hidden' | 'verified' | 'cleared';
|
||||
|
||||
interface LocalHidden {
|
||||
coords: string[];
|
||||
ids: string[];
|
||||
export interface LocalVerdictEntry {
|
||||
verdict: LocalVerdict;
|
||||
/** Unix seconds. */
|
||||
at: number;
|
||||
}
|
||||
|
||||
function readAt(key: string): LocalHidden {
|
||||
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 { coords: [], ids: [] };
|
||||
const parsed = JSON.parse(raw) as Partial<LocalHidden>;
|
||||
return {
|
||||
coords: Array.isArray(parsed.coords) ? parsed.coords : [],
|
||||
ids: Array.isArray(parsed.ids) ? parsed.ids : [],
|
||||
};
|
||||
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 { coords: [], ids: [] };
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeAt(key: string, data: LocalHidden): void {
|
||||
function read(): Store {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(data));
|
||||
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 */
|
||||
}
|
||||
}
|
||||
|
||||
const read = () => readAt(STORAGE_KEY);
|
||||
const write = (data: LocalHidden) => writeAt(STORAGE_KEY, data);
|
||||
|
||||
/**
|
||||
* The admin's POSITIVE action: they looked at a spot and vouched for it. Stored
|
||||
* locally so the review queue stops nagging about it instantly, and mirrored by
|
||||
* a public `verified` label (see useAdminVerify).
|
||||
*/
|
||||
export function addLocalConfirmed(opts: { coord?: string; id?: string }): void {
|
||||
const data = readAt(CONFIRMED_KEY);
|
||||
if (opts.coord && !data.coords.includes(opts.coord)) data.coords.push(opts.coord);
|
||||
if (opts.id && !data.ids.includes(opts.id)) data.ids.push(opts.id);
|
||||
writeAt(CONFIRMED_KEY, data);
|
||||
/** 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 the current local confirmed sets. */
|
||||
export function getLocalConfirmed(): { coords: Set<string>; ids: Set<string> } {
|
||||
const data = readAt(CONFIRMED_KEY);
|
||||
return { coords: new Set(data.coords), ids: new Set(data.ids) };
|
||||
/** Read every locally-recorded verdict, keyed by `targetKey`. */
|
||||
export function getLocalVerdicts(): Map<string, LocalVerdictEntry> {
|
||||
return new Map(Object.entries(read()));
|
||||
}
|
||||
|
||||
/** Record a coordinate + event id as locally hidden by the admin. */
|
||||
// ─── Thin wrappers, kept for existing callers ────────────────────────────────
|
||||
|
||||
/** @see setLocalVerdict */
|
||||
export function addLocalHidden(opts: { coord?: string; id?: string }): void {
|
||||
const data = read();
|
||||
if (opts.coord && !data.coords.includes(opts.coord)) data.coords.push(opts.coord);
|
||||
if (opts.id && !data.ids.includes(opts.id)) data.ids.push(opts.id);
|
||||
write(data);
|
||||
setLocalVerdict(opts, 'hidden');
|
||||
}
|
||||
|
||||
/** Read the current local hidden sets. */
|
||||
export function getLocalHidden(): { coords: Set<string>; ids: Set<string> } {
|
||||
const data = read();
|
||||
return { coords: new Set(data.coords), ids: new Set(data.ids) };
|
||||
/** @see setLocalVerdict */
|
||||
export function addLocalConfirmed(opts: { coord?: string; id?: string }): void {
|
||||
setLocalVerdict(opts, 'verified');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -230,6 +230,12 @@ export const MODERATION_LABEL_NAMESPACE = 'org.skatespots.moderation';
|
|||
export const MODERATION_LABEL_HIDDEN = 'hidden';
|
||||
/** Admin VOUCHES for the target (the positive counterpart to `hidden`). */
|
||||
export const MODERATION_LABEL_VERIFIED = 'verified';
|
||||
/**
|
||||
* UNDO: the admin retracted a previous verdict. Nothing is hidden or vouched —
|
||||
* the target goes back to neutral. Newest verdict wins, so this supersedes an
|
||||
* earlier `hidden`/`verified` label.
|
||||
*/
|
||||
export const MODERATION_LABEL_CLEARED = 'cleared';
|
||||
|
||||
/** Extract member pubkeys (hex) claimed by a Lab profile via `p` tags. */
|
||||
export function parseMemberPubkeys(event: NostrEvent): string[] {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Loader2,
|
||||
CheckCircle2,
|
||||
BadgeCheck,
|
||||
Undo2,
|
||||
Users,
|
||||
ShieldQuestion,
|
||||
} from 'lucide-react';
|
||||
|
|
@ -20,7 +21,8 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
||||
import { useBatchSpotStatus } from '@/hooks/useSpotStatus';
|
||||
import { useAdminHide, useAdminVerify } from '@/hooks/useAdminHide';
|
||||
import { useBatchSpotPhotos } from '@/hooks/useSpotContributions';
|
||||
import { useAdminHide, useAdminVerify, useAdminClear } from '@/hooks/useAdminHide';
|
||||
import { useAdminModeration } from '@/hooks/useAdminModeration';
|
||||
import { useNotASpotReport } from '@/hooks/useNotASpotReport';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
|
|
@ -44,9 +46,12 @@ import NotFound from '@/pages/NotFound';
|
|||
*/
|
||||
export default function AdminReviewPage() {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: spots, isLoading } = useSkateSpots(300);
|
||||
// includeHidden: a spot you just hid must stay listed here, or there is no
|
||||
// way to undo the verdict.
|
||||
const { data: spots, isLoading } = useSkateSpots(300, { includeHidden: true });
|
||||
const { hideContent, isPending: hiding } = useAdminHide();
|
||||
const { confirmContent, isPending: confirming } = useAdminVerify();
|
||||
const { clearVerdict, isPending: clearing } = useAdminClear();
|
||||
const moderation = useAdminModeration();
|
||||
const { reportNotASpot } = useNotASpotReport();
|
||||
const { toast } = useToast();
|
||||
|
|
@ -63,6 +68,7 @@ export default function AdminReviewPage() {
|
|||
[spots],
|
||||
);
|
||||
const { getStatus } = useBatchSpotStatus(statusInput);
|
||||
const { getCommunityPhotoCount } = useBatchSpotPhotos(statusInput);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
|
|
@ -70,23 +76,33 @@ export default function AdminReviewPage() {
|
|||
.map((spot) => {
|
||||
const coord = `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`;
|
||||
const status = getStatus(spot.pubkey, spot.identifier);
|
||||
const hidden = moderation.has({ coord, id: spot.id });
|
||||
const verified = moderation.isVerified({ coord, id: spot.id });
|
||||
const photos = getCommunityPhotoCount(spot.pubkey, spot.identifier);
|
||||
const reporters = status?.reporterCount ?? 0;
|
||||
return {
|
||||
spot,
|
||||
coord,
|
||||
verified: moderation.isVerified({ coord, id: spot.id }),
|
||||
hidden,
|
||||
verified,
|
||||
// An already-actioned row is kept so the admin can UNDO it.
|
||||
actioned: hidden || verified,
|
||||
trust: trustScores?.get(spot.pubkey),
|
||||
reporters: status?.reporterCount ?? 0,
|
||||
reporters,
|
||||
photos,
|
||||
flags: spotFlags(spot, {
|
||||
mapperTrust: trustScores?.get(spot.pubkey),
|
||||
communityStatusReports: status?.reporterCount ?? 0,
|
||||
communityStatusReports: reporters,
|
||||
communityPhotos: photos,
|
||||
}),
|
||||
};
|
||||
})
|
||||
// Admin-vouched spots are done — and confirming one should visibly
|
||||
// remove it from the pile.
|
||||
.filter((row) => !row.verified && row.flags.length > 0)
|
||||
.sort((a, b) => b.flags.length - a.flags.length),
|
||||
[spots, moderation, trustScores, getStatus],
|
||||
.filter((row) => row.actioned || row.flags.length > 0)
|
||||
// Un-actioned rows first (most flags first); actioned rows sink below.
|
||||
.sort((a, b) =>
|
||||
a.actioned === b.actioned ? b.flags.length - a.flags.length : a.actioned ? 1 : -1,
|
||||
),
|
||||
[spots, moderation, trustScores, getStatus, getCommunityPhotoCount],
|
||||
);
|
||||
|
||||
useSeoMeta({ title: 'Review queue — SkateSpots' });
|
||||
|
|
@ -110,6 +126,22 @@ export default function AdminReviewPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleUndo = async (coord: string, id: string) => {
|
||||
try {
|
||||
await clearVerdict({ coord, id, reason: 'verdict retracted' });
|
||||
toast({
|
||||
title: 'Undone',
|
||||
description: 'Back to neutral — not hidden, not vouched.',
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not undo',
|
||||
description: e instanceof Error ? e.message : 'Try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = async (coord: string, id: string) => {
|
||||
try {
|
||||
await confirmContent({ coord, id, reason: 'looks legit after review' });
|
||||
|
|
@ -151,8 +183,17 @@ export default function AdminReviewPage() {
|
|||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{rows.map(({ spot, coord, flags, trust, reporters }) => (
|
||||
<Card key={spot.id} className="border-2 border-white/10 bg-white/[0.03]">
|
||||
{rows.map(({ spot, coord, flags, trust, reporters, photos, hidden, verified }) => (
|
||||
<Card
|
||||
key={spot.id}
|
||||
className={
|
||||
hidden
|
||||
? 'border-2 border-red-500/25 bg-red-500/[0.04]'
|
||||
: verified
|
||||
? 'border-2 border-emerald-500/25 bg-emerald-500/[0.04]'
|
||||
: 'border-2 border-white/10 bg-white/[0.03]'
|
||||
}
|
||||
>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{spot.images?.[0] ? (
|
||||
|
|
@ -167,12 +208,26 @@ export default function AdminReviewPage() {
|
|||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-bold text-white">{spot.title || '(untitled)'}</p>
|
||||
<p className="flex items-center gap-2 truncate font-bold text-white">
|
||||
{hidden && (
|
||||
<Badge className="border border-red-500/50 bg-red-500/20 text-[10px] font-black uppercase tracking-wide text-red-300">
|
||||
<EyeOff className="mr-1 h-3 w-3" />
|
||||
Hidden
|
||||
</Badge>
|
||||
)}
|
||||
{verified && (
|
||||
<Badge className="border border-emerald-500/50 bg-emerald-500/20 text-[10px] font-black uppercase tracking-wide text-emerald-300">
|
||||
<BadgeCheck className="mr-1 h-3 w-3" />
|
||||
Confirmed
|
||||
</Badge>
|
||||
)}
|
||||
<span className="truncate">{spot.title || '(untitled)'}</span>
|
||||
</p>
|
||||
<p className="truncate text-xs text-gray-500">
|
||||
{spot.description?.trim() || 'no description'}
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{flags.map((f) => (
|
||||
{!hidden && !verified && flags.map((f) => (
|
||||
<Badge
|
||||
key={f}
|
||||
className="border border-amber-500/40 bg-amber-500/15 text-[10px] font-bold uppercase tracking-wide text-amber-300"
|
||||
|
|
@ -192,8 +247,13 @@ export default function AdminReviewPage() {
|
|||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{reporters > 0
|
||||
? `${reporters} status report${reporters === 1 ? '' : 's'}`
|
||||
{reporters > 0 || photos > 0
|
||||
? [
|
||||
reporters > 0 ? `${reporters} status report${reporters === 1 ? '' : 's'}` : null,
|
||||
photos > 0 ? `${photos} community photo${photos === 1 ? '' : 's'}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: 'no community signals'}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -201,6 +261,23 @@ export default function AdminReviewPage() {
|
|||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(hidden || verified) ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5 border-white/20 text-gray-200 hover:bg-white/10"
|
||||
disabled={clearing}
|
||||
onClick={() => void handleUndo(coord, spot.id)}
|
||||
>
|
||||
{clearing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Undo2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Undo
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5 bg-emerald-500 font-bold text-black hover:bg-emerald-400"
|
||||
|
|
@ -243,6 +320,8 @@ export default function AdminReviewPage() {
|
|||
<Flag className="h-3.5 w-3.5" />
|
||||
Not a skate spot
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button asChild size="sm" variant="ghost" className="gap-1.5 text-gray-400">
|
||||
<Link to={`/spot/${spot.pubkey}/${spot.identifier}`}>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
|
|
|
|||
Loading…
Reference in a new issue