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}
+
+
+
+ {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 */}
-
+ );
+ })}
{/* 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 Relay
-
+
+ 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;
+}