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