forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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 isSelfHosted: z.boolean().optional(),
32})
33export type PersistedAccount = z.infer<typeof accountSchema>
34
35/**
36 * The current account. Stored in the `currentAccount` field.
37 *
38 * In previous versions, this included tokens and other info. Now, it's used
39 * only to reference the `did` field, and all other fields are marked as
40 * optional. They should be considered deprecated and not used, but are kept
41 * here for backwards compat.
42 */
43const currentAccountSchema = accountSchema.extend({
44 service: z.string().optional(),
45 handle: z.string().optional(),
46})
47export type PersistedCurrentAccount = z.infer<typeof currentAccountSchema>
48
49const schema = z.object({
50 colorMode: z.enum(['system', 'light', 'dark']),
51 darkTheme: z.enum(['dim', 'dark']).optional(),
52 colorScheme: z.enum([
53 'witchsky',
54 'bluesky',
55 'blacksky',
56 'deer',
57 'zeppelin',
58 'kitty',
59 'reddwarf',
60 ]),
61 hue: z.number(),
62 session: z.object({
63 accounts: z.array(accountSchema),
64 currentAccount: currentAccountSchema.optional(),
65 }),
66 reminders: z.object({
67 lastEmailConfirm: z.string().optional(),
68 }),
69 languagePrefs: z.object({
70 /**
71 * The target language for translating posts.
72 *
73 * BCP-47 2-letter language code without region.
74 */
75 primaryLanguage: z.string(),
76 /**
77 * The languages the user can read, passed to feeds.
78 *
79 * BCP-47 2-letter language codes without region.
80 */
81 contentLanguages: z.array(z.string()),
82 /**
83 * The language(s) the user is currently posting in, configured within the
84 * composer. Multiple languages are separated by commas.
85 *
86 * BCP-47 2-letter language code without region.
87 */
88 postLanguage: z.string(),
89 /**
90 * The user's post language history, used to pre-populate the post language
91 * selector in the composer. Within each value, multiple languages are separated
92 * by commas.
93 *
94 * BCP-47 2-letter language codes without region.
95 */
96 postLanguageHistory: z.array(z.string()),
97 /**
98 * The language for UI translations in the app.
99 *
100 * BCP-47 2-letter language code with or without region,
101 * to match with {@link AppLanguage}.
102 */
103 appLanguage: z.string(),
104 }),
105 requireAltTextEnabled: z.boolean(), // should move to server
106 largeAltBadgeEnabled: z.boolean().optional(),
107 externalEmbeds: z
108 .object({
109 giphy: z.enum(externalEmbedOptions).optional(),
110 tenor: z.enum(externalEmbedOptions).optional(),
111 youtube: z.enum(externalEmbedOptions).optional(),
112 youtubeShorts: z.enum(externalEmbedOptions).optional(),
113 twitch: z.enum(externalEmbedOptions).optional(),
114 vimeo: z.enum(externalEmbedOptions).optional(),
115 spotify: z.enum(externalEmbedOptions).optional(),
116 appleMusic: z.enum(externalEmbedOptions).optional(),
117 soundcloud: z.enum(externalEmbedOptions).optional(),
118 flickr: z.enum(externalEmbedOptions).optional(),
119 streamplace: z.enum(externalEmbedOptions).optional(),
120 })
121 .optional(),
122 invites: z.object({
123 copiedInvites: z.array(z.string()),
124 }),
125 onboarding: z.object({
126 step: z.string(),
127 }),
128 hiddenPosts: z.array(z.string()).optional(), // should move to server
129 useInAppBrowser: z.boolean().optional(),
130 /** @deprecated */
131 lastSelectedHomeFeed: z.string().optional(),
132 pdsAddressHistory: z.array(z.string()).optional(),
133 disableHaptics: z.boolean().optional(),
134 disableAutoplay: z.boolean().optional(),
135 kawaii: z.boolean().optional(),
136 hasCheckedForStarterPack: z.boolean().optional(),
137 subtitlesEnabled: z.boolean().optional(),
138
139 // deer
140 goLinksEnabled: z.boolean().optional(),
141 constellationEnabled: z.boolean().optional(),
142 directFetchRecords: z.boolean().optional(),
143 noAppLabelers: z.boolean().optional(),
144 noDiscoverFallback: z.boolean().optional(),
145 repostCarouselEnabled: z.boolean().optional(),
146 constellationInstance: z.string().optional(),
147 showLinkInHandle: z.boolean().optional(),
148 hideFeedsPromoTab: z.boolean().optional(),
149 disableViaRepostNotification: z.boolean().optional(),
150 disableComposerPrompt: z.boolean().optional(),
151 disableLikesMetrics: z.boolean().optional(),
152 disableRepostsMetrics: z.boolean().optional(),
153 disableQuotesMetrics: z.boolean().optional(),
154 disableSavesMetrics: z.boolean().optional(),
155 disableReplyMetrics: z.boolean().optional(),
156 disableFollowersMetrics: z.boolean().optional(),
157 disableFollowingMetrics: z.boolean().optional(),
158 disableFollowedByMetrics: z.boolean().optional(),
159 disablePostsMetrics: z.boolean().optional(),
160 hideSimilarAccountsRecomm: z.boolean().optional(),
161 enableSquareAvatars: z.boolean().optional(),
162 enableSquareButtons: z.boolean().optional(),
163 disableVerifyEmailReminder: z.boolean().optional(),
164 deerVerification: z
165 .object({
166 enabled: z.boolean(),
167 trusted: z.array(z.string()),
168 })
169 .optional(),
170 highQualityImages: z.boolean().optional(),
171 hideUnreplyablePosts: z.boolean().optional(),
172
173 postReplacement: z.object({
174 enabled: z.boolean().optional(),
175 postName: z.string().optional(),
176 postsName: z.string().optional(),
177 }),
178
179 showExternalShareButtons: z.boolean().optional(),
180
181 translationServicePreference: z.enum([
182 'google',
183 'kagi',
184 'papago',
185 'libreTranslate',
186 ]),
187 libreTranslateInstance: z.string().optional(),
188
189 /** @deprecated */
190 mutedThreads: z.array(z.string()),
191 trendingDisabled: z.boolean().optional(),
192 trendingVideoDisabled: z.boolean().optional(),
193})
194export type Schema = z.infer<typeof schema>
195
196export const defaults: Schema = {
197 colorMode: 'system',
198 darkTheme: 'dim',
199 colorScheme: 'witchsky',
200 hue: 0,
201 session: {
202 accounts: [],
203 currentAccount: undefined,
204 },
205 reminders: {
206 lastEmailConfirm: undefined,
207 },
208 languagePrefs: {
209 primaryLanguage: deviceLanguageCodes[0] || 'en',
210 contentLanguages: [],
211 postLanguage: deviceLanguageCodes[0] || 'en',
212 postLanguageHistory: (deviceLanguageCodes || [])
213 .concat(['en', 'ja', 'pt', 'de'])
214 .slice(0, 6),
215 // try full language tag first, then fallback to language code
216 appLanguage: findSupportedAppLanguage([
217 deviceLocales.at(0)?.languageTag,
218 deviceLanguageCodes[0],
219 ]),
220 },
221 requireAltTextEnabled: true,
222 largeAltBadgeEnabled: false,
223 externalEmbeds: {},
224 mutedThreads: [],
225 invites: {
226 copiedInvites: [],
227 },
228 onboarding: {
229 step: 'Home',
230 },
231 hiddenPosts: [],
232 useInAppBrowser: undefined,
233 lastSelectedHomeFeed: undefined,
234 pdsAddressHistory: [],
235 disableHaptics: false,
236 disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(),
237 kawaii: false,
238 hasCheckedForStarterPack: false,
239 subtitlesEnabled: true,
240 trendingDisabled: true,
241 trendingVideoDisabled: true,
242
243 // deer
244 goLinksEnabled: true,
245 constellationEnabled: true,
246 directFetchRecords: true,
247 noAppLabelers: false,
248 noDiscoverFallback: false,
249 repostCarouselEnabled: false,
250 constellationInstance: 'https://constellation.microcosm.blue/',
251 showLinkInHandle: true,
252 hideFeedsPromoTab: false,
253 disableViaRepostNotification: false,
254 disableComposerPrompt: true,
255 disableLikesMetrics: false,
256 disableRepostsMetrics: false,
257 disableQuotesMetrics: false,
258 disableSavesMetrics: false,
259 disableReplyMetrics: false,
260 disableFollowersMetrics: false,
261 disableFollowingMetrics: false,
262 disableFollowedByMetrics: false,
263 disablePostsMetrics: false,
264 hideSimilarAccountsRecomm: true,
265 enableSquareAvatars: true,
266 enableSquareButtons: true,
267 disableVerifyEmailReminder: false,
268 deerVerification: {
269 enabled: false,
270 // https://witchsky.app/profile/did:plc:p2cp5gopk7mgjegy6wadk3ep/post/3lndyqyyr4k2k
271 // using https://bverified.vercel.app/trusted as a source
272 trusted: [
273 'did:plc:z72i7hdynmk6r22z27h6tvur',
274 'did:plc:b2kutgxqlltwc6lhs724cfwr',
275 'did:plc:inz4fkbbp7ms3ixufw6xuvdi',
276 'did:plc:eclio37ymobqex2ncko63h4r',
277 'did:plc:dzezcmpb3fhcpns4n4xm4ur5',
278 'did:plc:5u54z2qgkq43dh2nzwzdbbhb',
279 'did:plc:wmho6q2uiyktkam3jsvrms3s',
280 'did:plc:sqbswn3lalcc2dlh2k7zdpuw',
281 'did:plc:k5nskatzhyxersjilvtnz4lh',
282 'did:plc:d2jith367s6ybc3ldsusgdae',
283 'did:plc:y3xrmnwvkvsq4tqcsgwch4na',
284 'did:plc:i3fhjvvkbmirhyu4aeihhrnv',
285 'did:plc:fivojrvylkim4nuo3pfqcf3k',
286 'did:plc:ofbkqcjzvm6gtwuufsubnkaf',
287 'did:plc:xwqgusybtrpm67tcwqdfmzvy',
288 'did:plc:oxo226vi7t2btjokm2buusoy',
289 'did:plc:r4ve5hjtfjubdwrvlxcad62e',
290 'did:plc:j4eroku3volozvv6ljsnnfec',
291 'did:plc:6q2thhy2ohzog26mmqm4pffk',
292 'did:plc:rk25gdgk3cnnmtkvlae265nz',
293 ],
294 },
295 highQualityImages: false,
296 hideUnreplyablePosts: false,
297 showExternalShareButtons: false,
298 translationServicePreference: 'google',
299 libreTranslateInstance: 'https://libretranslate.com/',
300
301 postReplacement: {
302 enabled: false,
303 postName: 'skeet',
304 postsName: 'skeets',
305 },
306}
307
308export function tryParse(rawData: string): Schema | undefined {
309 let objData
310 try {
311 objData = JSON.parse(rawData)
312 } catch (e) {
313 logger.error('persisted state: failed to parse root state from storage', {
314 message: e,
315 })
316 }
317 if (!objData) {
318 return undefined
319 }
320 const parsed = schema.safeParse(objData)
321 if (parsed.success) {
322 return objData
323 } else {
324 const errors =
325 parsed.error?.errors?.map(e => ({
326 code: e.code,
327 // @ts-ignore exists on some types
328 expected: e?.expected,
329 path: e.path?.join('.'),
330 })) || []
331 logger.error(`persisted store: data failed validation on read`, {errors})
332 return undefined
333 }
334}
335
336export function tryStringify(value: Schema): string | undefined {
337 try {
338 schema.parse(value)
339 return JSON.stringify(value)
340 } catch (e) {
341 logger.error(`persisted state: failed stringifying root state`, {
342 message: e,
343 })
344 return undefined
345 }
346}