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 { 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 (
|
||||
<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() {
|
||||
const { config, updateConfig } = useAppContext();
|
||||
const { user } = useCurrentUser();
|
||||
|
|
@ -208,132 +245,153 @@ export function RelayListManager() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{/* Relay List */}
|
||||
<div className="space-y-2">
|
||||
{relays.map((relay) => (
|
||||
<div
|
||||
key={relay.url}
|
||||
className="flex items-center gap-3 p-3 rounded-md border bg-muted/20"
|
||||
>
|
||||
<Wifi className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<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}
|
||||
{relays.map((relay) => {
|
||||
const local = isLocalRelay(relay.url);
|
||||
return (
|
||||
<div
|
||||
key={relay.url}
|
||||
className="flex items-center gap-3 p-3.5 rounded-2xl border border-white/8 bg-white/3 transition-colors hover:border-white/12"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{/* Live connection dot */}
|
||||
<RelayStatusDot url={relay.url} />
|
||||
|
||||
<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>
|
||||
|
||||
{/* Add Relay Form */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 pt-1">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="new-relay-url" className="sr-only">
|
||||
Relay URL
|
||||
</Label>
|
||||
<Input
|
||||
id="new-relay-url"
|
||||
placeholder="Enter relay URL (e.g., wss://relay.example.com)"
|
||||
placeholder="wss://relay.example.com"
|
||||
value={newRelayUrl}
|
||||
onChange={(e) => setNewRelayUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
|
|
@ -341,22 +399,21 @@ export function RelayListManager() {
|
|||
handleAddRelay();
|
||||
}
|
||||
}}
|
||||
className="rounded-xl bg-white/3 border-white/8 font-mono text-sm h-10 focus-visible:ring-[#ccff00]/40"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
<button
|
||||
onClick={handleAddRelay}
|
||||
disabled={!newRelayUrl.trim()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-10 shrink-0"
|
||||
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"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Relay
|
||||
</Button>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!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
|
||||
</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