diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index 2808c46..b62d872 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -11,6 +11,7 @@ import { Paperclip, Copy, CopyCheck, + FileText, } from "lucide-react"; import { nip19 } from "nostr-tools"; import { getZapRequest } from "applesauce-common/helpers/zap"; @@ -24,6 +25,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"; @@ -41,6 +43,7 @@ import { StatusBadge } from "./live/StatusBadge"; import { ChatMessageContextMenu } from "./chat/ChatMessageContextMenu"; import { useGrimoire } from "@/core/state"; import { Button } from "./ui/button"; +import LoginDialog from "./nostr/LoginDialog"; import { MentionEditor, type MentionEditorHandle, @@ -589,6 +592,9 @@ export function ChatViewer({ // State for tooltip open (for mobile tap support) const [tooltipOpen, setTooltipOpen] = useState(false); + // State for login dialog + const [showLogin, setShowLogin] = useState(false); + // Handle sending messages with error handling const handleSend = async ( content: string, @@ -731,7 +737,9 @@ export function ChatViewer({ // Handle NIP badge click const handleNipClick = useCallback(() => { - if (conversation?.protocol === "nip-29") { + if (conversation?.protocol === "nip-10") { + addWindow("nip", { number: 10 }); + } else if (conversation?.protocol === "nip-29") { addWindow("nip", { number: 29 }); } else if (conversation?.protocol === "nip-53") { addWindow("nip", { number: 53 }); @@ -745,33 +753,63 @@ export function ChatViewer({ ? conversation?.metadata?.liveActivity : undefined; - // Derive participants from messages for live activities (unique pubkeys who have chatted) + // Derive participants from messages for live activities and NIP-10 threads const derivedParticipants = useMemo(() => { - if (conversation?.type !== "live-chat" || !messages) { - return conversation?.participants || []; - } + // NIP-10 threads: derive from messages with OP first + if (protocol === "nip-10" && messages && conversation) { + const rootAuthor = conversation.metadata?.rootEventId + ? messages.find((m) => m.id === conversation.metadata?.rootEventId) + ?.author + : undefined; - const hostPubkey = liveActivity?.hostPubkey; - const participants: { pubkey: string; role: "host" | "member" }[] = []; + const participants: { pubkey: string; role: "op" | "member" }[] = []; - // Host always first - if (hostPubkey) { - participants.push({ pubkey: hostPubkey, role: "host" }); - } - - // Add other participants from messages (excluding host) - const seen = new Set(hostPubkey ? [hostPubkey] : []); - for (const msg of messages) { - if (msg.type !== "system" && !seen.has(msg.author)) { - seen.add(msg.author); - participants.push({ pubkey: msg.author, role: "member" }); + // OP (root author) always first + if (rootAuthor) { + participants.push({ pubkey: rootAuthor, role: "op" }); } + + // Add other participants from messages (excluding OP) + const seen = new Set(rootAuthor ? [rootAuthor] : []); + for (const msg of messages) { + if (msg.type !== "system" && !seen.has(msg.author)) { + seen.add(msg.author); + participants.push({ pubkey: msg.author, role: "member" }); + } + } + + return participants; } - return participants; + // Live activities: derive from messages with host first + if (conversation?.type === "live-chat" && messages) { + const hostPubkey = liveActivity?.hostPubkey; + const participants: { pubkey: string; role: "host" | "member" }[] = []; + + // Host always first + if (hostPubkey) { + participants.push({ pubkey: hostPubkey, role: "host" }); + } + + // Add other participants from messages (excluding host) + const seen = new Set(hostPubkey ? [hostPubkey] : []); + for (const msg of messages) { + if (msg.type !== "system" && !seen.has(msg.author)) { + seen.add(msg.author); + participants.push({ pubkey: msg.author, role: "member" }); + } + } + + return participants; + } + + // Other protocols: use static participants from conversation + return conversation?.participants || []; }, [ + protocol, conversation?.type, conversation?.participants, + conversation?.metadata?.rootEventId, messages, liveActivity?.hostPubkey, ]); @@ -874,9 +912,16 @@ export function ChatViewer({ conversation.type === "live-chat") && ( )} - - {conversation.type} - + {conversation.protocol === "nip-10" ? ( + + + Thread + + ) : ( + + {conversation.type} + + )} {/* Live Activity Status */} {liveActivity?.status && ( @@ -946,7 +991,9 @@ export function ChatViewer({ alignToBottom components={{ Header: () => - hasMore && conversationResult.status === "success" ? ( + hasMore && + conversationResult.status === "success" && + protocol !== "nip-10" ? (
{" "} + to send messages
)} + + {/* Login dialog */} + ); } /** * 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/nostr/kinds/BaseEventRenderer.tsx b/src/components/nostr/kinds/BaseEventRenderer.tsx index 1c43ca3..d9b59fc 100644 --- a/src/components/nostr/kinds/BaseEventRenderer.tsx +++ b/src/components/nostr/kinds/BaseEventRenderer.tsx @@ -10,7 +10,15 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Menu, Copy, Check, FileJson, ExternalLink, Zap } from "lucide-react"; +import { + Menu, + Copy, + Check, + FileJson, + ExternalLink, + Zap, + MessageSquare, +} from "lucide-react"; import { useGrimoire } from "@/core/state"; import { useCopy } from "@/hooks/useCopy"; import { JsonViewer } from "@/components/JsonViewer"; @@ -184,6 +192,29 @@ export function EventMenu({ event }: { event: NostrEvent }) { }); }; + const openChatWindow = () => { + // Only kind 1 notes support NIP-10 thread chat + if (event.kind === 1) { + const seenRelaysSet = getSeenRelays(event); + const relays = seenRelaysSet ? Array.from(seenRelaysSet) : []; + + // Open chat with NIP-10 thread protocol + addWindow("chat", { + protocol: "nip-10", + identifier: { + type: "thread", + value: { + id: event.id, + relays, + author: event.pubkey, + kind: event.kind, + }, + relays, + }, + }); + } + }; + return ( @@ -212,6 +243,12 @@ export function EventMenu({ event }: { event: NostrEvent }) { Zap + {event.kind === 1 && ( + + + Chat + + )} {copied ? ( diff --git a/src/lib/chat-parser.ts b/src/lib/chat-parser.ts index 8a0778f..a393740 100644 --- a/src/lib/chat-parser.ts +++ b/src/lib/chat-parser.ts @@ -1,5 +1,6 @@ import type { ChatCommandResult, GroupListIdentifier } from "@/types/chat"; // import { NipC7Adapter } from "./chat/adapters/nip-c7-adapter"; +import { Nip10Adapter } from "./chat/adapters/nip-10-adapter"; import { Nip29Adapter } from "./chat/adapters/nip-29-adapter"; import { Nip53Adapter } from "./chat/adapters/nip-53-adapter"; import { nip19 } from "nostr-tools"; @@ -11,11 +12,12 @@ import { nip19 } from "nostr-tools"; * Parse a chat command identifier and auto-detect the protocol * * Tries each adapter's parseIdentifier() in priority order: - * 1. NIP-17 (encrypted DMs) - prioritized for privacy - * 2. NIP-28 (channels) - specific event format (kind 40) - * 3. NIP-29 (groups) - specific group ID format - * 4. NIP-53 (live chat) - specific addressable format (kind 30311) - * 5. NIP-C7 (simple chat) - fallback for generic pubkeys + * 1. NIP-10 (thread chat) - nevent/note format for kind 1 threads + * 2. NIP-17 (encrypted DMs) - prioritized for privacy + * 3. NIP-28 (channels) - specific event format (kind 40) + * 4. NIP-29 (groups) - specific group ID format + * 5. NIP-53 (live chat) - specific addressable format (kind 30311) + * 6. NIP-C7 (simple chat) - fallback for generic pubkeys * * @param args - Command arguments (first arg is the identifier) * @returns Parsed result with protocol and identifier @@ -62,6 +64,7 @@ export function parseChatCommand(args: string[]): ChatCommandResult { // Try each adapter in priority order const adapters = [ + new Nip10Adapter(), // NIP-10 - Thread chat (nevent/note) // new Nip17Adapter(), // Phase 2 // new Nip28Adapter(), // Phase 3 new Nip29Adapter(), // Phase 4 - Relay groups @@ -84,6 +87,10 @@ export function parseChatCommand(args: string[]): ChatCommandResult { `Unable to determine chat protocol from identifier: ${identifier} Currently supported formats: + - nevent1.../note1... (NIP-10 thread chat, kind 1 notes) + Examples: + chat nevent1qqsxyz... (thread with relay hints) + chat note1abc... (thread with event ID only) - relay.com'group-id (NIP-29 relay group, wss:// prefix optional) Examples: chat relay.example.com'bitcoin-dev @@ -99,7 +106,6 @@ Currently supported formats: chat naddr1... (group list address) More formats coming soon: - - npub/nprofile/hex pubkey (NIP-C7/NIP-17 direct messages) - - note/nevent (NIP-28 public channels)`, + - npub/nprofile/hex pubkey (NIP-C7/NIP-17 direct messages)`, ); } diff --git a/src/lib/chat/adapters/nip-10-adapter.ts b/src/lib/chat/adapters/nip-10-adapter.ts new file mode 100644 index 0000000..e746809 --- /dev/null +++ b/src/lib/chat/adapters/nip-10-adapter.ts @@ -0,0 +1,944 @@ +import { Observable, firstValueFrom, combineLatest } from "rxjs"; +import { map, first, toArray } from "rxjs/operators"; +import type { Filter } from "nostr-tools"; +import { nip19 } from "nostr-tools"; +import { + ChatProtocolAdapter, + type SendMessageOptions, + type ZapConfig, +} from "./base-adapter"; +import type { + Conversation, + Message, + ProtocolIdentifier, + ChatCapabilities, + LoadMessagesOptions, + Participant, +} from "@/types/chat"; +import type { NostrEvent } from "@/types/nostr"; +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"; +import { + getZapAmount, + getZapSender, + getZapRecipient, +} from "applesauce-common/helpers"; + +/** + * NIP-10 Adapter - Threaded Notes as Chat + * + * Features: + * - Turn any kind 1 note thread into a chat interface + * - Root event displayed prominently at top + * - All replies shown as chat messages + * - Proper NIP-10 tag structure (root/reply markers) + * - Smart relay selection (merges multiple sources) + * + * Thread ID format: nevent1... or note1... + * Events use "e" tags with markers ("root", "reply") + */ +export class Nip10Adapter extends ChatProtocolAdapter { + readonly protocol = "nip-10" as const; + readonly type = "group" as const; // Threads are multi-participant like groups + + /** + * Parse identifier - accepts nevent or note format + * Examples: + * - nevent1qqsxyz... (with relay hints, author, kind) + * - note1abc... (simple event ID) + */ + 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 as string; + 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; + } + + /** + * Resolve conversation from thread identifier + */ + 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 || []; + + // 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; + + 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; + } + + // 3. Determine conversation relays + const conversationRelays = await this.getThreadRelays( + rootEvent, + providedEvent, + relayHints, + ); + + // 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"); + } + + // 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) => { + // EOSE or event - both handled by EventStore + }, + }); + + // Store subscription for cleanup + this.subscriptions.set(conversationId, subscription); + + // Return observable from EventStore + // Combine root event with replies + const rootEvent$ = eventStore.event(rootEventId); + const replies$ = eventStore.timeline({ + kinds: [1, 7, 9735], + "#e": [rootEventId], + }); + + return combineLatest([rootEvent$, replies$]).pipe( + map(([rootEvent, replyEvents]) => { + const messages: Message[] = []; + + // Add root event as first message + if (rootEvent) { + const rootMessage = this.rootEventToMessage( + rootEvent, + conversationId, + rootEventId, + ); + if (rootMessage) { + messages.push(rootMessage); + } + } + + // Convert replies to messages + const replyMessages = replyEvents + .map((event) => + this.eventToMessage(event, conversationId, rootEventId), + ) + .filter((msg): msg is Message => msg !== null); + + messages.push(...replyMessages); + + // 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"); + } + + // 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()), + ); + + 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); + + // 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); + + // Publish to conversation relays + await publishEventToRelays(event, relays); + } + + /** + * Get zap configuration for a message in a NIP-10 thread + * Returns configuration for how zap requests should be constructed + */ + getZapConfig(message: Message, conversation: Conversation): ZapConfig { + // Get relays from conversation metadata + const relays = conversation.metadata?.relays || []; + + // Build eventPointer for the message being zapped + const eventPointer = { + id: message.id, + author: message.author, + relays, + }; + + // Recipient is the message author + return { + supported: true, + recipientPubkey: message.author, + eventPointer, + 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; + } + + 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: "op", // Root author is "op" (original poster) 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 + * Includes relays from root author, provided event author, p-tagged participants, and active user + */ + 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) - highest priority + 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. Collect unique participant pubkeys from both events' p-tags + const participantPubkeys = new Set(); + + // Add p-tags from root event + for (const tag of rootEvent.tags) { + if (tag[0] === "p" && tag[1]) { + participantPubkeys.add(tag[1]); + } + } + + // Add p-tags from provided event + for (const tag of providedEvent.tags) { + if (tag[0] === "p" && tag[1]) { + participantPubkeys.add(tag[1]); + } + } + + // Add provided event author if different from root + if (providedEvent.pubkey !== rootEvent.pubkey) { + participantPubkeys.add(providedEvent.pubkey); + } + + // 4. Fetch outbox relays from participant subset (limit to avoid slowdown) + // Take first 5 participants to get relay diversity without excessive fetching + const participantsToCheck = Array.from(participantPubkeys).slice(0, 5); + for (const pubkey of participantsToCheck) { + try { + const outbox = await this.getOutboxRelays(pubkey); + // Add 1 relay from each participant for diversity + if (outbox.length > 0) { + relays.add(normalizeURL(outbox[0])); + } + } catch (_err) { + // Silently continue if participant has no relay list + } + } + + // 5. Active user's outbox (for publishing replies) + const activePubkey = accountManager.active$.value?.pubkey; + if (activePubkey && !participantPubkeys.has(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); + } + } + + // 6. Fallback to aggregator relays if we have too few + if (relays.size < 3) { + AGGREGATOR_RELAYS.forEach((r) => relays.add(r)); + } + + // Limit to 10 relays max for performance + return Array.from(relays).slice(0, 10); + } + + /** + * 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(() => { + 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 aggregator relays + return AGGREGATOR_RELAYS; + } + + /** + * Convert root event to Message object + */ + private rootEventToMessage( + event: NostrEvent, + conversationId: string, + _rootEventId: string, + ): Message | null { + if (event.kind !== 1) { + return null; + } + + // Root event has no replyTo field + return { + id: event.id, + conversationId, + author: event.pubkey, + content: event.content, + timestamp: event.created_at, + type: "user", + replyTo: undefined, + protocol: "nip-10", + metadata: { + encrypted: false, + }, + event, + }; + } + + /** + * 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/lib/chat/adapters/nip-53-adapter.ts b/src/lib/chat/adapters/nip-53-adapter.ts index 4f71468..412f910 100644 --- a/src/lib/chat/adapters/nip-53-adapter.ts +++ b/src/lib/chat/adapters/nip-53-adapter.ts @@ -21,6 +21,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 { parseLiveActivity, getLiveStatus, @@ -720,7 +721,7 @@ export class Nip53Adapter extends ChatProtocolAdapter { } // Default fallback relays for live activities - return ["wss://relay.damus.io", "wss://nos.lol", "wss://purplepag.es"]; + return AGGREGATOR_RELAYS; } /** diff --git a/src/lib/relay-transformer.test.ts b/src/lib/relay-transformer.test.ts index 0cae894..d29d233 100644 --- a/src/lib/relay-transformer.test.ts +++ b/src/lib/relay-transformer.test.ts @@ -296,7 +296,7 @@ describe("relayReferences transformer", () => { "wss://relay.damus.io", "wss://nos.lol", "wss://relay.snort.social", - "wss://purplepag.es", + "wss://relay.primal.net", "wss://nostr.wine", ]; diff --git a/src/lib/zap-relay-selection.test.ts b/src/lib/zap-relay-selection.test.ts index ce9bf09..31f61ef 100644 --- a/src/lib/zap-relay-selection.test.ts +++ b/src/lib/zap-relay-selection.test.ts @@ -11,9 +11,10 @@ vi.mock("@/services/relay-list-cache", () => ({ // Mock the loaders for AGGREGATOR_RELAYS vi.mock("@/services/loaders", () => ({ AGGREGATOR_RELAYS: [ - "wss://relay.damus.io", - "wss://nos.lol", - "wss://relay.nostr.band", + "wss://nos.lol/", + "wss://relay.snort.social/", + "wss://relay.primal.net/", + "wss://relay.damus.io/", ], })); @@ -152,7 +153,7 @@ describe("selectZapRelays", () => { expect(result.relays.length).toBeGreaterThan(0); expect(result.sources.fallback.length).toBeGreaterThan(0); - expect(result.relays).toContain("wss://relay.damus.io"); + expect(result.relays).toContain("wss://relay.damus.io/"); }); it("should use fallback when recipient has empty relay list", async () => { diff --git a/src/services/loaders.test.ts b/src/services/loaders.test.ts index 4c5ebb6..ba151b9 100644 --- a/src/services/loaders.test.ts +++ b/src/services/loaders.test.ts @@ -385,9 +385,9 @@ describe("eventLoader", () => { // Should only have aggregator relays (normalized with trailing slash) expect(relays).toContain("wss://nos.lol/"); - expect(relays).toContain("wss://nos.lol/"); - expect(relays).toContain("wss://purplepag.es/"); + expect(relays).toContain("wss://relay.snort.social/"); expect(relays).toContain("wss://relay.primal.net/"); + expect(relays).toContain("wss://relay.damus.io/"); }); it("should limit cached relays to 3", () => { diff --git a/src/services/loaders.ts b/src/services/loaders.ts index 706265e..46a8729 100644 --- a/src/services/loaders.ts +++ b/src/services/loaders.ts @@ -53,8 +53,9 @@ function extractRelayContext(event: NostrEvent): { // IMPORTANT: URLs must be normalized (trailing slash, lowercase) to match RelayStateManager keys export const AGGREGATOR_RELAYS = [ "wss://nos.lol/", - "wss://purplepag.es/", + "wss://relay.snort.social/", "wss://relay.primal.net/", + "wss://relay.damus.io/", ]; // Base event loader (used internally) diff --git a/src/services/relay-selection.test.ts b/src/services/relay-selection.test.ts index 3b694f0..b1e78d1 100644 --- a/src/services/relay-selection.test.ts +++ b/src/services/relay-selection.test.ts @@ -76,7 +76,7 @@ describe("selectRelaysForFilter", () => { const relayListEvent = createRelayListEvent(testSecretKeys[0], [ ["r", "wss://relay.damus.io"], ["r", "wss://nos.lol"], - ["r", "wss://purplepag.es", "read"], + ["r", "wss://relay.snort.social", "read"], ]); // Add to event store @@ -99,7 +99,7 @@ describe("selectRelaysForFilter", () => { result.relays.includes("wss://nos.lol/"); expect(hasWriteRelay).toBe(true); // Should NOT include read-only relay - expect(result.relays).not.toContain("wss://purplepag.es/"); + expect(result.relays).not.toContain("wss://relay.snort.social/"); }); it("should handle multiple authors", async () => { @@ -141,7 +141,7 @@ describe("selectRelaysForFilter", () => { const relayListEvent = createRelayListEvent(testSecretKeys[2], [ ["r", "wss://relay.damus.io", "write"], ["r", "wss://nos.lol", "read"], - ["r", "wss://purplepag.es", "read"], + ["r", "wss://relay.snort.social", "read"], ]); eventStore.add(relayListEvent); @@ -160,7 +160,7 @@ describe("selectRelaysForFilter", () => { // Should include at least one read relay - selectOptimalRelays may pick subset const hasReadRelay = result.relays.includes("wss://nos.lol/") || - result.relays.includes("wss://purplepag.es/"); + result.relays.includes("wss://relay.snort.social/"); expect(hasReadRelay).toBe(true); // Should NOT include write-only relay expect(result.relays).not.toContain("wss://relay.damus.io/"); diff --git a/src/services/supporters.ts b/src/services/supporters.ts index 2d867ff..bee563c 100644 --- a/src/services/supporters.ts +++ b/src/services/supporters.ts @@ -70,7 +70,7 @@ class SupportersService { private async subscribeToZapReceipts() { try { // Start with hardcoded relays for immediate cold start - let grimRelays = [...GRIMOIRE_ZAP_RELAYS]; + const grimRelays = [...GRIMOIRE_ZAP_RELAYS]; // Fetch relay list in background (non-blocking) // Don't await - let it happen in parallel with subscription diff --git a/src/types/chat.ts b/src/types/chat.ts index 6030e08..98c3ef0 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 @@ -24,7 +30,7 @@ export type ConversationType = "dm" | "channel" | "group" | "live-chat"; /** * Participant role in a conversation */ -export type ParticipantRole = "admin" | "moderator" | "member" | "host"; +export type ParticipantRole = "admin" | "moderator" | "member" | "host" | "op"; /** * Participant in a conversation @@ -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