diff --git a/CLAUDE.md b/CLAUDE.md index 507ddfc..c5d0910 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,6 +186,38 @@ const text = getHighlightText(event); - Provides diagnostic UI with retry capability and error details - Error boundaries auto-reset when event changes +## Chat System + +**Current Status**: Only NIP-29 (relay-based groups) is supported. Other protocols are planned for future releases. + +**Architecture**: Protocol adapter pattern for supporting multiple Nostr messaging protocols: +- `src/lib/chat/adapters/base-adapter.ts` - Base interface all adapters implement +- `src/lib/chat/adapters/nip-29-adapter.ts` - NIP-29 relay groups (currently enabled) +- Other adapters (NIP-C7, NIP-17, NIP-28, NIP-53) are implemented but commented out + +**NIP-29 Group Format**: `relay'group-id` (wss:// prefix optional) +- Examples: `relay.example.com'bitcoin-dev`, `wss://nos.lol'welcome` +- Groups are hosted on a single relay that enforces membership and moderation +- Messages are kind 9, metadata is kind 39000, admins are kind 39001, members are kind 39002 + +**Key Components**: +- `src/components/ChatViewer.tsx` - Main chat interface (protocol-agnostic) +- `src/components/chat/ReplyPreview.tsx` - Shows reply context with scroll-to functionality +- `src/lib/chat-parser.ts` - Auto-detects protocol from identifier format +- `src/types/chat.ts` - Protocol-agnostic types (Conversation, Message, etc.) + +**Usage**: +```bash +chat relay.example.com'bitcoin-dev # Join NIP-29 group +chat wss://nos.lol'welcome # Join with explicit wss:// prefix +``` + +**Adding New Protocols** (for future work): +1. Create new adapter extending `ChatProtocolAdapter` in `src/lib/chat/adapters/` +2. Implement all required methods (parseIdentifier, resolveConversation, loadMessages, sendMessage) +3. Uncomment adapter registration in `src/lib/chat-parser.ts` and `src/components/ChatViewer.tsx` +4. Update command docs in `src/types/man.ts` if needed + ## Testing **Test Framework**: Vitest with node environment diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index 16506c8..f52ec79 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -1,13 +1,16 @@ -import { useMemo, useState, memo, useCallback } from "react"; +import { useMemo, useState, memo, useCallback, useRef } from "react"; import { use$ } from "applesauce-react/hooks"; import { from } from "rxjs"; -import { Virtuoso } from "react-virtuoso"; +import { Virtuoso, VirtuosoHandle } from "react-virtuoso"; +import { Reply } from "lucide-react"; +import accountManager from "@/services/accounts"; +import eventStore from "@/services/event-store"; import type { ChatProtocol, ProtocolIdentifier, Conversation, } from "@/types/chat"; -import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter"; +// import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter"; // Coming soon import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter"; import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter"; import type { Message } from "@/types/chat"; @@ -26,6 +29,54 @@ interface ChatViewerProps { customTitle?: string; } +/** + * ComposerReplyPreview - Shows who is being replied to in the composer + */ +const ComposerReplyPreview = memo(function ComposerReplyPreview({ + replyToId, + onClear, +}: { + replyToId: string; + onClear: () => void; +}) { + const replyEvent = use$(() => eventStore.event(replyToId), [replyToId]); + + if (!replyEvent) { + return ( +
+ + Replying to {replyToId.slice(0, 8)}... + + +
+ ); + } + + return ( +
+ + + + {replyEvent.content} + + +
+ ); +}); + /** * MessageItem - Memoized message component for performance */ @@ -33,19 +84,34 @@ const MessageItem = memo(function MessageItem({ message, adapter, conversation, + onReply, + canReply, + onScrollToMessage, }: { message: Message; adapter: ChatProtocolAdapter; conversation: Conversation; + onReply?: (messageId: string) => void; + canReply: boolean; + onScrollToMessage?: (messageId: string) => void; }) { return ( -
+
+ {canReply && onReply && ( + + )}
{message.event ? ( @@ -55,6 +121,7 @@ const MessageItem = memo(function MessageItem({ replyToId={message.replyTo} adapter={adapter} conversation={conversation} + onScrollToMessage={onScrollToMessage} /> )} @@ -82,6 +149,10 @@ export function ChatViewer({ }: ChatViewerProps) { const { addWindow } = useGrimoire(); + // Get active account + const activeAccount = use$(accountManager.active$); + const hasActiveAccount = !!activeAccount; + // Get the appropriate adapter for this protocol const adapter = useMemo(() => getAdapter(protocol), [protocol]); @@ -100,13 +171,37 @@ export function ChatViewer({ // Track reply context (which message is being replied to) const [replyTo, setReplyTo] = useState(); + // Ref to Virtuoso for programmatic scrolling + const virtuosoRef = useRef(null); + // Handle sending messages const handleSend = async (content: string, replyToId?: string) => { - if (!conversation) return; + if (!conversation || !hasActiveAccount) return; await adapter.sendMessage(conversation, content, replyToId); setReplyTo(undefined); // Clear reply context after sending }; + // Handle reply button click + const handleReply = useCallback((messageId: string) => { + setReplyTo(messageId); + }, []); + + // Handle scroll to message (when clicking on reply preview) + const handleScrollToMessage = useCallback( + (messageId: string) => { + if (!messages) return; + const index = messages.findIndex((m) => m.id === messageId); + if (index !== -1 && virtuosoRef.current) { + virtuosoRef.current.scrollToIndex({ + index, + align: "center", + behavior: "smooth", + }); + } + }, + [messages], + ); + // Handle NIP badge click const handleNipClick = useCallback(() => { if (conversation?.protocol === "nip-29") { @@ -158,6 +253,7 @@ export function ChatViewer({
{messages && messages.length > 0 ? ( )} style={{ height: "100%" }} @@ -178,71 +277,78 @@ export function ChatViewer({ )}
- {/* 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" - > -