feat: Add clickable channel links to kind 10005 renderer

Updates the kind 10005 (Public Channels List) renderer to match the functionality of kind 10009 (Public Chats/Groups List) by providing clickable links that open NIP-28 channels.

## Changes

**New Component** (src/components/nostr/ChannelLink.tsx):
- Clickable NIP-28 channel component similar to GroupLink
- Displays channel name from kind 40/41 metadata or channel ID
- Shows channel icon if available (from kind 41 picture field)
- Opens ChatViewer with NIP-28 protocol on click
- Uses nevent encoding with relay hints when available

**Updated Renderer** (src/components/nostr/kinds/ChannelListRenderer.tsx):
- Feed view now shows list of clickable ChannelLink components
- Batch-loads kind 40 (creation) and kind 41 (metadata) events
- Displays channel names instead of just event IDs
- Detail view updated to match feed view functionality
- Removes dependency on EventRefListFull in favor of ChannelLink

## User Experience

Before:
- Kind 10005 showed channel count but no way to open channels
- Had to manually copy event IDs to open channels

After:
- Click any channel in the list to open it in ChatViewer
- Channel names load dynamically from metadata
- Icons displayed if available
- Consistent UX with kind 10009 group lists

## Architecture

Follows the same pattern as PublicChatsRenderer (kind 10009):
1. Extract event pointers from e-tags
2. Batch-load creation events (kind 40)
3. Batch-load metadata events (kind 41)
4. Render clickable links with metadata
5. Open chat on click with proper protocol identifier
This commit is contained in:
Claude
2026-01-18 08:09:05 +00:00
parent 42694be80d
commit 32b4468c22
2 changed files with 331 additions and 6 deletions

View File

@@ -0,0 +1,108 @@
import { Hash } from "lucide-react";
import { useGrimoire } from "@/core/state";
import { cn } from "@/lib/utils";
import { nip19 } from "nostr-tools";
import { use$ } from "applesauce-react/hooks";
import eventStore from "@/services/event-store";
import type { NostrEvent } from "@/types/nostr";
import { useMemo } from "react";
export interface ChannelLinkProps {
channelId: string;
relayHints?: string[];
className?: string;
iconClassname?: string;
}
/**
* ChannelLink - Clickable NIP-28 channel component
* Displays channel name (from kind 40/41 events) or channel ID
* Opens chat window on click
*/
export function ChannelLink({
channelId,
relayHints = [],
className,
iconClassname,
}: ChannelLinkProps) {
const { addWindow } = useGrimoire();
// Fetch the kind 40 creation event
const kind40Event = use$(() => eventStore.event(channelId), [channelId]);
// Fetch the latest kind 41 metadata for this channel (if kind 40 is loaded)
const kind41Event = use$(
() =>
kind40Event
? eventStore.timeline({
kinds: [41],
authors: [kind40Event.pubkey],
"#e": [channelId],
limit: 1,
})
: undefined,
[channelId, kind40Event?.pubkey],
)?.[0];
// Parse metadata from kind 41 or fall back to kind 40 content
const { channelName, channelIcon } = useMemo(() => {
if (kind41Event) {
try {
const metadata = JSON.parse(kind41Event.content);
return {
channelName:
metadata.name || kind40Event?.content || channelId.slice(0, 8),
channelIcon: metadata.picture,
};
} catch {
// Invalid JSON, fall back
}
}
return {
channelName: kind40Event?.content || channelId.slice(0, 8),
channelIcon: undefined,
};
}, [kind41Event, kind40Event, channelId]);
const handleClick = () => {
// Create nevent with relay hints if available, otherwise use note
const identifier =
relayHints.length > 0
? nip19.neventEncode({ id: channelId, relays: relayHints })
: nip19.noteEncode(channelId);
addWindow("chat", {
protocol: "nip-28",
identifier,
});
};
return (
<div
className={cn(
"flex items-center gap-2 cursor-crosshair hover:bg-muted/50 rounded px-1 py-0.5 transition-colors",
className,
)}
onClick={handleClick}
>
<div className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
{channelIcon ? (
<img
src={channelIcon}
alt=""
className={cn("size-4 flex-shrink-0 rounded-sm", iconClassname)}
/>
) : (
<Hash
className={cn(
"size-4 flex-shrink-0 text-muted-foreground",
iconClassname,
)}
/>
)}
<span className="text-xs truncate">{channelName}</span>
</div>
</div>
);
}

View File

@@ -1,11 +1,16 @@
import { MessageCircle, Hash } from "lucide-react";
import { useEffect } from "react";
import { use$ } from "applesauce-react/hooks";
import { map } from "rxjs/operators";
import { getEventPointerFromETag } from "applesauce-core/helpers";
import {
BaseEventProps,
BaseEventContainer,
ClickableEventTitle,
} from "./BaseEventRenderer";
import { EventRefListFull } from "../lists";
import { ChannelLink } from "../ChannelLink";
import eventStore from "@/services/event-store";
import pool from "@/services/relay-pool";
import type { NostrEvent } from "@/types/nostr";
import type { EventPointer } from "nostr-tools/nip19";
@@ -29,11 +34,127 @@ function getChannelPointers(event: NostrEvent): EventPointer[] {
/**
* Kind 10005 Renderer - Public Chats List (Feed View)
* NIP-51 list of public chat channels (kind 40)
* Displays each channel as a clickable link with icon and name
* Batch-loads metadata for all channels to show their names
* Note: This is different from kind 10009 which is for NIP-29 groups
*/
export function ChannelListRenderer({ event }: BaseEventProps) {
const channels = getChannelPointers(event);
// Extract channel IDs and relay hints
const channelIds = channels.map((p) => p.id);
const relayHintsByChannel = new Map(
channels.map((p) => [p.id, p.relays || []]),
);
// Batch-load kind 40 creation events for all channels
useEffect(() => {
if (channelIds.length === 0) return;
console.log(
`[ChannelListRenderer] Fetching creation events for ${channelIds.length} channels`,
);
// Merge all relay hints
const allRelayHints = Array.from(
new Set(channels.flatMap((p) => p.relays || [])),
);
// Subscribe to fetch kind 40 creation events
const subscription = pool
.subscription(
allRelayHints.length > 0
? allRelayHints
: ["wss://relay.damus.io", "wss://nos.lol"],
[{ kinds: [40], ids: channelIds }],
{ eventStore },
)
.subscribe({
next: (response) => {
if (typeof response === "string") {
console.log("[ChannelListRenderer] EOSE received for kind 40");
} else {
console.log(
`[ChannelListRenderer] Received kind 40: ${response.id.slice(0, 8)}...`,
);
}
},
});
return () => {
subscription.unsubscribe();
};
}, [
channelIds.join(","),
channels.map((p) => p.relays?.join(",") || "").join(";"),
]);
// Batch-load kind 41 metadata for all channels
const kind40Events = use$(
() =>
channelIds.length > 0
? eventStore.timeline({ kinds: [40], ids: channelIds }).pipe(
map((events) => {
const eventMap = new Map<string, NostrEvent>();
for (const evt of events) {
eventMap.set(evt.id, evt);
}
return eventMap;
}),
)
: undefined,
[channelIds.join(",")],
);
// Fetch kind 41 metadata for channels we have kind 40 for
const kind40Pubkeys = kind40Events
? Array.from(
new Set(Array.from(kind40Events.values()).map((e) => e.pubkey)),
)
: [];
useEffect(() => {
if (kind40Pubkeys.length === 0 || channelIds.length === 0) return;
console.log(
`[ChannelListRenderer] Fetching metadata for ${channelIds.length} channels from ${kind40Pubkeys.length} creators`,
);
// Merge all relay hints
const allRelayHints = Array.from(
new Set(channels.flatMap((p) => p.relays || [])),
);
// Subscribe to fetch kind 41 metadata events
const subscription = pool
.subscription(
allRelayHints.length > 0
? allRelayHints
: ["wss://relay.damus.io", "wss://nos.lol"],
[{ kinds: [41], authors: kind40Pubkeys, "#e": channelIds }],
{ eventStore },
)
.subscribe({
next: (response) => {
if (typeof response === "string") {
console.log("[ChannelListRenderer] EOSE received for kind 41");
} else {
console.log(
`[ChannelListRenderer] Received kind 41: ${response.id.slice(0, 8)}...`,
);
}
},
});
return () => {
subscription.unsubscribe();
};
}, [
kind40Pubkeys.join(","),
channelIds.join(","),
channels.map((p) => p.relays?.join(",") || "").join(";"),
]);
if (channels.length === 0) {
return (
<BaseEventContainer event={event}>
@@ -57,6 +178,16 @@ export function ChannelListRenderer({ event }: BaseEventProps) {
<Hash className="size-3.5 text-muted-foreground" />
<span>{channels.length} channels</span>
</div>
<div className="flex flex-col gap-0.5">
{channels.map((channel) => (
<ChannelLink
key={channel.id}
channelId={channel.id}
relayHints={relayHintsByChannel.get(channel.id)}
/>
))}
</div>
</div>
</BaseEventContainer>
);
@@ -68,6 +199,87 @@ export function ChannelListRenderer({ event }: BaseEventProps) {
export function ChannelListDetailRenderer({ event }: { event: NostrEvent }) {
const channels = getChannelPointers(event);
// Extract channel IDs and relay hints
const channelIds = channels.map((p) => p.id);
const relayHintsByChannel = new Map(
channels.map((p) => [p.id, p.relays || []]),
);
// Batch-load kind 40 creation events for all channels
useEffect(() => {
if (channelIds.length === 0) return;
const allRelayHints = Array.from(
new Set(channels.flatMap((p) => p.relays || [])),
);
const subscription = pool
.subscription(
allRelayHints.length > 0
? allRelayHints
: ["wss://relay.damus.io", "wss://nos.lol"],
[{ kinds: [40], ids: channelIds }],
{ eventStore },
)
.subscribe();
return () => {
subscription.unsubscribe();
};
}, [
channelIds.join(","),
channels.map((p) => p.relays?.join(",") || "").join(";"),
]);
// Batch-load kind 41 metadata for all channels
const kind40Events = use$(
() =>
channelIds.length > 0
? eventStore.timeline({ kinds: [40], ids: channelIds }).pipe(
map((events) => {
const eventMap = new Map<string, NostrEvent>();
for (const evt of events) {
eventMap.set(evt.id, evt);
}
return eventMap;
}),
)
: undefined,
[channelIds.join(",")],
);
const kind40Pubkeys = kind40Events
? Array.from(
new Set(Array.from(kind40Events.values()).map((e) => e.pubkey)),
)
: [];
useEffect(() => {
if (kind40Pubkeys.length === 0 || channelIds.length === 0) return;
const allRelayHints = Array.from(
new Set(channels.flatMap((p) => p.relays || [])),
);
const subscription = pool
.subscription(
allRelayHints.length > 0
? allRelayHints
: ["wss://relay.damus.io", "wss://nos.lol"],
[{ kinds: [41], authors: kind40Pubkeys, "#e": channelIds }],
{ eventStore },
)
.subscribe();
return () => {
subscription.unsubscribe();
};
}, [
kind40Pubkeys.join(","),
channelIds.join(","),
channels.map((p) => p.relays?.join(",") || "").join(";"),
]);
return (
<div className="flex flex-col gap-6 p-4">
<div className="flex items-center gap-2">
@@ -76,11 +288,16 @@ export function ChannelListDetailRenderer({ event }: { event: NostrEvent }) {
</div>
{channels.length > 0 ? (
<EventRefListFull
eventPointers={channels}
label="Channels"
icon={<Hash className="size-5" />}
/>
<div className="flex flex-col gap-1">
{channels.map((channel) => (
<ChannelLink
key={channel.id}
channelId={channel.id}
relayHints={relayHintsByChannel.get(channel.id)}
className="p-2"
/>
))}
</div>
) : (
<div className="text-sm text-muted-foreground italic">No channels</div>
)}