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