diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index efd7d23..ff2a343 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -29,6 +29,7 @@ import { CHAT_KINDS } from "@/types/chat"; 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 { Nip22Adapter } from "@/lib/chat/adapters/nip-22-adapter"; import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter"; import type { Message } from "@/types/chat"; import type { ChatAction } from "@/types/chat-actions"; @@ -1235,13 +1236,19 @@ export function ChatViewer({ /** * Get the appropriate adapter for a protocol - * Currently NIP-10 (thread chat), NIP-29 (relay-based groups) and NIP-53 (live activity chat) are supported + * Currently supported: + * - NIP-10 (kind 1 note threads) + * - NIP-22 (event comments/threads for any kind) + * - NIP-29 (relay-based groups) + * - NIP-53 (live activity chat) * Other protocols will be enabled in future phases */ function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter { switch (protocol) { case "nip-10": return new Nip10Adapter(); + case "nip-22": + return new Nip22Adapter(); case "nip-29": return new Nip29Adapter(); // case "nip-17": // Phase 2 - Encrypted DMs (coming soon) diff --git a/src/components/DynamicWindowTitle.tsx b/src/components/DynamicWindowTitle.tsx index a3575ee..4ec6ab5 100644 --- a/src/components/DynamicWindowTitle.tsx +++ b/src/components/DynamicWindowTitle.tsx @@ -25,6 +25,7 @@ import { UserName } from "./nostr/UserName"; import { getTagValues } from "@/lib/nostr-utils"; import { getSemanticAuthor } from "@/lib/semantic-author"; import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter"; +import { Nip22Adapter } from "@/lib/chat/adapters/nip-22-adapter"; import type { ChatProtocol, ProtocolIdentifier } from "@/types/chat"; import { useState, useEffect } from "react"; @@ -738,9 +739,10 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData { const identifier = props.identifier as ProtocolIdentifier; // Get adapter and resolve conversation - // Currently only NIP-29 is supported const getAdapter = () => { switch (protocol) { + case "nip-22": + return new Nip22Adapter(); case "nip-29": return new Nip29Adapter(); default: diff --git a/src/lib/chat-parser.test.ts b/src/lib/chat-parser.test.ts index d0f430c..aadd6e1 100644 --- a/src/lib/chat-parser.test.ts +++ b/src/lib/chat-parser.test.ts @@ -89,13 +89,13 @@ describe("parseChatCommand", () => { ); }); - it("should throw error for npub (NIP-C7 disabled)", () => { + it("should throw error for npub (not an event)", () => { expect(() => parseChatCommand(["npub1xyz"])).toThrow( /Unable to determine chat protocol/, ); }); - it("should throw error for note/nevent (NIP-28 not implemented)", () => { + it("should throw error for malformed note/nevent", () => { expect(() => parseChatCommand(["note1xyz"])).toThrow( /Unable to determine chat protocol/, ); @@ -173,4 +173,61 @@ describe("parseChatCommand", () => { expect(result.protocol).toBe("nip-29"); }); }); + + describe("NIP-22 event comments (catch-all)", () => { + it("should parse note1 format", () => { + const eventId = + "0000000000000000000000000000000000000000000000000000000000000001"; + const note = nip19.noteEncode(eventId); + + const result = parseChatCommand([note]); + + // NIP-10 handles kind 1 notes specifically, so note1 goes to NIP-10 + // For NIP-22, we need nevent with non-kind-1 kind + expect(result.protocol).toBe("nip-10"); // note1 defaults to NIP-10 + }); + + it("should parse nevent for non-kind-1 events", () => { + const nevent = nip19.neventEncode({ + id: "0000000000000000000000000000000000000000000000000000000000000001", + kind: 30023, // Long-form article + relays: ["wss://relay.example.com"], + }); + + const result = parseChatCommand([nevent]); + + expect(result.protocol).toBe("nip-22"); + expect(result.identifier.type).toBe("thread"); + expect(result.adapter.protocol).toBe("nip-22"); + }); + + it("should parse naddr for non-NIP-29/NIP-53 addressable events", () => { + const naddr = nip19.naddrEncode({ + kind: 30023, // Long-form article (not 39000 or 30311) + pubkey: + "0000000000000000000000000000000000000000000000000000000000000001", + identifier: "my-article", + relays: ["wss://relay.example.com"], + }); + + const result = parseChatCommand([naddr]); + + expect(result.protocol).toBe("nip-22"); + expect(result.identifier.type).toBe("thread"); + expect(result.adapter.protocol).toBe("nip-22"); + }); + + it("should parse nevent without kind (defaults to NIP-10)", () => { + const nevent = nip19.neventEncode({ + id: "0000000000000000000000000000000000000000000000000000000000000001", + relays: ["wss://relay.example.com"], + }); + + const result = parseChatCommand([nevent]); + + // Without kind hint, NIP-10 accepts it (assumes kind 1 thread) + // Only nevents with explicit non-kind-1 kind hint go to NIP-22 + expect(result.protocol).toBe("nip-10"); + }); + }); }); diff --git a/src/lib/chat-parser.ts b/src/lib/chat-parser.ts index 5d8a351..f6d520c 100644 --- a/src/lib/chat-parser.ts +++ b/src/lib/chat-parser.ts @@ -2,6 +2,7 @@ import type { ChatCommandResult, GroupListIdentifier } from "@/types/chat"; 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 { Nip22Adapter } from "./chat/adapters/nip-22-adapter"; import { nip19 } from "nostr-tools"; // Import other adapters as they're implemented // import { Nip17Adapter } from "./chat/adapters/nip-17-adapter"; @@ -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-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 + * 1. NIP-10 (thread chat) - nevent/note format for kind 1 threads specifically + * 2. NIP-17 (encrypted DMs) - prioritized for privacy (coming soon) + * 3. NIP-28 (channels) - specific event format (kind 40) (coming soon) + * 4. NIP-29 (groups) - specific group ID format (relay'group-id or kind 39000) * 5. NIP-53 (live chat) - specific addressable format (kind 30311) + * 6. NIP-22 (event comments) - catch-all for any other nevent/naddr/note * * @param args - Command arguments (first arg is the identifier) * @returns Parsed result with protocol and identifier @@ -62,11 +64,12 @@ 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 - new Nip53Adapter(), // Phase 5 - Live activity chat + new Nip10Adapter(), // NIP-10 - Thread chat (kind 1 notes specifically) + // new Nip17Adapter(), // Phase 2 - Encrypted DMs + // new Nip28Adapter(), // Phase 3 - Public channels + new Nip29Adapter(), // NIP-29 - Relay groups + new Nip53Adapter(), // NIP-53 - Live activity chat + new Nip22Adapter(), // NIP-22 - Event comments (catch-all for any event) ]; for (const adapter of adapters) { @@ -84,10 +87,11 @@ 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) + - nevent1.../note1... (NIP-10/NIP-22 threaded chat) Examples: - chat nevent1qqsxyz... (thread with relay hints) - chat note1abc... (thread with event ID only) + chat nevent1qqsxyz... (event with relay hints) + chat note1abc... (event with ID only) + chat naddr1... (addressable event) - relay.com'group-id (NIP-29 relay group, wss:// prefix optional) Examples: chat relay.example.com'bitcoin-dev diff --git a/src/lib/chat/adapters/nip-22-adapter.test.ts b/src/lib/chat/adapters/nip-22-adapter.test.ts new file mode 100644 index 0000000..3c5fdfb --- /dev/null +++ b/src/lib/chat/adapters/nip-22-adapter.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect } from "vitest"; +import { nip19 } from "nostr-tools"; +import { Nip22Adapter } from "./nip-22-adapter"; + +describe("Nip22Adapter", () => { + const adapter = new Nip22Adapter(); + + describe("parseIdentifier", () => { + it("should parse note1 format (simple event ID)", () => { + const eventId = + "0000000000000000000000000000000000000000000000000000000000000001"; + const note = nip19.noteEncode(eventId); + + const result = adapter.parseIdentifier(note); + expect(result).toEqual({ + type: "thread", + value: { id: eventId }, + relays: [], + }); + }); + + it("should parse nevent format with relay hints", () => { + const nevent = nip19.neventEncode({ + id: "0000000000000000000000000000000000000000000000000000000000000001", + relays: ["wss://relay.example.com"], + }); + + const result = adapter.parseIdentifier(nevent); + expect(result).toEqual({ + type: "thread", + value: { + id: "0000000000000000000000000000000000000000000000000000000000000001", + relays: ["wss://relay.example.com"], + author: undefined, + kind: undefined, + }, + relays: ["wss://relay.example.com"], + }); + }); + + it("should parse nevent with author and kind hints", () => { + const nevent = nip19.neventEncode({ + id: "0000000000000000000000000000000000000000000000000000000000000001", + relays: ["wss://relay1.example.com", "wss://relay2.example.com"], + author: + "0000000000000000000000000000000000000000000000000000000000000002", + kind: 30023, + }); + + const result = adapter.parseIdentifier(nevent); + expect(result).toEqual({ + type: "thread", + value: { + id: "0000000000000000000000000000000000000000000000000000000000000001", + relays: ["wss://relay1.example.com", "wss://relay2.example.com"], + author: + "0000000000000000000000000000000000000000000000000000000000000002", + kind: 30023, + }, + relays: ["wss://relay1.example.com", "wss://relay2.example.com"], + }); + }); + + it("should parse naddr format (addressable events)", () => { + const naddr = nip19.naddrEncode({ + kind: 30023, + pubkey: + "0000000000000000000000000000000000000000000000000000000000000001", + identifier: "my-article", + relays: ["wss://relay.example.com"], + }); + + const result = adapter.parseIdentifier(naddr); + expect(result).toEqual({ + type: "thread", + value: { + id: "30023:0000000000000000000000000000000000000000000000000000000000000001:my-article", + relays: ["wss://relay.example.com"], + author: + "0000000000000000000000000000000000000000000000000000000000000001", + kind: 30023, + }, + relays: ["wss://relay.example.com"], + }); + }); + + it("should parse naddr with empty identifier", () => { + const naddr = nip19.naddrEncode({ + kind: 30311, + pubkey: + "0000000000000000000000000000000000000000000000000000000000000001", + identifier: "", + relays: ["wss://relay.example.com"], + }); + + const result = adapter.parseIdentifier(naddr); + expect(result).toEqual({ + type: "thread", + value: { + id: "30311:0000000000000000000000000000000000000000000000000000000000000001:", + relays: ["wss://relay.example.com"], + author: + "0000000000000000000000000000000000000000000000000000000000000001", + kind: 30311, + }, + relays: ["wss://relay.example.com"], + }); + }); + + it("should accept any event kind (catch-all)", () => { + // Kind 6 (repost) + const nevent1 = nip19.neventEncode({ + id: "0000000000000000000000000000000000000000000000000000000000000001", + kind: 6, + }); + expect(adapter.parseIdentifier(nevent1)).not.toBeNull(); + + // Kind 30023 (long-form article) + const nevent2 = nip19.neventEncode({ + id: "0000000000000000000000000000000000000000000000000000000000000002", + kind: 30023, + }); + expect(adapter.parseIdentifier(nevent2)).not.toBeNull(); + + // Kind 1063 (file metadata) + const nevent3 = nip19.neventEncode({ + id: "0000000000000000000000000000000000000000000000000000000000000003", + kind: 1063, + }); + expect(adapter.parseIdentifier(nevent3)).not.toBeNull(); + }); + + it("should return null for invalid formats", () => { + expect(adapter.parseIdentifier("")).toBeNull(); + expect(adapter.parseIdentifier("just-a-string")).toBeNull(); + expect(adapter.parseIdentifier("invalid1xyz")).toBeNull(); + }); + + it("should return null for npub (not an event)", () => { + const npub = nip19.npubEncode( + "0000000000000000000000000000000000000000000000000000000000000001", + ); + expect(adapter.parseIdentifier(npub)).toBeNull(); + }); + + it("should handle malformed nevent gracefully", () => { + expect(adapter.parseIdentifier("nevent1xyz")).toBeNull(); + }); + + it("should handle malformed naddr gracefully", () => { + expect(adapter.parseIdentifier("naddr1xyz")).toBeNull(); + }); + + it("should handle malformed note gracefully", () => { + expect(adapter.parseIdentifier("note1xyz")).toBeNull(); + }); + }); + + describe("protocol and type", () => { + it("should have correct protocol identifier", () => { + expect(adapter.protocol).toBe("nip-22"); + }); + + it("should have correct conversation type", () => { + expect(adapter.type).toBe("group"); + }); + }); + + describe("capabilities", () => { + it("should return correct capabilities", () => { + const caps = adapter.getCapabilities(); + expect(caps).toEqual({ + supportsEncryption: false, + supportsThreading: true, + supportsModeration: false, + supportsRoles: false, + supportsGroupManagement: false, + canCreateConversations: false, + requiresRelay: false, + }); + }); + }); +}); diff --git a/src/lib/chat/adapters/nip-22-adapter.ts b/src/lib/chat/adapters/nip-22-adapter.ts new file mode 100644 index 0000000..338808a --- /dev/null +++ b/src/lib/chat/adapters/nip-22-adapter.ts @@ -0,0 +1,1106 @@ +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 type { EventPointer, AddressPointer } from "nostr-tools/nip19"; +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 { mergeRelaySets } from "applesauce-core/helpers"; +import { getOutboxes } from "applesauce-core/helpers/mailboxes"; +import { + getEventPointerFromETag, + parseReplaceableAddress, +} from "applesauce-core/helpers/pointers"; +import { EventFactory } from "applesauce-core/event-factory"; +import { ReactionBlueprint } from "applesauce-common/blueprints"; +import { getCommentReplyPointer } from "applesauce-common/helpers"; +import { + getZapAmount, + getZapSender, + getZapRecipient, +} from "applesauce-common/helpers"; + +/** + * NIP-22 Adapter - Event Comments as Chat + * + * Features: + * - Turn any event into a threaded chat interface + * - Root event displayed prominently (using its appropriate renderer) + * - All comments (kind 1111) shown as chat messages + * - A-tag based threading (supports addressable and regular events) + * - Catch-all for any event type (after specialized adapters) + * + * Thread ID format: nevent1.../naddr1.../note1... + * Comments use "A" tags to reference root event + */ +export class Nip22Adapter extends ChatProtocolAdapter { + readonly protocol = "nip-22" as const; + readonly type = "group" as const; // Comments are multi-participant like groups + + /** + * Parse identifier - accepts nevent, naddr, or note format + * This is a catch-all adapter, so it accepts any event reference + */ + parseIdentifier(input: string): ProtocolIdentifier | null { + // Try note format (simple event ID) + 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 (event with relay hints) + if (input.startsWith("nevent1")) { + try { + const decoded = nip19.decode(input); + if (decoded.type === "nevent") { + const { id, relays, author, kind } = decoded.data; + + // Accept any kind for NIP-22 (catch-all) + return { + type: "thread", + value: { id, relays, author, kind }, + relays: relays || [], + }; + } + } catch { + return null; + } + } + + // Try naddr format (addressable event) + if (input.startsWith("naddr1")) { + try { + const decoded = nip19.decode(input); + if (decoded.type === "naddr") { + const { kind, pubkey, identifier, relays } = decoded.data; + + // Accept any addressable kind for NIP-22 (catch-all) + return { + type: "thread", + value: { + id: `${kind}:${pubkey}:${identifier}`, // Pseudo-ID for addressable events + relays, + author: pubkey, + 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-22 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, relayHints); + if (!providedEvent) { + throw new Error("Event not found"); + } + + // 2. Determine root event based on kind + let rootEvent: NostrEvent; + let rootId: string; + let rootCoordinate: string | undefined; + + if (providedEvent.kind === 1111) { + // This is a comment - find the root via A-tag + const rootPointer = getCommentReplyPointer(providedEvent); + if (!rootPointer) { + throw new Error("Comment has no A-tag root reference"); + } + + if (rootPointer.type === "event" && rootPointer.id) { + // Regular event pointer + rootId = rootPointer.id; + const eventPointer: EventPointer = { + id: rootPointer.id, + kind: rootPointer.kind, + author: rootPointer.pubkey, + relays: rootPointer.relay ? [rootPointer.relay] : undefined, + }; + const fetchedRoot = await this.fetchEventByPointer( + eventPointer, + relayHints, + ); + if (!fetchedRoot) { + throw new Error("Comment root not found"); + } + rootEvent = fetchedRoot; + } else if ( + rootPointer.type === "address" && + rootPointer.pubkey && + rootPointer.identifier !== undefined + ) { + // Addressable event pointer + rootCoordinate = `${rootPointer.kind}:${rootPointer.pubkey}:${rootPointer.identifier}`; + rootId = rootCoordinate; // Use coordinate as pseudo-ID + const addressPointer: AddressPointer = { + kind: rootPointer.kind, + pubkey: rootPointer.pubkey, + identifier: rootPointer.identifier, + relays: rootPointer.relay ? [rootPointer.relay] : undefined, + }; + const fetchedRoot = await this.fetchAddressableEvent( + addressPointer, + relayHints, + ); + if (!fetchedRoot) { + throw new Error("Comment root not found"); + } + rootEvent = fetchedRoot; + } else { + throw new Error("Unsupported comment root pointer type"); + } + } else { + // Not a comment - this IS the root + rootEvent = providedEvent; + rootId = providedEvent.id; + + // Check if this is an addressable event (kind 30000-39999) + if (rootEvent.kind >= 30000 && rootEvent.kind < 40000) { + const dTag = rootEvent.tags.find((t) => t[0] === "d")?.[1] || ""; + rootCoordinate = `${rootEvent.kind}:${rootEvent.pubkey}:${dTag}`; + rootId = rootCoordinate; // Use coordinate as primary ID + } + } + + // 3. Determine conversation relays + const conversationRelays = await this.getThreadRelays( + rootEvent, + providedEvent, + relayHints, + ); + + // 4. Extract title from root event + const title = this.extractTitle(rootEvent); + + // 5. Build participants list + const participants = this.extractParticipants(rootEvent, providedEvent); + + // 6. Build conversation object + return { + id: `nip-22:${rootId}`, + type: "group", + protocol: "nip-22", + title, + participants, + metadata: { + rootEventId: rootEvent.id, + providedEventId: providedEvent.id, + description: rootEvent.content.slice(0, 200), // First 200 chars + relays: conversationRelays, + // Store coordinate for addressable events + ...(rootCoordinate && { rootCoordinate }), + }, + unreadCount: 0, + }; + } + + /** + * Load messages for a thread + */ + loadMessages( + conversation: Conversation, + options?: LoadMessagesOptions, + ): Observable { + const rootEventId = conversation.metadata?.rootEventId; + const rootCoordinate = conversation.metadata?.rootCoordinate; + const relays = conversation.metadata?.relays || []; + + if (!rootEventId) { + throw new Error("Root event ID required"); + } + + // Build A-tag value for filter + // For addressable events, use coordinate; for regular events, use kind:id format + const aTagValue = rootCoordinate || rootEventId; + + // Build filter for all thread events: + // - kind 1111: comments (NIP-22) + // - kind 7: reactions + // - kind 16: generic reposts (not kind 6 which is for kind 1 only) + // - kind 9735: zap receipts + const filters: Filter[] = [ + // Comments: kind 1111 with A-tag pointing to root + { + kinds: [1111], + "#a": [aTagValue], + limit: options?.limit || 100, + }, + // Reactions: kind 7 with A-tag pointing to root + { + kinds: [7], + "#a": [aTagValue], + limit: 200, // Reactions are small, fetch more + }, + // Generic reposts: kind 16 with A-tag pointing to root + { + kinds: [16], + "#a": [aTagValue], + limit: 100, + }, + // Zaps: kind 9735 receipts with A-tag pointing to root + { + kinds: [9735], + "#a": [aTagValue], + 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-22:${aTagValue}`; + 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 comments + const rootEvent$ = eventStore.event(rootEventId); + const comments$ = eventStore.timeline({ + kinds: [1111, 7, 16, 9735], + "#a": [aTagValue], + }); + + return combineLatest([rootEvent$, comments$]).pipe( + map(([rootEvent, commentEvents]) => { + const messages: Message[] = []; + + // Add root event as first message + if (rootEvent) { + const rootMessage = this.rootEventToMessage( + rootEvent, + conversationId, + aTagValue, + ); + if (rootMessage) { + messages.push(rootMessage); + } + } + + // Convert comments to messages + const commentMessages = commentEvents + .map((event) => this.eventToMessage(event, conversationId, aTagValue)) + .filter((msg): msg is Message => msg !== null); + + messages.push(...commentMessages); + + // 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 rootCoordinate = conversation.metadata?.rootCoordinate; + const relays = conversation.metadata?.relays || []; + + if (!rootEventId) { + throw new Error("Root event ID required"); + } + + const aTagValue = rootCoordinate || rootEventId; + + // Same filters as loadMessages but with until for pagination + const filters: Filter[] = [ + { + kinds: [1111], + "#a": [aTagValue], + until: before, + limit: 50, + }, + { + kinds: [7], + "#a": [aTagValue], + until: before, + limit: 100, + }, + { + kinds: [16], + "#a": [aTagValue], + until: before, + limit: 50, + }, + { + kinds: [9735], + "#a": [aTagValue], + 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-22:${aTagValue}`; + + // Convert events to messages + const messages = events + .map((event) => this.eventToMessage(event, conversationId, aTagValue)) + .filter((msg): msg is Message => msg !== null); + + // Reverse for ascending chronological order + return messages.reverse(); + } + + /** + * Send a message (comment) 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 rootCoordinate = conversation.metadata?.rootCoordinate; + const relays = conversation.metadata?.relays || []; + + if (!rootEventId) { + throw new Error("Root event ID required"); + } + + // Get root event + 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 kind 1111 comment + const tags: string[][] = []; + + // Add A-tag for root reference + const aTagValue = rootCoordinate || `${rootEvent.kind}:${rootEvent.id}`; + tags.push(["a", aTagValue]); + + // Add E-tag for root event (relay hint) + const rootRelayHint = relays[0] || ""; + tags.push(["e", rootEvent.id, rootRelayHint]); + + // Add P-tag for root author + tags.push(["p", rootEvent.pubkey]); + + // Add K-tag for root event kind + tags.push(["k", rootEvent.kind.toString()]); + + // If replying to a comment, add reply tags + if (options?.replyTo) { + const parentEvent = await firstValueFrom( + eventStore.event(options.replyTo), + { defaultValue: undefined }, + ); + if (parentEvent) { + tags.push(["e", parentEvent.id, rootRelayHint, "reply"]); + tags.push(["p", parentEvent.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]); + } + } + + // Build draft + const draft = await factory.build({ kind: 1111, content, tags }); + + // Sign the event + 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); + + // Use ReactionBlueprint - auto-handles e-tag, k-tag, p-tag, custom emoji + const emojiArg = customEmoji + ? { shortcode: customEmoji.shortcode, url: customEmoji.url } + : emoji; + + const draft = await factory.create( + ReactionBlueprint, + messageEvent, + emojiArg, + ); + + // Sign the event + const event = await factory.sign(draft); + + // Publish to conversation relays + await publishEventToRelays(event, relays); + } + + /** + * Get zap configuration for a message in a NIP-22 thread + */ + getZapConfig(message: Message, conversation: Conversation): ZapConfig { + const relays = conversation.metadata?.relays || []; + + // Build eventPointer for the message being zapped + const eventPointer = { + id: message.id, + author: message.author, + relays, + }; + + return { + supported: true, + recipientPubkey: message.author, + eventPointer, + relays, + }; + } + + /** + * Load a replied-to message by pointer + */ + async loadReplyMessage( + conversation: Conversation, + pointer: EventPointer | AddressPointer, + ): Promise { + // Extract event ID from pointer + const eventId = "id" in pointer ? pointer.id : null; + + if (!eventId) { + console.warn( + "[NIP-22] AddressPointer not supported for loadReplyMessage", + ); + return null; + } + + // First check EventStore + const cachedEvent = await eventStore + .event(eventId) + .pipe(first()) + .toPromise(); + if (cachedEvent) { + return cachedEvent; + } + + // Build relay list + const conversationRelays = conversation.metadata?.relays || []; + const relays = mergeRelaySets(conversationRelays, pointer.relays || []); + + if (relays.length === 0) { + console.warn("[NIP-22] 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-22 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 + */ + private extractTitle(rootEvent: NostrEvent): string { + // Try to get title tag first (common for addressable events) + const titleTag = rootEvent.tags.find((t) => t[0] === "title")?.[1]; + if (titleTag && titleTag.trim()) { + return titleTag.length > 50 ? titleTag.slice(0, 47) + "..." : titleTag; + } + + // Try to get subject tag (common for some events) + const subjectTag = rootEvent.tags.find((t) => t[0] === "subject")?.[1]; + if (subjectTag && subjectTag.trim()) { + return subjectTag.length > 50 + ? subjectTag.slice(0, 47) + "..." + : subjectTag; + } + + // Fall back to content + const content = rootEvent.content.trim(); + if (!content) { + return `Event ${rootEvent.kind} 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 (OP) + participants.set(rootEvent.pubkey, { + pubkey: rootEvent.pubkey, + role: "op", + }); + + // 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 relaySets: string[][] = []; + + // 1. Provided relay hints (highest priority) + relaySets.push(providedRelays); + + // 2. Root author's outbox relays (NIP-65) + try { + const rootOutbox = await this.getOutboxRelays(rootEvent.pubkey); + relaySets.push(rootOutbox.slice(0, 3)); + } catch (err) { + console.warn("[NIP-22] Failed to get root author outbox:", err); + } + + // 3. Collect unique participant pubkeys from both events' p-tags + const participantPubkeys = new Set(); + for (const tag of rootEvent.tags) { + if (tag[0] === "p" && tag[1]) participantPubkeys.add(tag[1]); + } + for (const tag of providedEvent.tags) { + if (tag[0] === "p" && tag[1]) participantPubkeys.add(tag[1]); + } + if (providedEvent.pubkey !== rootEvent.pubkey) { + participantPubkeys.add(providedEvent.pubkey); + } + + // 4. Fetch outbox relays from participant subset + const participantsToCheck = Array.from(participantPubkeys).slice(0, 5); + for (const pubkey of participantsToCheck) { + try { + const outbox = await this.getOutboxRelays(pubkey); + if (outbox.length > 0) relaySets.push([outbox[0]]); + } catch { + // Silently continue + } + } + + // 5. Active user's outbox (for publishing comments) + const activePubkey = accountManager.active$.value?.pubkey; + if (activePubkey && !participantPubkeys.has(activePubkey)) { + try { + const userOutbox = await this.getOutboxRelays(activePubkey); + relaySets.push(userOutbox.slice(0, 2)); + } catch (err) { + console.warn("[NIP-22] Failed to get user outbox:", err); + } + } + + // Merge all relay sets + let relays = mergeRelaySets(...relaySets); + + // 6. Fallback to aggregator relays if we have too few + if (relays.length < 3) { + relays = mergeRelaySets(relays, AGGREGATOR_RELAYS); + } + + // Limit to 10 relays max for performance + return 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 []; + + return getOutboxes(relayList).slice(0, 5); + } + + /** + * Helper: Fetch an event by pointer + */ + private async fetchEvent( + pointer: { id: string; kind?: number }, + relayHints: string[] = [], + ): Promise { + // Check EventStore first + const cached = await firstValueFrom(eventStore.event(pointer.id), { + 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: [pointer.id], + limit: 1, + }; + + if (pointer.kind !== undefined) { + filter.kinds = [pointer.kind]; + } + + const events = await firstValueFrom( + pool.request(relays, [filter], { eventStore }).pipe(toArray()), + ); + + return events[0] || null; + } + + /** + * Helper: Fetch event by EventPointer + */ + private async fetchEventByPointer( + pointer: EventPointer, + additionalHints: string[] = [], + ): Promise { + const relayHints = mergeRelaySets(pointer.relays || [], additionalHints); + return this.fetchEvent({ id: pointer.id, kind: pointer.kind }, relayHints); + } + + /** + * Helper: Fetch addressable event by AddressPointer + */ + private async fetchAddressableEvent( + pointer: AddressPointer, + additionalHints: string[] = [], + ): Promise { + const { kind, pubkey, identifier } = pointer; + + // Check EventStore first (using replaceable) + const cached = await firstValueFrom( + eventStore.replaceable(kind, pubkey, identifier), + { defaultValue: undefined }, + ); + if (cached) return cached; + + // Not in store - fetch from relays + const relayHints = mergeRelaySets(pointer.relays || [], additionalHints); + const relays = + relayHints.length > 0 ? relayHints : await this.getDefaultRelays(); + + const filter: Filter = { + kinds: [kind], + authors: [pubkey], + "#d": [identifier], + limit: 1, + }; + + const events = await firstValueFrom( + pool.request(relays, [filter], { eventStore }).pipe(toArray()), + ); + + return events[0] || null; + } + + /** + * Helper: Get default relays + */ + 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); + } + + return AGGREGATOR_RELAYS; + } + + /** + * Convert root event to Message object + */ + private rootEventToMessage( + event: NostrEvent, + conversationId: string, + _aTagValue: string, + ): Message | 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-22", + metadata: { + encrypted: false, + }, + event, + }; + } + + /** + * Convert Nostr event to Message object + */ + private eventToMessage( + event: NostrEvent, + conversationId: string, + aTagValue: string, + ): Message | null { + // Handle zap receipts (kind 9735) + if (event.kind === 9735) { + return this.zapToMessage(event, conversationId); + } + + // Handle reposts (kind 16 only) - simple system messages + if (event.kind === 16) { + return this.repostToMessage(event, conversationId); + } + + // Handle reactions (kind 7) - skip for now, handled via MessageReactions + if (event.kind === 7) { + return null; + } + + // Handle comments (kind 1111) + if (event.kind === 1111) { + // Determine what this comment is responding to + let replyTo: EventPointer | AddressPointer | undefined; + + // Check for reply to another comment (e-tag with "reply" marker) + const replyETag = event.tags.find( + (t) => t[0] === "e" && t[3] === "reply", + ); + if (replyETag) { + const ePointer = getEventPointerFromETag(replyETag); + if (ePointer) { + replyTo = ePointer; + } + } else { + // Not replying to a comment - replying to root + // Parse A-tag to get root reference + const aTag = event.tags.find((t) => t[0] === "a" && t[1] === aTagValue); + if (aTag) { + const parsed = parseReplaceableAddress(aTag[1]); + if (parsed) { + replyTo = { + kind: parsed.kind, + pubkey: parsed.pubkey, + identifier: parsed.identifier, + relays: aTag[2] ? [aTag[2]] : undefined, + }; + } + } + } + + return { + id: event.id, + conversationId, + author: event.pubkey, + content: event.content, + timestamp: event.created_at, + type: "user", + replyTo, + protocol: "nip-22", + metadata: { + encrypted: false, + }, + event, + }; + } + + console.warn(`[NIP-22] Unknown event kind: ${event.kind}`); + return null; + } + + /** + * Convert zap receipt to Message object + */ + private zapToMessage( + zapReceipt: NostrEvent, + conversationId: string, + ): Message { + const amount = getZapAmount(zapReceipt); + const sender = getZapSender(zapReceipt); + const recipient = getZapRecipient(zapReceipt); + + const amountInSats = amount ? Math.floor(amount / 1000) : 0; + + // Find what event is being zapped (a-tag or e-tag in zap receipt) + const aTag = zapReceipt.tags.find((t) => t[0] === "a"); + const eTag = zapReceipt.tags.find((t) => t[0] === "e"); + + let replyTo: EventPointer | AddressPointer | undefined; + + if (aTag) { + const parsed = parseReplaceableAddress(aTag[1]); + if (parsed) { + replyTo = { + kind: parsed.kind, + pubkey: parsed.pubkey, + identifier: parsed.identifier, + relays: aTag[2] ? [aTag[2]] : undefined, + }; + } + } else if (eTag) { + const ePointer = getEventPointerFromETag(eTag); + if (ePointer) { + replyTo = ePointer; + } + } + + // Get zap request 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-22", + metadata: { + zapAmount: amountInSats, + zapRecipient: recipient, + }, + event: zapReceipt, + }; + } + + /** + * Convert repost event to system Message object + */ + private repostToMessage( + repostEvent: NostrEvent, + conversationId: string, + ): Message { + // Find what event is being reposted (a-tag or e-tag) + const aTag = repostEvent.tags.find((t) => t[0] === "a"); + const eTag = repostEvent.tags.find((t) => t[0] === "e"); + + let replyTo: EventPointer | AddressPointer | undefined; + + if (aTag) { + const parsed = parseReplaceableAddress(aTag[1]); + if (parsed) { + replyTo = { + kind: parsed.kind, + pubkey: parsed.pubkey, + identifier: parsed.identifier, + relays: aTag[2] ? [aTag[2]] : undefined, + }; + } + } else if (eTag) { + const ePointer = getEventPointerFromETag(eTag); + if (ePointer) { + replyTo = ePointer; + } + } + + return { + id: repostEvent.id, + conversationId, + author: repostEvent.pubkey, + content: "reposted", + timestamp: repostEvent.created_at, + type: "system", + replyTo, + protocol: "nip-22", + metadata: {}, + event: repostEvent, + }; + } +} diff --git a/src/types/chat.ts b/src/types/chat.ts index fa132af..b0832ad 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -15,7 +15,13 @@ export const CHAT_KINDS = [ /** * Chat protocol identifier */ -export type ChatProtocol = "nip-17" | "nip-28" | "nip-29" | "nip-53" | "nip-10"; +export type ChatProtocol = + | "nip-17" + | "nip-28" + | "nip-29" + | "nip-53" + | "nip-10" + | "nip-22"; /** * Conversation type @@ -78,11 +84,12 @@ export interface ConversationMetadata { encrypted?: boolean; giftWrapped?: boolean; - // NIP-10 thread + // NIP-10/NIP-22 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 + rootCoordinate?: string; // For addressable events: "kind:pubkey:d-tag" } /**