From 8f1ceee78af0d28d5ad7b359f4a3a757db67debc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Jan 2026 15:09:25 +0000 Subject: [PATCH] refactor: use AGGREGATOR_RELAYS constant and remove design docs Replace hardcoded relay URLs with AGGREGATOR_RELAYS constant: - Import AGGREGATOR_RELAYS from @/services/loaders - Use constant for fallback relays in getThreadRelays() - Use constant for default relays in getDefaultRelays() Remove design documentation files (no longer needed): - docs/nip10-thread-chat-design.md - docs/nip10-thread-chat-examples.md - docs/nip10-thread-chat-summary.md This improves maintainability by centralizing relay configuration and reduces repository clutter. --- docs/nip10-thread-chat-design.md | 612 --------------- docs/nip10-thread-chat-examples.md | 972 ------------------------ docs/nip10-thread-chat-summary.md | 472 ------------ src/lib/chat/adapters/nip-10-adapter.ts | 13 +- 4 files changed, 5 insertions(+), 2064 deletions(-) delete mode 100644 docs/nip10-thread-chat-design.md delete mode 100644 docs/nip10-thread-chat-examples.md delete mode 100644 docs/nip10-thread-chat-summary.md diff --git a/docs/nip10-thread-chat-design.md b/docs/nip10-thread-chat-design.md deleted file mode 100644 index 59188ef..0000000 --- a/docs/nip10-thread-chat-design.md +++ /dev/null @@ -1,612 +0,0 @@ -# NIP-10 Thread Chat Design - -## Overview - -Support `chat nevent1...` / `chat note1...` to treat NIP-10 threaded conversations as chat interfaces. The conversation revolves around the thread root event, with all replies displayed as chat messages. - -## User Experience - -### Command - -```bash -chat nevent1qqsxyz... # Open thread as chat -chat note1abc... # Open thread as chat (converts to nevent internally) -``` - -### Visual Layout - -``` -┌─────────────────────────────────────┐ -│ [Root Event Title or Truncated] │ ← Header (conversation title) -├─────────────────────────────────────┤ -│ │ -│ ┌──────────────────────┐ │ -│ │ ROOT EVENT │ │ ← Centered, full feed renderer -│ │ (feed renderer) │ │ (BaseEventContainer, actions, etc.) -│ └──────────────────────┘ │ -│ │ -│ ────────────────────────────────── │ ← Separator -│ │ -│ Alice: Hey, what do you think? │ ← Replies as chat messages -│ └─ ↳ root │ (show what they're replying to) -│ │ -│ Bob: I agree with Alice! │ -│ └─ ↳ Alice │ -│ │ -│ ⚡ 1000 Alice │ ← Zaps as special messages -│ │ -│ [──────────────────] [Send] │ ← Composer -└─────────────────────────────────────┘ -``` - -## Architecture - -### 1. Protocol Identifier - -**New type in `src/types/chat.ts`**: - -```typescript -/** - * NIP-10 thread identifier (kind 1 note thread) - */ -export interface ThreadIdentifier { - type: "thread"; - /** Event pointer to the provided event (may be root or a reply) */ - value: EventPointer; - /** Relay hints from nevent encoding */ - relays?: string[]; -} -``` - -Update `ProtocolIdentifier` union: -```typescript -export type ProtocolIdentifier = - | GroupIdentifier - | LiveActivityIdentifier - | DMIdentifier - | NIP05Identifier - | ChannelIdentifier - | GroupListIdentifier - | ThreadIdentifier; // ← Add this -``` - -Update `ChatProtocol` type: -```typescript -export type ChatProtocol = - | "nip-c7" - | "nip-17" - | "nip-28" - | "nip-29" - | "nip-53" - | "nip-10"; // ← Add this -``` - -### 2. Chat Parser - -**Update `src/lib/chat-parser.ts`**: - -```typescript -import { Nip10Adapter } from "./chat/adapters/nip-10-adapter"; - -export function parseChatCommand(args: string[]): ChatCommandResult { - // ... existing code ... - - // Try each adapter in priority order - const adapters = [ - new Nip10Adapter(), // ← Add before others (to catch nevent/note) - new Nip29Adapter(), - new Nip53Adapter(), - ]; - - // ... rest of function ... -} -``` - -### 3. NIP-10 Adapter - -**New file: `src/lib/chat/adapters/nip-10-adapter.ts`** - -#### Core Methods - -**`parseIdentifier(input: string)`**: -- Match `nevent1...` or `note1...` strings -- Decode to EventPointer -- Return ThreadIdentifier if kind 1 or unknown kind -- Return null for other kinds (let other adapters handle) - -**`resolveConversation(identifier: ThreadIdentifier)`**: -1. Fetch provided event from relays -2. If kind ≠ 1, throw error -3. Parse NIP-10 references with `getNip10References(event)` -4. Find root event: - - If `refs.root` exists: fetch root event - - Else: provided event IS the root -5. Determine conversation relays (see Relay Strategy below) -6. Extract title from root content (first line or truncate to ~50 chars) -7. Build participants list from all p-tags in thread -8. Return Conversation object - -**`loadMessages(conversation: Conversation)`**: -1. Subscribe to: - - All kind 1 replies (e-tags pointing to root) - - All kind 7 reactions (e-tags pointing to root or replies) - - All kind 9735 zaps (e-tags pointing to root or replies) -2. Convert events to Message objects: - - Parse NIP-10 references to determine reply hierarchy - - For direct replies to root: `replyTo = root.id` - - For nested replies: `replyTo = refs.reply.e?.id` - - Zaps: extract amount, sender, recipient from zap event -3. Return Observable sorted by created_at - -**`loadMoreMessages(conversation: Conversation, before: number)`**: -- Same filter as loadMessages but with `until: before` -- One-shot request for pagination - -**`sendMessage(conversation: Conversation, content: string, options?)`**: -1. Create kind 1 event -2. Add NIP-10 tags: - - Root tag: `["e", rootId, rootRelay, "root", rootAuthor]` - - Reply tag (if replying to a reply): `["e", parentId, parentRelay, "reply", parentAuthor]` - - If replying directly to root: only root tag -3. Add p-tags: - - Root author - - Parent author (if different) - - All authors mentioned in parent event -4. Add emoji tags (NIP-30) if provided -5. Add imeta tags (NIP-92) for attachments -6. Publish to conversation relays - -**`sendReaction(conversation: Conversation, messageId: string, emoji: string)`**: -1. Create kind 7 event -2. Add tags: - - `["e", messageId]` - event being reacted to - - `["k", "1"]` - kind of event being reacted to - - `["p", messageAuthor]` - author of message -3. Add NIP-30 custom emoji tag if provided -4. Publish to conversation relays - -**`loadReplyMessage(conversation: Conversation, eventId: string)`**: -- Check EventStore first -- If not found, fetch from conversation relays -- Return NostrEvent or null - -**`getCapabilities()`**: -```typescript -return { - supportsEncryption: false, - supportsThreading: true, - supportsModeration: false, - supportsRoles: false, - supportsGroupManagement: false, - canCreateConversations: false, - requiresRelay: false, -}; -``` - -#### Relay Strategy - -Determine relays using this priority: - -1. **Root event relays** (from `eventStore` seen relays) -2. **Provided event relays** (from nevent relay hints) -3. **Root author outbox** (kind 10002 relay list) -4. **Active user's inbox** (for receiving replies) - -Merge all sources, deduplicate, limit to top 5-7 relays. - -Implementation: -```typescript -async function getThreadRelays( - rootEvent: NostrEvent, - providedEvent: NostrEvent, - providedRelays: string[] = [] -): Promise { - const relays = new Set(); - - // 1. Seen relays from EventStore - const rootSeenRelays = eventStore.getSeenRelays?.(rootEvent.id) || []; - rootSeenRelays.forEach(r => relays.add(normalizeURL(r))); - - // 2. Provided event hints - providedRelays.forEach(r => relays.add(normalizeURL(r))); - - // 3. Root author's outbox relays - const rootOutbox = await getOutboxRelays(rootEvent.pubkey); - rootOutbox.slice(0, 3).forEach(r => relays.add(normalizeURL(r))); - - // 4. Active user's outbox/inbox (for publishing replies) - const activePubkey = accountManager.active$.value?.pubkey; - if (activePubkey) { - const userOutbox = await getOutboxRelays(activePubkey); - userOutbox.slice(0, 2).forEach(r => relays.add(normalizeURL(r))); - } - - // Limit to 7 relays max - return Array.from(relays).slice(0, 7); -} -``` - -#### Event to Message Conversion - -```typescript -private eventToMessage( - event: NostrEvent, - conversationId: string, - rootId: string -): Message { - if (event.kind === 9735) { - // Zap receipt - return this.zapToMessage(event, conversationId, rootId); - } - - // Kind 1 reply - const refs = getNip10References(event); - - // Determine reply target - let replyTo: string | undefined; - if (refs.reply?.e) { - replyTo = refs.reply.e.id; // Replying to another reply - } else if (refs.root?.e) { - replyTo = refs.root.e.id; // Replying to root - } - - return { - id: event.id, - conversationId, - author: event.pubkey, - content: event.content, - timestamp: event.created_at, - type: "user", - replyTo, - protocol: "nip-10", - metadata: { - encrypted: false, - }, - event, - }; -} - -private zapToMessage( - zapReceipt: NostrEvent, - conversationId: string, - rootId: string -): Message { - const zapRequest = getZapRequest(zapReceipt); - const amount = getZapAmount(zapReceipt); - const sender = getZapSender(zapReceipt); - const recipient = getZapRecipient(zapReceipt); - - // Find what event is being zapped (from e-tag in zap receipt) - const eTag = zapReceipt.tags.find(t => t[0] === "e"); - const replyTo = eTag?.[1]; - - // Get comment from zap request - const comment = zapRequest?.content || ""; - - return { - id: zapReceipt.id, - conversationId, - author: sender || zapReceipt.pubkey, - content: comment, - timestamp: zapReceipt.created_at, - type: "zap", - replyTo, - protocol: "nip-10", - metadata: { - zapAmount: amount, - zapRecipient: recipient, - }, - event: zapReceipt, - }; -} -``` - -### 4. ChatViewer Changes - -**Update `src/components/ChatViewer.tsx`**: - -Add special rendering mode for NIP-10 threads: - -```typescript -export function ChatViewer({ - protocol, - identifier, - customTitle, - headerPrefix, -}: ChatViewerProps) { - // ... existing code ... - - // Check if this is a NIP-10 thread - const isThreadChat = protocol === "nip-10"; - - // Fetch root event for thread display - const rootEventId = conversation?.metadata?.rootEventId; - const rootEvent = use$( - () => rootEventId ? eventStore.event(rootEventId) : undefined, - [rootEventId] - ); - - return ( -
- {/* Header */} -
- {/* ... existing header ... */} -
- - {/* Message timeline */} -
- {/* NIP-10 Thread: Show root event at top */} - {isThreadChat && rootEvent && ( -
-
- -
-
-
- )} - - {/* Messages list (scrollable) */} - {messagesWithMarkers && messagesWithMarkers.length > 0 ? ( - - ) : ( -
- {isThreadChat - ? "No replies yet. Start the conversation!" - : "No messages yet. Start the conversation!"} -
- )} -
- - {/* Composer */} - {canSign ? ( -
- {/* ... existing composer ... */} -
- ) : ( -
- Sign in to reply -
- )} -
- ); -} -``` - -### 5. Conversation Metadata - -**Update `ConversationMetadata` in `src/types/chat.ts`**: - -```typescript -export interface ConversationMetadata { - // ... existing fields ... - - // NIP-10 thread - rootEventId?: string; // Thread root event ID - providedEventId?: string; // Original event from nevent (may be reply) - threadDepth?: number; // Approximate depth of thread -} -``` - -### 6. Reply Preview Updates - -**Update `src/components/chat/ReplyPreview.tsx`**: - -Current implementation already supports showing replied-to messages. For NIP-10 threads, we need to: - -1. Show "↳ root" when replying directly to root -2. Show "↳ username" when replying to another reply -3. Fetch events from thread relays - -This should mostly work with existing code, but we can enhance the display: - -```typescript -export const ReplyPreview = memo(function ReplyPreview({ - replyToId, - adapter, - conversation, - onScrollToMessage, -}: ReplyPreviewProps) { - const replyEvent = use$(() => eventStore.event(replyToId), [replyToId]); - - // For NIP-10 threads, check if replying to root - const isRoot = conversation.metadata?.rootEventId === replyToId; - - // ... existing fetch logic ... - - if (!replyEvent) { - return ( -
- ↳ Replying to {isRoot ? "thread root" : replyToId.slice(0, 8)}... -
- ); - } - - return ( -
- - {isRoot ? ( - thread root - ) : ( - - )} -
- -
-
- ); -}); -``` - -### 7. Message Context Menu - -NIP-10 threads should support: -- Copy event ID / nevent -- Copy raw JSON -- Open in new window -- Quote (copy with > prefix) -- **View full thread** (new action - opens `chat nevent` for this message) - -This can be added to existing `ChatMessageContextMenu` component. - -## Implementation Checklist - -### Phase 1: Core Infrastructure -- [ ] Add `ThreadIdentifier` to `src/types/chat.ts` -- [ ] Add `"nip-10"` to `ChatProtocol` type -- [ ] Update `ConversationMetadata` with thread fields -- [ ] Create `src/lib/chat/adapters/nip-10-adapter.ts` skeleton - -### Phase 2: Identifier Parsing -- [ ] Implement `parseIdentifier()` for nevent/note -- [ ] Add Nip10Adapter to chat-parser.ts -- [ ] Test with various nevent formats - -### Phase 3: Conversation Resolution -- [ ] Implement `resolveConversation()`: - - [ ] Fetch provided event - - [ ] Find root event via NIP-10 refs - - [ ] Determine conversation relays - - [ ] Extract title and participants -- [ ] Test with root events and reply events - -### Phase 4: Message Loading -- [ ] Implement `loadMessages()`: - - [ ] Subscribe to replies (kind 1) - - [ ] Subscribe to reactions (kind 7) - - [ ] Subscribe to zaps (kind 9735) - - [ ] Convert to Message objects -- [ ] Implement `loadMoreMessages()` for pagination -- [ ] Test with threads of varying sizes - -### Phase 5: Message Sending -- [ ] Implement `sendMessage()`: - - [ ] Build NIP-10 tags (root + reply) - - [ ] Add p-tags for participants - - [ ] Support emoji tags - - [ ] Support imeta attachments -- [ ] Implement `sendReaction()` -- [ ] Test reply hierarchy - -### Phase 6: UI Integration -- [ ] Update ChatViewer for thread mode: - - [ ] Render root event at top (centered) - - [ ] Add separator between root and replies - - [ ] Adjust composer placeholder -- [ ] Update ReplyPreview for "thread root" display -- [ ] Test visual layout - -### Phase 7: Polish -- [ ] Add loading states -- [ ] Add error handling -- [ ] Add "View full thread" context menu action -- [ ] Update help text in chat-parser error -- [ ] Write tests -- [ ] Update CLAUDE.md documentation - -## Edge Cases - -1. **Provided event is deleted**: Show error, can't resolve thread -2. **Root event not found**: Treat provided event as root -3. **Very deep threads** (>100 replies): Pagination should handle this -4. **Multiple roots claimed**: Trust marked e-tags, fallback to first e-tag -5. **Mixed protocols**: nevent might point to kind 9 (NIP-29) - let Nip29Adapter handle -6. **No relay hints**: Use fallback relay strategy (author outbox + user outbox) -7. **Private relays**: May fail to fetch - show "unable to load thread" error -8. **Quote reposts vs replies**: NIP-10 doesn't distinguish - treat all e-tags as replies - -## Testing Strategy - -### Unit Tests -- `nip-10-adapter.test.ts`: - - parseIdentifier with various formats - - eventToMessage conversion - - NIP-10 tag building for replies - -### Integration Tests -- Resolve conversation from root event -- Resolve conversation from reply event -- Load messages with reactions and zaps -- Send reply with proper NIP-10 tags -- Pagination - -### Manual Tests -- Open thread from root event -- Open thread from nested reply (should show full thread) -- Reply to root -- Reply to reply (test hierarchy) -- Send reaction and zap -- Load older messages -- Test with threads containing media, links, mentions - -## Future Enhancements - -1. **Thread tree view**: Option to show replies in tree structure instead of flat chat -2. **Smart relay selection**: Learn which relays have the most complete thread -3. **Thread health indicator**: Show "X/Y replies loaded" if some are missing -4. **Thread export**: Export thread as markdown or JSON -5. **Thread notifications**: Subscribe to new replies (NIP-XX) -6. **Threaded zaps**: Show zap amount on specific reply being zapped -7. **Quote highlighting**: When replying, highlight quoted text -8. **Draft persistence**: Save draft replies per thread - -## Related NIPs - -- **NIP-10**: Text note references (threading) - core spec -- **NIP-19**: bech32 encoding (nevent, note formats) -- **NIP-30**: Custom emoji -- **NIP-57**: Zaps -- **NIP-92**: Media attachments (imeta) - -## Documentation Updates - -Update `CLAUDE.md`: - -```markdown -## Chat System - -**Current Status**: NIP-10 (threaded notes), NIP-29 (relay groups), and NIP-53 (live chats) are supported. - -### NIP-10 Thread Chat - -Turn any kind 1 note thread into a chat interface: - -```bash -chat nevent1qqsxyz... # Open thread as chat -chat note1abc... # Also works (converts to nevent) -``` - -**Format**: Thread root is displayed at top (centered, full feed renderer), all replies below as chat messages. - -**Reply Handling**: Sends kind 1 events with proper NIP-10 markers (root + reply tags). - -**Relay Selection**: Combines root event relays, provided hints, author outbox, and user outbox. -``` - -## Questions for Consideration - -1. **Root event interactions**: Should users be able to react/zap the root event from the chat UI? - - **Answer**: Yes, show actions bar on hover (same as feed renderer) - -2. **Reply depth indicator**: Should we show visual threading (indentation) or keep flat? - - **Answer**: Keep flat initially, add tree view as future enhancement - -3. **Title length**: How to truncate root content for chat title? - - **Answer**: First line OR truncate to 50 chars with "..." suffix - -4. **Empty threads**: What if root has no replies? - - **Answer**: Show root event + empty state "No replies yet" - -5. **Cross-protocol**: Can NIP-10 thread include NIP-29 group messages? - - **Answer**: No, strictly kind 1 events only - -6. **Root event scrolling**: Should clicking "thread root" in reply preview scroll to top? - - **Answer**: Yes, scroll to top (where root is displayed) diff --git a/docs/nip10-thread-chat-examples.md b/docs/nip10-thread-chat-examples.md deleted file mode 100644 index e7515c1..0000000 --- a/docs/nip10-thread-chat-examples.md +++ /dev/null @@ -1,972 +0,0 @@ -# NIP-10 Thread Chat - Implementation Examples - -This document provides concrete code examples for implementing NIP-10 thread chat support. - -## Example 1: Parsing nevent/note Identifiers - -```typescript -// src/lib/chat/adapters/nip-10-adapter.ts - -import { nip19 } from "nostr-tools"; -import type { EventPointer } from "applesauce-core/helpers"; -import type { ProtocolIdentifier, ThreadIdentifier } from "@/types/chat"; - -parseIdentifier(input: string): ProtocolIdentifier | null { - // Try note format first (simpler) - if (input.startsWith("note1")) { - try { - const decoded = nip19.decode(input); - if (decoded.type === "note") { - const eventId = decoded.data; - return { - type: "thread", - value: { id: eventId }, - relays: [], - }; - } - } catch { - return null; - } - } - - // Try nevent format (includes relay hints) - if (input.startsWith("nevent1")) { - try { - const decoded = nip19.decode(input); - if (decoded.type === "nevent") { - const { id, relays, author, kind } = decoded.data; - - // If kind is specified and NOT kind 1, let other adapters handle - if (kind !== undefined && kind !== 1) { - return null; - } - - return { - type: "thread", - value: { id, relays, author, kind }, - relays: relays || [], - }; - } - } catch { - return null; - } - } - - return null; -} -``` - -## Example 2: Resolving Thread Conversation - -```typescript -// src/lib/chat/adapters/nip-10-adapter.ts - -async resolveConversation( - identifier: ProtocolIdentifier, -): Promise { - if (identifier.type !== "thread") { - throw new Error(`NIP-10 adapter cannot handle identifier type: ${identifier.type}`); - } - - const pointer = identifier.value; - const relayHints = identifier.relays || []; - - console.log(`[NIP-10] Fetching event ${pointer.id.slice(0, 8)}...`); - - // 1. Fetch the provided event - const providedEvent = await this.fetchEvent(pointer.id, relayHints); - if (!providedEvent) { - throw new Error("Event not found"); - } - - if (providedEvent.kind !== 1) { - throw new Error(`Expected kind 1 note, got kind ${providedEvent.kind}`); - } - - // 2. Parse NIP-10 references to find root - const refs = getNip10References(providedEvent); - let rootEvent: NostrEvent; - let rootId: string; - - if (refs.root?.e) { - // This is a reply - fetch the root - rootId = refs.root.e.id; - console.log(`[NIP-10] Fetching root event ${rootId.slice(0, 8)}...`); - - const rootPointer: EventPointer = { - id: rootId, - relays: refs.root.e.relays, - author: refs.root.e.author, - }; - - const fetchedRoot = await this.fetchEvent(rootId, rootPointer.relays); - if (!fetchedRoot) { - throw new Error("Thread root not found"); - } - rootEvent = fetchedRoot; - } else { - // No root reference - this IS the root - rootEvent = providedEvent; - rootId = providedEvent.id; - console.log(`[NIP-10] Provided event is the root`); - } - - // 3. Determine conversation relays - const conversationRelays = await this.getThreadRelays( - rootEvent, - providedEvent, - relayHints, - ); - - console.log(`[NIP-10] Using ${conversationRelays.length} relays:`, conversationRelays); - - // 4. Extract title from root content - const title = this.extractTitle(rootEvent); - - // 5. Build participants list from root and provided event - const participants = this.extractParticipants(rootEvent, providedEvent); - - // 6. Build conversation object - return { - id: `nip-10:${rootId}`, - type: "group", // Use "group" type for multi-participant threads - protocol: "nip-10", - title, - participants, - metadata: { - rootEventId: rootId, - providedEventId: providedEvent.id, - description: rootEvent.content.slice(0, 200), // First 200 chars for tooltip - }, - unreadCount: 0, - }; -} - -/** - * Extract a readable title from root event content - */ -private extractTitle(rootEvent: NostrEvent): string { - const content = rootEvent.content.trim(); - if (!content) return `Thread by ${rootEvent.pubkey.slice(0, 8)}...`; - - // Try to get first line - const firstLine = content.split("\n")[0]; - if (firstLine && firstLine.length <= 50) { - return firstLine; - } - - // Truncate to 50 chars - if (content.length <= 50) { - return content; - } - - return content.slice(0, 47) + "..."; -} - -/** - * Extract unique participants from thread - */ -private extractParticipants( - rootEvent: NostrEvent, - providedEvent: NostrEvent, -): Participant[] { - const participants = new Map(); - - // Root author is always first - participants.set(rootEvent.pubkey, { - pubkey: rootEvent.pubkey, - role: "admin", // Root author is "admin" of the thread - }); - - // Add p-tags from root event - for (const tag of rootEvent.tags) { - if (tag[0] === "p" && tag[1] && tag[1] !== rootEvent.pubkey) { - participants.set(tag[1], { - pubkey: tag[1], - role: "member", - }); - } - } - - // Add provided event author (if different) - if (providedEvent.pubkey !== rootEvent.pubkey) { - participants.set(providedEvent.pubkey, { - pubkey: providedEvent.pubkey, - role: "member", - }); - } - - // Add p-tags from provided event - for (const tag of providedEvent.tags) { - if (tag[0] === "p" && tag[1] && tag[1] !== providedEvent.pubkey) { - participants.set(tag[1], { - pubkey: tag[1], - role: "member", - }); - } - } - - return Array.from(participants.values()); -} - -/** - * Determine best relays for the thread - */ -private async getThreadRelays( - rootEvent: NostrEvent, - providedEvent: NostrEvent, - providedRelays: string[], -): Promise { - const relays = new Set(); - - // 1. Seen relays from EventStore (if available) - if (eventStore.getSeenRelays) { - const rootSeenRelays = eventStore.getSeenRelays(rootEvent.id) || []; - rootSeenRelays.forEach((r) => relays.add(normalizeURL(r))); - } - - // 2. Provided relay hints - providedRelays.forEach((r) => relays.add(normalizeURL(r))); - - // 3. Root author's outbox relays (NIP-65) - try { - const rootOutbox = await this.getOutboxRelays(rootEvent.pubkey); - rootOutbox.slice(0, 3).forEach((r) => relays.add(normalizeURL(r))); - } catch (err) { - console.warn("[NIP-10] Failed to get root author outbox:", err); - } - - // 4. Active user's outbox (for publishing replies) - const activePubkey = accountManager.active$.value?.pubkey; - if (activePubkey) { - try { - const userOutbox = await this.getOutboxRelays(activePubkey); - userOutbox.slice(0, 2).forEach((r) => relays.add(normalizeURL(r))); - } catch (err) { - console.warn("[NIP-10] Failed to get user outbox:", err); - } - } - - // 5. Fallback to popular relays if we have too few - if (relays.size < 3) { - ["wss://relay.damus.io", "wss://nos.lol", "wss://relay.nostr.band"].forEach( - (r) => relays.add(r), - ); - } - - // Limit to 7 relays max for performance - return Array.from(relays).slice(0, 7); -} - -/** - * Helper: Get outbox relays for a pubkey (NIP-65) - */ -private async getOutboxRelays(pubkey: string): Promise { - const relayList = await firstValueFrom( - eventStore.replaceable(10002, pubkey, ""), - { defaultValue: undefined }, - ); - - if (!relayList) return []; - - // Extract write relays (r tags with "write" or no marker) - return relayList.tags - .filter((t) => { - if (t[0] !== "r") return false; - const marker = t[2]; - return !marker || marker === "write"; - }) - .map((t) => normalizeURL(t[1])) - .slice(0, 5); // Limit to 5 -} - -/** - * Helper: Fetch an event by ID from relays - */ -private async fetchEvent( - eventId: string, - relayHints: string[] = [], -): Promise { - // Check EventStore first - const cached = await firstValueFrom( - eventStore.event(eventId), - { defaultValue: undefined }, - ); - if (cached) return cached; - - // Not in store - fetch from relays - const relays = relayHints.length > 0 ? relayHints : await this.getDefaultRelays(); - - const filter: Filter = { - ids: [eventId], - limit: 1, - }; - - const events: NostrEvent[] = []; - const obs = pool.subscription(relays, [filter], { eventStore }); - - await new Promise((resolve) => { - const timeout = setTimeout(() => { - console.log(`[NIP-10] Fetch timeout for ${eventId.slice(0, 8)}...`); - resolve(); - }, 5000); - - const sub = obs.subscribe({ - next: (response) => { - if (typeof response === "string") { - // EOSE received - clearTimeout(timeout); - sub.unsubscribe(); - resolve(); - } else { - // Event received - events.push(response); - } - }, - error: (err) => { - clearTimeout(timeout); - console.error(`[NIP-10] Fetch error:`, err); - sub.unsubscribe(); - resolve(); - }, - }); - }); - - return events[0] || null; -} -``` - -## Example 3: Loading Thread Messages - -```typescript -// src/lib/chat/adapters/nip-10-adapter.ts - -loadMessages( - conversation: Conversation, - options?: LoadMessagesOptions, -): Observable { - const rootEventId = conversation.metadata?.rootEventId; - const relays = conversation.metadata?.relays || []; - - if (!rootEventId) { - throw new Error("Root event ID required"); - } - - console.log(`[NIP-10] Loading thread ${rootEventId.slice(0, 8)}...`); - - // Build filter for all thread events: - // - kind 1: replies to root - // - kind 7: reactions - // - kind 9735: zap receipts - const filters: Filter[] = [ - // Replies: kind 1 events with e-tag pointing to root - { - kinds: [1], - "#e": [rootEventId], - limit: options?.limit || 100, - }, - // Reactions: kind 7 events with e-tag pointing to root or replies - { - kinds: [7], - "#e": [rootEventId], - limit: 200, // Reactions are small, fetch more - }, - // Zaps: kind 9735 receipts with e-tag pointing to root or replies - { - kinds: [9735], - "#e": [rootEventId], - limit: 100, - }, - ]; - - if (options?.before) { - filters[0].until = options.before; - } - if (options?.after) { - filters[0].since = options.after; - } - - // Clean up any existing subscription - const conversationId = `nip-10:${rootEventId}`; - this.cleanup(conversationId); - - // Start persistent subscription - const subscription = pool - .subscription(relays, filters, { eventStore }) - .subscribe({ - next: (response) => { - if (typeof response === "string") { - console.log("[NIP-10] EOSE received"); - } else { - console.log( - `[NIP-10] Received event k${response.kind}: ${response.id.slice(0, 8)}...`, - ); - } - }, - }); - - // Store subscription for cleanup - this.subscriptions.set(conversationId, subscription); - - // Return observable from EventStore - // We need to merge all three filters into a single observable - return eventStore - .timeline({ kinds: [1, 7, 9735], "#e": [rootEventId] }) - .pipe( - map((events) => { - // Filter out the root event itself (we don't want it in messages list) - const threadEvents = events.filter((e) => e.id !== rootEventId); - - // Convert events to messages - const messages = threadEvents - .map((event) => this.eventToMessage(event, conversationId, rootEventId)) - .filter((msg): msg is Message => msg !== null); - - console.log(`[NIP-10] Timeline has ${messages.length} messages`); - - // Sort by timestamp ascending (chronological order) - return messages.sort((a, b) => a.timestamp - b.timestamp); - }), - ); -} -``` - -## Example 4: Sending Replies with NIP-10 Tags - -```typescript -// src/lib/chat/adapters/nip-10-adapter.ts - -async sendMessage( - conversation: Conversation, - content: string, - options?: SendMessageOptions, -): Promise { - const activePubkey = accountManager.active$.value?.pubkey; - const activeSigner = accountManager.active$.value?.signer; - - if (!activePubkey || !activeSigner) { - throw new Error("No active account or signer"); - } - - const rootEventId = conversation.metadata?.rootEventId; - const relays = conversation.metadata?.relays || []; - - if (!rootEventId) { - throw new Error("Root event ID required"); - } - - // Fetch root event for building tags - const rootEvent = await firstValueFrom( - eventStore.event(rootEventId), - { defaultValue: undefined }, - ); - if (!rootEvent) { - throw new Error("Root event not found in store"); - } - - // Create event factory - const factory = new EventFactory(); - factory.setSigner(activeSigner); - - // Build NIP-10 tags - const tags: string[][] = []; - - // Determine if we're replying to root or to another reply - if (options?.replyTo && options.replyTo !== rootEventId) { - // Replying to another reply - const parentEvent = await firstValueFrom( - eventStore.event(options.replyTo), - { defaultValue: undefined }, - ); - - if (!parentEvent) { - throw new Error("Parent event not found"); - } - - // Add root marker (always first) - tags.push([ - "e", - rootEventId, - relays[0] || "", - "root", - rootEvent.pubkey, - ]); - - // Add reply marker (the direct parent) - tags.push([ - "e", - options.replyTo, - relays[0] || "", - "reply", - parentEvent.pubkey, - ]); - - // Add p-tag for root author - tags.push(["p", rootEvent.pubkey]); - - // Add p-tag for parent author (if different) - if (parentEvent.pubkey !== rootEvent.pubkey) { - tags.push(["p", parentEvent.pubkey]); - } - - // Add p-tags from parent event (all mentioned users) - for (const tag of parentEvent.tags) { - if (tag[0] === "p" && tag[1]) { - const pubkey = tag[1]; - // Don't duplicate tags - if (!tags.some((t) => t[0] === "p" && t[1] === pubkey)) { - tags.push(["p", pubkey]); - } - } - } - } else { - // Replying directly to root - tags.push([ - "e", - rootEventId, - relays[0] || "", - "root", - rootEvent.pubkey, - ]); - - // Add p-tag for root author - tags.push(["p", rootEvent.pubkey]); - - // Add p-tags from root event - for (const tag of rootEvent.tags) { - if (tag[0] === "p" && tag[1]) { - const pubkey = tag[1]; - // Don't duplicate tags - if (!tags.some((t) => t[0] === "p" && t[1] === pubkey)) { - tags.push(["p", pubkey]); - } - } - } - } - - // Add NIP-30 emoji tags - if (options?.emojiTags) { - for (const emoji of options.emojiTags) { - tags.push(["emoji", emoji.shortcode, emoji.url]); - } - } - - // Add NIP-92 imeta tags for blob attachments - if (options?.blobAttachments) { - for (const blob of options.blobAttachments) { - const imetaParts = [`url ${blob.url}`]; - if (blob.sha256) imetaParts.push(`x ${blob.sha256}`); - if (blob.mimeType) imetaParts.push(`m ${blob.mimeType}`); - if (blob.size) imetaParts.push(`size ${blob.size}`); - tags.push(["imeta", ...imetaParts]); - } - } - - // Create and sign kind 1 event - const draft = await factory.build({ kind: 1, content, tags }); - const event = await factory.sign(draft); - - console.log(`[NIP-10] Publishing reply with ${tags.length} tags to ${relays.length} relays`); - - // Publish to conversation relays - await publishEventToRelays(event, relays); -} -``` - -## Example 5: Converting Events to Messages - -```typescript -// src/lib/chat/adapters/nip-10-adapter.ts - -/** - * Convert Nostr event to Message object - */ -private eventToMessage( - event: NostrEvent, - conversationId: string, - rootEventId: string, -): Message | null { - // Handle zap receipts (kind 9735) - if (event.kind === 9735) { - return this.zapToMessage(event, conversationId); - } - - // Handle reactions (kind 7) - skip for now, we'll handle via MessageReactions component - if (event.kind === 7) { - return null; // Reactions are shown inline, not as separate messages - } - - // Handle replies (kind 1) - if (event.kind === 1) { - const refs = getNip10References(event); - - // Determine what this reply is responding to - let replyTo: string | undefined; - - if (refs.reply?.e) { - // Replying to another reply - replyTo = refs.reply.e.id; - } else if (refs.root?.e) { - // Replying directly to root - replyTo = refs.root.e.id; - } else { - // Malformed or legacy reply - assume replying to root - replyTo = rootEventId; - } - - return { - id: event.id, - conversationId, - author: event.pubkey, - content: event.content, - timestamp: event.created_at, - type: "user", - replyTo, - protocol: "nip-10", - metadata: { - encrypted: false, - }, - event, - }; - } - - console.warn(`[NIP-10] Unknown event kind: ${event.kind}`); - return null; -} - -/** - * Convert zap receipt to Message object - */ -private zapToMessage( - zapReceipt: NostrEvent, - conversationId: string, -): Message { - // Extract zap metadata using applesauce helpers - const zapRequest = getZapRequest(zapReceipt); - const amount = getZapAmount(zapReceipt); - const sender = getZapSender(zapReceipt); - const recipient = getZapRecipient(zapReceipt); - - // Find what event is being zapped (e-tag in zap receipt) - const eTag = zapReceipt.tags.find((t) => t[0] === "e"); - const replyTo = eTag?.[1]; - - // Get comment from zap request - const comment = zapRequest?.content || ""; - - return { - id: zapReceipt.id, - conversationId, - author: sender || zapReceipt.pubkey, - content: comment, - timestamp: zapReceipt.created_at, - type: "zap", - replyTo, - protocol: "nip-10", - metadata: { - zapAmount: amount, - zapRecipient: recipient, - }, - event: zapReceipt, - }; -} -``` - -## Example 6: ChatViewer Root Event Display - -```typescript -// src/components/ChatViewer.tsx - -export function ChatViewer({ - protocol, - identifier, - customTitle, - headerPrefix, -}: ChatViewerProps) { - // ... existing setup code ... - - // Check if this is a NIP-10 thread - const isThreadChat = protocol === "nip-10"; - - // Fetch root event for thread display - const rootEventId = conversation?.metadata?.rootEventId; - const rootEvent = use$( - () => (rootEventId ? eventStore.event(rootEventId) : undefined), - [rootEventId], - ); - - return ( -
- {/* Header */} -
-
-
- {headerPrefix} - - {customTitle || conversation.title} - -
-
- - - {isThreadChat && ( - - )} -
-
-
- - {/* Message timeline */} -
- {/* NIP-10 Thread: Show root event at top */} - {isThreadChat && rootEvent && ( -
-
- {/* Use KindRenderer to render root with full feed functionality */} - -
- {/* Visual separator */} -
-
- Replies -
-
-
- )} - - {/* Scrollable messages list */} -
- {messagesWithMarkers && messagesWithMarkers.length > 0 ? ( - - ) : ( -
- {isThreadChat ? "No replies yet. Start the conversation!" : "No messages yet. Start the conversation!"} -
- )} -
-
- - {/* Composer */} - {canSign ? ( -
- {replyTo && ( - setReplyTo(undefined)} - /> - )} -
- {/* ... existing composer ... */} -
-
- ) : ( -
- Sign in to reply -
- )} -
- ); -} -``` - -## Example 7: Usage Examples - -### Opening a Thread from Root Event - -```bash -# User clicks on a tweet in their feed -# Extract event ID: abc123... -chat nevent1qqsabc123... -# → Opens thread chat with root at top -``` - -### Opening a Thread from Reply Event - -```bash -# User clicks on a reply deep in a thread -# Extract event ID: xyz789... (this is a reply, not root) -chat nevent1qqsxyz789... -# → Adapter fetches root, opens full thread -``` - -### Replying to Root - -``` -User types: "Great point!" -Click Reply button on root event -→ Sends kind 1 with: - ["e", "", "", "root", ""] - ["p", ""] -``` - -### Replying to a Reply - -``` -User clicks Reply on Alice's message (which replied to root) -User types: "I agree with Alice" -→ Sends kind 1 with: - ["e", "", "", "root", ""] - ["e", "", "", "reply", ""] - ["p", ""] - ["p", ""] -``` - -### Visual Flow - -``` -┌──────────────────────────────────────────┐ -│ chat nevent1qqsxyz... │ User enters command -└──────────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────┐ -│ Nip10Adapter.parseIdentifier() │ Parse nevent -│ → Returns ThreadIdentifier │ -└──────────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────┐ -│ Nip10Adapter.resolveConversation() │ Fetch events, find root -│ → Fetches provided event │ -│ → Parses NIP-10 refs │ -│ → Fetches root event │ -│ → Determines relays │ -│ → Returns Conversation │ -└──────────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────┐ -│ ChatViewer renders │ Display UI -│ ┌────────────────────────────────────┐ │ -│ │ Root Event (KindRenderer) │ │ Root at top -│ └────────────────────────────────────┘ │ -│ ────────────────────────────────────── │ -│ Alice: Great post! │ Replies as messages -│ Bob: +1 │ -│ [──────────────────] [Send] │ Composer -└──────────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────┐ -│ Nip10Adapter.loadMessages() │ Subscribe to replies -│ → Subscribes to kind 1 replies │ -│ → Subscribes to kind 7 reactions │ -│ → Subscribes to kind 9735 zaps │ -│ → Returns Observable │ -└──────────────────────────────────────────┘ -``` - -## Testing Example - -```typescript -// src/lib/chat/adapters/nip-10-adapter.test.ts - -import { describe, it, expect } from "vitest"; -import { Nip10Adapter } from "./nip-10-adapter"; - -describe("Nip10Adapter", () => { - const adapter = new Nip10Adapter(); - - describe("parseIdentifier", () => { - it("should parse nevent with relay hints", () => { - const nevent = "nevent1qqsabc123..."; // Valid nevent - const result = adapter.parseIdentifier(nevent); - - expect(result).toBeTruthy(); - expect(result?.type).toBe("thread"); - expect(result?.value.id).toBeTruthy(); - }); - - it("should parse note (event ID only)", () => { - const note = "note1abc123..."; // Valid note - const result = adapter.parseIdentifier(note); - - expect(result).toBeTruthy(); - expect(result?.type).toBe("thread"); - }); - - it("should return null for non-note/nevent input", () => { - const result = adapter.parseIdentifier("npub1..."); - expect(result).toBeNull(); - }); - - it("should return null for nevent with kind 9 (group message)", () => { - // nevent encoding includes kind: 9 - const nevent = "nevent1...kind9..."; - const result = adapter.parseIdentifier(nevent); - expect(result).toBeNull(); - }); - }); - - describe("extractTitle", () => { - it("should use first line if under 50 chars", () => { - const event = { - content: "Short title\nLonger content here...", - pubkey: "abc", - }; - // @ts-ignore - testing private method - const title = adapter.extractTitle(event); - expect(title).toBe("Short title"); - }); - - it("should truncate long content", () => { - const event = { - content: "A".repeat(100), - pubkey: "abc", - }; - // @ts-ignore - const title = adapter.extractTitle(event); - expect(title).toHaveLength(50); - expect(title).toEndWith("..."); - }); - }); - - describe("eventToMessage", () => { - it("should convert kind 1 reply to Message", () => { - const event = { - id: "reply123", - kind: 1, - pubkey: "alice", - content: "Great point!", - created_at: 1234567890, - tags: [ - ["e", "root123", "", "root", "bob"], - ["p", "bob"], - ], - }; - - // @ts-ignore - const message = adapter.eventToMessage(event, "nip-10:root123", "root123"); - - expect(message).toBeTruthy(); - expect(message?.type).toBe("user"); - expect(message?.replyTo).toBe("root123"); - expect(message?.content).toBe("Great point!"); - }); - - it("should return null for kind 7 (reactions handled separately)", () => { - const event = { - id: "reaction123", - kind: 7, - pubkey: "alice", - content: "🔥", - created_at: 1234567890, - tags: [["e", "msg123"]], - }; - - // @ts-ignore - const message = adapter.eventToMessage(event, "nip-10:root123", "root123"); - expect(message).toBeNull(); - }); - }); -}); -``` diff --git a/docs/nip10-thread-chat-summary.md b/docs/nip10-thread-chat-summary.md deleted file mode 100644 index c7968e6..0000000 --- a/docs/nip10-thread-chat-summary.md +++ /dev/null @@ -1,472 +0,0 @@ -# NIP-10 Thread Chat - Quick Reference - -## What It Is - -Turn any kind 1 Nostr thread into a chat interface, with the root event displayed prominently at the top and all replies shown as chat messages below. - -## Command Format - -```bash -chat nevent1qqsxyz... # Full nevent with relay hints -chat note1abc... # Simple note ID (less reliable) -``` - -## Visual Comparison - -### Before (Traditional Feed View) - -``` -┌─────────────────────────────────────┐ -│ Alice: Check out this cool feature │ ← Root event -│ [Like] [Repost] [Reply] [Zap] │ -├─────────────────────────────────────┤ -│ Bob: Great idea! │ ← Reply (separate event) -│ [Like] [Repost] [Reply] [Zap] │ -├─────────────────────────────────────┤ -│ Carol: I agree with Bob │ ← Reply (separate event) -│ [Like] [Repost] [Reply] [Zap] │ -└─────────────────────────────────────┘ -``` - -### After (Thread Chat View) - -``` -┌─────────────────────────────────────┐ -│ 📌 Check out this cool feature... │ ← Header (thread title) -├─────────────────────────────────────┤ -│ │ -│ ┌──────────────────────┐ │ -│ │ Alice │ │ ← Root event (centered) -│ │ Check out this │ │ Full feed renderer -│ │ cool feature I built │ │ (can like, zap, etc.) -│ │ [Like] [Zap] [Share] │ │ -│ └──────────────────────┘ │ -│ │ -│ ─────────── Replies ────────────── │ ← Visual separator -│ │ -│ Bob: Great idea! │ ← Replies as chat messages -│ └─ ↳ thread root │ (simpler, chat-like) -│ │ -│ Carol: I agree with Bob │ -│ └─ ↳ Bob │ -│ │ -│ ⚡ 1000 Alice │ ← Zaps inline -│ │ -│ [Type a message...] 📎 [Send] │ ← Chat composer -└─────────────────────────────────────┘ -``` - -## Architecture Overview - -``` -┌────────────────────────────────────────────────────────┐ -│ User Input │ -│ chat nevent1qqsxyz... │ -└───────────────────────┬────────────────────────────────┘ - │ - ▼ -┌────────────────────────────────────────────────────────┐ -│ src/lib/chat-parser.ts │ -│ Tries each adapter's parseIdentifier(): │ -│ 1. Nip10Adapter (nevent/note) ← NEW │ -│ 2. Nip29Adapter (relay'group) │ -│ 3. Nip53Adapter (naddr live chat) │ -└───────────────────────┬────────────────────────────────┘ - │ - ▼ -┌────────────────────────────────────────────────────────┐ -│ src/lib/chat/adapters/nip-10-adapter.ts ← NEW │ -│ │ -│ parseIdentifier(input) │ -│ • Match nevent/note format │ -│ • Decode to EventPointer │ -│ • Return ThreadIdentifier │ -│ │ -│ resolveConversation(identifier) │ -│ • Fetch provided event │ -│ • Parse NIP-10 refs → find root │ -│ • Determine relays (merge sources) │ -│ • Extract title from root │ -│ • Return Conversation │ -│ │ -│ loadMessages(conversation) │ -│ • Subscribe: kind 1 replies │ -│ • Subscribe: kind 7 reactions │ -│ • Subscribe: kind 9735 zaps │ -│ • Convert to Message[] │ -│ • Return Observable │ -│ │ -│ sendMessage(conversation, content, options) │ -│ • Build NIP-10 tags (root + reply markers) │ -│ • Add p-tags for participants │ -│ • Create kind 1 event │ -│ • Publish to conversation relays │ -└───────────────────────┬────────────────────────────────┘ - │ - ▼ -┌────────────────────────────────────────────────────────┐ -│ src/components/ChatViewer.tsx │ -│ │ -│ Detects: protocol === "nip-10" │ -│ │ -│ Special rendering: │ -│ • Fetch rootEventId from conversation.metadata │ -│ • Render root with KindRenderer (centered) │ -│ • Show visual separator │ -│ • Render replies as chat messages below │ -│ • Chat composer at bottom │ -└────────────────────────────────────────────────────────┘ -``` - -## Key Differences from Other Chat Protocols - -| Feature | NIP-29 Groups | NIP-53 Live Chat | **NIP-10 Threads (NEW)** | -|---------|---------------|------------------|---------------------------| -| **Event Kind** | 9 | 1311 | **1** | -| **Reply Tag** | q-tag | q-tag | **e-tag with markers** | -| **Root Display** | ❌ No root | ❌ No root | **✅ Root at top** | -| **Relay Model** | Single relay | Multiple relays | **Multiple relays** | -| **Membership** | Admin approval | Open | **Open** | -| **Protocol** | nip-29 | nip-53 | **nip-10** | -| **Identifier** | `relay'group` | `naddr1...` | **`nevent1...`** | -| **Use Case** | Private groups | Live streams | **Twitter threads** | - -## NIP-10 Tag Structure Comparison - -### Direct Reply to Root (Kind 1) - -```javascript -{ - kind: 1, - content: "Great point!", - tags: [ - ["e", "", "", "root", ""], - ["p", ""] - ] -} -``` - -### Nested Reply (Kind 1) - -```javascript -{ - kind: 1, - content: "I agree with Alice!", - tags: [ - ["e", "", "", "root", ""], // Thread root - ["e", "", "", "reply", ""], // Direct parent - ["p", ""], - ["p", ""] - ] -} -``` - -### NIP-29 Group Message (Kind 9) - For Comparison - -```javascript -{ - kind: 9, - content: "Hello group!", - tags: [ - ["h", ""], // Group identifier - ["q", ""] // Simple reply (if replying) - ] -} -``` - -## Relay Selection Strategy - -NIP-10 threads use **merged relay sources** for maximum reach: - -``` -1. Root Event Seen Relays (from EventStore) - └─ Where the root was originally found - -2. Provided Event Relay Hints (from nevent) - └─ User-specified relays in the nevent encoding - -3. Root Author's Outbox (NIP-65 kind 10002) - └─ Where the root author publishes - -4. Active User's Outbox (NIP-65 kind 10002) - └─ Where current user publishes - -5. Fallback Popular Relays (if < 3 found) - └─ relay.damus.io, nos.lol, relay.nostr.band - -Result: Top 5-7 relays (deduplicated, normalized) -``` - -## Data Flow - -### Loading a Thread - -``` -1. User: chat nevent1qqsxyz... - │ - ▼ -2. Parse nevent → EventPointer - │ - ▼ -3. Fetch event xyz - │ - ├─ Is it kind 1? ✓ - │ - ▼ -4. Parse NIP-10 references - │ - ├─ Has root marker? → Fetch root event - └─ No root marker? → xyz IS the root - │ - ▼ -5. Determine relays - │ - ├─ Merge: seen, hints, outbox - │ - ▼ -6. Subscribe to thread events - │ - ├─ kind 1 with #e = root.id - ├─ kind 7 with #e = root.id - └─ kind 9735 with #e = root.id - │ - ▼ -7. Convert events → Messages - │ - ├─ Parse reply hierarchy - ├─ Extract zap amounts - └─ Sort chronologically - │ - ▼ -8. Render UI - │ - ├─ Root event (centered, feed renderer) - ├─ Visual separator - └─ Replies (chat messages) -``` - -### Sending a Reply - -``` -1. User types: "Great idea!" - │ - ▼ -2. User clicks Reply on Alice's message - │ - ▼ -3. Adapter.sendMessage(conversation, content, { replyTo: alice.id }) - │ - ▼ -4. Build tags: - │ - ├─ ["e", root.id, relay, "root", root.author] - ├─ ["e", alice.id, relay, "reply", alice.pk] - ├─ ["p", root.author] - └─ ["p", alice.pk] - │ - ▼ -5. Create kind 1 event - │ - ▼ -6. Publish to conversation relays - │ - ├─ relay.damus.io - ├─ nos.lol - └─ root.author's outbox - │ - ▼ -7. Event propagates - │ - ▼ -8. Subscription receives event - │ - ▼ -9. UI updates (new message appears) -``` - -## Implementation Files - -### New Files (to be created) - -``` -src/lib/chat/adapters/nip-10-adapter.ts - └─ Full adapter implementation (~600 lines) - -src/lib/chat/adapters/nip-10-adapter.test.ts - └─ Unit tests for adapter - -docs/nip10-thread-chat-design.md - └─ Detailed design spec - -docs/nip10-thread-chat-examples.md - └─ Code examples - -docs/nip10-thread-chat-summary.md - └─ This file -``` - -### Modified Files - -``` -src/types/chat.ts - ├─ Add ThreadIdentifier type - ├─ Add "nip-10" to ChatProtocol - └─ Add rootEventId to ConversationMetadata - -src/lib/chat-parser.ts - ├─ Import Nip10Adapter - └─ Add to adapter priority list - -src/components/ChatViewer.tsx - ├─ Detect isThreadChat = protocol === "nip-10" - ├─ Fetch rootEvent from metadata - ├─ Render root with KindRenderer (centered) - └─ Show visual separator - -src/components/chat/ReplyPreview.tsx - └─ Show "thread root" when replying to root - -CLAUDE.md - └─ Document NIP-10 thread chat usage -``` - -## Usage Examples - -### Example 1: Opening Thread from Twitter-like Feed - -```bash -# User sees interesting post in feed (kind 1 note) -# Clicks "View as Thread" button -# App extracts event pointer -chat nevent1qqsrz7x... - -# Result: Opens thread chat with: -# - Root post at top (full renderer with actions) -# - All replies as chat messages below -# - Reply composer at bottom -``` - -### Example 2: Opening Thread from Deep Reply - -```bash -# User is reading a reply deep in a thread -# Clicks "View Thread" context menu -chat nevent1qqsabc... # This is a nested reply, not root - -# Adapter logic: -# 1. Fetch event abc -# 2. Parse NIP-10 refs → finds root XYZ -# 3. Fetch root event XYZ -# 4. Resolve conversation with root XYZ -# 5. Load all replies to XYZ -# 6. Display full thread (not just from abc onward) -``` - -### Example 3: Replying in Thread - -``` -Thread Chat Interface: - -┌──────────────────────────────────┐ -│ [Root Event by Alice] │ User can interact with root -│ 42 ⚡ 18 ♥ 3 💬 │ (zap, like, etc.) -├──────────────────────────────────┤ -│ │ -│ Bob: Great point! │ ← Hover shows Reply button -│ └─ ↳ thread root │ -│ │ -│ Carol: I agree │ ← Click Reply here -│ └─ ↳ Bob │ -│ │ -└──────────────────────────────────┘ - ↓ -┌──────────────────────────────────┐ -│ [Replying to Carol] │ ← Reply preview shows -│ Type message... [Send] │ -└──────────────────────────────────┘ - ↓ User types "Me too!" - ↓ Clicks Send - ↓ -Publishes kind 1 with: - ["e", rootId, relay, "root", alice] # Thread root - ["e", carolId, relay, "reply", carol] # Direct parent - ["p", alice] # Root author - ["p", carol] # Parent author - ["p", bob] # Mentioned in parent -``` - -## Benefits - -### For Users -- **Focused discussion**: See entire thread in one view -- **Better context**: Root always visible at top -- **Faster replies**: Chat-like composer instead of feed actions -- **Real-time**: New replies appear instantly (subscription) -- **Cross-client**: Works with any NIP-10 compliant client - -### For Developers -- **Reuses infrastructure**: Same ChatViewer, just different adapter -- **Protocol-agnostic UI**: Adapter pattern abstracts differences -- **Testable**: Unit tests for adapter, integration tests for UI -- **Extensible**: Easy to add features (threading UI, export, etc.) - -## Limitations & Trade-offs - -### Limitations -1. **Kind 1 only**: Doesn't work with other event kinds -2. **No encryption**: All messages public (NIP-10 has no encryption) -3. **No moderation**: Can't delete/hide replies (not in NIP-10) -4. **Relay dependent**: Need good relay coverage to see all replies -5. **No real-time guarantees**: Relies on relay subscriptions - -### Trade-offs -- **Simplicity vs Features**: Chat UI sacrifices some feed features (repost, quote, etc.) -- **Performance vs Completeness**: Limit to 7 relays for speed (might miss some replies) -- **UX vs Protocol**: Showing root separately breaks chronological order slightly - -## Future Enhancements - -1. **Thread Tree View**: Toggle between flat chat and tree structure -2. **Thread Statistics**: "X replies from Y participants" -3. **Smart Relay Discovery**: Learn which relays have full thread -4. **Missing Reply Detection**: "Some replies may be missing" indicator -5. **Thread Export**: Save thread as markdown/JSON -6. **Quote Highlighting**: Visual indication of quoted text -7. **Thread Branching**: Show sub-threads that diverge -8. **Participant Indicators**: Show who's been most active - -## FAQ - -**Q: What happens if I open a nevent for a kind 9 (group message)?** -A: Nip10Adapter returns null, Nip29Adapter handles it instead. - -**Q: Can I reply to the root event from the chat?** -A: Yes! Click the Reply button that appears on hover on the root event display. - -**Q: What if the root event is deleted?** -A: Adapter throws error "Thread root not found" - cannot display thread. - -**Q: Do reactions work?** -A: Yes! Reactions (kind 7) are subscribed to and shown inline via MessageReactions component. - -**Q: Can I zap messages in the thread?** -A: Yes! Zaps (kind 9735) are shown as special chat messages with ⚡ indicator. - -**Q: What if relays are slow/offline?** -A: EventStore caches events. If relay is offline, cached events still display. New replies won't arrive until relay reconnects. - -**Q: How is this different from opening the note in the feed?** -A: Thread chat provides focused, conversation-centric view with root prominent and chat-like UI. Feed view is more action-oriented (repost, quote, etc.). - -**Q: Can I use this for group discussions?** -A: It works, but NIP-29 groups are better for persistent communities. NIP-10 threads are ad-hoc, thread-specific. - -**Q: Does it support polls/forms/etc?** -A: No, only text replies (kind 1). Other kinds ignored. - -## References - -- [NIP-10: Text Note References](https://github.com/nostr-protocol/nips/blob/master/10.md) -- [NIP-19: bech32 Encoding](https://github.com/nostr-protocol/nips/blob/master/19.md) -- [NIP-65: Relay List Metadata](https://github.com/nostr-protocol/nips/blob/master/65.md) -- [Grimoire Chat System Docs](../CLAUDE.md#chat-system) diff --git a/src/lib/chat/adapters/nip-10-adapter.ts b/src/lib/chat/adapters/nip-10-adapter.ts index f582769..e746809 100644 --- a/src/lib/chat/adapters/nip-10-adapter.ts +++ b/src/lib/chat/adapters/nip-10-adapter.ts @@ -20,6 +20,7 @@ import eventStore from "@/services/event-store"; import pool from "@/services/relay-pool"; import { publishEventToRelays } from "@/services/hub"; import accountManager from "@/services/accounts"; +import { AGGREGATOR_RELAYS } from "@/services/loaders"; import { normalizeURL } from "applesauce-core/helpers"; import { EventFactory } from "applesauce-core/event-factory"; import { getNip10References } from "applesauce-common/helpers"; @@ -712,13 +713,9 @@ export class Nip10Adapter extends ChatProtocolAdapter { } } - // 6. Fallback to popular relays if we have too few + // 6. Fallback to aggregator relays if we have too few if (relays.size < 3) { - [ - "wss://relay.damus.io", - "wss://nos.lol", - "wss://relay.nostr.band", - ].forEach((r) => relays.add(r)); + AGGREGATOR_RELAYS.forEach((r) => relays.add(r)); } // Limit to 10 relays max for performance @@ -811,8 +808,8 @@ export class Nip10Adapter extends ChatProtocolAdapter { if (outbox.length > 0) return outbox.slice(0, 5); } - // Fallback to popular relays - return ["wss://relay.damus.io", "wss://nos.lol", "wss://relay.nostr.band"]; + // Fallback to aggregator relays + return AGGREGATOR_RELAYS; } /**