feat(crew): accept a phone-run Cordn coordinator in the crew form
Crew-level Cordn chat already existed (lib/cordn, useCordnChat, CrewChat, CrewCoordinatorCard, the crew 'coordinator' tag). The real gap: the crew form accepted a URL only, so a coordinator run from Cordn's phone app — which yields an nprofile, not a URL — could not be entered at all. - lib/coordinator.ts: parseCrewCoordinatorInput / crewCoordinatorInputValue — the same pointer formats as Settings -> Coordinator (nprofile / cordn.net link / npub / hex) plus a plain endpoint URL. - CrewForm: parses the pointer, shows live valid/invalid feedback, sets ['coordinator', url, pubkey?] (schema unchanged), and gains a one-tap 'Use my coordinator' prefill from Settings. - NIP.md: note the accepted pointer formats on the coordinator tag. - AGENTS.md: record that crew-level Cordn chat already exists (the lesson: grep src before building).
This commit is contained in:
parent
7b33d544b1
commit
ee380a5c74
4 changed files with 103 additions and 15 deletions
18
AGENTS.md
18
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', <url>, <coordinatorPubkey?>,
|
||||
<operatorPubkey?>]` (`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
|
||||
|
|
|
|||
2
NIP.md
2
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", <url>, <coordinatorPubkey?>, <operatorPubkey?>]`. `<url>` 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. `<coordinatorPubkey>` is the coordinator service pubkey (hex) for ContextVM addressing; `<operatorPubkey>` 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", <url>, <coordinatorPubkey?>, <operatorPubkey?>]`. `<url>` 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. `<coordinatorPubkey>` is the coordinator service pubkey (hex) for ContextVM addressing; `<operatorPubkey>` 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=<nprofile>` link — deriving `<url>` from the nprofile's relay hints (falling back to the ContextVM relay) and `<coordinatorPubkey>` 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", <memberPubkey>, <role>]`, repeatable (a member may hold several). `<role>` ∈ 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. |
|
||||
|
||||
|
|
|
|||
|
|
@ -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>{' '}
|
||||
(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 <span className="font-mono">nprofile</span>, a cordn.net link, or an
|
||||
endpoint URL. Leave blank to use the built-in chat.
|
||||
</p>
|
||||
<Input
|
||||
id="crew-coordinator"
|
||||
placeholder="https://your-coordinator.example or wss://…"
|
||||
value={coordinatorUrl}
|
||||
onChange={(e) => 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 ? (
|
||||
<p className="break-all text-[11px] text-teal-400">
|
||||
✓ {parsedCoordinator.pubkey ? `${shortNpub(parsedCoordinator.pubkey)} · ` : ''}
|
||||
{parsedCoordinator.url}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-amber-400">
|
||||
That doesn't look like a coordinator. Paste an nprofile, a cordn.net link, or an https/wss URL.
|
||||
</p>
|
||||
))}
|
||||
{myCoordinator && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-teal-500/40 text-teal-300 hover:bg-teal-500/10"
|
||||
onClick={() => setCoordinatorInput(coordinatorAddUrl(myCoordinator))}
|
||||
>
|
||||
<Server className="mr-1.5 h-3.5 w-3.5" />
|
||||
Use my coordinator
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -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 }[] = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue