Add relay list management

This commit is contained in:
2026-06-11 09:27:02 +02:00
parent 4acdb31b8a
commit 1acde02414
8 changed files with 828 additions and 2 deletions

View File

@@ -6,6 +6,7 @@ import { Explore } from "./pages/Explore";
import { Dashboard } from "./pages/Dashboard";
import { DashboardEvents } from "./pages/DashboardEvents";
import { DashboardExport } from "./pages/DashboardExport";
import { DashboardRelays } from "./pages/DashboardRelays";
import { NIP19Page } from "./pages/NIP19Page";
import { Terms } from "./pages/Terms";
import { Privacy } from "./pages/Privacy";
@@ -21,6 +22,7 @@ export function AppRouter() {
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/dashboard/events" element={<DashboardEvents />} />
<Route path="/dashboard/export" element={<DashboardExport />} />
<Route path="/dashboard/relays" element={<DashboardRelays />} />
<Route path="/terms" element={<Terms />} />
<Route path="/privacy" element={<Privacy />} />
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
@@ -31,4 +33,4 @@ export function AppRouter() {
</BrowserRouter>
);
}
export default AppRouter;
export default AppRouter;

View File

@@ -1,4 +1,4 @@
import { Home, LayoutDashboard, FileText, Download } from 'lucide-react';
import { Home, LayoutDashboard, FileText, Download, RadioTower } from 'lucide-react';
import { Link, useLocation } from 'react-router-dom';
import {
Sidebar,
@@ -34,6 +34,11 @@ const navigationItems = [
url: '/dashboard/export',
icon: Download,
},
{
title: 'Relay Lists',
url: '/dashboard/relays',
icon: RadioTower,
},
];
export function AppSidebar() {

View File

@@ -0,0 +1,455 @@
import { useEffect, useMemo, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
BookHeart,
Check,
CircleAlert,
Layers3,
Loader2,
Pencil,
Plus,
RadioTower,
Save,
Trash2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useRelayLists } from '@/hooks/useRelayLists';
import { useToast } from '@/hooks/useToast';
import {
FAVORITE_RELAYS_KIND,
normalizeRelayUrl,
parseFavoriteRelays,
parseRelayList,
RELAY_LIST_KIND,
RELAY_SET_KIND,
type RelayEntry,
type RelaySet,
} from '@/lib/relayLists';
import { RelayUrlEditor } from './RelayUrlEditor';
interface RelayListManagerProps {
pubkey: string;
}
interface RelaySetDraft {
identifier: string;
title: string;
description: string;
relays: string[];
event?: RelaySet['event'];
}
const emptySetDraft: RelaySetDraft = {
identifier: '',
title: '',
description: '',
relays: [],
};
function RelayListEditor({
relays,
onChange,
disabled,
}: {
relays: RelayEntry[];
onChange: (relays: RelayEntry[]) => void;
disabled?: boolean;
}) {
const [draft, setDraft] = useState('');
const [error, setError] = useState('');
const addRelay = () => {
try {
const url = normalizeRelayUrl(draft);
if (relays.some((relay) => relay.url === url)) {
setError('That relay is already in your list.');
return;
}
onChange([...relays, { url, mode: 'both' }]);
setDraft('');
setError('');
} catch (cause) {
setError(cause instanceof Error ? cause.message : 'Enter a valid relay URL.');
}
};
return (
<div className="space-y-3">
<div className="flex gap-2">
<Input
value={draft}
onChange={(event) => {
setDraft(event.target.value);
setError('');
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addRelay();
}
}}
placeholder="wss://relay.example.com"
className="font-mono text-sm"
disabled={disabled}
aria-invalid={Boolean(error)}
/>
<Button type="button" size="icon" onClick={addRelay} disabled={disabled || !draft.trim()} title="Add relay">
<Plus className="h-4 w-4" />
<span className="sr-only">Add relay</span>
</Button>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{relays.length ? (
<div className="divide-y rounded-md border">
{relays.map((relay) => (
<div key={relay.url} className="grid min-w-0 gap-3 px-3 py-3 sm:grid-cols-[minmax(0,1fr)_130px_32px] sm:items-center">
<span className="truncate font-mono text-sm" title={relay.url}>{relay.url}</span>
<Select
value={relay.mode}
onValueChange={(mode: RelayEntry['mode']) =>
onChange(relays.map((candidate) => candidate.url === relay.url ? { ...candidate, mode } : candidate))
}
disabled={disabled}
>
<SelectTrigger aria-label={`Usage for ${relay.url}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="both">Read + write</SelectItem>
<SelectItem value="read">Read</SelectItem>
<SelectItem value="write">Write</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => onChange(relays.filter((candidate) => candidate.url !== relay.url))}
disabled={disabled}
title="Remove relay"
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Remove {relay.url}</span>
</Button>
</div>
))}
</div>
) : (
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
Add the relays where people should find your events and mentions.
</div>
)}
</div>
);
}
export function RelayListManager({ pubkey }: RelayListManagerProps) {
const queryClient = useQueryClient();
const { toast } = useToast();
const relayLists = useRelayLists(pubkey);
const publish = useNostrPublish();
const [relayEntries, setRelayEntries] = useState<RelayEntry[]>([]);
const [favoriteRelays, setFavoriteRelays] = useState<string[]>([]);
const [setDraft, setSetDraft] = useState<RelaySetDraft | null>(null);
const [deletingSet, setDeletingSet] = useState<RelaySet | null>(null);
const [activeSave, setActiveSave] = useState<'routing' | 'favorites' | 'set' | 'delete' | null>(null);
useEffect(() => {
setRelayEntries(parseRelayList(relayLists.data?.relayListEvent));
}, [relayLists.data?.relayListEvent]);
useEffect(() => {
setFavoriteRelays(parseFavoriteRelays(relayLists.data?.favoritesEvent));
}, [relayLists.data?.favoritesEvent]);
const readCount = relayEntries.filter((relay) => relay.mode !== 'write').length;
const writeCount = relayEntries.filter((relay) => relay.mode !== 'read').length;
const setRelayCount = useMemo(
() => relayLists.data?.relaySets.reduce((total, set) => total + set.relays.length, 0) ?? 0,
[relayLists.data?.relaySets],
);
const finishSave = async (message: string) => {
await queryClient.invalidateQueries({ queryKey: ['relay-lists', pubkey] });
toast({ title: 'Published', description: message });
};
const saveRoutingRelays = async () => {
setActiveSave('routing');
try {
await publish.mutateAsync({
kind: RELAY_LIST_KIND,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags: relayEntries.map(({ url, mode }) => mode === 'both' ? ['r', url] : ['r', url, mode]),
});
await finishSave('Your NIP-65 read/write relay list is up to date.');
} catch (error) {
toast({ title: 'Could not publish relay list', description: error instanceof Error ? error.message : 'Try again.', variant: 'destructive' });
} finally {
setActiveSave(null);
}
};
const saveFavorites = async () => {
setActiveSave('favorites');
try {
const linkedSets = relayLists.data?.favoritesEvent?.tags.filter(([name]) => name === 'a') ?? [];
await publish.mutateAsync({
kind: FAVORITE_RELAYS_KIND,
created_at: Math.floor(Date.now() / 1000),
content: relayLists.data?.favoritesEvent?.content ?? '',
tags: [...favoriteRelays.map((url) => ['relay', url]), ...linkedSets],
});
await finishSave('Your favorite relay feeds are up to date.');
} catch (error) {
toast({ title: 'Could not publish favorites', description: error instanceof Error ? error.message : 'Try again.', variant: 'destructive' });
} finally {
setActiveSave(null);
}
};
const openSetEditor = (set?: RelaySet) => {
setSetDraft(set ? {
identifier: set.identifier,
title: set.title,
description: set.description,
relays: set.relays,
event: set.event,
} : { ...emptySetDraft, identifier: crypto.randomUUID() });
};
const saveRelaySet = async () => {
if (!setDraft || !setDraft.title.trim()) return;
setActiveSave('set');
try {
const tags = [
['d', setDraft.identifier],
['title', setDraft.title.trim()],
...(setDraft.description.trim() ? [['description', setDraft.description.trim()]] : []),
...setDraft.relays.map((url) => ['relay', url]),
];
await publish.mutateAsync({
kind: RELAY_SET_KIND,
created_at: Math.floor(Date.now() / 1000),
content: setDraft.event?.content ?? '',
tags,
});
setSetDraft(null);
await finishSave('Your named relay set has been published.');
} catch (error) {
toast({ title: 'Could not publish relay set', description: error instanceof Error ? error.message : 'Try again.', variant: 'destructive' });
} finally {
setActiveSave(null);
}
};
const deleteRelaySet = async () => {
if (!deletingSet?.event) return;
setActiveSave('delete');
try {
await publish.mutateAsync({
kind: 5,
created_at: Math.floor(Date.now() / 1000),
content: 'Relay set removed by its author.',
tags: [
['e', deletingSet.event.id],
['a', `${RELAY_SET_KIND}:${pubkey}:${deletingSet.identifier}`],
['k', String(RELAY_SET_KIND)],
],
});
setDeletingSet(null);
await finishSave('A NIP-09 deletion request was published for the relay set.');
} catch (error) {
toast({ title: 'Could not delete relay set', description: error instanceof Error ? error.message : 'Try again.', variant: 'destructive' });
} finally {
setActiveSave(null);
}
};
if (relayLists.isLoading) {
return <div className="space-y-4"><Skeleton className="h-12 w-full" /><Skeleton className="h-80 w-full" /></div>;
}
if (relayLists.isError) {
return (
<div className="flex items-start gap-3 rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm">
<CircleAlert className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div><p className="font-medium">Relay lists could not be loaded.</p><p className="text-muted-foreground">Check your connection and try again.</p></div>
</div>
);
}
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-px overflow-hidden rounded-md border bg-border lg:grid-cols-4">
{[
{ label: 'Read relays', value: readCount },
{ label: 'Write relays', value: writeCount },
{ label: 'Favorites', value: favoriteRelays.length },
{ label: 'Relays in sets', value: setRelayCount },
].map((stat) => (
<div key={stat.label} className="bg-background px-4 py-4">
<p className="text-xs font-medium uppercase text-muted-foreground">{stat.label}</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stat.value}</p>
</div>
))}
</div>
<Tabs defaultValue="routing" className="space-y-5">
<TabsList className="grid h-auto w-full grid-cols-3">
<TabsTrigger value="routing" className="gap-2 py-2.5"><RadioTower className="h-4 w-4" /><span className="hidden sm:inline">Read / write</span><span className="sm:hidden">Routing</span></TabsTrigger>
<TabsTrigger value="favorites" className="gap-2 py-2.5"><BookHeart className="h-4 w-4" />Favorites</TabsTrigger>
<TabsTrigger value="sets" className="gap-2 py-2.5"><Layers3 className="h-4 w-4" />Relay sets</TabsTrigger>
</TabsList>
<TabsContent value="routing">
<Card>
<CardHeader>
<CardTitle>Read and write relays</CardTitle>
<CardDescription>Kind 10002 tells other clients where to find your events and where to send your mentions.</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<RelayListEditor relays={relayEntries} onChange={setRelayEntries} disabled={Boolean(activeSave)} />
<div className="flex justify-end">
<Button onClick={saveRoutingRelays} disabled={Boolean(activeSave)}>
{activeSave === 'routing' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Publish kind 10002
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="favorites">
<Card>
<CardHeader>
<CardTitle>Favorite relay feeds</CardTitle>
<CardDescription>Kind 10012 is your public shortlist of relays worth browsing directly.</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<RelayUrlEditor relays={favoriteRelays} onChange={setFavoriteRelays} disabled={Boolean(activeSave)} />
<div className="flex justify-end">
<Button onClick={saveFavorites} disabled={Boolean(activeSave)}>
{activeSave === 'favorites' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Publish kind 10012
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="sets" className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="font-semibold">Named relay sets</h3>
<p className="text-sm text-muted-foreground">Reusable kind 30002 groups for publishing, reading, or sharing.</p>
</div>
<Button onClick={() => openSetEditor()} disabled={Boolean(activeSave)}><Plus className="h-4 w-4" />New set</Button>
</div>
{relayLists.data?.relaySets.length ? (
<div className="grid gap-4 lg:grid-cols-2">
{relayLists.data.relaySets.map((set) => (
<Card key={set.identifier}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="truncate text-base">{set.title}</CardTitle>
<CardDescription className="mt-1 line-clamp-2">{set.description || `${set.relays.length} relay${set.relays.length === 1 ? '' : 's'}`}</CardDescription>
</div>
<div className="flex shrink-0">
<Button variant="ghost" size="icon" onClick={() => openSetEditor(set)} title="Edit relay set"><Pencil className="h-4 w-4" /><span className="sr-only">Edit {set.title}</span></Button>
<Button variant="ghost" size="icon" className="text-muted-foreground hover:text-destructive" onClick={() => setDeletingSet(set)} title="Delete relay set"><Trash2 className="h-4 w-4" /><span className="sr-only">Delete {set.title}</span></Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
{set.relays.slice(0, 4).map((relay) => <p key={relay} className="truncate font-mono text-xs text-muted-foreground" title={relay}>{relay}</p>)}
{set.relays.length > 4 ? <p className="text-xs text-muted-foreground">+{set.relays.length - 4} more</p> : null}
{!set.relays.length ? <p className="text-sm text-muted-foreground">This set is empty.</p> : null}
</div>
</CardContent>
</Card>
))}
</div>
) : (
<div className="rounded-md border border-dashed px-6 py-12 text-center">
<Layers3 className="mx-auto h-6 w-6 text-muted-foreground" />
<p className="mt-3 font-medium">No named relay sets</p>
<p className="mt-1 text-sm text-muted-foreground">Create one for a community, topic, or publishing workflow.</p>
</div>
)}
</TabsContent>
</Tabs>
<Dialog open={Boolean(setDraft)} onOpenChange={(open) => !open && setSetDraft(null)}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{setDraft?.event ? 'Edit relay set' : 'Create relay set'}</DialogTitle>
<DialogDescription>Publish a named, addressable kind 30002 relay group.</DialogDescription>
</DialogHeader>
{setDraft ? (
<div className="space-y-5 py-2">
<div className="space-y-2">
<Label htmlFor="relay-set-title">Name</Label>
<Input id="relay-set-title" value={setDraft.title} onChange={(event) => setSetDraft({ ...setDraft, title: event.target.value })} placeholder="Local community" />
</div>
<div className="space-y-2">
<Label htmlFor="relay-set-description">Description</Label>
<Textarea id="relay-set-description" value={setDraft.description} onChange={(event) => setSetDraft({ ...setDraft, description: event.target.value })} placeholder="What this relay set is useful for" rows={3} />
</div>
<div className="space-y-2">
<Label>Relays</Label>
<RelayUrlEditor relays={setDraft.relays} onChange={(relays) => setSetDraft({ ...setDraft, relays })} disabled={activeSave === 'set'} />
</div>
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => setSetDraft(null)} disabled={activeSave === 'set'}>Cancel</Button>
<Button onClick={saveRelaySet} disabled={!setDraft?.title.trim() || activeSave === 'set'}>
{activeSave === 'set' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
Publish set
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={Boolean(deletingSet)} onOpenChange={(open) => !open && setDeletingSet(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete {deletingSet?.title}?</DialogTitle>
<DialogDescription>This publishes a NIP-09 deletion request. Copies already held by clients or relays may remain available.</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingSet(null)} disabled={activeSave === 'delete'}>Cancel</Button>
<Button variant="destructive" onClick={deleteRelaySet} disabled={activeSave === 'delete'}>
{activeSave === 'delete' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
Publish deletion
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { useState } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { normalizeRelayUrl } from '@/lib/relayLists';
interface RelayUrlEditorProps {
relays: string[];
onChange: (relays: string[]) => void;
disabled?: boolean;
placeholder?: string;
}
export function RelayUrlEditor({
relays,
onChange,
disabled,
placeholder = 'wss://relay.example.com',
}: RelayUrlEditorProps) {
const [draft, setDraft] = useState('');
const [error, setError] = useState('');
const addRelay = () => {
try {
const normalized = normalizeRelayUrl(draft);
if (relays.includes(normalized)) {
setError('That relay is already in this list.');
return;
}
onChange([...relays, normalized]);
setDraft('');
setError('');
} catch (cause) {
setError(cause instanceof Error ? cause.message : 'Enter a valid relay URL.');
}
};
return (
<div className="space-y-3">
<div className="flex gap-2">
<Input
value={draft}
onChange={(event) => {
setDraft(event.target.value);
setError('');
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addRelay();
}
}}
placeholder={placeholder}
disabled={disabled}
aria-invalid={Boolean(error)}
className="font-mono text-sm"
/>
<Button type="button" size="icon" onClick={addRelay} disabled={disabled || !draft.trim()} title="Add relay">
<Plus className="h-4 w-4" />
<span className="sr-only">Add relay</span>
</Button>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{relays.length ? (
<div className="divide-y rounded-md border">
{relays.map((relay) => (
<div key={relay} className="flex min-w-0 items-center gap-3 px-3 py-2.5">
<span className="min-w-0 flex-1 truncate font-mono text-sm" title={relay}>{relay}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => onChange(relays.filter((candidate) => candidate !== relay))}
disabled={disabled}
title="Remove relay"
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Remove {relay}</span>
</Button>
</div>
))}
</div>
) : (
<div className="rounded-md border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">
No relays in this list yet.
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import {
FAVORITE_RELAYS_KIND,
latestRelaySets,
latestReplaceable,
RELAY_LIST_KIND,
RELAY_SET_KIND,
} from '@/lib/relayLists';
export function useRelayLists(pubkey?: string) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['relay-lists', pubkey],
queryFn: async (context) => {
if (!pubkey) return { relayListEvent: null, favoritesEvent: null, relaySets: [] };
const signal = AbortSignal.any([context.signal, AbortSignal.timeout(5000)]);
const events = await nostr.query(
[
{ kinds: [RELAY_LIST_KIND, FAVORITE_RELAYS_KIND], authors: [pubkey], limit: 2 },
{ kinds: [RELAY_SET_KIND], authors: [pubkey], limit: 100 },
{ kinds: [5], authors: [pubkey], limit: 100 },
],
{ signal },
);
return {
relayListEvent: latestReplaceable(events, RELAY_LIST_KIND),
favoritesEvent: latestReplaceable(events, FAVORITE_RELAYS_KIND),
relaySets: latestRelaySets(events),
};
},
enabled: Boolean(pubkey),
});
}

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import type { NostrEvent } from '@nostrify/nostrify';
import {
latestRelaySets,
normalizeRelayUrl,
parseRelayList,
RELAY_LIST_KIND,
RELAY_SET_KIND,
} from './relayLists';
function event(overrides: Partial<NostrEvent>): NostrEvent {
return {
id: 'a'.repeat(64),
pubkey: 'b'.repeat(64),
sig: 'c'.repeat(128),
created_at: 1,
kind: RELAY_SET_KIND,
content: '',
tags: [],
...overrides,
};
}
describe('relay list helpers', () => {
it('normalizes websocket relay URLs', () => {
expect(normalizeRelayUrl(' wss://relay.example.com/ ')).toBe('wss://relay.example.com');
expect(() => normalizeRelayUrl('https://relay.example.com')).toThrow(/wss/);
});
it('parses NIP-65 read and write markers', () => {
const parsed = parseRelayList(event({
kind: RELAY_LIST_KIND,
tags: [
['r', 'wss://both.example.com'],
['r', 'wss://read.example.com', 'read'],
['r', 'wss://write.example.com', 'write'],
],
}));
expect(parsed.map(({ mode }) => mode)).toEqual(['both', 'read', 'write']);
});
it('keeps the newest set version and respects later deletion requests', () => {
const oldSet = event({ id: '1'.repeat(64), created_at: 10, tags: [['d', 'friends'], ['title', 'Old']] });
const newSet = event({ id: '2'.repeat(64), created_at: 20, tags: [['d', 'friends'], ['title', 'New']] });
const deletion = event({
id: '3'.repeat(64),
kind: 5,
created_at: 30,
tags: [['a', `${RELAY_SET_KIND}:${newSet.pubkey}:friends`]],
});
const recreated = event({ id: '4'.repeat(64), created_at: 40, tags: [['d', 'friends'], ['title', 'Recreated']] });
expect(latestRelaySets([oldSet, newSet])).toHaveLength(1);
expect(latestRelaySets([oldSet, newSet, deletion])).toHaveLength(0);
expect(latestRelaySets([oldSet, newSet, deletion, recreated])[0]?.title).toBe('Recreated');
});
});

120
src/lib/relayLists.ts Normal file
View File

@@ -0,0 +1,120 @@
import type { NostrEvent } from '@nostrify/nostrify';
export const RELAY_LIST_KIND = 10002;
export const FAVORITE_RELAYS_KIND = 10012;
export const RELAY_SET_KIND = 30002;
export type RelayMode = 'read' | 'write' | 'both';
export interface RelayEntry {
url: string;
mode: RelayMode;
}
export interface RelaySet {
identifier: string;
title: string;
description: string;
relays: string[];
event?: NostrEvent;
}
export function normalizeRelayUrl(value: string): string {
const trimmed = value.trim();
const url = new URL(trimmed);
if (url.protocol !== 'wss:' && url.protocol !== 'ws:') {
throw new Error('Relay URLs must start with wss:// or ws://');
}
url.hash = '';
url.search = '';
return url.toString().replace(/\/$/, '');
}
export function parseRelayList(event?: NostrEvent | null): RelayEntry[] {
if (!event || event.kind !== RELAY_LIST_KIND) return [];
return event.tags.flatMap(([name, value, marker]) => {
if (name !== 'r' || !value) return [];
try {
const url = normalizeRelayUrl(value);
const mode: RelayMode = marker === 'read' || marker === 'write' ? marker : 'both';
return [{ url, mode }];
} catch {
return [];
}
});
}
export function parseFavoriteRelays(event?: NostrEvent | null): string[] {
if (!event || event.kind !== FAVORITE_RELAYS_KIND) return [];
return event.tags.flatMap(([name, value]) => {
if (name !== 'relay' || !value) return [];
try {
return [normalizeRelayUrl(value)];
} catch {
return [];
}
});
}
export function parseRelaySet(event: NostrEvent): RelaySet | null {
if (event.kind !== RELAY_SET_KIND) return null;
const identifier = event.tags.find(([name]) => name === 'd')?.[1]?.trim();
if (!identifier) return null;
const title = event.tags.find(([name]) => name === 'title')?.[1]?.trim() || identifier;
const description = event.tags.find(([name]) => name === 'description')?.[1]?.trim() || '';
const relays = event.tags.flatMap(([name, value]) => {
if (name !== 'relay' || !value) return [];
try {
return [normalizeRelayUrl(value)];
} catch {
return [];
}
});
return { identifier, title, description, relays, event };
}
export function latestReplaceable(events: NostrEvent[], kind: number): NostrEvent | null {
return events
.filter((event) => event.kind === kind)
.sort((a, b) => b.created_at - a.created_at)[0] ?? null;
}
export function latestRelaySets(events: NostrEvent[]): RelaySet[] {
const latestByIdentifier = new Map<string, NostrEvent>();
for (const event of events.filter((candidate) => candidate.kind === RELAY_SET_KIND)) {
const identifier = event.tags.find(([name]) => name === 'd')?.[1]?.trim();
if (!identifier) continue;
const current = latestByIdentifier.get(identifier);
if (!current || event.created_at > current.created_at) {
latestByIdentifier.set(identifier, event);
}
}
const deletionRequests = events.filter((event) => event.kind === 5);
return [...latestByIdentifier.values()]
.filter((event) => {
const identifier = event.tags.find(([name]) => name === 'd')?.[1];
const coordinate = `${RELAY_SET_KIND}:${event.pubkey}:${identifier}`;
return !deletionRequests.some((request) =>
request.created_at >= event.created_at
&& request.tags.some(([name, value]) =>
(name === 'e' && value === event.id) || (name === 'a' && value === coordinate),
),
);
})
.map(parseRelaySet)
.filter((set): set is RelaySet => Boolean(set))
.sort((a, b) => a.title.localeCompare(b.title));
}

View File

@@ -0,0 +1,57 @@
import { RadioTower, InfoIcon } from 'lucide-react';
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/navigation/AppSidebar';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Card, CardContent } from '@/components/ui/card';
import { RelayListManager } from '@/components/relays/RelayListManager';
import { useCurrentUser } from '@/hooks/useCurrentUser';
export function DashboardRelays() {
const { user } = useCurrentUser();
return (
<SidebarProvider>
<div className="flex min-h-screen w-full overflow-x-hidden">
<AppSidebar />
<main className="min-w-0 flex-1">
<div className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b bg-background px-4 lg:h-[60px] lg:px-6">
<SidebarTrigger />
<h1 className="truncate text-lg font-semibold md:text-xl">Relay Lists</h1>
</div>
<div className="flex-1 space-y-6 overflow-x-hidden p-4 md:p-6 lg:p-8">
{!user ? (
<Card className="border-dashed">
<CardContent className="px-8 py-12 text-center">
<div className="mx-auto max-w-sm space-y-4">
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertDescription>Please log in to view and publish your relay lists.</AlertDescription>
</Alert>
</div>
</CardContent>
</Card>
) : (
<div className="mx-auto max-w-5xl space-y-6">
<div className="flex items-start gap-4">
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground">
<RadioTower className="h-5 w-5" />
</div>
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight md:text-3xl">Your relay map</h2>
<p className="max-w-2xl text-sm text-muted-foreground md:text-base">
Control where clients find your events, which relay feeds you recommend, and the named relay groups you reuse.
</p>
</div>
</div>
<RelayListManager pubkey={user.pubkey} />
</div>
)}
</div>
</main>
</div>
</SidebarProvider>
);
}
export default DashboardRelays;