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.
98 lines
3.7 KiB
TypeScript
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 };
|
|
}
|