forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {z} from 'zod'
2
3import {DEFAULT_ALT_TEXT_AI_MODEL} from '#/lib/constants'
4import {deviceLanguageCodes, deviceLocales} from '#/locale/deviceLocales'
5import {findSupportedAppLanguage} from '#/locale/helpers'
6import {logger} from '#/logger'
7import {PlatformInfo} from '../../../modules/expo-bluesky-swiss-army'
8
9const externalEmbedOptions = ['show', 'hide'] as const
10
11/**
12 * A account persisted to storage. Stored in the `accounts[]` array. Contains
13 * base account info and access tokens.
14 */
15const accountSchema = z.object({
16 service: z.string(),
17 did: z.string(),
18 handle: z.string(),
19 addedAt: z.string().optional(),
20 lastActiveAt: z.string().optional(),
21 email: z.string().optional(),
22 emailConfirmed: z.boolean().optional(),
23 emailAuthFactor: z.boolean().optional(),
24 refreshJwt: z.string().optional(), // optional because it can expire
25 accessJwt: z.string().optional(), // optional because it can expire
26 signupQueued: z.boolean().optional(),
27 active: z.boolean().optional(), // optional for backwards compat
28 /**
29 * Known values: takendown, suspended, deactivated
30 * @see https://github.com/bluesky-social/atproto/blob/5441fbde9ed3b22463e91481ec80cb095643e141/lexicons/com/atproto/server/getSession.json
31 */
32 status: z.string().optional(),
33 pdsUrl: z.string().optional(),
34 isSelfHosted: z.boolean().optional(),
35 isOauthSession: z.boolean().optional(),
36})
37export type PersistedAccount = z.infer<typeof accountSchema>
38
39/**
40 * The current account. Stored in the `currentAccount` field.
41 *
42 * In previous versions, this included tokens and other info. Now, it's used
43 * only to reference the `did` field, and all other fields are marked as
44 * optional. They should be considered deprecated and not used, but are kept
45 * here for backwards compat.
46 */
47const currentAccountSchema = accountSchema.extend({
48 service: z.string().optional(),
49 handle: z.string().optional(),
50})
51export type PersistedCurrentAccount = z.infer<typeof currentAccountSchema>
52
53const schema = z.object({
54 colorMode: z.enum(['system', 'light', 'dark']),
55 darkTheme: z.enum(['dim', 'dark']).optional(),
56 colorScheme: z.enum([
57 'witchsky',
58 'bluesky',
59 'blacksky',
60 'deer',
61 'zeppelin',
62 'kitty',
63 'reddwarf',
64 'catppuccin',
65 'evergarden',
66 'cyan base',
67 'material3',
68 ]),
69 hue: z.number(),
70 material3Accent: z
71 .string()
72 .regex(/^#[0-9a-fA-F]{6}$/, {
73 message:
74 'Invalid color format. Must be a 7-character hex code (e.g., #RRGGBB).',
75 })
76 .default('#ee6300'),
77 material3Style: z
78 .enum([
79 'SPRITZ',
80 'TONAL_SPOT',
81 'VIBRANT',
82 'EXPRESSIVE',
83 'RAINBOW',
84 'FRUIT_SALAD',
85 'CONTENT',
86 'MONOCHROMATIC',
87 ])
88 .default('TONAL_SPOT'),
89 session: z.object({
90 accounts: z.array(accountSchema),
91 currentAccount: currentAccountSchema.optional(),
92 }),
93 reminders: z.object({
94 lastEmailConfirm: z.string().optional(),
95 }),
96 languagePrefs: z.object({
97 /**
98 * The target language for translating posts.
99 *
100 * BCP-47 2-letter language code without region.
101 */
102 primaryLanguage: z.string(),
103 /**
104 * The languages the user can read, passed to feeds.
105 *
106 * BCP-47 2-letter language codes without region.
107 */
108 contentLanguages: z.array(z.string()),
109 /**
110 * The language(s) the user is currently posting in, configured within the
111 * composer. Multiple languages are separated by commas.
112 *
113 * BCP-47 2-letter language code without region.
114 */
115 postLanguage: z.string(),
116 /**
117 * The user's post language history, used to pre-populate the post language
118 * selector in the composer. Within each value, multiple languages are separated
119 * by commas.
120 *
121 * BCP-47 2-letter language codes without region.
122 */
123 postLanguageHistory: z.array(z.string()),
124 /**
125 * The language for UI translations in the app.
126 *
127 * BCP-47 2-letter language code with or without region,
128 * to match with {@link AppLanguage}.
129 */
130 appLanguage: z.string(),
131 }),
132 requireAltTextEnabled: z.boolean(), // should move to server
133 largeAltBadgeEnabled: z.boolean().optional(),
134 externalEmbeds: z
135 .object({
136 giphy: z.enum(externalEmbedOptions).optional(),
137 tenor: z.enum(externalEmbedOptions).optional(),
138 klipy: z.enum(externalEmbedOptions).optional(),
139 youtube: z.enum(externalEmbedOptions).optional(),
140 youtubeShorts: z.enum(externalEmbedOptions).optional(),
141 twitch: z.enum(externalEmbedOptions).optional(),
142 vimeo: z.enum(externalEmbedOptions).optional(),
143 spotify: z.enum(externalEmbedOptions).optional(),
144 appleMusic: z.enum(externalEmbedOptions).optional(),
145 soundcloud: z.enum(externalEmbedOptions).optional(),
146 flickr: z.enum(externalEmbedOptions).optional(),
147 bandcamp: z.enum(externalEmbedOptions).optional(),
148 streamplace: z.enum(externalEmbedOptions).optional(),
149 })
150 .optional(),
151 invites: z.object({
152 copiedInvites: z.array(z.string()),
153 }),
154 onboarding: z.object({
155 step: z.string(),
156 }),
157 hiddenPosts: z.array(z.string()).optional(), // should move to server
158 useInAppBrowser: z.boolean().optional(),
159 /** @deprecated */
160 lastSelectedHomeFeed: z.string().optional(),
161 pdsAddressHistory: z.array(z.string()).optional(),
162 disableHaptics: z.boolean().optional(),
163 disableAutoplay: z.boolean().optional(),
164 kawaii: z.boolean().optional(),
165 hasCheckedForStarterPack: z.boolean().optional(),
166 subtitlesEnabled: z.boolean().optional(),
167
168 // deer
169 goLinksEnabled: z.boolean().optional(),
170 constellationEnabled: z.boolean().optional(),
171 directFetchRecords: z.boolean().optional(),
172 noAppLabelers: z.boolean().optional(),
173 noDiscoverFallback: z.boolean().optional(),
174 repostCarouselEnabled: z.boolean().optional(),
175 alsoLikedFeedEnabled: z.boolean().optional(),
176 constellationInstance: z.string().optional(),
177 showLinkInHandle: z.boolean().optional(),
178 showLinkInHandleOnlyOnWorkingLinks: z.boolean().optional(),
179 hideFeedsPromoTab: z.boolean().optional(),
180 disableViaRepostNotification: z.boolean().optional(),
181 disableComposerPrompt: z.boolean().optional(),
182 disableTopOfFeedButton: z.boolean().optional(),
183 disableLikesMetrics: z.boolean().optional(),
184 disableRepostsMetrics: z.boolean().optional(),
185 disableQuotesMetrics: z.boolean().optional(),
186 disableSavesMetrics: z.boolean().optional(),
187 disableReplyMetrics: z.boolean().optional(),
188 disableFollowersMetrics: z.boolean().optional(),
189 disableFollowingMetrics: z.boolean().optional(),
190 disableFollowedByMetrics: z.boolean().optional(),
191 disablePostsMetrics: z.boolean().optional(),
192 showFollowsYouBadge: z.boolean().optional(),
193 hideSimilarAccountsRecomm: z.boolean().optional(),
194 hideScaryFollowButtons: z.boolean().optional(),
195 discoverContextEnabled: z.boolean().optional(),
196 enableSquareAvatars: z.boolean().optional(),
197 enableSquareButtons: z.boolean().optional(),
198 disableVerifyEmailReminder: z.boolean().optional(),
199 showViaClient: z.boolean().optional(),
200 deerVerification: z
201 .object({
202 enabled: z.boolean(),
203 trusted: z.array(z.string()),
204 })
205 .optional(),
206 highQualityImages: z.boolean().optional(),
207 imageCdnHost: z.string().optional(),
208 plcDirectory: z.string().optional(),
209 hideUnreplyablePosts: z.boolean().optional(),
210 pdsLabel: z
211 .object({
212 enabled: z.boolean(),
213 hideBskyPds: z.boolean(),
214 })
215 .optional(),
216 faviconService: z.string().optional(),
217
218 postReplacement: z.object({
219 enabled: z.boolean().optional(),
220 postName: z.string().optional(),
221 postsName: z.string().optional(),
222 }),
223
224 showExternalShareButtons: z.boolean().optional(),
225
226 translationServicePreference: z.enum([
227 'google',
228 'kagi',
229 'papago',
230 'libreTranslate',
231 ]),
232 libreTranslateInstance: z.string().optional(),
233
234 openRouterApiKey: z.string().optional(),
235 openRouterModel: z.string().optional(),
236 openRouterPrompt: z.string().optional(),
237
238 useHandleInLinks: z.boolean().optional(),
239
240 /** @deprecated */
241 mutedThreads: z.array(z.string()),
242 trendingDisabled: z.boolean().optional(),
243 trendingVideoDisabled: z.boolean().optional(),
244
245 autoLikeOnRepost: z.boolean().optional(),
246 omitViaField: z.boolean().optional(),
247})
248export type Schema = z.infer<typeof schema>
249
250export const defaults: Schema = {
251 colorMode: 'system',
252 darkTheme: 'dim',
253 colorScheme: 'witchsky',
254 hue: 0,
255 material3Accent: '#ee6300',
256 material3Style: 'TONAL_SPOT',
257 session: {
258 accounts: [],
259 currentAccount: undefined,
260 },
261 reminders: {
262 lastEmailConfirm: undefined,
263 },
264 languagePrefs: {
265 primaryLanguage: deviceLanguageCodes[0] || 'en',
266 contentLanguages: [],
267 postLanguage: deviceLanguageCodes[0] || 'en',
268 postLanguageHistory: (deviceLanguageCodes || [])
269 .concat(['en', 'ja', 'pt', 'de'])
270 .slice(0, 6),
271 // try full language tag first, then fallback to language code
272 appLanguage: findSupportedAppLanguage([
273 deviceLocales.at(0)?.languageTag,
274 deviceLanguageCodes[0],
275 ]),
276 },
277 requireAltTextEnabled: true,
278 largeAltBadgeEnabled: false,
279 externalEmbeds: {},
280 mutedThreads: [],
281 invites: {
282 copiedInvites: [],
283 },
284 onboarding: {
285 step: 'Home',
286 },
287 hiddenPosts: [],
288 useInAppBrowser: undefined,
289 lastSelectedHomeFeed: undefined,
290 pdsAddressHistory: [],
291 disableHaptics: false,
292 disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(),
293 kawaii: false,
294 hasCheckedForStarterPack: false,
295 subtitlesEnabled: true,
296 trendingDisabled: true,
297 trendingVideoDisabled: true,
298
299 // deer
300 goLinksEnabled: true,
301 constellationEnabled: true,
302 directFetchRecords: true,
303 noAppLabelers: false,
304 noDiscoverFallback: false,
305 repostCarouselEnabled: false,
306 alsoLikedFeedEnabled: true,
307 constellationInstance: 'https://constellation.microcosm.blue/',
308 showLinkInHandle: true,
309 showLinkInHandleOnlyOnWorkingLinks: true,
310 hideFeedsPromoTab: false,
311 disableViaRepostNotification: false,
312 disableComposerPrompt: true,
313 disableTopOfFeedButton: false,
314 disableLikesMetrics: false,
315 disableRepostsMetrics: false,
316 disableQuotesMetrics: false,
317 disableSavesMetrics: false,
318 disableReplyMetrics: false,
319 disableFollowersMetrics: false,
320 disableFollowingMetrics: false,
321 disableFollowedByMetrics: false,
322 disablePostsMetrics: false,
323 showFollowsYouBadge: false,
324 hideSimilarAccountsRecomm: true,
325 hideScaryFollowButtons: false,
326 discoverContextEnabled: false,
327 enableSquareAvatars: true,
328 enableSquareButtons: true,
329 disableVerifyEmailReminder: false,
330 showViaClient: true,
331 deerVerification: {
332 enabled: false,
333 // https://witchsky.app/profile/did:plc:p2cp5gopk7mgjegy6wadk3ep/post/3lndyqyyr4k2k
334 // using https://bverified.vercel.app/trusted as a source
335 trusted: [
336 'did:plc:z72i7hdynmk6r22z27h6tvur',
337 'did:plc:b2kutgxqlltwc6lhs724cfwr',
338 'did:plc:inz4fkbbp7ms3ixufw6xuvdi',
339 'did:plc:eclio37ymobqex2ncko63h4r',
340 'did:plc:dzezcmpb3fhcpns4n4xm4ur5',
341 'did:plc:5u54z2qgkq43dh2nzwzdbbhb',
342 'did:plc:wmho6q2uiyktkam3jsvrms3s',
343 'did:plc:sqbswn3lalcc2dlh2k7zdpuw',
344 'did:plc:k5nskatzhyxersjilvtnz4lh',
345 'did:plc:d2jith367s6ybc3ldsusgdae',
346 'did:plc:y3xrmnwvkvsq4tqcsgwch4na',
347 'did:plc:i3fhjvvkbmirhyu4aeihhrnv',
348 'did:plc:fivojrvylkim4nuo3pfqcf3k',
349 'did:plc:ofbkqcjzvm6gtwuufsubnkaf',
350 'did:plc:xwqgusybtrpm67tcwqdfmzvy',
351 'did:plc:oxo226vi7t2btjokm2buusoy',
352 'did:plc:r4ve5hjtfjubdwrvlxcad62e',
353 'did:plc:j4eroku3volozvv6ljsnnfec',
354 'did:plc:6q2thhy2ohzog26mmqm4pffk',
355 'did:plc:rk25gdgk3cnnmtkvlae265nz',
356 ],
357 },
358 highQualityImages: false,
359 imageCdnHost: 'https://cdn.bsky.app',
360 plcDirectory: 'https://plc.directory',
361 hideUnreplyablePosts: false,
362 pdsLabel: {
363 enabled: true,
364 hideBskyPds: true,
365 },
366 faviconService: 'https://twenty-icons.com/(pds)',
367 showExternalShareButtons: false,
368 translationServicePreference: 'google',
369 libreTranslateInstance: 'https://libretranslate.com/',
370
371 openRouterApiKey: undefined,
372 openRouterModel: DEFAULT_ALT_TEXT_AI_MODEL,
373 openRouterPrompt: undefined,
374
375 useHandleInLinks: false,
376
377 postReplacement: {
378 enabled: false,
379 postName: 'skeet',
380 postsName: 'skeets',
381 },
382
383 autoLikeOnRepost: false,
384 omitViaField: false,
385}
386
387export function tryParse(rawData: string): Schema | undefined {
388 let objData
389 try {
390 objData = JSON.parse(rawData)
391 } catch (e) {
392 logger.error('persisted state: failed to parse root state from storage', {
393 message: e,
394 })
395 }
396 if (!objData) {
397 return undefined
398 }
399 const parsed = schema.safeParse(objData)
400 if (parsed.success) {
401 return objData
402 } else {
403 const errors =
404 parsed.error?.errors?.map(e => ({
405 code: e.code,
406 // @ts-ignore exists on some types
407 expected: e?.expected,
408 path: e.path?.join('.'),
409 })) || []
410 logger.error(`persisted store: data failed validation on read`, {errors})
411 return undefined
412 }
413}
414
415export function tryStringify(value: Schema): string | undefined {
416 try {
417 schema.parse(value)
418 return JSON.stringify(value)
419 } catch (e) {
420 logger.error(`persisted state: failed stringifying root state`, {
421 message: e,
422 })
423 return undefined
424 }
425}