perf: improve loading speed (#116)
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
VerifiedEvent
|
||||
} from 'nostr-tools'
|
||||
import { AbstractRelay } from 'nostr-tools/abstract-relay'
|
||||
import indexedDb from './indexed-db.service'
|
||||
|
||||
type TTimelineRef = [string, number]
|
||||
|
||||
@@ -46,24 +47,29 @@ class ClientService extends EventTarget {
|
||||
this.eventBatchLoadFn.bind(this),
|
||||
{ cache: false }
|
||||
)
|
||||
private profileEventCache = new LRUCache<string, Promise<NEvent | undefined>>({ max: 10000 })
|
||||
private profileEventDataloader = new DataLoader<string, NEvent | undefined>(
|
||||
(ids) => Promise.all(ids.map((id) => this._fetchProfileEvent(id))),
|
||||
{ cacheMap: this.profileEventCache, maxBatchSize: 50 }
|
||||
{
|
||||
cache: false,
|
||||
maxBatchSize: 50
|
||||
}
|
||||
)
|
||||
private fetchProfileEventFromDefaultRelaysDataloader = new DataLoader<string, NEvent | undefined>(
|
||||
this.profileEventBatchLoadFn.bind(this),
|
||||
{ cache: false, maxBatchSize: 50 }
|
||||
{
|
||||
cacheMap: new LRUCache<string, Promise<NEvent | undefined>>({ max: 1000 }),
|
||||
maxBatchSize: 50
|
||||
}
|
||||
)
|
||||
private relayListEventDataLoader = new DataLoader<string, NEvent | undefined>(
|
||||
this.relayListEventBatchLoadFn.bind(this),
|
||||
{
|
||||
cacheMap: new LRUCache<string, Promise<NEvent | undefined>>({ max: 10000 }),
|
||||
cacheMap: new LRUCache<string, Promise<NEvent | undefined>>({ max: 1000 }),
|
||||
maxBatchSize: 50
|
||||
}
|
||||
)
|
||||
private followListCache = new LRUCache<string, Promise<NEvent | undefined>>({
|
||||
max: 10000,
|
||||
max: 2000,
|
||||
fetchMethod: this._fetchFollowListEvent.bind(this)
|
||||
})
|
||||
|
||||
@@ -448,12 +454,6 @@ class ClientService extends EventTarget {
|
||||
}
|
||||
|
||||
async fetchProfileEvent(id: string): Promise<NEvent | undefined> {
|
||||
const pubkey = userIdToPubkey(id)
|
||||
const cache = await this.profileEventCache.get(pubkey)
|
||||
if (cache) {
|
||||
return cache
|
||||
}
|
||||
|
||||
return await this.profileEventDataloader.load(id)
|
||||
}
|
||||
|
||||
@@ -504,8 +504,12 @@ class ClientService extends EventTarget {
|
||||
return getRelayListFromRelayListEvent(event)
|
||||
}
|
||||
|
||||
async fetchFollowListEvent(pubkey: string) {
|
||||
return this.followListCache.fetch(pubkey)
|
||||
async fetchFollowListEvent(pubkey: string, storeToIndexedDb = false) {
|
||||
const event = await this.followListCache.fetch(pubkey)
|
||||
if (storeToIndexedDb && event) {
|
||||
await indexedDb.putReplaceableEvent(event)
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
async fetchFollowings(pubkey: string) {
|
||||
@@ -576,9 +580,11 @@ class ClientService extends EventTarget {
|
||||
)
|
||||
}
|
||||
|
||||
async initUserIndexFromFollowings(pubkey: string) {
|
||||
async initUserIndexFromFollowings(pubkey: string, signal: AbortSignal) {
|
||||
const followings = await this.fetchFollowings(pubkey)
|
||||
for (let i = 0; i * 50 < followings.length; i++) {
|
||||
if (signal.aborted) return
|
||||
|
||||
await this.profileEventDataloader.loadMany(followings.slice(i * 50, (i + 1) * 50))
|
||||
await new Promise((resolve) => setTimeout(resolve, 30000))
|
||||
}
|
||||
@@ -686,10 +692,16 @@ class ClientService extends EventTarget {
|
||||
if (!pubkey) {
|
||||
throw new Error('Invalid id')
|
||||
}
|
||||
const localProfile = await indexedDb.getReplaceableEvent(pubkey, kinds.Metadata)
|
||||
if (localProfile) {
|
||||
this.addUsernameToIndex(localProfile)
|
||||
return localProfile
|
||||
}
|
||||
const profileFromDefaultRelays =
|
||||
await this.fetchProfileEventFromDefaultRelaysDataloader.load(pubkey)
|
||||
if (profileFromDefaultRelays) {
|
||||
this.addUsernameToIndex(profileFromDefaultRelays)
|
||||
await indexedDb.putReplaceableEvent(profileFromDefaultRelays)
|
||||
return profileFromDefaultRelays
|
||||
}
|
||||
|
||||
@@ -707,7 +719,10 @@ class ClientService extends EventTarget {
|
||||
}
|
||||
|
||||
if (profileEvent) {
|
||||
await this.addUsernameToIndex(profileEvent)
|
||||
await Promise.allSettled([
|
||||
this.addUsernameToIndex(profileEvent),
|
||||
indexedDb.putReplaceableEvent(profileEvent)
|
||||
])
|
||||
}
|
||||
|
||||
return profileEvent
|
||||
@@ -778,28 +793,50 @@ class ClientService extends EventTarget {
|
||||
eventsMap.set(pubkey, event)
|
||||
}
|
||||
}
|
||||
|
||||
return pubkeys.map((pubkey) => {
|
||||
const profileEvents = pubkeys.map((pubkey) => {
|
||||
return eventsMap.get(pubkey)
|
||||
})
|
||||
|
||||
await Promise.allSettled(
|
||||
profileEvents.map(
|
||||
(profileEvent) => profileEvent && indexedDb.putReplaceableEvent(profileEvent)
|
||||
)
|
||||
)
|
||||
return profileEvents
|
||||
}
|
||||
|
||||
private async relayListEventBatchLoadFn(pubkeys: readonly string[]) {
|
||||
const events = await this.query(this.defaultRelayUrls, {
|
||||
authors: pubkeys as string[],
|
||||
kinds: [kinds.RelayList],
|
||||
limit: pubkeys.length
|
||||
})
|
||||
const eventsMap = new Map<string, NEvent>()
|
||||
for (const event of events) {
|
||||
const pubkey = event.pubkey
|
||||
const existing = eventsMap.get(pubkey)
|
||||
if (!existing || existing.created_at < event.created_at) {
|
||||
eventsMap.set(pubkey, event)
|
||||
const relayEvents = await Promise.all(
|
||||
pubkeys.map((pubkey) => indexedDb.getReplaceableEvent(pubkey, kinds.RelayList))
|
||||
)
|
||||
const nonExistingPubkeys = pubkeys.filter((_, i) => !relayEvents[i])
|
||||
if (nonExistingPubkeys.length) {
|
||||
const events = await this.query(this.defaultRelayUrls, {
|
||||
authors: pubkeys as string[],
|
||||
kinds: [kinds.RelayList],
|
||||
limit: pubkeys.length
|
||||
})
|
||||
const eventsMap = new Map<string, NEvent>()
|
||||
for (const event of events) {
|
||||
const pubkey = event.pubkey
|
||||
const existing = eventsMap.get(pubkey)
|
||||
if (!existing || existing.created_at < event.created_at) {
|
||||
eventsMap.set(pubkey, event)
|
||||
}
|
||||
}
|
||||
await Promise.allSettled(
|
||||
Array.from(eventsMap.values()).map((evt) => indexedDb.putReplaceableEvent(evt))
|
||||
)
|
||||
nonExistingPubkeys.forEach((pubkey) => {
|
||||
const event = eventsMap.get(pubkey)
|
||||
if (event) {
|
||||
const index = pubkeys.indexOf(pubkey)
|
||||
relayEvents[index] = event
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return pubkeys.map((pubkey) => eventsMap.get(pubkey))
|
||||
return relayEvents
|
||||
}
|
||||
|
||||
private async _fetchFollowListEvent(pubkey: string) {
|
||||
|
||||
271
src/services/indexed-db.service.ts
Normal file
271
src/services/indexed-db.service.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { tagNameEquals } from '@/lib/tag'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
|
||||
type TValue<T = any> = {
|
||||
key: string
|
||||
value: T
|
||||
addedAt: number
|
||||
}
|
||||
|
||||
const StoreNames = {
|
||||
PROFILE_EVENTS: 'profileEvents',
|
||||
RELAY_LIST_EVENTS: 'relayListEvents',
|
||||
FOLLOW_LIST_EVENTS: 'followListEvents',
|
||||
MUTE_LIST_EVENTS: 'muteListEvents',
|
||||
MUTE_DECRYPTED_TAGS: 'muteDecryptedTags',
|
||||
RELAY_INFO_EVENTS: 'relayInfoEvents'
|
||||
}
|
||||
|
||||
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', 2)
|
||||
|
||||
request.onerror = (event) => {
|
||||
reject(event)
|
||||
}
|
||||
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result
|
||||
resolve()
|
||||
}
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
this.db = request.result
|
||||
if (!this.db.objectStoreNames.contains(StoreNames.PROFILE_EVENTS)) {
|
||||
this.db.createObjectStore(StoreNames.PROFILE_EVENTS, { keyPath: 'key' })
|
||||
}
|
||||
if (!this.db.objectStoreNames.contains(StoreNames.RELAY_LIST_EVENTS)) {
|
||||
this.db.createObjectStore(StoreNames.RELAY_LIST_EVENTS, { keyPath: 'key' })
|
||||
}
|
||||
if (!this.db.objectStoreNames.contains(StoreNames.FOLLOW_LIST_EVENTS)) {
|
||||
this.db.createObjectStore(StoreNames.FOLLOW_LIST_EVENTS, { keyPath: 'key' })
|
||||
}
|
||||
if (!this.db.objectStoreNames.contains(StoreNames.MUTE_LIST_EVENTS)) {
|
||||
this.db.createObjectStore(StoreNames.MUTE_LIST_EVENTS, { keyPath: 'key' })
|
||||
}
|
||||
if (!this.db.objectStoreNames.contains(StoreNames.MUTE_DECRYPTED_TAGS)) {
|
||||
this.db.createObjectStore(StoreNames.MUTE_DECRYPTED_TAGS, { keyPath: 'key' })
|
||||
}
|
||||
if (!this.db.objectStoreNames.contains(StoreNames.RELAY_INFO_EVENTS)) {
|
||||
this.db.createObjectStore(StoreNames.RELAY_INFO_EVENTS, { keyPath: 'key' })
|
||||
}
|
||||
}
|
||||
})
|
||||
setTimeout(() => this.cleanUp(), 1000 * 60) // 1 minute
|
||||
}
|
||||
return this.initPromise
|
||||
}
|
||||
|
||||
async putReplaceableEvent(event: Event): Promise<boolean> {
|
||||
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 getRequest = store.get(event.pubkey)
|
||||
getRequest.onsuccess = () => {
|
||||
const oldValue = getRequest.result as TValue<Event> | undefined
|
||||
if (oldValue && oldValue.value.created_at >= event.created_at) {
|
||||
return resolve(false)
|
||||
}
|
||||
const putRequest = store.put(this.formatValue(event.pubkey, event))
|
||||
putRequest.onsuccess = () => {
|
||||
resolve(true)
|
||||
}
|
||||
|
||||
putRequest.onerror = (event) => {
|
||||
reject(event)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async getReplaceableEvent(pubkey: string, kind: number): Promise<Event | undefined> {
|
||||
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 request = store.get(pubkey)
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve((request.result as TValue<Event>)?.value)
|
||||
}
|
||||
|
||||
request.onerror = (event) => {
|
||||
reject(event)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async getMuteDecryptedTags(id: string): Promise<string[][]> {
|
||||
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 = () => {
|
||||
resolve((request.result as TValue<string[][]>)?.value)
|
||||
}
|
||||
|
||||
request.onerror = (event) => {
|
||||
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 = () => {
|
||||
resolve()
|
||||
}
|
||||
|
||||
putRequest.onerror = (event) => {
|
||||
reject(event)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async getAllRelayInfoEvents(): Promise<Event[]> {
|
||||
await this.initPromise
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.db) {
|
||||
return reject('database not initialized')
|
||||
}
|
||||
const transaction = this.db.transaction(StoreNames.RELAY_INFO_EVENTS, 'readonly')
|
||||
const store = transaction.objectStore(StoreNames.RELAY_INFO_EVENTS)
|
||||
const request = store.getAll()
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve((request.result as TValue<Event>[])?.map((item) => item.value))
|
||||
}
|
||||
|
||||
request.onerror = (event) => {
|
||||
reject(event)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async putRelayInfoEvent(event: Event): Promise<void> {
|
||||
await this.initPromise
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.db) {
|
||||
return reject('database not initialized')
|
||||
}
|
||||
const dValue = event.tags.find(tagNameEquals('d'))?.[1]
|
||||
if (!dValue) {
|
||||
return resolve()
|
||||
}
|
||||
const transaction = this.db.transaction(StoreNames.RELAY_INFO_EVENTS, 'readwrite')
|
||||
const store = transaction.objectStore(StoreNames.RELAY_INFO_EVENTS)
|
||||
|
||||
const putRequest = store.put(this.formatValue(dValue, event))
|
||||
putRequest.onsuccess = () => {
|
||||
resolve()
|
||||
}
|
||||
|
||||
putRequest.onerror = (event) => {
|
||||
reject(event)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
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 expirationTimestamp = Date.now() - 1000 * 60 * 60 * 24 // 1 day
|
||||
const transaction = this.db!.transaction(Object.values(StoreNames), 'readwrite')
|
||||
await Promise.allSettled(
|
||||
Object.values(StoreNames).map((storeName) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const store = transaction.objectStore(storeName)
|
||||
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
|
||||
215
src/services/local-storage.service.ts
Normal file
215
src/services/local-storage.service.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { StorageKey } from '@/constants'
|
||||
import { isSameAccount } from '@/lib/account'
|
||||
import { randomString } from '@/lib/random'
|
||||
import {
|
||||
TAccount,
|
||||
TAccountPointer,
|
||||
TFeedType,
|
||||
TNoteListMode,
|
||||
TRelaySet,
|
||||
TThemeSetting
|
||||
} from '@/types'
|
||||
|
||||
const DEFAULT_RELAY_SETS: TRelaySet[] = [
|
||||
{
|
||||
id: randomString(),
|
||||
name: 'Global',
|
||||
relayUrls: ['wss://relay.damus.io/', 'wss://nos.lol/']
|
||||
},
|
||||
{
|
||||
id: randomString(),
|
||||
name: 'Safer Global',
|
||||
relayUrls: ['wss://nostr.wine/', 'wss://pyramid.fiatjaf.com/']
|
||||
},
|
||||
{
|
||||
id: randomString(),
|
||||
name: 'Short Notes',
|
||||
relayUrls: ['wss://140.f7z.io/']
|
||||
},
|
||||
{
|
||||
id: randomString(),
|
||||
name: 'News',
|
||||
relayUrls: ['wss://news.utxo.one/']
|
||||
},
|
||||
{
|
||||
id: randomString(),
|
||||
name: 'Algo',
|
||||
relayUrls: ['wss://algo.utxo.one']
|
||||
}
|
||||
]
|
||||
|
||||
class LocalStorageService {
|
||||
static instance: LocalStorageService
|
||||
|
||||
private relaySets: TRelaySet[] = []
|
||||
private activeRelaySetId: string | null = null
|
||||
private feedType: TFeedType = 'relays'
|
||||
private themeSetting: TThemeSetting = 'system'
|
||||
private accounts: TAccount[] = []
|
||||
private currentAccount: TAccount | null = null
|
||||
private noteListMode: TNoteListMode = 'posts'
|
||||
|
||||
constructor() {
|
||||
if (!LocalStorageService.instance) {
|
||||
this.init()
|
||||
LocalStorageService.instance = this
|
||||
}
|
||||
return LocalStorageService.instance
|
||||
}
|
||||
|
||||
init() {
|
||||
this.themeSetting =
|
||||
(window.localStorage.getItem(StorageKey.THEME_SETTING) as TThemeSetting) ?? 'system'
|
||||
const accountsStr = window.localStorage.getItem(StorageKey.ACCOUNTS)
|
||||
this.accounts = accountsStr ? JSON.parse(accountsStr) : []
|
||||
const currentAccountStr = window.localStorage.getItem(StorageKey.CURRENT_ACCOUNT)
|
||||
this.currentAccount = currentAccountStr ? JSON.parse(currentAccountStr) : null
|
||||
const feedType = window.localStorage.getItem(StorageKey.FEED_TYPE)
|
||||
if (feedType && ['following', 'relays'].includes(feedType)) {
|
||||
this.feedType = feedType as 'following' | 'relays'
|
||||
} else {
|
||||
this.feedType = 'relays'
|
||||
}
|
||||
const noteListModeStr = window.localStorage.getItem(StorageKey.NOTE_LIST_MODE)
|
||||
this.noteListMode =
|
||||
noteListModeStr && ['posts', 'postsAndReplies', 'pictures'].includes(noteListModeStr)
|
||||
? (noteListModeStr as TNoteListMode)
|
||||
: 'posts'
|
||||
|
||||
const relaySetsStr = window.localStorage.getItem(StorageKey.RELAY_SETS)
|
||||
if (!relaySetsStr) {
|
||||
let relaySets: TRelaySet[] = []
|
||||
const legacyRelayGroupsStr = window.localStorage.getItem('relayGroups')
|
||||
if (legacyRelayGroupsStr) {
|
||||
const legacyRelayGroups = JSON.parse(legacyRelayGroupsStr)
|
||||
relaySets = legacyRelayGroups.map((group: any) => {
|
||||
return {
|
||||
id: randomString(),
|
||||
name: group.groupName,
|
||||
relayUrls: group.relayUrls
|
||||
}
|
||||
})
|
||||
}
|
||||
if (!relaySets.length) {
|
||||
relaySets = DEFAULT_RELAY_SETS
|
||||
}
|
||||
const activeRelaySetId = relaySets[0].id
|
||||
window.localStorage.setItem(StorageKey.RELAY_SETS, JSON.stringify(relaySets))
|
||||
window.localStorage.setItem(StorageKey.ACTIVE_RELAY_SET_ID, activeRelaySetId)
|
||||
this.relaySets = relaySets
|
||||
this.activeRelaySetId = activeRelaySetId
|
||||
} else {
|
||||
this.relaySets = JSON.parse(relaySetsStr)
|
||||
this.activeRelaySetId = window.localStorage.getItem(StorageKey.ACTIVE_RELAY_SET_ID) ?? null
|
||||
}
|
||||
|
||||
// Clean up deprecated data
|
||||
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
|
||||
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
|
||||
window.localStorage.removeItem(StorageKey.ACCOUNT_RELAY_LIST_EVENT_MAP)
|
||||
window.localStorage.removeItem(StorageKey.ACCOUNT_MUTE_LIST_EVENT_MAP)
|
||||
window.localStorage.removeItem(StorageKey.ACCOUNT_MUTE_DECRYPTED_TAGS_MAP)
|
||||
}
|
||||
|
||||
getRelaySets() {
|
||||
return this.relaySets
|
||||
}
|
||||
|
||||
setRelaySets(relaySets: TRelaySet[]) {
|
||||
this.relaySets = relaySets
|
||||
window.localStorage.setItem(StorageKey.RELAY_SETS, JSON.stringify(this.relaySets))
|
||||
}
|
||||
|
||||
getActiveRelaySetId() {
|
||||
return this.activeRelaySetId
|
||||
}
|
||||
|
||||
setActiveRelaySetId(id: string | null) {
|
||||
this.activeRelaySetId = id
|
||||
if (id) {
|
||||
window.localStorage.setItem(StorageKey.ACTIVE_RELAY_SET_ID, id)
|
||||
} else {
|
||||
window.localStorage.removeItem(StorageKey.ACTIVE_RELAY_SET_ID)
|
||||
}
|
||||
}
|
||||
|
||||
getFeedType() {
|
||||
return this.feedType
|
||||
}
|
||||
|
||||
setFeedType(feedType: TFeedType) {
|
||||
this.feedType = feedType
|
||||
window.localStorage.setItem(StorageKey.FEED_TYPE, this.feedType)
|
||||
}
|
||||
|
||||
getThemeSetting() {
|
||||
return this.themeSetting
|
||||
}
|
||||
|
||||
setThemeSetting(themeSetting: TThemeSetting) {
|
||||
window.localStorage.setItem(StorageKey.THEME_SETTING, themeSetting)
|
||||
this.themeSetting = themeSetting
|
||||
}
|
||||
|
||||
getNoteListMode() {
|
||||
return this.noteListMode
|
||||
}
|
||||
|
||||
setNoteListMode(mode: TNoteListMode) {
|
||||
window.localStorage.setItem(StorageKey.NOTE_LIST_MODE, mode)
|
||||
this.noteListMode = mode
|
||||
}
|
||||
|
||||
getAccounts() {
|
||||
return this.accounts
|
||||
}
|
||||
|
||||
findAccount(account: TAccountPointer) {
|
||||
return this.accounts.find((act) => isSameAccount(act, account))
|
||||
}
|
||||
|
||||
getCurrentAccount() {
|
||||
return this.currentAccount
|
||||
}
|
||||
|
||||
getAccountNsec(pubkey: string) {
|
||||
const account = this.accounts.find((act) => act.pubkey === pubkey && act.signerType === 'nsec')
|
||||
return account?.nsec
|
||||
}
|
||||
|
||||
getAccountNcryptsec(pubkey: string) {
|
||||
const account = this.accounts.find(
|
||||
(act) => act.pubkey === pubkey && act.signerType === 'ncryptsec'
|
||||
)
|
||||
return account?.ncryptsec
|
||||
}
|
||||
|
||||
addAccount(account: TAccount) {
|
||||
if (this.accounts.find((act) => isSameAccount(act, account))) {
|
||||
return
|
||||
}
|
||||
this.accounts.push(account)
|
||||
window.localStorage.setItem(StorageKey.ACCOUNTS, JSON.stringify(this.accounts))
|
||||
return account
|
||||
}
|
||||
|
||||
removeAccount(account: TAccount) {
|
||||
this.accounts = this.accounts.filter((act) => !isSameAccount(act, account))
|
||||
window.localStorage.setItem(StorageKey.ACCOUNTS, JSON.stringify(this.accounts))
|
||||
}
|
||||
|
||||
switchAccount(account: TAccount | null) {
|
||||
if (isSameAccount(this.currentAccount, account)) {
|
||||
return
|
||||
}
|
||||
const act = this.accounts.find((act) => isSameAccount(act, account))
|
||||
if (!act) {
|
||||
return
|
||||
}
|
||||
this.currentAccount = act
|
||||
window.localStorage.setItem(StorageKey.CURRENT_ACCOUNT, JSON.stringify(act))
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new LocalStorageService()
|
||||
export default instance
|
||||
@@ -6,6 +6,7 @@ import DataLoader from 'dataloader'
|
||||
import FlexSearch from 'flexsearch'
|
||||
import { Event } from 'nostr-tools'
|
||||
import client from './client.service'
|
||||
import indexedDb from './indexed-db.service'
|
||||
|
||||
class RelayInfoService {
|
||||
static instance: RelayInfoService
|
||||
@@ -52,7 +53,12 @@ class RelayInfoService {
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
return Array.from(this.relayInfoMap.values())
|
||||
const arr = Array.from(this.relayInfoMap.values())
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[arr[i], arr[j]] = [arr[j], arr[i]]
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
const result = await this.relayInfoIndex.searchAsync(query)
|
||||
@@ -128,28 +134,39 @@ class RelayInfoService {
|
||||
}
|
||||
|
||||
private async loadRelayInfos() {
|
||||
let until: number = Math.round(Date.now() / 1000)
|
||||
const since = until - 60 * 60 * 48
|
||||
|
||||
while (until) {
|
||||
const relayInfoEvents = await client.fetchEvents(MONITOR_RELAYS, {
|
||||
authors: [MONITOR],
|
||||
kinds: [30166],
|
||||
since,
|
||||
until,
|
||||
limit: 1000
|
||||
})
|
||||
const events = relayInfoEvents.sort((a, b) => b.created_at - a.created_at)
|
||||
if (events.length === 0) {
|
||||
break
|
||||
}
|
||||
until = events[events.length - 1].created_at - 1
|
||||
const relayInfos = formatRelayInfoEvents(events)
|
||||
for (const relayInfo of relayInfos) {
|
||||
await this.addRelayInfo(relayInfo)
|
||||
}
|
||||
}
|
||||
const localRelayInfos = await indexedDb.getAllRelayInfoEvents()
|
||||
const relayInfos = formatRelayInfoEvents(localRelayInfos)
|
||||
relayInfos.forEach((relayInfo) => this.addRelayInfo(relayInfo))
|
||||
this.relayUrlsForRandom = Array.from(this.relayInfoMap.keys())
|
||||
|
||||
const loadFromInternet = async () => {
|
||||
let until: number = Math.round(Date.now() / 1000)
|
||||
const since = until - 60 * 60 * 48
|
||||
|
||||
while (until) {
|
||||
const relayInfoEvents = await client.fetchEvents(MONITOR_RELAYS, {
|
||||
authors: [MONITOR],
|
||||
kinds: [30166],
|
||||
since,
|
||||
until,
|
||||
limit: 1000
|
||||
})
|
||||
const events = relayInfoEvents.sort((a, b) => b.created_at - a.created_at)
|
||||
if (events.length === 0) {
|
||||
break
|
||||
}
|
||||
await Promise.allSettled(events.map((event) => indexedDb.putRelayInfoEvent(event)))
|
||||
until = events[events.length - 1].created_at - 1
|
||||
const relayInfos = formatRelayInfoEvents(events)
|
||||
relayInfos.forEach((relayInfo) => this.addRelayInfo(relayInfo))
|
||||
}
|
||||
this.relayUrlsForRandom = Array.from(this.relayInfoMap.keys())
|
||||
}
|
||||
if (localRelayInfos.length === 0) {
|
||||
await loadFromInternet()
|
||||
} else {
|
||||
loadFromInternet()
|
||||
}
|
||||
}
|
||||
|
||||
private async addRelayInfo(relayInfo: TNip66RelayInfo) {
|
||||
|
||||
@@ -1,370 +0,0 @@
|
||||
import { StorageKey } from '@/constants'
|
||||
import { isSameAccount } from '@/lib/account'
|
||||
import { randomString } from '@/lib/random'
|
||||
import {
|
||||
TAccount,
|
||||
TAccountPointer,
|
||||
TFeedType,
|
||||
TNoteListMode,
|
||||
TRelaySet,
|
||||
TThemeSetting
|
||||
} from '@/types'
|
||||
import { Event } from 'nostr-tools'
|
||||
|
||||
const DEFAULT_RELAY_SETS: TRelaySet[] = [
|
||||
{
|
||||
id: randomString(),
|
||||
name: 'Global',
|
||||
relayUrls: ['wss://relay.damus.io/', 'wss://nos.lol/']
|
||||
},
|
||||
{
|
||||
id: randomString(),
|
||||
name: 'Safer Global',
|
||||
relayUrls: ['wss://nostr.wine/', 'wss://pyramid.fiatjaf.com/']
|
||||
},
|
||||
{
|
||||
id: randomString(),
|
||||
name: 'Short Notes',
|
||||
relayUrls: ['wss://140.f7z.io/']
|
||||
},
|
||||
{
|
||||
id: randomString(),
|
||||
name: 'News',
|
||||
relayUrls: ['wss://news.utxo.one/']
|
||||
},
|
||||
{
|
||||
id: randomString(),
|
||||
name: 'Algo',
|
||||
relayUrls: ['wss://algo.utxo.one']
|
||||
}
|
||||
]
|
||||
|
||||
class StorageService {
|
||||
static instance: StorageService
|
||||
|
||||
private relaySets: TRelaySet[] = []
|
||||
private activeRelaySetId: string | null = null
|
||||
private feedType: TFeedType = 'relays'
|
||||
private themeSetting: TThemeSetting = 'system'
|
||||
private accounts: TAccount[] = []
|
||||
private currentAccount: TAccount | null = null
|
||||
private noteListMode: TNoteListMode = 'posts'
|
||||
private accountRelayListEventMap: Record<string, Event | undefined> = {} // pubkey -> relayListEvent
|
||||
private accountFollowListEventMap: Record<string, Event | undefined> = {} // pubkey -> followListEvent
|
||||
private accountMuteListEventMap: Record<string, Event | undefined> = {} // pubkey -> muteListEvent
|
||||
private accountMuteDecryptedTagsMap: Record<
|
||||
string,
|
||||
{ id: string; tags: string[][] } | undefined
|
||||
> = {} // pubkey -> { id, tags }
|
||||
private accountProfileEventMap: Record<string, Event | undefined> = {} // pubkey -> profileEvent
|
||||
|
||||
constructor() {
|
||||
if (!StorageService.instance) {
|
||||
this.init()
|
||||
StorageService.instance = this
|
||||
}
|
||||
return StorageService.instance
|
||||
}
|
||||
|
||||
init() {
|
||||
this.themeSetting =
|
||||
(window.localStorage.getItem(StorageKey.THEME_SETTING) as TThemeSetting) ?? 'system'
|
||||
const accountsStr = window.localStorage.getItem(StorageKey.ACCOUNTS)
|
||||
this.accounts = accountsStr ? JSON.parse(accountsStr) : []
|
||||
const currentAccountStr = window.localStorage.getItem(StorageKey.CURRENT_ACCOUNT)
|
||||
this.currentAccount = currentAccountStr ? JSON.parse(currentAccountStr) : null
|
||||
const feedType = window.localStorage.getItem(StorageKey.FEED_TYPE)
|
||||
if (feedType && ['following', 'relays'].includes(feedType)) {
|
||||
this.feedType = feedType as 'following' | 'relays'
|
||||
} else {
|
||||
this.feedType = 'relays'
|
||||
}
|
||||
const noteListModeStr = window.localStorage.getItem(StorageKey.NOTE_LIST_MODE)
|
||||
this.noteListMode =
|
||||
noteListModeStr && ['posts', 'postsAndReplies', 'pictures'].includes(noteListModeStr)
|
||||
? (noteListModeStr as TNoteListMode)
|
||||
: 'posts'
|
||||
|
||||
const accountRelayListEventMapStr = window.localStorage.getItem(
|
||||
StorageKey.ACCOUNT_RELAY_LIST_EVENT_MAP
|
||||
)
|
||||
this.accountRelayListEventMap = accountRelayListEventMapStr
|
||||
? JSON.parse(accountRelayListEventMapStr)
|
||||
: {}
|
||||
const accountFollowListEventMapStr = window.localStorage.getItem(
|
||||
StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP
|
||||
)
|
||||
this.accountFollowListEventMap = accountFollowListEventMapStr
|
||||
? JSON.parse(accountFollowListEventMapStr)
|
||||
: {}
|
||||
const accountMuteListEventMapStr = window.localStorage.getItem(
|
||||
StorageKey.ACCOUNT_MUTE_LIST_EVENT_MAP
|
||||
)
|
||||
this.accountMuteListEventMap = accountMuteListEventMapStr
|
||||
? JSON.parse(accountMuteListEventMapStr)
|
||||
: {}
|
||||
const accountMuteDecryptedTagsMapStr = window.localStorage.getItem(
|
||||
StorageKey.ACCOUNT_MUTE_DECRYPTED_TAGS_MAP
|
||||
)
|
||||
this.accountMuteDecryptedTagsMap = accountMuteDecryptedTagsMapStr
|
||||
? JSON.parse(accountMuteDecryptedTagsMapStr)
|
||||
: {}
|
||||
const accountProfileEventMapStr = window.localStorage.getItem(
|
||||
StorageKey.ACCOUNT_PROFILE_EVENT_MAP
|
||||
)
|
||||
this.accountProfileEventMap = accountProfileEventMapStr
|
||||
? JSON.parse(accountProfileEventMapStr)
|
||||
: {}
|
||||
|
||||
const relaySetsStr = window.localStorage.getItem(StorageKey.RELAY_SETS)
|
||||
if (!relaySetsStr) {
|
||||
let relaySets: TRelaySet[] = []
|
||||
const legacyRelayGroupsStr = window.localStorage.getItem('relayGroups')
|
||||
if (legacyRelayGroupsStr) {
|
||||
const legacyRelayGroups = JSON.parse(legacyRelayGroupsStr)
|
||||
relaySets = legacyRelayGroups.map((group: any) => {
|
||||
return {
|
||||
id: randomString(),
|
||||
name: group.groupName,
|
||||
relayUrls: group.relayUrls
|
||||
}
|
||||
})
|
||||
}
|
||||
if (!relaySets.length) {
|
||||
relaySets = DEFAULT_RELAY_SETS
|
||||
}
|
||||
const activeRelaySetId = relaySets[0].id
|
||||
window.localStorage.setItem(StorageKey.RELAY_SETS, JSON.stringify(relaySets))
|
||||
window.localStorage.setItem(StorageKey.ACTIVE_RELAY_SET_ID, activeRelaySetId)
|
||||
this.relaySets = relaySets
|
||||
this.activeRelaySetId = activeRelaySetId
|
||||
} else {
|
||||
this.relaySets = JSON.parse(relaySetsStr)
|
||||
this.activeRelaySetId = window.localStorage.getItem(StorageKey.ACTIVE_RELAY_SET_ID) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
getRelaySets() {
|
||||
return this.relaySets
|
||||
}
|
||||
|
||||
setRelaySets(relaySets: TRelaySet[]) {
|
||||
this.relaySets = relaySets
|
||||
window.localStorage.setItem(StorageKey.RELAY_SETS, JSON.stringify(this.relaySets))
|
||||
}
|
||||
|
||||
getActiveRelaySetId() {
|
||||
return this.activeRelaySetId
|
||||
}
|
||||
|
||||
setActiveRelaySetId(id: string | null) {
|
||||
this.activeRelaySetId = id
|
||||
if (id) {
|
||||
window.localStorage.setItem(StorageKey.ACTIVE_RELAY_SET_ID, id)
|
||||
} else {
|
||||
window.localStorage.removeItem(StorageKey.ACTIVE_RELAY_SET_ID)
|
||||
}
|
||||
}
|
||||
|
||||
getFeedType() {
|
||||
return this.feedType
|
||||
}
|
||||
|
||||
setFeedType(feedType: TFeedType) {
|
||||
this.feedType = feedType
|
||||
window.localStorage.setItem(StorageKey.FEED_TYPE, this.feedType)
|
||||
}
|
||||
|
||||
getThemeSetting() {
|
||||
return this.themeSetting
|
||||
}
|
||||
|
||||
setThemeSetting(themeSetting: TThemeSetting) {
|
||||
window.localStorage.setItem(StorageKey.THEME_SETTING, themeSetting)
|
||||
this.themeSetting = themeSetting
|
||||
}
|
||||
|
||||
getNoteListMode() {
|
||||
return this.noteListMode
|
||||
}
|
||||
|
||||
setNoteListMode(mode: TNoteListMode) {
|
||||
window.localStorage.setItem(StorageKey.NOTE_LIST_MODE, mode)
|
||||
this.noteListMode = mode
|
||||
}
|
||||
|
||||
getAccounts() {
|
||||
return this.accounts
|
||||
}
|
||||
|
||||
findAccount(account: TAccountPointer) {
|
||||
return this.accounts.find((act) => isSameAccount(act, account))
|
||||
}
|
||||
|
||||
getCurrentAccount() {
|
||||
return this.currentAccount
|
||||
}
|
||||
|
||||
getAccountNsec(pubkey: string) {
|
||||
const account = this.accounts.find((act) => act.pubkey === pubkey && act.signerType === 'nsec')
|
||||
return account?.nsec
|
||||
}
|
||||
|
||||
getAccountNcryptsec(pubkey: string) {
|
||||
const account = this.accounts.find(
|
||||
(act) => act.pubkey === pubkey && act.signerType === 'ncryptsec'
|
||||
)
|
||||
return account?.ncryptsec
|
||||
}
|
||||
|
||||
addAccount(account: TAccount) {
|
||||
if (this.accounts.find((act) => isSameAccount(act, account))) {
|
||||
return
|
||||
}
|
||||
this.accounts.push(account)
|
||||
window.localStorage.setItem(StorageKey.ACCOUNTS, JSON.stringify(this.accounts))
|
||||
return account
|
||||
}
|
||||
|
||||
removeAccount(account: TAccount) {
|
||||
this.accounts = this.accounts.filter((act) => !isSameAccount(act, account))
|
||||
delete this.accountFollowListEventMap[account.pubkey]
|
||||
delete this.accountRelayListEventMap[account.pubkey]
|
||||
delete this.accountMuteListEventMap[account.pubkey]
|
||||
delete this.accountMuteDecryptedTagsMap[account.pubkey]
|
||||
delete this.accountProfileEventMap[account.pubkey]
|
||||
window.localStorage.setItem(StorageKey.ACCOUNTS, JSON.stringify(this.accounts))
|
||||
window.localStorage.setItem(
|
||||
StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP,
|
||||
JSON.stringify(this.accountFollowListEventMap)
|
||||
)
|
||||
window.localStorage.setItem(
|
||||
StorageKey.ACCOUNT_MUTE_LIST_EVENT_MAP,
|
||||
JSON.stringify(this.accountMuteListEventMap)
|
||||
)
|
||||
window.localStorage.setItem(
|
||||
StorageKey.ACCOUNT_MUTE_DECRYPTED_TAGS_MAP,
|
||||
JSON.stringify(this.accountMuteDecryptedTagsMap)
|
||||
)
|
||||
window.localStorage.setItem(
|
||||
StorageKey.ACCOUNT_RELAY_LIST_EVENT_MAP,
|
||||
JSON.stringify(this.accountRelayListEventMap)
|
||||
)
|
||||
window.localStorage.setItem(
|
||||
StorageKey.ACCOUNT_PROFILE_EVENT_MAP,
|
||||
JSON.stringify(this.accountProfileEventMap)
|
||||
)
|
||||
}
|
||||
|
||||
switchAccount(account: TAccount | null) {
|
||||
if (isSameAccount(this.currentAccount, account)) {
|
||||
return
|
||||
}
|
||||
const act = this.accounts.find((act) => isSameAccount(act, account))
|
||||
if (!act) {
|
||||
return
|
||||
}
|
||||
this.currentAccount = act
|
||||
window.localStorage.setItem(StorageKey.CURRENT_ACCOUNT, JSON.stringify(act))
|
||||
}
|
||||
|
||||
getAccountRelayListEvent(pubkey: string) {
|
||||
return this.accountRelayListEventMap[pubkey]
|
||||
}
|
||||
|
||||
setAccountRelayListEvent(relayListEvent: Event) {
|
||||
const pubkey = relayListEvent.pubkey
|
||||
if (
|
||||
this.accountRelayListEventMap[pubkey] &&
|
||||
this.accountRelayListEventMap[pubkey].created_at > relayListEvent.created_at
|
||||
) {
|
||||
return false
|
||||
}
|
||||
this.accountRelayListEventMap[pubkey] = relayListEvent
|
||||
window.localStorage.setItem(
|
||||
StorageKey.ACCOUNT_RELAY_LIST_EVENT_MAP,
|
||||
JSON.stringify(this.accountRelayListEventMap)
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
getAccountFollowListEvent(pubkey: string) {
|
||||
return this.accountFollowListEventMap[pubkey]
|
||||
}
|
||||
|
||||
setAccountFollowListEvent(followListEvent: Event) {
|
||||
const pubkey = followListEvent.pubkey
|
||||
if (
|
||||
this.accountFollowListEventMap[pubkey] &&
|
||||
this.accountFollowListEventMap[pubkey].created_at > followListEvent.created_at
|
||||
) {
|
||||
return false
|
||||
}
|
||||
this.accountFollowListEventMap[pubkey] = followListEvent
|
||||
window.localStorage.setItem(
|
||||
StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP,
|
||||
JSON.stringify(this.accountFollowListEventMap)
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
getAccountMuteListEvent(pubkey: string) {
|
||||
return this.accountMuteListEventMap[pubkey]
|
||||
}
|
||||
|
||||
setAccountMuteListEvent(muteListEvent: Event) {
|
||||
const pubkey = muteListEvent.pubkey
|
||||
if (
|
||||
this.accountMuteListEventMap[pubkey] &&
|
||||
this.accountMuteListEventMap[pubkey].created_at > muteListEvent.created_at
|
||||
) {
|
||||
return false
|
||||
}
|
||||
this.accountMuteListEventMap[pubkey] = muteListEvent
|
||||
window.localStorage.setItem(
|
||||
StorageKey.ACCOUNT_MUTE_LIST_EVENT_MAP,
|
||||
JSON.stringify(this.accountMuteListEventMap)
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
getAccountMuteDecryptedTags(muteListEvent: Event) {
|
||||
const stored = this.accountMuteDecryptedTagsMap[muteListEvent.pubkey]
|
||||
if (stored && stored.id === muteListEvent.id) {
|
||||
return stored.tags
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
setAccountMuteDecryptedTags(muteListEvent: Event, tags: string[][]) {
|
||||
this.accountMuteDecryptedTagsMap[muteListEvent.pubkey] = { id: muteListEvent.id, tags }
|
||||
window.localStorage.setItem(
|
||||
StorageKey.ACCOUNT_MUTE_DECRYPTED_TAGS_MAP,
|
||||
JSON.stringify(this.accountMuteDecryptedTagsMap)
|
||||
)
|
||||
}
|
||||
|
||||
getAccountProfileEvent(pubkey: string) {
|
||||
return this.accountProfileEventMap[pubkey]
|
||||
}
|
||||
|
||||
setAccountProfileEvent(profileEvent: Event) {
|
||||
const pubkey = profileEvent.pubkey
|
||||
if (
|
||||
this.accountProfileEventMap[pubkey] &&
|
||||
this.accountProfileEventMap[pubkey].created_at > profileEvent.created_at
|
||||
) {
|
||||
return false
|
||||
}
|
||||
this.accountProfileEventMap[pubkey] = profileEvent
|
||||
window.localStorage.setItem(
|
||||
StorageKey.ACCOUNT_PROFILE_EVENT_MAP,
|
||||
JSON.stringify(this.accountProfileEventMap)
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new StorageService()
|
||||
|
||||
export default instance
|
||||
Reference in New Issue
Block a user