mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 16:07:15 +02:00
feat: add DM relays to user menu and cache decrypted NIP-17 messages
User menu: - Shows DM inbox relays (kind 10050) below regular relay list - Uses reactive eventStore.replaceable() for live updates Database: - Added decryptedMessages table (DB v15) to cache decrypted rumors - Indexed by giftWrapId, conversationId, senderPubkey, createdAt NIP-17 adapter: - Checks DB cache before decrypting gift wraps - Saves newly decrypted messages to DB for future use - loadReplyMessage also checks DB cache first - Significantly reduces redundant decryption operations
This commit is contained in:
@@ -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 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">
|
||||
DM Relays (NIP-17)
|
||||
</DropdownMenuLabel>
|
||||
{dmRelays.map((url) => (
|
||||
<RelayLink
|
||||
className="px-2 py-1"
|
||||
urlClassname="text-sm"
|
||||
iconClassname="size-4"
|
||||
key={url}
|
||||
url={url}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
{/* <DropdownMenuItem
|
||||
onClick={() => setShowSettings(true)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Profile>;
|
||||
nip05!: Table<Nip05>;
|
||||
@@ -90,6 +105,7 @@ class GrimoireDb extends Dexie {
|
||||
relayLiveness!: Table<RelayLivenessEntry>;
|
||||
spells!: Table<LocalSpell>;
|
||||
spellbooks!: Table<LocalSpellbook>;
|
||||
decryptedMessages!: Table<DecryptedMessage>;
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user