feat: add kind 10009 (Public Chats) renderer with clickable group links

Implements rendering of NIP-51 kind 10009 (Public Chats list) events,
displaying NIP-29 groups similar to how relay lists are rendered.

Components:
- GroupLink: Clickable group component with icon and name
  - Fetches kind 39000 metadata from EventStore for group info
  - Displays group icon (if available) or chat icon fallback
  - Opens chat window on click with relay'group-id identifier

- PublicChatsRenderer: Renderer for kind 10009 events
  - Extracts "group" tags: ["group", "<group-id>", "<relay-url>"]
  - Displays each group as clickable link
  - Similar styling to relay list renderers

Integration:
- Registered kind 10009 renderer in kinds index
- Opens chat with NIP-29 protocol on click

Fixes:
- Fix missing type imports in NIP-29 adapter (Participant, ParticipantRole)
- Fix relayUrl type error in sendMessage (use validated variable)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gómez
2026-01-11 22:04:41 +01:00
parent 6d01ee33ef
commit 64b97e4926
4 changed files with 156 additions and 1 deletions

View File

@@ -0,0 +1,94 @@
import { MessageSquare } from "lucide-react";
import { useGrimoire } from "@/core/state";
import { cn } from "@/lib/utils";
import { use$ } from "applesauce-react/hooks";
import { map } from "rxjs/operators";
import eventStore from "@/services/event-store";
import { getTagValue } from "applesauce-core/helpers";
/**
* Format group identifier for display
* Shows just the group-id part without the relay URL
*/
function formatGroupIdForDisplay(groupId: string): string {
return groupId;
}
export interface GroupLinkProps {
groupId: string;
relayUrl: string;
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
*/
export function GroupLink({
groupId,
relayUrl,
className,
iconClassname,
}: GroupLinkProps) {
const { addWindow } = useGrimoire();
// Try to fetch group metadata (kind 39000) from EventStore
// NIP-29 metadata events use #d tag with group ID
const groupMetadata = use$(
() =>
eventStore
.timeline([{ kinds: [39000], "#d": [groupId], limit: 1 }])
.pipe(map((events) => events[0])),
[groupId],
);
// Extract group name from metadata if available
const groupName =
groupMetadata && groupMetadata.kind === 39000
? getTagValue(groupMetadata, "name") || groupId
: groupId;
// Extract group icon if available
const groupIcon =
groupMetadata && groupMetadata.kind === 39000
? getTagValue(groupMetadata, "picture")
: undefined;
const handleClick = () => {
// Open chat with NIP-29 format: relay'group-id
const identifier = `${relayUrl}'${groupId}`;
addWindow("chat", { protocol: "nip-29", identifier });
};
const displayName = formatGroupIdForDisplay(groupName);
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">
{groupIcon ? (
<img
src={groupIcon}
alt=""
className={cn("size-4 flex-shrink-0 rounded-sm", iconClassname)}
/>
) : (
<MessageSquare
className={cn(
"size-4 flex-shrink-0 text-muted-foreground",
iconClassname,
)}
/>
)}
<span className="text-xs truncate">{displayName}</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { BaseEventProps, BaseEventContainer } from "./BaseEventRenderer";
import { GroupLink } from "../GroupLink";
/**
* Extract group references from a kind 10009 event
* Groups are stored in "group" tags: ["group", "<group-id>", "<relay-url>", ...]
*/
function extractGroups(event: { tags: string[][] }): Array<{
groupId: string;
relayUrl: string;
}> {
const groups: Array<{ groupId: string; relayUrl: string }> = [];
for (const tag of event.tags) {
if (tag[0] === "group" && tag[1] && tag[2]) {
groups.push({
groupId: tag[1],
relayUrl: tag[2],
});
}
}
return groups;
}
/**
* Public Chats Renderer (Kind 10009)
* NIP-51 list of NIP-29 groups
* Displays each group as a clickable link with icon and name
*/
export function PublicChatsRenderer({ event }: BaseEventProps) {
const groups = extractGroups(event);
if (groups.length === 0) {
return (
<BaseEventContainer event={event}>
<div className="text-xs text-muted-foreground italic">
No public chats configured
</div>
</BaseEventContainer>
);
}
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-0.5">
{groups.map((group) => (
<GroupLink
key={`${group.relayUrl}'${group.groupId}`}
groupId={group.groupId}
relayUrl={group.relayUrl}
/>
))}
</div>
</BaseEventContainer>
);
}

View File

@@ -37,6 +37,7 @@ import { RepositoryStateRenderer } from "./RepositoryStateRenderer";
import { RepositoryStateDetailRenderer } from "./RepositoryStateDetailRenderer";
import { Kind39701Renderer } from "./BookmarkRenderer";
import { GenericRelayListRenderer } from "./GenericRelayListRenderer";
import { PublicChatsRenderer } from "./PublicChatsRenderer";
import { LiveActivityRenderer } from "./LiveActivityRenderer";
import { LiveActivityDetailRenderer } from "./LiveActivityDetailRenderer";
import { SpellRenderer, SpellDetailRenderer } from "./SpellRenderer";
@@ -89,6 +90,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
10317: Kind10317Renderer, // User Grasp List (NIP-34)
10006: GenericRelayListRenderer, // Blocked Relays (NIP-51)
10007: GenericRelayListRenderer, // Search Relays (NIP-51)
10009: PublicChatsRenderer, // Public Chats List (NIP-51)
10012: GenericRelayListRenderer, // Favorite Relays (NIP-51)
10050: GenericRelayListRenderer, // DM Relay List (NIP-51)
30002: GenericRelayListRenderer, // Relay Sets (NIP-51)

View File

@@ -8,6 +8,8 @@ import type {
ProtocolIdentifier,
ChatCapabilities,
LoadMessagesOptions,
Participant,
ParticipantRole,
} from "@/types/chat";
import type { NostrEvent } from "@/types/nostr";
import eventStore from "@/services/event-store";
@@ -370,7 +372,7 @@ export class Nip29Adapter extends ChatProtocolAdapter {
const event = await factory.sign(draft);
// Publish only to the group relay
await publishEventToRelays(event, [conversation?.metadata?.relayUrl]);
await publishEventToRelays(event, [relayUrl]);
}
/**