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)} + /> + +
+ {error ?

{error}

: null} + + {relays.length ? ( +
+ {relays.map((relay) => ( +
+ {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 / writeRouting + Favorites + Relay sets + + + + + + Read and write relays + Kind 10002 tells other clients where to find your events and where to send your mentions. + + + +
+ +
+
+
+
+ + + + + Favorite relay feeds + Kind 10012 is your public shortlist of relays worth browsing directly. + + + +
+ +
+
+
+
+ + +
+
+

Named relay sets

+

Reusable kind 30002 groups for publishing, reading, or sharing.

+
+ +
+ + {relayLists.data?.relaySets.length ? ( +
+ {relayLists.data.relaySets.map((set) => ( + + +
+
+ {set.title} + {set.description || `${set.relays.length} relay${set.relays.length === 1 ? '' : 's'}`} +
+
+ + +
+
+
+ +
+ {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 ? ( +
+
+ + setSetDraft({ ...setDraft, title: event.target.value })} placeholder="Local community" /> +
+
+ +