diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx
index e3bb12e..f2786e3 100644
--- a/src/AppRouter.tsx
+++ b/src/AppRouter.tsx
@@ -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() {
} />
} />
} />
+ } />
} />
} />
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
@@ -31,4 +33,4 @@ export function AppRouter() {
);
}
-export default AppRouter;
\ No newline at end of file
+export default AppRouter;
diff --git a/src/components/navigation/AppSidebar.tsx b/src/components/navigation/AppSidebar.tsx
index 79481d4..cca7168 100644
--- a/src/components/navigation/AppSidebar.tsx
+++ b/src/components/navigation/AppSidebar.tsx
@@ -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() {
diff --git a/src/components/relays/RelayListManager.tsx b/src/components/relays/RelayListManager.tsx
new file mode 100644
index 0000000..28098e1
--- /dev/null
+++ b/src/components/relays/RelayListManager.tsx
@@ -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 (
+
+
+
{
+ 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)}
+ />
+
+
+ Add relay
+
+
+ {error ?
{error}
: null}
+
+ {relays.length ? (
+
+ {relays.map((relay) => (
+
+ {relay.url}
+
+ onChange(relays.map((candidate) => candidate.url === relay.url ? { ...candidate, mode } : candidate))
+ }
+ disabled={disabled}
+ >
+
+
+
+
+ Read + write
+ Read
+ Write
+
+
+ onChange(relays.filter((candidate) => candidate.url !== relay.url))}
+ disabled={disabled}
+ title="Remove relay"
+ >
+
+ Remove {relay.url}
+
+
+ ))}
+
+ ) : (
+
+ Add the relays where people should find your events and mentions.
+
+ )}
+
+ );
+}
+
+export function RelayListManager({ pubkey }: RelayListManagerProps) {
+ const queryClient = useQueryClient();
+ const { toast } = useToast();
+ const relayLists = useRelayLists(pubkey);
+ const publish = useNostrPublish();
+ const [relayEntries, setRelayEntries] = useState([]);
+ const [favoriteRelays, setFavoriteRelays] = useState([]);
+ const [setDraft, setSetDraft] = useState(null);
+ const [deletingSet, setDeletingSet] = useState(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
;
+ }
+
+ if (relayLists.isError) {
+ return (
+
+
+
Relay lists could not be loaded.
Check your connection and try again.
+
+ );
+ }
+
+ return (
+
+
+ {[
+ { label: 'Read relays', value: readCount },
+ { label: 'Write relays', value: writeCount },
+ { label: 'Favorites', value: favoriteRelays.length },
+ { label: 'Relays in sets', value: setRelayCount },
+ ].map((stat) => (
+
+
{stat.label}
+
{stat.value}
+
+ ))}
+
+
+
+
+ Read / write Routing
+ Favorites
+ Relay sets
+
+
+
+
+
+ Read and write relays
+ Kind 10002 tells other clients where to find your events and where to send your mentions.
+
+
+
+
+
+ {activeSave === 'routing' ? : }
+ Publish kind 10002
+
+
+
+
+
+
+
+
+
+ Favorite relay feeds
+ Kind 10012 is your public shortlist of relays worth browsing directly.
+
+
+
+
+
+ {activeSave === 'favorites' ? : }
+ Publish kind 10012
+
+
+
+
+
+
+
+
+
+
Named relay sets
+
Reusable kind 30002 groups for publishing, reading, or sharing.
+
+
openSetEditor()} disabled={Boolean(activeSave)}> New set
+
+
+ {relayLists.data?.relaySets.length ? (
+
+ {relayLists.data.relaySets.map((set) => (
+
+
+
+
+ {set.title}
+ {set.description || `${set.relays.length} relay${set.relays.length === 1 ? '' : 's'}`}
+
+
+
openSetEditor(set)} title="Edit relay set">Edit {set.title}
+
setDeletingSet(set)} title="Delete relay set">Delete {set.title}
+
+
+
+
+
+ {set.relays.slice(0, 4).map((relay) =>
{relay}
)}
+ {set.relays.length > 4 ?
+{set.relays.length - 4} more
: null}
+ {!set.relays.length ?
This set is empty.
: null}
+
+
+
+ ))}
+
+ ) : (
+
+
+
No named relay sets
+
Create one for a community, topic, or publishing workflow.
+
+ )}
+
+
+
+
!open && setSetDraft(null)}>
+
+
+ {setDraft?.event ? 'Edit relay set' : 'Create relay set'}
+ Publish a named, addressable kind 30002 relay group.
+
+ {setDraft ? (
+
+
+ Name
+ setSetDraft({ ...setDraft, title: event.target.value })} placeholder="Local community" />
+
+
+ Description
+
+
+ Relays
+ setSetDraft({ ...setDraft, relays })} disabled={activeSave === 'set'} />
+
+
+ ) : null}
+
+ setSetDraft(null)} disabled={activeSave === 'set'}>Cancel
+
+ {activeSave === 'set' ? : }
+ Publish set
+
+
+
+
+
+
!open && setDeletingSet(null)}>
+
+
+ Delete {deletingSet?.title}?
+ This publishes a NIP-09 deletion request. Copies already held by clients or relays may remain available.
+
+
+ setDeletingSet(null)} disabled={activeSave === 'delete'}>Cancel
+
+ {activeSave === 'delete' ? : }
+ Publish deletion
+
+
+
+
+
+ );
+}
+
diff --git a/src/components/relays/RelayUrlEditor.tsx b/src/components/relays/RelayUrlEditor.tsx
new file mode 100644
index 0000000..58cd68a
--- /dev/null
+++ b/src/components/relays/RelayUrlEditor.tsx
@@ -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 (
+
+
+
{
+ 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"
+ />
+
+
+ Add relay
+
+
+ {error ?
{error}
: null}
+
+ {relays.length ? (
+
+ {relays.map((relay) => (
+
+ {relay}
+ onChange(relays.filter((candidate) => candidate !== relay))}
+ disabled={disabled}
+ title="Remove relay"
+ >
+
+ Remove {relay}
+
+
+ ))}
+
+ ) : (
+
+ No relays in this list yet.
+
+ )}
+
+ );
+}
diff --git a/src/hooks/useRelayLists.ts b/src/hooks/useRelayLists.ts
new file mode 100644
index 0000000..4b80380
--- /dev/null
+++ b/src/hooks/useRelayLists.ts
@@ -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),
+ });
+}
diff --git a/src/lib/relayLists.test.ts b/src/lib/relayLists.test.ts
new file mode 100644
index 0000000..fb6a97a
--- /dev/null
+++ b/src/lib/relayLists.test.ts
@@ -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 {
+ 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');
+ });
+});
diff --git a/src/lib/relayLists.ts b/src/lib/relayLists.ts
new file mode 100644
index 0000000..9232a8f
--- /dev/null
+++ b/src/lib/relayLists.ts
@@ -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();
+
+ 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));
+}
diff --git a/src/pages/DashboardRelays.tsx b/src/pages/DashboardRelays.tsx
new file mode 100644
index 0000000..be2a08f
--- /dev/null
+++ b/src/pages/DashboardRelays.tsx
@@ -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 (
+
+
+
+
+
+
+
Relay Lists
+
+
+
+ {!user ? (
+
+
+
+
+
+ Please log in to view and publish your relay lists.
+
+
+
+
+ ) : (
+
+
+
+
+
+
+
Your relay map
+
+ Control where clients find your events, which relay feeds you recommend, and the named relay groups you reuse.
+
+
+
+
+
+ )}
+
+
+
+
+ );
+}
+
+export default DashboardRelays;