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