You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
546 lines
17 KiB
546 lines
17 KiB
import { ExtendedKind } from '@/constants' |
|
import { tagNameEquals } from '@/lib/tag' |
|
import { TRelayInfo } from '@/types' |
|
import { Event, kinds } from 'nostr-tools' |
|
|
|
type TValue<T = any> = { |
|
key: string |
|
value: T | null |
|
addedAt: number |
|
} |
|
|
|
const StoreNames = { |
|
PROFILE_EVENTS: 'profileEvents', |
|
RELAY_LIST_EVENTS: 'relayListEvents', |
|
FOLLOW_LIST_EVENTS: 'followListEvents', |
|
MUTE_LIST_EVENTS: 'muteListEvents', |
|
BOOKMARK_LIST_EVENTS: 'bookmarkListEvents', |
|
BLOSSOM_SERVER_LIST_EVENTS: 'blossomServerListEvents', |
|
INTEREST_LIST_EVENTS: 'interestListEvents', |
|
MUTE_DECRYPTED_TAGS: 'muteDecryptedTags', |
|
USER_EMOJI_LIST_EVENTS: 'userEmojiListEvents', |
|
EMOJI_SET_EVENTS: 'emojiSetEvents', |
|
FAVORITE_RELAYS: 'favoriteRelays', |
|
BLOCKED_RELAYS_EVENTS: 'blockedRelaysEvents', |
|
RELAY_SETS: 'relaySets', |
|
FOLLOWING_FAVORITE_RELAYS: 'followingFavoriteRelays', |
|
RELAY_INFOS: 'relayInfos', |
|
RELAY_INFO_EVENTS: 'relayInfoEvents' // deprecated |
|
} |
|
|
|
class IndexedDbService { |
|
static instance: IndexedDbService |
|
static getInstance(): IndexedDbService { |
|
if (!IndexedDbService.instance) { |
|
IndexedDbService.instance = new IndexedDbService() |
|
IndexedDbService.instance.init() |
|
} |
|
return IndexedDbService.instance |
|
} |
|
|
|
private db: IDBDatabase | null = null |
|
private initPromise: Promise<void> | null = null |
|
|
|
init(): Promise<void> { |
|
if (!this.initPromise) { |
|
this.initPromise = new Promise((resolve, reject) => { |
|
const request = window.indexedDB.open('jumble', 10) |
|
|
|
request.onerror = (event) => { |
|
reject(event) |
|
} |
|
|
|
request.onsuccess = () => { |
|
this.db = request.result |
|
resolve() |
|
} |
|
|
|
request.onupgradeneeded = () => { |
|
const db = request.result |
|
if (!db.objectStoreNames.contains(StoreNames.PROFILE_EVENTS)) { |
|
db.createObjectStore(StoreNames.PROFILE_EVENTS, { keyPath: 'key' }) |
|
} |
|
if (!db.objectStoreNames.contains(StoreNames.RELAY_LIST_EVENTS)) { |
|
db.createObjectStore(StoreNames.RELAY_LIST_EVENTS, { keyPath: 'key' }) |
|
} |
|
if (!db.objectStoreNames.contains(StoreNames.FOLLOW_LIST_EVENTS)) { |
|
db.createObjectStore(StoreNames.FOLLOW_LIST_EVENTS, { keyPath: 'key' }) |
|
} |
|
if (!db.objectStoreNames.contains(StoreNames.MUTE_LIST_EVENTS)) { |
|
db.createObjectStore(StoreNames.MUTE_LIST_EVENTS, { keyPath: 'key' }) |
|
} |
|
if (!db.objectStoreNames.contains(StoreNames.BOOKMARK_LIST_EVENTS)) { |
|
db.createObjectStore(StoreNames.BOOKMARK_LIST_EVENTS, { keyPath: 'key' }) |
|
} |
|
if (!db.objectStoreNames.contains(StoreNames.INTEREST_LIST_EVENTS)) { |
|
db.createObjectStore(StoreNames.INTEREST_LIST_EVENTS, { keyPath: 'key' }) |
|
} |
|
if (!db.objectStoreNames.contains(StoreNames.MUTE_DECRYPTED_TAGS)) { |
|
db.createObjectStore(StoreNames.MUTE_DECRYPTED_TAGS, { keyPath: 'key' }) |
|
} |
|
if (!db.objectStoreNames.contains(StoreNames.FAVORITE_RELAYS)) { |
|
db.createObjectStore(StoreNames.FAVORITE_RELAYS, { keyPath: 'key' }) |
|
} |
|
if (!db.objectStoreNames.contains(StoreNames.BLOCKED_RELAYS_EVENTS)) { |
|
db.createObjectStore(StoreNames.BLOCKED_RELAYS_EVENTS, { keyPath: 'key' }) |
|
} |
|
if (!db.objectStoreNames.contains(StoreNames.RELAY_SETS)) { |
|
db.createObjectStore(StoreNames.RELAY_SETS, { keyPath: 'key' }) |
|
} |
|
if (!db.objectStoreNames.contains(StoreNames.FOLLOWING_FAVORITE_RELAYS)) { |
|
db.createObjectStore(StoreNames.FOLLOWING_FAVORITE_RELAYS, { keyPath: 'key' }) |
|
} |
|
if (!db.objectStoreNames.contains(StoreNames.BLOSSOM_SERVER_LIST_EVENTS)) { |
|
db.createObjectStore(StoreNames.BLOSSOM_SERVER_LIST_EVENTS, { keyPath: 'key' }) |
|
} |
|
if (!db.objectStoreNames.contains(StoreNames.USER_EMOJI_LIST_EVENTS)) { |
|
db.createObjectStore(StoreNames.USER_EMOJI_LIST_EVENTS, { keyPath: 'key' }) |
|
} |
|
if (!db.objectStoreNames.contains(StoreNames.EMOJI_SET_EVENTS)) { |
|
db.createObjectStore(StoreNames.EMOJI_SET_EVENTS, { keyPath: 'key' }) |
|
} |
|
if (!db.objectStoreNames.contains(StoreNames.RELAY_INFOS)) { |
|
db.createObjectStore(StoreNames.RELAY_INFOS, { keyPath: 'key' }) |
|
} |
|
if (db.objectStoreNames.contains(StoreNames.RELAY_INFO_EVENTS)) { |
|
db.deleteObjectStore(StoreNames.RELAY_INFO_EVENTS) |
|
} |
|
this.db = db |
|
} |
|
}) |
|
setTimeout(() => this.cleanUp(), 1000 * 60) // 1 minute |
|
} |
|
return this.initPromise |
|
} |
|
|
|
async putNullReplaceableEvent(pubkey: string, kind: number, d?: string) { |
|
const storeName = this.getStoreNameByKind(kind) |
|
if (!storeName) { |
|
return Promise.reject('store name not found') |
|
} |
|
await this.initPromise |
|
return new Promise((resolve, reject) => { |
|
if (!this.db) { |
|
return reject('database not initialized') |
|
} |
|
const transaction = this.db.transaction(storeName, 'readwrite') |
|
const store = transaction.objectStore(storeName) |
|
|
|
const key = this.getReplaceableEventKey(pubkey, d) |
|
const getRequest = store.get(key) |
|
getRequest.onsuccess = () => { |
|
const oldValue = getRequest.result as TValue<Event> | undefined |
|
if (oldValue) { |
|
transaction.commit() |
|
return resolve(oldValue.value) |
|
} |
|
const putRequest = store.put(this.formatValue(key, null)) |
|
putRequest.onsuccess = () => { |
|
transaction.commit() |
|
resolve(null) |
|
} |
|
|
|
putRequest.onerror = (event) => { |
|
transaction.commit() |
|
reject(event) |
|
} |
|
} |
|
|
|
getRequest.onerror = (event) => { |
|
transaction.commit() |
|
reject(event) |
|
} |
|
}) |
|
} |
|
|
|
async putReplaceableEvent(event: Event): Promise<Event> { |
|
const storeName = this.getStoreNameByKind(event.kind) |
|
if (!storeName) { |
|
return Promise.reject('store name not found') |
|
} |
|
await this.initPromise |
|
return new Promise((resolve, reject) => { |
|
if (!this.db) { |
|
return reject('database not initialized') |
|
} |
|
const transaction = this.db.transaction(storeName, 'readwrite') |
|
const store = transaction.objectStore(storeName) |
|
|
|
const key = this.getReplaceableEventKeyFromEvent(event) |
|
const getRequest = store.get(key) |
|
getRequest.onsuccess = () => { |
|
const oldValue = getRequest.result as TValue<Event> | undefined |
|
if (oldValue?.value && oldValue.value.created_at >= event.created_at) { |
|
transaction.commit() |
|
return resolve(oldValue.value) |
|
} |
|
const putRequest = store.put(this.formatValue(key, event)) |
|
putRequest.onsuccess = () => { |
|
transaction.commit() |
|
resolve(event) |
|
} |
|
|
|
putRequest.onerror = (event) => { |
|
transaction.commit() |
|
reject(event) |
|
} |
|
} |
|
|
|
getRequest.onerror = (event) => { |
|
transaction.commit() |
|
reject(event) |
|
} |
|
}) |
|
} |
|
|
|
async getReplaceableEvent( |
|
pubkey: string, |
|
kind: number, |
|
d?: string |
|
): Promise<Event | undefined | null> { |
|
const storeName = this.getStoreNameByKind(kind) |
|
if (!storeName) { |
|
return Promise.reject('store name not found') |
|
} |
|
await this.initPromise |
|
return new Promise((resolve, reject) => { |
|
if (!this.db) { |
|
return reject('database not initialized') |
|
} |
|
const transaction = this.db.transaction(storeName, 'readonly') |
|
const store = transaction.objectStore(storeName) |
|
const key = this.getReplaceableEventKey(pubkey, d) |
|
const request = store.get(key) |
|
|
|
request.onsuccess = () => { |
|
transaction.commit() |
|
resolve((request.result as TValue<Event>)?.value) |
|
} |
|
|
|
request.onerror = (event) => { |
|
transaction.commit() |
|
reject(event) |
|
} |
|
}) |
|
} |
|
|
|
async getManyReplaceableEvents( |
|
pubkeys: readonly string[], |
|
kind: number |
|
): Promise<(Event | undefined | null)[]> { |
|
const storeName = this.getStoreNameByKind(kind) |
|
if (!storeName) { |
|
return Promise.reject('store name not found') |
|
} |
|
await this.initPromise |
|
return new Promise((resolve, reject) => { |
|
if (!this.db) { |
|
return reject('database not initialized') |
|
} |
|
const transaction = this.db.transaction(storeName, 'readonly') |
|
const store = transaction.objectStore(storeName) |
|
const events: (Event | null)[] = new Array(pubkeys.length).fill(undefined) |
|
let count = 0 |
|
pubkeys.forEach((pubkey, i) => { |
|
const request = store.get(this.getReplaceableEventKey(pubkey)) |
|
|
|
request.onsuccess = () => { |
|
const event = (request.result as TValue<Event | null>)?.value |
|
if (event || event === null) { |
|
events[i] = event |
|
} |
|
|
|
if (++count === pubkeys.length) { |
|
transaction.commit() |
|
resolve(events) |
|
} |
|
} |
|
|
|
request.onerror = () => { |
|
if (++count === pubkeys.length) { |
|
transaction.commit() |
|
resolve(events) |
|
} |
|
} |
|
}) |
|
}) |
|
} |
|
|
|
async getMuteDecryptedTags(id: string): Promise<string[][] | null> { |
|
await this.initPromise |
|
return new Promise((resolve, reject) => { |
|
if (!this.db) { |
|
return reject('database not initialized') |
|
} |
|
const transaction = this.db.transaction(StoreNames.MUTE_DECRYPTED_TAGS, 'readonly') |
|
const store = transaction.objectStore(StoreNames.MUTE_DECRYPTED_TAGS) |
|
const request = store.get(id) |
|
|
|
request.onsuccess = () => { |
|
transaction.commit() |
|
resolve((request.result as TValue<string[][]>)?.value) |
|
} |
|
|
|
request.onerror = (event) => { |
|
transaction.commit() |
|
reject(event) |
|
} |
|
}) |
|
} |
|
|
|
async putMuteDecryptedTags(id: string, tags: string[][]): Promise<void> { |
|
await this.initPromise |
|
return new Promise((resolve, reject) => { |
|
if (!this.db) { |
|
return reject('database not initialized') |
|
} |
|
const transaction = this.db.transaction(StoreNames.MUTE_DECRYPTED_TAGS, 'readwrite') |
|
const store = transaction.objectStore(StoreNames.MUTE_DECRYPTED_TAGS) |
|
|
|
const putRequest = store.put(this.formatValue(id, tags)) |
|
putRequest.onsuccess = () => { |
|
transaction.commit() |
|
resolve() |
|
} |
|
|
|
putRequest.onerror = (event) => { |
|
transaction.commit() |
|
reject(event) |
|
} |
|
}) |
|
} |
|
|
|
async iterateProfileEvents(callback: (event: Event) => Promise<void>): Promise<void> { |
|
await this.initPromise |
|
if (!this.db) { |
|
return |
|
} |
|
|
|
return new Promise<void>((resolve, reject) => { |
|
const transaction = this.db!.transaction(StoreNames.PROFILE_EVENTS, 'readwrite') |
|
const store = transaction.objectStore(StoreNames.PROFILE_EVENTS) |
|
const request = store.openCursor() |
|
request.onsuccess = (event) => { |
|
const cursor = (event.target as IDBRequest).result |
|
if (cursor) { |
|
const value = (cursor.value as TValue<Event>).value |
|
if (value) { |
|
callback(value) |
|
} |
|
cursor.continue() |
|
} else { |
|
transaction.commit() |
|
resolve() |
|
} |
|
} |
|
|
|
request.onerror = (event) => { |
|
transaction.commit() |
|
reject(event) |
|
} |
|
}) |
|
} |
|
|
|
async putFollowingFavoriteRelays(pubkey: string, relays: [string, string[]][]): Promise<void> { |
|
await this.initPromise |
|
return new Promise((resolve, reject) => { |
|
if (!this.db) { |
|
return reject('database not initialized') |
|
} |
|
const transaction = this.db.transaction(StoreNames.FOLLOWING_FAVORITE_RELAYS, 'readwrite') |
|
const store = transaction.objectStore(StoreNames.FOLLOWING_FAVORITE_RELAYS) |
|
|
|
const putRequest = store.put(this.formatValue(pubkey, relays)) |
|
putRequest.onsuccess = () => { |
|
transaction.commit() |
|
resolve() |
|
} |
|
|
|
putRequest.onerror = (event) => { |
|
transaction.commit() |
|
reject(event) |
|
} |
|
}) |
|
} |
|
|
|
async getFollowingFavoriteRelays(pubkey: string): Promise<[string, string[]][] | null> { |
|
await this.initPromise |
|
return new Promise((resolve, reject) => { |
|
if (!this.db) { |
|
return reject('database not initialized') |
|
} |
|
const transaction = this.db.transaction(StoreNames.FOLLOWING_FAVORITE_RELAYS, 'readonly') |
|
const store = transaction.objectStore(StoreNames.FOLLOWING_FAVORITE_RELAYS) |
|
const request = store.get(pubkey) |
|
|
|
request.onsuccess = () => { |
|
transaction.commit() |
|
resolve((request.result as TValue<[string, string[]][]>)?.value) |
|
} |
|
|
|
request.onerror = (event) => { |
|
transaction.commit() |
|
reject(event) |
|
} |
|
}) |
|
} |
|
|
|
async putRelayInfo(relayInfo: TRelayInfo): Promise<void> { |
|
await this.initPromise |
|
return new Promise((resolve, reject) => { |
|
if (!this.db) { |
|
return reject('database not initialized') |
|
} |
|
const transaction = this.db.transaction(StoreNames.RELAY_INFOS, 'readwrite') |
|
const store = transaction.objectStore(StoreNames.RELAY_INFOS) |
|
|
|
const putRequest = store.put(this.formatValue(relayInfo.url, relayInfo)) |
|
putRequest.onsuccess = () => { |
|
transaction.commit() |
|
resolve() |
|
} |
|
|
|
putRequest.onerror = (event) => { |
|
transaction.commit() |
|
reject(event) |
|
} |
|
}) |
|
} |
|
|
|
async getRelayInfo(url: string): Promise<TRelayInfo | null> { |
|
await this.initPromise |
|
return new Promise((resolve, reject) => { |
|
if (!this.db) { |
|
return reject('database not initialized') |
|
} |
|
const transaction = this.db.transaction(StoreNames.RELAY_INFOS, 'readonly') |
|
const store = transaction.objectStore(StoreNames.RELAY_INFOS) |
|
const request = store.get(url) |
|
|
|
request.onsuccess = () => { |
|
transaction.commit() |
|
resolve((request.result as TValue<TRelayInfo>)?.value) |
|
} |
|
|
|
request.onerror = (event) => { |
|
transaction.commit() |
|
reject(event) |
|
} |
|
}) |
|
} |
|
|
|
private getReplaceableEventKeyFromEvent(event: Event): string { |
|
if ( |
|
[kinds.Metadata, kinds.Contacts].includes(event.kind) || |
|
(event.kind >= 10000 && event.kind < 20000) |
|
) { |
|
return this.getReplaceableEventKey(event.pubkey) |
|
} |
|
|
|
const [, d] = event.tags.find(tagNameEquals('d')) ?? [] |
|
return this.getReplaceableEventKey(event.pubkey, d) |
|
} |
|
|
|
private getReplaceableEventKey(pubkey: string, d?: string): string { |
|
return d === undefined ? pubkey : `${pubkey}:${d}` |
|
} |
|
|
|
private getStoreNameByKind(kind: number): string | undefined { |
|
switch (kind) { |
|
case kinds.Metadata: |
|
return StoreNames.PROFILE_EVENTS |
|
case kinds.RelayList: |
|
return StoreNames.RELAY_LIST_EVENTS |
|
case kinds.Contacts: |
|
return StoreNames.FOLLOW_LIST_EVENTS |
|
case kinds.Mutelist: |
|
return StoreNames.MUTE_LIST_EVENTS |
|
case kinds.BookmarkList: |
|
return StoreNames.BOOKMARK_LIST_EVENTS |
|
case 10015: // Interest list |
|
return StoreNames.INTEREST_LIST_EVENTS |
|
case ExtendedKind.BLOSSOM_SERVER_LIST: |
|
return StoreNames.BLOSSOM_SERVER_LIST_EVENTS |
|
case kinds.Relaysets: |
|
return StoreNames.RELAY_SETS |
|
case ExtendedKind.FAVORITE_RELAYS: |
|
return StoreNames.FAVORITE_RELAYS |
|
case ExtendedKind.BLOCKED_RELAYS: |
|
return StoreNames.BLOCKED_RELAYS_EVENTS |
|
case kinds.BookmarkList: |
|
return StoreNames.BOOKMARK_LIST_EVENTS |
|
case kinds.UserEmojiList: |
|
return StoreNames.USER_EMOJI_LIST_EVENTS |
|
case kinds.Emojisets: |
|
return StoreNames.EMOJI_SET_EVENTS |
|
default: |
|
return undefined |
|
} |
|
} |
|
|
|
private formatValue<T>(key: string, value: T): TValue<T> { |
|
return { |
|
key, |
|
value, |
|
addedAt: Date.now() |
|
} |
|
} |
|
|
|
private async cleanUp() { |
|
await this.initPromise |
|
if (!this.db) { |
|
return |
|
} |
|
|
|
const stores = [ |
|
{ name: StoreNames.PROFILE_EVENTS, expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 }, // 1 day |
|
{ name: StoreNames.RELAY_LIST_EVENTS, expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 }, // 1 day |
|
{ |
|
name: StoreNames.FOLLOW_LIST_EVENTS, |
|
expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 // 1 day |
|
}, |
|
{ |
|
name: StoreNames.BLOSSOM_SERVER_LIST_EVENTS, |
|
expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 // 1 days |
|
}, |
|
{ |
|
name: StoreNames.RELAY_INFOS, |
|
expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 // 1 days |
|
} |
|
] |
|
const transaction = this.db!.transaction( |
|
stores.map((store) => store.name), |
|
'readwrite' |
|
) |
|
await Promise.allSettled( |
|
stores.map(({ name, expirationTimestamp }) => { |
|
if (expirationTimestamp < 0) { |
|
return Promise.resolve() |
|
} |
|
return new Promise<void>((resolve, reject) => { |
|
const store = transaction.objectStore(name) |
|
const request = store.openCursor() |
|
request.onsuccess = (event) => { |
|
const cursor = (event.target as IDBRequest).result |
|
if (cursor) { |
|
const value: TValue = cursor.value |
|
if (value.addedAt < expirationTimestamp) { |
|
cursor.delete() |
|
} |
|
cursor.continue() |
|
} else { |
|
resolve() |
|
} |
|
} |
|
|
|
request.onerror = (event) => { |
|
reject(event) |
|
} |
|
}) |
|
}) |
|
) |
|
} |
|
} |
|
|
|
const instance = IndexedDbService.getInstance() |
|
export default instance
|
|
|