From cd052d657f4fbd0ee4ac961d2c7a64a9e763eac0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Jan 2026 11:39:08 +0000 Subject: [PATCH] feat: Add NIP-17/59 gift-wrapped DM support with caching Implements encrypted private messaging using gift wraps: - Add event cache service (Dexie) for offline access - Add rumor storage for caching decrypted gift wrap content - Wire persistEventsToCache and persistEncryptedContent to EventStore - Create NIP-17 adapter for gift-wrapped DMs - Add InboxViewer component for DM conversation list - Add `inbox` command to open private message inbox - Register NIP-17 adapter in ChatViewer Features: - Decrypt once, cache forever - no re-decryption needed - Explicit decrypt button (user-initiated) - Conversation list derived from decrypted gift wraps - Private inbox relay discovery (kind 10050) - Send not yet implemented (TODO: use SendWrappedMessage action) --- src/components/ChatViewer.tsx | 7 +- src/components/InboxViewer.tsx | 445 +++++++++++++++++ src/components/WindowRenderer.tsx | 6 + src/lib/chat/adapters/nip-17-adapter.ts | 613 ++++++++++++++++++++++++ src/services/db.ts | 48 ++ src/services/event-cache.ts | 130 +++++ src/services/event-store.ts | 18 + src/services/rumor-storage.ts | 165 +++++++ src/types/app.ts | 1 + src/types/man.ts | 12 + 10 files changed, 1443 insertions(+), 2 deletions(-) create mode 100644 src/components/InboxViewer.tsx create mode 100644 src/lib/chat/adapters/nip-17-adapter.ts create mode 100644 src/services/event-cache.ts create mode 100644 src/services/rumor-storage.ts diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index 6749b5f..7f7d0e1 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -23,6 +23,7 @@ import type { // import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter"; // Coming soon import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter"; import { Nip53Adapter } from "@/lib/chat/adapters/nip-53-adapter"; +import { Nip17Adapter } from "@/lib/chat/adapters/nip-17-adapter"; import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter"; import type { Message } from "@/types/chat"; import type { ChatAction } from "@/types/chat-actions"; @@ -630,6 +631,8 @@ export function ChatViewer({ addWindow("nip", { number: 29 }); } else if (conversation?.protocol === "nip-53") { addWindow("nip", { number: 53 }); + } else if (conversation?.protocol === "nip-17") { + addWindow("nip", { number: 17 }); } }, [conversation?.protocol, addWindow]); @@ -955,8 +958,8 @@ function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter { // return new NipC7Adapter(); case "nip-29": return new Nip29Adapter(); - // case "nip-17": // Phase 2 - Encrypted DMs (coming soon) - // return new Nip17Adapter(); + case "nip-17": + return new Nip17Adapter(); // case "nip-28": // Phase 3 - Public channels (coming soon) // return new Nip28Adapter(); case "nip-53": diff --git a/src/components/InboxViewer.tsx b/src/components/InboxViewer.tsx new file mode 100644 index 0000000..0e18834 --- /dev/null +++ b/src/components/InboxViewer.tsx @@ -0,0 +1,445 @@ +/** + * InboxViewer - Private DM Inbox (NIP-17/59 Gift Wrapped Messages) + * + * Displays list of encrypted DM conversations using gift wraps. + * Messages are cached after decryption to avoid re-decryption on page load. + * + * Features: + * - Lists all DM conversations from decrypted gift wraps + * - Shows pending (undecrypted) message count + * - Explicit decrypt button (no auto-decrypt) + * - Opens individual DM conversations in ChatViewer + */ +import { useState, useMemo, memo, useCallback, useEffect } from "react"; +import { use$ } from "applesauce-react/hooks"; +import { + Loader2, + Lock, + Unlock, + Mail, + AlertCircle, + PanelLeft, +} from "lucide-react"; +import accountManager from "@/services/accounts"; +import { ChatViewer } from "./ChatViewer"; +import type { ProtocolIdentifier } from "@/types/chat"; +import { cn } from "@/lib/utils"; +import Timestamp from "./Timestamp"; +import { UserName } from "./nostr/UserName"; +import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; +import { Button } from "@/components/ui/button"; +import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; +import * as VisuallyHidden from "@radix-ui/react-visually-hidden"; +import { Nip17Adapter } from "@/lib/chat/adapters/nip-17-adapter"; +import { useProfile } from "@/hooks/useProfile"; +import { getDisplayName } from "@/lib/nostr-utils"; + +/** + * UserAvatar - Display a user's avatar with profile data + */ +const UserAvatar = memo(function UserAvatar({ + pubkey, + className, +}: { + pubkey: string; + className?: string; +}) { + const profile = useProfile(pubkey); + const name = getDisplayName(pubkey, profile); + + return ( + + + {name.slice(0, 2).toUpperCase()} + + ); +}); + +const MOBILE_BREAKPOINT = 768; + +function useIsMobile() { + const [isMobile, setIsMobile] = useState(undefined); + + useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + }; + mql.addEventListener("change", onChange); + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + return () => mql.removeEventListener("change", onChange); + }, []); + + return isMobile; +} + +/** + * Conversation info for display + */ +interface ConversationInfo { + id: string; + partnerPubkey: string; + lastMessage?: { + content: string; + timestamp: number; + isOwn: boolean; + }; +} + +/** + * ConversationListItem - Single conversation in the list + */ +const ConversationListItem = memo(function ConversationListItem({ + conversation, + isSelected, + onClick, +}: { + conversation: ConversationInfo; + isSelected: boolean; + onClick: () => void; +}) { + return ( +
+ +
+
+ + {conversation.lastMessage && ( + + + + )} +
+ {conversation.lastMessage && ( +
+ {conversation.lastMessage.isOwn && ( + You: + )} + {conversation.lastMessage.content} +
+ )} +
+
+ ); +}); + +/** + * DecryptButton - Shows pending count and triggers decryption + */ +const DecryptButton = memo(function DecryptButton({ + pendingCount, + isDecrypting, + onDecrypt, +}: { + pendingCount: number; + isDecrypting: boolean; + onDecrypt: () => void; +}) { + if (pendingCount === 0) return null; + + return ( + + ); +}); + +/** + * MemoizedChatViewer - Memoized chat viewer to prevent unnecessary re-renders + */ +const MemoizedChatViewer = memo( + function MemoizedChatViewer({ + partnerPubkey, + headerPrefix, + }: { + partnerPubkey: string; + headerPrefix?: React.ReactNode; + }) { + return ( + + ); + }, + (prev, next) => prev.partnerPubkey === next.partnerPubkey, +); + +/** + * InboxViewer - Main inbox component + */ +export function InboxViewer() { + const activeAccount = use$(accountManager.active$); + const activePubkey = activeAccount?.pubkey; + + // Mobile detection + const isMobile = useIsMobile(); + + // State + const [selectedPartner, setSelectedPartner] = useState(null); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [sidebarWidth, setSidebarWidth] = useState(300); + const [isResizing, setIsResizing] = useState(false); + const [isDecrypting, setIsDecrypting] = useState(false); + + // NIP-17 adapter instance + const adapter = useMemo(() => new Nip17Adapter(), []); + + // Get pending count + const pendingCount = use$(() => adapter.getPendingCount$(), [adapter]) ?? 0; + + // Get conversations from adapter + const conversations = use$( + () => (activePubkey ? adapter.getConversations$() : undefined), + [adapter, activePubkey], + ); + + // Convert to display format + const conversationList = useMemo(() => { + if (!conversations || !activePubkey) return []; + + return conversations.map((conv): ConversationInfo => { + const partner = conv.participants.find((p) => p.pubkey !== activePubkey); + return { + id: conv.id, + partnerPubkey: partner?.pubkey || "", + lastMessage: conv.lastMessage + ? { + content: conv.lastMessage.content, + timestamp: conv.lastMessage.timestamp, + isOwn: conv.lastMessage.author === activePubkey, + } + : undefined, + }; + }); + }, [conversations, activePubkey]); + + // Handle conversation selection + const handleSelect = useCallback( + (partnerPubkey: string) => { + setSelectedPartner(partnerPubkey); + if (isMobile) { + setSidebarOpen(false); + } + }, + [isMobile], + ); + + // Handle decrypt + const handleDecrypt = useCallback(async () => { + setIsDecrypting(true); + try { + const result = await adapter.decryptPending(); + console.log( + `[Inbox] Decrypted ${result.success} messages, ${result.failed} failed`, + ); + } catch (error) { + console.error("[Inbox] Decrypt error:", error); + } finally { + setIsDecrypting(false); + } + }, [adapter]); + + // Handle resize + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + setIsResizing(true); + + const startX = e.clientX; + const startWidth = sidebarWidth; + + const handleMouseMove = (moveEvent: MouseEvent) => { + const deltaX = moveEvent.clientX - startX; + const newWidth = startWidth + deltaX; + setSidebarWidth(Math.max(200, Math.min(500, newWidth))); + }; + + const handleMouseUp = () => { + setIsResizing(false); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, + [sidebarWidth], + ); + + // Cleanup on unmount + useEffect(() => { + return () => { + adapter.cleanupAll(); + }; + }, [adapter]); + + // Not signed in + if (!activePubkey) { + return ( +
+ + Sign in to view your encrypted messages +
+ ); + } + + // Sidebar content + const sidebarContent = ( +
+ {/* Header */} +
+
+ +

Private Messages

+
+ +
+ + {/* Conversation list */} +
+ {conversationList.length === 0 ? ( +
+ + + {(pendingCount || 0) > 0 + ? "Decrypt messages to see conversations" + : "No conversations yet"} + +
+ ) : ( + conversationList.map((conv) => ( + handleSelect(conv.partnerPubkey)} + /> + )) + )} +
+
+ ); + + // Sidebar toggle button for mobile + const sidebarToggle = isMobile ? ( + + ) : null; + + // Chat content + const chatContent = selectedPartner ? ( + + ) : ( +
+ {isMobile ? ( + + ) : ( + <> + + Select a conversation + + )} +
+ ); + + // Mobile layout + if (isMobile) { + return ( +
+ + + + Messages + +
{sidebarContent}
+
+
+ +
{chatContent}
+
+ ); + } + + // Desktop layout + return ( +
+ {/* Sidebar */} + + + {/* Resize handle */} +
+ + {/* Chat panel */} +
{chatContent}
+
+ ); +} diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index 84d0ffa..2f82348 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -42,6 +42,9 @@ const SpellbooksViewer = lazy(() => const BlossomViewer = lazy(() => import("./BlossomViewer").then((m) => ({ default: m.BlossomViewer })), ); +const InboxViewer = lazy(() => + import("./InboxViewer").then((m) => ({ default: m.InboxViewer })), +); // Loading fallback component function ViewerLoading() { @@ -210,6 +213,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) { /> ); break; + case "inbox": + content = ; + break; default: content = (
diff --git a/src/lib/chat/adapters/nip-17-adapter.ts b/src/lib/chat/adapters/nip-17-adapter.ts new file mode 100644 index 0000000..861fad3 --- /dev/null +++ b/src/lib/chat/adapters/nip-17-adapter.ts @@ -0,0 +1,613 @@ +/** + * NIP-17 Adapter - Private Direct Messages (Gift Wrapped) + * + * Implements NIP-17 encrypted DMs using NIP-59 gift wraps: + * - kind 1059: Gift wrap (outer encrypted layer with ephemeral key) + * - kind 13: Seal (middle layer encrypted with sender's key) + * - kind 14: DM rumor (inner content - the actual message) + * + * Privacy features: + * - Sender identity hidden (ephemeral gift wrap key) + * - Deniability (rumors are unsigned) + * - Uses recipient's private inbox relays (kind 10050) + * + * Caching: + * - Gift wraps are cached to Dexie events table + * - Decrypted rumors are cached to avoid re-decryption + */ +import { Observable, firstValueFrom, BehaviorSubject } from "rxjs"; +import { map, first } from "rxjs/operators"; +import { nip19 } from "nostr-tools"; +import type { Filter } from "nostr-tools"; +import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter"; +import type { + Conversation, + Message, + ProtocolIdentifier, + ChatCapabilities, + LoadMessagesOptions, +} from "@/types/chat"; +import type { NostrEvent } from "@/types/nostr"; +import eventStore from "@/services/event-store"; +import pool from "@/services/relay-pool"; +import accountManager from "@/services/accounts"; +import { isNip05, resolveNip05 } from "@/lib/nip05"; +import { getDisplayName } from "@/lib/nostr-utils"; +import { isValidHexPubkey } from "@/lib/nostr-validation"; +import { getProfileContent } from "applesauce-core/helpers"; +import { + unlockGiftWrap, + getConversationParticipants, + getConversationIdentifierFromMessage, + type Rumor, +} from "applesauce-common/helpers"; +import { + getDecryptedRumors, + isGiftWrapDecrypted, + storeDecryptedRumor, +} from "@/services/rumor-storage"; + +/** + * Kind constants + */ +const GIFT_WRAP_KIND = 1059; +const DM_RUMOR_KIND = 14; +const DM_RELAY_LIST_KIND = 10050; + +/** + * NIP-17 Adapter - Gift Wrapped Private DMs + */ +export class Nip17Adapter extends ChatProtocolAdapter { + readonly protocol = "nip-17" as const; + readonly type = "dm" as const; + + /** Observable of all decrypted rumors for the current user */ + private rumors$ = new BehaviorSubject([]); + + /** Track pending (undecrypted) gift wrap IDs */ + private pendingGiftWraps$ = new BehaviorSubject([]); + + /** + * Parse identifier - accepts npub, nprofile, hex pubkey, or NIP-05 + */ + parseIdentifier(input: string): ProtocolIdentifier | null { + // Try bech32 decoding (npub/nprofile) + try { + const decoded = nip19.decode(input); + if (decoded.type === "npub") { + return { + type: "dm-recipient", + value: decoded.data, + }; + } + if (decoded.type === "nprofile") { + return { + type: "dm-recipient", + value: decoded.data.pubkey, + relays: decoded.data.relays, + }; + } + } catch { + // Not bech32, try other formats + } + + // Try hex pubkey + if (isValidHexPubkey(input)) { + return { + type: "dm-recipient", + value: input, + }; + } + + // Try NIP-05 + if (isNip05(input)) { + return { + type: "chat-partner-nip05", + value: input, + }; + } + + return null; + } + + /** + * Resolve conversation from identifier + */ + async resolveConversation( + identifier: ProtocolIdentifier, + ): Promise { + let partnerPubkey: string; + + // Resolve NIP-05 if needed + if (identifier.type === "chat-partner-nip05") { + const resolved = await resolveNip05(identifier.value); + if (!resolved) { + throw new Error(`Failed to resolve NIP-05: ${identifier.value}`); + } + partnerPubkey = resolved; + } else if ( + identifier.type === "dm-recipient" || + identifier.type === "chat-partner" + ) { + partnerPubkey = identifier.value; + } else { + throw new Error( + `NIP-17 adapter cannot handle identifier type: ${identifier.type}`, + ); + } + + const activePubkey = accountManager.active$.value?.pubkey; + if (!activePubkey) { + throw new Error("No active account"); + } + + // Get display name for partner + const metadataEvent = await this.getMetadata(partnerPubkey); + const metadata = metadataEvent + ? getProfileContent(metadataEvent) + : undefined; + const title = getDisplayName(partnerPubkey, metadata); + + // Create conversation ID from sorted participants (deterministic) + const participants = [activePubkey, partnerPubkey].sort(); + const conversationId = `nip-17:${participants.join(",")}`; + + return { + id: conversationId, + type: "dm", + protocol: "nip-17", + title, + participants: [ + { pubkey: activePubkey, role: "member" }, + { pubkey: partnerPubkey, role: "member" }, + ], + metadata: { + encrypted: true, + giftWrapped: true, + }, + unreadCount: 0, + }; + } + + /** + * Load messages for a conversation + * Returns decrypted rumors that match this conversation + */ + loadMessages( + conversation: Conversation, + _options?: LoadMessagesOptions, + ): Observable { + const activePubkey = accountManager.active$.value?.pubkey; + if (!activePubkey) { + throw new Error("No active account"); + } + + // Get partner pubkey + const partner = conversation.participants.find( + (p) => p.pubkey !== activePubkey, + ); + if (!partner) { + throw new Error("No conversation partner found"); + } + + // Expected participants for this conversation + const expectedParticipants = [activePubkey, partner.pubkey].sort(); + + // Load initial rumors from cache + this.loadCachedRumors(activePubkey); + + // Subscribe to gift wraps for this user + this.subscribeToGiftWraps(activePubkey); + + // Filter rumors to this conversation and convert to messages + return this.rumors$.pipe( + map((rumors) => { + // Filter rumors that belong to this conversation + const conversationRumors = rumors.filter((rumor) => { + // Only kind 14 DM rumors + if (rumor.kind !== DM_RUMOR_KIND) return false; + + // Get participants from rumor + const rumorParticipants = getConversationParticipants(rumor).sort(); + + // Check if participants match + return ( + rumorParticipants.length === expectedParticipants.length && + rumorParticipants.every((p, i) => p === expectedParticipants[i]) + ); + }); + + // Convert to messages and sort by timestamp + return conversationRumors + .map((rumor) => this.rumorToMessage(rumor, conversation.id)) + .sort((a, b) => a.timestamp - b.timestamp); + }), + ); + } + + /** + * Load more historical messages (pagination) + */ + async loadMoreMessages( + _conversation: Conversation, + _before: number, + ): Promise { + // For now, return empty - pagination to be implemented + // Gift wraps don't paginate well since we need to decrypt all + return []; + } + + /** + * Send a gift-wrapped DM + */ + async sendMessage( + conversation: Conversation, + content: string, + options?: SendMessageOptions, + ): Promise { + const activePubkey = accountManager.active$.value?.pubkey; + const activeSigner = accountManager.active$.value?.signer; + + if (!activePubkey || !activeSigner) { + throw new Error("No active account or signer"); + } + + const partner = conversation.participants.find( + (p) => p.pubkey !== activePubkey, + ); + if (!partner) { + throw new Error("No conversation partner found"); + } + + // Build rumor tags + const tags: string[][] = [["p", partner.pubkey]]; + if (options?.replyTo) { + tags.push(["e", options.replyTo, "", "reply"]); + } + + // Get recipient's private inbox relays + const inboxRelays = await this.getPrivateInboxRelays(partner.pubkey); + if (inboxRelays.length === 0) { + throw new Error( + "Recipient has no private inbox relays configured (kind 10050)", + ); + } + + // TODO: Implement gift wrap creation and sending + // 1. Create the DM rumor (kind 14, unsigned) with: activePubkey, tags, content + // 2. Use SendWrappedMessage action from applesauce-actions to create and send gift wraps + // 3. Publish to each recipient's private inbox relays + void inboxRelays; // Will be used when implemented + void tags; + void content; + void activePubkey; + + throw new Error( + "Send not yet implemented - use applesauce SendWrappedMessage action", + ); + } + + /** + * Get protocol capabilities + */ + getCapabilities(): ChatCapabilities { + return { + supportsEncryption: true, + supportsThreading: true, // e-tag replies + supportsModeration: false, + supportsRoles: false, + supportsGroupManagement: false, + canCreateConversations: true, + requiresRelay: false, + }; + } + + /** + * Load a replied-to message + */ + async loadReplyMessage( + _conversation: Conversation, + eventId: string, + ): Promise { + // Check if we have a cached rumor with this ID + const rumors = this.rumors$.value; + const rumor = rumors.find((r) => r.id === eventId); + + if (rumor) { + // Convert rumor to a pseudo-event for display + return { + ...rumor, + sig: "", // Rumors are unsigned + } as NostrEvent; + } + + return null; + } + + /** + * Get count of pending (undecrypted) gift wraps + */ + getPendingCount(): number { + return this.pendingGiftWraps$.value.length; + } + + /** + * Get observable of pending gift wrap count + */ + getPendingCount$(): Observable { + return this.pendingGiftWraps$.pipe(map((ids) => ids.length)); + } + + /** + * Decrypt all pending gift wraps + */ + async decryptPending(): Promise<{ success: number; failed: number }> { + const signer = accountManager.active$.value?.signer; + const pubkey = accountManager.active$.value?.pubkey; + + if (!signer || !pubkey) { + throw new Error("No active account"); + } + + const pendingIds = this.pendingGiftWraps$.value; + let success = 0; + let failed = 0; + + for (const giftWrapId of pendingIds) { + try { + // Get the gift wrap event + const giftWrap = await firstValueFrom( + eventStore.event(giftWrapId).pipe(first()), + ); + + if (!giftWrap) { + failed++; + continue; + } + + // Decrypt using signer + const rumor = await unlockGiftWrap(giftWrap, signer); + + if (rumor) { + // Store decrypted rumor + await storeDecryptedRumor(giftWrapId, rumor, pubkey); + + // Add to rumors list + const currentRumors = this.rumors$.value; + if (!currentRumors.find((r) => r.id === rumor.id)) { + this.rumors$.next([...currentRumors, rumor]); + } + + success++; + } else { + failed++; + } + } catch (error) { + console.error( + `[NIP-17] Failed to decrypt gift wrap ${giftWrapId}:`, + error, + ); + failed++; + } + } + + // Clear pending list for successfully decrypted + const remainingPending = this.pendingGiftWraps$.value.filter( + (id) => !pendingIds.includes(id) || failed > 0, + ); + this.pendingGiftWraps$.next(remainingPending); + + return { success, failed }; + } + + /** + * Get all conversations from decrypted rumors + */ + getConversations$(): Observable { + const activePubkey = accountManager.active$.value?.pubkey; + if (!activePubkey) { + return new BehaviorSubject([]); + } + + return this.rumors$.pipe( + map((rumors) => { + // Group rumors by conversation + const conversationMap = new Map< + string, + { participants: string[]; lastRumor: Rumor } + >(); + + for (const rumor of rumors) { + if (rumor.kind !== DM_RUMOR_KIND) continue; + + const convId = getConversationIdentifierFromMessage(rumor); + const participants = getConversationParticipants(rumor); + + const existing = conversationMap.get(convId); + if (!existing || rumor.created_at > existing.lastRumor.created_at) { + conversationMap.set(convId, { participants, lastRumor: rumor }); + } + } + + // Convert to Conversation objects + const conversations: Conversation[] = []; + + for (const [convId, { participants, lastRumor }] of conversationMap) { + const partner = participants.find((p) => p !== activePubkey); + if (!partner) continue; + + conversations.push({ + id: `nip-17:${participants.sort().join(",")}`, + type: "dm", + protocol: "nip-17", + title: partner.slice(0, 8) + "...", // Will be replaced with display name + participants: participants.map((p) => ({ + pubkey: p, + role: "member" as const, + })), + metadata: { encrypted: true, giftWrapped: true }, + lastMessage: this.rumorToMessage(lastRumor, convId), + unreadCount: 0, + }); + } + + // Sort by last message timestamp + conversations.sort( + (a, b) => + (b.lastMessage?.timestamp || 0) - (a.lastMessage?.timestamp || 0), + ); + + return conversations; + }), + ); + } + + // ==================== Private Methods ==================== + + /** + * Load cached rumors from Dexie + */ + private async loadCachedRumors(pubkey: string): Promise { + const rumors = await getDecryptedRumors(pubkey); + this.rumors$.next(rumors); + } + + /** + * Subscribe to gift wraps for the user + */ + private subscribeToGiftWraps(pubkey: string): void { + const conversationId = `nip-17:inbox:${pubkey}`; + + // Clean up existing subscription + this.cleanup(conversationId); + + // Subscribe to gift wraps addressed to this user + const filter: Filter = { + kinds: [GIFT_WRAP_KIND], + "#p": [pubkey], + }; + + const subscription = pool + .subscription([], [filter], { eventStore }) + .subscribe({ + next: async (response) => { + if (typeof response === "string") { + // EOSE + console.log("[NIP-17] EOSE received for gift wraps"); + } else { + // New gift wrap received + console.log( + `[NIP-17] Received gift wrap: ${response.id.slice(0, 8)}...`, + ); + + // Check if already decrypted + const isDecrypted = await isGiftWrapDecrypted(response.id, pubkey); + + if (!isDecrypted) { + // Add to pending list + const pending = this.pendingGiftWraps$.value; + if (!pending.includes(response.id)) { + this.pendingGiftWraps$.next([...pending, response.id]); + } + } + } + }, + }); + + this.subscriptions.set(conversationId, subscription); + } + + /** + * Get private inbox relays for a user (kind 10050) + */ + private async getPrivateInboxRelays(pubkey: string): Promise { + // Try to fetch from EventStore first + const existing = await firstValueFrom( + eventStore.replaceable(DM_RELAY_LIST_KIND, pubkey, ""), + { defaultValue: undefined }, + ); + + if (existing) { + return this.extractRelaysFromEvent(existing); + } + + // Fetch from relays + const filter: Filter = { + kinds: [DM_RELAY_LIST_KIND], + authors: [pubkey], + limit: 1, + }; + + const events: NostrEvent[] = []; + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 5000); + const sub = pool.subscription([], [filter], { eventStore }).subscribe({ + next: (response) => { + if (typeof response === "string") { + clearTimeout(timeout); + sub.unsubscribe(); + resolve(); + } else { + events.push(response); + } + }, + error: () => { + clearTimeout(timeout); + resolve(); + }, + }); + }); + + if (events.length > 0) { + return this.extractRelaysFromEvent(events[0]); + } + + return []; + } + + /** + * Extract relay URLs from kind 10050 event + */ + private extractRelaysFromEvent(event: NostrEvent): string[] { + return event.tags.filter((t) => t[0] === "relay" && t[1]).map((t) => t[1]); + } + + /** + * Convert a rumor to a Message + */ + private rumorToMessage(rumor: Rumor, conversationId: string): Message { + // Check for reply reference + const replyTag = rumor.tags.find( + (t) => t[0] === "e" && (t[3] === "reply" || !t[3]), + ); + const replyTo = replyTag?.[1]; + + return { + id: rumor.id, + conversationId, + author: rumor.pubkey, + content: rumor.content, + timestamp: rumor.created_at, + type: "user", + replyTo, + protocol: "nip-17", + metadata: { + encrypted: true, + }, + // Create a pseudo-event for the rumor (unsigned) + event: { + ...rumor, + sig: "", + } as NostrEvent, + }; + } + + /** + * Get metadata for a pubkey + */ + private async getMetadata(pubkey: string): Promise { + return firstValueFrom(eventStore.replaceable(0, pubkey), { + defaultValue: undefined, + }); + } +} diff --git a/src/services/db.ts b/src/services/db.ts index 0bb2fa9..4320bb4 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -80,6 +80,37 @@ export interface LocalSpellbook { deletedAt?: number; } +/** + * Cached Nostr event for offline access + */ +export interface CachedEvent { + id: string; + event: NostrEvent; + cachedAt: number; +} + +/** + * Decrypted rumor from gift wrap (NIP-59) + * Stored separately so we don't have to re-decrypt + */ +export interface DecryptedRumor { + /** Gift wrap event ID */ + giftWrapId: string; + /** The decrypted rumor (unsigned event) */ + rumor: { + id: string; + pubkey: string; + created_at: number; + kind: number; + tags: string[][]; + content: string; + }; + /** Pubkey that decrypted this (for multi-account support) */ + decryptedBy: string; + /** When it was decrypted */ + decryptedAt: number; +} + class GrimoireDb extends Dexie { profiles!: Table; nip05!: Table; @@ -90,6 +121,8 @@ class GrimoireDb extends Dexie { relayLiveness!: Table; spells!: Table; spellbooks!: Table; + events!: Table; + decryptedRumors!: Table; constructor(name: string) { super(name); @@ -311,6 +344,21 @@ class GrimoireDb extends Dexie { spells: "&id, alias, createdAt, isPublished, deletedAt", spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt", }); + + // Version 15: Add event cache and decrypted rumor storage for NIP-59 gift wraps + 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", + events: "&id, cachedAt", + decryptedRumors: "&giftWrapId, decryptedBy", + }); } } diff --git a/src/services/event-cache.ts b/src/services/event-cache.ts new file mode 100644 index 0000000..e39bcfe --- /dev/null +++ b/src/services/event-cache.ts @@ -0,0 +1,130 @@ +/** + * Event cache service for persisting Nostr events to Dexie + * + * Provides: + * - Event caching for offline access + * - CacheRequest function for applesauce loaders + * - Automatic persistence of events from EventStore + */ +import type { Filter, NostrEvent } from "nostr-tools"; +import db, { type CachedEvent } from "./db"; +import { matchFilter } from "nostr-tools"; + +/** + * Add events to the cache + */ +export async function cacheEvents(events: NostrEvent[]): Promise { + if (events.length === 0) return; + + const now = Date.now(); + const cachedEvents: CachedEvent[] = events.map((event) => ({ + id: event.id, + event, + cachedAt: now, + })); + + // Use bulkPut to handle duplicates gracefully + await db.events.bulkPut(cachedEvents); +} + +/** + * Get a single event from cache by ID + */ +export async function getCachedEvent( + id: string, +): Promise { + const cached = await db.events.get(id); + return cached?.event; +} + +/** + * Get events from cache matching filters + * This is used as a CacheRequest for applesauce loaders + */ +export async function getEventsForFilters( + filters: Filter[], +): Promise { + // For simple ID lookups, use direct queries + const idFilters = filters.filter( + (f) => f.ids && f.ids.length > 0 && Object.keys(f).length === 1, + ); + + if (idFilters.length === filters.length && idFilters.length > 0) { + // All filters are simple ID lookups + const allIds = idFilters.flatMap((f) => f.ids || []); + const cached = await db.events.bulkGet(allIds); + return cached + .filter((c): c is CachedEvent => c !== undefined) + .map((c) => c.event); + } + + // For complex filters, we need to scan and filter + // This is less efficient but necessary for kind/author/tag queries + const allEvents = await db.events.toArray(); + const matchingEvents: NostrEvent[] = []; + + for (const cached of allEvents) { + for (const filter of filters) { + if (matchFilter(filter, cached.event)) { + matchingEvents.push(cached.event); + break; // Event matches at least one filter + } + } + } + + // Apply limit if specified (use smallest limit from filters) + const limits = filters + .map((f) => f.limit) + .filter((l): l is number => l !== undefined); + if (limits.length > 0) { + const minLimit = Math.min(...limits); + // Sort by created_at descending and take limit + matchingEvents.sort((a, b) => b.created_at - a.created_at); + return matchingEvents.slice(0, minLimit); + } + + return matchingEvents; +} + +/** + * CacheRequest function for applesauce loaders + * Compatible with createTimelineLoader's cache option + */ +export const cacheRequest = (filters: Filter[]): Promise => + getEventsForFilters(filters); + +/** + * Clear old cached events (older than maxAge in milliseconds) + * Default: 30 days + */ +export async function pruneEventCache( + maxAgeMs: number = 30 * 24 * 60 * 60 * 1000, +): Promise { + const cutoff = Date.now() - maxAgeMs; + const deleted = await db.events.where("cachedAt").below(cutoff).delete(); + return deleted; +} + +/** + * Get cache statistics + */ +export async function getCacheStats(): Promise<{ + eventCount: number; + oldestEvent: number | null; + newestEvent: number | null; +}> { + const count = await db.events.count(); + + if (count === 0) { + return { eventCount: 0, oldestEvent: null, newestEvent: null }; + } + + const oldest = await db.events.orderBy("cachedAt").first(); + const newest = await db.events.orderBy("cachedAt").last(); + + return { + eventCount: count, + oldestEvent: oldest?.cachedAt ?? null, + newestEvent: newest?.cachedAt ?? null, + }; +} diff --git a/src/services/event-store.ts b/src/services/event-store.ts index cb9ae2d..9b70e71 100644 --- a/src/services/event-store.ts +++ b/src/services/event-store.ts @@ -1,5 +1,23 @@ import { EventStore } from "applesauce-core"; +import { persistEventsToCache } from "applesauce-core/helpers"; +import { persistEncryptedContent } from "applesauce-common/helpers"; +import { cacheEvents } from "./event-cache"; +import { rumorStorage, setCurrentPubkey } from "./rumor-storage"; +import accountManager from "./accounts"; +import { of } from "rxjs"; const eventStore = new EventStore(); +// Persist all events to Dexie cache for offline access +persistEventsToCache(eventStore, cacheEvents); + +// Persist decrypted gift wrap content to Dexie +// This ensures we don't have to re-decrypt messages on every page load +persistEncryptedContent(eventStore, of(rumorStorage)); + +// Sync current pubkey for rumor storage when account changes +accountManager.active$.subscribe((account) => { + setCurrentPubkey(account?.pubkey ?? null); +}); + export default eventStore; diff --git a/src/services/rumor-storage.ts b/src/services/rumor-storage.ts new file mode 100644 index 0000000..fe1f608 --- /dev/null +++ b/src/services/rumor-storage.ts @@ -0,0 +1,165 @@ +/** + * Rumor storage service for caching decrypted gift wrap content + * + * When a gift wrap (kind 1059) is decrypted, the inner rumor is cached + * so we don't have to decrypt it again. This is especially important + * because decryption requires the signer (browser extension interaction). + * + * Storage format matches applesauce's persistEncryptedContent expectations: + * - Key: `rumor:${giftWrapId}` + * - Value: The decrypted rumor object + */ +import type { Rumor } from "applesauce-common/helpers"; +import db, { type DecryptedRumor } from "./db"; +import { BehaviorSubject, type Observable } from "rxjs"; + +/** + * Current user pubkey for multi-account support + * Set this when account changes + */ +const currentPubkey$ = new BehaviorSubject(null); + +export function setCurrentPubkey(pubkey: string | null): void { + currentPubkey$.next(pubkey); +} + +export function getCurrentPubkey(): string | null { + return currentPubkey$.value; +} + +/** + * Storage interface compatible with applesauce's persistEncryptedContent + * + * The keys are in format "rumor:{giftWrapId}" or "seal:{giftWrapId}" + */ +export const rumorStorage = { + async getItem(key: string): Promise { + const pubkey = currentPubkey$.value; + if (!pubkey) return null; + + // Parse key format: "rumor:{giftWrapId}" or "seal:{giftWrapId}" + const match = key.match(/^(rumor|seal):(.+)$/); + if (!match) return null; + + const [, type, giftWrapId] = match; + + if (type === "rumor") { + const entry = await db.decryptedRumors.get(giftWrapId); + if (entry && entry.decryptedBy === pubkey) { + return JSON.stringify(entry.rumor); + } + } + + // For seals, we don't cache them separately (they're intermediate) + return null; + }, + + async setItem(key: string, value: string): Promise { + const pubkey = currentPubkey$.value; + if (!pubkey) return; + + // Parse key format + const match = key.match(/^(rumor|seal):(.+)$/); + if (!match) return; + + const [, type, giftWrapId] = match; + + if (type === "rumor") { + const rumor = JSON.parse(value) as Rumor; + const entry: DecryptedRumor = { + giftWrapId, + rumor, + decryptedBy: pubkey, + decryptedAt: Date.now(), + }; + await db.decryptedRumors.put(entry); + } + + // We don't persist seals - they're just intermediate decryption steps + }, + + async removeItem(key: string): Promise { + const match = key.match(/^(rumor|seal):(.+)$/); + if (!match) return; + + const [, , giftWrapId] = match; + await db.decryptedRumors.delete(giftWrapId); + }, +}; + +/** + * Get all decrypted rumors for the current user + */ +export async function getDecryptedRumors(pubkey: string): Promise { + const entries = await db.decryptedRumors + .where("decryptedBy") + .equals(pubkey) + .toArray(); + + return entries.map((e) => e.rumor); +} + +/** + * Get a specific decrypted rumor by gift wrap ID + */ +export async function getDecryptedRumor( + giftWrapId: string, + pubkey: string, +): Promise { + const entry = await db.decryptedRumors.get(giftWrapId); + if (entry && entry.decryptedBy === pubkey) { + return entry.rumor; + } + return null; +} + +/** + * Check if a gift wrap has already been decrypted + */ +export async function isGiftWrapDecrypted( + giftWrapId: string, + pubkey: string, +): Promise { + const entry = await db.decryptedRumors.get(giftWrapId); + return entry !== null && entry !== undefined && entry.decryptedBy === pubkey; +} + +/** + * Store a decrypted rumor directly (for manual decryption flows) + */ +export async function storeDecryptedRumor( + giftWrapId: string, + rumor: Rumor, + decryptedBy: string, +): Promise { + const entry: DecryptedRumor = { + giftWrapId, + rumor, + decryptedBy, + decryptedAt: Date.now(), + }; + await db.decryptedRumors.put(entry); +} + +/** + * Get count of decrypted rumors for a user + */ +export async function getDecryptedRumorCount(pubkey: string): Promise { + return db.decryptedRumors.where("decryptedBy").equals(pubkey).count(); +} + +/** + * Clear all decrypted rumors for a user + * Useful for "forget me" functionality + */ +export async function clearDecryptedRumors(pubkey: string): Promise { + return db.decryptedRumors.where("decryptedBy").equals(pubkey).delete(); +} + +/** + * Observable storage for applesauce's persistEncryptedContent + * Returns an observable that emits the storage when pubkey is set + */ +export function getRumorStorage$(): Observable { + return new BehaviorSubject(rumorStorage); +} diff --git a/src/types/app.ts b/src/types/app.ts index 6c42a94..014dc46 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -17,6 +17,7 @@ export type AppId = | "debug" | "conn" | "chat" + | "inbox" | "spells" | "spellbooks" | "blossom" diff --git a/src/types/man.ts b/src/types/man.ts index 69f47d2..f274e93 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -346,6 +346,18 @@ export const manPages: Record = { return parsed; }, }, + inbox: { + name: "inbox", + section: "1", + synopsis: "inbox", + description: + "View your encrypted private messages (NIP-17 gift-wrapped DMs). Messages are encrypted using NIP-44 and wrapped in NIP-59 gift wraps for privacy. Decrypted messages are cached locally so you only decrypt once. Click 'Decrypt' to unlock pending messages.", + examples: ["inbox Open your private message inbox"], + seeAlso: ["chat", "profile"], + appId: "inbox", + category: "Nostr", + defaultProps: {}, + }, chat: { name: "chat", section: "1",