From 7a1ed09e8c0244ea883e0da4e11239fe7df3b7a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Jan 2026 14:00:52 +0000 Subject: [PATCH] fix: Improve NIP-17 message sending and decryption tracking Multiple fixes for NIP-17 gift-wrapped DMs: 1. Fix sent messages not appearing immediately - After sending, query eventStore for new gift wraps - Pick up our own gift wrap and add to local state - Message now appears right after sending 2. Track failed decryption separately - Add failedGiftWraps$ BehaviorSubject - Mark gift wraps as failed after decrypt error - Exclude failed from pending count (don't retry) - getFailedCount() for UI display if needed 3. Ensure subscription is active - Add ensureSubscription() public method - Track subscriptionActive state to prevent duplicates - Call ensureSubscription in InboxViewer on mount - Call ensureSubscription in ChatViewer for NIP-17 4. Refactor gift wrap handling - Extract handleGiftWrap() for consistent processing - Add removeFromPending() and markAsFailed() helpers - Better error handling in subscription --- src/components/ChatViewer.tsx | 7 + src/components/InboxViewer.tsx | 7 + src/lib/chat/adapters/nip-17-adapter.ts | 186 ++++++++++++++++++++---- 3 files changed, 172 insertions(+), 28 deletions(-) diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index 81a936d..a389126 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -371,6 +371,13 @@ export function ChatViewer({ // Get the appropriate adapter for this protocol const adapter = useMemo(() => getAdapter(protocol), [protocol]); + // Ensure NIP-17 subscription is active when ChatViewer mounts + useEffect(() => { + if (protocol === "nip-17") { + nip17Adapter.ensureSubscription(); + } + }, [protocol]); + // State for retry trigger const [retryCount, setRetryCount] = useState(0); diff --git a/src/components/InboxViewer.tsx b/src/components/InboxViewer.tsx index c7b82d4..ae16100 100644 --- a/src/components/InboxViewer.tsx +++ b/src/components/InboxViewer.tsx @@ -261,6 +261,13 @@ export function InboxViewer() { // NIP-17 adapter singleton instance const adapter = nip17Adapter; + // Ensure subscription is active when component mounts + useEffect(() => { + if (activePubkey) { + adapter.ensureSubscription(); + } + }, [adapter, activePubkey]); + // Get pending count const pendingCount = use$(() => adapter.getPendingCount$(), [adapter]) ?? 0; diff --git a/src/lib/chat/adapters/nip-17-adapter.ts b/src/lib/chat/adapters/nip-17-adapter.ts index c936d51..960d28b 100644 --- a/src/lib/chat/adapters/nip-17-adapter.ts +++ b/src/lib/chat/adapters/nip-17-adapter.ts @@ -65,9 +65,15 @@ export class Nip17Adapter extends ChatProtocolAdapter { /** Track pending (undecrypted) gift wrap IDs */ private pendingGiftWraps$ = new BehaviorSubject>(new Set()); + /** Track failed (could not decrypt) gift wrap IDs */ + private failedGiftWraps$ = new BehaviorSubject>(new Set()); + /** Observable of gift wrap events from event store */ private giftWraps$ = new BehaviorSubject([]); + /** Track if subscription is active */ + private subscriptionActive = false; + /** * Parse identifier - accepts npub, nprofile, hex pubkey, NIP-05, or $me */ @@ -340,6 +346,9 @@ export class Nip17Adapter extends ChatProtocolAdapter { throw new Error("No conversation recipient found"); } + // Track existing gift wrap IDs before sending + const existingIds = new Set(this.giftWraps$.value.map((g) => g.id)); + // Use applesauce's SendWrappedMessage action // This handles: // - Creating the wrapped message rumor @@ -350,6 +359,43 @@ export class Nip17Adapter extends ChatProtocolAdapter { console.log( `[NIP-17] Sent wrapped message to ${recipientPubkey.slice(0, 8)}...${isSelfConversation ? " (saved)" : ""}`, ); + + // After sending, check eventStore for new gift wraps addressed to us + // The publishEvent function adds events to eventStore, so our own gift wrap should be there + this.pickUpNewGiftWrapsFromStore(activePubkey, existingIds); + } + + /** + * Pick up new gift wraps from eventStore that we don't have yet + * Used after sending to ensure sent message appears immediately + */ + private async pickUpNewGiftWrapsFromStore( + pubkey: string, + existingIds: Set, + ): Promise { + try { + // Query eventStore for gift wraps addressed to us + const giftWraps = await firstValueFrom( + eventStore + .timeline([{ kinds: [GIFT_WRAP_KIND], "#p": [pubkey] }]) + .pipe(first()), + { defaultValue: [] }, + ); + + let added = 0; + for (const giftWrap of giftWraps) { + if (!existingIds.has(giftWrap.id)) { + this.handleGiftWrap(giftWrap); + added++; + } + } + + if (added > 0) { + console.log(`[NIP-17] Picked up ${added} new gift wrap(s) from store`); + } + } catch (error) { + console.warn("[NIP-17] Failed to pick up gift wraps from store:", error); + } } /** @@ -398,17 +444,32 @@ export class Nip17Adapter extends ChatProtocolAdapter { } /** - * Get count of pending (undecrypted) gift wraps + * Get count of pending (undecrypted) gift wraps (excludes failed) */ getPendingCount(): number { - return this.pendingGiftWraps$.value.size; + const pending = this.pendingGiftWraps$.value; + const failed = this.failedGiftWraps$.value; + // Only count pending that haven't failed + return Array.from(pending).filter((id) => !failed.has(id)).length; } /** - * Get observable of pending gift wrap count + * Get observable of pending gift wrap count (excludes failed) */ getPendingCount$(): Observable { - return this.pendingGiftWraps$.pipe(map((set) => set.size)); + return this.pendingGiftWraps$.pipe( + map((pending) => { + const failed = this.failedGiftWraps$.value; + return Array.from(pending).filter((id) => !failed.has(id)).length; + }), + ); + } + + /** + * Get count of failed gift wraps + */ + getFailedCount(): number { + return this.failedGiftWraps$.value.size; } /** @@ -422,7 +483,11 @@ export class Nip17Adapter extends ChatProtocolAdapter { throw new Error("No active account"); } - const pendingIds = Array.from(this.pendingGiftWraps$.value); + // Only try pending that haven't already failed + const failedSet = this.failedGiftWraps$.value; + const pendingIds = Array.from(this.pendingGiftWraps$.value).filter( + (id) => !failedSet.has(id), + ); let success = 0; let failed = 0; @@ -434,6 +499,8 @@ export class Nip17Adapter extends ChatProtocolAdapter { ); if (!giftWrap) { + // Mark as failed - couldn't find the event + this.markAsFailed(giftWrapId); failed++; continue; } @@ -441,9 +508,7 @@ export class Nip17Adapter extends ChatProtocolAdapter { // Already unlocked? if (isGiftWrapUnlocked(giftWrap)) { // Remove from pending - const pending = new Set(this.pendingGiftWraps$.value); - pending.delete(giftWrapId); - this.pendingGiftWraps$.next(pending); + this.removeFromPending(giftWrapId); success++; continue; } @@ -451,10 +516,8 @@ export class Nip17Adapter extends ChatProtocolAdapter { // Decrypt using signer - applesauce handles caching automatically await unlockGiftWrap(giftWrap, signer); - // Remove from pending - const pending = new Set(this.pendingGiftWraps$.value); - pending.delete(giftWrapId); - this.pendingGiftWraps$.next(pending); + // Remove from pending (success) + this.removeFromPending(giftWrapId); // Refresh gift wraps list this.giftWraps$.next([...this.giftWraps$.value]); @@ -465,6 +528,8 @@ export class Nip17Adapter extends ChatProtocolAdapter { `[NIP-17] Failed to decrypt gift wrap ${giftWrapId}:`, error, ); + // Mark as failed so we don't retry + this.markAsFailed(giftWrapId); failed++; } } @@ -472,6 +537,24 @@ export class Nip17Adapter extends ChatProtocolAdapter { return { success, failed }; } + /** + * Mark a gift wrap as failed (won't retry decryption) + */ + private markAsFailed(giftWrapId: string): void { + const failed = new Set(this.failedGiftWraps$.value); + failed.add(giftWrapId); + this.failedGiftWraps$.next(failed); + } + + /** + * Remove a gift wrap from pending + */ + private removeFromPending(giftWrapId: string): void { + const pending = new Set(this.pendingGiftWraps$.value); + pending.delete(giftWrapId); + this.pendingGiftWraps$.next(pending); + } + /** * Get all conversations from decrypted rumors */ @@ -574,16 +657,45 @@ export class Nip17Adapter extends ChatProtocolAdapter { ); } + // ==================== Public Methods for Subscription Management ==================== + + /** + * Ensure gift wrap subscription is active for the current user + * Call this when InboxViewer or ChatViewer mounts + */ + ensureSubscription(): void { + const activePubkey = accountManager.active$.value?.pubkey; + if (!activePubkey) { + console.warn("[NIP-17] Cannot start subscription: no active account"); + return; + } + + if (!this.subscriptionActive) { + console.log("[NIP-17] Starting gift wrap subscription"); + this.subscribeToGiftWraps(activePubkey); + } + } + + /** + * Check if subscription is currently active + */ + isSubscriptionActive(): boolean { + return this.subscriptionActive; + } + // ==================== Private Methods ==================== /** * Subscribe to gift wraps for the user from their inbox relays */ private async subscribeToGiftWraps(pubkey: string): Promise { - const conversationId = `nip-17:inbox:${pubkey}`; + // Don't create duplicate subscriptions + if (this.subscriptionActive) { + console.log("[NIP-17] Subscription already active, skipping"); + return; + } - // Clean up existing subscription - this.cleanup(conversationId); + const conversationId = `nip-17:inbox:${pubkey}`; // Get user's private inbox relays (kind 10050) const inboxRelays = await this.fetchInboxRelays(pubkey); @@ -605,6 +717,8 @@ export class Nip17Adapter extends ChatProtocolAdapter { "#p": [pubkey], }; + this.subscriptionActive = true; + const subscription = pool .subscription(inboxRelays, [filter], { eventStore }) .subscribe({ @@ -617,26 +731,42 @@ export class Nip17Adapter extends ChatProtocolAdapter { console.log( `[NIP-17] Received gift wrap: ${response.id.slice(0, 8)}...`, ); - - // Add to gift wraps list - const current = this.giftWraps$.value; - if (!current.find((g) => g.id === response.id)) { - this.giftWraps$.next([...current, response]); - } - - // Check if unlocked (cached) or pending - if (!isGiftWrapUnlocked(response)) { - const pending = new Set(this.pendingGiftWraps$.value); - pending.add(response.id); - this.pendingGiftWraps$.next(pending); - } + this.handleGiftWrap(response); } }, + error: (err) => { + console.error("[NIP-17] Subscription error:", err); + this.subscriptionActive = false; + }, + complete: () => { + console.log("[NIP-17] Subscription completed"); + this.subscriptionActive = false; + }, }); this.subscriptions.set(conversationId, subscription); } + /** + * Handle a received or sent gift wrap + */ + private handleGiftWrap(giftWrap: NostrEvent): void { + // Add to gift wraps list if not already present + const current = this.giftWraps$.value; + if (!current.find((g) => g.id === giftWrap.id)) { + this.giftWraps$.next([...current, giftWrap]); + } + + // Check if unlocked (cached) or pending (skip if already failed) + if (!isGiftWrapUnlocked(giftWrap)) { + if (!this.failedGiftWraps$.value.has(giftWrap.id)) { + const pending = new Set(this.pendingGiftWraps$.value); + pending.add(giftWrap.id); + this.pendingGiftWraps$.next(pending); + } + } + } + /** Cache for inbox relays */ private inboxRelayCache = new Map();