From b3f775d1d88957b8cb3f21934c9f70eebb008764 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 8 Jan 2026 15:36:26 -0600 Subject: [PATCH] [AAv2] Improve minimum age handling (#9650) * Improve miminum age handling * Update api sdk * Fix bad import --- package.json | 2 +- .../components/NoAccessScreen.tsx | 11 +- src/ageAssurance/debug.ts | 28 +++-- src/ageAssurance/index.tsx | 28 ++++- src/ageAssurance/util.ts | 56 ++++----- src/geolocation/debug.ts | 18 ++- src/geolocation/device.ts | 8 +- src/geolocation/index.tsx | 5 +- src/screens/Signup/StepInfo/Policies.tsx | 31 +---- src/screens/Signup/StepInfo/index.tsx | 108 ++++++++++++++++-- yarn.lock | 56 ++++++--- 11 files changed, 253 insertions(+), 98 deletions(-) diff --git a/package.json b/package.json index 7d201e90a5..92b0374720 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { - "@atproto/api": "^0.18.8", + "@atproto/api": "^0.18.11", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", "@bsky.app/alf": "^0.1.6", diff --git a/src/ageAssurance/components/NoAccessScreen.tsx b/src/ageAssurance/components/NoAccessScreen.tsx index 3aa69dc789..8b8ad0ccc1 100644 --- a/src/ageAssurance/components/NoAccessScreen.tsx +++ b/src/ageAssurance/components/NoAccessScreen.tsx @@ -177,10 +177,19 @@ export function NoAccessScreen() { + {!aa.flags.isOverRegionMinAccessAge && ( + + + Unfortunately, your declared age indicates that you + are not old enough to access Bluesky in your region. + + + )} + {!isBlocked && birthdateUpdateText} - + {aa.flags.isOverRegionMinAccessAge && } ) : ( diff --git a/src/ageAssurance/debug.ts b/src/ageAssurance/debug.ts index 20ed6174eb..8a97cf84ee 100644 --- a/src/ageAssurance/debug.ts +++ b/src/ageAssurance/debug.ts @@ -12,23 +12,26 @@ export const enabled = (IS_DEV && false) || IS_E2E export const geolocation: Geolocation | undefined = enabled ? { - countryCode: 'AA', + countryCode: 'BB', regionCode: undefined, } : undefined -export const deviceGeolocation: Geolocation | undefined = enabled - ? { - countryCode: 'AA', - regionCode: undefined, - } - : undefined +const deviceGeolocationEnabled = false +export const deviceGeolocation: Geolocation | undefined = + enabled && deviceGeolocationEnabled + ? { + countryCode: 'AA', + regionCode: undefined, + } + : undefined export const config: AppBskyAgeassuranceDefs.Config = { regions: [ { countryCode: 'AA', regionCode: undefined, + minAccessAge: 13, rules: [ { $type: ids.Default, @@ -36,6 +39,17 @@ export const config: AppBskyAgeassuranceDefs.Config = { }, ], }, + { + countryCode: 'BB', + regionCode: undefined, + minAccessAge: 16, + rules: [ + { + $type: ids.Default, + access: 'none', + }, + ], + }, ], } diff --git a/src/ageAssurance/index.tsx b/src/ageAssurance/index.tsx index 9a0a9c9d51..68654b384b 100644 --- a/src/ageAssurance/index.tsx +++ b/src/ageAssurance/index.tsx @@ -14,7 +14,11 @@ import { type AgeAssuranceState, AgeAssuranceStatus, } from '#/ageAssurance/types' -import {isUserUnderAdultAge} from '#/ageAssurance/util' +import { + isUnderAge, + MIN_ACCESS_AGE, + useAgeAssuranceRegionConfigWithFallback, +} from '#/ageAssurance/util' export { prefetchConfig as prefetchAgeAssuranceConfig, @@ -24,6 +28,7 @@ export { usePatchServerState as usePatchAgeAssuranceServerState, } from '#/ageAssurance/data' export {logger} from '#/ageAssurance/logger' +export {MIN_ACCESS_AGE} from '#/ageAssurance/util' const AgeAssuranceStateContext = createContext<{ Access: typeof AgeAssuranceAccess @@ -32,6 +37,8 @@ const AgeAssuranceStateContext = createContext<{ flags: { adultContentDisabled: boolean chatDisabled: boolean + isOverRegionMinAccessAge: boolean + isOverAppMinAccessAge: boolean } }>({ Access: AgeAssuranceAccess, @@ -44,6 +51,8 @@ const AgeAssuranceStateContext = createContext<{ flags: { adultContentDisabled: false, chatDisabled: false, + isOverRegionMinAccessAge: false, + isOverAppMinAccessAge: false, }, }) @@ -69,6 +78,7 @@ export function Provider({children}: {children: React.ReactNode}) { function InnerProvider({children}: {children: React.ReactNode}) { const state = useAgeAssuranceState() const {data} = useAgeAssuranceDataContext() + const config = useAgeAssuranceRegionConfigWithFallback() const getAndRegisterPushToken = useGetAndRegisterPushToken() const handleAccessUpdate = useCallback( @@ -89,11 +99,17 @@ function InnerProvider({children}: {children: React.ReactNode}) { { const chatDisabled = state.access !== AgeAssuranceAccess.Full - const isUnderage = data?.birthdate - ? isUserUnderAdultAge(data.birthdate) + const isUnderAdultAge = data?.birthdate + ? isUnderAge(data.birthdate, 18) : true + const isOverRegionMinAccessAge = data?.birthdate + ? !isUnderAge(data.birthdate, config.minAccessAge) + : false + const isOverAppMinAccessAge = data?.birthdate + ? !isUnderAge(data.birthdate, MIN_ACCESS_AGE) + : false const adultContentDisabled = - state.access !== AgeAssuranceAccess.Full || isUnderage + state.access !== AgeAssuranceAccess.Full || isUnderAdultAge return { Access: AgeAssuranceAccess, Status: AgeAssuranceStatus, @@ -101,9 +117,11 @@ function InnerProvider({children}: {children: React.ReactNode}) { flags: { adultContentDisabled, chatDisabled, + isOverRegionMinAccessAge, + isOverAppMinAccessAge, }, } - }, [state, data])}> + }, [state, data, config])}> {children} ) diff --git a/src/ageAssurance/util.ts b/src/ageAssurance/util.ts index e31f89b14e..d55ec61762 100644 --- a/src/ageAssurance/util.ts +++ b/src/ageAssurance/util.ts @@ -12,7 +12,23 @@ import {useAgeAssuranceDataContext} from '#/ageAssurance/data' import {AgeAssuranceAccess} from '#/ageAssurance/types' import {type Geolocation, useGeolocation} from '#/geolocation' -const DEFAULT_MIN_AGE = 13 +export const MIN_ACCESS_AGE = 13 +const FALLBACK_REGION_CONFIG: AppBskyAgeassuranceDefs.ConfigRegion = { + countryCode: '*', + regionCode: undefined, + minAccessAge: MIN_ACCESS_AGE, + rules: [ + { + $type: ids.IfDeclaredOverAge, + age: MIN_ACCESS_AGE, + access: AgeAssuranceAccess.Full, + }, + { + $type: ids.Default, + access: AgeAssuranceAccess.None, + }, + ], +} /** * Get age assurance region config based on geolocation, with fallback to @@ -30,23 +46,7 @@ export function getAgeAssuranceRegionConfigWithFallback( regionCode: geolocation.regionCode, }) - return ( - region || { - countryCode: '*', - regionCode: undefined, - rules: [ - { - $type: ids.IfDeclaredOverAge, - age: DEFAULT_MIN_AGE, - access: AgeAssuranceAccess.Full, - }, - { - $type: ids.Default, - access: AgeAssuranceAccess.None, - }, - ], - } - ) + return region || FALLBACK_REGION_CONFIG } /** @@ -67,6 +67,14 @@ export function useAgeAssuranceRegionConfig() { }, [config, geolocation]) } +/** + * Hook to get the age assurance region config based on current geolocation. + * Falls back to our app defaults if no region config is found. + */ +export function useAgeAssuranceRegionConfigWithFallback() { + return useAgeAssuranceRegionConfig() || FALLBACK_REGION_CONFIG +} + /** * Some users may have erroneously set their birth date to the current date * if one wasn't set on their account. We previously didn't do validation on @@ -78,15 +86,11 @@ export function isLegacyBirthdateBug(birthDate: string) { } /** - * Returns whether the user is under the minimum age required to use the app. - * This applies to all regions. + * Returns whether the date (converted to an age as a whole integer) is under + * the provided minimum age. */ -export function isUserUnderMinimumAge(birthDate: string) { - return getAge(new Date(birthDate)) < DEFAULT_MIN_AGE -} - -export function isUserUnderAdultAge(birthDate: string) { - return getAge(new Date(birthDate)) < 18 +export function isUnderAge(birthDate: string, age: number) { + return getAge(new Date(birthDate)) < age } export function getBirthdateStringFromAge(age: number) { diff --git a/src/geolocation/debug.ts b/src/geolocation/debug.ts index f062a94cef..0d2564c305 100644 --- a/src/geolocation/debug.ts +++ b/src/geolocation/debug.ts @@ -5,14 +5,20 @@ import {type Geolocation} from '#/geolocation/types' const localEnabled = false export const enabled = IS_DEV && (localEnabled || aaDebug.geolocation) export const geolocation: Geolocation = aaDebug.geolocation ?? { - countryCode: 'AU', - regionCode: undefined, -} -export const deviceGeolocation: Geolocation = aaDebug.deviceGeolocation ?? { - countryCode: 'AU', - regionCode: undefined, + countryCode: 'US', + regionCode: 'TX', } +const deviceLocalEnabled = false +export const deviceGeolocation: Geolocation | undefined = + aaDebug.deviceGeolocation || + (deviceLocalEnabled + ? { + countryCode: 'US', + regionCode: 'TX', + } + : undefined) + export async function resolve(data: T) { await new Promise(y => setTimeout(y, 500)) // simulate network return data diff --git a/src/geolocation/device.ts b/src/geolocation/device.ts index d14d4eb677..b98e6a2363 100644 --- a/src/geolocation/device.ts +++ b/src/geolocation/device.ts @@ -46,7 +46,8 @@ const useForegroundPermissions = createPermissionHook({ }) export async function getDeviceGeolocation(): Promise { - if (debug.enabled) return debug.resolve(debug.deviceGeolocation) + if (debug.enabled && debug.deviceGeolocation) + return debug.resolve(debug.deviceGeolocation) try { const geocode = await Location.getCurrentPositionAsync() @@ -142,3 +143,8 @@ export function useSyncDeviceGeolocationOnStartup( }) }, [status, sync]) } + +export function useIsDeviceGeolocationGranted() { + const [status] = useForegroundPermissions() + return status?.granted === true +} diff --git a/src/geolocation/index.tsx b/src/geolocation/index.tsx index 231182a579..c9dc0cb1ed 100644 --- a/src/geolocation/index.tsx +++ b/src/geolocation/index.tsx @@ -12,7 +12,10 @@ import {type Geolocation} from '#/geolocation/types' import {mergeGeolocations} from '#/geolocation/util' import {device, useStorage} from '#/storage' -export {useRequestDeviceGeolocation} from '#/geolocation/device' +export { + useIsDeviceGeolocationGranted, + useRequestDeviceGeolocation, +} from '#/geolocation/device' export {resolve} from '#/geolocation/service' export * from '#/geolocation/types' diff --git a/src/screens/Signup/StepInfo/Policies.tsx b/src/screens/Signup/StepInfo/Policies.tsx index 2c609cfc4d..565c2d4cd1 100644 --- a/src/screens/Signup/StepInfo/Policies.tsx +++ b/src/screens/Signup/StepInfo/Policies.tsx @@ -11,12 +11,8 @@ import {Text} from '#/components/Typography' export const Policies = ({ serviceDescription, - needsGuardian, - under13, }: { serviceDescription: ComAtprotoServerDescribeServer.OutputSchema - needsGuardian: boolean - under13: boolean }) => { const t = useTheme() const {_} = useLingui() @@ -91,30 +87,9 @@ export const Policies = ({ return null } - return ( - - {els ? ( - - {els} - - ) : null} - - {under13 ? ( - - - You must be 13 years of age or older to create an account. - - - ) : needsGuardian ? ( - - - If you are not yet an adult according to the laws of your country, - your parent or legal guardian must read these Terms on your behalf. - - - ) : undefined} - - ) + return els ? ( + {els} + ) : null } function validWebLink(url?: string): string | undefined { diff --git a/src/screens/Signup/StepInfo/index.tsx b/src/screens/Signup/StepInfo/index.tsx index e3664c0194..4beced1852 100644 --- a/src/screens/Signup/StepInfo/index.tsx +++ b/src/screens/Signup/StepInfo/index.tsx @@ -7,9 +7,13 @@ import type tldts from 'tldts' import {isEmailMaybeInvalid} from '#/lib/strings/email' import {logger} from '#/logger' -import {is13, is18, useSignupContext} from '#/screens/Signup/state' +import {isNative} from '#/platform/detection' +import {useSignupContext} from '#/screens/Signup/state' import {Policies} from '#/screens/Signup/StepInfo/Policies' import {atoms as a, native} from '#/alf' +import * as Admonition from '#/components/Admonition' +import * as Dialog from '#/components/Dialog' +import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog' import * as DateField from '#/components/forms/DateField' import {type DateFieldRef} from '#/components/forms/DateField/types' import {FormError} from '#/components/forms/FormError' @@ -18,8 +22,19 @@ import * as TextField from '#/components/forms/TextField' import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope' import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' +import {createStaticClick, SimpleInlineLinkText} from '#/components/Link' import {Loader} from '#/components/Loader' import {usePreemptivelyCompleteActivePolicyUpdate} from '#/components/PolicyUpdateOverlay/usePreemptivelyCompleteActivePolicyUpdate' +import * as Toast from '#/components/Toast' +import { + isUnderAge, + MIN_ACCESS_AGE, + useAgeAssuranceRegionConfigWithFallback, +} from '#/ageAssurance/util' +import { + useDeviceGeolocationApi, + useIsDeviceGeolocationGranted, +} from '#/geolocation' import {BackNextButtons} from '../BackNextButtons' function sanitizeDate(date: Date): Date { @@ -57,6 +72,20 @@ export function StepInfo({ const passwordInputRef = useRef(null) const birthdateInputRef = useRef(null) + const aaRegionConfig = useAgeAssuranceRegionConfigWithFallback() + const {setDeviceGeolocation} = useDeviceGeolocationApi() + const locationControl = Dialog.useDialogControl() + const isOverRegionMinAccessAge = state.dateOfBirth + ? !isUnderAge(state.dateOfBirth.toISOString(), aaRegionConfig.minAccessAge) + : true + const isOverAppMinAccessAge = state.dateOfBirth + ? !isUnderAge(state.dateOfBirth.toISOString(), MIN_ACCESS_AGE) + : true + const isOverMinAdultAge = state.dateOfBirth + ? !isUnderAge(state.dateOfBirth.toISOString(), 18) + : true + const isDeviceGeolocationGranted = useIsDeviceGeolocationGranted() + const [hasWarnedEmail, setHasWarnedEmail] = React.useState(false) const tldtsRef = React.useRef(undefined) @@ -76,7 +105,7 @@ export function StepInfo({ const emailChanged = prevEmailValueRef.current !== email const password = passwordValueRef.current - if (!is13(state.dateOfBirth)) { + if (!isOverRegionMinAccessAge) { return } @@ -274,16 +303,79 @@ export function StepInfo({ maximumDate={new Date()} /> - + + + + + {!isOverRegionMinAccessAge || !isOverAppMinAccessAge ? ( + + + + + + {!isOverAppMinAccessAge ? ( + + You must be {MIN_ACCESS_AGE} years of age or older + to create an account. + + ) : ( + + You must be {aaRegionConfig.minAccessAge} years of + age or older to create an account in your region. + + )} + + {isNative && + !isDeviceGeolocationGranted && + isOverAppMinAccessAge && ( + + + Have we got your location wrong?{' '} + { + locationControl.open() + })}> + Tap here to confirm your location with GPS. + + + + )} + + + + ) : !isOverMinAdultAge ? ( + + + If you are not yet an adult according to the laws of your + country, your parent or legal guardian must read these Terms + on your behalf. + + + ) : undefined} + + + {isNative && ( + { + props.closeDialog(() => { + // set this after close! + setDeviceGeolocation(props.geolocation) + Toast.show(_(msg`Your location has been updated.`), { + type: 'success', + }) + }) + }} + /> + )} ) : undefined}