Bluesky app fork with some witchin' additions 馃挮
at main 371 lines 12 kB view raw
1import {z} from 'zod' 2 3import {DEFAULT_ALT_TEXT_AI_MODEL} from '#/lib/constants' 4import {deviceLanguageCodes, deviceLocales} from '#/locale/deviceLocales' 5import {findSupportedAppLanguage} from '#/locale/helpers' 6import {logger} from '#/logger' 7import {PlatformInfo} from '../../../modules/expo-bluesky-swiss-army' 8 9const externalEmbedOptions = ['show', 'hide'] as const 10 11/** 12 * A account persisted to storage. Stored in the `accounts[]` array. Contains 13 * base account info and access tokens. 14 */ 15const accountSchema = z.object({ 16 service: z.string(), 17 did: z.string(), 18 handle: z.string(), 19 email: z.string().optional(), 20 emailConfirmed: z.boolean().optional(), 21 emailAuthFactor: z.boolean().optional(), 22 refreshJwt: z.string().optional(), // optional because it can expire 23 accessJwt: z.string().optional(), // optional because it can expire 24 signupQueued: z.boolean().optional(), 25 active: z.boolean().optional(), // optional for backwards compat 26 /** 27 * Known values: takendown, suspended, deactivated 28 * @see https://github.com/bluesky-social/atproto/blob/5441fbde9ed3b22463e91481ec80cb095643e141/lexicons/com/atproto/server/getSession.json 29 */ 30 status: z.string().optional(), 31 pdsUrl: z.string().optional(), 32 isSelfHosted: z.boolean().optional(), 33}) 34export type PersistedAccount = z.infer<typeof accountSchema> 35 36/** 37 * The current account. Stored in the `currentAccount` field. 38 * 39 * In previous versions, this included tokens and other info. Now, it's used 40 * only to reference the `did` field, and all other fields are marked as 41 * optional. They should be considered deprecated and not used, but are kept 42 * here for backwards compat. 43 */ 44const currentAccountSchema = accountSchema.extend({ 45 service: z.string().optional(), 46 handle: z.string().optional(), 47}) 48export type PersistedCurrentAccount = z.infer<typeof currentAccountSchema> 49 50const schema = z.object({ 51 colorMode: z.enum(['system', 'light', 'dark']), 52 darkTheme: z.enum(['dim', 'dark']).optional(), 53 colorScheme: z.enum([ 54 'witchsky', 55 'bluesky', 56 'blacksky', 57 'deer', 58 'zeppelin', 59 'kitty', 60 'reddwarf', 61 'catppuccin', 62 ]), 63 hue: z.number(), 64 session: z.object({ 65 accounts: z.array(accountSchema), 66 currentAccount: currentAccountSchema.optional(), 67 }), 68 reminders: z.object({ 69 lastEmailConfirm: z.string().optional(), 70 }), 71 languagePrefs: z.object({ 72 /** 73 * The target language for translating posts. 74 * 75 * BCP-47 2-letter language code without region. 76 */ 77 primaryLanguage: z.string(), 78 /** 79 * The languages the user can read, passed to feeds. 80 * 81 * BCP-47 2-letter language codes without region. 82 */ 83 contentLanguages: z.array(z.string()), 84 /** 85 * The language(s) the user is currently posting in, configured within the 86 * composer. Multiple languages are separated by commas. 87 * 88 * BCP-47 2-letter language code without region. 89 */ 90 postLanguage: z.string(), 91 /** 92 * The user's post language history, used to pre-populate the post language 93 * selector in the composer. Within each value, multiple languages are separated 94 * by commas. 95 * 96 * BCP-47 2-letter language codes without region. 97 */ 98 postLanguageHistory: z.array(z.string()), 99 /** 100 * The language for UI translations in the app. 101 * 102 * BCP-47 2-letter language code with or without region, 103 * to match with {@link AppLanguage}. 104 */ 105 appLanguage: z.string(), 106 }), 107 requireAltTextEnabled: z.boolean(), // should move to server 108 largeAltBadgeEnabled: z.boolean().optional(), 109 externalEmbeds: z 110 .object({ 111 giphy: z.enum(externalEmbedOptions).optional(), 112 tenor: z.enum(externalEmbedOptions).optional(), 113 youtube: z.enum(externalEmbedOptions).optional(), 114 youtubeShorts: z.enum(externalEmbedOptions).optional(), 115 twitch: z.enum(externalEmbedOptions).optional(), 116 vimeo: z.enum(externalEmbedOptions).optional(), 117 spotify: z.enum(externalEmbedOptions).optional(), 118 appleMusic: z.enum(externalEmbedOptions).optional(), 119 soundcloud: z.enum(externalEmbedOptions).optional(), 120 flickr: z.enum(externalEmbedOptions).optional(), 121 bandcamp: z.enum(externalEmbedOptions).optional(), 122 streamplace: z.enum(externalEmbedOptions).optional(), 123 }) 124 .optional(), 125 invites: z.object({ 126 copiedInvites: z.array(z.string()), 127 }), 128 onboarding: z.object({ 129 step: z.string(), 130 }), 131 hiddenPosts: z.array(z.string()).optional(), // should move to server 132 useInAppBrowser: z.boolean().optional(), 133 /** @deprecated */ 134 lastSelectedHomeFeed: z.string().optional(), 135 pdsAddressHistory: z.array(z.string()).optional(), 136 disableHaptics: z.boolean().optional(), 137 disableAutoplay: z.boolean().optional(), 138 kawaii: z.boolean().optional(), 139 hasCheckedForStarterPack: z.boolean().optional(), 140 subtitlesEnabled: z.boolean().optional(), 141 142 // deer 143 goLinksEnabled: z.boolean().optional(), 144 constellationEnabled: z.boolean().optional(), 145 directFetchRecords: z.boolean().optional(), 146 noAppLabelers: z.boolean().optional(), 147 noDiscoverFallback: z.boolean().optional(), 148 repostCarouselEnabled: z.boolean().optional(), 149 constellationInstance: z.string().optional(), 150 showLinkInHandle: z.boolean().optional(), 151 hideFeedsPromoTab: z.boolean().optional(), 152 disableViaRepostNotification: z.boolean().optional(), 153 disableComposerPrompt: z.boolean().optional(), 154 disableLikesMetrics: z.boolean().optional(), 155 disableRepostsMetrics: z.boolean().optional(), 156 disableQuotesMetrics: z.boolean().optional(), 157 disableSavesMetrics: z.boolean().optional(), 158 disableReplyMetrics: z.boolean().optional(), 159 disableFollowersMetrics: z.boolean().optional(), 160 disableFollowingMetrics: z.boolean().optional(), 161 disableFollowedByMetrics: z.boolean().optional(), 162 disablePostsMetrics: z.boolean().optional(), 163 hideSimilarAccountsRecomm: z.boolean().optional(), 164 discoverContextEnabled: z.boolean().optional(), 165 enableSquareAvatars: z.boolean().optional(), 166 enableSquareButtons: z.boolean().optional(), 167 disableVerifyEmailReminder: z.boolean().optional(), 168 deerVerification: z 169 .object({ 170 enabled: z.boolean(), 171 trusted: z.array(z.string()), 172 }) 173 .optional(), 174 highQualityImages: z.boolean().optional(), 175 hideUnreplyablePosts: z.boolean().optional(), 176 pdsLabel: z 177 .object({ 178 enabled: z.boolean(), 179 hideBskyPds: z.boolean(), 180 }) 181 .optional(), 182 183 postReplacement: z.object({ 184 enabled: z.boolean().optional(), 185 postName: z.string().optional(), 186 postsName: z.string().optional(), 187 }), 188 189 showExternalShareButtons: z.boolean().optional(), 190 191 translationServicePreference: z.enum([ 192 'google', 193 'kagi', 194 'papago', 195 'libreTranslate', 196 ]), 197 libreTranslateInstance: z.string().optional(), 198 199 openRouterApiKey: z.string().optional(), 200 openRouterModel: z.string().optional(), 201 202 useHandleInLinks: z.boolean().optional(), 203 204 /** @deprecated */ 205 mutedThreads: z.array(z.string()), 206 trendingDisabled: z.boolean().optional(), 207 trendingVideoDisabled: z.boolean().optional(), 208}) 209export type Schema = z.infer<typeof schema> 210 211export const defaults: Schema = { 212 colorMode: 'system', 213 darkTheme: 'dim', 214 colorScheme: 'witchsky', 215 hue: 0, 216 session: { 217 accounts: [], 218 currentAccount: undefined, 219 }, 220 reminders: { 221 lastEmailConfirm: undefined, 222 }, 223 languagePrefs: { 224 primaryLanguage: deviceLanguageCodes[0] || 'en', 225 contentLanguages: [], 226 postLanguage: deviceLanguageCodes[0] || 'en', 227 postLanguageHistory: (deviceLanguageCodes || []) 228 .concat(['en', 'ja', 'pt', 'de']) 229 .slice(0, 6), 230 // try full language tag first, then fallback to language code 231 appLanguage: findSupportedAppLanguage([ 232 deviceLocales.at(0)?.languageTag, 233 deviceLanguageCodes[0], 234 ]), 235 }, 236 requireAltTextEnabled: true, 237 largeAltBadgeEnabled: false, 238 externalEmbeds: {}, 239 mutedThreads: [], 240 invites: { 241 copiedInvites: [], 242 }, 243 onboarding: { 244 step: 'Home', 245 }, 246 hiddenPosts: [], 247 useInAppBrowser: undefined, 248 lastSelectedHomeFeed: undefined, 249 pdsAddressHistory: [], 250 disableHaptics: false, 251 disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(), 252 kawaii: false, 253 hasCheckedForStarterPack: false, 254 subtitlesEnabled: true, 255 trendingDisabled: true, 256 trendingVideoDisabled: true, 257 258 // deer 259 goLinksEnabled: true, 260 constellationEnabled: true, 261 directFetchRecords: true, 262 noAppLabelers: false, 263 noDiscoverFallback: false, 264 repostCarouselEnabled: false, 265 constellationInstance: 'https://constellation.microcosm.blue/', 266 showLinkInHandle: true, 267 hideFeedsPromoTab: false, 268 disableViaRepostNotification: false, 269 disableComposerPrompt: true, 270 disableLikesMetrics: false, 271 disableRepostsMetrics: false, 272 disableQuotesMetrics: false, 273 disableSavesMetrics: false, 274 disableReplyMetrics: false, 275 disableFollowersMetrics: false, 276 disableFollowingMetrics: false, 277 disableFollowedByMetrics: false, 278 disablePostsMetrics: false, 279 hideSimilarAccountsRecomm: true, 280 discoverContextEnabled: false, 281 enableSquareAvatars: true, 282 enableSquareButtons: true, 283 disableVerifyEmailReminder: false, 284 deerVerification: { 285 enabled: false, 286 // https://witchsky.app/profile/did:plc:p2cp5gopk7mgjegy6wadk3ep/post/3lndyqyyr4k2k 287 // using https://bverified.vercel.app/trusted as a source 288 trusted: [ 289 'did:plc:z72i7hdynmk6r22z27h6tvur', 290 'did:plc:b2kutgxqlltwc6lhs724cfwr', 291 'did:plc:inz4fkbbp7ms3ixufw6xuvdi', 292 'did:plc:eclio37ymobqex2ncko63h4r', 293 'did:plc:dzezcmpb3fhcpns4n4xm4ur5', 294 'did:plc:5u54z2qgkq43dh2nzwzdbbhb', 295 'did:plc:wmho6q2uiyktkam3jsvrms3s', 296 'did:plc:sqbswn3lalcc2dlh2k7zdpuw', 297 'did:plc:k5nskatzhyxersjilvtnz4lh', 298 'did:plc:d2jith367s6ybc3ldsusgdae', 299 'did:plc:y3xrmnwvkvsq4tqcsgwch4na', 300 'did:plc:i3fhjvvkbmirhyu4aeihhrnv', 301 'did:plc:fivojrvylkim4nuo3pfqcf3k', 302 'did:plc:ofbkqcjzvm6gtwuufsubnkaf', 303 'did:plc:xwqgusybtrpm67tcwqdfmzvy', 304 'did:plc:oxo226vi7t2btjokm2buusoy', 305 'did:plc:r4ve5hjtfjubdwrvlxcad62e', 306 'did:plc:j4eroku3volozvv6ljsnnfec', 307 'did:plc:6q2thhy2ohzog26mmqm4pffk', 308 'did:plc:rk25gdgk3cnnmtkvlae265nz', 309 ], 310 }, 311 highQualityImages: false, 312 hideUnreplyablePosts: false, 313 pdsLabel: { 314 enabled: false, 315 hideBskyPds: true, 316 }, 317 showExternalShareButtons: false, 318 translationServicePreference: 'google', 319 libreTranslateInstance: 'https://libretranslate.com/', 320 321 openRouterApiKey: undefined, 322 openRouterModel: DEFAULT_ALT_TEXT_AI_MODEL, 323 324 useHandleInLinks: false, 325 326 postReplacement: { 327 enabled: false, 328 postName: 'skeet', 329 postsName: 'skeets', 330 }, 331} 332 333export function tryParse(rawData: string): Schema | undefined { 334 let objData 335 try { 336 objData = JSON.parse(rawData) 337 } catch (e) { 338 logger.error('persisted state: failed to parse root state from storage', { 339 message: e, 340 }) 341 } 342 if (!objData) { 343 return undefined 344 } 345 const parsed = schema.safeParse(objData) 346 if (parsed.success) { 347 return objData 348 } else { 349 const errors = 350 parsed.error?.errors?.map(e => ({ 351 code: e.code, 352 // @ts-ignore exists on some types 353 expected: e?.expected, 354 path: e.path?.join('.'), 355 })) || [] 356 logger.error(`persisted store: data failed validation on read`, {errors}) 357 return undefined 358 } 359} 360 361export function tryStringify(value: Schema): string | undefined { 362 try { 363 schema.parse(value) 364 return JSON.stringify(value) 365 } catch (e) { 366 logger.error(`persisted state: failed stringifying root state`, { 367 message: e, 368 }) 369 return undefined 370 } 371}