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.
215 lines
7.5 KiB
TypeScript
215 lines
7.5 KiB
TypeScript
import { useQuery } from '@tanstack/react-query';
|
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
|
import { ADMIN_PUBKEYS } from '@/lib/admins';
|
|
import {
|
|
MODERATION_LABEL_NAMESPACE,
|
|
MODERATION_LABEL_HIDDEN,
|
|
MODERATION_LABEL_VERIFIED,
|
|
MODERATION_LABEL_CLEARED,
|
|
} from '@/lib/types';
|
|
import { getLocalVerdicts, type LocalVerdict } from '@/lib/localModeration';
|
|
|
|
/**
|
|
* App-wide admin moderation layer.
|
|
*
|
|
* SkateSpots admins (see src/lib/admins.ts) can take down ANY listing (spot,
|
|
* crew, tour, event, shop, Lab profile, …). Because a NIP-09 deletion request
|
|
* signed by a non-author is not honored by most relays, admin takedowns also
|
|
* publish a NIP-32 kind:1985 `hidden` label referencing the target. This hook
|
|
* fetches those admin labels so the client can hide the content everywhere,
|
|
* reliably, regardless of relay NIP-09 behavior.
|
|
*
|
|
* Security: only labels authored by a hardcoded ADMIN pubkey are honored, so a
|
|
* random user cannot hide someone else's listing. The label authorship is the
|
|
* trust boundary (see .agents/skills/nostr-security §3).
|
|
*/
|
|
export interface AdminHiddenSet {
|
|
/** Hidden addressable coordinates `kind:pubkey:d`. */
|
|
coords: Set<string>;
|
|
/** Hidden concrete event ids. */
|
|
ids: Set<string>;
|
|
/** True if either set contains anything (cheap guard for callers). */
|
|
has: (opts: { coord?: string; id?: string }) => boolean;
|
|
/**
|
|
* Admin-VOUCHED targets (the positive label). Used to promote a spot out of
|
|
* the review queue and to show a "Verified" badge.
|
|
*/
|
|
verifiedCoords: Set<string>;
|
|
verifiedIds: Set<string>;
|
|
isVerified: (opts: { coord?: string; id?: string }) => boolean;
|
|
}
|
|
|
|
const EMPTY: AdminHiddenSet = {
|
|
coords: new Set(),
|
|
ids: new Set(),
|
|
has: () => false,
|
|
verifiedCoords: new Set(),
|
|
verifiedIds: new Set(),
|
|
isVerified: () => false,
|
|
};
|
|
|
|
/**
|
|
* Drop admin-hidden entities from a parsed list. Works for any entity that
|
|
* carries `{ id, pubkey, identifier }` (spots, crews, tours, shops, events, …).
|
|
* `kind` is the entity's Nostr kind, used to build the `a` coordinate.
|
|
*/
|
|
export function filterAdminHidden<
|
|
T extends { id: string; pubkey: string; identifier: string },
|
|
>(items: T[], kind: number, hidden: AdminHiddenSet): T[] {
|
|
if (hidden.coords.size === 0 && hidden.ids.size === 0) return items;
|
|
return items.filter(
|
|
(it) =>
|
|
!hidden.has({ coord: `${kind}:${it.pubkey}:${it.identifier}`, id: it.id }),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Like `filterAdminHidden`, but for a mixed-kind list where each item carries
|
|
* its own source `event` (e.g. Lab profiles, which span 10 different kinds).
|
|
* The kind is read from `it.event.kind`.
|
|
*/
|
|
export function filterAdminHiddenMixed<
|
|
T extends {
|
|
id: string;
|
|
pubkey: string;
|
|
identifier: string;
|
|
event: { kind: number };
|
|
},
|
|
>(items: T[], hidden: AdminHiddenSet): T[] {
|
|
if (hidden.coords.size === 0 && hidden.ids.size === 0) return items;
|
|
return items.filter(
|
|
(it) =>
|
|
!hidden.has({
|
|
coord: `${it.event.kind}:${it.pubkey}:${it.identifier}`,
|
|
id: it.id,
|
|
}),
|
|
);
|
|
}
|
|
|
|
export function useAdminModeration() {
|
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
|
|
|
const query = useQuery({
|
|
queryKey: ['admin-moderation', getQueryKeySuffix()],
|
|
queryFn: async (): Promise<AdminHiddenSet> => {
|
|
const signal = AbortSignal.timeout(8000);
|
|
|
|
const events = await querySkateContent(
|
|
[
|
|
{
|
|
kinds: [1985],
|
|
authors: ADMIN_PUBKEYS,
|
|
// One request for BOTH admin verdicts: hidden and verified.
|
|
'#l': [MODERATION_LABEL_HIDDEN, MODERATION_LABEL_VERIFIED],
|
|
limit: 1000,
|
|
},
|
|
],
|
|
{ signal },
|
|
);
|
|
|
|
const adminSet = new Set(ADMIN_PUBKEYS.map((p) => p.toLowerCase()));
|
|
|
|
// 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.
|
|
if (!adminSet.has(ev.pubkey.toLowerCase())) continue;
|
|
const hasNamespace = ev.tags.some(
|
|
([n, v]) => n === 'L' && v === MODERATION_LABEL_NAMESPACE,
|
|
);
|
|
if (!hasNamespace) 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) {
|
|
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 });
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
ids,
|
|
has: ({ coord, id }) =>
|
|
(!!coord && coords.has(coord)) || (!!id && ids.has(id)),
|
|
verifiedCoords,
|
|
verifiedIds,
|
|
isVerified: ({ coord, id }) =>
|
|
(!!coord && verifiedCoords.has(coord)) || (!!id && verifiedIds.has(id)),
|
|
};
|
|
},
|
|
staleTime: 60 * 1000,
|
|
gcTime: 10 * 60 * 1000,
|
|
refetchOnMount: true,
|
|
});
|
|
|
|
// 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 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,
|
|
ids,
|
|
has: ({ coord, id }) =>
|
|
(!!coord && coords.has(coord)) || (!!id && ids.has(id)),
|
|
verifiedCoords,
|
|
verifiedIds,
|
|
isVerified: ({ coord, id }) =>
|
|
(!!coord && verifiedCoords.has(coord)) || (!!id && verifiedIds.has(id)),
|
|
};
|
|
}
|
|
|
|
return query.data;
|
|
}
|