From ae2a1c70b8aa6c5733a98fe70b4664251783950d Mon Sep 17 00:00:00 2001
From: SkateSpots
Date: Tue, 29 Sep 2026 16:17:16 +0700
Subject: [PATCH] =?UTF-8?q?feat(coordinator):=20profile=20discovery=20?=
=?UTF-8?q?=E2=80=94=20publish=20yours,=20add=20theirs=20in=20one=20tap?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Carrier is the existing kind-0 'cordn' field (app convention, NIP.md) — no new
kind. It now holds a coordinator *pointer* rather than only a URL.
- EditProfileForm: accepts nprofile / cordn.net link / npub / hex / http URL,
validates via parseCoordinatorInput, normalises a bare pointer to Cordn's
canonical add-link on save, and adds 'Use my coordinator'. Label -> Coordinator.
- NIP19Page: when the profile's cordn value parses as a pointer, render a
Coordinator row with a one-tap Add (sets the viewer's coordinator, with toast)
and an 'Open in Cordn' link; otherwise keep the old plain-link row.
- NIP.md: document the broadened cordn field (pointer formats + one-tap add).
- smoke test: now boots a real profile route too.
---
AGENTS.md | 10 +++++-
NIP.md | 2 +-
scripts/smoke-test.mjs | 1 +
src/components/EditProfileForm.tsx | 41 ++++++++++++++++++------
src/pages/NIP19Page.tsx | 50 +++++++++++++++++++++++++++---
5 files changed, 89 insertions(+), 15 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index c5964af..0870ca6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -596,6 +596,14 @@ session with **no other memory of this project**. So:
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.
+ **Profile discovery (built 2026-09-29):** the carrier is the existing kind-0
+ **`cordn`** field (app convention, documented in NIP.md — not a new kind).
+ `EditProfileForm` accepts a pointer (nprofile / cordn.net link / npub / hex /
+ http URL), **normalises a bare pointer to Cordn's canonical add-link** before
+ saving (`coordinatorAddUrl`), and has **"Use my coordinator"**. `NIP19Page`
+ renders it as a **Coordinator** row with a one-tap **Add** (sets the viewer's
+ `useCoordinator`, with toast) plus an "Open in Cordn" link — falling back to
+ the old plain-link row when the value doesn't parse.
**⚠️ 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
@@ -608,7 +616,7 @@ session with **no other memory of this project**. So:
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
+ from Settings → Coordinator. **Still future:** 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
diff --git a/NIP.md b/NIP.md
index cc8e065..2e0dc2e 100644
--- a/NIP.md
+++ b/NIP.md
@@ -14,7 +14,7 @@ other clients, and preserved on edit:
| `noffer` | string | CLINK Offer code (`noffer1…`) — self-custodial Bitcoin tip code. |
| `bitcoin_silent_payment` | string | Bitcoin Silent Payment address (BIP-352, `sp1…`) — a reusable, private on-chain address. |
| `sponsors` | string | Freeform highlight of the skater's sponsors / supporters (brands, shops, people). |
-| `cordn` | string | A Cordn (cordn.net) contact URL for private, end-to-end encrypted messaging via a self-hosted coordinator. |
+| `cordn` | string | The **Cordn coordinator** others use to reach you — private, end-to-end encrypted messaging. Holds a coordinator **pointer**: either an `nprofile` (coordinator pubkey + relay hints), Cordn's canonical `https://cordn.net/chat/coordinators?c=` add-link, or an `http(s)` endpoint URL. This is the discovery field: clients SHOULD offer a one-tap **add** when it parses as a pointer, and otherwise open it as a plain link. See *Private Crew Chat (Cordn / MLS)*. |
| `cashu_escrow` | string | Cashu escrow pubkey (33-byte compressed secp256k1 hex, `02…`) used as the P2PK lock target for marketplace escrow. Derived from the Cashu wallet keyring. See `ESCROW.md`. |
`lud16` (Lightning address) is standard NIP-01 but is also editable directly in
diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs
index ae61a07..4c677d3 100755
--- a/scripts/smoke-test.mjs
+++ b/scripts/smoke-test.mjs
@@ -138,6 +138,7 @@ const ROUTES = [
'/settings',
'/settings?section=profile',
'/settings?section=coordinator',
+ '/npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx',
'/stash',
'/collections',
'/stats',
diff --git a/src/components/EditProfileForm.tsx b/src/components/EditProfileForm.tsx
index 7884f40..0248efa 100644
--- a/src/components/EditProfileForm.tsx
+++ b/src/components/EditProfileForm.tsx
@@ -5,6 +5,8 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { Button } from '@/components/ui/button';
+import { useCoordinator } from '@/hooks/useCoordinator';
+import { coordinatorAddUrl, parseCoordinatorInput } from '@/lib/coordinator';
import {
Form,
FormControl,
@@ -18,7 +20,7 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
-import { Loader2, Upload, Zap, ShieldCheck, AlertCircle, Bitcoin, Award, MessageSquareLock, UserRound, X, Plus, Bot, BadgeCheck, HelpCircle } from 'lucide-react';
+import { Loader2, Upload, Zap, ShieldCheck, AlertCircle, Bitcoin, Award, MessageSquareLock, UserRound, X, Plus, Bot, BadgeCheck, HelpCircle, Server } from 'lucide-react';
import { NSchema as n, type NostrMetadata } from '@nostrify/nostrify';
import { useQueryClient } from '@tanstack/react-query';
import { useUploadFile } from '@/hooks/useUploadFile';
@@ -62,11 +64,15 @@ export const EditProfileForm: React.FC = () => {
]);
// Cordn contact link — private (MLS-encrypted) group messaging you can own.
- // A URL others can use to reach out to you on Cordn (cordn.net).
+ // The coordinator others can use to message you on Cordn. Accepts the same
+ // pointer formats as Settings → Coordinator (nprofile / cordn.net link /
+ // npub / hex) as well as an http(s) URL — this is the discovery field.
const [cordn, setCordn] = useState('');
const cordnTrimmed = cordn.trim();
+ const cordnParsed = cordnTrimmed ? parseCoordinatorInput(cordnTrimmed) : null;
const cordnInvalid =
- cordnTrimmed.length > 0 && !/^https?:\/\/.+/i.test(cordnTrimmed);
+ cordnTrimmed.length > 0 && !cordnParsed && !/^https?:\/\/.+/i.test(cordnTrimmed);
+ const { coordinator: myCoordinator } = useCoordinator();
// Initialize the form with default values
const form = useForm({
@@ -230,7 +236,10 @@ export const EditProfileForm: React.FC = () => {
// Cordn contact link (custom field).
if (cordnTrimmed) {
- data.cordn = cordnTrimmed;
+ // Normalise a bare pointer (nprofile/npub/hex) to Cordn's canonical
+ // add-link, so the profile carries a human-clickable URL other clients
+ // can parse back. A plain http(s) URL is stored as-is.
+ data.cordn = cordnParsed ? coordinatorAddUrl(cordnParsed) : cordnTrimmed;
} else {
delete data.cordn;
}
@@ -512,21 +521,35 @@ export const EditProfileForm: React.FC = () => {
- {/* Cordn contact link */}
+ {/* Cordn coordinator — the pointer others discover you by. */}
}
iconClass="text-teal-500 bg-teal-500/10"
- label="Cordn contact link"
+ label="Coordinator"
value={cordn}
onChange={setCordn}
- placeholder="https://cordn.net/…"
+ placeholder="nprofile1… · cordn.net/chat/coordinators?c=… · https://…"
status={
cordnInvalid
- ? { kind: 'error', text: 'Paste a full link starting with https://.' }
- : { kind: 'hint', text: 'A link others use to reach you on Cordn — private, encrypted messaging you own.' }
+ ? { kind: 'error', text: "That doesn't look like a coordinator. Paste an nprofile, a cordn.net link, or an https:// URL." }
+ : cordnTrimmed
+ ? { kind: 'ok', text: 'Shown on your profile — others can add it in one tap.' }
+ : { kind: 'hint', text: 'The Cordn coordinator others use to message you. Private, encrypted, yours.' }
}
/>
+ {myCoordinator && (
+
+ )}
{/* ── Section: Account ── */}
diff --git a/src/pages/NIP19Page.tsx b/src/pages/NIP19Page.tsx
index 4d6ec4d..0b07d00 100644
--- a/src/pages/NIP19Page.tsx
+++ b/src/pages/NIP19Page.tsx
@@ -20,13 +20,16 @@ import { useUserCrews } from '@/hooks/useCrews';
import { useUserPublicStash } from '@/hooks/useStashShowcase';
import { useUserSkaterProfile } from '@/hooks/useSkaters';
import { useCrewNip05 } from '@/hooks/useCrewNip05';
+import { useCoordinator } from '@/hooks/useCoordinator';
+import { coordinatorAddUrl, parseCoordinatorInput, shortNpub, CORDN_RELAY_URL } from '@/lib/coordinator';
+import { toast } from '@/hooks/useToast';
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import { SKATER_STANCES, SKATER_STYLES, SKATER_LEVELS, NEWSLETTER_KIND } from '@/lib/types';
import { NewsletterIssueView } from '@/components/NewsletterIssueView';
import NotFound from './NotFound';
-import { MapPin, Store, MessageCircle, Globe, Zap, ExternalLink, Copy, Check, ShieldCheck, Instagram, Share2, Users, Crown, Bot, Pencil, Package, Tag, ArrowRightLeft, Bitcoin, Award, MessageSquareLock, PersonStanding, Sparkles, Home, Clock } from 'lucide-react';
+import { MapPin, Store, MessageCircle, Globe, Zap, ExternalLink, Copy, Check, ShieldCheck, Instagram, Share2, Users, Crown, Bot, Pencil, Package, Tag, ArrowRightLeft, Bitcoin, Award, MessageSquareLock, PersonStanding, Sparkles, Home, Clock, Server, Plus } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
import { ShareProfileDialog } from '@/components/ShareProfileDialog';
@@ -107,6 +110,7 @@ function StatPill({
function ProfilePage({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const { user } = useCurrentUser();
+ const { coordinator: myCoordinator, setCoordinator } = useCoordinator();
const { data: spots, isLoading: spotsLoading } = useUserSkateSpots(pubkey);
const { data: shops, isLoading: shopsLoading } = useUserShops(pubkey);
const { memberships: crews, isLoading: crewsLoading } = useUserCrews(pubkey);
@@ -134,6 +138,9 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
const silentPayment = typeof raw?.bitcoin_silent_payment === 'string' ? raw.bitcoin_silent_payment : undefined;
const userBadges = parseBadges(metadata);
const cordnUrl = typeof raw?.cordn === 'string' ? raw.cordn : undefined;
+ // When the profile's `cordn` value is a coordinator pointer, we can offer a
+ // one-tap "Add" instead of just a link.
+ const cordnPointer = cordnUrl ? parseCoordinatorInput(cordnUrl) : null;
const [spCopied, setSpCopied] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
@@ -764,8 +771,43 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
)}
- {/* Cordn contact link */}
- {cordnUrl && (
+ {/* Cordn coordinator — addable in one tap */}
+ {cordnPointer ? (
+