indexed-db.service.ts raw
1 import { ExtendedKind } from '@/constants'
2 import { tagNameEquals } from '@/lib/tag'
3 import { TDMDeletedState, TRelayInfo } from '@/types'
4 import { Event, Filter, kinds, matchFilters } from 'nostr-tools'
5
6 type TValue<T = any> = {
7 key: string
8 value: T | null
9 addedAt: number
10 }
11
12 const StoreNames = {
13 PROFILE_EVENTS: 'profileEvents',
14 RELAY_LIST_EVENTS: 'relayListEvents',
15 FOLLOW_LIST_EVENTS: 'followListEvents',
16 MUTE_LIST_EVENTS: 'muteListEvents',
17 BOOKMARK_LIST_EVENTS: 'bookmarkListEvents',
18 BLOSSOM_SERVER_LIST_EVENTS: 'blossomServerListEvents',
19 USER_EMOJI_LIST_EVENTS: 'userEmojiListEvents',
20 EMOJI_SET_EVENTS: 'emojiSetEvents',
21 PIN_LIST_EVENTS: 'pinListEvents',
22 FAVORITE_RELAYS: 'favoriteRelays',
23 RELAY_SETS: 'relaySets',
24 FOLLOWING_FAVORITE_RELAYS: 'followingFavoriteRelays',
25 RELAY_INFOS: 'relayInfos',
26 DECRYPTED_CONTENTS: 'decryptedContents',
27 PINNED_USERS_EVENTS: 'pinnedUsersEvents',
28 DM_EVENTS: 'dmEvents',
29 DM_CONVERSATIONS: 'dmConversations',
30 DM_MESSAGES: 'dmMessages',
31 UNWRAPPED_GIFT_WRAPS: 'unwrappedGiftWraps',
32 DM_DELETED_STATE: 'dmDeletedState',
33 CACHED_EVENTS: 'cachedEvents', // General event cache for NRC cache relays
34 RELAY_STATS: 'relayStats', // Per-relay per-network failure stats
35 MANAGED_RELAYS: 'managedRelays', // Outbox relay approval state
36 MUTE_DECRYPTED_TAGS: 'muteDecryptedTags', // deprecated
37 RELAY_INFO_EVENTS: 'relayInfoEvents' // deprecated
38 }
39
40 class IndexedDbService {
41 static instance: IndexedDbService
42 static getInstance(): IndexedDbService {
43 if (!IndexedDbService.instance) {
44 IndexedDbService.instance = new IndexedDbService()
45 IndexedDbService.instance.init()
46 }
47 return IndexedDbService.instance
48 }
49
50 private db: IDBDatabase | null = null
51 private initPromise: Promise<void> | null = null
52
53 init(): Promise<void> {
54 if (!this.initPromise) {
55 this.initPromise = new Promise((resolve, reject) => {
56 const request = window.indexedDB.open('smesh', 16)
57
58 request.onerror = (event) => {
59 reject(event)
60 }
61
62 request.onsuccess = () => {
63 this.db = request.result
64 resolve()
65 }
66
67 request.onupgradeneeded = () => {
68 const db = request.result
69 if (!db.objectStoreNames.contains(StoreNames.PROFILE_EVENTS)) {
70 db.createObjectStore(StoreNames.PROFILE_EVENTS, { keyPath: 'key' })
71 }
72 if (!db.objectStoreNames.contains(StoreNames.RELAY_LIST_EVENTS)) {
73 db.createObjectStore(StoreNames.RELAY_LIST_EVENTS, { keyPath: 'key' })
74 }
75 if (!db.objectStoreNames.contains(StoreNames.FOLLOW_LIST_EVENTS)) {
76 db.createObjectStore(StoreNames.FOLLOW_LIST_EVENTS, { keyPath: 'key' })
77 }
78 if (!db.objectStoreNames.contains(StoreNames.MUTE_LIST_EVENTS)) {
79 db.createObjectStore(StoreNames.MUTE_LIST_EVENTS, { keyPath: 'key' })
80 }
81 if (!db.objectStoreNames.contains(StoreNames.BOOKMARK_LIST_EVENTS)) {
82 db.createObjectStore(StoreNames.BOOKMARK_LIST_EVENTS, { keyPath: 'key' })
83 }
84 if (!db.objectStoreNames.contains(StoreNames.DECRYPTED_CONTENTS)) {
85 db.createObjectStore(StoreNames.DECRYPTED_CONTENTS, { keyPath: 'key' })
86 }
87 if (!db.objectStoreNames.contains(StoreNames.FAVORITE_RELAYS)) {
88 db.createObjectStore(StoreNames.FAVORITE_RELAYS, { keyPath: 'key' })
89 }
90 if (!db.objectStoreNames.contains(StoreNames.RELAY_SETS)) {
91 db.createObjectStore(StoreNames.RELAY_SETS, { keyPath: 'key' })
92 }
93 if (!db.objectStoreNames.contains(StoreNames.FOLLOWING_FAVORITE_RELAYS)) {
94 db.createObjectStore(StoreNames.FOLLOWING_FAVORITE_RELAYS, { keyPath: 'key' })
95 }
96 if (!db.objectStoreNames.contains(StoreNames.BLOSSOM_SERVER_LIST_EVENTS)) {
97 db.createObjectStore(StoreNames.BLOSSOM_SERVER_LIST_EVENTS, { keyPath: 'key' })
98 }
99 if (!db.objectStoreNames.contains(StoreNames.USER_EMOJI_LIST_EVENTS)) {
100 db.createObjectStore(StoreNames.USER_EMOJI_LIST_EVENTS, { keyPath: 'key' })
101 }
102 if (!db.objectStoreNames.contains(StoreNames.EMOJI_SET_EVENTS)) {
103 db.createObjectStore(StoreNames.EMOJI_SET_EVENTS, { keyPath: 'key' })
104 }
105 if (!db.objectStoreNames.contains(StoreNames.RELAY_INFOS)) {
106 db.createObjectStore(StoreNames.RELAY_INFOS, { keyPath: 'key' })
107 }
108 if (!db.objectStoreNames.contains(StoreNames.PIN_LIST_EVENTS)) {
109 db.createObjectStore(StoreNames.PIN_LIST_EVENTS, { keyPath: 'key' })
110 }
111 if (!db.objectStoreNames.contains(StoreNames.PINNED_USERS_EVENTS)) {
112 db.createObjectStore(StoreNames.PINNED_USERS_EVENTS, { keyPath: 'key' })
113 }
114 if (!db.objectStoreNames.contains(StoreNames.DM_EVENTS)) {
115 db.createObjectStore(StoreNames.DM_EVENTS, { keyPath: 'key' })
116 }
117 if (!db.objectStoreNames.contains(StoreNames.DM_CONVERSATIONS)) {
118 db.createObjectStore(StoreNames.DM_CONVERSATIONS, { keyPath: 'key' })
119 }
120 if (!db.objectStoreNames.contains(StoreNames.DM_MESSAGES)) {
121 db.createObjectStore(StoreNames.DM_MESSAGES, { keyPath: 'key' })
122 }
123 if (!db.objectStoreNames.contains(StoreNames.UNWRAPPED_GIFT_WRAPS)) {
124 db.createObjectStore(StoreNames.UNWRAPPED_GIFT_WRAPS, { keyPath: 'key' })
125 }
126 if (!db.objectStoreNames.contains(StoreNames.DM_DELETED_STATE)) {
127 db.createObjectStore(StoreNames.DM_DELETED_STATE, { keyPath: 'key' })
128 }
129 if (!db.objectStoreNames.contains(StoreNames.CACHED_EVENTS)) {
130 const store = db.createObjectStore(StoreNames.CACHED_EVENTS, { keyPath: 'id' })
131 store.createIndex('kind', 'kind', { unique: false })
132 store.createIndex('pubkey', 'pubkey', { unique: false })
133 store.createIndex('created_at', 'created_at', { unique: false })
134 }
135
136 if (!db.objectStoreNames.contains(StoreNames.RELAY_STATS)) {
137 db.createObjectStore(StoreNames.RELAY_STATS, { keyPath: 'key' })
138 }
139 if (!db.objectStoreNames.contains(StoreNames.MANAGED_RELAYS)) {
140 db.createObjectStore(StoreNames.MANAGED_RELAYS, { keyPath: 'key' })
141 }
142
143 if (db.objectStoreNames.contains(StoreNames.RELAY_INFO_EVENTS)) {
144 db.deleteObjectStore(StoreNames.RELAY_INFO_EVENTS)
145 }
146 if (db.objectStoreNames.contains(StoreNames.MUTE_DECRYPTED_TAGS)) {
147 db.deleteObjectStore(StoreNames.MUTE_DECRYPTED_TAGS)
148 }
149 this.db = db
150 }
151 })
152 setTimeout(() => this.cleanUp(), 1000 * 60) // 1 minute
153 }
154 return this.initPromise
155 }
156
157 async putNullReplaceableEvent(pubkey: string, kind: number, d?: string) {
158 const storeName = this.getStoreNameByKind(kind)
159 if (!storeName) {
160 return Promise.reject('store name not found')
161 }
162 await this.initPromise
163 return new Promise((resolve, reject) => {
164 if (!this.db) {
165 return reject('database not initialized')
166 }
167 const transaction = this.db.transaction(storeName, 'readwrite')
168 const store = transaction.objectStore(storeName)
169
170 const key = this.getReplaceableEventKey(pubkey, d)
171 const getRequest = store.get(key)
172 getRequest.onsuccess = () => {
173 const oldValue = getRequest.result as TValue<Event> | undefined
174 if (oldValue) {
175 transaction.commit()
176 return resolve(oldValue.value)
177 }
178 const putRequest = store.put(this.formatValue(key, null))
179 putRequest.onsuccess = () => {
180 transaction.commit()
181 resolve(null)
182 }
183
184 putRequest.onerror = (event) => {
185 transaction.commit()
186 reject(event)
187 }
188 }
189
190 getRequest.onerror = (event) => {
191 transaction.commit()
192 reject(event)
193 }
194 })
195 }
196
197 async putReplaceableEvent(event: Event): Promise<Event> {
198 const storeName = this.getStoreNameByKind(event.kind)
199 if (!storeName) {
200 return Promise.reject('store name not found')
201 }
202 await this.initPromise
203 return new Promise((resolve, reject) => {
204 if (!this.db) {
205 return reject('database not initialized')
206 }
207 const transaction = this.db.transaction(storeName, 'readwrite')
208 const store = transaction.objectStore(storeName)
209
210 const key = this.getReplaceableEventKeyFromEvent(event)
211 const getRequest = store.get(key)
212 getRequest.onsuccess = () => {
213 const oldValue = getRequest.result as TValue<Event> | undefined
214 if (oldValue?.value && oldValue.value.created_at >= event.created_at) {
215 transaction.commit()
216 return resolve(oldValue.value)
217 }
218 const putRequest = store.put(this.formatValue(key, event))
219 putRequest.onsuccess = () => {
220 transaction.commit()
221 resolve(event)
222 }
223
224 putRequest.onerror = (event) => {
225 transaction.commit()
226 reject(event)
227 }
228 }
229
230 getRequest.onerror = (event) => {
231 transaction.commit()
232 reject(event)
233 }
234 })
235 }
236
237 async getReplaceableEventByCoordinate(coordinate: string): Promise<Event | undefined | null> {
238 const [kind, pubkey, ...rest] = coordinate.split(':')
239 const d = rest.length > 0 ? rest.join(':') : undefined
240 return this.getReplaceableEvent(pubkey, parseInt(kind), d)
241 }
242
243 async getReplaceableEvent(
244 pubkey: string,
245 kind: number,
246 d?: string
247 ): Promise<Event | undefined | null> {
248 const storeName = this.getStoreNameByKind(kind)
249 if (!storeName) {
250 return undefined
251 }
252 await this.initPromise
253 return new Promise((resolve, reject) => {
254 if (!this.db) {
255 return reject('database not initialized')
256 }
257 const transaction = this.db.transaction(storeName, 'readonly')
258 const store = transaction.objectStore(storeName)
259 const key = this.getReplaceableEventKey(pubkey, d)
260 const request = store.get(key)
261
262 request.onsuccess = () => {
263 transaction.commit()
264 resolve((request.result as TValue<Event>)?.value)
265 }
266
267 request.onerror = (event) => {
268 transaction.commit()
269 reject(event)
270 }
271 })
272 }
273
274 async getManyReplaceableEvents(
275 pubkeys: readonly string[],
276 kind: number
277 ): Promise<(Event | undefined | null)[]> {
278 const storeName = this.getStoreNameByKind(kind)
279 if (!storeName) {
280 return Promise.reject('store name not found')
281 }
282 await this.initPromise
283 return new Promise((resolve, reject) => {
284 if (!this.db) {
285 return reject('database not initialized')
286 }
287 const transaction = this.db.transaction(storeName, 'readonly')
288 const store = transaction.objectStore(storeName)
289 const events: (Event | null)[] = new Array(pubkeys.length).fill(undefined)
290 let count = 0
291 pubkeys.forEach((pubkey, i) => {
292 const request = store.get(this.getReplaceableEventKey(pubkey))
293
294 request.onsuccess = () => {
295 const event = (request.result as TValue<Event | null>)?.value
296 if (event || event === null) {
297 events[i] = event
298 }
299
300 if (++count === pubkeys.length) {
301 transaction.commit()
302 resolve(events)
303 }
304 }
305
306 request.onerror = () => {
307 if (++count === pubkeys.length) {
308 transaction.commit()
309 resolve(events)
310 }
311 }
312 })
313 })
314 }
315
316 async getDecryptedContent(key: string): Promise<string | null> {
317 await this.initPromise
318 return new Promise((resolve, reject) => {
319 if (!this.db) {
320 return reject('database not initialized')
321 }
322 const transaction = this.db.transaction(StoreNames.DECRYPTED_CONTENTS, 'readonly')
323 const store = transaction.objectStore(StoreNames.DECRYPTED_CONTENTS)
324 const request = store.get(key)
325
326 request.onsuccess = () => {
327 transaction.commit()
328 resolve((request.result as TValue<string>)?.value)
329 }
330
331 request.onerror = (event) => {
332 transaction.commit()
333 reject(event)
334 }
335 })
336 }
337
338 async putDecryptedContent(key: string, content: string): Promise<void> {
339 await this.initPromise
340 return new Promise((resolve, reject) => {
341 if (!this.db) {
342 return reject('database not initialized')
343 }
344 const transaction = this.db.transaction(StoreNames.DECRYPTED_CONTENTS, 'readwrite')
345 const store = transaction.objectStore(StoreNames.DECRYPTED_CONTENTS)
346
347 const putRequest = store.put(this.formatValue(key, content))
348 putRequest.onsuccess = () => {
349 transaction.commit()
350 resolve()
351 }
352
353 putRequest.onerror = (event) => {
354 transaction.commit()
355 reject(event)
356 }
357 })
358 }
359
360 async iterateProfileEvents(callback: (event: Event) => Promise<void>): Promise<void> {
361 await this.initPromise
362 if (!this.db) {
363 return
364 }
365
366 return new Promise<void>((resolve, reject) => {
367 const transaction = this.db!.transaction(StoreNames.PROFILE_EVENTS, 'readwrite')
368 const store = transaction.objectStore(StoreNames.PROFILE_EVENTS)
369 const request = store.openCursor()
370 request.onsuccess = (event) => {
371 const cursor = (event.target as IDBRequest).result
372 if (cursor) {
373 const value = (cursor.value as TValue<Event>).value
374 if (value) {
375 callback(value)
376 }
377 cursor.continue()
378 } else {
379 transaction.commit()
380 resolve()
381 }
382 }
383
384 request.onerror = (event) => {
385 transaction.commit()
386 reject(event)
387 }
388 })
389 }
390
391 async putFollowingFavoriteRelays(pubkey: string, relays: [string, string[]][]): Promise<void> {
392 await this.initPromise
393 return new Promise((resolve, reject) => {
394 if (!this.db) {
395 return reject('database not initialized')
396 }
397 const transaction = this.db.transaction(StoreNames.FOLLOWING_FAVORITE_RELAYS, 'readwrite')
398 const store = transaction.objectStore(StoreNames.FOLLOWING_FAVORITE_RELAYS)
399
400 const putRequest = store.put(this.formatValue(pubkey, relays))
401 putRequest.onsuccess = () => {
402 transaction.commit()
403 resolve()
404 }
405
406 putRequest.onerror = (event) => {
407 transaction.commit()
408 reject(event)
409 }
410 })
411 }
412
413 async getFollowingFavoriteRelays(pubkey: string): Promise<[string, string[]][] | null> {
414 await this.initPromise
415 return new Promise((resolve, reject) => {
416 if (!this.db) {
417 return reject('database not initialized')
418 }
419 const transaction = this.db.transaction(StoreNames.FOLLOWING_FAVORITE_RELAYS, 'readonly')
420 const store = transaction.objectStore(StoreNames.FOLLOWING_FAVORITE_RELAYS)
421 const request = store.get(pubkey)
422
423 request.onsuccess = () => {
424 transaction.commit()
425 resolve((request.result as TValue<[string, string[]][]>)?.value)
426 }
427
428 request.onerror = (event) => {
429 transaction.commit()
430 reject(event)
431 }
432 })
433 }
434
435 async putRelayInfo(relayInfo: TRelayInfo): Promise<void> {
436 await this.initPromise
437 return new Promise((resolve, reject) => {
438 if (!this.db) {
439 return reject('database not initialized')
440 }
441 const transaction = this.db.transaction(StoreNames.RELAY_INFOS, 'readwrite')
442 const store = transaction.objectStore(StoreNames.RELAY_INFOS)
443
444 const putRequest = store.put(this.formatValue(relayInfo.url, relayInfo))
445 putRequest.onsuccess = () => {
446 transaction.commit()
447 resolve()
448 }
449
450 putRequest.onerror = (event) => {
451 transaction.commit()
452 reject(event)
453 }
454 })
455 }
456
457 async getRelayInfo(url: string): Promise<TRelayInfo | null> {
458 await this.initPromise
459 return new Promise((resolve, reject) => {
460 if (!this.db) {
461 return reject('database not initialized')
462 }
463 const transaction = this.db.transaction(StoreNames.RELAY_INFOS, 'readonly')
464 const store = transaction.objectStore(StoreNames.RELAY_INFOS)
465 const request = store.get(url)
466
467 request.onsuccess = () => {
468 transaction.commit()
469 resolve((request.result as TValue<TRelayInfo>)?.value)
470 }
471
472 request.onerror = (event) => {
473 transaction.commit()
474 reject(event)
475 }
476 })
477 }
478
479 // DM-related methods
480 async putDMEvent(event: Event): Promise<void> {
481 await this.initPromise
482 return new Promise((resolve, reject) => {
483 if (!this.db) {
484 return reject('database not initialized')
485 }
486 const transaction = this.db.transaction(StoreNames.DM_EVENTS, 'readwrite')
487 const store = transaction.objectStore(StoreNames.DM_EVENTS)
488
489 const putRequest = store.put(this.formatValue(event.id, event))
490 putRequest.onsuccess = () => {
491 transaction.commit()
492 resolve()
493 }
494
495 putRequest.onerror = (event) => {
496 transaction.commit()
497 reject(event)
498 }
499 })
500 }
501
502 async getDMEvent(eventId: string): Promise<Event | null> {
503 await this.initPromise
504 return new Promise((resolve, reject) => {
505 if (!this.db) {
506 return reject('database not initialized')
507 }
508 const transaction = this.db.transaction(StoreNames.DM_EVENTS, 'readonly')
509 const store = transaction.objectStore(StoreNames.DM_EVENTS)
510 const request = store.get(eventId)
511
512 request.onsuccess = () => {
513 transaction.commit()
514 resolve((request.result as TValue<Event>)?.value ?? null)
515 }
516
517 request.onerror = (event) => {
518 transaction.commit()
519 reject(event)
520 }
521 })
522 }
523
524 async getAllDMEvents(userPubkey: string): Promise<Event[]> {
525 await this.initPromise
526 return new Promise((resolve, reject) => {
527 if (!this.db) {
528 return reject('database not initialized')
529 }
530 const transaction = this.db.transaction(StoreNames.DM_EVENTS, 'readonly')
531 const store = transaction.objectStore(StoreNames.DM_EVENTS)
532 const request = store.openCursor()
533 const events: Event[] = []
534
535 request.onsuccess = (event) => {
536 const cursor = (event.target as IDBRequest).result
537 if (cursor) {
538 const dmEvent = (cursor.value as TValue<Event>).value
539 if (dmEvent) {
540 // Include events where user is sender or recipient
541 const isUserEvent =
542 dmEvent.pubkey === userPubkey ||
543 dmEvent.tags.some((tag) => tag[0] === 'p' && tag[1] === userPubkey)
544 if (isUserEvent) {
545 events.push(dmEvent)
546 }
547 }
548 cursor.continue()
549 } else {
550 transaction.commit()
551 resolve(events)
552 }
553 }
554
555 request.onerror = (event) => {
556 transaction.commit()
557 reject(event)
558 }
559 })
560 }
561
562 async putDMConversation(
563 userPubkey: string,
564 partnerPubkey: string,
565 lastMessageAt: number,
566 lastMessagePreview: string,
567 encryptionType: 'nip04' | 'nip17' | null
568 ): Promise<void> {
569 await this.initPromise
570 return new Promise((resolve, reject) => {
571 if (!this.db) {
572 return reject('database not initialized')
573 }
574 const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readwrite')
575 const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
576 const key = `${userPubkey}:${partnerPubkey}`
577
578 const putRequest = store.put(
579 this.formatValue(key, {
580 partnerPubkey,
581 lastMessageAt,
582 lastMessagePreview,
583 encryptionType
584 })
585 )
586 putRequest.onsuccess = () => {
587 transaction.commit()
588 resolve()
589 }
590
591 putRequest.onerror = (event) => {
592 transaction.commit()
593 reject(event)
594 }
595 })
596 }
597
598 async getDMConversations(
599 userPubkey: string
600 ): Promise<
601 Array<{
602 partnerPubkey: string
603 lastMessageAt: number
604 lastMessagePreview: string
605 encryptionType: 'nip04' | 'nip17' | null
606 }>
607 > {
608 await this.initPromise
609 return new Promise((resolve, reject) => {
610 if (!this.db) {
611 return reject('database not initialized')
612 }
613 const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readonly')
614 const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
615 const request = store.openCursor()
616 const conversations: Array<{
617 partnerPubkey: string
618 lastMessageAt: number
619 lastMessagePreview: string
620 encryptionType: 'nip04' | 'nip17' | null
621 }> = []
622
623 request.onsuccess = (event) => {
624 const cursor = (event.target as IDBRequest).result
625 if (cursor) {
626 const key = cursor.key as string
627 if (key.startsWith(`${userPubkey}:`)) {
628 const value = (cursor.value as TValue).value
629 if (value) {
630 conversations.push(value)
631 }
632 }
633 cursor.continue()
634 } else {
635 transaction.commit()
636 // Sort by lastMessageAt descending
637 conversations.sort((a, b) => b.lastMessageAt - a.lastMessageAt)
638 resolve(conversations)
639 }
640 }
641
642 request.onerror = (event) => {
643 transaction.commit()
644 reject(event)
645 }
646 })
647 }
648
649 async putConversationRelaySettings(
650 userPubkey: string,
651 partnerPubkey: string,
652 selectedRelays: string[]
653 ): Promise<void> {
654 await this.initPromise
655 return new Promise((resolve, reject) => {
656 if (!this.db) {
657 return reject('database not initialized')
658 }
659 const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readwrite')
660 const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
661 const key = `${userPubkey}:${partnerPubkey}:relays`
662
663 const putRequest = store.put(this.formatValue(key, { selectedRelays }))
664 putRequest.onsuccess = () => {
665 transaction.commit()
666 resolve()
667 }
668
669 putRequest.onerror = (event) => {
670 transaction.commit()
671 reject(event)
672 }
673 })
674 }
675
676 async getConversationRelaySettings(
677 userPubkey: string,
678 partnerPubkey: string
679 ): Promise<string[] | null> {
680 await this.initPromise
681 return new Promise((resolve, reject) => {
682 if (!this.db) {
683 return reject('database not initialized')
684 }
685 const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readonly')
686 const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
687 const key = `${userPubkey}:${partnerPubkey}:relays`
688 const request = store.get(key)
689
690 request.onsuccess = () => {
691 transaction.commit()
692 const result = (request.result as TValue)?.value
693 resolve(result?.selectedRelays ?? null)
694 }
695
696 request.onerror = (event) => {
697 transaction.commit()
698 reject(event)
699 }
700 })
701 }
702
703 async putConversationEncryptionPreference(
704 userPubkey: string,
705 partnerPubkey: string,
706 preference: 'nip04' | 'nip17' | 'auto'
707 ): Promise<void> {
708 await this.initPromise
709 return new Promise((resolve, reject) => {
710 if (!this.db) {
711 return reject('database not initialized')
712 }
713 const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readwrite')
714 const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
715 const key = `${userPubkey}:${partnerPubkey}:encryption`
716
717 const putRequest = store.put(this.formatValue(key, { preference }))
718 putRequest.onsuccess = () => {
719 transaction.commit()
720 resolve()
721 }
722
723 putRequest.onerror = (event) => {
724 transaction.commit()
725 reject(event)
726 }
727 })
728 }
729
730 async getConversationEncryptionPreference(
731 userPubkey: string,
732 partnerPubkey: string
733 ): Promise<'nip04' | 'nip17' | 'auto' | null> {
734 await this.initPromise
735 return new Promise((resolve, reject) => {
736 if (!this.db) {
737 return reject('database not initialized')
738 }
739 const transaction = this.db.transaction(StoreNames.DM_CONVERSATIONS, 'readonly')
740 const store = transaction.objectStore(StoreNames.DM_CONVERSATIONS)
741 const key = `${userPubkey}:${partnerPubkey}:encryption`
742 const request = store.get(key)
743
744 request.onsuccess = () => {
745 transaction.commit()
746 const result = (request.result as TValue)?.value
747 resolve(result?.preference ?? null)
748 }
749
750 request.onerror = (event) => {
751 transaction.commit()
752 reject(event)
753 }
754 })
755 }
756
757 async putConversationMessages(
758 userPubkey: string,
759 partnerPubkey: string,
760 messages: Array<{
761 id: string
762 senderPubkey: string
763 recipientPubkey: string
764 content: string
765 createdAt: number
766 encryptionType: 'nip04' | 'nip17'
767 seenOnRelays?: string[]
768 }>
769 ): Promise<void> {
770 await this.initPromise
771 return new Promise((resolve, reject) => {
772 if (!this.db) {
773 return reject('database not initialized')
774 }
775 const transaction = this.db.transaction(StoreNames.DM_MESSAGES, 'readwrite')
776 const store = transaction.objectStore(StoreNames.DM_MESSAGES)
777 const key = `${userPubkey}:${partnerPubkey}`
778
779 const putRequest = store.put(this.formatValue(key, messages))
780 putRequest.onsuccess = () => {
781 transaction.commit()
782 resolve()
783 }
784
785 putRequest.onerror = (event) => {
786 transaction.commit()
787 reject(event)
788 }
789 })
790 }
791
792 async getConversationMessages(
793 userPubkey: string,
794 partnerPubkey: string
795 ): Promise<
796 Array<{
797 id: string
798 senderPubkey: string
799 recipientPubkey: string
800 content: string
801 createdAt: number
802 encryptionType: 'nip04' | 'nip17'
803 seenOnRelays?: string[]
804 }> | null
805 > {
806 await this.initPromise
807 return new Promise((resolve, reject) => {
808 if (!this.db) {
809 return reject('database not initialized')
810 }
811 const transaction = this.db.transaction(StoreNames.DM_MESSAGES, 'readonly')
812 const store = transaction.objectStore(StoreNames.DM_MESSAGES)
813 const key = `${userPubkey}:${partnerPubkey}`
814 const request = store.get(key)
815
816 request.onsuccess = () => {
817 transaction.commit()
818 resolve((request.result as TValue)?.value ?? null)
819 }
820
821 request.onerror = (event) => {
822 transaction.commit()
823 reject(event)
824 }
825 })
826 }
827
828 /**
829 * Cache an unwrapped NIP-17 gift wrap inner event
830 * This avoids repeated decryption just to identify the sender
831 */
832 async putUnwrappedGiftWrap(
833 giftWrapId: string,
834 innerEvent: {
835 pubkey: string // actual sender
836 recipientPubkey: string
837 content: string
838 createdAt: number
839 }
840 ): Promise<void> {
841 await this.initPromise
842 return new Promise((resolve, reject) => {
843 if (!this.db) {
844 return reject('database not initialized')
845 }
846 const transaction = this.db.transaction(StoreNames.UNWRAPPED_GIFT_WRAPS, 'readwrite')
847 const store = transaction.objectStore(StoreNames.UNWRAPPED_GIFT_WRAPS)
848
849 const putRequest = store.put(this.formatValue(giftWrapId, innerEvent))
850 putRequest.onsuccess = () => {
851 transaction.commit()
852 resolve()
853 }
854
855 putRequest.onerror = (event) => {
856 transaction.commit()
857 reject(event)
858 }
859 })
860 }
861
862 /**
863 * Get a cached unwrapped NIP-17 gift wrap inner event
864 */
865 async getUnwrappedGiftWrap(
866 giftWrapId: string
867 ): Promise<{
868 pubkey: string
869 recipientPubkey: string
870 content: string
871 createdAt: number
872 } | null> {
873 await this.initPromise
874 return new Promise((resolve, reject) => {
875 if (!this.db) {
876 return reject('database not initialized')
877 }
878 const transaction = this.db.transaction(StoreNames.UNWRAPPED_GIFT_WRAPS, 'readonly')
879 const store = transaction.objectStore(StoreNames.UNWRAPPED_GIFT_WRAPS)
880 const request = store.get(giftWrapId)
881
882 request.onsuccess = () => {
883 transaction.commit()
884 resolve((request.result as TValue)?.value ?? null)
885 }
886
887 request.onerror = (event) => {
888 transaction.commit()
889 reject(event)
890 }
891 })
892 }
893
894 /**
895 * Clear all DM-related caches (for full refresh)
896 */
897 async clearAllDMCaches(): Promise<void> {
898 await this.initPromise
899 if (!this.db) {
900 return
901 }
902
903 const storeNames = [
904 StoreNames.DM_EVENTS,
905 StoreNames.DM_CONVERSATIONS,
906 StoreNames.DM_MESSAGES,
907 StoreNames.UNWRAPPED_GIFT_WRAPS,
908 StoreNames.DECRYPTED_CONTENTS
909 ]
910
911 const transaction = this.db.transaction(storeNames, 'readwrite')
912
913 await Promise.all(
914 storeNames.map(
915 (storeName) =>
916 new Promise<void>((resolve, reject) => {
917 const store = transaction.objectStore(storeName)
918 const request = store.clear()
919 request.onsuccess = () => resolve()
920 request.onerror = (event) => reject(event)
921 })
922 )
923 )
924
925 transaction.commit()
926 }
927
928 /**
929 * Get the deleted messages state for a user (local cache only)
930 */
931 async getDeletedMessagesState(pubkey: string): Promise<TDMDeletedState | null> {
932 await this.initPromise
933 return new Promise((resolve, reject) => {
934 if (!this.db) {
935 return reject('database not initialized')
936 }
937 const transaction = this.db.transaction(StoreNames.DM_DELETED_STATE, 'readonly')
938 const store = transaction.objectStore(StoreNames.DM_DELETED_STATE)
939 const request = store.get(pubkey)
940
941 request.onsuccess = () => {
942 transaction.commit()
943 resolve((request.result as TValue<TDMDeletedState>)?.value ?? null)
944 }
945
946 request.onerror = (event) => {
947 transaction.commit()
948 reject(event)
949 }
950 })
951 }
952
953 /**
954 * Store the deleted messages state for a user (local cache)
955 */
956 async putDeletedMessagesState(pubkey: string, state: TDMDeletedState): Promise<void> {
957 await this.initPromise
958 return new Promise((resolve, reject) => {
959 if (!this.db) {
960 return reject('database not initialized')
961 }
962 const transaction = this.db.transaction(StoreNames.DM_DELETED_STATE, 'readwrite')
963 const store = transaction.objectStore(StoreNames.DM_DELETED_STATE)
964
965 const putRequest = store.put(this.formatValue(pubkey, state))
966 putRequest.onsuccess = () => {
967 transaction.commit()
968 resolve()
969 }
970
971 putRequest.onerror = (event) => {
972 transaction.commit()
973 reject(event)
974 }
975 })
976 }
977
978 private getReplaceableEventKeyFromEvent(event: Event): string {
979 if (
980 [kinds.Metadata, kinds.Contacts].includes(event.kind) ||
981 (event.kind >= 10000 && event.kind < 20000)
982 ) {
983 return this.getReplaceableEventKey(event.pubkey)
984 }
985
986 const [, d] = event.tags.find(tagNameEquals('d')) ?? []
987 return this.getReplaceableEventKey(event.pubkey, d)
988 }
989
990 private getReplaceableEventKey(pubkey: string, d?: string): string {
991 return d === undefined ? pubkey : `${pubkey}:${d}`
992 }
993
994 private getStoreNameByKind(kind: number): string | undefined {
995 switch (kind) {
996 case kinds.Metadata:
997 return StoreNames.PROFILE_EVENTS
998 case kinds.RelayList:
999 return StoreNames.RELAY_LIST_EVENTS
1000 case kinds.Contacts:
1001 return StoreNames.FOLLOW_LIST_EVENTS
1002 case kinds.Mutelist:
1003 return StoreNames.MUTE_LIST_EVENTS
1004 case ExtendedKind.BLOSSOM_SERVER_LIST:
1005 return StoreNames.BLOSSOM_SERVER_LIST_EVENTS
1006 case kinds.Relaysets:
1007 return StoreNames.RELAY_SETS
1008 case ExtendedKind.FAVORITE_RELAYS:
1009 return StoreNames.FAVORITE_RELAYS
1010 case kinds.BookmarkList:
1011 return StoreNames.BOOKMARK_LIST_EVENTS
1012 case kinds.UserEmojiList:
1013 return StoreNames.USER_EMOJI_LIST_EVENTS
1014 case kinds.Emojisets:
1015 return StoreNames.EMOJI_SET_EVENTS
1016 case kinds.Pinlist:
1017 return StoreNames.PIN_LIST_EVENTS
1018 case ExtendedKind.PINNED_USERS:
1019 return StoreNames.PINNED_USERS_EVENTS
1020 default:
1021 return undefined
1022 }
1023 }
1024
1025 private formatValue<T>(key: string, value: T): TValue<T> {
1026 return {
1027 key,
1028 value,
1029 addedAt: Date.now()
1030 }
1031 }
1032
1033 /**
1034 * Query all events across all stores for NRC sync.
1035 * Returns events matching the provided filters.
1036 *
1037 * Note: This method queries all event-containing stores and filters
1038 * client-side using matchFilters. Device-specific event filtering
1039 * should be done by the caller.
1040 */
1041 async queryEventsForNRC(filters: Filter[]): Promise<Event[]> {
1042 await this.initPromise
1043 if (!this.db) {
1044 return []
1045 }
1046
1047 // List of stores that contain Event objects
1048 const eventStores = [
1049 StoreNames.PROFILE_EVENTS,
1050 StoreNames.RELAY_LIST_EVENTS,
1051 StoreNames.FOLLOW_LIST_EVENTS,
1052 StoreNames.MUTE_LIST_EVENTS,
1053 StoreNames.BOOKMARK_LIST_EVENTS,
1054 StoreNames.BLOSSOM_SERVER_LIST_EVENTS,
1055 StoreNames.USER_EMOJI_LIST_EVENTS,
1056 StoreNames.EMOJI_SET_EVENTS,
1057 StoreNames.PIN_LIST_EVENTS,
1058 StoreNames.PINNED_USERS_EVENTS,
1059 StoreNames.FAVORITE_RELAYS,
1060 StoreNames.RELAY_SETS,
1061 StoreNames.DM_EVENTS
1062 ]
1063
1064 const allEvents: Event[] = []
1065
1066 // Query each store
1067 const transaction = this.db.transaction(eventStores, 'readonly')
1068
1069 await Promise.all(
1070 eventStores.map(
1071 (storeName) =>
1072 new Promise<void>((resolve) => {
1073 const store = transaction.objectStore(storeName)
1074 const request = store.openCursor()
1075
1076 request.onsuccess = (event) => {
1077 const cursor = (event.target as IDBRequest).result
1078 if (cursor) {
1079 const value = cursor.value as TValue<Event | null>
1080 if (value.value) {
1081 // Check if event matches any of the filters
1082 if (matchFilters(filters, value.value)) {
1083 allEvents.push(value.value)
1084 }
1085 }
1086 cursor.continue()
1087 } else {
1088 resolve()
1089 }
1090 }
1091
1092 request.onerror = () => {
1093 resolve() // Continue even if one store fails
1094 }
1095 })
1096 )
1097 )
1098
1099 // Sort by created_at descending (newest first)
1100 allEvents.sort((a, b) => b.created_at - a.created_at)
1101
1102 // Apply limit from filters if specified
1103 const limit = Math.min(...filters.map((f) => f.limit ?? Infinity))
1104 if (limit !== Infinity && limit > 0) {
1105 return allEvents.slice(0, limit)
1106 }
1107
1108 return allEvents
1109 }
1110
1111 /**
1112 * Store an event in the general cache.
1113 * Used by NRC cache relays to cache events fetched from regular relays.
1114 */
1115 async putCachedEvent(event: Event): Promise<void> {
1116 await this.initPromise
1117 if (!this.db) {
1118 return
1119 }
1120
1121 return new Promise((resolve, reject) => {
1122 const transaction = this.db!.transaction(StoreNames.CACHED_EVENTS, 'readwrite')
1123 const store = transaction.objectStore(StoreNames.CACHED_EVENTS)
1124
1125 // Store the event directly (it already has an 'id' field)
1126 const putRequest = store.put(event)
1127 putRequest.onsuccess = () => {
1128 transaction.commit()
1129 resolve()
1130 }
1131
1132 putRequest.onerror = (event) => {
1133 transaction.commit()
1134 reject(event)
1135 }
1136 })
1137 }
1138
1139 /**
1140 * Store multiple events in the general cache.
1141 */
1142 async putCachedEvents(events: Event[]): Promise<void> {
1143 if (events.length === 0) return
1144
1145 await this.initPromise
1146 if (!this.db) {
1147 return
1148 }
1149
1150 return new Promise((resolve) => {
1151 const transaction = this.db!.transaction(StoreNames.CACHED_EVENTS, 'readwrite')
1152 const store = transaction.objectStore(StoreNames.CACHED_EVENTS)
1153
1154 let completed = 0
1155 for (const event of events) {
1156 const putRequest = store.put(event)
1157 putRequest.onsuccess = () => {
1158 completed++
1159 if (completed === events.length) {
1160 transaction.commit()
1161 resolve()
1162 }
1163 }
1164 putRequest.onerror = () => {
1165 completed++
1166 if (completed === events.length) {
1167 transaction.commit()
1168 resolve()
1169 }
1170 }
1171 }
1172 })
1173 }
1174
1175 /**
1176 * Get a cached event by ID.
1177 */
1178 async getCachedEvent(id: string): Promise<Event | null> {
1179 await this.initPromise
1180 if (!this.db) {
1181 return null
1182 }
1183
1184 return new Promise((resolve, reject) => {
1185 const transaction = this.db!.transaction(StoreNames.CACHED_EVENTS, 'readonly')
1186 const store = transaction.objectStore(StoreNames.CACHED_EVENTS)
1187 const request = store.get(id)
1188
1189 request.onsuccess = () => {
1190 transaction.commit()
1191 resolve(request.result ?? null)
1192 }
1193
1194 request.onerror = (event) => {
1195 transaction.commit()
1196 reject(event)
1197 }
1198 })
1199 }
1200
1201 /**
1202 * Query cached events matching the provided filters.
1203 * Returns events sorted by created_at descending.
1204 */
1205 async queryCachedEvents(filters: Filter[]): Promise<Event[]> {
1206 await this.initPromise
1207 if (!this.db) {
1208 return []
1209 }
1210
1211 return new Promise((resolve) => {
1212 const transaction = this.db!.transaction(StoreNames.CACHED_EVENTS, 'readonly')
1213 const store = transaction.objectStore(StoreNames.CACHED_EVENTS)
1214 const request = store.openCursor()
1215 const events: Event[] = []
1216
1217 request.onsuccess = (event) => {
1218 const cursor = (event.target as IDBRequest).result
1219 if (cursor) {
1220 const cachedEvent = cursor.value as Event
1221 if (cachedEvent && matchFilters(filters, cachedEvent)) {
1222 events.push(cachedEvent)
1223 }
1224 cursor.continue()
1225 } else {
1226 transaction.commit()
1227 // Sort by created_at descending
1228 events.sort((a, b) => b.created_at - a.created_at)
1229
1230 // Apply limit from filters if specified
1231 const limit = Math.min(...filters.map((f) => f.limit ?? Infinity))
1232 if (limit !== Infinity && limit > 0) {
1233 resolve(events.slice(0, limit))
1234 } else {
1235 resolve(events)
1236 }
1237 }
1238 }
1239
1240 request.onerror = () => {
1241 transaction.commit()
1242 resolve([])
1243 }
1244 })
1245 }
1246
1247 /**
1248 * Clean up expired cached events.
1249 * Removes events older than the specified number of days.
1250 */
1251 async cleanupExpiredCache(maxAgeDays: number = 7): Promise<number> {
1252 await this.initPromise
1253 if (!this.db) {
1254 return 0
1255 }
1256
1257 const expirationTimestamp = Math.floor(Date.now() / 1000) - maxAgeDays * 24 * 60 * 60
1258
1259 return new Promise((resolve) => {
1260 const transaction = this.db!.transaction(StoreNames.CACHED_EVENTS, 'readwrite')
1261 const store = transaction.objectStore(StoreNames.CACHED_EVENTS)
1262 const index = store.index('created_at')
1263 const range = IDBKeyRange.upperBound(expirationTimestamp)
1264 const request = index.openCursor(range)
1265 let deletedCount = 0
1266
1267 request.onsuccess = (event) => {
1268 const cursor = (event.target as IDBRequest).result
1269 if (cursor) {
1270 cursor.delete()
1271 deletedCount++
1272 cursor.continue()
1273 } else {
1274 transaction.commit()
1275 resolve(deletedCount)
1276 }
1277 }
1278
1279 request.onerror = () => {
1280 transaction.commit()
1281 resolve(deletedCount)
1282 }
1283 })
1284 }
1285
1286 /**
1287 * Get the count of cached events.
1288 */
1289 async getCachedEventCount(): Promise<number> {
1290 await this.initPromise
1291 if (!this.db) {
1292 return 0
1293 }
1294
1295 return new Promise((resolve) => {
1296 const transaction = this.db!.transaction(StoreNames.CACHED_EVENTS, 'readonly')
1297 const store = transaction.objectStore(StoreNames.CACHED_EVENTS)
1298 const request = store.count()
1299
1300 request.onsuccess = () => {
1301 transaction.commit()
1302 resolve(request.result)
1303 }
1304
1305 request.onerror = () => {
1306 transaction.commit()
1307 resolve(0)
1308 }
1309 })
1310 }
1311
1312 private async cleanUp() {
1313 await this.initPromise
1314 if (!this.db) {
1315 return
1316 }
1317
1318 const stores = [
1319 {
1320 name: StoreNames.PROFILE_EVENTS,
1321 expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 30 // 30 day
1322 },
1323 {
1324 name: StoreNames.RELAY_LIST_EVENTS,
1325 expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 30 // 30 day
1326 },
1327 {
1328 name: StoreNames.FOLLOW_LIST_EVENTS,
1329 expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 30 // 30 day
1330 },
1331 {
1332 name: StoreNames.BLOSSOM_SERVER_LIST_EVENTS,
1333 expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 30 // 30 day
1334 },
1335 {
1336 name: StoreNames.RELAY_INFOS,
1337 expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 30 // 30 day
1338 },
1339 {
1340 name: StoreNames.PIN_LIST_EVENTS,
1341 expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 * 30 // 30 days
1342 }
1343 ]
1344 // Also clean up cached events (separate cleanup due to different key structure)
1345 this.cleanupExpiredCache(7) // 7 days for cached events
1346 const transaction = this.db!.transaction(
1347 stores.map((store) => store.name),
1348 'readwrite'
1349 )
1350 await Promise.allSettled(
1351 stores.map(({ name, expirationTimestamp }) => {
1352 if (expirationTimestamp < 0) {
1353 return Promise.resolve()
1354 }
1355 return new Promise<void>((resolve, reject) => {
1356 const store = transaction.objectStore(name)
1357 const request = store.openCursor()
1358 request.onsuccess = (event) => {
1359 const cursor = (event.target as IDBRequest).result
1360 if (cursor) {
1361 const value: TValue = cursor.value
1362 if (value.addedAt < expirationTimestamp) {
1363 cursor.delete()
1364 }
1365 cursor.continue()
1366 } else {
1367 resolve()
1368 }
1369 }
1370
1371 request.onerror = (event) => {
1372 reject(event)
1373 }
1374 })
1375 })
1376 )
1377 }
1378
1379 // ── Relay Stats CRUD ──
1380
1381 async putRelayStats(key: string, value: unknown): Promise<void> {
1382 await this.initPromise
1383 if (!this.db) return
1384 const transaction = this.db.transaction(StoreNames.RELAY_STATS, 'readwrite')
1385 const store = transaction.objectStore(StoreNames.RELAY_STATS)
1386 store.put({ key, value, addedAt: Date.now() })
1387 }
1388
1389 async getAllRelayStats(): Promise<Array<{ key: string; value: unknown }>> {
1390 await this.initPromise
1391 if (!this.db) return []
1392 return new Promise((resolve, reject) => {
1393 const transaction = this.db!.transaction(StoreNames.RELAY_STATS, 'readonly')
1394 const store = transaction.objectStore(StoreNames.RELAY_STATS)
1395 const request = store.getAll()
1396 request.onsuccess = () => resolve(request.result ?? [])
1397 request.onerror = () => reject(request.error)
1398 })
1399 }
1400
1401 async deleteRelayStats(key: string): Promise<void> {
1402 await this.initPromise
1403 if (!this.db) return
1404 const transaction = this.db.transaction(StoreNames.RELAY_STATS, 'readwrite')
1405 const store = transaction.objectStore(StoreNames.RELAY_STATS)
1406 store.delete(key)
1407 }
1408
1409 // ── Managed Relays CRUD ──
1410
1411 async putManagedRelay(key: string, value: unknown): Promise<void> {
1412 await this.initPromise
1413 if (!this.db) return
1414 const transaction = this.db.transaction(StoreNames.MANAGED_RELAYS, 'readwrite')
1415 const store = transaction.objectStore(StoreNames.MANAGED_RELAYS)
1416 store.put({ key, value, addedAt: Date.now() })
1417 }
1418
1419 async getAllManagedRelays(): Promise<Array<{ key: string; value: unknown }>> {
1420 await this.initPromise
1421 if (!this.db) return []
1422 return new Promise((resolve, reject) => {
1423 const transaction = this.db!.transaction(StoreNames.MANAGED_RELAYS, 'readonly')
1424 const store = transaction.objectStore(StoreNames.MANAGED_RELAYS)
1425 const request = store.getAll()
1426 request.onsuccess = () => resolve(request.result ?? [])
1427 request.onerror = () => reject(request.error)
1428 })
1429 }
1430
1431 async deleteManagedRelay(key: string): Promise<void> {
1432 await this.initPromise
1433 if (!this.db) return
1434 const transaction = this.db.transaction(StoreNames.MANAGED_RELAYS, 'readwrite')
1435 const store = transaction.objectStore(StoreNames.MANAGED_RELAYS)
1436 store.delete(key)
1437 }
1438 }
1439
1440 const instance = IndexedDbService.getInstance()
1441 export default instance
1442