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 lastSelectedHomeFeed: z.string().optional(),
131 pdsAddressHistory: z.array(z.string()).optional(),
132 disableHaptics: z.boolean().optional(),
133 disableAutoplay: z.boolean().optional(),
134 kawaii: z.boolean().optional(),
135 hasCheckedForStarterPack: z.boolean().optional(),
136 subtitlesEnabled: z.boolean().optional(),
137
138 // deer
139 goLinksEnabled: z.boolean().optional(),
140 constellationEnabled: z.boolean().optional(),
141 directFetchRecords: z.boolean().optional(),
142 noAppLabelers: z.boolean().optional(),
143 noDiscoverFallback: z.boolean().optional(),
144 repostCarouselEnabled: z.boolean().optional(),
145 constellationInstance: z.string().optional(),
146 showLinkInHandle: z.boolean().optional(),
147 hideFeedsPromoTab: z.boolean().optional(),
148 disableViaRepostNotification: z.boolean().optional(),
149 disableLikesMetrics: z.boolean().optional(),
150 disableRepostsMetrics: z.boolean().optional(),
151 disableQuotesMetrics: z.boolean().optional(),
152 disableSavesMetrics: z.boolean().optional(),
153 disableReplyMetrics: z.boolean().optional(),
154 disableFollowersMetrics: z.boolean().optional(),
155 disableFollowingMetrics: z.boolean().optional(),
156 disableFollowedByMetrics: z.boolean().optional(),
157 disablePostsMetrics: z.boolean().optional(),
158 hideSimilarAccountsRecomm: z.boolean().optional(),
159 enableSquareAvatars: z.boolean().optional(),
160 enableSquareButtons: z.boolean().optional(),
161 disableVerifyEmailReminder: z.boolean().optional(),
162 deerVerification: z
163 .object({
164 enabled: z.boolean(),
165 trusted: z.array(z.string()),
166 })
167 .optional(),
168 highQualityImages: z.boolean().optional(),
169 hideUnreplyablePosts: z.boolean().optional(),
170
171 showExternalShareButtons: z.boolean().optional(),
172 terminologyPreference: z.enum(['skeet', 'post', 'spell']).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: false,
250 enableSquareButtons: false,
251 disableVerifyEmailReminder: false,
252 deerVerification: {
253 enabled: false,
254 // https://witchsky.app/profile/did:plc:p2cp5gopk7mgjegy6wadk3ep/post/3lndyqyyr4k2k
255 // using https://bverified.vercel.app/ 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 ],
272 },
273 highQualityImages: false,
274 hideUnreplyablePosts: false,
275 showExternalShareButtons: false,
276 terminologyPreference: 'skeet',
277}
278
279export function tryParse(rawData: string): Schema | undefined {
280 let objData
281 try {
282 objData = JSON.parse(rawData)
283 } catch (e) {
284 logger.error('persisted state: failed to parse root state from storage', {
285 message: e,
286 })
287 }
288 if (!objData) {
289 return undefined
290 }
291 const parsed = schema.safeParse(objData)
292 if (parsed.success) {
293 return objData
294 } else {
295 const errors =
296 parsed.error?.errors?.map(e => ({
297 code: e.code,
298 // @ts-ignore exists on some types
299 expected: e?.expected,
300 path: e.path?.join('.'),
301 })) || []
302 logger.error(`persisted store: data failed validation on read`, {errors})
303 return undefined
304 }
305}
306
307export function tryStringify(value: Schema): string | undefined {
308 try {
309 schema.parse(value)
310 return JSON.stringify(value)
311 } catch (e) {
312 logger.error(`persisted state: failed stringifying root state`, {
313 message: e,
314 })
315 return undefined
316 }
317}