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