From 7b33d544b163a0670f092ac5998fa50183b0e0bf Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Tue, 29 Sep 2026 15:11:36 +0700 Subject: [PATCH] =?UTF-8?q?feat(coordinator):=20Cordn=20client=20slice=20?= =?UTF-8?q?=E2=80=94=20pick/add=20a=20coordinator=20+=20run-your-own=20han?= =?UTF-8?q?d-off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SkateSpots stays a Cordn *client*: it points at a coordinator the user chooses or runs, and hands phone-hosting off to Cordn's own app. No embedded coordinator, no default SkateSpots coordinator (deliberate: liability). - src/lib/coordinator.ts: Cordn's own formats — an nprofile pointer, the canonical add-link https://cordn.net/chat/coordinators?c= (from Cordn packages/server/src/main.ts), default relay wss://relay.contextvm.org, the docker one-liner. Parses nprofile/npub/hex/link; encodes an add-link. - src/hooks/useCoordinator.ts: device-local store + presence probe. Health is NOT Cordn RPC — it queries the coordinator's relays for authors:[pubkey] (its ContextVM announcement/heartbeat), so a private/unannounced coordinator honestly reads 'Not seen' rather than failing. - src/components/CoordinatorSettingsCard.tsx + Settings -> Coordinator: empty/connected states, add by paste or QR scan, add-link copy + QR, and a 'Run your own' hand-off (open Cordn, scan its QR, copy the docker command). - smoke test now boots /settings?section=coordinator. --- AGENTS.md | 22 ++ scripts/smoke-test.mjs | 1 + src/components/CoordinatorSettingsCard.tsx | 301 +++++++++++++++++++++ src/hooks/useCoordinator.ts | 144 ++++++++++ src/lib/coordinator.ts | 126 +++++++++ src/pages/SettingsPage.tsx | 20 +- 6 files changed, 612 insertions(+), 2 deletions(-) create mode 100644 src/components/CoordinatorSettingsCard.tsx create mode 100644 src/hooks/useCoordinator.ts create mode 100644 src/lib/coordinator.ts diff --git a/AGENTS.md b/AGENTS.md index cfbe603..f7301ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -575,6 +575,28 @@ session with **no other memory of this project**. So: (2026-09-29): HOLD at Phase 1 + 1b** — the zero-server design stands and closed-app DMs/crew are a documented limitation, revisited only if it becomes load-bearing. `useResolvedDmRelays` is exported for the envelope hook. +- **Cordn coordinator support (client-side, 2026-09-29):** Cordn is an **MLS + delivery coordinator** exposed as a **ContextVM server** over Nostr relays + (E2EE; it sees ciphertext + *metadata* — who/when/which group). SkateSpots is + a **Cordn client only** — we do **not** embed a coordinator in the APK and we + don't run the default one (liability/centralisation — deliberate). Formats are + taken from Cordn itself, not invented: a coordinator is an **`nprofile`** + (pubkey + relay hints); its canonical add-link is + **`https://cordn.net/chat/coordinators?c=`** (Cordn + `packages/server/src/main.ts`); default relay **`wss://relay.contextvm.org`**; + self-host is the `docker run … ghcr.io/cordn-msg/cordn:latest` one-liner. + Files: `src/lib/coordinator.ts` (parse/encode/add-url/name), + `src/hooks/useCoordinator.ts` (device-local store key `skatespots:coordinator` + + `useCoordinatorHealth`), `src/components/CoordinatorSettingsCard.tsx`, + **Settings → Coordinator** (`?section=coordinator`). Health is a **presence + probe, not Cordn RPC**: query the coordinator's relays for `authors:[pubkey]` + (its ContextVM announcement/heartbeat) — so an **unannounced/private + coordinator honestly shows "Not seen"**. "Run your own" is a **hand-off** + (link to Cordn + copy Docker + scan its QR), never an embedded runtime — the + 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. **Not built (future):** publishing the coordinator on the + profile (discovery) and crew-level coordinators. - **Empty-map nudge (2026-09-28):** `MapPage` distinguishes the two empties — the existing "No spots match" card is for *filters* (`filteredSpots.length === 0 && allLocations.length > 0`) and a new "No spots mapped yet — be the first" card diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index 0d5f196..ae61a07 100755 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -137,6 +137,7 @@ const ROUTES = [ '/bounties', '/settings', '/settings?section=profile', + '/settings?section=coordinator', '/stash', '/collections', '/stats', diff --git a/src/components/CoordinatorSettingsCard.tsx b/src/components/CoordinatorSettingsCard.tsx new file mode 100644 index 0000000..80ca7bc --- /dev/null +++ b/src/components/CoordinatorSettingsCard.tsx @@ -0,0 +1,301 @@ +import { useEffect, useState } from 'react'; +import { + Activity, + AlertTriangle, + CheckCircle2, + Copy, + ExternalLink, + Loader2, + QrCode, + RefreshCw, + ScanLine, + Server, + Smartphone, + Trash2, + XCircle, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { QRCodeCanvas } from '@/components/ui/qrcode'; +import { QRScannerModal } from '@/components/wallet/QRScannerModal'; +import { useCoordinator, useCoordinatorHealth, type CoordinatorHealthStatus } from '@/hooks/useCoordinator'; +import { toast } from '@/hooks/useToast'; +import { + CORDN_APP_URL, + CORDN_DOCKER_COMMAND, + CORDN_RELAY_URL, + coordinatorAddUrl, + parseCoordinatorInput, + shortNpub, +} from '@/lib/coordinator'; +import { cn } from '@/lib/utils'; + +function timeAgo(unixSeconds: number): string { + const s = Math.floor(Date.now() / 1000 - unixSeconds); + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} + +const STATUS_META: Record< + CoordinatorHealthStatus, + { label: string; color: string; ring: string; dot: string } +> = { + idle: { label: 'Not checked', color: 'text-gray-400', ring: 'border-white/10', dot: 'bg-gray-500' }, + checking: { label: 'Checking…', color: 'text-blue-300', ring: 'border-blue-500/30', dot: 'bg-blue-400' }, + online: { label: 'Connected', color: 'text-green-400', ring: 'border-green-500/30', dot: 'bg-green-400' }, + stale: { label: 'Quiet', color: 'text-amber-400', ring: 'border-amber-500/30', dot: 'bg-amber-400' }, + offline: { label: 'Not seen', color: 'text-red-400', ring: 'border-red-500/30', dot: 'bg-red-500' }, + error: { label: 'Unreachable', color: 'text-red-400', ring: 'border-red-500/30', dot: 'bg-red-500' }, +}; + +async function copyText(text: string, label: string) { + try { + await navigator.clipboard.writeText(text); + toast({ title: `${label} copied` }); + } catch { + toast({ title: 'Copy failed', description: 'Copy it manually.', variant: 'destructive' }); + } +} + +/** + * Settings → Coordinator. + * + * Lets a user point SkateSpots at a Cordn coordinator they've chosen or run + * themselves — and, crucially, makes "run one on a phone" a hand-off to Cordn + * rather than something we embed. The connected state is an honest presence + * probe: a ContextVM server is visible only if it announces. + */ +export function CoordinatorSettingsCard() { + const { coordinator, setCoordinator, removeCoordinator } = useCoordinator(); + const { status, lastSeen, eventCount, name, error, check } = useCoordinatorHealth(coordinator); + + const [input, setInput] = useState(''); + const [showQr, setShowQr] = useState(false); + const [scanOpen, setScanOpen] = useState(false); + + // Probe once when a coordinator is set/changed, so the state is "connected" + // without the user pressing anything. + useEffect(() => { + if (coordinator) void check(); + }, [coordinator, check]); + + const handleAdd = (raw: string) => { + const parsed = parseCoordinatorInput(raw); + if (!parsed) { + toast({ + variant: 'destructive', + title: "That's not a coordinator", + description: 'Paste an nprofile, an npub, or a cordn.net link.', + }); + return; + } + setCoordinator({ + pubkey: parsed.pubkey, + relays: parsed.relays.length ? parsed.relays : [CORDN_RELAY_URL], + }); + setInput(''); + toast({ title: 'Coordinator added', description: shortNpub(parsed.pubkey) }); + }; + + const meta = STATUS_META[status]; + const StatusIcon = + status === 'checking' + ? Loader2 + : status === 'online' + ? CheckCircle2 + : status === 'stale' + ? AlertTriangle + : status === 'idle' + ? Activity + : XCircle; + + return ( +
+ {/* ── Current coordinator, or the empty state ─────────────────────── */} + {coordinator ? ( +
+
+
+

+ + {name ?? shortNpub(coordinator.pubkey)} +

+

+ {coordinator.relays.length} relay{coordinator.relays.length === 1 ? '' : 's'} + {lastSeen ? ` · last seen ${timeAgo(lastSeen)}` : ''} +

+
+ + + {meta.label} + +
+ +
+

+ {status === 'online' && 'Announcing. Groups that use it can deliver messages now.'} + {status === 'stale' && 'Found, but it has not announced recently — it may be asleep.'} + {status === 'offline' && + 'No announcement seen. It may be private (that is fine), or simply offline.'} + {status === 'error' && (error ?? 'Could not reach the relays.')} + {status === 'idle' && 'Checking…'} + {status === 'checking' && 'Looking for its announcement…'} + {eventCount > 0 && ` · ${eventCount} event${eventCount === 1 ? '' : 's'} found.`} +

+ +
+ + + + +
+ + {showQr && ( +
+ +

+ Scan in Cordn to join this coordinator. +

+
+ )} + +

+ {shortNpub(coordinator.pubkey)} +

+
+
+ ) : ( +
+

+ + No coordinator yet +

+

+ A coordinator delivers your crew's encrypted group + messages in order. It sees who, when and which group — never the contents. Pick one, + or run your own. +

+
+ )} + + {/* ── Add / change ────────────────────────────────────────────────── */} +
+

{coordinator ? 'Change coordinator' : 'Add a coordinator'}

+
+ setInput(e.target.value)} + placeholder="nprofile… or a cordn.net link" + spellCheck={false} + onKeyDown={(e) => { + if (e.key === 'Enter' && input.trim()) handleAdd(input); + }} + /> + +
+ +
+ + {/* ── Run your own (the hand-off) ─────────────────────────────────── */} +
+
+

Run your own

+

+ It can live on an old phone, a Raspberry Pi, or any small box you leave on. Your group keeps + working while it's up — and you can move the group whenever you like. +

+
+ +
+

+ + On a phone +

+

+ Use the Cordn Coordinator app, then scan its QR here to add it. +

+
+ + + + +
+
+ +
+

+ + On a server +

+
+ + {CORDN_DOCKER_COMMAND} + + +
+

+ + Android may sleep a phone coordinator. Expect to move a group now and then. +

+
+
+ +

+ 🔒 A coordinator sees ciphertext and metadata only — never message contents. Run your own to keep + even the metadata yours. +

+ + setScanOpen(false)} + onResult={(result) => handleAdd(result.value)} + /> +
+ ); +} diff --git a/src/hooks/useCoordinator.ts b/src/hooks/useCoordinator.ts new file mode 100644 index 0000000..3e5d852 --- /dev/null +++ b/src/hooks/useCoordinator.ts @@ -0,0 +1,144 @@ +import { useCallback, useMemo, useState } from 'react'; +import { useNostr } from '@nostrify/react'; +import { useLocalStorage } from '@/hooks/useLocalStorage'; +import { + COORDINATOR_STORAGE_KEY, + CORDN_RELAY_URL, + coordinatorNameFromEvent, + type CoordinatorConfig, +} from '@/lib/coordinator'; + +/** The user's chosen Cordn coordinator, persisted device-locally. */ +export function useCoordinator() { + const [stored, setStored] = useLocalStorage( + COORDINATOR_STORAGE_KEY, + null, + ); + + const coordinator = stored && stored.pubkey ? stored : null; + + const setCoordinator = useCallback( + (config: Omit) => { + setStored({ + pubkey: config.pubkey, + relays: config.relays, + addedAt: Date.now(), + }); + }, + [setStored], + ); + + const removeCoordinator = useCallback(() => setStored(null), [setStored]); + + return { coordinator, setCoordinator, removeCoordinator }; +} + +export type CoordinatorHealthStatus = + | 'idle' + | 'checking' + | 'online' + | 'stale' + | 'offline' + | 'error'; + +export interface CoordinatorHealth { + status: CoordinatorHealthStatus; + /** Unix seconds of the newest event authored by the coordinator. */ + lastSeen: number | null; + eventCount: number; + /** Display name parsed from the announcement, when we can find one. */ + name: string | null; + error: string | null; + checkedAt: number | null; +} + +/** + * Same freshness rule as the CVM server probe: ContextVM servers re-announce on + * a heartbeat, so allow a few missed beats before calling it stale. + */ +const STALE_AFTER_MS = 45 * 60 * 1000; + +const INITIAL: CoordinatorHealth = { + status: 'idle', + lastSeen: null, + eventCount: 0, + name: null, + error: null, + checkedAt: null, +}; + +/** + * Presence probe for a coordinator. + * + * A Cordn coordinator is a ContextVM server, so "is it alive?" is answerable + * without speaking Cordn's RPC: query the relays it advertises for events it + * authored (its announcement + heartbeat). A private/unannounced coordinator + * legitimately shows nothing here — the copy says so rather than alarming. + */ +export function useCoordinatorHealth(coordinator: CoordinatorConfig | null) { + const { nostr } = useNostr(); + const [result, setResult] = useState(INITIAL); + + const relays = useMemo( + () => (coordinator?.relays.length ? coordinator.relays : [CORDN_RELAY_URL]), + [coordinator], + ); + + const check = useCallback(async () => { + if (!coordinator) return; + setResult((r) => ({ ...r, status: 'checking', error: null })); + + try { + const signal = AbortSignal.timeout(8000); + const batches = await Promise.all( + relays.map(async (url) => { + try { + return await nostr.relay(url).query( + [{ authors: [coordinator.pubkey], limit: 25 }], + { signal }, + ); + } catch { + // One relay failing must not stop the probe. + return []; + } + }), + ); + + const events = batches.flat(); + if (events.length === 0) { + setResult({ + status: 'offline', + lastSeen: null, + eventCount: 0, + name: null, + error: null, + checkedAt: Date.now(), + }); + return; + } + + const newest = events.reduce((a, b) => (b.created_at > a.created_at ? b : a)); + const ageMs = Date.now() - newest.created_at * 1000; + + setResult({ + status: ageMs <= STALE_AFTER_MS ? 'online' : 'stale', + lastSeen: newest.created_at, + eventCount: events.length, + name: coordinatorNameFromEvent(newest), + error: null, + checkedAt: Date.now(), + }); + } catch (err) { + setResult({ + status: 'error', + lastSeen: null, + eventCount: 0, + name: null, + error: err instanceof Error ? err.message : 'Could not reach the relays', + checkedAt: Date.now(), + }); + } + }, [coordinator, relays, nostr]); + + return { ...result, check }; +} diff --git a/src/lib/coordinator.ts b/src/lib/coordinator.ts new file mode 100644 index 0000000..4af5861 --- /dev/null +++ b/src/lib/coordinator.ts @@ -0,0 +1,126 @@ +import { nip19 } from 'nostr-tools'; + +/** + * Cordn coordinator support — mostly *formats*, taken from Cordn itself so we + * don't invent anything interoperable. + * + * - A coordinator is identified by a Nostr pubkey plus the ContextVM relays + * that reach it (see `nprofile`). + * - Cordn's own server prints the canonical "add me" link as + * `https://cordn.net/chat/coordinators?c=` + * (`packages/server/src/main.ts`), and defaults to `wss://relay.contextvm.org` + * (`packages/server/src/coordinatorServer.ts`). + * - Cordn is a ContextVM server, so a coordinator can be probed the same way + * our own CVM server is: look for events it authored on that relay. + */ + +/** Cordn's default ContextVM relay. */ +export const CORDN_RELAY_URL = 'wss://relay.contextvm.org'; + +/** Cordn's "add this coordinator" URL base. */ +export const CORDN_ADD_BASE = 'https://cordn.net/chat/coordinators'; + +/** Cordn's home — where the docs/one-liner/phone app live. */ +export const CORDN_HOME = 'https://cordn.net'; + +/** The self-host one-liner advertised on cordn.net. */ +export const CORDN_DOCKER_COMMAND = 'docker run --rm ghcr.io/cordn-msg/cordn:latest'; + +/** Where a user gets a coordinator they can run on a phone. */ +export const CORDN_APP_URL = 'https://cordn.net'; + +/** Device-local: which coordinator this user points at. */ +export const COORDINATOR_STORAGE_KEY = 'skatespots:coordinator'; + +export interface CoordinatorConfig { + /** Coordinator server pubkey (hex). */ + pubkey: string; + /** ContextVM relays that reach it (deduped, sorted-ish). */ + relays: string[]; + addedAt: number; +} + +const HEX64 = /^[0-9a-f]{64}$/i; + +function uniqueRelays(relays: Iterable): string[] { + return [...new Set([...relays].map((r) => r.trim()).filter((r) => /^wss?:\/\//i.test(r)))]; +} + +/** Pull the `c=` nprofile out of a cordn.net add URL. */ +function extractFromUrl(input: string): string | null { + try { + return new URL(input).searchParams.get('c'); + } catch { + return null; + } +} + +/** + * Parse anything a user might paste: an `nprofile`, an `npub`, a bare hex + * pubkey, or a `cordn.net/chat/coordinators?c=` link. Returns null + * when it isn't a usable coordinator pointer. + */ +export function parseCoordinatorInput(raw: string): Omit | null { + const input = raw.trim(); + if (!input) return null; + + const candidate = input.includes('://') ? extractFromUrl(input) ?? input : input; + + try { + const decoded = nip19.decode(candidate); + if (decoded.type === 'nprofile') { + return { pubkey: decoded.data.pubkey, relays: uniqueRelays(decoded.data.relays ?? []) }; + } + if (decoded.type === 'npub') { + return { pubkey: decoded.data, relays: [] }; + } + } catch { + // Not bech32 — try a raw hex pubkey below. + } + + if (HEX64.test(candidate)) return { pubkey: candidate.toLowerCase(), relays: [] }; + return null; +} + +/** The canonical cordn.net link that adds this coordinator. */ +export function coordinatorAddUrl(config: Pick): string { + const nprofile = nip19.nprofileEncode({ pubkey: config.pubkey, relays: config.relays }); + return `${CORDN_ADD_BASE}?c=${nprofile}`; +} + +/** `npub1abcd…wxyz` for display; falls back to a hex prefix. */ +export function shortNpub(pubkey: string): string { + try { + const npub = nip19.npubEncode(pubkey); + return `${npub.slice(0, 12)}…${npub.slice(-6)}`; + } catch { + return `${pubkey.slice(0, 12)}…`; + } +} + +/** + * Best-effort coordinator display name from an announcement event. Cordn relies + * on the ContextVM transport announcement, whose exact shape we don't hard-code + * here — so we look in the obvious places and give up gracefully. + */ +export function coordinatorNameFromEvent(event: { + tags: string[][]; + content: string; +}): string | null { + const tagName = event.tags.find(([t]) => t === 'name')?.[1]; + if (tagName?.trim()) return tagName.trim().slice(0, 40); + + try { + const parsed = JSON.parse(event.content) as { + name?: unknown; + serverInfo?: { name?: unknown }; + }; + const candidates = [parsed.serverInfo?.name, parsed.name]; + for (const value of candidates) { + if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 40); + } + } catch { + // content isn't JSON — no name to show. + } + return null; +} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index c66fe53..e23f027 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -13,6 +13,7 @@ import { SasquatchAiStatusCard } from '@/components/SasquatchAiStatusCard'; import { GrantProCard } from '@/components/GrantProCard'; import { AdminReviewCard } from '@/components/AdminReviewCard'; import { NotificationSettingsCard } from '@/components/NotificationSettingsCard'; +import { CoordinatorSettingsCard } from '@/components/CoordinatorSettingsCard'; import { LanguageSelector } from '@/components/LanguageToggle'; import { DesktopNavManager } from '@/components/DesktopNavManager'; import { MyDataTab } from '@/components/MyDataTab'; @@ -29,7 +30,7 @@ import { toast } from '@/hooks/useToast'; import { cn } from '@/lib/utils'; import { NIP05_PROVIDERS } from '@/lib/nip05Providers'; import { SKATE_NIP05_DOMAIN } from '@/lib/skateNip05'; -import { User, Radio, Wallet, Wrench, RotateCcw, Shield, ExternalLink, Languages, RefreshCw, AlertCircle, Database, Package, ChevronRight, ChevronLeft, Smartphone, Download, Zap, WifiOff, Bell } from 'lucide-react'; +import { User, Radio, Wallet, Wrench, RotateCcw, Shield, ExternalLink, Languages, RefreshCw, AlertCircle, Database, Package, ChevronRight, ChevronLeft, Smartphone, Download, Zap, WifiOff, Bell, Server } from 'lucide-react'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -46,6 +47,7 @@ type Section = | 'wallet' | 'stash' | 'notifications' + | 'coordinator' | 'advanced'; // ─── Back header ────────────────────────────────────────────────────────────── @@ -800,6 +802,14 @@ function SettingsMenu({ onNavigate }: { onNavigate: (s: Section) => void }) { iconBg="bg-yellow-500/15" onClick={() => onNavigate('notifications')} /> + onNavigate('coordinator')} + /> {/* Data & Advanced */} @@ -847,7 +857,7 @@ export default function SettingsPage() { // stale bookmark to ?section=language) falls back to the menu rather than a // blank screen with no way back. const RENDERABLE_SECTIONS: Section[] = [ - 'profile', 'security', 'nip05', 'mydata', 'relays', 'appearance', 'wallet', 'notifications', 'advanced', + 'profile', 'security', 'nip05', 'mydata', 'relays', 'appearance', 'wallet', 'notifications', 'coordinator', 'advanced', ]; const requestedSection = searchParams.get('section') as Section; const initialSection = @@ -894,6 +904,12 @@ export default function SettingsPage() { )} + {section === 'coordinator' && ( +
+ + +
+ )} {section === 'advanced' && } {/* Main menu */}