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
This commit is contained in:
Claude
2026-01-20 11:53:38 +00:00
parent 58bdb3216f
commit 1ed8b8a6b6
2 changed files with 109 additions and 53 deletions

View File

@@ -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 */}
<div className="flex items-center gap-3">
{/* Stats - Compact numbers only */}
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground/80">
{stats.totalGiftWraps}
</span>
</TooltipTrigger>
<TooltipContent>
<p>Total gift wraps</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-green-600/70">
@@ -491,13 +490,13 @@ export function InboxViewer(_props: InboxViewerProps) {
) : (
<>
<div>
{conversationsList.map(({ key, latestMessage, otherPubkey }) => (
{conversationsList.map(({ key, latestMessage, otherPubkeys }) => (
<ConversationRow
key={key}
conversationKey={key}
otherPubkey={otherPubkey}
otherPubkeys={otherPubkeys}
latestMessage={latestMessage}
onClick={() => handleOpenConversation(key, otherPubkey)}
onClick={() => handleOpenConversation(key, otherPubkeys)}
/>
))}
</div>
@@ -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 */}
<div className="shrink-0">
<UserName
pubkey={otherPubkey}
className="text-xs font-medium truncate"
/>
{/* Name(s) - no fixed width */}
<div className="shrink-0 flex items-center gap-1">
{otherPubkeys.map((pubkey, index) => (
<span key={pubkey} className="flex items-center">
<UserName
pubkey={pubkey}
className="text-xs font-medium truncate"
/>
{index < otherPubkeys.length - 1 && (
<span className="text-muted-foreground/50">,</span>
)}
</span>
))}
</div>
{/* Message preview - use CSS truncation and RichText with pointer-events-none */}

View File

@@ -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<void>((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<void>[] = [];
await new Promise<void>((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();