Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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