- Settings -> Relays: rewritten with sectioned cards (NIP-65 / DM inbox / Local / Blocked), inline clickable Read/Write chips (no more Edit popover), tone-coded sections with icons + counts + help text, cleaner add rows and empty state. - Map: removed the desktop 'Near Me' button (mobile floating control remains). - Removed NoGood Radio everywhere: deleted NOGOOD_RADIO_PROPOSAL.md and RADIO_PLAYER_GUIDE.md, dropped the README feature line, scrubbed 'DJ NoGood' examples from LIVE_MUSIC_PROPOSAL.md. Gate: 101 tests, 0 errors/warnings, boot smoke green.
587 lines
19 KiB
TypeScript
587 lines
19 KiB
TypeScript
import { useState, useEffect, type ReactNode } from 'react';
|
|
import {
|
|
Plus,
|
|
X,
|
|
Radio,
|
|
Inbox,
|
|
HardDrive,
|
|
Ban,
|
|
ArrowDownToLine,
|
|
ArrowUpFromLine,
|
|
type LucideIcon,
|
|
} from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
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 {
|
|
url: string;
|
|
read: boolean;
|
|
write: boolean;
|
|
}
|
|
|
|
type Tone = 'neutral' | 'cyan' | 'lime' | 'red';
|
|
|
|
const TONE_ICON: Record<Tone, string> = {
|
|
neutral: 'bg-white/[0.06] text-gray-300',
|
|
cyan: 'bg-cyan-500/12 text-cyan-300',
|
|
lime: 'bg-[#ccff00]/12 text-[#ccff00]',
|
|
red: 'bg-red-500/12 text-red-400',
|
|
};
|
|
|
|
const TONE_CHIP: Record<Tone, string> = {
|
|
neutral: 'border-white/10 bg-white/[0.04] text-gray-300',
|
|
cyan: 'border-cyan-500/25 bg-cyan-500/10 text-cyan-300',
|
|
lime: 'border-[#ccff00]/25 bg-[#ccff00]/10 text-[#ccff00]',
|
|
red: 'border-red-500/30 bg-red-500/10 text-red-400',
|
|
};
|
|
|
|
const TONE_ADD: Record<Tone, string> = {
|
|
neutral: 'border-white/15',
|
|
cyan: 'border-cyan-500/30 text-cyan-300 hover:bg-cyan-500/10',
|
|
lime: 'border-[#ccff00]/30 text-[#ccff00] hover:bg-[#ccff00]/10',
|
|
red: 'border-red-500/30 text-red-400 hover:bg-red-500/10',
|
|
};
|
|
|
|
/** Display-only host, e.g. `wss://relay.example.com/x` → `relay.example.com/x`. */
|
|
function formatRelayUrl(url: string): string {
|
|
try {
|
|
const parsed = new URL(url);
|
|
if (parsed.protocol === 'wss:' || parsed.protocol === 'ws:') {
|
|
return parsed.host + (parsed.pathname === '/' ? '' : parsed.pathname);
|
|
}
|
|
return parsed.href;
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|
|
|
|
/** 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 h-4 w-4 flex-shrink-0 items-center justify-center">
|
|
<span
|
|
aria-hidden="true"
|
|
className={cn(
|
|
'h-2 w-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="mt-0.5 font-mono text-[10px] text-muted-foreground">{reason}</p>
|
|
)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
);
|
|
}
|
|
|
|
/** A titled, tinted card grouping one category of relays. */
|
|
function Section({
|
|
icon: Icon,
|
|
title,
|
|
count,
|
|
description,
|
|
tone = 'neutral',
|
|
children,
|
|
}: {
|
|
icon: LucideIcon;
|
|
title: string;
|
|
count?: number;
|
|
description?: string;
|
|
tone?: Tone;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<section className="space-y-3 rounded-2xl border border-white/8 bg-white/[0.02] p-4">
|
|
<div className="flex items-start gap-3">
|
|
<div className={cn('mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl', TONE_ICON[tone])}>
|
|
<Icon className="h-4 w-4" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="text-sm font-bold text-white">{title}</h3>
|
|
{count !== undefined && count > 0 && (
|
|
<span className="rounded-full bg-white/[0.08] px-1.5 py-0.5 text-[10px] font-bold text-muted-foreground">
|
|
{count}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{description && (
|
|
<p className="mt-0.5 text-[11px] leading-snug text-muted-foreground">{description}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">{children}</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/** Inline, clickable Read/Write chip. */
|
|
function ToggleChip({
|
|
active,
|
|
onClick,
|
|
icon: Icon,
|
|
label,
|
|
hint,
|
|
}: {
|
|
active: boolean;
|
|
onClick: () => void;
|
|
icon: LucideIcon;
|
|
label: string;
|
|
hint: string;
|
|
}) {
|
|
return (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
aria-pressed={active}
|
|
className={cn(
|
|
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-bold transition-colors',
|
|
active
|
|
? 'border-[#ccff00]/30 bg-[#ccff00]/12 text-[#ccff00]'
|
|
: 'border-white/8 bg-white/5 text-white/35 hover:text-white/70',
|
|
)}
|
|
>
|
|
<Icon className="h-2.5 w-2.5" />
|
|
{label}
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="max-w-xs">
|
|
<p className="text-xs">{hint}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
);
|
|
}
|
|
|
|
/** Removable URL pills. */
|
|
function UrlChips({ urls, tone, onRemove }: { urls: string[]; tone: Tone; onRemove: (url: string) => void }) {
|
|
if (urls.length === 0) return null;
|
|
return (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{urls.map((url) => (
|
|
<span
|
|
key={url}
|
|
className={cn('inline-flex items-center gap-1 rounded-full border px-2 py-0.5 font-mono text-[11px]', TONE_CHIP[tone])}
|
|
>
|
|
{formatRelayUrl(url)}
|
|
<button
|
|
type="button"
|
|
onClick={() => onRemove(url)}
|
|
className="opacity-60 transition-opacity hover:opacity-100"
|
|
aria-label={`Remove ${url}`}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Input + Add button for a category. */
|
|
function AddUrlRow({
|
|
value,
|
|
onChange,
|
|
onAdd,
|
|
placeholder,
|
|
tone = 'neutral',
|
|
primary = false,
|
|
}: {
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
onAdd: () => void;
|
|
placeholder: string;
|
|
tone?: Tone;
|
|
primary?: boolean;
|
|
}) {
|
|
return (
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') onAdd();
|
|
}}
|
|
placeholder={placeholder}
|
|
className="h-9 rounded-xl border-white/8 bg-white/[0.03] font-mono text-sm focus-visible:ring-[#ccff00]/40"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant={primary ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={onAdd}
|
|
disabled={!value.trim()}
|
|
className={cn(
|
|
'h-9 flex-shrink-0 gap-1.5 rounded-xl font-bold',
|
|
primary ? 'bg-[#ccff00] text-black hover:bg-[#ccff00]/90' : TONE_ADD[tone],
|
|
)}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Add
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function RelayListManager() {
|
|
const { config, updateConfig } = useAppContext();
|
|
const { user } = useCurrentUser();
|
|
const { mutate: publishEvent } = useNostrPublish();
|
|
const { toast } = useToast();
|
|
|
|
const [relays, setRelays] = useState<Relay[]>(config.relayMetadata.relays);
|
|
const [newRelayUrl, setNewRelayUrl] = useState('');
|
|
const [newLocalUrl, setNewLocalUrl] = useState('');
|
|
const [newBlockedUrl, setNewBlockedUrl] = useState('');
|
|
const [newDmUrl, setNewDmUrl] = useState('');
|
|
|
|
const localRelays = config.relayMetadata.localRelays ?? [];
|
|
const blockedRelays = config.relayMetadata.blockedRelays ?? [];
|
|
const dmRelays = config.relayMetadata.dmRelays ?? [];
|
|
|
|
// Sync local state with config when it changes (e.g., from NostrProvider sync)
|
|
useEffect(() => {
|
|
setRelays(config.relayMetadata.relays);
|
|
}, [config.relayMetadata.relays]);
|
|
|
|
const normalizeRelayUrl = (url: string): string => {
|
|
url = url.trim();
|
|
try {
|
|
return new URL(url).toString();
|
|
} catch {
|
|
try {
|
|
return new URL(`wss://${url}`).toString();
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|
|
};
|
|
|
|
const isValidRelayUrl = (url: string): boolean => {
|
|
const trimmed = url.trim();
|
|
if (!trimmed) return false;
|
|
try {
|
|
new URL(normalizeRelayUrl(trimmed));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// ── NIP-65 list (published as kind 10002) ─────────────────────────────────
|
|
const saveRelays = (newRelays: Relay[]) => {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
updateConfig((current) => ({
|
|
...current,
|
|
relayMetadata: {
|
|
...(current.relayMetadata ?? { relays: [], updatedAt: 0 }),
|
|
relays: newRelays,
|
|
updatedAt: now,
|
|
},
|
|
}));
|
|
if (user) publishNIP65RelayList(newRelays);
|
|
};
|
|
|
|
const publishNIP65RelayList = (relayList: Relay[]) => {
|
|
const tags = relayList
|
|
.map((relay) => {
|
|
if (relay.read && relay.write) return ['r', relay.url];
|
|
if (relay.read) return ['r', relay.url, 'read'];
|
|
if (relay.write) return ['r', relay.url, 'write'];
|
|
return null;
|
|
})
|
|
.filter((tag): tag is string[] => tag !== null);
|
|
|
|
publishEvent(
|
|
{ kind: 10002, content: '', tags },
|
|
{
|
|
onSuccess: () =>
|
|
toast({ title: 'Relay list published', description: 'Synced to Nostr (kind 10002).' }),
|
|
onError: (error) => {
|
|
console.error('Failed to publish relay list:', error);
|
|
toast({ title: 'Failed to publish relay list', variant: 'destructive' });
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
const handleAddRelay = () => {
|
|
if (!isValidRelayUrl(newRelayUrl)) {
|
|
toast({
|
|
title: 'Invalid relay URL',
|
|
description: 'Try something like wss://relay.example.com',
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
const normalized = normalizeRelayUrl(newRelayUrl);
|
|
if (relays.some((r) => r.url === normalized)) {
|
|
toast({ title: 'Relay already in your list', variant: 'destructive' });
|
|
return;
|
|
}
|
|
const newRelays = [...relays, { url: normalized, read: true, write: true }];
|
|
setRelays(newRelays);
|
|
setNewRelayUrl('');
|
|
saveRelays(newRelays);
|
|
};
|
|
|
|
const handleRemoveRelay = (url: string) => {
|
|
const newRelays = relays.filter((r) => r.url !== url);
|
|
setRelays(newRelays);
|
|
saveRelays(newRelays);
|
|
};
|
|
|
|
const handleToggleRead = (url: string) => {
|
|
const relay = relays.find((r) => r.url === url);
|
|
if (!relay) return;
|
|
if (relay.read && !relay.write) {
|
|
toast({
|
|
title: 'Keep read or write on',
|
|
description: 'A relay needs at least one of read/write.',
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
const newRelays = relays.map((r) => (r.url === url ? { ...r, read: !r.read } : r));
|
|
setRelays(newRelays);
|
|
saveRelays(newRelays);
|
|
};
|
|
|
|
const handleToggleWrite = (url: string) => {
|
|
const relay = relays.find((r) => r.url === url);
|
|
if (!relay) return;
|
|
if (!relay.read && relay.write) {
|
|
toast({
|
|
title: 'Keep read or write on',
|
|
description: 'A relay needs at least one of read/write.',
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
const newRelays = relays.map((r) => (r.url === url ? { ...r, write: !r.write } : r));
|
|
setRelays(newRelays);
|
|
saveRelays(newRelays);
|
|
};
|
|
|
|
// ── Local / Blocked (app-only, never published) ───────────────────────────
|
|
const updateCategory = (key: 'localRelays' | 'blockedRelays', list: string[]) => {
|
|
updateConfig((c) => ({
|
|
...c,
|
|
relayMetadata: { ...(c.relayMetadata ?? { relays: [], updatedAt: 0 }), [key]: list },
|
|
}));
|
|
};
|
|
|
|
const addCategoryRelay = (key: 'localRelays' | 'blockedRelays', value: string, reset: () => void) => {
|
|
if (!isValidRelayUrl(value)) {
|
|
toast({ title: 'Invalid relay URL', variant: 'destructive' });
|
|
return;
|
|
}
|
|
const normalized = normalizeRelayUrl(value);
|
|
const existing = config.relayMetadata[key] ?? [];
|
|
if (existing.includes(normalized)) {
|
|
toast({ title: 'Relay already in this list', variant: 'destructive' });
|
|
return;
|
|
}
|
|
updateCategory(key, [...existing, normalized]);
|
|
reset();
|
|
};
|
|
|
|
const removeCategoryRelay = (key: 'localRelays' | 'blockedRelays', url: string) => {
|
|
updateCategory(key, (config.relayMetadata[key] ?? []).filter((u) => u !== url));
|
|
};
|
|
|
|
// ── DM relays (kind 10050, published) ─────────────────────────────────────
|
|
const publishDmRelayList = (urls: string[]) => {
|
|
if (!user) return;
|
|
publishEvent(
|
|
{ kind: 10050, content: '', tags: urls.map((url) => ['relay', url]) },
|
|
{
|
|
onSuccess: () => toast({ title: 'DM relays published', description: 'NIP-17 inbox updated.' }),
|
|
onError: (error) => {
|
|
console.error('Failed to publish DM relay list:', error);
|
|
toast({ title: 'Failed to publish DM relays', variant: 'destructive' });
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
const setDmRelays = (urls: string[], publish = true) => {
|
|
updateConfig((c) => ({
|
|
...c,
|
|
relayMetadata: { ...(c.relayMetadata ?? { relays: [], updatedAt: 0 }), dmRelays: urls },
|
|
}));
|
|
if (publish) publishDmRelayList(urls);
|
|
};
|
|
|
|
const addDmRelay = () => {
|
|
if (!isValidRelayUrl(newDmUrl)) {
|
|
toast({ title: 'Invalid relay URL', variant: 'destructive' });
|
|
return;
|
|
}
|
|
const normalized = normalizeRelayUrl(newDmUrl);
|
|
if (dmRelays.includes(normalized)) {
|
|
toast({ title: 'Relay already in this list', variant: 'destructive' });
|
|
return;
|
|
}
|
|
setDmRelays([...dmRelays, normalized]);
|
|
setNewDmUrl('');
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* NIP-65 */}
|
|
<Section
|
|
icon={Radio}
|
|
title="Relays (NIP-65)"
|
|
count={relays.length}
|
|
tone="lime"
|
|
description={
|
|
user
|
|
? 'Tap Read / Write to toggle. Published to Nostr as your kind 10002 list.'
|
|
: 'Tap Read / Write to toggle. Log in to publish your list to Nostr.'
|
|
}
|
|
>
|
|
{relays.length === 0 ? (
|
|
<p className="rounded-xl border border-dashed border-white/10 px-3 py-4 text-center text-xs text-muted-foreground">
|
|
No relays yet — add one below.
|
|
</p>
|
|
) : (
|
|
relays.map((relay) => (
|
|
<div
|
|
key={relay.url}
|
|
className="flex items-center gap-3 rounded-xl border border-white/8 bg-white/[0.03] p-3 transition-colors hover:border-white/12"
|
|
>
|
|
<RelayStatusDot url={relay.url} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="truncate font-mono text-sm text-white" title={relay.url}>
|
|
{formatRelayUrl(relay.url)}
|
|
</span>
|
|
{isLocalRelay(relay.url) && (
|
|
<span className="flex-shrink-0 rounded-full border border-[#ccff00]/25 bg-[#ccff00]/12 px-1.5 py-0.5 text-[9px] font-black uppercase tracking-[0.15em] text-[#ccff00]">
|
|
Local
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-1.5 flex gap-1.5">
|
|
<ToggleChip
|
|
active={relay.read}
|
|
onClick={() => handleToggleRead(relay.url)}
|
|
icon={ArrowDownToLine}
|
|
label="Read"
|
|
hint="Fetch content from this relay"
|
|
/>
|
|
<ToggleChip
|
|
active={relay.write}
|
|
onClick={() => handleToggleWrite(relay.url)}
|
|
icon={ArrowUpFromLine}
|
|
label="Write"
|
|
hint="Publish your events to this relay"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handleRemoveRelay(relay.url)}
|
|
disabled={relays.length <= 1}
|
|
className="size-7 flex-shrink-0 rounded-full text-muted-foreground hover:bg-white/5 hover:text-destructive"
|
|
aria-label={`Remove ${relay.url}`}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))
|
|
)}
|
|
<AddUrlRow
|
|
value={newRelayUrl}
|
|
onChange={setNewRelayUrl}
|
|
onAdd={handleAddRelay}
|
|
placeholder="wss://relay.example.com"
|
|
primary
|
|
/>
|
|
</Section>
|
|
|
|
{/* DM inbox */}
|
|
<Section
|
|
icon={Inbox}
|
|
title="DM inbox relays"
|
|
count={dmRelays.length}
|
|
tone="cyan"
|
|
description="Where you receive NIP-17 direct messages (kind 10050). Falls back to your write relays when empty."
|
|
>
|
|
<UrlChips urls={dmRelays} tone="cyan" onRemove={(u) => setDmRelays(dmRelays.filter((x) => x !== u))} />
|
|
<AddUrlRow
|
|
value={newDmUrl}
|
|
onChange={setNewDmUrl}
|
|
onAdd={addDmRelay}
|
|
placeholder="wss://inbox.relay.example"
|
|
tone="cyan"
|
|
/>
|
|
</Section>
|
|
|
|
{/* Local */}
|
|
<Section
|
|
icon={HardDrive}
|
|
title="Local relays"
|
|
count={localRelays.length}
|
|
tone="lime"
|
|
description="App-only (e.g. a LAN node). Used for reads and writes; never published."
|
|
>
|
|
<UrlChips urls={localRelays} tone="lime" onRemove={(u) => removeCategoryRelay('localRelays', u)} />
|
|
<AddUrlRow
|
|
value={newLocalUrl}
|
|
onChange={setNewLocalUrl}
|
|
onAdd={() => addCategoryRelay('localRelays', newLocalUrl, () => setNewLocalUrl(''))}
|
|
placeholder="ws://192.168.1.10:7777"
|
|
tone="lime"
|
|
/>
|
|
</Section>
|
|
|
|
{/* Blocked */}
|
|
<Section
|
|
icon={Ban}
|
|
title="Blocked relays"
|
|
count={blockedRelays.length}
|
|
tone="red"
|
|
description="Never connect to these — excluded from every read and write."
|
|
>
|
|
<UrlChips urls={blockedRelays} tone="red" onRemove={(u) => removeCategoryRelay('blockedRelays', u)} />
|
|
<AddUrlRow
|
|
value={newBlockedUrl}
|
|
onChange={setNewBlockedUrl}
|
|
onAdd={() => addCategoryRelay('blockedRelays', newBlockedUrl, () => setNewBlockedUrl(''))}
|
|
placeholder="wss://relay.example.com"
|
|
tone="red"
|
|
/>
|
|
</Section>
|
|
</div>
|
|
);
|
|
}
|