feat: render relay lists

This commit is contained in:
Alejandro Gómez
2025-12-11 11:16:47 +01:00
parent 1866a000d7
commit a1d145905c
7 changed files with 221 additions and 23 deletions

View File

@@ -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.

View File

@@ -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) {
<Kind30023DetailRenderer event={event} />
) : event.kind === kinds.Highlights ? (
<Kind9802DetailRenderer event={event} />
) : event.kind === kinds.RelayList ? (
<Kind10002DetailRenderer event={event} />
) : (
<KindRenderer event={event} />
)}

View File

@@ -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 (
<div
className={`flex items-center justify-between gap-2 cursor-crosshair hover:bg-muted/50 ${className || ""}`}
className={cn(
"flex items-center justify-between gap-2 cursor-crosshair hover:bg-muted/50",
className,
)}
onClick={handleClick}
>
<div className="flex items-center gap-1">
<Circle
className={`size-2 ${
connected
? "fill-green-500 text-green-500"
: "fill-muted-foreground text-muted-foreground"
}`}
/>
<span className="text-xs">{url}</span>
<div className="flex items-center gap-1.5 min-w-0">
{relayInfo?.icon && (
<img
src={relayInfo.icon}
alt=""
className={cn("size-3 flex-shrink-0 rounded-sm", iconClassname)}
/>
)}
<span className={cn("text-xs truncate", urlClassname)}>{url}</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{read && (
<div title="Read (inbox)">
<Inbox className="size-3 text-muted-foreground" />
</div>
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
<div className="cursor-help">
<Inbox
className={cn("size-3 text-muted-foreground", iconClassname)}
/>
</div>
</HoverCardTrigger>
<HoverCardContent
side="top"
className="w-64 text-xs"
onClick={(e) => e.stopPropagation()}
>
<div className="space-y-1">
<div className="font-semibold">Read / Inbox</div>
<p className="text-muted-foreground">
This relay is used to read events. Your client will fetch
events from this relay when loading your feed or searching for
content.
</p>
</div>
</HoverCardContent>
</HoverCard>
)}
{write && (
<div title="Write (outbox)">
<Send className="size-3 text-muted-foreground" />
</div>
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
<div className="cursor-help">
<Send
className={cn("size-3 text-muted-foreground", iconClassname)}
/>
</div>
</HoverCardTrigger>
<HoverCardContent
side="top"
className="w-64 text-xs"
onClick={(e) => e.stopPropagation()}
>
<div className="space-y-1">
<div className="font-semibold">Write / Outbox</div>
<p className="text-muted-foreground">
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.
</p>
</div>
</HoverCardContent>
</HoverCard>
)}
</div>
</div>

View File

@@ -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<string, RelayWithMode>();
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 (
<div className="p-4 text-center text-muted-foreground text-sm">
No relays configured
</div>
);
}
return (
<div className="flex flex-col gap-2 p-4">
{/*
<h1 className="text-2xl font-bold">Relay List ({allRelays.length})</h1>
*/}
{allRelays.map((relay) => (
<RelayLink
key={relay.url}
url={relay.url}
read={relay.read}
write={relay.write}
urlClassname="text-md underline decoration-dotted"
iconClassname="size-4"
/>
))}
</div>
);
}

View File

@@ -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<string, RelayWithMode>();
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 (
<BaseEventContainer event={event}>
<div className="text-xs text-muted-foreground italic">
No relays configured
</div>
</BaseEventContainer>
);
}
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-0.5">
{allRelays.map((relay) => (
<RelayLink
key={relay.url}
url={relay.url}
read={relay.read}
write={relay.write}
className="py-0.5 hover:bg-none"
iconClassname="size-4"
urlClassname="underline decoration-dotted"
/>
))}
</div>
</BaseEventContainer>
);
}

View File

@@ -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<number, React.ComponentType<BaseEventProps>> = {
1111: Kind1Renderer, // Post
9735: Kind9735Renderer, // Zap Receipt
9802: Kind9802Renderer, // Highlight
10002: Kind10002Renderer, // Relay List Metadata (NIP-65)
30023: Kind30023Renderer, // Long-form Article
};

View File

@@ -112,7 +112,9 @@ export default function UserMenu() {
</DropdownMenuLabel>
{relays.all.map((relay) => (
<RelayLink
className="px-2 py-0.5"
className="px-2 py-1"
urlClassname="text-sm"
iconClassname="size-4"
key={relay.url}
url={relay.url}
read={relay.read}