feat(coordinator): profile discovery — publish yours, add theirs in one tap
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.
This commit is contained in:
parent
ee380a5c74
commit
ae2a1c70b8
5 changed files with 89 additions and 15 deletions
10
AGENTS.md
10
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
|
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
|
points at `cordn.net`; the exact Android-app deep link is unconfirmed — fill
|
||||||
it in when known.
|
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
|
**⚠️ Crew-level Cordn chat ALREADY EXISTS — do NOT rebuild it
|
||||||
(2026-09-29):** `src/lib/cordn/{client,crypto,endpoint,types}.ts` +
|
(2026-09-29):** `src/lib/cordn/{client,crypto,endpoint,types}.ts` +
|
||||||
`useCordnChat.ts` + `CrewChat.tsx` + `crew/CrewCoordinatorCard.tsx`, driven by
|
`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
|
Cordn's phone app (an nprofile, not a URL) was un-enterable — `CrewForm` now
|
||||||
uses the shared `parseCrewCoordinatorInput` / `crewCoordinatorInputValue`
|
uses the shared `parseCrewCoordinatorInput` / `crewCoordinatorInputValue`
|
||||||
(`lib/coordinator.ts`) and has a **"Use my coordinator"** button that prefills
|
(`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
|
(`ts-mls`) instead of group-key crypto. **Lesson: grep `src` for the feature
|
||||||
before building it — this was nearly duplicated.**
|
before building it — this was nearly duplicated.**
|
||||||
- **Empty-map nudge (2026-09-28):** `MapPage` distinguishes the two empties — the
|
- **Empty-map nudge (2026-09-28):** `MapPage` distinguishes the two empties — the
|
||||||
|
|
|
||||||
2
NIP.md
2
NIP.md
|
|
@ -14,7 +14,7 @@ other clients, and preserved on edit:
|
||||||
| `noffer` | string | CLINK Offer code (`noffer1…`) — self-custodial Bitcoin tip code. |
|
| `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. |
|
| `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). |
|
| `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=<nprofile>` 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`. |
|
| `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
|
`lud16` (Lightning address) is standard NIP-01 but is also editable directly in
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,7 @@ const ROUTES = [
|
||||||
'/settings',
|
'/settings',
|
||||||
'/settings?section=profile',
|
'/settings?section=profile',
|
||||||
'/settings?section=coordinator',
|
'/settings?section=coordinator',
|
||||||
|
'/npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx',
|
||||||
'/stash',
|
'/stash',
|
||||||
'/collections',
|
'/collections',
|
||||||
'/stats',
|
'/stats',
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useCoordinator } from '@/hooks/useCoordinator';
|
||||||
|
import { coordinatorAddUrl, parseCoordinatorInput } from '@/lib/coordinator';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
|
@ -18,7 +20,7 @@ import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Label } from '@/components/ui/label';
|
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 { NSchema as n, type NostrMetadata } from '@nostrify/nostrify';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
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.
|
// 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 [cordn, setCordn] = useState('');
|
||||||
const cordnTrimmed = cordn.trim();
|
const cordnTrimmed = cordn.trim();
|
||||||
|
const cordnParsed = cordnTrimmed ? parseCoordinatorInput(cordnTrimmed) : null;
|
||||||
const cordnInvalid =
|
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
|
// Initialize the form with default values
|
||||||
const form = useForm<NostrMetadata>({
|
const form = useForm<NostrMetadata>({
|
||||||
|
|
@ -230,7 +236,10 @@ export const EditProfileForm: React.FC = () => {
|
||||||
|
|
||||||
// Cordn contact link (custom field).
|
// Cordn contact link (custom field).
|
||||||
if (cordnTrimmed) {
|
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 {
|
} else {
|
||||||
delete data.cordn;
|
delete data.cordn;
|
||||||
}
|
}
|
||||||
|
|
@ -512,21 +521,35 @@ export const EditProfileForm: React.FC = () => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cordn contact link */}
|
{/* Cordn coordinator — the pointer others discover you by. */}
|
||||||
<FieldRow
|
<FieldRow
|
||||||
id="profile-cordn"
|
id="profile-cordn"
|
||||||
icon={<MessageSquareLock className="h-4 w-4" />}
|
icon={<MessageSquareLock className="h-4 w-4" />}
|
||||||
iconClass="text-teal-500 bg-teal-500/10"
|
iconClass="text-teal-500 bg-teal-500/10"
|
||||||
label="Cordn contact link"
|
label="Coordinator"
|
||||||
value={cordn}
|
value={cordn}
|
||||||
onChange={setCordn}
|
onChange={setCordn}
|
||||||
placeholder="https://cordn.net/…"
|
placeholder="nprofile1… · cordn.net/chat/coordinators?c=… · https://…"
|
||||||
status={
|
status={
|
||||||
cordnInvalid
|
cordnInvalid
|
||||||
? { kind: 'error', text: 'Paste a full link starting with https://.' }
|
? { kind: 'error', text: "That doesn't look like a coordinator. Paste an nprofile, a cordn.net link, or an https:// URL." }
|
||||||
: { kind: 'hint', text: 'A link others use to reach you on Cordn — private, encrypted messaging you own.' }
|
: 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 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-teal-500/40 text-teal-600 hover:bg-teal-500/10 dark:text-teal-400"
|
||||||
|
onClick={() => setCordn(coordinatorAddUrl(myCoordinator))}
|
||||||
|
>
|
||||||
|
<Server className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Use my coordinator
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{/* ── Section: Account ── */}
|
{/* ── Section: Account ── */}
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,16 @@ import { useUserCrews } from '@/hooks/useCrews';
|
||||||
import { useUserPublicStash } from '@/hooks/useStashShowcase';
|
import { useUserPublicStash } from '@/hooks/useStashShowcase';
|
||||||
import { useUserSkaterProfile } from '@/hooks/useSkaters';
|
import { useUserSkaterProfile } from '@/hooks/useSkaters';
|
||||||
import { useCrewNip05 } from '@/hooks/useCrewNip05';
|
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 { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { SKATER_STANCES, SKATER_STYLES, SKATER_LEVELS, NEWSLETTER_KIND } from '@/lib/types';
|
import { SKATER_STANCES, SKATER_STYLES, SKATER_LEVELS, NEWSLETTER_KIND } from '@/lib/types';
|
||||||
import { NewsletterIssueView } from '@/components/NewsletterIssueView';
|
import { NewsletterIssueView } from '@/components/NewsletterIssueView';
|
||||||
import NotFound from './NotFound';
|
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 { useState, type ReactNode } from 'react';
|
||||||
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
|
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
|
||||||
import { ShareProfileDialog } from '@/components/ShareProfileDialog';
|
import { ShareProfileDialog } from '@/components/ShareProfileDialog';
|
||||||
|
|
@ -107,6 +110,7 @@ function StatPill({
|
||||||
function ProfilePage({ pubkey }: { pubkey: string }) {
|
function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
const author = useAuthor(pubkey);
|
const author = useAuthor(pubkey);
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
|
const { coordinator: myCoordinator, setCoordinator } = useCoordinator();
|
||||||
const { data: spots, isLoading: spotsLoading } = useUserSkateSpots(pubkey);
|
const { data: spots, isLoading: spotsLoading } = useUserSkateSpots(pubkey);
|
||||||
const { data: shops, isLoading: shopsLoading } = useUserShops(pubkey);
|
const { data: shops, isLoading: shopsLoading } = useUserShops(pubkey);
|
||||||
const { memberships: crews, isLoading: crewsLoading } = useUserCrews(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 silentPayment = typeof raw?.bitcoin_silent_payment === 'string' ? raw.bitcoin_silent_payment : undefined;
|
||||||
const userBadges = parseBadges(metadata);
|
const userBadges = parseBadges(metadata);
|
||||||
const cordnUrl = typeof raw?.cordn === 'string' ? raw.cordn : undefined;
|
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 [spCopied, setSpCopied] = useState(false);
|
||||||
const [shareOpen, setShareOpen] = useState(false);
|
const [shareOpen, setShareOpen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -764,8 +771,43 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cordn contact link */}
|
{/* Cordn coordinator — addable in one tap */}
|
||||||
{cordnUrl && (
|
{cordnPointer ? (
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-gray-900">
|
||||||
|
<Server className="h-4 w-4 text-teal-400 flex-shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[10px] uppercase tracking-wide text-gray-500 font-bold">Coordinator</p>
|
||||||
|
<span className="text-gray-300 text-sm truncate block font-mono">
|
||||||
|
{shortNpub(cordnPointer.pubkey)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isOwnProfile && myCoordinator?.pubkey !== cordnPointer.pubkey && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="flex-shrink-0 border-teal-500/40 text-teal-600 hover:bg-teal-500/10 dark:text-teal-400"
|
||||||
|
onClick={() => {
|
||||||
|
setCoordinator({
|
||||||
|
pubkey: cordnPointer.pubkey,
|
||||||
|
relays: cordnPointer.relays.length ? cordnPointer.relays : [CORDN_RELAY_URL],
|
||||||
|
});
|
||||||
|
toast({ title: 'Coordinator added', description: shortNpub(cordnPointer.pubkey) });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3.5 w-3.5" /> Add
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={coordinatorAddUrl(cordnPointer)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex-shrink-0 text-gray-500 transition-colors hover:text-teal-300"
|
||||||
|
title="Open in Cordn"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : cordnUrl ? (
|
||||||
<a
|
<a
|
||||||
href={cordnUrl}
|
href={cordnUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
@ -779,7 +821,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
</div>
|
</div>
|
||||||
<ExternalLink className="h-3 w-3 text-gray-500 ml-auto flex-shrink-0" />
|
<ExternalLink className="h-3 w-3 text-gray-500 ml-auto flex-shrink-0" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{/* Npub Copy */}
|
{/* Npub Copy */}
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue