feat(chat): add Communikey support to group list viewer

Updates PublicChatsRenderer and GroupLink to properly display Communikeys:

- PublicChatsRenderer now detects Communikeys (pubkey-based groups)
  - Splits groups into Communikeys vs regular NIP-29 groups
  - Fetches kind 0 (profile) for Communikeys
  - Fetches kind 39000 (group metadata) for NIP-29 groups
  - Merges both metadata types into single map

- GroupLink now handles both metadata types
  - kind 39000: NIP-29 group metadata (name, picture from tags)
  - kind 0: Communikey profile (name, picture from JSON content)
  - Automatically detects and opens with correct protocol

This enables Communikeys stored in kind 10009 group lists to show
proper names and pictures from their profile metadata, just like
regular NIP-29 groups.
This commit is contained in:
Claude
2026-01-20 12:39:12 +00:00
parent f72991e7c7
commit 1cdbb22694
2 changed files with 91 additions and 40 deletions

View File

@@ -16,15 +16,15 @@ function formatRelayForDisplay(url: string): string {
export interface GroupLinkProps {
groupId: string;
relayUrl: string;
metadata?: NostrEvent; // Optional pre-loaded metadata
metadata?: NostrEvent; // Optional pre-loaded metadata (kind 39000 for NIP-29, kind 0 for Communikey)
className?: string;
iconClassname?: string;
}
/**
* GroupLink - Clickable NIP-29 group component
* Displays group name (from kind 39000 metadata) or group ID
* Opens chat window on click
* GroupLink - Clickable group component for NIP-29 and Communikey
* Displays group name from metadata (kind 39000 for NIP-29, kind 0 for Communikey) or group ID
* Opens chat window on click with automatic protocol detection
*
* Special case: "_" group ID represents the unmanaged relay top-level group
*/
@@ -42,21 +42,32 @@ export function GroupLink({
// Extract group name from metadata if available
let groupName: string;
let groupIcon: string | undefined;
if (isUnmanagedGroup) {
// For "_" groups, show the relay name
groupName = formatRelayForDisplay(relayUrl);
} else if (metadata && metadata.kind === 39000) {
groupName = getTagValue(metadata, "name") || groupId;
} else if (metadata) {
if (metadata.kind === 39000) {
// NIP-29 group metadata
groupName = getTagValue(metadata, "name") || groupId;
groupIcon = getTagValue(metadata, "picture");
} else if (metadata.kind === 0) {
// Communikey profile metadata
try {
const profile = JSON.parse(metadata.content);
groupName = profile.name || groupId;
groupIcon = profile.picture;
} catch {
groupName = groupId;
}
} else {
groupName = groupId;
}
} else {
groupName = groupId;
}
// Extract group icon if available (not applicable for "_" groups)
const groupIcon =
!isUnmanagedGroup && metadata && metadata.kind === 39000
? getTagValue(metadata, "picture")
: undefined;
const handleClick = async () => {
// Check if this is a Communikey (group ID is pubkey with kind 10222)
if (await isCommunikey(groupId, [relayUrl])) {

View File

@@ -5,6 +5,7 @@ import { BaseEventProps, BaseEventContainer } from "./BaseEventRenderer";
import { GroupLink } from "../GroupLink";
import eventStore from "@/services/event-store";
import pool from "@/services/relay-pool";
import { isValidPubkey } from "@/lib/chat-parser";
import type { NostrEvent } from "@/types/nostr";
/**
@@ -31,42 +32,60 @@ function extractGroups(event: { tags: string[][] }): Array<{
/**
* Public Chats Renderer (Kind 10009)
* NIP-51 list of NIP-29 groups
* NIP-51 list of NIP-29 groups and Communikeys
* Displays each group as a clickable link with icon and name
* Batch-loads metadata for all groups to show their names
* For Communikeys (pubkey-based groups), fetches kind 0 (profile) metadata
* For regular NIP-29 groups, fetches kind 39000 (group metadata)
*/
export function PublicChatsRenderer({ event }: BaseEventProps) {
const groups = extractGroups(event);
// Batch-load metadata for all groups at once
// Split groups into Communikeys (valid pubkeys) and regular NIP-29 groups
// Filter out "_" which is the unmanaged relay group (doesn't have metadata)
const groupIds = groups.map((g) => g.groupId).filter((id) => id !== "_");
const communikeyGroups = groups.filter(
(g) => g.groupId !== "_" && isValidPubkey(g.groupId),
);
const nip29Groups = groups.filter(
(g) => g.groupId !== "_" && !isValidPubkey(g.groupId),
);
const communikeyPubkeys = communikeyGroups.map((g) => g.groupId);
const nip29GroupIds = nip29Groups.map((g) => g.groupId);
// Subscribe to relays to fetch group metadata
// Extract unique relay URLs from groups
const relayUrls = Array.from(new Set(groups.map((g) => g.relayUrl)));
useEffect(() => {
if (groupIds.length === 0) return;
if (communikeyPubkeys.length === 0 && nip29GroupIds.length === 0) return;
console.log(
`[PublicChatsRenderer] Fetching metadata for ${groupIds.length} groups from ${relayUrls.length} relays`,
`[PublicChatsRenderer] Fetching metadata for ${communikeyPubkeys.length} Communikeys and ${nip29GroupIds.length} NIP-29 groups from ${relayUrls.length} relays`,
);
// Subscribe to fetch metadata events (kind 39000) from the group relays
// Build filters for both types
const filters = [];
// Fetch kind 0 (profiles) for Communikeys
if (communikeyPubkeys.length > 0) {
filters.push({ kinds: [0], authors: communikeyPubkeys });
}
// Fetch kind 39000 (group metadata) for regular NIP-29 groups
if (nip29GroupIds.length > 0) {
filters.push({ kinds: [39000], "#d": nip29GroupIds });
}
// Subscribe to fetch metadata from the group relays
const subscription = pool
.subscription(
relayUrls,
[{ kinds: [39000], "#d": groupIds }],
{ eventStore }, // Automatically add to store
)
.subscription(relayUrls, filters, { eventStore })
.subscribe({
next: (response) => {
if (typeof response === "string") {
console.log("[PublicChatsRenderer] EOSE received for metadata");
} else {
console.log(
`[PublicChatsRenderer] Received metadata: ${response.id.slice(0, 8)}...`,
`[PublicChatsRenderer] Received metadata k${response.kind}: ${response.id.slice(0, 8)}...`,
);
}
},
@@ -75,26 +94,47 @@ export function PublicChatsRenderer({ event }: BaseEventProps) {
return () => {
subscription.unsubscribe();
};
}, [groupIds.join(","), relayUrls.join(",")]);
}, [
communikeyPubkeys.join(","),
nip29GroupIds.join(","),
relayUrls.join(","),
]);
// Build combined metadata map from both kind 0 (Communikeys) and kind 39000 (NIP-29)
const groupMetadataMap = use$(
() =>
groupIds.length > 0
? eventStore.timeline([{ kinds: [39000], "#d": groupIds }]).pipe(
map((events) => {
const metadataMap = new Map<string, NostrEvent>();
for (const evt of events) {
// Extract group ID from #d tag
const dTag = evt.tags.find((t) => t[0] === "d");
if (dTag && dTag[1]) {
metadataMap.set(dTag[1], evt);
communikeyPubkeys.length > 0 || nip29GroupIds.length > 0
? eventStore
.timeline([
...(communikeyPubkeys.length > 0
? [{ kinds: [0], authors: communikeyPubkeys }]
: []),
...(nip29GroupIds.length > 0
? [{ kinds: [39000], "#d": nip29GroupIds }]
: []),
])
.pipe(
map((events) => {
const metadataMap = new Map<string, NostrEvent>();
for (const evt of events) {
if (evt.kind === 0) {
// Communikey profile (kind 0) - map by pubkey
metadataMap.set(evt.pubkey, evt);
} else if (evt.kind === 39000) {
// NIP-29 group metadata - map by d-tag (group ID)
const dTag = evt.tags.find((t) => t[0] === "d");
if (dTag && dTag[1]) {
metadataMap.set(dTag[1], evt);
}
}
}
}
return metadataMap;
}),
)
return metadataMap;
}),
)
: undefined,
[groupIds.join(",")],
[communikeyPubkeys.join(","), nip29GroupIds.join(",")],
);
if (groups.length === 0) {