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