Files
bsky-social-app/src/state/persisted/schema.ts
T
2026-02-13 13:30:10 +00:00

220 lines
6.8 KiB
TypeScript

import {z} from 'zod'
import {deviceLanguageCodes, deviceLocales} from '#/locale/deviceLocales'
import {findSupportedAppLanguage} from '#/locale/helpers'
import {logger} from '#/logger'
import {PlatformInfo} from '../../../modules/expo-bluesky-swiss-army'
const externalEmbedOptions = ['show', 'hide'] as const
/**
* A account persisted to storage. Stored in the `accounts[]` array. Contains
* base account info and access tokens.
*/
const accountSchema = z.object({
service: z.string(),
did: z.string(),
handle: z.string(),
email: z.string().optional(),
emailConfirmed: z.boolean().optional(),
emailAuthFactor: z.boolean().optional(),
refreshJwt: z.string().optional(), // optional because it can expire
accessJwt: z.string().optional(), // optional because it can expire
signupQueued: z.boolean().optional(),
active: z.boolean().optional(), // optional for backwards compat
/**
* Known values: takendown, suspended, deactivated
* @see https://github.com/bluesky-social/atproto/blob/5441fbde9ed3b22463e91481ec80cb095643e141/lexicons/com/atproto/server/getSession.json
*/
status: z.string().optional(),
pdsUrl: z.string().optional(),
isSelfHosted: z.boolean().optional(),
})
export type PersistedAccount = z.infer<typeof accountSchema>
/**
* The current account. Stored in the `currentAccount` field.
*
* In previous versions, this included tokens and other info. Now, it's used
* only to reference the `did` field, and all other fields are marked as
* optional. They should be considered deprecated and not used, but are kept
* here for backwards compat.
*/
const currentAccountSchema = accountSchema.extend({
service: z.string().optional(),
handle: z.string().optional(),
})
export type PersistedCurrentAccount = z.infer<typeof currentAccountSchema>
const schema = z.object({
colorMode: z.enum(['system', 'light', 'dark']),
darkTheme: z.enum(['dim', 'dark']).optional(),
session: z.object({
accounts: z.array(accountSchema),
currentAccount: currentAccountSchema.optional(),
}),
reminders: z.object({
lastEmailConfirm: z.string().optional(),
}),
languagePrefs: z.object({
/**
* The target language for translating posts.
*
* BCP-47 2-letter language code without region.
*/
primaryLanguage: z.string(),
/**
* The languages the user can read, passed to feeds.
*
* BCP-47 2-letter language codes without region.
*/
contentLanguages: z.array(z.string()),
/**
* The language(s) the user is currently posting in, configured within the
* composer. Multiple languages are separated by commas.
*
* BCP-47 2-letter language code without region.
*/
postLanguage: z.string(),
/**
* The user's post language history, used to pre-populate the post language
* selector in the composer. Within each value, multiple languages are separated
* by commas.
*
* BCP-47 2-letter language codes without region.
*/
postLanguageHistory: z.array(z.string()),
/**
* The language for UI translations in the app.
*
* BCP-47 2-letter language code with or without region,
* to match with {@link AppLanguage}.
*/
appLanguage: z.string(),
}),
requireAltTextEnabled: z.boolean(), // should move to server
largeAltBadgeEnabled: z.boolean().optional(),
externalEmbeds: z
.object({
giphy: z.enum(externalEmbedOptions).optional(),
tenor: z.enum(externalEmbedOptions).optional(),
youtube: z.enum(externalEmbedOptions).optional(),
youtubeShorts: z.enum(externalEmbedOptions).optional(),
twitch: z.enum(externalEmbedOptions).optional(),
vimeo: z.enum(externalEmbedOptions).optional(),
spotify: z.enum(externalEmbedOptions).optional(),
appleMusic: z.enum(externalEmbedOptions).optional(),
soundcloud: z.enum(externalEmbedOptions).optional(),
flickr: z.enum(externalEmbedOptions).optional(),
bandcamp: z.enum(externalEmbedOptions).optional(),
})
.optional(),
invites: z.object({
copiedInvites: z.array(z.string()),
}),
onboarding: z.object({
step: z.string(),
}),
hiddenPosts: z.array(z.string()).optional(), // should move to server
useInAppBrowser: z.boolean().optional(),
/** @deprecated */
lastSelectedHomeFeed: z.string().optional(),
pdsAddressHistory: z.array(z.string()).optional(),
disableHaptics: z.boolean().optional(),
disableAutoplay: z.boolean().optional(),
kawaii: z.boolean().optional(),
hasCheckedForStarterPack: z.boolean().optional(),
subtitlesEnabled: z.boolean().optional(),
/** @deprecated */
mutedThreads: z.array(z.string()),
trendingDisabled: z.boolean().optional(),
trendingVideoDisabled: z.boolean().optional(),
})
export type Schema = z.infer<typeof schema>
export const defaults: Schema = {
colorMode: 'system',
darkTheme: 'dim',
session: {
accounts: [],
currentAccount: undefined,
},
reminders: {
lastEmailConfirm: undefined,
},
languagePrefs: {
primaryLanguage: deviceLanguageCodes[0] || 'en',
contentLanguages: deviceLanguageCodes || [],
postLanguage: deviceLanguageCodes[0] || 'en',
postLanguageHistory: (deviceLanguageCodes || [])
.concat(['en', 'ja', 'pt', 'de'])
.slice(0, 6),
// try full language tag first, then fallback to language code
appLanguage: findSupportedAppLanguage([
deviceLocales.at(0)?.languageTag,
deviceLanguageCodes[0],
]),
},
requireAltTextEnabled: false,
largeAltBadgeEnabled: false,
externalEmbeds: {},
mutedThreads: [],
invites: {
copiedInvites: [],
},
onboarding: {
step: 'Home',
},
hiddenPosts: [],
useInAppBrowser: undefined,
lastSelectedHomeFeed: undefined,
pdsAddressHistory: [],
disableHaptics: false,
disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(),
kawaii: false,
hasCheckedForStarterPack: false,
subtitlesEnabled: true,
trendingDisabled: false,
trendingVideoDisabled: false,
}
export function tryParse(rawData: string): Schema | undefined {
let objData
try {
objData = JSON.parse(rawData)
} catch (e) {
logger.error('persisted state: failed to parse root state from storage', {
message: e,
})
}
if (!objData) {
return undefined
}
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 function tryStringify(value: Schema): string | undefined {
try {
schema.parse(value)
return JSON.stringify(value)
} catch (e) {
logger.error(`persisted state: failed stringifying root state`, {
message: e,
})
return undefined
}
}