import {createContext, useCallback, useContext, useEffect, useMemo} from 'react' import { type AppBskyAgeassuranceDefs, type AppBskyAgeassuranceGetConfig, type AppBskyAgeassuranceGetState, AtpAgent, type ChatBskyActorDeclaration, getAgeAssuranceRegionConfig, } from '@atproto/api' import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' import {focusManager, QueryClient, useQuery} from '@tanstack/react-query' import {persistQueryClient} from '@tanstack/react-query-persist-client' import debounce from 'lodash.debounce' import * as AgeRange from 'expo-age-range'; import {networkRetry} from '#/lib/async/retry' import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' import {createPersistedQueryStorage} from '#/lib/persisted-query-storage' import {getAge} from '#/lib/strings/time' import { hasSnoozedBirthdateUpdateForDid, snoozeBirthdateUpdateAllowedForDid, } from '#/state/birthdate' import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declaration' import {useAgent, useSession} from '#/state/session' import * as debug from '#/ageAssurance/debug' import {logger} from '#/ageAssurance/logger' import {type AgeAssuranceMetadata} from '#/ageAssurance/types' import { getBirthdateStringFromAge, isLegacyBirthdateBug, } from '#/ageAssurance/util' import {IS_DEV} from '#/env' import {device} from '#/storage' /** * Special query client for age assurance data so we can prefetch on app * load without interfering with other queries. */ const qc = new QueryClient({ defaultOptions: { queries: { /** * We clear this manually, so disable automatic garbage collection. * @see https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient#how-it-works */ gcTime: Infinity, }, }, }) const persister = createAsyncStoragePersister({ storage: createPersistedQueryStorage('age-assurance'), key: 'age-assurance-query-client', }) const [, cacheHydrationPromise] = persistQueryClient({ queryClient: qc, persister, }) export function getDidFromAgentSession(agent: AtpAgent) { const sessionManager = agent.sessionManager if (!sessionManager || !sessionManager.did) return return sessionManager.did } /* * Optimistic data */ const createdAtCache = new Map() export function setCreatedAtForDid({ did, createdAt, }: { did: string createdAt: string }) { createdAtCache.set(did, createdAt) } const birthdateCache = new Map() export function setBirthdateForDid({ did, birthdate, }: { did: string birthdate: string }) { birthdateCache.set(did, birthdate) } /* * Config */ export const configQueryKey = ['config'] export async function getConfig() { if (debug.enabled) return debug.resolve(debug.config) const agent = new AtpAgent({ service: PUBLIC_BSKY_SERVICE, }) const res = await agent.app.bsky.ageassurance.getConfig() return res.data } export function getConfigFromCache(): | AppBskyAgeassuranceGetConfig.OutputSchema | undefined { return qc.getQueryData( configQueryKey, ) } let configPrefetchPromise: Promise | undefined export function prefetchConfig() { if (configPrefetchPromise) { logger.debug(`prefetchAgeAssuranceConfig: already in progress`) return } configPrefetchPromise = (async () => { await cacheHydrationPromise const cached = getConfigFromCache() if (cached) { logger.debug(`prefetchAgeAssuranceConfig: using cache`) } else { try { logger.debug(`prefetchAgeAssuranceConfig: resolving...`) const res = await networkRetry(3, () => getConfig()) qc.setQueryData( configQueryKey, res, ) } catch (err) { const e = err as Error logger.warn(`prefetchAgeAssuranceConfig: failed`, { safeMessage: e.message, }) } } })() } export async function refetchConfig() { logger.debug(`refetchConfig: fetching...`) const res = await getConfig() qc.setQueryData( configQueryKey, res, ) return res } export function useConfigQuery() { return useQuery( { /** * Will re-fetch when stale, at most every hour (or 5s in dev for easier * testing). * * @see https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#initial-data-from-the-cache-with-initialdataupdatedat */ staleTime: IS_DEV ? 5e3 : 1000 * 60 * 60, /** * N.B. if prefetch failed above, we'll have no `initialData`, and this * query will run on startup. */ initialData: getConfigFromCache(), initialDataUpdatedAt: () => qc.getQueryState(configQueryKey)?.dataUpdatedAt, queryKey: configQueryKey, async queryFn() { logger.debug(`useConfigQuery: fetching config`) return getConfig() }, }, qc, ) } /* * Server state */ export function createServerStateQueryKey({did}: {did: string}) { return ['serverState', did] } export async function getServerState({agent}: {agent: AtpAgent}) { if (debug.enabled && debug.serverState) return debug.resolve(debug.serverState) const geolocation = device.get(['mergedGeolocation']) if (!geolocation || !geolocation.countryCode) { logger.error(`getServerState: missing geolocation countryCode`) return null } const {data} = await agent.app.bsky.ageassurance.getState({ countryCode: geolocation.countryCode, regionCode: geolocation.regionCode, }) const did = getDidFromAgentSession(agent) if (data && did && createdAtCache.has(did)) { /* * If account was just created, just use the local cache if available. On * subsequent reloads, the server should have the correct value. */ data.metadata.accountCreatedAt = createdAtCache.get(did) } return data ?? null } export function getServerStateFromCache({ did, }: { did: string }): AppBskyAgeassuranceGetState.OutputSchema | undefined { return qc.getQueryData( createServerStateQueryKey({did}), ) } export async function prefetchServerState({agent}: {agent: AtpAgent}) { const did = getDidFromAgentSession(agent) if (!did) return await cacheHydrationPromise const qk = createServerStateQueryKey({did}) const cached = getServerStateFromCache({did}) if (cached) { logger.debug(`prefetchServerState: using cache`) return } try { logger.debug(`prefetchServerState: resolving...`) const res = await networkRetry(3, () => getServerState({agent})) if (res) { qc.setQueryData(qk, res) } } catch (err) { const e = err as Error logger.warn(`prefetchServerState: failed`, { safeMessage: e.message, }) } } export async function refetchServerState({agent}: {agent: AtpAgent}) { const did = getDidFromAgentSession(agent) if (!did) return logger.debug(`refetchServerState: fetching...`) const res = await networkRetry(3, () => getServerState({agent})) if (res) { qc.setQueryData( createServerStateQueryKey({did}), res, ) } return res } export function usePatchServerState() { const {currentAccount} = useSession() return useCallback( (next: AppBskyAgeassuranceDefs.State) => { if (!currentAccount) return const did = currentAccount.did const prev = getServerStateFromCache({did}) const merged: AppBskyAgeassuranceGetState.OutputSchema = { metadata: {}, ...(prev || {}), state: next, } qc.setQueryData( createServerStateQueryKey({did}), merged, ) }, [currentAccount], ) } export function useServerStateQuery() { const agent = useAgent() const did = getDidFromAgentSession(agent) const query = useQuery( { enabled: !!did, initialData: () => { if (!did) return return getServerStateFromCache({did}) }, queryKey: createServerStateQueryKey({did: did!}), async queryFn() { return getServerState({agent}) }, }, qc, ) const refetch = useMemo(() => debounce(query.refetch, 100), [query.refetch]) const isAssured = query.data?.state?.status === 'assured' /** * `refetchOnWindowFocus` doesn't seem to want to work for this custom query * client, so we manually subscribe to focus changes. */ useEffect(() => { return focusManager.subscribe(() => { // logged out if (!did) return const isFocused = focusManager.isFocused() if (!isFocused) return const config = getConfigFromCache() const geolocation = device.get(['mergedGeolocation']) const isAArequired = Boolean( config && geolocation && !!getAgeAssuranceRegionConfig(config, { countryCode: geolocation?.countryCode ?? '', regionCode: geolocation?.regionCode, }), ) // only refetch when needed if (isAssured || !isAArequired) return void refetch() }) }, [did, refetch, isAssured]) return query } /* * Other required data */ export type OtherRequiredData = { birthdate: string | undefined actorDeclaration?: ChatBskyActorDeclaration.Main } export function createOtherRequiredDataQueryKey({did}: {did: string}) { return ['otherRequiredData', did] } async function getOtherRequiredData({ agent, }: { agent: AtpAgent }): Promise { if (debug.enabled) return debug.resolve(debug.otherRequiredData) const did = getDidFromAgentSession(agent) const [prefs, actorDeclaration] = await Promise.all([ agent.getPreferences(), fetchActorDeclarationRecord({did, agent}), ]) const data: OtherRequiredData = { birthdate: prefs.birthDate ? prefs.birthDate.toISOString() : undefined, actorDeclaration, } /** * If we can't read a birthdate, it may be due to the user accessing the * account via an app password. In that case, fall-back to declared age * flags. */ if (!data.birthdate) { if (prefs.declaredAge?.isOverAge18) { data.birthdate = getBirthdateStringFromAge(18) } else if (prefs.declaredAge?.isOverAge16) { data.birthdate = getBirthdateStringFromAge(16) } else if (prefs.declaredAge?.isOverAge13) { data.birthdate = getBirthdateStringFromAge(13) } } if (data && did && birthdateCache.has(did)) { /* * If birthdate was just set, use the local cache value. On subsequent * reloads, the server should have the correct value. */ data.birthdate = birthdateCache.get(did) } /** * If the user is under the minimum age, and the birthdate is not due to the * legacy bug, AND we've not already snoozed their birthdate update, snooze * further birthdate updates for this user. * * This is basically a migration step for this initial rollout. */ if ( data.birthdate && !isLegacyBirthdateBug(data.birthdate) && !hasSnoozedBirthdateUpdateForDid(did!) ) { snoozeBirthdateUpdateAllowedForDid(did!) } return data } export function getOtherRequiredDataFromCache({ did, }: { did: string }): OtherRequiredData | undefined { return qc.getQueryData( createOtherRequiredDataQueryKey({did}), ) } export function setOtherRequiredDataActorDeclarationCache({ did, actorDeclaration, }: { did: string actorDeclaration: ChatBskyActorDeclaration.Main }) { const prev = getOtherRequiredDataFromCache({did}) const next: OtherRequiredData = { birthdate: prev?.birthdate, actorDeclaration: { ...(prev?.actorDeclaration || {}), ...actorDeclaration, }, } qc.setQueryData( createOtherRequiredDataQueryKey({did}), next, ) } export async function prefetchOtherRequiredData({agent}: {agent: AtpAgent}) { const did = getDidFromAgentSession(agent) if (!did) return await cacheHydrationPromise const qk = createOtherRequiredDataQueryKey({did}) const cached = getOtherRequiredDataFromCache({did}) if (cached) { logger.debug(`prefetchOtherRequiredData: using cache`) return } try { logger.debug(`prefetchOtherRequiredData: resolving...`) const res = await networkRetry(3, () => getOtherRequiredData({agent})) qc.setQueryData(qk, res) } catch (err) { const e = err as Error logger.warn(`prefetchOtherRequiredData: failed`, { safeMessage: e.message, }) } } export function usePatchOtherRequiredData() { const {currentAccount} = useSession() return useCallback( (next: OtherRequiredData) => { if (!currentAccount) return const did = currentAccount.did const prev = getOtherRequiredDataFromCache({did}) const merged: OtherRequiredData = { ...(prev || {}), ...next, } qc.setQueryData( createOtherRequiredDataQueryKey({did}), merged, ) }, [currentAccount], ) } export function useOtherRequiredDataQuery() { const agent = useAgent() const did = getDidFromAgentSession(agent) return useQuery( { enabled: !!did, initialData: () => { if (!did) return return getOtherRequiredDataFromCache({did}) }, queryKey: createOtherRequiredDataQueryKey({did: did!}), async queryFn() { return getOtherRequiredData({agent}) }, }, qc, ) } export function createDeviceSignalsQueryKey({did}: {did: string}) { return ['device-signals', did] } export async function getDeviceSignals(): Promise { if (debug.enabled) return debug.resolve(debug.deviceSignals) try { return AgeRange.requestAgeRangeAsync({ threshold1: 13, threshold2: 16, threshold3: 18, }); } catch (e: any) { logger.error(`getDeviceSignals: failed to get device signals`, { safeMessage: e.message, }) return undefined } } export function getDeviceSignalsFromCache({ did, }: { did: string }): | AgeRange.AgeRangeResponse | undefined { return qc.getQueryData( createDeviceSignalsQueryKey({did}), ) } let deviceSignalsPrefetchPromise: Promise | undefined export async function prefetchDeviceSignals({ agent, }: { agent: AtpAgent }) { const did = getDidFromAgentSession(agent) if (!did) return /** * If we don't have a cache, it's possible the user hasn't granted access. * We don't want to do this during the prefetch phase, so just exit early, * the user can potentially enable it later. */ const cached = getDeviceSignalsFromCache({did}) if (!cached) return if (deviceSignalsPrefetchPromise) { logger.debug(`prefetchDeviceSignals: already in progress`) return } deviceSignalsPrefetchPromise = new Promise(async resolve => { await cacheHydrationPromise const cached = getDeviceSignalsFromCache({did}) if (cached) { logger.debug(`prefetchDeviceSignals: using cache`) resolve() } else { try { logger.debug(`prefetchDeviceSignals: resolving...`) const res = await getDeviceSignals() qc.setQueryData( createDeviceSignalsQueryKey({did}), res, ) } catch (e: any) { logger.warn(`prefetchDeviceSignals: failed`, { safeMessage: e.message, }) } finally { resolve() } } }) } export function useDeviceSignalsQuery() { const agent = useAgent() const did = getDidFromAgentSession(agent) return useQuery( { enabled: !!did, initialData: getDeviceSignalsFromCache({did: did!}), queryKey: createDeviceSignalsQueryKey({did: did!}), async queryFn() { logger.debug(`useDeviceSignalsQuery: fetching device signals`) return getDeviceSignals() }, }, qc, ) } /** * Helper to prefetch all age assurance data from the server. */ export function prefetchAgeAssuranceServerData({agent}: {agent: AtpAgent}) { return Promise.allSettled([ // config fetch initiated at the top of the App.platform.tsx files, awaited here configPrefetchPromise, prefetchServerState({agent}), prefetchOtherRequiredData({agent}), prefetchDeviceSignals({agent}), ]) } export function clearAgeAssuranceServerDataForDid({did}: {did: string}) { logger.debug(`clearAgeAssuranceServerDataForDid: ${did}`) qc.removeQueries({queryKey: createServerStateQueryKey({did}), exact: true}) qc.removeQueries({ queryKey: createOtherRequiredDataQueryKey({did}), exact: true, }) } export function clearAgeAssuranceServerDataForAll() { logger.debug(`clearAgeAssuranceServerDataForAll`) qc.clear() } /* * Context */ export type AgeAssuranceServerData = { /** * The raw config from the appview. */ config: AppBskyAgeassuranceDefs.Config | undefined /** * The raw state from the appview. Must be further processed before being useful. */ state: AppBskyAgeassuranceDefs.State | undefined metadata: AgeAssuranceMetadata | undefined } const AgeAssuranceServerDataContext = createContext({ config: undefined, state: undefined, metadata: { accountCreatedAt: undefined, declaredAge: undefined, birthdate: undefined, }, }) export function useAgeAssuranceServerDataContext() { return useContext(AgeAssuranceServerDataContext) } export function AgeAssuranceServerDataProvider({ children, }: { children: React.ReactNode }) { const {data: config} = useConfigQuery() const serverState = useServerStateQuery() const {state, metadata} = serverState.data || {} const {data} = useOtherRequiredDataQuery() const ctx = useMemo( () => ({ config, state, metadata: { // yes, it's weird, but accountCreatedAt comes back on the `getState` endpoint accountCreatedAt: metadata?.accountCreatedAt, declaredAge: data?.birthdate ? getAge(new Date(data.birthdate)) : undefined, birthdate: data?.birthdate, }, }), [config, state, data, metadata], ) return ( {children} ) }