Bluesky app fork with some witchin' additions 馃挮
at main 374 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 'evergarden', 63 ]), 64 hue: z.number(), 65 session: z.object({ 66 accounts: z.array(accountSchema), 67 currentAccount: currentAccountSchema.optional(), 68 }), 69 reminders: z.object({ 70 lastEmailConfirm: z.string().optional(), 71 }), 72 languagePrefs: z.object({ 73 /** 74 * The target language for translating posts. 75 * 76 * BCP-47 2-letter language code without region. 77 */ 78 primaryLanguage: z.string(), 79 /** 80 * The languages the user can read, passed to feeds. 81 * 82 * BCP-47 2-letter language codes without region. 83 */ 84 contentLanguages: z.array(z.string()), 85 /** 86 * The language(s) the user is currently posting in, configured within the 87 * composer. Multiple languages are separated by commas. 88 * 89 * BCP-47 2-letter language code without region. 90 */ 91 postLanguage: z.string(), 92 /** 93 * The user's post language history, used to pre-populate the post language 94 * selector in the composer. Within each value, multiple languages are separated 95 * by commas. 96 * 97 * BCP-47 2-letter language codes without region. 98 */ 99 postLanguageHistory: z.array(z.string()), 100 /** 101 * The language for UI translations in the app. 102 * 103 * BCP-47 2-letter language code with or without region, 104 * to match with {@link AppLanguage}. 105 */ 106 appLanguage: z.string(), 107 }), 108 requireAltTextEnabled: z.boolean(), // should move to server 109 largeAltBadgeEnabled: z.boolean().optional(), 110 externalEmbeds: z 111 .object({ 112 giphy: z.enum(externalEmbedOptions).optional(), 113 tenor: z.enum(externalEmbedOptions).optional(), 114 youtube: z.enum(externalEmbedOptions).optional(), 115 youtubeShorts: z.enum(externalEmbedOptions).optional(), 116 twitch: z.enum(externalEmbedOptions).optional(), 117 vimeo: z.enum(externalEmbedOptions).optional(), 118 spotify: z.enum(externalEmbedOptions).optional(), 119 appleMusic: z.enum(externalEmbedOptions).optional(), 120 soundcloud: z.enum(externalEmbedOptions).optional(), 121 flickr: z.enum(externalEmbedOptions).optional(), 122 bandcamp: z.enum(externalEmbedOptions).optional(), 123 streamplace: z.enum(externalEmbedOptions).optional(), 124 }) 125 .optional(), 126 invites: z.object({ 127 copiedInvites: z.array(z.string()), 128 }), 129 onboarding: z.object({ 130 step: z.string(), 131 }), 132 hiddenPosts: z.array(z.string()).optional(), // should move to server 133 useInAppBrowser: z.boolean().optional(), 134 /** @deprecated */ 135 lastSelectedHomeFeed: z.string().optional(), 136 pdsAddressHistory: z.array(z.string()).optional(), 137 disableHaptics: z.boolean().optional(), 138 disableAutoplay: z.boolean().optional(), 139 kawaii: z.boolean().optional(), 140 hasCheckedForStarterPack: z.boolean().optional(), 141 subtitlesEnabled: z.boolean().optional(), 142 143 // deer 144 goLinksEnabled: z.boolean().optional(), 145 constellationEnabled: z.boolean().optional(), 146 directFetchRecords: z.boolean().optional(), 147 noAppLabelers: z.boolean().optional(), 148 noDiscoverFallback: z.boolean().optional(), 149 repostCarouselEnabled: z.boolean().optional(), 150 constellationInstance: z.string().optional(), 151 showLinkInHandle: z.boolean().optional(), 152 hideFeedsPromoTab: z.boolean().optional(), 153 disableViaRepostNotification: z.boolean().optional(), 154 disableComposerPrompt: z.boolean().optional(), 155 disableLikesMetrics: z.boolean().optional(), 156 disableRepostsMetrics: z.boolean().optional(), 157 disableQuotesMetrics: z.boolean().optional(), 158 disableSavesMetrics: z.boolean().optional(), 159 disableReplyMetrics: z.boolean().optional(), 160 disableFollowersMetrics: z.boolean().optional(), 161 disableFollowingMetrics: z.boolean().optional(), 162 disableFollowedByMetrics: z.boolean().optional(), 163 disablePostsMetrics: z.boolean().optional(), 164 hideSimilarAccountsRecomm: z.boolean().optional(), 165 discoverContextEnabled: z.boolean().optional(), 166 enableSquareAvatars: z.boolean().optional(), 167 enableSquareButtons: z.boolean().optional(), 168 disableVerifyEmailReminder: z.boolean().optional(), 169 deerVerification: z 170 .object({ 171 enabled: z.boolean(), 172 trusted: z.array(z.string()), 173 }) 174 .optional(), 175 highQualityImages: z.boolean().optional(), 176 imageCdnHost: z.string().optional(), 177 hideUnreplyablePosts: z.boolean().optional(), 178 pdsLabel: z 179 .object({ 180 enabled: z.boolean(), 181 hideBskyPds: z.boolean(), 182 }) 183 .optional(), 184 185 postReplacement: z.object({ 186 enabled: z.boolean().optional(), 187 postName: z.string().optional(), 188 postsName: z.string().optional(), 189 }), 190 191 showExternalShareButtons: z.boolean().optional(), 192 193 translationServicePreference: z.enum([ 194 'google', 195 'kagi', 196 'papago', 197 'libreTranslate', 198 ]), 199 libreTranslateInstance: z.string().optional(), 200 201 openRouterApiKey: z.string().optional(), 202 openRouterModel: z.string().optional(), 203 204 useHandleInLinks: z.boolean().optional(), 205 206 /** @deprecated */ 207 mutedThreads: z.array(z.string()), 208 trendingDisabled: z.boolean().optional(), 209 trendingVideoDisabled: z.boolean().optional(), 210}) 211export type Schema = z.infer<typeof schema> 212 213export const defaults: Schema = { 214 colorMode: 'system', 215 darkTheme: 'dim', 216 colorScheme: 'witchsky', 217 hue: 0, 218 session: { 219 accounts: [], 220 currentAccount: undefined, 221 }, 222 reminders: { 223 lastEmailConfirm: undefined, 224 }, 225 languagePrefs: { 226 primaryLanguage: deviceLanguageCodes[0] || 'en', 227 contentLanguages: [], 228 postLanguage: deviceLanguageCodes[0] || 'en', 229 postLanguageHistory: (deviceLanguageCodes || []) 230 .concat(['en', 'ja', 'pt', 'de']) 231 .slice(0, 6), 232 // try full language tag first, then fallback to language code 233 appLanguage: findSupportedAppLanguage([ 234 deviceLocales.at(0)?.languageTag, 235 deviceLanguageCodes[0], 236 ]), 237 }, 238 requireAltTextEnabled: true, 239 largeAltBadgeEnabled: false, 240 externalEmbeds: {}, 241 mutedThreads: [], 242 invites: { 243 copiedInvites: [], 244 }, 245 onboarding: { 246 step: 'Home', 247 }, 248 hiddenPosts: [], 249 useInAppBrowser: undefined, 250 lastSelectedHomeFeed: undefined, 251 pdsAddressHistory: [], 252 disableHaptics: false, 253 disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(), 254 kawaii: false, 255 hasCheckedForStarterPack: false, 256 subtitlesEnabled: true, 257 trendingDisabled: true, 258 trendingVideoDisabled: true, 259 260 // deer 261 goLinksEnabled: true, 262 constellationEnabled: true, 263 directFetchRecords: true, 264 noAppLabelers: false, 265 noDiscoverFallback: false, 266 repostCarouselEnabled: false, 267 constellationInstance: 'https://constellation.microcosm.blue/', 268 showLinkInHandle: true, 269 hideFeedsPromoTab: false, 270 disableViaRepostNotification: false, 271 disableComposerPrompt: true, 272 disableLikesMetrics: false, 273 disableRepostsMetrics: false, 274 disableQuotesMetrics: false, 275 disableSavesMetrics: false, 276 disableReplyMetrics: false, 277 disableFollowersMetrics: false, 278 disableFollowingMetrics: false, 279 disableFollowedByMetrics: false, 280 disablePostsMetrics: false, 281 hideSimilarAccountsRecomm: true, 282 discoverContextEnabled: false, 283 enableSquareAvatars: true, 284 enableSquareButtons: true, 285 disableVerifyEmailReminder: false, 286 deerVerification: { 287 enabled: false, 288 // https://witchsky.app/profile/did:plc:p2cp5gopk7mgjegy6wadk3ep/post/3lndyqyyr4k2k 289 // using https://bverified.vercel.app/trusted as a source 290 trusted: [ 291 'did:plc:z72i7hdynmk6r22z27h6tvur', 292 'did:plc:b2kutgxqlltwc6lhs724cfwr', 293 'did:plc:inz4fkbbp7ms3ixufw6xuvdi', 294 'did:plc:eclio37ymobqex2ncko63h4r', 295 'did:plc:dzezcmpb3fhcpns4n4xm4ur5', 296 'did:plc:5u54z2qgkq43dh2nzwzdbbhb', 297 'did:plc:wmho6q2uiyktkam3jsvrms3s', 298 'did:plc:sqbswn3lalcc2dlh2k7zdpuw', 299 'did:plc:k5nskatzhyxersjilvtnz4lh', 300 'did:plc:d2jith367s6ybc3ldsusgdae', 301 'did:plc:y3xrmnwvkvsq4tqcsgwch4na', 302 'did:plc:i3fhjvvkbmirhyu4aeihhrnv', 303 'did:plc:fivojrvylkim4nuo3pfqcf3k', 304 'did:plc:ofbkqcjzvm6gtwuufsubnkaf', 305 'did:plc:xwqgusybtrpm67tcwqdfmzvy', 306 'did:plc:oxo226vi7t2btjokm2buusoy', 307 'did:plc:r4ve5hjtfjubdwrvlxcad62e', 308 'did:plc:j4eroku3volozvv6ljsnnfec', 309 'did:plc:6q2thhy2ohzog26mmqm4pffk', 310 'did:plc:rk25gdgk3cnnmtkvlae265nz', 311 ], 312 }, 313 highQualityImages: false, 314 imageCdnHost: 'https://cdn.bsky.app', 315 hideUnreplyablePosts: false, 316 pdsLabel: { 317 enabled: true, 318 hideBskyPds: true, 319 }, 320 showExternalShareButtons: false, 321 translationServicePreference: 'google', 322 libreTranslateInstance: 'https://libretranslate.com/', 323 324 openRouterApiKey: undefined, 325 openRouterModel: DEFAULT_ALT_TEXT_AI_MODEL, 326 327 useHandleInLinks: false, 328 329 postReplacement: { 330 enabled: false, 331 postName: 'skeet', 332 postsName: 'skeets', 333 }, 334} 335 336export function tryParse(rawData: string): Schema | undefined { 337 let objData 338 try { 339 objData = JSON.parse(rawData) 340 } catch (e) { 341 logger.error('persisted state: failed to parse root state from storage', { 342 message: e, 343 }) 344 } 345 if (!objData) { 346 return undefined 347 } 348 const parsed = schema.safeParse(objData) 349 if (parsed.success) { 350 return objData 351 } else { 352 const errors = 353 parsed.error?.errors?.map(e => ({ 354 code: e.code, 355 // @ts-ignore exists on some types 356 expected: e?.expected, 357 path: e.path?.join('.'), 358 })) || [] 359 logger.error(`persisted store: data failed validation on read`, {errors}) 360 return undefined 361 } 362} 363 364export function tryStringify(value: Schema): string | undefined { 365 try { 366 schema.parse(value) 367 return JSON.stringify(value) 368 } catch (e) { 369 logger.error(`persisted state: failed stringifying root state`, { 370 message: e, 371 }) 372 return undefined 373 } 374}