diff --git a/src/components/nostr/ChannelLink.tsx b/src/components/nostr/ChannelLink.tsx
new file mode 100644
index 0000000..efe1b20
--- /dev/null
+++ b/src/components/nostr/ChannelLink.tsx
@@ -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 (
+
+
+ {channelIcon ? (
+

+ ) : (
+
+ )}
+
{channelName}
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/ChannelListRenderer.tsx b/src/components/nostr/kinds/ChannelListRenderer.tsx
index b8a8e37..65b59d1 100644
--- a/src/components/nostr/kinds/ChannelListRenderer.tsx
+++ b/src/components/nostr/kinds/ChannelListRenderer.tsx
@@ -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();
+ 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 (
@@ -57,6 +178,16 @@ export function ChannelListRenderer({ event }: BaseEventProps) {
{channels.length} channels
+
+
+ {channels.map((channel) => (
+
+ ))}
+
);
@@ -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();
+ 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 (
@@ -76,11 +288,16 @@ export function ChannelListDetailRenderer({ event }: { event: NostrEvent }) {
{channels.length > 0 ? (
-
}
- />
+
+ {channels.map((channel) => (
+
+ ))}
+
) : (
No channels
)}