diff --git a/TODO.md b/TODO.md
index 5dcd568..cb2744f 100644
--- a/TODO.md
+++ b/TODO.md
@@ -31,3 +31,6 @@ look into reconnecting on errors
## TODO: improve text rendering
avoid inserting `br`, look into noStrudel's eol metadata
+
+## TODO: window crashes on unsupported kind event
+## TODO: app-wide error boundary. splash crash screen.
diff --git a/src/components/EventDetailViewer.tsx b/src/components/EventDetailViewer.tsx
index 2e3e6b0..acd34be 100644
--- a/src/components/EventDetailViewer.tsx
+++ b/src/components/EventDetailViewer.tsx
@@ -6,6 +6,7 @@ import { Kind0DetailRenderer } from "./nostr/kinds/Kind0DetailRenderer";
import { Kind3DetailView } from "./nostr/kinds/Kind3Renderer";
import { Kind30023DetailRenderer } from "./nostr/kinds/Kind30023DetailRenderer";
import { Kind9802DetailRenderer } from "./nostr/kinds/Kind9802DetailRenderer";
+import { Kind10002DetailRenderer } from "./nostr/kinds/Kind10002DetailRenderer";
import { KindBadge } from "./KindBadge";
import {
Copy,
@@ -32,6 +33,7 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
const event = useNostrEvent(pointer);
const [showJson, setShowJson] = useState(false);
const [showRelays, setShowRelays] = useState(false);
+ const { copy, copied } = useCopy();
// Loading state
if (!event) {
@@ -42,8 +44,6 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
);
}
- const { copy, copied } = useCopy();
-
// Get relays this event was seen on using applesauce
const seenRelaysSet = getSeenRelays(event);
const relays = seenRelaysSet ? Array.from(seenRelaysSet) : undefined;
@@ -181,6 +181,8 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
) : event.kind === kinds.Highlights ? (
+ ) : event.kind === kinds.RelayList ? (
+
) : (
)}
diff --git a/src/components/nostr/RelayLink.tsx b/src/components/nostr/RelayLink.tsx
index 6078017..3b62ca3 100644
--- a/src/components/nostr/RelayLink.tsx
+++ b/src/components/nostr/RelayLink.tsx
@@ -1,27 +1,37 @@
-import { Circle, Inbox, Send } from "lucide-react";
+import { Inbox, Send } from "lucide-react";
import { useGrimoire } from "@/core/state";
+import { useRelayInfo } from "@/hooks/useRelayInfo";
+import {
+ HoverCard,
+ HoverCardContent,
+ HoverCardTrigger,
+} from "@/components/ui/hover-card";
+import { cn } from "@/lib/utils";
export interface RelayLinkProps {
url: string;
read?: boolean;
write?: boolean;
- connected?: boolean;
className?: string;
+ urlClassname?: string;
+ iconClassname?: string;
}
/**
* RelayLink - Clickable relay URL component
- * Displays relay URL with connection status indicator and read/write badges
+ * Displays relay URL with read/write badges and tooltips
* Opens relay detail window on click
*/
export function RelayLink({
url,
+ urlClassname,
+ iconClassname,
read = false,
write = false,
- connected = true,
className,
}: RelayLinkProps) {
const { addWindow } = useGrimoire();
+ const relayInfo = useRelayInfo(url);
const handleClick = () => {
addWindow("relay", { url }, `Relay ${url}`);
@@ -29,29 +39,72 @@ export function RelayLink({
return (
-
-
-
{url}
+
+ {relayInfo?.icon && (
+

+ )}
+
{url}
{read && (
-
-
-
+
+
+
+
+
+
+ e.stopPropagation()}
+ >
+
+
Read / Inbox
+
+ This relay is used to read events. Your client will fetch
+ events from this relay when loading your feed or searching for
+ content.
+
+
+
+
)}
{write && (
-
-
-
+
+
+
+
+
+
+ e.stopPropagation()}
+ >
+
+
Write / Outbox
+
+ This relay is used to publish events. When you create a post
+ or update your profile, it will be sent to this relay for
+ others to discover.
+
+
+
+
)}
diff --git a/src/components/nostr/kinds/Kind10002DetailRenderer.tsx b/src/components/nostr/kinds/Kind10002DetailRenderer.tsx
new file mode 100644
index 0000000..e9ea3c7
--- /dev/null
+++ b/src/components/nostr/kinds/Kind10002DetailRenderer.tsx
@@ -0,0 +1,67 @@
+import { NostrEvent } from "@/types/nostr";
+import { getInboxes, getOutboxes } from "applesauce-core/helpers/mailboxes";
+import { RelayLink } from "../RelayLink";
+
+interface RelayWithMode {
+ url: string;
+ read: boolean;
+ write: boolean;
+}
+
+/**
+ * Kind 10002 Detail Renderer - NIP-65 Relay List Metadata (Detail View)
+ * Shows full relay list with read/write indicators
+ */
+export function Kind10002DetailRenderer({ event }: { event: NostrEvent }) {
+ const inboxRelays = getInboxes(event);
+ const outboxRelays = getOutboxes(event);
+
+ // Combine into unified list with read/write flags
+ const relayMap = new Map
();
+
+ inboxRelays.forEach((url) => {
+ const existing = relayMap.get(url);
+ if (existing) {
+ existing.read = true;
+ } else {
+ relayMap.set(url, { url, read: true, write: false });
+ }
+ });
+
+ outboxRelays.forEach((url) => {
+ const existing = relayMap.get(url);
+ if (existing) {
+ existing.write = true;
+ } else {
+ relayMap.set(url, { url, read: false, write: true });
+ }
+ });
+
+ const allRelays = Array.from(relayMap.values());
+
+ if (allRelays.length === 0) {
+ return (
+
+ No relays configured
+
+ );
+ }
+
+ return (
+
+ {/*
+
Relay List ({allRelays.length})
+ */}
+ {allRelays.map((relay) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/nostr/kinds/Kind10002Renderer.tsx b/src/components/nostr/kinds/Kind10002Renderer.tsx
new file mode 100644
index 0000000..3bfe980
--- /dev/null
+++ b/src/components/nostr/kinds/Kind10002Renderer.tsx
@@ -0,0 +1,69 @@
+import { BaseEventProps, BaseEventContainer } from "./BaseEventRenderer";
+import { getInboxes, getOutboxes } from "applesauce-core/helpers/mailboxes";
+import { RelayLink } from "../RelayLink";
+
+interface RelayWithMode {
+ url: string;
+ read: boolean;
+ write: boolean;
+}
+
+/**
+ * Kind 10002 Renderer - NIP-65 Relay List Metadata (Feed View)
+ * Shows relay list with read/write indicators
+ */
+export function Kind10002Renderer({ event }: BaseEventProps) {
+ const inboxRelays = getInboxes(event);
+ const outboxRelays = getOutboxes(event);
+
+ // Combine into unified list with read/write flags
+ const relayMap = new Map();
+
+ inboxRelays.forEach((url) => {
+ const existing = relayMap.get(url);
+ if (existing) {
+ existing.read = true;
+ } else {
+ relayMap.set(url, { url, read: true, write: false });
+ }
+ });
+
+ outboxRelays.forEach((url) => {
+ const existing = relayMap.get(url);
+ if (existing) {
+ existing.write = true;
+ } else {
+ relayMap.set(url, { url, read: false, write: true });
+ }
+ });
+
+ const allRelays = Array.from(relayMap.values());
+
+ if (allRelays.length === 0) {
+ return (
+
+
+ No relays configured
+
+
+ );
+ }
+
+ return (
+
+
+ {allRelays.map((relay) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx
index c0ddc0a..e1b2b04 100644
--- a/src/components/nostr/kinds/index.tsx
+++ b/src/components/nostr/kinds/index.tsx
@@ -9,6 +9,7 @@ import { Kind22Renderer } from "./Kind22Renderer";
import { Kind1063Renderer } from "./Kind1063Renderer";
import { Kind9735Renderer } from "./Kind9735Renderer";
import { Kind9802Renderer } from "./Kind9802Renderer";
+import { Kind10002Renderer } from "./Kind10002Renderer";
import { Kind30023Renderer } from "./Kind30023Renderer";
import { NostrEvent } from "@/types/nostr";
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
@@ -30,6 +31,7 @@ const kindRenderers: Record> = {
1111: Kind1Renderer, // Post
9735: Kind9735Renderer, // Zap Receipt
9802: Kind9802Renderer, // Highlight
+ 10002: Kind10002Renderer, // Relay List Metadata (NIP-65)
30023: Kind30023Renderer, // Long-form Article
};
diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx
index de7a641..7c90917 100644
--- a/src/components/nostr/user-menu.tsx
+++ b/src/components/nostr/user-menu.tsx
@@ -112,7 +112,9 @@ export default function UserMenu() {
{relays.all.map((relay) => (