diff --git a/src/components/InboxViewer.tsx b/src/components/InboxViewer.tsx index 25e12ed..41a6317 100644 --- a/src/components/InboxViewer.tsx +++ b/src/components/InboxViewer.tsx @@ -19,20 +19,13 @@ import { } from "@/hooks/useGiftWrap"; import { useProfile } from "@/hooks/useProfile"; import eventStore from "@/services/event-store"; -import accountManager from "@/services/accounts"; import { getDisplayName } from "@/lib/nostr-utils"; -import { - Copy, - Settings, - RefreshCw, - MessageSquare, - ChevronDown, -} from "lucide-react"; +import { Copy, Settings, MessageSquare, ChevronDown } from "lucide-react"; import { useCopy } from "@/hooks/useCopy"; import { toast } from "sonner"; import giftWrapManager from "@/services/gift-wrap"; -interface InboxViewerProps {} +type InboxViewerProps = Record; const CONVERSATIONS_PAGE_SIZE = 50; @@ -50,18 +43,15 @@ export function InboxViewer(_props: InboxViewerProps) { // Get DM relays (kind 10050) const dmRelayEvent = use$(() => { - if (!pubkey) return null; - return eventStore - .getAll() - .filter((e) => e.kind === 10050 && e.pubkey === pubkey) - .sort((a, b) => b.created_at - a.created_at)[0]; + if (!pubkey) return undefined; + return eventStore.replaceable(10050, pubkey, ""); }, [pubkey]); const dmRelays = useMemo(() => { if (!dmRelayEvent) return []; return dmRelayEvent.tags - .filter((t) => t[0] === "relay" && t[1]) - .map((t) => t[1]); + .filter((t: string[]) => t[0] === "relay" && t[1]) + .map((t: string[]) => t[1]); }, [dmRelayEvent]); // Convert conversations map to sorted array with pagination @@ -110,7 +100,7 @@ export function InboxViewer(_props: InboxViewerProps) { }; const handleOpenConversation = ( - conversationKey: string, + _conversationKey: string, otherPubkey: string, ) => { // Open chat window with the other participant @@ -122,8 +112,16 @@ export function InboxViewer(_props: InboxViewerProps) { ); }; + const [isLoadingMore, setIsLoadingMore] = useState(false); + const handleLoadMoreConversations = () => { - setConversationsPage((prev) => prev + 1); + if (isLoadingMore) return; // Prevent double-clicks + setIsLoadingMore(true); + // Use setTimeout to ensure UI updates + setTimeout(() => { + setConversationsPage((prev) => prev + 1); + setIsLoadingMore(false); + }, 100); }; const handleLoadOlderGiftWraps = async () => { @@ -307,11 +305,13 @@ export function InboxViewer(_props: InboxViewerProps) {
)} @@ -341,7 +341,8 @@ function ConversationRow({ const handleCopyPubkey = (e: React.MouseEvent) => { e.stopPropagation(); - copy(otherPubkey, "Pubkey copied"); + copy(otherPubkey); + toast.success("Pubkey copied"); }; // Format timestamp diff --git a/src/hooks/useGiftWrap.ts b/src/hooks/useGiftWrap.ts index 4c7bcd7..556c965 100644 --- a/src/hooks/useGiftWrap.ts +++ b/src/hooks/useGiftWrap.ts @@ -37,6 +37,7 @@ export function useGiftWrapConversations(): Map | null { string, UnsealedDM > | null>(null); + const [isLoading, setIsLoading] = useState(false); const activeAccount = use$(accountManager.active$); useEffect(() => { @@ -45,26 +46,44 @@ export function useGiftWrapConversations(): Map | null { return; } + let isMounted = true; + // Load conversations from storage - giftWrapManager - .getConversations(activeAccount.pubkey) - .then(setConversations) - .catch((error) => { + const loadConversations = async () => { + if (isLoading) return; // Prevent overlapping fetches + + setIsLoading(true); + try { + const result = await giftWrapManager.getConversations( + activeAccount.pubkey, + ); + if (isMounted) { + setConversations(result); + } + } catch (error) { console.error("[useGiftWrapConversations] Failed to load:", error); - setConversations(new Map()); - }); + if (isMounted) { + setConversations(new Map()); + } + } finally { + if (isMounted) { + setIsLoading(false); + } + } + }; + + // Initial load + loadConversations(); // Poll for updates every 5 seconds // TODO: Replace with proper reactive subscription when gift wrap manager emits updates - const interval = setInterval(() => { - giftWrapManager - .getConversations(activeAccount.pubkey) - .then(setConversations) - .catch(console.error); - }, 5000); + const interval = setInterval(loadConversations, 5000); - return () => clearInterval(interval); - }, [activeAccount?.pubkey]); + return () => { + isMounted = false; + clearInterval(interval); + }; + }, [activeAccount?.pubkey]); // Removed isLoading from deps to avoid infinite loop return conversations; } @@ -76,6 +95,7 @@ export function useConversationMessages( conversationKey: string | null, ): UnsealedDM[] | null { const [messages, setMessages] = useState(null); + const [isLoading, setIsLoading] = useState(false); useEffect(() => { if (!conversationKey) { @@ -83,26 +103,43 @@ export function useConversationMessages( return; } + let isMounted = true; + // Load messages from storage - giftWrapManager - .getConversationMessages(conversationKey) - .then(setMessages) - .catch((error) => { + const loadMessages = async () => { + if (isLoading) return; // Prevent overlapping fetches + + setIsLoading(true); + try { + const result = + await giftWrapManager.getConversationMessages(conversationKey); + if (isMounted) { + setMessages(result); + } + } catch (error) { console.error("[useConversationMessages] Failed to load:", error); - setMessages([]); - }); + if (isMounted) { + setMessages([]); + } + } finally { + if (isMounted) { + setIsLoading(false); + } + } + }; + + // Initial load + loadMessages(); // Poll for updates every 3 seconds // TODO: Replace with proper reactive subscription - const interval = setInterval(() => { - giftWrapManager - .getConversationMessages(conversationKey) - .then(setMessages) - .catch(console.error); - }, 3000); + const interval = setInterval(loadMessages, 3000); - return () => clearInterval(interval); - }, [conversationKey]); + return () => { + isMounted = false; + clearInterval(interval); + }; + }, [conversationKey]); // Removed isLoading from deps to avoid infinite loop return messages; } diff --git a/src/lib/chat/adapters/nip-17-adapter.ts b/src/lib/chat/adapters/nip-17-adapter.ts index 4ca6112..3e78c0f 100644 --- a/src/lib/chat/adapters/nip-17-adapter.ts +++ b/src/lib/chat/adapters/nip-17-adapter.ts @@ -9,13 +9,7 @@ */ import { Observable, from, map } from "rxjs"; -import { - nip19, - nip44, - generateSecretKey, - getPublicKey, - finalizeEvent, -} from "nostr-tools"; +import { nip19, nip44, generateSecretKey, finalizeEvent } from "nostr-tools"; import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter"; import type { Conversation, @@ -23,12 +17,10 @@ import type { ProtocolIdentifier, ChatCapabilities, LoadMessagesOptions, - DMIdentifier, } from "@/types/chat"; import type { NostrEvent } from "@/types/nostr"; import giftWrapManager from "@/services/gift-wrap"; import eventStore from "@/services/event-store"; -import pool from "@/services/relay-pool"; import accountManager from "@/services/accounts"; import { publishEventToRelays } from "@/services/hub"; import type { UnsealedDM } from "@/services/db"; @@ -256,14 +248,14 @@ export class Nip17Adapter extends ChatProtocolAdapter { // Step 3: Create the gift wrap (kind 1059) // Generate ephemeral keypair for gift wrap const ephemeralSecretKey = generateSecretKey(); - const ephemeralPubkey = getPublicKey(ephemeralSecretKey); // Encrypt the seal with ephemeral key → recipient const sealJSON = JSON.stringify(seal); - const encryptedSeal = nip44.encrypt( - sealJSON, - nip44.utils.getConversationKey(ephemeralSecretKey, recipientPubkey), + const conversationKey = nip44.getConversationKey( + ephemeralSecretKey, + recipientPubkey, ); + const encryptedSeal = nip44.encrypt(sealJSON, conversationKey); // Create and sign gift wrap with ephemeral key const giftWrapDraft = { @@ -275,26 +267,45 @@ export class Nip17Adapter extends ChatProtocolAdapter { const giftWrap = finalizeEvent(giftWrapDraft, ephemeralSecretKey); - // Publish gift wrap to recipient's relays - await publishEventToRelays(giftWrap, targetRelays); + // Publish gift wrap to recipient's relays with error handling + try { + await publishEventToRelays(giftWrap, targetRelays); + console.log( + `[NIP-17] Sent message to ${recipientPubkey.slice(0, 8)}... via ${targetRelays.length} relays`, + ); + } catch (error) { + console.error( + "[NIP-17] Failed to publish gift wrap to recipient:", + error, + ); + throw new Error( + `Failed to send message: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } // Also send a copy to ourselves (for sent message history) - const selfGiftWrapDraft = { - kind: 1059, - content: nip44.encrypt( - sealJSON, - nip44.utils.getConversationKey(ephemeralSecretKey, activePubkey), - ), - tags: [["p", activePubkey]], - created_at: this.randomPastTimestamp(), - }; + // Don't fail the whole operation if this fails + try { + const selfConversationKey = nip44.getConversationKey( + ephemeralSecretKey, + activePubkey, + ); + const selfGiftWrapDraft = { + kind: 1059, + content: nip44.encrypt(sealJSON, selfConversationKey), + tags: [["p", activePubkey]], + created_at: this.randomPastTimestamp(), + }; - const selfGiftWrap = finalizeEvent(selfGiftWrapDraft, ephemeralSecretKey); - await publishEventToRelays(selfGiftWrap, senderDMRelays); - - console.log( - `[NIP-17] Sent message to ${recipientPubkey.slice(0, 8)}... via ${targetRelays.length} relays`, - ); + const selfGiftWrap = finalizeEvent(selfGiftWrapDraft, ephemeralSecretKey); + await publishEventToRelays(selfGiftWrap, senderDMRelays); + } catch (error) { + console.warn( + "[NIP-17] Failed to save copy to own relays (non-fatal):", + error, + ); + // Don't throw - message was already sent to recipient + } } /** @@ -400,17 +411,12 @@ export class Nip17Adapter extends ChatProtocolAdapter { */ private async getDMRelays(pubkey: string): Promise { // Try to get kind 10050 from event store - const dmRelayEvent = eventStore.get( - eventStore - .getAll() - .filter((e) => e.kind === 10050 && e.pubkey === pubkey) - .sort((a, b) => b.created_at - a.created_at)[0]?.id || "", - ); + const dmRelayEvent = eventStore.getReplaceable(10050, pubkey, ""); if (dmRelayEvent) { const relays = dmRelayEvent.tags - .filter((t) => t[0] === "relay" && t[1]) - .map((t) => t[1]); + .filter((t: string[]) => t[0] === "relay" && t[1]) + .map((t: string[]) => t[1]); if (relays.length > 0) { return relays; diff --git a/src/services/gift-wrap.ts b/src/services/gift-wrap.ts index fb994c6..f3b3700 100644 --- a/src/services/gift-wrap.ts +++ b/src/services/gift-wrap.ts @@ -60,6 +60,7 @@ class GiftWrapManager { private lastSyncTimestamp: number = 0; // Last sync time (for incremental updates) private isAuthenticating = false; private authenticated = new Set(); // Track which relays are authenticated + private isSyncing = false; // Prevent concurrent sync attempts /** * Start syncing gift wraps for the active account @@ -68,6 +69,12 @@ class GiftWrapManager { * 3. Subscribes to gift wraps with pagination */ async startSync(): Promise { + // Prevent concurrent sync attempts + if (this.isSyncing) { + console.log("[GiftWrap] Sync already in progress, skipping"); + return; + } + const account = accountManager.active$.value; if (!account) { console.log("[GiftWrap] No active account"); @@ -77,88 +84,101 @@ class GiftWrapManager { const { pubkey } = account; console.log(`[GiftWrap] Starting sync for ${pubkey.slice(0, 8)}...`); - // Stop any existing sync - this.stopSync(); + this.isSyncing = true; - // Get user's DM relays (kind 10050) or fall back to general relays - const dmRelays = await this.getDMRelays(pubkey); - if (dmRelays.length === 0) { - console.warn("[GiftWrap] No DM relays found, cannot sync gift wraps"); - // TODO: Get general relays from user's relay list - return; - } + try { + // Stop any existing sync + this.stopSync(); - console.log(`[GiftWrap] Syncing from ${dmRelays.length} relays:`, dmRelays); + // Get user's DM relays (kind 10050) or fall back to general relays + const dmRelays = await this.getDMRelays(pubkey); + if (dmRelays.length === 0) { + console.warn("[GiftWrap] No DM relays found, cannot sync gift wraps"); + // TODO: Get general relays from user's relay list + return; + } - // Step 1: Authenticate with relays using dummy REQ - // This triggers NIP-42 AUTH which is required for relays to serve kind 1059 - await this.authenticateWithRelays(dmRelays, pubkey); - - // Step 2: Determine sync window (initial vs incremental) - const now = Math.floor(Date.now() / 1000); - const isInitialSync = this.lastSyncTimestamp === 0; - - let since: number | undefined; - if (isInitialSync) { - // Initial sync: Fetch last N days of gift wraps - since = now - GIFT_WRAP_CONFIG.MAX_STORAGE_DAYS * 24 * 60 * 60; console.log( - `[GiftWrap] Initial sync from ${new Date(since * 1000).toISOString()}`, + `[GiftWrap] Syncing from ${dmRelays.length} relays:`, + dmRelays, ); - } else { - // Incremental sync: Fetch only new gift wraps since last sync - since = this.lastSyncTimestamp; - console.log( - `[GiftWrap] Incremental sync from ${new Date(since * 1000).toISOString()}`, - ); - } - // Step 3: Subscribe to gift wraps with pagination - const filter: Filter = { - kinds: [1059], - "#p": [pubkey], - since, - limit: isInitialSync ? GIFT_WRAP_CONFIG.INITIAL_LIMIT : undefined, - }; + // Step 1: Authenticate with relays using dummy REQ + // This triggers NIP-42 AUTH which is required for relays to serve kind 1059 + await this.authenticateWithRelays(dmRelays, pubkey); - const subscription = pool - .subscription(dmRelays, [filter], { - eventStore, // Automatically add to event store - }) - .subscribe({ - next: (response) => { - if (typeof response === "string") { - console.log("[GiftWrap] EOSE received"); - // Update last sync timestamp after EOSE - this.lastSyncTimestamp = now; - } else { - console.log( - `[GiftWrap] Received gift wrap: ${response.id.slice(0, 8)}...`, - ); - // Process gift wrap asynchronously - this.processGiftWrap(response, pubkey).catch((error) => { - console.error( - `[GiftWrap] Error processing ${response.id.slice(0, 8)}:`, - error, + // Step 2: Determine sync window (initial vs incremental) + const now = Math.floor(Date.now() / 1000); + const isInitialSync = this.lastSyncTimestamp === 0; + + let since: number | undefined; + if (isInitialSync) { + // Initial sync: Fetch last N days of gift wraps + since = now - GIFT_WRAP_CONFIG.MAX_STORAGE_DAYS * 24 * 60 * 60; + console.log( + `[GiftWrap] Initial sync from ${new Date(since * 1000).toISOString()}`, + ); + } else { + // Incremental sync: Fetch only new gift wraps since last sync + since = this.lastSyncTimestamp; + console.log( + `[GiftWrap] Incremental sync from ${new Date(since * 1000).toISOString()}`, + ); + } + + // Step 3: Subscribe to gift wraps with pagination + const filter: Filter = { + kinds: [1059], + "#p": [pubkey], + since, + limit: isInitialSync ? GIFT_WRAP_CONFIG.INITIAL_LIMIT : undefined, + }; + + const subscription = pool + .subscription(dmRelays, [filter], { + eventStore, // Automatically add to event store + }) + .subscribe({ + next: (response) => { + if (typeof response === "string") { + console.log("[GiftWrap] EOSE received"); + // Update last sync timestamp after EOSE + this.lastSyncTimestamp = now; + } else { + console.log( + `[GiftWrap] Received gift wrap: ${response.id.slice(0, 8)}...`, ); - }); - } - }, - error: (error) => { - console.error("[GiftWrap] Subscription error:", error); - }, - }); + // Process gift wrap asynchronously + this.processGiftWrap(response, pubkey).catch((error) => { + console.error( + `[GiftWrap] Error processing ${response.id.slice(0, 8)}:`, + error, + ); + }); + } + }, + error: (error) => { + console.error("[GiftWrap] Subscription error:", error); + }, + }); - this.subscriptions.set(pubkey, subscription); + this.subscriptions.set(pubkey, subscription); - // Process any existing gift wraps in the event store (from previous sessions) - await this.processExistingGiftWraps(pubkey); + // Process any existing gift wraps in the event store (from previous sessions) + await this.processExistingGiftWraps(pubkey); - // Update stats - await this.updateStats(); + // Update stats + await this.updateStats(); - // Clean up old gift wraps - await this.cleanupOldGiftWraps(); + // Clean up old gift wraps + await this.cleanupOldGiftWraps(); + } catch (error) { + console.error("[GiftWrap] Fatal error during sync:", error); + // Clean up on error + this.stopSync(); + } finally { + this.isSyncing = false; + } } /** @@ -198,27 +218,34 @@ class GiftWrapManager { // Create a promise that resolves after timeout or first event await new Promise((resolve) => { + let subscription: Subscription | null = null; + const timeout = setTimeout(() => { - subscription.unsubscribe(); + if (subscription) { + subscription.unsubscribe(); + } resolve(); }, GIFT_WRAP_CONFIG.AUTH_TIMEOUT_MS); - const subscription = pool - .subscription(relays, [dummyFilter], {}) - .subscribe({ - next: (response) => { - // Got EOSE or event, auth likely completed - if (typeof response === "string") { - clearTimeout(timeout); - subscription.unsubscribe(); - resolve(); - } - }, - error: () => { + subscription = pool.subscription(relays, [dummyFilter], {}).subscribe({ + next: (response) => { + // Got EOSE or event, auth likely completed + if (typeof response === "string") { clearTimeout(timeout); + if (subscription) { + subscription.unsubscribe(); + } resolve(); - }, - }); + } + }, + error: () => { + clearTimeout(timeout); + if (subscription) { + subscription.unsubscribe(); + } + resolve(); + }, + }); }); console.log("[GiftWrap] Authentication complete"); @@ -358,18 +385,13 @@ class GiftWrapManager { */ private async getDMRelays(pubkey: string): Promise { // Try to get kind 10050 from event store - const dmRelayEvent = eventStore.get( - eventStore - .getAll() - .filter((e) => e.kind === 10050 && e.pubkey === pubkey) - .sort((a, b) => b.created_at - a.created_at)[0]?.id || "", - ); + const dmRelayEvent = eventStore.getReplaceable(10050, pubkey, ""); if (dmRelayEvent) { // Extract relay URLs from "relay" tags const relays = dmRelayEvent.tags - .filter((t) => t[0] === "relay" && t[1]) - .map((t) => t[1]); + .filter((t: string[]) => t[0] === "relay" && t[1]) + .map((t: string[]) => t[1]); if (relays.length > 0) { return relays; @@ -387,13 +409,10 @@ class GiftWrapManager { console.log("[GiftWrap] Processing existing gift wraps..."); // Get all kind 1059 events addressed to us - const giftWraps = eventStore - .getAll() - .filter( - (e) => - e.kind === 1059 && - e.tags.some((t) => t[0] === "p" && t[1] === pubkey), - ); + const giftWraps = eventStore.getByFilters({ + kinds: [1059], + "#p": [pubkey], + }); console.log(`[GiftWrap] Found ${giftWraps.length} existing gift wraps`); @@ -587,34 +606,46 @@ class GiftWrapManager { } /** - * Update statistics + * Update statistics (optimized to avoid loading all records into memory) */ private async updateStats(): Promise { - const decryptions = await db.giftWrapDecryptions.toArray(); + // Use Dexie's count() to avoid loading all records + const totalGiftWraps = await db.giftWrapDecryptions.count(); + const successfulDecryptions = await db.giftWrapDecryptions + .where("decryptionState") + .equals("success") + .count(); + const failedDecryptions = await db.giftWrapDecryptions + .where("decryptionState") + .equals("failed") + .count(); + const pendingDecryptions = await db.giftWrapDecryptions + .where("decryptionState") + .equals("pending") + .count(); - // Find oldest and newest gift wrap timestamps + // Find oldest and newest gift wrap timestamps efficiently let oldestTimestamp: number | undefined; let newestTimestamp: number | undefined; - if (decryptions.length > 0) { - const timestamps = decryptions - .map((d) => d.lastAttempt) - .sort((a, b) => a - b); - oldestTimestamp = timestamps[0]; - newestTimestamp = timestamps[timestamps.length - 1]; + if (totalGiftWraps > 0) { + const oldest = await db.giftWrapDecryptions + .orderBy("lastAttempt") + .first(); + const newest = await db.giftWrapDecryptions + .orderBy("lastAttempt") + .reverse() + .first(); + + oldestTimestamp = oldest?.lastAttempt; + newestTimestamp = newest?.lastAttempt; } const stats: GiftWrapStats = { - totalGiftWraps: decryptions.length, - successfulDecryptions: decryptions.filter( - (d) => d.decryptionState === "success", - ).length, - failedDecryptions: decryptions.filter( - (d) => d.decryptionState === "failed", - ).length, - pendingDecryptions: decryptions.filter( - (d) => d.decryptionState === "pending", - ).length, + totalGiftWraps, + successfulDecryptions, + failedDecryptions, + pendingDecryptions, oldestGiftWrap: oldestTimestamp, newestGiftWrap: newestTimestamp, };