Handle birthday confusion by adding a util that determines if manually updating bday could even help

This commit is contained in:
Eric Bailey
2026-07-02 10:20:59 -05:00
parent 590281f44f
commit f099a99470
3 changed files with 250 additions and 169 deletions
+143 -154
View File
@@ -6,10 +6,6 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
import {
SupportCode,
useCreateSupportLink,
} from '#/lib/hooks/useCreateSupportLink'
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo' import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useIsBirthdateUpdateAllowed} from '#/state/birthdate' import {useIsBirthdateUpdateAllowed} from '#/state/birthdate'
import {useSession, useSessionApi} from '#/state/session' import {useSession, useSessionApi} from '#/state/session'
@@ -43,6 +39,7 @@ import {logger} from '#/ageAssurance/logger'
import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state' import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state'
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess' import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
import { import {
canBirthdateUpdateIncreaseAccess,
createGeolocationString, createGeolocationString,
getAgeAssuranceDataFromDeviceSignals, getAgeAssuranceDataFromDeviceSignals,
isLegacyBirthdateBug, isLegacyBirthdateBug,
@@ -67,7 +64,6 @@ export function NoAccessScreen() {
const region = useAgeAssuranceRegionConfig() const region = useAgeAssuranceRegionConfig()
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed() const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
const {logoutCurrentAccount} = useSessionApi() const {logoutCurrentAccount} = useSessionApi()
const createSupportLink = useCreateSupportLink()
const geolocation = useGeolocation() const geolocation = useGeolocation()
const {setDeviceGeolocation} = useDeviceGeolocationApi() const {setDeviceGeolocation} = useDeviceGeolocationApi()
const locationControl = Dialog.useDialogControl() const locationControl = Dialog.useDialogControl()
@@ -77,8 +73,13 @@ export function NoAccessScreen() {
const isBlocked = aa.state.status === aa.Status.Blocked const isBlocked = aa.state.status === aa.Status.Blocked
const isAARegion = !!region const isAARegion = !!region
const hasDeclaredAge = aa.flags.hasDeclaredAge const hasDeclaredAge = aa.flags.hasDeclaredAge
const birthdateMightIncreaseAccess = Boolean(
region && canBirthdateUpdateIncreaseAccess({region, metadata}),
)
const canUpdateBirthday = const canUpdateBirthday =
isBirthdateUpdateAllowed || isLegacyBirthdateBug(metadata?.birthdate || '') (isBirthdateUpdateAllowed ||
isLegacyBirthdateBug(metadata?.birthdate || '')) &&
birthdateMightIncreaseAccess
const geolocationString = createGeolocationString(geolocation, i18n.locale) const geolocationString = createGeolocationString(geolocation, i18n.locale)
const isUsingGPS = !!geolocation.deviceGeolocation?.countryCode && IS_NATIVE const isUsingGPS = !!geolocation.deviceGeolocation?.countryCode && IS_NATIVE
@@ -136,20 +137,7 @@ export function NoAccessScreen() {
{orgAdmonition} {orgAdmonition}
</> </>
) : ( ) : null
<Text style={[textStyles]}>
<Trans>
If you believe your birthdate is incorrect, please{' '}
<SimpleInlineLinkText
to={createSupportLink({code: SupportCode.AA_BIRTHDATE})}
label={_(msg`Click here to contact our support team`)}
style={[textStyles]}>
contact our support team
</SimpleInlineLinkText>
.
</Trans>
</Text>
)
return ( return (
<> <>
@@ -180,149 +168,152 @@ export function NoAccessScreen() {
<AgeAssuranceBadge /> <AgeAssuranceBadge />
</View> </View>
{hasDeclaredAge ? ( <View style={[a.gap_lg]}>
<> {hasDeclaredAge ? (
{isAARegion ? ( <>
<> {isAARegion ? (
<View style={[a.gap_lg]}> <>
<Text style={[textStyles]}> <View style={[a.gap_lg]}>
<Trans>Hey there!</Trans>
</Text>
<Text style={[textStyles]}>
<Trans>
You are accessing Bluesky from a region that legally
requires us to verify your age before allowing you to
access the app.
</Trans>
</Text>
{region && (
<Text style={[textStyles]}> <Text style={[textStyles]}>
{isUsingGPS ? ( <Trans>Hey there!</Trans>
<Trans> </Text>
Based on your device's location, we think you're <Text style={[textStyles]}>
in{' '} <Trans>
<Text style={[textStyles, a.font_bold]}> You are accessing Bluesky from a region that legally
{geolocationString} requires us to verify your age before allowing you
</Text> to access the app.
. </Trans>
</Trans> </Text>
) : (
<> {region && (
<Text style={[textStyles]}>
{isUsingGPS ? (
<Trans> <Trans>
Based on your network, we think you're in{' '} Based on your device's location, we think you're
in{' '}
<Text style={[textStyles, a.font_bold]}> <Text style={[textStyles, a.font_bold]}>
{geolocationString} {geolocationString}
</Text> </Text>
. This estimate may be inaccurate if you're .
using a VPN.
</Trans> </Trans>
</> ) : (
)} <>
{IS_NATIVE && (
<>
{' '}
<SimpleInlineLinkText
label={_(msg`Update your location`)}
{...createStaticClick(() => {
locationControl.open()
})}
style={[textStyles]}>
<Trans> <Trans>
Tap here to update your location via GPS. Based on your network, we think you're in{' '}
<Text style={[textStyles, a.font_bold]}>
{geolocationString}
</Text>
. This estimate may be inaccurate if you're
using a VPN.
</Trans> </Trans>
</SimpleInlineLinkText> </>
<DeviceLocationRequestDialog )}
control={locationControl} {IS_NATIVE && (
onLocationAcquired={props => { <>
const access = {' '}
computeAgeAssuranceRegionAccess( <SimpleInlineLinkText
props.geolocation, label={_(msg`Update your location`)}
) {...createStaticClick(() => {
if (access !== aa.Access.Full) { locationControl.open()
props.disableDialogAction() })}
props.setDialogError( style={[textStyles]}>
_( <Trans>
msg`We're sorry, but based on your device's location, you are currently located in a region that requires age assurance.`, Tap here to update your location with GPS.
), </Trans>
) </SimpleInlineLinkText>
} else { <DeviceLocationRequestDialog
props.closeDialog(() => { control={locationControl}
// set this after close! onLocationAcquired={props => {
setDeviceGeolocation(props.geolocation) const access =
Toast.show( computeAgeAssuranceRegionAccess(
_(msg`Thanks! You're all set.`), props.geolocation,
{
type: 'success',
},
) )
}) if (access !== aa.Access.Full) {
} props.disableDialogAction()
}} props.setDialogError(
/> _(
</> msg`We're sorry, but based on your device's location, you are currently located in a region that requires age assurance.`,
)} ),
</Text> )
)} } else {
props.closeDialog(() => {
// set this after close!
setDeviceGeolocation(props.geolocation)
Toast.show(
_(msg`Thanks! You're all set.`),
{
type: 'success',
},
)
})
}
}}
/>
</>
)}
</Text>
)}
{!aa.flags.isOverRegionMinAccessAge && ( {!aa.flags.isOverRegionMinAccessAge && (
<Text style={[textStyles]}> <Text style={[textStyles]}>
<Trans> <Trans>
Unfortunately, your declared age indicates that you Unfortunately, your declared age indicates that
are not old enough to access Bluesky in your region. you are not old enough to access Bluesky in your
</Trans> region.
</Text> </Trans>
)} </Text>
)}
{!isBlocked && birthdateUpdateText} {!isBlocked && birthdateUpdateText}
</View>
{aa.flags.isOverRegionMinAccessAge && <AccessSection />}
</>
) : (
<View style={[a.gap_lg]}>
<Text style={[textStyles]}>
<Trans>
Unfortunately, the birthdate you have saved to your
profile makes you too young to access Bluesky.
</Trans>
</Text>
{birthdateUpdateText}
</View> </View>
)}
</>
) : (
<View style={[a.gap_lg]}>
<Text style={[textStyles]}>
<Trans>Hi there!</Trans>
</Text>
<Text style={[textStyles]}>
<Trans>
In order to provide an age-appropriate experience, we need
to know your birthdate. This is a one-time thing, and your
data will be kept private.
</Trans>
</Text>
<Text style={[textStyles]}>
<Trans>
Set your birthdate below and we'll get you back to posting
and exploring in no time!
</Trans>
</Text>
<Button
color="primary"
size="large"
label={_(msg`Click here to update your birthdate`)}
onPress={() => birthdateControl.open()}>
<ButtonText>
<Trans>Add your birthdate</Trans>
</ButtonText>
</Button>
{aa.flags.isOverRegionMinAccessAge && <AccessSection />} {orgAdmonition}
</> </View>
) : ( )}
<View style={[a.gap_lg]}> </View>
<Text style={[textStyles]}>
<Trans>
Unfortunately, the birthdate you have saved to your
profile makes you too young to access Bluesky.
</Trans>
</Text>
{birthdateUpdateText}
</View>
)}
</>
) : (
<View style={[a.gap_lg]}>
<Text style={[textStyles]}>
<Trans>Hi there!</Trans>
</Text>
<Text style={[textStyles]}>
<Trans>
In order to provide an age-appropriate experience, we need
to know your birthdate. This is a one-time thing, and your
data will be kept private.
</Trans>
</Text>
<Text style={[textStyles]}>
<Trans>
Set your birthdate below and we'll get you back to posting
and exploring in no time!
</Trans>
</Text>
<Button
color="primary"
size="large"
label={_(msg`Click here to update your birthdate`)}
onPress={() => birthdateControl.open()}>
<ButtonText>
<Trans>Add your birthdate</Trans>
</ButtonText>
</Button>
{orgAdmonition}
</View>
)}
<View style={[a.pt_lg, a.gap_xl, {maxWidth: 280}]}> <View style={[a.pt_lg, a.gap_xl, {maxWidth: 280}]}>
<Logo width={120} textFill={t.atoms.text.color} /> <Logo width={120} textFill={t.atoms.text.color} />
@@ -540,7 +531,7 @@ function AccessSection() {
<ButtonText>{verifyCta}</ButtonText> <ButtonText>{verifyCta}</ButtonText>
</Button> </Button>
{allowsDeviceVerification && ( {allowsDeviceVerification ? (
<Text <Text
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}> style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
<Trans> <Trans>
@@ -558,9 +549,7 @@ function AccessSection() {
platforms. platforms.
</Trans> </Trans>
</Text> </Text>
)} ) : lastInitiatedAt && timeAgo && diff ? (
{lastInitiatedAt && timeAgo && diff ? (
<Text <Text
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]} style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}
title={i18n.date(lastInitiatedAt, { title={i18n.date(lastInitiatedAt, {
+93 -1
View File
@@ -1,7 +1,8 @@
import {useMemo} from 'react' import {useMemo} from 'react'
import type * as AgeRange from 'expo-age-range' import type * as AgeRange from 'expo-age-range'
import { import {
type AppBskyAgeassuranceDefs, AppBskyAgeassuranceDefs,
computeAgeAssuranceRegionAccess,
getAgeAssuranceRegionConfig, getAgeAssuranceRegionConfig,
type ModerationPrefs, type ModerationPrefs,
} from '@atproto/api' } from '@atproto/api'
@@ -109,6 +110,97 @@ export function getAgeAssuranceDataFromDeviceSignals(
} }
} }
/**
* Ranks access levels from most to least restrictive so we can compare two
* outcomes. Higher number = more access.
*/
const ACCESS_RANK: Record<string, number> = {
[AgeAssuranceAccess.None]: 0,
[AgeAssuranceAccess.Safe]: 1,
[AgeAssuranceAccess.Full]: 2,
// `unknown` isn't a real granted level; treat it as the floor.
[AgeAssuranceAccess.Unknown]: -1,
}
/**
* Whether correcting the user's declared age (i.e. updating their birthdate)
* could meaningfully improve their standing in the current region.
*
* There are two ways a birthdate update can help:
*
* 1. Raising the rule-engine access level. Some regions grant `safe`/`full` off
* a sufficient *declared* age, so a user whose birthdate is wrong (too young)
* can unlock more by correcting it. Other regions gate higher access purely
* on an *assured* age (or account date), where a declared age changes
* nothing.
* 2. Crossing the region's `minAccessAge`. Below it the user is hard-blocked
* with no verify path (see `isOverRegionMinAccessAge` gating in the
* NoAccessScreen); crossing it unlocks the verify flow, which is itself a
* path to more access even when the rule-engine level would still be `none`.
*
* We answer by simulating the real rule engine: hold `accountCreatedAt` and
* `assuredAge` fixed and re-run access for a set of candidate declared ages
* drawn from the region's declared-age rule thresholds and its `minAccessAge`.
* If any candidate yields strictly more access, or crosses `minAccessAge` when
* the current declared age doesn't, a birthdate update could help. Simulating
* rather than statically inspecting rules means first-match precedence (e.g. an
* assured/account rule pre-empting a declared rule) is handled correctly for
* free.
*/
export function canBirthdateUpdateIncreaseAccess({
region,
metadata,
}: {
region: AppBskyAgeassuranceDefs.ConfigRegion
metadata?: AgeAssuranceMetadata
}): boolean {
const baseline = computeAgeAssuranceRegionAccess(region, {
accountCreatedAt: metadata?.accountCreatedAt,
declaredAge: metadata?.declaredAge,
assuredAge: metadata?.assuredAge,
})
const baselineRank =
ACCESS_RANK[baseline?.access ?? AgeAssuranceAccess.Unknown]
const baselineOverMin =
metadata?.declaredAge !== undefined &&
metadata.declaredAge >= region.minAccessAge
/*
* Candidate declared ages to probe: each declared-age rule's threshold and
* the region's `minAccessAge`, plus one below each (to cover
* `IfDeclaredUnderAge` and the min-age boundary). Anything a birthdate edit
* could achieve is captured by crossing one of these thresholds, so we don't
* need to sweep every integer.
*/
const thresholds = new Set<number>([region.minAccessAge])
for (const rule of region.rules) {
if (
AppBskyAgeassuranceDefs.isConfigRegionRuleIfDeclaredOverAge(rule) ||
AppBskyAgeassuranceDefs.isConfigRegionRuleIfDeclaredUnderAge(rule)
) {
thresholds.add(rule.age)
}
}
const candidates = new Set<number>()
for (const threshold of thresholds) {
candidates.add(threshold)
candidates.add(Math.max(0, threshold - 1))
}
for (const declaredAge of candidates) {
const result = computeAgeAssuranceRegionAccess(region, {
accountCreatedAt: metadata?.accountCreatedAt,
declaredAge,
assuredAge: metadata?.assuredAge,
})
const rank = ACCESS_RANK[result?.access ?? AgeAssuranceAccess.Unknown]
const overMin = declaredAge >= region.minAccessAge
if (rank > baselineRank || (overMin && !baselineOverMin)) return true
}
return false
}
/** /**
* Hook to get the age assurance region config based on current geolocation. * Hook to get the age assurance region config based on current geolocation.
* Does not fall-back to our app defaults. If no config is found, returns * Does not fall-back to our app defaults. If no config is found, returns
@@ -235,20 +235,20 @@ function RegionNotice() {
estimate may be inaccurate if you're using a VPN. estimate may be inaccurate if you're using a VPN.
</Trans> </Trans>
)} )}
</Text> {IS_NATIVE && (
)} <Text style={[a.text_sm, a.leading_snug]}>
{' '}
{IS_NATIVE && ( <Trans>
<Text style={[a.text_sm, a.leading_snug]}> <InlineLinkText
<Trans> label={l`Update your location`}
<InlineLinkText {...createStaticClick(() => {
label={l`Update your location`} locationControl.open()
{...createStaticClick(() => { })}>
locationControl.open() Tap here to update your location with GPS.
})}> </InlineLinkText>
Tap here to update your location with GPS. </Trans>
</InlineLinkText> </Text>
</Trans> )}
</Text> </Text>
)} )}
</> </>