16 changed files with 705 additions and 514 deletions
@ -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 |
||||||
@ -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 |
||||||
@ -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 |
|
||||||
Loading…
Reference in new issue