From 32b4468c22c8a6306d99a090138198610a2c9050 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Jan 2026 08:09:05 +0000 Subject: [PATCH] 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 --- src/components/nostr/ChannelLink.tsx | 108 +++++++++ .../nostr/kinds/ChannelListRenderer.tsx | 229 +++++++++++++++++- 2 files changed, 331 insertions(+), 6 deletions(-) create mode 100644 src/components/nostr/ChannelLink.tsx 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
)}