Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
119
fork

Configure Feed

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

at a876aae44ea07494ebea9727350aa060b81f317b 531 lines 14 kB view raw
1import {createContext, useCallback, useContext, useEffect, useMemo} from 'react' 2import { 3 type AppBskyAgeassuranceDefs, 4 type AppBskyAgeassuranceGetConfig, 5 type AppBskyAgeassuranceGetState, 6 type AtpAgent, 7 getAgeAssuranceRegionConfig, 8} from '@atproto/api' 9import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' 10import {focusManager, QueryClient, useQuery} from '@tanstack/react-query' 11import {persistQueryClient} from '@tanstack/react-query-persist-client' 12import debounce from 'lodash.debounce' 13 14import {networkRetry} from '#/lib/async/retry' 15import {createPersistedQueryStorage} from '#/lib/persisted-query-storage' 16import {getAge} from '#/lib/strings/time' 17import { 18 hasSnoozedBirthdateUpdateForDid, 19 snoozeBirthdateUpdateAllowedForDid, 20} from '#/state/birthdate' 21import {useAgent, useSession} from '#/state/session' 22import {pdsAgent} from '#/state/session/agent' 23import * as debug from '#/ageAssurance/debug' 24import {logger} from '#/ageAssurance/logger' 25import { 26 getBirthdateStringFromAge, 27 isLegacyBirthdateBug, 28} from '#/ageAssurance/util' 29import {IS_DEV} from '#/env' 30import {device} from '#/storage' 31 32/** 33 * Special query client for age assurance data so we can prefetch on app 34 * load without interfering with other queries. 35 */ 36const qc = new QueryClient({ 37 defaultOptions: { 38 queries: { 39 /** 40 * We clear this manually, so disable automatic garbage collection. 41 * @see https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient#how-it-works 42 */ 43 gcTime: Infinity, 44 }, 45 }, 46}) 47const persister = createAsyncStoragePersister({ 48 storage: createPersistedQueryStorage('age-assurance'), 49 key: 'age-assurance-query-client', 50}) 51const [, cacheHydrationPromise] = persistQueryClient({ 52 queryClient: qc, 53 persister, 54}) 55 56function getDidFromAgentSession(agent: AtpAgent) { 57 const sessionManager = agent.sessionManager 58 if (!sessionManager || !sessionManager.did) return 59 return sessionManager.did 60} 61 62/* 63 * Optimistic data 64 */ 65 66const createdAtCache = new Map<string, string>() 67export function setCreatedAtForDid({ 68 did, 69 createdAt, 70}: { 71 did: string 72 createdAt: string 73}) { 74 createdAtCache.set(did, createdAt) 75} 76const birthdateCache = new Map<string, string>() 77export function setBirthdateForDid({ 78 did, 79 birthdate, 80}: { 81 did: string 82 birthdate: string 83}) { 84 birthdateCache.set(did, birthdate) 85} 86 87/* 88 * Config 89 */ 90 91export const configQueryKey = ['config'] 92export async function getConfig() { 93 if (debug.enabled) return debug.resolve(debug.config) 94 return { 95 regions: [], 96 } 97} 98export function getConfigFromCache(): 99 | AppBskyAgeassuranceGetConfig.OutputSchema 100 | undefined { 101 return qc.getQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>( 102 configQueryKey, 103 ) 104} 105let configPrefetchPromise: Promise<void> | undefined 106export function prefetchConfig() { 107 if (configPrefetchPromise) { 108 logger.debug(`prefetchAgeAssuranceConfig: already in progress`) 109 return 110 } 111 112 configPrefetchPromise = (async () => { 113 await cacheHydrationPromise 114 const cached = getConfigFromCache() 115 116 if (cached) { 117 logger.debug(`prefetchAgeAssuranceConfig: using cache`) 118 } else { 119 try { 120 logger.debug(`prefetchAgeAssuranceConfig: resolving...`) 121 const res = await networkRetry(3, () => getConfig()) 122 qc.setQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>( 123 configQueryKey, 124 res, 125 ) 126 } catch (err) { 127 const e = err as Error 128 logger.warn(`prefetchAgeAssuranceConfig: failed`, { 129 safeMessage: e.message, 130 }) 131 } 132 } 133 })() 134} 135export async function refetchConfig() { 136 logger.debug(`refetchConfig: fetching...`) 137 const res = await getConfig() 138 qc.setQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>( 139 configQueryKey, 140 res, 141 ) 142 return res 143} 144export function useConfigQuery() { 145 return useQuery( 146 { 147 /** 148 * Will re-fetch when stale, at most every hour (or 5s in dev for easier 149 * testing). 150 * 151 * @see https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#initial-data-from-the-cache-with-initialdataupdatedat 152 */ 153 staleTime: IS_DEV ? 5e3 : 1000 * 60 * 60, 154 /** 155 * N.B. if prefetch failed above, we'll have no `initialData`, and this 156 * query will run on startup. 157 */ 158 initialData: getConfigFromCache(), 159 initialDataUpdatedAt: () => 160 qc.getQueryState(configQueryKey)?.dataUpdatedAt, 161 queryKey: configQueryKey, 162 async queryFn() { 163 logger.debug(`useConfigQuery: fetching config`) 164 return getConfig() 165 }, 166 }, 167 qc, 168 ) 169} 170 171/* 172 * Server state 173 */ 174 175export function createServerStateQueryKey({did}: {did: string}) { 176 return ['serverState', did] 177} 178export async function getServerState() { 179 if (debug.enabled && debug.serverState) 180 return debug.resolve(debug.serverState) 181 return { 182 state: { 183 lastInitiatedAt: '2025-07-14T14:22:43.912Z', 184 status: 'assured' as const, 185 access: 'full' as const, 186 }, 187 metadata: { 188 accountCreatedAt: '2022-11-17T00:35:16.391Z', 189 }, 190 } 191} 192export function getServerStateFromCache({ 193 did, 194}: { 195 did: string 196}): AppBskyAgeassuranceGetState.OutputSchema | undefined { 197 return qc.getQueryData<AppBskyAgeassuranceGetState.OutputSchema>( 198 createServerStateQueryKey({did}), 199 ) 200} 201export async function prefetchServerState({agent}: {agent: AtpAgent}) { 202 const did = getDidFromAgentSession(agent) 203 204 if (!did) return 205 206 await cacheHydrationPromise 207 const qk = createServerStateQueryKey({did}) 208 const cached = getServerStateFromCache({did}) 209 210 if (cached) { 211 logger.debug(`prefetchServerState: using cache`) 212 return 213 } 214 215 try { 216 logger.debug(`prefetchServerState: resolving...`) 217 const res = await networkRetry(3, () => getServerState()) 218 if (res) { 219 qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(qk, res) 220 } 221 } catch (err) { 222 const e = err as Error 223 logger.warn(`prefetchServerState: failed`, { 224 safeMessage: e.message, 225 }) 226 } 227} 228export async function refetchServerState({agent}: {agent: AtpAgent}) { 229 const did = getDidFromAgentSession(agent) 230 if (!did) return 231 logger.debug(`refetchServerState: fetching...`) 232 const res = await networkRetry(3, () => getServerState()) 233 if (res) { 234 qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>( 235 createServerStateQueryKey({did}), 236 res, 237 ) 238 } 239 return res 240} 241export function usePatchServerState() { 242 const {currentAccount} = useSession() 243 return useCallback( 244 (next: AppBskyAgeassuranceDefs.State) => { 245 if (!currentAccount) return 246 const did = currentAccount.did 247 const prev = getServerStateFromCache({did}) 248 const merged: AppBskyAgeassuranceGetState.OutputSchema = { 249 metadata: {}, 250 ...(prev || {}), 251 state: next, 252 } 253 qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>( 254 createServerStateQueryKey({did}), 255 merged, 256 ) 257 }, 258 [currentAccount], 259 ) 260} 261export function useServerStateQuery() { 262 const agent = useAgent() 263 const did = getDidFromAgentSession(agent) 264 const query = useQuery( 265 { 266 enabled: !!did, 267 initialData: () => { 268 if (!did) return 269 return getServerStateFromCache({did}) 270 }, 271 queryKey: createServerStateQueryKey({did: did!}), 272 async queryFn() { 273 return getServerState() 274 }, 275 }, 276 qc, 277 ) 278 const refetch = useMemo(() => debounce(query.refetch, 100), [query.refetch]) 279 280 const isAssured = query.data?.state?.status === 'assured' 281 282 /** 283 * `refetchOnWindowFocus` doesn't seem to want to work for this custom query 284 * client, so we manually subscribe to focus changes. 285 */ 286 useEffect(() => { 287 return focusManager.subscribe(() => { 288 // logged out 289 if (!did) return 290 291 const isFocused = focusManager.isFocused() 292 293 if (!isFocused) return 294 295 const config = getConfigFromCache() 296 const geolocation = device.get(['mergedGeolocation']) 297 const isAArequired = Boolean( 298 config && 299 geolocation && 300 !!getAgeAssuranceRegionConfig(config, { 301 countryCode: geolocation?.countryCode ?? '', 302 regionCode: geolocation?.regionCode, 303 }), 304 ) 305 306 // only refetch when needed 307 if (isAssured || !isAArequired) return 308 309 void refetch() 310 }) 311 }, [did, refetch, isAssured]) 312 313 return query 314} 315 316/* 317 * Other required data 318 */ 319 320export type OtherRequiredData = { 321 birthdate: string | undefined 322} 323export function createOtherRequiredDataQueryKey({did}: {did: string}) { 324 return ['otherRequiredData', did] 325} 326export async function getOtherRequiredData({ 327 agent, 328}: { 329 agent: AtpAgent 330}): Promise<OtherRequiredData> { 331 if (debug.enabled) return debug.resolve(debug.otherRequiredData) 332 const [prefs] = await Promise.all([pdsAgent(agent).getPreferences()]) 333 const data: OtherRequiredData = { 334 birthdate: prefs.birthDate ? prefs.birthDate.toISOString() : undefined, 335 } 336 337 /** 338 * If we can't read a birthdate, it may be due to the user accessing the 339 * account via an app password. In that case, fall-back to declared age 340 * flags. 341 */ 342 if (!data.birthdate) { 343 if (prefs.declaredAge?.isOverAge18) { 344 data.birthdate = getBirthdateStringFromAge(18) 345 } else if (prefs.declaredAge?.isOverAge16) { 346 data.birthdate = getBirthdateStringFromAge(16) 347 } else if (prefs.declaredAge?.isOverAge13) { 348 data.birthdate = getBirthdateStringFromAge(13) 349 } 350 } 351 352 const did = getDidFromAgentSession(agent) 353 if (data && did && birthdateCache.has(did)) { 354 /* 355 * If birthdate was just set, use the local cache value. On subsequent 356 * reloads, the server should have the correct value. 357 */ 358 data.birthdate = birthdateCache.get(did) 359 } 360 361 /** 362 * If the user is under the minimum age, and the birthdate is not due to the 363 * legacy bug, AND we've not already snoozed their birthdate update, snooze 364 * further birthdate updates for this user. 365 * 366 * This is basically a migration step for this initial rollout. 367 */ 368 if ( 369 data.birthdate && 370 !isLegacyBirthdateBug(data.birthdate) && 371 !hasSnoozedBirthdateUpdateForDid(did!) 372 ) { 373 snoozeBirthdateUpdateAllowedForDid(did!) 374 } 375 376 return data 377} 378export function getOtherRequiredDataFromCache({ 379 did, 380}: { 381 did: string 382}): OtherRequiredData | undefined { 383 return qc.getQueryData<OtherRequiredData>( 384 createOtherRequiredDataQueryKey({did}), 385 ) 386} 387export async function prefetchOtherRequiredData({agent}: {agent: AtpAgent}) { 388 const did = getDidFromAgentSession(agent) 389 390 if (!did) return 391 392 await cacheHydrationPromise 393 const qk = createOtherRequiredDataQueryKey({did}) 394 const cached = getOtherRequiredDataFromCache({did}) 395 396 if (cached) { 397 logger.debug(`prefetchOtherRequiredData: using cache`) 398 return 399 } 400 401 try { 402 logger.debug(`prefetchOtherRequiredData: resolving...`) 403 const res = await networkRetry(3, () => getOtherRequiredData({agent})) 404 qc.setQueryData<OtherRequiredData>(qk, res) 405 } catch (err) { 406 const e = err as Error 407 logger.warn(`prefetchOtherRequiredData: failed`, { 408 safeMessage: e.message, 409 }) 410 } 411} 412export function usePatchOtherRequiredData() { 413 const {currentAccount} = useSession() 414 return useCallback( 415 (next: OtherRequiredData) => { 416 if (!currentAccount) return 417 const did = currentAccount.did 418 const prev = getOtherRequiredDataFromCache({did}) 419 const merged: OtherRequiredData = { 420 ...(prev || {}), 421 ...next, 422 } 423 qc.setQueryData<OtherRequiredData>( 424 createOtherRequiredDataQueryKey({did}), 425 merged, 426 ) 427 }, 428 [currentAccount], 429 ) 430} 431export function useOtherRequiredDataQuery() { 432 const agent = useAgent() 433 const did = getDidFromAgentSession(agent) 434 return useQuery( 435 { 436 enabled: !!did, 437 initialData: () => { 438 if (!did) return 439 return getOtherRequiredDataFromCache({did}) 440 }, 441 queryKey: createOtherRequiredDataQueryKey({did: did!}), 442 async queryFn() { 443 return getOtherRequiredData({agent}) 444 }, 445 }, 446 qc, 447 ) 448} 449 450/** 451 * Helper to prefetch all age assurance data. 452 */ 453export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) { 454 return Promise.allSettled([ 455 // config fetch initiated at the top of the App.platform.tsx files, awaited here 456 configPrefetchPromise, 457 prefetchServerState({agent}), 458 prefetchOtherRequiredData({agent}), 459 ]) 460} 461 462export function clearAgeAssuranceDataForDid({did}: {did: string}) { 463 logger.debug(`clearAgeAssuranceDataForDid: ${did}`) 464 qc.removeQueries({queryKey: createServerStateQueryKey({did}), exact: true}) 465 qc.removeQueries({ 466 queryKey: createOtherRequiredDataQueryKey({did}), 467 exact: true, 468 }) 469} 470 471export function clearAgeAssuranceData() { 472 logger.debug(`clearAgeAssuranceData`) 473 qc.clear() 474} 475 476/* 477 * Context 478 */ 479 480export type AgeAssuranceData = { 481 config: AppBskyAgeassuranceDefs.Config | undefined 482 state: AppBskyAgeassuranceDefs.State | undefined 483 data: 484 | { 485 accountCreatedAt: AppBskyAgeassuranceDefs.StateMetadata['accountCreatedAt'] 486 declaredAge: number | undefined 487 birthdate: string | undefined 488 } 489 | undefined 490} 491export const AgeAssuranceDataContext = createContext<AgeAssuranceData>({ 492 config: undefined, 493 state: undefined, 494 data: { 495 accountCreatedAt: undefined, 496 declaredAge: undefined, 497 birthdate: undefined, 498 }, 499}) 500export function useAgeAssuranceDataContext() { 501 return useContext(AgeAssuranceDataContext) 502} 503export function AgeAssuranceDataProvider({ 504 children, 505}: { 506 children: React.ReactNode 507}) { 508 const {data: config} = useConfigQuery() 509 const serverState = useServerStateQuery() 510 const {state, metadata} = serverState.data || {} 511 const {data} = useOtherRequiredDataQuery() 512 const ctx = useMemo( 513 () => ({ 514 config, 515 state, 516 data: { 517 accountCreatedAt: metadata?.accountCreatedAt, 518 declaredAge: data?.birthdate 519 ? getAge(new Date(data.birthdate)) 520 : undefined, 521 birthdate: data?.birthdate, 522 }, 523 }), 524 [config, state, data, metadata], 525 ) 526 return ( 527 <AgeAssuranceDataContext.Provider value={ctx}> 528 {children} 529 </AgeAssuranceDataContext.Provider> 530 ) 531}