diff --git a/src/App.tsx b/src/App.tsx index 8d38a7c..8861bf2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,12 +6,15 @@ import { createHead, UnheadProvider } from '@unhead/react/client'; import { InferSeoMetaPlugin } from '@unhead/addons'; import { Suspense } from 'react'; import NostrProvider from '@/components/NostrProvider'; +import { NostrSync } from '@/components/NostrSync'; import { Toaster } from "@/components/ui/toaster"; import { TooltipProvider } from "@/components/ui/tooltip"; import { NostrLoginProvider } from '@nostrify/react/login'; import { AppProvider } from '@/components/AppProvider'; import { NWCProvider } from '@/contexts/NWCContext'; +import { DMProvider, type DMConfig } from '@/components/DMProvider'; import { AppConfig } from '@/contexts/AppContext'; +import { PROTOCOL_MODE } from '@/lib/dmConstants'; import AppRouter from './AppRouter'; const head = createHead({ @@ -32,30 +35,44 @@ const queryClient = new QueryClient({ const defaultConfig: AppConfig = { theme: "light", - relayUrl: "wss://relay.nostr.band", + relayMetadata: { + relays: [ + { url: 'wss://relay.ditto.pub', read: true, write: true }, + { url: 'wss://relay.nostr.band', read: true, write: true }, + { url: 'wss://relay.damus.io', read: true, write: true }, + ], + updatedAt: 0, + }, }; -const presetRelays = [ - { url: 'wss://ditto.pub/relay', name: 'Ditto' }, - { url: 'wss://relay.nostr.band', name: 'Nostr.Band' }, - { url: 'wss://relay.damus.io', name: 'Damus' }, - { url: 'wss://relay.primal.net', name: 'Primal' }, -]; +const dmConfig: DMConfig = { + // Enable or disable DMs entirely + enabled: true, // Set to false to completely disable messaging functionality + + // Choose one protocol mode: + // PROTOCOL_MODE.NIP04_ONLY - Force NIP-04 (legacy) only + // PROTOCOL_MODE.NIP17_ONLY - Force NIP-17 (private) only + // PROTOCOL_MODE.NIP04_OR_NIP17 - Allow users to choose between NIP-04 and NIP-17 (defaults to NIP-17) + protocolMode: PROTOCOL_MODE.NIP04_OR_NIP17, +}; export function App() { return ( - + + - - - - - - + + + + + + + + diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index b544587..8b72567 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -1,7 +1,7 @@ import { ReactNode, useEffect } from 'react'; import { z } from 'zod'; import { useLocalStorage } from '@/hooks/useLocalStorage'; -import { AppContext, type AppConfig, type AppContextType, type Theme } from '@/contexts/AppContext'; +import { AppContext, type AppConfig, type AppContextType, type Theme, type RelayMetadata } from '@/contexts/AppContext'; interface AppProviderProps { children: ReactNode; @@ -9,47 +9,54 @@ interface AppProviderProps { storageKey: string; /** Default app configuration */ defaultConfig: AppConfig; - /** Optional list of preset relays to display in the RelaySelector */ - presetRelays?: { name: string; url: string }[]; } +// Zod schema for RelayMetadata validation +const RelayMetadataSchema = z.object({ + relays: z.array(z.object({ + url: z.string().url(), + read: z.boolean(), + write: z.boolean(), + })), + updatedAt: z.number(), +}) satisfies z.ZodType; + // Zod schema for AppConfig validation -const AppConfigSchema: z.ZodType = z.object({ +const AppConfigSchema = z.object({ theme: z.enum(['dark', 'light', 'system']), - relayUrl: z.string().url(), - blogOwnerPubkey: z.string().length(64).optional(), // deprecated, optional for backward compatibility -}); + relayMetadata: RelayMetadataSchema, +}) satisfies z.ZodType; export function AppProvider(props: AppProviderProps) { const { children, storageKey, defaultConfig, - presetRelays, } = props; // App configuration state with localStorage persistence - const [config, setConfig] = useLocalStorage( + const [rawConfig, setConfig] = useLocalStorage>( storageKey, - defaultConfig, + {}, { serialize: JSON.stringify, deserialize: (value: string) => { const parsed = JSON.parse(value); - return AppConfigSchema.parse(parsed); + return AppConfigSchema.partial().parse(parsed); } } ); // Generic config updater with callback pattern - const updateConfig = (updater: (currentConfig: AppConfig) => AppConfig) => { + const updateConfig = (updater: (currentConfig: Partial) => Partial) => { setConfig(updater); }; + const config = { ...defaultConfig, ...rawConfig }; + const appContextValue: AppContextType = { config, updateConfig, - presetRelays, }; // Apply theme effects to document @@ -89,11 +96,11 @@ function useApplyTheme(theme: Theme) { if (theme !== 'system') return; const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - + const handleChange = () => { const root = window.document.documentElement; root.classList.remove('light', 'dark'); - + const systemTheme = mediaQuery.matches ? 'dark' : 'light'; root.classList.add(systemTheme); }; diff --git a/src/components/DMProvider.tsx b/src/components/DMProvider.tsx new file mode 100644 index 0000000..43710cb --- /dev/null +++ b/src/components/DMProvider.tsx @@ -0,0 +1,1524 @@ +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); + + // ============================================================================ + // 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; + + // 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/NostrProvider.tsx b/src/components/NostrProvider.tsx index 6d74597..2b8a7ef 100644 --- a/src/components/NostrProvider.tsx +++ b/src/components/NostrProvider.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef } from 'react'; -import { NostrEvent, NPool, NRelay1 } from '@nostrify/nostrify'; +import { NostrEvent, NostrFilter, NPool, NRelay1 } from '@nostrify/nostrify'; import { NostrContext } from '@nostrify/react'; import { useQueryClient } from '@tanstack/react-query'; import { useAppContext } from '@/hooks/useAppContext'; @@ -10,7 +10,7 @@ interface NostrProviderProps { const NostrProvider: React.FC = (props) => { const { children } = props; - const { config, presetRelays } = useAppContext(); + const { config } = useAppContext(); const queryClient = useQueryClient(); @@ -18,13 +18,13 @@ const NostrProvider: React.FC = (props) => { const pool = useRef(undefined); // Use refs so the pool always has the latest data - const relayUrl = useRef(config.relayUrl); + const relayMetadata = useRef(config.relayMetadata); - // Update refs when config changes + // Invalidate Nostr queries when relay metadata changes useEffect(() => { - relayUrl.current = config.relayUrl; - queryClient.resetQueries(); - }, [config.relayUrl, queryClient]); + relayMetadata.current = config.relayMetadata; + queryClient.invalidateQueries({ queryKey: ['nostr'] }); + }, [config.relayMetadata, queryClient]); // Initialize NPool only once if (!pool.current) { @@ -32,21 +32,27 @@ const NostrProvider: React.FC = (props) => { open(url: string) { return new NRelay1(url); }, - reqRouter(filters) { - return new Map([[relayUrl.current, filters]]); + reqRouter(filters: NostrFilter[]) { + const routes = new Map(); + + // Route to all read relays + const readRelays = relayMetadata.current.relays + .filter(r => r.read) + .map(r => r.url); + + for (const url of readRelays) { + routes.set(url, filters); + } + + return routes; }, eventRouter(_event: NostrEvent) { - // Publish to the selected relay - const allRelays = new Set([relayUrl.current]); + // Get write relays from metadata + const writeRelays = relayMetadata.current.relays + .filter(r => r.write) + .map(r => r.url); - // Also publish to the preset relays, capped to 5 - for (const { url } of (presetRelays ?? [])) { - allRelays.add(url); - - if (allRelays.size >= 5) { - break; - } - } + const allRelays = new Set(writeRelays); return [...allRelays]; }, diff --git a/src/components/NostrSync.tsx b/src/components/NostrSync.tsx new file mode 100644 index 0000000..2ebf856 --- /dev/null +++ b/src/components/NostrSync.tsx @@ -0,0 +1,62 @@ +import { useEffect } from 'react'; +import { useNostr } from '@nostrify/react'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useAppContext } from '@/hooks/useAppContext'; + +/** + * NostrSync - Syncs user's Nostr data + * + * This component runs globally to sync various Nostr data when the user logs in. + * Currently syncs: + * - NIP-65 relay list (kind 10002) + */ +export function NostrSync() { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + const { config, updateConfig } = useAppContext(); + + useEffect(() => { + if (!user) return; + + const syncRelaysFromNostr = async () => { + try { + const events = await nostr.query( + [{ kinds: [10002], authors: [user.pubkey], limit: 1 }], + { signal: AbortSignal.timeout(5000) } + ); + + if (events.length > 0) { + const event = events[0]; + + // Only update if the event is newer than our stored data + if (event.created_at > config.relayMetadata.updatedAt) { + const fetchedRelays = event.tags + .filter(([name]) => name === 'r') + .map(([_, url, marker]) => ({ + url, + read: !marker || marker === 'read', + write: !marker || marker === 'write', + })); + + if (fetchedRelays.length > 0) { + console.log('Syncing relay list from Nostr:', fetchedRelays); + updateConfig((current) => ({ + ...current, + relayMetadata: { + relays: fetchedRelays, + updatedAt: event.created_at, + }, + })); + } + } + } + } catch (error) { + console.error('Failed to sync relays from Nostr:', error); + } + }; + + syncRelaysFromNostr(); + }, [user, config.relayMetadata.updatedAt, nostr, updateConfig]); + + return null; +} \ No newline at end of file diff --git a/src/components/RelayListManager.tsx b/src/components/RelayListManager.tsx new file mode 100644 index 0000000..2ae7b6a --- /dev/null +++ b/src/components/RelayListManager.tsx @@ -0,0 +1,286 @@ +import { useState, useEffect } from 'react'; +import { Plus, X, Wifi, Settings } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { useAppContext } from '@/hooks/useAppContext'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useToast } from '@/hooks/useToast'; + +interface Relay { + url: string; + read: boolean; + write: boolean; +} + +export function RelayListManager() { + const { config, updateConfig } = useAppContext(); + const { user } = useCurrentUser(); + const { mutate: publishEvent } = useNostrPublish(); + const { toast } = useToast(); + + const [relays, setRelays] = useState(config.relayMetadata.relays); + const [newRelayUrl, setNewRelayUrl] = useState(''); + + // Sync local state with config when it changes (e.g., from NostrProvider sync) + useEffect(() => { + setRelays(config.relayMetadata.relays); + }, [config.relayMetadata.relays]); + + const normalizeRelayUrl = (url: string): string => { + url = url.trim(); + try { + return new URL(url).toString(); + } catch { + try { + return new URL(`wss://${url}`).toString(); + } catch { + return url; + } + } + }; + + const isValidRelayUrl = (url: string): boolean => { + const trimmed = url.trim(); + if (!trimmed) return false; + + const normalized = normalizeRelayUrl(trimmed); + try { + new URL(normalized); + return true; + } catch { + return false; + } + }; + + const handleAddRelay = () => { + if (!isValidRelayUrl(newRelayUrl)) { + toast({ + title: 'Invalid relay URL', + description: 'Please enter a valid relay URL (e.g., wss://relay.example.com)', + variant: 'destructive', + }); + return; + } + + const normalized = normalizeRelayUrl(newRelayUrl); + + if (relays.some(r => r.url === normalized)) { + toast({ + title: 'Relay already exists', + description: 'This relay is already in your list.', + variant: 'destructive', + }); + return; + } + + const newRelays = [...relays, { url: normalized, read: true, write: true }]; + setRelays(newRelays); + setNewRelayUrl(''); + + saveRelays(newRelays); + }; + + const handleRemoveRelay = (url: string) => { + const newRelays = relays.filter(r => r.url !== url); + setRelays(newRelays); + saveRelays(newRelays); + }; + + const handleToggleRead = (url: string) => { + const newRelays = relays.map(r => + r.url === url ? { ...r, read: !r.read } : r + ); + setRelays(newRelays); + saveRelays(newRelays); + }; + + const handleToggleWrite = (url: string) => { + const newRelays = relays.map(r => + r.url === url ? { ...r, write: !r.write } : r + ); + setRelays(newRelays); + saveRelays(newRelays); + }; + + const saveRelays = (newRelays: Relay[]) => { + const now = Math.floor(Date.now() / 1000); + + // Update local config + updateConfig((current) => ({ + ...current, + relayMetadata: { + relays: newRelays, + updatedAt: now, + }, + })); + + // Publish to Nostr if user is logged in + if (user) { + publishNIP65RelayList(newRelays); + } + }; + + const publishNIP65RelayList = (relayList: Relay[]) => { + const tags = relayList.map(relay => { + if (relay.read && relay.write) { + return ['r', relay.url]; + } else if (relay.read) { + return ['r', relay.url, 'read']; + } else if (relay.write) { + return ['r', relay.url, 'write']; + } + // If neither read nor write, don't include (shouldn't happen) + return null; + }).filter((tag): tag is string[] => tag !== null); + + publishEvent( + { + kind: 10002, + content: '', + tags, + }, + { + onSuccess: () => { + toast({ + title: 'Relay list published', + description: 'Your relay list has been published to Nostr.', + }); + }, + onError: (error) => { + console.error('Failed to publish relay list:', error); + toast({ + title: 'Failed to publish relay list', + description: 'There was an error publishing your relay list to Nostr.', + variant: 'destructive', + }); + }, + } + ); + }; + + const renderRelayUrl = (url: string): string => { + try { + const parsed = new URL(url); + if (parsed.protocol === 'wss:') { + if (parsed.pathname === '/') { + return parsed.host; + } else { + return parsed.host + parsed.pathname; + } + } else { + return parsed.href; + } + } catch { + return url; + } + } + + return ( +
+ {/* Relay List */} +
+ {relays.map((relay) => ( +
+ + + {renderRelayUrl(relay.url)} + + + {/* Settings Popover */} + + + + + +
+
+ + handleToggleRead(relay.url)} + className="data-[state=checked]:bg-green-500 scale-75" + /> +
+
+ + handleToggleWrite(relay.url)} + className="data-[state=checked]:bg-blue-500 scale-75" + /> +
+
+
+
+ + {/* Remove Button */} + +
+ ))} +
+ + {/* Add Relay Form */} +
+
+ + setNewRelayUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleAddRelay(); + } + }} + /> +
+ +
+ + {!user && ( +

+ Log in to sync your relay list with Nostr +

+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/WalletModal.tsx b/src/components/WalletModal.tsx index 7643ea5..280052c 100644 --- a/src/components/WalletModal.tsx +++ b/src/components/WalletModal.tsx @@ -29,6 +29,7 @@ import { useWallet } from '@/hooks/useWallet'; import { useToast } from '@/hooks/useToast'; import { useIsMobile } from '@/hooks/useIsMobile'; import type { NWCConnection, NWCInfo } from '@/hooks/useNWC'; +import type { WebLNProvider } from "@webbtc/webln-types"; interface WalletModalProps { children?: React.ReactNode; @@ -68,8 +69,7 @@ AddWalletContent.displayName = 'AddWalletContent'; // Extracted WalletContent to prevent re-renders const WalletContent = forwardRef; @@ -78,8 +78,7 @@ const WalletContent = forwardRef void; setAddDialogOpen: (open: boolean) => void; }>(({ - hasWebLN, - isDetecting, + webln, hasNWC, connections, connectionInfo, @@ -103,9 +102,9 @@ const WalletContent = forwardRef
- {hasWebLN && } - - {isDetecting ? "..." : hasWebLN ? "Ready" : "Not Found"} + {webln && } + + {webln ? "Ready" : "Not Found"}
@@ -191,7 +190,7 @@ const WalletContent = forwardRef {/* Help */} - {!hasWebLN && connections.length === 0 && ( + {!webln && connections.length === 0 && ( <>
@@ -222,7 +221,7 @@ export function WalletModal({ children, className }: WalletModalProps) { setActiveConnection } = useNWC(); - const { hasWebLN, isDetecting } = useWallet(); + const { webln } = useWallet(); const hasNWC = connections.length > 0 && connections.some(c => c.isConnected); const { toast } = useToast(); @@ -263,8 +262,7 @@ export function WalletModal({ children, className }: WalletModalProps) { }; const walletContentProps = { - hasWebLN, - isDetecting, + webln, hasNWC, connections, connectionInfo, diff --git a/src/components/ZapButton.tsx b/src/components/ZapButton.tsx index 5313a73..bb3bbd8 100644 --- a/src/components/ZapButton.tsx +++ b/src/components/ZapButton.tsx @@ -5,13 +5,11 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthor } from '@/hooks/useAuthor'; import { Zap } from 'lucide-react'; import type { Event } from 'nostr-tools'; -import { Button } from './ui/button'; interface ZapButtonProps { target: Event; className?: string; showCount?: boolean; - buttonVariant?: 'default' | 'outline' | 'ghost' | 'link' | 'destructive'; zapData?: { count: number; totalSats: number; isLoading?: boolean }; } @@ -19,7 +17,6 @@ export function ZapButton({ target, className = "text-xs ml-1", showCount = true, - buttonVariant = "outline", zapData: externalZapData }: ZapButtonProps) { const { user } = useCurrentUser(); @@ -44,18 +41,18 @@ export function ZapButton({ return ( - +
+ + + {showLoading ? ( + '...' + ) : showCount && totalSats > 0 ? ( + `${totalSats.toLocaleString()}` + ) : ( + 'Zap' + )} + +
); } \ No newline at end of file diff --git a/src/components/ZapDialog.tsx b/src/components/ZapDialog.tsx index 8404362..c50bf20 100644 --- a/src/components/ZapDialog.tsx +++ b/src/components/ZapDialog.tsx @@ -33,6 +33,7 @@ import { useWallet } from '@/hooks/useWallet'; import { useIsMobile } from '@/hooks/useIsMobile'; import type { Event } from 'nostr-tools'; import QRCode from 'qrcode'; +import type { WebLNProvider } from "@webbtc/webln-types"; interface ZapDialogProps { target: Event; @@ -55,7 +56,7 @@ interface ZapContentProps { isZapping: boolean; qrCodeUrl: string; copied: boolean; - hasWebLN: boolean; + webln: WebLNProvider | null; handleZap: () => void; handleCopy: () => void; openInWallet: () => void; @@ -73,7 +74,7 @@ const ZapContent = forwardRef(({ isZapping, qrCodeUrl, copied, - hasWebLN, + webln, handleZap, handleCopy, openInWallet, @@ -138,7 +139,7 @@ const ZapContent = forwardRef(({ {/* Payment buttons */}
- {hasWebLN && ( + {webln && ( - {/* Profile quick link */} - navigate(`/${nip19.npubEncode(currentUser.pubkey)}`)} - > - - - {getDisplayName(currentUser).charAt(0)} - -
-

{getDisplayName(currentUser)}

-
-
- -
Switch Relay
- -
Switch Account
{otherUsers.map((user) => ( Reply - - {/* Zap Button */} - {hasReplies && ( diff --git a/src/components/dm/DMChatArea.tsx b/src/components/dm/DMChatArea.tsx new file mode 100644 index 0000000..b5b6c5f --- /dev/null +++ b/src/components/dm/DMChatArea.tsx @@ -0,0 +1,409 @@ +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 { 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 { 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 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { NoteContent } from '@/components/NoteContent'; +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 displayName = metadata?.name || genUserName(pubkey); + const avatarUrl = metadata?.picture; + const initials = displayName.slice(0, 2).toUpperCase(); + + return ( +
+ {onBack && ( + + )} + + + + {initials} + + +
+

{displayName}

+ {metadata?.nip05 && ( +

{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); + + // 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) => ( + + ))} +
+ )} +
+ +
+
+