[Persisted] Fork web and native, make it synchronous on the web (#4872)
* Delete logic for legacy storage * Delete superfluous tests At this point these tests aren't testing anything useful, let's just get rid of them. * Inline store.ts methods into persisted/index.ts * Fork persisted/index.ts into index.web.ts * Remove non-essential code and comments from both forks * Remove async/await from web fork of persisted/index.ts * Remove unused return * Enforce that forked types matchzio/stable
parent
74b0318d89
commit
5bf7f3769d
|
@ -1,67 +0,0 @@
|
||||||
import type {LegacySchema} from '#/state/persisted/legacy'
|
|
||||||
|
|
||||||
export const ALICE_DID = 'did:plc:ALICE_DID'
|
|
||||||
export const BOB_DID = 'did:plc:BOB_DID'
|
|
||||||
|
|
||||||
export const LEGACY_DATA_DUMP: LegacySchema = {
|
|
||||||
session: {
|
|
||||||
data: {
|
|
||||||
service: 'https://bsky.social/',
|
|
||||||
did: ALICE_DID,
|
|
||||||
},
|
|
||||||
accounts: [
|
|
||||||
{
|
|
||||||
service: 'https://bsky.social',
|
|
||||||
did: ALICE_DID,
|
|
||||||
refreshJwt: 'refreshJwt',
|
|
||||||
accessJwt: 'accessJwt',
|
|
||||||
handle: 'alice.test',
|
|
||||||
email: 'alice@bsky.test',
|
|
||||||
displayName: 'Alice',
|
|
||||||
aviUrl: 'avi',
|
|
||||||
emailConfirmed: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
service: 'https://bsky.social',
|
|
||||||
did: BOB_DID,
|
|
||||||
refreshJwt: 'refreshJwt',
|
|
||||||
accessJwt: 'accessJwt',
|
|
||||||
handle: 'bob.test',
|
|
||||||
email: 'bob@bsky.test',
|
|
||||||
displayName: 'Bob',
|
|
||||||
aviUrl: 'avi',
|
|
||||||
emailConfirmed: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
me: {
|
|
||||||
did: ALICE_DID,
|
|
||||||
handle: 'alice.test',
|
|
||||||
displayName: 'Alice',
|
|
||||||
description: '',
|
|
||||||
avatar: 'avi',
|
|
||||||
},
|
|
||||||
onboarding: {step: 'Home'},
|
|
||||||
shell: {colorMode: 'system'},
|
|
||||||
preferences: {
|
|
||||||
primaryLanguage: 'en',
|
|
||||||
contentLanguages: ['en'],
|
|
||||||
postLanguage: 'en',
|
|
||||||
postLanguageHistory: ['en', 'en', 'ja', 'pt', 'de', 'en'],
|
|
||||||
contentLabels: {
|
|
||||||
nsfw: 'warn',
|
|
||||||
nudity: 'warn',
|
|
||||||
suggestive: 'warn',
|
|
||||||
gore: 'warn',
|
|
||||||
hate: 'hide',
|
|
||||||
spam: 'hide',
|
|
||||||
impersonation: 'warn',
|
|
||||||
},
|
|
||||||
savedFeeds: ['feed_a', 'feed_b', 'feed_c'],
|
|
||||||
pinnedFeeds: ['feed_a', 'feed_b'],
|
|
||||||
requireAltTextEnabled: false,
|
|
||||||
},
|
|
||||||
invitedUsers: {seenDids: [], copiedInvites: []},
|
|
||||||
mutedThreads: {uris: []},
|
|
||||||
reminders: {},
|
|
||||||
}
|
|
|
@ -1,49 +0,0 @@
|
||||||
import {jest, expect, test, afterEach} from '@jest/globals'
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
||||||
|
|
||||||
import {defaults} from '#/state/persisted/schema'
|
|
||||||
import {migrate} from '#/state/persisted/legacy'
|
|
||||||
import * as store from '#/state/persisted/store'
|
|
||||||
import * as persisted from '#/state/persisted'
|
|
||||||
|
|
||||||
const write = jest.mocked(store.write)
|
|
||||||
const read = jest.mocked(store.read)
|
|
||||||
|
|
||||||
jest.mock('#/logger')
|
|
||||||
jest.mock('#/state/persisted/legacy', () => ({
|
|
||||||
migrate: jest.fn(),
|
|
||||||
}))
|
|
||||||
jest.mock('#/state/persisted/store', () => ({
|
|
||||||
write: jest.fn(),
|
|
||||||
read: jest.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.useFakeTimers()
|
|
||||||
jest.clearAllMocks()
|
|
||||||
AsyncStorage.clear()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('init: fresh install, no migration', async () => {
|
|
||||||
await persisted.init()
|
|
||||||
|
|
||||||
expect(migrate).toHaveBeenCalledTimes(1)
|
|
||||||
expect(read).toHaveBeenCalledTimes(1)
|
|
||||||
expect(write).toHaveBeenCalledWith(defaults)
|
|
||||||
|
|
||||||
// default value
|
|
||||||
expect(persisted.get('colorMode')).toBe('system')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('init: fresh install, migration ran', async () => {
|
|
||||||
read.mockResolvedValueOnce(defaults)
|
|
||||||
|
|
||||||
await persisted.init()
|
|
||||||
|
|
||||||
expect(migrate).toHaveBeenCalledTimes(1)
|
|
||||||
expect(read).toHaveBeenCalledTimes(1)
|
|
||||||
expect(write).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
// default value
|
|
||||||
expect(persisted.get('colorMode')).toBe('system')
|
|
||||||
})
|
|
|
@ -1,93 +0,0 @@
|
||||||
import {jest, expect, test, afterEach} from '@jest/globals'
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
||||||
|
|
||||||
import {defaults, schema} from '#/state/persisted/schema'
|
|
||||||
import {transform, migrate} from '#/state/persisted/legacy'
|
|
||||||
import * as store from '#/state/persisted/store'
|
|
||||||
import {logger} from '#/logger'
|
|
||||||
import * as fixtures from '#/state/persisted/__tests__/fixtures'
|
|
||||||
|
|
||||||
const write = jest.mocked(store.write)
|
|
||||||
const read = jest.mocked(store.read)
|
|
||||||
|
|
||||||
jest.mock('#/logger')
|
|
||||||
jest.mock('#/state/persisted/store', () => ({
|
|
||||||
write: jest.fn(),
|
|
||||||
read: jest.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.clearAllMocks()
|
|
||||||
AsyncStorage.clear()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('migrate: fresh install', async () => {
|
|
||||||
await migrate()
|
|
||||||
|
|
||||||
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
|
|
||||||
expect(read).toHaveBeenCalledTimes(1)
|
|
||||||
expect(logger.debug).toHaveBeenCalledWith(
|
|
||||||
'persisted state: no migration needed',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('migrate: fresh install, existing new storage', async () => {
|
|
||||||
read.mockResolvedValueOnce(defaults)
|
|
||||||
|
|
||||||
await migrate()
|
|
||||||
|
|
||||||
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
|
|
||||||
expect(read).toHaveBeenCalledTimes(1)
|
|
||||||
expect(logger.debug).toHaveBeenCalledWith(
|
|
||||||
'persisted state: no migration needed',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('migrate: fresh install, AsyncStorage error', async () => {
|
|
||||||
const prevGetItem = AsyncStorage.getItem
|
|
||||||
|
|
||||||
const error = new Error('test error')
|
|
||||||
|
|
||||||
AsyncStorage.getItem = jest.fn(() => {
|
|
||||||
throw error
|
|
||||||
})
|
|
||||||
|
|
||||||
await migrate()
|
|
||||||
|
|
||||||
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
|
|
||||||
expect(logger.error).toHaveBeenCalledWith(error, {
|
|
||||||
message: 'persisted state: error migrating legacy storage',
|
|
||||||
})
|
|
||||||
|
|
||||||
AsyncStorage.getItem = prevGetItem
|
|
||||||
})
|
|
||||||
|
|
||||||
test('migrate: has legacy data', async () => {
|
|
||||||
await AsyncStorage.setItem('root', JSON.stringify(fixtures.LEGACY_DATA_DUMP))
|
|
||||||
|
|
||||||
await migrate()
|
|
||||||
|
|
||||||
expect(write).toHaveBeenCalledWith(transform(fixtures.LEGACY_DATA_DUMP))
|
|
||||||
expect(logger.debug).toHaveBeenCalledWith(
|
|
||||||
'persisted state: migrated legacy storage',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('migrate: has legacy data, fails validation', async () => {
|
|
||||||
const legacy = fixtures.LEGACY_DATA_DUMP
|
|
||||||
// @ts-ignore
|
|
||||||
legacy.shell.colorMode = 'invalid'
|
|
||||||
await AsyncStorage.setItem('root', JSON.stringify(legacy))
|
|
||||||
|
|
||||||
await migrate()
|
|
||||||
|
|
||||||
const transformed = transform(legacy)
|
|
||||||
const validate = schema.safeParse(transformed)
|
|
||||||
|
|
||||||
expect(write).not.toHaveBeenCalled()
|
|
||||||
expect(logger.error).toHaveBeenCalledWith(
|
|
||||||
'persisted state: legacy data failed validation',
|
|
||||||
// @ts-ignore
|
|
||||||
{message: validate.error},
|
|
||||||
)
|
|
||||||
})
|
|
|
@ -1,21 +0,0 @@
|
||||||
import {expect, test} from '@jest/globals'
|
|
||||||
|
|
||||||
import {transform} from '#/state/persisted/legacy'
|
|
||||||
import {defaults, schema} from '#/state/persisted/schema'
|
|
||||||
import * as fixtures from '#/state/persisted/__tests__/fixtures'
|
|
||||||
|
|
||||||
test('defaults', () => {
|
|
||||||
expect(() => schema.parse(defaults)).not.toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('transform', () => {
|
|
||||||
const data = transform({})
|
|
||||||
expect(() => schema.parse(data)).not.toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('transform: legacy fixture', () => {
|
|
||||||
const data = transform(fixtures.LEGACY_DATA_DUMP)
|
|
||||||
expect(() => schema.parse(data)).not.toThrow()
|
|
||||||
expect(data.session.currentAccount?.did).toEqual(fixtures.ALICE_DID)
|
|
||||||
expect(data.session.accounts.length).toEqual(2)
|
|
||||||
})
|
|
|
@ -1,49 +1,35 @@
|
||||||
import EventEmitter from 'eventemitter3'
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
|
|
||||||
import BroadcastChannel from '#/lib/broadcast'
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {migrate} from '#/state/persisted/legacy'
|
import {defaults, Schema, schema} from '#/state/persisted/schema'
|
||||||
import {defaults, Schema} from '#/state/persisted/schema'
|
import {PersistedApi} from './types'
|
||||||
import * as store from '#/state/persisted/store'
|
|
||||||
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
|
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
|
||||||
export {defaults} from '#/state/persisted/schema'
|
export {defaults} from '#/state/persisted/schema'
|
||||||
|
|
||||||
const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL')
|
const BSKY_STORAGE = 'BSKY_STORAGE'
|
||||||
const UPDATE_EVENT = 'BSKY_UPDATE'
|
|
||||||
|
|
||||||
let _state: Schema = defaults
|
let _state: Schema = defaults
|
||||||
const _emitter = new EventEmitter()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes and returns persisted data state, so that it can be passed to
|
|
||||||
* the Provider.
|
|
||||||
*/
|
|
||||||
export async function init() {
|
export async function init() {
|
||||||
logger.debug('persisted state: initializing')
|
|
||||||
|
|
||||||
broadcast.onmessage = onBroadcastMessage
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await migrate() // migrate old store
|
const stored = await readFromStorage()
|
||||||
const stored = await store.read() // check for new store
|
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
logger.debug('persisted state: initializing default storage')
|
await writeToStorage(defaults)
|
||||||
await store.write(defaults) // opt: init new store
|
|
||||||
}
|
}
|
||||||
_state = stored || defaults // return new store
|
_state = stored || defaults
|
||||||
logger.debug('persisted state: initialized')
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('persisted state: failed to load root state from storage', {
|
logger.error('persisted state: failed to load root state from storage', {
|
||||||
message: e,
|
message: e,
|
||||||
})
|
})
|
||||||
// AsyncStorage failure, but we can still continue in memory
|
|
||||||
return defaults
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
init satisfies PersistedApi['init']
|
||||||
|
|
||||||
export function get<K extends keyof Schema>(key: K): Schema[K] {
|
export function get<K extends keyof Schema>(key: K): Schema[K] {
|
||||||
return _state[key]
|
return _state[key]
|
||||||
}
|
}
|
||||||
|
get satisfies PersistedApi['get']
|
||||||
|
|
||||||
export async function write<K extends keyof Schema>(
|
export async function write<K extends keyof Schema>(
|
||||||
key: K,
|
key: K,
|
||||||
|
@ -51,47 +37,55 @@ export async function write<K extends keyof Schema>(
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
_state[key] = value
|
_state[key] = value
|
||||||
await store.write(_state)
|
await writeToStorage(_state)
|
||||||
// must happen on next tick, otherwise the tab will read stale storage data
|
|
||||||
setTimeout(() => broadcast.postMessage({event: UPDATE_EVENT}), 0)
|
|
||||||
logger.debug(`persisted state: wrote root state to storage`, {
|
|
||||||
updatedKey: key,
|
|
||||||
})
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`persisted state: failed writing root state to storage`, {
|
logger.error(`persisted state: failed writing root state to storage`, {
|
||||||
message: e,
|
message: e,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
write satisfies PersistedApi['write']
|
||||||
|
|
||||||
export function onUpdate(cb: () => void): () => void {
|
export function onUpdate(_cb: () => void): () => void {
|
||||||
_emitter.addListener('update', cb)
|
return () => {}
|
||||||
return () => _emitter.removeListener('update', cb)
|
|
||||||
}
|
}
|
||||||
|
onUpdate satisfies PersistedApi['onUpdate']
|
||||||
|
|
||||||
async function onBroadcastMessage({data}: MessageEvent) {
|
export async function clearStorage() {
|
||||||
// validate event
|
|
||||||
if (typeof data === 'object' && data.event === UPDATE_EVENT) {
|
|
||||||
try {
|
try {
|
||||||
// read next state, possibly updated by another tab
|
await AsyncStorage.removeItem(BSKY_STORAGE)
|
||||||
const next = await store.read()
|
} catch (e: any) {
|
||||||
|
logger.error(`persisted store: failed to clear`, {message: e.toString()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearStorage satisfies PersistedApi['clearStorage']
|
||||||
|
|
||||||
if (next) {
|
async function writeToStorage(value: Schema) {
|
||||||
logger.debug(`persisted state: handling update from broadcast channel`)
|
schema.parse(value)
|
||||||
_state = next
|
await AsyncStorage.setItem(BSKY_STORAGE, JSON.stringify(value))
|
||||||
_emitter.emit('update')
|
}
|
||||||
|
|
||||||
|
async function readFromStorage(): Promise<Schema | undefined> {
|
||||||
|
const rawData = await AsyncStorage.getItem(BSKY_STORAGE)
|
||||||
|
const objData = rawData ? JSON.parse(rawData) : undefined
|
||||||
|
|
||||||
|
// new user
|
||||||
|
if (!objData) return undefined
|
||||||
|
|
||||||
|
// existing user, validate
|
||||||
|
const parsed = schema.safeParse(objData)
|
||||||
|
|
||||||
|
if (parsed.success) {
|
||||||
|
return objData
|
||||||
} else {
|
} else {
|
||||||
logger.error(
|
const errors =
|
||||||
`persisted state: handled update update from broadcast channel, but found no data`,
|
parsed.error?.errors?.map(e => ({
|
||||||
)
|
code: e.code,
|
||||||
}
|
// @ts-ignore exists on some types
|
||||||
} catch (e) {
|
expected: e?.expected,
|
||||||
logger.error(
|
path: e.path?.join('.'),
|
||||||
`persisted state: failed handling update from broadcast channel`,
|
})) || []
|
||||||
{
|
logger.error(`persisted store: data failed validation on read`, {errors})
|
||||||
message: e,
|
return undefined
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,126 @@
|
||||||
|
import EventEmitter from 'eventemitter3'
|
||||||
|
|
||||||
|
import BroadcastChannel from '#/lib/broadcast'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {defaults, Schema, schema} from '#/state/persisted/schema'
|
||||||
|
import {PersistedApi} from './types'
|
||||||
|
|
||||||
|
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
|
||||||
|
export {defaults} from '#/state/persisted/schema'
|
||||||
|
|
||||||
|
const BSKY_STORAGE = 'BSKY_STORAGE'
|
||||||
|
|
||||||
|
const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL')
|
||||||
|
const UPDATE_EVENT = 'BSKY_UPDATE'
|
||||||
|
|
||||||
|
let _state: Schema = defaults
|
||||||
|
const _emitter = new EventEmitter()
|
||||||
|
|
||||||
|
export async function init() {
|
||||||
|
broadcast.onmessage = onBroadcastMessage
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = readFromStorage()
|
||||||
|
if (!stored) {
|
||||||
|
writeToStorage(defaults)
|
||||||
|
}
|
||||||
|
_state = stored || defaults
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('persisted state: failed to load root state from storage', {
|
||||||
|
message: e,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
init satisfies PersistedApi['init']
|
||||||
|
|
||||||
|
export function get<K extends keyof Schema>(key: K): Schema[K] {
|
||||||
|
return _state[key]
|
||||||
|
}
|
||||||
|
get satisfies PersistedApi['get']
|
||||||
|
|
||||||
|
export async function write<K extends keyof Schema>(
|
||||||
|
key: K,
|
||||||
|
value: Schema[K],
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
_state[key] = value
|
||||||
|
writeToStorage(_state)
|
||||||
|
// must happen on next tick, otherwise the tab will read stale storage data
|
||||||
|
setTimeout(() => broadcast.postMessage({event: UPDATE_EVENT}), 0)
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`persisted state: failed writing root state to storage`, {
|
||||||
|
message: e,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write satisfies PersistedApi['write']
|
||||||
|
|
||||||
|
export function onUpdate(cb: () => void): () => void {
|
||||||
|
_emitter.addListener('update', cb)
|
||||||
|
return () => _emitter.removeListener('update', cb)
|
||||||
|
}
|
||||||
|
onUpdate satisfies PersistedApi['onUpdate']
|
||||||
|
|
||||||
|
export async function clearStorage() {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(BSKY_STORAGE)
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error(`persisted store: failed to clear`, {message: e.toString()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearStorage satisfies PersistedApi['clearStorage']
|
||||||
|
|
||||||
|
async function onBroadcastMessage({data}: MessageEvent) {
|
||||||
|
if (typeof data === 'object' && data.event === UPDATE_EVENT) {
|
||||||
|
try {
|
||||||
|
// read next state, possibly updated by another tab
|
||||||
|
const next = readFromStorage()
|
||||||
|
|
||||||
|
if (next) {
|
||||||
|
_state = next
|
||||||
|
_emitter.emit('update')
|
||||||
|
} else {
|
||||||
|
logger.error(
|
||||||
|
`persisted state: handled update update from broadcast channel, but found no data`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
`persisted state: failed handling update from broadcast channel`,
|
||||||
|
{
|
||||||
|
message: e,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeToStorage(value: Schema) {
|
||||||
|
schema.parse(value)
|
||||||
|
localStorage.setItem(BSKY_STORAGE, JSON.stringify(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFromStorage(): Schema | undefined {
|
||||||
|
const rawData = localStorage.getItem(BSKY_STORAGE)
|
||||||
|
const objData = rawData ? JSON.parse(rawData) : undefined
|
||||||
|
|
||||||
|
// new user
|
||||||
|
if (!objData) return undefined
|
||||||
|
|
||||||
|
// existing user, validate
|
||||||
|
const parsed = schema.safeParse(objData)
|
||||||
|
|
||||||
|
if (parsed.success) {
|
||||||
|
return objData
|
||||||
|
} else {
|
||||||
|
const errors =
|
||||||
|
parsed.error?.errors?.map(e => ({
|
||||||
|
code: e.code,
|
||||||
|
// @ts-ignore exists on some types
|
||||||
|
expected: e?.expected,
|
||||||
|
path: e.path?.join('.'),
|
||||||
|
})) || []
|
||||||
|
logger.error(`persisted store: data failed validation on read`, {errors})
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,167 +0,0 @@
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
||||||
|
|
||||||
import {logger} from '#/logger'
|
|
||||||
import {defaults, Schema, schema} from '#/state/persisted/schema'
|
|
||||||
import {read, write} from '#/state/persisted/store'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The shape of the serialized data from our legacy Mobx store.
|
|
||||||
*/
|
|
||||||
export type LegacySchema = {
|
|
||||||
shell: {
|
|
||||||
colorMode: 'system' | 'light' | 'dark'
|
|
||||||
}
|
|
||||||
session: {
|
|
||||||
data: {
|
|
||||||
service: string
|
|
||||||
did: `did:plc:${string}`
|
|
||||||
} | null
|
|
||||||
accounts: {
|
|
||||||
service: string
|
|
||||||
did: `did:plc:${string}`
|
|
||||||
refreshJwt: string
|
|
||||||
accessJwt: string
|
|
||||||
handle: string
|
|
||||||
email: string
|
|
||||||
displayName: string
|
|
||||||
aviUrl: string
|
|
||||||
emailConfirmed: boolean
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
me: {
|
|
||||||
did: `did:plc:${string}`
|
|
||||||
handle: string
|
|
||||||
displayName: string
|
|
||||||
description: string
|
|
||||||
avatar: string
|
|
||||||
}
|
|
||||||
onboarding: {
|
|
||||||
step: string
|
|
||||||
}
|
|
||||||
preferences: {
|
|
||||||
primaryLanguage: string
|
|
||||||
contentLanguages: string[]
|
|
||||||
postLanguage: string
|
|
||||||
postLanguageHistory: string[]
|
|
||||||
contentLabels: {
|
|
||||||
nsfw: string
|
|
||||||
nudity: string
|
|
||||||
suggestive: string
|
|
||||||
gore: string
|
|
||||||
hate: string
|
|
||||||
spam: string
|
|
||||||
impersonation: string
|
|
||||||
}
|
|
||||||
savedFeeds: string[]
|
|
||||||
pinnedFeeds: string[]
|
|
||||||
requireAltTextEnabled: boolean
|
|
||||||
}
|
|
||||||
invitedUsers: {
|
|
||||||
seenDids: string[]
|
|
||||||
copiedInvites: string[]
|
|
||||||
}
|
|
||||||
mutedThreads: {uris: string[]}
|
|
||||||
reminders: {lastEmailConfirm?: string}
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEPRECATED_ROOT_STATE_STORAGE_KEY = 'root'
|
|
||||||
|
|
||||||
export function transform(legacy: Partial<LegacySchema>): Schema {
|
|
||||||
return {
|
|
||||||
colorMode: legacy.shell?.colorMode || defaults.colorMode,
|
|
||||||
darkTheme: defaults.darkTheme,
|
|
||||||
session: {
|
|
||||||
accounts: legacy.session?.accounts || defaults.session.accounts,
|
|
||||||
currentAccount:
|
|
||||||
legacy.session?.accounts?.find(
|
|
||||||
a => a.did === legacy.session?.data?.did,
|
|
||||||
) || defaults.session.currentAccount,
|
|
||||||
},
|
|
||||||
reminders: {
|
|
||||||
lastEmailConfirm:
|
|
||||||
legacy.reminders?.lastEmailConfirm ||
|
|
||||||
defaults.reminders.lastEmailConfirm,
|
|
||||||
},
|
|
||||||
languagePrefs: {
|
|
||||||
primaryLanguage:
|
|
||||||
legacy.preferences?.primaryLanguage ||
|
|
||||||
defaults.languagePrefs.primaryLanguage,
|
|
||||||
contentLanguages:
|
|
||||||
legacy.preferences?.contentLanguages ||
|
|
||||||
defaults.languagePrefs.contentLanguages,
|
|
||||||
postLanguage:
|
|
||||||
legacy.preferences?.postLanguage || defaults.languagePrefs.postLanguage,
|
|
||||||
postLanguageHistory:
|
|
||||||
legacy.preferences?.postLanguageHistory ||
|
|
||||||
defaults.languagePrefs.postLanguageHistory,
|
|
||||||
appLanguage:
|
|
||||||
legacy.preferences?.primaryLanguage ||
|
|
||||||
defaults.languagePrefs.appLanguage,
|
|
||||||
},
|
|
||||||
requireAltTextEnabled:
|
|
||||||
legacy.preferences?.requireAltTextEnabled ||
|
|
||||||
defaults.requireAltTextEnabled,
|
|
||||||
mutedThreads: legacy.mutedThreads?.uris || defaults.mutedThreads,
|
|
||||||
invites: {
|
|
||||||
copiedInvites:
|
|
||||||
legacy.invitedUsers?.copiedInvites || defaults.invites.copiedInvites,
|
|
||||||
},
|
|
||||||
onboarding: {
|
|
||||||
step: legacy.onboarding?.step || defaults.onboarding.step,
|
|
||||||
},
|
|
||||||
hiddenPosts: defaults.hiddenPosts,
|
|
||||||
externalEmbeds: defaults.externalEmbeds,
|
|
||||||
lastSelectedHomeFeed: defaults.lastSelectedHomeFeed,
|
|
||||||
pdsAddressHistory: defaults.pdsAddressHistory,
|
|
||||||
disableHaptics: defaults.disableHaptics,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrates legacy persisted state to new store if new store doesn't exist in
|
|
||||||
* local storage AND old storage exists.
|
|
||||||
*/
|
|
||||||
export async function migrate() {
|
|
||||||
logger.debug('persisted state: check need to migrate')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rawLegacyData = await AsyncStorage.getItem(
|
|
||||||
DEPRECATED_ROOT_STATE_STORAGE_KEY,
|
|
||||||
)
|
|
||||||
const newData = await read()
|
|
||||||
const alreadyMigrated = Boolean(newData)
|
|
||||||
|
|
||||||
if (!alreadyMigrated && rawLegacyData) {
|
|
||||||
logger.debug('persisted state: migrating legacy storage')
|
|
||||||
|
|
||||||
const legacyData = JSON.parse(rawLegacyData)
|
|
||||||
const newData = transform(legacyData)
|
|
||||||
const validate = schema.safeParse(newData)
|
|
||||||
|
|
||||||
if (validate.success) {
|
|
||||||
await write(newData)
|
|
||||||
logger.debug('persisted state: migrated legacy storage')
|
|
||||||
} else {
|
|
||||||
logger.error('persisted state: legacy data failed validation', {
|
|
||||||
message: validate.error,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.debug('persisted state: no migration needed')
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
logger.error(e, {
|
|
||||||
message: 'persisted state: error migrating legacy storage',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearLegacyStorage() {
|
|
||||||
try {
|
|
||||||
await AsyncStorage.removeItem(DEPRECATED_ROOT_STATE_STORAGE_KEY)
|
|
||||||
} catch (e: any) {
|
|
||||||
logger.error(`persisted legacy store: failed to clear`, {
|
|
||||||
message: e.toString(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,44 +0,0 @@
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
||||||
|
|
||||||
import {logger} from '#/logger'
|
|
||||||
import {Schema, schema} from '#/state/persisted/schema'
|
|
||||||
|
|
||||||
const BSKY_STORAGE = 'BSKY_STORAGE'
|
|
||||||
|
|
||||||
export async function write(value: Schema) {
|
|
||||||
schema.parse(value)
|
|
||||||
await AsyncStorage.setItem(BSKY_STORAGE, JSON.stringify(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function read(): Promise<Schema | undefined> {
|
|
||||||
const rawData = await AsyncStorage.getItem(BSKY_STORAGE)
|
|
||||||
const objData = rawData ? JSON.parse(rawData) : undefined
|
|
||||||
|
|
||||||
// new user
|
|
||||||
if (!objData) return undefined
|
|
||||||
|
|
||||||
// existing user, validate
|
|
||||||
const parsed = schema.safeParse(objData)
|
|
||||||
|
|
||||||
if (parsed.success) {
|
|
||||||
return objData
|
|
||||||
} else {
|
|
||||||
const errors =
|
|
||||||
parsed.error?.errors?.map(e => ({
|
|
||||||
code: e.code,
|
|
||||||
// @ts-ignore exists on some types
|
|
||||||
expected: e?.expected,
|
|
||||||
path: e.path?.join('.'),
|
|
||||||
})) || []
|
|
||||||
logger.error(`persisted store: data failed validation on read`, {errors})
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clear() {
|
|
||||||
try {
|
|
||||||
await AsyncStorage.removeItem(BSKY_STORAGE)
|
|
||||||
} catch (e: any) {
|
|
||||||
logger.error(`persisted store: failed to clear`, {message: e.toString()})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
import type {Schema} from './schema'
|
||||||
|
|
||||||
|
export type PersistedApi = {
|
||||||
|
init(): Promise<void>
|
||||||
|
get<K extends keyof Schema>(key: K): Schema[K]
|
||||||
|
write<K extends keyof Schema>(key: K, value: Schema[K]): Promise<void>
|
||||||
|
onUpdate(_cb: () => void): () => void
|
||||||
|
clearStorage: () => Promise<void>
|
||||||
|
}
|
|
@ -20,8 +20,7 @@ import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
import {useModalControls} from '#/state/modals'
|
import {useModalControls} from '#/state/modals'
|
||||||
import {clearLegacyStorage} from '#/state/persisted/legacy'
|
import {clearStorage} from '#/state/persisted'
|
||||||
import {clear as clearStorage} from '#/state/persisted/store'
|
|
||||||
import {
|
import {
|
||||||
useInAppBrowser,
|
useInAppBrowser,
|
||||||
useSetInAppBrowser,
|
useSetInAppBrowser,
|
||||||
|
@ -299,10 +298,6 @@ export function SettingsScreen({}: Props) {
|
||||||
await clearStorage()
|
await clearStorage()
|
||||||
Toast.show(_(msg`Storage cleared, you need to restart the app now.`))
|
Toast.show(_(msg`Storage cleared, you need to restart the app now.`))
|
||||||
}, [_])
|
}, [_])
|
||||||
const clearAllLegacyStorage = React.useCallback(async () => {
|
|
||||||
await clearLegacyStorage()
|
|
||||||
Toast.show(_(msg`Legacy storage cleared, you need to restart the app now.`))
|
|
||||||
}, [_])
|
|
||||||
|
|
||||||
const deactivateAccountControl = useDialogControl()
|
const deactivateAccountControl = useDialogControl()
|
||||||
const onPressDeactivateAccount = React.useCallback(() => {
|
const onPressDeactivateAccount = React.useCallback(() => {
|
||||||
|
@ -863,18 +858,6 @@ export function SettingsScreen({}: Props) {
|
||||||
<Trans>Reset onboarding state</Trans>
|
<Trans>Reset onboarding state</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
|
||||||
style={[pal.view, styles.linkCardNoIcon]}
|
|
||||||
onPress={clearAllLegacyStorage}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`Clear all legacy storage data`)}
|
|
||||||
accessibilityHint={_(msg`Clears all legacy storage data`)}>
|
|
||||||
<Text type="lg" style={pal.text}>
|
|
||||||
<Trans>
|
|
||||||
Clear all legacy storage data (restart after this)
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[pal.view, styles.linkCardNoIcon]}
|
style={[pal.view, styles.linkCardNoIcon]}
|
||||||
onPress={clearAllStorage}
|
onPress={clearAllStorage}
|
||||||
|
|
Loading…
Reference in New Issue