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 { useTranslation } from '@/contexts/language'; import { toast } from '@/hooks/useToast'; import { CORDN_APP_URL, CORDN_DOCKER_COMMAND, CORDN_RELAY_URL, coordinatorAddUrl, parseCoordinatorInput, shortNpub, SKATESPOTS_COORDINATOR, } from '@/lib/coordinator'; import { cn } from '@/lib/utils'; function timeAgo(unixSeconds: number, t: (key: string, params?: Record) => string): string { const s = Math.floor(Date.now() / 1000 - unixSeconds); if (s < 60) return t('socialUi.coordinator.secondsAgo', { count: s }); const m = Math.floor(s / 60); if (m < 60) return t('socialUi.coordinator.minutesAgo', { count: m }); const h = Math.floor(m / 60); if (h < 24) return t('socialUi.coordinator.hoursAgo', { count: h }); return t('socialUi.coordinator.daysAgo', { count: Math.floor(h / 24) }); } const STATUS_META: Record< CoordinatorHealthStatus, { label: string; color: string; ring: string; dot: string } > = { idle: { label: 'socialUi.coordinator.statusIdle', color: 'text-gray-400', ring: 'border-white/10', dot: 'bg-gray-500' }, checking: { label: 'socialUi.coordinator.statusChecking', color: 'text-blue-300', ring: 'border-blue-500/30', dot: 'bg-blue-400' }, online: { label: 'socialUi.coordinator.statusOnline', color: 'text-green-400', ring: 'border-green-500/30', dot: 'bg-green-400' }, stale: { label: 'socialUi.coordinator.statusStale', color: 'text-amber-400', ring: 'border-amber-500/30', dot: 'bg-amber-400' }, offline: { label: 'socialUi.coordinator.statusOffline', color: 'text-red-400', ring: 'border-red-500/30', dot: 'bg-red-500' }, error: { label: 'socialUi.coordinator.statusError', color: 'text-red-400', ring: 'border-red-500/30', dot: 'bg-red-500' }, }; async function copyText(text: string, copiedText: string, failedTitle: string, failedDesc: string) { try { await navigator.clipboard.writeText(text); toast({ title: copiedText }); } catch { toast({ title: failedTitle, description: failedDesc, 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 { t } = useTranslation(); 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: t('socialUi.coordinator.notACoordinator'), description: t('socialUi.coordinator.notACoordinatorDesc'), }); return; } setCoordinator({ pubkey: parsed.pubkey, relays: parsed.relays.length ? parsed.relays : [CORDN_RELAY_URL], }); setInput(''); toast({ title: t('socialUi.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 === 1 ? t('socialUi.coordinator.relayOne', { count: coordinator.relays.length }) : t('socialUi.coordinator.relayMany', { count: coordinator.relays.length })} {lastSeen ? ` · ${t('socialUi.coordinator.lastSeen', { time: timeAgo(lastSeen, t) })}` : ''}

{t(meta.label)}

{status === 'online' && t('socialUi.coordinator.onlineDesc')} {status === 'stale' && t('socialUi.coordinator.staleDesc')} {status === 'offline' && t('socialUi.coordinator.offlineDesc')} {status === 'error' && (error ?? t('socialUi.coordinator.errorDesc'))} {status === 'idle' && t('socialUi.coordinator.idleDesc')} {status === 'checking' && t('socialUi.coordinator.checkingDesc')} {eventCount > 0 && (eventCount === 1 ? ` · ${t('socialUi.coordinator.eventFoundOne', { count: eventCount })}` : ` · ${t('socialUi.coordinator.eventFoundMany', { count: eventCount })}`)}

{showQr && (

{t('socialUi.coordinator.scanToJoin')}

)}

{shortNpub(coordinator.pubkey)}

) : (

{t('socialUi.coordinator.noCoordinator')}

{t('socialUi.coordinator.noCoordinatorDescA')}{' '} {t('socialUi.coordinator.encryptedWord')}{' '} {t('socialUi.coordinator.noCoordinatorDescB')}

)} {/* ── SkateSpots' provided default ───────────────────────────────── */}

{t('socialUi.coordinator.ourDefaultTitle')}

{t('socialUi.coordinator.ourDefaultDesc')}

{/* ── Add / change ────────────────────────────────────────────────── */}

{coordinator ? t('socialUi.coordinator.change') : t('socialUi.coordinator.addTitle')}

setInput(e.target.value)} placeholder={t('socialUi.coordinator.inputPlaceholder')} spellCheck={false} onKeyDown={(e) => { if (e.key === 'Enter' && input.trim()) handleAdd(input); }} />
{/* ── Run your own (the hand-off) ─────────────────────────────────── */}

{t('socialUi.coordinator.runOwn')}

{t('socialUi.coordinator.runOwnDesc')}

{t('socialUi.coordinator.onPhone')}

{t('socialUi.coordinator.onPhoneDesc')}

{t('socialUi.coordinator.onServer')}

{CORDN_DOCKER_COMMAND}

{t('socialUi.coordinator.androidSleep')}

{t('socialUi.coordinator.privacyNote')}

setScanOpen(false)} onResult={(result) => handleAdd(result.value)} />
); }