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

+ ) : (
+
+ )}
+
{displayName}
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/PublicChatsRenderer.tsx b/src/components/nostr/kinds/PublicChatsRenderer.tsx
new file mode 100644
index 0000000..03d5f20
--- /dev/null
+++ b/src/components/nostr/kinds/PublicChatsRenderer.tsx
@@ -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", "", "", ...]
+ */
+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 (
+
+
+ No public chats configured
+
+
+ );
+ }
+
+ return (
+
+
+ {groups.map((group) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx
index da8f34c..54201fb 100644
--- a/src/components/nostr/kinds/index.tsx
+++ b/src/components/nostr/kinds/index.tsx
@@ -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> = {
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)
diff --git a/src/lib/chat/adapters/nip-29-adapter.ts b/src/lib/chat/adapters/nip-29-adapter.ts
index 3cd04c2..c17f4a0 100644
--- a/src/lib/chat/adapters/nip-29-adapter.ts
+++ b/src/lib/chat/adapters/nip-29-adapter.ts
@@ -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]);
}
/**