diff --git a/src/components/DMProvider.tsx b/src/components/DMProvider.tsx deleted file mode 100644 index 1c271d43..00000000 --- a/src/components/DMProvider.tsx +++ /dev/null @@ -1,1580 +0,0 @@ -import { useEffect, useState, ReactNode, useCallback, useMemo, useRef } from 'react'; -import { useMutation } from '@tanstack/react-query'; -import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useNostr } from '@nostrify/react'; -import { useAppContext } from '@/hooks/useAppContext'; -import { useNostrPublish } from '@/hooks/useNostrPublish'; -import { useToast } from '@/hooks/useToast'; -import { validateDMEvent } from '@/lib/dmUtils'; -import { LOADING_PHASES, type LoadingPhase, PROTOCOL_MODE, type ProtocolMode } from '@/lib/dmConstants'; -import { NSecSigner, type NostrEvent } from '@nostrify/nostrify'; -import { generateSecretKey } from 'nostr-tools'; -import type { MessageProtocol } from '@/lib/dmConstants'; -import { MESSAGE_PROTOCOL } from '@/lib/dmConstants'; -import { DMContext, DMContextType, FileAttachment } from '@/contexts/DMContext'; - -// ============================================================================ -// DM Types and Constants -// ============================================================================ - -interface ParticipantData { - messages: DecryptedMessage[]; - lastActivity: number; - lastMessage: DecryptedMessage | null; - hasNIP4: boolean; - hasNIP17: boolean; -} - -type MessagesState = Map; - -interface LastSyncData { - nip4: number | null; - nip17: number | null; -} - -interface SubscriptionStatus { - isNIP4Connected: boolean; - isNIP17Connected: boolean; -} - -interface ScanProgress { - current: number; - status: string; -} - -interface ScanProgressState { - nip4: ScanProgress | null; - nip17: ScanProgress | null; -} - -interface ConversationSummary { - id: string; - pubkey: string; - lastMessage: DecryptedMessage | null; - lastActivity: number; - hasNIP4Messages: boolean; - hasNIP17Messages: boolean; - isKnown: boolean; - isRequest: boolean; - lastMessageFromUser: boolean; -} - -interface MessageProcessingResult { - lastMessageTimestamp?: number; - messageCount: number; -} - -interface DecryptionResult { - decryptedContent: string; - error?: string; -} - -interface DecryptedMessage extends NostrEvent { - decryptedContent?: string; - error?: string; - isSending?: boolean; - clientFirstSeen?: number; - decryptedEvent?: NostrEvent; // For NIP-17: the inner kind 14/15 event - originalGiftWrapId?: string; // Store gift wrap ID for NIP-17 deduplication -} - -interface NIP17ProcessingResult { - processedMessage: DecryptedMessage; - conversationPartner: string; - sealEvent: NostrEvent; // Return the seal so we can cache it -} - -const DM_CONSTANTS = { - DEBOUNCED_WRITE_DELAY: 15000, - RECENT_MESSAGE_THRESHOLD: 5000, - SUBSCRIPTION_OVERLAP_SECONDS: 10, // Overlap for subscriptions to catch race conditions - SCAN_TOTAL_LIMIT: 20000, - SCAN_BATCH_SIZE: 1000, - NIP4_QUERY_TIMEOUT: 15000, - NIP17_QUERY_TIMEOUT: 30000, - ERROR_LOG_DEBOUNCE_DELAY: 2000, -} as const; - -const SCAN_STATUS_MESSAGES = { - NIP4_STARTING: 'Starting NIP-4 scan...', - NIP17_STARTING: 'Starting NIP-17 scan...', -} as const; - -const createErrorLogger = (name: string) => { - let count = 0; - let timeout: NodeJS.Timeout | null = null; - - return (_error: Error) => { - count++; - if (timeout) clearTimeout(timeout); - timeout = setTimeout(() => { - if (count > 0) { - console.error(`[DM] ${name} processing complete with ${count} errors`); - count = 0; - } - }, DM_CONSTANTS.ERROR_LOG_DEBOUNCE_DELAY); - }; -}; - -const nip17ErrorLogger = createErrorLogger('NIP-17'); - -export interface DMConfig { - enabled?: boolean; - protocolMode?: ProtocolMode; -} - -interface DMProviderProps { - children: ReactNode; - config?: DMConfig; -} - -// ============================================================================ -// Message Sending Types and Helpers (Internal) -// ============================================================================ - -/** - * Prepare message content with file URLs appended - */ -function prepareMessageContent(content: string, attachments: FileAttachment[] = []): string { - if (attachments.length === 0) return content; - - const fileUrls = attachments.map(file => file.url).join('\n'); - return content ? `${content}\n\n${fileUrls}` : fileUrls; -} - -/** - * Create imeta tags for file attachments (NIP-92) - */ -function createImetaTags(attachments: FileAttachment[] = []): string[][] { - return attachments.map(file => { - const imetaTag = ['imeta']; - imetaTag.push(`url ${file.url}`); - if (file.mimeType) imetaTag.push(`m ${file.mimeType}`); - if (file.size) imetaTag.push(`size ${file.size}`); - if (file.name) imetaTag.push(`alt ${file.name}`); - - // Add hash tags from file.tags - file.tags.forEach(tag => { - if (tag[0] === 'x') imetaTag.push(`x ${tag[1]}`); - if (tag[0] === 'ox') imetaTag.push(`ox ${tag[1]}`); - }); - - return imetaTag; - }); -} - -// ============================================================================ -// DMProvider Component -// ============================================================================ - -export function DMProvider({ children, config }: DMProviderProps) { - const { enabled = false, protocolMode = PROTOCOL_MODE.NIP17_ONLY } = config || {}; - const { user } = useCurrentUser(); - const { nostr } = useNostr(); - const { mutateAsync: createEvent } = useNostrPublish(); - const { toast } = useToast(); - const { config: appConfig } = useAppContext(); - - const userPubkey = useMemo(() => user?.pubkey, [user?.pubkey]); - - // Track relay metadata to detect changes - const previousRelayMetadata = useRef(appConfig.relayMetadata); - - // Determine if NIP-17 is enabled based on protocol mode - const enableNIP17 = protocolMode !== PROTOCOL_MODE.NIP04_ONLY; - - const [messages, setMessages] = useState(new Map()); - const [lastSync, setLastSync] = useState({ - nip4: null, - nip17: null - }); - const [isLoading, setIsLoading] = useState(false); - const [loadingPhase, setLoadingPhase] = useState(LOADING_PHASES.IDLE); - const [subscriptions, setSubscriptions] = useState({ - isNIP4Connected: false, - isNIP17Connected: false - }); - const [hasInitialLoadCompleted, setHasInitialLoadCompleted] = useState(false); - const [shouldSaveImmediately, setShouldSaveImmediately] = useState(false); - const [scanProgress, setScanProgress] = useState({ - nip4: null, - nip17: null - }); - - const nip4SubscriptionRef = useRef<{ close: () => void } | null>(null); - const nip17SubscriptionRef = useRef<{ close: () => void } | null>(null); - const debouncedWriteRef = useRef(null); - - // Reset all DM state when the user account changes. Without this, switching - // accounts leaves the previous user's decrypted conversations visible and - // prevents new subscriptions from starting (hasInitialLoadCompleted stays true). - const prevDMPubkey = useRef(undefined); - useEffect(() => { - const pubkey = userPubkey; - if (prevDMPubkey.current !== undefined && pubkey !== prevDMPubkey.current) { - // Close existing subscriptions - if (nip4SubscriptionRef.current) { - nip4SubscriptionRef.current.close(); - nip4SubscriptionRef.current = null; - } - if (nip17SubscriptionRef.current) { - nip17SubscriptionRef.current.close(); - nip17SubscriptionRef.current = null; - } - if (debouncedWriteRef.current) { - clearTimeout(debouncedWriteRef.current); - debouncedWriteRef.current = null; - } - - // Reset all state so the main loading effect re-triggers for the new user - setMessages(new Map()); - setLastSync({ nip4: null, nip17: null }); - setIsLoading(false); - setLoadingPhase(LOADING_PHASES.IDLE); - setSubscriptions({ isNIP4Connected: false, isNIP17Connected: false }); - setHasInitialLoadCompleted(false); - setScanProgress({ nip4: null, nip17: null }); - } - prevDMPubkey.current = pubkey; - }, [userPubkey]); - - // ============================================================================ - // Internal Message Sending Mutations - // ============================================================================ - - // Send NIP-04 Message (internal) - const sendNIP4Message = useMutation({ - mutationFn: async ({ recipientPubkey, content, attachments = [] }) => { - if (!user) { - throw new Error('User is not logged in'); - } - - if (!user.signer.nip04) { - throw new Error('NIP-04 encryption not available'); - } - - // Prepare content with file URLs - const messageContent = prepareMessageContent(content, attachments); - - // Encrypt the content - const encryptedContent = await user.signer.nip04.encrypt(recipientPubkey, messageContent); - - // Build tags with imeta tags for attachments - const tags: string[][] = [ - ['p', recipientPubkey], - ...createImetaTags(attachments) - ]; - - // Create and publish the event - return await createEvent({ - kind: 4, - content: encryptedContent, - tags, - }); - }, - onError: (error) => { - console.error('[DM] Failed to send NIP-04 message:', error); - toast({ - title: 'Failed to send message', - description: error.message, - variant: 'destructive', - }); - }, - }); - - // Send NIP-17 Message (internal) - const sendNIP17Message = useMutation({ - mutationFn: async ({ recipientPubkey, content, attachments = [] }) => { - if (!user) { - throw new Error('User is not logged in'); - } - - if (!user.signer.nip44) { - throw new Error('NIP-44 encryption not available'); - } - - // Step 1: Create the inner Kind 14 Private Direct Message - const now = Math.floor(Date.now() / 1000); - - // Generate randomized timestamps for gift wraps (NIP-59 metadata privacy) - // Randomize within ±2 days in the PAST only (relays reject future timestamps > +30min) - const randomizeTimestamp = (baseTime: number) => { - const twoDaysInSeconds = 2 * 24 * 60 * 60; - // Random offset between -2 days and 0 (never future) - const randomOffset = -Math.floor(Math.random() * twoDaysInSeconds); - return baseTime + randomOffset; - }; - - // Prepare content with file URLs - const messageContent = prepareMessageContent(content, attachments); - - // Build tags with imeta tags for attachments - const tags: string[][] = [ - ['p', recipientPubkey], - ...createImetaTags(attachments) - ]; - - // Use kind 15 for messages with file attachments, kind 14 for text-only - const messageKind = (attachments && attachments.length > 0) ? 15 : 14; - - const privateMessage: Omit = { - kind: messageKind, - pubkey: user.pubkey, - created_at: now, - tags, - content: messageContent, - }; - - // Step 2: Create TWO Kind 13 Seal events (one for recipient, one for myself) - const recipientSeal: Omit = { - kind: 13, - pubkey: user.pubkey, - created_at: now, - tags: [], - content: await user.signer.nip44.encrypt(recipientPubkey, JSON.stringify(privateMessage)), - }; - - const senderSeal: Omit = { - kind: 13, - pubkey: user.pubkey, - created_at: now, - tags: [], - content: await user.signer.nip44.encrypt(user.pubkey, JSON.stringify(privateMessage)), - }; - - // Step 3: Create TWO Kind 1059 Gift Wrap events - // Per NIP-17/NIP-59: Gift wraps MUST be signed with random, ephemeral keys - // to hide the sender's identity and provide - some - metadata privacy - - // Generate random secret keys for each gift wrap - const recipientRandomKey = generateSecretKey(); - const senderRandomKey = generateSecretKey(); - - // Create signers with the random keys - const recipientRandomSigner = new NSecSigner(recipientRandomKey); - const senderRandomSigner = new NSecSigner(senderRandomKey); - - // Encrypt the seals using the RANDOM signers (so recipient can decrypt with the random pubkey) - // The recipient will decrypt using the gift wrap's pubkey (the random ephemeral key) - const recipientGiftWrapContent = await recipientRandomSigner.nip44!.encrypt(recipientPubkey, JSON.stringify(recipientSeal)); - const senderGiftWrapContent = await senderRandomSigner.nip44!.encrypt(user.pubkey, JSON.stringify(senderSeal)); - - // Sign both gift wraps with random keys and randomized timestamps - // Random keys hide the sender's identity; encryption to recipient allows decryption - const [recipientGiftWrap, senderGiftWrap] = await Promise.all([ - recipientRandomSigner.signEvent({ - kind: 1059, - created_at: randomizeTimestamp(now), // Randomized to hide real send time - tags: [['p', recipientPubkey]], - content: recipientGiftWrapContent, - }), - senderRandomSigner.signEvent({ - kind: 1059, - created_at: randomizeTimestamp(now), // Randomized to hide real send time - tags: [['p', user.pubkey]], - content: senderGiftWrapContent, - }), - ]); - - // Publish both to relays - try { - const results = await Promise.allSettled([ - nostr.event(recipientGiftWrap), - nostr.event(senderGiftWrap), - ]); - - // Check for failures and log detailed errors - const recipientResult = results[0]; - const senderResult = results[1]; - - if (recipientResult.status === 'rejected') { - console.error('[DM] Failed to publish recipient gift wrap'); - console.error('[DM] Recipient gift wrap event:', recipientGiftWrap); - - // Try to extract detailed errors from AggregateError - const error = recipientResult.reason; - if (error && typeof error === 'object' && 'errors' in error) { - console.error('[DM] Recipient individual relay errors:', error.errors); - } else { - console.error('[DM] Recipient error:', error); - } - } - - if (senderResult.status === 'rejected') { - console.error('[DM] Failed to publish sender gift wrap'); - console.error('[DM] Sender gift wrap event:', senderGiftWrap); - - // Try to extract detailed errors from AggregateError - const error = senderResult.reason; - if (error && typeof error === 'object' && 'errors' in error) { - console.error('[DM] Sender individual relay errors:', error.errors); - } else { - console.error('[DM] Sender error:', error); - } - } - - // If both failed, throw error - if (recipientResult.status === 'rejected' && senderResult.status === 'rejected') { - throw new Error(`Both gift wraps rejected. Recipient: ${recipientResult.reason}, Sender: ${senderResult.reason}`); - } - } catch (publishError) { - console.error('[DM] Publish error:', publishError); - throw publishError; - } - - return recipientGiftWrap; - }, - onError: (error) => { - console.error('[DM] Failed to send NIP-17 message:', error); - toast({ - title: 'Failed to send message', - description: error.message, - variant: 'destructive', - }); - }, - }); - - // ============================================================================ - // Message Loading and Processing - // ============================================================================ - - // Load past NIP-4 messages - const loadPastNIP4Messages = useCallback(async (sinceTimestamp?: number) => { - if (!user?.pubkey) return; - - let allMessages: NostrEvent[] = []; - let processedMessages = 0; - let currentSince = sinceTimestamp || 0; - - - setScanProgress(prev => ({ ...prev, nip4: { current: 0, status: SCAN_STATUS_MESSAGES.NIP4_STARTING } })); - - while (processedMessages < DM_CONSTANTS.SCAN_TOTAL_LIMIT) { - const batchLimit = Math.min(DM_CONSTANTS.SCAN_BATCH_SIZE, DM_CONSTANTS.SCAN_TOTAL_LIMIT - processedMessages); - - const filters = [ - { kinds: [4], '#p': [user.pubkey], limit: batchLimit, since: currentSince }, - { kinds: [4], authors: [user.pubkey], limit: batchLimit, since: currentSince } - ]; - - try { - const batchDMs = await nostr.query(filters, { signal: AbortSignal.timeout(DM_CONSTANTS.NIP4_QUERY_TIMEOUT) }); - const validBatchDMs = batchDMs.filter(validateDMEvent); - - if (validBatchDMs.length === 0) break; - - allMessages = [...allMessages, ...validBatchDMs]; - processedMessages += validBatchDMs.length; - - setScanProgress(prev => ({ - ...prev, - nip4: { - current: allMessages.length, - status: `Batch ${Math.floor(processedMessages / DM_CONSTANTS.SCAN_BATCH_SIZE) + 1} complete: ${validBatchDMs.length} messages` - } - })); - - const oldestToMe = validBatchDMs.filter(m => m.pubkey !== user.pubkey).length > 0 - ? Math.min(...validBatchDMs.filter(m => m.pubkey !== user.pubkey).map(m => m.created_at)) - : Infinity; - const oldestFromMe = validBatchDMs.filter(m => m.pubkey === user.pubkey).length > 0 - ? Math.min(...validBatchDMs.filter(m => m.pubkey === user.pubkey).map(m => m.created_at)) - : Infinity; - - const oldestInBatch = Math.min(oldestToMe, oldestFromMe); - if (oldestInBatch !== Infinity) { - currentSince = oldestInBatch; - } - - if (validBatchDMs.length < batchLimit * 2) break; - } catch (error) { - console.error('[DM] NIP-4 Error in batch query:', error); - break; - } - } - - setScanProgress(prev => ({ ...prev, nip4: null })); - return allMessages; - }, [user, nostr]); - - // Load past NIP-17 messages - const loadPastNIP17Messages = useCallback(async (sinceTimestamp?: number) => { - if (!user?.pubkey) return; - - let allNIP17Events: NostrEvent[] = []; - let processedMessages = 0; - - // Adjust since timestamp to account for NIP-17 timestamp fuzzing (±2 days) - // We need to query from (lastSync - 2 days) to catch messages with randomized past timestamps - // This may fetch duplicates, but they're filtered by message ID in addMessageToState - const TWO_DAYS_IN_SECONDS = 2 * 24 * 60 * 60; - let currentSince = sinceTimestamp ? sinceTimestamp - TWO_DAYS_IN_SECONDS : 0; - - - setScanProgress(prev => ({ ...prev, nip17: { current: 0, status: SCAN_STATUS_MESSAGES.NIP17_STARTING } })); - - while (processedMessages < DM_CONSTANTS.SCAN_TOTAL_LIMIT) { - const batchLimit = Math.min(DM_CONSTANTS.SCAN_BATCH_SIZE, DM_CONSTANTS.SCAN_TOTAL_LIMIT - processedMessages); - - const filters = [ - { kinds: [1059], '#p': [user.pubkey], limit: batchLimit, since: currentSince } - ]; - - try { - const batchEvents = await nostr.query(filters, { signal: AbortSignal.timeout(DM_CONSTANTS.NIP17_QUERY_TIMEOUT) }); - - if (batchEvents.length === 0) break; - - allNIP17Events = [...allNIP17Events, ...batchEvents]; - processedMessages += batchEvents.length; - - setScanProgress(prev => ({ - ...prev, - nip17: { - current: allNIP17Events.length, - status: `Batch ${Math.floor(processedMessages / DM_CONSTANTS.SCAN_BATCH_SIZE) + 1} complete: ${batchEvents.length} messages` - } - })); - - if (batchEvents.length > 0) { - const oldestInBatch = Math.min(...batchEvents.map(m => m.created_at)); - currentSince = oldestInBatch; - } - - if (batchEvents.length < batchLimit) break; - } catch (error) { - console.error('[DM] NIP-17 Error in batch query:', error); - break; - } - } - - setScanProgress(prev => ({ ...prev, nip17: null })); - return allNIP17Events; - }, [user, nostr]); - - // Query relays for messages - const queryRelaysForMessagesSince = useCallback(async (protocol: MessageProtocol, sinceTimestamp?: number): Promise => { - if (protocol === MESSAGE_PROTOCOL.NIP17 && !enableNIP17) { - return { lastMessageTimestamp: sinceTimestamp, messageCount: 0 }; - } - - if (!userPubkey) { - return { lastMessageTimestamp: sinceTimestamp, messageCount: 0 }; - } - - if (protocol === MESSAGE_PROTOCOL.NIP04) { - const messages = await loadPastNIP4Messages(sinceTimestamp); - - if (messages && messages.length > 0) { - const newState = new Map(); - - for (const message of messages) { - const isFromUser = message.pubkey === user?.pubkey; - const recipientPTag = message.tags?.find(([name]) => name === 'p')?.[1]; - const otherPubkey = isFromUser ? recipientPTag : message.pubkey; - - if (!otherPubkey || otherPubkey === user?.pubkey) continue; - - const { decryptedContent, error } = await decryptNIP4Message(message, otherPubkey); - - const decryptedMessage: DecryptedMessage = { - ...message, - content: message.content, - decryptedContent: decryptedContent, - error: error, - }; - - const messageAge = Date.now() - (message.created_at * 1000); - if (messageAge < 5000) { - decryptedMessage.clientFirstSeen = Date.now(); - } - - if (!newState.has(otherPubkey)) { - newState.set(otherPubkey, createEmptyParticipant()); - } - - const participant = newState.get(otherPubkey)!; - participant.messages.push(decryptedMessage); - participant.hasNIP4 = true; - } - - newState.forEach(participant => { - sortAndUpdateParticipantState(participant); - }); - - mergeMessagesIntoState(newState); - - const currentTime = Math.floor(Date.now() / 1000); - setLastSync(prev => ({ ...prev, nip4: currentTime })); - - const newestMessage = messages.reduce((newest, msg) => - msg.created_at > newest.created_at ? msg : newest - ); - return { lastMessageTimestamp: newestMessage.created_at, messageCount: messages.length }; - } else { - // No new messages, but we still successfully queried relays - update lastSync - const currentTime = Math.floor(Date.now() / 1000); - setLastSync(prev => ({ ...prev, nip4: currentTime })); - return { lastMessageTimestamp: sinceTimestamp, messageCount: 0 }; - } - } else if (protocol === MESSAGE_PROTOCOL.NIP17) { - const messages = await loadPastNIP17Messages(sinceTimestamp); - - if (messages && messages.length > 0) { - const newState = new Map(); - - for (const giftWrap of messages) { - try { - const { processedMessage, conversationPartner, sealEvent } = await processNIP17GiftWrap(giftWrap); - - // Skip messages with decryption errors - if (processedMessage.error) { - continue; - } - - // Store the seal (kind 13) as-is + add decryptedEvent for inner message access - const messageWithAnimation: DecryptedMessage = { - ...sealEvent, // Seal fields (kind 13, seal pubkey, encrypted content, etc.) - created_at: processedMessage.created_at, // Use real timestamp from inner message - decryptedEvent: { - ...processedMessage, - content: processedMessage.decryptedContent, - } as NostrEvent, - decryptedContent: processedMessage.decryptedContent, - originalGiftWrapId: giftWrap.id, // Store gift wrap ID for deduplication - }; - - // Use real message timestamp for recency check - const messageAge = Date.now() - (processedMessage.created_at * 1000); - if (messageAge < 5000) { - messageWithAnimation.clientFirstSeen = Date.now(); - } - - if (!newState.has(conversationPartner)) { - newState.set(conversationPartner, createEmptyParticipant()); - } - - newState.get(conversationPartner)!.messages.push(messageWithAnimation); - newState.get(conversationPartner)!.hasNIP17 = true; - } catch (error) { - console.error('[DM] Error processing gift wrap from relay:', error); - } - } - - newState.forEach(participant => { - sortAndUpdateParticipantState(participant); - }); - - mergeMessagesIntoState(newState); - - const currentTime = Math.floor(Date.now() / 1000); - setLastSync(prev => ({ ...prev, nip17: currentTime })); - - const newestMessage = messages.reduce((newest, msg) => - msg.created_at > newest.created_at ? msg : newest - ); - return { lastMessageTimestamp: newestMessage.created_at, messageCount: messages.length }; - } else { - // No new messages, but we still successfully queried relays - update lastSync - const currentTime = Math.floor(Date.now() / 1000); - setLastSync(prev => ({ ...prev, nip17: currentTime })); - return { lastMessageTimestamp: sinceTimestamp, messageCount: 0 }; - } - } - - return { lastMessageTimestamp: sinceTimestamp, messageCount: 0 }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enableNIP17, userPubkey, loadPastNIP4Messages, loadPastNIP17Messages, user]); - - // Decrypt NIP-4 message - const decryptNIP4Message = useCallback(async (event: NostrEvent, otherPubkey: string): Promise => { - try { - if (user?.signer?.nip04) { - const decryptedContent = await user.signer.nip04.decrypt(otherPubkey, event.content); - return { decryptedContent }; - } else { - return { - decryptedContent: '', - error: 'No NIP-04 decryption available' - }; - } - } catch (error) { - console.error(`[DM] Failed to decrypt NIP-4 message ${event.id}:`, error); - return { - decryptedContent: '', - error: 'Decryption failed' - }; - } - }, [user]); - - // Create empty participant - const createEmptyParticipant = useCallback(() => ({ - messages: [], - lastActivity: 0, - lastMessage: null, - hasNIP4: false, - hasNIP17: false, - }), []); - - // Sort and update participant state - const sortAndUpdateParticipantState = useCallback((participant: { messages: DecryptedMessage[]; lastActivity: number; lastMessage: DecryptedMessage | null }) => { - participant.messages.sort((a, b) => a.created_at - b.created_at); - if (participant.messages.length > 0) { - participant.lastActivity = participant.messages[participant.messages.length - 1].created_at; - participant.lastMessage = participant.messages[participant.messages.length - 1]; - } - }, []); - - // Merge messages into state - const mergeMessagesIntoState = useCallback((newState: MessagesState) => { - setMessages(prev => { - const finalMap = new Map(prev); - - newState.forEach((value, key) => { - const existing = finalMap.get(key); - if (existing) { - // For NIP-17 messages with originalGiftWrapId, dedupe by gift wrap ID - // For NIP-04 and cached NIP-17 messages, dedupe by message ID - const existingMessageIds = new Set( - existing.messages.map(msg => msg.originalGiftWrapId || msg.id) - ); - const newMessages = value.messages.filter(msg => - !existingMessageIds.has(msg.originalGiftWrapId || msg.id) - ); - - const mergedMessages = [...existing.messages, ...newMessages]; - mergedMessages.sort((a, b) => a.created_at - b.created_at); - - // Recalculate lastActivity and lastMessage after merging - const lastMessage = mergedMessages.length > 0 ? mergedMessages[mergedMessages.length - 1] : null; - const lastActivity = lastMessage ? lastMessage.created_at : existing.lastActivity; - - finalMap.set(key, { - ...existing, - messages: mergedMessages, - lastActivity, - lastMessage, - hasNIP4: existing.hasNIP4 || value.hasNIP4, - hasNIP17: existing.hasNIP17 || value.hasNIP17, - }); - } else { - finalMap.set(key, value); - } - }); - - return finalMap; - }); - }, []); - - // Add message to state - const addMessageToState = useCallback((message: DecryptedMessage, conversationPartner: string, protocol: MessageProtocol) => { - setMessages(prev => { - const newMap = new Map(prev); - const existing = newMap.get(conversationPartner); - - if (existing) { - // For NIP-17 messages with originalGiftWrapId, dedupe by gift wrap ID - // For NIP-04 and cached NIP-17 messages, dedupe by message ID - const messageId = message.originalGiftWrapId || message.id; - if (existing.messages.some(msg => (msg.originalGiftWrapId || msg.id) === messageId)) { - return prev; - } - - const optimisticIndex = existing.messages.findIndex(msg => - msg.isSending && - msg.pubkey === message.pubkey && - msg.decryptedContent === message.decryptedContent && - Math.abs(msg.created_at - message.created_at) <= 30 - ); - - let updatedMessages: DecryptedMessage[]; - if (optimisticIndex !== -1) { - const existingMessage = existing.messages[optimisticIndex]; - updatedMessages = [...existing.messages]; - updatedMessages[optimisticIndex] = { - ...message, - created_at: existingMessage.created_at, - clientFirstSeen: existingMessage.clientFirstSeen - }; - } else { - updatedMessages = [...existing.messages, message]; - } - - updatedMessages.sort((a, b) => a.created_at - b.created_at); - - const actualLastMessage = updatedMessages[updatedMessages.length - 1]; - - newMap.set(conversationPartner, { - ...existing, - messages: updatedMessages, - lastActivity: actualLastMessage.created_at, - lastMessage: actualLastMessage, - hasNIP4: protocol === MESSAGE_PROTOCOL.NIP04 ? true : existing.hasNIP4, - hasNIP17: protocol === MESSAGE_PROTOCOL.NIP17 ? true : existing.hasNIP17, - }); - } else { - const newConversation = { - messages: [message], - lastActivity: message.created_at, - lastMessage: message, - hasNIP4: protocol === MESSAGE_PROTOCOL.NIP04, - hasNIP17: protocol === MESSAGE_PROTOCOL.NIP17, - }; - - newMap.set(conversationPartner, newConversation); - } - - return newMap; - }); - }, []); - - // Process incoming NIP-4 message - const processIncomingNIP4Message = useCallback(async (event: NostrEvent) => { - if (!user?.pubkey) return; - - if (!validateDMEvent(event)) return; - - const isFromUser = event.pubkey === user.pubkey; - const recipientPTag = event.tags?.find(([name]) => name === 'p')?.[1]; - const otherPubkey = isFromUser ? recipientPTag : event.pubkey; - - if (!otherPubkey || otherPubkey === user.pubkey) return; - - const { decryptedContent, error } = await decryptNIP4Message(event, otherPubkey); - - const decryptedMessage: DecryptedMessage = { - ...event, - content: event.content, - decryptedContent: decryptedContent, - error: error, - }; - - const messageAge = Date.now() - (event.created_at * 1000); - if (messageAge < 5000) { - decryptedMessage.clientFirstSeen = Date.now(); - } - - addMessageToState(decryptedMessage, otherPubkey, MESSAGE_PROTOCOL.NIP04); - }, [user, decryptNIP4Message, addMessageToState]); - - // Process NIP-17 Gift Wrap - const processNIP17GiftWrap = useCallback(async (event: NostrEvent): Promise => { - if (!user?.signer?.nip44) { - return { - processedMessage: { - ...event, - content: '', - decryptedContent: '', - error: 'No NIP-44 decryption available', - }, - conversationPartner: event.pubkey, - sealEvent: event, // Return the event itself as fallback - }; - } - - try { - // Decrypt using the ephemeral sender's pubkey (event.pubkey) - const sealContent = await user.signer.nip44.decrypt(event.pubkey, event.content); - const sealEvent = JSON.parse(sealContent) as NostrEvent; - - if (sealEvent.kind !== 13) { - console.log(`[DM] ⚠️ NIP-17 INVALID SEAL - expected kind 13, got ${sealEvent.kind}`, { - giftWrapId: event.id, - sealKind: sealEvent.kind, - }); - return { - processedMessage: { - ...event, - content: '', - decryptedContent: '', - error: `Invalid Seal format - expected kind 13, got ${sealEvent.kind}`, - }, - conversationPartner: event.pubkey, - sealEvent: event, // Return the gift wrap as fallback - }; - } - - const messageContent = await user.signer.nip44.decrypt(sealEvent.pubkey, sealEvent.content); - const messageEvent = JSON.parse(messageContent) as NostrEvent; - - // NIP-17: clients MUST verify that the inner rumor's pubkey matches the - // seal's pubkey. Without this check, anyone can gift-wrap a rumor whose - // `pubkey` field claims to be someone else and impersonate that user. - // The seal signature authenticates only the seal author, not whatever - // pubkey appears inside the (unsigned) rumor. - if (messageEvent.pubkey !== sealEvent.pubkey) { - console.log(`[DM] ⚠️ NIP-17 IMPERSONATION ATTEMPT - inner pubkey does not match seal pubkey`, { - giftWrapId: event.id, - sealPubkey: sealEvent.pubkey, - innerPubkey: messageEvent.pubkey, - }); - return { - processedMessage: { - ...event, - content: '', - decryptedContent: '', - error: 'Inner event pubkey does not match seal pubkey (possible impersonation)', - }, - conversationPartner: event.pubkey, - sealEvent, - }; - } - - // Accept both kind 14 (text) and kind 15 (files/attachments) - if (messageEvent.kind !== 14 && messageEvent.kind !== 15) { - console.log(`[DM] ⚠️ NIP-17 MESSAGE WITH UNSUPPORTED INNER EVENT KIND:`, { - giftWrapId: event.id, - innerKind: messageEvent.kind, - expectedKinds: [14, 15], - sealPubkey: sealEvent.pubkey, - messageEvent: messageEvent, - }); - return { - processedMessage: { - ...event, - content: '', - decryptedContent: '', - error: `Invalid message format - expected kind 14 or 15, got ${messageEvent.kind}`, - }, - conversationPartner: event.pubkey, - sealEvent, // Return the seal - }; - } - - let conversationPartner: string; - if (sealEvent.pubkey === user.pubkey) { - const recipient = messageEvent.tags.find(([name]) => name === 'p')?.[1]; - if (!recipient || recipient === user.pubkey) { - return { - processedMessage: { - ...event, - content: '', - decryptedContent: '', - error: 'Invalid recipient - malformed p tag', - }, - conversationPartner: event.pubkey, - sealEvent, // Return the seal - }; - } else { - conversationPartner = recipient; - } - } else { - conversationPartner = sealEvent.pubkey; - } - - return { - processedMessage: { - ...messageEvent, - id: messageEvent.id || `missing-nip17-inner-${messageEvent.created_at}-${messageEvent.pubkey.substring(0, 8)}-${messageEvent.content.substring(0, 16)}`, - decryptedContent: messageEvent.content, // Plaintext from inner message - }, - conversationPartner, - sealEvent, // Return the seal (kind 13) for storage - }; - } catch (error) { - console.error('[DM] Failed to process NIP-17 gift wrap:', { - giftWrapId: event.id, - error: error instanceof Error ? error.message : String(error), - }); - nip17ErrorLogger(error as Error); - return { - processedMessage: { - ...event, - content: '', - decryptedContent: '', - error: error instanceof Error ? error.message : 'Failed to decrypt or parse NIP-17 message', - }, - conversationPartner: event.pubkey, - sealEvent: event, // Return the gift wrap as fallback - }; - } - }, [user]); - - // Process incoming NIP-17 message - const processIncomingNIP17Message = useCallback(async (event: NostrEvent) => { - if (!user?.pubkey) return; - - if (event.kind !== 1059) return; - - try { - const { processedMessage, conversationPartner, sealEvent } = await processNIP17GiftWrap(event); - - // Check if decryption failed - if (processedMessage.error) { - console.error('[DM] NIP-17 message decryption failed:', { - giftWrapId: event.id, - error: processedMessage.error, - }); - nip17ErrorLogger(new Error(processedMessage.error)); - return; - } - - // Store the seal (kind 13) as-is + add decryptedEvent for inner message access - const messageWithAnimation: DecryptedMessage = { - ...sealEvent, // Seal fields (kind 13, seal pubkey, encrypted content, etc.) - created_at: processedMessage.created_at, // Use real timestamp from inner message - decryptedEvent: { - ...processedMessage, - content: processedMessage.decryptedContent, - } as NostrEvent, - decryptedContent: processedMessage.decryptedContent, - originalGiftWrapId: event.id, // Store gift wrap ID for deduplication - }; - - // Use real message timestamp for recency check - const messageAge = Date.now() - (processedMessage.created_at * 1000); - if (messageAge < 5000) { - messageWithAnimation.clientFirstSeen = Date.now(); - } - - addMessageToState(messageWithAnimation, conversationPartner, MESSAGE_PROTOCOL.NIP17); - } catch (error) { - console.error('[DM] Exception in processIncomingNIP17Message:', { - giftWrapId: event.id, - error: error instanceof Error ? error.message : String(error), - }); - nip17ErrorLogger(error as Error); - } - }, [user, processNIP17GiftWrap, addMessageToState]); - - // Start NIP-4 subscription - const startNIP4Subscription = useCallback(async (sinceTimestamp?: number) => { - if (!user?.pubkey || !nostr) return; - - if (nip4SubscriptionRef.current) { - nip4SubscriptionRef.current.close(); - } - - try { - let subscriptionSince = sinceTimestamp || Math.floor(Date.now() / 1000); - if (!sinceTimestamp && lastSync.nip4) { - subscriptionSince = lastSync.nip4 - DM_CONSTANTS.SUBSCRIPTION_OVERLAP_SECONDS; - } - - const filters = [ - { kinds: [4], '#p': [user.pubkey], since: subscriptionSince }, - { kinds: [4], authors: [user.pubkey], since: subscriptionSince } - ]; - - const subscription = nostr.req(filters); - let isActive = true; - - (async () => { - try { - for await (const msg of subscription) { - if (!isActive) break; - if (msg[0] === 'EVENT') { - await processIncomingNIP4Message(msg[2]); - } - } - } catch (error) { - if (isActive) { - console.error('[DM] NIP-4 subscription error:', error); - } - } - })(); - - nip4SubscriptionRef.current = { - close: () => { - isActive = false; - } - }; - - setSubscriptions(prev => ({ ...prev, isNIP4Connected: true })); - } catch (error) { - console.error('[DM] Failed to start NIP-4 subscription:', error); - setSubscriptions(prev => ({ ...prev, isNIP4Connected: false })); - } - }, [user, nostr, lastSync.nip4, processIncomingNIP4Message]); - - // Start NIP-17 subscription - const startNIP17Subscription = useCallback(async (sinceTimestamp?: number) => { - if (!user?.pubkey || !nostr || !enableNIP17) return; - - if (nip17SubscriptionRef.current) { - nip17SubscriptionRef.current.close(); - } - - try { - let subscriptionSince = sinceTimestamp || Math.floor(Date.now() / 1000); - if (!sinceTimestamp && lastSync.nip17) { - subscriptionSince = lastSync.nip17 - DM_CONSTANTS.SUBSCRIPTION_OVERLAP_SECONDS; - } - - // Adjust for NIP-17 timestamp fuzzing (±2 days) - // Subscribe from (lastSync - 2 days) to catch messages with randomized past timestamps - const TWO_DAYS_IN_SECONDS = 2 * 24 * 60 * 60; - subscriptionSince = subscriptionSince - TWO_DAYS_IN_SECONDS; - - const filters = [{ - kinds: [1059], - '#p': [user.pubkey], - since: subscriptionSince, - }]; - - const subscription = nostr.req(filters); - let isActive = true; - - (async () => { - try { - for await (const msg of subscription) { - if (!isActive) break; - if (msg[0] === 'EVENT') { - await processIncomingNIP17Message(msg[2]); - } - } - } catch (error) { - if (isActive) { - console.error('[DM] NIP-17 subscription error:', error); - } - } - })(); - - nip17SubscriptionRef.current = { - close: () => { - isActive = false; - } - }; - - setSubscriptions(prev => ({ ...prev, isNIP17Connected: true })); - } catch (error) { - console.error('[DM] Failed to start NIP-17 subscription:', error); - setSubscriptions(prev => ({ ...prev, isNIP17Connected: false })); - } - }, [user, nostr, lastSync.nip17, enableNIP17, processIncomingNIP17Message]); - - // Load all cached messages at once (both protocols) - const loadAllCachedMessages = useCallback(async (): Promise<{ nip4Since?: number; nip17Since?: number }> => { - if (!userPubkey) return {}; - - try { - const { readMessagesFromDB } = await import('@/lib/dmMessageStore'); - - const cachedStore = await readMessagesFromDB(userPubkey); - - if (!cachedStore || Object.keys(cachedStore.participants).length === 0) { - return {}; - } - - const filteredParticipants = enableNIP17 - ? cachedStore.participants - : Object.fromEntries( - Object.entries(cachedStore.participants).filter(([_, participant]) => !participant.hasNIP17) - ); - - const newState = new Map(); - - // Decrypt each message individually (they're stored in original encrypted form) - for (const [participantPubkey, participant] of Object.entries(filteredParticipants)) { - const processedMessages = await Promise.all(participant.messages.map(async (msg) => { - // Decrypt based on message kind - let decryptedContent: string | undefined; - let error: string | undefined; - - if (msg.kind === 4) { - // NIP-04 message - const otherPubkey = msg.pubkey === user?.pubkey - ? msg.tags.find(([name]) => name === 'p')?.[1] - : msg.pubkey; - - if (otherPubkey && user?.signer?.nip04) { - try { - decryptedContent = await user.signer.nip04.decrypt(otherPubkey, msg.content); - } catch { - error = 'Decryption failed'; - } - } - } else if (msg.kind === 13) { - // NIP-17 seal - decrypt to get the inner kind 14/15 event - if (user?.signer?.nip44) { - try { - const sealContent = await user.signer.nip44.decrypt(msg.pubkey, msg.content); - const decryptedEvent = JSON.parse(sealContent) as NostrEvent; - - // Keep seal structure but add decryptedEvent for access to inner fields - return { - ...msg, - decryptedEvent, // Full inner event (kind 14/15) - decryptedContent: decryptedEvent.content, // Plaintext message - } as NostrEvent & { decryptedEvent?: NostrEvent; decryptedContent?: string; error?: string }; - } catch { - error = 'Decryption failed'; - } - } - } - - return { - ...msg, - id: msg.id || `missing-${msg.kind}-${msg.created_at}-${msg.pubkey.substring(0, 8)}-${msg.content?.substring(0, 16) || 'nocontent'}`, - decryptedContent, - error, - } as NostrEvent & { decryptedContent?: string; error?: string }; - })); - - newState.set(participantPubkey, { - messages: processedMessages, - lastActivity: participant.lastActivity, - lastMessage: processedMessages.length > 0 ? processedMessages[processedMessages.length - 1] : null, - hasNIP4: participant.hasNIP4, - hasNIP17: participant.hasNIP17, - }); - } - - setMessages(newState); - if (cachedStore.lastSync) { - setLastSync(cachedStore.lastSync); - } - - return { - nip4Since: cachedStore.lastSync?.nip4 || undefined, - nip17Since: cachedStore.lastSync?.nip17 || undefined, - }; - } catch (error) { - console.error('[DM] Error loading cached messages:', error); - return {}; - } - }, [userPubkey, enableNIP17, user]); - - // Start message loading - const startMessageLoading = useCallback(async () => { - if (isLoading) return; - - setIsLoading(true); - setLoadingPhase(LOADING_PHASES.CACHE); - - try { - // ===== PHASE 1: Load cache and show immediately ===== - const { nip4Since, nip17Since } = await loadAllCachedMessages(); - - // Mark as completed BEFORE releasing isLoading to prevent re-trigger - setHasInitialLoadCompleted(true); - - // Show cached messages immediately! Don't wait for relays - setLoadingPhase(LOADING_PHASES.READY); - setIsLoading(false); - - // ===== PHASE 2: Query relays in background (non-blocking, parallel) ===== - setLoadingPhase(LOADING_PHASES.RELAYS); - - // Run NIP-04 and NIP-17 queries IN PARALLEL - const [nip4Result, nip17Result] = await Promise.all([ - queryRelaysForMessagesSince(MESSAGE_PROTOCOL.NIP04, nip4Since), - enableNIP17 ? queryRelaysForMessagesSince(MESSAGE_PROTOCOL.NIP17, nip17Since) : Promise.resolve({ lastMessageTimestamp: undefined, messageCount: 0 }) - ]); - - const totalNewMessages = nip4Result.messageCount + (nip17Result?.messageCount || 0); - if (totalNewMessages > 0) { - setShouldSaveImmediately(true); - } - - // ===== PHASE 3: Setup subscriptions ===== - setLoadingPhase(LOADING_PHASES.SUBSCRIPTIONS); - - await Promise.all([ - startNIP4Subscription(nip4Result.lastMessageTimestamp), - enableNIP17 ? startNIP17Subscription(nip17Result?.lastMessageTimestamp) : Promise.resolve() - ]); - - setLoadingPhase(LOADING_PHASES.READY); - } catch (error) { - console.error('[DM] Error in message loading:', error); - setHasInitialLoadCompleted(true); - setLoadingPhase(LOADING_PHASES.READY); - setIsLoading(false); - } - }, [loadAllCachedMessages, queryRelaysForMessagesSince, startNIP4Subscription, startNIP17Subscription, enableNIP17, isLoading]); - - // Clear cache and refetch from relays - const clearCacheAndRefetch = useCallback(async () => { - if (!enabled || !userPubkey) return; - - try { - // Close existing subscriptions - if (nip4SubscriptionRef.current) { - nip4SubscriptionRef.current.close(); - nip4SubscriptionRef.current = null; - } - if (nip17SubscriptionRef.current) { - nip17SubscriptionRef.current.close(); - nip17SubscriptionRef.current = null; - } - - // Clear IndexedDB cache - const { deleteMessagesFromDB } = await import('@/lib/dmMessageStore'); - await deleteMessagesFromDB(userPubkey); - - // Reset all state - setMessages(new Map()); - setLastSync({ nip4: null, nip17: null }); - setSubscriptions({ isNIP4Connected: false, isNIP17Connected: false }); - setScanProgress({ nip4: null, nip17: null }); - setLoadingPhase(LOADING_PHASES.IDLE); - - // Trigger reload by setting hasInitialLoadCompleted to false - setHasInitialLoadCompleted(false); - } catch (error) { - console.error('[DM] Error clearing cache:', error); - throw error; - } - }, [enabled, userPubkey]); - - // Main effect to load messages - useEffect(() => { - if (!enabled || !userPubkey || hasInitialLoadCompleted || isLoading) return; - startMessageLoading(); - }, [enabled, userPubkey, hasInitialLoadCompleted, isLoading, startMessageLoading]); - - // Cleanup effect - useEffect(() => { - if (!enabled) return; - - return () => { - if (nip4SubscriptionRef.current) { - nip4SubscriptionRef.current.close(); - nip4SubscriptionRef.current = null; - } - if (nip17SubscriptionRef.current) { - nip17SubscriptionRef.current.close(); - nip17SubscriptionRef.current = null; - } - }; - }, [enabled, userPubkey]); - - // Cleanup subscriptions - useEffect(() => { - if (!enabled) return; - - return () => { - if (nip4SubscriptionRef.current) { - nip4SubscriptionRef.current.close(); - } - if (nip17SubscriptionRef.current) { - nip17SubscriptionRef.current.close(); - } - if (debouncedWriteRef.current) { - clearTimeout(debouncedWriteRef.current); - } - setSubscriptions({ isNIP4Connected: false, isNIP17Connected: false }); - }; - }, [enabled]); - - // Detect relay changes and reload messages - useEffect(() => { - const relayChanged = JSON.stringify(previousRelayMetadata.current) !== JSON.stringify(appConfig.relayMetadata); - - previousRelayMetadata.current = appConfig.relayMetadata; - - if (relayChanged && enabled && userPubkey && hasInitialLoadCompleted) { - clearCacheAndRefetch(); - } - }, [enabled, userPubkey, appConfig.relayMetadata, hasInitialLoadCompleted, clearCacheAndRefetch]); - - // Detect hard refresh shortcut (Ctrl+Shift+R / Cmd+Shift+R) to clear cache - useEffect(() => { - if (!enabled || !userPubkey) return; - - const handleHardRefresh = (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'R' || e.key === 'r')) { - try { - sessionStorage.setItem('dm-clear-cache-on-load', 'true'); - } catch (error) { - console.warn('[DM] SessionStorage unavailable, cache won\'t clear on hard refresh:', error); - } - } - }; - - window.addEventListener('keydown', handleHardRefresh); - return () => window.removeEventListener('keydown', handleHardRefresh); - }, [enabled, userPubkey]); - - // Clear cache after hard refresh - useEffect(() => { - if (!enabled || !userPubkey) return; - - try { - const shouldClearCache = sessionStorage.getItem('dm-clear-cache-on-load'); - if (shouldClearCache) { - sessionStorage.removeItem('dm-clear-cache-on-load'); - clearCacheAndRefetch(); - } - } catch (error) { - console.warn('[DM] Could not check sessionStorage for cache clear flag:', error); - } - }, [enabled, userPubkey, clearCacheAndRefetch]); - - // Conversations summary - const conversations = useMemo(() => { - const conversationsList: ConversationSummary[] = []; - - messages.forEach((participant, participantPubkey) => { - if (!participant.messages.length) return; - - const userHasSentMessage = participant.messages.some(msg => msg.pubkey === user?.pubkey); - const isKnown = userHasSentMessage; - const isRequest = !userHasSentMessage; - - const lastMessage = participant.messages[participant.messages.length - 1]; - const isFromUser = lastMessage.pubkey === user?.pubkey; - - conversationsList.push({ - id: participantPubkey, - pubkey: participantPubkey, - lastMessage: participant.lastMessage, - lastActivity: participant.lastActivity, - hasNIP4Messages: participant.hasNIP4, - hasNIP17Messages: participant.hasNIP17, - isKnown: isKnown, - isRequest: isRequest, - lastMessageFromUser: isFromUser, - }); - }); - - return conversationsList.sort((a, b) => b.lastActivity - a.lastActivity); - }, [messages, user?.pubkey]); - - // Write to store - const writeAllMessagesToStore = useCallback(async () => { - if (!userPubkey) return; - - try { - const { writeMessagesToDB } = await import('@/lib/dmMessageStore'); - - const messageStore = { - participants: {} as Record, - lastSync: { - nip4: lastSync.nip4, - nip17: lastSync.nip17, - } - }; - - messages.forEach((participant, participantPubkey) => { - messageStore.participants[participantPubkey] = { - messages: participant.messages.map(msg => ({ - // Store messages in their ORIGINAL ENCRYPTED form - // Just strip the decrypted fields (decryptedContent, decryptedEvent) - // Keep originalGiftWrapId for NIP-17 deduplication on cache load - id: msg.id, - pubkey: msg.pubkey, - content: msg.content, // Encrypted content (NIP-04 or seal) - created_at: msg.created_at, - kind: msg.kind, // 4 for NIP-04, 13 for NIP-17 - tags: msg.tags, - sig: msg.sig, - ...(msg.originalGiftWrapId && { originalGiftWrapId: msg.originalGiftWrapId }), - } as NostrEvent)), - lastActivity: participant.lastActivity, - hasNIP4: participant.hasNIP4, - hasNIP17: participant.hasNIP17, - }; - }); - - await writeMessagesToDB(userPubkey, messageStore); - - const currentTime = Math.floor(Date.now() / 1000); - setLastSync(prev => ({ - nip4: prev.nip4 || currentTime, - nip17: prev.nip17 || currentTime - })); - } catch (error) { - console.error('[DM] Error writing messages to IndexedDB:', error); - } - }, [messages, userPubkey, lastSync]); - - // Trigger debounced write - const triggerDebouncedWrite = useCallback(() => { - if (debouncedWriteRef.current) { - clearTimeout(debouncedWriteRef.current); - } - debouncedWriteRef.current = setTimeout(() => { - writeAllMessagesToStore(); - debouncedWriteRef.current = null; - }, DM_CONSTANTS.DEBOUNCED_WRITE_DELAY); - }, [writeAllMessagesToStore]); - - // Watch messages and save - useEffect(() => { - if (!enabled || messages.size === 0) return; - - if (shouldSaveImmediately) { - setShouldSaveImmediately(false); - writeAllMessagesToStore(); - } else { - triggerDebouncedWrite(); - } - }, [enabled, messages, shouldSaveImmediately, writeAllMessagesToStore, triggerDebouncedWrite]); - - // Send message - const sendMessage = useCallback(async (params: { - recipientPubkey: string; - content: string; - protocol?: MessageProtocol; - attachments?: FileAttachment[]; - }) => { - if (!enabled) return; - - const { recipientPubkey, content, protocol = MESSAGE_PROTOCOL.NIP04, attachments } = params; - if (!userPubkey) return; - - const optimisticId = `optimistic-${Date.now()}-${Math.random()}`; - const optimisticMessage: DecryptedMessage = { - id: optimisticId, - kind: protocol === MESSAGE_PROTOCOL.NIP04 ? 4 : 14, // Use kind 14 for NIP-17 (the real message kind) - pubkey: userPubkey, - created_at: Math.floor(Date.now() / 1000), // Real timestamp - tags: [['p', recipientPubkey]], - content: '', - decryptedContent: content, - sig: '', - isSending: true, - clientFirstSeen: Date.now(), - }; - - addMessageToState(optimisticMessage, recipientPubkey, protocol === MESSAGE_PROTOCOL.NIP04 ? MESSAGE_PROTOCOL.NIP04 : MESSAGE_PROTOCOL.NIP17); - - try { - if (protocol === MESSAGE_PROTOCOL.NIP04) { - await sendNIP4Message.mutateAsync({ recipientPubkey, content, attachments }); - } else if (protocol === MESSAGE_PROTOCOL.NIP17) { - await sendNIP17Message.mutateAsync({ recipientPubkey, content, attachments }); - } - } catch (error) { - console.error(`[DM] Failed to send ${protocol} message:`, error); - } - }, [enabled, userPubkey, addMessageToState, sendNIP4Message, sendNIP17Message]); - - const isDoingInitialLoad = isLoading && (loadingPhase === LOADING_PHASES.CACHE || loadingPhase === LOADING_PHASES.RELAYS); - - const contextValue: DMContextType = { - messages, - isLoading, - loadingPhase, - isDoingInitialLoad, - lastSync, - conversations, - sendMessage, - protocolMode, - scanProgress, - subscriptions, - clearCacheAndRefetch, - }; - - return ( - - {children} - - ); -} - diff --git a/src/components/dm/DMChatArea.tsx b/src/components/dm/DMChatArea.tsx deleted file mode 100644 index 5b548ddf..00000000 --- a/src/components/dm/DMChatArea.tsx +++ /dev/null @@ -1,495 +0,0 @@ -import { useState, useRef, useEffect, useCallback, memo } from 'react'; -import { useConversationMessages } from '@/hooks/useConversationMessages'; -import { useDMContext } from '@/hooks/useDMContext'; -import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useAuthor } from '@/hooks/useAuthor'; -import { VerifiedNip05Text } from '@/components/Nip05Badge'; -import { genUserName } from '@/lib/genUserName'; -import { MESSAGE_PROTOCOL, PROTOCOL_MODE, type MessageProtocol } from '@/lib/dmConstants'; -import { formatConversationTime, formatFullDateTime } from '@/lib/dmUtils'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { getAvatarShape } from '@/lib/avatarShape'; -import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; -import { Textarea } from '@/components/ui/textarea'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { ArrowLeft, Send, Loader2, AlertTriangle, Key, ShieldCheck, ImagePlay, Smile } from 'lucide-react'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { cn } from '@/lib/utils'; -import { NoteContent } from '@/components/NoteContent'; -import { GifPicker } from '@/components/GifPicker'; -import { EmojiPicker } from '@/components/EmojiPicker'; -import { EmojiShortcodeAutocomplete } from '@/components/EmojiShortcodeAutocomplete'; -import { useCustomEmojis } from '@/hooks/useCustomEmojis'; -import { useFeedSettings } from '@/hooks/useFeedSettings'; -import { useInsertText } from '@/hooks/useInsertText'; -import type { NostrEvent } from '@nostrify/nostrify'; - -interface DMChatAreaProps { - pubkey: string | null; - onBack?: () => void; - className?: string; -} - -const MessageBubble = memo(({ - message, - isFromCurrentUser -}: { - message: { - id: string; - pubkey: string; - kind: number; - tags: string[][]; - decryptedContent?: string; - decryptedEvent?: NostrEvent; - error?: string; - created_at: number; - isSending?: boolean; - }; - isFromCurrentUser: boolean; -}) => { - // For NIP-17, use inner message kind (14/15); for NIP-04, use message kind (4) - const actualKind = message.decryptedEvent?.kind || message.kind; - const isNIP4Message = message.kind === 4; - const isFileAttachment = actualKind === 15; // Kind 15 = files/attachments - - // Create a NostrEvent object for NoteContent (only used for kind 15) - // For NIP-17 file attachments, use the decryptedEvent which has the actual tags - const messageEvent: NostrEvent = message.decryptedEvent || { - id: message.id, - pubkey: message.pubkey, - created_at: message.created_at, - kind: message.kind, - tags: message.tags, - content: message.decryptedContent || '', - sig: '', // Not needed for display - }; - - return ( -
-
- {message.error ? ( - - -

🔒 Failed to decrypt

-
- -

{message.error}

-
-
- ) : isFileAttachment ? ( - // Kind 15: Use NoteContent to render files/media with imeta tags -
- -
- ) : ( - // Kind 4 (NIP-04) and Kind 14 (NIP-17 text): Display plain text -

- {message.decryptedContent} -

- )} -
- - - - - {formatConversationTime(message.created_at)} - - - -

{formatFullDateTime(message.created_at)}

-
-
-
- - - - - - {message.kind === 4 ? ( - - ) : ( - - )} - - - -

- {message.kind === 4 && "NIP-04 Kind 4 (Legacy DM)"} - {message.kind === 14 && "NIP-17 Kind 14 (Private Message)"} - {message.kind === 15 && "NIP-17 Kind 15 (Media)"} - {message.kind !== 4 && message.kind !== 14 && message.kind !== 15 && `Kind ${message.kind}`} -

-
-
-
- {isNIP4Message && ( - - - -
- -
-
- -

Uses outdated NIP-04 encryption

-
-
-
- )} - {message.isSending && ( - - )} -
-
-
- ); -}); - -MessageBubble.displayName = 'MessageBubble'; - -const ChatHeader = ({ pubkey, onBack }: { pubkey: string; onBack?: () => void }) => { - const author = useAuthor(pubkey); - const metadata = author.data?.metadata; - const avatarShape = getAvatarShape(metadata); - - const displayName = metadata?.name || genUserName(pubkey); - const avatarUrl = metadata?.picture; - const initials = displayName.slice(0, 2).toUpperCase(); - - return ( -
- {onBack && ( - - )} - - - - {initials} - - -
-

{displayName}

- {metadata?.nip05 && ( - - )} -
-
- ); -}; - -const EmptyState = ({ isLoading }: { isLoading: boolean }) => { - return ( -
-
- {isLoading ? ( - <> - -

Loading conversations...

-

- Fetching encrypted messages from relays -

- - ) : ( - <> -

Select a conversation to start messaging

-

- Your messages are encrypted and stored locally -

- - )} -
-
- ); -}; - -export const DMChatArea = ({ pubkey, onBack, className }: DMChatAreaProps) => { - const { user } = useCurrentUser(); - const { sendMessage, protocolMode, isLoading } = useDMContext(); - const { messages, hasMoreMessages, loadEarlierMessages } = useConversationMessages(pubkey || ''); - - const [messageText, setMessageText] = useState(''); - const [isSending, setIsSending] = useState(false); - const [isLoadingMore, setIsLoadingMore] = useState(false); - const [gifOpen, setGifOpen] = useState(false); - const [emojiOpen, setEmojiOpen] = useState(false); - const { feedSettings } = useFeedSettings(); - const { emojis: allCustomEmojis } = useCustomEmojis(); - const customEmojis = feedSettings.showCustomEmojis !== false ? allCustomEmojis : []; - const textareaRef = useRef(null); - const { insertAtCursor, insertEmoji } = useInsertText(textareaRef, messageText, setMessageText); - - // Determine default protocol based on mode - const getDefaultProtocol = () => { - if (protocolMode === PROTOCOL_MODE.NIP04_ONLY) return MESSAGE_PROTOCOL.NIP04; - if (protocolMode === PROTOCOL_MODE.NIP17_ONLY) return MESSAGE_PROTOCOL.NIP17; - if (protocolMode === PROTOCOL_MODE.NIP04_OR_NIP17) return MESSAGE_PROTOCOL.NIP17; - // Fallback to NIP-17 for any unexpected mode - return MESSAGE_PROTOCOL.NIP17; - }; - - const [selectedProtocol, setSelectedProtocol] = useState(getDefaultProtocol()); - const scrollAreaRef = useRef(null); - - // Determine if selection is allowed - const allowSelection = protocolMode === PROTOCOL_MODE.NIP04_OR_NIP17; - - // Auto-scroll to bottom when new messages arrive - useEffect(() => { - if (scrollAreaRef.current) { - const scrollContainer = scrollAreaRef.current.querySelector('[data-radix-scroll-area-viewport]'); - if (scrollContainer) { - scrollContainer.scrollTop = scrollContainer.scrollHeight; - } - } - }, [messages.length]); - - const handleSend = useCallback(async () => { - if (!messageText.trim() || !pubkey || !user) return; - - setIsSending(true); - try { - await sendMessage({ - recipientPubkey: pubkey, - content: messageText.trim(), - protocol: selectedProtocol, - }); - setMessageText(''); - } catch (error) { - console.error('Failed to send message:', error); - } finally { - setIsSending(false); - } - }, [messageText, pubkey, user, sendMessage, selectedProtocol]); - - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSend(); - } - }, [handleSend]); - - const handleLoadMore = useCallback(async () => { - if (!scrollAreaRef.current || isLoadingMore) return; - - const scrollContainer = scrollAreaRef.current.querySelector('[data-radix-scroll-area-viewport]'); - if (!scrollContainer) return; - - // Store current scroll position and height - const previousScrollHeight = scrollContainer.scrollHeight; - const previousScrollTop = scrollContainer.scrollTop; - - setIsLoadingMore(true); - - // Load more messages - loadEarlierMessages(); - - // Wait for DOM to update, then restore relative scroll position - setTimeout(() => { - if (scrollContainer) { - const newScrollHeight = scrollContainer.scrollHeight; - const heightDifference = newScrollHeight - previousScrollHeight; - scrollContainer.scrollTop = previousScrollTop + heightDifference; - } - setIsLoadingMore(false); - }, 0); - }, [loadEarlierMessages, isLoadingMore]); - - if (!pubkey) { - return ( - - - - ); - } - - if (!user) { - return ( - -
-

Please log in to view messages

-
-
- ); - } - - return ( - - - - - {messages.length === 0 ? ( -
-
-

No messages yet

-

Send a message to start the conversation

-
-
- ) : ( -
- {hasMoreMessages && ( -
- -
- )} - {messages.map((message) => ( - - ))} -
- )} -
- -
-
-
-
-