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.
155 lines
5.7 KiB
TypeScript
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] });
|
|
},
|
|
};
|
|
}
|