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