feat: Update UI components for new gift wrap system

Updated all UI components to use the new gift wrap manager API:

InboxViewer.tsx:
- Use giftWrapManager.syncAll() for initial sync
- Use giftWrapManager.subscribeToNew() for real-time updates
- Use giftWrapManager.decryptBatch() for batch decryption
- Use giftWrapManager.state for counts (pending/decrypted/failed)
- Read from db.decryptedGiftWraps for display
- Simplified UI - no more complex conversation tracking

user-menu.tsx:
- Use use$(giftWrapManager.state) instead of useLiveQuery
- Get pendingCount from syncState.pendingCount
- Remove giftWrapLoader import

useAccountSync.ts:
- Remove gift wrap loading logic entirely
- Gift wrap sync now handled in InboxViewer component
- Hook only manages relay lists and blossom servers

nip-17-adapter.ts:
- Use WrappedMessagesModel from applesauce-common
- Load decrypted kind 14 messages directly from model
- No custom DB tables (decryptedRumors, conversations)
- Simplified message loading with filter logic
- Fixed type signatures for ChatProtocolAdapter

WindowRenderer.tsx:
- Fix InboxViewer lazy load (now default export)

Build verified: All TypeScript errors resolved 
This commit is contained in:
Claude
2026-01-16 08:46:33 +00:00
parent 3a53ebf367
commit d93bdc01a1
5 changed files with 451 additions and 686 deletions

View File

@@ -1,543 +1,338 @@
import { useState, useEffect } from "react";
import { useLiveQuery } from "dexie-react-hooks";
import {
Mail,
Settings,
Lock,
Unlock,
Loader2,
CheckCircle,
XCircle,
MessageSquare,
Radio,
Database,
} from "lucide-react";
import { useGrimoire } from "@/core/state";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Button } from "./ui/button";
import { Switch } from "./ui/switch";
import giftWrapLoader from "@/services/gift-wrap-loader";
import { getConversations } from "@/services/gift-wrap";
import db from "@/services/db";
import { toast } from "sonner";
import { useEffect, useState } from "react";
import { use$ } from "applesauce-react/hooks";
import accounts from "@/services/accounts";
import { useProfile } from "@/hooks/useProfile";
import { getDisplayName } from "@/lib/nostr-utils";
import { formatDistanceToNow } from "date-fns";
import {
Package,
Loader2,
AlertCircle,
CheckCircle2,
Trash2,
} from "lucide-react";
import { firstValueFrom } from "rxjs";
import giftWrapManager from "@/services/gift-wrap";
import accountManager from "@/services/accounts";
import eventStore from "@/services/event-store";
import db, { DecryptedGiftWrap } from "@/services/db";
export function InboxViewer() {
const { state, setPrivateMessagesEnabled, setAutoDecryptGiftWraps } =
useGrimoire();
const activeAccount = use$(accounts.active$);
const [isDecrypting, setIsDecrypting] = useState(false);
const [decryptResult, setDecryptResult] = useState<{
success: number;
failed: number;
total: number;
} | null>(null);
interface InboxViewerProps {
action?: "decrypt-pending" | "clear-failed" | null;
}
// Get settings
const privateMessagesEnabled = state.privateMessagesEnabled ?? false;
const autoDecrypt = state.autoDecryptGiftWraps ?? false;
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);
// Get pending count (gift wraps not yet decrypted)
const pendingCount = useLiveQuery(async () => {
if (!activeAccount?.pubkey) return 0;
return db.giftWraps
.where("[recipientPubkey+status]")
.equals([activeAccount.pubkey, "pending"])
.count();
}, [activeAccount?.pubkey]);
const pubkey = account?.pubkey;
// Get decrypted count (successfully decrypted)
const decryptedCount = useLiveQuery(async () => {
if (!activeAccount?.pubkey) return 0;
return db.giftWraps
.where("[recipientPubkey+status]")
.equals([activeAccount.pubkey, "decrypted"])
.count();
}, [activeAccount?.pubkey]);
// Get failed count (decryption failed)
const failedCount = useLiveQuery(async () => {
if (!activeAccount?.pubkey) return 0;
return db.giftWraps
.where("[recipientPubkey+status]")
.equals([activeAccount.pubkey, "failed"])
.count();
}, [activeAccount?.pubkey]);
// Get failed gift wraps with error messages
const failedGiftWraps = useLiveQuery(async () => {
if (!activeAccount?.pubkey) return [];
return db.giftWraps
.where("[recipientPubkey+status]")
.equals([activeAccount.pubkey, "failed"])
.limit(10)
.toArray();
}, [activeAccount?.pubkey]);
// Get conversations (from decrypted rumors)
const conversations = useLiveQuery(async () => {
if (!activeAccount?.pubkey) return [];
return await getConversations(activeAccount.pubkey);
}, [activeAccount?.pubkey]);
// Get loader state for relay info
const [loaderState, setLoaderState] = useState<any>(null);
// Subscribe to loader state
// Load decrypted gift wraps from Dexie
useEffect(() => {
const subscription = giftWrapLoader.state.subscribe(setLoaderState);
return () => subscription.unsubscribe();
}, []);
if (!pubkey) return;
const handleTogglePrivateMessages = (enabled: boolean) => {
setPrivateMessagesEnabled(enabled);
db.decryptedGiftWraps
.orderBy("receivedAt")
.reverse()
.offset(page * 50)
.limit(50)
.toArray()
.then(setDecrypted);
}, [pubkey, page, syncState?.decryptedCount]);
if (enabled) {
toast.success("Private messages enabled");
} else {
toast.info("Private messages disabled");
}
};
// Initial sync on mount
useEffect(() => {
if (!pubkey) return;
const handleToggleAutoDecrypt = (enabled: boolean) => {
setAutoDecryptGiftWraps(enabled);
const syncGiftWraps = async () => {
setLoading(true);
try {
// Get DM relays from user's kind 10050 relay list (NIP-17)
const dmRelayListEvent = await firstValueFrom(
eventStore.replaceable({ kind: 10050, pubkey }),
);
if (enabled) {
toast.success("Auto-decrypt enabled");
} else {
toast.info("Auto-decrypt disabled");
}
};
// Kind 10050 uses read/write relay tags
const dmRelays = dmRelayListEvent
? dmRelayListEvent.tags
.filter((t) => t[0] === "relay" && (!t[2] || t[2] === "read"))
.map((t) => t[1])
: [];
const handleDecryptPending = async () => {
setIsDecrypting(true);
setDecryptResult(null);
// Fallback to default relays if no DM relays configured
const relays =
dmRelays.length > 0
? dmRelays
: [
"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 {
const result = await giftWrapLoader.decryptPending();
setDecryptResult(result);
// Get pending gift wrap events (returns array)
const pendingEvents = await firstValueFrom(
giftWrapManager.getPendingGiftWraps(pubkey),
);
if (result.success > 0) {
toast.success(
`Decrypted ${result.success} message${result.success === 1 ? "" : "s"}`,
);
// Extract IDs
const pending = Array.isArray(pendingEvents)
? pendingEvents.map((e) => e.id)
: [];
if (pending.length === 0) {
console.log("[InboxViewer] No pending gift wraps to decrypt");
setDecrypting(false);
return;
}
if (result.failed > 0) {
toast.error(
`Failed to decrypt ${result.failed} message${result.failed === 1 ? "" : "s"}`,
);
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);
}
}
if (result.total === 0) {
toast.info("No pending messages to decrypt");
}
// Update counts
await giftWrapManager.updateCounts(pubkey);
console.log("[InboxViewer] Batch decrypt complete");
} catch (error) {
console.error("Failed to decrypt pending messages:", error);
toast.error("Failed to decrypt pending messages");
console.error("[InboxViewer] Decrypt error:", error);
} finally {
setIsDecrypting(false);
setDecrypting(false);
}
};
if (!activeAccount) {
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 flex-col items-center justify-center h-full p-8 text-center">
<Lock className="h-12 w-12 mb-4 text-muted-foreground" />
<h2 className="text-xl font-semibold mb-2">No Active Account</h2>
<p className="text-muted-foreground">
Sign in to view your private messages
</p>
<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 overflow-y-auto">
<div className="p-4 space-y-4">
{/* Header */}
<div className="flex items-center gap-2">
<Mail className="h-5 w-5" />
<h1 className="text-xl font-semibold">Private Messages</h1>
<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="grid grid-cols-3 gap-4">
<Card>
<CardHeader className="p-4">
<CardTitle className="text-sm font-medium text-muted-foreground">
Decrypted
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="text-2xl font-bold text-green-600">
{decryptedCount ?? 0}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="p-4">
<CardTitle className="text-sm font-medium text-muted-foreground">
Failed
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="text-2xl font-bold text-red-600">
{failedCount ?? 0}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="p-4">
<CardTitle className="text-sm font-medium text-muted-foreground">
Pending
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="text-2xl font-bold text-yellow-600">
{pendingCount ?? 0}
</div>
</CardContent>
</Card>
</div>
{/* Settings */}
<Card>
<CardHeader className="p-4">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4" />
<CardTitle className="text-base">Settings</CardTitle>
</div>
</CardHeader>
<CardContent className="p-4 pt-0 space-y-4">
{/* Enable Private Messages */}
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<label
htmlFor="enable-private-messages"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Enable Private Messages
</label>
<p className="text-sm text-muted-foreground">
Fetch and store encrypted gift wraps from DM relays
</p>
</div>
<Switch
id="enable-private-messages"
checked={privateMessagesEnabled}
onCheckedChange={handleTogglePrivateMessages}
/>
</div>
{/* Auto-Decrypt */}
{privateMessagesEnabled && (
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<label
htmlFor="auto-decrypt"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Auto-Decrypt Messages
</label>
<p className="text-sm text-muted-foreground">
Automatically decrypt gift wraps as they arrive
</p>
</div>
<Switch
id="auto-decrypt"
checked={autoDecrypt}
onCheckedChange={handleToggleAutoDecrypt}
/>
</div>
)}
</CardContent>
</Card>
{/* Manual Decrypt */}
{privateMessagesEnabled && !autoDecrypt && (pendingCount ?? 0) > 0 && (
<Card>
<CardHeader className="p-4">
<div className="flex items-center gap-2">
<Unlock className="h-4 w-4" />
<CardTitle className="text-base">Pending Messages</CardTitle>
</div>
</CardHeader>
<CardContent className="p-4 pt-0 space-y-4">
<p className="text-sm text-muted-foreground">
You have {pendingCount} encrypted message
{pendingCount === 1 ? "" : "s"} waiting to be decrypted.
</p>
<Button
onClick={handleDecryptPending}
disabled={isDecrypting}
className="w-full"
>
{isDecrypting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Decrypting...
</>
) : (
<>
<Unlock className="mr-2 h-4 w-4" />
Decrypt Pending Messages
</>
)}
</Button>
{decryptResult && (
<div className="space-y-2">
{decryptResult.success > 0 && (
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<CheckCircle className="h-4 w-4" />
<span>
{decryptResult.success} message
{decryptResult.success === 1 ? "" : "s"} decrypted
</span>
</div>
)}
{decryptResult.failed > 0 && (
<div className="flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
<XCircle className="h-4 w-4" />
<span>
{decryptResult.failed} message
{decryptResult.failed === 1 ? "" : "s"} failed
</span>
</div>
)}
</div>
)}
</CardContent>
</Card>
)}
{/* Status */}
{privateMessagesEnabled && (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-sm">
<div
className={`h-2 w-2 rounded-full ${
privateMessagesEnabled
? "bg-green-500"
: "bg-muted-foreground"
}`}
/>
<span className="text-muted-foreground">
{privateMessagesEnabled
? autoDecrypt
? "Auto-decrypt enabled - messages will be decrypted automatically"
: "Manual decrypt - messages will be queued for manual decryption"
: "Private messages disabled"}
</span>
</div>
</CardContent>
</Card>
)}
{/* Relay Status */}
{privateMessagesEnabled &&
loaderState?.relays &&
loaderState.relays.length > 0 && (
<Card>
<CardHeader className="p-4">
<div className="flex items-center gap-2">
<Radio className="h-4 w-4" />
<CardTitle className="text-base">DM Relays</CardTitle>
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="space-y-1">
{loaderState.relays.map((relay: string) => (
<div
key={relay}
className="text-sm font-mono text-muted-foreground flex items-center gap-2"
>
<div className="h-2 w-2 rounded-full bg-green-500" />
{relay}
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Debug Info */}
{privateMessagesEnabled && (
<Card>
<CardHeader className="p-4">
<div className="flex items-center gap-2">
<Database className="h-4 w-4" />
<CardTitle className="text-base">Debug Info</CardTitle>
</div>
</CardHeader>
<CardContent className="p-4 pt-0 space-y-2">
<div className="text-sm space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">Loader Enabled:</span>
<span className="font-mono">
{loaderState?.enabled ? "Yes" : "No"}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Auto-Decrypt:</span>
<span className="font-mono">
{loaderState?.autoDecrypt ? "Yes" : "No"}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Loading:</span>
<span className="font-mono">
{loaderState?.loading ? "Yes" : "No"}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Error Count:</span>
<span className="font-mono">
{loaderState?.errorCount ?? 0}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Last Sync:</span>
<span className="font-mono text-xs">
{loaderState?.lastSync
? formatDistanceToNow(loaderState.lastSync, {
addSuffix: true,
})
: "Never"}
</span>
</div>
</div>
</CardContent>
</Card>
)}
{/* Conversations List */}
{privateMessagesEnabled && (
<Card>
<CardHeader className="p-4">
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
<CardTitle className="text-base">
Conversations ({conversations?.length ?? 0})
</CardTitle>
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
{conversations && conversations.length > 0 ? (
<div className="space-y-2">
{conversations.map((conv) => (
<ConversationItem key={conv.id} conversation={conv} />
))}
</div>
) : (
<div className="text-sm text-muted-foreground">
No conversations yet. Decrypt pending messages to see
conversations.
</div>
)}
</CardContent>
</Card>
)}
{/* Decryption Errors */}
{privateMessagesEnabled &&
failedGiftWraps &&
failedGiftWraps.length > 0 && (
<Card>
<CardHeader className="p-4">
<div className="flex items-center gap-2">
<XCircle className="h-4 w-4 text-red-600" />
<CardTitle className="text-base text-red-600">
Decryption Errors ({failedCount ?? 0})
</CardTitle>
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="space-y-2">
{failedGiftWraps.map((gw) => (
<div
key={gw.id}
className="p-2 rounded bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-900"
>
<div className="text-xs font-mono text-red-900 dark:text-red-300 mb-1">
{gw.id.slice(0, 16)}...
</div>
<div className="text-sm text-red-700 dark:text-red-400">
{gw.failureReason || "Unknown error"}
</div>
</div>
))}
{(failedCount ?? 0) > 10 && (
<div className="text-xs text-muted-foreground">
Showing first 10 of {failedCount} errors
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Help Text */}
{!privateMessagesEnabled && (
<div className="text-sm text-muted-foreground space-y-2">
<p>
Private messages use NIP-59 gift wraps to provide
metadata-obscured messaging.
</p>
<p>
Messages are fetched from your DM relays (NIP-17: kind 10050) and
encrypted with NIP-44.
</p>
<p>
Enable private messages above to start receiving encrypted
messages.
</p>
<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 ?? 0}</span>
<span className="text-sm">Pending</span>
</div>
)}
</div>
</div>
);
}
function ConversationItem({ conversation }: { conversation: any }) {
const profile = useProfile(conversation.senderPubkey);
const displayName = getDisplayName(conversation.senderPubkey, profile);
return (
<div className="flex items-start gap-3 p-3 rounded-lg border hover:bg-accent cursor-pointer">
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<div className="font-medium truncate">{displayName}</div>
<div className="text-xs text-muted-foreground">
{formatDistanceToNow(conversation.lastMessageCreatedAt * 1000, {
addSuffix: true,
})}
</div>
</div>
<div className="text-sm text-muted-foreground truncate">
{conversation.lastMessagePreview}
</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-muted-foreground">
{conversation.messageCount} message
{conversation.messageCount === 1 ? "" : "s"}
</span>
{conversation.unreadCount > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary text-primary-foreground font-medium">
{conversation.unreadCount} new
<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 ?? 0}
</span>
<span className="text-sm">Decrypted</span>
</div>
{(syncState?.failedCount ?? 0) > 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) === 0}
className="btn btn-sm btn-primary"
>
{decrypting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Decrypting...
</>
) : (
<>Decrypt All Pending ({syncState?.pendingCount ?? 0})</>
)}
</button>
{(syncState?.failedCount ?? 0) > 0 && (
<button
onClick={() => giftWrapManager.clearErrors()}
className="btn btn-sm btn-ghost"
>
Clear Failed Attempts
</button>
)}
{(syncState?.decryptedCount ?? 0) > 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) > 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) > 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>
);
}

View File

@@ -42,9 +42,7 @@ const SpellbooksViewer = lazy(() =>
const BlossomViewer = lazy(() =>
import("./BlossomViewer").then((m) => ({ default: m.BlossomViewer })),
);
const InboxViewer = lazy(() =>
import("./InboxViewer").then((m) => ({ default: m.InboxViewer })),
);
const InboxViewer = lazy(() => import("./InboxViewer"));
const CountViewer = lazy(() => import("./CountViewer"));
// Loading fallback component

View File

@@ -6,7 +6,7 @@ import { getDisplayName } from "@/lib/nostr-utils";
import { useGrimoire } from "@/core/state";
import { Button } from "@/components/ui/button";
import { useLiveQuery } from "dexie-react-hooks";
import giftWrapLoader from "@/services/gift-wrap-loader";
import giftWrapManager from "@/services/gift-wrap";
import { dmRelayListCache } from "@/services/dm-relay-list-cache";
import {
DropdownMenu,
@@ -73,16 +73,13 @@ export default function UserMenu() {
}, [account?.pubkey]);
// Get pending gift wrap count
const pendingCount = useLiveQuery(async () => {
if (!account?.pubkey || !state.privateMessagesEnabled) return 0;
// Only show count if auto-decrypt is disabled
if (state.autoDecryptGiftWraps) return 0;
return giftWrapLoader.getPendingCount(account.pubkey);
}, [
account?.pubkey,
state.privateMessagesEnabled,
state.autoDecryptGiftWraps,
]);
const syncState = use$(giftWrapManager.state);
const pendingCount =
account?.pubkey &&
state.privateMessagesEnabled &&
!state.autoDecryptGiftWraps
? (syncState?.pendingCount ?? 0)
: 0;
function openProfile() {
if (!account?.pubkey) return;

View File

@@ -6,14 +6,12 @@ import { addressLoader } from "@/services/loaders";
import type { RelayInfo } from "@/types/app";
import { normalizeRelayURL } from "@/lib/relay-url";
import { getServersFromEvent } from "@/services/blossom";
import giftWrapLoader from "@/services/gift-wrap-loader";
/**
* Hook that syncs active account with Grimoire state and fetches relay lists and blossom servers
*/
export function useAccountSync() {
const {
state,
setActiveAccount,
setActiveAccountRelays,
setActiveAccountBlossomServers,
@@ -127,39 +125,4 @@ export function useAccountSync() {
storeSubscription.unsubscribe();
};
}, [activeAccount?.pubkey, eventStore, setActiveAccountBlossomServers]);
// Enable/disable gift wrap loader based on feature flag and active account
useEffect(() => {
const privateMessagesEnabled = state.privateMessagesEnabled ?? false;
const autoDecrypt = state.autoDecryptGiftWraps ?? false;
if (
privateMessagesEnabled &&
activeAccount?.pubkey &&
activeAccount.signer
) {
// Enable gift wrap loading
console.log(
`[AccountSync] Enabling private messages for ${activeAccount.pubkey.slice(0, 8)}`,
);
giftWrapLoader.enable(
activeAccount.pubkey,
activeAccount.signer,
autoDecrypt,
);
} else {
// Disable gift wrap loading
giftWrapLoader.disable();
}
return () => {
// Cleanup on unmount
giftWrapLoader.disable();
};
}, [
state.privateMessagesEnabled,
state.autoDecryptGiftWraps,
activeAccount?.pubkey,
activeAccount?.signer,
]);
}

View File

@@ -4,14 +4,15 @@
* This adapter provides read-only access to NIP-17 encrypted DMs
* that have been received and decrypted via gift wraps (NIP-59).
*
* Messages are loaded from the local decryptedRumors database table,
* which is populated by the gift-wrap-loader service.
* Messages are loaded from applesauce WrappedMessagesModel which
* returns decrypted kind 14 rumors.
*
* Protocol: https://github.com/nostr-protocol/nips/blob/master/17.md
*/
import { Observable } from "rxjs";
import { Observable, map } from "rxjs";
import { nip19 } from "nostr-tools";
import { WrappedMessagesModel } from "applesauce-common/models";
import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter";
import type {
Conversation,
@@ -22,8 +23,8 @@ import type {
Participant,
} from "@/types/chat";
import type { NostrEvent } from "@/types/nostr";
import db from "@/services/db";
import eventStore from "@/services/event-store";
import accountManager from "@/services/accounts";
export class Nip17Adapter extends ChatProtocolAdapter {
readonly protocol = "nip-17" as const;
@@ -73,24 +74,21 @@ export class Nip17Adapter extends ChatProtocolAdapter {
identifier: ProtocolIdentifier,
): Promise<Conversation> {
if (identifier.type !== "dm-recipient") {
throw new Error("Invalid identifier type for NIP-17");
throw new Error(
`NIP-17 adapter cannot handle identifier type: ${identifier.type}`,
);
}
const peerPubkey = identifier.value;
const recipientPubkey = identifier.value;
const activePubkey = accountManager.active$.value?.pubkey;
// Get active account pubkey
const activePubkey = await this.getActivePubkey();
// Try to get conversation metadata from database
const conversationId = `${peerPubkey}:${activePubkey}`;
const conversation = await db.conversations.get(conversationId);
// Get profile from eventStore
const profile = eventStore.getReplaceable(0, peerPubkey, "");
if (!activePubkey) {
throw new Error("No active account");
}
// Get peer participant
const peerParticipant: Participant = {
pubkey: peerPubkey,
pubkey: recipientPubkey,
};
// Get self participant
@@ -98,162 +96,176 @@ export class Nip17Adapter extends ChatProtocolAdapter {
pubkey: activePubkey,
};
// Create conversation ID (use sorted pubkeys for consistent ID)
const conversationId = [activePubkey, recipientPubkey].sort().join(":");
return {
id: conversationId,
type: "dm",
protocol: "nip-17",
title: profile?.content
? JSON.parse(profile.content).name || peerPubkey.slice(0, 8)
: peerPubkey.slice(0, 8),
protocol: this.protocol,
type: this.type,
title: `DM with ${recipientPubkey.slice(0, 8)}...`,
participants: [peerParticipant, selfParticipant],
unreadCount: conversation?.unreadCount ?? 0,
metadata: {
encrypted: true,
giftWrapped: true,
},
unreadCount: 0,
metadata: {},
};
}
/**
* Load messages from decryptedRumors table
* Returns an Observable with all messages for this conversation
* Load messages for a conversation
* Uses WrappedMessagesModel to get decrypted kind 14 messages
*/
loadMessages(
conversation: Conversation,
options?: LoadMessagesOptions,
_options?: LoadMessagesOptions,
): Observable<Message[]> {
// Parse peer pubkey from conversation ID
const [peerPubkey] = conversation.id.split(":");
const activePubkey = accountManager.active$.value?.pubkey;
if (!activePubkey) {
throw new Error("No active account");
}
// Get messages from database (async)
const messagesPromise = this.loadMessagesFromDb(
peerPubkey,
conversation.id,
options?.limit ?? 100,
const recipientPubkey = conversation.participants.find(
(p) => p.pubkey !== activePubkey,
)?.pubkey;
if (!recipientPubkey) {
throw new Error("Recipient pubkey not found in conversation");
}
console.log(
`[NIP-17] Loading messages with ${recipientPubkey} for ${activePubkey}`,
);
// Convert promise to observable
return new Observable((subscriber) => {
messagesPromise
.then((messages) => {
subscriber.next(messages);
subscriber.complete();
})
.catch((err) => subscriber.error(err));
});
// WrappedMessagesModel returns ALL decrypted rumors for the user
// We need to filter for this specific conversation
return eventStore.model(WrappedMessagesModel, activePubkey).pipe(
map((rumors) => {
// rumors is an array of decrypted kind 14 events
if (!Array.isArray(rumors)) {
console.warn("[NIP-17] WrappedMessagesModel returned non-array");
return [];
}
// Filter for messages with the specific conversation partner
const conversationRumors = rumors.filter((rumor) => {
// Message is in this conversation if:
// 1. We sent it to the recipient (rumor.pubkey === activePubkey, p-tag === recipientPubkey)
// 2. Recipient sent it to us (rumor.pubkey === recipientPubkey)
const pTags = rumor.tags.filter((t) => t[0] === "p");
const hasRecipient = pTags.some((t) => t[1] === recipientPubkey);
return (
(rumor.pubkey === activePubkey && hasRecipient) ||
rumor.pubkey === recipientPubkey
);
});
console.log(
`[NIP-17] Got ${conversationRumors.length} messages for conversation (out of ${rumors.length} total)`,
);
const messages = conversationRumors.map((rumor) =>
this.rumorToMessage(rumor, conversation.id),
);
// Sort by timestamp (oldest first)
return messages.sort((a, b) => a.timestamp - b.timestamp);
}),
);
}
/**
* Load more historical messages (pagination)
* Convert a rumor (kind 14) to a Message
*/
private rumorToMessage(rumor: any, conversationId: string): Message {
// Extract reply info if present (e-tag referencing previous message)
const eTags = rumor.tags.filter((t: string[]) => t[0] === "e");
const replyTo = eTags.length > 0 ? eTags[0][1] : undefined;
// Create a NostrEvent from rumor (add sig field)
const event: NostrEvent = {
...rumor,
sig: rumor.sig || "", // Rumors don't have sig
};
return {
id: rumor.id,
conversationId,
author: rumor.pubkey,
content: rumor.content,
timestamp: rumor.created_at,
protocol: this.protocol,
event,
replyTo,
};
}
/**
* Load more messages (not applicable for NIP-17)
*/
async loadMoreMessages(
conversation: Conversation,
before: number,
_conversation: Conversation,
_before: number,
): Promise<Message[]> {
const [peerPubkey] = conversation.id.split(":");
return this.loadMessagesFromDb(peerPubkey, conversation.id, 50, before);
// NIP-17 loads all messages at once, no pagination
return [];
}
/**
* Load messages from database
* Load a specific reply message (not implemented for NIP-17)
*/
private async loadMessagesFromDb(
peerPubkey: string,
conversationId: string,
limit: number,
before?: number,
): Promise<Message[]> {
const activePubkey = await this.getActivePubkey();
// Query decryptedRumors table for messages with this peer
let receivedQuery = db.decryptedRumors
.where("[recipientPubkey+senderPubkey]")
.equals([activePubkey, peerPubkey]);
if (before) {
receivedQuery = receivedQuery.filter((r) => r.rumorCreatedAt < before);
}
const rumors = await receivedQuery.reverse().limit(limit).toArray();
// Also get messages I sent to them (if any)
let sentQuery = db.decryptedRumors
.where("[recipientPubkey+senderPubkey]")
.equals([peerPubkey, activePubkey]);
if (before) {
sentQuery = sentQuery.filter((r) => r.rumorCreatedAt < before);
}
const sentRumors = await sentQuery.reverse().limit(limit).toArray();
// Combine and sort by timestamp
const allRumors = [...rumors, ...sentRumors].sort(
(a, b) => a.rumorCreatedAt - b.rumorCreatedAt,
);
// Convert to Message format
return allRumors
.filter((r) => r.rumorKind === 14) // Only chat messages (kind 14)
.map((r) => ({
id: r.giftWrapId,
conversationId,
author: r.senderPubkey,
content: r.rumor.content,
timestamp: r.rumorCreatedAt,
protocol: "nip-17" as const,
event: r.rumor,
}));
async loadReplyMessage(
_conversation: Conversation,
_eventId: string,
): Promise<NostrEvent | null> {
// Would need to search through decrypted rumors
return null;
}
/**
* Send message - NOT IMPLEMENTED (read-only for now)
* Send a message (not yet implemented for NIP-17)
*/
async sendMessage(
_conversation: Conversation,
_content: string,
_options?: SendMessageOptions,
): Promise<void> {
throw new Error(
"Sending NIP-17 messages is not yet implemented. This adapter is read-only.",
);
throw new Error("Sending NIP-17 messages is not yet implemented");
}
/**
* Get capabilities - read-only for now
* React to a message (not supported for NIP-17)
*/
async reactToMessage(_message: Message, _emoji: string): Promise<NostrEvent> {
throw new Error("Reactions are not supported for NIP-17");
}
/**
* Delete a message (not supported for NIP-17)
*/
async deleteMessage(_message: Message): Promise<void> {
throw new Error("Message deletion is not supported for NIP-17");
}
/**
* List conversations (not yet implemented for NIP-17)
* This would require scanning all decrypted rumors to find unique conversation partners
*/
listConversations(): Observable<Conversation[]> {
throw new Error("Listing NIP-17 conversations is not yet implemented");
}
/**
* Get capabilities - NIP-17 is read-only for now
*/
getCapabilities(): ChatCapabilities {
return {
supportsEncryption: true, // NIP-17 is encrypted
supportsThreading: false, // DMs don't have threading
supportsModeration: false, // No moderation in DMs
supportsRoles: false, // No roles in 1-on-1 DMs
supportsGroupManagement: false, // Not a group
canCreateConversations: false, // Read-only for now
requiresRelay: false, // Uses DM relay lists, not specific relay
supportsEncryption: true,
supportsThreading: false,
supportsModeration: false,
supportsRoles: false,
supportsGroupManagement: false,
canCreateConversations: false,
requiresRelay: false,
};
}
/**
* Load reply message - not implemented for NIP-17
*/
async loadReplyMessage(
_conversation: Conversation,
_eventId: string,
): Promise<NostrEvent | null> {
return null;
}
/**
* Get active pubkey from account manager
*/
private async getActivePubkey(): Promise<string> {
// Import dynamically to avoid circular dependency
const { default: accountManager } = await import("@/services/accounts");
const account = accountManager.active;
if (!account) {
throw new Error("No active account");
}
return account.pubkey;
}
}