From d5f46690c447774de82b48c2e35624d696b2347f Mon Sep 17 00:00:00 2001 From: Cody Tseng Date: Wed, 12 Feb 2025 22:09:00 +0800 Subject: [PATCH] perf: improve loading speed (#116) --- src/components/MailboxSetting/SaveButton.tsx | 2 +- src/components/NoteList/index.tsx | 2 +- src/constants.ts | 10 +- src/hooks/useFetchProfile.tsx | 5 +- .../secondary/ProfileEditorPage/index.tsx | 2 +- src/providers/FeedProvider.tsx | 2 +- src/providers/FollowListProvider.tsx | 23 +- src/providers/MuteListProvider.tsx | 19 +- src/providers/NostrProvider/index.tsx | 138 ++++--- src/providers/RelaySetsProvider.tsx | 2 +- src/providers/ThemeProvider.tsx | 2 +- src/services/client.service.ts | 95 +++-- src/services/indexed-db.service.ts | 271 +++++++++++++ src/services/local-storage.service.ts | 215 ++++++++++ src/services/relay-info.service.ts | 61 +-- src/services/storage.service.ts | 370 ------------------ 16 files changed, 705 insertions(+), 514 deletions(-) create mode 100644 src/services/indexed-db.service.ts create mode 100644 src/services/local-storage.service.ts delete mode 100644 src/services/storage.service.ts diff --git a/src/components/MailboxSetting/SaveButton.tsx b/src/components/MailboxSetting/SaveButton.tsx index 8e53733c..221b9adf 100644 --- a/src/components/MailboxSetting/SaveButton.tsx +++ b/src/components/MailboxSetting/SaveButton.tsx @@ -25,7 +25,7 @@ export default function SaveButton({ setPushing(true) const event = createRelayListDraftEvent(mailboxRelays) const relayListEvent = await publish(event) - updateRelayListEvent(relayListEvent) + await updateRelayListEvent(relayListEvent) toast({ title: 'Save Successful', description: 'Successfully saved mailbox relays' diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index cbb7b1bf..bd42d3bd 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -10,7 +10,7 @@ import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import client from '@/services/client.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 dayjs from 'dayjs' import { Event, Filter, kinds } from 'nostr-tools' diff --git a/src/constants.ts b/src/constants.ts index 3d4ce299..cf15a409 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -7,11 +7,11 @@ export const StorageKey = { CURRENT_ACCOUNT: 'currentAccount', ADD_CLIENT_TAG: 'addClientTag', NOTE_LIST_MODE: 'noteListMode', - ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', - ACCOUNT_FOLLOW_LIST_EVENT_MAP: 'accountFollowListEventMap', - ACCOUNT_MUTE_LIST_EVENT_MAP: 'accountMuteListEventMap', - ACCOUNT_MUTE_DECRYPTED_TAGS_MAP: 'accountMuteDecryptedTagsMap', - ACCOUNT_PROFILE_EVENT_MAP: 'accountProfileEventMap' + ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated + ACCOUNT_FOLLOW_LIST_EVENT_MAP: 'accountFollowListEventMap', // deprecated + ACCOUNT_MUTE_LIST_EVENT_MAP: 'accountMuteListEventMap', // deprecated + ACCOUNT_MUTE_DECRYPTED_TAGS_MAP: 'accountMuteDecryptedTagsMap', // deprecated + ACCOUNT_PROFILE_EVENT_MAP: 'accountProfileEventMap' // deprecated } export const BIG_RELAY_URLS = [ diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx index ba69e1eb..214301df 100644 --- a/src/hooks/useFetchProfile.tsx +++ b/src/hooks/useFetchProfile.tsx @@ -2,8 +2,9 @@ import { getProfileFromProfileEvent } from '@/lib/event' import { userIdToPubkey } from '@/lib/pubkey' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' -import storage from '@/services/storage.service' +import indexedDb from '@/services/indexed-db.service' import { TProfile } from '@/types' +import { kinds } from 'nostr-tools' import { useEffect, useState } from 'react' export function useFetchProfile(id?: string) { @@ -27,7 +28,7 @@ export function useFetchProfile(id?: string) { const pubkey = userIdToPubkey(id) setPubkey(pubkey) - const storedProfileEvent = storage.getAccountProfileEvent(pubkey) + const storedProfileEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.Metadata) if (storedProfileEvent) { const profile = getProfileFromProfileEvent(storedProfileEvent) setProfile(profile) diff --git a/src/pages/secondary/ProfileEditorPage/index.tsx b/src/pages/secondary/ProfileEditorPage/index.tsx index acf59e1d..012ce61a 100644 --- a/src/pages/secondary/ProfileEditorPage/index.tsx +++ b/src/pages/secondary/ProfileEditorPage/index.tsx @@ -74,7 +74,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { profileEvent?.tags ) const newProfileEvent = await publish(profileDraftEvent) - updateProfileEvent(newProfileEvent) + await updateProfileEvent(newProfileEvent) setSaving(false) pop() } diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx index 6b71c26d..efd79e7c 100644 --- a/src/providers/FeedProvider.tsx +++ b/src/providers/FeedProvider.tsx @@ -2,7 +2,7 @@ import { BIG_RELAY_URLS } from '@/constants' import { checkAlgoRelay } from '@/lib/relay' import { isWebsocketUrl, normalizeUrl } from '@/lib/url' 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 { TFeedType } from '@/types' import { Filter } from 'nostr-tools' diff --git a/src/providers/FollowListProvider.tsx b/src/providers/FollowListProvider.tsx index 00500e69..aefd0bf1 100644 --- a/src/providers/FollowListProvider.tsx +++ b/src/providers/FollowListProvider.tsx @@ -1,8 +1,8 @@ import { createFollowListDraftEvent } from '@/lib/draft-event' import { extractPubkeysFromEventTags } from '@/lib/tag' import client from '@/services/client.service' -import storage from '@/services/storage.service' -import { Event } from 'nostr-tools' +import indexedDb from '@/services/indexed-db.service' +import { Event, kinds } from 'nostr-tools' import { createContext, useContext, useEffect, useMemo, useState } from 'react' import { useNostr } from './NostrProvider' @@ -40,13 +40,16 @@ export function FollowListProvider({ children }: { children: React.ReactNode }) const init = async () => { setIsFetching(true) setFollowListEvent(undefined) - const storedFollowListEvent = storage.getAccountFollowListEvent(accountPubkey) + const storedFollowListEvent = await indexedDb.getReplaceableEvent( + accountPubkey, + kinds.Contacts + ) if (storedFollowListEvent) { setFollowListEvent(storedFollowListEvent) } - const event = await client.fetchFollowListEvent(accountPubkey) + const event = await client.fetchFollowListEvent(accountPubkey, true) if (event) { - updateFollowListEvent(event) + await updateFollowListEvent(event) } setIsFetching(false) } @@ -54,8 +57,8 @@ export function FollowListProvider({ children }: { children: React.ReactNode }) init() }, [accountPubkey]) - const updateFollowListEvent = (event: Event) => { - const isNew = storage.setAccountFollowListEvent(event) + const updateFollowListEvent = async (event: Event) => { + const isNew = await indexedDb.putReplaceableEvent(event) if (!isNew) return setFollowListEvent(event) } @@ -69,7 +72,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode }) ) const newFollowListEvent = await publish(newFollowListDraftEvent) client.updateFollowListCache(accountPubkey, newFollowListEvent) - updateFollowListEvent(newFollowListEvent) + await updateFollowListEvent(newFollowListEvent) } const unfollow = async (pubkey: string) => { @@ -81,11 +84,11 @@ export function FollowListProvider({ children }: { children: React.ReactNode }) ) const newFollowListEvent = await publish(newFollowListDraftEvent) client.updateFollowListCache(accountPubkey, newFollowListEvent) - updateFollowListEvent(newFollowListEvent) + await updateFollowListEvent(newFollowListEvent) } const getFollowings = async (pubkey: string) => { - const followListEvent = storage.getAccountFollowListEvent(pubkey) + const followListEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.Contacts) if (followListEvent) { return extractPubkeysFromEventTags(followListEvent.tags) } diff --git a/src/providers/MuteListProvider.tsx b/src/providers/MuteListProvider.tsx index 948f12dd..534ff18a 100644 --- a/src/providers/MuteListProvider.tsx +++ b/src/providers/MuteListProvider.tsx @@ -2,7 +2,7 @@ import { createMuteListDraftEvent } from '@/lib/draft-event' import { getLatestEvent } from '@/lib/event' import { extractPubkeysFromEventTags, isSameTag } from '@/lib/tag' 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 { createContext, useContext, useEffect, useMemo, useState } from 'react' import { z } from 'zod' @@ -35,7 +35,7 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { const init = async () => { setMuteListEvent(undefined) - const storedMuteListEvent = storage.getAccountMuteListEvent(accountPubkey) + const storedMuteListEvent = await indexedDb.getReplaceableEvent(accountPubkey, kinds.Mutelist) if (storedMuteListEvent) { setMuteListEvent(storedMuteListEvent) const tags = await extractMuteTags(storedMuteListEvent) @@ -47,6 +47,7 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { }) const muteEvent = getLatestEvent(events) as Event | undefined if (muteEvent) { + await indexedDb.putReplaceableEvent(muteEvent) setMuteListEvent(muteEvent) const tags = await extractMuteTags(muteEvent) setTags(tags) @@ -59,7 +60,7 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { const extractMuteTags = async (muteListEvent: Event) => { const tags = [...muteListEvent.tags] if (muteListEvent.content) { - const storedDecryptedTags = storage.getAccountMuteDecryptedTags(muteListEvent) + const storedDecryptedTags = await indexedDb.getMuteDecryptedTags(muteListEvent.id) if (storedDecryptedTags) { tags.push(...storedDecryptedTags) @@ -67,7 +68,7 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { try { const plainText = await nip04Decrypt(muteListEvent.pubkey, muteListEvent.content) 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)))) } catch (error) { console.error('Failed to decrypt mute list content', error) @@ -77,10 +78,10 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { return tags } - const update = (event: Event, tags: string[][]) => { - const isNew = storage.setAccountMuteListEvent(event) + const update = async (event: Event, tags: string[][]) => { + const isNew = await indexedDb.putReplaceableEvent(event) if (!isNew) return - storage.setAccountMuteDecryptedTags(event, tags) + await indexedDb.putMuteDecryptedTags(event.id, tags) setMuteListEvent(event) setTags(tags) } @@ -92,7 +93,7 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newTags)) const newMuteListDraftEvent = createMuteListDraftEvent(muteListEvent?.tags ?? [], cipherText) const newMuteListEvent = await publish(newMuteListDraftEvent) - update(newMuteListEvent, newTags) + await update(newMuteListEvent, newTags) } const unmutePubkey = async (pubkey: string) => { @@ -105,7 +106,7 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) { cipherText ) const newMuteListEvent = await publish(newMuteListDraftEvent) - update(newMuteListEvent, newTags) + await update(newMuteListEvent, newTags) } return ( diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 144adb5f..d805acae 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -4,7 +4,8 @@ import { useToast } from '@/hooks' import { getProfileFromProfileEvent, getRelayListFromRelayListEvent } from '@/lib/event' import { formatPubkey } from '@/lib/pubkey' 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 dayjs from 'dayjs' import { Event, kinds, VerifiedEvent } from 'nostr-tools' @@ -45,8 +46,8 @@ type TNostrContext = { startLogin: () => void checkLogin: (cb?: () => T) => Promise getRelayList: (pubkey: string) => Promise - updateRelayListEvent: (relayListEvent: Event) => void - updateProfileEvent: (profileEvent: Event) => void + updateRelayListEvent: (relayListEvent: Event) => Promise + updateProfileEvent: (profileEvent: Event) => Promise } const NostrContext = createContext(undefined) @@ -99,64 +100,79 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { }, []) useEffect(() => { - setRelayList(null) - setProfile(null) - setProfileEvent(null) - setNsec(null) - if (!account) { - return - } - - const storedNsec = storage.getAccountNsec(account.pubkey) - if (storedNsec) { - setNsec(storedNsec) - } else { + const init = async () => { + setRelayList(null) + setProfile(null) + setProfileEvent(null) setNsec(null) - } - const storedNcryptsec = storage.getAccountNcryptsec(account.pubkey) - if (storedNcryptsec) { - setNcryptsec(storedNcryptsec) - } else { - setNcryptsec(null) - } - const storedRelayListEvent = storage.getAccountRelayListEvent(account.pubkey) - if (storedRelayListEvent) { - setRelayList( - storedRelayListEvent ? getRelayListFromRelayListEvent(storedRelayListEvent) : null - ) - } - const storedProfileEvent = storage.getAccountProfileEvent(account.pubkey) - if (storedProfileEvent) { - setProfileEvent(storedProfileEvent) - setProfile(getProfileFromProfileEvent(storedProfileEvent)) - } - client.fetchRelayListEvent(account.pubkey).then(async (relayListEvent) => { - if (!relayListEvent) { - if (storedRelayListEvent) return - - setRelayList({ write: BIG_RELAY_URLS, read: BIG_RELAY_URLS, originalRelays: [] }) + if (!account) { return } - const isNew = storage.setAccountRelayListEvent(relayListEvent) - if (!isNew) return - setRelayList(getRelayListFromRelayListEvent(relayListEvent)) - }) - client.fetchProfileEvent(account.pubkey).then(async (profileEvent) => { - if (!profileEvent) { - if (storedProfileEvent) return - setProfile({ - pubkey: account.pubkey, - username: formatPubkey(account.pubkey) - }) - return + const controller = new AbortController() + const storedNsec = storage.getAccountNsec(account.pubkey) + if (storedNsec) { + setNsec(storedNsec) + } else { + setNsec(null) } - const isNew = storage.setAccountProfileEvent(profileEvent) - if (!isNew) return - setProfileEvent(profileEvent) - setProfile(getProfileFromProfileEvent(profileEvent)) - }) - client.initUserIndexFromFollowings(account.pubkey) + const storedNcryptsec = storage.getAccountNcryptsec(account.pubkey) + if (storedNcryptsec) { + setNcryptsec(storedNcryptsec) + } else { + setNcryptsec(null) + } + const [storedRelayListEvent, storedProfileEvent] = await Promise.all([ + indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList), + indexedDb.getReplaceableEvent(account.pubkey, kinds.Metadata) + ]) + if (storedRelayListEvent) { + setRelayList( + storedRelayListEvent ? getRelayListFromRelayListEvent(storedRelayListEvent) : null + ) + } + if (storedProfileEvent) { + setProfileEvent(storedProfileEvent) + setProfile(getProfileFromProfileEvent(storedProfileEvent)) + } + + client.fetchRelayListEvent(account.pubkey).then(async (relayListEvent) => { + if (!relayListEvent) { + if (storedRelayListEvent) return + + setRelayList({ write: BIG_RELAY_URLS, read: BIG_RELAY_URLS, originalRelays: [] }) + return + } + const event = await indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList) + if (event) { + setRelayList(getRelayListFromRelayListEvent(event)) + } + }) + client.fetchProfileEvent(account.pubkey).then(async (profileEvent) => { + if (!profileEvent) { + if (storedProfileEvent) return + + setProfile({ + pubkey: account.pubkey, + username: formatPubkey(account.pubkey) + }) + return + } + const event = await indexedDb.getReplaceableEvent(account.pubkey, kinds.Metadata) + if (event) { + setProfileEvent(event) + setProfile(getProfileFromProfileEvent(event)) + } + }) + client.initUserIndexFromFollowings(account.pubkey, controller.signal) + return controller + } + const promise = init() + return () => { + promise.then((controller) => { + controller?.abort() + }) + } }, [account]) useEffect(() => { @@ -379,21 +395,21 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } const getRelayList = async (pubkey: string) => { - const storedRelayListEvent = storage.getAccountRelayListEvent(pubkey) + const storedRelayListEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.RelayList) if (storedRelayListEvent) { return getRelayListFromRelayListEvent(storedRelayListEvent) } return await client.fetchRelayList(pubkey) } - const updateRelayListEvent = (relayListEvent: Event) => { - const isNew = storage.setAccountRelayListEvent(relayListEvent) + const updateRelayListEvent = async (relayListEvent: Event) => { + const isNew = await indexedDb.putReplaceableEvent(relayListEvent) if (!isNew) return setRelayList(getRelayListFromRelayListEvent(relayListEvent)) } - const updateProfileEvent = (profileEvent: Event) => { - const isNew = storage.setAccountProfileEvent(profileEvent) + const updateProfileEvent = async (profileEvent: Event) => { + const isNew = await indexedDb.putReplaceableEvent(profileEvent) if (!isNew) return setProfileEvent(profileEvent) setProfile(getProfileFromProfileEvent(profileEvent)) diff --git a/src/providers/RelaySetsProvider.tsx b/src/providers/RelaySetsProvider.tsx index 491ed3d3..d79a43eb 100644 --- a/src/providers/RelaySetsProvider.tsx +++ b/src/providers/RelaySetsProvider.tsx @@ -1,6 +1,6 @@ import { randomString } from '@/lib/random' import { isWebsocketUrl, normalizeUrl } from '@/lib/url' -import storage from '@/services/storage.service' +import storage from '@/services/local-storage.service' import { TRelaySet } from '@/types' import { createContext, useContext, useEffect, useState } from 'react' diff --git a/src/providers/ThemeProvider.tsx b/src/providers/ThemeProvider.tsx index 3ae0dd7e..c244b25f 100644 --- a/src/providers/ThemeProvider.tsx +++ b/src/providers/ThemeProvider.tsx @@ -1,4 +1,4 @@ -import storage from '@/services/storage.service' +import storage from '@/services/local-storage.service' import { TTheme, TThemeSetting } from '@/types' import { createContext, useContext, useEffect, useState } from 'react' diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 5db22c1a..a68e99d5 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -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>({ max: 10000 }) private profileEventDataloader = new DataLoader( (ids) => Promise.all(ids.map((id) => this._fetchProfileEvent(id))), - { cacheMap: this.profileEventCache, maxBatchSize: 50 } + { + cache: false, + maxBatchSize: 50 + } ) private fetchProfileEventFromDefaultRelaysDataloader = new DataLoader( this.profileEventBatchLoadFn.bind(this), - { cache: false, maxBatchSize: 50 } + { + cacheMap: new LRUCache>({ max: 1000 }), + maxBatchSize: 50 + } ) private relayListEventDataLoader = new DataLoader( this.relayListEventBatchLoadFn.bind(this), { - cacheMap: new LRUCache>({ max: 10000 }), + cacheMap: new LRUCache>({ max: 1000 }), maxBatchSize: 50 } ) private followListCache = new LRUCache>({ - max: 10000, + max: 2000, fetchMethod: this._fetchFollowListEvent.bind(this) }) @@ -448,12 +454,6 @@ class ClientService extends EventTarget { } async fetchProfileEvent(id: string): Promise { - 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() - 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() + 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) { diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts new file mode 100644 index 00000000..d53f9459 --- /dev/null +++ b/src/services/indexed-db.service.ts @@ -0,0 +1,271 @@ +import { tagNameEquals } from '@/lib/tag' +import { Event, kinds } from 'nostr-tools' + +type TValue = { + 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 | null = null + + init(): Promise { + 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 { + 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 | 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 { + 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)?.value) + } + + request.onerror = (event) => { + reject(event) + } + }) + } + + async getMuteDecryptedTags(id: string): Promise { + 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)?.value) + } + + request.onerror = (event) => { + reject(event) + } + }) + } + + async putMuteDecryptedTags(id: string, tags: string[][]): Promise { + 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 { + 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[])?.map((item) => item.value)) + } + + request.onerror = (event) => { + reject(event) + } + }) + } + + async putRelayInfoEvent(event: Event): Promise { + 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(key: string, value: T): TValue { + 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((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 diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts new file mode 100644 index 00000000..ce511062 --- /dev/null +++ b/src/services/local-storage.service.ts @@ -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 diff --git a/src/services/relay-info.service.ts b/src/services/relay-info.service.ts index 0b30803c..35af7b53 100644 --- a/src/services/relay-info.service.ts +++ b/src/services/relay-info.service.ts @@ -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) { diff --git a/src/services/storage.service.ts b/src/services/storage.service.ts deleted file mode 100644 index 060ff391..00000000 --- a/src/services/storage.service.ts +++ /dev/null @@ -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 = {} // pubkey -> relayListEvent - private accountFollowListEventMap: Record = {} // pubkey -> followListEvent - private accountMuteListEventMap: Record = {} // pubkey -> muteListEvent - private accountMuteDecryptedTagsMap: Record< - string, - { id: string; tags: string[][] } | undefined - > = {} // pubkey -> { id, tags } - private accountProfileEventMap: Record = {} // 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