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