feat(coordinator): Cordn client slice — pick/add a coordinator + run-your-own hand-off
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=<nprofile> (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.
This commit is contained in:
parent
010910500b
commit
7b33d544b1
6 changed files with 612 additions and 2 deletions
22
AGENTS.md
22
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=<nprofile>`** (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
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ const ROUTES = [
|
|||
'/bounties',
|
||||
'/settings',
|
||||
'/settings?section=profile',
|
||||
'/settings?section=coordinator',
|
||||
'/stash',
|
||||
'/collections',
|
||||
'/stats',
|
||||
|
|
|
|||
301
src/components/CoordinatorSettingsCard.tsx
Normal file
301
src/components/CoordinatorSettingsCard.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="space-y-4">
|
||||
{/* ── Current coordinator, or the empty state ─────────────────────── */}
|
||||
{coordinator ? (
|
||||
<div className={cn('rounded-2xl border overflow-hidden bg-white/2', meta.ring)}>
|
||||
<div className="px-4 py-3.5 border-b border-white/5 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-cyan-400" />
|
||||
<span className="truncate">{name ?? shortNpub(coordinator.pubkey)}</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{coordinator.relays.length} relay{coordinator.relays.length === 1 ? '' : 's'}
|
||||
{lastSeen ? ` · last seen ${timeAgo(lastSeen)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 flex items-center gap-1.5 text-xs font-bold px-2.5 py-1 rounded-full border',
|
||||
meta.ring,
|
||||
meta.color,
|
||||
)}
|
||||
>
|
||||
<span className={cn('h-1.5 w-1.5 rounded-full', meta.dot)} />
|
||||
{meta.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3.5 space-y-3">
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{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.`}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => void check()} disabled={status === 'checking'}>
|
||||
<RefreshCw className={cn('mr-1.5 h-3.5 w-3.5', status === 'checking' && 'animate-spin')} />
|
||||
Check
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => void copyText(coordinatorAddUrl(coordinator), 'Add link')}>
|
||||
<Copy className="mr-1.5 h-3.5 w-3.5" />
|
||||
Add link
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowQr((v) => !v)}>
|
||||
<QrCode className="mr-1.5 h-3.5 w-3.5" />
|
||||
QR
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="ml-auto text-muted-foreground hover:text-red-400"
|
||||
onClick={() => {
|
||||
if (window.confirm('Remove this coordinator from this device?')) removeCoordinator();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showQr && (
|
||||
<div className="flex flex-col items-center gap-2 rounded-xl bg-black/30 border border-white/5 p-4">
|
||||
<QRCodeCanvas value={coordinatorAddUrl(coordinator)} size={180} level="M" />
|
||||
<p className="text-[11px] text-muted-foreground text-center">
|
||||
Scan in Cordn to join this coordinator.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-muted-foreground/70 font-mono truncate">
|
||||
{shortNpub(coordinator.pubkey)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-cyan-500/25 bg-cyan-500/[0.06] p-4 space-y-3">
|
||||
<p className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-cyan-400" />
|
||||
No coordinator yet
|
||||
</p>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
A coordinator delivers your crew's <strong className="text-white">encrypted</strong> group
|
||||
messages in order. It sees <em>who, when and which group</em> — never the contents. Pick one,
|
||||
or run your own.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Add / change ────────────────────────────────────────────────── */}
|
||||
<div className="rounded-2xl border border-white/8 bg-white/2 p-4 space-y-3">
|
||||
<p className="text-sm font-bold text-white">{coordinator ? 'Change coordinator' : 'Add a coordinator'}</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="nprofile… or a cordn.net link"
|
||||
spellCheck={false}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && input.trim()) handleAdd(input);
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" onClick={() => handleAdd(input)} disabled={!input.trim()} className="shrink-0">
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setScanOpen(true)}>
|
||||
<ScanLine className="mr-1.5 h-3.5 w-3.5" />
|
||||
Scan a coordinator QR
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Run your own (the hand-off) ─────────────────────────────────── */}
|
||||
<div className="rounded-2xl border border-white/8 bg-white/2 overflow-hidden">
|
||||
<div className="px-4 py-3.5 border-b border-white/5">
|
||||
<p className="text-sm font-bold text-white">Run your own</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3.5 space-y-3 border-b border-white/5">
|
||||
<p className="flex items-center gap-2 text-xs font-bold text-white">
|
||||
<Smartphone className="h-3.5 w-3.5 text-cyan-400" />
|
||||
On a phone
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use the Cordn Coordinator app, then scan its QR here to add it.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a href={CORDN_APP_URL} target="_blank" rel="noopener noreferrer">
|
||||
<Button size="sm" variant="outline">
|
||||
Open Cordn
|
||||
<ExternalLink className="ml-1.5 h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</a>
|
||||
<Button size="sm" variant="outline" onClick={() => setScanOpen(true)}>
|
||||
<ScanLine className="mr-1.5 h-3.5 w-3.5" />
|
||||
Scan its QR
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3.5 space-y-3">
|
||||
<p className="flex items-center gap-2 text-xs font-bold text-white">
|
||||
<Server className="h-3.5 w-3.5 text-cyan-400" />
|
||||
On a server
|
||||
</p>
|
||||
<div className="flex items-center gap-2 rounded-xl bg-black/40 border border-white/5 px-3 py-2">
|
||||
<code className="flex-1 min-w-0 truncate text-[11px] font-mono text-gray-300">
|
||||
{CORDN_DOCKER_COMMAND}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(CORDN_DOCKER_COMMAND, 'Command')}
|
||||
className="shrink-0 text-muted-foreground hover:text-white transition-colors"
|
||||
aria-label="Copy command"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="flex items-start gap-2 text-[11px] text-amber-400/90 leading-relaxed">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||||
Android may sleep a phone coordinator. Expect to move a group now and then.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="px-1 text-[11px] leading-relaxed text-muted-foreground/70">
|
||||
🔒 A coordinator sees ciphertext and metadata only — never message contents. Run your own to keep
|
||||
even the metadata yours.
|
||||
</p>
|
||||
|
||||
<QRScannerModal
|
||||
isOpen={scanOpen}
|
||||
onClose={() => setScanOpen(false)}
|
||||
onResult={(result) => handleAdd(result.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
src/hooks/useCoordinator.ts
Normal file
144
src/hooks/useCoordinator.ts
Normal file
|
|
@ -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<CoordinatorConfig | null>(
|
||||
COORDINATOR_STORAGE_KEY,
|
||||
null,
|
||||
);
|
||||
|
||||
const coordinator = stored && stored.pubkey ? stored : null;
|
||||
|
||||
const setCoordinator = useCallback(
|
||||
(config: Omit<CoordinatorConfig, 'addedAt'>) => {
|
||||
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<CoordinatorHealth>(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 };
|
||||
}
|
||||
126
src/lib/coordinator.ts
Normal file
126
src/lib/coordinator.ts
Normal file
|
|
@ -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=<nprofile>`
|
||||
* (`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>): 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=<nprofile>` link. Returns null
|
||||
* when it isn't a usable coordinator pointer.
|
||||
*/
|
||||
export function parseCoordinatorInput(raw: string): Omit<CoordinatorConfig, 'addedAt'> | 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<CoordinatorConfig, 'pubkey' | 'relays'>): 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;
|
||||
}
|
||||
|
|
@ -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')}
|
||||
/>
|
||||
<MenuRow
|
||||
icon={Server}
|
||||
label="Coordinator"
|
||||
sublabel="Your Cordn group server"
|
||||
iconColor="text-cyan-400"
|
||||
iconBg="bg-cyan-500/15"
|
||||
onClick={() => onNavigate('coordinator')}
|
||||
/>
|
||||
</MenuGroup>
|
||||
|
||||
{/* 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() {
|
|||
<NotificationSettingsCard />
|
||||
</div>
|
||||
)}
|
||||
{section === 'coordinator' && (
|
||||
<div>
|
||||
<BackHeader title="Coordinator" onBack={handleBack} />
|
||||
<CoordinatorSettingsCard />
|
||||
</div>
|
||||
)}
|
||||
{section === 'advanced' && <AdvancedScreen onBack={handleBack} />}
|
||||
|
||||
{/* Main menu */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue