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 string: z.string().optional(),
176 }),
177
178 showExternalShareButtons: z.boolean().optional(),
179
180 translationServicePreference: z.enum([
181 'google',
182 'kagi',
183 'papago',
184 'libreTranslate',
185 ]),
186 libreTranslateInstance: z.string().optional(),
187
188 /** @deprecated */
189 mutedThreads: z.array(z.string()),
190 trendingDisabled: z.boolean().optional(),
191 trendingVideoDisabled: z.boolean().optional(),
192})
193export type Schema = z.infer<typeof schema>
194
195export const defaults: Schema = {
196 colorMode: 'system',
197 darkTheme: 'dim',
198 colorScheme: 'witchsky',
199 hue: 0,
200 session: {
201 accounts: [],
202 currentAccount: undefined,
203 },
204 reminders: {
205 lastEmailConfirm: undefined,
206 },
207 languagePrefs: {
208 primaryLanguage: deviceLanguageCodes[0] || 'en',
209 contentLanguages: deviceLanguageCodes || [],
210 postLanguage: deviceLanguageCodes[0] || 'en',
211 postLanguageHistory: (deviceLanguageCodes || [])
212 .concat(['en', 'ja', 'pt', 'de'])
213 .slice(0, 6),
214 // try full language tag first, then fallback to language code
215 appLanguage: findSupportedAppLanguage([
216 deviceLocales.at(0)?.languageTag,
217 deviceLanguageCodes[0],
218 ]),
219 },
220 requireAltTextEnabled: true,
221 largeAltBadgeEnabled: true,
222 externalEmbeds: {},
223 mutedThreads: [],
224 invites: {
225 copiedInvites: [],
226 },
227 onboarding: {
228 step: 'Home',
229 },
230 hiddenPosts: [],
231 useInAppBrowser: undefined,
232 lastSelectedHomeFeed: undefined,
233 pdsAddressHistory: [],
234 disableHaptics: false,
235 disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(),
236 kawaii: false,
237 hasCheckedForStarterPack: false,
238 subtitlesEnabled: true,
239 trendingDisabled: false,
240 trendingVideoDisabled: false,
241
242 // deer
243 goLinksEnabled: true,
244 constellationEnabled: true,
245 directFetchRecords: false,
246 noAppLabelers: false,
247 noDiscoverFallback: false,
248 repostCarouselEnabled: false,
249 constellationInstance: 'https://constellation.microcosm.blue/',
250 showLinkInHandle: true,
251 hideFeedsPromoTab: false,
252 disableViaRepostNotification: false,
253 disableComposerPrompt: true,
254 disableLikesMetrics: false,
255 disableRepostsMetrics: false,
256 disableQuotesMetrics: false,
257 disableSavesMetrics: false,
258 disableReplyMetrics: false,
259 disableFollowersMetrics: false,
260 disableFollowingMetrics: false,
261 disableFollowedByMetrics: false,
262 disablePostsMetrics: false,
263 hideSimilarAccountsRecomm: true,
264 enableSquareAvatars: true,
265 enableSquareButtons: true,
266 disableVerifyEmailReminder: false,
267 deerVerification: {
268 enabled: false,
269 // https://witchsky.app/profile/did:plc:p2cp5gopk7mgjegy6wadk3ep/post/3lndyqyyr4k2k
270 // using https://bverified.vercel.app/trusted as a source
271 trusted: [
272 'did:plc:z72i7hdynmk6r22z27h6tvur',
273 'did:plc:b2kutgxqlltwc6lhs724cfwr',
274 'did:plc:inz4fkbbp7ms3ixufw6xuvdi',
275 'did:plc:eclio37ymobqex2ncko63h4r',
276 'did:plc:dzezcmpb3fhcpns4n4xm4ur5',
277 'did:plc:5u54z2qgkq43dh2nzwzdbbhb',
278 'did:plc:wmho6q2uiyktkam3jsvrms3s',
279 'did:plc:sqbswn3lalcc2dlh2k7zdpuw',
280 'did:plc:k5nskatzhyxersjilvtnz4lh',
281 'did:plc:d2jith367s6ybc3ldsusgdae',
282 'did:plc:y3xrmnwvkvsq4tqcsgwch4na',
283 'did:plc:i3fhjvvkbmirhyu4aeihhrnv',
284 'did:plc:fivojrvylkim4nuo3pfqcf3k',
285 'did:plc:ofbkqcjzvm6gtwuufsubnkaf',
286 'did:plc:xwqgusybtrpm67tcwqdfmzvy',
287 'did:plc:oxo226vi7t2btjokm2buusoy',
288 'did:plc:r4ve5hjtfjubdwrvlxcad62e',
289 'did:plc:j4eroku3volozvv6ljsnnfec',
290 'did:plc:6q2thhy2ohzog26mmqm4pffk',
291 'did:plc:rk25gdgk3cnnmtkvlae265nz',
292 ],
293 },
294 highQualityImages: false,
295 hideUnreplyablePosts: false,
296 showExternalShareButtons: false,
297 translationServicePreference: 'google',
298 libreTranslateInstance: 'https://libretranslate.com/',
299
300 postReplacement: {
301 enabled: false,
302 string: 'skeet',
303 },
304}
305
306export function tryParse(rawData: string): Schema | undefined {
307 let objData
308 try {
309 objData = JSON.parse(rawData)
310 } catch (e) {
311 logger.error('persisted state: failed to parse root state from storage', {
312 message: e,
313 })
314 }
315 if (!objData) {
316 return undefined
317 }
318 const parsed = schema.safeParse(objData)
319 if (parsed.success) {
320 return objData
321 } else {
322 const errors =
323 parsed.error?.errors?.map(e => ({
324 code: e.code,
325 // @ts-ignore exists on some types
326 expected: e?.expected,
327 path: e.path?.join('.'),
328 })) || []
329 logger.error(`persisted store: data failed validation on read`, {errors})
330 return undefined
331 }
332}
333
334export function tryStringify(value: Schema): string | undefined {
335 try {
336 schema.parse(value)
337 return JSON.stringify(value)
338 } catch (e) {
339 logger.error(`persisted state: failed stringifying root state`, {
340 message: e,
341 })
342 return undefined
343 }
344}