diff --git a/AGENTS.md b/AGENTS.md index f7301ef..c5964af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -595,8 +595,22 @@ session with **no other memory of this project**. So: (link to Cordn + copy Docker + scan its QR), never an embedded runtime — the phone-hosting story belongs to Cordn's own app. ⚠️ `CORDN_APP_URL` currently points at `cordn.net`; the exact Android-app deep link is unconfirmed — fill - it in when known. **Not built (future):** publishing the coordinator on the - profile (discovery) and crew-level coordinators. + it in when known. + **⚠️ Crew-level Cordn chat ALREADY EXISTS — do NOT rebuild it + (2026-09-29):** `src/lib/cordn/{client,crypto,endpoint,types}.ts` + + `useCordnChat.ts` + `CrewChat.tsx` + `crew/CrewCoordinatorCard.tsx`, driven by + a crew's **`coordinator` tag** `['coordinator', , , + ]` (`SkateCrew.coordinator`, parsed in `types.ts`, + `resolveCordnEndpoint()` → `CordnEndpoint`), documented in NIP.md ("Private + Crew Chat (Cordn / MLS)"). Every crew already has built-in NIP-17 E2E chat; the + tag *upgrades* it to sealed NIP-44 over ContextVM. **The only gap bridged this + session:** the crew form accepted a URL only, so a coordinator run from + Cordn's phone app (an nprofile, not a URL) was un-enterable — `CrewForm` now + uses the shared `parseCrewCoordinatorInput` / `crewCoordinatorInputValue` + (`lib/coordinator.ts`) and has a **"Use my coordinator"** button that prefills + from Settings → Coordinator. **Still future:** profile discovery, and real MLS + (`ts-mls`) instead of group-key crypto. **Lesson: grep `src` for the feature + before building it — this was nearly duplicated.** - **Empty-map nudge (2026-09-28):** `MapPage` distinguishes the two empties — the existing "No spots match" card is for *filters* (`filteredSpots.length === 0 && allLocations.length > 0`) and a new "No spots mapped yet — be the first" card diff --git a/NIP.md b/NIP.md index 4a1a989..cc8e065 100644 --- a/NIP.md +++ b/NIP.md @@ -2241,7 +2241,7 @@ bare `p` tag on the crew is only a claim until the member self-labels. | `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. | | `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, **advanced/self-host**: routes the crew's private chat through a founder-run **Cordn** coordinator instead of the built-in NIP-17 chat. Shape: `["coordinator", , , ]`. `` is an `http(s)`/`wss` endpoint of a Cordn coordinator (a Delivery Service reached over ContextVM). The crew event stays public and discoverable; message content and keys live client-side; nothing crew-specific is published to public relays. `` is the coordinator service pubkey (hex) for ContextVM addressing; `` is the human (hex) who runs it, if delegated. Not required — every crew has E2E chat without it. | +| `coordinator` | Optional, **advanced/self-host**: routes the crew's private chat through a founder-run **Cordn** coordinator instead of the built-in NIP-17 chat. Shape: `["coordinator", , , ]`. `` is an `http(s)`/`wss` endpoint of a Cordn coordinator (a Delivery Service reached over ContextVM). The crew event stays public and discoverable; message content and keys live client-side; nothing crew-specific is published to public relays. `` is the coordinator service pubkey (hex) for ContextVM addressing; `` is the human (hex) who runs it, if delegated. Not required — every crew has E2E chat without it. Clients that accept a pasted pointer **MUST** also accept the formats a coordinator run from Cordn's own app yields: an `nprofile`, or a `https://cordn.net/chat/coordinators?c=` link — deriving `` from the nprofile's relay hints (falling back to the ContextVM relay) and `` from its pubkey. A plain `http(s)`/`wss` endpoint remains valid. | | `video` | Optional **crew video / montage** ("The Part") — a **YouTube** or **Instagram** URL, rendered as the crew's headline embed. Only these two sources are accepted (skate parts live on YouTube, clips on Instagram); any other URL is ignored. No native/self-hosted video. Clients must parse it to a known embed (YouTube video id / Instagram shortcode) and reject anything else rather than render an untrusted iframe. | | `crew-role` | Optional **skate role** assigned to a roster member. Shape: `["crew-role", , ]`, repeatable (a member may hold several). `` ∈ the full Lab-craft set — `filmer`, `photographer`, `editor`, `coach`, `builder`, `artist`, `graffiti`, `tattoo`, `trainer`, `musician`, `judge` — plus the crew-specific `manager` and `og`. The Lab-craft roles mirror the Lab profession kinds (35001–35012), so a crew's "filmer" speaks the same language as a Lab Filmer profile. These are the *functions* on a skate team (the filmer is as vital as the skaters) and are purely a **flex/label** — they grant **no powers** (only the `admin` marker does). Only honoured for the founder or a listed member; roles on non-roster pubkeys are ignored. | diff --git a/src/components/CrewForm.tsx b/src/components/CrewForm.tsx index 5eeed5f..1fa000e 100644 --- a/src/components/CrewForm.tsx +++ b/src/components/CrewForm.tsx @@ -22,6 +22,8 @@ import { CrewSponsorPicker } from '@/components/crew/CrewSponsorPicker'; import { NofferField } from '@/components/NofferField'; import { SKATE_CREW_KIND, SKATE_SPOT_KIND, SKATE_SHOP_KIND, parseAddrCoord, ZAP_GOAL_KIND, CREW_VIBE_LABELS, CREW_FOCUS_LABELS, CREW_FOCUS_DESCRIPTIONS, CREW_FOCUS_TAG, CREW_LOOKING_FOR_OPTIONS, CREW_ROLE_META, CREW_ROLE_ORDER, SHOW_CREW_TREASURY, isValidNoffer, type SkateCrew, type CrewFocus, type CrewRole } from '@/lib/types'; import { useCrewGoal } from '@/hooks/useCrewGoal'; +import { useCoordinator } from '@/hooks/useCoordinator'; +import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, shortNpub } from '@/lib/coordinator'; import { useRelays } from '@/lib/relays'; import { cn } from '@/lib/utils'; import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon, Server } from 'lucide-react'; @@ -278,9 +280,15 @@ export function CrewForm({ existing }: CrewFormProps) { const toggleLookingFor = (slug: string) => setLookingFor((prev) => (prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug])); - // Sponsoring shop coordinate (30017:pubkey:d) and Cordn/MLS coordinator URL. + // Sponsoring shop coordinate (30017:pubkey:d) and Cordn/MLS coordinator pointer. const [sponsorRef, setSponsorRef] = useState(existing?.sponsorRef ?? ''); - const [coordinatorUrl, setCoordinatorUrl] = useState(existing?.coordinator?.url ?? ''); + const [coordinatorInput, setCoordinatorInput] = useState( + crewCoordinatorInputValue(existing?.coordinator), + ); + // The coordinator the user set in Settings → Coordinator, for a one-tap prefill. + const { coordinator: myCoordinator } = useCoordinator(); + // Live parse so the founder gets feedback (and the publish path reuses it). + const parsedCoordinator = parseCrewCoordinatorInput(coordinatorInput); // Crew video / montage — YouTube or Instagram URL ("The Part"). const [videoUrl, setVideoUrl] = useState(existing?.videoUrl ?? ''); @@ -513,10 +521,14 @@ export function CrewForm({ existing }: CrewFormProps) { 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]); + // Cordn / MLS coordinator for the crew's encrypted chat. Accepts the same + // pointer formats as Settings → Coordinator (nprofile / cordn.net link / + // npub / hex) as well as a plain endpoint URL, so a coordinator run from + // Cordn's phone app works too. Tag: ['coordinator', url, pubkey?]. + if (parsedCoordinator) { + const coordinatorTag = ['coordinator', parsedCoordinator.url]; + if (parsedCoordinator.pubkey) coordinatorTag.push(parsedCoordinator.pubkey); + tags.push(coordinatorTag); } // Crew video / montage — only stored if it's a valid YouTube/Instagram URL. @@ -1088,16 +1100,40 @@ export function CrewForm({ existing }: CrewFormProps) { > Cordn coordinator {' '} - (a small self-hosted MLS delivery service). Paste its URL to route chat through it instead. Leave blank - to use the built-in chat. + — even on an old phone. Paste its nprofile, a cordn.net link, or an + endpoint URL. Leave blank to use the built-in chat.

setCoordinatorUrl(e.target.value)} + placeholder="nprofile1… · cordn.net/chat/coordinators?c=… · https://…" + value={coordinatorInput} + onChange={(e) => setCoordinatorInput(e.target.value)} className="bg-gray-800 border-gray-700 font-mono text-xs" + spellCheck={false} /> + {coordinatorInput.trim() !== '' && + (parsedCoordinator ? ( +

+ ✓ {parsedCoordinator.pubkey ? `${shortNpub(parsedCoordinator.pubkey)} · ` : ''} + {parsedCoordinator.url} +

+ ) : ( +

+ That doesn't look like a coordinator. Paste an nprofile, a cordn.net link, or an https/wss URL. +

+ ))} + {myCoordinator && ( + + )} ); @@ -1284,7 +1320,7 @@ export function CrewForm({ existing }: CrewFormProps) { const hasExtras = !!videoUrl.trim() || !!sponsorRef.trim() || labRefs.length > 0 || socials.length > 0 || !!website.trim() || !!instagram.trim() || - !!bandName.trim() || !!nip05.trim() || agentEnabled || !!coordinatorUrl.trim() || + !!bandName.trim() || !!nip05.trim() || agentEnabled || !!coordinatorInput.trim() || (goalEnabled && !!goalTitle.trim()); const steps: { title: string; short: string; icon: typeof Users; hint: string; filled: boolean; content: ReactNode }[] = [ diff --git a/src/lib/coordinator.ts b/src/lib/coordinator.ts index 4af5861..512e495 100644 --- a/src/lib/coordinator.ts +++ b/src/lib/coordinator.ts @@ -98,6 +98,44 @@ export function shortNpub(pubkey: string): string { } } +/** A crew coordinator as stored on the crew event: `['coordinator', url, pubkey?, operator?]`. */ +export interface CrewCoordinatorPointer { + /** Endpoint: a `wss://` ContextVM relay, or an http(s) coordinator URL. */ + url: string; + /** Coordinator service pubkey (hex), when addressed by identity. */ + pubkey?: string; +} + +/** + * Parse what a crew founder pastes into the crew's coordinator field. Accepts + * everything Settings → Coordinator does (nprofile / npub / hex / cordn.net + * link) **plus** a plain endpoint URL — so a coordinator run from Cordn's phone + * app (which yields an nprofile, not a URL) is enterable, not just a + * self-hosted endpoint. + */ +export function parseCrewCoordinatorInput(raw: string): CrewCoordinatorPointer | null { + const input = raw.trim(); + if (!input) return null; + + const pointer = parseCoordinatorInput(input); + if (pointer) { + return { url: pointer.relays[0] ?? CORDN_RELAY_URL, pubkey: pointer.pubkey }; + } + if (/^(https?|wss?):\/\//i.test(input)) return { url: input }; + return null; +} + +/** Rebuild a paste-able input from a stored crew coordinator (round-trips). */ +export function crewCoordinatorInputValue( + coordinator: { url: string; pubkey?: string } | undefined, +): string { + if (!coordinator?.url) return ''; + if (coordinator.pubkey && /^wss?:\/\//i.test(coordinator.url)) { + return nip19.nprofileEncode({ pubkey: coordinator.pubkey, relays: [coordinator.url] }); + } + return coordinator.url; +} + /** * Best-effort coordinator display name from an announcement event. Cordn relies * on the ContextVM transport announcement, whose exact shape we don't hard-code