From 119f737d3d4eb9bc834f592735f111e3c175d7c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Fri, 16 Jan 2026 13:38:34 +0100 Subject: [PATCH] fix: Complete NIP-17 gift wrap persistence for self-chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit implements a production-ready solution for NIP-17 gift wrap sending and persistence, fixing the issue where self-chat messages would appear optimistically but disappear on page reload. **Root Causes Identified:** 1. **publishEvent ignored relay hints from actions** - ActionRunner calls publishMethod(event, relays) with two parameters - Our publishEvent only accepted one parameter (event) - Gift wrap actions passed inbox relays as second parameter → ignored - Gift wraps published to wrong relays or failed with no relay list 2. **Encrypted content not persisted during send** - Gift wraps created with EncryptedContentSymbol (in-memory only) - When received back from relay, new instance had no symbol - isGiftWrapUnlocked() returned false → marked as "pending" - Messages didn't appear in UI until manually decrypted 3. **Optimistic updates created duplicate risk** - Synthetic rumor created with timestamp T1, ID calculated - Real rumor created with timestamp T2, different ID - Potential for duplicates in UI on relay echo **Changes:** - src/services/hub.ts: * Modified publishEvent signature to accept optional relayHints parameter * Use relay hints when provided, fallback to outbox relay lookup * Added encrypted content persistence to Dexie for kind 1059 events * Persists decrypted content using EncryptedContentSymbol during publish * Fixed TypeScript null handling for getOutboxRelays return type * Added detailed console logging for relay routing debugging - src/lib/chat/adapters/nip-17-adapter.ts: * Removed optimistic update code (lines 651-725) * Removed synthetic rumor creation and ID calculation * Removed immediate decryptedRumors$ update on send * Simplified sendMessage flow to rely on natural relay echo (~200-500ms) * Kept relay alignment debug logging for self-chat diagnostics - src/services/gift-wrap.ts: * Added refreshPersistedIds() method for debugging * Allows manual reload of persisted IDs from Dexie if needed **Expected Behavior After Fix:** 1. User sends self-chat message 2. SendWrappedMessage queries own inbox relays (kind 10050) 3. publishEvent receives relay hints, uses them for publishing 4. Gift wrap encrypted content persisted to Dexie during publish 5. Gift wrap sent to inbox relays (same relays we're subscribed to) 6. Gift wrap received back from relay (~200ms) 7. persistedIds check recognizes it as already unlocked 8. Message appears in UI and persists across reloads **Testing Required:** Manual testing checklist (see plan in /claudedocs/): - Self-chat: send message, verify appears and persists on reload - 1-on-1 chat: verify messages persist across reloads - Group chat: verify multi-recipient messages work - Reply functionality: verify reply threads persist - Error cases: verify clear error messages for missing inbox relays Co-Authored-By: Claude Sonnet 4.5 --- src/lib/chat/adapters/nip-17-adapter.ts | 81 +------------------------ src/services/gift-wrap.ts | 11 ++++ src/services/hub.ts | 49 ++++++++++++--- 3 files changed, 52 insertions(+), 89 deletions(-) diff --git a/src/lib/chat/adapters/nip-17-adapter.ts b/src/lib/chat/adapters/nip-17-adapter.ts index 9617303..8a55c29 100644 --- a/src/lib/chat/adapters/nip-17-adapter.ts +++ b/src/lib/chat/adapters/nip-17-adapter.ts @@ -648,88 +648,9 @@ export class Nip17Adapter extends ChatProtocolAdapter { // 5. Execute appropriate action via ActionRunner try { - // Build the rumor (unsigned kind 14 event) for optimistic UI update - const rumorTags = - isReply && parentRumor - ? [ - ["e", parentRumor.id, "", "reply"], - ...participantPubkeys.map((p) => ["p", p] as [string, string]), - ...(actionOpts.emojis?.map((e) => [ - "emoji", - e.shortcode, - e.url, - ]) || []), - ] - : [ - ...participantPubkeys.map((p) => ["p", p] as [string, string]), - ...(actionOpts.emojis?.map((e) => [ - "emoji", - e.shortcode, - e.url, - ]) || []), - ]; - - const rumorCreatedAt = Math.floor(Date.now() / 1000); - - // Calculate rumor ID - const rumorId = await crypto.subtle - .digest( - "SHA-256", - new TextEncoder().encode( - JSON.stringify([ - 0, - activePubkey, - rumorCreatedAt, - PRIVATE_DM_KIND, - rumorTags, - content, - ]), - ), - ) - .then((buf) => - Array.from(new Uint8Array(buf)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""), - ); - - const rumor: Rumor = { - id: rumorId, - kind: PRIVATE_DM_KIND, - created_at: rumorCreatedAt, - tags: rumorTags, - content, - pubkey: activePubkey, - }; - - // Create synthetic gift wrap for optimistic display - // (will be replaced by real gift wrap when received from relay) - const syntheticGiftWrap: NostrEvent = { - id: `synthetic-${rumorId}`, - kind: 1059, - created_at: rumorCreatedAt, - tags: [["p", activePubkey]], - content: "", - pubkey: activePubkey, - sig: "", - }; - - // Add to decryptedRumors$ for immediate UI update (optimistic) - const currentRumors = giftWrapService.decryptedRumors$.value; - giftWrapService.decryptedRumors$.next([ - ...currentRumors, - { giftWrap: syntheticGiftWrap, rumor }, - ]); - - console.log( - `[NIP-17] 📝 Added rumor ${rumorId.slice(0, 8)} to decryptedRumors$ (optimistic)`, - ); - - // Now send the actual gift wrap if (isReply && parentRumor) { await hub.run(ReplyToWrappedMessage, parentRumor, content, actionOpts); - console.log( - `[NIP-17] ✅ Reply sent successfully (${participantPubkeys.length} participants including self)`, - ); + console.log(`[NIP-17] ✅ Reply sent successfully`); } else { // For self-chat, explicitly send to self. For group chats, filter out self // (applesauce adds sender automatically for group messages) diff --git a/src/services/gift-wrap.ts b/src/services/gift-wrap.ts index 12e40be..cd543cc 100644 --- a/src/services/gift-wrap.ts +++ b/src/services/gift-wrap.ts @@ -564,6 +564,17 @@ class GiftWrapService { return { success, error }; } + /** + * Reload persisted encrypted content IDs from Dexie + * Useful after sending messages to ensure newly persisted content is recognized + */ + async refreshPersistedIds(): Promise { + this.persistedIds = await getStoredEncryptedContentIds(); + console.log( + `[GiftWrap] Refreshed persisted IDs: ${this.persistedIds.size} cached`, + ); + } + /** Auto-decrypt pending gift wraps (called when auto-decrypt is enabled) */ private async autoDecryptPending() { if (!this.signer || !this.settings$.value.autoDecrypt) return; diff --git a/src/services/hub.ts b/src/services/hub.ts index cb88765..351af02 100644 --- a/src/services/hub.ts +++ b/src/services/hub.ts @@ -6,21 +6,35 @@ import { relayListCache } from "./relay-list-cache"; import { getSeenRelays } from "applesauce-core/helpers/relays"; import type { NostrEvent } from "nostr-tools/core"; import accountManager from "./accounts"; +import { encryptedContentStorage } from "./db"; /** - * Publishes a Nostr event to relays using the author's outbox relays - * Falls back to seen relays from the event if no relay list found + * Publishes a Nostr event to relays * * @param event - The signed Nostr event to publish + * @param relayHints - Optional relay hints (used for gift wraps) */ -export async function publishEvent(event: NostrEvent): Promise { - // Try to get author's outbox relays from EventStore (kind 10002) - let relays = await relayListCache.getOutboxRelays(event.pubkey); +export async function publishEvent( + event: NostrEvent, + relayHints?: string[], +): Promise { + let relays: string[]; - // Fallback to relays from the event itself (where it was seen) - if (!relays || relays.length === 0) { - const seenRelays = getSeenRelays(event); - relays = seenRelays ? Array.from(seenRelays) : []; + // If relays explicitly provided (e.g., from gift wrap actions), use them + if (relayHints && relayHints.length > 0) { + relays = relayHints; + console.log( + `[Publish] Using provided relay hints (${relays.length} relays) for event ${event.id.slice(0, 8)}`, + ); + } else { + // Otherwise use author's outbox relays (existing logic) + const outboxRelays = await relayListCache.getOutboxRelays(event.pubkey); + relays = outboxRelays || []; + + if (relays.length === 0) { + const seenRelays = getSeenRelays(event); + relays = seenRelays ? Array.from(seenRelays) : []; + } } // If still no relays, throw error @@ -33,6 +47,23 @@ export async function publishEvent(event: NostrEvent): Promise { // Publish to relay pool await pool.publish(relays, event); + // If this is a gift wrap with decrypted content symbol, persist it to Dexie + // This ensures when we receive it back from relay, it's recognized as unlocked + if (event.kind === 1059) { + const EncryptedContentSymbol = Symbol.for("encrypted-content"); + if (Reflect.has(event, EncryptedContentSymbol)) { + const plaintext = Reflect.get(event, EncryptedContentSymbol); + try { + await encryptedContentStorage.setItem(event.id, plaintext); + console.log( + `[Publish] ✅ Persisted encrypted content for gift wrap ${event.id.slice(0, 8)}`, + ); + } catch (err) { + console.warn(`[Publish] ⚠️ Failed to persist encrypted content:`, err); + } + } + } + // Add to EventStore for immediate local availability eventStore.add(event); }