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",