From 4d960d828796afd734e73ea2d2670f49a3595ea4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Jan 2026 16:28:49 +0000 Subject: [PATCH] refactor: update chat adapters to use shared utilities - NIP-10 adapter: use eventToMessage, zapReceiptToMessage, getNip10ReplyTo, fetchEvent, getOutboxRelays, mergeRelays from shared utilities - NIP-29 adapter: use eventToMessage, nutzapToMessage, getQTagReplyTo, fetchEvent from shared utilities - NIP-53 adapter: use eventToMessage, zapReceiptToMessage, getNip10ReplyTo, fetchEvent, getOutboxRelays from shared utilities - Fix empty interface lint error in message-utils.ts Reduces ~770 lines of duplicated code across adapters. --- src/lib/chat/adapters/nip-10-adapter.ts | 645 +++++------------ src/lib/chat/adapters/nip-29-adapter.ts | 893 ++++++++---------------- src/lib/chat/adapters/nip-53-adapter.ts | 230 ++---- src/lib/chat/utils/message-utils.ts | 2 +- 4 files changed, 499 insertions(+), 1271 deletions(-) diff --git a/src/lib/chat/adapters/nip-10-adapter.ts b/src/lib/chat/adapters/nip-10-adapter.ts index e746809..16fe770 100644 --- a/src/lib/chat/adapters/nip-10-adapter.ts +++ b/src/lib/chat/adapters/nip-10-adapter.ts @@ -1,5 +1,5 @@ import { Observable, firstValueFrom, combineLatest } 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,18 @@ 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"; + fetchEvent, + getOutboxRelays, + mergeRelays, + zapReceiptToMessage, + eventToMessage, + AGGREGATOR_RELAYS, +} from "../utils"; + +const LOG_PREFIX = "[NIP-10]"; /** * NIP-10 Adapter - Threaded Notes as Chat @@ -49,9 +52,6 @@ export class Nip10Adapter extends ChatProtocolAdapter { /** * 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) @@ -113,7 +113,11 @@ export class Nip10Adapter extends ChatProtocolAdapter { const relayHints = identifier.relays || []; // 1. Fetch the provided event - const providedEvent = await this.fetchEvent(pointer.id, relayHints); + const providedEvent = await fetchEvent(pointer.id, { + relayHints, + logPrefix: LOG_PREFIX, + }); + if (!providedEvent) { throw new Error("Event not found"); } @@ -131,10 +135,11 @@ export class Nip10Adapter extends ChatProtocolAdapter { // This is a reply - fetch the root rootId = refs.root.e.id; - const fetchedRoot = await this.fetchEvent( - rootId, - refs.root.e.relays || [], - ); + const fetchedRoot = await fetchEvent(rootId, { + relayHints: refs.root.e.relays || [], + logPrefix: LOG_PREFIX, + }); + if (!fetchedRoot) { throw new Error("Thread root not found"); } @@ -146,29 +151,23 @@ export class Nip10Adapter extends ChatProtocolAdapter { } // 3. Determine conversation relays - const conversationRelays = await this.getThreadRelays( + const conversationRelays = await this.buildRelays( 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 + // 4. Build conversation object return { id: `nip-10:${rootId}`, type: "group", protocol: "nip-10", - title, - participants, + title: this.extractTitle(rootEvent), + participants: this.extractParticipants(rootEvent, providedEvent), metadata: { rootEventId: rootId, providedEventId: providedEvent.id, - description: rootEvent.content.slice(0, 200), // First 200 chars + description: rootEvent.content.slice(0, 200), relays: conversationRelays, }, unreadCount: 0, @@ -189,56 +188,29 @@ export class Nip10Adapter extends ChatProtocolAdapter { 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 conversationId = `nip-10:${rootEventId}`; + + // Build filters for thread events 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, - }, + { kinds: [1], "#e": [rootEventId], limit: options?.limit || 100 }, + { kinds: [7], "#e": [rootEventId], limit: 200 }, + { kinds: [9735], "#e": [rootEventId], limit: 100 }, ]; - 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 = `nip-10:${rootEventId}`; + // 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); // Return observable from EventStore - // Combine root event with replies const rootEvent$ = eventStore.event(rootEventId); const replies$ = eventStore.timeline({ kinds: [1, 7, 9735], @@ -251,26 +223,24 @@ export class Nip10Adapter extends ChatProtocolAdapter { // Add root event as first message if (rootEvent) { - const rootMessage = this.rootEventToMessage( - rootEvent, - conversationId, - rootEventId, + messages.push( + eventToMessage(rootEvent, { + conversationId, + protocol: "nip-10", + }), ); - 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); + for (const event of replyEvents) { + const msg = this.convertEventToMessage( + event, + conversationId, + rootEventId, + ); + if (msg) messages.push(msg); + } - messages.push(...replyMessages); - - // Sort by timestamp ascending (chronological order) return messages.sort((a, b) => a.timestamp - b.timestamp); }), ); @@ -290,42 +260,22 @@ export class Nip10Adapter extends ChatProtocolAdapter { 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, - }, + { 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(); + return events + .map((e) => this.convertEventToMessage(e, conversationId, rootEventId)) + .filter((msg): msg is Message => msg !== null) + .reverse(); } /** @@ -350,7 +300,6 @@ export class Nip10Adapter extends ChatProtocolAdapter { throw new Error("Root event ID required"); } - // Fetch root event for building tags const rootEvent = await firstValueFrom(eventStore.event(rootEventId), { defaultValue: undefined, }); @@ -358,14 +307,12 @@ export class Nip10Adapter extends ChatProtocolAdapter { 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 + // Build NIP-10 tags based on reply target if (options?.replyTo && options.replyTo !== rootEventId) { // Replying to another reply const parentEvent = await firstValueFrom( @@ -377,10 +324,7 @@ export class Nip10Adapter extends ChatProtocolAdapter { 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, @@ -388,40 +332,34 @@ export class Nip10Adapter extends ChatProtocolAdapter { "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) + // Add p-tags from parent event 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]); - } + if ( + tag[0] === "p" && + tag[1] && + !tags.some((t) => t[0] === "p" && t[1] === tag[1]) + ) { + tags.push(["p", tag[1]]); } } } 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]); - } + if ( + tag[0] === "p" && + tag[1] && + !tags.some((t) => t[0] === "p" && t[1] === tag[1]) + ) { + tags.push(["p", tag[1]]); } } } @@ -433,7 +371,7 @@ export class Nip10Adapter 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}`]; @@ -444,16 +382,14 @@ export class Nip10Adapter extends ChatProtocolAdapter { } } - // 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 + * Send a reaction (kind 7) to a message */ async sendReaction( conversation: Conversation, @@ -470,7 +406,6 @@ export class Nip10Adapter extends ChatProtocolAdapter { const relays = conversation.metadata?.relays || []; - // Fetch the message being reacted to const messageEvent = await firstValueFrom(eventStore.event(messageId), { defaultValue: undefined, }); @@ -479,49 +414,39 @@ export class Nip10Adapter extends ChatProtocolAdapter { 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 + ["e", messageId], + ["k", "1"], + ["p", messageEvent.pubkey], ]; - // 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 + * Get zap configuration for a message */ 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, + eventPointer: { + id: message.id, + author: message.author, + relays, + }, relays, }; } @@ -533,32 +458,12 @@ export class Nip10Adapter extends ChatProtocolAdapter { 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; + return fetchEvent(eventId, { + relayHints: relays, + logPrefix: LOG_PREFIX, + }); } /** @@ -576,29 +481,72 @@ export class Nip10Adapter extends ChatProtocolAdapter { }; } + // --- Private helpers --- + /** - * Extract a readable title from root event content + * Build relay list from thread participants + */ + private async buildRelays( + rootEvent: NostrEvent, + providedEvent: NostrEvent, + providedRelays: string[], + ): Promise { + const relaySources: string[][] = [providedRelays]; + + // Root author's outbox + const rootOutbox = await getOutboxRelays(rootEvent.pubkey, { + maxRelays: 3, + }); + relaySources.push(rootOutbox); + + // Collect participant pubkeys + 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); + } + + // Add one relay from each participant (limit to 5) + for (const pubkey of Array.from(participantPubkeys).slice(0, 5)) { + const outbox = await getOutboxRelays(pubkey, { maxRelays: 1 }); + if (outbox.length > 0) relaySources.push(outbox); + } + + // Active user's outbox + const activePubkey = accountManager.active$.value?.pubkey; + if (activePubkey && !participantPubkeys.has(activePubkey)) { + const userOutbox = await getOutboxRelays(activePubkey, { maxRelays: 2 }); + relaySources.push(userOutbox); + } + + return mergeRelays(relaySources, { + maxRelays: 10, + minRelays: 3, + fallbackRelays: AGGREGATOR_RELAYS, + }); + } + + /** + * Extract title from root event */ 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; - } + if (firstLine && firstLine.length <= 50) return firstLine; + if (content.length <= 50) return content; return content.slice(0, 47) + "..."; } /** - * Extract unique participants from thread + * Extract participants from thread */ private extractParticipants( rootEvent: NostrEvent, @@ -606,23 +554,20 @@ export class Nip10Adapter extends ChatProtocolAdapter { ): Participant[] { const participants = new Map(); - // Root author is always first + // Root author is OP participants.set(rootEvent.pubkey, { pubkey: rootEvent.pubkey, - role: "op", // Root author is "op" (original poster) of the thread + role: "op", }); - // Add p-tags from root event + // 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" }); } } - // Add provided event author (if different) + // Add provided event author if (providedEvent.pubkey !== rootEvent.pubkey) { participants.set(providedEvent.pubkey, { pubkey: providedEvent.pubkey, @@ -632,11 +577,8 @@ export class Nip10Adapter extends ChatProtocolAdapter { // 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", - }); + if (tag[0] === "p" && tag[1] && !participants.has(tag[1])) { + participants.set(tag[1], { pubkey: tag[1], role: "member" }); } } @@ -644,301 +586,44 @@ export class Nip10Adapter extends ChatProtocolAdapter { } /** - * Determine best relays for the thread - * Includes relays from root author, provided event author, p-tagged participants, and active user + * Convert event to Message, handling different types */ - 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( + private convertEventToMessage( event: NostrEvent, conversationId: string, rootEventId: string, ): Message | null { - // Handle zap receipts (kind 9735) + // Zap receipts if (event.kind === 9735) { - return this.zapToMessage(event, conversationId); + return zapReceiptToMessage(event, { + conversationId, + protocol: "nip-10", + }); } - // Handle reactions (kind 7) - skip for now, handled via MessageReactions + // Skip reactions (handled via MessageReactions) if (event.kind === 7) { return null; } - // Handle replies (kind 1) + // 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, + const getReplyTo = (): string | undefined => { + if (refs.reply?.e) return refs.reply.e.id; + if (refs.root?.e) return refs.root.e.id; + return rootEventId; }; + + return eventToMessage(event, { + conversationId, + protocol: "nip-10", + getReplyTo, + }); } - console.warn(`[NIP-10] Unknown event kind: ${event.kind}`); + console.warn(`${LOG_PREFIX} 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-29-adapter.ts b/src/lib/chat/adapters/nip-29-adapter.ts index 3f51c30..0358a9e 100644 --- a/src/lib/chat/adapters/nip-29-adapter.ts +++ b/src/lib/chat/adapters/nip-29-adapter.ts @@ -1,5 +1,5 @@ import { Observable, firstValueFrom } 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 { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter"; @@ -21,6 +21,14 @@ import accountManager from "@/services/accounts"; import { getTagValues } from "@/lib/nostr-utils"; import { normalizeRelayURL } from "@/lib/relay-url"; import { EventFactory } from "applesauce-core/event-factory"; +import { + fetchEvent, + nutzapToMessage, + eventToMessage, + getQTagReplyTo, +} from "../utils"; + +const LOG_PREFIX = "[NIP-29]"; /** * NIP-29 Adapter - Relay-Based Groups @@ -40,10 +48,6 @@ export class Nip29Adapter extends ChatProtocolAdapter { /** * Parse identifier - accepts group ID format or naddr - * Examples: - * - wss://relay.example.com'bitcoin-dev - * - relay.example.com'bitcoin-dev (wss:// prefix is optional) - * - naddr1... (kind 39000 group metadata address) */ parseIdentifier(input: string): ProtocolIdentifier | null { // Try naddr format first (kind 39000 group metadata) @@ -58,7 +62,6 @@ export class Nip29Adapter extends ChatProtocolAdapter { return null; } - // Ensure relay URL has wss:// prefix let normalizedRelay = relayUrl; if ( !normalizedRelay.startsWith("ws://") && @@ -74,7 +77,7 @@ export class Nip29Adapter extends ChatProtocolAdapter { }; } } catch { - // Not a valid naddr, fall through to try other formats + // Not a valid naddr } } @@ -85,7 +88,6 @@ export class Nip29Adapter extends ChatProtocolAdapter { let [, relayUrl] = match; const groupId = match[2]; - // Add wss:// prefix if not present if (!relayUrl.startsWith("ws://") && !relayUrl.startsWith("wss://")) { relayUrl = `wss://${relayUrl}`; } @@ -103,12 +105,12 @@ export class Nip29Adapter extends ChatProtocolAdapter { async resolveConversation( identifier: ProtocolIdentifier, ): Promise { - // This adapter only handles group identifiers if (identifier.type !== "group") { throw new Error( `NIP-29 adapter cannot handle identifier type: ${identifier.type}`, ); } + const groupId = identifier.value; const relayUrl = identifier.relays?.[0]; @@ -122,61 +124,23 @@ export class Nip29Adapter extends ChatProtocolAdapter { } console.log( - `[NIP-29] Fetching group metadata for ${groupId} from ${relayUrl}`, + `${LOG_PREFIX} Fetching group metadata for ${groupId} from ${relayUrl}`, ); - // Fetch group metadata from the specific relay (kind 39000) + // Fetch group metadata (kind 39000) const metadataFilter: Filter = { kinds: [39000], "#d": [groupId], limit: 1, }; - // Use pool.subscription to fetch from the relay - const metadataEvents: NostrEvent[] = []; - const metadataObs = pool.subscription([relayUrl], [metadataFilter], { - eventStore, // Automatically add to store - }); - - // Subscribe and wait for EOSE - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - console.log("[NIP-29] Metadata fetch timeout"); - resolve(); - }, 5000); - - const sub = metadataObs.subscribe({ - next: (response) => { - if (typeof response === "string") { - // EOSE received - clearTimeout(timeout); - console.log( - `[NIP-29] Got ${metadataEvents.length} metadata events`, - ); - sub.unsubscribe(); - resolve(); - } else { - // Event received - metadataEvents.push(response); - } - }, - error: (err) => { - clearTimeout(timeout); - console.error("[NIP-29] Metadata fetch error:", err); - sub.unsubscribe(); - reject(err); - }, - }); - }); - + const metadataEvents = await this.fetchFromRelay(relayUrl, metadataFilter); const metadataEvent = metadataEvents[0]; - // Debug: Log metadata event tags if (metadataEvent) { - console.log(`[NIP-29] Metadata event tags:`, metadataEvent.tags); + console.log(`${LOG_PREFIX} Metadata event tags:`, metadataEvent.tags); } - // Extract group info from metadata event const title = metadataEvent ? getTagValues(metadataEvent, "name")[0] || groupId : groupId; @@ -187,106 +151,22 @@ export class Nip29Adapter extends ChatProtocolAdapter { ? getTagValues(metadataEvent, "picture")[0] : undefined; - console.log(`[NIP-29] Group title: ${title}`); + console.log(`${LOG_PREFIX} Group title: ${title}`); - // Fetch admins (kind 39001) and members (kind 39002) - // Both use d tag (addressable events signed by relay) + // Fetch participants (kinds 39001 admins, 39002 members) const participantsFilter: Filter = { kinds: [39001, 39002], "#d": [groupId], - limit: 10, // Should be 1 of each kind, but allow for duplicates + limit: 10, }; - const participantEvents: NostrEvent[] = []; - const participantsObs = pool.subscription( - [relayUrl], - [participantsFilter], - { - eventStore, - }, + const participantEvents = await this.fetchFromRelay( + relayUrl, + participantsFilter, ); + const participants = this.extractParticipants(participantEvents); - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - console.log("[NIP-29] Participants fetch timeout"); - resolve(); - }, 5000); - - const sub = participantsObs.subscribe({ - next: (response) => { - if (typeof response === "string") { - // EOSE received - clearTimeout(timeout); - console.log( - `[NIP-29] Got ${participantEvents.length} participant events`, - ); - sub.unsubscribe(); - resolve(); - } else { - // Event received - participantEvents.push(response); - } - }, - error: (err) => { - clearTimeout(timeout); - console.error("[NIP-29] Participants fetch error:", err); - sub.unsubscribe(); - reject(err); - }, - }); - }); - - // Helper to validate and normalize role names - const normalizeRole = (role: string | undefined): ParticipantRole => { - if (!role) return "member"; - const lower = role.toLowerCase(); - if (lower === "admin") return "admin"; - if (lower === "moderator") return "moderator"; - if (lower === "host") return "host"; - // Default to member for unknown roles - return "member"; - }; - - // Extract participants from both admins and members events - const participantsMap = new Map(); - - // Process kind:39001 (admins with roles) - const adminEvents = participantEvents.filter((e) => e.kind === 39001); - for (const event of adminEvents) { - // Each p tag: ["p", "", "", "", ...] - for (const tag of event.tags) { - if (tag[0] === "p" && tag[1]) { - const pubkey = tag[1]; - const roles = tag.slice(2).filter((r) => r); // Get all roles after pubkey - const primaryRole = normalizeRole(roles[0]); // Use first role as primary - participantsMap.set(pubkey, { pubkey, role: primaryRole }); - } - } - } - - // Process kind:39002 (members without roles) - const memberEvents = participantEvents.filter((e) => e.kind === 39002); - for (const event of memberEvents) { - // Each p tag: ["p", ""] - for (const tag of event.tags) { - if (tag[0] === "p" && tag[1]) { - const pubkey = tag[1]; - // Only add if not already in map (admins take precedence) - if (!participantsMap.has(pubkey)) { - participantsMap.set(pubkey, { pubkey, role: "member" }); - } - } - } - } - - const participants = Array.from(participantsMap.values()); - - console.log( - `[NIP-29] Found ${participants.length} participants (${adminEvents.length} admin events, ${memberEvents.length} member events)`, - ); - console.log( - `[NIP-29] Metadata - title: ${title}, icon: ${icon}, description: ${description}`, - ); + console.log(`${LOG_PREFIX} Found ${participants.length} participants`); return { id: `nip-29:${relayUrl}'${groupId}`, @@ -318,66 +198,44 @@ export class Nip29Adapter extends ChatProtocolAdapter { throw new Error("Group ID and relay URL required"); } - console.log(`[NIP-29] Loading messages for ${groupId} from ${relayUrl}`); + console.log( + `${LOG_PREFIX} Loading messages for ${groupId} from ${relayUrl}`, + ); - // Single filter for all group events: - // kind 9: chat messages - // kind 9000: put-user (admin adds user) - // kind 9001: remove-user (admin removes user) - // kind 9321: nutzaps (NIP-61) const filter: Filter = { kinds: [9, 9000, 9001, 9321], "#h": [groupId], limit: options?.limit || 50, }; - if (options?.before) { - filter.until = options.before; - } - if (options?.after) { - filter.since = options.after; - } + if (options?.before) filter.until = options.before; + if (options?.after) filter.since = options.after; - // Clean up any existing subscription for this conversation const conversationId = `nip-29:${relayUrl}'${groupId}`; this.cleanup(conversationId); - // Start a persistent subscription to the group relay const subscription = pool - .subscription([relayUrl], [filter], { - eventStore, - }) + .subscription([relayUrl], [filter], { eventStore }) .subscribe({ next: (response) => { if (typeof response === "string") { - console.log("[NIP-29] EOSE received"); + console.log(`${LOG_PREFIX} EOSE received`); } else { console.log( - `[NIP-29] Received event k${response.kind}: ${response.id.slice(0, 8)}...`, + `${LOG_PREFIX} Received event k${response.kind}: ${response.id.slice(0, 8)}...`, ); } }, }); - // Store subscription for cleanup this.subscriptions.set(conversationId, subscription); - // Return observable from EventStore which will update automatically return eventStore.timeline(filter).pipe( map((events) => { - const messages = events.map((event) => { - // Convert nutzaps (kind 9321) using nutzapToMessage - if (event.kind === 9321) { - return this.nutzapToMessage(event, conversation.id); - } - // All other events use eventToMessage - return this.eventToMessage(event, conversation.id); - }); - - console.log(`[NIP-29] Timeline has ${messages.length} events`); - // EventStore timeline returns events sorted by created_at desc, - // we need ascending order for chat. Since it's already sorted, - // just reverse instead of full sort (O(n) vs O(n log n)) + const messages = events.map((event) => + this.convertEventToMessage(event, conversationId), + ); + console.log(`${LOG_PREFIX} Timeline has ${messages.length} events`); return messages.reverse(); }), ); @@ -398,10 +256,9 @@ export class Nip29Adapter extends ChatProtocolAdapter { } console.log( - `[NIP-29] Loading older messages for ${groupId} before ${before}`, + `${LOG_PREFIX} Loading older messages for ${groupId} before ${before}`, ); - // Same filter as loadMessages but with until for pagination const filter: Filter = { kinds: [9, 9000, 9001, 9321], "#h": [groupId], @@ -409,24 +266,16 @@ export class Nip29Adapter extends ChatProtocolAdapter { limit: 50, }; - // One-shot request to fetch older messages const events = await firstValueFrom( pool.request([relayUrl], [filter], { eventStore }).pipe(toArray()), ); - console.log(`[NIP-29] Loaded ${events.length} older events`); + console.log(`${LOG_PREFIX} Loaded ${events.length} older events`); - // Convert events to messages - const messages = events.map((event) => { - if (event.kind === 9321) { - return this.nutzapToMessage(event, conversation.id); - } - return this.eventToMessage(event, conversation.id); - }); - - // loadMoreMessages returns events in desc order from relay, - // reverse for ascending chronological order - return messages.reverse(); + const conversationId = conversation.id; + return events + .map((event) => this.convertEventToMessage(event, conversationId)) + .reverse(); } /** @@ -451,25 +300,21 @@ export class Nip29Adapter extends ChatProtocolAdapter { throw new Error("Group ID and relay URL required"); } - // Create event factory and sign event const factory = new EventFactory(); factory.setSigner(activeSigner); const tags: string[][] = [["h", groupId]]; if (options?.replyTo) { - // NIP-29 uses q-tag for replies (same as NIP-C7) tags.push(["q", options.replyTo]); } - // 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}`]; @@ -480,16 +325,14 @@ export class Nip29Adapter extends ChatProtocolAdapter { } } - // Use kind 9 for group chat messages const draft = await factory.build({ kind: 9, content, tags }); const event = await factory.sign(draft); - // Publish only to the group relay await publishEventToRelays(event, [relayUrl]); } /** - * Send a reaction (kind 7) to a message in the group + * Send a reaction (kind 7) to a message */ async sendReaction( conversation: Conversation, @@ -511,26 +354,22 @@ export class Nip29Adapter extends ChatProtocolAdapter { throw new Error("Group ID and relay URL required"); } - // Create event factory and sign event const factory = new EventFactory(); factory.setSigner(activeSigner); const tags: string[][] = [ - ["e", messageId], // Event being reacted to - ["h", groupId], // Group context (NIP-29 specific) - ["k", "9"], // Kind of event being reacted to (group chat message) + ["e", messageId], + ["h", groupId], + ["k", "9"], ]; - // Add NIP-30 custom emoji tag if provided if (customEmoji) { tags.push(["emoji", customEmoji.shortcode, customEmoji.url]); } - // Use kind 7 for reactions const draft = await factory.build({ kind: 7, content: emoji, tags }); const event = await factory.sign(draft); - // Publish only to the group relay await publishEventToRelays(event, [relayUrl]); } @@ -539,305 +378,59 @@ export class Nip29Adapter extends ChatProtocolAdapter { */ getCapabilities(): ChatCapabilities { return { - supportsEncryption: false, // kind 9 messages are public - supportsThreading: true, // q-tag replies (NIP-C7 style) - supportsModeration: true, // kind 9005/9006 for delete/ban - supportsRoles: true, // admin, moderator, member - supportsGroupManagement: true, // join/leave via kind 9021 - canCreateConversations: false, // Groups created by admins (kind 9007) - requiresRelay: true, // Single relay enforces rules + supportsEncryption: false, + supportsThreading: true, + supportsModeration: true, + supportsRoles: true, + supportsGroupManagement: true, + canCreateConversations: false, + requiresRelay: true, }; } /** * Get available actions for NIP-29 groups - * Filters actions based on user's membership status: - * - /join: only shown when user is NOT a member/admin - * - /leave: only shown when user IS a member - * - /bookmark: only shown when group is NOT in user's kind 10009 list - * - /unbookmark: only shown when group IS in user's kind 10009 list */ getActions(options?: GetActionsOptions): ChatAction[] { const actions: ChatAction[] = []; - // Check if we have context to filter actions if (!options?.conversation || !options?.activePubkey) { - // No context - return all actions return this.getAllActions(); } const { conversation, activePubkey } = options; - - // Find user's participant info const userParticipant = conversation.participants.find( (p) => p.pubkey === activePubkey, ); - const isMember = !!userParticipant; - // Add /join if user is NOT a member if (!isMember) { - actions.push({ - name: "join", - description: "Request to join the group", - handler: async (context) => { - try { - await this.joinConversation(context.conversation); - return { - success: true, - message: "Join request sent", - }; - } catch (error) { - return { - success: false, - message: - error instanceof Error ? error.message : "Failed to join group", - }; - } - }, - }); + actions.push(this.createJoinAction()); } - // Add /leave if user IS a member if (isMember) { - actions.push({ - name: "leave", - description: "Leave the group", - handler: async (context) => { - try { - await this.leaveConversation(context.conversation); - return { - success: true, - message: "You left the group", - }; - } catch (error) { - return { - success: false, - message: - error instanceof Error - ? error.message - : "Failed to leave group", - }; - } - }, - }); + actions.push(this.createLeaveAction()); } - // Add bookmark/unbookmark actions - // These are always available - the handler checks current state - actions.push({ - name: "bookmark", - description: "Add group to your group list", - handler: async (context) => { - try { - await this.bookmarkGroup(context.conversation, context.activePubkey); - return { - success: true, - message: "Group added to your list", - }; - } catch (error) { - return { - success: false, - message: - error instanceof Error - ? error.message - : "Failed to bookmark group", - }; - } - }, - }); - - actions.push({ - name: "unbookmark", - description: "Remove group from your group list", - handler: async (context) => { - try { - await this.unbookmarkGroup( - context.conversation, - context.activePubkey, - ); - return { - success: true, - message: "Group removed from your list", - }; - } catch (error) { - return { - success: false, - message: - error instanceof Error - ? error.message - : "Failed to unbookmark group", - }; - } - }, - }); + actions.push(this.createBookmarkAction()); + actions.push(this.createUnbookmarkAction()); return actions; } - /** - * Get all possible actions (used when no context available) - * @private - */ - private getAllActions(): ChatAction[] { - return [ - { - name: "join", - description: "Request to join the group", - handler: async (context) => { - try { - await this.joinConversation(context.conversation); - return { - success: true, - message: "Join request sent", - }; - } catch (error) { - return { - success: false, - message: - error instanceof Error ? error.message : "Failed to join group", - }; - } - }, - }, - { - name: "leave", - description: "Leave the group", - handler: async (context) => { - try { - await this.leaveConversation(context.conversation); - return { - success: true, - message: "You left the group", - }; - } catch (error) { - return { - success: false, - message: - error instanceof Error - ? error.message - : "Failed to leave group", - }; - } - }, - }, - { - name: "bookmark", - description: "Add group to your group list", - handler: async (context) => { - try { - await this.bookmarkGroup( - context.conversation, - context.activePubkey, - ); - return { - success: true, - message: "Group added to your list", - }; - } catch (error) { - return { - success: false, - message: - error instanceof Error - ? error.message - : "Failed to bookmark group", - }; - } - }, - }, - { - name: "unbookmark", - description: "Remove group from your group list", - handler: async (context) => { - try { - await this.unbookmarkGroup( - context.conversation, - context.activePubkey, - ); - return { - success: true, - message: "Group removed from your list", - }; - } catch (error) { - return { - success: false, - message: - error instanceof Error - ? error.message - : "Failed to unbookmark group", - }; - } - }, - }, - ]; - } - /** * Load a replied-to message - * First checks EventStore, then fetches from group relay if needed */ 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 group relay const relayUrl = conversation.metadata?.relayUrl; - if (!relayUrl) { - console.warn("[NIP-29] No relay URL for loading reply message"); - return null; - } - console.log( - `[NIP-29] Fetching reply message ${eventId.slice(0, 8)}... from ${relayUrl}`, - ); - - const filter: Filter = { - ids: [eventId], - limit: 1, - }; - - const events: NostrEvent[] = []; - const obs = pool.subscription([relayUrl], [filter], { eventStore }); - - await new Promise((resolve) => { - const timeout = setTimeout(() => { - console.log( - `[NIP-29] Reply message fetch timeout for ${eventId.slice(0, 8)}...`, - ); - resolve(); - }, 3000); - - 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-29] Reply message fetch error:`, err); - sub.unsubscribe(); - resolve(); - }, - }); + return fetchEvent(eventId, { + relayHints: relayUrl ? [relayUrl] : [], + logPrefix: LOG_PREFIX, }); - - return events[0] || null; } /** @@ -858,7 +451,6 @@ export class Nip29Adapter extends ChatProtocolAdapter { throw new Error("Group ID and relay URL required"); } - // Create join request (kind 9021) const factory = new EventFactory(); factory.setSigner(activeSigner); @@ -867,11 +459,7 @@ export class Nip29Adapter extends ChatProtocolAdapter { ["relay", relayUrl], ]; - const draft = await factory.build({ - kind: 9021, - content: "", - tags, - }); + const draft = await factory.build({ kind: 9021, content: "", tags }); const event = await factory.sign(draft); await publishEventToRelays(event, [relayUrl]); } @@ -894,7 +482,6 @@ export class Nip29Adapter extends ChatProtocolAdapter { throw new Error("Group ID and relay URL required"); } - // Create leave request (kind 9022) const factory = new EventFactory(); factory.setSigner(activeSigner); @@ -903,37 +490,11 @@ export class Nip29Adapter extends ChatProtocolAdapter { ["relay", relayUrl], ]; - const draft = await factory.build({ - kind: 9022, - content: "", - tags, - }); + const draft = await factory.build({ kind: 9022, content: "", tags }); const event = await factory.sign(draft); await publishEventToRelays(event, [relayUrl]); } - /** - * Helper: Check if a tag matches a group by ID and relay URL (normalized comparison) - */ - private isMatchingGroupTag( - tag: string[], - groupId: string, - normalizedRelayUrl: string, - ): boolean { - if (tag[0] !== "group" || tag[1] !== groupId) { - return false; - } - // Normalize the tag's relay URL for comparison - try { - const tagRelayUrl = tag[2]; - if (!tagRelayUrl) return false; - return normalizeRelayURL(tagRelayUrl) === normalizedRelayUrl; - } catch { - // If normalization fails, try exact match as fallback - return tag[2] === normalizedRelayUrl; - } - } - /** * Add a group to the user's group list (kind 10009) */ @@ -954,23 +515,18 @@ export class Nip29Adapter extends ChatProtocolAdapter { throw new Error("Group ID and relay URL required"); } - // Normalize the relay URL for comparison const normalizedRelayUrl = normalizeRelayURL(relayUrl); - // Fetch current kind 10009 event (group list) const currentEvent = await firstValueFrom( eventStore.replaceable(10009, activePubkey, ""), { defaultValue: undefined }, ); - // Build new tags array let tags: string[][] = []; if (currentEvent) { - // Copy existing tags tags = [...currentEvent.tags]; - // Check if group is already in the list (using normalized URL comparison) const existingGroup = tags.find((t) => this.isMatchingGroupTag(t, groupId, normalizedRelayUrl), ); @@ -980,18 +536,12 @@ export class Nip29Adapter extends ChatProtocolAdapter { } } - // Add the new group tag (use normalized URL for consistency) tags.push(["group", groupId, normalizedRelayUrl]); - // Create and publish the updated event const factory = new EventFactory(); factory.setSigner(activeSigner); - const draft = await factory.build({ - kind: 10009, - content: "", - tags, - }); + const draft = await factory.build({ kind: 10009, content: "", tags }); const event = await factory.sign(draft); await publishEvent(event); } @@ -1016,10 +566,8 @@ export class Nip29Adapter extends ChatProtocolAdapter { throw new Error("Group ID and relay URL required"); } - // Normalize the relay URL for comparison const normalizedRelayUrl = normalizeRelayURL(relayUrl); - // Fetch current kind 10009 event (group list) const currentEvent = await firstValueFrom( eventStore.replaceable(10009, activePubkey, ""), { defaultValue: undefined }, @@ -1029,7 +577,6 @@ export class Nip29Adapter extends ChatProtocolAdapter { throw new Error("No group list found"); } - // Find and remove the group tag (using normalized URL comparison) const originalLength = currentEvent.tags.length; const tags = currentEvent.tags.filter( (t) => !this.isMatchingGroupTag(t, groupId, normalizedRelayUrl), @@ -1039,135 +586,249 @@ export class Nip29Adapter extends ChatProtocolAdapter { throw new Error("Group is not in your list"); } - // Create and publish the updated event const factory = new EventFactory(); factory.setSigner(activeSigner); - const draft = await factory.build({ - kind: 10009, - content: "", - tags, - }); + const draft = await factory.build({ kind: 10009, content: "", tags }); const event = await factory.sign(draft); await publishEvent(event); } + // --- Private helpers --- + /** - * Helper: Convert Nostr event to Message + * Fetch events from a relay with timeout */ - private eventToMessage(event: NostrEvent, conversationId: string): Message { - // Handle admin events (join/leave) as system messages - if (event.kind === 9000 || event.kind === 9001) { - // Extract the affected user's pubkey from p-tag - const pTags = event.tags.filter((t) => t[0] === "p"); - const affectedPubkey = pTags[0]?.[1] || event.pubkey; // Fall back to event author + private async fetchFromRelay( + relayUrl: string, + filter: Filter, + ): Promise { + const events: NostrEvent[] = []; + const obs = pool.subscription([relayUrl], [filter], { eventStore }); - let content = ""; - if (event.kind === 9000) { - // put-user: admin adds someone (show as joined) - content = "joined"; - } else if (event.kind === 9001) { - // remove-user: admin removes someone - content = "left"; - } + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + console.log(`${LOG_PREFIX} Fetch timeout`); + resolve(); + }, 5000); - return { - id: event.id, - conversationId, - author: affectedPubkey, // Show the user who joined/left - content, - timestamp: event.created_at, - type: "system", - protocol: "nip-29", - metadata: { - encrypted: false, + const sub = obs.subscribe({ + next: (response) => { + if (typeof response === "string") { + clearTimeout(timeout); + console.log(`${LOG_PREFIX} Got ${events.length} events`); + sub.unsubscribe(); + resolve(); + } else { + events.push(response); + } }, - event, - }; - } + error: (err) => { + clearTimeout(timeout); + console.error(`${LOG_PREFIX} Fetch error:`, err); + sub.unsubscribe(); + reject(err); + }, + }); + }); - // Regular chat message (kind 9) - // Look for reply q-tags (NIP-29 uses q-tags like NIP-C7) - const qTags = getTagValues(event, "q"); - const replyTo = qTags[0]; // First q-tag is the reply target - - return { - id: event.id, - conversationId, - author: event.pubkey, - content: event.content, - timestamp: event.created_at, - type: "user", - replyTo, - protocol: "nip-29", - metadata: { - encrypted: false, // kind 9 messages are always public - }, - event, - }; + return events; } /** - * Helper: Convert nutzap event (kind 9321) to Message - * NIP-61 nutzaps are P2PK-locked Cashu token transfers + * Extract participants from admin/member events */ - private nutzapToMessage(event: NostrEvent, conversationId: string): Message { - // Sender is the event author - const sender = event.pubkey; + private extractParticipants(events: NostrEvent[]): Participant[] { + const normalizeRole = (role: string | undefined): ParticipantRole => { + if (!role) return "member"; + const lower = role.toLowerCase(); + if (lower === "admin") return "admin"; + if (lower === "moderator") return "moderator"; + if (lower === "host") return "host"; + return "member"; + }; - // Recipient is the p-tag value - const pTag = event.tags.find((t) => t[0] === "p"); - const recipient = pTag?.[1] || ""; + const participantsMap = new Map(); - // Reply target is the e-tag (the event being nutzapped) - const eTag = event.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 a JSON-encoded Cashu proof - let amount = 0; - for (const tag of event.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 + // Process admins (kind 39001) + for (const event of events.filter((e) => e.kind === 39001)) { + for (const tag of event.tags) { + if (tag[0] === "p" && tag[1]) { + const pubkey = tag[1]; + const roles = tag.slice(2).filter((r) => r); + const primaryRole = normalizeRole(roles[0]); + participantsMap.set(pubkey, { pubkey, role: primaryRole }); } } } - // Unit defaults to "sat" per NIP-61 - const unitTag = event.tags.find((t) => t[0] === "unit"); - const unit = unitTag?.[1] || "sat"; + // Process members (kind 39002) + for (const event of events.filter((e) => e.kind === 39002)) { + for (const tag of event.tags) { + if (tag[0] === "p" && tag[1]) { + const pubkey = tag[1]; + if (!participantsMap.has(pubkey)) { + participantsMap.set(pubkey, { pubkey, role: "member" }); + } + } + } + } - // Comment is in the content field - const comment = event.content || ""; + return Array.from(participantsMap.values()); + } - return { - id: event.id, + /** + * Check if a tag matches a group + */ + private isMatchingGroupTag( + tag: string[], + groupId: string, + normalizedRelayUrl: string, + ): boolean { + if (tag[0] !== "group" || tag[1] !== groupId) { + return false; + } + try { + const tagRelayUrl = tag[2]; + if (!tagRelayUrl) return false; + return normalizeRelayURL(tagRelayUrl) === normalizedRelayUrl; + } catch { + return tag[2] === normalizedRelayUrl; + } + } + + /** + * Convert event to Message + */ + private convertEventToMessage( + event: NostrEvent, + conversationId: string, + ): Message { + // Nutzaps (kind 9321) + if (event.kind === 9321) { + return nutzapToMessage(event, { + conversationId, + protocol: "nip-29", + }); + } + + // Admin events (join/leave) as system messages + if (event.kind === 9000 || event.kind === 9001) { + const pTags = event.tags.filter((t) => t[0] === "p"); + const affectedPubkey = pTags[0]?.[1] || event.pubkey; + + const content = event.kind === 9000 ? "joined" : "left"; + + return eventToMessage( + { ...event, content, pubkey: affectedPubkey }, + { + conversationId, + protocol: "nip-29", + type: "system", + }, + ); + } + + // Regular chat messages (kind 9) + return eventToMessage(event, { conversationId, - author: sender, - content: comment, - timestamp: event.created_at, - type: "zap", // Render the same as zaps - replyTo, protocol: "nip-29", - metadata: { - encrypted: false, - zapAmount: amount, // In the unit specified (usually sats) - zapRecipient: recipient, - nutzapUnit: unit, // Store unit for potential future use + getReplyTo: getQTagReplyTo, + }); + } + + /** + * Get all possible actions + */ + private getAllActions(): ChatAction[] { + return [ + this.createJoinAction(), + this.createLeaveAction(), + this.createBookmarkAction(), + this.createUnbookmarkAction(), + ]; + } + + private createJoinAction(): ChatAction { + return { + name: "join", + description: "Request to join the group", + handler: async (context) => { + try { + await this.joinConversation(context.conversation); + return { success: true, message: "Join request sent" }; + } catch (error) { + return { + success: false, + message: + error instanceof Error ? error.message : "Failed to join group", + }; + } + }, + }; + } + + private createLeaveAction(): ChatAction { + return { + name: "leave", + description: "Leave the group", + handler: async (context) => { + try { + await this.leaveConversation(context.conversation); + return { success: true, message: "You left the group" }; + } catch (error) { + return { + success: false, + message: + error instanceof Error ? error.message : "Failed to leave group", + }; + } + }, + }; + } + + private createBookmarkAction(): ChatAction { + return { + name: "bookmark", + description: "Add group to your group list", + handler: async (context) => { + try { + await this.bookmarkGroup(context.conversation, context.activePubkey); + return { success: true, message: "Group added to your list" }; + } catch (error) { + return { + success: false, + message: + error instanceof Error + ? error.message + : "Failed to bookmark group", + }; + } + }, + }; + } + + private createUnbookmarkAction(): ChatAction { + return { + name: "unbookmark", + description: "Remove group from your group list", + handler: async (context) => { + try { + await this.unbookmarkGroup( + context.conversation, + context.activePubkey, + ); + return { success: true, message: "Group removed from your list" }; + } catch (error) { + return { + success: false, + message: + error instanceof Error + ? error.message + : "Failed to unbookmark group", + }; + } }, - event, }; } } diff --git a/src/lib/chat/adapters/nip-53-adapter.ts b/src/lib/chat/adapters/nip-53-adapter.ts index 412f910..c424372 100644 --- a/src/lib/chat/adapters/nip-53-adapter.ts +++ b/src/lib/chat/adapters/nip-53-adapter.ts @@ -1,5 +1,5 @@ import { Observable, firstValueFrom } 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 { @@ -21,19 +21,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 { parseLiveActivity, getLiveStatus, getLiveHost, } from "@/lib/live-activity"; -import { - getZapAmount, - getZapRequest, - getZapSender, - isValidZap, -} from "applesauce-common/helpers/zap"; +import { isValidZap } from "applesauce-common/helpers/zap"; import { EventFactory } from "applesauce-core/event-factory"; +import { + fetchEvent, + getOutboxRelays, + AGGREGATOR_RELAYS, + zapReceiptToMessage, + eventToMessage, + getNip10ReplyTo, +} from "../utils"; /** * NIP-53 Adapter - Live Activity Chat @@ -108,8 +110,14 @@ export class Nip53Adapter extends ChatProtocolAdapter { ); // Use author's outbox relays plus any relay hints - const authorOutboxes = await this.getAuthorOutboxes(pubkey); - const relays = [...new Set([...relayHints, ...authorOutboxes])]; + const authorOutboxes = await getOutboxRelays(pubkey, { + maxRelays: 3, + logPrefix: "[NIP-53]", + }); + // If no outbox relays found, use aggregator relays as fallback + const outboxFallback = + authorOutboxes.length > 0 ? authorOutboxes : AGGREGATOR_RELAYS; + const relays = [...new Set([...relayHints, ...outboxFallback])]; if (relays.length === 0) { throw new Error("No relays available to fetch live activity"); @@ -184,7 +192,7 @@ export class Nip53Adapter extends ChatProtocolAdapter { // Combine activity relays, relay hints, and host outboxes for comprehensive coverage const chatRelays = [ - ...new Set([...activity.relays, ...relayHints, ...authorOutboxes]), + ...new Set([...activity.relays, ...relayHints, ...outboxFallback]), ]; console.log( @@ -306,16 +314,7 @@ export class Nip53Adapter extends ChatProtocolAdapter { return eventStore.timeline(filter).pipe( map((events) => { const messages = events - .map((event) => { - // Convert zaps (kind 9735) using zapToMessage - if (event.kind === 9735) { - // Only include valid zaps - if (!isValidZap(event)) return null; - return this.zapToMessage(event, conversation.id); - } - // All other events (kind 1311) use eventToMessage - return this.eventToMessage(event, conversation.id); - }) + .map((event) => this.convertEventToMessage(event, conversation.id)) .filter((msg): msg is Message => msg !== null); console.log(`[NIP-53] Timeline has ${messages.length} events`); @@ -382,13 +381,7 @@ export class Nip53Adapter extends ChatProtocolAdapter { // Convert events to messages const messages = events - .map((event) => { - if (event.kind === 9735) { - if (!isValidZap(event)) return null; - return this.zapToMessage(event, conversation.id); - } - return this.eventToMessage(event, conversation.id); - }) + .map((event) => this.convertEventToMessage(event, conversation.id)) .filter((msg): msg is Message => msg !== null); // loadMoreMessages returns events in desc order from relay, @@ -623,105 +616,33 @@ export class Nip53Adapter extends ChatProtocolAdapter { conversation: Conversation, eventId: string, ): Promise { - // First check EventStore - const cachedEvent = await eventStore - .event(eventId) - .pipe(first()) - .toPromise(); - if (cachedEvent) { - return cachedEvent; - } + // Get relays from conversation metadata + const relays = this.getConversationRelays(conversation); - // Not in store, fetch from activity relays - const liveActivity = conversation.metadata?.liveActivity as - | { - relays?: string[]; - } - | undefined; - - // Get relays - use immutable pattern to avoid mutating metadata - const relays = - liveActivity?.relays && liveActivity.relays.length > 0 - ? liveActivity.relays - : conversation.metadata?.relayUrl - ? [conversation.metadata.relayUrl] - : []; - - if (relays.length === 0) { - console.warn("[NIP-53] No relays for loading reply message"); - return null; - } - - console.log( - `[NIP-53] Fetching reply message ${eventId.slice(0, 8)}... from ${relays.length} relays`, - ); - - const filter: Filter = { - ids: [eventId], - limit: 1, - }; - - const events: NostrEvent[] = []; - const obs = pool.subscription(relays, [filter], { eventStore }); - - await new Promise((resolve) => { - const timeout = setTimeout(() => { - console.log( - `[NIP-53] Reply message fetch timeout for ${eventId.slice(0, 8)}...`, - ); - resolve(); - }, 3000); - - 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-53] Reply message fetch error:`, err); - sub.unsubscribe(); - resolve(); - }, - }); + return fetchEvent(eventId, { + relayHints: relays, + timeout: 3000, + logPrefix: "[NIP-53]", }); - - return events[0] || null; } /** - * Helper: Get author's outbox relays via NIP-65 + * Helper: Get relays from conversation metadata */ - private async getAuthorOutboxes(pubkey: string): Promise { - try { - // Try to get from EventStore first (kind 10002) - const relayListEvent = await eventStore - .replaceable(10002, pubkey) - .pipe(first()) - .toPromise(); + private getConversationRelays(conversation: Conversation): string[] { + const liveActivity = conversation.metadata?.liveActivity as + | { relays?: string[] } + | undefined; - if (relayListEvent) { - // Extract write relays from r tags - const writeRelays = relayListEvent.tags - .filter((t) => t[0] === "r" && (!t[2] || t[2] === "write")) - .map((t) => t[1]) - .filter(Boolean); - - if (writeRelays.length > 0) { - return writeRelays.slice(0, 3); // Limit to 3 relays - } - } - } catch { - // Fall through to defaults + if (liveActivity?.relays && liveActivity.relays.length > 0) { + return liveActivity.relays; } - // Default fallback relays for live activities - return AGGREGATOR_RELAYS; + if (conversation.metadata?.relayUrl) { + return [conversation.metadata.relayUrl]; + } + + return []; } /** @@ -736,65 +657,26 @@ export class Nip53Adapter extends ChatProtocolAdapter { } /** - * Helper: Convert Nostr event to Message + * Helper: Convert Nostr event to Message using shared utilities */ - private eventToMessage(event: NostrEvent, conversationId: string): Message { - // Look for reply e-tags (NIP-10 style) - const eTags = event.tags.filter((t) => t[0] === "e"); - // Find the reply tag (has "reply" marker or is the last e-tag without marker) - const replyTag = - eTags.find((t) => t[3] === "reply") || - eTags.find((t) => !t[3] && eTags.length === 1); - const replyTo = replyTag?.[1]; + private convertEventToMessage( + event: NostrEvent, + conversationId: string, + ): Message | null { + // Convert zap receipts (kind 9735) + if (event.kind === 9735) { + if (!isValidZap(event)) return null; + return zapReceiptToMessage(event, { + conversationId, + protocol: "nip-53", + }); + } - return { - id: event.id, + // All other events (kind 1311) use eventToMessage with NIP-10 style reply extraction + return eventToMessage(event, { conversationId, - author: event.pubkey, - content: event.content, - timestamp: event.created_at, - type: "user", - replyTo, protocol: "nip-53", - metadata: { - encrypted: false, - }, - event, - }; - } - - /** - * Helper: Convert zap receipt to Message - */ - private zapToMessage(event: NostrEvent, conversationId: string): Message { - const zapSender = getZapSender(event); - const zapAmount = getZapAmount(event); - const zapRequest = getZapRequest(event); - - // Convert from msats to sats - const amountInSats = zapAmount ? Math.floor(zapAmount / 1000) : 0; - - // Get zap comment from request - const zapComment = zapRequest?.content || ""; - - // The recipient is the pubkey in the p tag of the zap receipt - const pTag = event.tags.find((t) => t[0] === "p"); - const zapRecipient = pTag?.[1] || event.pubkey; - - return { - id: event.id, - conversationId, - author: zapSender || event.pubkey, - content: zapComment, - timestamp: event.created_at, - type: "zap", - protocol: "nip-53", - metadata: { - encrypted: false, - zapAmount: amountInSats, - zapRecipient, - }, - event, - }; + getReplyTo: getNip10ReplyTo, + }); } } diff --git a/src/lib/chat/utils/message-utils.ts b/src/lib/chat/utils/message-utils.ts index 993b0db..150fb7c 100644 --- a/src/lib/chat/utils/message-utils.ts +++ b/src/lib/chat/utils/message-utils.ts @@ -71,7 +71,7 @@ export function zapReceiptToMessage( }; } -export interface NutzapToMessageOptions extends ZapToMessageOptions {} +export type NutzapToMessageOptions = ZapToMessageOptions; /** * Convert a nutzap event (kind 9321) to a Message