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[], expirationSeconds?: number) => 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 totalUnreadCount: number
40 hasNewMessages: boolean
41 markInboxAsSeen: () => void
42 selectedMessages: Set<string>
43 isSelectionMode: boolean
44 toggleMessageSelection: (messageId: string) => void
45 selectAllMessages: () => void
46 clearSelection: () => void
47 deleteSelectedMessages: () => Promise<void>
48 deleteAllInConversation: () => Promise<void>
49 undeleteAllInConversation: () => Promise<void>
50 }
51
52 const DMContext = createContext<TDMContext | undefined>(undefined)
53
54 export const useDM = () => {
55 const context = useContext(DMContext)
56 if (!context) {
57 throw new Error('useDM must be used within a DMProvider')
58 }
59 return context
60 }
61
62 // Merge two message arrays, dedupe by ID, sort by timestamp
63 function mergeAndDedupe(
64 existing: TDirectMessage[],
65 incoming: TDirectMessage[]
66 ): TDirectMessage[] {
67 const ids = new Set(existing.map((m) => m.id))
68 const innerIds = new Set(existing.map((m) => m.innerEventId).filter(Boolean))
69 const merged = [...existing]
70 for (const msg of incoming) {
71 if (ids.has(msg.id)) continue
72 if (msg.innerEventId && innerIds.has(msg.innerEventId)) continue
73 merged.push(msg)
74 ids.add(msg.id)
75 if (msg.innerEventId) innerIds.add(msg.innerEventId)
76 }
77 return merged.sort((a, b) => a.createdAt - b.createdAt)
78 }
79
80 export function DMProvider({ children }: { children: React.ReactNode }) {
81 const {
82 pubkey,
83 relayList,
84 nip04Encrypt,
85 nip04Decrypt,
86 nip44Encrypt,
87 nip44Decrypt,
88 hasNip44Support,
89 signEvent
90 } = useNostr()
91
92 const [conversations, setConversations] = useState<TConversation[]>([])
93 const [allConversations, setAllConversations] = useState<TConversation[]>([])
94 const [currentConversation, setCurrentConversation] = useState<string | null>(null)
95 const [messages, setMessages] = useState<TDirectMessage[]>([])
96 const [isLoading, setIsLoading] = useState(false)
97 const [isLoadingConversation, setIsLoadingConversation] = useState(false)
98 const [error, setError] = useState<string | null>(null)
99 const [preferNip44, setPreferNip44State] = useState(() => storage.getPreferNip44())
100 const [hasMoreConversations, setHasMoreConversations] = useState(false)
101 const [isNewConversation, setIsNewConversation] = useState(false)
102 const [provisionalPubkey, setProvisionalPubkey] = useState<string | null>(null)
103 const [deletedState, setDeletedState] = useState<TDMDeletedState | null>(null)
104 const [selectedMessages, setSelectedMessages] = useState<Set<string>>(new Set())
105 const [isSelectionMode, setIsSelectionMode] = useState(false)
106 const [lastSeenTimestamp, setLastSeenTimestamp] = useState<number>(() =>
107 pubkey ? storage.getDMLastSeenTimestamp(pubkey) : 0
108 )
109 const [newestIncomingTimestamp, setNewestIncomingTimestamp] = useState(0)
110 const [reloadTrigger, setReloadTrigger] = useState(0)
111 const CONVERSATIONS_PER_PAGE = 100
112
113 const loadingConversationRef = useRef<string | null>(null)
114 const hasInitializedRef = useRef(false)
115 const lastPubkeyRef = useRef<string | null>(null)
116 const dmSubscriptionRef = useRef<{ close: () => void } | null>(null)
117 const currentConversationRef = useRef<string | null>(null)
118
119 // Keep ref in sync
120 useEffect(() => {
121 currentConversationRef.current = currentConversation
122 }, [currentConversation])
123
124 const encryption: IDMEncryption | null = useMemo(() => {
125 if (!pubkey) return null
126 return {
127 nip04Encrypt,
128 nip04Decrypt,
129 nip44Encrypt: hasNip44Support ? nip44Encrypt : undefined,
130 nip44Decrypt: hasNip44Support ? nip44Decrypt : undefined,
131 signEvent,
132 getPublicKey: () => pubkey
133 }
134 }, [pubkey, nip04Encrypt, nip04Decrypt, nip44Encrypt, nip44Decrypt, hasNip44Support, signEvent])
135
136 // Initialize: load deleted state, conversations, start subscription
137 useEffect(() => {
138 if (pubkey && encryption) {
139 if (hasInitializedRef.current && lastPubkeyRef.current === pubkey) {
140 return
141 }
142 hasInitializedRef.current = true
143 lastPubkeyRef.current = pubkey
144
145 const savedTimestamp = storage.getDMLastSeenTimestamp(pubkey)
146 if (savedTimestamp > 0) {
147 setLastSeenTimestamp(savedTimestamp)
148 }
149
150 const initialize = async () => {
151 // Load deleted state
152 let currentDeletedState: TDMDeletedState = { deletedIds: [], deletedRanges: {} }
153 const cached = await indexedDb.getDeletedMessagesState(pubkey)
154 if (cached) {
155 currentDeletedState = cached
156 setDeletedState(cached)
157 } else {
158 setDeletedState(currentDeletedState)
159 }
160
161 try {
162 const relayUrls = relayList?.read.length ? relayList.read : client.currentRelays
163 const events = await client.fetchEvents(relayUrls, {
164 kinds: [kinds.Application],
165 authors: [pubkey],
166 '#d': [ApplicationDataKey.DM_DELETED_MESSAGES],
167 limit: 1
168 })
169 if (events.length > 0) {
170 try {
171 const parsedState = JSON.parse(events[0].content) as TDMDeletedState
172 currentDeletedState = parsedState
173 setDeletedState(parsedState)
174 await indexedDb.putDeletedMessagesState(pubkey, parsedState)
175 } catch {
176 // Invalid JSON
177 }
178 }
179 } catch {
180 // Relay fetch failed
181 }
182
183 // Load cached conversations
184 const cachedConvs = await indexedDb.getDMConversations(pubkey)
185 if (cachedConvs.length > 0) {
186 const convs: TConversation[] = cachedConvs
187 .filter((c) => c.partnerPubkey && typeof c.partnerPubkey === 'string')
188 .filter((c) => !isConversationDeleted(c.partnerPubkey, c.lastMessageAt, currentDeletedState))
189 .map((c) => ({
190 partnerPubkey: c.partnerPubkey,
191 lastMessageAt: c.lastMessageAt,
192 lastMessagePreview: c.lastMessagePreview || '',
193 unreadCount: 0,
194 preferredEncryption: c.encryptionType
195 }))
196 setAllConversations(convs)
197 setConversations(convs.slice(0, CONVERSATIONS_PER_PAGE))
198 setHasMoreConversations(convs.length > CONVERSATIONS_PER_PAGE)
199 }
200
201 // Background refresh
202 backgroundRefreshConversations()
203
204 // Start real-time subscription
205 if (dmSubscriptionRef.current) {
206 dmSubscriptionRef.current.close()
207 }
208 const relayUrls = relayList?.read || []
209 const newestKnownTimestamp = cachedConvs.length > 0
210 ? Math.max(...cachedConvs.map((c) => c.lastMessageAt))
211 : undefined
212
213 dmSubscriptionRef.current = dmService.subscribeToDMs(pubkey, relayUrls, async (event) => {
214 setNewestIncomingTimestamp(event.created_at)
215
216 if (encryption) {
217 try {
218 const message = await dmService.decryptMessage(event, encryption, pubkey)
219 if (message && message.senderPubkey && message.recipientPubkey) {
220 const partner = message.senderPubkey === pubkey
221 ? message.recipientPubkey
222 : message.senderPubkey
223 const activeConv = currentConversationRef.current
224
225 if (activeConv && partner === activeConv) {
226 // Append directly — dedup by outer ID and inner event ID (self-copy has same inner)
227 setMessages((prev) => {
228 if (prev.some((m) => m.id === message.id)) return prev
229 if (message.innerEventId && prev.some((m) => m.innerEventId === message.innerEventId)) return prev
230 return [...prev, message].sort((a, b) => a.createdAt - b.createdAt)
231 })
232 }
233
234 // Always update conversation list (for both active and non-active conversations)
235 const preview = (message.content ?? '').substring(0, 100)
236 const updateConvList = (prev: TConversation[]) => {
237 const existing = prev.find((c) => c.partnerPubkey === partner)
238 if (existing) {
239 if (message.createdAt <= existing.lastMessageAt) return prev
240 return prev.map((c) =>
241 c.partnerPubkey === partner
242 ? {
243 ...c,
244 lastMessageAt: message.createdAt,
245 lastMessagePreview: preview,
246 unreadCount: partner !== activeConv ? c.unreadCount + 1 : c.unreadCount
247 }
248 : c
249 ).sort((a, b) => b.lastMessageAt - a.lastMessageAt)
250 }
251 return [{
252 partnerPubkey: partner,
253 lastMessageAt: message.createdAt,
254 lastMessagePreview: preview,
255 unreadCount: 1,
256 preferredEncryption: message.encryptionType
257 }, ...prev]
258 }
259 setConversations(updateConvList)
260 setAllConversations(updateConvList)
261
262 // Persist to IndexedDB
263 indexedDb.putDMConversation(
264 pubkey, partner, message.createdAt, preview, message.encryptionType
265 ).catch(() => {})
266 }
267 } catch {
268 // Decryption failed
269 }
270 }
271 }, newestKnownTimestamp)
272 }
273
274 initialize()
275 } else {
276 // Logout
277 setConversations([])
278 setAllConversations([])
279 setMessages([])
280 setCurrentConversation(null)
281 setDeletedState(null)
282 setSelectedMessages(new Set())
283 setIsSelectionMode(false)
284 clearPlaintextCache()
285 if (dmSubscriptionRef.current) {
286 dmSubscriptionRef.current.close()
287 dmSubscriptionRef.current = null
288 }
289 hasInitializedRef.current = false
290 lastPubkeyRef.current = null
291 }
292 }, [pubkey, encryption, relayList])
293
294 // Load conversation when selected (or reloadTrigger changes)
295 useEffect(() => {
296 if (!currentConversation || !pubkey || !encryption) {
297 setMessages([])
298 loadingConversationRef.current = null
299 return
300 }
301
302 const targetConversation = currentConversation
303 loadingConversationRef.current = targetConversation
304
305 const loadConversation = async () => {
306 setIsLoadingConversation(true)
307 try {
308 // IndexedDB cache — single setMessages, no progressive flashing
309 const cached = await indexedDb.getConversationMessages(pubkey, targetConversation)
310 if (cached && cached.length > 0 && loadingConversationRef.current === targetConversation) {
311 const cachedMessages: TDirectMessage[] = cached
312 .filter((m) => !isMessageDeleted(m.id, targetConversation, m.createdAt, deletedState))
313 .map((m) => ({
314 id: m.id,
315 senderPubkey: m.senderPubkey,
316 recipientPubkey: m.recipientPubkey,
317 content: m.content,
318 createdAt: m.createdAt,
319 encryptionType: m.encryptionType,
320 event: {} as Event,
321 decryptedContent: m.content,
322 seenOnRelays: m.seenOnRelays
323 }))
324 setMessages(cachedMessages)
325 }
326
327 // Network fetch
328 const relayUrls = relayList?.read || []
329 const events = await dmService.fetchConversationEvents(pubkey, targetConversation, relayUrls)
330
331 if (loadingConversationRef.current !== targetConversation) return
332
333 // Pre-filter gift wraps older than delete cutoff.
334 // Gift wrap outer timestamps are randomized up to 2 days in the past (NIP-59),
335 // so subtract 3 days buffer to avoid filtering out recent messages.
336 const deleteCutoff = getGlobalDeleteCutoff(deletedState)
337 const giftWrapCutoff = deleteCutoff > 0 ? deleteCutoff - 3 * 24 * 60 * 60 : 0
338 const filteredEvents = giftWrapCutoff > 0
339 ? events.filter((e) => e.kind !== 1059 || e.created_at > giftWrapCutoff)
340 : events
341
342 // Decrypt all at once — no progressive batch rendering
343 const allDecrypted = await decryptMessagesInBatches(
344 filteredEvents, encryption, pubkey, 10
345 )
346
347 if (loadingConversationRef.current !== targetConversation) return
348
349 // Filter — deduplicate by inner event ID for gift wraps (recipient + self-copy
350 // share the same inner DM but have different outer wrap IDs)
351 const seenIds = new Set<string>()
352 const seenInnerIds = new Set<string>()
353 const validMessages = allDecrypted.filter((message) => {
354 if (seenIds.has(message.id)) return false
355 if (message.innerEventId && seenInnerIds.has(message.innerEventId)) return false
356 if (isNircProtocolMessage(message.content ?? '')) return false
357 const partner = message.senderPubkey === pubkey
358 ? message.recipientPubkey
359 : message.senderPubkey
360 if (partner !== targetConversation) return false
361 if (isMessageDeleted(message.id, targetConversation, message.createdAt, deletedState)) return false
362 seenIds.add(message.id)
363 if (message.innerEventId) seenInnerIds.add(message.innerEventId)
364 return true
365 })
366
367 // Merge with existing messages (preserves subscription-appended messages)
368 setMessages((prev) => mergeAndDedupe(prev, validMessages))
369
370 // Cache to IndexedDB — merge with existing cache, never replace with fewer messages
371 const toCache = validMessages.map((m) => ({
372 id: m.id,
373 senderPubkey: m.senderPubkey,
374 recipientPubkey: m.recipientPubkey,
375 content: m.decryptedContent || m.content,
376 createdAt: m.createdAt,
377 encryptionType: m.encryptionType,
378 seenOnRelays: m.seenOnRelays
379 }))
380 type CachedMsg = typeof toCache[number]
381 const existingCache = await indexedDb.getConversationMessages(pubkey, targetConversation)
382 if (existingCache && existingCache.length > 0 && toCache.length > 0) {
383 const ids = new Set(toCache.map((m) => m.id))
384 const merged: CachedMsg[] = [...toCache]
385 for (const m of existingCache) {
386 if (!ids.has(m.id)) {
387 merged.push(m as CachedMsg)
388 ids.add(m.id)
389 }
390 }
391 merged.sort((a, b) => a.createdAt - b.createdAt)
392 await indexedDb.putConversationMessages(pubkey, targetConversation, merged)
393 } else if (toCache.length > 0) {
394 await indexedDb.putConversationMessages(pubkey, targetConversation, toCache)
395 }
396 // If toCache is empty, don't write — keep existing cache intact
397 } catch {
398 // Failed to load
399 } finally {
400 if (loadingConversationRef.current === targetConversation) {
401 setIsLoadingConversation(false)
402 }
403 }
404 }
405
406 loadConversation()
407 }, [currentConversation, pubkey, encryption, relayList, deletedState, reloadTrigger])
408
409 // Background refresh conversations list
410 const backgroundRefreshConversations = useCallback(async () => {
411 if (!pubkey || !encryption) return
412
413 try {
414 const relayUrls = relayList?.read || []
415 const events = await dmService.fetchRecentDMEvents(pubkey, relayUrls)
416
417 const nip04Events = events.filter((e) => e.kind === 4)
418 const giftWraps = events.filter((e) => e.kind === 1059)
419
420 const conversationMap = new Map<string, TConversation>()
421 allConversations.forEach((c) => conversationMap.set(c.partnerPubkey, c))
422
423 const nip04Convs = dmService.groupEventsIntoConversations(nip04Events, pubkey)
424 nip04Convs.forEach((conv, key) => {
425 const existing = conversationMap.get(key)
426 if (!existing || conv.lastMessageAt > existing.lastMessageAt) {
427 conversationMap.set(key, conv)
428 }
429 })
430
431 const updateAndShowConversations = () => {
432 const validConversations = Array.from(conversationMap.values())
433 .filter((conv) => conv.partnerPubkey && typeof conv.partnerPubkey === 'string')
434 .filter((conv) => !isConversationDeleted(conv.partnerPubkey, conv.lastMessageAt, deletedState))
435 const sortedConversations = validConversations.sort(
436 (a, b) => b.lastMessageAt - a.lastMessageAt
437 )
438 setAllConversations(sortedConversations)
439 setConversations(sortedConversations.slice(0, CONVERSATIONS_PER_PAGE))
440 setHasMoreConversations(sortedConversations.length > CONVERSATIONS_PER_PAGE)
441 }
442
443 updateAndShowConversations()
444
445 const sortedGiftWraps = giftWraps.sort((a, b) => b.created_at - a.created_at)
446 const deleteCutoff = getGlobalDeleteCutoff(deletedState)
447
448 for (const giftWrap of sortedGiftWraps) {
449 if (deleteCutoff > 0 && giftWrap.created_at <= deleteCutoff) continue
450
451 try {
452 const message = await dmService.decryptMessage(giftWrap, encryption, pubkey)
453 if (message && message.senderPubkey && message.recipientPubkey) {
454 const partnerPubkey =
455 message.senderPubkey === pubkey ? message.recipientPubkey : message.senderPubkey
456 if (!partnerPubkey || partnerPubkey === '__reaction__') continue
457 if (isNircProtocolMessage(message.content ?? '')) continue
458
459 const existing = conversationMap.get(partnerPubkey)
460 if (!existing || message.createdAt > existing.lastMessageAt) {
461 conversationMap.set(partnerPubkey, {
462 partnerPubkey,
463 lastMessageAt: message.createdAt,
464 lastMessagePreview: (message.content ?? '').substring(0, 100),
465 unreadCount: 0,
466 preferredEncryption: 'nip17'
467 })
468 updateAndShowConversations()
469 }
470
471 indexedDb.putDMConversation(
472 pubkey, partnerPubkey, message.createdAt,
473 (message.content ?? '').substring(0, 100), 'nip17'
474 ).catch(() => {})
475 }
476 } catch {
477 // Skip
478 }
479 }
480
481 updateAndShowConversations()
482 const finalConversations = Array.from(conversationMap.values())
483 Promise.all(
484 finalConversations.map((conv) =>
485 indexedDb.putDMConversation(
486 pubkey, conv.partnerPubkey, conv.lastMessageAt,
487 conv.lastMessagePreview, conv.preferredEncryption
488 )
489 )
490 ).catch(() => {})
491 } catch {
492 // Background refresh failed
493 }
494 }, [pubkey, encryption, relayList, deletedState, allConversations])
495
496 // Manual refresh
497 const refreshConversations = useCallback(async () => {
498 if (!pubkey || !encryption) return
499
500 setIsLoading(true)
501 setError(null)
502
503 try {
504 const relayUrls = relayList?.read || []
505 const events = await dmService.fetchRecentDMEvents(pubkey, relayUrls)
506
507 const nip04Events = events.filter((e) => e.kind === 4)
508 const giftWraps = events.filter((e) => e.kind === 1059)
509
510 const conversationMap = new Map<string, TConversation>()
511 allConversations.forEach((c) => conversationMap.set(c.partnerPubkey, c))
512
513 const nip04Convs = dmService.groupEventsIntoConversations(nip04Events, pubkey)
514 nip04Convs.forEach((conv, key) => {
515 const existing = conversationMap.get(key)
516 if (!existing || conv.lastMessageAt > existing.lastMessageAt) {
517 conversationMap.set(key, conv)
518 }
519 })
520
521 const updateAndShowConversations = () => {
522 const validConversations = Array.from(conversationMap.values())
523 .filter((conv) => conv.partnerPubkey && typeof conv.partnerPubkey === 'string')
524 .filter((conv) => !isConversationDeleted(conv.partnerPubkey, conv.lastMessageAt, deletedState))
525 const sortedConversations = validConversations.sort(
526 (a, b) => b.lastMessageAt - a.lastMessageAt
527 )
528 setAllConversations(sortedConversations)
529 setConversations(sortedConversations.slice(0, CONVERSATIONS_PER_PAGE))
530 setHasMoreConversations(sortedConversations.length > CONVERSATIONS_PER_PAGE)
531 }
532
533 updateAndShowConversations()
534 setIsLoading(false)
535
536 const sortedGiftWraps = giftWraps.sort((a, b) => b.created_at - a.created_at)
537 for (const giftWrap of sortedGiftWraps) {
538 try {
539 const message = await dmService.decryptMessage(giftWrap, encryption, pubkey)
540 if (message && message.senderPubkey && message.recipientPubkey) {
541 const partnerPubkey =
542 message.senderPubkey === pubkey ? message.recipientPubkey : message.senderPubkey
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 conversationMap.set(partnerPubkey, {
549 partnerPubkey,
550 lastMessageAt: message.createdAt,
551 lastMessagePreview: (message.content ?? '').substring(0, 100),
552 unreadCount: 0,
553 preferredEncryption: 'nip17'
554 })
555 updateAndShowConversations()
556 }
557
558 indexedDb.putDMConversation(
559 pubkey, partnerPubkey, message.createdAt,
560 (message.content ?? '').substring(0, 100), 'nip17'
561 ).catch(() => {})
562 }
563 } catch {
564 // Skip
565 }
566 }
567
568 updateAndShowConversations()
569 const finalConversations = Array.from(conversationMap.values())
570 Promise.all(
571 finalConversations.map((conv) =>
572 indexedDb.putDMConversation(
573 pubkey, conv.partnerPubkey, conv.lastMessageAt,
574 conv.lastMessagePreview, conv.preferredEncryption
575 )
576 )
577 ).catch(() => {})
578 } catch {
579 setError('Failed to load conversations')
580 setIsLoading(false)
581 }
582 }, [pubkey, encryption, relayList, deletedState, allConversations])
583
584 const loadMoreConversations = useCallback(async () => {
585 if (!hasMoreConversations) return
586 const currentCount = conversations.length
587 const nextBatch = allConversations.slice(currentCount, currentCount + CONVERSATIONS_PER_PAGE)
588 setConversations((prev) => [...prev, ...nextBatch])
589 setHasMoreConversations(currentCount + nextBatch.length < allConversations.length)
590 }, [conversations.length, allConversations, hasMoreConversations])
591
592 const selectConversation = useCallback(
593 (partnerPubkey: string | null) => {
594 if (partnerPubkey !== currentConversation) {
595 setMessages([])
596 }
597 setCurrentConversation(partnerPubkey)
598 },
599 [currentConversation]
600 )
601
602 const startConversation = useCallback(
603 (partnerPubkey: string) => {
604 const existingConversation = allConversations.find(
605 (c) => c.partnerPubkey === partnerPubkey
606 )
607 if (!existingConversation) {
608 setIsNewConversation(true)
609 setProvisionalPubkey(partnerPubkey)
610 const provisionalConversation: TConversation = {
611 partnerPubkey,
612 lastMessageAt: Math.floor(Date.now() / 1000),
613 lastMessagePreview: '',
614 unreadCount: 0,
615 preferredEncryption: 'nip17'
616 }
617 setAllConversations((prev) => [provisionalConversation, ...prev])
618 setConversations((prev) => [provisionalConversation, ...prev])
619 }
620 setMessages([])
621 setCurrentConversation(partnerPubkey)
622 },
623 [allConversations]
624 )
625
626 const clearNewConversationFlag = useCallback(() => {
627 setIsNewConversation(false)
628 }, [])
629
630 const dismissProvisionalConversation = useCallback(() => {
631 if (!provisionalPubkey) return
632 setAllConversations((prev) => prev.filter((c) => c.partnerPubkey !== provisionalPubkey))
633 setConversations((prev) => prev.filter((c) => c.partnerPubkey !== provisionalPubkey))
634 setProvisionalPubkey(null)
635 setIsNewConversation(false)
636 if (currentConversation === provisionalPubkey) {
637 setCurrentConversation(null)
638 setMessages([])
639 }
640 }, [provisionalPubkey, currentConversation])
641
642 const reloadConversation = useCallback(() => {
643 if (!currentConversation) return
644 setMessages([])
645 setReloadTrigger((prev) => prev + 1)
646 }, [currentConversation])
647
648 const sendMessage = useCallback(
649 async (content: string, customRelayUrls?: string[], expirationSeconds?: number) => {
650 if (!pubkey || !encryption || !currentConversation) {
651 throw new Error('Cannot send message: not logged in or no conversation selected')
652 }
653
654 const relayUrls = customRelayUrls && customRelayUrls.length > 0
655 ? customRelayUrls
656 : (relayList?.write || [])
657
658 const conversation = conversations.find((c) => c.partnerPubkey === currentConversation)
659 const existingEncryptionType: TDMEncryptionType | null =
660 conversation?.preferredEncryption ?? null
661
662 const encryptionPref = await indexedDb.getConversationEncryptionPreference(
663 pubkey, currentConversation
664 )
665
666 let effectiveEncryption: TDMEncryptionType | null = existingEncryptionType
667 if (encryptionPref === 'nip04') {
668 effectiveEncryption = 'nip04'
669 } else if (encryptionPref === 'nip17') {
670 effectiveEncryption = 'nip17'
671 }
672
673 // Read expiration preference from IndexedDB if not provided by caller
674 let effectiveExpiration = expirationSeconds ?? 0
675 if (effectiveExpiration === 0) {
676 effectiveExpiration = await indexedDb.getConversationExpirationPreference(
677 pubkey, currentConversation
678 )
679 }
680
681 const sentEvents = await dmService.sendDM(
682 currentConversation, content, encryption, relayUrls, preferNip44, effectiveEncryption,
683 effectiveExpiration
684 )
685
686 const now = Math.floor(Date.now() / 1000)
687 const usedEncryptionType: TDMEncryptionType =
688 effectiveEncryption || (preferNip44 ? 'nip17' : 'nip04')
689 const sentEvent = sentEvents[0]
690 const innerEventId = (sentEvent as any)?._innerEventId as string | undefined
691 const newMessage: TDirectMessage = {
692 id: sentEvent?.id || `local-${now}`,
693 innerEventId,
694 senderPubkey: pubkey,
695 recipientPubkey: currentConversation,
696 content,
697 createdAt: now,
698 encryptionType: usedEncryptionType,
699 event: sentEvent || ({} as Event),
700 decryptedContent: content
701 }
702
703 setMessages((prev) => [...prev, newMessage])
704
705 setConversations((prev) => {
706 const existing = prev.find((c) => c.partnerPubkey === currentConversation)
707 if (existing) {
708 return prev.map((c) =>
709 c.partnerPubkey === currentConversation
710 ? { ...c, lastMessageAt: now, lastMessagePreview: content.substring(0, 100), preferredEncryption: usedEncryptionType }
711 : c
712 )
713 } else {
714 return [{
715 partnerPubkey: currentConversation,
716 lastMessageAt: now,
717 lastMessagePreview: content.substring(0, 100),
718 unreadCount: 0,
719 preferredEncryption: usedEncryptionType
720 }, ...prev]
721 }
722 })
723
724 if (provisionalPubkey === currentConversation) {
725 setProvisionalPubkey(null)
726 setIsNewConversation(false)
727 }
728 },
729 [pubkey, encryption, currentConversation, relayList, conversations, preferNip44, provisionalPubkey]
730 )
731
732 const setPreferNip44 = useCallback((prefer: boolean) => {
733 setPreferNip44State(prefer)
734 storage.setPreferNip44(prefer)
735 dispatchSettingsChanged()
736 }, [])
737
738 const toggleMessageSelection = useCallback((messageId: string) => {
739 setSelectedMessages((prev) => {
740 const next = new Set(prev)
741 if (next.has(messageId)) {
742 next.delete(messageId)
743 if (next.size === 0) setIsSelectionMode(false)
744 } else {
745 next.add(messageId)
746 if (!isSelectionMode) setIsSelectionMode(true)
747 }
748 return next
749 })
750 }, [isSelectionMode])
751
752 const selectAllMessages = useCallback(() => {
753 const allIds = new Set(messages.map((m) => m.id))
754 setSelectedMessages(allIds)
755 setIsSelectionMode(true)
756 }, [messages])
757
758 const clearSelection = useCallback(() => {
759 setSelectedMessages(new Set())
760 setIsSelectionMode(false)
761 }, [])
762
763 const publishDeletedState = useCallback(
764 async (newState: TDMDeletedState) => {
765 if (!pubkey || !encryption) return
766 await indexedDb.putDeletedMessagesState(pubkey, newState)
767 const relayUrls = relayList?.write.length ? relayList.write : client.currentRelays
768 const draftEvent = createDeletedMessagesDraftEvent(newState)
769 const signedEvent = await encryption.signEvent(draftEvent)
770 await client.publishEvent(relayUrls, signedEvent)
771 },
772 [pubkey, encryption, relayList]
773 )
774
775 const deleteSelectedMessages = useCallback(async () => {
776 if (!pubkey || selectedMessages.size === 0) return
777
778 const messageIds = Array.from(selectedMessages)
779 const newDeletedState: TDMDeletedState = {
780 deletedIds: [...(deletedState?.deletedIds || []), ...messageIds],
781 deletedRanges: deletedState?.deletedRanges || {}
782 }
783 setDeletedState(newDeletedState)
784 setMessages((prev) => prev.filter((m) => !selectedMessages.has(m.id)))
785 setSelectedMessages(new Set())
786 setIsSelectionMode(false)
787 await publishDeletedState(newDeletedState)
788 }, [pubkey, selectedMessages, deletedState, publishDeletedState])
789
790 const deleteAllInConversation = useCallback(async () => {
791 if (!pubkey || !currentConversation) return
792
793 const now = Math.floor(Date.now() / 1000)
794 const newDeletedState: TDMDeletedState = {
795 deletedIds: deletedState?.deletedIds || [],
796 deletedRanges: {
797 ...(deletedState?.deletedRanges || {}),
798 [currentConversation]: [
799 ...(deletedState?.deletedRanges[currentConversation] || []),
800 { start: 0, end: now }
801 ]
802 }
803 }
804 setDeletedState(newDeletedState)
805 setMessages([])
806 setConversations((prev) => prev.filter((c) => c.partnerPubkey !== currentConversation))
807 setAllConversations((prev) => prev.filter((c) => c.partnerPubkey !== currentConversation))
808 setSelectedMessages(new Set())
809 setIsSelectionMode(false)
810 setCurrentConversation(null)
811 await publishDeletedState(newDeletedState)
812 }, [pubkey, currentConversation, deletedState, publishDeletedState])
813
814 const undeleteAllInConversation = useCallback(async () => {
815 if (!pubkey || !currentConversation) return
816
817 const newDeletedState: TDMDeletedState = {
818 deletedIds: deletedState?.deletedIds || [],
819 deletedRanges: {
820 ...(deletedState?.deletedRanges || {}),
821 [currentConversation]: []
822 }
823 }
824 setDeletedState(newDeletedState)
825 setMessages([])
826 setReloadTrigger((prev) => prev + 1)
827 await publishDeletedState(newDeletedState)
828 await backgroundRefreshConversations()
829 }, [pubkey, currentConversation, deletedState, publishDeletedState, backgroundRefreshConversations])
830
831 const filteredConversations = useMemo(() => {
832 if (!deletedState) return conversations
833 return conversations.filter(
834 (c) => !isConversationDeleted(c.partnerPubkey, c.lastMessageAt, deletedState)
835 )
836 }, [conversations, deletedState])
837
838 const totalUnreadCount = useMemo(() => {
839 return filteredConversations.reduce((sum, c) => sum + c.unreadCount, 0)
840 }, [filteredConversations])
841
842 const newestMessageTimestamp = useMemo(() => {
843 const fromConversations = filteredConversations.length === 0
844 ? 0
845 : Math.max(...filteredConversations.map((c) => c.lastMessageAt))
846 return Math.max(fromConversations, newestIncomingTimestamp)
847 }, [filteredConversations, newestIncomingTimestamp])
848
849 const hasNewMessages = lastSeenTimestamp > 0
850 ? newestMessageTimestamp > lastSeenTimestamp
851 : newestIncomingTimestamp > 0
852
853 const markInboxAsSeen = useCallback(() => {
854 if (!pubkey) return
855 setNewestIncomingTimestamp(0)
856 if (newestMessageTimestamp > 0) {
857 setLastSeenTimestamp(newestMessageTimestamp)
858 storage.setDMLastSeenTimestamp(pubkey, newestMessageTimestamp)
859 }
860 }, [pubkey, newestMessageTimestamp])
861
862 return (
863 <DMContext.Provider
864 value={{
865 conversations: filteredConversations,
866 currentConversation,
867 messages,
868 isLoading,
869 isLoadingConversation,
870 error,
871 selectConversation,
872 startConversation,
873 sendMessage,
874 refreshConversations,
875 reloadConversation,
876 loadMoreConversations,
877 hasMoreConversations,
878 preferNip44,
879 setPreferNip44,
880 isNewConversation,
881 clearNewConversationFlag,
882 dismissProvisionalConversation,
883 totalUnreadCount,
884 hasNewMessages,
885 markInboxAsSeen,
886 selectedMessages,
887 isSelectionMode,
888 toggleMessageSelection,
889 selectAllMessages,
890 clearSelection,
891 deleteSelectedMessages,
892 deleteAllInConversation,
893 undeleteAllInConversation
894 }}
895 >
896 {children}
897 </DMContext.Provider>
898 )
899 }
900