UI for age assurance compliance (#8652)

* Add geo prop

* Add prelim fetch

* Add geo debug

* Pass in assurance state to notifications registration

* Comments

* Bump git index

* Add some component utils, no design, gate chat

* Disable mod prefs buttons, does not yet edit mod prefs

* Add initial prompt component

* Refine logic for showing prompt

* Add send email dialog

* Hook up dialog to fake mutation

* Fix geo debug bug

* Move provider inside query provider

* Slightly better screen gater

* Ok decent fallback with isExempt

* Reorg

* Wrap prompt in new logic

* Override mod prefs

* Use real endpoints, optimistic state

* Add persistent card, add time-ago, warning to dialog

* Add comment

* No undefined query values

* Fix case in import

* Wait for AA to load before registering push

* Override prefs in all locations

* Small refactor of notifications registration

* Register push after aa state

* Add retries

* Update blocked screens UI

* Strengthen email validation

* Add intent dialog

* Do service auth for init

* Rug refreshJwt

* Update copy

* Some mobile styles, add dev mode option

* Fix links on native

* Clean up intent dialog on native

* Don't mutate existing session, only copy

* Handle email validation error from server

* Clarity is better

* Moar clear

* Fixes

* Tweaks

* Add country code

* Gate it

* Refresh state after redirect

* Re-check on window focus

* Remove todo

* Enable in dev

* Check for did match on redirect

* Add blocked state

* Add appeal dialog

* Copy tweaks

* Inset in blue well

* Nux the prompt

* Copy updates

* Refetch just in case

* Uppercase country code

* Align copy, add notice to chat screens

* Tweak copy

* Add test code

* Add debug code

* Refactor AccountCard

* Big refactor

* Delay post-feed queries instead

* Debug code

* Clean up state

* Reorg

* Clean up copy

* Comments

* Reorg

* UPdate URL

* Cleanup

* Remove todo

* Update debug code

* revert unneeded changes

* UPdate nux name

* Revert unneeded change

* Updaet storage schema

* Checkpoint: cleanup

* Checkpoint: almost there

* isLoaded -> isReady

* Rename useAgeAssurance

* isUnderage -> isDeclaredUnderage

* Decompose, add docblocks

* Refactor

* UPdate debug

* Apply suggestion from @surfdude29

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Apply suggestion from @surfdude29

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Apply suggestion from @surfdude29

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Apply suggestion from @surfdude29

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Apply suggestion from @surfdude29

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Apply suggestion from @surfdude29

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Drop including Bluesky

* Apply suggestion from @surfdude29

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Apply suggestion from @surfdude29

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Remove todo

* Gate debug

* Revert unneeded change

* Fail closed

* Comments

* Comment

* Comment

* fix prettier

* rm viewheader

* bump sdk

* prevent overlap in admonition

* add age assurance intent route

* Just meow

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

* Nix callback

* Fix spelling of dismissible lol

* Don't compare translated string

* Better KWS link labels

* Hide DMs send options in menu

* Add button

* Fix order

* Use only supported languages

* Rm button

* best-effort language mapping

* improve typing

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
Eric Bailey
2025-07-16 13:58:07 -05:00
committed by GitHub
parent 712c3ad421
commit 1dbc331314
50 changed files with 2204 additions and 263 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: [],
}
+140
View File
@@ -0,0 +1,140 @@
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 {isNetworkError} from '#/lib/strings/errors'
import {Logger} from '#/logger'
import {
type AgeAssuranceAPIContextType,
type AgeAssuranceContextType,
} from '#/state/ageAssurance/types'
import {useIsAgeAssuranceEnabled} from '#/state/ageAssurance/useIsAgeAssuranceEnabled'
import {useGeolocation} from '#/state/geolocation'
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',
isReady: false,
lastInitiatedAt: undefined,
isAgeRestricted: false,
})
const AgeAssuranceAPIContext = createContext<AgeAssuranceAPIContextType>({
// @ts-ignore can't be bothered to type this
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 gate = useGate()
const agent = useAgent()
const {geolocation} = useGeolocation()
const isAgeAssuranceEnabled = useIsAgeAssuranceEnabled()
const getAndRegisterPushToken = useGetAndRegisterPushToken()
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.
*
* However, it only needs to run if AA is enabled.
*/
enabled: isAgeAssuranceEnabled,
queryKey: createAgeAssuranceQueryKey(agent.session?.did ?? 'never'),
async queryFn() {
if (!agent.session) return null
try {
const {data} = await networkRetry(3, () =>
agent.app.bsky.unspecced.getAgeAssuranceState(),
)
// const {data} = {
// data: {
// lastInitiatedAt: new Date().toISOString(),
// status: 'pending',
// } as AppBskyUnspeccedDefs.AgeAssuranceState,
// }
logger.debug(`fetch`, {
data,
account: agent.session?.did,
})
if (gate('age_assurance')) {
await getAndRegisterPushToken({
isAgeRestricted:
!!geolocation?.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 {status, lastInitiatedAt} = data || DEFAULT_AGE_ASSURANCE_STATE
const ctx: AgeAssuranceContextType = {
isReady: isFetched || !isAgeAssuranceEnabled,
status,
lastInitiatedAt,
isAgeRestricted: isAgeAssuranceEnabled ? status !== 'assured' : false,
}
logger.debug(`context`, ctx)
return ctx
}, [isFetched, data, isAgeAssuranceEnabled])
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)
}
+33
View File
@@ -0,0 +1,33 @@
import {type AppBskyUnspeccedDefs} from '@atproto/api'
import {type QueryObserverBaseResult} from '@tanstack/react-query'
export type AgeAssuranceContextType = {
/**
* Whether the age assurance state has been fetched from the server. If user
* is not in a region that requires AA, or AA is otherwise disabled, this
* will always be `true`.
*/
isReady: 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: AppBskyUnspeccedDefs.AgeAssuranceState['lastInitiatedAt']
/**
* Indicates the user is age restricted based on the requirements of their
* region, and their server-provided age assurance status. Does not factor in
* the user's declared age. If AA is otherise disabled, this will always be
* `false`.
*/
isAgeRestricted: boolean
}
export type AgeAssuranceAPIContextType = {
/**
* Refreshes the age assurance state by fetching it from the server.
*/
refetch: QueryObserverBaseResult['refetch']
}
+45
View File
@@ -0,0 +1,45 @@
import {useMemo} from 'react'
import {Logger} from '#/logger'
import {useAgeAssuranceContext} from '#/state/ageAssurance'
import {usePreferencesQuery} from '#/state/queries/preferences'
const logger = Logger.create(Logger.Context.AgeAssurance)
type AgeAssurance = ReturnType<typeof useAgeAssuranceContext> & {
/**
* The age the user has declared in their preferences, if any.
*/
declaredAge: number | undefined
/**
* Indicates whether the user has declared an age under 18.
*/
isDeclaredUnderage: boolean
}
/**
* 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 useAgeAssurance(): AgeAssurance {
const aa = useAgeAssuranceContext()
const {isFetched: preferencesLoaded, data: preferences} =
usePreferencesQuery()
const declaredAge = preferences?.userAge
return useMemo(() => {
const isReady = aa.isReady && preferencesLoaded
const isDeclaredUnderage = (declaredAge || 0) < 18
const state: AgeAssurance = {
isReady,
status: aa.status,
lastInitiatedAt: aa.lastInitiatedAt,
isAgeRestricted: aa.isAgeRestricted,
declaredAge,
isDeclaredUnderage,
}
logger.debug(`state`, state)
return state
}, [aa, preferencesLoaded, declaredAge])
}
@@ -0,0 +1,85 @@
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,
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
/*
* Uncomment if using the local dev-env
*/
// if (__DEV__) {
// APPVIEW = DEV_ENV_APPVIEW
// /*
// * IMPORTANT: you need to get this value from `http://localhost:2581`
// * introspection endpoint and updated in `constants`, since it changes
// * every time you run the 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,13 @@
import {useMemo} from 'react'
import {useGate} from '#/lib/statsig/statsig'
import {useGeolocation} from '#/state/geolocation'
export function useIsAgeAssuranceEnabled() {
const gate = useGate()
const {geolocation} = useGeolocation()
return useMemo(() => {
return gate('age_assurance') && !!geolocation?.isAgeRestrictedGeo
}, [geolocation, gate])
}
+5 -1
View File
@@ -25,6 +25,7 @@ const onGeolocationUpdate = (
*/
export const DEFAULT_GEOLOCATION: Device['geolocation'] = {
countryCode: undefined,
isAgeRestrictedGeo: false,
}
async function getGeolocation(): Promise<Device['geolocation']> {
@@ -39,6 +40,7 @@ async function getGeolocation(): Promise<Device['geolocation']> {
if (json.countryCode) {
return {
countryCode: json.countryCode,
isAgeRestrictedGeo: json.isAgeRestrictedGeo ?? false,
}
} else {
return undefined
@@ -66,7 +68,9 @@ export function beginResolveGeolocation() {
*/
if (__DEV__) {
geolocationResolution = new Promise(y => y({success: true}))
device.set(['geolocation'], DEFAULT_GEOLOCATION)
if (!device.get(['geolocation'])) {
device.set(['geolocation'], DEFAULT_GEOLOCATION)
}
return
}
+12
View File
@@ -7,6 +7,8 @@ export enum Nux {
ExploreInterestsCard = 'ExploreInterestsCard',
InitialVerificationAnnouncement = 'InitialVerificationAnnouncement',
ActivitySubscriptions = 'ActivitySubscriptions',
AgeAssuranceDismissibleNotice = 'AgeAssuranceDismissibleNotice',
AgeAssuranceDismissibleHeaderButton = 'AgeAssuranceDismissibleHeaderButton',
}
export const nuxNames = new Set(Object.values(Nux))
@@ -28,6 +30,14 @@ export type AppNux = BaseNux<
id: Nux.ActivitySubscriptions
data: undefined
}
| {
id: Nux.AgeAssuranceDismissibleNotice
data: undefined
}
| {
id: Nux.AgeAssuranceDismissibleHeaderButton
data: undefined
}
>
export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
@@ -35,4 +45,6 @@ export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
[Nux.ExploreInterestsCard]: undefined,
[Nux.InitialVerificationAnnouncement]: undefined,
[Nux.ActivitySubscriptions]: undefined,
[Nux.AgeAssuranceDismissibleNotice]: undefined,
[Nux.AgeAssuranceDismissibleHeaderButton]: undefined,
}
+15 -1
View File
@@ -1,6 +1,6 @@
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {AppNux, Nux} from '#/state/queries/nuxs/definitions'
import {type AppNux, type Nux} from '#/state/queries/nuxs/definitions'
import {parseAppNux, serializeAppNux} from '#/state/queries/nuxs/util'
import {
preferencesQueryKey,
@@ -40,6 +40,20 @@ export function useNuxs():
}
}
// if (__DEV__) {
// const queryClient = useQueryClient()
// const agent = useAgent()
// // @ts-ignore
// window.clearNux = async (ids: string[]) => {
// await agent.bskyAppRemoveNuxs(ids)
// // triggers a refetch
// await queryClient.invalidateQueries({
// queryKey: preferencesQueryKey,
// })
// }
// }
return {
nuxs: undefined,
status,
+23 -4
View File
@@ -8,6 +8,7 @@ import {
type BskyAgent,
moderatePost,
type ModerationDecision,
type ModerationPrefs,
} from '@atproto/api'
import {
type InfiniteData,
@@ -31,6 +32,7 @@ import {FeedTuner, type FeedTunerFn} from '#/lib/api/feed-manip'
import {DISCOVER_FEED_URI} from '#/lib/constants'
import {BSKY_FEED_OWNER_DIDS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgeAssuranceContext} from '#/state/ageAssurance'
import {STALE} from '#/state/queries'
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
import {useAgent} from '#/state/session'
@@ -134,8 +136,18 @@ export function usePostFeedQuery(
const feedTuners = useFeedTuners(feedDesc)
const moderationOpts = useModerationOpts()
const {data: preferences} = usePreferencesQuery()
/**
* Load bearing: we need to await AA state or risk FOUC. This marginally
* delays feeds, but AA state is fetched immediately on load and is then
* available for the remainder of the session, so this delay only affects cold
* loads. -esb
*/
const {isReady: isAgeAssuranceReady} = useAgeAssuranceContext()
const enabled =
opts?.enabled !== false && Boolean(moderationOpts) && Boolean(preferences)
opts?.enabled !== false &&
Boolean(moderationOpts) &&
Boolean(preferences) &&
isAgeAssuranceReady
const userInterests = aggregateUserInterests(preferences)
const followingPinnedIndex =
preferences?.savedFeeds?.findIndex(
@@ -206,7 +218,11 @@ export function usePostFeedQuery(
* some not.
*/
if (!agent.session) {
assertSomePostsPassModeration(res.feed)
assertSomePostsPassModeration(
res.feed,
preferences?.moderationPrefs ||
DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
)
}
return {
@@ -596,7 +612,10 @@ export function* findAllProfilesInQueryData(
}
}
function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) {
function assertSomePostsPassModeration(
feed: AppBskyFeedDefs.FeedViewPost[],
moderationPrefs: ModerationPrefs,
) {
// no posts in this feed
if (feed.length === 0) return true
@@ -606,7 +625,7 @@ function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) {
for (const item of feed) {
const moderation = moderatePost(item.post, {
userDid: undefined,
prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
prefs: moderationPrefs,
})
if (!moderation.ui('contentList').filter) {
+15
View File
@@ -1,3 +1,4 @@
import {useCallback} from 'react'
import {
type AppBskyActorDefs,
type BskyFeedViewPreference,
@@ -9,6 +10,8 @@ import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {replaceEqualDeep} from '#/lib/functions'
import {getAge} from '#/lib/strings/time'
import {logger} from '#/logger'
import {useAgeAssuranceContext} from '#/state/ageAssurance'
import {AGE_RESTRICTED_MODERATION_PREFS} from '#/state/ageAssurance/const'
import {STALE} from '#/state/queries'
import {
DEFAULT_HOME_FEED_PREFS,
@@ -31,6 +34,8 @@ export const preferencesQueryKey = [preferencesQueryKeyRoot]
export function usePreferencesQuery() {
const agent = useAgent()
const {isAgeRestricted} = useAgeAssuranceContext()
return useQuery({
staleTime: STALE.SECONDS.FIFTEEN,
structuralSharing: replaceEqualDeep,
@@ -68,6 +73,16 @@ export function usePreferencesQuery() {
return preferences
}
},
select: useCallback(
(data: UsePreferencesQueryResponse) => {
const isUnderage = (data.userAge || 0) < 18
if (isUnderage || isAgeRestricted) {
data.moderationPrefs = AGE_RESTRICTED_MODERATION_PREFS
}
return data
},
[isAgeRestricted],
),
})
}