Polish relay list UI: match CitrineCard style, add Local chip, on-brand read/write chips, and live connection dots

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-14 10:14:16 +07:00
parent 46fbb0df43
commit 06cfadff33
3 changed files with 367 additions and 124 deletions

View file

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; 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 { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -10,6 +10,8 @@ import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useRelayStatus } from '@/hooks/useRelayStatus';
import { isLocalRelay } from '@/lib/relayProbe';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface Relay { interface Relay {
@ -18,6 +20,41 @@ interface Relay {
write: boolean; 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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="relative flex-shrink-0 flex items-center justify-center w-4 h-4">
<span
aria-hidden="true"
className={cn(
'w-2 h-2 rounded-full motion-reduce:animate-none',
status === 'connected' && 'bg-[#ccff00] animate-status-live',
status === 'connecting' && 'bg-[#ccff00]/70 animate-status-breathe',
status === 'offline' && 'bg-white/25',
)}
/>
<span className="sr-only">{label}</span>
</span>
</TooltipTrigger>
<TooltipContent side="left">
<p className="text-xs font-medium">{label}</p>
{reason && status === 'offline' && (
<p className="text-[10px] text-muted-foreground mt-0.5 font-mono">{reason}</p>
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export function RelayListManager() { export function RelayListManager() {
const { config, updateConfig } = useAppContext(); const { config, updateConfig } = useAppContext();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
@ -208,132 +245,153 @@ export function RelayListManager() {
} }
return ( return (
<div className="space-y-4"> <div className="space-y-3">
{/* Relay List */} {/* Relay List */}
<div className="space-y-2"> <div className="space-y-2">
{relays.map((relay) => ( {relays.map((relay) => {
<div const local = isLocalRelay(relay.url);
key={relay.url} return (
className="flex items-center gap-3 p-3 rounded-md border bg-muted/20" <div
> key={relay.url}
<Wifi className="h-4 w-4 text-muted-foreground shrink-0" /> className="flex items-center gap-3 p-3.5 rounded-2xl border border-white/8 bg-white/3 transition-colors hover:border-white/12"
<div className="flex-1 min-w-0">
<div className="font-mono text-sm truncate" title={relay.url}>
{renderRelayUrl(relay.url)}
</div>
{/* Read/Write status badges */}
<div className="flex gap-1.5 mt-1">
<span className={cn(
"text-[10px] px-1.5 py-0.5 rounded font-medium",
relay.read
? "bg-green-500/20 text-green-400 border border-green-500/30"
: "bg-gray-500/20 text-gray-500 border border-gray-500/30"
)}>
Read {relay.read ? '✓' : '✗'}
</span>
<span className={cn(
"text-[10px] px-1.5 py-0.5 rounded font-medium",
relay.write
? "bg-blue-500/20 text-blue-400 border border-blue-500/30"
: "bg-gray-500/20 text-gray-500 border border-gray-500/30"
)}>
Write {relay.write ? '✓' : '✗'}
</span>
</div>
</div>
{/* Settings Button */}
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 px-2 gap-1.5 shrink-0 text-xs"
>
<Settings className="h-3 w-3" />
Settings
</Button>
</PopoverTrigger>
<PopoverContent className="w-56" align="end">
<div className="space-y-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Label htmlFor={`read-${relay.url}`} className="text-sm cursor-pointer">
Read
</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-xs">Fetch content from this relay</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Switch
id={`read-${relay.url}`}
checked={relay.read}
onCheckedChange={() => handleToggleRead(relay.url)}
className="data-[state=checked]:bg-green-500 scale-75"
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Label htmlFor={`write-${relay.url}`} className="text-sm cursor-pointer">
Write
</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-xs">Publish your posts to this relay</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Switch
id={`write-${relay.url}`}
checked={relay.write}
onCheckedChange={() => handleToggleWrite(relay.url)}
className="data-[state=checked]:bg-blue-500 scale-75"
/>
</div>
</div>
<p className="text-[10px] text-muted-foreground border-t pt-2">
At least one option must be enabled
</p>
</div>
</PopoverContent>
</Popover>
{/* Remove Button */}
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveRelay(relay.url)}
className="size-5 text-muted-foreground hover:text-destructive hover:bg-transparent shrink-0"
disabled={relays.length <= 1}
> >
<X className="h-4 w-4" /> {/* Live connection dot */}
</Button> <RelayStatusDot url={relay.url} />
</div>
))} <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span
className="font-mono text-sm text-white truncate"
title={relay.url}
>
{renderRelayUrl(relay.url)}
</span>
{local && (
<span className="flex-shrink-0 text-[9px] font-black uppercase tracking-[0.15em] text-[#ccff00] bg-[#ccff00]/12 border border-[#ccff00]/25 rounded-full px-1.5 py-0.5">
Local
</span>
)}
</div>
{/* Read/Write chips — on-brand, subtle */}
<div className="flex gap-1.5 mt-1.5">
<span
className={cn(
'inline-flex items-center gap-1 text-[10px] font-bold rounded-full px-2 py-0.5 transition-colors',
relay.read
? 'bg-[#ccff00]/12 text-[#ccff00] border border-[#ccff00]/25'
: 'bg-white/5 text-white/30 border border-white/8'
)}
>
<ArrowDownToLine className="w-2.5 h-2.5" />
Read
</span>
<span
className={cn(
'inline-flex items-center gap-1 text-[10px] font-bold rounded-full px-2 py-0.5 transition-colors',
relay.write
? 'bg-[#ccff00]/12 text-[#ccff00] border border-[#ccff00]/25'
: 'bg-white/5 text-white/30 border border-white/8'
)}
>
<ArrowUpFromLine className="w-2.5 h-2.5" />
Write
</span>
</div>
</div>
{/* Settings popover */}
<Popover>
<PopoverTrigger asChild>
<button
className="flex-shrink-0 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[11px] font-medium text-muted-foreground hover:text-white hover:bg-white/5 transition-colors"
aria-label="Relay read/write settings"
>
<Radio className="w-3.5 h-3.5" />
Edit
</button>
</PopoverTrigger>
<PopoverContent className="w-56" align="end">
<div className="space-y-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Label htmlFor={`read-${relay.url}`} className="text-sm cursor-pointer">
Read
</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-xs">Fetch content from this relay</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Switch
id={`read-${relay.url}`}
checked={relay.read}
onCheckedChange={() => handleToggleRead(relay.url)}
className="data-[state=checked]:bg-[#ccff00] scale-75"
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Label htmlFor={`write-${relay.url}`} className="text-sm cursor-pointer">
Write
</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-xs">Publish your posts to this relay</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Switch
id={`write-${relay.url}`}
checked={relay.write}
onCheckedChange={() => handleToggleWrite(relay.url)}
className="data-[state=checked]:bg-[#ccff00] scale-75"
/>
</div>
</div>
<p className="text-[10px] text-muted-foreground border-t pt-2">
At least one option must be enabled
</p>
</div>
</PopoverContent>
</Popover>
{/* Remove Button */}
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveRelay(relay.url)}
className="size-6 rounded-full text-muted-foreground hover:text-destructive hover:bg-white/5 flex-shrink-0"
disabled={relays.length <= 1}
>
<X className="h-4 w-4" />
</Button>
</div>
);
})}
</div> </div>
{/* Add Relay Form */} {/* Add Relay Form */}
<div className="flex gap-2"> <div className="flex gap-2 pt-1">
<div className="flex-1"> <div className="flex-1">
<Label htmlFor="new-relay-url" className="sr-only"> <Label htmlFor="new-relay-url" className="sr-only">
Relay URL Relay URL
</Label> </Label>
<Input <Input
id="new-relay-url" id="new-relay-url"
placeholder="Enter relay URL (e.g., wss://relay.example.com)" placeholder="wss://relay.example.com"
value={newRelayUrl} value={newRelayUrl}
onChange={(e) => setNewRelayUrl(e.target.value)} onChange={(e) => setNewRelayUrl(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
@ -341,22 +399,21 @@ export function RelayListManager() {
handleAddRelay(); handleAddRelay();
} }
}} }}
className="rounded-xl bg-white/3 border-white/8 font-mono text-sm h-10 focus-visible:ring-[#ccff00]/40"
/> />
</div> </div>
<Button <button
onClick={handleAddRelay} onClick={handleAddRelay}
disabled={!newRelayUrl.trim()} disabled={!newRelayUrl.trim()}
variant="outline" className="flex-shrink-0 inline-flex items-center gap-1.5 rounded-xl bg-[#ccff00] px-4 h-10 text-sm font-black uppercase tracking-wide text-black hover:bg-[#ccff00]/90 active:scale-95 transition-all disabled:opacity-40 disabled:pointer-events-none"
size="sm"
className="h-10 shrink-0"
> >
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4" />
Add Relay Add
</Button> </button>
</div> </div>
{!user && ( {!user && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground pt-1">
Log in to sync your relay list with Nostr Log in to sync your relay list with Nostr
</p> </p>
)} )}

View file

@ -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<RelayProbeResult>({
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();
},
};
}

131
src/lib/relayProbe.ts Normal file
View file

@ -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<RelayProbeResult> {
return new Promise<RelayProbeResult>((resolve) => {
let settled = false;
let opened = false;
let ws: WebSocket | undefined;
let timer: ReturnType<typeof setTimeout> | 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;
}