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