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:
parent
4ff5e25fee
commit
9251aea5b1
6 changed files with 84 additions and 1 deletions
|
|
@ -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
|
pubkey. Also fixed a status-badge bug: `STATUS_META` labels in
|
||||||
`CoordinatorSettingsCard` were missing the `socialUi.` prefix and rendered raw
|
`CoordinatorSettingsCard` were missing the `socialUi.` prefix and rendered raw
|
||||||
keys (e.g. `coordinator.statusStale`).
|
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
|
- **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`
|
default pools, the DM/CVM/zap relay arrays, the `cvm-server` + `push-service`
|
||||||
defaults, and the archive default all point at the new relay.
|
defaults, and the archive default all point at the new relay.
|
||||||
|
|
|
||||||
|
|
@ -338,6 +338,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
// The coordinator the user set in Settings → Coordinator, for a one-tap prefill.
|
// The coordinator the user set in Settings → Coordinator, for a one-tap prefill.
|
||||||
const { coordinator: myCoordinator } = useCoordinator();
|
const { coordinator: myCoordinator } = useCoordinator();
|
||||||
// Live parse so the founder gets feedback (and the publish path reuses it).
|
// Live parse so the founder gets feedback (and the publish path reuses it).
|
||||||
|
const [cordnGroupInput, setCordnGroupInput] = useState(existing?.cordnGroup ?? '');
|
||||||
const parsedCoordinator = parseCrewCoordinatorInput(coordinatorInput);
|
const parsedCoordinator = parseCrewCoordinatorInput(coordinatorInput);
|
||||||
// On-demand health probe (the "Check" button) for a coordinator addressed by
|
// On-demand health probe (the "Check" button) for a coordinator addressed by
|
||||||
// pubkey — mirrors the Settings → Coordinator card.
|
// pubkey — mirrors the Settings → Coordinator card.
|
||||||
|
|
@ -615,6 +616,11 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
tags.push(coordinatorTag);
|
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.
|
// Crew video / montage — only stored if it's a valid YouTube/Instagram URL.
|
||||||
const vid = videoUrl.trim();
|
const vid = videoUrl.trim();
|
||||||
if (vid && isCrewVideoUrl(vid)) {
|
if (vid && isCrewVideoUrl(vid)) {
|
||||||
|
|
@ -1225,6 +1231,19 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
{t('socialUi.coordinator.useMine')}
|
{t('socialUi.coordinator.useMine')}
|
||||||
</Button>
|
</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 && (
|
{parsedCoordinator?.pubkey && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1454,7 +1473,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
const hasExtras =
|
const hasExtras =
|
||||||
!!videoUrl.trim() || !!sponsorRef.trim() || labRefs.length > 0 ||
|
!!videoUrl.trim() || !!sponsorRef.trim() || labRefs.length > 0 ||
|
||||||
socials.length > 0 || !!website.trim() || !!instagram.trim() ||
|
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());
|
(goalEnabled && !!goalTitle.trim());
|
||||||
|
|
||||||
const steps: { title: string; short: string; icon: typeof Users; hint: string; filled: boolean; content: ReactNode }[] = [
|
const steps: { title: string; short: string; icon: typeof Users; hint: string; filled: boolean; content: ReactNode }[] = [
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ export function CrewCoordinatorCard({
|
||||||
<span className="font-bold text-teal-300">{t('socialUi.crewCoordinator.e2ee')}</span> {t('socialUi.crewCoordinator.defaultC')}
|
<span className="font-bold text-teal-300">{t('socialUi.crewCoordinator.e2ee')}</span> {t('socialUi.crewCoordinator.defaultC')}
|
||||||
</p>
|
</p>
|
||||||
<StaircaseLink coordinator={SKATESPOTS_COORDINATOR} />
|
<StaircaseLink coordinator={SKATESPOTS_COORDINATOR} />
|
||||||
|
{crew.cordnGroup && <CordnGroupLink group={crew.cordnGroup} />}
|
||||||
{isFounder && (
|
{isFounder && (
|
||||||
<p className="text-[11px] leading-relaxed text-gray-600">
|
<p className="text-[11px] leading-relaxed text-gray-600">
|
||||||
{t('socialUi.crewCoordinator.advancedA')}{' '}
|
{t('socialUi.crewCoordinator.advancedA')}{' '}
|
||||||
|
|
@ -109,6 +110,7 @@ export function CrewCoordinatorCard({
|
||||||
relays: /^wss?:\/\//i.test(coordinator.url) ? [coordinator.url] : [],
|
relays: /^wss?:\/\//i.test(coordinator.url) ? [coordinator.url] : [],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{crew.cordnGroup && <CordnGroupLink group={crew.cordnGroup} />}
|
||||||
|
|
||||||
{endpoint && (
|
{endpoint && (
|
||||||
<p className="text-[11px] leading-relaxed text-gray-500">
|
<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 }) {
|
function CoordinatorEndpoint({ coordinator }: { coordinator: CrewCoordinator }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const safe = sanitizeUrl(coordinator.url);
|
const safe = sanitizeUrl(coordinator.url);
|
||||||
|
|
|
||||||
|
|
@ -4046,6 +4046,7 @@ socialUi: {
|
||||||
manage: 'Manage coordinator',
|
manage: 'Manage coordinator',
|
||||||
open: 'Open {{domain}}',
|
open: 'Open {{domain}}',
|
||||||
openInCordnWeb: 'Cordn (web)',
|
openInCordnWeb: 'Cordn (web)',
|
||||||
|
openCordnGroup: 'Open crew chat',
|
||||||
copyForStaircase: 'Copy for Staircase',
|
copyForStaircase: 'Copy for Staircase',
|
||||||
runBy: 'Coordinator run by',
|
runBy: 'Coordinator run by',
|
||||||
},
|
},
|
||||||
|
|
@ -4124,6 +4125,8 @@ socialUi: {
|
||||||
crewCreated: 'Crew created!',
|
crewCreated: 'Crew created!',
|
||||||
crewUpdated: 'Crew updated!',
|
crewUpdated: 'Crew updated!',
|
||||||
crewName: 'Crew Name',
|
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',
|
crewOnlyWord: 'Crew-only',
|
||||||
crewType: 'Crew Type',
|
crewType: 'Crew Type',
|
||||||
crewTypeDescA: 'Is your crew focused on a craft? Pick one to also show up in that scene in',
|
crewTypeDescA: 'Is your crew focused on a craft? Pick one to also show up in that scene in',
|
||||||
|
|
|
||||||
|
|
@ -4046,6 +4046,7 @@ socialUi: {
|
||||||
manage: 'Gestionar coordinador',
|
manage: 'Gestionar coordinador',
|
||||||
open: 'Abrir {{domain}}',
|
open: 'Abrir {{domain}}',
|
||||||
openInCordnWeb: 'Cordn (web)',
|
openInCordnWeb: 'Cordn (web)',
|
||||||
|
openCordnGroup: 'Abrir chat de la peña',
|
||||||
copyForStaircase: 'Copiar para Staircase',
|
copyForStaircase: 'Copiar para Staircase',
|
||||||
runBy: 'Coordinador gestionado por',
|
runBy: 'Coordinador gestionado por',
|
||||||
},
|
},
|
||||||
|
|
@ -4124,6 +4125,8 @@ socialUi: {
|
||||||
crewCreated: '¡Peña creada!',
|
crewCreated: '¡Peña creada!',
|
||||||
crewUpdated: '¡Peña actualizada!',
|
crewUpdated: '¡Peña actualizada!',
|
||||||
crewName: 'Nombre de la peña',
|
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',
|
crewOnlyWord: 'Solo peña',
|
||||||
crewType: 'Tipo de 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',
|
crewTypeDescA: '¿Tu peña se centra en un oficio? Elige uno para aparecer también en esa escena en',
|
||||||
|
|
|
||||||
|
|
@ -2247,6 +2247,7 @@ export interface SkateCrew {
|
||||||
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
|
sponsorRef?: string; // Sponsoring skate shop coordinate (30017:pubkey:d) via `sponsor` tag
|
||||||
coordinator?: CrewCoordinator; // Cordn/MLS encrypted-chat coordinator for this crew
|
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)
|
videoUrl?: string; // Crew's headline video / montage — YouTube or Instagram URL (`video` tag)
|
||||||
crewRoles: CrewMemberRole[]; // Optional skate roles (filmer/photographer/…) assigned to members
|
crewRoles: CrewMemberRole[]; // Optional skate roles (filmer/photographer/…) assigned to members
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
|
@ -3266,6 +3267,9 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
// Crew video / montage — only kept if it parses as a YouTube/Instagram URL.
|
// 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 videoRaw = event.tags.find(([name]) => name === 'video')?.[1]?.trim();
|
||||||
const videoUrl = videoRaw && parseCrewVideo(videoRaw) ? videoRaw : undefined;
|
const videoUrl = videoRaw && parseCrewVideo(videoRaw) ? videoRaw : undefined;
|
||||||
// Skate roles (filmer/photographer/…) — only for people actually on the roster.
|
// Skate roles (filmer/photographer/…) — only for people actually on the roster.
|
||||||
|
|
@ -3302,6 +3306,7 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
|
||||||
lookingFor,
|
lookingFor,
|
||||||
sponsorRef,
|
sponsorRef,
|
||||||
coordinator,
|
coordinator,
|
||||||
|
cordnGroup,
|
||||||
videoUrl,
|
videoUrl,
|
||||||
crewRoles,
|
crewRoles,
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue