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.
112 lines
4.6 KiB
TypeScript
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 };
|
|
}
|