SkateSpots/src/components/RelayListManager.tsx
SkateSpots 020fc11163 feat(settings,map,docs): polish Relays UI, drop desktop Near Me, remove NoGood Radio
- 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.
2026-09-26 23:37:32 +07:00

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>
);
}