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