mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 06:57:07 +02:00
feat: render relay lists
This commit is contained in:
3
TODO.md
3
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.
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
67
src/components/nostr/kinds/Kind10002DetailRenderer.tsx
Normal file
67
src/components/nostr/kinds/Kind10002DetailRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
src/components/nostr/kinds/Kind10002Renderer.tsx
Normal file
69
src/components/nostr/kinds/Kind10002Renderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user