mirror of
https://github.com/layer-systems/website.git
synced 2026-06-17 10:08:35 +02:00
Add relay list management
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
455
src/components/relays/RelayListManager.tsx
Normal file
455
src/components/relays/RelayListManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
92
src/components/relays/RelayUrlEditor.tsx
Normal file
92
src/components/relays/RelayUrlEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/hooks/useRelayLists.ts
Normal file
37
src/hooks/useRelayLists.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
58
src/lib/relayLists.test.ts
Normal file
58
src/lib/relayLists.test.ts
Normal 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
120
src/lib/relayLists.ts
Normal 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));
|
||||
}
|
||||
57
src/pages/DashboardRelays.tsx
Normal file
57
src/pages/DashboardRelays.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user