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