mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
Refactor NIP-17 adapter to use applesauce helpers properly
- Fix encrypted content storage to use event.id as key (matching applesauce) - Replace custom rumor storage with simple key-value encrypted content cache - Simplify NIP-17 adapter to use isGiftWrapUnlocked/getGiftWrapRumor helpers - Implement sendMessage using applesauce's SendWrappedMessage action - Remove redundant getPrivateInboxRelays (handled by applesauce action) - Clean up unused imports and constants
This commit is contained in:
@@ -13,10 +13,10 @@
|
||||
*
|
||||
* Caching:
|
||||
* - Gift wraps are cached to Dexie events table
|
||||
* - Decrypted rumors are cached to avoid re-decryption
|
||||
* - Decrypted content persisted via applesauce's persistEncryptedContent
|
||||
*/
|
||||
import { Observable, firstValueFrom, BehaviorSubject } from "rxjs";
|
||||
import { map, first } from "rxjs/operators";
|
||||
import { map, first, distinctUntilChanged } from "rxjs/operators";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import type { Filter } from "nostr-tools";
|
||||
import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter";
|
||||
@@ -31,28 +31,26 @@ import type { NostrEvent } from "@/types/nostr";
|
||||
import eventStore from "@/services/event-store";
|
||||
import pool from "@/services/relay-pool";
|
||||
import accountManager from "@/services/accounts";
|
||||
import { hub } from "@/services/hub";
|
||||
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,
|
||||
isGiftWrapUnlocked,
|
||||
getGiftWrapRumor,
|
||||
getConversationParticipants,
|
||||
getConversationIdentifierFromMessage,
|
||||
type Rumor,
|
||||
} from "applesauce-common/helpers";
|
||||
import {
|
||||
getDecryptedRumors,
|
||||
isGiftWrapDecrypted,
|
||||
storeDecryptedRumor,
|
||||
} from "@/services/rumor-storage";
|
||||
import { SendWrappedMessage } from "applesauce-actions/actions";
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -61,11 +59,11 @@ 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<Rumor[]>([]);
|
||||
|
||||
/** Track pending (undecrypted) gift wrap IDs */
|
||||
private pendingGiftWraps$ = new BehaviorSubject<string[]>([]);
|
||||
private pendingGiftWraps$ = new BehaviorSubject<Set<string>>(new Set());
|
||||
|
||||
/** Observable of gift wrap events from event store */
|
||||
private giftWraps$ = new BehaviorSubject<NostrEvent[]>([]);
|
||||
|
||||
/**
|
||||
* Parse identifier - accepts npub, nprofile, hex pubkey, or NIP-05
|
||||
@@ -193,35 +191,50 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
// 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 rumors from unlocked gift wraps and filter to this conversation
|
||||
return this.giftWraps$.pipe(
|
||||
map((giftWraps) => {
|
||||
const messages: Message[] = [];
|
||||
|
||||
// Get participants from rumor
|
||||
const rumorParticipants = getConversationParticipants(rumor).sort();
|
||||
for (const gift of giftWraps) {
|
||||
// Skip locked gift wraps
|
||||
if (!isGiftWrapUnlocked(gift)) continue;
|
||||
|
||||
// Check if participants match
|
||||
return (
|
||||
rumorParticipants.length === expectedParticipants.length &&
|
||||
rumorParticipants.every((p, i) => p === expectedParticipants[i])
|
||||
);
|
||||
});
|
||||
try {
|
||||
const rumor = getGiftWrapRumor(gift);
|
||||
|
||||
// Convert to messages and sort by timestamp
|
||||
return conversationRumors
|
||||
.map((rumor) => this.rumorToMessage(rumor, conversation.id))
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
// Only kind 14 DM rumors
|
||||
if (rumor.kind !== DM_RUMOR_KIND) continue;
|
||||
|
||||
// Get participants from rumor
|
||||
const rumorParticipants = getConversationParticipants(rumor).sort();
|
||||
|
||||
// Check if participants match this conversation
|
||||
if (
|
||||
rumorParticipants.length !== expectedParticipants.length ||
|
||||
!rumorParticipants.every((p, i) => p === expectedParticipants[i])
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
messages.push(this.rumorToMessage(rumor, conversation.id));
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[NIP-17] Failed to get rumor from gift wrap ${gift.id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp
|
||||
return messages.sort((a, b) => a.timestamp - b.timestamp);
|
||||
}),
|
||||
distinctUntilChanged(
|
||||
(a, b) => a.length === b.length && a.every((m, i) => m.id === b[i].id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -232,18 +245,23 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
_conversation: Conversation,
|
||||
_before: number,
|
||||
): Promise<Message[]> {
|
||||
// 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
|
||||
*
|
||||
* Uses applesauce's SendWrappedMessage action which:
|
||||
* 1. Creates kind 14 rumor with message content
|
||||
* 2. Wraps in seal (kind 13) encrypted to each participant
|
||||
* 3. Wraps seal in gift wrap (kind 1059) with ephemeral key
|
||||
* 4. Publishes to each participant's private inbox relays (kind 10050)
|
||||
*/
|
||||
async sendMessage(
|
||||
conversation: Conversation,
|
||||
content: string,
|
||||
options?: SendMessageOptions,
|
||||
_options?: SendMessageOptions,
|
||||
): Promise<void> {
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
const activeSigner = accountManager.active$.value?.signer;
|
||||
@@ -259,31 +277,15 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
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"]);
|
||||
}
|
||||
// Use applesauce's SendWrappedMessage action
|
||||
// This handles:
|
||||
// - Creating the wrapped message rumor
|
||||
// - Gift wrapping for all participants (partner + self)
|
||||
// - Publishing to each participant's inbox relays
|
||||
await hub.run(SendWrappedMessage, partner.pubkey, content);
|
||||
|
||||
// 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",
|
||||
console.log(
|
||||
`[NIP-17] Sent wrapped message to ${partner.pubkey.slice(0, 8)}...`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -309,16 +311,24 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
_conversation: Conversation,
|
||||
eventId: string,
|
||||
): Promise<NostrEvent | null> {
|
||||
// Check if we have a cached rumor with this ID
|
||||
const rumors = this.rumors$.value;
|
||||
const rumor = rumors.find((r) => r.id === eventId);
|
||||
// Check if we have an unlocked gift wrap with a rumor matching this ID
|
||||
const giftWraps = this.giftWraps$.value;
|
||||
|
||||
if (rumor) {
|
||||
// Convert rumor to a pseudo-event for display
|
||||
return {
|
||||
...rumor,
|
||||
sig: "", // Rumors are unsigned
|
||||
} as NostrEvent;
|
||||
for (const gift of giftWraps) {
|
||||
if (!isGiftWrapUnlocked(gift)) continue;
|
||||
|
||||
try {
|
||||
const rumor = getGiftWrapRumor(gift);
|
||||
if (rumor.id === eventId) {
|
||||
// Return as pseudo-event
|
||||
return {
|
||||
...rumor,
|
||||
sig: "",
|
||||
} as NostrEvent;
|
||||
}
|
||||
} catch {
|
||||
// Skip
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -328,14 +338,14 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
* Get count of pending (undecrypted) gift wraps
|
||||
*/
|
||||
getPendingCount(): number {
|
||||
return this.pendingGiftWraps$.value.length;
|
||||
return this.pendingGiftWraps$.value.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get observable of pending gift wrap count
|
||||
*/
|
||||
getPendingCount$(): Observable<number> {
|
||||
return this.pendingGiftWraps$.pipe(map((ids) => ids.length));
|
||||
return this.pendingGiftWraps$.pipe(map((set) => set.size));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -349,7 +359,7 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
throw new Error("No active account");
|
||||
}
|
||||
|
||||
const pendingIds = this.pendingGiftWraps$.value;
|
||||
const pendingIds = Array.from(this.pendingGiftWraps$.value);
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
|
||||
@@ -365,23 +375,28 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
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]);
|
||||
}
|
||||
|
||||
// Already unlocked?
|
||||
if (isGiftWrapUnlocked(giftWrap)) {
|
||||
// Remove from pending
|
||||
const pending = new Set(this.pendingGiftWraps$.value);
|
||||
pending.delete(giftWrapId);
|
||||
this.pendingGiftWraps$.next(pending);
|
||||
success++;
|
||||
} else {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Decrypt using signer - applesauce handles caching automatically
|
||||
await unlockGiftWrap(giftWrap, signer);
|
||||
|
||||
// Remove from pending
|
||||
const pending = new Set(this.pendingGiftWraps$.value);
|
||||
pending.delete(giftWrapId);
|
||||
this.pendingGiftWraps$.next(pending);
|
||||
|
||||
// Refresh gift wraps list
|
||||
this.giftWraps$.next([...this.giftWraps$.value]);
|
||||
|
||||
success++;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[NIP-17] Failed to decrypt gift wrap ${giftWrapId}:`,
|
||||
@@ -391,12 +406,6 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 };
|
||||
}
|
||||
|
||||
@@ -409,23 +418,30 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
return new BehaviorSubject([]);
|
||||
}
|
||||
|
||||
return this.rumors$.pipe(
|
||||
map((rumors) => {
|
||||
return this.giftWraps$.pipe(
|
||||
map((giftWraps) => {
|
||||
// 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;
|
||||
for (const gift of giftWraps) {
|
||||
if (!isGiftWrapUnlocked(gift)) continue;
|
||||
|
||||
const convId = getConversationIdentifierFromMessage(rumor);
|
||||
const participants = getConversationParticipants(rumor);
|
||||
try {
|
||||
const rumor = getGiftWrapRumor(gift);
|
||||
if (rumor.kind !== DM_RUMOR_KIND) continue;
|
||||
|
||||
const existing = conversationMap.get(convId);
|
||||
if (!existing || rumor.created_at > existing.lastRumor.created_at) {
|
||||
conversationMap.set(convId, { participants, lastRumor: rumor });
|
||||
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 });
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid gift wraps
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,14 +480,6 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
|
||||
// ==================== Private Methods ====================
|
||||
|
||||
/**
|
||||
* Load cached rumors from Dexie
|
||||
*/
|
||||
private async loadCachedRumors(pubkey: string): Promise<void> {
|
||||
const rumors = await getDecryptedRumors(pubkey);
|
||||
this.rumors$.next(rumors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to gift wraps for the user
|
||||
*/
|
||||
@@ -490,7 +498,7 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
const subscription = pool
|
||||
.subscription([], [filter], { eventStore })
|
||||
.subscribe({
|
||||
next: async (response) => {
|
||||
next: (response) => {
|
||||
if (typeof response === "string") {
|
||||
// EOSE
|
||||
console.log("[NIP-17] EOSE received for gift wraps");
|
||||
@@ -500,15 +508,17 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
`[NIP-17] Received gift wrap: ${response.id.slice(0, 8)}...`,
|
||||
);
|
||||
|
||||
// Check if already decrypted
|
||||
const isDecrypted = await isGiftWrapDecrypted(response.id, pubkey);
|
||||
// Add to gift wraps list
|
||||
const current = this.giftWraps$.value;
|
||||
if (!current.find((g) => g.id === response.id)) {
|
||||
this.giftWraps$.next([...current, response]);
|
||||
}
|
||||
|
||||
if (!isDecrypted) {
|
||||
// Add to pending list
|
||||
const pending = this.pendingGiftWraps$.value;
|
||||
if (!pending.includes(response.id)) {
|
||||
this.pendingGiftWraps$.next([...pending, response.id]);
|
||||
}
|
||||
// Check if unlocked (cached) or pending
|
||||
if (!isGiftWrapUnlocked(response)) {
|
||||
const pending = new Set(this.pendingGiftWraps$.value);
|
||||
pending.add(response.id);
|
||||
this.pendingGiftWraps$.next(pending);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -517,61 +527,6 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
this.subscriptions.set(conversationId, subscription);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get private inbox relays for a user (kind 10050)
|
||||
*/
|
||||
private async getPrivateInboxRelays(pubkey: string): Promise<string[]> {
|
||||
// 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<void>((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
|
||||
*/
|
||||
|
||||
@@ -90,25 +90,17 @@ export interface CachedEvent {
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypted rumor from gift wrap (NIP-59)
|
||||
* Stored separately so we don't have to re-decrypt
|
||||
* Encrypted content cache for gift wraps and seals (NIP-59)
|
||||
* Stores decrypted content strings so we don't have to re-decrypt.
|
||||
* This matches applesauce's persistEncryptedContent expectations.
|
||||
*/
|
||||
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;
|
||||
export interface EncryptedContentEntry {
|
||||
/** Event ID (gift wrap or seal) */
|
||||
id: string;
|
||||
/** The decrypted content string */
|
||||
content: string;
|
||||
/** When it was cached */
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
class GrimoireDb extends Dexie {
|
||||
@@ -122,7 +114,7 @@ class GrimoireDb extends Dexie {
|
||||
spells!: Table<LocalSpell>;
|
||||
spellbooks!: Table<LocalSpellbook>;
|
||||
events!: Table<CachedEvent>;
|
||||
decryptedRumors!: Table<DecryptedRumor>;
|
||||
encryptedContent!: Table<EncryptedContentEntry>;
|
||||
|
||||
constructor(name: string) {
|
||||
super(name);
|
||||
@@ -345,7 +337,7 @@ class GrimoireDb extends Dexie {
|
||||
spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt",
|
||||
});
|
||||
|
||||
// Version 15: Add event cache and decrypted rumor storage for NIP-59 gift wraps
|
||||
// Version 15: Add event cache and encrypted content storage for NIP-59 gift wraps
|
||||
this.version(15).stores({
|
||||
profiles: "&pubkey",
|
||||
nip05: "&nip05",
|
||||
@@ -357,7 +349,7 @@ class GrimoireDb extends Dexie {
|
||||
spells: "&id, alias, createdAt, isPublished, deletedAt",
|
||||
spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt",
|
||||
events: "&id, cachedAt",
|
||||
decryptedRumors: "&giftWrapId, decryptedBy",
|
||||
encryptedContent: "&id",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@ 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";
|
||||
import { encryptedContentStorage } from "./rumor-storage";
|
||||
|
||||
const eventStore = new EventStore();
|
||||
|
||||
@@ -13,11 +11,7 @@ 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);
|
||||
});
|
||||
// The storage handles both gift wraps (decrypted seal) and seals (decrypted rumor)
|
||||
persistEncryptedContent(eventStore, encryptedContentStorage);
|
||||
|
||||
export default eventStore;
|
||||
|
||||
@@ -1,165 +1,53 @@
|
||||
/**
|
||||
* Rumor storage service for caching decrypted gift wrap content
|
||||
* Encrypted content storage for gift wraps and seals (NIP-59)
|
||||
*
|
||||
* 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).
|
||||
* This implements the EncryptedContentCache interface expected by
|
||||
* applesauce's persistEncryptedContent helper.
|
||||
*
|
||||
* Storage format matches applesauce's persistEncryptedContent expectations:
|
||||
* - Key: `rumor:${giftWrapId}`
|
||||
* - Value: The decrypted rumor object
|
||||
* Storage format:
|
||||
* - Key: event.id (the gift wrap or seal event ID)
|
||||
* - Value: decrypted content string (the JSON string from decryption)
|
||||
*/
|
||||
import type { Rumor } from "applesauce-common/helpers";
|
||||
import db, { type DecryptedRumor } from "./db";
|
||||
import { BehaviorSubject, type Observable } from "rxjs";
|
||||
import db from "./db";
|
||||
import type { EncryptedContentCache } from "applesauce-common/helpers";
|
||||
|
||||
/**
|
||||
* Current user pubkey for multi-account support
|
||||
* Set this when account changes
|
||||
* Dexie-backed encrypted content storage
|
||||
* Implements applesauce's EncryptedContentCache interface
|
||||
*/
|
||||
const currentPubkey$ = new BehaviorSubject<string | null>(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 = {
|
||||
export const encryptedContentStorage: EncryptedContentCache = {
|
||||
async getItem(key: string): Promise<string | null> {
|
||||
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;
|
||||
const entry = await db.encryptedContent.get(key);
|
||||
return entry?.content ?? null;
|
||||
},
|
||||
|
||||
async setItem(key: string, value: string): Promise<void> {
|
||||
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<void> {
|
||||
const match = key.match(/^(rumor|seal):(.+)$/);
|
||||
if (!match) return;
|
||||
|
||||
const [, , giftWrapId] = match;
|
||||
await db.decryptedRumors.delete(giftWrapId);
|
||||
await db.encryptedContent.put({
|
||||
id: key,
|
||||
content: value,
|
||||
cachedAt: Date.now(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all decrypted rumors for the current user
|
||||
* Check if we have cached encrypted content for an event
|
||||
*/
|
||||
export async function getDecryptedRumors(pubkey: string): Promise<Rumor[]> {
|
||||
const entries = await db.decryptedRumors
|
||||
.where("decryptedBy")
|
||||
.equals(pubkey)
|
||||
.toArray();
|
||||
|
||||
return entries.map((e) => e.rumor);
|
||||
export async function hasEncryptedContent(eventId: string): Promise<boolean> {
|
||||
const count = await db.encryptedContent.where("id").equals(eventId).count();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific decrypted rumor by gift wrap ID
|
||||
* Get count of cached encrypted content entries
|
||||
*/
|
||||
export async function getDecryptedRumor(
|
||||
giftWrapId: string,
|
||||
pubkey: string,
|
||||
): Promise<Rumor | null> {
|
||||
const entry = await db.decryptedRumors.get(giftWrapId);
|
||||
if (entry && entry.decryptedBy === pubkey) {
|
||||
return entry.rumor;
|
||||
}
|
||||
return null;
|
||||
export async function getEncryptedContentCount(): Promise<number> {
|
||||
return db.encryptedContent.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a gift wrap has already been decrypted
|
||||
* Clear all cached encrypted content
|
||||
*/
|
||||
export async function isGiftWrapDecrypted(
|
||||
giftWrapId: string,
|
||||
pubkey: string,
|
||||
): Promise<boolean> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<typeof rumorStorage | null> {
|
||||
return new BehaviorSubject(rumorStorage);
|
||||
export async function clearEncryptedContent(): Promise<void> {
|
||||
await db.encryptedContent.clear();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user