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",
+ });
}
}