Age Assurance V2 (#9479)

* Age Assurance V2

* Tighten up test

* Add todos for sdk migration

* Align RQ versions

* Use useEffect for side effect

* Improve effects, memoize

* Standarize on birthdate

* Copy feedback

* Copilot

* Add support link

* Reove double ..

* Cleanup

* Remove redirect dialog

* Cleanup todos, add comments

* Update splash in main template too

* Mock some stuff

* Exhaustive checks

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

* Exhaustive checks

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

* Small fix to bday handling

* Add comment

* onboarding style tweak

sneaking this in sorry!

* rm unreachable breaks

* Put useIntentHandler back on web

* Remove misleading success set

* Align on birthdate

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
Eric Bailey
2025-12-04 15:20:00 -06:00
committed by GitHub
parent 7735183af4
commit c4aef9f668
91 changed files with 3016 additions and 1799 deletions
@@ -4,9 +4,6 @@ import {useLingui} from '@lingui/react'
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {isNative} from '#/platform/detection'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {useDeviceGeolocationApi} from '#/state/geolocation'
import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
@@ -23,14 +20,13 @@ import {Divider} from '#/components/Divider'
import {createStaticClick, InlineLinkText} from '#/components/Link'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {logger, useAgeAssurance} from '#/ageAssurance'
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
import {useDeviceGeolocationApi} from '#/geolocation'
export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) {
const {isReady, isAgeRestricted, isDeclaredUnderage} = useAgeAssurance()
if (!isReady) return null
if (isDeclaredUnderage) return null
if (!isAgeRestricted) return null
const aa = useAgeAssurance()
if (aa.state.access === aa.Access.Full) return null
return <Inner style={style} />
}
@@ -43,10 +39,12 @@ function Inner({style}: ViewStyleProp & {}) {
const getTimeAgo = useGetTimeAgo()
const {gtPhone} = useBreakpoints()
const {setDeviceGeolocation} = useDeviceGeolocationApi()
const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess()
const copy = useAgeAssuranceCopy()
const {status, lastInitiatedAt} = useAgeAssurance()
const isBlocked = status === 'blocked'
const aa = useAgeAssurance()
const {status, lastInitiatedAt} = aa.state
const isBlocked = status === aa.Status.Blocked
const hasInitiated = !!lastInitiatedAt
const timeAgo = lastInitiatedAt
? getTimeAgo(lastInitiatedAt, new Date())
@@ -98,7 +96,10 @@ function Inner({style}: ViewStyleProp & {}) {
<DeviceLocationRequestDialog
control={locationControl}
onLocationAcquired={props => {
if (props.geolocationStatus.isAgeRestrictedGeo) {
const access = computeAgeAssuranceRegionAccess(
props.geolocation,
)
if (access !== aa.Access.Full) {
props.disableDialogAction()
props.setDialogError(
_(
@@ -108,10 +109,7 @@ function Inner({style}: ViewStyleProp & {}) {
} else {
props.closeDialog(() => {
// set this after close!
setDeviceGeolocation({
countryCode: props.geolocationStatus.countryCode,
regionCode: props.geolocationStatus.regionCode,
})
setDeviceGeolocation(props.geolocation)
Toast.show(_(msg`Thanks! You're all set.`), {
type: 'success',
})
@@ -2,25 +2,23 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf'
import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog'
import type * as Dialog from '#/components/Dialog'
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance'
import {logger} from '#/ageAssurance'
export function AgeAssuranceAdmonition({
children,
style,
}: ViewStyleProp & {children: React.ReactNode}) {
const control = useDialogControl()
const {isReady, isDeclaredUnderage, isAgeRestricted} = useAgeAssurance()
const aa = useAgeAssurance()
if (!isReady) return null
if (isDeclaredUnderage) return null
if (!isAgeRestricted) return null
if (aa.state.access === aa.Access.Full) return null
return (
<Inner style={style} control={control}>
@@ -6,7 +6,6 @@ import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {BLUESKY_MOD_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/state/ageAssurance/util'
import {useAgent, useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints, web} from '#/alf'
@@ -15,6 +14,7 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {logger} from '#/ageAssurance'
export function AgeAssuranceAppealDialog({
control,
@@ -3,8 +3,6 @@ import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {atoms as a, select, useTheme} from '#/alf'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
@@ -13,30 +11,22 @@ import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance'
import {logger} from '#/ageAssurance'
export function useInternalState() {
const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} =
useAgeAssurance()
const aa = useAgeAssurance()
const {nux} = useNux(Nux.AgeAssuranceDismissibleFeedBanner)
const {mutate: save, variables} = useSaveNux()
const hidden = !!variables
const visible = useMemo(() => {
if (!isReady) return false
if (isDeclaredUnderage) return false
if (!isAgeRestricted) return false
if (lastInitiatedAt) return false
if (aa.state.access === aa.Access.Full) return false
if (aa.state.lastInitiatedAt) return false
if (hidden) return false
if (nux && nux.completed) return false
return true
}, [
isReady,
isDeclaredUnderage,
isAgeRestricted,
lastInitiatedAt,
hidden,
nux,
])
}, [aa, hidden, nux])
const close = () => {
save({
@@ -2,28 +2,25 @@ import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {atoms as a, type ViewStyleProp} from '#/alf'
import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {useAgeAssurance} from '#/ageAssurance'
import {logger} from '#/ageAssurance'
export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) {
const {_} = useLingui()
const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} =
useAgeAssurance()
const aa = useAgeAssurance()
const {nux} = useNux(Nux.AgeAssuranceDismissibleNotice)
const copy = useAgeAssuranceCopy()
const {mutate: save, variables} = useSaveNux()
const hidden = !!variables
if (!isReady) return null
if (isDeclaredUnderage) return null
if (!isAgeRestricted) return null
if (lastInitiatedAt) return null
if (aa.state.access === aa.Access.Full) return null
if (aa.state.lastInitiatedAt) return null
if (hidden) return null
if (nux && nux.completed) return null
@@ -14,9 +14,6 @@ import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useTLDs} from '#/lib/hooks/useTLDs'
import {isEmailMaybeInvalid} from '#/lib/strings/email'
import {type AppLanguage} from '#/locale/languages'
import {useAgeAssuranceContext} from '#/state/ageAssurance'
import {useInitAgeAssurance} from '#/state/ageAssurance/useInitAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {useLanguagePrefs} from '#/state/preferences'
import {useSession} from '#/state/session'
import {atoms as a, useTheme, web} from '#/alf'
@@ -30,9 +27,12 @@ import {Divider} from '#/components/Divider'
import * as TextField from '#/components/forms/TextField'
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
import {LanguageSelect} from '#/components/LanguageSelect'
import {InlineLinkText} from '#/components/Link'
import {SimpleInlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {logger} from '#/ageAssurance'
import {useAgeAssurance} from '#/ageAssurance'
import {useBeginAgeAssurance} from '#/ageAssurance/useBeginAgeAssurance'
export {useDialogControl} from '#/components/Dialog/context'
@@ -69,7 +69,8 @@ function Inner() {
const langPrefs = useLanguagePrefs()
const cleanError = useCleanError()
const {close} = Dialog.useDialogContext()
const {lastInitiatedAt} = useAgeAssuranceContext()
const aa = useAgeAssurance()
const lastInitiatedAt = aa.state.lastInitiatedAt
const getTimeAgo = useGetTimeAgo()
const tlds = useTLDs()
const createSupportLink = useCreateSupportLink()
@@ -88,7 +89,7 @@ function Inner() {
)
const [error, setError] = useState<React.ReactNode>(null)
const {mutateAsync: init, isPending} = useInitAgeAssurance()
const {mutateAsync: begin, isPending} = useBeginAgeAssurance()
const runEmailValidation = () => {
if (validateEmail(email)) {
@@ -127,7 +128,7 @@ function Inner() {
return
}
await init({
await begin({
email,
language,
})
@@ -150,11 +151,11 @@ function Inner() {
<Trans>
We're having issues initializing the age assurance process for
your account. Please{' '}
<InlineLinkText
<SimpleInlineLinkText
to={createSupportLink({code: SupportCode.AA_DID, email})}
label={_(msg`Contact support`)}>
contact support
</InlineLinkText>{' '}
</SimpleInlineLinkText>{' '}
for assistance.
</Trans>
</>
@@ -195,14 +196,12 @@ function Inner() {
<Text style={[a.text_sm, a.leading_snug]}>
<Trans>
We have partnered with{' '}
<InlineLinkText
overridePresentation
disableMismatchWarning
<SimpleInlineLinkText
label={_(msg`KWS website`)}
to={urls.kwsHome}
style={[a.text_sm, a.leading_snug]}>
KWS
</InlineLinkText>{' '}
</SimpleInlineLinkText>{' '}
to verify that youre an adult. When you click "Begin" below,
KWS will check if you have previously verified your age using
this email address for other games/services powered by KWS
@@ -328,24 +327,20 @@ function Inner() {
style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
By continuing, you agree to the{' '}
<InlineLinkText
overridePresentation
disableMismatchWarning
<SimpleInlineLinkText
label={_(msg`KWS Terms of Use`)}
to={urls.kwsTermsOfUse}
style={[a.text_xs, a.leading_snug]}>
KWS Terms of Use
</InlineLinkText>{' '}
</SimpleInlineLinkText>{' '}
and acknowledge that KWS will store your verified status with
your hashed email address in accordance with the{' '}
<InlineLinkText
overridePresentation
disableMismatchWarning
<SimpleInlineLinkText
label={_(msg`KWS Privacy Policy`)}
to={urls.kwsPrivacyPolicy}
style={[a.text_xs, a.leading_snug]}>
KWS Privacy Policy
</InlineLinkText>
</SimpleInlineLinkText>
. This means you wont need to verify again the next time you
use this email for other apps, games, and services powered by
KWS technology.
@@ -6,8 +6,6 @@ import {useLingui} from '@lingui/react'
import {retry} from '#/lib/async/retry'
import {wait} from '#/lib/async/wait'
import {isNative} from '#/platform/detection'
import {useAgeAssuranceAPIContext} from '#/state/ageAssurance'
import {logger} from '#/state/ageAssurance/util'
import {useAgent} from '#/state/session'
import {atoms as a, useTheme, web} from '#/alf'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
@@ -18,6 +16,8 @@ import {CheckThick_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/ic
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {refetchAgeAssuranceServerState} from '#/ageAssurance'
import {logger} from '#/ageAssurance'
export type AgeAssuranceRedirectDialogState = {
result: 'success' | 'unknown'
@@ -63,7 +63,7 @@ export function AgeAssuranceRedirectDialog() {
const {_} = useLingui()
const control = useAgeAssuranceRedirectDialogControl()
// TODO for testing
// for testing
// Dialog.useAutoOpen(control.control, 3e3)
return (
@@ -88,7 +88,6 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
const control = useAgeAssuranceRedirectDialogControl()
const [error, setError] = useState(false)
const [success, setSuccess] = useState(false)
const {refetch: refreshAgeAssuranceState} = useAgeAssuranceAPIContext()
useEffect(() => {
if (polling.current) return
@@ -106,9 +105,9 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
if (!agent.session) return
if (unmounted.current) return
const {data} = await agent.app.bsky.unspecced.getAgeAssuranceState()
const data = await refetchAgeAssuranceServerState({agent})
if (data.status !== 'assured') {
if (data?.state.status !== 'assured') {
throw new Error(
`Polling for age assurance state did not receive assured status`,
)
@@ -124,9 +123,6 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
if (!agent.session) return
if (unmounted.current) return
// success! update state
await refreshAgeAssuranceState()
setSuccess(true)
logger.metric('ageAssurance:redirectDialogSuccess', {})
@@ -134,15 +130,13 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
.catch(() => {
if (unmounted.current) return
setError(true)
// try a refetch anyway
refreshAgeAssuranceState()
logger.metric('ageAssurance:redirectDialogFail', {})
})
return () => {
unmounted.current = true
}
}, [agent, control, refreshAgeAssuranceState])
}, [agent, control])
if (success) {
return (
@@ -2,8 +2,6 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
@@ -13,6 +11,8 @@ import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components
import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance'
import {logger} from '#/ageAssurance'
export function AgeRestrictedScreen({
children,
@@ -27,22 +27,9 @@ export function AgeRestrictedScreen({
}) {
const {_} = useLingui()
const copy = useAgeAssuranceCopy()
const {isReady, isAgeRestricted} = useAgeAssurance()
const aa = useAgeAssurance()
if (!isReady) {
return (
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.Content>
<Layout.Header.TitleText> </Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<Layout.Content />
</Layout.Screen>
)
}
if (!isAgeRestricted) return children
if (aa.state.access === aa.Access.Full) return children
return (
<Layout.Screen>
@@ -2,14 +2,22 @@ import {useMemo} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/ageAssurance'
export function useAgeAssuranceCopy() {
const {_} = useLingui()
const aa = useAgeAssurance()
return useMemo(() => {
return {
notice: _(
msg`The laws in your location require you to verify you're an adult before accessing certain features on Bluesky, like adult content and direct messaging.`,
),
notice:
aa.state.access === aa.Access.Safe
? _(
msg`Due to laws in your region, certain features on Bluesky are currently restricted until you're able to verify you're an adult.`,
)
: _(
msg`The laws in your location require you to verify you're an adult before accessing certain features on Bluesky, like adult content and direct messaging.`,
),
banner: _(
msg`The laws in your location require you to verify you're an adult to access certain features. Tap to learn more.`,
),
@@ -17,5 +25,5 @@ export function useAgeAssuranceCopy() {
msg`Don't worry! All existing messages and settings are saved and will be available after you verify you're an adult.`,
),
}
}, [_])
}, [_, aa])
}