Merge remote-tracking branch 'origin/main' into app-1750

* origin/main:
  [AAv2] Improve minimum age handling (#9650)
  Handle AA config failure (#9660)
  Fix full-screen back gesture over PagerView on iOS 26 (#9659)
  Bump version to v1.114 (#9661)
  Unify build concurrency to one build per platform (#9655)
This commit is contained in:
Eric Bailey
2026-01-08 15:38:17 -06:00
25 changed files with 492 additions and 318 deletions
+10 -1
View File
@@ -177,10 +177,19 @@ export function NoAccessScreen() {
</Trans>
</Text>
{!aa.flags.isOverRegionMinAccessAge && (
<Text style={[textStyles]}>
<Trans>
Unfortunately, your declared age indicates that you
are not old enough to access Bluesky in your region.
</Trans>
</Text>
)}
{!isBlocked && birthdateUpdateText}
</View>
<AccessSection />
{aa.flags.isOverRegionMinAccessAge && <AccessSection />}
</>
) : (
<View style={[a.gap_lg]}>
+13
View File
@@ -136,6 +136,15 @@ export async function prefetchConfig() {
}
})
}
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(
{
@@ -146,6 +155,10 @@ export function useConfigQuery() {
* @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,
+21 -7
View File
@@ -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',
},
],
},
],
}
+23 -5
View File
@@ -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}) {
<AgeAssuranceStateContext.Provider
value={useMemo(() => {
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}
</AgeAssuranceStateContext.Provider>
)
+9 -2
View File
@@ -30,12 +30,19 @@ export function useAgeAssuranceState(): AgeAssuranceState {
access: AgeAssuranceAccess.Safe,
}
// should never happen, but need to guard
/**
* This can happen if the prefetch fails (such as due to network issues).
* The query handler will try it again, but if it continues to fail, of
* course we won't have config.
*
* In this case, fail open to avoid blocking users.
*/
if (!config) {
logger.warn('useAgeAssuranceState: missing config')
return {
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.Unknown,
access: AgeAssuranceAccess.Safe,
error: 'config',
}
}
+1
View File
@@ -18,6 +18,7 @@ export type AgeAssuranceState = {
lastInitiatedAt?: string
status: AgeAssuranceStatus
access: AgeAssuranceAccess
error?: 'config' // maybe other specific cases in the future
}
export function parseStatusFromString(raw: string) {
+30 -26
View File
@@ -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) {
@@ -8,6 +8,7 @@ import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
import {AgeAssuranceConfigUnavailableError} from '#/components/ageAssurance/AgeAssuranceErrors'
import {
AgeAssuranceInitDialog,
useDialogControl,
@@ -27,6 +28,13 @@ import {useDeviceGeolocationApi} from '#/geolocation'
export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) {
const aa = useAgeAssurance()
if (aa.state.access === aa.Access.Full) return null
if (aa.state.error === 'config') {
return (
<View style={style}>
<AgeAssuranceConfigUnavailableError />
</View>
)
}
return <Inner style={style} />
}
@@ -3,6 +3,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf'
import {AgeAssuranceConfigUnavailableError} from '#/components/ageAssurance/AgeAssuranceErrors'
import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog'
import type * as Dialog from '#/components/Dialog'
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
@@ -19,6 +20,9 @@ export function AgeAssuranceAdmonition({
const aa = useAgeAssurance()
if (aa.state.access === aa.Access.Full) return null
if (aa.state.error === 'config') {
return <AgeAssuranceConfigUnavailableError style={style} />
}
return (
<Inner style={style} control={control}>
@@ -23,6 +23,7 @@ export function useInternalState() {
const visible = useMemo(() => {
if (aa.state.access === aa.Access.Full) return false
if (aa.state.lastInitiatedAt) return false
if (aa.state.error === 'config') return false
if (hidden) return false
if (nux && nux.completed) return false
return true
@@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {atoms as a, type ViewStyleProp} from '#/alf'
import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
import {AgeAssuranceConfigUnavailableError} from '#/components/ageAssurance/AgeAssuranceErrors'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
@@ -26,33 +27,37 @@ export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) {
return (
<View style={style}>
<View>
<AgeAssuranceAdmonition>{copy.notice}</AgeAssuranceAdmonition>
{aa.state.error === 'config' ? (
<AgeAssuranceConfigUnavailableError />
) : (
<View>
<AgeAssuranceAdmonition>{copy.notice}</AgeAssuranceAdmonition>
<Button
label={_(msg`Don't show again`)}
size="tiny"
variant="solid"
color="secondary_inverted"
shape="round"
onPress={() => {
save({
id: Nux.AgeAssuranceDismissibleNotice,
completed: true,
data: undefined,
})
logger.metric('ageAssurance:dismissSettingsNotice', {})
}}
style={[
a.absolute,
{
top: 12,
right: 12,
},
]}>
<ButtonIcon icon={X} />
</Button>
</View>
<Button
label={_(msg`Don't show again`)}
size="tiny"
variant="solid"
color="secondary_inverted"
shape="round"
onPress={() => {
save({
id: Nux.AgeAssuranceDismissibleNotice,
completed: true,
data: undefined,
})
logger.metric('ageAssurance:dismissSettingsNotice', {})
}}
style={[
a.absolute,
{
top: 12,
right: 12,
},
]}>
<ButtonIcon icon={X} />
</Button>
</View>
)}
</View>
)
}
@@ -0,0 +1,37 @@
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {type ViewStyleProp} from '#/alf'
import * as Admonition from '#/components/Admonition'
import {ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
import {refetchConfig} from '#/ageAssurance/data'
export function AgeAssuranceConfigUnavailableError(props: ViewStyleProp) {
const {_} = useLingui()
return (
<Admonition.Outer type="error" style={props.style}>
<Admonition.Row>
<Admonition.Icon />
<Admonition.Content>
<Admonition.Text>
<Trans>
We were unable to load the age assurance configuration for your
region, probably due to a network error. Some content and features
may be unavailable temporarily. Please try again later.
</Trans>
</Admonition.Text>
</Admonition.Content>
<Admonition.Button
color="negative_subtle"
label={_(msg`Retry`)}
onPress={() => refetchConfig().catch(() => {})}>
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
<ButtonIcon icon={RetryIcon} />
</Admonition.Button>
</Admonition.Row>
</Admonition.Outer>
)
}
@@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react'
import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
import {AgeAssuranceConfigUnavailableError} from '#/components/ageAssurance/AgeAssuranceErrors'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {ButtonIcon, ButtonText} from '#/components/Button'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
@@ -44,6 +45,12 @@ export function AgeRestrictedScreen({
</Layout.Header.Outer>
<Layout.Content>
<View style={[a.p_lg]}>
{aa.state.error === 'config' && (
<View style={[a.pb_lg]}>
<AgeAssuranceConfigUnavailableError />
</View>
)}
<View style={[a.align_start, a.pb_lg]}>
<AgeAssuranceBadge />
</View>
+12 -6
View File
@@ -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<T>(data: T) {
await new Promise(y => setTimeout(y, 500)) // simulate network
return data
+7 -1
View File
@@ -46,7 +46,8 @@ const useForegroundPermissions = createPermissionHook({
})
export async function getDeviceGeolocation(): Promise<Geolocation> {
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
}
+4 -1
View File
@@ -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'
+3 -28
View File
@@ -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 (
<View style={[a.gap_sm]}>
{els ? (
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
{els}
</Text>
) : null}
{under13 ? (
<Admonition type="error">
<Trans>
You must be 13 years of age or older to create an account.
</Trans>
</Admonition>
) : needsGuardian ? (
<Admonition type="warning">
<Trans>
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.
</Trans>
</Admonition>
) : undefined}
</View>
)
return els ? (
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>{els}</Text>
) : null
}
function validWebLink(url?: string): string | undefined {
+100 -8
View File
@@ -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<TextInput>(null)
const birthdateInputRef = useRef<DateFieldRef>(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<boolean>(false)
const tldtsRef = React.useRef<typeof tldts>(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()}
/>
</View>
<Policies
serviceDescription={state.serviceDescription}
needsGuardian={!is18(state.dateOfBirth)}
under13={!is13(state.dateOfBirth)}
/>
<View style={[a.gap_sm]}>
<Policies serviceDescription={state.serviceDescription} />
{!isOverRegionMinAccessAge || !isOverAppMinAccessAge ? (
<Admonition.Outer type="error">
<Admonition.Row>
<Admonition.Icon />
<Admonition.Content>
<Admonition.Text>
{!isOverAppMinAccessAge ? (
<Trans>
You must be {MIN_ACCESS_AGE} years of age or older
to create an account.
</Trans>
) : (
<Trans>
You must be {aaRegionConfig.minAccessAge} years of
age or older to create an account in your region.
</Trans>
)}
</Admonition.Text>
{isNative &&
!isDeviceGeolocationGranted &&
isOverAppMinAccessAge && (
<Admonition.Text>
<Trans>
Have we got your location wrong?{' '}
<SimpleInlineLinkText
label={_(
msg`Tap here to confirm your location with GPS.`,
)}
{...createStaticClick(() => {
locationControl.open()
})}>
Tap here to confirm your location with GPS.
</SimpleInlineLinkText>
</Trans>
</Admonition.Text>
)}
</Admonition.Content>
</Admonition.Row>
</Admonition.Outer>
) : !isOverMinAdultAge ? (
<Admonition.Admonition type="warning">
<Trans>
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.
</Trans>
</Admonition.Admonition>
) : undefined}
</View>
{isNative && (
<DeviceLocationRequestDialog
control={locationControl}
onLocationAcquired={props => {
props.closeDialog(() => {
// set this after close!
setDeviceGeolocation(props.geolocation)
Toast.show(_(msg`Your location has been updated.`), {
type: 'success',
})
})
}}
/>
)}
</>
) : undefined}
</View>
<BackNextButtons
hideNext={!is13(state.dateOfBirth)}
hideNext={!isOverRegionMinAccessAge}
showRetry={isServerError}
isLoading={state.isLoading}
onBackPress={onPressBack}