STATUS_META labels were 'coordinator.*' but keys live under 'socialUi.coordinator.*', so badges rendered raw keys (e.g. coordinator.statusStale). Prefixed all six. Also dropped relay announce_interval_secs from 86400 (24h) to 900 (15m) so the coordinator health probe reads Connected instead of stale.
306 lines
14 KiB
TypeScript
306 lines
14 KiB
TypeScript
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 | number>) => 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 (
|
|
<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 === 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) })}` : ''}
|
|
</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)} />
|
|
{t(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' && 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 })}`)}
|
|
</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')} />
|
|
{t('socialUi.coordinator.check')}
|
|
</Button>
|
|
<Button size="sm" variant="outline" onClick={() => void copyText(coordinatorAddUrl(coordinator), t('socialUi.coordinator.addLinkCopied'), t('socialUi.coordinator.copyFailed'), t('socialUi.coordinator.copyManually'))}>
|
|
<Copy className="mr-1.5 h-3.5 w-3.5" />
|
|
{t('socialUi.coordinator.addLink')}
|
|
</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(t('socialUi.coordinator.removeConfirm'))) 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">
|
|
{t('socialUi.coordinator.scanToJoin')}
|
|
</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" />
|
|
{t('socialUi.coordinator.noCoordinator')}
|
|
</p>
|
|
<p className="text-xs leading-relaxed text-muted-foreground">
|
|
{t('socialUi.coordinator.noCoordinatorDescA')}{' '}
|
|
<strong className="text-white">{t('socialUi.coordinator.encryptedWord')}</strong>{' '}
|
|
{t('socialUi.coordinator.noCoordinatorDescB')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── SkateSpots' provided default ───────────────────────────────── */}
|
|
<div className="rounded-2xl border border-white/8 bg-white/2 p-4 space-y-2.5">
|
|
<p className="flex items-center gap-2 text-sm font-bold text-white">
|
|
<Server className="h-4 w-4 text-cyan-400" />
|
|
{t('socialUi.coordinator.ourDefaultTitle')}
|
|
</p>
|
|
<p className="text-xs leading-relaxed text-muted-foreground">
|
|
{t('socialUi.coordinator.ourDefaultDesc')}
|
|
</p>
|
|
<Button size="sm" variant="outline" onClick={() => setCoordinator(SKATESPOTS_COORDINATOR)}>
|
|
{t('socialUi.coordinator.useOurs')}
|
|
</Button>
|
|
</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 ? t('socialUi.coordinator.change') : t('socialUi.coordinator.addTitle')}</p>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
placeholder={t('socialUi.coordinator.inputPlaceholder')}
|
|
spellCheck={false}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && input.trim()) handleAdd(input);
|
|
}}
|
|
/>
|
|
<Button size="sm" onClick={() => handleAdd(input)} disabled={!input.trim()} className="shrink-0">
|
|
{t('socialUi.coordinator.add')}
|
|
</Button>
|
|
</div>
|
|
<Button size="sm" variant="outline" onClick={() => setScanOpen(true)}>
|
|
<ScanLine className="mr-1.5 h-3.5 w-3.5" />
|
|
{t('socialUi.coordinator.scanQR')}
|
|
</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">{t('socialUi.coordinator.runOwn')}</p>
|
|
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
|
{t('socialUi.coordinator.runOwnDesc')}
|
|
</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" />
|
|
{t('socialUi.coordinator.onPhone')}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('socialUi.coordinator.onPhoneDesc')}
|
|
</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
<a href={CORDN_APP_URL} target="_blank" rel="noopener noreferrer">
|
|
<Button size="sm" variant="outline">
|
|
{t('socialUi.coordinator.openCordn')}
|
|
<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" />
|
|
{t('socialUi.coordinator.scanItsQR')}
|
|
</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" />
|
|
{t('socialUi.coordinator.onServer')}
|
|
</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, t('socialUi.coordinator.commandCopied'), t('socialUi.coordinator.copyFailed'), t('socialUi.coordinator.copyManually'))}
|
|
className="shrink-0 text-muted-foreground hover:text-white transition-colors"
|
|
aria-label={t('socialUi.coordinator.copyCommand')}
|
|
>
|
|
<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" />
|
|
{t('socialUi.coordinator.androidSleep')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="px-1 text-[11px] leading-relaxed text-muted-foreground/70">
|
|
{t('socialUi.coordinator.privacyNote')}
|
|
</p>
|
|
|
|
<QRScannerModal
|
|
isOpen={scanOpen}
|
|
onClose={() => setScanOpen(false)}
|
|
onResult={(result) => handleAdd(result.value)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|