diff --git a/src/components/RelayListManager.tsx b/src/components/RelayListManager.tsx index 0dbd778..0384323 100644 --- a/src/components/RelayListManager.tsx +++ b/src/components/RelayListManager.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { Plus, X, Wifi, Settings, HelpCircle } from 'lucide-react'; +import { Plus, X, Radio, HelpCircle, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; @@ -10,6 +10,8 @@ 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'; interface Relay { @@ -18,6 +20,41 @@ interface Relay { write: boolean; } +/** Live connection dot — green pulsing when connected, breathing while connecting, grey offline. */ +function RelayStatusDot({ url }: { url: string }) { + const { status, reason } = useRelayStatus(url); + + const label = + status === 'connected' ? 'Connected' : status === 'connecting' ? 'Connecting…' : 'Offline'; + + return ( + + + + + + + +

{label}

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

{reason}

+ )} +
+
+
+ ); +} + export function RelayListManager() { const { config, updateConfig } = useAppContext(); const { user } = useCurrentUser(); @@ -208,132 +245,153 @@ export function RelayListManager() { } return ( -
+
{/* Relay List */}
- {relays.map((relay) => ( -
- -
-
- {renderRelayUrl(relay.url)} -
- {/* Read/Write status badges */} -
- - Read {relay.read ? '✓' : '✗'} - - - Write {relay.write ? '✓' : '✗'} - -
-
- - {/* Settings Button */} - - - - - -
-
-
-
- - - - - - - -

Fetch content from this relay

-
-
-
-
- handleToggleRead(relay.url)} - className="data-[state=checked]:bg-green-500 scale-75" - /> -
-
-
- - - - - - - -

Publish your posts to this relay

-
-
-
-
- handleToggleWrite(relay.url)} - className="data-[state=checked]:bg-blue-500 scale-75" - /> -
-
-

- At least one option must be enabled -

-
-
-
- - {/* Remove Button */} - -
- ))} + {/* Live connection dot */} + + +
+
+ + {renderRelayUrl(relay.url)} + + {local && ( + + Local + + )} +
+ + {/* Read/Write chips — on-brand, subtle */} +
+ + + Read + + + + Write + +
+
+ + {/* Settings popover */} + + + + + +
+
+
+
+ + + + + + + +

Fetch content from this relay

+
+
+
+
+ handleToggleRead(relay.url)} + className="data-[state=checked]:bg-[#ccff00] scale-75" + /> +
+
+
+ + + + + + + +

Publish your posts to this relay

+
+
+
+
+ handleToggleWrite(relay.url)} + className="data-[state=checked]:bg-[#ccff00] scale-75" + /> +
+
+

+ At least one option must be enabled +

+
+
+
+ + {/* Remove Button */} + +
+ ); + })}
{/* Add Relay Form */} -
+
setNewRelayUrl(e.target.value)} onKeyDown={(e) => { @@ -341,25 +399,24 @@ export function RelayListManager() { handleAddRelay(); } }} + className="rounded-xl bg-white/3 border-white/8 font-mono text-sm h-10 focus-visible:ring-[#ccff00]/40" />
- + + Add +
{!user && ( -

+

Log in to sync your relay list with Nostr

)}
); -} \ No newline at end of file +} diff --git a/src/hooks/useRelayStatus.ts b/src/hooks/useRelayStatus.ts new file mode 100644 index 0000000..56f1356 --- /dev/null +++ b/src/hooks/useRelayStatus.ts @@ -0,0 +1,55 @@ +import { useQuery } from '@tanstack/react-query'; +import { probeRelay, type RelayProbeResult } from '@/lib/relayProbe'; + +export type RelayStatus = 'connecting' | 'connected' | 'offline'; + +/** + * useRelayStatus — live reachability of a single relay URL. + * + * Backed by react-query so every row that probes the same URL shares one result + * and we don't open redundant sockets. Returns a coarse status the UI can map to + * a coloured dot: + * - 'connecting': first probe in flight (no result yet). + * - 'connected': the relay answered a Nostr handshake. + * - 'offline': the probe finished and the relay did not answer. + * + * Reconnected relays are picked up because we keep polling while offline, and + * stop polling once connected to avoid churn (a healthy relay rarely vanishes + * silently, and any real read failure elsewhere is a separate concern). + */ +export function useRelayStatus(url: string): { + status: RelayStatus; + probing: boolean; + reason: string | null; + recheck: () => void; +} { + const { data, isFetching, isFetched, refetch } = useQuery({ + queryKey: ['relay-status', url], + queryFn: () => probeRelay(url), + // Consider a result fresh for a minute so switching sub-screens doesn't + // re-probe every relay each time. + staleTime: 60_000, + // Poll only while still offline so a relay that comes back is detected; + // once connected, stop the background churn. + refetchInterval: (query) => (query.state.data?.ok ? false : 30_000), + refetchOnWindowFocus: (query) => !query.state.data?.ok, + retry: false, + }); + + const status: RelayStatus = !isFetched + ? 'connecting' + : data?.ok + ? 'connected' + : isFetching + ? 'connecting' + : 'offline'; + + return { + status, + probing: isFetching, + reason: data?.reason ?? null, + recheck: () => { + refetch(); + }, + }; +} diff --git a/src/lib/relayProbe.ts b/src/lib/relayProbe.ts new file mode 100644 index 0000000..3925265 --- /dev/null +++ b/src/lib/relayProbe.ts @@ -0,0 +1,131 @@ +/** + * relayProbe — lightweight connectivity check for an arbitrary Nostr relay. + * ───────────────────────────────────────────────────────────────────────────── + * Adapted from the Citrine probe: opening a socket alone is not enough to prove + * a relay is alive and speaking Nostr, so we complete a minimal handshake — + * open → tiny bounded REQ → require a protocol-shaped reply (EVENT / EOSE / + * NOTICE / CLOSED) referencing our subscription id. This gives the Relays UI an + * honest green "connected" dot instead of an optimistic one. + * + * Never throws, never rejects — always resolves to a boolean-plus-reason so the + * caller can render a status without try/catch noise. + */ + +export interface RelayProbeResult { + ok: boolean; + /** Short diagnostic for the outcome (useful for debugging / tooltips). */ + reason: string; +} + +/** + * Probe whether a relay at `url` is reachable and answering as a Nostr relay. + * + * Mirrors the Citrine handshake: a bare "socket opened" is treated as NOT ok — + * we require a real protocol reply so a non-Nostr process squatting on the port + * doesn't show a misleading green dot. + */ +export function probeRelay(url: string, timeoutMs = 4000): Promise { + return new Promise((resolve) => { + let settled = false; + let opened = false; + let ws: WebSocket | undefined; + let timer: ReturnType | undefined; + + const done = (ok: boolean, reason: string) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + try { + ws?.close(); + } catch { + // ignore close errors + } + resolve({ ok, reason }); + }; + + try { + ws = new WebSocket(url); + } catch (err) { + resolve({ + ok: false, + reason: `blocked: ${err instanceof Error ? err.message : String(err)}`, + }); + return; + } + + const subId = `probe-${Math.random().toString(36).slice(2, 10)}`; + + timer = setTimeout(() => { + done(false, opened ? 'opened-but-no-nostr-reply' : 'timeout-no-open'); + }, timeoutMs); + + ws.onopen = () => { + opened = true; + try { + ws!.send(JSON.stringify(['REQ', subId, { limit: 1 }])); + } catch (err) { + done(false, `send-failed: ${err instanceof Error ? err.message : String(err)}`); + } + }; + + ws.onmessage = (ev) => { + try { + const data = JSON.parse(typeof ev.data === 'string' ? ev.data : ''); + if (Array.isArray(data) && typeof data[0] === 'string') { + const verb = data[0]; + const matchesSub = data[1] === subId; + if ( + ((verb === 'EVENT' || verb === 'EOSE' || verb === 'CLOSED') && matchesSub) || + verb === 'NOTICE' + ) { + done(true, 'ok'); + return; + } + } + } catch { + // Non-JSON frame → keep waiting for a valid reply until timeout. + } + }; + + ws.onerror = () => { + done(false, opened ? 'error-after-open' : 'connect-error'); + }; + ws.onclose = (ev) => { + done(false, opened ? 'closed-after-open' : `closed-before-open (code ${ev.code})`); + }; + }); +} + +/** + * True if a relay URL points at the local machine (loopback / localhost / *.local + * / LAN address). Used to badge local relays (e.g. Citrine, a self-hosted relay) + * distinctly in the UI, matching the reference "Local" chip. + */ +export function isLocalRelay(url: string): boolean { + let host = ''; + try { + host = new URL(url).hostname.toLowerCase(); + } catch { + // Fall back to a loose match on the raw string. + const lower = url.toLowerCase(); + return ( + lower.includes('127.0.0.1') || + lower.includes('localhost') || + lower.startsWith('ws://') + ); + } + + if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') { + return true; + } + if (host.endsWith('.local')) return true; + // Private LAN ranges. + if (host.startsWith('10.')) return true; + if (host.startsWith('192.168.')) return true; + const m = host.match(/^172\.(\d+)\./); + if (m) { + const second = Number(m[1]); + if (second >= 16 && second <= 31) return true; + } + return false; +}