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 ? ( +
+ +
+

Coordinator

+ + {shortNpub(cordnPointer.pubkey)} + +
+ {!isOwnProfile && myCoordinator?.pubkey !== cordnPointer.pubkey && ( + + )} + + + +
+ ) : cordnUrl ? ( - )} + ) : null} {/* Npub Copy */}