diff --git a/src/components/InboxViewer.tsx b/src/components/InboxViewer.tsx new file mode 100644 index 0000000..91df48e --- /dev/null +++ b/src/components/InboxViewer.tsx @@ -0,0 +1,327 @@ +import { useEffect, useState } from "react"; +import { use$ } from "applesauce-react/hooks"; +import { + Package, + Loader2, + AlertCircle, + CheckCircle2, + Trash2, +} from "lucide-react"; +import giftWrapManager from "@/services/gift-wrap"; +import accountManager from "@/services/accounts"; +import db, { DecryptedGiftWrap } from "@/services/db"; +import { getInboxes } from "applesauce-core/helpers"; + +interface InboxViewerProps { + action?: "decrypt-pending" | "clear-failed" | null; +} + +export default function InboxViewer({ action }: InboxViewerProps) { + const account = use$(accountManager.active$); + const syncState = use$(giftWrapManager.state); + const [decrypted, setDecrypted] = useState([]); + const [page, setPage] = useState(0); + const [loading, setLoading] = useState(false); + const [decrypting, setDecrypting] = useState(false); + + const pubkey = account?.pubkey; + + // Load decrypted gift wraps from Dexie + useEffect(() => { + if (!pubkey) return; + + db.decryptedGiftWraps + .orderBy("receivedAt") + .reverse() + .offset(page * 50) + .limit(50) + .toArray() + .then(setDecrypted); + }, [pubkey, page, syncState.decryptedCount]); + + // Initial sync on mount + useEffect(() => { + if (!pubkey) return; + + const syncGiftWraps = async () => { + setLoading(true); + try { + // Get inbox relays from user's relay list + const inboxRelays = Array.from( + await getInboxes(pubkey).then((set) => set || new Set()), + ); + + // Fallback to default relays if no inbox relays + const relays = + inboxRelays.length > 0 + ? inboxRelays + : [ + "wss://relay.damus.io", + "wss://nos.lol", + "wss://relay.nostr.band", + ]; + + await giftWrapManager.syncAll(pubkey, relays); + + // Subscribe to new gift wraps + giftWrapManager.subscribeToNew(pubkey, relays); + } catch (error) { + console.error("[InboxViewer] Sync error:", error); + } finally { + setLoading(false); + } + }; + + syncGiftWraps(); + + return () => { + giftWrapManager.unsubscribe(pubkey); + }; + }, [pubkey]); + + // Handle action flags + useEffect(() => { + if (!action || !pubkey || !account) return; + + const handleAction = async () => { + if (action === "decrypt-pending") { + await handleDecryptAll(); + } else if (action === "clear-failed") { + await giftWrapManager.clearErrors(); + await giftWrapManager.updateCounts(pubkey); + } + }; + + handleAction(); + }, [action, pubkey, account]); + + const handleDecryptAll = async () => { + if (!pubkey || !account) return; + + setDecrypting(true); + try { + // Get pending gift wrap IDs + const pending = await new Promise((resolve) => { + giftWrapManager.getPendingCount(pubkey).subscribe((set) => { + // Get IDs from EventStore + const ids = Array.from(set as any).map((e: any) => e.id); + resolve(ids); + }); + }); + + if (pending.length === 0) { + console.log("[InboxViewer] No pending gift wraps to decrypt"); + setDecrypting(false); + return; + } + + console.log(`[InboxViewer] Decrypting ${pending.length} gift wraps...`); + + // Decrypt batch + for await (const result of giftWrapManager.decryptBatch( + pending, + account, + )) { + if (result.status === "success") { + // Refresh decrypted list + const updated = await db.decryptedGiftWraps + .orderBy("receivedAt") + .reverse() + .offset(page * 50) + .limit(50) + .toArray(); + setDecrypted(updated); + } + } + + // Update counts + await giftWrapManager.updateCounts(pubkey); + console.log("[InboxViewer] Batch decrypt complete"); + } catch (error) { + console.error("[InboxViewer] Decrypt error:", error); + } finally { + setDecrypting(false); + } + }; + + const handleClearAll = async () => { + if (!confirm("Clear all decrypted gift wraps? This cannot be undone.")) { + return; + } + + await giftWrapManager.clearDecrypted(); + setDecrypted([]); + + if (pubkey) { + await giftWrapManager.updateCounts(pubkey); + } + }; + + if (!account || !pubkey) { + return ( +
+
+ +

No active account. Please login to view gift wraps.

+
+
+ ); + } + + return ( +
+ {/* Header with status */} +
+
+

+ + Gift Wrap Inbox +

+ + {loading && ( +
+ + Syncing... +
+ )} +
+ + {/* Stats */} +
+
+ + {syncState.pendingCount} + Pending +
+ +
+ + {syncState.decryptedCount} + Decrypted +
+ + {syncState.failedCount > 0 && ( +
+ + {syncState.failedCount} + Failed +
+ )} +
+ + {/* Actions */} +
+ + + {syncState.failedCount > 0 && ( + + )} + + {syncState.decryptedCount > 0 && ( + + )} +
+
+ + {/* Decrypted gift wraps list */} +
+ {decrypted.length === 0 ? ( +
+
+ +

No decrypted gift wraps yet.

+ {syncState.pendingCount > 0 && ( + + )} +
+
+ ) : ( +
+ {decrypted.map((wrap) => ( +
+
+ +
+
+ + Kind {wrap.rumor.kind} + + {wrap.sealPubkey && ( + + from {wrap.sealPubkey.slice(0, 8)}... + + )} +
+

+ {wrap.rumor.content.slice(0, 200)} + {wrap.rumor.content.length > 200 && "..."} +

+
+ + Received:{" "} + {new Date(wrap.receivedAt * 1000).toLocaleString()} + + + Decrypted:{" "} + {new Date(wrap.decryptedAt * 1000).toLocaleString()} + +
+
+
+
+ ))} +
+ )} + + {/* Load more button */} + {decrypted.length >= 50 && ( +
+ +
+ )} +
+ + {/* Footer with sync info */} + {syncState.lastSyncAt > 0 && ( +
+ Last synced: {new Date(syncState.lastSyncAt).toLocaleString()} +
+ )} +
+ ); +} diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index 84d0ffa..c4424c0 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -33,6 +33,9 @@ const ChatViewer = lazy(() => const GroupListViewer = lazy(() => import("./GroupListViewer").then((m) => ({ default: m.GroupListViewer })), ); +const InboxViewer = lazy(() => + import("./InboxViewer").then((m) => ({ default: m.default })), +); const SpellsViewer = lazy(() => import("./SpellsViewer").then((m) => ({ default: m.SpellsViewer })), ); @@ -192,6 +195,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) { ); } break; + case "inbox": + content = ; + break; case "spells": content = ; break; diff --git a/src/lib/command-reconstructor.ts b/src/lib/command-reconstructor.ts index 627c8d3..444953f 100644 --- a/src/lib/command-reconstructor.ts +++ b/src/lib/command-reconstructor.ts @@ -139,6 +139,19 @@ export function reconstructCommand(window: WindowInstance): string { return "chat"; } + case "inbox": { + // Reconstruct inbox command with action flags + const { action } = props; + + if (action === "decrypt-pending") { + return "inbox --decrypt-pending"; + } else if (action === "clear-failed") { + return "inbox --clear-failed"; + } + + return "inbox"; + } + default: return appId; // Fallback to just the command name } diff --git a/src/services/db.ts b/src/services/db.ts index 6e9ee79..1d07bbc 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -87,6 +87,22 @@ export interface LocalSpellbook { deletedAt?: number; } +export interface DecryptedGiftWrap { + giftWrapId: string; // kind 1059 event ID (primary key) + rumorId: string; // Unwrapped rumor ID + rumor: NostrEvent; // Actual unsigned event (JSON) + sealPubkey: string; // Who sent it (from seal) + decryptedAt: number; // When we decrypted + receivedAt: number; // Gift wrap created_at (for sorting) +} + +export interface GiftWrapDecryptionError { + giftWrapId: string; // kind 1059 event ID (primary key) + attemptCount: number; // Number of failed decrypt attempts + lastAttempt: number; // Timestamp of last attempt + errorMessage: string; // Error message from last attempt +} + class GrimoireDb extends Dexie { profiles!: Table; nip05!: Table; @@ -98,6 +114,8 @@ class GrimoireDb extends Dexie { blossomServers!: Table; spells!: Table; spellbooks!: Table; + decryptedGiftWraps!: Table; + giftWrapErrors!: Table; constructor(name: string) { super(name); @@ -333,6 +351,22 @@ class GrimoireDb extends Dexie { spells: "&id, alias, createdAt, isPublished, deletedAt", spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt", }); + + // Version 16: Add gift wrap storage + this.version(16).stores({ + profiles: "&pubkey", + nip05: "&nip05", + nips: "&id", + relayInfo: "&url", + relayAuthPreferences: "&url", + relayLists: "&pubkey, updatedAt", + relayLiveness: "&url", + blossomServers: "&pubkey, updatedAt", + spells: "&id, alias, createdAt, isPublished, deletedAt", + spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt", + decryptedGiftWraps: "&giftWrapId, sealPubkey, receivedAt, decryptedAt", + giftWrapErrors: "&giftWrapId, lastAttempt", + }); } } diff --git a/src/services/gift-wrap.ts b/src/services/gift-wrap.ts new file mode 100644 index 0000000..c9b7875 --- /dev/null +++ b/src/services/gift-wrap.ts @@ -0,0 +1,313 @@ +import { BehaviorSubject, map, Subscription } from "rxjs"; +import { createTimelineLoader } from "applesauce-loaders/loaders"; +import { onlyEvents, mapEventsToStore } from "applesauce-core"; +import { unlockGiftWrap, getGiftWrapSeal } from "applesauce-common/helpers"; +import { GiftWrapsModel } from "applesauce-common/models"; +import type { Signer } from "applesauce-signers"; +import type { NostrEvent } from "@/types/nostr"; +import eventStore from "./event-store"; +import pool from "./relay-pool"; +import db from "./db"; +import { getEventsForFilters } from "nostr-idb"; + +/** + * Gift wrap sync state + */ +export interface GiftWrapSyncState { + syncing: boolean; + pendingCount: number; + decryptedCount: number; + failedCount: number; + totalCount: number; + lastSyncAt: number; +} + +/** + * Manager for gift wrap syncing and decryption + * + * Simple strategy: + * 1. Load all gift wraps using paginated timeline loader + * 2. Subscribe to new gift wraps in real-time + * 3. Decrypt on demand and cache in Dexie + * 4. Track counts and state + */ +class GiftWrapManager { + private state$ = new BehaviorSubject({ + syncing: false, + pendingCount: 0, + decryptedCount: 0, + failedCount: 0, + totalCount: 0, + lastSyncAt: 0, + }); + + private subscriptions = new Map(); + + /** + * Get observable of gift wrap sync state + */ + get state() { + return this.state$.asObservable(); + } + + /** + * Sync all gift wraps for a pubkey from relays + * Loads all pages until timeline is exhausted + */ + async syncAll(pubkey: string, relays: string[]): Promise { + if (this.state$.value.syncing) { + console.log("[GiftWrap] Already syncing, skipping..."); + return; + } + + this.updateState({ syncing: true }); + console.log( + `[GiftWrap] Starting sync for ${pubkey} on ${relays.length} relays`, + ); + + try { + // Create timeline loader with cache fallback + const timeline = createTimelineLoader( + pool, + relays, + { kinds: [1059], "#p": [pubkey], limit: 100 }, + { + eventStore, + cache: (filters) => getEventsForFilters(await db.open(), filters), + }, + ); + + // Load pages until no more events + let page = 0; + let hasMore = true; + + while (hasMore) { + const result = await new Promise((resolve) => { + const events: NostrEvent[] = []; + timeline().subscribe({ + next: (event) => events.push(event), + complete: () => resolve(events), + error: (err) => { + console.error("[GiftWrap] Timeline error:", err); + resolve(events); + }, + }); + }); + + page++; + hasMore = result.length > 0; + + if (hasMore) { + console.log( + `[GiftWrap] Loaded page ${page}: ${result.length} events`, + ); + } + } + + console.log(`[GiftWrap] Sync complete: loaded ${page} pages`); + await this.updateCounts(pubkey); + this.updateState({ lastSyncAt: Date.now() }); + } catch (error) { + console.error("[GiftWrap] Sync error:", error); + } finally { + this.updateState({ syncing: false }); + } + } + + /** + * Subscribe to new gift wraps in real-time + */ + subscribeToNew(pubkey: string, relays: string[]): void { + const key = pubkey; + + // Cleanup existing subscription + this.subscriptions.get(key)?.unsubscribe(); + + console.log(`[GiftWrap] Subscribing to new gift wraps for ${pubkey}`); + + // Subscribe to each relay + const subs = relays.map((relay) => + pool + .relay(relay) + .subscription({ + kinds: [1059], + "#p": [pubkey], + since: Math.floor(Date.now() / 1000), + }) + .pipe(onlyEvents(), mapEventsToStore(eventStore)) + .subscribe({ + next: (event) => { + console.log("[GiftWrap] New gift wrap received:", event.id); + this.updateCounts(pubkey); + }, + error: (err) => console.error("[GiftWrap] Subscription error:", err), + }), + ); + + // Store combined subscription + const combined = new Subscription(); + subs.forEach((sub) => combined.add(sub)); + this.subscriptions.set(key, combined); + } + + /** + * Unsubscribe from gift wrap updates + */ + unsubscribe(pubkey: string): void { + this.subscriptions.get(pubkey)?.unsubscribe(); + this.subscriptions.delete(pubkey); + } + + /** + * Update counts from EventStore and Dexie + */ + async updateCounts(pubkey: string): Promise { + // Get pending count from applesauce model + const pending = await new Promise((resolve) => { + eventStore + .model(GiftWrapsModel, pubkey, true) + .pipe(map((set) => set.size)) + .subscribe(resolve); + }); + + // Get decrypted count from Dexie + const decrypted = await db.decryptedGiftWraps.count(); + + // Get failed count from Dexie + const failed = await db.giftWrapErrors.count(); + + // Total is pending + decrypted + const total = pending + decrypted; + + this.updateState({ + pendingCount: pending, + decryptedCount: decrypted, + failedCount: failed, + totalCount: total, + }); + } + + /** + * Get observable of pending gift wrap count + */ + getPendingCount(pubkey: string) { + return eventStore + .model(GiftWrapsModel, pubkey, true) + .pipe(map((set) => set.size)); + } + + /** + * Decrypt a single gift wrap + * Returns cached result if already decrypted + */ + async decryptOne(giftWrapId: string, signer: Signer): Promise { + // Check cache first + const cached = await db.decryptedGiftWraps.get(giftWrapId); + if (cached) { + console.log("[GiftWrap] Using cached decryption:", giftWrapId); + return cached.rumor; + } + + // Check if previously failed + const error = await db.giftWrapErrors.get(giftWrapId); + if (error && error.attemptCount >= 3) { + throw new Error( + `Max decrypt attempts exceeded (${error.attemptCount}): ${error.errorMessage}`, + ); + } + + // Get gift wrap from EventStore + const gift = eventStore.event(giftWrapId); + if (!gift) { + throw new Error(`Gift wrap not found: ${giftWrapId}`); + } + + try { + console.log("[GiftWrap] Decrypting:", giftWrapId); + const rumor = await unlockGiftWrap(gift, signer); + + // Cache decrypted rumor + await db.decryptedGiftWraps.add({ + giftWrapId: gift.id, + rumorId: rumor.id, + rumor, + sealPubkey: getGiftWrapSeal(gift)?.pubkey || "", + decryptedAt: Math.floor(Date.now() / 1000), + receivedAt: gift.created_at, + }); + + console.log("[GiftWrap] Decrypted successfully:", giftWrapId); + return rumor; + } catch (err) { + const errorMessage = String(err); + console.error("[GiftWrap] Decryption failed:", giftWrapId, errorMessage); + + // Track error + await db.giftWrapErrors.put({ + giftWrapId, + attemptCount: (error?.attemptCount || 0) + 1, + lastAttempt: Math.floor(Date.now() / 1000), + errorMessage, + }); + + throw err; + } + } + + /** + * Batch decrypt gift wraps with progress tracking + */ + async *decryptBatch( + giftWrapIds: string[], + signer: Signer, + ): AsyncGenerator<{ + id: string; + status: "success" | "error"; + rumor?: NostrEvent; + error?: string; + }> { + console.log(`[GiftWrap] Batch decrypting ${giftWrapIds.length} gift wraps`); + + for (const id of giftWrapIds) { + try { + const rumor = await this.decryptOne(id, signer); + yield { id, status: "success", rumor }; + } catch (err) { + yield { id, status: "error", error: String(err) }; + } + + // Small delay to avoid blocking UI + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + console.log("[GiftWrap] Batch decrypt complete"); + } + + /** + * Clear all failed decryption errors + */ + async clearErrors(): Promise { + await db.giftWrapErrors.clear(); + console.log("[GiftWrap] Cleared all decryption errors"); + } + + /** + * Clear all decrypted gift wraps from cache + */ + async clearDecrypted(): Promise { + await db.decryptedGiftWraps.clear(); + await db.giftWrapErrors.clear(); + console.log("[GiftWrap] Cleared all decrypted gift wraps"); + } + + /** + * Update state (partial update) + */ + private updateState(partial: Partial): void { + this.state$.next({ ...this.state$.value, ...partial }); + } +} + +// Export singleton instance +const giftWrapManager = new GiftWrapManager(); +export default giftWrapManager; diff --git a/src/types/app.ts b/src/types/app.ts index ac99533..2ec2453 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..368b952 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -351,21 +351,22 @@ export const manPages: Record = { section: "1", synopsis: "chat ", description: - "Join and participate in Nostr chat conversations. Supports NIP-29 relay-based groups, NIP-53 live activity chat, and multi-room group list interface. For NIP-29 groups, use format 'relay'group-id' where relay is the WebSocket URL (wss:// prefix optional). For NIP-53 live activities, pass the naddr of a kind 30311 live event. For multi-room interface, pass the naddr of a kind 10009 group list event.", + "Join and participate in Nostr chat conversations. Supports NIP-17 private DMs, NIP-29 relay-based groups, NIP-53 live activity chat, and multi-room group list interface. For private DMs, provide npub/nprofile/hex pubkey. For NIP-29 groups, use format 'relay'group-id' where relay is the WebSocket URL (wss:// prefix optional). For NIP-53 live activities, pass the naddr of a kind 30311 live event. For multi-room interface, pass the naddr of a kind 10009 group list event.", options: [ { flag: "", description: - "NIP-29 group (relay'group-id), NIP-53 live activity (naddr1... kind 30311), or group list (naddr1... kind 10009)", + "npub/nprofile/hex (NIP-17), NIP-29 group (relay'group-id), NIP-53 live activity (naddr1... kind 30311), or group list (naddr1... kind 10009)", }, ], examples: [ + "chat npub1... Open NIP-17 private DM", "chat relay.example.com'bitcoin-dev Join NIP-29 relay group", "chat wss://nos.lol'welcome Join NIP-29 group with explicit protocol", "chat naddr1...30311... Join NIP-53 live activity chat", "chat naddr1...10009... Open multi-room group list interface", ], - seeAlso: ["profile", "open", "req", "live"], + seeAlso: ["inbox", "profile", "open", "req", "live"], appId: "chat", category: "Nostr", argParser: async (args: string[]) => { @@ -376,6 +377,40 @@ export const manPages: Record = { }; }, }, + inbox: { + name: "inbox", + section: "1", + synopsis: "inbox [--decrypt-pending | --clear-failed]", + description: + "View and manage encrypted gift wrap messages (NIP-59). Shows pending, decrypted, and failed gift wraps. Gift wraps are used for private messages (NIP-17) and other private events. Use --decrypt-pending to decrypt all pending gift wraps, or --clear-failed to reset failed decryption attempts.", + options: [ + { + flag: "--decrypt-pending", + description: "Decrypt all pending gift wraps", + }, + { + flag: "--clear-failed", + description: "Clear failed decryption attempts", + }, + ], + examples: [ + "inbox Open inbox viewer", + "inbox --decrypt-pending Decrypt all pending gift wraps", + "inbox --clear-failed Clear failed decryption attempts", + ], + seeAlso: ["chat", "profile"], + appId: "inbox", + category: "Nostr", + argParser: (args: string[]) => { + const action = args.includes("--decrypt-pending") + ? "decrypt-pending" + : args.includes("--clear-failed") + ? "clear-failed" + : null; + return { action }; + }, + defaultProps: { action: null }, + }, profile: { name: "profile", section: "1", diff --git a/tsconfig.node.tsbuildinfo b/tsconfig.node.tsbuildinfo index 75ea001..5e39d3d 100644 --- a/tsconfig.node.tsbuildinfo +++ b/tsconfig.node.tsbuildinfo @@ -1 +1 @@ -{"root":["./vite.config.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./vite.config.ts"],"errors":true,"version":"5.9.3"} \ No newline at end of file