perf: improve loading speed (#116)

This commit is contained in:
Cody Tseng
2025-02-12 22:09:00 +08:00
committed by GitHub
parent 6f91c200a9
commit d5f46690c4
16 changed files with 705 additions and 514 deletions

View File

@@ -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) {

View 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

View 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

View File

@@ -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) {

View File

@@ -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