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

155 lines
5.7 KiB
TypeScript

import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { ADMIN_PUBKEYS } from '@/lib/admins';
import {
MODERATION_LABEL_NAMESPACE,
MODERATION_LABEL_CLAIM_APPROVED,
SPOT_CLAIM_NAMESPACE,
SPOT_CLAIM_LABEL,
} from '@/lib/types';
/**
* Private-spot ownership claims.
*
* A private spot (a backyard ramp, a gated park) can't be verified against a
* domain the way a shop can. Instead the owner FILES A CLAIM — a signed NIP-32
* kind 1985 label in `org.skatespots.spotclaim` — and a SkateSpots moderator
* confirms it by publishing a `claim-approved` label in the moderation
* namespace. Only then does the claimant finish (re-publish the spot under their
* own key). Nothing is ever auto-transferred, so a stranger can't grief a pin.
*/
export interface SpotClaim {
/** Claimant hex pubkey (the label's author). */
claimant: string;
createdAt: number;
/** The NIP-05 handle they claimed with (label content). */
nip05: string;
}
/** Ownership claims filed against a spot coordinate (public, signed). */
export function useSpotClaims(coord: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery<SpotClaim[]>({
queryKey: ['spot-claims', coord, getQueryKeySuffix()],
enabled: !!coord,
staleTime: 60_000,
queryFn: async () => {
if (!coord) return [];
const signal = AbortSignal.timeout(8000);
const events = await querySkateContent(
[{ kinds: [1985], '#a': [coord], limit: 200 }],
{ signal },
);
const byClaimant = new Map<string, SpotClaim>();
for (const ev of events) {
const inNamespace = ev.tags.some(([n, v]) => n === 'L' && v === SPOT_CLAIM_NAMESPACE);
const isClaim = ev.tags.some(([n, v]) => n === 'l' && v === SPOT_CLAIM_LABEL);
if (!inNamespace || !isClaim) continue;
const current = byClaimant.get(ev.pubkey);
if (!current || ev.created_at > current.createdAt) {
byClaimant.set(ev.pubkey, { claimant: ev.pubkey, createdAt: ev.created_at, nip05: ev.content });
}
}
return [...byClaimant.values()].sort((a, b) => b.createdAt - a.createdAt);
},
});
}
/** `coord` → set of claimant pubkeys a moderator has approved. */
export function useApprovedSpotClaims() {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery<Map<string, Set<string>>>({
queryKey: ['spot-claim-approvals', getQueryKeySuffix()],
staleTime: 60_000,
queryFn: async () => {
const signal = AbortSignal.timeout(8000);
const events = await querySkateContent(
[{ kinds: [1985], authors: ADMIN_PUBKEYS, '#l': [MODERATION_LABEL_CLAIM_APPROVED], limit: 500 }],
{ signal },
);
const map = new Map<string, Set<string>>();
for (const ev of events) {
const inNamespace = ev.tags.some(([n, v]) => n === 'L' && v === MODERATION_LABEL_NAMESPACE);
if (!inNamespace) continue;
const coord = ev.tags.find(([n]) => n === 'a')?.[1];
const claimant = ev.tags.find(([n]) => n === 'p')?.[1];
if (!coord || !claimant) continue;
if (!map.has(coord)) map.set(coord, new Set());
map.get(coord)!.add(claimant.toLowerCase());
}
return map;
},
});
}
export interface AllSpotClaim {
coord: string;
ownerPubkey: string;
identifier: string;
claimant: string;
nip05: string;
createdAt: number;
}
/**
* EVERY private-spot ownership claim on the relays (admins use this to find
* work). Small volume — claims are rare and manual.
*/
export function useAllSpotClaims() {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery<AllSpotClaim[]>({
queryKey: ['spot-claims-all', getQueryKeySuffix()],
staleTime: 60_000,
queryFn: async () => {
const signal = AbortSignal.timeout(8000);
const events = await querySkateContent(
[{ kinds: [1985], '#L': [SPOT_CLAIM_NAMESPACE], limit: 500 }],
{ signal },
);
const seen = new Map<string, AllSpotClaim>();
for (const ev of events) {
const isClaim = ev.tags.some(([n, v]) => n === 'l' && v === SPOT_CLAIM_LABEL);
if (!isClaim) continue;
const coord = ev.tags.find(([n]) => n === 'a')?.[1];
if (!coord) continue;
const [, ownerPubkey, identifier] = coord.split(':');
const key = `${coord}:${ev.pubkey}`;
const current = seen.get(key);
if (!current || ev.created_at > current.createdAt) {
seen.set(key, { coord, ownerPubkey, identifier, claimant: ev.pubkey, nip05: ev.content, createdAt: ev.created_at });
}
}
return [...seen.values()].sort((a, b) => b.createdAt - a.createdAt);
},
});
}
/** File (or refresh) an ownership claim for a private spot. */export function useSubmitSpotClaim() {
const { user } = useCurrentUser();
const { mutateAsync: publish, isPending } = useNostrPublish();
const queryClient = useQueryClient();
return {
isPending,
submit: async ({ coord, ownerPubkey, nip05 }: { coord: string; ownerPubkey: string; nip05: string }) => {
if (!user) throw new Error('Log in first.');
await publish({
kind: 1985,
content: nip05,
tags: [
['L', SPOT_CLAIM_NAMESPACE],
['l', SPOT_CLAIM_LABEL, SPOT_CLAIM_NAMESPACE],
['a', coord],
['p', ownerPubkey],
['alt', 'SkateSpots private-spot ownership claim'],
],
});
queryClient.invalidateQueries({ queryKey: ['spot-claims', coord] });
},
};
}