mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {z} from 'zod'
2
3import {logger} from '#/logger'
4import {deviceLocales} from '#/platform/detection'
5import {PlatformInfo} from '../../../modules/expo-bluesky-swiss-army'
6
7const externalEmbedOptions = ['show', 'hide'] as const
8
9/**
10 * A account persisted to storage. Stored in the `accounts[]` array. Contains
11 * base account info and access tokens.
12 */
13const accountSchema = z.object({
14 service: z.string(),
15 did: z.string(),
16 handle: z.string(),
17 email: z.string().optional(),
18 emailConfirmed: z.boolean().optional(),
19 emailAuthFactor: z.boolean().optional(),
20 refreshJwt: z.string().optional(), // optional because it can expire
21 accessJwt: z.string().optional(), // optional because it can expire
22 signupQueued: z.boolean().optional(),
23 active: z.boolean().optional(), // optional for backwards compat
24 /**
25 * Known values: takendown, suspended, deactivated
26 * @see https://github.com/bluesky-social/atproto/blob/5441fbde9ed3b22463e91481ec80cb095643e141/lexicons/com/atproto/server/getSession.json
27 */
28 status: z.string().optional(),
29 pdsUrl: z.string().optional(),
30})
31export type PersistedAccount = z.infer<typeof accountSchema>
32
33/**
34 * The current account. Stored in the `currentAccount` field.
35 *
36 * In previous versions, this included tokens and other info. Now, it's used
37 * only to reference the `did` field, and all other fields are marked as
38 * optional. They should be considered deprecated and not used, but are kept
39 * here for backwards compat.
40 */
41const currentAccountSchema = accountSchema.extend({
42 service: z.string().optional(),
43 handle: z.string().optional(),
44})
45export type PersistedCurrentAccount = z.infer<typeof currentAccountSchema>
46
47const schema = z.object({
48 colorMode: z.enum(['system', 'light', 'dark']),
49 darkTheme: z.enum(['dim', 'dark']).optional(),
50 session: z.object({
51 accounts: z.array(accountSchema),
52 currentAccount: currentAccountSchema.optional(),
53 }),
54 reminders: z.object({
55 lastEmailConfirm: z.string().optional(),
56 }),
57 languagePrefs: z.object({
58 primaryLanguage: z.string(), // should move to server
59 contentLanguages: z.array(z.string()), // should move to server
60 postLanguage: z.string(), // should move to server
61 postLanguageHistory: z.array(z.string()),
62 appLanguage: z.string(),
63 }),
64 requireAltTextEnabled: z.boolean(), // should move to server
65 largeAltBadgeEnabled: z.boolean().optional(),
66 externalEmbeds: z
67 .object({
68 giphy: z.enum(externalEmbedOptions).optional(),
69 tenor: z.enum(externalEmbedOptions).optional(),
70 youtube: z.enum(externalEmbedOptions).optional(),
71 youtubeShorts: z.enum(externalEmbedOptions).optional(),
72 twitch: z.enum(externalEmbedOptions).optional(),
73 vimeo: z.enum(externalEmbedOptions).optional(),
74 spotify: z.enum(externalEmbedOptions).optional(),
75 appleMusic: z.enum(externalEmbedOptions).optional(),
76 soundcloud: z.enum(externalEmbedOptions).optional(),
77 flickr: z.enum(externalEmbedOptions).optional(),
78 })
79 .optional(),
80 invites: z.object({
81 copiedInvites: z.array(z.string()),
82 }),
83 onboarding: z.object({
84 step: z.string(),
85 }),
86 hiddenPosts: z.array(z.string()).optional(), // should move to server
87 useInAppBrowser: z.boolean().optional(),
88 lastSelectedHomeFeed: z.string().optional(),
89 pdsAddressHistory: z.array(z.string()).optional(),
90 disableHaptics: z.boolean().optional(),
91 disableAutoplay: z.boolean().optional(),
92 kawaii: z.boolean().optional(),
93 hasCheckedForStarterPack: z.boolean().optional(),
94 subtitlesEnabled: z.boolean().optional(),
95 /** @deprecated */
96 mutedThreads: z.array(z.string()),
97})
98export type Schema = z.infer<typeof schema>
99
100export const defaults: Schema = {
101 colorMode: 'system',
102 darkTheme: 'dim',
103 session: {
104 accounts: [],
105 currentAccount: undefined,
106 },
107 reminders: {
108 lastEmailConfirm: undefined,
109 },
110 languagePrefs: {
111 primaryLanguage: deviceLocales[0] || 'en',
112 contentLanguages: deviceLocales || [],
113 postLanguage: deviceLocales[0] || 'en',
114 postLanguageHistory: (deviceLocales || [])
115 .concat(['en', 'ja', 'pt', 'de'])
116 .slice(0, 6),
117 appLanguage: deviceLocales[0] || 'en',
118 },
119 requireAltTextEnabled: false,
120 largeAltBadgeEnabled: false,
121 externalEmbeds: {},
122 mutedThreads: [],
123 invites: {
124 copiedInvites: [],
125 },
126 onboarding: {
127 step: 'Home',
128 },
129 hiddenPosts: [],
130 useInAppBrowser: undefined,
131 lastSelectedHomeFeed: undefined,
132 pdsAddressHistory: [],
133 disableHaptics: false,
134 disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(),
135 kawaii: false,
136 hasCheckedForStarterPack: false,
137 subtitlesEnabled: true,
138}
139
140export function tryParse(rawData: string): Schema | undefined {
141 let objData
142 try {
143 objData = JSON.parse(rawData)
144 } catch (e) {
145 logger.error('persisted state: failed to parse root state from storage', {
146 message: e,
147 })
148 }
149 if (!objData) {
150 return undefined
151 }
152 const parsed = schema.safeParse(objData)
153 if (parsed.success) {
154 return objData
155 } else {
156 const errors =
157 parsed.error?.errors?.map(e => ({
158 code: e.code,
159 // @ts-ignore exists on some types
160 expected: e?.expected,
161 path: e.path?.join('.'),
162 })) || []
163 logger.error(`persisted store: data failed validation on read`, {errors})
164 return undefined
165 }
166}
167
168export function tryStringify(value: Schema): string | undefined {
169 try {
170 schema.parse(value)
171 return JSON.stringify(value)
172 } catch (e) {
173 logger.error(`persisted state: failed stringifying root state`, {
174 message: e,
175 })
176 return undefined
177 }
178}