This commit is contained in:
Eric Bailey
2025-07-15 13:14:16 -05:00
parent 5701990606
commit 1117bd7481
18 changed files with 19 additions and 19 deletions
+11
View File
@@ -0,0 +1,11 @@
import {type ModerationPrefs} from '@atproto/api'
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
export const AGE_RESTRICTED_MODERATION_PREFS: ModerationPrefs = {
adultContentEnabled: false,
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
labelers: [],
mutedWords: [],
hiddenPosts: [],
}
+142
View File
@@ -0,0 +1,142 @@
import {createContext, useContext, useMemo} from 'react'
import {type AppBskyUnspeccedDefs} from '@atproto/api'
import {useQuery} from '@tanstack/react-query'
import {networkRetry} from '#/lib/async/retry'
import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
import {useGate} from '#/lib/statsig/statsig'
// import {wait} from '#/lib/async/wait'
import {isNetworkError} from '#/lib/strings/errors'
import {Logger} from '#/logger'
import {
type AgeAssuranceAPIContextType,
type AgeAssuranceContextType,
} from '#/state/ageAssurance/types'
import {useGeolocation} from '#/state/geolocation'
import {STALE} from '#/state/queries'
import {useAgent} from '#/state/session'
const logger = Logger.create(Logger.Context.AgeAssurance)
export const createAgeAssuranceQueryKey = (did: string) =>
['ageAssurance', did] as const
const DEFAULT_AGE_ASSURANCE_STATE: AppBskyUnspeccedDefs.AgeAssuranceState = {
lastInitiatedAt: undefined,
status: 'unknown',
}
const AgeAssuranceContext = createContext<AgeAssuranceContextType>({
status: 'unknown',
isLoaded: false,
isAgeRestricted: false,
lastInitiatedAt: undefined,
})
const AgeAssuranceAPIContext = createContext<AgeAssuranceAPIContextType>({
// @ts-ignore
refetch: () => Promise.resolve(),
})
/**
* Low-level provider for fetching age assurance state on app load. Do not add
* any other data fetching in here to avoid complications and reduced
* performance.
*/
export function Provider({children}: {children: React.ReactNode}) {
const agent = useAgent()
const {geolocation} = useGeolocation()
const getAndRegisterPushToken = useGetAndRegisterPushToken()
const isAgeRestrictedGeo = !!geolocation?.isAgeRestrictedGeo
const gate = useGate()
const {data, isFetched, refetch} = useQuery({
/**
* This is load bearing. We always want this query to run and end in a
* "fetched" state, even if we fall back to defaults. This lets the rest of
* the app know that we've at least attempted to load the AA state.
*/
enabled: true,
queryKey: createAgeAssuranceQueryKey(agent.session?.did ?? 'never'),
staleTime: STALE.MINUTES.ONE,
refetchOnWindowFocus: true,
async queryFn() {
if (!agent.session) return null
try {
const {data} = await networkRetry(3, () =>
agent.app.bsky.unspecced.getAgeAssuranceState(),
)
// const {data} = await wait(
// 1e3,
// (() => ({
// data: {
// lastInitiatedAt: undefined,//new Date().toISOString(),
// status: 'unknown',
// } as AppBskyUnspeccedDefs.AgeAssuranceState,
// }))(),
// )
logger.debug(`fetch`, {
data,
account: agent.session?.did,
})
await getAndRegisterPushToken({
isAgeRestricted: Boolean(
isAgeRestrictedGeo && data.status !== 'assured',
),
})
return data
} catch (e) {
if (!isNetworkError(e)) {
logger.error(`ageAssurance: failed to fetch`, {safeMessage: e})
}
// don't re-throw error, we'll just fall back to defaults
return null
}
},
})
/**
* Derive state, or fall back to defaults
*/
const ageAssuranceContext = useMemo<AgeAssuranceContextType>(() => {
const enabled = __DEV__ || gate('age_assurance')
const {status, lastInitiatedAt} = data || DEFAULT_AGE_ASSURANCE_STATE
const ctx: AgeAssuranceContextType = {
isLoaded: isFetched,
status,
lastInitiatedAt,
isAgeRestricted: isAgeRestrictedGeo && status !== 'assured' && enabled,
}
logger.debug(`context`, ctx)
return ctx
}, [gate, isAgeRestrictedGeo, isFetched, data])
const ageAssuranceAPIContext = useMemo<AgeAssuranceAPIContextType>(
() => ({
refetch,
}),
[refetch],
)
return (
<AgeAssuranceAPIContext.Provider value={ageAssuranceAPIContext}>
<AgeAssuranceContext.Provider value={ageAssuranceContext}>
{children}
</AgeAssuranceContext.Provider>
</AgeAssuranceAPIContext.Provider>
)
}
/**
* Access to low-level AA state. Prefer using {@link useAgeInfo} for a
* more user-friendly interface.
*/
export function useAgeAssuranceContext() {
return useContext(AgeAssuranceContext)
}
export function useAgeAssuranceAPIContext() {
return useContext(AgeAssuranceAPIContext)
}
+26
View File
@@ -0,0 +1,26 @@
import {type AppBskyUnspeccedDefs} from '@atproto/api'
import {type QueryObserverBaseResult} from '@tanstack/react-query'
export type AgeAssuranceContextType = {
isLoaded: boolean
/**
* The server-reported status of the user's age verification process.
*/
status: AppBskyUnspeccedDefs.AgeAssuranceState['status']
/**
* The last time the age assurance state was attempted by the user.
*/
lastInitiatedAt: string | undefined
/**
* Whether the current user is age-restricted based on their geolocation and
* age assurance state retrieved from the server.
*/
isAgeRestricted: boolean
}
export type AgeAssuranceAPIContextType = {
/**
* Refreshes the age assurance state by fetching it from the server.
*/
refetch: QueryObserverBaseResult['refetch']
}
+25
View File
@@ -0,0 +1,25 @@
import {useMemo} from 'react'
import {useAgeAssuranceContext} from '#/state/ageAssurance'
import {usePreferencesQuery} from '#/state/queries/preferences'
/**
* Computed age information based on age assurance status and the user's
* declared age. Use this instead of {@link useAgeAssuranceContext} to get a
* more user-friendly interface.
*/
export function useAgeInfo() {
const ctx = useAgeAssuranceContext()
const {isFetched: preferencesLoaded, data: preferences} =
usePreferencesQuery()
const declaredAge = useMemo(() => preferences?.userAge, [preferences])
return useMemo(() => {
return {
isLoaded: ctx.isLoaded && preferencesLoaded,
declaredAge,
isUnderage: ctx.isAgeRestricted && (declaredAge || 0) < 18,
assurance: ctx,
}
}, [ctx, preferencesLoaded, declaredAge])
}
@@ -0,0 +1,78 @@
import {
type AppBskyUnspeccedDefs,
type AppBskyUnspeccedInitAgeAssurance,
AtpAgent,
} from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {wait} from '#/lib/async/wait'
import {
// DEV_ENV_APPVIEW,
// DEV_ENV_APPVIEW_DID,
PUBLIC_APPVIEW,
PUBLIC_APPVIEW_DID,
} from '#/lib/constants'
import {isNetworkError} from '#/lib/hooks/useCleanError'
import {logger} from '#/logger'
import {createAgeAssuranceQueryKey} from '#/state/ageAssurance'
import {useGeolocation} from '#/state/geolocation'
import {useAgent} from '#/state/session'
let APPVIEW = PUBLIC_APPVIEW
let APPVIEW_DID = PUBLIC_APPVIEW_DID
// if (__DEV__) {
// APPVIEW = DEV_ENV_APPVIEW
// APPVIEW_DID = DEV_ENV_APPVIEW_DID
// }
export function useInitAgeAssurance() {
const qc = useQueryClient()
const agent = useAgent()
const {geolocation} = useGeolocation()
return useMutation({
async mutationFn(
props: Omit<AppBskyUnspeccedInitAgeAssurance.InputSchema, 'countryCode'>,
) {
if (!geolocation?.countryCode) {
throw new Error(`Geolocation not available, cannot init age assurance.`)
}
const {
data: {token},
} = await agent.com.atproto.server.getServiceAuth({
aud: APPVIEW_DID,
lxm: `app.bsky.unspecced.initAgeAssurance`,
})
const appView = new AtpAgent({service: APPVIEW})
appView.sessionManager.session = {...agent.session!}
appView.sessionManager.session.accessJwt = token
appView.sessionManager.session.refreshJwt = ''
/*
* 2s wait is good actually. Email sending takes a hot sec and this helps
* ensure the email is ready for the user once they open their inbox.
*/
const {data} = await wait(
2e3,
appView.app.bsky.unspecced.initAgeAssurance({
...props,
countryCode: geolocation?.countryCode?.toUpperCase(),
}),
)
qc.setQueryData<AppBskyUnspeccedDefs.AgeAssuranceState>(
createAgeAssuranceQueryKey(agent.session?.did ?? 'never'),
() => data,
)
},
onError(e) {
if (!isNetworkError(e)) {
logger.error(`useInitAgeAssurance failed`, {
safeMessage: e,
})
}
},
})
}
@@ -0,0 +1,23 @@
import {useCallback} from 'react'
import {type ModerationPrefs} from '@atproto/api'
import {useAgeAssuranceContext} from '#/state/ageAssurance'
import {AGE_RESTRICTED_MODERATION_PREFS} from '#/state/ageAssurance/const'
/**
* Hook to conditionally apply age-restricted moderation preferences, if
* needed. If not needed, the mod prefs passed to the callback will be used.
*/
export function useMaybeApplyAgeRestrictedModerationPrefs() {
const state = useAgeAssuranceContext()
return useCallback(
(prev: ModerationPrefs) => {
if (state.isAgeRestricted) {
return AGE_RESTRICTED_MODERATION_PREFS
}
return prev
},
[state],
)
}