666 lines
18 KiB
TypeScript
666 lines
18 KiB
TypeScript
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<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]
|
|
}
|
|
export async function getDeviceSignals(): Promise<AgeRange.AgeRangeResponse | undefined> {
|
|
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<AgeRange.AgeRangeResponse>(
|
|
createDeviceSignalsQueryKey({did}),
|
|
)
|
|
}
|
|
let deviceSignalsPrefetchPromise: Promise<void> | 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<AgeRange.AgeRangeResponse>(
|
|
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<AgeAssuranceServerData>({
|
|
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 (
|
|
<AgeAssuranceServerDataContext.Provider value={ctx}>
|
|
{children}
|
|
</AgeAssuranceServerDataContext.Provider>
|
|
)
|
|
}
|