my fork of the bluesky client
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}