DMProvider.tsx raw
1 import { ApplicationDataKey } from '@/constants'
2 import { createDeletedMessagesDraftEvent } from '@/lib/draft-event'
3 import dmService, {
4 clearPlaintextCache,
5 decryptMessagesInBatches,
6 getGlobalDeleteCutoff,
7 IDMEncryption,
8 isConversationDeleted,
9 isMessageDeleted,
10 isNircProtocolMessage
11 } from '@/services/dm.service'
12 import indexedDb from '@/services/indexed-db.service'
13 import storage, { dispatchSettingsChanged } from '@/services/local-storage.service'
14 import client from '@/services/client.service'
15 import { TConversation, TDirectMessage, TDMDeletedState, TDMEncryptionType } from '@/types'
16 import { Event, kinds } from 'nostr-tools'
17 import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
18 import { useNostr } from './NostrProvider'
19
20 type TDMContext = {
21 conversations: TConversation[]
22 currentConversation: string | null
23 messages: TDirectMessage[]
24 isLoading: boolean
25 isLoadingConversation: boolean
26 error: string | null
27 selectConversation: (partnerPubkey: string | null) => void
28 startConversation: (partnerPubkey: string) => void
29 sendMessage: (content: string, customRelayUrls?: string[]) => Promise<void>
30 refreshConversations: () => Promise<void>
31 reloadConversation: () => void
32 loadMoreConversations: () => Promise<void>
33 hasMoreConversations: boolean
34 preferNip44: boolean
35 setPreferNip44: (prefer: boolean) => void
36 isNewConversation: boolean
37 clearNewConversationFlag: () => void
38 dismissProvisionalConversation: () => void
39 // Unread tracking
40 totalUnreadCount: number
41 hasNewMessages: boolean
42 markInboxAsSeen: () => void
43 // Selection mode
44 selectedMessages: Set<string>
45 isSelectionMode: boolean
46 toggleMessageSelection: (messageId: string) => void
47 selectAllMessages: () => void
48 clearSelection: () => void
49 // Deletion
50 deleteSelectedMessages: () => Promise<void>
51 deleteAllInConversation: () => Promise<void>
52 undeleteAllInConversation: () => Promise<void>
53 }
54
55 const DMContext = createContext<TDMContext | undefined>(undefined)
56
57 export const useDM = () => {
58 const context = useContext(DMContext)
59 if (!context) {
60 throw new Error('useDM must be used within a DMProvider')
61 }
62 return context
63 }
64
65 export function DMProvider({ children }: { children: React.ReactNode }) {
66 const {
67 pubkey,
68 relayList,
69 nip04Encrypt,
70 nip04Decrypt,
71 nip44Encrypt,
72 nip44Decrypt,
73 hasNip44Support,
74 signEvent
75 } = useNostr()
76
77 const [conversations, setConversations] = useState<TConversation[]>([])
78 const [allConversations, setAllConversations] = useState<TConversation[]>([])
79 const [currentConversation, setCurrentConversation] = useState<string | null>(null)
80 const [messages, setMessages] = useState<TDirectMessage[]>([])
81 const [conversationMessages, setConversationMessages] = useState<Map<string, TDirectMessage[]>>(
82 () => new Map()
83 )
84 const [loadedConversations, setLoadedConversations] = useState<Set<string>>(() => new Set())
85 const [isLoading, setIsLoading] = useState(false)
86 const [isLoadingConversation, setIsLoadingConversation] = useState(false)
87 const [error, setError] = useState<string | null>(null)
88 const [preferNip44, setPreferNip44State] = useState(() => storage.getPreferNip44())
89 const [hasMoreConversations, setHasMoreConversations] = useState(false)
90 const [isNewConversation, setIsNewConversation] = useState(false)
91 const [provisionalPubkey, setProvisionalPubkey] = useState<string | null>(null)
92 const [deletedState, setDeletedState] = useState<TDMDeletedState | null>(null)
93 const [selectedMessages, setSelectedMessages] = useState<Set<string>>(new Set())
94 const [isSelectionMode, setIsSelectionMode] = useState(false)
95 const [lastSeenTimestamp, setLastSeenTimestamp] = useState<number>(() =>
96 pubkey ? storage.getDMLastSeenTimestamp(pubkey) : 0
97 )
98 const CONVERSATIONS_PER_PAGE = 100
99
100 // Track which conversation load is in progress to prevent race conditions
101 const loadingConversationRef = useRef<string | null>(null)
102 // Track if we've already initialized to avoid reloading on navigation
103 const hasInitializedRef = useRef(false)
104 const lastPubkeyRef = useRef<string | null>(null)
105 // Background subscription for real-time DM updates
106 const dmSubscriptionRef = useRef<{ close: () => void } | null>(null)
107 // Track newest message timestamp from subscription (to update hasNewMessages)
108 const [newestIncomingTimestamp, setNewestIncomingTimestamp] = useState(0)
109
110 // Create encryption wrapper object for dm.service
111 const encryption: IDMEncryption | null = useMemo(() => {
112 if (!pubkey) return null
113 return {
114 nip04Encrypt,
115 nip04Decrypt,
116 nip44Encrypt: hasNip44Support ? nip44Encrypt : undefined,
117 nip44Decrypt: hasNip44Support ? nip44Decrypt : undefined,
118 signEvent,
119 getPublicKey: () => pubkey
120 }
121 }, [pubkey, nip04Encrypt, nip04Decrypt, nip44Encrypt, nip44Decrypt, hasNip44Support, signEvent])
122
123 // Load deleted state and conversations when user is logged in
124 useEffect(() => {
125 if (pubkey && encryption) {
126 // Skip if already initialized for this pubkey (e.g., navigating back)
127 if (hasInitializedRef.current && lastPubkeyRef.current === pubkey) {
128 return
129 }
130
131 // Mark as initialized for this pubkey
132 hasInitializedRef.current = true
133 lastPubkeyRef.current = pubkey
134
135 // Reload lastSeenTimestamp from storage (useState initializer may have run when pubkey was null)
136 const savedTimestamp = storage.getDMLastSeenTimestamp(pubkey)
137 if (savedTimestamp > 0) {
138 setLastSeenTimestamp(savedTimestamp)
139 }
140
141 // Load deleted state FIRST before anything else
142 const loadDeletedStateAndConversations = async () => {
143 // Step 1: Load deleted state from IndexedDB
144 let currentDeletedState: TDMDeletedState = { deletedIds: [], deletedRanges: {} }
145 const cached = await indexedDb.getDeletedMessagesState(pubkey)
146 if (cached) {
147 currentDeletedState = cached
148 setDeletedState(cached)
149 } else {
150 setDeletedState(currentDeletedState)
151 }
152
153 // Step 2: Fetch from relays (kind 30078 Application Specific Data) - this takes priority
154 try {
155 const relayUrls = relayList?.read.length ? relayList.read : client.currentRelays
156 const events = await client.fetchEvents(relayUrls, {
157 kinds: [kinds.Application],
158 authors: [pubkey],
159 '#d': [ApplicationDataKey.DM_DELETED_MESSAGES],
160 limit: 1
161 })
162 if (events.length > 0) {
163 const event = events[0]
164 try {
165 const parsedState = JSON.parse(event.content) as TDMDeletedState
166 currentDeletedState = parsedState
167 setDeletedState(parsedState)
168 await indexedDb.putDeletedMessagesState(pubkey, parsedState)
169 } catch {
170 // Invalid JSON, ignore
171 }
172 }
173 } catch {
174 // Relay fetch failed, use cached
175 }
176
177 // Step 3: Load cached conversations (filtered by deleted state)
178 const cachedConvs = await indexedDb.getDMConversations(pubkey)
179 if (cachedConvs.length > 0) {
180 const conversations: TConversation[] = cachedConvs
181 .filter((c) => c.partnerPubkey && typeof c.partnerPubkey === 'string')
182 .filter((c) => !isConversationDeleted(c.partnerPubkey, c.lastMessageAt, currentDeletedState))
183 .map((c) => ({
184 partnerPubkey: c.partnerPubkey,
185 lastMessageAt: c.lastMessageAt,
186 lastMessagePreview: c.lastMessagePreview || '',
187 unreadCount: 0,
188 preferredEncryption: c.encryptionType
189 }))
190 setAllConversations(conversations)
191 setConversations(conversations.slice(0, CONVERSATIONS_PER_PAGE))
192 setHasMoreConversations(conversations.length > CONVERSATIONS_PER_PAGE)
193 }
194
195 // Step 4: Background refresh from network (don't clear existing data)
196 backgroundRefreshConversations()
197
198 // Step 5: Start real-time subscription for new DMs
199 if (dmSubscriptionRef.current) {
200 dmSubscriptionRef.current.close()
201 }
202 const relayUrls = relayList?.read || []
203 // Use the newest known conversation timestamp to avoid gaps between fetch and subscription
204 const newestKnownTimestamp = cachedConvs.length > 0
205 ? Math.max(...cachedConvs.map((c) => c.lastMessageAt))
206 : undefined
207 dmSubscriptionRef.current = dmService.subscribeToDMs(pubkey, relayUrls, (event) => {
208 // New DM event received - update timestamp to trigger hasNewMessages
209 setNewestIncomingTimestamp(event.created_at)
210 }, newestKnownTimestamp)
211 }
212
213 loadDeletedStateAndConversations()
214 } else {
215 // Clear all state on logout
216 setConversations([])
217 setAllConversations([])
218 setMessages([])
219 setConversationMessages(new Map())
220 setLoadedConversations(new Set())
221 setCurrentConversation(null)
222 setDeletedState(null)
223 setSelectedMessages(new Set())
224 setIsSelectionMode(false)
225 // Clear in-memory plaintext cache
226 clearPlaintextCache()
227 // Stop DM subscription
228 if (dmSubscriptionRef.current) {
229 dmSubscriptionRef.current.close()
230 dmSubscriptionRef.current = null
231 }
232 // Reset initialization flag so we reload on next login
233 hasInitializedRef.current = false
234 lastPubkeyRef.current = null
235 }
236 }, [pubkey, encryption, relayList])
237
238 // Load full conversation when selected
239 useEffect(() => {
240 if (!currentConversation || !pubkey || !encryption) {
241 setMessages([])
242 loadingConversationRef.current = null
243 return
244 }
245
246 // Capture the conversation we're loading to detect stale updates
247 const targetConversation = currentConversation
248 loadingConversationRef.current = targetConversation
249
250 // Check if we already have messages in memory for this conversation
251 const existing = conversationMessages.get(targetConversation)
252 if (existing && existing.length > 0) {
253 setMessages(existing)
254 // If already fully loaded and data is present, don't fetch again
255 if (loadedConversations.has(targetConversation)) {
256 return
257 }
258 }
259
260 // Load full conversation history
261 const loadConversation = async () => {
262 setIsLoadingConversation(true)
263 try {
264 // First, try to load from IndexedDB cache for instant display
265 const cached = await indexedDb.getConversationMessages(pubkey, targetConversation)
266 if (cached && cached.length > 0 && loadingConversationRef.current === targetConversation) {
267 const cachedMessages: TDirectMessage[] = cached
268 .filter(
269 (m) => !isMessageDeleted(m.id, targetConversation, m.createdAt, deletedState)
270 )
271 .map((m) => ({
272 id: m.id,
273 senderPubkey: m.senderPubkey,
274 recipientPubkey: m.recipientPubkey,
275 content: m.content,
276 createdAt: m.createdAt,
277 encryptionType: m.encryptionType,
278 event: {} as Event,
279 decryptedContent: m.content,
280 seenOnRelays: m.seenOnRelays
281 }))
282 setMessages(cachedMessages)
283 setConversationMessages((prev) => new Map(prev).set(targetConversation, cachedMessages))
284 }
285
286 // Then fetch fresh from relays
287 const relayUrls = relayList?.read || []
288 const events = await dmService.fetchConversationEvents(pubkey, targetConversation, relayUrls)
289
290 // Check if user switched to a different conversation while we were loading
291 if (loadingConversationRef.current !== targetConversation) {
292 return // Abort - user switched conversations
293 }
294
295 // Pre-filter events: skip gift wraps older than global delete cutoff (before decryption)
296 const deleteCutoff = getGlobalDeleteCutoff(deletedState)
297 const filteredEvents = deleteCutoff > 0
298 ? events.filter((e) => e.kind !== 1059 || e.created_at > deleteCutoff)
299 : events
300
301 // Decrypt messages in batches to avoid blocking UI
302 // Progressive updates: show messages as they're decrypted
303 const allDecrypted: TDirectMessage[] = []
304 const seenIds = new Set<string>()
305
306 await decryptMessagesInBatches(
307 filteredEvents,
308 encryption,
309 pubkey,
310 10, // batch size
311 (batchMessages) => {
312 // Check if still on same conversation before updating
313 if (loadingConversationRef.current !== targetConversation) return
314
315 // Filter to only messages in this conversation (excluding deleted and duplicates)
316 const validMessages = batchMessages.filter((message) => {
317 if (seenIds.has(message.id)) return false
318 if (isNircProtocolMessage(message.content ?? '')) return false
319 const partner =
320 message.senderPubkey === pubkey ? message.recipientPubkey : message.senderPubkey
321 if (partner !== targetConversation) return false
322 if (isMessageDeleted(message.id, targetConversation, message.createdAt, deletedState)) return false
323 seenIds.add(message.id)
324 return true
325 })
326
327 allDecrypted.push(...validMessages)
328
329 // Sort and update progressively
330 const sorted = [...allDecrypted].sort((a, b) => a.createdAt - b.createdAt)
331 setMessages(sorted)
332 setConversationMessages((prev) => new Map(prev).set(targetConversation, sorted))
333 }
334 )
335
336 // Check again after decryption (which can take time)
337 if (loadingConversationRef.current !== targetConversation) {
338 return // Abort - user switched conversations
339 }
340
341 // Final sort
342 const sorted = allDecrypted.sort((a, b) => a.createdAt - b.createdAt)
343
344 // Update state only if still on same conversation
345 setConversationMessages((prev) => new Map(prev).set(targetConversation, sorted))
346 setLoadedConversations((prev) => new Set(prev).add(targetConversation))
347 setMessages(sorted)
348
349 // Cache messages to IndexedDB (without the full event object)
350 const toCache = sorted.map((m) => ({
351 id: m.id,
352 senderPubkey: m.senderPubkey,
353 recipientPubkey: m.recipientPubkey,
354 content: m.decryptedContent || m.content,
355 createdAt: m.createdAt,
356 encryptionType: m.encryptionType,
357 seenOnRelays: m.seenOnRelays
358 }))
359 await indexedDb.putConversationMessages(pubkey, targetConversation, toCache)
360 } catch {
361 // Failed to load conversation
362 } finally {
363 // Only clear loading state if this is still the active load
364 if (loadingConversationRef.current === targetConversation) {
365 setIsLoadingConversation(false)
366 }
367 }
368 }
369
370 loadConversation()
371 }, [currentConversation, pubkey, encryption, relayList, deletedState])
372
373 // Background refresh - merges new data without clearing existing cache
374 const backgroundRefreshConversations = useCallback(async () => {
375 if (!pubkey || !encryption) return
376
377 try {
378 // Get relay URLs
379 const relayUrls = relayList?.read || []
380
381 // Fetch recent DM events (raw, not decrypted)
382 const events = await dmService.fetchRecentDMEvents(pubkey, relayUrls)
383
384 // Separate NIP-04 events and gift wraps
385 const nip04Events = events.filter((e) => e.kind === 4)
386 const giftWraps = events.filter((e) => e.kind === 1059)
387
388 // Build conversation map from existing conversations
389 const conversationMap = new Map<string, TConversation>()
390 allConversations.forEach((c) => conversationMap.set(c.partnerPubkey, c))
391
392 // Add NIP-04 conversations
393 const nip04Convs = dmService.groupEventsIntoConversations(nip04Events, pubkey)
394 nip04Convs.forEach((conv, key) => {
395 const existing = conversationMap.get(key)
396 if (!existing || conv.lastMessageAt > existing.lastMessageAt) {
397 conversationMap.set(key, conv)
398 }
399 })
400
401 // Update UI with NIP-04 data (filtered by deleted state)
402 const updateAndShowConversations = () => {
403 const validConversations = Array.from(conversationMap.values())
404 .filter((conv) => conv.partnerPubkey && typeof conv.partnerPubkey === 'string')
405 .filter((conv) => !isConversationDeleted(conv.partnerPubkey, conv.lastMessageAt, deletedState))
406 const sortedConversations = validConversations.sort(
407 (a, b) => b.lastMessageAt - a.lastMessageAt
408 )
409 setAllConversations(sortedConversations)
410 setConversations(sortedConversations.slice(0, CONVERSATIONS_PER_PAGE))
411 setHasMoreConversations(sortedConversations.length > CONVERSATIONS_PER_PAGE)
412 }
413
414 updateAndShowConversations()
415
416 // Process gift wraps in background (progressive, no UI blocking)
417 const sortedGiftWraps = giftWraps.sort((a, b) => b.created_at - a.created_at)
418
419 // Calculate global delete cutoff for pre-filtering (skip decryption for old deleted messages)
420 const deleteCutoff = getGlobalDeleteCutoff(deletedState)
421
422 for (const giftWrap of sortedGiftWraps) {
423 // Skip gift wraps older than global delete cutoff (no decryption needed)
424 if (deleteCutoff > 0 && giftWrap.created_at <= deleteCutoff) {
425 continue
426 }
427
428 try {
429 const message = await dmService.decryptMessage(giftWrap, encryption, pubkey)
430 if (message && message.senderPubkey && message.recipientPubkey) {
431 const partnerPubkey =
432 message.senderPubkey === pubkey ? message.recipientPubkey : message.senderPubkey
433
434 if (!partnerPubkey || partnerPubkey === '__reaction__') continue
435 if (isNircProtocolMessage(message.content ?? '')) continue
436
437 const existing = conversationMap.get(partnerPubkey)
438 if (!existing || message.createdAt > existing.lastMessageAt) {
439 const preview = (message.content ?? '').substring(0, 100)
440 conversationMap.set(partnerPubkey, {
441 partnerPubkey,
442 lastMessageAt: message.createdAt,
443 lastMessagePreview: preview,
444 unreadCount: 0,
445 preferredEncryption: 'nip17'
446 })
447 updateAndShowConversations()
448 }
449
450 // Cache conversation metadata
451 indexedDb
452 .putDMConversation(
453 pubkey,
454 partnerPubkey,
455 message.createdAt,
456 (message.content ?? '').substring(0, 100),
457 'nip17'
458 )
459 .catch(() => {})
460 }
461 } catch {
462 // Skip failed decryptions silently
463 }
464 }
465
466 // Final update and cache all conversations
467 updateAndShowConversations()
468 const finalConversations = Array.from(conversationMap.values())
469 Promise.all(
470 finalConversations.map((conv) =>
471 indexedDb.putDMConversation(
472 pubkey,
473 conv.partnerPubkey,
474 conv.lastMessageAt,
475 conv.lastMessagePreview,
476 conv.preferredEncryption
477 )
478 )
479 ).catch(() => {})
480 } catch {
481 // Background refresh failed silently - cached data still shown
482 }
483 }, [pubkey, encryption, relayList, deletedState, allConversations])
484
485 // Full refresh - fetches fresh data from network (manual action)
486 const refreshConversations = useCallback(async () => {
487 if (!pubkey || !encryption) return
488
489 setIsLoading(true)
490 setError(null)
491
492 try {
493 // Get relay URLs
494 const relayUrls = relayList?.read || []
495
496 // Fetch recent DM events (raw, not decrypted)
497 const events = await dmService.fetchRecentDMEvents(pubkey, relayUrls)
498
499 // Separate NIP-04 events and gift wraps
500 const nip04Events = events.filter((e) => e.kind === 4)
501 const giftWraps = events.filter((e) => e.kind === 1059)
502
503 // Build conversation map from existing conversations (merge, don't replace)
504 const conversationMap = new Map<string, TConversation>()
505 allConversations.forEach((c) => conversationMap.set(c.partnerPubkey, c))
506
507 // Add NIP-04 conversations
508 const nip04Convs = dmService.groupEventsIntoConversations(nip04Events, pubkey)
509 nip04Convs.forEach((conv, key) => {
510 const existing = conversationMap.get(key)
511 if (!existing || conv.lastMessageAt > existing.lastMessageAt) {
512 conversationMap.set(key, conv)
513 }
514 })
515
516 // Show NIP-04 conversations immediately (filtered by deleted state)
517 const updateAndShowConversations = () => {
518 const validConversations = Array.from(conversationMap.values())
519 .filter((conv) => conv.partnerPubkey && typeof conv.partnerPubkey === 'string')
520 .filter((conv) => !isConversationDeleted(conv.partnerPubkey, conv.lastMessageAt, deletedState))
521 const sortedConversations = validConversations.sort(
522 (a, b) => b.lastMessageAt - a.lastMessageAt
523 )
524 setAllConversations(sortedConversations)
525 setConversations(sortedConversations.slice(0, CONVERSATIONS_PER_PAGE))
526 setHasMoreConversations(sortedConversations.length > CONVERSATIONS_PER_PAGE)
527 }
528
529 updateAndShowConversations()
530 setIsLoading(false) // Stop spinner, but continue processing in background
531
532 // Sort gift wraps by created_at descending (newest first)
533 const sortedGiftWraps = giftWraps.sort((a, b) => b.created_at - a.created_at)
534
535 // Process gift wraps one by one in the background (progressive loading)
536 for (const giftWrap of sortedGiftWraps) {
537 try {
538 const message = await dmService.decryptMessage(giftWrap, encryption, pubkey)
539 if (message && message.senderPubkey && message.recipientPubkey) {
540 const partnerPubkey =
541 message.senderPubkey === pubkey ? message.recipientPubkey : message.senderPubkey
542
543 if (!partnerPubkey || partnerPubkey === '__reaction__') continue
544 if (isNircProtocolMessage(message.content ?? '')) continue
545
546 const existing = conversationMap.get(partnerPubkey)
547 if (!existing || message.createdAt > existing.lastMessageAt) {
548 const preview = (message.content ?? '').substring(0, 100)
549 conversationMap.set(partnerPubkey, {
550 partnerPubkey,
551 lastMessageAt: message.createdAt,
552 lastMessagePreview: preview,
553 unreadCount: 0,
554 preferredEncryption: 'nip17'
555 })
556 // Update UI progressively
557 updateAndShowConversations()
558 }
559
560 // Cache conversation metadata
561 indexedDb
562 .putDMConversation(
563 pubkey,
564 partnerPubkey,
565 message.createdAt,
566 (message.content ?? '').substring(0, 100),
567 'nip17'
568 )
569 .catch(() => {})
570 }
571 } catch {
572 // Skip failed decryptions silently
573 }
574 }
575
576 // Final update and cache all conversations
577 updateAndShowConversations()
578 const finalConversations = Array.from(conversationMap.values())
579 Promise.all(
580 finalConversations.map((conv) =>
581 indexedDb.putDMConversation(
582 pubkey,
583 conv.partnerPubkey,
584 conv.lastMessageAt,
585 conv.lastMessagePreview,
586 conv.preferredEncryption
587 )
588 )
589 ).catch(() => {})
590 } catch {
591 setError('Failed to load conversations')
592 setIsLoading(false)
593 }
594 }, [pubkey, encryption, relayList, deletedState, allConversations])
595
596 const loadMoreConversations = useCallback(async () => {
597 if (!hasMoreConversations) return
598
599 const currentCount = conversations.length
600 const nextBatch = allConversations.slice(currentCount, currentCount + CONVERSATIONS_PER_PAGE)
601 setConversations((prev) => [...prev, ...nextBatch])
602 setHasMoreConversations(currentCount + nextBatch.length < allConversations.length)
603 }, [conversations.length, allConversations, hasMoreConversations])
604
605 const selectConversation = useCallback(
606 (partnerPubkey: string | null) => {
607 // Clear messages immediately to prevent showing old conversation
608 if (partnerPubkey !== currentConversation) {
609 setMessages([])
610 }
611 setCurrentConversation(partnerPubkey)
612 },
613 [currentConversation]
614 )
615
616 // Start a new conversation - marks it as new for UI effects (pulsing settings button)
617 // Creates a provisional conversation that appears in the list immediately
618 const startConversation = useCallback(
619 (partnerPubkey: string) => {
620 // Check if this is a new conversation (not in existing list)
621 const existingConversation = allConversations.find(
622 (c) => c.partnerPubkey === partnerPubkey
623 )
624 if (!existingConversation) {
625 setIsNewConversation(true)
626 setProvisionalPubkey(partnerPubkey)
627 // Add a provisional conversation to the list so it appears immediately
628 // Default to nip17 (modern encryption) to avoid dual-send on first message
629 const provisionalConversation: TConversation = {
630 partnerPubkey,
631 lastMessageAt: Math.floor(Date.now() / 1000),
632 lastMessagePreview: '',
633 unreadCount: 0,
634 preferredEncryption: 'nip17'
635 }
636 // Add to front of both lists
637 setAllConversations((prev) => [provisionalConversation, ...prev])
638 setConversations((prev) => [provisionalConversation, ...prev])
639 }
640 // Clear messages and select the conversation
641 setMessages([])
642 setCurrentConversation(partnerPubkey)
643 },
644 [allConversations]
645 )
646
647 const clearNewConversationFlag = useCallback(() => {
648 setIsNewConversation(false)
649 }, [])
650
651 // Dismiss a provisional conversation (remove from list without sending any messages)
652 const dismissProvisionalConversation = useCallback(() => {
653 if (!provisionalPubkey) return
654
655 // Remove from conversation lists
656 setAllConversations((prev) => prev.filter((c) => c.partnerPubkey !== provisionalPubkey))
657 setConversations((prev) => prev.filter((c) => c.partnerPubkey !== provisionalPubkey))
658
659 // Clear provisional state
660 setProvisionalPubkey(null)
661 setIsNewConversation(false)
662
663 // Deselect if this was the current conversation
664 if (currentConversation === provisionalPubkey) {
665 setCurrentConversation(null)
666 setMessages([])
667 }
668 }, [provisionalPubkey, currentConversation])
669
670 // Reload the current conversation by clearing its cached state
671 const reloadConversation = useCallback(() => {
672 if (!currentConversation) return
673
674 // Clear the loaded state and cached messages for this conversation
675 setLoadedConversations((prev) => {
676 const next = new Set(prev)
677 next.delete(currentConversation)
678 return next
679 })
680 setConversationMessages((prev) => {
681 const next = new Map(prev)
682 next.delete(currentConversation)
683 return next
684 })
685 // Clear current messages to trigger a reload
686 setMessages([])
687 }, [currentConversation])
688
689 const sendMessage = useCallback(
690 async (content: string, customRelayUrls?: string[]) => {
691 if (!pubkey || !encryption || !currentConversation) {
692 throw new Error('Cannot send message: not logged in or no conversation selected')
693 }
694
695 // Use custom relays if provided, otherwise fall back to user's write relays
696 const relayUrls = customRelayUrls && customRelayUrls.length > 0
697 ? customRelayUrls
698 : (relayList?.write || [])
699
700 // Find existing encryption type for this conversation
701 const conversation = conversations.find((c) => c.partnerPubkey === currentConversation)
702 const existingEncryptionType: TDMEncryptionType | null =
703 conversation?.preferredEncryption ?? null
704
705 // Check for conversation-specific encryption preference
706 const encryptionPref = await indexedDb.getConversationEncryptionPreference(
707 pubkey,
708 currentConversation
709 )
710
711 // Determine the encryption to use based on preference
712 let effectiveEncryption: TDMEncryptionType | null = existingEncryptionType
713
714 if (encryptionPref === 'nip04') {
715 effectiveEncryption = 'nip04'
716 } else if (encryptionPref === 'nip17') {
717 effectiveEncryption = 'nip17'
718 }
719 // 'auto' keeps the existing behavior (match conversation or send both)
720
721 // Send the message
722 const sentEvents = await dmService.sendDM(
723 currentConversation,
724 content,
725 encryption,
726 relayUrls,
727 preferNip44,
728 effectiveEncryption
729 )
730
731 // Create local message for immediate display
732 const now = Math.floor(Date.now() / 1000)
733 // Determine the actual encryption type used for the message
734 const usedEncryptionType: TDMEncryptionType =
735 effectiveEncryption || (preferNip44 ? 'nip17' : 'nip04')
736 const newMessage: TDirectMessage = {
737 id: sentEvents[0]?.id || `local-${now}`,
738 senderPubkey: pubkey,
739 recipientPubkey: currentConversation,
740 content,
741 createdAt: now,
742 encryptionType: usedEncryptionType,
743 event: sentEvents[0] || ({} as Event),
744 decryptedContent: content
745 }
746
747 // Add to messages for this conversation
748 setConversationMessages((prev) => {
749 const existing = prev.get(currentConversation) || []
750 return new Map(prev).set(currentConversation, [...existing, newMessage])
751 })
752 setMessages((prev) => [...prev, newMessage])
753
754 // Update conversation
755 setConversations((prev) => {
756 const existing = prev.find((c) => c.partnerPubkey === currentConversation)
757 if (existing) {
758 return prev.map((c) =>
759 c.partnerPubkey === currentConversation
760 ? {
761 ...c,
762 lastMessageAt: now,
763 lastMessagePreview: content.substring(0, 100),
764 preferredEncryption: usedEncryptionType
765 }
766 : c
767 )
768 } else {
769 return [
770 {
771 partnerPubkey: currentConversation,
772 lastMessageAt: now,
773 lastMessagePreview: content.substring(0, 100),
774 unreadCount: 0,
775 preferredEncryption: usedEncryptionType
776 },
777 ...prev
778 ]
779 }
780 })
781
782 // Clear provisional state - conversation is now permanent
783 if (provisionalPubkey === currentConversation) {
784 setProvisionalPubkey(null)
785 setIsNewConversation(false)
786 }
787 },
788 [pubkey, encryption, currentConversation, relayList, conversations, preferNip44, provisionalPubkey]
789 )
790
791 const setPreferNip44 = useCallback((prefer: boolean) => {
792 setPreferNip44State(prefer)
793 storage.setPreferNip44(prefer)
794 dispatchSettingsChanged()
795 }, [])
796
797 // Selection mode methods
798 const toggleMessageSelection = useCallback((messageId: string) => {
799 setSelectedMessages((prev) => {
800 const next = new Set(prev)
801 if (next.has(messageId)) {
802 next.delete(messageId)
803 // Exit selection mode if nothing selected
804 if (next.size === 0) {
805 setIsSelectionMode(false)
806 }
807 } else {
808 next.add(messageId)
809 // Enter selection mode when first message selected
810 if (!isSelectionMode) {
811 setIsSelectionMode(true)
812 }
813 }
814 return next
815 })
816 }, [isSelectionMode])
817
818 const selectAllMessages = useCallback(() => {
819 const allIds = new Set(messages.map((m) => m.id))
820 setSelectedMessages(allIds)
821 setIsSelectionMode(true)
822 }, [messages])
823
824 const clearSelection = useCallback(() => {
825 setSelectedMessages(new Set())
826 setIsSelectionMode(false)
827 }, [])
828
829 // Helper to publish deleted state to relays
830 const publishDeletedState = useCallback(
831 async (newState: TDMDeletedState) => {
832 if (!pubkey || !encryption) return
833
834 // Save to IndexedDB
835 await indexedDb.putDeletedMessagesState(pubkey, newState)
836
837 // Publish to relays
838 const relayUrls = relayList?.write.length ? relayList.write : client.currentRelays
839 const draftEvent = createDeletedMessagesDraftEvent(newState)
840 const signedEvent = await encryption.signEvent(draftEvent)
841 await client.publishEvent(relayUrls, signedEvent)
842 },
843 [pubkey, encryption, relayList]
844 )
845
846 // Delete selected messages (soft delete only - no kind 5, so undelete always works)
847 const deleteSelectedMessages = useCallback(async () => {
848 if (!pubkey || selectedMessages.size === 0) return
849
850 const messageIds = Array.from(selectedMessages)
851
852 // Update deleted state
853 const newDeletedState: TDMDeletedState = {
854 deletedIds: [...(deletedState?.deletedIds || []), ...messageIds],
855 deletedRanges: deletedState?.deletedRanges || {}
856 }
857 setDeletedState(newDeletedState)
858
859 // Remove from UI
860 setMessages((prev) => prev.filter((m) => !selectedMessages.has(m.id)))
861 if (currentConversation) {
862 setConversationMessages((prev) => {
863 const existing = prev.get(currentConversation) || []
864 return new Map(prev).set(
865 currentConversation,
866 existing.filter((m) => !selectedMessages.has(m.id))
867 )
868 })
869 }
870
871 // Clear selection
872 setSelectedMessages(new Set())
873 setIsSelectionMode(false)
874
875 // Publish to relays
876 await publishDeletedState(newDeletedState)
877 }, [pubkey, selectedMessages, deletedState, currentConversation, publishDeletedState])
878
879 // Delete all messages in current conversation (timestamp range)
880 const deleteAllInConversation = useCallback(async () => {
881 if (!pubkey || !currentConversation) return
882
883 const now = Math.floor(Date.now() / 1000)
884 const newRange = { start: 0, end: now }
885
886 // Update deleted state with new range
887 const newDeletedState: TDMDeletedState = {
888 deletedIds: deletedState?.deletedIds || [],
889 deletedRanges: {
890 ...(deletedState?.deletedRanges || {}),
891 [currentConversation]: [
892 ...(deletedState?.deletedRanges[currentConversation] || []),
893 newRange
894 ]
895 }
896 }
897 setDeletedState(newDeletedState)
898
899 // Clear messages from UI
900 setMessages([])
901 setConversationMessages((prev) => {
902 const next = new Map(prev)
903 next.delete(currentConversation)
904 return next
905 })
906
907 // Remove conversation from list
908 setConversations((prev) => prev.filter((c) => c.partnerPubkey !== currentConversation))
909 setAllConversations((prev) => prev.filter((c) => c.partnerPubkey !== currentConversation))
910
911 // Clear selection and close conversation
912 setSelectedMessages(new Set())
913 setIsSelectionMode(false)
914 setCurrentConversation(null)
915
916 // Publish to relays
917 await publishDeletedState(newDeletedState)
918 }, [pubkey, currentConversation, deletedState, publishDeletedState])
919
920 // Undelete all messages in current conversation (remove delete markers)
921 const undeleteAllInConversation = useCallback(async () => {
922 if (!pubkey || !currentConversation) return
923
924 // Remove all delete markers for this conversation
925 const newDeletedState: TDMDeletedState = {
926 deletedIds: deletedState?.deletedIds || [],
927 deletedRanges: {
928 ...(deletedState?.deletedRanges || {}),
929 [currentConversation]: [] // Clear all ranges for this conversation
930 }
931 }
932 setDeletedState(newDeletedState)
933
934 // Clear cached messages to force reload
935 setConversationMessages((prev) => {
936 const next = new Map(prev)
937 next.delete(currentConversation)
938 return next
939 })
940 setLoadedConversations((prev) => {
941 const next = new Set(prev)
942 next.delete(currentConversation)
943 return next
944 })
945
946 // Publish to relays
947 await publishDeletedState(newDeletedState)
948
949 // Trigger a background refresh of conversations
950 await backgroundRefreshConversations()
951 }, [pubkey, currentConversation, deletedState, publishDeletedState, backgroundRefreshConversations])
952
953 // Filter out deleted conversations from the list
954 const filteredConversations = useMemo(() => {
955 if (!deletedState) return conversations
956 return conversations.filter(
957 (c) => !isConversationDeleted(c.partnerPubkey, c.lastMessageAt, deletedState)
958 )
959 }, [conversations, deletedState])
960
961 // Calculate total unread count across all conversations
962 const totalUnreadCount = useMemo(() => {
963 return filteredConversations.reduce((sum, c) => sum + c.unreadCount, 0)
964 }, [filteredConversations])
965
966 // Check if there are new messages since last seen
967 const newestMessageTimestamp = useMemo(() => {
968 const fromConversations = filteredConversations.length === 0
969 ? 0
970 : Math.max(...filteredConversations.map((c) => c.lastMessageAt))
971 // Also consider real-time incoming messages
972 return Math.max(fromConversations, newestIncomingTimestamp)
973 }, [filteredConversations, newestIncomingTimestamp])
974
975 // Only show notification dot if:
976 // 1. User has a saved lastSeenTimestamp AND there are newer messages
977 // 2. OR we received a real-time message during this session (newestIncomingTimestamp > 0)
978 // This prevents the dot from appearing on first load when lastSeenTimestamp is 0
979 const hasNewMessages = lastSeenTimestamp > 0
980 ? newestMessageTimestamp > lastSeenTimestamp
981 : newestIncomingTimestamp > 0
982
983 // Mark inbox as seen (update last seen timestamp)
984 const markInboxAsSeen = useCallback(() => {
985 if (!pubkey) return
986 // Always clear incoming timestamp first to remove notification dot
987 setNewestIncomingTimestamp(0)
988 // Only update storage if we have a valid timestamp
989 if (newestMessageTimestamp > 0) {
990 setLastSeenTimestamp(newestMessageTimestamp)
991 storage.setDMLastSeenTimestamp(pubkey, newestMessageTimestamp)
992 }
993 }, [pubkey, newestMessageTimestamp])
994
995 return (
996 <DMContext.Provider
997 value={{
998 conversations: filteredConversations,
999 currentConversation,
1000 messages,
1001 isLoading,
1002 isLoadingConversation,
1003 error,
1004 selectConversation,
1005 startConversation,
1006 sendMessage,
1007 refreshConversations,
1008 reloadConversation,
1009 loadMoreConversations,
1010 hasMoreConversations,
1011 preferNip44,
1012 setPreferNip44,
1013 isNewConversation,
1014 clearNewConversationFlag,
1015 dismissProvisionalConversation,
1016 // Unread tracking
1017 totalUnreadCount,
1018 hasNewMessages,
1019 markInboxAsSeen,
1020 // Selection mode
1021 selectedMessages,
1022 isSelectionMode,
1023 toggleMessageSelection,
1024 selectAllMessages,
1025 clearSelection,
1026 // Deletion
1027 deleteSelectedMessages,
1028 deleteAllInConversation,
1029 undeleteAllInConversation
1030 }}
1031 >
1032 {children}
1033 </DMContext.Provider>
1034 )
1035 }
1036