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