SkateSpots/src/hooks/useAdminHide.ts
SkateSpots 411f6e219a feat(spots): claim ownership of a private spot (NIP-05 + moderator confirm)
Private spots can't be domain-verified like shops, so ownership is a gated
two-step flow — never an instant takeover:
- A NIP-05-verified user files a claim (kind 1985 label in the separate
  org.skatespots.spotclaim namespace) on a spot marked access=private.
- A moderator approves by publishing claim-approved (moderation namespace,
  a=coord + p=claimant); surfaced in a pending-claims list atop /review.
- The claimant then finishes by re-publishing the spot with
  claim-reason=private-approved (existing claim mechanism).

Adds useSpotClaims/useApprovedSpotClaims/useAllSpotClaims/useSubmitSpotClaim,
useAdminApproveClaim, the private branch in SpotOwnership, i18n, and docs.
Non-private spots keep the abandoned-claim flow. Nothing is auto-transferred.
2026-10-02 11:06:58 +07:00

112 lines
4.6 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,
MODERATION_LABEL_CLAIM_APPROVED,
} 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,
subject,
}: {
/** 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;
/** Optional subject pubkey (e.g. the claimant a claim-approval grants). */
subject?: 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]);
if (subject) tags.push(['p', subject]);
await publish({ kind: 1985, content: reason ?? '', tags });
// Optimistic, this device — newest verdict wins on merge. `claim-approved`
// is NOT a hide/verify state, so it writes no local verdict (it must not
// clobber a real hidden/verified verdict on the same coord).
if (label === MODERATION_LABEL_HIDDEN) setLocalVerdict({ coord, id }, 'hidden');
else if (label === MODERATION_LABEL_VERIFIED) setLocalVerdict({ coord, id }, 'verified');
else if (label === MODERATION_LABEL_CLEARED) setLocalVerdict({ coord, id }, 'cleared');
queryClient.invalidateQueries({ queryKey: ['admin-moderation'] });
queryClient.invalidateQueries({ queryKey: ['spot-claim-approvals'] });
},
});
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 };
}
/**
* APPROVE a private-spot ownership claim. Publishes `claim-approved` tagged
* with the spot coordinate and the claimant's pubkey; the claimant then finishes
* the claim themselves (only they can author their own version of the spot).
*/
export function useAdminApproveClaim() {
const { run, isPending } = usePublishModerationLabel(MODERATION_LABEL_CLAIM_APPROVED);
return { approveClaim: run, isPending };
}