feat(cordn): crew Cordn group link-out (Level 1)

Crews can carry a Cordn group ref (['cordn', <cordn1-ref-or-url>] -> SkateCrew.cordnGroup): CrewForm gains a 'Cordn group' field and CrewCoordinatorCard shows an 'Open crew chat' (cordn.net/chat/<ref>) + 'Copy for Staircase' row. Verified a real ContextVM client (@contextvm/sdk) connects + lists tools against our self-hosted coordinator.
This commit is contained in:
SkateSpots 2026-10-12 21:46:03 +07:00
parent 4ff5e25fee
commit 9251aea5b1
6 changed files with 84 additions and 1 deletions

View file

@ -2154,6 +2154,15 @@ The community relay moved off the hosted relay.tools instance
pubkey. Also fixed a status-badge bug: `STATUS_META` labels in
`CoordinatorSettingsCard` were missing the `socialUi.` prefix and rendered raw
keys (e.g. `coordinator.statusStale`).
**Verified (2026-10-12):** the reference `@contextvm/sdk` client (encrypted
ContextVM transport) connects to our coordinator and lists its tools (`kp_*`,
`welcome_*`, `join_request_*`, `msg_*`) — a real Cordn client works. The
**cordn.net web app does not** complete the handshake (connects + NIP-42-auths,
then sends no request; upstream issue drafted). **Crew Cordn group link
(Level 1, 2026-10-12):** a crew may carry `['cordn', <cordn1-ref-or-url>]`
(`SkateCrew.cordnGroup`); `CrewForm` has a "Cordn group" field and
`CrewCoordinatorCard` shows an "Open crew chat" + "Copy for Staircase" row.
In-app Cordn/MLS (Level 2) remains the big client build.
- **Cutover (2026-10-12):** `SKATE_RELAY` (`src/lib/types.ts`) → the new relay; the
default pools, the DM/CVM/zap relay arrays, the `cvm-server` + `push-service`
defaults, and the archive default all point at the new relay.

View file

@ -338,6 +338,7 @@ export function CrewForm({ existing }: CrewFormProps) {
// 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 [cordnGroupInput, setCordnGroupInput] = useState(existing?.cordnGroup ?? '');
const parsedCoordinator = parseCrewCoordinatorInput(coordinatorInput);
// On-demand health probe (the "Check" button) for a coordinator addressed by
// pubkey — mirrors the Settings → Coordinator card.
@ -615,6 +616,11 @@ export function CrewForm({ existing }: CrewFormProps) {
tags.push(coordinatorTag);
}
// Cordn group link for the crew's MLS chat (created in Staircase / Cordn web).
if (cordnGroupInput.trim()) {
tags.push(['cordn', cordnGroupInput.trim()]);
}
// Crew video / montage — only stored if it's a valid YouTube/Instagram URL.
const vid = videoUrl.trim();
if (vid && isCrewVideoUrl(vid)) {
@ -1225,6 +1231,19 @@ export function CrewForm({ existing }: CrewFormProps) {
{t('socialUi.coordinator.useMine')}
</Button>
)}
<div className="space-y-1.5">
<Label htmlFor="crew-cordn-group" className="text-xs text-gray-400">{t('socialUi.form.cordnGroup')}</Label>
<Input
id="crew-cordn-group"
placeholder="cordn1… · https://cordn.net/chat/…"
value={cordnGroupInput}
onChange={(e) => setCordnGroupInput(e.target.value)}
className="bg-gray-800 border-gray-700 font-mono text-xs"
spellCheck={false}
/>
<p className="text-[11px] text-gray-500">{t('socialUi.form.cordnGroupHint')}</p>
</div>
{parsedCoordinator?.pubkey && (
<div className="flex items-center gap-2">
<Button
@ -1454,7 +1473,7 @@ export function CrewForm({ existing }: CrewFormProps) {
const hasExtras =
!!videoUrl.trim() || !!sponsorRef.trim() || labRefs.length > 0 ||
socials.length > 0 || !!website.trim() || !!instagram.trim() ||
!!bandName.trim() || nip05s.length > 0 || agentEnabled || !!coordinatorInput.trim() ||
!!bandName.trim() || nip05s.length > 0 || agentEnabled || !!coordinatorInput.trim() || !!cordnGroupInput.trim() ||
(goalEnabled && !!goalTitle.trim());
const steps: { title: string; short: string; icon: typeof Users; hint: string; filled: boolean; content: ReactNode }[] = [

View file

@ -63,6 +63,7 @@ export function CrewCoordinatorCard({
<span className="font-bold text-teal-300">{t('socialUi.crewCoordinator.e2ee')}</span> {t('socialUi.crewCoordinator.defaultC')}
</p>
<StaircaseLink coordinator={SKATESPOTS_COORDINATOR} />
{crew.cordnGroup && <CordnGroupLink group={crew.cordnGroup} />}
{isFounder && (
<p className="text-[11px] leading-relaxed text-gray-600">
{t('socialUi.crewCoordinator.advancedA')}{' '}
@ -109,6 +110,7 @@ export function CrewCoordinatorCard({
relays: /^wss?:\/\//i.test(coordinator.url) ? [coordinator.url] : [],
}}
/>
{crew.cordnGroup && <CordnGroupLink group={crew.cordnGroup} />}
{endpoint && (
<p className="text-[11px] leading-relaxed text-gray-500">
@ -192,6 +194,48 @@ function StaircaseLink({ coordinator }: { coordinator: { pubkey?: string; relays
);
}
/** Link a crew to its Cordn group (created in Staircase / Cordn web) for real MLS chat. */
function CordnGroupLink({ group }: { group: string }) {
const { t } = useTranslation();
const { toast } = useToast();
const url = /^cordn1/i.test(group)
? `https://cordn.net/chat/${group}`
: /^https?:\/\//i.test(group)
? group
: null;
const copy = async () => {
try {
await navigator.clipboard.writeText(group);
toast({ title: t('common.copied') });
} catch {
toast({ title: t('common.tryAgain'), variant: 'destructive' });
}
};
const btn =
'inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-bold transition-colors';
return (
<div className="flex flex-wrap items-center gap-2">
{url && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className={`${btn} border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20`}
>
<ExternalLink className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.openCordnGroup')}
</a>
)}
<button
type="button"
onClick={() => void copy()}
className={`${btn} border-white/15 bg-white/5 text-gray-300 hover:bg-white/10`}
>
<Copy className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.copyForStaircase')}
</button>
</div>
);
}
function CoordinatorEndpoint({ coordinator }: { coordinator: CrewCoordinator }) {
const { t } = useTranslation();
const safe = sanitizeUrl(coordinator.url);

View file

@ -4046,6 +4046,7 @@ socialUi: {
manage: 'Manage coordinator',
open: 'Open {{domain}}',
openInCordnWeb: 'Cordn (web)',
openCordnGroup: 'Open crew chat',
copyForStaircase: 'Copy for Staircase',
runBy: 'Coordinator run by',
},
@ -4124,6 +4125,8 @@ socialUi: {
crewCreated: 'Crew created!',
crewUpdated: 'Crew updated!',
crewName: 'Crew Name',
cordnGroup: 'Cordn group',
cordnGroupHint: 'Optional: paste the group link from Staircase or Cordn web. Your crew then gets real end-to-end MLS chat, hosted on our coordinator.',
crewOnlyWord: 'Crew-only',
crewType: 'Crew Type',
crewTypeDescA: 'Is your crew focused on a craft? Pick one to also show up in that scene in',

View file

@ -4046,6 +4046,7 @@ socialUi: {
manage: 'Gestionar coordinador',
open: 'Abrir {{domain}}',
openInCordnWeb: 'Cordn (web)',
openCordnGroup: 'Abrir chat de la peña',
copyForStaircase: 'Copiar para Staircase',
runBy: 'Coordinador gestionado por',
},
@ -4124,6 +4125,8 @@ socialUi: {
crewCreated: '¡Peña creada!',
crewUpdated: '¡Peña actualizada!',
crewName: 'Nombre de la peña',
cordnGroup: 'Grupo de Cordn',
cordnGroupHint: 'Opcional: pega el enlace del grupo desde Staircase o Cordn web. Así tu peña tiene chat MLS cifrado de extremo a extremo, alojado en nuestro coordinador.',
crewOnlyWord: 'Solo peña',
crewType: 'Tipo de peña',
crewTypeDescA: '¿Tu peña se centra en un oficio? Elige uno para aparecer también en esa escena en',

View file

@ -2247,6 +2247,7 @@ export interface SkateCrew {
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
cordnGroup?: string; // Cordn group ref/link for the crew's MLS chat (from Staircase/Cordn web) (`cordn` tag)
videoUrl?: string; // Crew's headline video / montage — YouTube or Instagram URL (`video` tag)
crewRoles: CrewMemberRole[]; // Optional skate roles (filmer/photographer/…) assigned to members
createdAt: number;
@ -3266,6 +3267,9 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
}
: undefined;
// Crew video / montage — only kept if it parses as a YouTube/Instagram URL.
// Cordn group link for the crew's MLS chat (optional): ['cordn', <ref-or-url>].
const cordnGroup = event.tags.find(([name, value]) => name === 'cordn' && !!value?.trim())?.[1]?.trim() || undefined;
const videoRaw = event.tags.find(([name]) => name === 'video')?.[1]?.trim();
const videoUrl = videoRaw && parseCrewVideo(videoRaw) ? videoRaw : undefined;
// Skate roles (filmer/photographer/…) — only for people actually on the roster.
@ -3302,6 +3306,7 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
lookingFor,
sponsorRef,
coordinator,
cordnGroup,
videoUrl,
crewRoles,
createdAt: event.created_at,