SkateSpots/src/hooks/useAdminHide.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

98 lines
3.7 KiB
TypeScript

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { isAdminPubkey } from '@/lib/admins';
import { setLocalVerdict } from '@/lib/localModeration';
import {
MODERATION_LABEL_NAMESPACE,
MODERATION_LABEL_HIDDEN,
MODERATION_LABEL_VERIFIED,
MODERATION_LABEL_CLEARED,
} from '@/lib/types';
/**
* Publish an admin moderation label (NIP-32 kind 1985) for a target.
*
* THE MISSING HALF: `useAdminModeration` has always READ these labels and
* filtered on them, but nothing ever PUBLISHED one — the only working hide was
* the local, this-device-only one in `useDeleteEntity`. This is the write side.
*
* 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;
* • `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
* honest behaviour on a censorship-resistant network.
*
* A local optimistic mirror is written too, so the effect is instant on this
* device instead of waiting for the relay round-trip.
*/
function usePublishModerationLabel(label: string) {
const { user } = useCurrentUser();
const { mutateAsync: publish, isPending: publishing } = useNostrPublish();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async ({
coord,
id,
reason,
}: {
/** Addressable coordinate, e.g. `30818:<pubkey>:<d>`. */
coord?: string;
/** Event id (for regular/replaceable events). */
id?: string;
/** Why — stored in the label's content for transparency. */
reason?: string;
}) => {
if (!isAdminPubkey(user?.pubkey)) throw new Error('Admins only.');
if (!coord && !id) throw new Error('Need a coordinate or an event id.');
const tags: string[][] = [
['L', MODERATION_LABEL_NAMESPACE],
['l', label, MODERATION_LABEL_NAMESPACE],
['alt', `SkateSpots: moderation label — ${label}`],
];
if (coord) tags.push(['a', coord]);
if (id) tags.push(['e', id]);
await publish({ kind: 1985, content: reason ?? '', tags });
// 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'] });
},
});
return { run: mutation.mutateAsync, isPending: mutation.isPending || publishing };
}
/** Hide a target across the app (soft + reversible). */
export function useAdminHide() {
const { run, isPending } = usePublishModerationLabel(MODERATION_LABEL_HIDDEN);
return { hideContent: run, isPending };
}
/** Vouch for a target — the positive counterpart, which clears the review queue. */
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 };
}