From c402ac16f8d653dca63b9021f69ddbaf90ab86bf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 11:19:03 +0000 Subject: [PATCH] fix: NIP-17 chat fixes and inbox UX improvements Enable NIP-17 Support: - Uncomment Nip17Adapter in ChatViewer getAdapter function - Import Nip17Adapter and register in protocol switch - Update adapter documentation to list NIP-17 as supported Fix Auto-Decrypt Behavior: - Add autoDecrypt parameter to processGiftWrap (default true) - Only decrypt gift wraps when autoDecrypt=true, otherwise store as pending - Pass autoDecrypt from state to startSync and loadOlderGiftWraps - Update useAccountSync to pass autoDecrypt setting - Update InboxViewer to pass autoDecrypt to loadOlderGiftWraps - Prevents unwanted decryption attempts when setting is off Fix Stats Persistence: - Add initializeStats() method to load stats from DB on first access - Add statsInitialized flag to prevent duplicate initialization - Call initializeStats() in getStats() before returning observable - Stats now persist across page reloads even when sync is disabled Inbox UX Improvements: - Increase message preview from 50 to 100 characters - Remove flex-1 from preview span to eliminate large gap before timestamp - Add ml-auto to timestamp to push it to the right - Change gap from gap-1 to gap-2 for better spacing - Messages now show more content with timestamp properly aligned All tests passing (980/980). No new lint errors. --- src/components/ChatViewer.tsx | 16 ++++--- src/components/InboxViewer.tsx | 20 +++++---- src/hooks/useAccountSync.ts | 9 +++- src/services/gift-wrap.ts | 77 ++++++++++++++++++++++++++-------- 4 files changed, 88 insertions(+), 34 deletions(-) diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index af29920..df81198 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -26,6 +26,7 @@ import type { import { CHAT_KINDS } from "@/types/chat"; // import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter"; // Coming soon import { Nip10Adapter } from "@/lib/chat/adapters/nip-10-adapter"; +import { Nip17Adapter } from "@/lib/chat/adapters/nip-17-adapter"; import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter"; import { Nip53Adapter } from "@/lib/chat/adapters/nip-53-adapter"; import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter"; @@ -1138,21 +1139,24 @@ export function ChatViewer({ /** * Get the appropriate adapter for a protocol - * Currently NIP-10 (thread chat), NIP-29 (relay-based groups) and NIP-53 (live activity chat) are supported - * Other protocols will be enabled in future phases + * Currently supported: + * - NIP-10 (thread chat) + * - NIP-17 (encrypted DMs with gift wrap) + * - NIP-29 (relay-based groups) + * - NIP-53 (live activity chat) */ function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter { switch (protocol) { case "nip-10": return new Nip10Adapter(); + case "nip-17": + return new Nip17Adapter(); // case "nip-c7": // Phase 1 - Simple chat (coming soon) // return new NipC7Adapter(); - case "nip-29": - return new Nip29Adapter(); - // case "nip-17": // Phase 2 - Encrypted DMs (coming soon) - // return new Nip17Adapter(); // case "nip-28": // Phase 3 - Public channels (coming soon) // return new Nip28Adapter(); + case "nip-29": + return new Nip29Adapter(); case "nip-53": return new Nip53Adapter(); default: diff --git a/src/components/InboxViewer.tsx b/src/components/InboxViewer.tsx index 91942e4..30e3a43 100644 --- a/src/components/InboxViewer.tsx +++ b/src/components/InboxViewer.tsx @@ -173,7 +173,7 @@ export function InboxViewer(_props: InboxViewerProps) { const handleLoadOlderGiftWraps = async () => { setIsLoadingOlder(true); try { - const count = await giftWrapManager.loadOlderGiftWraps(); + const count = await giftWrapManager.loadOlderGiftWraps(autoDecrypt); if (count > 0) { toast.success(`Loaded ${count} older gift wraps`); } else { @@ -542,28 +542,30 @@ function ConversationRow({ day: "numeric", }); - // Truncate content preview - const preview = latestMessage.content.slice(0, 50); - const truncated = latestMessage.content.length > 50; + // Truncate content preview - show more text + const preview = latestMessage.content.slice(0, 100); + const truncated = latestMessage.content.length > 100; return (
{/* Name */}
- {/* Message preview */} - + {/* Message preview - no flex-1 to avoid big gap */} + {preview} {truncated && "..."} - {/* Timestamp */} - {timeStr} + {/* Timestamp - push to end with ml-auto */} + + {timeStr} +
); } diff --git a/src/hooks/useAccountSync.ts b/src/hooks/useAccountSync.ts index 47bda42..83278af 100644 --- a/src/hooks/useAccountSync.ts +++ b/src/hooks/useAccountSync.ts @@ -131,6 +131,7 @@ export function useAccountSync() { // Start gift wrap sync (NIP-17) when account changes useEffect(() => { const syncEnabled = grimoire.state.giftWrapSettings?.syncEnabled ?? false; + const autoDecrypt = grimoire.state.giftWrapSettings?.autoDecrypt ?? false; if (!activeAccount?.pubkey || !syncEnabled) { // Stop sync when no account is active or sync is disabled @@ -139,7 +140,7 @@ export function useAccountSync() { } // Start syncing gift wraps for this account - giftWrapManager.startSync().catch((error) => { + giftWrapManager.startSync(autoDecrypt).catch((error) => { console.error("[useAccountSync] Failed to start gift wrap sync:", error); }); @@ -147,5 +148,9 @@ export function useAccountSync() { return () => { giftWrapManager.stopSync(); }; - }, [activeAccount?.pubkey, grimoire.state.giftWrapSettings?.syncEnabled]); + }, [ + activeAccount?.pubkey, + grimoire.state.giftWrapSettings?.syncEnabled, + grimoire.state.giftWrapSettings?.autoDecrypt, + ]); } diff --git a/src/services/gift-wrap.ts b/src/services/gift-wrap.ts index 2cccbb7..f8bec68 100644 --- a/src/services/gift-wrap.ts +++ b/src/services/gift-wrap.ts @@ -62,14 +62,26 @@ class GiftWrapManager { private authenticated = new Set(); // Track which relays are authenticated private isSyncing = false; // Prevent concurrent sync attempts private statsUpdateTimer: NodeJS.Timeout | null = null; // Debounce stats updates + private statsInitialized = false; // Track if stats have been loaded from DB + + /** + * Initialize stats from database + * Called automatically when stats are first accessed + */ + private async initializeStats(): Promise { + if (this.statsInitialized) return; + this.statsInitialized = true; + await this.updateStats(); + } /** * Start syncing gift wraps for the active account * 1. Gets DM relays * 2. Authenticates with dummy REQ (triggers NIP-42 AUTH) * 3. Subscribes to gift wraps with pagination + * @param autoDecrypt - If false, stores gift wraps without decrypting */ - async startSync(): Promise { + async startSync(autoDecrypt = true): Promise { // Prevent concurrent sync attempts if (this.isSyncing) { console.log("[GiftWrap] Sync already in progress, skipping"); @@ -150,12 +162,14 @@ class GiftWrapManager { `[GiftWrap] Received gift wrap: ${response.id.slice(0, 8)}...`, ); // Process gift wrap asynchronously - this.processGiftWrap(response, pubkey).catch((error) => { - console.error( - `[GiftWrap] Error processing ${response.id.slice(0, 8)}:`, - error, - ); - }); + this.processGiftWrap(response, pubkey, autoDecrypt).catch( + (error) => { + console.error( + `[GiftWrap] Error processing ${response.id.slice(0, 8)}:`, + error, + ); + }, + ); } }, error: (error) => { @@ -166,7 +180,7 @@ class GiftWrapManager { this.subscriptions.set(pubkey, subscription); // Process any existing gift wraps in the event store (from previous sessions) - await this.processExistingGiftWraps(pubkey); + await this.processExistingGiftWraps(pubkey, autoDecrypt); // Update stats await this.updateStats(); @@ -261,8 +275,9 @@ class GiftWrapManager { /** * Load older gift wraps for pagination * Fetches gift wraps before the oldest currently loaded + * @param autoDecrypt - If false, stores gift wraps without decrypting */ - async loadOlderGiftWraps(): Promise { + async loadOlderGiftWraps(autoDecrypt = true): Promise { const account = accountManager.active$.value; if (!account) { console.log("[GiftWrap] No active account"); @@ -331,12 +346,14 @@ class GiftWrapManager { resolve(); } else { count++; - this.processGiftWrap(response, pubkey).catch((error) => { - console.error( - `[GiftWrap] Error processing ${response.id.slice(0, 8)}:`, - error, - ); - }); + this.processGiftWrap(response, pubkey, autoDecrypt).catch( + (error) => { + console.error( + `[GiftWrap] Error processing ${response.id.slice(0, 8)}:`, + error, + ); + }, + ); } }, error: () => { @@ -525,7 +542,10 @@ class GiftWrapManager { /** * Process existing gift wraps from event store */ - private async processExistingGiftWraps(pubkey: string): Promise { + private async processExistingGiftWraps( + pubkey: string, + autoDecrypt = true, + ): Promise { console.log("[GiftWrap] Processing existing gift wraps..."); // Get all kind 1059 events addressed to us @@ -539,7 +559,7 @@ class GiftWrapManager { // Process each gift wrap for (const giftWrap of giftWraps) { try { - await this.processGiftWrap(giftWrap, pubkey); + await this.processGiftWrap(giftWrap, pubkey, autoDecrypt); } catch (error) { console.error( `[GiftWrap] Error processing ${giftWrap.id.slice(0, 8)}:`, @@ -551,10 +571,12 @@ class GiftWrapManager { /** * Process a single gift wrap event + * @param autoDecrypt - If false, only stores gift wrap without attempting decryption */ private async processGiftWrap( giftWrap: NostrEvent, recipientPubkey: string, + autoDecrypt = true, ): Promise { const giftWrapId = giftWrap.id; @@ -569,6 +591,24 @@ class GiftWrapManager { // Failed too many times, don't retry return; } + // If autoDecrypt is false and we have a pending record, don't retry + if (!autoDecrypt && existing.decryptionState === "pending") { + return; + } + } + + // If autoDecrypt is false, just store as pending without attempting decryption + if (!autoDecrypt) { + const decryption: GiftWrapDecryption = { + giftWrapId, + recipientPubkey, + decryptionState: "pending", + lastAttempt: Math.floor(Date.now() / 1000), + attempts: existing?.attempts || 0, + }; + await db.giftWrapDecryptions.put(decryption); + this.debouncedUpdateStats(); + return; } // Create or update decryption record @@ -800,8 +840,11 @@ class GiftWrapManager { /** * Get statistics observable + * Initializes stats from database on first call */ getStats(): Observable { + // Initialize stats from database on first access + this.initializeStats(); return this.stats$.asObservable(); }