From 1ed8b8a6b6c55c8136fe5201576c055f04f28217 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 11:53:38 +0000 Subject: [PATCH] feat: add live gift wrap sync and group DM support - Add dedicated live subscription for new gift wraps (stays open after EOSE) - Separate historical sync from live subscription for better reliability - Remove total gift wrap count from stats display (shown in tooltip) - Fix load older to await gift wrap processing before updating stats - Add group DM support in inbox conversation list - Parse conversation keys to extract all participants - Display multiple participant names with comma separation - Support opening group DM chats with comma-separated pubkeys --- src/components/InboxViewer.tsx | 72 ++++++++++++++------------- src/services/gift-wrap.ts | 90 ++++++++++++++++++++++++++-------- 2 files changed, 109 insertions(+), 53 deletions(-) diff --git a/src/components/InboxViewer.tsx b/src/components/InboxViewer.tsx index cdbc77c..403a0f7 100644 --- a/src/components/InboxViewer.tsx +++ b/src/components/InboxViewer.tsx @@ -111,14 +111,20 @@ export function InboxViewer(_props: InboxViewerProps) { }; const allConversations = Array.from(conversations.entries()) - .map(([key, latestMessage]) => ({ - key, - latestMessage, - otherPubkey: - latestMessage.senderPubkey === pubkey - ? latestMessage.recipientPubkey - : latestMessage.senderPubkey, - })) + .map(([key, latestMessage]) => { + // Parse conversation key to get all participants + // Key format: "pubkey1:pubkey2:..." (sorted) + const participants = key.split(":"); + + // Filter out current user to get other participants + const otherPubkeys = participants.filter((p) => p !== pubkey); + + return { + key, + latestMessage, + otherPubkeys, + }; + }) .sort((a, b) => b.latestMessage.createdAt - a.latestMessage.createdAt); const pageSize = CONVERSATIONS_PAGE_SIZE * conversationsPage; @@ -147,14 +153,18 @@ export function InboxViewer(_props: InboxViewerProps) { const handleOpenConversation = ( _conversationKey: string, - otherPubkey: string, + otherPubkeys: string[], ) => { - // Open chat window with the other participant using NIP-17 + // Open chat window with the other participant(s) using NIP-17 + // For group DMs, join pubkeys with commas + const recipientValue = + otherPubkeys.length === 1 ? otherPubkeys[0] : otherPubkeys.join(","); + addWindow("chat", { protocol: "nip-17", identifier: { type: "dm-recipient", - value: otherPubkey, + value: recipientValue, }, }); }; @@ -261,17 +271,6 @@ export function InboxViewer(_props: InboxViewerProps) { {/* Center: Stats */}
{/* Stats - Compact numbers only */} - - - - {stats.totalGiftWraps} - - - -

Total gift wraps

-
-
- @@ -491,13 +490,13 @@ export function InboxViewer(_props: InboxViewerProps) { ) : ( <>
- {conversationsList.map(({ key, latestMessage, otherPubkey }) => ( + {conversationsList.map(({ key, latestMessage, otherPubkeys }) => ( handleOpenConversation(key, otherPubkey)} + onClick={() => handleOpenConversation(key, otherPubkeys)} /> ))}
@@ -523,13 +522,13 @@ export function InboxViewer(_props: InboxViewerProps) { interface ConversationRowProps { conversationKey: string; - otherPubkey: string; + otherPubkeys: string[]; latestMessage: any; onClick: () => void; } function ConversationRow({ - otherPubkey, + otherPubkeys, latestMessage, onClick, }: ConversationRowProps) { @@ -538,12 +537,19 @@ function ConversationRow({ onClick={onClick} className="flex cursor-pointer items-center gap-2 border-b px-3 py-1.5 hover:bg-muted/30 last:border-b-0 font-mono text-xs" > - {/* Name - no fixed width */} -
- + {/* Name(s) - no fixed width */} +
+ {otherPubkeys.map((pubkey, index) => ( + + + {index < otherPubkeys.length - 1 && ( + , + )} + + ))}
{/* Message preview - use CSS truncation and RichText with pointer-events-none */} diff --git a/src/services/gift-wrap.ts b/src/services/gift-wrap.ts index f1dc1e1..629e0ba 100644 --- a/src/services/gift-wrap.ts +++ b/src/services/gift-wrap.ts @@ -139,27 +139,69 @@ class GiftWrapManager { ); } - // Step 3: Subscribe to gift wraps with pagination - const filter: Filter = { + // Step 3: Subscribe to historical gift wraps (if initial sync) + if (isInitialSync) { + const historicalFilter: Filter = { + kinds: [1059], + "#p": [pubkey], + since, + until: now, + limit: GIFT_WRAP_CONFIG.INITIAL_LIMIT, + }; + + await new Promise((resolve) => { + const historicalSub = pool + .subscription(dmRelays, [historicalFilter], { + eventStore, + }) + .subscribe({ + next: (response) => { + if (typeof response === "string") { + console.log("[GiftWrap] Historical EOSE received"); + historicalSub.unsubscribe(); + resolve(); + } else { + console.log( + `[GiftWrap] Historical gift wrap: ${response.id.slice(0, 8)}...`, + ); + this.processGiftWrap(response, pubkey, autoDecrypt).catch( + (error) => { + console.error( + `[GiftWrap] Error processing ${response.id.slice(0, 8)}:`, + error, + ); + }, + ); + } + }, + error: () => { + historicalSub.unsubscribe(); + resolve(); + }, + }); + }); + } + + // Step 4: Create live subscription for new gift wraps + // This subscription stays open and listens for new events + const liveFilter: Filter = { kinds: [1059], "#p": [pubkey], - since, - limit: isInitialSync ? GIFT_WRAP_CONFIG.INITIAL_LIMIT : undefined, + since: now, // Only new events from now onwards }; - const subscription = pool - .subscription(dmRelays, [filter], { - eventStore, // Automatically add to event store + const liveSubscription = pool + .subscription(dmRelays, [liveFilter], { + eventStore, }) .subscribe({ next: (response) => { if (typeof response === "string") { - console.log("[GiftWrap] EOSE received"); - // Update last sync timestamp after EOSE + console.log("[GiftWrap] Live subscription EOSE received"); this.lastSyncTimestamp = now; } else { console.log( - `[GiftWrap] Received gift wrap: ${response.id.slice(0, 8)}...`, + `[GiftWrap] New gift wrap: ${response.id.slice(0, 8)}...`, ); // Process gift wrap asynchronously this.processGiftWrap(response, pubkey, autoDecrypt).catch( @@ -173,11 +215,11 @@ class GiftWrapManager { } }, error: (error) => { - console.error("[GiftWrap] Subscription error:", error); + console.error("[GiftWrap] Live subscription error:", error); }, }); - this.subscriptions.set(pubkey, subscription); + this.subscriptions.set(pubkey, liveSubscription); // Process any existing gift wraps in the event store (from previous sessions) await this.processExistingGiftWraps(pubkey, autoDecrypt); @@ -350,6 +392,7 @@ class GiftWrapManager { }; let count = 0; + const processingPromises: Promise[] = []; await new Promise((resolve) => { const subscription = pool @@ -364,14 +407,18 @@ class GiftWrapManager { resolve(); } else { count++; - this.processGiftWrap(response, pubkey, autoDecrypt).catch( - (error) => { - console.error( - `[GiftWrap] Error processing ${response.id.slice(0, 8)}:`, - error, - ); - }, - ); + // Collect all processing promises to await them later + const promise = this.processGiftWrap( + response, + pubkey, + autoDecrypt, + ).catch((error) => { + console.error( + `[GiftWrap] Error processing ${response.id.slice(0, 8)}:`, + error, + ); + }); + processingPromises.push(promise); } }, error: () => { @@ -381,6 +428,9 @@ class GiftWrapManager { }); }); + // Wait for all gift wraps to be processed before updating stats + await Promise.all(processingPromises); + // Update stats await this.updateStats();