SkateSpots/src/hooks/useAdminModeration.ts
SkateSpots e66888128f feat(trust): a real mute (NIP-51 kind 10000) that hides content
Completes the safety loop. useMuteList reads the viewer's kind-10000 mute
list plus optimistic local mutes (lib/localMutes.ts); useMuteUser
republishes the list with the target added, records it locally and
invalidates content queries. useAdminModeration carries a new
mutedPubkeys set, and the shared filterAdminHidden/filterAdminHiddenMixed
drop muted authors — so every list hook filters them with no per-hook
changes (admin tooling with includeHidden still sees them). The flagged
notice gains a Mute action.
2026-10-09 18:23:04 +07:00

237 lines
8.8 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';
import { getLocalDeletions } from '@/lib/localDeletions';
import { useMuteList } from '@/hooks/useMuteList';
/**
* 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;
/** Authors the viewer muted (NIP-51 kind 10000 + optimistic). */
mutedPubkeys: Set<string>;
}
const EMPTY_PUBKEYS = new Set<string>();
const EMPTY: AdminHiddenSet = {
coords: new Set(),
ids: new Set(),
has: () => false,
verifiedCoords: new Set(),
verifiedIds: new Set(),
isVerified: () => false,
mutedPubkeys: EMPTY_PUBKEYS,
};
/**
* 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[] {
const deleted = getLocalDeletions();
if (hidden.coords.size === 0 && hidden.ids.size === 0 && deleted.size === 0 && hidden.mutedPubkeys.size === 0) return items;
return items.filter((it) => {
// Muted authors are dropped everywhere this filter is used (NIP-51 mute).
if (hidden.mutedPubkeys.has(it.pubkey)) return false;
const coord = `${kind}:${it.pubkey}:${it.identifier}`;
if (hidden.has({ coord, id: it.id })) return false;
// Locally-deleted (NIP-09 pending): drop immediately so a deleted item can
// never flash back from the persistence/offline cache on refresh.
if (deleted.has(coord) || deleted.has(`e:${it.id}`)) return false;
return true;
});
}
/**
* 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[] {
const deleted = getLocalDeletions();
if (hidden.coords.size === 0 && hidden.ids.size === 0 && deleted.size === 0 && hidden.mutedPubkeys.size === 0) return items;
return items.filter((it) => {
if (hidden.mutedPubkeys.has(it.pubkey)) return false;
const coord = `${it.event.kind}:${it.pubkey}:${it.identifier}`;
if (hidden.has({ coord, id: it.id })) return false;
if (deleted.has(coord) || deleted.has(`e:${it.id}`)) return false;
return true;
});
}
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 ALL admin verdicts: hidden, verified and cleared.
// `cleared` (undo) MUST be included or a hidden item can never be
// un-hidden on another device — it is the newest-wins retraction.
'#l': [MODERATION_LABEL_HIDDEN, MODERATION_LABEL_VERIFIED, MODERATION_LABEL_CLEARED],
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)),
mutedPubkeys: EMPTY_PUBKEYS,
};
},
staleTime: 60 * 1000,
gcTime: 10 * 60 * 1000,
refetchOnMount: true,
});
const mutes = useMuteList();
const mutedPubkeys = mutes.data ?? EMPTY_PUBKEYS;
// 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, mutedPubkeys };
}
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)),
mutedPubkeys,
};
}
return { ...query.data, mutedPubkeys };
}