fix: Complete NIP-17 gift wrap persistence for self-chat

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 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gómez
2026-01-16 13:38:34 +01:00
parent 195f9046ef
commit 119f737d3d
3 changed files with 52 additions and 89 deletions

View File

@@ -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)

View File

@@ -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<void> {
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;

View File

@@ -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<void> {
// 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<void> {
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<void> {
// 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);
}