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

@@ -25,7 +25,7 @@ export default function SaveButton({
setPushing(true) setPushing(true)
const event = createRelayListDraftEvent(mailboxRelays) const event = createRelayListDraftEvent(mailboxRelays)
const relayListEvent = await publish(event) const relayListEvent = await publish(event)
updateRelayListEvent(relayListEvent) await updateRelayListEvent(relayListEvent)
toast({ toast({
title: 'Save Successful', title: 'Save Successful',
description: 'Successfully saved mailbox relays' description: 'Successfully saved mailbox relays'

View File

@@ -10,7 +10,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import relayInfoService from '@/services/relay-info.service' import relayInfoService from '@/services/relay-info.service'
import storage from '@/services/storage.service' import storage from '@/services/local-storage.service'
import { TNoteListMode } from '@/types' import { TNoteListMode } from '@/types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Event, Filter, kinds } from 'nostr-tools' import { Event, Filter, kinds } from 'nostr-tools'

View File

@@ -7,11 +7,11 @@ export const StorageKey = {
CURRENT_ACCOUNT: 'currentAccount', CURRENT_ACCOUNT: 'currentAccount',
ADD_CLIENT_TAG: 'addClientTag', ADD_CLIENT_TAG: 'addClientTag',
NOTE_LIST_MODE: 'noteListMode', NOTE_LIST_MODE: 'noteListMode',
ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated
ACCOUNT_FOLLOW_LIST_EVENT_MAP: 'accountFollowListEventMap', ACCOUNT_FOLLOW_LIST_EVENT_MAP: 'accountFollowListEventMap', // deprecated
ACCOUNT_MUTE_LIST_EVENT_MAP: 'accountMuteListEventMap', ACCOUNT_MUTE_LIST_EVENT_MAP: 'accountMuteListEventMap', // deprecated
ACCOUNT_MUTE_DECRYPTED_TAGS_MAP: 'accountMuteDecryptedTagsMap', ACCOUNT_MUTE_DECRYPTED_TAGS_MAP: 'accountMuteDecryptedTagsMap', // deprecated
ACCOUNT_PROFILE_EVENT_MAP: 'accountProfileEventMap' ACCOUNT_PROFILE_EVENT_MAP: 'accountProfileEventMap' // deprecated
} }
export const BIG_RELAY_URLS = [ export const BIG_RELAY_URLS = [

View File

@@ -2,8 +2,9 @@ import { getProfileFromProfileEvent } from '@/lib/event'
import { userIdToPubkey } from '@/lib/pubkey' import { userIdToPubkey } from '@/lib/pubkey'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import storage from '@/services/storage.service' import indexedDb from '@/services/indexed-db.service'
import { TProfile } from '@/types' import { TProfile } from '@/types'
import { kinds } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
export function useFetchProfile(id?: string) { export function useFetchProfile(id?: string) {
@@ -27,7 +28,7 @@ export function useFetchProfile(id?: string) {
const pubkey = userIdToPubkey(id) const pubkey = userIdToPubkey(id)
setPubkey(pubkey) setPubkey(pubkey)
const storedProfileEvent = storage.getAccountProfileEvent(pubkey) const storedProfileEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.Metadata)
if (storedProfileEvent) { if (storedProfileEvent) {
const profile = getProfileFromProfileEvent(storedProfileEvent) const profile = getProfileFromProfileEvent(storedProfileEvent)
setProfile(profile) setProfile(profile)

View File

@@ -74,7 +74,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
profileEvent?.tags profileEvent?.tags
) )
const newProfileEvent = await publish(profileDraftEvent) const newProfileEvent = await publish(profileDraftEvent)
updateProfileEvent(newProfileEvent) await updateProfileEvent(newProfileEvent)
setSaving(false) setSaving(false)
pop() pop()
} }

View File

@@ -2,7 +2,7 @@ import { BIG_RELAY_URLS } from '@/constants'
import { checkAlgoRelay } from '@/lib/relay' import { checkAlgoRelay } from '@/lib/relay'
import { isWebsocketUrl, normalizeUrl } from '@/lib/url' import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
import client from '@/services/client.service' import client from '@/services/client.service'
import storage from '@/services/storage.service' import storage from '@/services/local-storage.service'
import relayInfoService from '@/services/relay-info.service' import relayInfoService from '@/services/relay-info.service'
import { TFeedType } from '@/types' import { TFeedType } from '@/types'
import { Filter } from 'nostr-tools' import { Filter } from 'nostr-tools'

View File

@@ -1,8 +1,8 @@
import { createFollowListDraftEvent } from '@/lib/draft-event' import { createFollowListDraftEvent } from '@/lib/draft-event'
import { extractPubkeysFromEventTags } from '@/lib/tag' import { extractPubkeysFromEventTags } from '@/lib/tag'
import client from '@/services/client.service' import client from '@/services/client.service'
import storage from '@/services/storage.service' import indexedDb from '@/services/indexed-db.service'
import { Event } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { createContext, useContext, useEffect, useMemo, useState } from 'react' import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import { useNostr } from './NostrProvider' import { useNostr } from './NostrProvider'
@@ -40,13 +40,16 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
const init = async () => { const init = async () => {
setIsFetching(true) setIsFetching(true)
setFollowListEvent(undefined) setFollowListEvent(undefined)
const storedFollowListEvent = storage.getAccountFollowListEvent(accountPubkey) const storedFollowListEvent = await indexedDb.getReplaceableEvent(
accountPubkey,
kinds.Contacts
)
if (storedFollowListEvent) { if (storedFollowListEvent) {
setFollowListEvent(storedFollowListEvent) setFollowListEvent(storedFollowListEvent)
} }
const event = await client.fetchFollowListEvent(accountPubkey) const event = await client.fetchFollowListEvent(accountPubkey, true)
if (event) { if (event) {
updateFollowListEvent(event) await updateFollowListEvent(event)
} }
setIsFetching(false) setIsFetching(false)
} }
@@ -54,8 +57,8 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
init() init()
}, [accountPubkey]) }, [accountPubkey])
const updateFollowListEvent = (event: Event) => { const updateFollowListEvent = async (event: Event) => {
const isNew = storage.setAccountFollowListEvent(event) const isNew = await indexedDb.putReplaceableEvent(event)
if (!isNew) return if (!isNew) return
setFollowListEvent(event) setFollowListEvent(event)
} }
@@ -69,7 +72,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
) )
const newFollowListEvent = await publish(newFollowListDraftEvent) const newFollowListEvent = await publish(newFollowListDraftEvent)
client.updateFollowListCache(accountPubkey, newFollowListEvent) client.updateFollowListCache(accountPubkey, newFollowListEvent)
updateFollowListEvent(newFollowListEvent) await updateFollowListEvent(newFollowListEvent)
} }
const unfollow = async (pubkey: string) => { const unfollow = async (pubkey: string) => {
@@ -81,11 +84,11 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
) )
const newFollowListEvent = await publish(newFollowListDraftEvent) const newFollowListEvent = await publish(newFollowListDraftEvent)
client.updateFollowListCache(accountPubkey, newFollowListEvent) client.updateFollowListCache(accountPubkey, newFollowListEvent)
updateFollowListEvent(newFollowListEvent) await updateFollowListEvent(newFollowListEvent)
} }
const getFollowings = async (pubkey: string) => { const getFollowings = async (pubkey: string) => {
const followListEvent = storage.getAccountFollowListEvent(pubkey) const followListEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.Contacts)
if (followListEvent) { if (followListEvent) {
return extractPubkeysFromEventTags(followListEvent.tags) return extractPubkeysFromEventTags(followListEvent.tags)
} }

View File

@@ -2,7 +2,7 @@ import { createMuteListDraftEvent } from '@/lib/draft-event'
import { getLatestEvent } from '@/lib/event' import { getLatestEvent } from '@/lib/event'
import { extractPubkeysFromEventTags, isSameTag } from '@/lib/tag' import { extractPubkeysFromEventTags, isSameTag } from '@/lib/tag'
import client from '@/services/client.service' import client from '@/services/client.service'
import storage from '@/services/storage.service' import indexedDb from '@/services/indexed-db.service'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { createContext, useContext, useEffect, useMemo, useState } from 'react' import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import { z } from 'zod' import { z } from 'zod'
@@ -35,7 +35,7 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
const init = async () => { const init = async () => {
setMuteListEvent(undefined) setMuteListEvent(undefined)
const storedMuteListEvent = storage.getAccountMuteListEvent(accountPubkey) const storedMuteListEvent = await indexedDb.getReplaceableEvent(accountPubkey, kinds.Mutelist)
if (storedMuteListEvent) { if (storedMuteListEvent) {
setMuteListEvent(storedMuteListEvent) setMuteListEvent(storedMuteListEvent)
const tags = await extractMuteTags(storedMuteListEvent) const tags = await extractMuteTags(storedMuteListEvent)
@@ -47,6 +47,7 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
}) })
const muteEvent = getLatestEvent(events) as Event | undefined const muteEvent = getLatestEvent(events) as Event | undefined
if (muteEvent) { if (muteEvent) {
await indexedDb.putReplaceableEvent(muteEvent)
setMuteListEvent(muteEvent) setMuteListEvent(muteEvent)
const tags = await extractMuteTags(muteEvent) const tags = await extractMuteTags(muteEvent)
setTags(tags) setTags(tags)
@@ -59,7 +60,7 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
const extractMuteTags = async (muteListEvent: Event) => { const extractMuteTags = async (muteListEvent: Event) => {
const tags = [...muteListEvent.tags] const tags = [...muteListEvent.tags]
if (muteListEvent.content) { if (muteListEvent.content) {
const storedDecryptedTags = storage.getAccountMuteDecryptedTags(muteListEvent) const storedDecryptedTags = await indexedDb.getMuteDecryptedTags(muteListEvent.id)
if (storedDecryptedTags) { if (storedDecryptedTags) {
tags.push(...storedDecryptedTags) tags.push(...storedDecryptedTags)
@@ -67,7 +68,7 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
try { try {
const plainText = await nip04Decrypt(muteListEvent.pubkey, muteListEvent.content) const plainText = await nip04Decrypt(muteListEvent.pubkey, muteListEvent.content)
const contentTags = z.array(z.array(z.string())).parse(JSON.parse(plainText)) const contentTags = z.array(z.array(z.string())).parse(JSON.parse(plainText))
storage.setAccountMuteDecryptedTags(muteListEvent, contentTags) await indexedDb.putMuteDecryptedTags(muteListEvent.id, contentTags)
tags.push(...contentTags.filter((tag) => tags.every((t) => !isSameTag(t, tag)))) tags.push(...contentTags.filter((tag) => tags.every((t) => !isSameTag(t, tag))))
} catch (error) { } catch (error) {
console.error('Failed to decrypt mute list content', error) console.error('Failed to decrypt mute list content', error)
@@ -77,10 +78,10 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
return tags return tags
} }
const update = (event: Event, tags: string[][]) => { const update = async (event: Event, tags: string[][]) => {
const isNew = storage.setAccountMuteListEvent(event) const isNew = await indexedDb.putReplaceableEvent(event)
if (!isNew) return if (!isNew) return
storage.setAccountMuteDecryptedTags(event, tags) await indexedDb.putMuteDecryptedTags(event.id, tags)
setMuteListEvent(event) setMuteListEvent(event)
setTags(tags) setTags(tags)
} }
@@ -92,7 +93,7 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newTags)) const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newTags))
const newMuteListDraftEvent = createMuteListDraftEvent(muteListEvent?.tags ?? [], cipherText) const newMuteListDraftEvent = createMuteListDraftEvent(muteListEvent?.tags ?? [], cipherText)
const newMuteListEvent = await publish(newMuteListDraftEvent) const newMuteListEvent = await publish(newMuteListDraftEvent)
update(newMuteListEvent, newTags) await update(newMuteListEvent, newTags)
} }
const unmutePubkey = async (pubkey: string) => { const unmutePubkey = async (pubkey: string) => {
@@ -105,7 +106,7 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
cipherText cipherText
) )
const newMuteListEvent = await publish(newMuteListDraftEvent) const newMuteListEvent = await publish(newMuteListDraftEvent)
update(newMuteListEvent, newTags) await update(newMuteListEvent, newTags)
} }
return ( return (

View File

@@ -4,7 +4,8 @@ import { useToast } from '@/hooks'
import { getProfileFromProfileEvent, getRelayListFromRelayListEvent } from '@/lib/event' import { getProfileFromProfileEvent, getRelayListFromRelayListEvent } from '@/lib/event'
import { formatPubkey } from '@/lib/pubkey' import { formatPubkey } from '@/lib/pubkey'
import client from '@/services/client.service' import client from '@/services/client.service'
import storage from '@/services/storage.service' import storage from '@/services/local-storage.service'
import indexedDb from '@/services/indexed-db.service'
import { ISigner, TAccount, TAccountPointer, TDraftEvent, TProfile, TRelayList } from '@/types' import { ISigner, TAccount, TAccountPointer, TDraftEvent, TProfile, TRelayList } from '@/types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Event, kinds, VerifiedEvent } from 'nostr-tools' import { Event, kinds, VerifiedEvent } from 'nostr-tools'
@@ -45,8 +46,8 @@ type TNostrContext = {
startLogin: () => void startLogin: () => void
checkLogin: <T>(cb?: () => T) => Promise<T | void> checkLogin: <T>(cb?: () => T) => Promise<T | void>
getRelayList: (pubkey: string) => Promise<TRelayList> getRelayList: (pubkey: string) => Promise<TRelayList>
updateRelayListEvent: (relayListEvent: Event) => void updateRelayListEvent: (relayListEvent: Event) => Promise<void>
updateProfileEvent: (profileEvent: Event) => void updateProfileEvent: (profileEvent: Event) => Promise<void>
} }
const NostrContext = createContext<TNostrContext | undefined>(undefined) const NostrContext = createContext<TNostrContext | undefined>(undefined)
@@ -99,6 +100,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}, []) }, [])
useEffect(() => { useEffect(() => {
const init = async () => {
setRelayList(null) setRelayList(null)
setProfile(null) setProfile(null)
setProfileEvent(null) setProfileEvent(null)
@@ -107,6 +109,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return return
} }
const controller = new AbortController()
const storedNsec = storage.getAccountNsec(account.pubkey) const storedNsec = storage.getAccountNsec(account.pubkey)
if (storedNsec) { if (storedNsec) {
setNsec(storedNsec) setNsec(storedNsec)
@@ -119,17 +122,20 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} else { } else {
setNcryptsec(null) setNcryptsec(null)
} }
const storedRelayListEvent = storage.getAccountRelayListEvent(account.pubkey) const [storedRelayListEvent, storedProfileEvent] = await Promise.all([
indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList),
indexedDb.getReplaceableEvent(account.pubkey, kinds.Metadata)
])
if (storedRelayListEvent) { if (storedRelayListEvent) {
setRelayList( setRelayList(
storedRelayListEvent ? getRelayListFromRelayListEvent(storedRelayListEvent) : null storedRelayListEvent ? getRelayListFromRelayListEvent(storedRelayListEvent) : null
) )
} }
const storedProfileEvent = storage.getAccountProfileEvent(account.pubkey)
if (storedProfileEvent) { if (storedProfileEvent) {
setProfileEvent(storedProfileEvent) setProfileEvent(storedProfileEvent)
setProfile(getProfileFromProfileEvent(storedProfileEvent)) setProfile(getProfileFromProfileEvent(storedProfileEvent))
} }
client.fetchRelayListEvent(account.pubkey).then(async (relayListEvent) => { client.fetchRelayListEvent(account.pubkey).then(async (relayListEvent) => {
if (!relayListEvent) { if (!relayListEvent) {
if (storedRelayListEvent) return if (storedRelayListEvent) return
@@ -137,9 +143,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setRelayList({ write: BIG_RELAY_URLS, read: BIG_RELAY_URLS, originalRelays: [] }) setRelayList({ write: BIG_RELAY_URLS, read: BIG_RELAY_URLS, originalRelays: [] })
return return
} }
const isNew = storage.setAccountRelayListEvent(relayListEvent) const event = await indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList)
if (!isNew) return if (event) {
setRelayList(getRelayListFromRelayListEvent(relayListEvent)) setRelayList(getRelayListFromRelayListEvent(event))
}
}) })
client.fetchProfileEvent(account.pubkey).then(async (profileEvent) => { client.fetchProfileEvent(account.pubkey).then(async (profileEvent) => {
if (!profileEvent) { if (!profileEvent) {
@@ -151,12 +158,21 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}) })
return return
} }
const isNew = storage.setAccountProfileEvent(profileEvent) const event = await indexedDb.getReplaceableEvent(account.pubkey, kinds.Metadata)
if (!isNew) return if (event) {
setProfileEvent(profileEvent) setProfileEvent(event)
setProfile(getProfileFromProfileEvent(profileEvent)) setProfile(getProfileFromProfileEvent(event))
}
}) })
client.initUserIndexFromFollowings(account.pubkey) client.initUserIndexFromFollowings(account.pubkey, controller.signal)
return controller
}
const promise = init()
return () => {
promise.then((controller) => {
controller?.abort()
})
}
}, [account]) }, [account])
useEffect(() => { useEffect(() => {
@@ -379,21 +395,21 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
const getRelayList = async (pubkey: string) => { const getRelayList = async (pubkey: string) => {
const storedRelayListEvent = storage.getAccountRelayListEvent(pubkey) const storedRelayListEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.RelayList)
if (storedRelayListEvent) { if (storedRelayListEvent) {
return getRelayListFromRelayListEvent(storedRelayListEvent) return getRelayListFromRelayListEvent(storedRelayListEvent)
} }
return await client.fetchRelayList(pubkey) return await client.fetchRelayList(pubkey)
} }
const updateRelayListEvent = (relayListEvent: Event) => { const updateRelayListEvent = async (relayListEvent: Event) => {
const isNew = storage.setAccountRelayListEvent(relayListEvent) const isNew = await indexedDb.putReplaceableEvent(relayListEvent)
if (!isNew) return if (!isNew) return
setRelayList(getRelayListFromRelayListEvent(relayListEvent)) setRelayList(getRelayListFromRelayListEvent(relayListEvent))
} }
const updateProfileEvent = (profileEvent: Event) => { const updateProfileEvent = async (profileEvent: Event) => {
const isNew = storage.setAccountProfileEvent(profileEvent) const isNew = await indexedDb.putReplaceableEvent(profileEvent)
if (!isNew) return if (!isNew) return
setProfileEvent(profileEvent) setProfileEvent(profileEvent)
setProfile(getProfileFromProfileEvent(profileEvent)) setProfile(getProfileFromProfileEvent(profileEvent))

View File

@@ -1,6 +1,6 @@
import { randomString } from '@/lib/random' import { randomString } from '@/lib/random'
import { isWebsocketUrl, normalizeUrl } from '@/lib/url' import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
import storage from '@/services/storage.service' import storage from '@/services/local-storage.service'
import { TRelaySet } from '@/types' import { TRelaySet } from '@/types'
import { createContext, useContext, useEffect, useState } from 'react' import { createContext, useContext, useEffect, useState } from 'react'

View File

@@ -1,4 +1,4 @@
import storage from '@/services/storage.service' import storage from '@/services/local-storage.service'
import { TTheme, TThemeSetting } from '@/types' import { TTheme, TThemeSetting } from '@/types'
import { createContext, useContext, useEffect, useState } from 'react' import { createContext, useContext, useEffect, useState } from 'react'

View File

@@ -18,6 +18,7 @@ import {
VerifiedEvent VerifiedEvent
} from 'nostr-tools' } from 'nostr-tools'
import { AbstractRelay } from 'nostr-tools/abstract-relay' import { AbstractRelay } from 'nostr-tools/abstract-relay'
import indexedDb from './indexed-db.service'
type TTimelineRef = [string, number] type TTimelineRef = [string, number]
@@ -46,24 +47,29 @@ class ClientService extends EventTarget {
this.eventBatchLoadFn.bind(this), this.eventBatchLoadFn.bind(this),
{ cache: false } { cache: false }
) )
private profileEventCache = new LRUCache<string, Promise<NEvent | undefined>>({ max: 10000 })
private profileEventDataloader = new DataLoader<string, NEvent | undefined>( private profileEventDataloader = new DataLoader<string, NEvent | undefined>(
(ids) => Promise.all(ids.map((id) => this._fetchProfileEvent(id))), (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>( private fetchProfileEventFromDefaultRelaysDataloader = new DataLoader<string, NEvent | undefined>(
this.profileEventBatchLoadFn.bind(this), 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>( private relayListEventDataLoader = new DataLoader<string, NEvent | undefined>(
this.relayListEventBatchLoadFn.bind(this), this.relayListEventBatchLoadFn.bind(this),
{ {
cacheMap: new LRUCache<string, Promise<NEvent | undefined>>({ max: 10000 }), cacheMap: new LRUCache<string, Promise<NEvent | undefined>>({ max: 1000 }),
maxBatchSize: 50 maxBatchSize: 50
} }
) )
private followListCache = new LRUCache<string, Promise<NEvent | undefined>>({ private followListCache = new LRUCache<string, Promise<NEvent | undefined>>({
max: 10000, max: 2000,
fetchMethod: this._fetchFollowListEvent.bind(this) fetchMethod: this._fetchFollowListEvent.bind(this)
}) })
@@ -448,12 +454,6 @@ class ClientService extends EventTarget {
} }
async fetchProfileEvent(id: string): Promise<NEvent | undefined> { 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) return await this.profileEventDataloader.load(id)
} }
@@ -504,8 +504,12 @@ class ClientService extends EventTarget {
return getRelayListFromRelayListEvent(event) return getRelayListFromRelayListEvent(event)
} }
async fetchFollowListEvent(pubkey: string) { async fetchFollowListEvent(pubkey: string, storeToIndexedDb = false) {
return this.followListCache.fetch(pubkey) const event = await this.followListCache.fetch(pubkey)
if (storeToIndexedDb && event) {
await indexedDb.putReplaceableEvent(event)
}
return event
} }
async fetchFollowings(pubkey: string) { 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) const followings = await this.fetchFollowings(pubkey)
for (let i = 0; i * 50 < followings.length; i++) { 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 this.profileEventDataloader.loadMany(followings.slice(i * 50, (i + 1) * 50))
await new Promise((resolve) => setTimeout(resolve, 30000)) await new Promise((resolve) => setTimeout(resolve, 30000))
} }
@@ -686,10 +692,16 @@ class ClientService extends EventTarget {
if (!pubkey) { if (!pubkey) {
throw new Error('Invalid id') throw new Error('Invalid id')
} }
const localProfile = await indexedDb.getReplaceableEvent(pubkey, kinds.Metadata)
if (localProfile) {
this.addUsernameToIndex(localProfile)
return localProfile
}
const profileFromDefaultRelays = const profileFromDefaultRelays =
await this.fetchProfileEventFromDefaultRelaysDataloader.load(pubkey) await this.fetchProfileEventFromDefaultRelaysDataloader.load(pubkey)
if (profileFromDefaultRelays) { if (profileFromDefaultRelays) {
this.addUsernameToIndex(profileFromDefaultRelays) this.addUsernameToIndex(profileFromDefaultRelays)
await indexedDb.putReplaceableEvent(profileFromDefaultRelays)
return profileFromDefaultRelays return profileFromDefaultRelays
} }
@@ -707,7 +719,10 @@ class ClientService extends EventTarget {
} }
if (profileEvent) { if (profileEvent) {
await this.addUsernameToIndex(profileEvent) await Promise.allSettled([
this.addUsernameToIndex(profileEvent),
indexedDb.putReplaceableEvent(profileEvent)
])
} }
return profileEvent return profileEvent
@@ -778,13 +793,24 @@ class ClientService extends EventTarget {
eventsMap.set(pubkey, event) eventsMap.set(pubkey, event)
} }
} }
const profileEvents = pubkeys.map((pubkey) => {
return pubkeys.map((pubkey) => {
return eventsMap.get(pubkey) return eventsMap.get(pubkey)
}) })
await Promise.allSettled(
profileEvents.map(
(profileEvent) => profileEvent && indexedDb.putReplaceableEvent(profileEvent)
)
)
return profileEvents
} }
private async relayListEventBatchLoadFn(pubkeys: readonly string[]) { private async relayListEventBatchLoadFn(pubkeys: readonly string[]) {
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, { const events = await this.query(this.defaultRelayUrls, {
authors: pubkeys as string[], authors: pubkeys as string[],
kinds: [kinds.RelayList], kinds: [kinds.RelayList],
@@ -798,8 +824,19 @@ class ClientService extends EventTarget {
eventsMap.set(pubkey, event) 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) { 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 FlexSearch from 'flexsearch'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import client from './client.service' import client from './client.service'
import indexedDb from './indexed-db.service'
class RelayInfoService { class RelayInfoService {
static instance: RelayInfoService static instance: RelayInfoService
@@ -52,7 +53,12 @@ class RelayInfoService {
} }
if (!query) { 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) const result = await this.relayInfoIndex.searchAsync(query)
@@ -128,6 +134,12 @@ class RelayInfoService {
} }
private async loadRelayInfos() { private async loadRelayInfos() {
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) let until: number = Math.round(Date.now() / 1000)
const since = until - 60 * 60 * 48 const since = until - 60 * 60 * 48
@@ -143,14 +155,19 @@ class RelayInfoService {
if (events.length === 0) { if (events.length === 0) {
break break
} }
await Promise.allSettled(events.map((event) => indexedDb.putRelayInfoEvent(event)))
until = events[events.length - 1].created_at - 1 until = events[events.length - 1].created_at - 1
const relayInfos = formatRelayInfoEvents(events) const relayInfos = formatRelayInfoEvents(events)
for (const relayInfo of relayInfos) { relayInfos.forEach((relayInfo) => this.addRelayInfo(relayInfo))
await this.addRelayInfo(relayInfo)
}
} }
this.relayUrlsForRandom = Array.from(this.relayInfoMap.keys()) this.relayUrlsForRandom = Array.from(this.relayInfoMap.keys())
} }
if (localRelayInfos.length === 0) {
await loadFromInternet()
} else {
loadFromInternet()
}
}
private async addRelayInfo(relayInfo: TNip66RelayInfo) { private async addRelayInfo(relayInfo: TNip66RelayInfo) {
const oldRelayInfo = this.relayInfoMap.get(relayInfo.url) const oldRelayInfo = this.relayInfoMap.get(relayInfo.url)

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