dm.service.ts raw

   1  /**
   2   * DM Service - Direct Message handling with NIP-04 and NIP-17 encryption support
   3   *
   4   * NIP-04: Kind 4 encrypted direct messages (legacy)
   5   * NIP-17: Kind 14 private direct messages with NIP-59 gift wrapping (modern)
   6   */
   7  
   8  import { ExtendedKind } from '@/constants'
   9  import { TConversation, TDirectMessage, TDMDeletedState, TDMEncryptionType, TDraftEvent } from '@/types'
  10  import { Event, kinds, VerifiedEvent } from 'nostr-tools'
  11  import client from './client.service'
  12  import indexedDb from './indexed-db.service'
  13  import storage from './local-storage.service'
  14  
  15  /** Check if a DM is an NIRC protocol message that should be hidden from the inbox */
  16  export function isNircProtocolMessage(content: string): boolean {
  17    if (!content) return false
  18    if (content.startsWith('nirc:request:')) return true
  19    if (content.startsWith('nirc:')) return true
  20    return false
  21  }
  22  
  23  // In-memory plaintext cache for fast access (avoids async IndexedDB lookups on re-render)
  24  const plaintextCache = new Map<string, string>()
  25  const MAX_CACHE_SIZE = 1000
  26  
  27  /**
  28   * Get plaintext from in-memory cache
  29   */
  30  export function getCachedPlaintext(eventId: string): string | undefined {
  31    return plaintextCache.get(eventId)
  32  }
  33  
  34  /**
  35   * Set plaintext in in-memory cache (with LRU eviction)
  36   */
  37  export function setCachedPlaintext(eventId: string, plaintext: string): void {
  38    // Simple LRU: if cache is full, delete oldest entries
  39    if (plaintextCache.size >= MAX_CACHE_SIZE) {
  40      const keysToDelete = Array.from(plaintextCache.keys()).slice(0, 100)
  41      keysToDelete.forEach(k => plaintextCache.delete(k))
  42    }
  43    plaintextCache.set(eventId, plaintext)
  44  }
  45  
  46  /**
  47   * Clear the plaintext cache (e.g., on logout)
  48   */
  49  export function clearPlaintextCache(): void {
  50    plaintextCache.clear()
  51  }
  52  
  53  /**
  54   * Decrypt messages in batches to avoid blocking the UI
  55   * Yields control back to the event loop between batches
  56   */
  57  export async function decryptMessagesInBatches(
  58    events: Event[],
  59    encryption: IDMEncryption,
  60    myPubkey: string,
  61    batchSize: number = 10,
  62    onBatchComplete?: (messages: TDirectMessage[], progress: number) => void
  63  ): Promise<TDirectMessage[]> {
  64    const allMessages: TDirectMessage[] = []
  65    const total = events.length
  66  
  67    for (let i = 0; i < events.length; i += batchSize) {
  68      const batch = events.slice(i, i + batchSize)
  69  
  70      // Process batch
  71      const batchResults = await Promise.all(
  72        batch.map((event) => dmService.decryptMessage(event, encryption, myPubkey))
  73      )
  74  
  75      const validMessages = batchResults.filter((m): m is TDirectMessage => m !== null)
  76      allMessages.push(...validMessages)
  77  
  78      // Report progress
  79      const progress = Math.min((i + batchSize) / total, 1)
  80      onBatchComplete?.(validMessages, progress)
  81  
  82      // Yield to event loop between batches (prevents UI blocking)
  83      if (i + batchSize < events.length) {
  84        await new Promise(resolve => setTimeout(resolve, 0))
  85      }
  86    }
  87  
  88    return allMessages
  89  }
  90  
  91  /**
  92   * Create and publish a kind 5 delete request for own messages
  93   * This requests relays to delete the original event
  94   */
  95  export async function publishDeleteRequest(
  96    eventIds: string[],
  97    eventKind: number,
  98    encryption: IDMEncryption,
  99    relayUrls: string[]
 100  ): Promise<void> {
 101    if (eventIds.length === 0) return
 102  
 103    const draftEvent: TDraftEvent = {
 104      kind: kinds.EventDeletion, // 5
 105      created_at: Math.floor(Date.now() / 1000),
 106      content: 'Deleted by sender',
 107      tags: [
 108        ['k', eventKind.toString()],
 109        ...eventIds.map((id) => ['e', id])
 110      ]
 111    }
 112  
 113    const signedEvent = await encryption.signEvent(draftEvent)
 114    await client.publishEvent(relayUrls, signedEvent)
 115  }
 116  
 117  /**
 118   * Encryption methods interface for DM operations
 119   */
 120  export interface IDMEncryption {
 121    nip04Encrypt: (pubkey: string, plainText: string) => Promise<string>
 122    nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
 123    nip44Encrypt?: (pubkey: string, plainText: string) => Promise<string>
 124    nip44Decrypt?: (pubkey: string, cipherText: string) => Promise<string>
 125    signEvent: (draftEvent: TDraftEvent) => Promise<VerifiedEvent>
 126    getPublicKey: () => string
 127  }
 128  
 129  // NIP-04 uses kind 4
 130  const KIND_ENCRYPTED_DM = kinds.EncryptedDirectMessage // 4
 131  
 132  // NIP-17 uses kind 14 for chat messages, wrapped in gift wraps
 133  const KIND_PRIVATE_DM = ExtendedKind.PRIVATE_DM // 14
 134  const KIND_SEAL = ExtendedKind.SEAL // 13
 135  const KIND_GIFT_WRAP = ExtendedKind.GIFT_WRAP // 1059
 136  const KIND_REACTION = kinds.Reaction // 7
 137  
 138  // 15 second timeout for DM fetches - if relays are dead, don't wait forever
 139  const DM_FETCH_TIMEOUT_MS = 15000
 140  
 141  /**
 142   * Wrap a promise with a timeout that returns empty array on timeout or error
 143   */
 144  function withTimeout<T>(promise: Promise<T[]>, ms: number): Promise<T[]> {
 145    const timeoutPromise = new Promise<T[]>((resolve) => {
 146      setTimeout(() => resolve([]), ms)
 147    })
 148    const safePromise = promise.catch(() => [] as T[])
 149    return Promise.race([safePromise, timeoutPromise])
 150  }
 151  
 152  class DMService {
 153    /**
 154     * Fetch all DM events for a user from relays
 155     */
 156    async fetchDMEvents(pubkey: string, relayUrls: string[], limit = 500): Promise<Event[]> {
 157      // Use provided relays - no hardcoded fallback
 158      const allRelays = [...new Set(relayUrls)]
 159  
 160      // Fetch NIP-04 DMs (kind 4) and NIP-17 gift wraps in parallel
 161      const nip04Filter = {
 162        kinds: [KIND_ENCRYPTED_DM],
 163        limit
 164      }
 165  
 166      const [incomingNip04, outgoingNip04, giftWraps] = await Promise.all([
 167        // Fetch messages sent TO the user
 168        withTimeout(
 169          client.fetchEvents(allRelays, {
 170            ...nip04Filter,
 171            '#p': [pubkey]
 172          }),
 173          DM_FETCH_TIMEOUT_MS
 174        ),
 175        // Fetch messages sent BY the user
 176        withTimeout(
 177          client.fetchEvents(allRelays, {
 178            ...nip04Filter,
 179            authors: [pubkey]
 180          }),
 181          DM_FETCH_TIMEOUT_MS
 182        ),
 183        // Fetch NIP-17 gift wraps (kind 1059) - these are addressed to the user
 184        withTimeout(
 185          client.fetchEvents(allRelays, {
 186            kinds: [KIND_GIFT_WRAP],
 187            '#p': [pubkey],
 188            limit
 189          }),
 190          DM_FETCH_TIMEOUT_MS
 191        )
 192      ])
 193  
 194      // Combine all events
 195      const allEvents = [...incomingNip04, ...outgoingNip04, ...giftWraps]
 196  
 197      // Store in IndexedDB for caching
 198      await Promise.all(allEvents.map((event) => indexedDb.putDMEvent(event)))
 199  
 200      return allEvents
 201    }
 202  
 203    /**
 204     * Fetch recent DM events (limited) for building conversation list
 205     * Returns only most recent events to quickly show conversations
 206     */
 207    async fetchRecentDMEvents(pubkey: string, relayUrls: string[]): Promise<Event[]> {
 208      // Fetch with smaller limit for faster initial load
 209      return this.fetchDMEvents(pubkey, relayUrls, 100)
 210    }
 211  
 212    /**
 213     * Fetch all DM events for a specific conversation partner
 214     */
 215    async fetchConversationEvents(
 216      pubkey: string,
 217      partnerPubkey: string,
 218      relayUrls: string[]
 219    ): Promise<Event[]> {
 220      // Use provided relays - no hardcoded fallback
 221      const allRelays = [...new Set(relayUrls)]
 222  
 223      // Get partner's inbox relays for better NIP-17 discovery
 224      const partnerInboxRelays = await this.fetchPartnerInboxRelays(partnerPubkey)
 225      const inboxRelays = [...new Set([...relayUrls, ...partnerInboxRelays])]
 226  
 227      // Fetch NIP-04 messages between user and partner (with timeout)
 228      const [incomingNip04, outgoingNip04, giftWraps] = await Promise.all([
 229        // Messages FROM partner TO user
 230        withTimeout(
 231          client.fetchEvents(allRelays, {
 232            kinds: [KIND_ENCRYPTED_DM],
 233            authors: [partnerPubkey],
 234            '#p': [pubkey],
 235            limit: 500
 236          }),
 237          DM_FETCH_TIMEOUT_MS
 238        ),
 239        // Messages FROM user TO partner
 240        withTimeout(
 241          client.fetchEvents(allRelays, {
 242            kinds: [KIND_ENCRYPTED_DM],
 243            authors: [pubkey],
 244            '#p': [partnerPubkey],
 245            limit: 500
 246          }),
 247          DM_FETCH_TIMEOUT_MS
 248        ),
 249        // Gift wraps addressed to user - check both regular relays and inbox relays
 250        withTimeout(
 251          client.fetchEvents(inboxRelays, {
 252            kinds: [KIND_GIFT_WRAP],
 253            '#p': [pubkey],
 254            limit: 500
 255          }),
 256          DM_FETCH_TIMEOUT_MS
 257        )
 258      ])
 259  
 260      const allEvents = [...incomingNip04, ...outgoingNip04, ...giftWraps]
 261  
 262      // Store in IndexedDB for caching
 263      await Promise.all(allEvents.map((event) => indexedDb.putDMEvent(event)))
 264  
 265      return allEvents
 266    }
 267  
 268    /**
 269     * Decrypt a DM event and return a TDirectMessage
 270     */
 271    async decryptMessage(
 272      event: Event,
 273      encryption: IDMEncryption,
 274      myPubkey: string
 275    ): Promise<TDirectMessage | null> {
 276      try {
 277        if (event.kind === KIND_ENCRYPTED_DM) {
 278          // NIP-04 decryption - check in-memory cache first (fastest)
 279          const memCached = getCachedPlaintext(event.id)
 280          if (memCached) {
 281            return this.buildDirectMessage(event, memCached, myPubkey, 'nip04')
 282          }
 283  
 284          // Check IndexedDB cache (slower but persistent)
 285          const dbCached = await indexedDb.getDecryptedContent(event.id)
 286          if (dbCached) {
 287            // Populate in-memory cache for next access
 288            setCachedPlaintext(event.id, dbCached)
 289            return this.buildDirectMessage(event, dbCached, myPubkey, 'nip04')
 290          }
 291  
 292          const otherPubkey = this.getOtherPartyPubkey(event, myPubkey)
 293          if (!otherPubkey) return null
 294  
 295          const decryptedContent = await encryption.nip04Decrypt(otherPubkey, event.content)
 296  
 297          // Cache in both layers
 298          setCachedPlaintext(event.id, decryptedContent)
 299          indexedDb.putDecryptedContent(event.id, decryptedContent).catch(() => {})
 300  
 301          return this.buildDirectMessage(event, decryptedContent, myPubkey, 'nip04')
 302        } else if (event.kind === KIND_GIFT_WRAP) {
 303          // NIP-17 - check in-memory cache first
 304          const memCached = getCachedPlaintext(event.id)
 305          if (memCached) {
 306            // Stored as JSON: {s: senderPubkey, r: recipientPubkey, c: content}
 307            try {
 308              const parsed = JSON.parse(memCached) as { s: string; r: string; c: string }
 309              if (parsed.r === '__reaction__') return null
 310              const seenOnRelays = client.getSeenEventRelayUrls(event.id)
 311              return {
 312                id: event.id,
 313                senderPubkey: parsed.s,
 314                recipientPubkey: parsed.r,
 315                content: parsed.c,
 316                createdAt: event.created_at,
 317                encryptionType: 'nip17',
 318                event,
 319                decryptedContent: parsed.c,
 320                seenOnRelays: seenOnRelays.length > 0 ? seenOnRelays : undefined
 321              }
 322            } catch {
 323              // Invalid cache entry, fall through to re-decrypt
 324            }
 325          }
 326  
 327          // Check IndexedDB cache (includes sender info)
 328          const cachedUnwrapped = await indexedDb.getUnwrappedGiftWrap(event.id)
 329          if (cachedUnwrapped) {
 330            // Skip reactions in cache for now (they're stored but not returned as messages)
 331            if (cachedUnwrapped.recipientPubkey === '__reaction__') {
 332              return null
 333            }
 334            // Populate in-memory cache
 335            setCachedPlaintext(event.id, JSON.stringify({ s: cachedUnwrapped.pubkey, r: cachedUnwrapped.recipientPubkey, c: cachedUnwrapped.content }))
 336            const seenOnRelays = client.getSeenEventRelayUrls(event.id)
 337            return {
 338              id: event.id,
 339              senderPubkey: cachedUnwrapped.pubkey,
 340              recipientPubkey: cachedUnwrapped.recipientPubkey,
 341              content: cachedUnwrapped.content,
 342              createdAt: cachedUnwrapped.createdAt,
 343              encryptionType: 'nip17',
 344              event,
 345              decryptedContent: cachedUnwrapped.content,
 346              seenOnRelays: seenOnRelays.length > 0 ? seenOnRelays : undefined
 347            }
 348          }
 349  
 350          // Decrypt (unwrap gift wrap -> unseal -> decrypt)
 351          const unwrapped = await this.unwrapGiftWrap(event, encryption)
 352          if (!unwrapped) return null
 353  
 354          const innerEvent = unwrapped.innerEvent
 355          if (!innerEvent.tags) innerEvent.tags = []
 356  
 357          // Handle reactions - cache them but don't return as messages
 358          if (unwrapped.type === 'reaction') {
 359            // Cache the reaction for later display
 360            // TODO: Store reaction separately and associate with target message via 'e' tag
 361            indexedDb
 362              .putUnwrappedGiftWrap(event.id, {
 363                pubkey: innerEvent.pubkey,
 364                recipientPubkey: '__reaction__', // Marker for reactions
 365                content: unwrapped.content, // The emoji
 366                createdAt: innerEvent.created_at
 367              })
 368              .catch(() => {})
 369            // For now, just skip reactions (they're cached for future use)
 370            return null
 371          }
 372  
 373          const recipientPubkey = this.getRecipientFromTags(innerEvent.tags) || myPubkey
 374  
 375          // Cache in both layers
 376          setCachedPlaintext(event.id, JSON.stringify({ s: innerEvent.pubkey, r: recipientPubkey, c: unwrapped.content }))
 377          indexedDb
 378            .putUnwrappedGiftWrap(event.id, {
 379              pubkey: innerEvent.pubkey,
 380              recipientPubkey,
 381              content: unwrapped.content,
 382              createdAt: innerEvent.created_at
 383            })
 384            .catch(() => {})
 385  
 386          const seenOnRelays = client.getSeenEventRelayUrls(event.id)
 387          return {
 388            id: event.id,
 389            senderPubkey: innerEvent.pubkey,
 390            recipientPubkey,
 391            content: unwrapped.content,
 392            createdAt: innerEvent.created_at,
 393            encryptionType: 'nip17',
 394            event,
 395            decryptedContent: unwrapped.content,
 396            seenOnRelays: seenOnRelays.length > 0 ? seenOnRelays : undefined
 397          }
 398        } else {
 399          return null
 400        }
 401      } catch (error) {
 402        if (storage.getVerboseLogging()) {
 403          console.warn('[DM] Gift wrap decryption failed:', {
 404            eventId: event.id,
 405            created_at: event.created_at,
 406            error: error instanceof Error ? error.message : 'Unknown error'
 407          })
 408        }
 409        return null
 410      }
 411    }
 412  
 413    /**
 414     * Unwrap a NIP-59 gift wrap to get the inner message or reaction
 415     */
 416    private async unwrapGiftWrap(
 417      giftWrap: Event,
 418      encryption: IDMEncryption
 419    ): Promise<{ content: string; innerEvent: Event; type: 'dm' | 'reaction' } | null> {
 420      try {
 421        // Step 1: Decrypt the gift wrap content using NIP-44
 422        if (!encryption.nip44Decrypt) {
 423          return null
 424        }
 425  
 426        const sealJson = await encryption.nip44Decrypt(giftWrap.pubkey, giftWrap.content)
 427        const seal = JSON.parse(sealJson) as Event
 428  
 429        if (seal.kind !== KIND_SEAL) {
 430          return null
 431        }
 432  
 433        // Step 2: Decrypt the seal content using NIP-44
 434        const innerEventJson = await encryption.nip44Decrypt(seal.pubkey, seal.content)
 435        const innerEvent = JSON.parse(innerEventJson) as Event
 436  
 437        if (innerEvent.kind === KIND_PRIVATE_DM) {
 438          return {
 439            content: innerEvent.content,
 440            innerEvent,
 441            type: 'dm'
 442          }
 443        } else if (innerEvent.kind === KIND_REACTION) {
 444          return {
 445            content: innerEvent.content, // The emoji
 446            innerEvent,
 447            type: 'reaction'
 448          }
 449        } else {
 450          // Silently ignore other event types (e.g., read receipts)
 451          return null
 452        }
 453      } catch (error) {
 454        if (storage.getVerboseLogging()) {
 455          console.warn('[DM] unwrapGiftWrap failed:', {
 456            giftWrapId: giftWrap.id,
 457            error: error instanceof Error ? error.message : 'Unknown error'
 458          })
 459        }
 460        return null
 461      }
 462    }
 463  
 464    /**
 465     * Build a TDirectMessage from an event
 466     */
 467    private buildDirectMessage(
 468      event: Event,
 469      decryptedContent: string,
 470      myPubkey: string,
 471      encryptionType: TDMEncryptionType = 'nip04'
 472    ): TDirectMessage {
 473      const recipient = this.getRecipientFromTags(event.tags)
 474      const isSender = event.pubkey === myPubkey
 475      const seenOnRelays = client.getSeenEventRelayUrls(event.id)
 476  
 477      return {
 478        id: event.id,
 479        senderPubkey: event.pubkey,
 480        recipientPubkey: recipient || (isSender ? '' : myPubkey),
 481        content: decryptedContent,
 482        createdAt: event.created_at,
 483        encryptionType,
 484        event,
 485        decryptedContent,
 486        seenOnRelays: seenOnRelays.length > 0 ? seenOnRelays : undefined
 487      }
 488    }
 489  
 490    /**
 491     * Send a DM to a recipient
 492     * When no existing conversation, sends in BOTH formats (NIP-04 and NIP-17)
 493     */
 494    async sendDM(
 495      recipientPubkey: string,
 496      content: string,
 497      encryption: IDMEncryption,
 498      relayUrls: string[],
 499      _preferNip44: boolean,
 500      existingEncryption: TDMEncryptionType | null
 501    ): Promise<Event[]> {
 502      const sentEvents: Event[] = []
 503  
 504      // Get recipient's relays for better delivery
 505      // Use inbox relays for NIP-17 (where recipient receives messages)
 506      // Use write relays for NIP-04 (where recipient publishes from)
 507      const [recipientInboxRelays, recipientWriteRelays] = await Promise.all([
 508        this.fetchPartnerInboxRelays(recipientPubkey),
 509        this.fetchPartnerRelays(recipientPubkey)
 510      ])
 511      const allRelays = [...new Set([...relayUrls, ...recipientWriteRelays])]
 512      const inboxRelays = [...new Set([...relayUrls, ...recipientInboxRelays])]
 513  
 514      if (existingEncryption === null) {
 515        // No existing conversation - send in BOTH formats
 516        try {
 517          const nip04Event = await this.createAndPublishNip04DM(
 518            recipientPubkey,
 519            content,
 520            encryption,
 521            allRelays
 522          )
 523          sentEvents.push(nip04Event)
 524        } catch (error) {
 525          console.error('Failed to send NIP-04 DM:', error)
 526        }
 527  
 528        try {
 529          if (encryption.nip44Encrypt) {
 530            // Use inbox relays for NIP-17 delivery
 531            const nip17Event = await this.createAndPublishNip17DM(
 532              recipientPubkey,
 533              content,
 534              encryption,
 535              inboxRelays
 536            )
 537            sentEvents.push(nip17Event)
 538          }
 539        } catch (error) {
 540          console.error('Failed to send NIP-17 DM:', error)
 541        }
 542      } else if (existingEncryption === 'nip04') {
 543        // Match existing NIP-04 encryption
 544        try {
 545          const nip04Event = await this.createAndPublishNip04DM(
 546            recipientPubkey,
 547            content,
 548            encryption,
 549            allRelays
 550          )
 551          sentEvents.push(nip04Event)
 552        } catch (error) {
 553          console.error('Failed to send NIP-04 DM:', error)
 554          throw error // Re-throw so caller knows it failed
 555        }
 556      } else if (existingEncryption === 'nip17') {
 557        // Match existing NIP-17 encryption - use inbox relays
 558        if (!encryption.nip44Encrypt) {
 559          throw new Error('Encryption does not support NIP-44')
 560        }
 561        try {
 562          const nip17Event = await this.createAndPublishNip17DM(
 563            recipientPubkey,
 564            content,
 565            encryption,
 566            inboxRelays
 567          )
 568          sentEvents.push(nip17Event)
 569        } catch (error) {
 570          console.error('Failed to send NIP-17 DM:', error)
 571          throw error // Re-throw so caller knows it failed
 572        }
 573      }
 574  
 575      return sentEvents
 576    }
 577  
 578    /**
 579     * Create and publish a NIP-04 DM (kind 4)
 580     */
 581    private async createAndPublishNip04DM(
 582      recipientPubkey: string,
 583      content: string,
 584      encryption: IDMEncryption,
 585      relayUrls: string[]
 586    ): Promise<VerifiedEvent> {
 587      const encryptedContent = await encryption.nip04Encrypt(recipientPubkey, content)
 588  
 589      const draftEvent: TDraftEvent = {
 590        kind: KIND_ENCRYPTED_DM,
 591        created_at: Math.floor(Date.now() / 1000),
 592        content: encryptedContent,
 593        tags: [['p', recipientPubkey]]
 594      }
 595  
 596      const signedEvent = await encryption.signEvent(draftEvent)
 597      await client.publishEvent(relayUrls, signedEvent)
 598      await indexedDb.putDMEvent(signedEvent)
 599      await indexedDb.putDecryptedContent(signedEvent.id, content)
 600  
 601      return signedEvent
 602    }
 603  
 604    /**
 605     * Create and publish a NIP-17 DM with gift wrapping (kind 14 -> 13 -> 1059)
 606     */
 607    private async createAndPublishNip17DM(
 608      recipientPubkey: string,
 609      content: string,
 610      encryption: IDMEncryption,
 611      relayUrls: string[]
 612    ): Promise<VerifiedEvent> {
 613      if (!encryption.nip44Encrypt) {
 614        throw new Error('Encryption does not support NIP-44')
 615      }
 616  
 617      // Note: senderPubkey is determined by the signer when signing the event
 618  
 619      // Step 1: Create the inner chat message (kind 14)
 620      const chatMessage: TDraftEvent = {
 621        kind: KIND_PRIVATE_DM,
 622        created_at: Math.floor(Date.now() / 1000),
 623        content,
 624        tags: [['p', recipientPubkey]]
 625      }
 626  
 627      // Step 2: Sign the chat message
 628      const signedChat = await encryption.signEvent(chatMessage)
 629  
 630      // Step 3: Create a seal (kind 13) containing the encrypted chat message
 631      const sealContent = await encryption.nip44Encrypt(recipientPubkey, JSON.stringify(signedChat))
 632      const seal: TDraftEvent = {
 633        kind: KIND_SEAL,
 634        created_at: this.randomizeTimestamp(signedChat.created_at),
 635        content: sealContent,
 636        tags: []
 637      }
 638      const signedSeal = await encryption.signEvent(seal)
 639  
 640      // Step 4: Create a gift wrap (kind 1059) with random sender key
 641      // For simplicity, we'll use the same encryption but in production you'd use a random key
 642      const giftWrapContent = await encryption.nip44Encrypt(recipientPubkey, JSON.stringify(signedSeal))
 643      const giftWrap: TDraftEvent = {
 644        kind: KIND_GIFT_WRAP,
 645        created_at: this.randomizeTimestamp(signedSeal.created_at),
 646        content: giftWrapContent,
 647        tags: [['p', recipientPubkey]]
 648      }
 649      const signedGiftWrap = await encryption.signEvent(giftWrap)
 650  
 651      // Publish the gift wrap
 652      await client.publishEvent(relayUrls, signedGiftWrap)
 653      await indexedDb.putDMEvent(signedGiftWrap)
 654      await indexedDb.putDecryptedContent(signedGiftWrap.id, content)
 655  
 656      return signedGiftWrap
 657    }
 658  
 659    /**
 660     * Randomize timestamp for privacy (NIP-59)
 661     */
 662    private randomizeTimestamp(baseTime: number): number {
 663      // Add random offset between -2 days and +2 days
 664      const offset = Math.floor(Math.random() * 4 * 24 * 60 * 60) - 2 * 24 * 60 * 60
 665      return baseTime + offset
 666    }
 667  
 668    /**
 669     * Fetch partner's write relays for better DM delivery
 670     */
 671    async fetchPartnerRelays(pubkey: string): Promise<string[]> {
 672      try {
 673        // Try to get relay list from IndexedDB first
 674        const cachedEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.RelayList)
 675        if (cachedEvent) {
 676          return this.parseWriteRelays(cachedEvent)
 677        }
 678  
 679        // Fetch from user's current relays (no hardcoded fallback to protect privacy)
 680        const relays = client.currentRelays.length > 0 ? client.currentRelays : []
 681        if (relays.length === 0) {
 682          // No relays configured - return empty to signal DM feature unavailable
 683          return []
 684        }
 685  
 686        const relayListEvents = await client.fetchEvents(relays, {
 687          kinds: [kinds.RelayList],
 688          authors: [pubkey],
 689          limit: 1
 690        })
 691  
 692        if (relayListEvents.length > 0) {
 693          const event = relayListEvents[0]
 694          await indexedDb.putReplaceableEvent(event)
 695          return this.parseWriteRelays(event)
 696        }
 697  
 698        // No relay list found - return empty (don't leak to third-party relay)
 699        return []
 700      } catch {
 701        return []
 702      }
 703    }
 704  
 705    /**
 706     * Fetch partner's inbox (read) relays for NIP-17 DM delivery
 707     * NIP-65: Inbox relays are where a user receives messages
 708     */
 709    async fetchPartnerInboxRelays(pubkey: string): Promise<string[]> {
 710      try {
 711        // Try to get relay list from IndexedDB first
 712        const cachedEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.RelayList)
 713        if (cachedEvent) {
 714          return this.parseInboxRelays(cachedEvent)
 715        }
 716  
 717        // Fetch from user's current relays (not hardcoded relays)
 718        const relays = client.currentRelays.length > 0 ? client.currentRelays : []
 719        if (relays.length === 0) {
 720          return client.currentRelays // Fall back to user's relays
 721        }
 722  
 723        const relayListEvents = await client.fetchEvents(relays, {
 724          kinds: [kinds.RelayList],
 725          authors: [pubkey],
 726          limit: 1
 727        })
 728  
 729        if (relayListEvents.length > 0) {
 730          const event = relayListEvents[0]
 731          await indexedDb.putReplaceableEvent(event)
 732          return this.parseInboxRelays(event)
 733        }
 734  
 735        // Fallback to user's current relays
 736        return client.currentRelays
 737      } catch {
 738        return client.currentRelays
 739      }
 740    }
 741  
 742    /**
 743     * Parse write (outbox) relays from kind 10002 event
 744     */
 745    private parseWriteRelays(event: Event): string[] {
 746      const writeRelays: string[] = []
 747  
 748      for (const tag of event.tags) {
 749        if (tag[0] === 'r') {
 750          const url = tag[1]
 751          const scope = tag[2]
 752          // Include if it's a write relay or has no scope (both)
 753          if (!scope || scope === 'write') {
 754            writeRelays.push(url)
 755          }
 756        }
 757      }
 758  
 759      // Return empty if no write relays found (don't fall back to third-party relay)
 760      return writeRelays
 761    }
 762  
 763    /**
 764     * Parse inbox (read) relays from kind 10002 event
 765     * These are where the user receives DMs
 766     */
 767    private parseInboxRelays(event: Event): string[] {
 768      const inboxRelays: string[] = []
 769  
 770      for (const tag of event.tags) {
 771        if (tag[0] === 'r') {
 772          const url = tag[1]
 773          const scope = tag[2]
 774          // Include if it's a read relay or has no scope (both)
 775          if (!scope || scope === 'read') {
 776            inboxRelays.push(url)
 777          }
 778        }
 779      }
 780  
 781      return inboxRelays.length > 0 ? inboxRelays : client.currentRelays
 782    }
 783  
 784    /**
 785     * Check other relays for an event and return which ones have it
 786     */
 787    async checkOtherRelaysForEvent(
 788      eventId: string,
 789      knownRelays: string[]
 790    ): Promise<string[]> {
 791      const knownSet = new Set(knownRelays.map((r) => r.replace(/\/$/, '')))
 792      // Check user's current relays that aren't already known
 793      const relaysToCheck = client.currentRelays.filter(
 794        (url) => !knownSet.has(url.replace(/\/$/, ''))
 795      )
 796  
 797      const foundOnRelays: string[] = []
 798  
 799      // Check each relay individually
 800      await Promise.all(
 801        relaysToCheck.map(async (relayUrl) => {
 802          try {
 803            const events = await client.fetchEvents([relayUrl], {
 804              ids: [eventId],
 805              limit: 1
 806            })
 807            if (events.length > 0) {
 808              foundOnRelays.push(relayUrl)
 809              // Track the event as seen on this relay
 810              client.trackEventSeenOn(eventId, { url: relayUrl } as any)
 811            }
 812          } catch {
 813            // Relay unreachable, ignore
 814          }
 815        })
 816      )
 817  
 818      return foundOnRelays
 819    }
 820  
 821    /**
 822     * Group messages into conversations
 823     */
 824    groupMessagesIntoConversations(
 825      messages: TDirectMessage[],
 826      myPubkey: string
 827    ): Map<string, TConversation> {
 828      const conversations = new Map<string, TConversation>()
 829  
 830      for (const message of messages) {
 831        // Skip NIRC protocol messages (access requests, invites, etc.)
 832        if (isNircProtocolMessage(message.content ?? '')) continue
 833  
 834        const partnerPubkey =
 835          message.senderPubkey === myPubkey ? message.recipientPubkey : message.senderPubkey
 836  
 837        if (!partnerPubkey) continue
 838  
 839        const existing = conversations.get(partnerPubkey)
 840        if (!existing || message.createdAt > existing.lastMessageAt) {
 841          conversations.set(partnerPubkey, {
 842            partnerPubkey,
 843            lastMessageAt: message.createdAt,
 844            lastMessagePreview: (message.content ?? '').substring(0, 100),
 845            unreadCount: 0,
 846            preferredEncryption: message.encryptionType
 847          })
 848        }
 849      }
 850  
 851      return conversations
 852    }
 853  
 854    /**
 855     * Build conversation list from raw events WITHOUT decryption (fast)
 856     * Only works for NIP-04 events - NIP-17 gift wraps need decryption
 857     */
 858    groupEventsIntoConversations(events: Event[], myPubkey: string): Map<string, TConversation> {
 859      const conversations = new Map<string, TConversation>()
 860  
 861      for (const event of events) {
 862        // Only process NIP-04 events (kind 4) - we can get metadata without decryption
 863        if (event.kind !== KIND_ENCRYPTED_DM) continue
 864  
 865        const recipient = this.getRecipientFromTags(event.tags)
 866        const partnerPubkey = event.pubkey === myPubkey ? recipient : event.pubkey
 867  
 868        if (!partnerPubkey) continue
 869  
 870        const existing = conversations.get(partnerPubkey)
 871        if (!existing || event.created_at > existing.lastMessageAt) {
 872          conversations.set(partnerPubkey, {
 873            partnerPubkey,
 874            lastMessageAt: event.created_at,
 875            lastMessagePreview: '', // Skip preview for speed - will be filled on conversation open
 876            unreadCount: 0,
 877            preferredEncryption: 'nip04'
 878          })
 879        }
 880      }
 881  
 882      return conversations
 883    }
 884  
 885    /**
 886     * Get messages for a specific conversation
 887     */
 888    getMessagesForConversation(
 889      messages: TDirectMessage[],
 890      partnerPubkey: string,
 891      myPubkey: string
 892    ): TDirectMessage[] {
 893      return messages
 894        .filter(
 895          (m) =>
 896            (m.senderPubkey === partnerPubkey && m.recipientPubkey === myPubkey) ||
 897            (m.senderPubkey === myPubkey && m.recipientPubkey === partnerPubkey)
 898        )
 899        .sort((a, b) => a.createdAt - b.createdAt)
 900    }
 901  
 902    /**
 903     * Get the other party's pubkey from a DM event
 904     */
 905    private getOtherPartyPubkey(event: Event, myPubkey: string): string | null {
 906      if (event.pubkey === myPubkey) {
 907        // I'm the sender, get recipient from tags
 908        return this.getRecipientFromTags(event.tags)
 909      } else {
 910        // I'm the recipient, sender is the pubkey
 911        return event.pubkey
 912      }
 913    }
 914  
 915    /**
 916     * Get recipient pubkey from event tags
 917     */
 918    private getRecipientFromTags(tags: string[][] | undefined): string | null {
 919      if (!tags) return null
 920      const pTag = tags.find((t) => t[0] === 'p')
 921      return pTag ? pTag[1] : null
 922    }
 923  
 924    /**
 925     * Subscribe to incoming DMs in real-time
 926     * Returns a close function to stop the subscription
 927     */
 928    subscribeToDMs(
 929      pubkey: string,
 930      relayUrls: string[],
 931      onEvent: (event: Event) => void,
 932      sinceTimestamp?: number
 933    ): { close: () => void } {
 934      // Use provided relays - no hardcoded fallback
 935      const allRelays = [...new Set(relayUrls)]
 936      // Use caller-provided timestamp (e.g., last fetched event time) or fall back to 5 minutes ago
 937      const since = sinceTimestamp ?? Math.floor(Date.now() / 1000) - 300
 938  
 939      // Subscribe to NIP-04 DMs (kind 4) addressed to user
 940      const nip04Sub = client.subscribe(
 941        allRelays,
 942        [
 943          { kinds: [KIND_ENCRYPTED_DM], '#p': [pubkey], since },
 944          { kinds: [KIND_ENCRYPTED_DM], authors: [pubkey], since }
 945        ],
 946        {
 947          onevent: (event) => {
 948            indexedDb.putDMEvent(event).catch(() => {})
 949            onEvent(event)
 950          }
 951        }
 952      )
 953  
 954      // Subscribe to NIP-17 gift wraps (kind 1059) addressed to user
 955      const giftWrapSub = client.subscribe(
 956        allRelays,
 957        { kinds: [KIND_GIFT_WRAP], '#p': [pubkey], since },
 958        {
 959          onevent: (event) => {
 960            indexedDb.putDMEvent(event).catch(() => {})
 961            onEvent(event)
 962          }
 963        }
 964      )
 965  
 966      return {
 967        close: async () => {
 968          const [nip04, giftWrap] = await Promise.all([nip04Sub, giftWrapSub])
 969          nip04.close()
 970          giftWrap.close()
 971        }
 972      }
 973    }
 974  }
 975  
 976  const dmService = new DMService()
 977  export default dmService
 978  
 979  /**
 980   * Check if a message should be treated as deleted based on the deleted state
 981   * @param messageId - The event ID of the message
 982   * @param partnerPubkey - The conversation partner's pubkey
 983   * @param timestamp - The message timestamp (created_at)
 984   * @param deletedState - The user's deleted messages state
 985   * @returns true if the message should be hidden
 986   */
 987  export function isMessageDeleted(
 988    messageId: string,
 989    partnerPubkey: string,
 990    timestamp: number,
 991    deletedState: TDMDeletedState | null
 992  ): boolean {
 993    if (!deletedState) return false
 994  
 995    // Check if message ID is explicitly deleted
 996    if (deletedState.deletedIds.includes(messageId)) {
 997      return true
 998    }
 999  
1000    // Check if timestamp falls within any deleted range for this conversation
1001    const ranges = deletedState.deletedRanges[partnerPubkey]
1002    if (ranges) {
1003      for (const range of ranges) {
1004        if (timestamp >= range.start && timestamp <= range.end) {
1005          return true
1006        }
1007      }
1008    }
1009  
1010    return false
1011  }
1012  
1013  /**
1014   * Check if a conversation should be hidden based on its last message timestamp
1015   * A conversation is deleted if its lastMessageAt falls within any deleted range
1016   * @param partnerPubkey - The conversation partner's pubkey
1017   * @param lastMessageAt - The timestamp of the last message in the conversation
1018   * @param deletedState - The user's deleted messages state
1019   * @returns true if the conversation should be hidden
1020   */
1021  export function isConversationDeleted(
1022    partnerPubkey: string,
1023    lastMessageAt: number,
1024    deletedState: TDMDeletedState | null
1025  ): boolean {
1026    if (!deletedState) return false
1027  
1028    const ranges = deletedState.deletedRanges[partnerPubkey]
1029    if (!ranges || ranges.length === 0) return false
1030  
1031    // Check if lastMessageAt falls within any deleted range
1032    for (const range of ranges) {
1033      if (lastMessageAt >= range.start && lastMessageAt <= range.end) {
1034        return true
1035      }
1036    }
1037  
1038    return false
1039  }
1040  
1041  /**
1042   * Get the global delete cutoff timestamp.
1043   * Returns the maximum 'end' timestamp from all "delete all" ranges (where start=0).
1044   * Gift wraps with created_at <= this value can be skipped without decryption.
1045   * @param deletedState - The user's deleted messages state
1046   * @returns The cutoff timestamp, or 0 if no global cutoff exists
1047   */
1048  export function getGlobalDeleteCutoff(deletedState: TDMDeletedState | null): number {
1049    if (!deletedState) return 0
1050  
1051    let maxCutoff = 0
1052    for (const ranges of Object.values(deletedState.deletedRanges)) {
1053      for (const range of ranges) {
1054        // Only consider "delete all" ranges (start=0) as global cutoffs
1055        if (range.start === 0 && range.end > maxCutoff) {
1056          maxCutoff = range.end
1057        }
1058      }
1059    }
1060    return maxCutoff
1061  }
1062