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 session: z.object({
53 accounts: z.array(accountSchema),
54 currentAccount: currentAccountSchema.optional(),
55 }),
56 reminders: z.object({
57 lastEmailConfirm: z.string().optional(),
58 }),
59 languagePrefs: z.object({
60 /**
61 * The target language for translating posts.
62 *
63 * BCP-47 2-letter language code without region.
64 */
65 primaryLanguage: z.string(),
66 /**
67 * The languages the user can read, passed to feeds.
68 *
69 * BCP-47 2-letter language codes without region.
70 */
71 contentLanguages: z.array(z.string()),
72 /**
73 * The language(s) the user is currently posting in, configured within the
74 * composer. Multiple languages are separated by commas.
75 *
76 * BCP-47 2-letter language code without region.
77 */
78 postLanguage: z.string(),
79 /**
80 * The user's post language history, used to pre-populate the post language
81 * selector in the composer. Within each value, multiple languages are separated
82 * by commas.
83 *
84 * BCP-47 2-letter language codes without region.
85 */
86 postLanguageHistory: z.array(z.string()),
87 /**
88 * The language for UI translations in the app.
89 *
90 * BCP-47 2-letter language code with or without region,
91 * to match with {@link AppLanguage}.
92 */
93 appLanguage: z.string(),
94 }),
95 requireAltTextEnabled: z.boolean(), // should move to server
96 largeAltBadgeEnabled: z.boolean().optional(),
97 externalEmbeds: z
98 .object({
99 giphy: z.enum(externalEmbedOptions).optional(),
100 tenor: z.enum(externalEmbedOptions).optional(),
101 youtube: z.enum(externalEmbedOptions).optional(),
102 youtubeShorts: z.enum(externalEmbedOptions).optional(),
103 twitch: z.enum(externalEmbedOptions).optional(),
104 vimeo: z.enum(externalEmbedOptions).optional(),
105 spotify: z.enum(externalEmbedOptions).optional(),
106 appleMusic: z.enum(externalEmbedOptions).optional(),
107 soundcloud: z.enum(externalEmbedOptions).optional(),
108 flickr: z.enum(externalEmbedOptions).optional(),
109 bandcamp: z.enum(externalEmbedOptions).optional(),
110 })
111 .optional(),
112 invites: z.object({
113 copiedInvites: z.array(z.string()),
114 }),
115 onboarding: z.object({
116 step: z.string(),
117 }),
118 hiddenPosts: z.array(z.string()).optional(), // should move to server
119 useInAppBrowser: z.boolean().optional(),
120 /** @deprecated */
121 lastSelectedHomeFeed: z.string().optional(),
122 pdsAddressHistory: z.array(z.string()).optional(),
123 disableHaptics: z.boolean().optional(),
124 disableAutoplay: z.boolean().optional(),
125 kawaii: z.boolean().optional(),
126 hasCheckedForStarterPack: z.boolean().optional(),
127 subtitlesEnabled: z.boolean().optional(),
128 /** @deprecated */
129 mutedThreads: z.array(z.string()),
130 trendingDisabled: z.boolean().optional(),
131 trendingVideoDisabled: z.boolean().optional(),
132})
133export type Schema = z.infer<typeof schema>
134
135export const defaults: Schema = {
136 colorMode: 'system',
137 darkTheme: 'dim',
138 session: {
139 accounts: [],
140 currentAccount: undefined,
141 },
142 reminders: {
143 lastEmailConfirm: undefined,
144 },
145 languagePrefs: {
146 primaryLanguage: deviceLanguageCodes[0] || 'en',
147 contentLanguages: deviceLanguageCodes || [],
148 postLanguage: deviceLanguageCodes[0] || 'en',
149 postLanguageHistory: (deviceLanguageCodes || [])
150 .concat(['en', 'ja', 'pt', 'de'])
151 .slice(0, 6),
152 // try full language tag first, then fallback to language code
153 appLanguage: findSupportedAppLanguage([
154 deviceLocales.at(0)?.languageTag,
155 deviceLanguageCodes[0],
156 ]),
157 },
158 requireAltTextEnabled: false,
159 largeAltBadgeEnabled: false,
160 externalEmbeds: {},
161 mutedThreads: [],
162 invites: {
163 copiedInvites: [],
164 },
165 onboarding: {
166 step: 'Home',
167 },
168 hiddenPosts: [],
169 useInAppBrowser: undefined,
170 lastSelectedHomeFeed: undefined,
171 pdsAddressHistory: [],
172 disableHaptics: false,
173 disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(),
174 kawaii: false,
175 hasCheckedForStarterPack: false,
176 subtitlesEnabled: true,
177 trendingDisabled: false,
178 trendingVideoDisabled: false,
179}
180
181export function tryParse(rawData: string): Schema | undefined {
182 let objData
183 try {
184 objData = JSON.parse(rawData)
185 } catch (e) {
186 logger.error('persisted state: failed to parse root state from storage', {
187 message: e,
188 })
189 }
190 if (!objData) {
191 return undefined
192 }
193 const parsed = schema.safeParse(objData)
194 if (parsed.success) {
195 return objData
196 } else {
197 const errors =
198 parsed.error?.errors?.map(e => ({
199 code: e.code,
200 // @ts-ignore exists on some types
201 expected: e?.expected,
202 path: e.path?.join('.'),
203 })) || []
204 logger.error(`persisted store: data failed validation on read`, {errors})
205 return undefined
206 }
207}
208
209export function tryStringify(value: Schema): string | undefined {
210 try {
211 schema.parse(value)
212 return JSON.stringify(value)
213 } catch (e) {
214 logger.error(`persisted state: failed stringifying root state`, {
215 message: e,
216 })
217 return undefined
218 }
219}