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:
parent
46fbb0df43
commit
06cfadff33
3 changed files with 367 additions and 124 deletions
|
|
@ -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,51 +245,71 @@ 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) => {
|
||||||
|
const local = isLocalRelay(relay.url);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={relay.url}
|
key={relay.url}
|
||||||
className="flex items-center gap-3 p-3 rounded-md border bg-muted/20"
|
className="flex items-center gap-3 p-3.5 rounded-2xl border border-white/8 bg-white/3 transition-colors hover:border-white/12"
|
||||||
>
|
>
|
||||||
<Wifi className="h-4 w-4 text-muted-foreground shrink-0" />
|
{/* Live connection dot */}
|
||||||
|
<RelayStatusDot url={relay.url} />
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-mono text-sm truncate" title={relay.url}>
|
<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)}
|
{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>
|
||||||
<span className={cn(
|
{local && (
|
||||||
"text-[10px] px-1.5 py-0.5 rounded font-medium",
|
<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
|
relay.write
|
||||||
? "bg-blue-500/20 text-blue-400 border border-blue-500/30"
|
? 'bg-[#ccff00]/12 text-[#ccff00] border border-[#ccff00]/25'
|
||||||
: "bg-gray-500/20 text-gray-500 border border-gray-500/30"
|
: 'bg-white/5 text-white/30 border border-white/8'
|
||||||
)}>
|
)}
|
||||||
Write {relay.write ? '✓' : '✗'}
|
>
|
||||||
|
<ArrowUpFromLine className="w-2.5 h-2.5" />
|
||||||
|
Write
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Settings Button */}
|
{/* Settings popover */}
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<button
|
||||||
variant="outline"
|
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"
|
||||||
size="sm"
|
aria-label="Relay read/write settings"
|
||||||
className="h-7 px-2 gap-1.5 shrink-0 text-xs"
|
|
||||||
>
|
>
|
||||||
<Settings className="h-3 w-3" />
|
<Radio className="w-3.5 h-3.5" />
|
||||||
Settings
|
Edit
|
||||||
</Button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-56" align="end">
|
<PopoverContent className="w-56" align="end">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -277,7 +334,7 @@ export function RelayListManager() {
|
||||||
id={`read-${relay.url}`}
|
id={`read-${relay.url}`}
|
||||||
checked={relay.read}
|
checked={relay.read}
|
||||||
onCheckedChange={() => handleToggleRead(relay.url)}
|
onCheckedChange={() => handleToggleRead(relay.url)}
|
||||||
className="data-[state=checked]:bg-green-500 scale-75"
|
className="data-[state=checked]:bg-[#ccff00] scale-75"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -300,7 +357,7 @@ export function RelayListManager() {
|
||||||
id={`write-${relay.url}`}
|
id={`write-${relay.url}`}
|
||||||
checked={relay.write}
|
checked={relay.write}
|
||||||
onCheckedChange={() => handleToggleWrite(relay.url)}
|
onCheckedChange={() => handleToggleWrite(relay.url)}
|
||||||
className="data-[state=checked]:bg-blue-500 scale-75"
|
className="data-[state=checked]:bg-[#ccff00] scale-75"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -316,24 +373,25 @@ export function RelayListManager() {
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleRemoveRelay(relay.url)}
|
onClick={() => handleRemoveRelay(relay.url)}
|
||||||
className="size-5 text-muted-foreground hover:text-destructive hover:bg-transparent shrink-0"
|
className="size-6 rounded-full text-muted-foreground hover:text-destructive hover:bg-white/5 flex-shrink-0"
|
||||||
disabled={relays.length <= 1}
|
disabled={relays.length <= 1}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
55
src/hooks/useRelayStatus.ts
Normal file
55
src/hooks/useRelayStatus.ts
Normal 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
131
src/lib/relayProbe.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue