import { useState, useEffect, type ReactNode } from 'react'; import { Plus, X, Radio, Inbox, HardDrive, Ban, ArrowDownToLine, ArrowUpFromLine, type LucideIcon, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useAppContext } from '@/hooks/useAppContext'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useToast } from '@/hooks/useToast'; import { useRelayStatus } from '@/hooks/useRelayStatus'; import { isLocalRelay } from '@/lib/relayProbe'; import { cn } from '@/lib/utils'; import { useTranslation } from '@/contexts/language'; interface Relay { url: string; read: boolean; write: boolean; } type Tone = 'neutral' | 'cyan' | 'lime' | 'red'; const TONE_ICON: Record = { neutral: 'bg-white/[0.06] text-gray-300', cyan: 'bg-cyan-500/12 text-cyan-300', lime: 'bg-[#ccff00]/12 text-[#ccff00]', red: 'bg-red-500/12 text-red-400', }; const TONE_CHIP: Record = { neutral: 'border-white/10 bg-white/[0.04] text-gray-300', cyan: 'border-cyan-500/25 bg-cyan-500/10 text-cyan-300', lime: 'border-[#ccff00]/25 bg-[#ccff00]/10 text-[#ccff00]', red: 'border-red-500/30 bg-red-500/10 text-red-400', }; const TONE_ADD: Record = { neutral: 'border-white/15', cyan: 'border-cyan-500/30 text-cyan-300 hover:bg-cyan-500/10', lime: 'border-[#ccff00]/30 text-[#ccff00] hover:bg-[#ccff00]/10', red: 'border-red-500/30 text-red-400 hover:bg-red-500/10', }; /** Display-only host, e.g. `wss://relay.example.com/x` → `relay.example.com/x`. */ function formatRelayUrl(url: string): string { try { const parsed = new URL(url); if (parsed.protocol === 'wss:' || parsed.protocol === 'ws:') { return parsed.host + (parsed.pathname === '/' ? '' : parsed.pathname); } return parsed.href; } catch { return url; } } /** Live connection dot — green pulsing when connected, breathing while connecting, grey offline. */ function RelayStatusDot({ url }: { url: string }) { const { status, reason } = useRelayStatus(url); const { t } = useTranslation(); const label = status === 'connected' ? t('settingsUi.relayList.connected') : status === 'connecting' ? t('settingsUi.relayList.connecting') : t('settingsUi.relayList.offline'); return (

{label}

{reason && status === 'offline' && (

{reason}

)}
); } /** A titled, tinted card grouping one category of relays. */ function Section({ icon: Icon, title, count, description, tone = 'neutral', children, }: { icon: LucideIcon; title: string; count?: number; description?: string; tone?: Tone; children: ReactNode; }) { return (

{title}

{count !== undefined && count > 0 && ( {count} )}
{description && (

{description}

)}
{children}
); } /** Inline, clickable Read/Write chip. */ function ToggleChip({ active, onClick, icon: Icon, label, hint, }: { active: boolean; onClick: () => void; icon: LucideIcon; label: string; hint: string; }) { return (

{hint}

); } /** Removable URL pills. */ function UrlChips({ urls, tone, onRemove }: { urls: string[]; tone: Tone; onRemove: (url: string) => void }) { const { t } = useTranslation(); if (urls.length === 0) return null; return (
{urls.map((url) => ( {formatRelayUrl(url)} ))}
); } /** Input + Add button for a category. */ function AddUrlRow({ value, onChange, onAdd, placeholder, tone = 'neutral', primary = false, }: { value: string; onChange: (v: string) => void; onAdd: () => void; placeholder: string; tone?: Tone; primary?: boolean; }) { const { t } = useTranslation(); return (
onChange(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') onAdd(); }} placeholder={placeholder} className="h-9 rounded-xl border-white/8 bg-white/[0.03] font-mono text-sm focus-visible:ring-[#ccff00]/40" />
); } export function RelayListManager() { const { config, updateConfig } = useAppContext(); const { user } = useCurrentUser(); const { mutate: publishEvent } = useNostrPublish(); const { toast } = useToast(); const { t } = useTranslation(); const [relays, setRelays] = useState(config.relayMetadata.relays); const [newRelayUrl, setNewRelayUrl] = useState(''); const [newLocalUrl, setNewLocalUrl] = useState(''); const [newBlockedUrl, setNewBlockedUrl] = useState(''); const [newDmUrl, setNewDmUrl] = useState(''); const localRelays = config.relayMetadata.localRelays ?? []; const blockedRelays = config.relayMetadata.blockedRelays ?? []; const dmRelays = config.relayMetadata.dmRelays ?? []; // Sync local state with config when it changes (e.g., from NostrProvider sync) useEffect(() => { setRelays(config.relayMetadata.relays); }, [config.relayMetadata.relays]); const normalizeRelayUrl = (url: string): string => { url = url.trim(); try { return new URL(url).toString(); } catch { try { return new URL(`wss://${url}`).toString(); } catch { return url; } } }; const isValidRelayUrl = (url: string): boolean => { const trimmed = url.trim(); if (!trimmed) return false; try { new URL(normalizeRelayUrl(trimmed)); return true; } catch { return false; } }; // ── NIP-65 list (published as kind 10002) ───────────────────────────────── const saveRelays = (newRelays: Relay[]) => { const now = Math.floor(Date.now() / 1000); updateConfig((current) => ({ ...current, relayMetadata: { ...(current.relayMetadata ?? { relays: [], updatedAt: 0 }), relays: newRelays, updatedAt: now, }, })); if (user) publishNIP65RelayList(newRelays); }; const publishNIP65RelayList = (relayList: Relay[]) => { const tags = relayList .map((relay) => { if (relay.read && relay.write) return ['r', relay.url]; if (relay.read) return ['r', relay.url, 'read']; if (relay.write) return ['r', relay.url, 'write']; return null; }) .filter((tag): tag is string[] => tag !== null); publishEvent( { kind: 10002, content: '', tags }, { onSuccess: () => toast({ title: t('settingsUi.relayList.publishedTitle'), description: t('settingsUi.relayList.publishedDesc') }), onError: (error) => { console.error('Failed to publish relay list:', error); toast({ title: t('settingsUi.relayList.publishFailedTitle'), variant: 'destructive' }); }, }, ); }; const handleAddRelay = () => { if (!isValidRelayUrl(newRelayUrl)) { toast({ title: t('settingsUi.relayList.invalidUrlTitle'), description: t('settingsUi.relayList.invalidUrlDesc'), variant: 'destructive', }); return; } const normalized = normalizeRelayUrl(newRelayUrl); if (relays.some((r) => r.url === normalized)) { toast({ title: t('settingsUi.relayList.alreadyInList'), variant: 'destructive' }); return; } const newRelays = [...relays, { url: normalized, read: true, write: true }]; setRelays(newRelays); setNewRelayUrl(''); saveRelays(newRelays); }; const handleRemoveRelay = (url: string) => { const newRelays = relays.filter((r) => r.url !== url); setRelays(newRelays); saveRelays(newRelays); }; const handleToggleRead = (url: string) => { const relay = relays.find((r) => r.url === url); if (!relay) return; if (relay.read && !relay.write) { toast({ title: t('settingsUi.relayList.keepReadWriteTitle'), description: t('settingsUi.relayList.keepReadWriteDesc'), variant: 'destructive', }); return; } const newRelays = relays.map((r) => (r.url === url ? { ...r, read: !r.read } : r)); setRelays(newRelays); saveRelays(newRelays); }; const handleToggleWrite = (url: string) => { const relay = relays.find((r) => r.url === url); if (!relay) return; if (!relay.read && relay.write) { toast({ title: t('settingsUi.relayList.keepReadWriteTitle'), description: t('settingsUi.relayList.keepReadWriteDesc'), variant: 'destructive', }); return; } const newRelays = relays.map((r) => (r.url === url ? { ...r, write: !r.write } : r)); setRelays(newRelays); saveRelays(newRelays); }; // ── Local / Blocked (app-only, never published) ─────────────────────────── const updateCategory = (key: 'localRelays' | 'blockedRelays', list: string[]) => { updateConfig((c) => ({ ...c, relayMetadata: { ...(c.relayMetadata ?? { relays: [], updatedAt: 0 }), [key]: list }, })); }; const addCategoryRelay = (key: 'localRelays' | 'blockedRelays', value: string, reset: () => void) => { if (!isValidRelayUrl(value)) { toast({ title: t('settingsUi.relayList.invalidUrlTitle'), variant: 'destructive' }); return; } const normalized = normalizeRelayUrl(value); const existing = config.relayMetadata[key] ?? []; if (existing.includes(normalized)) { toast({ title: t('settingsUi.relayList.alreadyInThisList'), variant: 'destructive' }); return; } updateCategory(key, [...existing, normalized]); reset(); }; const removeCategoryRelay = (key: 'localRelays' | 'blockedRelays', url: string) => { updateCategory(key, (config.relayMetadata[key] ?? []).filter((u) => u !== url)); }; // ── DM relays (kind 10050, published) ───────────────────────────────────── const publishDmRelayList = (urls: string[]) => { if (!user) return; publishEvent( { kind: 10050, content: '', tags: urls.map((url) => ['relay', url]) }, { onSuccess: () => toast({ title: t('settingsUi.relayList.dmPublishedTitle'), description: t('settingsUi.relayList.dmPublishedDesc') }), onError: (error) => { console.error('Failed to publish DM relay list:', error); toast({ title: t('settingsUi.relayList.dmPublishFailedTitle'), variant: 'destructive' }); }, }, ); }; const setDmRelays = (urls: string[], publish = true) => { updateConfig((c) => ({ ...c, relayMetadata: { ...(c.relayMetadata ?? { relays: [], updatedAt: 0 }), dmRelays: urls }, })); if (publish) publishDmRelayList(urls); }; const addDmRelay = () => { if (!isValidRelayUrl(newDmUrl)) { toast({ title: t('settingsUi.relayList.invalidUrlTitle'), variant: 'destructive' }); return; } const normalized = normalizeRelayUrl(newDmUrl); if (dmRelays.includes(normalized)) { toast({ title: t('settingsUi.relayList.alreadyInThisList'), variant: 'destructive' }); return; } setDmRelays([...dmRelays, normalized]); setNewDmUrl(''); }; return (
{/* NIP-65 */}
{relays.length === 0 ? (

{t('settingsUi.relayList.noRelays')}

) : ( relays.map((relay) => (
{formatRelayUrl(relay.url)} {isLocalRelay(relay.url) && ( {t('settingsUi.relayList.local')} )}
handleToggleRead(relay.url)} icon={ArrowDownToLine} label={t('settingsUi.relayList.read')} hint={t('settingsUi.relayList.readHint')} /> handleToggleWrite(relay.url)} icon={ArrowUpFromLine} label={t('settingsUi.relayList.write')} hint={t('settingsUi.relayList.writeHint')} />
)) )}
{/* DM inbox */}
setDmRelays(dmRelays.filter((x) => x !== u))} />
{/* Local */}
removeCategoryRelay('localRelays', u)} /> addCategoryRelay('localRelays', newLocalUrl, () => setNewLocalUrl(''))} placeholder="ws://192.168.1.10:7777" tone="lime" />
{/* Blocked */}
removeCategoryRelay('blockedRelays', u)} /> addCategoryRelay('blockedRelays', newBlockedUrl, () => setNewBlockedUrl(''))} placeholder="wss://relay.example.com" tone="red" />
); }