From 411f6e219ad785c0b50f3223b9d495c7fa680ad0 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 2 Oct 2026 11:06:58 +0700 Subject: [PATCH] feat(spots): claim ownership of a private spot (NIP-05 + moderator confirm) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 11 ++ NIP.md | 37 ++++ src/components/SpotOwnership.tsx | 313 ++++++++++++++++++++++++++----- src/hooks/useAdminHide.ts | 30 ++- src/hooks/useSpotClaims.ts | 155 +++++++++++++++ src/i18n/en.ts | 28 +++ src/i18n/es.ts | 28 +++ src/lib/types.ts | 15 ++ src/pages/AdminReviewPage.tsx | 61 +++++- 9 files changed, 625 insertions(+), 53 deletions(-) create mode 100644 src/hooks/useSpotClaims.ts diff --git a/AGENTS.md b/AGENTS.md index f8c6e53..ba73ee7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -717,6 +717,17 @@ session with **no other memory of this project**. So: stays optional, per the earlier decision. And the flattering **"Conditions … NN/100"** block is **hidden until the spot is confirmed** (community or staff); on an unconfirmed pin it read as endorsement of the *spot*. +- **Private-spot ownership claims (2026-10-02):** a private spot (`access=private`) + can't be domain-verified like a shop, so ownership is **NIP-05 + moderator + confirm**, never an instant takeover. Claimant (must have a NIP-05) publishes a + kind-1985 label in the **separate** `org.skatespots.spotclaim` namespace; + an admin publishes `claim-approved` (moderation namespace, `a`=coord + `p`=claimant); + the claimant then re-publishes the spot with `claim-reason=private-approved` to + become the owner. `useSpotClaims.ts` (`useSpotClaims`/`useApprovedSpotClaims`/ + `useAllSpotClaims`/`useSubmitSpotClaim`) + `useAdminApproveClaim`; UI in + `SpotOwnership.tsx` (private branch) and a pending-claims list atop `/review`. + ⚠️ Proves key control + NIP-05, **not property ownership**. Non-private spots + keep the abandoned-claim flow. See NIP.md → "Private-spot ownership claims". - **Photo is recommended, not required (2026-09-28):** the hard block in `CreateSpotPage` was removed — it punished an honest mapper with no photo on hand. Publishing with no photo is allowed; the spot stays **Unverified** diff --git a/NIP.md b/NIP.md index c8fb67a..9e923dd 100644 --- a/NIP.md +++ b/NIP.md @@ -3419,6 +3419,43 @@ Full design, threat model and status: **`ESCROW.md`**. Core logic lives in `src/lib/cashuEscrow.ts` (unit-tested). Escrow is **native-only** (the Cashu wallet is Capacitor-gated). +## Private-spot ownership claims + +A private spot (`kind 30818` with `access=private`) can't be verified against a +domain the way a shop can, so ownership is a **two-step, moderator-gated claim** — +a stranger can never be auto-granted someone's pin. + +1. **Claim (the claimant).** A logged-in user **with a NIP-05** publishes a + NIP-32 kind 1985 label of their own: + + ```json + { "kind": 1985, "content": "", "tags": [ + ["L", "org.skatespots.spotclaim"], + ["l", "claim", "org.skatespots.spotclaim"], + ["a", "30818::"], + ["p", ""], + ["alt", "SkateSpots private-spot ownership claim"] + ] } + ``` + + The namespace is **separate** from `org.skatespots.moderation`, so + `useAdminModeration` never reads a non-admin label as a verdict. + +2. **Approve (a moderator).** An admin publishes `claim-approved` in the standard + moderation namespace, tagged with the spot coordinate **and** the claimant: + `["L","org.skatespots.moderation"]` · `["l","claim-approved",…]` · + `["a","30818::"]` · `["p",""]`. + +3. **Finish (the claimant).** On approval the claimant re-publishes the spot + under their own key with `["claim-from",""]` and + `["claim-reason","private-approved"]` — the existing claim mechanism. They are + now the author/owner. + +⚠️ Honest limits: a valid claim proves **who controls a Nostr key and passed +NIP-05** — NOT that they own the property. Approval is a human judgement. The +original spot stays on Nostr (decentralized); nothing is deleted. Only +**private** spots use this; non-private spots keep the abandoned-claim flow. + ## Future (from the NIP) - `31873` **Attestor Recommendation** / `11871` **Proficiency** — trusted-trader diff --git a/src/components/SpotOwnership.tsx b/src/components/SpotOwnership.tsx index b658a20..c3907c3 100644 --- a/src/components/SpotOwnership.tsx +++ b/src/components/SpotOwnership.tsx @@ -1,5 +1,6 @@ import { useState, useMemo } from 'react'; -import { UserPlus, AlertTriangle, Loader2, Crown, Clock } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { UserPlus, AlertTriangle, Loader2, Crown, Clock, ShieldCheck, Hourglass, BadgeCheck, Lock } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent } from '@/components/ui/card'; @@ -22,10 +23,14 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useAuthor } from '@/hooks/useAuthor'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useToast } from '@/hooks/useToast'; +import { useSpotClaims, useApprovedSpotClaims, useSubmitSpotClaim } from '@/hooks/useSpotClaims'; +import { useAdminApproveClaim } from '@/hooks/useAdminHide'; import { useQueryClient } from '@tanstack/react-query'; import { SKATE_SPOT_KIND, type SkateSpot } from '@/lib/types'; +import { isAdminPubkey } from '@/lib/admins'; import { LoginArea } from '@/components/auth/LoginArea'; import { useTranslation } from '@/contexts/language'; import { nip19 } from 'nostr-tools'; @@ -38,9 +43,12 @@ interface SpotOwnershipProps { } /** - * Component that handles: - * 1. Transfer ownership (owner can transfer to another user) - * 2. Claim abandoned spot (any user can claim if owner inactive 6+ months) + * Ownership controls for a spot: + * 1. Transfer — the owner hands it to someone else. + * 2. Claim an abandoned spot (owner inactive 6+ months) — any user. + * 3. Claim a PRIVATE spot — a signed claim (NIP-05 required) that a SkateSpots + * moderator must approve before the claimant finishes the transfer. We can't + * verify a property deed, so control is never auto-transferred to a stranger. */ export function SpotOwnership({ spot }: SpotOwnershipProps) { const { t } = useTranslation(); @@ -48,19 +56,34 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) { const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish(); const { toast } = useToast(); const queryClient = useQueryClient(); - + const [transferOpen, setTransferOpen] = useState(false); const [claimOpen, setClaimOpen] = useState(false); + const [privateClaimOpen, setPrivateClaimOpen] = useState(false); const [recipientNpub, setRecipientNpub] = useState(''); - - const isOwner = user && user.pubkey === spot.pubkey; - + + const isOwner = !!user && user.pubkey === spot.pubkey; + const isPrivate = spot.accessType === 'private'; + const isAdmin = isAdminPubkey(user?.pubkey); + const coord = `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`; + + const { data: claims = [] } = useSpotClaims(isPrivate ? coord : undefined); + const { data: approvals } = useApprovedSpotClaims(); + const { submit: submitClaim, isPending: submittingClaim } = useSubmitSpotClaim(); + const { approveClaim, isPending: approvingClaim } = useAdminApproveClaim(); + + const myAuthor = useAuthor(user?.pubkey ?? ''); + const myNip05 = myAuthor.data?.metadata?.nip05; + + const myClaim = user ? claims.find((c) => c.claimant === user.pubkey) : undefined; + const amApproved = !!user && !!approvals?.get(coord)?.has(user.pubkey.toLowerCase()); + // Check if spot is abandoned (owner hasn't updated in 6+ months) const isAbandoned = useMemo(() => { const now = Math.floor(Date.now() / 1000); return (now - spot.createdAt) > ABANDONED_THRESHOLD; }, [spot.createdAt]); - + // Calculate how long ago the spot was last updated const lastUpdatedText = useMemo(() => { const now = Math.floor(Date.now() / 1000); @@ -68,12 +91,12 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) { const days = Math.floor(diff / (24 * 60 * 60)); const months = Math.floor(days / 30); const years = Math.floor(months / 12); - + if (years > 0) return years > 1 ? t('spotUi.yearsAgoPlural', { count: years }) : t('spotUi.yearsAgo', { count: years }); if (months > 0) return months > 1 ? t('spotUi.monthsAgoPlural', { count: months }) : t('spotUi.monthsAgo', { count: months }); return days > 1 ? t('spotUi.daysAgoPlural', { count: days }) : t('spotUi.daysAgo', { count: days }); }, [spot.createdAt, t]); - + const handleTransfer = async () => { if (!recipientNpub.trim()) { toast({ @@ -83,7 +106,7 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) { }); return; } - + // Decode npub to get hex pubkey let recipientPubkey: string; try { @@ -100,7 +123,7 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) { }); return; } - + if (recipientPubkey === spot.pubkey) { toast({ title: t('spotUi.sameOwner'), @@ -109,25 +132,25 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) { }); return; } - + try { // Publish a transfer event (kind 1111 comment with transfer marker) await publishEvent({ kind: 1111, content: t('spotUi.transferRequestContent', { title: spot.title }), tags: [ - ['a', `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`], + ['a', coord], ['K', String(SKATE_SPOT_KIND)], ['t', 'spot-transfer'], ['p', recipientPubkey, '', 'new-owner'], ], }); - + toast({ title: t('spotUi.transferRequested'), description: t('spotUi.transferRequestedDesc'), }); - + setTransferOpen(false); setRecipientNpub(''); queryClient.invalidateQueries({ queryKey: ['spot-transfers', spot.pubkey, spot.identifier] }); @@ -139,15 +162,14 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) { }); } }; - - const handleClaim = async () => { + + /** Become the owner by re-publishing the spot under our own key. */ + const handleClaim = async (reason: 'abandoned' | 'transfer' | 'private-approved') => { if (!user) return; - + try { - // Re-publish the spot under the new owner's pubkey - // This creates a new addressable event with the same d-tag but new owner const eventTags: string[][] = []; - + // Copy all original tags EXCEPT provenance markers we're about to (re)set. // Without this filter, claiming a spot that was itself a prior claim would // stack multiple claim-from/claim-reason pairs on the event. @@ -155,22 +177,21 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) { if (tag[0] === 'claim-from' || tag[0] === 'claim-reason') continue; eventTags.push([...tag]); } - - // Add a single, fresh claim reference pointing at the immediate prior owner. + eventTags.push(['claim-from', spot.pubkey]); - eventTags.push(['claim-reason', isAbandoned ? 'abandoned' : 'transfer']); - + eventTags.push(['claim-reason', reason]); + await publishEvent({ kind: SKATE_SPOT_KIND, content: spot.description, tags: eventTags, }); - + toast({ title: t('spotUi.spotClaimed'), description: t('spotUi.spotClaimedDesc'), }); - + setClaimOpen(false); queryClient.invalidateQueries({ queryKey: ['skate-spots'] }); queryClient.invalidateQueries({ queryKey: ['skate-spot', spot.identifier] }); @@ -182,12 +203,40 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) { }); } }; - + + const handleSubmitPrivateClaim = async () => { + if (!myNip05) return; + try { + await submitClaim({ coord, ownerPubkey: spot.pubkey, nip05: myNip05 }); + toast({ title: t('spotUi.claim.submitted'), description: t('spotUi.claim.submittedDesc') }); + setPrivateClaimOpen(false); + } catch (error) { + toast({ + title: t('spotUi.claim.submitFailed'), + description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'), + variant: 'destructive', + }); + } + }; + + const handleApproveClaim = async (claimant: string) => { + try { + await approveClaim({ coord, subject: claimant, reason: 'private-spot-claim' }); + toast({ title: t('spotUi.claim.adminApproved'), description: t('spotUi.claim.adminApprovedDesc') }); + } catch (error) { + toast({ + title: t('spotUi.claim.adminApproveFailed'), + description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'), + variant: 'destructive', + }); + } + }; + // Don't show anything for shops if (spot.types.includes('shop')) { return null; } - + return ( <> {/* Owner: Transfer button */} @@ -209,7 +258,7 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) { {t('spotUi.transferDesc', { title: spot.title })} - +
@@ -222,7 +271,7 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) { {t('spotUi.newOwnerNpubHint')}

- +
@@ -236,7 +285,7 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
- +
+
+
+ + + )} + + {/* Claim pending review */} + {user && !amApproved && myClaim && ( + + +
+ +
+

{t('spotUi.claim.pendingTitle')}

+

{t('spotUi.claim.pendingDesc')}

+
+
+
+
+ )} + + {/* Can claim (NIP-05 present) */} + {user && !amApproved && !myClaim && myNip05 && ( + + +
+ +
+

{t('spotUi.claim.areYouOwner')}

+

{t('spotUi.claim.privateDesc')}

+ +
+
+
+
+ )} + + {/* Logged in but no NIP-05 → nudge */} + {user && !amApproved && !myClaim && !myNip05 && ( + + +
+ +
+

{t('spotUi.claim.areYouOwner')}

+

{t('spotUi.claim.nip05Required')}

+ + + +
+
+
+
+ )} + + {/* Not logged in */} + {!user && ( + + +
+ +
+

{t('spotUi.claim.areYouOwner')}

+

{t('spotUi.claim.loginRequired')}

+ +
+
+
+
+ )} + + )} + + {/* ── Admin: pending private-spot claims ─────────────────────────────── */} + {isAdmin && isPrivate && claims.length > 0 && ( + + +

{t('spotUi.claim.adminTitle')}

+
+ {claims.map((c) => { + const approved = !!approvals?.get(coord)?.has(c.claimant.toLowerCase()); + return ( +
+ + {c.nip05 || `${nip19.npubEncode(c.claimant).slice(0, 12)}…`} + + {approved ? ( + {t('spotUi.claim.adminApproved')} + ) : ( + + )} +
+ ); + })} +
+
+
+ )} + + {/* Non-owner: Claim abandoned spot (non-private spots only) */} + {!isOwner && !isPrivate && isAbandoned && user && (
@@ -287,9 +473,9 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) { )} - - {/* Not logged in: Show login for abandoned spots */} - {!isOwner && isAbandoned && !user && ( + + {/* Not logged in: abandoned non-private spots */} + {!isOwner && !isPrivate && isAbandoned && !user && (
@@ -307,8 +493,8 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) { )} - - {/* Claim confirmation dialog */} + + {/* Claim confirmation dialog (abandoned) */} @@ -335,7 +521,7 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) { {t('common.cancel')} handleClaim(isAbandoned ? 'abandoned' : 'transfer')} disabled={isPublishing} className="bg-orange-500 hover:bg-orange-600" > @@ -351,6 +537,47 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) { + + {/* Claim confirmation dialog (private) */} + + + + + + {t('spotUi.claim.confirmTitle', { title: spot.title })} + + +
+

{t('spotUi.claim.confirmIntro')}

+
+
    +
  • {t('spotUi.claim.confirmBullet1')}
  • +
  • {t('spotUi.claim.confirmBullet2')}
  • +
  • {t('spotUi.claim.confirmBullet3')}
  • +
+
+
+
+
+ + {t('common.cancel')} + + {submittingClaim ? ( + <> + + {t('spotUi.claim.submitting')} + + ) : ( + t('spotUi.claim.submit') + )} + + +
+
); } diff --git a/src/hooks/useAdminHide.ts b/src/hooks/useAdminHide.ts index 0cd2600..5b945b8 100644 --- a/src/hooks/useAdminHide.ts +++ b/src/hooks/useAdminHide.ts @@ -8,6 +8,7 @@ import { MODERATION_LABEL_HIDDEN, MODERATION_LABEL_VERIFIED, MODERATION_LABEL_CLEARED, + MODERATION_LABEL_CLAIM_APPROVED, } from '@/lib/types'; /** @@ -39,6 +40,7 @@ function usePublishModerationLabel(label: string) { coord, id, reason, + subject, }: { /** Addressable coordinate, e.g. `30818::`. */ coord?: string; @@ -46,6 +48,8 @@ function usePublishModerationLabel(label: string) { 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.'); @@ -57,18 +61,18 @@ function usePublishModerationLabel(label: string) { ]; 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. - const verdict = - label === MODERATION_LABEL_HIDDEN - ? 'hidden' - : label === MODERATION_LABEL_VERIFIED - ? 'verified' - : 'cleared'; - setLocalVerdict({ coord, id }, verdict); + // 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'] }); }, }); @@ -96,3 +100,13 @@ 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 }; +} diff --git a/src/hooks/useSpotClaims.ts b/src/hooks/useSpotClaims.ts new file mode 100644 index 0000000..424c0ae --- /dev/null +++ b/src/hooks/useSpotClaims.ts @@ -0,0 +1,155 @@ +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({ + 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(); + 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>>({ + 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>(); + 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({ + 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(); + 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] }); + }, + }; +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 1e34007..9cf8ece 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -965,6 +965,34 @@ mapUi: { }, spotUi: { + claim: { + areYouOwner: 'Is this your spot?', + privateDesc: 'This spot is marked private. If you own or manage it, claim it — a SkateSpots moderator confirms ownership, then you take over the listing.', + claimSpot: 'Claim this spot', + nip05Required: 'You need a verified NIP-05 handle to claim a private spot (proof you control a Nostr identity). Add one in your profile, then come back.', + addNip05: 'Add NIP-05 in Settings', + pendingTitle: 'Claim pending review', + pendingDesc: 'Your claim has been submitted. A SkateSpots moderator will review it — you can finish claiming the spot once it\'s approved.', + approvedTitle: 'Claim approved', + approvedDesc: 'A moderator approved your claim. Finish to become the owner of this spot.', + complete: 'Finish claiming', + submitted: 'Claim submitted', + submittedDesc: 'A moderator will review your claim. You\'ll take over the spot once it\'s approved.', + submitFailed: 'Could not submit the claim', + submit: 'Submit claim', + submitting: 'Submitting…', + confirmTitle: 'Claim "{{title}}"?', + confirmIntro: 'You are asking to become the owner of this private spot.', + confirmBullet1: 'A SkateSpots moderator reviews the request (we can\'t verify property, only Nostr keys)', + confirmBullet2: 'If approved, you finish the claim to become the owner and can edit the spot', + confirmBullet3: 'The original spot stays on Nostr — the network is decentralized', + adminTitle: 'Pending ownership claims', + adminApprove: 'Approve', + adminApproved: 'Approved', + adminApprovedDesc: 'They can now finish claiming the spot.', + adminApproveFailed: 'Could not approve the claim', + loginRequired: 'Log in to claim this private spot, or to request ownership.', + }, abandonedDesc: 'Last updated {{time}}. If the original owner is inactive, you can claim this spot to maintain and improve it.', abandonedLoginDesc: 'Last updated {{time}}. Log in to claim and maintain this spot.', accessDeniedDesc: 'You can only edit spots that you created.', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index dd70fac..62fb352 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -965,6 +965,34 @@ mapUi: { }, spotUi: { + claim: { + areYouOwner: '¿Es este tu spot?', + privateDesc: 'Este spot está marcado como privado. Si es tuyo o lo gestionas, reclámalo: un moderador de SkateSpots confirma la propiedad y después asumes el anuncio.', + claimSpot: 'Reclamar este spot', + nip05Required: 'Necesitas un identificador NIP-05 verificado para reclamar un spot privado (prueba de que controlas una identidad Nostr). Añádelo en tu perfil y vuelve.', + addNip05: 'Añadir NIP-05 en Ajustes', + pendingTitle: 'Reclamación pendiente de revisión', + pendingDesc: 'Tu reclamación se ha enviado. Un moderador de SkateSpots la revisará; podrás completarla cuando se apruebe.', + approvedTitle: 'Reclamación aprobada', + approvedDesc: 'Un moderador aprobó tu reclamación. Complétala para convertirte en el dueño de este spot.', + complete: 'Completar reclamación', + submitted: 'Reclamación enviada', + submittedDesc: 'Un moderador revisará tu reclamación. Asumirás el spot cuando se apruebe.', + submitFailed: 'No se pudo enviar la reclamación', + submit: 'Enviar reclamación', + submitting: 'Enviando…', + confirmTitle: '¿Reclamar "{{title}}"?', + confirmIntro: 'Estás pidiendo convertirte en el dueño de este spot privado.', + confirmBullet1: 'Un moderador de SkateSpots revisa la solicitud (no podemos verificar la propiedad, solo claves Nostr)', + confirmBullet2: 'Si se aprueba, completas la reclamación y puedes editar el spot', + confirmBullet3: 'El spot original permanece en Nostr: la red es descentralizada', + adminTitle: 'Reclamaciones pendientes', + adminApprove: 'Aprobar', + adminApproved: 'Aprobada', + adminApprovedDesc: 'Ya puede completar la reclamación del spot.', + adminApproveFailed: 'No se pudo aprobar la reclamación', + loginRequired: 'Inicia sesión para reclamar este spot privado o solicitar su propiedad.', + }, abandonedDesc: 'Actualizado por última vez {{time}}. Si el dueño original está inactivo, puedes reclamar este spot para mantenerlo y mejorarlo.', abandonedLoginDesc: 'Actualizado por última vez {{time}}. Inicia sesión para reclamar y mantener este spot.', accessDeniedDesc: 'Solo puedes editar los spots que has creado.', diff --git a/src/lib/types.ts b/src/lib/types.ts index 4e77a59..acc3ea5 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -237,6 +237,21 @@ export const MODERATION_LABEL_VERIFIED = 'verified'; * earlier `hidden`/`verified` label. */ export const MODERATION_LABEL_CLEARED = 'cleared'; +/** + * A moderator APPROVED a private-spot ownership claim. Tagged `["a", coord]` + + * `["p", claimantPubkey]`; the claimant then finishes the claim (re-publishes + * the spot under their key). Distinct from `verified` (which vouches for the + * spot itself, not who owns it). + */ +export const MODERATION_LABEL_CLAIM_APPROVED = 'claim-approved'; + +/** + * A claimant's own signed ownership claim (NIP-32 kind 1985). Kept in a SEPARATE + * namespace from moderation so `useAdminModeration` never reads non-admin labels + * as verdicts. + */ +export const SPOT_CLAIM_NAMESPACE = 'org.skatespots.spotclaim'; +export const SPOT_CLAIM_LABEL = 'claim'; /** Extract member pubkeys (hex) claimed by a Lab profile via `p` tags. */ export function parseMemberPubkeys(event: NostrEvent): string[] { diff --git a/src/pages/AdminReviewPage.tsx b/src/pages/AdminReviewPage.tsx index d102429..ec3a13a 100644 --- a/src/pages/AdminReviewPage.tsx +++ b/src/pages/AdminReviewPage.tsx @@ -41,7 +41,8 @@ import { useBatchTrustScores } from '@/hooks/useBatchTrustScores'; import { useBatchSpotStatus } from '@/hooks/useSpotStatus'; import { useBatchSpotPhotos } from '@/hooks/useSpotContributions'; import { useSpotReports } from '@/hooks/useSpotReports'; -import { useAdminHide, useAdminVerify, useAdminClear } from '@/hooks/useAdminHide'; +import { useAdminHide, useAdminVerify, useAdminClear, useAdminApproveClaim } from '@/hooks/useAdminHide'; +import { useAllSpotClaims, useApprovedSpotClaims } from '@/hooks/useSpotClaims'; import { useAdminModeration } from '@/hooks/useAdminModeration'; import { useNotASpotReport } from '@/hooks/useNotASpotReport'; import { useCurrentUser } from '@/hooks/useCurrentUser'; @@ -654,8 +655,62 @@ function LabQueue() { ); } -export default function AdminReviewPage() { +/** + * Private-spot ownership claims awaiting a moderator. Approving publishes the + * `claim-approved` label tagged with the claimant, who then finishes the claim. + */ +function SpotClaimsQueue() { const { t } = useTranslation(); + const { data: claims = [] } = useAllSpotClaims(); + const { data: approvals } = useApprovedSpotClaims(); + const { approveClaim, isPending } = useAdminApproveClaim(); + const { toast } = useToast(); + + const pending = claims.filter((c) => !approvals?.get(c.coord)?.has(c.claimant.toLowerCase())); + if (pending.length === 0) return null; + + const approve = async (coord: string, claimant: string) => { + try { + await approveClaim({ coord, subject: claimant, reason: 'private-spot-claim' }); + toast({ title: t('spotUi.claim.adminApproved') }); + } catch { + toast({ title: t('spotUi.claim.adminApproveFailed'), variant: 'destructive' }); + } + }; + + return ( + + +

+ {t('spotUi.claim.adminTitle')} ({pending.length}) +

+
+ {pending.map((c) => ( +
+
+ + {c.identifier} + + {c.nip05 || `${c.claimant.slice(0, 12)}…`} +
+ +
+ ))} +
+
+
+ ); +} + +export default function AdminReviewPage() { const { t } = useTranslation(); const { user } = useCurrentUser(); const [kind, setKind] = useState('reports'); @@ -673,6 +728,8 @@ export default function AdminReviewPage() { subtitle={t('walletUi.admin.subtitle')} /> + +
{TABS.map((tab) => (