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:
parent
8a384c79d2
commit
4f3339650d
6 changed files with 389 additions and 6 deletions
82
NIP.md
82
NIP.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
142
src/components/crew/CrewCoordinatorCard.tsx
Normal file
142
src/components/crew/CrewCoordinatorCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/components/crew/CrewSponsorBanner.tsx
Normal file
62
src/components/crew/CrewSponsorBanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue