From bd8cd1f387f51eb21d261239ddec0dfd9be2c8b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Jan 2026 10:38:06 +0000 Subject: [PATCH] docs: add comprehensive NIP-10 thread chat design documentation Add detailed design documents for implementing NIP-10 thread chat feature: - nip10-thread-chat-design.md: Full architecture, data structures, adapter implementation plan, relay selection strategy, UI requirements, and 7-phase implementation checklist - nip10-thread-chat-examples.md: Complete code examples showing identifier parsing, conversation resolution, message loading, reply sending with proper NIP-10 tags, and ChatViewer integration - nip10-thread-chat-summary.md: Quick reference with visual comparisons, architecture diagrams, protocol comparison table, data flow, and FAQ The feature will enable "chat nevent1..." to display kind 1 threaded conversations as chat interfaces, with the root event prominently displayed at the top and all replies shown as chat messages below. Key design decisions: - Use e-tags with NIP-10 markers (root/reply) instead of q-tags - Merge multiple relay sources (seen, hints, outbox) for coverage - Display root event centered with full feed renderer - Reuse existing ChatViewer infrastructure via adapter pattern - Support both nevent (with relay hints) and note (ID only) formats --- docs/nip10-thread-chat-design.md | 612 ++++++++++++++++++ docs/nip10-thread-chat-examples.md | 972 +++++++++++++++++++++++++++++ docs/nip10-thread-chat-summary.md | 472 ++++++++++++++ 3 files changed, 2056 insertions(+) create mode 100644 docs/nip10-thread-chat-design.md create mode 100644 docs/nip10-thread-chat-examples.md create mode 100644 docs/nip10-thread-chat-summary.md diff --git a/docs/nip10-thread-chat-design.md b/docs/nip10-thread-chat-design.md new file mode 100644 index 0000000..59188ef --- /dev/null +++ b/docs/nip10-thread-chat-design.md @@ -0,0 +1,612 @@ +# 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 new file mode 100644 index 0000000..e7515c1 --- /dev/null +++ b/docs/nip10-thread-chat-examples.md @@ -0,0 +1,972 @@ +# 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 new file mode 100644 index 0000000..c7968e6 --- /dev/null +++ b/docs/nip10-thread-chat-summary.md @@ -0,0 +1,472 @@ +# 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)