Add crew roles & relationships (Phase 3: sponsor + Cordn coordinator)

- Extend SkateCrew with `sponsorRef` (sponsoring shop, NIP-15 stall coord) and
  `coordinator` (Cordn/MLS encrypted-chat Delivery Service pointer), plus parser.
- CrewSponsorBanner: "Sponsored by [Shop]" banner that resolves and links the
  backing shop; renders nothing if the shop can't be resolved.
- CrewCoordinatorCard: surfaces the crew's private-chat coordinator (E2E · MLS),
  explains what the coordinator can/can't see, shows the operator, and prompts
  the leader to set one up when absent — the UI foundation for Cordn chat.
- Wire the sponsor banner (under hero) and coordinator card (sidebar) into
  CrewDetailPage.
- CrewForm: add "Sponsored by" and "Private Chat Coordinator" inputs with
  validation; publish `sponsor` and `coordinator` tags.
- NIP.md: document the `sponsor` and `coordinator` tags, the structured invite
  kind 36229 (with accept/decline flow), and the Cordn/MLS private-chat model
  and why it's the chosen fit for crews.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-02 23:52:33 +07:00
parent 8a384c79d2
commit 4f3339650d
6 changed files with 389 additions and 6 deletions

82
NIP.md
View file

@ -2056,6 +2056,8 @@ bare `p` tag on the crew is only a claim until the member self-labels.
| `noffer` | Optional shared tip jar (CLINK Offer — see below) | | `noffer` | Optional shared tip jar (CLINK Offer — see below) |
| `goal` | Optional **crew treasury** — the event id of a NIP-75 Zap Goal (kind `9041`) the crew is raising funds for. See Crew Treasury below. | | `goal` | Optional **crew treasury** — the event id of a NIP-75 Zap Goal (kind `9041`) the crew is raising funds for. See Crew Treasury below. |
| `looking-for` | Optional **open call** — a Lab craft the crew is hiring for. Repeatable. Slugs: `filmer`, `photographer`, `editor`, `coach`, `builder`, `artist`, `musician`, `members`. Surfaces the crew's needs so matching Lab providers can reach out. | | `looking-for` | Optional **open call** — a Lab craft the crew is hiring for. Repeatable. Slugs: `filmer`, `photographer`, `editor`, `coach`, `builder`, `artist`, `musician`, `members`. Surfaces the crew's needs so matching Lab providers can reach out. |
| `sponsor` | Optional **sponsoring shop** — a NIP-15 stall coordinate (`30017:pubkey:d`) for a local skate shop that backs the crew. Renders a "Sponsored by [Shop]" banner linking to the shop. Only shown when the shop resolves. |
| `coordinator` | Optional **encrypted-chat coordinator** for the crew's private group. Shape: `["coordinator", <url>, <coordinatorPubkey?>, <operatorPubkey?>]`. `<url>` is an `http(s)`/`wss` endpoint of a **Cordn** coordinator (an MLS / RFC 9420 Delivery Service reached over ContextVM). The crew event stays public and discoverable; message content, membership secrets and MLS ratchets live client-side. Nothing crew-specific is published to public relays. `<coordinatorPubkey>` is the coordinator service pubkey (hex) for ContextVM addressing; `<operatorPubkey>` is the human (hex) who runs it, if delegated. |
### Crew-Hosted Events ### Crew-Hosted Events
@ -2187,13 +2189,83 @@ A client honors a label only when its **author is the crew founder or a listed
admin**, so outsiders can't forge moderation. Moderators still see hidden items admin**, so outsiders can't forge moderation. Moderators still see hidden items
(greyed, marked "Hidden") so the action is transparent and reversible. (greyed, marked "Hidden") so the action is transparent and reversible.
#### Invites #### Invites (kind `36229`) — structured, notifiable
Because only the founder can write the crew's `p` (member) list, an admin cannot Because only the founder can write the crew's `p` (member) list, an admin cannot
silently add members. Every moderator can **invite** — share a link/message to silently add members. A moderator (founder or admin) can send a **structured
the crew page where the invitee, once listed by the founder, confirms membership invite**: an addressable event (kind `36229`) that `p`-tags a specific invitee so
with their own key (the standard bilateral attestation above). Founders get an that person can query "invites addressed to me" and be **notified**, then decide
extra shortcut to add a member key directly in crew settings. Accept or Decline. The invite carries nothing sensitive — real membership stays
unforgeable (founder `p` tag + invitee attestation).
```json
{
"kind": 36229,
"content": "Come skate with us — the ledge crew wants you.",
"tags": [
["d", "35006:<founder>:<crewDtag>:<inviteePubkey>"],
["p", "<inviteePubkey>"],
["a", "35006:<founder>:<crewDtag>"],
["crew_name", "Barcelona Night Crew"],
["role", "member"],
["alt", "Crew invite: join Barcelona Night Crew on SkateSpots"]
]
}
```
| Tag | Description |
|-----|-------------|
| `d` | Deterministic id `<crewCoord>:<inviteePubkey>` — re-inviting the same person **replaces** the prior invite (addressable). |
| `p` | The invitee's pubkey (hex). Queryable at the relay level so the recipient can find invites addressed to them: `{ kinds: [36229], "#p": ["<me>"] }`. |
| `a` | The crew coordinate (`35006:pubkey:d`) being invited to. |
| `crew_name` | Readable crew name for rendering the notification without a second fetch. |
| `role` | `member` (default) or `admin` — the role proposed for the invitee. |
**Flow:**
1. **Send.** A moderator publishes the invite. When the sender is the **founder**,
they *optimistically pre-list* the invitee's `p` tag on the crew event, so the
moment the invitee accepts (attests) they are a **confirmed** member with no
extra founder step. An **admin** can send the invite but cannot edit the
founder's event; the founder confirms the add on accept.
2. **Notify.** The invitee's client queries `#p` invites and surfaces a
notification with **Accept** / **Decline**.
3. **Accept.** The invitee publishes their own NIP-32 membership attestation
(kind `1985`, as above). Since the invitee can't delete an invite they didn't
author, the invite is cleared from *their* notifications via a local dismissal.
4. **Decline.** Local dismissal only — no event required.
Founders also retain a shareable **link/message** invite for people not yet on
Nostr, and a shortcut to add a member key directly in crew settings.
#### Private Crew Chat (Cordn / MLS) — the `coordinator` tag
A crew's **public** identity (roster, spots, events, treasury, sponsor) lives on
Nostr as the kind-`35006` event above. A crew's **private** group chat is designed
to run over **Cordn** — an MLS ([RFC 9420](https://datatracker.ietf.org/doc/rfc9420/))
Delivery Service coordinator, **one per group**, reached over ContextVM.
A crew advertises its coordinator with the optional `coordinator` tag (see the
Optional Tags table). This is intentionally just a **pointer**:
- **On relays:** only the public crew event and a coordinator URL. No
protocol-specific messaging events (no per-message kinds) are ever published to
public relays, so there is nothing crew-specific to fingerprint or cluster.
- **Client-side / under MLS:** message content, the MLS member tree, epoch keys
and ratchets. Forward secrecy and post-compromise security hold; membership
changes are O(log n).
- **The coordinator** routes sealed ContextVM traffic and never sees message
content, client IPs, or (for routine sends using ephemeral transport keys) who
is talking. If it is unreachable, chat delivery stalls but the group, its
membership and its history on members' devices survive; groups can migrate
coordinators (per-group cursors preserve ordering).
Cordn is chosen over Marmot (fully decentralised MLS) and Concord (shared-key,
no forward secrecy) because its **coordinator-per-group** model mirrors a crew's
**leader / coordinator** structure, it is browser/TypeScript-native, and it keeps
the private chat un-fingerprintable next to an already-public, geolocated crew
identity. Until a crew configures a coordinator, the built-in NIP-17 group-DM
chat remains the fallback.
### NIP-05 Verification ### NIP-05 Verification

View file

@ -20,6 +20,8 @@ import { NofferField } from '@/components/NofferField';
import { import {
SKATE_CREW_KIND, SKATE_CREW_KIND,
SKATE_SPOT_KIND, SKATE_SPOT_KIND,
SKATE_SHOP_KIND,
parseAddrCoord,
ZAP_GOAL_KIND, ZAP_GOAL_KIND,
CREW_VIBE_LABELS, CREW_VIBE_LABELS,
CREW_FOCUS_LABELS, CREW_FOCUS_LABELS,
@ -34,7 +36,7 @@ import {
import { useCrewGoal } from '@/hooks/useCrewGoal'; import { useCrewGoal } from '@/hooks/useCrewGoal';
import { useAppContext } from '@/hooks/useAppContext'; import { useAppContext } from '@/hooks/useAppContext';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye } from 'lucide-react'; import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Lock } from 'lucide-react';
const FOCUS_ORDER: CrewFocus[] = ['mixed', 'builder', 'photo', 'coaching', 'art', 'music', 'wellness']; const FOCUS_ORDER: CrewFocus[] = ['mixed', 'builder', 'photo', 'coaching', 'art', 'music', 'wellness'];
const FOCUS_ICONS: Record<CrewFocus, typeof Users> = { const FOCUS_ICONS: Record<CrewFocus, typeof Users> = {
@ -168,6 +170,10 @@ export function CrewForm({ existing }: CrewFormProps) {
const toggleLookingFor = (slug: string) => const toggleLookingFor = (slug: string) =>
setLookingFor((prev) => (prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug])); setLookingFor((prev) => (prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug]));
// Sponsoring shop coordinate (30017:pubkey:d) and Cordn/MLS coordinator URL.
const [sponsorRef, setSponsorRef] = useState(existing?.sponsorRef ?? '');
const [coordinatorUrl, setCoordinatorUrl] = useState(existing?.coordinator?.url ?? '');
// Crew treasury (NIP-75 zap goal). Load existing goal to prefill when editing. // Crew treasury (NIP-75 zap goal). Load existing goal to prefill when editing.
const { data: existingGoal } = useCrewGoal(existing?.goalRef); const { data: existingGoal } = useCrewGoal(existing?.goalRef);
const { config } = useAppContext(); const { config } = useAppContext();
@ -386,6 +392,18 @@ export function CrewForm({ existing }: CrewFormProps) {
tags.push(['looking-for', slug]); tags.push(['looking-for', slug]);
} }
// Sponsoring shop — must be a valid NIP-15 stall coordinate (30017:pubkey:d).
const sponsorCoord = parseAddrCoord(sponsorRef.trim());
if (sponsorCoord && sponsorCoord.kind === SKATE_SHOP_KIND) {
tags.push(['sponsor', sponsorCoord.coord]);
}
// Cordn / MLS coordinator for the crew's encrypted chat (https/wss URL only).
const coordUrl = coordinatorUrl.trim();
if (coordUrl && /^(https?|wss?):\/\//i.test(coordUrl)) {
tags.push(['coordinator', coordUrl]);
}
await createEvent({ kind: SKATE_CREW_KIND, content: bio.trim(), tags }); await createEvent({ kind: SKATE_CREW_KIND, content: bio.trim(), tags });
toast({ title: existing ? 'Crew updated!' : 'Crew created!', description: 'Published to the scene.' }); toast({ title: existing ? 'Crew updated!' : 'Crew created!', description: 'Published to the scene.' });
@ -724,6 +742,44 @@ export function CrewForm({ existing }: CrewFormProps) {
{/* Linked Lab profiles (cross-link) */} {/* Linked Lab profiles (cross-link) */}
<LabLinkField value={labRefs} onChange={setLabRefs} /> <LabLinkField value={labRefs} onChange={setLabRefs} />
{/* Sponsor shop */}
<div className="space-y-2 rounded-lg border border-amber-500/30 bg-amber-500/[0.04] p-4">
<Label htmlFor="crew-sponsor" className="flex items-center gap-2 text-amber-400">
<Store className="h-4 w-4" /> Sponsored by <span className="font-normal text-gray-500">· optional</span>
</Label>
<p className="text-xs text-gray-500">
If a local shop backs your crew, paste their shop address here to show a
“Sponsored by” banner linking to them. Use the shop's coordinate:
<code className="text-amber-300"> 30017:pubkey:d</code>.
</p>
<Input
id="crew-sponsor"
placeholder="30017:pubkey:shop-id"
value={sponsorRef}
onChange={(e) => setSponsorRef(e.target.value)}
className="bg-gray-800 border-gray-700 font-mono text-xs"
/>
</div>
{/* Encrypted chat coordinator (Cordn / MLS) */}
<div className="space-y-2 rounded-lg border border-emerald-500/30 bg-emerald-500/[0.04] p-4">
<Label htmlFor="crew-coordinator" className="flex items-center gap-2 text-emerald-400">
<Lock className="h-4 w-4" /> Private Chat Coordinator <span className="font-normal text-gray-500">· optional</span>
</Label>
<p className="text-xs text-gray-500">
Point your crew at a <span className="font-semibold text-emerald-300">Cordn</span> coordinator
(an MLS delivery service reached over ContextVM) to enable end-to-end encrypted group chat.
Nothing crew-specific ever hits a public relay. Paste the coordinator's URL.
</p>
<Input
id="crew-coordinator"
placeholder="https://your-coordinator.example or wss://…"
value={coordinatorUrl}
onChange={(e) => setCoordinatorUrl(e.target.value)}
className="bg-gray-800 border-gray-700 font-mono text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="crew-location">Home Turf (City / Area)</Label> <Label htmlFor="crew-location">Home Turf (City / Area)</Label>

View file

@ -0,0 +1,142 @@
import { Link } from 'react-router-dom';
import { Lock, ShieldCheck, Server, ExternalLink, Settings } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
import type { CrewCoordinator, SkateCrew } from '@/lib/types';
import { nip19 } from 'nostr-tools';
/**
* Shows the crew's encrypted-chat coordinator (Cordn / MLS Delivery Service).
*
* A crew's PUBLIC identity lives on Nostr; its PRIVATE group chat is designed to
* run over Cordn — an MLS (RFC 9420) coordinator, one per group, reached over
* ContextVM. This card surfaces which coordinator the crew uses, explains what
* the coordinator can and cannot see, and (for the leader) prompts to set one
* when none is configured.
*/
export function CrewCoordinatorCard({
crew,
isFounder,
}: {
crew: SkateCrew;
isFounder: boolean;
}) {
const { coordinator } = crew;
if (!coordinator) {
// Only the leader sees the "set it up" prompt; others see nothing (keeps the
// page clean for crews that just use the built-in feed chat for now).
if (!isFounder) return null;
return (
<Card className="border-2 border-dashed border-emerald-500/25 bg-gradient-to-br from-emerald-500/[0.05] to-transparent">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
<Lock className="h-5 w-5 text-emerald-400" /> Private Crew Chat
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm leading-relaxed text-gray-400">
Turn on <span className="font-bold text-emerald-300">end-to-end encrypted</span> group chat
for your crew, powered by an MLS coordinator (Cordn). Forward secrecy, post-compromise
security, and nothing about your chat ever hits a public relay.
</p>
<Link
to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}
className="inline-flex items-center gap-1.5 rounded-xl border border-emerald-500/40 bg-emerald-500/10 px-3 py-2 text-sm font-bold text-emerald-300 transition-colors hover:bg-emerald-500/20"
>
<Settings className="h-4 w-4" /> Set up a coordinator
</Link>
</CardContent>
</Card>
);
}
return (
<Card className="border-2 border-emerald-500/30 bg-gradient-to-br from-emerald-500/[0.08] to-black/90">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
<Lock className="h-5 w-5 text-emerald-400" /> Private Crew Chat
<span className="ml-auto inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2 py-0.5 text-[10px] font-black uppercase tracking-wide text-emerald-300">
<ShieldCheck className="h-3 w-3" /> E2E · MLS
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<CoordinatorEndpoint coordinator={coordinator} />
{coordinator.operator && <CoordinatorOperator pubkey={coordinator.operator} />}
<div className="rounded-xl border border-white/5 bg-black/40 p-3">
<p className="text-[11px] leading-relaxed text-gray-500">
Messages are end-to-end encrypted with MLS ratchets. The coordinator only routes sealed
traffic over ContextVM — it never sees message content, your IP, or who is talking, and
nothing crew-specific is published to public relays.
</p>
</div>
{isFounder && (
<Link
to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}
className="inline-flex items-center gap-1.5 text-xs font-bold text-emerald-400 hover:text-emerald-300"
>
<Settings className="h-3.5 w-3.5" /> Manage coordinator
</Link>
)}
</CardContent>
</Card>
);
}
function CoordinatorEndpoint({ coordinator }: { coordinator: CrewCoordinator }) {
const safe = sanitizeUrl(coordinator.url);
return (
<div className="flex items-center gap-3 rounded-xl border border-emerald-500/20 bg-black/40 p-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-emerald-500/15">
<Server className="h-5 w-5 text-emerald-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[10px] font-black uppercase tracking-widest text-emerald-400/80">
Coordinator
</p>
<p className="truncate text-sm font-bold text-white">{urlDomain(coordinator.url)}</p>
</div>
{safe && (
<a
href={safe}
target="_blank"
rel="noopener noreferrer"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-white/10 text-gray-400 transition-colors hover:border-emerald-500/40 hover:text-emerald-300"
title={`Open ${urlDomain(coordinator.url)}`}
>
<ExternalLink className="h-4 w-4" />
</a>
)}
</div>
);
}
function CoordinatorOperator({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const name = author.data?.metadata?.name ?? genUserName(pubkey);
const pic = author.data?.metadata?.picture;
const npub = nip19.npubEncode(pubkey);
return (
<Link
to={`/${npub}`}
className="flex items-center gap-2.5 rounded-xl bg-black/40 p-2.5 transition-colors hover:bg-black/60"
>
<Avatar className="h-8 w-8">
<AvatarImage src={pic} alt={name} />
<AvatarFallback className="text-[10px]">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">Coordinator run by</p>
<p className="truncate text-sm font-bold text-gray-200">{name}</p>
</div>
</Link>
);
}

View file

@ -0,0 +1,62 @@
import { Link } from 'react-router-dom';
import { Store, ChevronRight, Loader2, BadgeCheck } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useSkateShop } from '@/hooks/useSkateShops';
import { parseAddrCoord } from '@/lib/types';
/**
* "Sponsored by [Shop]" banner for a crew backed by a local skate shop.
*
* Renders nothing if the crew has no sponsor, or the referenced shop can't be
* resolved — a sponsor should be a real, linkable shop (NIP-15 stall), not a
* dangling claim.
*/
export function CrewSponsorBanner({ sponsorRef }: { sponsorRef: string | undefined }) {
const coord = sponsorRef ? parseAddrCoord(sponsorRef) : null;
const { data: shop, isLoading } = useSkateShop(coord?.pubkey, coord?.identifier);
if (!coord) return null;
if (isLoading) {
return (
<div className="flex items-center gap-3 rounded-2xl border border-amber-500/25 bg-amber-500/[0.05] px-4 py-3">
<Loader2 className="h-4 w-4 animate-spin text-amber-400" />
<span className="text-sm text-gray-400">Loading sponsor…</span>
</div>
);
}
if (!shop) return null;
const logo = shop.images?.[0];
return (
<Link
to={`/shop/${coord.pubkey}/${coord.identifier}`}
className="group relative flex items-center gap-3 overflow-hidden rounded-2xl border border-amber-500/30 bg-gradient-to-r from-amber-500/[0.12] via-amber-500/[0.04] to-transparent px-4 py-3 transition-all hover:border-amber-500/50 active:scale-[0.99]"
>
<div
aria-hidden
className="pointer-events-none absolute -left-10 -top-10 h-32 w-32 rounded-full bg-amber-500/20 blur-2xl"
/>
<Avatar className="relative h-12 w-12 rounded-xl border-2 border-amber-500/40">
<AvatarImage src={logo} alt={shop.name} className="object-cover" />
<AvatarFallback className="rounded-xl bg-amber-500/15 text-amber-300">
<Store className="h-5 w-5" />
</AvatarFallback>
</Avatar>
<div className="relative min-w-0 flex-1">
<p className="flex items-center gap-1 text-[10px] font-black uppercase tracking-widest text-amber-400/80">
<BadgeCheck className="h-3 w-3" /> Sponsored by
</p>
<p className="truncate text-base font-black text-white">{shop.name}</p>
{shop.location && (
<p className="truncate text-xs text-gray-400">{shop.location}</p>
)}
</div>
<ChevronRight className="relative h-5 w-5 shrink-0 text-amber-400/60 transition-transform group-hover:translate-x-0.5 group-hover:text-amber-400" />
</Link>
);
}

View file

@ -1611,10 +1611,37 @@ export interface SkateCrew {
labRefs: string[]; // Linked Lab profile coordinates (kind:pubkey:d) labRefs: string[]; // Linked Lab profile coordinates (kind:pubkey:d)
goalRef?: string; // NIP-75 zap goal event id (crew funding goal / treasury) goalRef?: string; // NIP-75 zap goal event id (crew funding goal / treasury)
lookingFor: string[]; // Lab craft slugs the crew is hiring for (e.g. "filmer", "editor") lookingFor: string[]; // Lab craft slugs the crew is hiring for (e.g. "filmer", "editor")
sponsorRef?: string; // Sponsoring skate shop coordinate (30017:pubkey:d) via `sponsor` tag
coordinator?: CrewCoordinator; // Cordn/MLS encrypted-chat coordinator for this crew
createdAt: number; createdAt: number;
event: NostrEvent; event: NostrEvent;
} }
/**
* A crew's encrypted-group coordinator (Cordn / MLS Delivery Service).
*
* SkateSpots crews keep their PUBLIC identity on Nostr (roster, spots, events),
* but the crew's private group chat is designed to run over Cordn — an MLS
* (RFC 9420) delivery-service coordinator, one per group, reached over ContextVM.
* A crew can advertise which coordinator it uses so members' clients know where
* to publish KeyPackages and fetch group messages.
*
* This is intentionally just a pointer: the crew event stays public and
* discoverable, while message content, membership secrets and ratchets live
* client-side under MLS. See the "Marmot / Concord / Cordn" analysis — Cordn is
* the chosen fit because its coordinator-per-group model mirrors a crew's
* leader/coordinator structure, it is browser/TypeScript-native, and it leaks no
* protocol-specific events onto public relays.
*/
export interface CrewCoordinator {
/** ContextVM server / coordinator endpoint (https or wss URL). */
url: string;
/** Optional coordinator service pubkey (hex) for ContextVM addressing. */
pubkey?: string;
/** Human pubkey (hex) responsible for running/operating the coordinator, if delegated. */
operator?: string;
}
export interface SkateBuilder { export interface SkateBuilder {
id: string; id: string;
pubkey: string; pubkey: string;
@ -2401,6 +2428,20 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
const lookingFor = event.tags const lookingFor = event.tags
.filter(([name, value]) => name === 'looking-for' && !!value?.trim()) .filter(([name, value]) => name === 'looking-for' && !!value?.trim())
.map(([, value]) => value.trim().toLowerCase()); .map(([, value]) => value.trim().toLowerCase());
// Sponsoring shop — a NIP-15 stall coordinate (30017:pubkey:d).
const sponsorRef = event.tags.find(
([name, value]) => name === 'sponsor' && value?.startsWith(`${SKATE_SHOP_KIND}:`),
)?.[1];
// Cordn / MLS coordinator for the crew's encrypted group chat.
// Tag shape: ['coordinator', <url>, <coordinator-pubkey?>, <operator-pubkey?>]
const coordinatorTag = event.tags.find(([name, value]) => name === 'coordinator' && !!value?.trim());
const coordinator: CrewCoordinator | undefined = coordinatorTag
? {
url: coordinatorTag[1].trim(),
pubkey: /^[0-9a-f]{64}$/i.test(coordinatorTag[2] ?? '') ? coordinatorTag[2] : undefined,
operator: /^[0-9a-f]{64}$/i.test(coordinatorTag[3] ?? '') ? coordinatorTag[3] : undefined,
}
: undefined;
return { return {
id: event.id, id: event.id,
@ -2429,6 +2470,8 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
labRefs, labRefs,
goalRef, goalRef,
lookingFor, lookingFor,
sponsorRef,
coordinator,
createdAt: event.created_at, createdAt: event.created_at,
event, event,
}; };

View file

@ -14,6 +14,8 @@ import { ClinkPayButton } from '@/components/ClinkPayButton';
import { CollectiveMembers } from '@/components/CollectiveMembers'; import { CollectiveMembers } from '@/components/CollectiveMembers';
import { CrewHero } from '@/components/crew/CrewHero'; import { CrewHero } from '@/components/crew/CrewHero';
import { CrewLeadership } from '@/components/crew/CrewLeadership'; import { CrewLeadership } from '@/components/crew/CrewLeadership';
import { CrewSponsorBanner } from '@/components/crew/CrewSponsorBanner';
import { CrewCoordinatorCard } from '@/components/crew/CrewCoordinatorCard';
import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity'; import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity';
import { CrewChat } from '@/components/CrewChat'; import { CrewChat } from '@/components/CrewChat';
import { CrewAgentPanel } from '@/components/CrewAgentPanel'; import { CrewAgentPanel } from '@/components/CrewAgentPanel';
@ -295,6 +297,9 @@ export default function CrewDetailPage() {
} }
/> />
{/* Sponsor — a local shop backing the crew */}
{crew.sponsorRef && <CrewSponsorBanner sponsorRef={crew.sponsorRef} />}
{/* Open call — looking for filmers/editors/members (Lab pipeline) */} {/* Open call — looking for filmers/editors/members (Lab pipeline) */}
<CrewLookingForCard lookingFor={crew.lookingFor} /> <CrewLookingForCard lookingFor={crew.lookingFor} />
@ -380,6 +385,9 @@ export default function CrewDetailPage() {
{/* Leadership — team leader (founder) + captains (admins) */} {/* Leadership — team leader (founder) + captains (admins) */}
<CrewLeadership crew={crew} confirmedAdmins={confirmedAdmins} /> <CrewLeadership crew={crew} confirmedAdmins={confirmedAdmins} />
{/* Private encrypted chat coordinator (Cordn / MLS) */}
<CrewCoordinatorCard crew={crew} isFounder={isFounder} />
{/* Members (with per-member visibility respected) */} {/* Members (with per-member visibility respected) */}
{hasMembers && ( {hasMembers && (
<CollectiveMembers <CollectiveMembers