diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx index f6dd4ed..277ddb9 100644 --- a/src/components/nostr/user-menu.tsx +++ b/src/components/nostr/user-menu.tsx @@ -20,6 +20,8 @@ import { RelayLink } from "./RelayLink"; import SettingsDialog from "@/components/SettingsDialog"; import LoginDialog from "./LoginDialog"; import { useState } from "react"; +import eventStore from "@/services/event-store"; +import { getRelaysFromList } from "applesauce-common/helpers"; function UserAvatar({ pubkey }: { pubkey: string }) { const profile = useProfile(pubkey); @@ -57,6 +59,16 @@ export default function UserMenu() { const [showSettings, setShowSettings] = useState(false); const [showLogin, setShowLogin] = useState(false); + // Get DM relays (kind 10050) for the active user + const dmRelayListEvent = use$( + () => + account?.pubkey + ? eventStore.replaceable(10050, account.pubkey) + : undefined, + [account?.pubkey], + ); + const dmRelays = dmRelayListEvent ? getRelaysFromList(dmRelayListEvent) : []; + function openProfile() { if (!account?.pubkey) return; addWindow( @@ -123,6 +135,26 @@ export default function UserMenu() { )} + {dmRelays.length > 0 && ( + <> + + + + DM Relays (NIP-17) + + {dmRelays.map((url) => ( + + ))} + + + )} + {/* setShowSettings(true)} diff --git a/src/lib/chat/adapters/nip-17-adapter.ts b/src/lib/chat/adapters/nip-17-adapter.ts index 27cafd5..8f71f41 100644 --- a/src/lib/chat/adapters/nip-17-adapter.ts +++ b/src/lib/chat/adapters/nip-17-adapter.ts @@ -39,6 +39,7 @@ import { } from "applesauce-common/helpers/wrapped-messages"; import { GiftWrapBlueprint } from "applesauce-common/blueprints/gift-wrap"; import { WrappedMessageBlueprint } from "applesauce-common/blueprints/wrapped-message"; +import db, { type DecryptedMessage } from "@/services/db"; /** * NIP-17 Adapter - Private Direct Messages @@ -389,6 +390,7 @@ export class Nip17Adapter extends ChatProtocolAdapter { /** * Decrypt gift wraps and filter messages for a specific conversation + * Uses DB cache to avoid re-decrypting messages */ private async decryptAndFilterMessages( giftWraps: NostrEvent[], @@ -405,15 +407,46 @@ export class Nip17Adapter extends ChatProtocolAdapter { const messages: Message[] = []; const expectedParticipants = [selfPubkey, partnerPubkey].sort().join(":"); + // Get gift wrap IDs for DB lookup + const giftWrapIds = giftWraps.map((gw) => gw.id); + + // Load cached decrypted messages from DB + const cachedMessages = await db.decryptedMessages + .where("giftWrapId") + .anyOf(giftWrapIds) + .toArray(); + + const cachedByGiftWrapId = new Map( + cachedMessages.map((m) => [m.giftWrapId, m]), + ); + + console.log( + `[NIP-17] Found ${cachedMessages.length}/${giftWraps.length} cached messages`, + ); + + // Track newly decrypted messages to save to DB + const newDecryptedMessages: DecryptedMessage[] = []; + for (const giftWrap of giftWraps) { try { + // Check DB cache first + const cached = cachedByGiftWrapId.get(giftWrap.id); + if (cached) { + // Only include if it belongs to this conversation + if (cached.conversationId === conversationId) { + messages.push(this.cachedMessageToMessage(cached)); + } + continue; + } + + // Not in DB cache - need to decrypt let rumor: Rumor | undefined; - // Check if already unlocked (cached) + // Check if already unlocked (in-memory cache from applesauce) if (isGiftWrapUnlocked(giftWrap)) { rumor = getGiftWrapRumor(giftWrap); } else { - // Decrypt - this will cache the result + // Decrypt - this will cache in memory rumor = await unlockGiftWrap(giftWrap, signer); } @@ -429,9 +462,23 @@ export class Nip17Adapter extends ChatProtocolAdapter { // Check if this message belongs to this conversation const messageParticipants = getConversationParticipants(rumor); const participantKey = messageParticipants.sort().join(":"); + const messageConversationId = `nip-17:${messageParticipants.find((p) => p !== selfPubkey) || selfPubkey}`; + + // Save to DB cache (regardless of conversation match) + const sender = getWrappedMessageSender(rumor); + newDecryptedMessages.push({ + id: rumor.id, + giftWrapId: giftWrap.id, + conversationId: messageConversationId, + senderPubkey: sender, + content: rumor.content, + tags: rumor.tags, + createdAt: rumor.created_at, + decryptedAt: Math.floor(Date.now() / 1000), + }); if (participantKey !== expectedParticipants) { - // Message is for a different conversation + // Message is for a different conversation - saved to DB but not returned continue; } @@ -447,9 +494,62 @@ export class Nip17Adapter extends ChatProtocolAdapter { } } + // Bulk save newly decrypted messages to DB + if (newDecryptedMessages.length > 0) { + try { + await db.decryptedMessages.bulkPut(newDecryptedMessages); + console.log( + `[NIP-17] Cached ${newDecryptedMessages.length} newly decrypted messages`, + ); + } catch (err) { + console.error("[NIP-17] Failed to save decrypted messages to DB:", err); + } + } + return messages; } + /** + * Convert a cached DB message to a Message object + */ + private cachedMessageToMessage(cached: DecryptedMessage): Message { + // Reconstruct a rumor-like object for processing + const rumorLike = { + id: cached.id, + pubkey: cached.senderPubkey, + created_at: cached.createdAt, + kind: 14, + tags: cached.tags, + content: cached.content, + }; + + // Check for reply references + const replyTo = getWrappedMessageParent(rumorLike as unknown as Rumor); + const eTags = getTagValues(rumorLike as unknown as NostrEvent, "e"); + const eTagReply = eTags.find((_, i) => { + const tag = rumorLike.tags.find( + (t) => t[0] === "e" && t[1] === eTags[i] && t[3] === "reply", + ); + return !!tag; + }); + + return { + id: cached.id, + conversationId: cached.conversationId, + author: cached.senderPubkey, + content: cached.content, + timestamp: cached.createdAt, + type: "user", + replyTo: replyTo || eTagReply, + protocol: "nip-17", + metadata: { + encrypted: true, + }, + // Reconstruct event-like object for rendering + event: rumorLike as unknown as NostrEvent, + }; + } + /** * Load more historical messages (pagination) */ @@ -647,7 +747,7 @@ export class Nip17Adapter extends ChatProtocolAdapter { /** * Load a replied-to message - * For NIP-17, we need to search through decrypted rumors + * For NIP-17, we check the DB cache first, then search through gift wraps */ async loadReplyMessage( _conversation: Conversation, @@ -660,8 +760,24 @@ export class Nip17Adapter extends ChatProtocolAdapter { return null; } - // First check if we have this event in our decrypted messages - // The eventId for NIP-17 refers to the rumor ID, not the gift wrap ID + // Check DB cache first (eventId is the rumor ID) + const cached = await db.decryptedMessages.get(eventId); + if (cached) { + console.log( + `[NIP-17] Found reply message ${eventId.slice(0, 8)} in DB cache`, + ); + return { + id: cached.id, + pubkey: cached.senderPubkey, + created_at: cached.createdAt, + kind: 14, + tags: cached.tags, + content: cached.content, + sig: "", // Rumors don't have signatures + } as NostrEvent; + } + + // Not in DB, search through gift wraps const giftWrapFilter: Filter = { kinds: [1059], "#p": [activePubkey], @@ -685,7 +801,22 @@ export class Nip17Adapter extends ChatProtocolAdapter { } if (rumor && rumor.id === eventId) { - // Found it - return as NostrEvent (rumors are structurally similar) + // Found it - save to DB for future lookups + const sender = getWrappedMessageSender(rumor); + const participants = getConversationParticipants(rumor); + const conversationId = `nip-17:${participants.find((p) => p !== activePubkey) || activePubkey}`; + + await db.decryptedMessages.put({ + id: rumor.id, + giftWrapId: giftWrap.id, + conversationId, + senderPubkey: sender, + content: rumor.content, + tags: rumor.tags, + createdAt: rumor.created_at, + decryptedAt: Math.floor(Date.now() / 1000), + }); + return rumor as unknown as NostrEvent; } } catch { @@ -694,7 +825,7 @@ export class Nip17Adapter extends ChatProtocolAdapter { } console.log( - `[NIP-17] Reply message ${eventId.slice(0, 8)} not found in decrypted messages`, + `[NIP-17] Reply message ${eventId.slice(0, 8)} not found in cache or gift wraps`, ); return null; } diff --git a/src/services/db.ts b/src/services/db.ts index 0bb2fa9..3f7bb82 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -80,6 +80,21 @@ export interface LocalSpellbook { deletedAt?: number; } +/** + * Decrypted NIP-17 DM message (rumor from gift wrap) + * Stored to avoid re-decrypting the same messages + */ +export interface DecryptedMessage { + id: string; // Rumor ID + giftWrapId: string; // Original gift wrap event ID + conversationId: string; // nip-17:pubkey format + senderPubkey: string; // Who sent the message + content: string; // Decrypted message content + tags: string[][]; // Rumor tags (for reply references, etc.) + createdAt: number; // Message timestamp + decryptedAt: number; // When we decrypted it +} + class GrimoireDb extends Dexie { profiles!: Table; nip05!: Table; @@ -90,6 +105,7 @@ class GrimoireDb extends Dexie { relayLiveness!: Table; spells!: Table; spellbooks!: Table; + decryptedMessages!: Table; constructor(name: string) { super(name); @@ -311,6 +327,22 @@ class GrimoireDb extends Dexie { spells: "&id, alias, createdAt, isPublished, deletedAt", spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt", }); + + // Version 15: Add decrypted NIP-17 messages cache + this.version(15).stores({ + profiles: "&pubkey", + nip05: "&nip05", + nips: "&id", + relayInfo: "&url", + relayAuthPreferences: "&url", + relayLists: "&pubkey, updatedAt", + relayLiveness: "&url", + spells: "&id, alias, createdAt, isPublished, deletedAt", + spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt", + // Index by giftWrapId (for dedup), conversationId (for queries), senderPubkey (for filtering) + decryptedMessages: + "&id, giftWrapId, conversationId, senderPubkey, createdAt", + }); } }