Add persistent state provider (#1830)
* Add persistent state provider * Catch write error * Handle read errors, update error msgs * Fix lint * Don't provide initial state to loader * Remove colorMode from shell state * Idea: hook into persisted context from other files * Migrate settings to new hook * Rework persisted state to split individual contexts * Tweak persisted schema and validation --------- Co-authored-by: Paul Frazee <pfrazee@gmail.com>zio/stable
parent
bfe196bac5
commit
96d8faf4b0
|
@ -10,6 +10,8 @@ import {QueryClientProvider} from '@tanstack/react-query'
|
||||||
|
|
||||||
import 'view/icons'
|
import 'view/icons'
|
||||||
|
|
||||||
|
import {init as initPersistedState} from '#/state/persisted'
|
||||||
|
import {useColorMode} from 'state/shell'
|
||||||
import {ThemeProvider} from 'lib/ThemeContext'
|
import {ThemeProvider} from 'lib/ThemeContext'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
import {RootStoreModel, setupState, RootStoreProvider} from './state'
|
import {RootStoreModel, setupState, RootStoreProvider} from './state'
|
||||||
|
@ -23,7 +25,8 @@ import {Provider as ShellStateProvider} from 'state/shell'
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync()
|
SplashScreen.preventAutoHideAsync()
|
||||||
|
|
||||||
const App = observer(function AppImpl() {
|
const InnerApp = observer(function AppImpl() {
|
||||||
|
const colorMode = useColorMode()
|
||||||
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
|
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
)
|
)
|
||||||
|
@ -45,23 +48,39 @@ const App = observer(function AppImpl() {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ShellStateProvider>
|
<QueryClientProvider client={queryClient}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<ThemeProvider theme={colorMode}>
|
||||||
<ThemeProvider theme={rootStore.shell.colorMode}>
|
<RootSiblingParent>
|
||||||
<RootSiblingParent>
|
<analytics.Provider>
|
||||||
<analytics.Provider>
|
<RootStoreProvider value={rootStore}>
|
||||||
<RootStoreProvider value={rootStore}>
|
<GestureHandlerRootView style={s.h100pct}>
|
||||||
<GestureHandlerRootView style={s.h100pct}>
|
<TestCtrls />
|
||||||
<TestCtrls />
|
<Shell />
|
||||||
<Shell />
|
</GestureHandlerRootView>
|
||||||
</GestureHandlerRootView>
|
</RootStoreProvider>
|
||||||
</RootStoreProvider>
|
</analytics.Provider>
|
||||||
</analytics.Provider>
|
</RootSiblingParent>
|
||||||
</RootSiblingParent>
|
</ThemeProvider>
|
||||||
</ThemeProvider>
|
</QueryClientProvider>
|
||||||
</QueryClientProvider>
|
|
||||||
</ShellStateProvider>
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [isReady, setReady] = useState(false)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
initPersistedState().then(() => setReady(true))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!isReady) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ShellStateProvider>
|
||||||
|
<InnerApp />
|
||||||
|
</ShellStateProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|
|
@ -8,6 +8,8 @@ import {RootSiblingParent} from 'react-native-root-siblings'
|
||||||
|
|
||||||
import 'view/icons'
|
import 'view/icons'
|
||||||
|
|
||||||
|
import {init as initPersistedState} from '#/state/persisted'
|
||||||
|
import {useColorMode} from 'state/shell'
|
||||||
import * as analytics from 'lib/analytics/analytics'
|
import * as analytics from 'lib/analytics/analytics'
|
||||||
import {RootStoreModel, setupState, RootStoreProvider} from './state'
|
import {RootStoreModel, setupState, RootStoreProvider} from './state'
|
||||||
import {Shell} from 'view/shell/index'
|
import {Shell} from 'view/shell/index'
|
||||||
|
@ -16,7 +18,8 @@ import {ThemeProvider} from 'lib/ThemeContext'
|
||||||
import {queryClient} from 'lib/react-query'
|
import {queryClient} from 'lib/react-query'
|
||||||
import {Provider as ShellStateProvider} from 'state/shell'
|
import {Provider as ShellStateProvider} from 'state/shell'
|
||||||
|
|
||||||
const App = observer(function AppImpl() {
|
const InnerApp = observer(function AppImpl() {
|
||||||
|
const colorMode = useColorMode()
|
||||||
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
|
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
)
|
)
|
||||||
|
@ -35,23 +38,39 @@ const App = observer(function AppImpl() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ShellStateProvider>
|
<QueryClientProvider client={queryClient}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<ThemeProvider theme={colorMode}>
|
||||||
<ThemeProvider theme={rootStore.shell.colorMode}>
|
<RootSiblingParent>
|
||||||
<RootSiblingParent>
|
<analytics.Provider>
|
||||||
<analytics.Provider>
|
<RootStoreProvider value={rootStore}>
|
||||||
<RootStoreProvider value={rootStore}>
|
<SafeAreaProvider>
|
||||||
<SafeAreaProvider>
|
<Shell />
|
||||||
<Shell />
|
</SafeAreaProvider>
|
||||||
</SafeAreaProvider>
|
<ToastContainer />
|
||||||
<ToastContainer />
|
</RootStoreProvider>
|
||||||
</RootStoreProvider>
|
</analytics.Provider>
|
||||||
</analytics.Provider>
|
</RootSiblingParent>
|
||||||
</RootSiblingParent>
|
</ThemeProvider>
|
||||||
</ThemeProvider>
|
</QueryClientProvider>
|
||||||
</QueryClientProvider>
|
|
||||||
</ShellStateProvider>
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [isReady, setReady] = useState(false)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
initPersistedState().then(() => setReady(true))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!isReady) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ShellStateProvider>
|
||||||
|
<InnerApp />
|
||||||
|
</ShellStateProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|
|
@ -74,7 +74,6 @@ export class RootStoreModel {
|
||||||
session: this.session.serialize(),
|
session: this.session.serialize(),
|
||||||
me: this.me.serialize(),
|
me: this.me.serialize(),
|
||||||
onboarding: this.onboarding.serialize(),
|
onboarding: this.onboarding.serialize(),
|
||||||
shell: this.shell.serialize(),
|
|
||||||
preferences: this.preferences.serialize(),
|
preferences: this.preferences.serialize(),
|
||||||
invitedUsers: this.invitedUsers.serialize(),
|
invitedUsers: this.invitedUsers.serialize(),
|
||||||
mutedThreads: this.mutedThreads.serialize(),
|
mutedThreads: this.mutedThreads.serialize(),
|
||||||
|
@ -99,9 +98,6 @@ export class RootStoreModel {
|
||||||
if (hasProp(v, 'session')) {
|
if (hasProp(v, 'session')) {
|
||||||
this.session.hydrate(v.session)
|
this.session.hydrate(v.session)
|
||||||
}
|
}
|
||||||
if (hasProp(v, 'shell')) {
|
|
||||||
this.shell.hydrate(v.shell)
|
|
||||||
}
|
|
||||||
if (hasProp(v, 'preferences')) {
|
if (hasProp(v, 'preferences')) {
|
||||||
this.preferences.hydrate(v.preferences)
|
this.preferences.hydrate(v.preferences)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,13 +2,11 @@ import {AppBskyEmbedRecord, AppBskyActorDefs, ModerationUI} from '@atproto/api'
|
||||||
import {RootStoreModel} from '../root-store'
|
import {RootStoreModel} from '../root-store'
|
||||||
import {makeAutoObservable, runInAction} from 'mobx'
|
import {makeAutoObservable, runInAction} from 'mobx'
|
||||||
import {ProfileModel} from '../content/profile'
|
import {ProfileModel} from '../content/profile'
|
||||||
import {isObj, hasProp} from 'lib/type-guards'
|
|
||||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||||
import {ImageModel} from '../media/image'
|
import {ImageModel} from '../media/image'
|
||||||
import {ListModel} from '../content/list'
|
import {ListModel} from '../content/list'
|
||||||
import {GalleryModel} from '../media/gallery'
|
import {GalleryModel} from '../media/gallery'
|
||||||
import {StyleProp, ViewStyle} from 'react-native'
|
import {StyleProp, ViewStyle} from 'react-native'
|
||||||
import {isWeb} from 'platform/detection'
|
|
||||||
|
|
||||||
export type ColorMode = 'system' | 'light' | 'dark'
|
export type ColorMode = 'system' | 'light' | 'dark'
|
||||||
|
|
||||||
|
@ -265,7 +263,6 @@ export interface ComposerOpts {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ShellUiModel {
|
export class ShellUiModel {
|
||||||
colorMode: ColorMode = 'system'
|
|
||||||
isModalActive = false
|
isModalActive = false
|
||||||
activeModals: Modal[] = []
|
activeModals: Modal[] = []
|
||||||
isLightboxActive = false
|
isLightboxActive = false
|
||||||
|
@ -276,40 +273,13 @@ export class ShellUiModel {
|
||||||
|
|
||||||
constructor(public rootStore: RootStoreModel) {
|
constructor(public rootStore: RootStoreModel) {
|
||||||
makeAutoObservable(this, {
|
makeAutoObservable(this, {
|
||||||
serialize: false,
|
|
||||||
rootStore: false,
|
rootStore: false,
|
||||||
hydrate: false,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
this.setupClock()
|
this.setupClock()
|
||||||
this.setupLoginModals()
|
this.setupLoginModals()
|
||||||
}
|
}
|
||||||
|
|
||||||
serialize(): unknown {
|
|
||||||
return {
|
|
||||||
colorMode: this.colorMode,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hydrate(v: unknown) {
|
|
||||||
if (isObj(v)) {
|
|
||||||
if (hasProp(v, 'colorMode') && isColorMode(v.colorMode)) {
|
|
||||||
this.setColorMode(v.colorMode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setColorMode(mode: ColorMode) {
|
|
||||||
this.colorMode = mode
|
|
||||||
|
|
||||||
if (isWeb && typeof window !== 'undefined') {
|
|
||||||
const html = window.document.documentElement
|
|
||||||
// remove any other color mode classes
|
|
||||||
html.className = html.className.replace(/colorMode--\w+/g, '')
|
|
||||||
html.classList.add(`colorMode--${mode}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* returns true if something was closed
|
* returns true if something was closed
|
||||||
* (used by the android hardware back btn)
|
* (used by the android hardware back btn)
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default class BroadcastChannel {
|
||||||
|
constructor(public name: string) {}
|
||||||
|
postMessage(_data: any) {}
|
||||||
|
close() {}
|
||||||
|
onmessage: (event: MessageEvent) => void = () => {}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export default BroadcastChannel
|
|
@ -0,0 +1,91 @@
|
||||||
|
import EventEmitter from 'eventemitter3'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {defaults, Schema} from '#/state/persisted/schema'
|
||||||
|
import {migrate} from '#/state/persisted/legacy'
|
||||||
|
import * as store from '#/state/persisted/store'
|
||||||
|
import BroadcastChannel from '#/state/persisted/broadcast'
|
||||||
|
|
||||||
|
export type {Schema} from '#/state/persisted/schema'
|
||||||
|
export {defaults as schema} from '#/state/persisted/schema'
|
||||||
|
|
||||||
|
const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL')
|
||||||
|
const UPDATE_EVENT = 'BSKY_UPDATE'
|
||||||
|
|
||||||
|
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() {
|
||||||
|
logger.debug('persisted state: initializing')
|
||||||
|
|
||||||
|
broadcast.onmessage = onBroadcastMessage
|
||||||
|
|
||||||
|
try {
|
||||||
|
await migrate() // migrate old store
|
||||||
|
const stored = await store.read() // check for new store
|
||||||
|
if (!stored) await store.write(defaults) // opt: init new store
|
||||||
|
_state = stored || defaults // return new store
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('persisted state: failed to load root state from storage', {
|
||||||
|
error: e,
|
||||||
|
})
|
||||||
|
// AsyncStorage failured, but we can still continue in memory
|
||||||
|
return defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function get<K extends keyof Schema>(key: K): Schema[K] {
|
||||||
|
return _state[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function write<K extends keyof Schema>(
|
||||||
|
key: K,
|
||||||
|
value: Schema[K],
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
_state[key] = value
|
||||||
|
await store.write(_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`)
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`persisted state: failed writing root state to storage`, {
|
||||||
|
error: e,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onUpdate(cb: () => void): () => void {
|
||||||
|
_emitter.addListener('update', cb)
|
||||||
|
return () => _emitter.removeListener('update', cb)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onBroadcastMessage({data}: MessageEvent) {
|
||||||
|
// validate event
|
||||||
|
if (typeof data === 'object' && data.event === UPDATE_EVENT) {
|
||||||
|
try {
|
||||||
|
// read next state, possibly updated by another tab
|
||||||
|
const next = await store.read()
|
||||||
|
|
||||||
|
if (next) {
|
||||||
|
logger.debug(`persisted state: handling update from broadcast channel`)
|
||||||
|
_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`,
|
||||||
|
{
|
||||||
|
error: e,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,137 @@
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
|
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {defaults, Schema} from '#/state/persisted/schema'
|
||||||
|
import {write, read} from '#/state/persisted/store'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shape of the serialized data from our legacy Mobx store.
|
||||||
|
*/
|
||||||
|
type LegacySchema = {
|
||||||
|
shell: {
|
||||||
|
colorMode: 'system' | 'light' | 'dark'
|
||||||
|
}
|
||||||
|
session: {
|
||||||
|
data: {
|
||||||
|
service: string
|
||||||
|
did: `did:plc:${string}`
|
||||||
|
}
|
||||||
|
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: LegacySchema): Schema {
|
||||||
|
return {
|
||||||
|
colorMode: legacy.shell?.colorMode || defaults.colorMode,
|
||||||
|
session: {
|
||||||
|
accounts: legacy.session.accounts || defaults.session.accounts,
|
||||||
|
currentAccount:
|
||||||
|
legacy.session.accounts.find(a => a.did === legacy.session.data.did) ||
|
||||||
|
defaults.session.currentAccount,
|
||||||
|
},
|
||||||
|
reminders: {
|
||||||
|
lastEmailConfirmReminder:
|
||||||
|
legacy.reminders.lastEmailConfirm ||
|
||||||
|
defaults.reminders.lastEmailConfirmReminder,
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
requireAltTextEnabled:
|
||||||
|
legacy.preferences.requireAltTextEnabled ||
|
||||||
|
defaults.requireAltTextEnabled,
|
||||||
|
mutedThreads: legacy.mutedThreads.uris || defaults.mutedThreads,
|
||||||
|
invitedUsers: {
|
||||||
|
seenDids: legacy.invitedUsers.seenDids || defaults.invitedUsers.seenDids,
|
||||||
|
copiedInvites:
|
||||||
|
legacy.invitedUsers.copiedInvites ||
|
||||||
|
defaults.invitedUsers.copiedInvites,
|
||||||
|
},
|
||||||
|
onboarding: {
|
||||||
|
step: legacy.onboarding.step || defaults.onboarding.step,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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: migrate')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawLegacyData = await AsyncStorage.getItem(
|
||||||
|
DEPRECATED_ROOT_STATE_STORAGE_KEY,
|
||||||
|
)
|
||||||
|
const alreadyMigrated = Boolean(await read())
|
||||||
|
|
||||||
|
if (!alreadyMigrated && rawLegacyData) {
|
||||||
|
logger.debug('persisted state: migrating legacy storage')
|
||||||
|
const legacyData = JSON.parse(rawLegacyData)
|
||||||
|
const newData = transform(legacyData)
|
||||||
|
await write(newData)
|
||||||
|
logger.debug('persisted state: migrated legacy storage')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('persisted state: error migrating legacy storage', {
|
||||||
|
error: String(e),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,68 @@
|
||||||
|
import {z} from 'zod'
|
||||||
|
import {deviceLocales} from '#/platform/detection'
|
||||||
|
|
||||||
|
// only data needed for rendering account page
|
||||||
|
const accountSchema = z.object({
|
||||||
|
service: z.string(),
|
||||||
|
did: z.string(),
|
||||||
|
refreshJwt: z.string().optional(),
|
||||||
|
accessJwt: z.string().optional(),
|
||||||
|
handle: z.string(),
|
||||||
|
displayName: z.string(),
|
||||||
|
aviUrl: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const schema = z.object({
|
||||||
|
colorMode: z.enum(['system', 'light', 'dark']),
|
||||||
|
session: z.object({
|
||||||
|
accounts: z.array(accountSchema),
|
||||||
|
currentAccount: accountSchema.optional(),
|
||||||
|
}),
|
||||||
|
reminders: z.object({
|
||||||
|
lastEmailConfirmReminder: z.string().optional(),
|
||||||
|
}),
|
||||||
|
languagePrefs: z.object({
|
||||||
|
primaryLanguage: z.string(), // should move to server
|
||||||
|
contentLanguages: z.array(z.string()), // should move to server
|
||||||
|
postLanguage: z.string(), // should move to server
|
||||||
|
postLanguageHistory: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
requireAltTextEnabled: z.boolean(), // should move to server
|
||||||
|
mutedThreads: z.array(z.string()), // should move to server
|
||||||
|
invitedUsers: z.object({
|
||||||
|
seenDids: z.array(z.string()),
|
||||||
|
copiedInvites: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
onboarding: z.object({
|
||||||
|
step: z.string(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
export type Schema = z.infer<typeof schema>
|
||||||
|
|
||||||
|
export const defaults: Schema = {
|
||||||
|
colorMode: 'system',
|
||||||
|
session: {
|
||||||
|
accounts: [],
|
||||||
|
currentAccount: undefined,
|
||||||
|
},
|
||||||
|
reminders: {
|
||||||
|
lastEmailConfirmReminder: undefined,
|
||||||
|
},
|
||||||
|
languagePrefs: {
|
||||||
|
primaryLanguage: deviceLocales[0] || 'en',
|
||||||
|
contentLanguages: deviceLocales || [],
|
||||||
|
postLanguage: deviceLocales[0] || 'en',
|
||||||
|
postLanguageHistory: (deviceLocales || [])
|
||||||
|
.concat(['en', 'ja', 'pt', 'de'])
|
||||||
|
.slice(0, 6),
|
||||||
|
},
|
||||||
|
requireAltTextEnabled: false,
|
||||||
|
mutedThreads: [],
|
||||||
|
invitedUsers: {
|
||||||
|
seenDids: [],
|
||||||
|
copiedInvites: [],
|
||||||
|
},
|
||||||
|
onboarding: {
|
||||||
|
step: 'Home',
|
||||||
|
},
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
|
|
||||||
|
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
|
||||||
|
if (schema.safeParse(objData).success) {
|
||||||
|
return objData
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,56 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {isWeb} from '#/platform/detection'
|
||||||
|
import * as persisted from '#/state/persisted'
|
||||||
|
|
||||||
|
type StateContext = persisted.Schema['colorMode']
|
||||||
|
type SetContext = (v: persisted.Schema['colorMode']) => void
|
||||||
|
|
||||||
|
const stateContext = React.createContext<StateContext>('system')
|
||||||
|
const setContext = React.createContext<SetContext>(
|
||||||
|
(_: persisted.Schema['colorMode']) => {},
|
||||||
|
)
|
||||||
|
|
||||||
|
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
|
const [state, setState] = React.useState(persisted.get('colorMode'))
|
||||||
|
|
||||||
|
const setStateWrapped = React.useCallback(
|
||||||
|
(colorMode: persisted.Schema['colorMode']) => {
|
||||||
|
setState(colorMode)
|
||||||
|
persisted.write('colorMode', colorMode)
|
||||||
|
updateDocument(colorMode)
|
||||||
|
},
|
||||||
|
[setState],
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
return persisted.onUpdate(() => {
|
||||||
|
setState(persisted.get('colorMode'))
|
||||||
|
updateDocument(persisted.get('colorMode'))
|
||||||
|
})
|
||||||
|
}, [setStateWrapped])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<stateContext.Provider value={state}>
|
||||||
|
<setContext.Provider value={setStateWrapped}>
|
||||||
|
{children}
|
||||||
|
</setContext.Provider>
|
||||||
|
</stateContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useColorMode() {
|
||||||
|
return React.useContext(stateContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetColorMode() {
|
||||||
|
return React.useContext(setContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDocument(colorMode: string) {
|
||||||
|
if (isWeb && typeof window !== 'undefined') {
|
||||||
|
const html = window.document.documentElement
|
||||||
|
// remove any other color mode classes
|
||||||
|
html.className = html.className.replace(/colorMode--\w+/g, '')
|
||||||
|
html.classList.add(`colorMode--${colorMode}`)
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ import React from 'react'
|
||||||
import {Provider as DrawerOpenProvider} from './drawer-open'
|
import {Provider as DrawerOpenProvider} from './drawer-open'
|
||||||
import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled'
|
import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled'
|
||||||
import {Provider as MinimalModeProvider} from './minimal-mode'
|
import {Provider as MinimalModeProvider} from './minimal-mode'
|
||||||
|
import {Provider as ColorModeProvider} from './color-mode'
|
||||||
|
|
||||||
export {useIsDrawerOpen, useSetDrawerOpen} from './drawer-open'
|
export {useIsDrawerOpen, useSetDrawerOpen} from './drawer-open'
|
||||||
export {
|
export {
|
||||||
|
@ -9,12 +10,15 @@ export {
|
||||||
useSetDrawerSwipeDisabled,
|
useSetDrawerSwipeDisabled,
|
||||||
} from './drawer-swipe-disabled'
|
} from './drawer-swipe-disabled'
|
||||||
export {useMinimalShellMode, useSetMinimalShellMode} from './minimal-mode'
|
export {useMinimalShellMode, useSetMinimalShellMode} from './minimal-mode'
|
||||||
|
export {useColorMode, useSetColorMode} from './color-mode'
|
||||||
|
|
||||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
return (
|
return (
|
||||||
<DrawerOpenProvider>
|
<DrawerOpenProvider>
|
||||||
<DrawerSwipableProvider>
|
<DrawerSwipableProvider>
|
||||||
<MinimalModeProvider>{children}</MinimalModeProvider>
|
<MinimalModeProvider>
|
||||||
|
<ColorModeProvider>{children}</ColorModeProvider>
|
||||||
|
</MinimalModeProvider>
|
||||||
</DrawerSwipableProvider>
|
</DrawerSwipableProvider>
|
||||||
</DrawerOpenProvider>
|
</DrawerOpenProvider>
|
||||||
)
|
)
|
||||||
|
|
|
@ -46,7 +46,11 @@ import Clipboard from '@react-native-clipboard/clipboard'
|
||||||
import {makeProfileLink} from 'lib/routes/links'
|
import {makeProfileLink} from 'lib/routes/links'
|
||||||
import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
|
import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {useSetMinimalShellMode} from '#/state/shell'
|
import {
|
||||||
|
useSetMinimalShellMode,
|
||||||
|
useColorMode,
|
||||||
|
useSetColorMode,
|
||||||
|
} from '#/state/shell'
|
||||||
|
|
||||||
// TEMPORARY (APP-700)
|
// TEMPORARY (APP-700)
|
||||||
// remove after backend testing finishes
|
// remove after backend testing finishes
|
||||||
|
@ -57,6 +61,8 @@ import {STATUS_PAGE_URL} from 'lib/constants'
|
||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
|
||||||
export const SettingsScreen = withAuthRequired(
|
export const SettingsScreen = withAuthRequired(
|
||||||
observer(function Settings({}: Props) {
|
observer(function Settings({}: Props) {
|
||||||
|
const colorMode = useColorMode()
|
||||||
|
const setColorMode = useSetColorMode()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const setMinimalShellMode = useSetMinimalShellMode()
|
const setMinimalShellMode = useSetMinimalShellMode()
|
||||||
|
@ -379,23 +385,23 @@ export const SettingsScreen = withAuthRequired(
|
||||||
<View>
|
<View>
|
||||||
<View style={[styles.linkCard, pal.view, styles.selectableBtns]}>
|
<View style={[styles.linkCard, pal.view, styles.selectableBtns]}>
|
||||||
<SelectableBtn
|
<SelectableBtn
|
||||||
selected={store.shell.colorMode === 'system'}
|
selected={colorMode === 'system'}
|
||||||
label="System"
|
label="System"
|
||||||
left
|
left
|
||||||
onSelect={() => store.shell.setColorMode('system')}
|
onSelect={() => setColorMode('system')}
|
||||||
accessibilityHint="Set color theme to system setting"
|
accessibilityHint="Set color theme to system setting"
|
||||||
/>
|
/>
|
||||||
<SelectableBtn
|
<SelectableBtn
|
||||||
selected={store.shell.colorMode === 'light'}
|
selected={colorMode === 'light'}
|
||||||
label="Light"
|
label="Light"
|
||||||
onSelect={() => store.shell.setColorMode('light')}
|
onSelect={() => setColorMode('light')}
|
||||||
accessibilityHint="Set color theme to light"
|
accessibilityHint="Set color theme to light"
|
||||||
/>
|
/>
|
||||||
<SelectableBtn
|
<SelectableBtn
|
||||||
selected={store.shell.colorMode === 'dark'}
|
selected={colorMode === 'dark'}
|
||||||
label="Dark"
|
label="Dark"
|
||||||
right
|
right
|
||||||
onSelect={() => store.shell.setColorMode('dark')}
|
onSelect={() => setColorMode('dark')}
|
||||||
accessibilityHint="Set color theme to dark"
|
accessibilityHint="Set color theme to dark"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
Loading…
Reference in New Issue