From 67440c55d6fc14e6a71fe4079ea497c7134e7621 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Jan 2026 10:56:10 +0000 Subject: [PATCH] feat: implement NIP-10 thread chat support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add complete NIP-10 thread chat implementation enabling "chat nevent..." to display kind 1 threaded conversations as chat interfaces. **Type Definitions**: - Add ThreadIdentifier for nevent/note event pointers - Add "nip-10" to ChatProtocol type - Extend ConversationMetadata with thread-specific fields (rootEventId, providedEventId, threadDepth, relays) **NIP-10 Adapter** (src/lib/chat/adapters/nip-10-adapter.ts): - parseIdentifier: Decode nevent/note format, reject non-kind-1 events - resolveConversation: Fetch provided event, find root via NIP-10 refs, determine conversation relays (merge hints, outbox, fallbacks) - loadMessages: Subscribe to kind 1 replies, kind 7 reactions, kind 9735 zaps - sendMessage: Build proper NIP-10 tags (root/reply markers), add p-tags for all participants - sendReaction: Send kind 7 with proper event/author references - Smart relay selection: Merges seen relays, nevent hints, author outbox, user outbox (limit 7 relays for performance) **ChatViewer Updates**: - Detect NIP-10 threads (protocol === "nip-10") - Fetch and display root event at top (centered with KindRenderer) - Show visual separator ("Replies") between root and messages - Update empty state message for threads ("No replies yet...") - Enhanced header: Show "Author • Preview" for thread chats - Update getAdapter to handle "nip-10" protocol **Chat Parser**: - Add Nip10Adapter to priority list (before other adapters to catch nevent/note) - Update error message with nevent/note format examples - Update adapter priority documentation **Component Enhancements**: - ReplyPreview: Show "thread root" when replying to root event (NIP-10) - RelaysDropdown: Support conversation.metadata.relays for thread relay breakdown - ChatMessageContextMenu: Add "Zap" option to context menu (opens ZapWindow) **Features**: - Root event displayed with full feed renderer (can interact: like, zap, etc.) - All replies shown as chat messages with proper threading - Reply/React/Zap options on all messages - Relay dropdown shows breakdown of thread relays - Participants dropdown shows all thread participants - @ mention autocomplete works for participants - Proper NIP-10 tag structure for nested replies - Smart relay selection for maximum reach **Usage**: chat nevent1qqsxyz... # Thread with relay hints chat note1abc... # Thread with event ID only Root event is centered at top, all replies below as chat messages. Sending replies creates kind 1 events with proper NIP-10 root/reply markers and p-tags for all participants. --- src/components/ChatViewer.tsx | 176 ++-- .../chat/ChatMessageContextMenu.tsx | 24 +- src/components/chat/RelaysDropdown.tsx | 9 +- src/components/chat/ReplyPreview.tsx | 17 +- src/lib/chat-parser.ts | 20 +- src/lib/chat/adapters/nip-10-adapter.ts | 871 ++++++++++++++++++ src/types/chat.ts | 37 +- 7 files changed, 1059 insertions(+), 95 deletions(-) create mode 100644 src/lib/chat/adapters/nip-10-adapter.ts diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index 2808c46..f4cfe4c 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -24,6 +24,7 @@ import type { } from "@/types/chat"; import { CHAT_KINDS } from "@/types/chat"; // import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter"; // Coming soon +import { Nip10Adapter } from "@/lib/chat/adapters/nip-10-adapter"; import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter"; import { Nip53Adapter } from "@/lib/chat/adapters/nip-53-adapter"; import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter"; @@ -33,6 +34,7 @@ import { parseSlashCommand } from "@/lib/chat/slash-command-parser"; import { UserName } from "./nostr/UserName"; import { RichText } from "./nostr/RichText"; import Timestamp from "./Timestamp"; +import { KindRenderer } from "./nostr/kinds"; import { ReplyPreview } from "./chat/ReplyPreview"; import { MembersDropdown } from "./chat/MembersDropdown"; import { RelaysDropdown } from "./chat/RelaysDropdown"; @@ -507,6 +509,16 @@ export function ChatViewer({ ? conversationResult.conversation : null; + // Check if this is a NIP-10 thread + const isThreadChat = protocol === "nip-10"; + + // Fetch root event for NIP-10 threads + const rootEventId = conversation?.metadata?.rootEventId; + const rootEvent = use$( + () => (rootEventId ? eventStore.event(rootEventId) : undefined), + [rootEventId], + ); + // Slash command search for action autocomplete // Context-aware: only shows relevant actions based on membership status const searchCommands = useCallback( @@ -821,10 +833,23 @@ export function ChatViewer({ {/* Message timeline with virtualization */} -
- {messagesWithMarkers && messagesWithMarkers.length > 0 ? ( - - hasMore && conversationResult.status === "success" ? ( -
- -
- ) : null, - Footer: () =>
, - }} - itemContent={(_index, item) => { - if (item.type === "day-marker") { - return ( -
- -
- ); - } - return ( - - ); - }} - style={{ height: "100%" }} - /> - ) : ( -
- No messages yet. Start the conversation! +
+ {/* 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 ? ( + + hasMore && conversationResult.status === "success" ? ( +
+ +
+ ) : null, + Footer: () =>
, + }} + itemContent={(_index, item) => { + if (item.type === "day-marker") { + return ( +
+ +
+ ); + } + return ( + + ); + }} + style={{ height: "100%" }} + /> + ) : ( +
+ {isThreadChat + ? "No replies yet. Start the conversation!" + : "No messages yet. Start the conversation!"} +
+ )} +
{/* Message composer - only show if user can sign */} @@ -1070,11 +1116,13 @@ export function ChatViewer({ /** * Get the appropriate adapter for a protocol - * Currently NIP-29 (relay-based groups) and NIP-53 (live activity chat) are supported + * Currently NIP-10 (thread chat), NIP-29 (relay-based groups) and NIP-53 (live activity chat) are supported * Other protocols will be enabled in future phases */ function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter { switch (protocol) { + case "nip-10": + return new Nip10Adapter(); // case "nip-c7": // Phase 1 - Simple chat (coming soon) // return new NipC7Adapter(); case "nip-29": diff --git a/src/components/chat/ChatMessageContextMenu.tsx b/src/components/chat/ChatMessageContextMenu.tsx index 6c23fca..b48a59f 100644 --- a/src/components/chat/ChatMessageContextMenu.tsx +++ b/src/components/chat/ChatMessageContextMenu.tsx @@ -133,6 +133,18 @@ export function ChatMessageContextMenu({ setEmojiPickerOpen(true); }; + const openZapWindow = () => { + if (!zapConfig || !zapConfig.supported) return; + + addWindow("zap", { + recipientPubkey: zapConfig.recipientPubkey, + eventPointer: zapConfig.eventPointer, + addressPointer: zapConfig.addressPointer, + customTags: zapConfig.customTags, + relays: zapConfig.relays, + }); + }; + const handleEmojiSelect = async (emoji: string, customEmoji?: EmojiTag) => { if (!conversation || !adapter) { console.error( @@ -148,18 +160,6 @@ export function ChatMessageContextMenu({ } }; - const openZapWindow = () => { - if (!zapConfig || !zapConfig.supported) return; - - addWindow("zap", { - recipientPubkey: zapConfig.recipientPubkey, - eventPointer: zapConfig.eventPointer, - addressPointer: zapConfig.addressPointer, - customTags: zapConfig.customTags, - relays: zapConfig.relays, - }); - }; - return ( <> diff --git a/src/components/chat/RelaysDropdown.tsx b/src/components/chat/RelaysDropdown.tsx index b6c0927..c00ed2c 100644 --- a/src/components/chat/RelaysDropdown.tsx +++ b/src/components/chat/RelaysDropdown.tsx @@ -23,12 +23,15 @@ export function RelaysDropdown({ conversation }: RelaysDropdownProps) { // Get relays for this conversation (immutable pattern) const liveActivityRelays = conversation.metadata?.liveActivity?.relays; + const metadataRelays = conversation.metadata?.relays; const relays: string[] = Array.isArray(liveActivityRelays) && liveActivityRelays.length > 0 ? liveActivityRelays - : conversation.metadata?.relayUrl - ? [conversation.metadata.relayUrl] - : []; + : Array.isArray(metadataRelays) && metadataRelays.length > 0 + ? metadataRelays + : conversation.metadata?.relayUrl + ? [conversation.metadata.relayUrl] + : []; // Pre-compute normalized URLs and state lookups in a single pass (O(n)) const relayData = relays.map((url) => { diff --git a/src/components/chat/ReplyPreview.tsx b/src/components/chat/ReplyPreview.tsx index 197c5e5..c1b5d63 100644 --- a/src/components/chat/ReplyPreview.tsx +++ b/src/components/chat/ReplyPreview.tsx @@ -26,6 +26,9 @@ export const ReplyPreview = memo(function ReplyPreview({ // Load the event being replied to (reactive - updates when event arrives) const replyEvent = use$(() => eventStore.event(replyToId), [replyToId]); + // Check if replying to thread root (NIP-10) + const isRoot = conversation.metadata?.rootEventId === replyToId; + // Fetch event from relays if not in store useEffect(() => { if (!replyEvent) { @@ -47,7 +50,7 @@ export const ReplyPreview = memo(function ReplyPreview({ if (!replyEvent) { return (
- ↳ Replying to {replyToId.slice(0, 8)}... + ↳ Replying to {isRoot ? "thread root" : replyToId.slice(0, 8)}...
); } @@ -59,10 +62,14 @@ export const ReplyPreview = memo(function ReplyPreview({ title="Click to scroll to message" > - + {isRoot ? ( + thread root + ) : ( + + )}
{ + 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 fetchedRoot = await this.fetchEvent( + rootId, + refs.root.e.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", + protocol: "nip-10", + title, + participants, + metadata: { + rootEventId: rootId, + providedEventId: providedEvent.id, + description: rootEvent.content.slice(0, 200), // First 200 chars + relays: conversationRelays, + }, + unreadCount: 0, + }; + } + + /** + * Load messages for a thread + */ + 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 + 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); + }), + ); + } + + /** + * Load more historical messages (pagination) + */ + async loadMoreMessages( + conversation: Conversation, + before: number, + ): Promise { + const rootEventId = conversation.metadata?.rootEventId; + const relays = conversation.metadata?.relays || []; + + if (!rootEventId) { + throw new Error("Root event ID required"); + } + + console.log( + `[NIP-10] Loading older messages for ${rootEventId.slice(0, 8)} before ${before}`, + ); + + // Same filters as loadMessages but with until for pagination + const filters: Filter[] = [ + { + kinds: [1], + "#e": [rootEventId], + until: before, + limit: 50, + }, + { + kinds: [7], + "#e": [rootEventId], + until: before, + limit: 100, + }, + { + kinds: [9735], + "#e": [rootEventId], + until: before, + limit: 50, + }, + ]; + + // One-shot request to fetch older messages + const events = await firstValueFrom( + pool.request(relays, filters, { eventStore }).pipe(toArray()), + ); + + console.log(`[NIP-10] Loaded ${events.length} older events`); + + const conversationId = `nip-10:${rootEventId}`; + + // Convert events to messages + const messages = events + .map((event) => this.eventToMessage(event, conversationId, rootEventId)) + .filter((msg): msg is Message => msg !== null); + + // Reverse for ascending chronological order + return messages.reverse(); + } + + /** + * Send a message (reply) to the thread + */ + 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); + } + + /** + * Send a reaction (kind 7) to a message in the thread + */ + async sendReaction( + conversation: Conversation, + messageId: string, + emoji: string, + customEmoji?: { shortcode: string; url: string }, + ): 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 relays = conversation.metadata?.relays || []; + + // Fetch the message being reacted to + const messageEvent = await firstValueFrom(eventStore.event(messageId), { + defaultValue: undefined, + }); + + if (!messageEvent) { + throw new Error("Message event not found"); + } + + // Create event factory + const factory = new EventFactory(); + factory.setSigner(activeSigner); + + const tags: string[][] = [ + ["e", messageId], // Event being reacted to + ["k", "1"], // Kind of event being reacted to + ["p", messageEvent.pubkey], // Author of message + ]; + + // Add NIP-30 custom emoji tag if provided + if (customEmoji) { + tags.push(["emoji", customEmoji.shortcode, customEmoji.url]); + } + + // Create and sign kind 7 event + const draft = await factory.build({ kind: 7, content: emoji, tags }); + const event = await factory.sign(draft); + + console.log(`[NIP-10] Publishing reaction to ${relays.length} relays`); + + // Publish to conversation relays + await publishEventToRelays(event, relays); + } + + /** + * Load a replied-to message by ID + */ + async loadReplyMessage( + conversation: Conversation, + eventId: string, + ): Promise { + // First check EventStore - might already be loaded + const cachedEvent = await eventStore + .event(eventId) + .pipe(first()) + .toPromise(); + if (cachedEvent) { + return cachedEvent; + } + + // Not in store, fetch from conversation relays + const relays = conversation.metadata?.relays || []; + if (relays.length === 0) { + console.warn("[NIP-10] No relays for loading reply message"); + return null; + } + + console.log( + `[NIP-10] Fetching reply message ${eventId.slice(0, 8)} from ${relays.length} relays`, + ); + + const filter: Filter = { + ids: [eventId], + limit: 1, + }; + + const events = await firstValueFrom( + pool.request(relays, [filter], { eventStore }).pipe(toArray()), + ); + + return events[0] || null; + } + + /** + * Get capabilities of NIP-10 protocol + */ + getCapabilities(): ChatCapabilities { + return { + supportsEncryption: false, + supportsThreading: true, + supportsModeration: false, + supportsRoles: false, + supportsGroupManagement: false, + canCreateConversations: false, + requiresRelay: false, + }; + } + + /** + * 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. Provided relay hints + providedRelays.forEach((r) => relays.add(normalizeURL(r))); + + // 2. 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); + } + + // 3. 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); + } + } + + // 4. 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; + } + + /** + * Helper: Get default relays to use when no hints provided + */ + private async getDefaultRelays(): Promise { + const activePubkey = accountManager.active$.value?.pubkey; + if (activePubkey) { + const outbox = await this.getOutboxRelays(activePubkey); + 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"]; + } + + /** + * 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, handled via MessageReactions + if (event.kind === 7) { + return null; + } + + // 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 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 zap request event for comment + const zapRequestTag = zapReceipt.tags.find((t) => t[0] === "description"); + let comment = ""; + if (zapRequestTag && zapRequestTag[1]) { + try { + const zapRequest = JSON.parse(zapRequestTag[1]) as NostrEvent; + comment = zapRequest.content || ""; + } catch { + // Invalid JSON + } + } + + 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, + }; + } +} diff --git a/src/types/chat.ts b/src/types/chat.ts index 6030e08..13d5c5a 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -14,7 +14,13 @@ export const CHAT_KINDS = [ /** * Chat protocol identifier */ -export type ChatProtocol = "nip-c7" | "nip-17" | "nip-28" | "nip-29" | "nip-53"; +export type ChatProtocol = + | "nip-c7" + | "nip-17" + | "nip-28" + | "nip-29" + | "nip-53" + | "nip-10"; /** * Conversation type @@ -61,8 +67,8 @@ export interface ConversationMetadata { // NIP-29 group groupId?: string; // host'group-id format - relayUrl?: string; // Relay enforcing group rules - description?: string; // Group description + relayUrl?: string; // Relay URL for single-relay protocols + description?: string; // Group/thread description icon?: string; // Group icon/picture URL // NIP-53 live chat @@ -76,6 +82,12 @@ export interface ConversationMetadata { // NIP-17 DM encrypted?: boolean; giftWrapped?: boolean; + + // NIP-10 thread + rootEventId?: string; // Thread root event ID + providedEventId?: string; // Original event from nevent (may be reply) + threadDepth?: number; // Approximate depth of thread + relays?: string[]; // Relays for this conversation } /** @@ -206,6 +218,22 @@ export interface GroupListIdentifier { relays?: string[]; } +/** + * 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: { + id: string; + relays?: string[]; + author?: string; + kind?: number; + }; + /** Relay hints from nevent encoding */ + relays?: string[]; +} + /** * Protocol-specific identifier - discriminated union * Returned by adapter parseIdentifier() @@ -216,7 +244,8 @@ export type ProtocolIdentifier = | DMIdentifier | NIP05Identifier | ChannelIdentifier - | GroupListIdentifier; + | GroupListIdentifier + | ThreadIdentifier; /** * Chat command parsing result