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.
This commit is contained in:
parent
391eeb9bf9
commit
411f6e219a
9 changed files with 625 additions and 53 deletions
11
AGENTS.md
11
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**
|
||||
|
|
|
|||
37
NIP.md
37
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": "<their nip05 handle>", "tags": [
|
||||
["L", "org.skatespots.spotclaim"],
|
||||
["l", "claim", "org.skatespots.spotclaim"],
|
||||
["a", "30818:<owner-pubkey>:<d>"],
|
||||
["p", "<owner-pubkey>"],
|
||||
["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:<owner>:<d>"]` · `["p","<claimant>"]`.
|
||||
|
||||
3. **Finish (the claimant).** On approval the claimant re-publishes the spot
|
||||
under their own key with `["claim-from","<owner>"]` 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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -51,9 +59,24 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
|||
|
||||
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(() => {
|
||||
|
|
@ -116,7 +139,7 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
|||
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'],
|
||||
|
|
@ -140,12 +163,11 @@ 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.
|
||||
|
|
@ -156,9 +178,8 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
|||
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,
|
||||
|
|
@ -183,6 +204,34 @@ 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;
|
||||
|
|
@ -261,8 +310,145 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
|||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* Non-owner: Claim abandoned spot */}
|
||||
{!isOwner && isAbandoned && user && (
|
||||
{/* ── PRIVATE SPOT — ownership claim (NIP-05 + moderator confirm) ────── */}
|
||||
{!isOwner && isPrivate && (
|
||||
<>
|
||||
{/* Claim approved → finish */}
|
||||
{user && amApproved && (
|
||||
<Card className="bg-green-500/10 border-2 border-green-500/40">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<BadgeCheck className="h-6 w-6 text-green-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-green-400 uppercase text-sm mb-1">{t('spotUi.claim.approvedTitle')}</h3>
|
||||
<p className="text-xs text-gray-400 mb-3">{t('spotUi.claim.approvedDesc')}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-2 bg-green-500 hover:bg-green-600 text-black font-bold"
|
||||
onClick={() => handleClaim('private-approved')}
|
||||
disabled={isPublishing}
|
||||
>
|
||||
{isPublishing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Crown className="h-4 w-4" />}
|
||||
{isPublishing ? t('spotUi.claiming') : t('spotUi.claim.complete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Claim pending review */}
|
||||
{user && !amApproved && myClaim && (
|
||||
<Card className="bg-blue-500/10 border-2 border-blue-500/40">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Hourglass className="h-6 w-6 text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-blue-300 uppercase text-sm mb-1">{t('spotUi.claim.pendingTitle')}</h3>
|
||||
<p className="text-xs text-gray-400">{t('spotUi.claim.pendingDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Can claim (NIP-05 present) */}
|
||||
{user && !amApproved && !myClaim && myNip05 && (
|
||||
<Card className="bg-[#ccff00]/10 border-2 border-[#ccff00]/40">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Lock className="h-6 w-6 text-[#ccff00] flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-[#ccff00] uppercase text-sm mb-1">{t('spotUi.claim.areYouOwner')}</h3>
|
||||
<p className="text-xs text-gray-400 mb-3">{t('spotUi.claim.privateDesc')}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase"
|
||||
onClick={() => setPrivateClaimOpen(true)}
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
{t('spotUi.claim.claimSpot')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Logged in but no NIP-05 → nudge */}
|
||||
{user && !amApproved && !myClaim && !myNip05 && (
|
||||
<Card className="bg-yellow-500/10 border-2 border-yellow-500/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-6 w-6 text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-yellow-400 uppercase text-sm mb-1">{t('spotUi.claim.areYouOwner')}</h3>
|
||||
<p className="text-xs text-gray-400 mb-3">{t('spotUi.claim.nip05Required')}</p>
|
||||
<Link to="/settings?section=profile">
|
||||
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold">
|
||||
{t('spotUi.claim.addNip05')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Not logged in */}
|
||||
{!user && (
|
||||
<Card className="bg-[#ccff00]/10 border-2 border-[#ccff00]/40">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Lock className="h-6 w-6 text-[#ccff00] flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-[#ccff00] uppercase text-sm mb-1">{t('spotUi.claim.areYouOwner')}</h3>
|
||||
<p className="text-xs text-gray-400 mb-3">{t('spotUi.claim.loginRequired')}</p>
|
||||
<LoginArea className="justify-start" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Admin: pending private-spot claims ─────────────────────────────── */}
|
||||
{isAdmin && isPrivate && claims.length > 0 && (
|
||||
<Card className="bg-purple-500/10 border-2 border-purple-500/30">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-bold text-purple-300 uppercase text-sm mb-3">{t('spotUi.claim.adminTitle')}</h3>
|
||||
<div className="space-y-2">
|
||||
{claims.map((c) => {
|
||||
const approved = !!approvals?.get(coord)?.has(c.claimant.toLowerCase());
|
||||
return (
|
||||
<div key={c.claimant} className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-gray-300 truncate font-mono">
|
||||
{c.nip05 || `${nip19.npubEncode(c.claimant).slice(0, 12)}…`}
|
||||
</span>
|
||||
{approved ? (
|
||||
<span className="text-green-400 font-bold uppercase shrink-0">{t('spotUi.claim.adminApproved')}</span>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 bg-purple-500 hover:bg-purple-600 text-white"
|
||||
onClick={() => handleApproveClaim(c.claimant)}
|
||||
disabled={approvingClaim}
|
||||
>
|
||||
{approvingClaim ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <BadgeCheck className="h-3.5 w-3.5" />}
|
||||
{t('spotUi.claim.adminApprove')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Non-owner: Claim abandoned spot (non-private spots only) */}
|
||||
{!isOwner && !isPrivate && isAbandoned && user && (
|
||||
<Card className="bg-orange-500/10 border-2 border-orange-500/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
|
|
@ -288,8 +474,8 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{/* Not logged in: Show login for abandoned spots */}
|
||||
{!isOwner && isAbandoned && !user && (
|
||||
{/* Not logged in: abandoned non-private spots */}
|
||||
{!isOwner && !isPrivate && isAbandoned && !user && (
|
||||
<Card className="bg-orange-500/10 border-2 border-orange-500/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
|
|
@ -308,7 +494,7 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{/* Claim confirmation dialog */}
|
||||
{/* Claim confirmation dialog (abandoned) */}
|
||||
<AlertDialog open={claimOpen} onOpenChange={setClaimOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
|
|
@ -335,7 +521,7 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
|||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleClaim}
|
||||
onClick={() => handleClaim(isAbandoned ? 'abandoned' : 'transfer')}
|
||||
disabled={isPublishing}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
>
|
||||
|
|
@ -351,6 +537,47 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
|||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Claim confirmation dialog (private) */}
|
||||
<AlertDialog open={privateClaimOpen} onOpenChange={setPrivateClaimOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-[#ccff00]" />
|
||||
{t('spotUi.claim.confirmTitle', { title: spot.title })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
<p>{t('spotUi.claim.confirmIntro')}</p>
|
||||
<div className="bg-muted p-3 rounded-lg text-sm">
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-1">
|
||||
<li>{t('spotUi.claim.confirmBullet1')}</li>
|
||||
<li>{t('spotUi.claim.confirmBullet2')}</li>
|
||||
<li>{t('spotUi.claim.confirmBullet3')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleSubmitPrivateClaim}
|
||||
disabled={submittingClaim}
|
||||
className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
{submittingClaim ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
{t('spotUi.claim.submitting')}
|
||||
</>
|
||||
) : (
|
||||
t('spotUi.claim.submit')
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:<pubkey>:<d>`. */
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
155
src/hooks/useSpotClaims.ts
Normal file
155
src/hooks/useSpotClaims.ts
Normal file
|
|
@ -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<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] });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Card className="mb-4 bg-purple-500/10 border-2 border-purple-500/30">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="mb-3 text-sm font-bold uppercase text-purple-300">
|
||||
{t('spotUi.claim.adminTitle')} ({pending.length})
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{pending.map((c) => (
|
||||
<div key={`${c.coord}:${c.claimant}`} className="flex items-center justify-between gap-3 text-xs">
|
||||
<div className="min-w-0">
|
||||
<Link to={`/spot/${c.ownerPubkey}/${c.identifier}`} className="block truncate text-[#ccff00] hover:underline">
|
||||
{c.identifier}
|
||||
</Link>
|
||||
<span className="font-mono text-gray-500">{c.nip05 || `${c.claimant.slice(0, 12)}…`}</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 shrink-0 gap-1.5 bg-purple-500 text-white hover:bg-purple-600"
|
||||
disabled={isPending}
|
||||
onClick={() => approve(c.coord, c.claimant)}
|
||||
>
|
||||
{isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <BadgeCheck className="h-3.5 w-3.5" />}
|
||||
{t('spotUi.claim.adminApprove')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminReviewPage() { const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const [kind, setKind] = useState<QueueKind>('reports');
|
||||
|
||||
|
|
@ -673,6 +728,8 @@ export default function AdminReviewPage() {
|
|||
subtitle={t('walletUi.admin.subtitle')}
|
||||
/>
|
||||
|
||||
<SpotClaimsQueue />
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-1.5">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
|
|
|
|||
Loading…
Reference in a new issue