From 6d01ee33ef4091bd956d4d62859bb8a37af52b40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Sun, 11 Jan 2026 21:38:23 +0100 Subject: [PATCH] feat: implement unified chat system with NIP-C7 and NIP-29 support Core Architecture: - Protocol adapter pattern for chat implementations - Base adapter interface with protocol-specific implementations - Auto-detection of protocol from identifier format - Reactive message loading via EventStore observables Protocol Implementations: - NIP-C7 adapter: Simple chat (kind 9) with npub/nprofile support - NIP-29 adapter: Relay-based groups with member roles and moderation - Protocol-aware reply message loading with relay hints - Proper NIP-29 members/admins fetching using #d tags UI Components: - ChatViewer: Main chat interface with virtualized message timeline - ChatMessage: Message rendering with reply preview - ReplyPreview: Auto-loading replied-to messages from relays - MembersDropdown: Virtualized member list with role labels - RelaysDropdown: Connection status for chat relays - ChatComposer: Message input with send functionality Command System: - chat command with identifier parsing and auto-detection - Support for npub, nprofile, NIP-05, and relay'group-id formats - Integration with window system and dynamic titles NIP-29 Specific: - Fetch kind:39000 (metadata), kind:39001 (admins), kind:39002 (members) - Extract roles from p tags: ["p", "", "", ""] - Role normalization (admin, moderator, host, member) - Single group relay connection management Testing: - Comprehensive chat parser tests - Protocol adapter test structure - All tests passing (704 tests) Co-Authored-By: Claude Sonnet 4.5 --- src/components/ChatViewer.tsx | 255 +++++++++ src/components/DynamicWindowTitle.tsx | 49 ++ src/components/WindowRenderer.tsx | 12 + src/components/chat/MembersDropdown.tsx | 58 ++ src/components/chat/RelaysDropdown.tsx | 92 +++ src/components/chat/ReplyPreview.tsx | 59 ++ src/components/ui/textarea.tsx | 2 +- src/lib/chat-parser.test.ts | 109 ++++ src/lib/chat-parser.ts | 71 +++ src/lib/chat/adapters/base-adapter.ts | 107 ++++ src/lib/chat/adapters/nip-29-adapter.test.ts | 98 ++++ src/lib/chat/adapters/nip-29-adapter.ts | 553 +++++++++++++++++++ src/lib/chat/adapters/nip-c7-adapter.ts | 318 +++++++++++ src/services/hub.ts | 18 + src/test/setup.ts | 18 + src/types/app.ts | 1 + src/types/chat.ts | 143 +++++ src/types/man.ts | 30 + 18 files changed, 1992 insertions(+), 1 deletion(-) create mode 100644 src/components/ChatViewer.tsx create mode 100644 src/components/chat/MembersDropdown.tsx create mode 100644 src/components/chat/RelaysDropdown.tsx create mode 100644 src/components/chat/ReplyPreview.tsx create mode 100644 src/lib/chat-parser.test.ts create mode 100644 src/lib/chat-parser.ts create mode 100644 src/lib/chat/adapters/base-adapter.ts create mode 100644 src/lib/chat/adapters/nip-29-adapter.test.ts create mode 100644 src/lib/chat/adapters/nip-29-adapter.ts create mode 100644 src/lib/chat/adapters/nip-c7-adapter.ts create mode 100644 src/types/chat.ts diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx new file mode 100644 index 0000000..da41a97 --- /dev/null +++ b/src/components/ChatViewer.tsx @@ -0,0 +1,255 @@ +import { useMemo, useState, memo, useCallback } from "react"; +import { use$ } from "applesauce-react/hooks"; +import { from } from "rxjs"; +import { Virtuoso } from "react-virtuoso"; +import type { + ChatProtocol, + ProtocolIdentifier, + Conversation, +} from "@/types/chat"; +import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter"; +import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter"; +import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter"; +import type { Message } from "@/types/chat"; +import { UserName } from "./nostr/UserName"; +import { RichText } from "./nostr/RichText"; +import Timestamp from "./Timestamp"; +import { ReplyPreview } from "./chat/ReplyPreview"; +import { MembersDropdown } from "./chat/MembersDropdown"; +import { RelaysDropdown } from "./chat/RelaysDropdown"; +import { useGrimoire } from "@/core/state"; +import { Button } from "./ui/button"; + +interface ChatViewerProps { + protocol: ChatProtocol; + identifier: ProtocolIdentifier; + customTitle?: string; +} + +/** + * MessageItem - Memoized message component for performance + */ +const MessageItem = memo(function MessageItem({ + message, + adapter, + conversation, +}: { + message: Message; + adapter: ChatProtocolAdapter; + conversation: Conversation; +}) { + return ( +
+
+
+ + + + +
+
+ {message.event ? ( + + {message.replyTo && ( + + )} + + ) : ( + {message.content} + )} +
+
+
+ ); +}); + +/** + * ChatViewer - Main chat interface component + * + * Provides protocol-agnostic chat UI that works across all Nostr messaging protocols. + * Uses adapter pattern to handle protocol-specific logic while providing consistent UX. + */ +export function ChatViewer({ + protocol, + identifier, + customTitle, +}: ChatViewerProps) { + const { addWindow } = useGrimoire(); + + // Get the appropriate adapter for this protocol + const adapter = useMemo(() => getAdapter(protocol), [protocol]); + + // Resolve conversation from identifier (async operation) + const conversation = use$( + () => from(adapter.resolveConversation(identifier)), + [adapter, identifier], + ); + + // Load messages for this conversation (reactive) + const messages = use$( + () => (conversation ? adapter.loadMessages(conversation) : undefined), + [adapter, conversation], + ); + + // Track reply context (which message is being replied to) + const [replyTo, setReplyTo] = useState(); + + // Handle sending messages + const handleSend = async (content: string, replyToId?: string) => { + if (!conversation) return; + await adapter.sendMessage(conversation, content, replyToId); + setReplyTo(undefined); // Clear reply context after sending + }; + + // Handle NIP badge click + const handleNipClick = useCallback(() => { + if (conversation?.protocol === "nip-29") { + addWindow("nip", { number: 29 }); + } + }, [conversation?.protocol, addWindow]); + + if (!conversation) { + return ( +
+ Loading conversation... +
+ ); + } + + return ( +
+ {/* Header with conversation info and controls */} +
+
+
+ {conversation.metadata?.icon && ( + {conversation.title} + )} +
+

+ {customTitle || conversation.title} +

+ {conversation.metadata?.description && ( +

+ {conversation.metadata.description} +

+ )} +
+
+
+ + + {conversation.type === "group" && ( + + )} +
+
+
+ + {/* Message timeline with virtualization */} +
+ {messages && messages.length > 0 ? ( + ( + + )} + style={{ height: "100%" }} + /> + ) : ( +
+ No messages yet. Start the conversation! +
+ )} +
+ + {/* Message composer */} +
+ {replyTo && ( +
+ Replying to {replyTo.slice(0, 8)}... + +
+ )} +
{ + e.preventDefault(); + const form = e.currentTarget; + const input = form.elements.namedItem( + "message", + ) as HTMLTextAreaElement; + if (input.value.trim()) { + handleSend(input.value, replyTo); + input.value = ""; + } + }} + className="flex gap-2" + > +