761 lines
22 KiB
TypeScript
761 lines
22 KiB
TypeScript
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
|
|
import * as AgeRange from 'expo-age-range'
|
|
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 {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 {DEVICE_SIGNALS_SUPPORTED} from '#/ageAssurance/const'
|
|
import * as debug from '#/ageAssurance/debug'
|
|
import {logger} from '#/ageAssurance/logger'
|
|
import {
|
|
type AgeAssuranceDeviceSignals,
|
|
type AgeAssuranceMetadata,
|
|
} from '#/ageAssurance/types'
|
|
import {
|
|
createRegionKey,
|
|
getBirthdateStringFromAge,
|
|
isLegacyBirthdateBug,
|
|
} from '#/ageAssurance/util'
|
|
import {IS_DEV} from '#/env'
|
|
import {useGeolocation} from '#/geolocation'
|
|
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<string, string>()
|
|
export function setCreatedAtForDid({
|
|
did,
|
|
createdAt,
|
|
}: {
|
|
did: string
|
|
createdAt: string
|
|
}) {
|
|
createdAtCache.set(did, createdAt)
|
|
}
|
|
const birthdateCache = new Map<string, string>()
|
|
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<AppBskyAgeassuranceGetConfig.OutputSchema>(
|
|
configQueryKey,
|
|
)
|
|
}
|
|
let configPrefetchPromise: Promise<void> | 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<AppBskyAgeassuranceGetConfig.OutputSchema>(
|
|
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<AppBskyAgeassuranceGetConfig.OutputSchema>(
|
|
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<AppBskyAgeassuranceGetState.OutputSchema>(
|
|
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<AppBskyAgeassuranceGetState.OutputSchema>(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<AppBskyAgeassuranceGetState.OutputSchema>(
|
|
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<AppBskyAgeassuranceGetState.OutputSchema>(
|
|
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<OtherRequiredData> {
|
|
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<OtherRequiredData>(
|
|
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<OtherRequiredData>(
|
|
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<OtherRequiredData>(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<OtherRequiredData>(
|
|
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]
|
|
}
|
|
/**
|
|
* Prompts the native OS age API. Returns the raw response, or undefined if the
|
|
* platform can't provide one.
|
|
*
|
|
* Native-only: on web `expo-age-range` returns a misleading default (e.g.
|
|
* `{lowerBound: 18}`), so we never call it there — web users fall back to KWS.
|
|
*
|
|
* If this method throws for whatever reason, we catch and log the error and
|
|
* return undefined. The caller should treat undefined as "no device signals
|
|
* available" and fall back to KWS.
|
|
*/
|
|
export async function getDeviceSignals(): Promise<
|
|
AgeRange.AgeRangeResponse | undefined
|
|
> {
|
|
if (debug.enabled && debug.useMockDeviceSignalsAPIResponse)
|
|
return debug.resolve(debug.deviceSignals)
|
|
if (!DEVICE_SIGNALS_SUPPORTED) return undefined
|
|
try {
|
|
return await AgeRange.requestAgeRangeAsync({
|
|
threshold1: 13,
|
|
threshold2: 16,
|
|
threshold3: 18,
|
|
})
|
|
} catch (err) {
|
|
const e = err as Error
|
|
logger.error(`getDeviceSignals: failed to get device signals`, {
|
|
safeMessage: e.message,
|
|
})
|
|
return undefined
|
|
}
|
|
}
|
|
/**
|
|
* The raw region-keyed map of device signals (all regions). Used internally by
|
|
* the query + writer, which operate on the full map. Most consumers want
|
|
* {@link getDeviceSignalsFromCacheForRegion}, which resolves to the
|
|
* current region.
|
|
*/
|
|
export function getDeviceSignalsMapFromCache({
|
|
did,
|
|
}: {
|
|
did: string
|
|
}): AgeAssuranceDeviceSignals | undefined {
|
|
return qc.getQueryData<AgeAssuranceDeviceSignals>(
|
|
createDeviceSignalsQueryKey({did}),
|
|
)
|
|
}
|
|
/**
|
|
* Returns the device signals for the region the user is currently in, or
|
|
* undefined.
|
|
*/
|
|
export function getDeviceSignalsFromCacheForRegion({
|
|
did,
|
|
region,
|
|
}: {
|
|
did: string
|
|
region: AppBskyAgeassuranceDefs.ConfigRegion
|
|
}): AgeRange.AgeRangeResponse | undefined {
|
|
const regionKey = createRegionKey(region)
|
|
return getDeviceSignalsMapFromCache({did})?.[regionKey]
|
|
}
|
|
/**
|
|
* Stores freshly granted device signals into the (persisted) cache under the
|
|
* region they were captured in, merging with any signals already stored for
|
|
* other regions. Notifies the disabled `useDeviceSignalsQuery` observer so the
|
|
* AA state recomputes.
|
|
*
|
|
* Device assurance is client-side only (it can't be verified server-side) and
|
|
* region-bound — keying by region is what binds it. See
|
|
* {@link AgeAssuranceDeviceSignals}.
|
|
*/
|
|
export function setDeviceSignalsForRegion({
|
|
did,
|
|
region,
|
|
signals,
|
|
}: {
|
|
did: string
|
|
region: {countryCode: string; regionCode?: string}
|
|
signals: AgeRange.AgeRangeResponse
|
|
}) {
|
|
const regionKey = createRegionKey(region)
|
|
qc.setQueryData<AgeAssuranceDeviceSignals | undefined>(
|
|
createDeviceSignalsQueryKey({did}),
|
|
prev => ({...prev, [regionKey]: signals}),
|
|
)
|
|
}
|
|
export async function prefetchDeviceSignals({agent}: {agent: AtpAgent}) {
|
|
const did = getDidFromAgentSession(agent)
|
|
if (!did) return
|
|
|
|
/**
|
|
* Device signals are restored from the persisted cache only — we never call
|
|
* the native age API during prefetch, since that would prompt the OS for
|
|
* users who haven't opted in. Awaiting cache hydration ensures any previously
|
|
* granted signals are available before the AA state is first computed. The
|
|
* user can (re)grant access later via the NoAccessScreen verify flow.
|
|
*/
|
|
await cacheHydrationPromise
|
|
const cached = getDeviceSignalsMapFromCache({did})
|
|
logger.debug(
|
|
`prefetchDeviceSignals: ${cached ? 'restored from cache' : 'no cache'}`,
|
|
cached,
|
|
)
|
|
|
|
/*
|
|
* Future silent-refresh path (intentionally left commented out). The OS
|
|
* returns a previously granted age range without re-prompting, so once a
|
|
* region is already cached we could refresh it on load instead of waiting for
|
|
* the user to re-verify. We don't do this yet because the native call can
|
|
* still surface a prompt for users who never opted in, so it must stay gated
|
|
* behind an explicit grant for now.
|
|
*
|
|
* const geolocation = device.get(['mergedGeolocation'])
|
|
* if (cached && geolocation?.countryCode) {
|
|
* const signals = await getDeviceSignals()
|
|
* if (signals) {
|
|
* setDeviceSignalsForRegion({did, region: geolocation, signals})
|
|
* }
|
|
* }
|
|
*/
|
|
}
|
|
export function useDeviceSignalsQuery() {
|
|
const agent = useAgent()
|
|
const did = getDidFromAgentSession(agent)
|
|
const {data: config} = useConfigQuery()
|
|
const geolocation = useGeolocation()
|
|
/*
|
|
* Resolve the matched config region (no fallback) and key off it, so the read
|
|
* stays symmetric with the write (see `setDeviceSignalsForRegion`). When
|
|
* geolocation matches no AA region there's no device grant to surface.
|
|
*/
|
|
const regionConfig = config
|
|
? getAgeAssuranceRegionConfig(config, {
|
|
countryCode: geolocation.countryCode ?? '',
|
|
regionCode: geolocation.regionCode,
|
|
})
|
|
: undefined
|
|
const regionKey = regionConfig ? createRegionKey(regionConfig) : undefined
|
|
|
|
return useQuery(
|
|
{
|
|
/**
|
|
* Disabled so we never auto-call the native age API on load — that would
|
|
* prompt the OS for every logged-in user. We restore from the persisted
|
|
* cache (via `initialData`) and otherwise only update reactively when the
|
|
* user explicitly verifies (see `getDeviceSignals` +
|
|
* `setDeviceSignalsForRegion` in the NoAccessScreen verify flow).
|
|
*
|
|
* A future enhancement could silently refresh here when already cached,
|
|
* since the OS returns the granted result without re-prompting.
|
|
*/
|
|
enabled: false,
|
|
initialData: getDeviceSignalsMapFromCache({did: did!}),
|
|
queryKey: createDeviceSignalsQueryKey({did: did!}),
|
|
queryFn() {
|
|
// Never auto-fetches (see `enabled: false`); the verify flow writes the
|
|
// region-keyed signals directly via `setDeviceSignalsForRegion`.
|
|
return getDeviceSignalsMapFromCache({did: did!})
|
|
},
|
|
// The cache holds the full region-keyed map (the writer merges into it);
|
|
// `select` resolves it to the current region for consumers without
|
|
// mutating the cached value.
|
|
select: map => (map && regionKey ? map[regionKey] : undefined),
|
|
},
|
|
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
|
|
/**
|
|
* The native on-device age signals for the region the user is currently in,
|
|
* if they've granted access there. Already resolved from the region-keyed
|
|
* cache (see `getDeviceSignalsFromCacheForRegion`), so a grant from
|
|
* another region won't appear here. Only consumed for regions that permit
|
|
* device verification.
|
|
*/
|
|
deviceSignals: AgeRange.AgeRangeResponse | undefined
|
|
}
|
|
const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
|
|
config: undefined,
|
|
state: undefined,
|
|
metadata: {
|
|
accountCreatedAt: undefined,
|
|
declaredAge: undefined,
|
|
birthdate: undefined,
|
|
},
|
|
deviceSignals: 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()
|
|
// `select` resolves the cached region-keyed map to the current region.
|
|
const {data: deviceSignals} = useDeviceSignalsQuery()
|
|
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,
|
|
},
|
|
deviceSignals,
|
|
}),
|
|
[config, state, data, metadata, deviceSignals],
|
|
)
|
|
return (
|
|
<AgeAssuranceServerDataContext.Provider value={ctx}>
|
|
{children}
|
|
</AgeAssuranceServerDataContext.Provider>
|
|
)
|
|
}
|