Add crew NIP-05 (any-domain, founder-verified) + founder/delegated-admin governance

- SkateCrew: add nip05 + admins fields; parse `admin` role marker on p tags (admins = subset of members)
- CrewForm: NIP-05 input + per-member "Make admin" toggle; write nip05 tag + admin-marked p tags (works on create & edit via shared form)
- useCrewNip05: verify crew handle on ANY domain against the founder pubkey (verified/mismatch/unreachable), CORS-proxy fallback + caching
- CrewNip05Badge: green ✓ / red ⚠ / muted handle states with tooltips
- CollectiveMembers: show Admin chips, sort admins first, admin count in summary
- useCrewRole: centralized founder/admin/member/none roles (canManage, canEditProfile) — founder owns identity, admins get delegated powers with their own keys; egalitarian shared-editing intentionally unsupported
- CrewDetailPage: NIP-05 badge by crew name, viewer role badges (admin/member)
- NIP.md: document crew nip05 verification, admin role marker, and governance/conflict-resolution model

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-24 16:46:20 +07:00
parent e48a5e6bc5
commit 40e4a27565
8 changed files with 466 additions and 21 deletions

51
NIP.md
View file

@ -1716,9 +1716,10 @@ bare `p` tag on the crew is only a claim until the member self-labels.
["founded", "2019"],
["t", "street"],
["t", "transition"],
["p", "<memberPubkey1>"],
["p", "<memberPubkey1>", "", "admin"],
["p", "<memberPubkey2>"],
["a", "30818:<spotFounder>:<spotDtag>"],
["nip05", "barcelonanightcrew@bcnnightcrew.example"],
["website", "https://bcnnightcrew.example"],
["noffer", "noffer1qqs..."],
["alt", "Skate crew: Barcelona Night Crew"]
@ -1743,8 +1744,9 @@ bare `p` tag on the crew is only a claim until the member self-labels.
| `location` | Human-readable home location |
| `founded` | Year founded (cosmetic) |
| `t: <vibe>` | Style/focus tags. Preset vibes plus any user-defined custom hashtag (lowercased, hyphenated slug). Examples: `street`, `transition`, `park`, `diy`, `oldschool`, `allterrain`, `cruising`, `girls`, `youth`, `adaptive`, `downhill`, `night-sessions`, etc. |
| `p` | Member pubkeys (hex). Confirmed only when the member self-attests (see below). |
| `p` | Member pubkeys (hex). Confirmed only when the member self-attests (see below). An optional 4th element `"admin"` marks the member as a delegated admin (see Governance). |
| `a` | Home spots the crew claims/haunts — references to kind `30818` skate spots (up to 8). Cross-links the crew to the map. |
| `nip05` | Optional crew NIP-05 identifier (`local@domain`). Verified against the **founder** pubkey — see NIP-05 Verification. |
| `website`, `instagram`, `social` | Links |
| `noffer` | Optional shared tip jar (CLINK Offer — see below) |
@ -1755,6 +1757,51 @@ belonging by publishing a NIP-32 label (kind `1985`) referencing the crew's
`a`-coordinate, `["l", "member", "org.skatespots.membership"]`. Confirmed /
pending chips and removal semantics are identical to Lab collectives.
### Governance (founder + delegated admins)
A crew's identity is a single **addressable event authored by the founder**.
On Nostr, an addressable event can only be overwritten by its author's key, so
the founder's pubkey is the crew's single source of truth — **conflicts are
impossible at the data layer** (a fork by anyone else is a *different* event
under a *different* author and is ignored by clients querying the founder's
coordinate).
Roles:
| Role | Determined by | Powers |
|------|---------------|--------|
| **Founder** | Authored the crew event | Sole owner of the crew identity. Only role that can **edit the profile** (name, logo, members, admins, NIP-05, etc.). |
| **Admin** | A confirmed member whose `p` tag carries the `"admin"` marker | Delegated *powers* exercised with the admin's **own key**: invite/vouch members, moderate the crew feed & chat, represent the crew. Cannot rewrite the profile. |
| **Member** | Listed `p` tag + self-attested | Appears in the crew, posts to the feed/chat. |
Admins are always a **subset of members** — a pubkey marked `admin` that is not
also a confirmed member is treated as a plain (pending) member until it attests.
> **Why not egalitarian shared editing?** Letting arbitrary members overwrite
> the crew profile would require either a **shared private key** (a single leak
> hijacks the crew) or a **consensus/multisig layer** (heavy and
> non-interoperable). SkateSpots deliberately uses the owner + delegated-mods
> model (like GitHub orgs, Discord, and NIP-72 communities): the founder owns
> the record, admins wield delegated powers with their own keys.
### NIP-05 Verification
The optional `nip05` tag is a crew handle on **any domain the crew controls**
(fully decentralized — SkateSpots does not gatekeep the handle). Because the
crew event is authored by the founder, a client verifies the handle by fetching
`https://<domain>/.well-known/nostr.json?name=<local>` and checking that the
returned pubkey equals the **founder** pubkey:
| Result | Meaning | UI |
|--------|---------|----|
| name → founder pubkey | Verified | Green ✓ handle |
| name → different pubkey | Impersonation / stale | Red ⚠ handle |
| domain/JSON unreachable | Can't verify right now | Muted handle |
| no `nip05` | — | (nothing) |
Verification is fetched directly with a CORS-proxy fallback and cached
client-side.
### Map Integration
Crews with a `g` (home turf) render on the SkateSpots map as teal pins. Clicking

View file

@ -20,6 +20,8 @@ interface CollectiveMembersProps {
founderPubkey: string;
identifier: string;
claimedMembers: string[];
/** Delegated admin pubkeys (subset of members). */
admins?: string[];
accentText?: string;
accentBorder?: string;
}
@ -48,7 +50,15 @@ function StatusChip({ status }: { status: CollectiveMember['status'] }) {
);
}
function MemberRow({ member }: { member: CollectiveMember }) {
function AdminChip() {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-teal-500/15 px-2 py-0.5 text-xs font-bold text-teal-300">
<ShieldCheck className="h-3 w-3" /> Admin
</span>
);
}
function MemberRow({ member, isAdmin }: { member: CollectiveMember; isAdmin: boolean }) {
const author = useAuthor(member.pubkey);
const metadata = author.data?.metadata;
const name = metadata?.name ?? genUserName(member.pubkey);
@ -64,6 +74,7 @@ function MemberRow({ member }: { member: CollectiveMember }) {
<AvatarFallback className="text-xs">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-gray-200">{name}</span>
{isAdmin && <AdminChip />}
<StatusChip status={member.status} />
</Link>
);
@ -78,6 +89,7 @@ export function CollectiveMembers({
founderPubkey,
identifier,
claimedMembers,
admins = [],
accentText = 'text-indigo-400',
accentBorder = 'border-gray-700',
}: CollectiveMembersProps) {
@ -91,8 +103,18 @@ export function CollectiveMembers({
if (claimedMembers.length === 0) return null;
const adminSet = new Set(admins);
const confirmedCount = members?.filter((m) => m.status === 'confirmed').length ?? 0;
// Admins first, then the hook's existing confirmed-first ordering.
const orderedMembers = members
? [...members].sort((a, b) => {
const aa = adminSet.has(a.pubkey) ? 1 : 0;
const ba = adminSet.has(b.pubkey) ? 1 : 0;
return ba - aa;
})
: members;
return (
<Card className={cn('bg-black/90 border-2', accentBorder)}>
<CardHeader>
@ -108,6 +130,7 @@ export function CollectiveMembers({
{confirmedCount > 0
? `${confirmedCount} of ${claimedMembers.length} verified — each confirmed member signed with their own Nostr key.`
: 'Membership is verified per member. Each member confirms with their own Nostr key.'}
{admins.length > 0 && ` ${admins.length} admin${admins.length !== 1 ? 's' : ''} help run the crew.`}
</span>
</p>
@ -123,8 +146,8 @@ export function CollectiveMembers({
</div>
) : (
<div className="space-y-2">
{members?.map((m) => (
<MemberRow key={m.pubkey} member={m} />
{orderedMembers?.map((m) => (
<MemberRow key={m.pubkey} member={m} isAdmin={adminSet.has(m.pubkey)} />
))}
</div>
)}

View file

@ -25,12 +25,22 @@ import {
type SkateCrew,
} from '@/lib/types';
import { cn } from '@/lib/utils';
import { Users, Upload, X, Loader2, MapPin, Plus } from 'lucide-react';
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign } from 'lucide-react';
const VIBE_ENTRIES = Object.entries(CREW_VIBE_LABELS);
const TEAL = 'bg-teal-500 hover:bg-teal-600 text-black';
function MemberRow({ pubkey, onRemove }: { pubkey: string; onRemove: () => void }) {
function MemberRow({
pubkey,
isAdmin,
onToggleAdmin,
onRemove,
}: {
pubkey: string;
isAdmin: boolean;
onToggleAdmin: () => void;
onRemove: () => void;
}) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const name = metadata?.name ?? genUserName(pubkey);
@ -42,9 +52,30 @@ function MemberRow({ pubkey, onRemove }: { pubkey: string; onRemove: () => void
<AvatarFallback className="text-xs">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{name}</p>
<p className="flex items-center gap-1.5 truncate text-sm font-medium">
{name}
{isAdmin && (
<span className="inline-flex items-center gap-0.5 rounded-full bg-teal-500/20 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">
<ShieldCheck className="h-2.5 w-2.5" /> Admin
</span>
)}
</p>
<p className="truncate text-xs text-gray-500">{npub.slice(0, 16)}…</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
'h-7 shrink-0 gap-1 px-2 text-xs',
isAdmin ? 'text-teal-300 hover:text-teal-200' : 'text-gray-400 hover:text-teal-300',
)}
onClick={onToggleAdmin}
aria-label={isAdmin ? 'Remove admin role' : 'Make admin'}
>
<ShieldCheck className="h-3.5 w-3.5" />
{isAdmin ? 'Admin' : 'Make admin'}
</Button>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-gray-400 hover:text-red-400" onClick={onRemove} aria-label="Remove member">
<X className="h-4 w-4" />
</Button>
@ -85,10 +116,20 @@ export function CrewForm({ existing }: CrewFormProps) {
const [instagram, setInstagram] = useState(existing?.instagram ?? '');
const [socials, setSocials] = useState<string[]>(existing?.socials ?? []);
const [socialInput, setSocialInput] = useState('');
const [nip05, setNip05] = useState(existing?.nip05 ?? '');
const [noffer, setNoffer] = useState(existing?.noffer ?? '');
const [members, setMembers] = useState<string[]>(existing?.members ?? []);
const [admins, setAdmins] = useState<string[]>(existing?.admins ?? []);
const [memberInput, setMemberInput] = useState('');
const [memberError, setMemberError] = useState<string | null>(null);
const toggleAdmin = (pk: string) =>
setAdmins((prev) => (prev.includes(pk) ? prev.filter((x) => x !== pk) : [...prev, pk]));
const removeMember = (pk: string) => {
setMembers((prev) => prev.filter((m) => m !== pk));
setAdmins((prev) => prev.filter((a) => a !== pk));
};
const [selectedSpots, setSelectedSpots] = useState<string[]>(
(existing?.spotRefs ?? []).map((ref) => {
const parts = ref.split(':');
@ -232,9 +273,17 @@ export function CrewForm({ existing }: CrewFormProps) {
for (const s of socials) {
if (s.trim()) tags.push(['social', s.trim()]);
}
if (nip05.trim()) tags.push(['nip05', nip05.trim().toLowerCase()]);
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
for (const pk of members) tags.push(['p', pk]);
// Members: admins get an `admin` role marker on their `p` tag.
for (const pk of members) {
if (admins.includes(pk)) {
tags.push(['p', pk, '', 'admin']);
} else {
tags.push(['p', pk]);
}
}
for (const spotId of selectedSpots) {
const [spotPubkey, spotIdentifier] = spotId.split(':');
@ -346,6 +395,14 @@ export function CrewForm({ existing }: CrewFormProps) {
<p className="mt-1 text-xs text-gray-500">
Add your crew's Nostr accounts. Each member confirms from their own key — until then they show as “pending”.
</p>
<p className="mt-1 flex items-start gap-1 text-xs text-gray-500">
<ShieldCheck className="mt-0.5 h-3 w-3 shrink-0 text-teal-400" />
<span>
Make trusted members <strong className="text-teal-300">admins</strong> — they can invite members
and moderate the crew feed &amp; chat with their own keys. You (the founder) always stay the
owner of the crew's identity.
</span>
</p>
</div>
<div className="flex gap-2">
<Input
@ -365,7 +422,13 @@ export function CrewForm({ existing }: CrewFormProps) {
{members.length > 0 && (
<div className="space-y-2">
{members.map((pk) => (
<MemberRow key={pk} pubkey={pk} onRemove={() => setMembers(members.filter((m) => m !== pk))} />
<MemberRow
key={pk}
pubkey={pk}
isAdmin={admins.includes(pk)}
onToggleAdmin={() => toggleAdmin(pk)}
onRemove={() => removeMember(pk)}
/>
))}
</div>
)}
@ -408,6 +471,26 @@ export function CrewForm({ existing }: CrewFormProps) {
)}
</div>
{/* Crew NIP-05 identity */}
<div className="space-y-2 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
<Label htmlFor="crew-nip05" className="flex items-center gap-2 text-teal-400">
<AtSign className="h-4 w-4" /> Crew NIP-05 <span className="font-normal text-gray-500">· optional</span>
</Label>
<p className="text-xs text-gray-500">
A verifiable handle you control on any domain, e.g. <code className="text-teal-300">crew@yourdomain.com</code>.
It's verified against this crew's founder key — a green check shows when the domain confirms it.
</p>
<Input
id="crew-nip05"
placeholder="crew@yourdomain.com"
value={nip05}
onChange={(e) => setNip05(e.target.value)}
spellCheck={false}
autoCapitalize="none"
className="bg-gray-800 border-gray-700 font-mono text-sm"
/>
</div>
{/* Links */}
<div className="space-y-3">
<Label className="text-base font-bold">Links</Label>

View file

@ -0,0 +1,91 @@
import { BadgeCheck, Loader2, AlertTriangle } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useCrewNip05 } from '@/hooks/useCrewNip05';
import { cn } from '@/lib/utils';
interface CrewNip05BadgeProps {
nip05?: string;
founderPubkey?: string;
className?: string;
}
/**
* Verified-handle badge for a crew's optional NIP-05.
*
* - `verified`: green check + the handle. The domain confirms the founder key.
* - `mismatch`: red warning — the domain points the handle at a different key.
* - `unreachable`: muted handle (shown but unverified) while the domain is down.
* - `none`: renders nothing.
*/
export function CrewNip05Badge({ nip05, founderPubkey, className }: CrewNip05BadgeProps) {
const { data, isLoading } = useCrewNip05(nip05, founderPubkey);
if (!nip05) return null;
const status = data?.status ?? (isLoading ? 'loading' : 'unreachable');
const handle = nip05.trim().toLowerCase();
if (status === 'mismatch') {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'inline-flex items-center gap-1 rounded-full bg-red-500/15 px-2 py-0.5 text-xs font-bold text-red-400',
className,
)}
>
<AlertTriangle className="h-3 w-3" /> {handle}
</span>
</TooltipTrigger>
<TooltipContent>
This domain points the handle at a different Nostr key than the crew founder. Not verified.
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
if (status === 'verified') {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'inline-flex items-center gap-1 rounded-full bg-teal-500/15 px-2 py-0.5 text-xs font-bold text-teal-300',
className,
)}
>
<BadgeCheck className="h-3 w-3" /> {handle}
</span>
</TooltipTrigger>
<TooltipContent>
Verified — the domain confirms this handle belongs to the crew's founder key.
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
// loading / unreachable — show the handle, muted.
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full bg-white/5 px-2 py-0.5 text-xs font-medium text-gray-400',
className,
)}
>
{status === 'loading' ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : null}
{handle}
</span>
);
}

88
src/hooks/useCrewNip05.ts Normal file
View file

@ -0,0 +1,88 @@
import { useQuery } from '@tanstack/react-query';
export type Nip05Status = 'none' | 'verified' | 'mismatch' | 'unreachable';
interface Nip05Result {
status: Nip05Status;
/** The pubkey the domain claims for this name, if any. */
resolvedPubkey?: string;
}
/** Basic `local@domain.tld` shape check. */
function parseNip05(nip05: string): { name: string; domain: string } | null {
const trimmed = nip05.trim().toLowerCase();
const at = trimmed.indexOf('@');
if (at <= 0) return null;
const name = trimmed.slice(0, at);
const domain = trimmed.slice(at + 1);
if (!/^[a-z0-9._-]+$/.test(name)) return null;
if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) return null;
return { name, domain };
}
async function fetchWellKnown(name: string, domain: string, signal: AbortSignal) {
const target = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`;
// Try direct first; fall back to the CORS proxy (many nostr.json hosts don't
// send permissive CORS headers).
const attempts = [
target,
`https://proxy.shakespeare.diy/?url=${encodeURIComponent(target)}`,
];
for (const url of attempts) {
try {
const res = await fetch(url, { signal, headers: { Accept: 'application/json' } });
if (!res.ok) continue;
const data = await res.json();
const names = data?.names;
if (names && typeof names === 'object') {
return names as Record<string, string>;
}
} catch {
// try next attempt
}
}
return null;
}
/**
* Verify a crew's optional NIP-05 identifier against its founder pubkey.
*
* The crew profile is authored by the founder, so a NIP-05 is legitimate only if
* the domain's `.well-known/nostr.json` maps the name to that same founder key.
* This works with ANY domain the crew controls (fully decentralized) — SkateSpots
* does not gatekeep the handle.
*
* States:
* - `verified`: domain maps the name to the founder pubkey. Trusted ✓
* - `mismatch`: domain maps the name to a DIFFERENT pubkey. Do not trust.
* - `unreachable`: the domain / nostr.json couldn't be fetched (network/CORS).
* - `none`: no NIP-05 set, or malformed.
*/
export function useCrewNip05(nip05: string | undefined, founderPubkey: string | undefined) {
return useQuery<Nip05Result>({
queryKey: ['crew-nip05', nip05, founderPubkey],
queryFn: async (c): Promise<Nip05Result> => {
if (!nip05 || !founderPubkey) return { status: 'none' };
const parsed = parseNip05(nip05);
if (!parsed) return { status: 'none' };
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
const names = await fetchWellKnown(parsed.name, parsed.domain, signal);
if (!names) return { status: 'unreachable' };
const resolved = names[parsed.name]?.toLowerCase();
if (!resolved) return { status: 'unreachable' };
if (resolved === founderPubkey.toLowerCase()) {
return { status: 'verified', resolvedPubkey: resolved };
}
return { status: 'mismatch', resolvedPubkey: resolved };
},
enabled: !!nip05 && !!founderPubkey,
staleTime: 10 * 60 * 1000, // 10 min
gcTime: 30 * 60 * 1000,
retry: 1,
});
}

71
src/hooks/useCrewRole.ts Normal file
View file

@ -0,0 +1,71 @@
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { SkateCrew } from '@/lib/types';
export type CrewRole = 'founder' | 'admin' | 'member' | 'none';
export interface CrewRoleInfo {
role: CrewRole;
isFounder: boolean;
/** Founder OR delegated admin — the set allowed elevated actions. */
isAdmin: boolean;
isMember: boolean;
/** Can invite/vouch members & moderate the crew feed/chat. */
canManage: boolean;
/** Can edit the crew's core identity (founder only — addressable event owner). */
canEditProfile: boolean;
}
/**
* Resolve the logged-in user's role within a crew.
*
* Governance model (see NIP.md):
* - **founder**: authored the crew's addressable event → sole owner of the
* crew identity. Only they can edit the profile (Nostr addressable events can
* only be overwritten by their author's key).
* - **admin**: a delegated, confirmed member the founder marked with an `admin`
* role. Admins act with their OWN keys to invite members and moderate the
* crew feed/chat, but cannot rewrite the profile.
* - **member**: a claimed member.
* - **none**: not part of the crew.
*
* Egalitarian shared-editing is intentionally unsupported: it would require a
* shared private key (insecure) or a consensus layer (non-interoperable).
*/
export function useCrewRole(crew: SkateCrew | null | undefined): CrewRoleInfo {
const { user } = useCurrentUser();
const pubkey = user?.pubkey;
if (!crew || !pubkey) {
return {
role: 'none',
isFounder: false,
isAdmin: false,
isMember: false,
canManage: false,
canEditProfile: false,
};
}
const isFounder = pubkey === crew.pubkey;
const isDelegatedAdmin = crew.admins.includes(pubkey);
const isMember = crew.members.includes(pubkey);
const role: CrewRole = isFounder
? 'founder'
: isDelegatedAdmin
? 'admin'
: isMember
? 'member'
: 'none';
const isAdmin = isFounder || isDelegatedAdmin;
return {
role,
isFounder,
isAdmin,
isMember,
canManage: isAdmin,
canEditProfile: isFounder,
};
}

View file

@ -85,6 +85,27 @@ export function parseMemberPubkeys(event: NostrEvent): string[] {
.map(([, value]) => value.toLowerCase());
}
/**
* Extract the admin pubkeys a collective delegates powers to.
*
* Admins are ordinary members whose `p` tag carries an `"admin"` role marker in
* the 4th position: `["p", <pubkey>, <relay-hint?>, "admin"]`. Using a marker on
* the standard `p` tag keeps admins visible to any client that reads members,
* while letting SkateSpots grant them elevated *actions* (invite, moderate).
*
* Note: admins are delegated *powers*, not edit rights — only the founder (the
* event author) can overwrite the addressable crew profile. Admins act with
* their own keys.
*/
export function parseAdminPubkeys(event: NostrEvent): string[] {
return event.tags
.filter(
([name, value, , marker]) =>
name === 'p' && /^[0-9a-f]{64}$/i.test(value ?? '') && marker === 'admin',
)
.map(([, value]) => value.toLowerCase());
}
/** All Lab provider kinds (The Lab service profiles). */
export const LAB_KINDS = [35001, 35002, 35003, 35004, 35005] as const;
@ -861,8 +882,10 @@ export interface SkateCrew {
instagram?: string;
social?: string; // First social link (backward compat)
socials: string[]; // All additional social links (`social` tags)
nip05?: string; // Crew's NIP-05 identifier (verified against founder pubkey)
noffer?: string; // Optional shared tip jar (CLINK)
members: string[]; // Member pubkeys (hex) via `p` tags
admins: string[]; // Delegated admin pubkeys (subset of members, `admin` marker)
spotRefs: string[]; // Home spots (kind 30818 refs)
labRefs: string[]; // Linked Lab profile coordinates (kind:pubkey:d)
createdAt: number;
@ -1387,7 +1410,10 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
const social = socials[0];
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined;
const nip05 = clampStr(event.tags.find(([name]) => name === 'nip05')?.[1], 100);
const members = parseMemberPubkeys(event);
// Admins must be a subset of claimed members.
const admins = parseAdminPubkeys(event).filter((pk) => members.includes(pk));
const spotRefs = event.tags
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
.map(([, value]) => value);
@ -1408,8 +1434,10 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
instagram,
social,
socials,
nip05,
noffer,
members,
admins,
spotRefs,
labRefs,
createdAt: event.created_at,

View file

@ -11,20 +11,21 @@ import { ZapButton } from '@/components/ZapButton';
import { ZapCrewDialog } from '@/components/ZapCrewDialog';
import { ClinkPayButton } from '@/components/ClinkPayButton';
import { CollectiveMembers } from '@/components/CollectiveMembers';
import { CrewNip05Badge } from '@/components/CrewNip05Badge';
import { CrewChat } from '@/components/CrewChat';
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
import { LinkedLabCard } from '@/components/CrossLinkCards';
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
import { useCrewRole } from '@/hooks/useCrewRole';
import { NoteContent } from '@/components/NoteContent';
import { useCrew } from '@/hooks/useCrews';
import { useCrewFeed } from '@/hooks/useCrewFeed';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { SKATE_CREW_KIND, SKATE_SPOT_KIND, CREW_VIBE_LABELS } from '@/lib/types';
import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
import NotFound from './NotFound';
import { Users, MapPin, ArrowLeft, Calendar, Globe, Instagram, Edit, MessageSquare, Zap } from 'lucide-react';
import { Users, MapPin, ArrowLeft, Calendar, Globe, Instagram, Edit, MessageSquare, Zap, ShieldCheck } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
function FeedItem({ event }: { event: NostrEvent }) {
@ -59,8 +60,8 @@ function FeedItem({ event }: { event: NostrEvent }) {
export default function CrewDetailPage() {
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
const { data: crew, isLoading, error } = useCrew(identifier || '', pubkey);
const { user } = useCurrentUser();
const author = useAuthor(crew?.pubkey);
const { isFounder, isAdmin, role } = useCrewRole(crew);
useSeoMeta({
title: crew ? `${crew.name} - SkateSpots Crew` : 'Crew - SkateSpots',
@ -109,7 +110,6 @@ export default function CrewDetailPage() {
const npub = nip19.npubEncode(crew.pubkey);
const founderName = author.data?.metadata?.name ?? genUserName(crew.pubkey);
const isFounder = user?.pubkey === crew.pubkey;
const hasMembers = crew.members.length > 0;
return (
@ -153,6 +153,7 @@ export default function CrewDetailPage() {
<Badge variant="outline" className="gap-1 text-teal-400 border-teal-500/40">
<Users className="h-3 w-3" /> Crew
</Badge>
<CrewNip05Badge nip05={crew.nip05} founderPubkey={crew.pubkey} />
</div>
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-400">
{crew.location && (
@ -167,13 +168,25 @@ export default function CrewDetailPage() {
)}
</div>
</div>
{isFounder && (
<Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}>
<Button variant="outline" size="sm" className="gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10">
<Edit className="h-3.5 w-3.5" /> Edit
</Button>
</Link>
)}
<div className="flex flex-col items-end gap-2">
{isFounder && (
<Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}>
<Button variant="outline" size="sm" className="gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10">
<Edit className="h-3.5 w-3.5" /> Edit
</Button>
</Link>
)}
{isAdmin && !isFounder && (
<Badge className="gap-1 bg-teal-500/20 text-teal-300 border-0">
<ShieldCheck className="h-3 w-3" /> You're an admin
</Badge>
)}
{role === 'member' && (
<Badge variant="outline" className="gap-1 border-teal-500/30 text-teal-400">
<Users className="h-3 w-3" /> Member
</Badge>
)}
</div>
</div>
{crew.bio && (
@ -309,6 +322,7 @@ export default function CrewDetailPage() {
founderPubkey={crew.pubkey}
identifier={crew.identifier}
claimedMembers={crew.members}
admins={crew.admins}
accentText="text-teal-400"
accentBorder="border-teal-500/40"
/>