my fork of the bluesky client
at main 212 lines 6.7 kB view raw
1import {z} from 'zod' 2 3import {deviceLanguageCodes, deviceLocales} from '#/locale/deviceLocales' 4import {findSupportedAppLanguage} from '#/locale/helpers' 5import {logger} from '#/logger' 6import {PlatformInfo} from '../../../modules/expo-bluesky-swiss-army' 7 8const externalEmbedOptions = ['show', 'hide'] as const 9 10/** 11 * A account persisted to storage. Stored in the `accounts[]` array. Contains 12 * base account info and access tokens. 13 */ 14const accountSchema = z.object({ 15 service: z.string(), 16 did: z.string(), 17 handle: z.string(), 18 email: z.string().optional(), 19 emailConfirmed: z.boolean().optional(), 20 emailAuthFactor: z.boolean().optional(), 21 refreshJwt: z.string().optional(), // optional because it can expire 22 accessJwt: z.string().optional(), // optional because it can expire 23 signupQueued: z.boolean().optional(), 24 active: z.boolean().optional(), // optional for backwards compat 25 /** 26 * Known values: takendown, suspended, deactivated 27 * @see https://github.com/bluesky-social/atproto/blob/5441fbde9ed3b22463e91481ec80cb095643e141/lexicons/com/atproto/server/getSession.json 28 */ 29 status: z.string().optional(), 30 pdsUrl: z.string().optional(), 31}) 32export type PersistedAccount = z.infer<typeof accountSchema> 33 34/** 35 * The current account. Stored in the `currentAccount` field. 36 * 37 * In previous versions, this included tokens and other info. Now, it's used 38 * only to reference the `did` field, and all other fields are marked as 39 * optional. They should be considered deprecated and not used, but are kept 40 * here for backwards compat. 41 */ 42const currentAccountSchema = accountSchema.extend({ 43 service: z.string().optional(), 44 handle: z.string().optional(), 45}) 46export type PersistedCurrentAccount = z.infer<typeof currentAccountSchema> 47 48const schema = z.object({ 49 colorMode: z.enum(['system', 'light', 'dark']), 50 darkTheme: z.enum(['dim', 'dark']).optional(), 51 session: z.object({ 52 accounts: z.array(accountSchema), 53 currentAccount: currentAccountSchema.optional(), 54 }), 55 reminders: z.object({ 56 lastEmailConfirm: z.string().optional(), 57 }), 58 languagePrefs: z.object({ 59 /** 60 * The target language for translating posts. 61 * 62 * BCP-47 2-letter language code without region. 63 */ 64 primaryLanguage: z.string(), 65 /** 66 * The languages the user can read, passed to feeds. 67 * 68 * BCP-47 2-letter language codes without region. 69 */ 70 contentLanguages: z.array(z.string()), 71 /** 72 * The language(s) the user is currently posting in, configured within the 73 * composer. Multiple languages are psearate by commas. 74 * 75 * BCP-47 2-letter language code without region. 76 */ 77 postLanguage: z.string(), 78 /** 79 * The user's post language history, used to pre-populate the post language 80 * selector in the composer. Within each value, multiple languages are 81 * separated by values. 82 * 83 * BCP-47 2-letter language codes without region. 84 */ 85 postLanguageHistory: z.array(z.string()), 86 /** 87 * The language for UI translations in the app. 88 * 89 * BCP-47 2-letter language code with or without region, 90 * to match with {@link AppLanguage}. 91 */ 92 appLanguage: z.string(), 93 }), 94 requireAltTextEnabled: z.boolean(), // should move to server 95 largeAltBadgeEnabled: z.boolean().optional(), 96 externalEmbeds: z 97 .object({ 98 giphy: z.enum(externalEmbedOptions).optional(), 99 tenor: z.enum(externalEmbedOptions).optional(), 100 youtube: z.enum(externalEmbedOptions).optional(), 101 youtubeShorts: z.enum(externalEmbedOptions).optional(), 102 twitch: z.enum(externalEmbedOptions).optional(), 103 vimeo: z.enum(externalEmbedOptions).optional(), 104 spotify: z.enum(externalEmbedOptions).optional(), 105 appleMusic: z.enum(externalEmbedOptions).optional(), 106 soundcloud: z.enum(externalEmbedOptions).optional(), 107 flickr: z.enum(externalEmbedOptions).optional(), 108 }) 109 .optional(), 110 invites: z.object({ 111 copiedInvites: z.array(z.string()), 112 }), 113 onboarding: z.object({ 114 step: z.string(), 115 }), 116 hiddenPosts: z.array(z.string()).optional(), // should move to server 117 useInAppBrowser: z.boolean().optional(), 118 lastSelectedHomeFeed: z.string().optional(), 119 pdsAddressHistory: z.array(z.string()).optional(), 120 disableHaptics: z.boolean().optional(), 121 disableAutoplay: z.boolean().optional(), 122 kawaii: z.boolean().optional(), 123 hasCheckedForStarterPack: z.boolean().optional(), 124 subtitlesEnabled: z.boolean().optional(), 125 /** @deprecated */ 126 mutedThreads: z.array(z.string()), 127}) 128export type Schema = z.infer<typeof schema> 129 130export const defaults: Schema = { 131 colorMode: 'system', 132 darkTheme: 'dim', 133 session: { 134 accounts: [], 135 currentAccount: undefined, 136 }, 137 reminders: { 138 lastEmailConfirm: undefined, 139 }, 140 languagePrefs: { 141 primaryLanguage: deviceLanguageCodes[0] || 'en', 142 contentLanguages: deviceLanguageCodes || [], 143 postLanguage: deviceLanguageCodes[0] || 'en', 144 postLanguageHistory: (deviceLanguageCodes || []) 145 .concat(['en', 'ja', 'pt', 'de']) 146 .slice(0, 6), 147 // try full language tag first, then fallback to language code 148 appLanguage: findSupportedAppLanguage([ 149 deviceLocales.at(0)?.languageTag, 150 deviceLanguageCodes[0], 151 ]), 152 }, 153 requireAltTextEnabled: false, 154 largeAltBadgeEnabled: false, 155 externalEmbeds: {}, 156 mutedThreads: [], 157 invites: { 158 copiedInvites: [], 159 }, 160 onboarding: { 161 step: 'Home', 162 }, 163 hiddenPosts: [], 164 useInAppBrowser: undefined, 165 lastSelectedHomeFeed: undefined, 166 pdsAddressHistory: [], 167 disableHaptics: false, 168 disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(), 169 kawaii: false, 170 hasCheckedForStarterPack: false, 171 subtitlesEnabled: true, 172} 173 174export function tryParse(rawData: string): Schema | undefined { 175 let objData 176 try { 177 objData = JSON.parse(rawData) 178 } catch (e) { 179 logger.error('persisted state: failed to parse root state from storage', { 180 message: e, 181 }) 182 } 183 if (!objData) { 184 return undefined 185 } 186 const parsed = schema.safeParse(objData) 187 if (parsed.success) { 188 return objData 189 } else { 190 const errors = 191 parsed.error?.errors?.map(e => ({ 192 code: e.code, 193 // @ts-ignore exists on some types 194 expected: e?.expected, 195 path: e.path?.join('.'), 196 })) || [] 197 logger.error(`persisted store: data failed validation on read`, {errors}) 198 return undefined 199 } 200} 201 202export function tryStringify(value: Schema): string | undefined { 203 try { 204 schema.parse(value) 205 return JSON.stringify(value) 206 } catch (e) { 207 logger.error(`persisted state: failed stringifying root state`, { 208 message: e, 209 }) 210 return undefined 211 } 212}