From 99d320c7b92378fbb75e44b5007c1dd33f37aab0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Jan 2026 16:17:51 +0000 Subject: [PATCH] refactor: extract shared utilities from chat adapters Create reusable utility modules for chat adapters to reduce code duplication and improve testability: - event-fetcher.ts: fetchEvent(), fetchReplaceableEvent() with EventStore caching and timeout handling - relay-utils.ts: getOutboxRelays(), getInboxRelays(), mergeRelays(), collectOutboxRelays() for NIP-65 relay list resolution - message-utils.ts: zapReceiptToMessage(), nutzapToMessage(), eventToMessage(), and protocol-specific reply extractors (getNip10ReplyTo, getNip22ReplyTo, getQTagReplyTo) Refactored NIP-22 adapter to use shared utilities, reducing code from ~1165 lines to ~830 lines (~30% reduction). Other adapters (NIP-10, NIP-29, NIP-53) can be refactored similarly using the same pattern. --- src/lib/chat/adapters/nip-22-adapter.ts | 639 ++++++------------------ src/lib/chat/utils/event-fetcher.ts | 188 +++++++ src/lib/chat/utils/index.ts | 53 ++ src/lib/chat/utils/message-utils.ts | 254 ++++++++++ src/lib/chat/utils/relay-utils.ts | 223 +++++++++ 5 files changed, 874 insertions(+), 483 deletions(-) create mode 100644 src/lib/chat/utils/event-fetcher.ts create mode 100644 src/lib/chat/utils/index.ts create mode 100644 src/lib/chat/utils/message-utils.ts create mode 100644 src/lib/chat/utils/relay-utils.ts diff --git a/src/lib/chat/adapters/nip-22-adapter.ts b/src/lib/chat/adapters/nip-22-adapter.ts index dde7cb6..cf72558 100644 --- a/src/lib/chat/adapters/nip-22-adapter.ts +++ b/src/lib/chat/adapters/nip-22-adapter.ts @@ -1,5 +1,5 @@ import { Observable, firstValueFrom, combineLatest, of } from "rxjs"; -import { map, first, toArray } from "rxjs/operators"; +import { map, toArray } from "rxjs/operators"; import type { Filter } from "nostr-tools"; import { nip19 } from "nostr-tools"; import { @@ -20,15 +20,21 @@ 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, getTagValue } from "applesauce-core/helpers"; +import { getTagValue } from "applesauce-core/helpers"; import { EventFactory } from "applesauce-core/event-factory"; +import { getArticleTitle } from "applesauce-common/helpers"; import { - getZapAmount, - getZapSender, - getZapRecipient, - getArticleTitle, -} from "applesauce-common/helpers"; + fetchEvent, + fetchReplaceableEvent, + getOutboxRelays, + mergeRelays, + zapReceiptToMessage, + eventToMessage, + getNip22ReplyTo, + AGGREGATOR_RELAYS, +} from "../utils"; + +const LOG_PREFIX = "[NIP-22]"; /** * NIP-22 Adapter - Comments on Non-Kind-1 Events @@ -49,9 +55,6 @@ export class Nip22Adapter extends ChatProtocolAdapter { /** * Parse identifier - accepts nevent (non-kind-1) or naddr format - * Examples: - * - nevent1... (with kind != 1) - * - naddr1... (addressable events like articles) */ parseIdentifier(input: string): ProtocolIdentifier | null { // Try nevent format @@ -62,7 +65,6 @@ export class Nip22Adapter extends ChatProtocolAdapter { const { id, relays, author, kind } = decoded.data; // Only handle non-kind-1 events (kind 1 is handled by NIP-10) - // If kind is unspecified, we'll check after fetching if (kind === 1) { return null; } @@ -85,10 +87,7 @@ export class Nip22Adapter extends ChatProtocolAdapter { if (decoded.type === "naddr") { const { kind, pubkey, identifier, relays } = decoded.data; - // Skip certain kinds handled by other adapters - // 39000 = NIP-29 group metadata - // 30311 = NIP-53 live activity - // 10009 = Group list + // Skip kinds handled by other adapters if (kind === 39000 || kind === 30311 || kind === 10009) { return null; } @@ -121,12 +120,9 @@ export class Nip22Adapter extends ChatProtocolAdapter { const relayHints = identifier.relays || []; - // Determine if this is an event pointer or address pointer if ("id" in identifier.value) { - // Event pointer return this.resolveFromEventPointer(identifier.value, relayHints); } else { - // Address pointer return this.resolveFromAddressPointer(identifier.value, relayHints); } } @@ -138,39 +134,37 @@ export class Nip22Adapter extends ChatProtocolAdapter { pointer: { id: string; relays?: string[]; author?: string; kind?: number }, relayHints: string[], ): Promise { - // 1. Fetch the provided event - const providedEvent = await this.fetchEvent( - pointer.id, - pointer.relays || relayHints, - ); + const providedEvent = await fetchEvent(pointer.id, { + relayHints: pointer.relays || relayHints, + logPrefix: LOG_PREFIX, + }); + if (!providedEvent) { throw new Error("Event not found"); } - // 2. If this is a kind 1111 comment, resolve to the actual root + // If kind 1111 comment, resolve to actual root if (providedEvent.kind === 1111) { return this.resolveFromComment(providedEvent, relayHints); } - // 3. If this is kind 1, reject (NIP-10 should handle it) + // Kind 1 should use NIP-10 if (providedEvent.kind === 1) { throw new Error("Kind 1 notes should use NIP-10 thread chat"); } - // 4. This event IS the root - build conversation around it - const conversationRelays = await this.getCommentRelays( - providedEvent, + // This event IS the root + const conversationRelays = await this.buildRelays( + providedEvent.pubkey, relayHints, ); - const title = this.extractTitle(providedEvent); - const participants = this.extractParticipants(providedEvent); return { id: `nip-22:${providedEvent.id}`, type: "group", protocol: "nip-22", - title, - participants, + title: this.extractTitle(providedEvent), + participants: this.extractParticipants(providedEvent), metadata: { rootEventId: providedEvent.id, providedEventId: providedEvent.id, @@ -189,18 +183,19 @@ export class Nip22Adapter extends ChatProtocolAdapter { pointer: { kind: number; pubkey: string; identifier: string }, relayHints: string[], ): Promise { - // Fetch the replaceable event - const rootEvent = await this.fetchReplaceableEvent( + const rootEvent = await fetchReplaceableEvent( pointer.kind, pointer.pubkey, - pointer.identifier, - relayHints, + { + identifier: pointer.identifier, + relayHints, + logPrefix: LOG_PREFIX, + }, ); const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`; if (!rootEvent) { - // Even without the event, we can create a conversation based on the address return { id: `nip-22:${coordinate}`, type: "group", @@ -216,19 +211,17 @@ export class Nip22Adapter extends ChatProtocolAdapter { }; } - const conversationRelays = await this.getCommentRelays( - rootEvent, + const conversationRelays = await this.buildRelays( + rootEvent.pubkey, relayHints, ); - const title = this.extractTitle(rootEvent); - const participants = this.extractParticipants(rootEvent); return { id: `nip-22:${coordinate}`, type: "group", protocol: "nip-22", - title, - participants, + title: this.extractTitle(rootEvent), + participants: this.extractParticipants(rootEvent), metadata: { rootEventId: rootEvent.id, rootAddress: pointer, @@ -247,39 +240,35 @@ export class Nip22Adapter extends ChatProtocolAdapter { comment: NostrEvent, relayHints: string[], ): Promise { - // Find root reference using uppercase tags const eTag = comment.tags.find((t) => t[0] === "E"); const aTag = comment.tags.find((t) => t[0] === "A"); const iTag = comment.tags.find((t) => t[0] === "I"); const kTag = comment.tags.find((t) => t[0] === "K"); - const rootKind = kTag ? parseInt(kTag[1], 10) : undefined; // Try E tag (event root) - if (eTag && eTag[1]) { + if (eTag?.[1]) { const eventId = eTag[1]; const relay = eTag[2]; const rootPubkey = eTag[4]; - const rootEvent = await this.fetchEvent( - eventId, - relay ? [relay, ...relayHints] : relayHints, - ); + const rootEvent = await fetchEvent(eventId, { + relayHints: relay ? [relay, ...relayHints] : relayHints, + logPrefix: LOG_PREFIX, + }); if (rootEvent) { - const conversationRelays = await this.getCommentRelays( - rootEvent, + const conversationRelays = await this.buildRelays( + rootEvent.pubkey, relayHints, ); - const title = this.extractTitle(rootEvent); - const participants = this.extractParticipants(rootEvent, comment); return { id: `nip-22:${eventId}`, type: "group", protocol: "nip-22", - title, - participants, + title: this.extractTitle(rootEvent), + participants: this.extractParticipants(rootEvent, comment), metadata: { rootEventId: eventId, providedEventId: comment.id, @@ -291,12 +280,12 @@ export class Nip22Adapter extends ChatProtocolAdapter { }; } - // Root not found but we have the ID - create minimal conversation + // Root not found - create minimal conversation return { id: `nip-22:${eventId}`, type: "group", protocol: "nip-22", - title: `Comments on event`, + title: "Comments on event", participants: rootPubkey ? [{ pubkey: rootPubkey, role: "op" }] : [{ pubkey: comment.pubkey, role: "member" }], @@ -311,7 +300,7 @@ export class Nip22Adapter extends ChatProtocolAdapter { } // Try A tag (addressable root) - if (aTag && aTag[1]) { + if (aTag?.[1]) { const coordinate = aTag[1]; const parts = coordinate.split(":"); if (parts.length >= 3) { @@ -320,29 +309,26 @@ export class Nip22Adapter extends ChatProtocolAdapter { const identifier = parts.slice(2).join(":"); const relay = aTag[2]; - const rootEvent = await this.fetchReplaceableEvent( - kind, - pubkey, + const rootEvent = await fetchReplaceableEvent(kind, pubkey, { identifier, - relay ? [relay, ...relayHints] : relayHints, - ); + relayHints: relay ? [relay, ...relayHints] : relayHints, + logPrefix: LOG_PREFIX, + }); const rootAddress = { kind, pubkey, identifier }; if (rootEvent) { - const conversationRelays = await this.getCommentRelays( - rootEvent, + const conversationRelays = await this.buildRelays( + rootEvent.pubkey, relayHints, ); - const title = this.extractTitle(rootEvent); - const participants = this.extractParticipants(rootEvent, comment); return { id: `nip-22:${coordinate}`, type: "group", protocol: "nip-22", - title, - participants, + title: this.extractTitle(rootEvent), + participants: this.extractParticipants(rootEvent, comment), metadata: { rootEventId: rootEvent.id, rootAddress, @@ -355,7 +341,6 @@ export class Nip22Adapter extends ChatProtocolAdapter { }; } - // Root not found but we have the address return { id: `nip-22:${coordinate}`, type: "group", @@ -373,8 +358,8 @@ export class Nip22Adapter extends ChatProtocolAdapter { } } - // Try I tag (external identifier like URL) - if (iTag && iTag[1]) { + // Try I tag (external identifier) + if (iTag?.[1]) { const externalId = iTag[1]; return { @@ -386,7 +371,7 @@ export class Nip22Adapter extends ChatProtocolAdapter { metadata: { rootExternal: externalId, providedEventId: comment.id, - rootKind: 0, // External content has no kind + rootKind: 0, relays: relayHints.length > 0 ? relayHints : AGGREGATOR_RELAYS, }, unreadCount: 0, @@ -407,29 +392,18 @@ export class Nip22Adapter extends ChatProtocolAdapter { const rootAddress = conversation.metadata?.rootAddress; const rootExternal = conversation.metadata?.rootExternal; const relays = conversation.metadata?.relays || []; + const conversationId = conversation.id; - // Build filter based on root type + // Build filters based on root type const filters: Filter[] = []; if (rootEventId) { - // Comments referencing this event filters.push({ kinds: [1111], "#E": [rootEventId], limit: options?.limit || 100, }); - // Reactions on comments - filters.push({ - kinds: [7], - "#e": [rootEventId], - limit: 200, - }); - // Zaps on comments - filters.push({ - kinds: [9735], - "#e": [rootEventId], - limit: 100, - }); + filters.push({ kinds: [7, 9735], "#e": [rootEventId], limit: 200 }); } if (rootAddress) { @@ -453,51 +427,32 @@ export class Nip22Adapter extends ChatProtocolAdapter { return of([]); } - if (options?.before) { - filters[0].until = options.before; - } - if (options?.after) { - filters[0].since = options.after; - } + if (options?.before) filters[0].until = options.before; + if (options?.after) filters[0].since = options.after; - // Clean up any existing subscription - const conversationId = conversation.id; + // Cleanup existing subscription this.cleanup(conversationId); // Start persistent subscription const subscription = pool .subscription(relays, filters, { eventStore }) - .subscribe({ - next: (_response) => { - // EOSE or event - both handled by EventStore - }, - }); + .subscribe(); - // Store subscription for cleanup this.subscriptions.set(conversationId, subscription); - // Build timeline observable based on root type - const commentFilters: Filter[] = []; + // Build timeline observable + const commentFilter: Filter = rootEventId + ? { kinds: [1111, 7, 9735], "#E": [rootEventId] } + : rootAddress + ? { + kinds: [1111, 7, 9735], + "#A": [ + `${rootAddress.kind}:${rootAddress.pubkey}:${rootAddress.identifier}`, + ], + } + : { kinds: [1111], "#I": [rootExternal!] }; - if (rootEventId) { - commentFilters.push({ kinds: [1111, 7, 9735], "#E": [rootEventId] }); - commentFilters.push({ kinds: [7, 9735], "#e": [rootEventId] }); - } - if (rootAddress) { - const coordinate = `${rootAddress.kind}:${rootAddress.pubkey}:${rootAddress.identifier}`; - commentFilters.push({ kinds: [1111, 7, 9735], "#A": [coordinate] }); - } - if (rootExternal) { - commentFilters.push({ kinds: [1111], "#I": [rootExternal] }); - } - - // Combine all comment sources - const comments$ = - commentFilters.length > 0 - ? eventStore.timeline(commentFilters[0]) - : of([]); - - // Optionally fetch root event for display + const comments$ = eventStore.timeline(commentFilter); const rootEvent$ = rootEventId ? eventStore.event(rootEventId) : of(undefined); @@ -506,27 +461,26 @@ export class Nip22Adapter extends ChatProtocolAdapter { map(([rootEvent, commentEvents]) => { const messages: Message[] = []; - // Add root event as first message (if it's a regular event, not addressable) + // Add root event as first message (if available and not a comment) if (rootEvent && rootEvent.kind !== 1111) { - const rootMessage = this.rootEventToMessage( - rootEvent, - conversationId, + messages.push( + eventToMessage(rootEvent, { + conversationId, + protocol: "nip-22", + }), ); - if (rootMessage) { - messages.push(rootMessage); - } } // Convert comments to messages - const commentMessages = commentEvents - .map((event) => - this.eventToMessage(event, conversationId, rootEventId), - ) - .filter((msg): msg is Message => msg !== null); + for (const event of commentEvents) { + const msg = this.convertEventToMessage( + event, + conversationId, + rootEventId, + ); + if (msg) messages.push(msg); + } - messages.push(...commentMessages); - - // Sort by timestamp ascending (chronological order) return messages.sort((a, b) => a.timestamp - b.timestamp); }), ); @@ -542,6 +496,7 @@ export class Nip22Adapter extends ChatProtocolAdapter { const rootEventId = conversation.metadata?.rootEventId; const rootAddress = conversation.metadata?.rootAddress; const relays = conversation.metadata?.relays || []; + const conversationId = conversation.id; const filters: Filter[] = []; @@ -564,21 +519,16 @@ export class Nip22Adapter extends ChatProtocolAdapter { }); } - if (filters.length === 0) { - return []; - } + if (filters.length === 0) return []; const events = await firstValueFrom( pool.request(relays, filters, { eventStore }).pipe(toArray()), ); - const conversationId = conversation.id; - - const messages = events - .map((event) => this.eventToMessage(event, conversationId, rootEventId)) - .filter((msg): msg is Message => msg !== null); - - return messages.reverse(); + return events + .map((e) => this.convertEventToMessage(e, conversationId, rootEventId)) + .filter((msg): msg is Message => msg !== null) + .reverse(); } /** @@ -602,16 +552,13 @@ export class Nip22Adapter extends ChatProtocolAdapter { const rootKind = conversation.metadata?.rootKind; const relays = conversation.metadata?.relays || []; - // Create event factory const factory = new EventFactory(); factory.setSigner(activeSigner); - // Build NIP-22 tags const tags: string[][] = []; // Add root reference (uppercase tags) if (rootEventId) { - // Fetch root to get author const rootEvent = await firstValueFrom(eventStore.event(rootEventId), { defaultValue: undefined, }); @@ -626,9 +573,7 @@ export class Nip22Adapter extends ChatProtocolAdapter { rootPubkey || "", ]); tags.push(["K", (rootEventKind || 0).toString()]); - if (rootPubkey) { - tags.push(["P", rootPubkey]); - } + if (rootPubkey) tags.push(["P", rootPubkey]); } else if (rootAddress) { const coordinate = `${rootAddress.kind}:${rootAddress.pubkey}:${rootAddress.identifier}`; tags.push(["A", coordinate, relays[0] || ""]); @@ -636,7 +581,7 @@ export class Nip22Adapter extends ChatProtocolAdapter { tags.push(["P", rootAddress.pubkey]); } else if (rootExternal) { tags.push(["I", rootExternal]); - tags.push(["K", "0"]); // External content has no kind + tags.push(["K", "0"]); } // Handle reply to another comment @@ -647,7 +592,6 @@ export class Nip22Adapter extends ChatProtocolAdapter { ); if (parentEvent) { - // Add parent reference (lowercase tags) tags.push(["e", options.replyTo, relays[0] || "", parentEvent.pubkey]); tags.push(["k", parentEvent.kind.toString()]); tags.push(["p", parentEvent.pubkey]); @@ -661,7 +605,7 @@ export class Nip22Adapter extends ChatProtocolAdapter { } } - // Add NIP-92 imeta tags for blob attachments + // Add NIP-92 imeta tags if (options?.blobAttachments) { for (const blob of options.blobAttachments) { const imetaParts = [`url ${blob.url}`]; @@ -672,11 +616,9 @@ export class Nip22Adapter extends ChatProtocolAdapter { } } - // Create and sign kind 1111 event const draft = await factory.build({ kind: 1111, content, tags }); const event = await factory.sign(draft); - // Publish to conversation relays await publishEventToRelays(event, relays); } @@ -731,16 +673,14 @@ export class Nip22Adapter extends ChatProtocolAdapter { getZapConfig(message: Message, conversation: Conversation): ZapConfig { const relays = conversation.metadata?.relays || []; - const eventPointer = { - id: message.id, - author: message.author, - relays, - }; - return { supported: true, recipientPubkey: message.author, - eventPointer, + eventPointer: { + id: message.id, + author: message.author, + relays, + }, relays, }; } @@ -752,30 +692,12 @@ export class Nip22Adapter extends ChatProtocolAdapter { conversation: Conversation, eventId: string, ): Promise { - const cachedEvent = await eventStore - .event(eventId) - .pipe(first()) - .toPromise(); - if (cachedEvent) { - return cachedEvent; - } - const relays = conversation.metadata?.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; + return fetchEvent(eventId, { + relayHints: relays, + logPrefix: LOG_PREFIX, + }); } /** @@ -793,21 +715,43 @@ export class Nip22Adapter extends ChatProtocolAdapter { }; } + // --- Private helpers --- + /** - * Extract a readable title from root event + * Build relay list from pubkey outboxes and hints + */ + private async buildRelays( + rootPubkey: string, + providedRelays: string[], + ): Promise { + const rootOutbox = await getOutboxRelays(rootPubkey, { maxRelays: 3 }); + + const activePubkey = accountManager.active$.value?.pubkey; + const userOutbox = + activePubkey && activePubkey !== rootPubkey + ? await getOutboxRelays(activePubkey, { maxRelays: 2 }) + : []; + + return mergeRelays([providedRelays, rootOutbox, userOutbox], { + maxRelays: 10, + minRelays: 3, + }); + } + + /** + * Extract title from root event */ private extractTitle(rootEvent: NostrEvent): string { - // Try article title first (kind 30023) + // Try article title (kind 30023) if (rootEvent.kind === 30023) { const title = getArticleTitle(rootEvent); if (title) return title; } - // Try title tag + // Try title/name tags const titleTag = getTagValue(rootEvent, "title"); if (titleTag) return titleTag; - // Try name tag (for badges, etc.) const nameTag = getTagValue(rootEvent, "name"); if (nameTag) return nameTag; @@ -816,19 +760,14 @@ export class Nip22Adapter extends ChatProtocolAdapter { if (!content) return `Comments on kind ${rootEvent.kind}`; const firstLine = content.split("\n")[0]; - if (firstLine && firstLine.length <= 50) { - return firstLine; - } - - if (content.length <= 50) { - return content; - } + if (firstLine && firstLine.length <= 50) return firstLine; + if (content.length <= 50) return content; return content.slice(0, 47) + "..."; } /** - * Extract participants from root and optional comment + * Extract participants from root event */ private extractParticipants( rootEvent: NostrEvent, @@ -845,10 +784,7 @@ export class Nip22Adapter extends ChatProtocolAdapter { // Add p-tags from root 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", - }); + participants.set(tag[1], { pubkey: tag[1], role: "member" }); } } @@ -864,302 +800,39 @@ export class Nip22Adapter extends ChatProtocolAdapter { } /** - * Get relays for the comment thread + * Convert event to Message, handling different event types */ - private async getCommentRelays( - rootEvent: 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 - try { - const rootOutbox = await this.getOutboxRelays(rootEvent.pubkey); - rootOutbox.slice(0, 3).forEach((r) => relays.add(normalizeURL(r))); - } catch (err) { - console.warn("[NIP-22] Failed to get root author outbox:", err); - } - - // 3. Active user's outbox - const activePubkey = accountManager.active$.value?.pubkey; - if (activePubkey && activePubkey !== rootEvent.pubkey) { - try { - const userOutbox = await this.getOutboxRelays(activePubkey); - userOutbox.slice(0, 2).forEach((r) => relays.add(normalizeURL(r))); - } catch (err) { - console.warn("[NIP-22] Failed to get user outbox:", err); - } - } - - // 4. Fallback to aggregator relays - if (relays.size < 3) { - AGGREGATOR_RELAYS.forEach((r) => relays.add(r)); - } - - return Array.from(relays).slice(0, 10); - } - - /** - * 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 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); - } - - /** - * Fetch an event by ID - */ - private async fetchEvent( - eventId: string, - relayHints: string[] = [], - ): Promise { - const cached = await firstValueFrom(eventStore.event(eventId), { - defaultValue: undefined, - }); - if (cached) return cached; - - 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") { - clearTimeout(timeout); - sub.unsubscribe(); - resolve(); - } else { - events.push(response); - } - }, - error: (err) => { - clearTimeout(timeout); - console.error(`[NIP-22] Fetch error:`, err); - sub.unsubscribe(); - resolve(); - }, - }); - }); - - return events[0] || null; - } - - /** - * Fetch a replaceable event by address - */ - private async fetchReplaceableEvent( - kind: number, - pubkey: string, - identifier: string, - relayHints: string[] = [], - ): Promise { - const cached = await firstValueFrom( - eventStore.replaceable(kind, pubkey, identifier), - { defaultValue: undefined }, - ); - if (cached) return cached; - - const relays = - relayHints.length > 0 ? relayHints : await this.getDefaultRelays(); - - const filter: Filter = { - kinds: [kind], - authors: [pubkey], - "#d": [identifier], - 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") { - clearTimeout(timeout); - sub.unsubscribe(); - resolve(); - } else { - events.push(response); - } - }, - error: (err) => { - clearTimeout(timeout); - console.error(`[NIP-22] Fetch error:`, err); - sub.unsubscribe(); - resolve(); - }, - }); - }); - - return events[0] || null; - } - - /** - * 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, - ): Message | null { - 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 event to Message object - */ - private eventToMessage( + private convertEventToMessage( event: NostrEvent, conversationId: string, rootEventId?: string, ): Message | null { - // Handle zap receipts + // Zap receipts if (event.kind === 9735) { - return this.zapToMessage(event, conversationId); + return zapReceiptToMessage(event, { + conversationId, + protocol: "nip-22", + }); } - // Handle reactions - skip for now + // Skip reactions (handled separately in UI) if (event.kind === 7) { return null; } - // Handle comments (kind 1111) + // Comments (kind 1111) - use NIP-22 reply extraction if (event.kind === 1111) { - // Find parent reference (lowercase e tag) - const parentTag = event.tags.find((t) => t[0] === "e"); - const replyTo = parentTag?.[1] || rootEventId; - - return { - id: event.id, + return eventToMessage(event, { conversationId, - author: event.pubkey, - content: event.content, - timestamp: event.created_at, - type: "user", - replyTo, protocol: "nip-22", - metadata: { - encrypted: false, - }, - event, - }; + getReplyTo: (e) => getNip22ReplyTo(e) || rootEventId, + }); } - // Other event types (the root itself) - return { - id: event.id, + // Other events (root) + return eventToMessage(event, { conversationId, - author: event.pubkey, - content: event.content, - timestamp: event.created_at, - type: "user", - replyTo: undefined, protocol: "nip-22", - metadata: { - encrypted: false, - }, - event, - }; - } - - /** - * Convert zap receipt to Message - */ - private zapToMessage( - zapReceipt: NostrEvent, - conversationId: string, - ): Message { - const amount = getZapAmount(zapReceipt); - const sender = getZapSender(zapReceipt); - const recipient = getZapRecipient(zapReceipt); - - const eTag = zapReceipt.tags.find((t) => t[0] === "e"); - const replyTo = eTag?.[1]; - - 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: amount, - zapRecipient: recipient, - }, - event: zapReceipt, - }; + }); } } diff --git a/src/lib/chat/utils/event-fetcher.ts b/src/lib/chat/utils/event-fetcher.ts new file mode 100644 index 0000000..50e6ae1 --- /dev/null +++ b/src/lib/chat/utils/event-fetcher.ts @@ -0,0 +1,188 @@ +/** + * Shared event fetching utilities for chat adapters + * + * Provides reusable functions for fetching events from relays + * with EventStore caching and timeout handling. + */ + +import { firstValueFrom } from "rxjs"; +import type { Filter } from "nostr-tools"; +import type { NostrEvent } from "@/types/nostr"; +import eventStore from "@/services/event-store"; +import pool from "@/services/relay-pool"; +import { getOutboxRelays, AGGREGATOR_RELAYS } from "./relay-utils"; +import accountManager from "@/services/accounts"; + +export interface FetchEventOptions { + /** Relay hints to try first */ + relayHints?: string[]; + /** Timeout in milliseconds (default: 5000) */ + timeout?: number; + /** Log prefix for debugging */ + logPrefix?: string; +} + +export interface FetchReplaceableOptions extends FetchEventOptions { + /** The d-tag identifier */ + identifier: string; +} + +/** + * Fetch an event by ID + * + * First checks EventStore cache, then fetches from relays. + * Uses provided relay hints, falling back to user's outbox + aggregator relays. + * + * @param eventId - The event ID to fetch + * @param options - Fetch options + * @returns The event or null if not found + */ +export async function fetchEvent( + eventId: string, + options: FetchEventOptions = {}, +): Promise { + const { + relayHints = [], + timeout = 5000, + logPrefix = "[ChatUtils]", + } = options; + + // Check EventStore first + const cached = await firstValueFrom(eventStore.event(eventId), { + defaultValue: undefined, + }); + if (cached) return cached; + + // Determine relays to use + const relays = await resolveRelays(relayHints); + + if (relays.length === 0) { + console.warn(`${logPrefix} No relays available for fetching event`); + return null; + } + + const filter: Filter = { + ids: [eventId], + limit: 1, + }; + + return fetchWithTimeout(relays, [filter], timeout, logPrefix); +} + +/** + * Fetch a replaceable event by kind, pubkey, and identifier + * + * First checks EventStore cache, then fetches from relays. + * + * @param kind - Event kind + * @param pubkey - Author pubkey + * @param options - Fetch options including identifier + * @returns The event or null if not found + */ +export async function fetchReplaceableEvent( + kind: number, + pubkey: string, + options: FetchReplaceableOptions, +): Promise { + const { + identifier, + relayHints = [], + timeout = 5000, + logPrefix = "[ChatUtils]", + } = options; + + // Check EventStore first + const cached = await firstValueFrom( + eventStore.replaceable(kind, pubkey, identifier), + { defaultValue: undefined }, + ); + if (cached) return cached; + + // Determine relays to use + const relays = await resolveRelays(relayHints); + + if (relays.length === 0) { + console.warn( + `${logPrefix} No relays available for fetching replaceable event`, + ); + return null; + } + + const filter: Filter = { + kinds: [kind], + authors: [pubkey], + "#d": [identifier], + limit: 1, + }; + + return fetchWithTimeout(relays, [filter], timeout, logPrefix); +} + +/** + * Fetch events matching a filter with timeout + * + * Returns the first event found or null after timeout/EOSE. + * + * @internal + */ +async function fetchWithTimeout( + relays: string[], + filters: Filter[], + timeout: number, + logPrefix: string, +): Promise { + const events: NostrEvent[] = []; + const obs = pool.subscription(relays, filters, { eventStore }); + + await new Promise((resolve) => { + const timer = setTimeout(() => { + resolve(); + }, timeout); + + const sub = obs.subscribe({ + next: (response) => { + if (typeof response === "string") { + // EOSE received + clearTimeout(timer); + sub.unsubscribe(); + resolve(); + } else { + events.push(response); + } + }, + error: (err) => { + clearTimeout(timer); + console.error(`${logPrefix} Fetch error:`, err); + sub.unsubscribe(); + resolve(); + }, + }); + }); + + return events[0] || null; +} + +/** + * Resolve relays to use for fetching + * + * Priority: provided hints > user's outbox > aggregator relays + * + * @internal + */ +async function resolveRelays(relayHints: string[]): Promise { + if (relayHints.length > 0) { + return relayHints; + } + + // Try user's outbox relays + const activePubkey = accountManager.active$.value?.pubkey; + if (activePubkey) { + const outbox = await getOutboxRelays(activePubkey); + if (outbox.length > 0) { + return outbox.slice(0, 5); + } + } + + // Fall back to aggregator relays + return AGGREGATOR_RELAYS; +} diff --git a/src/lib/chat/utils/index.ts b/src/lib/chat/utils/index.ts new file mode 100644 index 0000000..a8c3396 --- /dev/null +++ b/src/lib/chat/utils/index.ts @@ -0,0 +1,53 @@ +/** + * Shared utilities for chat adapters + * + * This module provides reusable utilities for: + * - Event fetching (with caching and timeout handling) + * - Relay resolution (outbox/inbox relays, merging) + * - Message conversion (zaps, nutzaps, generic events) + * + * @example + * ```typescript + * import { + * fetchEvent, + * fetchReplaceableEvent, + * getOutboxRelays, + * mergeRelays, + * zapReceiptToMessage, + * eventToMessage, + * getNip10ReplyTo, + * } from "@/lib/chat/utils"; + * ``` + */ + +// Event fetching utilities +export { + fetchEvent, + fetchReplaceableEvent, + type FetchEventOptions, + type FetchReplaceableOptions, +} from "./event-fetcher"; + +// Relay utilities +export { + getOutboxRelays, + getInboxRelays, + mergeRelays, + collectOutboxRelays, + AGGREGATOR_RELAYS, + type OutboxRelayOptions, + type MergeRelaysOptions, +} from "./relay-utils"; + +// Message conversion utilities +export { + zapReceiptToMessage, + nutzapToMessage, + eventToMessage, + getNip10ReplyTo, + getNip22ReplyTo, + getQTagReplyTo, + type ZapToMessageOptions, + type NutzapToMessageOptions, + type EventToMessageOptions, +} from "./message-utils"; diff --git a/src/lib/chat/utils/message-utils.ts b/src/lib/chat/utils/message-utils.ts new file mode 100644 index 0000000..993b0db --- /dev/null +++ b/src/lib/chat/utils/message-utils.ts @@ -0,0 +1,254 @@ +/** + * Shared message utilities for chat adapters + * + * Provides reusable functions for converting Nostr events + * to chat Message objects. + */ + +import type { NostrEvent } from "@/types/nostr"; +import type { Message, ChatProtocol } from "@/types/chat"; +import { + getZapAmount, + getZapSender, + getZapRecipient, + getZapRequest, +} from "applesauce-common/helpers/zap"; + +export interface ZapToMessageOptions { + /** The conversation ID for the message */ + conversationId: string; + /** The protocol to set on the message */ + protocol: ChatProtocol; +} + +/** + * Convert a zap receipt (kind 9735) to a Message + * + * Extracts zap metadata using applesauce helpers and builds + * a Message object with type "zap". + * + * @param zapReceipt - The kind 9735 zap receipt event + * @param options - Options including conversationId and protocol + * @returns A Message object with type "zap" + */ +export function zapReceiptToMessage( + zapReceipt: NostrEvent, + options: ZapToMessageOptions, +): Message { + const { conversationId, protocol } = options; + + // Extract zap metadata using applesauce helpers + const amount = getZapAmount(zapReceipt); + const sender = getZapSender(zapReceipt); + const recipient = getZapRecipient(zapReceipt); + const zapRequest = getZapRequest(zapReceipt); + + // Convert from msats to sats + const amountInSats = amount ? Math.floor(amount / 1000) : 0; + + // Get zap comment from request content + const comment = zapRequest?.content || ""; + + // Find the event being zapped (e-tag) + const eTag = zapReceipt.tags.find((t) => t[0] === "e"); + const replyTo = eTag?.[1]; + + return { + id: zapReceipt.id, + conversationId, + author: sender || zapReceipt.pubkey, + content: comment, + timestamp: zapReceipt.created_at, + type: "zap", + replyTo, + protocol, + metadata: { + encrypted: false, + zapAmount: amountInSats, + zapRecipient: recipient, + }, + event: zapReceipt, + }; +} + +export interface NutzapToMessageOptions extends ZapToMessageOptions {} + +/** + * Convert a nutzap event (kind 9321) to a Message + * + * NIP-61 nutzaps are P2PK-locked Cashu token transfers. + * Extracts proof amounts and builds a Message with type "zap". + * + * @param nutzapEvent - The kind 9321 nutzap event + * @param options - Options including conversationId and protocol + * @returns A Message object with type "zap" + */ +export function nutzapToMessage( + nutzapEvent: NostrEvent, + options: NutzapToMessageOptions, +): Message { + const { conversationId, protocol } = options; + + // Sender is the event author + const sender = nutzapEvent.pubkey; + + // Recipient is the p-tag value + const pTag = nutzapEvent.tags.find((t) => t[0] === "p"); + const recipient = pTag?.[1] || ""; + + // Reply target is the e-tag (the event being nutzapped) + const eTag = nutzapEvent.tags.find((t) => t[0] === "e"); + const replyTo = eTag?.[1]; + + // Amount is sum of proof amounts from all proof tags + // NIP-61 allows multiple proof tags, each containing JSON-encoded Cashu proofs + let amount = 0; + for (const tag of nutzapEvent.tags) { + if (tag[0] === "proof" && tag[1]) { + try { + const proof = JSON.parse(tag[1]); + // Proof can be a single object or an array of proofs + if (Array.isArray(proof)) { + amount += proof.reduce( + (sum: number, p: { amount?: number }) => sum + (p.amount || 0), + 0, + ); + } else if (typeof proof === "object" && proof.amount) { + amount += proof.amount; + } + } catch { + // Invalid proof JSON, skip this tag + } + } + } + + // Unit defaults to "sat" per NIP-61 + const unitTag = nutzapEvent.tags.find((t) => t[0] === "unit"); + const unit = unitTag?.[1] || "sat"; + + // Comment is in the content field + const comment = nutzapEvent.content || ""; + + return { + id: nutzapEvent.id, + conversationId, + author: sender, + content: comment, + timestamp: nutzapEvent.created_at, + type: "zap", + replyTo, + protocol, + metadata: { + encrypted: false, + zapAmount: amount, + zapRecipient: recipient, + nutzapUnit: unit, + }, + event: nutzapEvent, + }; +} + +export interface EventToMessageOptions { + /** The conversation ID for the message */ + conversationId: string; + /** The protocol to set on the message */ + protocol: ChatProtocol; + /** Function to extract replyTo from the event */ + getReplyTo?: (event: NostrEvent) => string | undefined; + /** Message type (defaults to "user") */ + type?: "user" | "system"; +} + +/** + * Convert a generic event to a Message + * + * This is a base conversion that works for most chat message types. + * Adapters can customize the replyTo extraction via options. + * + * @param event - The Nostr event to convert + * @param options - Options including conversationId, protocol, and replyTo extractor + * @returns A Message object + */ +export function eventToMessage( + event: NostrEvent, + options: EventToMessageOptions, +): Message { + const { conversationId, protocol, getReplyTo, type = "user" } = options; + + const replyTo = getReplyTo ? getReplyTo(event) : undefined; + + return { + id: event.id, + conversationId, + author: event.pubkey, + content: event.content, + timestamp: event.created_at, + type, + replyTo, + protocol, + metadata: { + encrypted: false, + }, + event, + }; +} + +/** + * Extract reply-to event ID from NIP-10 style e-tags + * + * Looks for "reply" marker first, then falls back to root. + * Works for kind 1 (notes) and kind 1311 (live chat). + * + * @param event - The event to extract reply-to from + * @param rootEventId - Optional root event ID (for fallback) + * @returns The reply-to event ID or undefined + */ +export function getNip10ReplyTo( + event: NostrEvent, + rootEventId?: string, +): string | undefined { + const eTags = event.tags.filter((t) => t[0] === "e"); + + // Find explicit reply marker + const replyTag = eTags.find((t) => t[3] === "reply"); + if (replyTag) return replyTag[1]; + + // Find explicit root marker (if no reply, it's a direct reply to root) + const rootTag = eTags.find((t) => t[3] === "root"); + if (rootTag) return rootTag[1]; + + // Legacy: single e-tag means reply to that event + if (eTags.length === 1 && !eTags[0][3]) { + return eTags[0][1]; + } + + // Fallback to provided root + return rootEventId; +} + +/** + * Extract reply-to event ID from NIP-22 style e-tags (comments) + * + * NIP-22 uses lowercase e tag for parent reference. + * + * @param event - The kind 1111 comment event + * @returns The reply-to event ID or undefined + */ +export function getNip22ReplyTo(event: NostrEvent): string | undefined { + // Lowercase e tag is the parent comment reference + const parentTag = event.tags.find((t) => t[0] === "e"); + return parentTag?.[1]; +} + +/** + * Extract reply-to event ID from q-tag (NIP-29 style) + * + * NIP-29 groups use q-tag for quote/reply references. + * + * @param event - The event to extract reply-to from + * @returns The reply-to event ID or undefined + */ +export function getQTagReplyTo(event: NostrEvent): string | undefined { + const qTag = event.tags.find((t) => t[0] === "q"); + return qTag?.[1]; +} diff --git a/src/lib/chat/utils/relay-utils.ts b/src/lib/chat/utils/relay-utils.ts new file mode 100644 index 0000000..0617026 --- /dev/null +++ b/src/lib/chat/utils/relay-utils.ts @@ -0,0 +1,223 @@ +/** + * Shared relay utilities for chat adapters + * + * Provides reusable functions for relay selection, + * outbox resolution, and relay set merging. + */ + +import { firstValueFrom } from "rxjs"; +import { normalizeURL } from "applesauce-core/helpers"; +import eventStore from "@/services/event-store"; +import { AGGREGATOR_RELAYS as LOADERS_AGGREGATOR_RELAYS } from "@/services/loaders"; + +// Re-export aggregator relays for convenience +export const AGGREGATOR_RELAYS = LOADERS_AGGREGATOR_RELAYS; + +export interface OutboxRelayOptions { + /** Maximum number of relays to return (default: 5) */ + maxRelays?: number; + /** Log prefix for debugging */ + logPrefix?: string; +} + +/** + * Get outbox (write) relays for a pubkey via NIP-65 + * + * Fetches kind 10002 relay list and extracts write relays. + * Falls back to empty array if no relay list found. + * + * @param pubkey - The pubkey to get outbox relays for + * @param options - Options + * @returns Array of normalized relay URLs + */ +export async function getOutboxRelays( + pubkey: string, + options: OutboxRelayOptions = {}, +): Promise { + const { maxRelays = 5, logPrefix = "[RelayUtils]" } = options; + + try { + const relayList = await firstValueFrom( + eventStore.replaceable(10002, pubkey, ""), + { defaultValue: undefined }, + ); + + if (!relayList) return []; + + // Extract write relays (r tags with "write" marker or no marker) + const writeRelays = relayList.tags + .filter((t) => { + if (t[0] !== "r") return false; + const marker = t[2]; + return !marker || marker === "write"; + }) + .map((t) => { + try { + return normalizeURL(t[1]); + } catch { + return t[1]; // Return unnormalized if normalization fails + } + }) + .filter(Boolean); + + return writeRelays.slice(0, maxRelays); + } catch (err) { + console.warn( + `${logPrefix} Failed to get outbox relays for ${pubkey}:`, + err, + ); + return []; + } +} + +/** + * Get inbox (read) relays for a pubkey via NIP-65 + * + * Fetches kind 10002 relay list and extracts read relays. + * Falls back to empty array if no relay list found. + * + * @param pubkey - The pubkey to get inbox relays for + * @param options - Options + * @returns Array of normalized relay URLs + */ +export async function getInboxRelays( + pubkey: string, + options: OutboxRelayOptions = {}, +): Promise { + const { maxRelays = 5, logPrefix = "[RelayUtils]" } = options; + + try { + const relayList = await firstValueFrom( + eventStore.replaceable(10002, pubkey, ""), + { defaultValue: undefined }, + ); + + if (!relayList) return []; + + // Extract read relays (r tags with "read" marker or no marker) + const readRelays = relayList.tags + .filter((t) => { + if (t[0] !== "r") return false; + const marker = t[2]; + return !marker || marker === "read"; + }) + .map((t) => { + try { + return normalizeURL(t[1]); + } catch { + return t[1]; + } + }) + .filter(Boolean); + + return readRelays.slice(0, maxRelays); + } catch (err) { + console.warn(`${logPrefix} Failed to get inbox relays for ${pubkey}:`, err); + return []; + } +} + +export interface MergeRelaysOptions { + /** Maximum total relays to return (default: 10) */ + maxRelays?: number; + /** Fallback relays if result is empty or below minimum */ + fallbackRelays?: string[]; + /** Minimum relays needed before adding fallback (default: 3) */ + minRelays?: number; +} + +/** + * Merge multiple relay sources into a deduplicated, normalized list + * + * Relays are added in order of priority (first sources have higher priority). + * Duplicates are removed using normalized URLs. + * + * @param relaySources - Arrays of relay URLs in priority order + * @param options - Merge options + * @returns Deduplicated array of normalized relay URLs + */ +export function mergeRelays( + relaySources: string[][], + options: MergeRelaysOptions = {}, +): string[] { + const { + maxRelays = 10, + fallbackRelays = AGGREGATOR_RELAYS, + minRelays = 3, + } = options; + + const seen = new Set(); + const result: string[] = []; + + // Add relays from each source in order + for (const source of relaySources) { + for (const relay of source) { + if (!relay) continue; + + try { + const normalized = normalizeURL(relay); + if (!seen.has(normalized)) { + seen.add(normalized); + result.push(normalized); + } + } catch { + // Skip invalid URLs + } + + // Stop if we have enough + if (result.length >= maxRelays) { + return result; + } + } + } + + // Add fallback relays if we don't have enough + if (result.length < minRelays) { + for (const relay of fallbackRelays) { + try { + const normalized = normalizeURL(relay); + if (!seen.has(normalized)) { + seen.add(normalized); + result.push(normalized); + } + } catch { + // Skip invalid URLs + } + + if (result.length >= maxRelays) { + break; + } + } + } + + return result; +} + +/** + * Collect relays from multiple pubkeys' outboxes + * + * Fetches outbox relays for each pubkey and merges them. + * Useful for getting relays for thread participants. + * + * @param pubkeys - Array of pubkeys to get outboxes for + * @param options - Options including max relays per pubkey + * @returns Merged array of relay URLs + */ +export async function collectOutboxRelays( + pubkeys: string[], + options: OutboxRelayOptions & MergeRelaysOptions = {}, +): Promise { + const { maxRelays: perPubkeyMax = 3 } = options; + + const relaySources: string[][] = []; + + for (const pubkey of pubkeys.slice(0, 5)) { + // Limit to 5 pubkeys + const outbox = await getOutboxRelays(pubkey, { maxRelays: perPubkeyMax }); + if (outbox.length > 0) { + relaySources.push(outbox); + } + } + + return mergeRelays(relaySources, options); +}