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 {Trans} from '@lingui/react/macro'
import {
SupportCode,
useCreateSupportLink,
} from '#/lib/hooks/useCreateSupportLink'
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useIsBirthdateUpdateAllowed} from '#/state/birthdate'
import {useSession, useSessionApi} from '#/state/session'
@@ -43,6 +39,7 @@ import {logger} from '#/ageAssurance/logger'
import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state'
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
import {
canBirthdateUpdateIncreaseAccess,
createGeolocationString,
getAgeAssuranceDataFromDeviceSignals,
isLegacyBirthdateBug,
@@ -67,7 +64,6 @@ export function NoAccessScreen() {
const region = useAgeAssuranceRegionConfig()
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
const {logoutCurrentAccount} = useSessionApi()
const createSupportLink = useCreateSupportLink()
const geolocation = useGeolocation()
const {setDeviceGeolocation} = useDeviceGeolocationApi()
const locationControl = Dialog.useDialogControl()
@@ -77,8 +73,13 @@ export function NoAccessScreen() {
const isBlocked = aa.state.status === aa.Status.Blocked
const isAARegion = !!region
const hasDeclaredAge = aa.flags.hasDeclaredAge
const birthdateMightIncreaseAccess = Boolean(
region && canBirthdateUpdateIncreaseAccess({region, metadata}),
)
const canUpdateBirthday =
isBirthdateUpdateAllowed || isLegacyBirthdateBug(metadata?.birthdate || '')
(isBirthdateUpdateAllowed ||
isLegacyBirthdateBug(metadata?.birthdate || '')) &&
birthdateMightIncreaseAccess
const geolocationString = createGeolocationString(geolocation, i18n.locale)
const isUsingGPS = !!geolocation.deviceGeolocation?.countryCode && IS_NATIVE
@@ -136,20 +137,7 @@ export function NoAccessScreen() {
{orgAdmonition}
</>
) : (
<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>
)
) : null
return (
<>
@@ -180,149 +168,152 @@ export function NoAccessScreen() {
<AgeAssuranceBadge />
</View>
{hasDeclaredAge ? (
<>
{isAARegion ? (
<>
<View style={[a.gap_lg]}>
<Text style={[textStyles]}>
<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 && (
<View style={[a.gap_lg]}>
{hasDeclaredAge ? (
<>
{isAARegion ? (
<>
<View style={[a.gap_lg]}>
<Text style={[textStyles]}>
{isUsingGPS ? (
<Trans>
Based on your device's location, we think you're
in{' '}
<Text style={[textStyles, a.font_bold]}>
{geolocationString}
</Text>
.
</Trans>
) : (
<>
<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]}>
{isUsingGPS ? (
<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]}>
{geolocationString}
</Text>
. This estimate may be inaccurate if you're
using a VPN.
.
</Trans>
</>
)}
{IS_NATIVE && (
<>
{' '}
<SimpleInlineLinkText
label={_(msg`Update your location`)}
{...createStaticClick(() => {
locationControl.open()
})}
style={[textStyles]}>
) : (
<>
<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>
</SimpleInlineLinkText>
<DeviceLocationRequestDialog
control={locationControl}
onLocationAcquired={props => {
const access =
computeAgeAssuranceRegionAccess(
props.geolocation,
)
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.`,
),
)
} else {
props.closeDialog(() => {
// set this after close!
setDeviceGeolocation(props.geolocation)
Toast.show(
_(msg`Thanks! You're all set.`),
{
type: 'success',
},
</>
)}
{IS_NATIVE && (
<>
{' '}
<SimpleInlineLinkText
label={_(msg`Update your location`)}
{...createStaticClick(() => {
locationControl.open()
})}
style={[textStyles]}>
<Trans>
Tap here to update your location with GPS.
</Trans>
</SimpleInlineLinkText>
<DeviceLocationRequestDialog
control={locationControl}
onLocationAcquired={props => {
const access =
computeAgeAssuranceRegionAccess(
props.geolocation,
)
})
}
}}
/>
</>
)}
</Text>
)}
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.`,
),
)
} else {
props.closeDialog(() => {
// set this after close!
setDeviceGeolocation(props.geolocation)
Toast.show(
_(msg`Thanks! You're all set.`),
{
type: 'success',
},
)
})
}
}}
/>
</>
)}
</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>
)}
{!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}
{!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 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 />}
</>
) : (
<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 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>
)}
{orgAdmonition}
</View>
)}
</View>
<View style={[a.pt_lg, a.gap_xl, {maxWidth: 280}]}>
<Logo width={120} textFill={t.atoms.text.color} />
@@ -540,7 +531,7 @@ function AccessSection() {
<ButtonText>{verifyCta}</ButtonText>
</Button>
{allowsDeviceVerification && (
{allowsDeviceVerification ? (
<Text
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
<Trans>
@@ -558,9 +549,7 @@ function AccessSection() {
platforms.
</Trans>
</Text>
)}
{lastInitiatedAt && timeAgo && diff ? (
) : lastInitiatedAt && timeAgo && diff ? (
<Text
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}
title={i18n.date(lastInitiatedAt, {
+93 -1
View File
@@ -1,7 +1,8 @@
import {useMemo} from 'react'
import type * as AgeRange from 'expo-age-range'
import {
type AppBskyAgeassuranceDefs,
AppBskyAgeassuranceDefs,
computeAgeAssuranceRegionAccess,
getAgeAssuranceRegionConfig,
type ModerationPrefs,
} 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.
* 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.
</Trans>
)}
</Text>
)}
{IS_NATIVE && (
<Text style={[a.text_sm, a.leading_snug]}>
<Trans>
<InlineLinkText
label={l`Update your location`}
{...createStaticClick(() => {
locationControl.open()
})}>
Tap here to update your location with GPS.
</InlineLinkText>
</Trans>
{IS_NATIVE && (
<Text style={[a.text_sm, a.leading_snug]}>
{' '}
<Trans>
<InlineLinkText
label={l`Update your location`}
{...createStaticClick(() => {
locationControl.open()
})}>
Tap here to update your location with GPS.
</InlineLinkText>
</Trans>
</Text>
)}
</Text>
)}
</>