import { useEffect, useState } from 'react'; import { AlertTriangle, Copy, ExternalLink, QrCode, RefreshCw, ScanLine, Server, Smartphone, Trash2, } 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]; 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)} />
); }