From d93bdc01a1184ada3ac1aed06d5ae34465d497e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Jan 2026 08:46:33 +0000 Subject: [PATCH] feat: Update UI components for new gift wrap system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ✅ --- src/components/InboxViewer.tsx | 799 +++++++++--------------- src/components/WindowRenderer.tsx | 4 +- src/components/nostr/user-menu.tsx | 19 +- src/hooks/useAccountSync.ts | 37 -- src/lib/chat/adapters/nip-17-adapter.ts | 278 +++++---- 5 files changed, 451 insertions(+), 686 deletions(-) diff --git a/src/components/InboxViewer.tsx b/src/components/InboxViewer.tsx index 4ed9c59..891140a 100644 --- a/src/components/InboxViewer.tsx +++ b/src/components/InboxViewer.tsx @@ -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([]); + 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(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 ( -
- -

No Active Account

-

- Sign in to view your private messages -

+
+
+ +

No active account. Please login to view gift wraps.

+
); } return ( -
-
- {/* Header */} -
- -

Private Messages

+
+ {/* Header with status */} +
+
+

+ + Gift Wrap Inbox +

+ + {loading && ( +
+ + Syncing... +
+ )}
{/* Stats */} -
- - - - Decrypted - - - -
- {decryptedCount ?? 0} -
-
-
- - - - - Failed - - - -
- {failedCount ?? 0} -
-
-
- - - - - Pending - - - -
- {pendingCount ?? 0} -
-
-
-
- - {/* Settings */} - - -
- - Settings -
-
- - {/* Enable Private Messages */} -
-
- -

- Fetch and store encrypted gift wraps from DM relays -

-
- -
- - {/* Auto-Decrypt */} - {privateMessagesEnabled && ( -
-
- -

- Automatically decrypt gift wraps as they arrive -

-
- -
- )} -
-
- - {/* Manual Decrypt */} - {privateMessagesEnabled && !autoDecrypt && (pendingCount ?? 0) > 0 && ( - - -
- - Pending Messages -
-
- -

- You have {pendingCount} encrypted message - {pendingCount === 1 ? "" : "s"} waiting to be decrypted. -

- - - - {decryptResult && ( -
- {decryptResult.success > 0 && ( -
- - - {decryptResult.success} message - {decryptResult.success === 1 ? "" : "s"} decrypted - -
- )} - {decryptResult.failed > 0 && ( -
- - - {decryptResult.failed} message - {decryptResult.failed === 1 ? "" : "s"} failed - -
- )} -
- )} -
-
- )} - - {/* Status */} - {privateMessagesEnabled && ( - - -
-
- - {privateMessagesEnabled - ? autoDecrypt - ? "Auto-decrypt enabled - messages will be decrypted automatically" - : "Manual decrypt - messages will be queued for manual decryption" - : "Private messages disabled"} - -
- - - )} - - {/* Relay Status */} - {privateMessagesEnabled && - loaderState?.relays && - loaderState.relays.length > 0 && ( - - -
- - DM Relays -
-
- -
- {loaderState.relays.map((relay: string) => ( -
-
- {relay} -
- ))} -
- - - )} - - {/* Debug Info */} - {privateMessagesEnabled && ( - - -
- - Debug Info -
-
- -
-
- Loader Enabled: - - {loaderState?.enabled ? "Yes" : "No"} - -
-
- Auto-Decrypt: - - {loaderState?.autoDecrypt ? "Yes" : "No"} - -
-
- Loading: - - {loaderState?.loading ? "Yes" : "No"} - -
-
- Error Count: - - {loaderState?.errorCount ?? 0} - -
-
- Last Sync: - - {loaderState?.lastSync - ? formatDistanceToNow(loaderState.lastSync, { - addSuffix: true, - }) - : "Never"} - -
-
-
-
- )} - - {/* Conversations List */} - {privateMessagesEnabled && ( - - -
- - - Conversations ({conversations?.length ?? 0}) - -
-
- - {conversations && conversations.length > 0 ? ( -
- {conversations.map((conv) => ( - - ))} -
- ) : ( -
- No conversations yet. Decrypt pending messages to see - conversations. -
- )} -
-
- )} - - {/* Decryption Errors */} - {privateMessagesEnabled && - failedGiftWraps && - failedGiftWraps.length > 0 && ( - - -
- - - Decryption Errors ({failedCount ?? 0}) - -
-
- -
- {failedGiftWraps.map((gw) => ( -
-
- {gw.id.slice(0, 16)}... -
-
- {gw.failureReason || "Unknown error"} -
-
- ))} - {(failedCount ?? 0) > 10 && ( -
- Showing first 10 of {failedCount} errors -
- )} -
-
-
- )} - - {/* Help Text */} - {!privateMessagesEnabled && ( -
-

- Private messages use NIP-59 gift wraps to provide - metadata-obscured messaging. -

-

- Messages are fetched from your DM relays (NIP-17: kind 10050) and - encrypted with NIP-44. -

-

- Enable private messages above to start receiving encrypted - messages. -

+
+
+ + {syncState?.pendingCount ?? 0} + Pending
- )} -
-
- ); -} -function ConversationItem({ conversation }: { conversation: any }) { - const profile = useProfile(conversation.senderPubkey); - const displayName = getDisplayName(conversation.senderPubkey, profile); - - return ( -
-
-
-
{displayName}
-
- {formatDistanceToNow(conversation.lastMessageCreatedAt * 1000, { - addSuffix: true, - })} -
-
-
- {conversation.lastMessagePreview} -
-
- - {conversation.messageCount} message - {conversation.messageCount === 1 ? "" : "s"} - - {conversation.unreadCount > 0 && ( - - {conversation.unreadCount} new +
+ + + {syncState?.decryptedCount ?? 0} + Decrypted +
+ + {(syncState?.failedCount ?? 0) > 0 && ( +
+ + {syncState?.failedCount} + Failed +
+ )} +
+ + {/* Actions */} +
+ + + {(syncState?.failedCount ?? 0) > 0 && ( + + )} + + {(syncState?.decryptedCount ?? 0) > 0 && ( + )}
+ + {/* Decrypted gift wraps list */} +
+ {decrypted.length === 0 ? ( +
+
+ +

No decrypted gift wraps yet.

+ {(syncState?.pendingCount ?? 0) > 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) > 0 && ( +
+ Last synced: {new Date(syncState!.lastSyncAt).toLocaleString()} +
+ )}
); } diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index 77ca0ee..89746cd 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -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 diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx index 6c48b33..806748e 100644 --- a/src/components/nostr/user-menu.tsx +++ b/src/components/nostr/user-menu.tsx @@ -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; diff --git a/src/hooks/useAccountSync.ts b/src/hooks/useAccountSync.ts index 39e6edf..bc7f96f 100644 --- a/src/hooks/useAccountSync.ts +++ b/src/hooks/useAccountSync.ts @@ -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, - ]); } diff --git a/src/lib/chat/adapters/nip-17-adapter.ts b/src/lib/chat/adapters/nip-17-adapter.ts index 4a87c82..15855dc 100644 --- a/src/lib/chat/adapters/nip-17-adapter.ts +++ b/src/lib/chat/adapters/nip-17-adapter.ts @@ -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 { 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 { - // 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 { - 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 { - 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 { + // 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 { - 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 { + throw new Error("Reactions are not supported for NIP-17"); + } + + /** + * Delete a message (not supported for NIP-17) + */ + async deleteMessage(_message: Message): Promise { + 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 { + 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 { - return null; - } - - /** - * Get active pubkey from account manager - */ - private async getActivePubkey(): Promise { - // 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; - } }