mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-15 09:08:43 +02:00
feat: Add gift wrap infrastructure and inbox command
Core infrastructure for NIP-59 gift wrap support: **Database Schema (v16):** - Add decryptedGiftWraps table with indexed fields - Add giftWrapErrors table for tracking failed decrypts - Indexes: giftWrapId, sealPubkey, receivedAt, decryptedAt **Gift Wrap Service (`gift-wrap.ts`):** - Simple sync strategy: paginated loading + real-time subscription - Uses createTimelineLoader for initial sync (loads all pages) - Subscribes to new gift wraps via relay subscriptions - On-demand decryption with Dexie caching - Batch decrypt with progress tracking and error handling - Integrates with applesauce GiftWrapsModel and unlockGiftWrap **Inbox Command & Viewer:** - `inbox` - View pending/decrypted/failed gift wraps - `inbox --decrypt-pending` - Decrypt all pending - `inbox --clear-failed` - Reset failed attempts - Auto-syncs on mount using user's inbox relays (NIP-65) - Shows stats: pending/decrypted/failed counts - Paginated list of decrypted gift wraps with metadata - Real-time updates via giftWrapManager state observable **Integration:** - Added "inbox" to AppId type - Wired up InboxViewer in WindowRenderer (lazy loaded) - Added command reconstructor for inbox commands - Updated chat command description to mention NIP-17 No password/encryption layer yet - decrypted content stored in plain Dexie tables. NIP-17 adapter implementation next.
This commit is contained in:
327
src/components/InboxViewer.tsx
Normal file
327
src/components/InboxViewer.tsx
Normal file
@@ -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<DecryptedGiftWrap[]>([]);
|
||||
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<string[]>((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 (
|
||||
<div className="flex items-center justify-center h-full text-base-content/50">
|
||||
<div className="text-center space-y-4">
|
||||
<Package className="w-12 h-12 mx-auto opacity-50" />
|
||||
<p>No active account. Please login to view gift wraps.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header with status */}
|
||||
<div className="flex flex-col gap-4 p-4 border-b border-base-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Package className="w-5 h-5" />
|
||||
Gift Wrap Inbox
|
||||
</h2>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-sm text-base-content/60">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Syncing...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-warning/10 text-warning rounded-lg">
|
||||
<Package className="w-4 h-4" />
|
||||
<span className="font-medium">{syncState.pendingCount}</span>
|
||||
<span className="text-sm">Pending</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-success/10 text-success rounded-lg">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span className="font-medium">{syncState.decryptedCount}</span>
|
||||
<span className="text-sm">Decrypted</span>
|
||||
</div>
|
||||
|
||||
{syncState.failedCount > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-error/10 text-error rounded-lg">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span className="font-medium">{syncState.failedCount}</span>
|
||||
<span className="text-sm">Failed</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={handleDecryptAll}
|
||||
disabled={decrypting || syncState.pendingCount === 0}
|
||||
className="btn btn-sm btn-primary"
|
||||
>
|
||||
{decrypting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Decrypting...
|
||||
</>
|
||||
) : (
|
||||
<>Decrypt All Pending ({syncState.pendingCount})</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{syncState.failedCount > 0 && (
|
||||
<button
|
||||
onClick={() => giftWrapManager.clearErrors()}
|
||||
className="btn btn-sm btn-ghost"
|
||||
>
|
||||
Clear Failed Attempts
|
||||
</button>
|
||||
)}
|
||||
|
||||
{syncState.decryptedCount > 0 && (
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
className="btn btn-sm btn-ghost text-error"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Clear All Decrypted
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decrypted gift wraps list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{decrypted.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-base-content/50">
|
||||
<div className="text-center space-y-4">
|
||||
<Package className="w-12 h-12 mx-auto opacity-50" />
|
||||
<p>No decrypted gift wraps yet.</p>
|
||||
{syncState.pendingCount > 0 && (
|
||||
<button
|
||||
onClick={handleDecryptAll}
|
||||
disabled={decrypting}
|
||||
className="btn btn-primary btn-sm"
|
||||
>
|
||||
Decrypt {syncState.pendingCount} Pending
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-base-300">
|
||||
{decrypted.map((wrap) => (
|
||||
<div key={wrap.giftWrapId} className="p-4 hover:bg-base-200/50">
|
||||
<div className="flex items-start gap-3">
|
||||
<Package className="w-5 h-5 text-base-content/40 mt-1" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium">
|
||||
Kind {wrap.rumor.kind}
|
||||
</span>
|
||||
{wrap.sealPubkey && (
|
||||
<span className="text-xs text-base-content/60 font-mono">
|
||||
from {wrap.sealPubkey.slice(0, 8)}...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-base-content/80 line-clamp-2">
|
||||
{wrap.rumor.content.slice(0, 200)}
|
||||
{wrap.rumor.content.length > 200 && "..."}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-base-content/60">
|
||||
<span>
|
||||
Received:{" "}
|
||||
{new Date(wrap.receivedAt * 1000).toLocaleString()}
|
||||
</span>
|
||||
<span>
|
||||
Decrypted:{" "}
|
||||
{new Date(wrap.decryptedAt * 1000).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more button */}
|
||||
{decrypted.length >= 50 && (
|
||||
<div className="p-4 text-center">
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="btn btn-sm btn-ghost"
|
||||
>
|
||||
Load More
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with sync info */}
|
||||
{syncState.lastSyncAt > 0 && (
|
||||
<div className="p-2 text-xs text-center text-base-content/50 border-t border-base-300">
|
||||
Last synced: {new Date(syncState.lastSyncAt).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 = <InboxViewer action={window.props.action} />;
|
||||
break;
|
||||
case "spells":
|
||||
content = <SpellsViewer />;
|
||||
break;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<Profile>;
|
||||
nip05!: Table<Nip05>;
|
||||
@@ -98,6 +114,8 @@ class GrimoireDb extends Dexie {
|
||||
blossomServers!: Table<CachedBlossomServerList>;
|
||||
spells!: Table<LocalSpell>;
|
||||
spellbooks!: Table<LocalSpellbook>;
|
||||
decryptedGiftWraps!: Table<DecryptedGiftWrap>;
|
||||
giftWrapErrors!: Table<GiftWrapDecryptionError>;
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
313
src/services/gift-wrap.ts
Normal file
313
src/services/gift-wrap.ts
Normal file
@@ -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<GiftWrapSyncState>({
|
||||
syncing: false,
|
||||
pendingCount: 0,
|
||||
decryptedCount: 0,
|
||||
failedCount: 0,
|
||||
totalCount: 0,
|
||||
lastSyncAt: 0,
|
||||
});
|
||||
|
||||
private subscriptions = new Map<string, Subscription>();
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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<NostrEvent[]>((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<void> {
|
||||
// Get pending count from applesauce model
|
||||
const pending = await new Promise<number>((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<NostrEvent> {
|
||||
// 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<void> {
|
||||
await db.giftWrapErrors.clear();
|
||||
console.log("[GiftWrap] Cleared all decryption errors");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all decrypted gift wraps from cache
|
||||
*/
|
||||
async clearDecrypted(): Promise<void> {
|
||||
await db.decryptedGiftWraps.clear();
|
||||
await db.giftWrapErrors.clear();
|
||||
console.log("[GiftWrap] Cleared all decrypted gift wraps");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update state (partial update)
|
||||
*/
|
||||
private updateState(partial: Partial<GiftWrapSyncState>): void {
|
||||
this.state$.next({ ...this.state$.value, ...partial });
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
const giftWrapManager = new GiftWrapManager();
|
||||
export default giftWrapManager;
|
||||
@@ -17,6 +17,7 @@ export type AppId =
|
||||
| "debug"
|
||||
| "conn"
|
||||
| "chat"
|
||||
| "inbox"
|
||||
| "spells"
|
||||
| "spellbooks"
|
||||
| "blossom"
|
||||
|
||||
@@ -351,21 +351,22 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
section: "1",
|
||||
synopsis: "chat <identifier>",
|
||||
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: "<identifier>",
|
||||
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<string, ManPageEntry> = {
|
||||
};
|
||||
},
|
||||
},
|
||||
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",
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./vite.config.ts"],"version":"5.6.3"}
|
||||
{"root":["./vite.config.ts"],"errors":true,"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user