mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-15 17:19:27 +02:00
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:
108
src/components/nostr/ChannelLink.tsx
Normal file
108
src/components/nostr/ChannelLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user