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