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
+18 -29
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,6 +168,7 @@ export function NoAccessScreen() {
<AgeAssuranceBadge /> <AgeAssuranceBadge />
</View> </View>
<View style={[a.gap_lg]}>
{hasDeclaredAge ? ( {hasDeclaredAge ? (
<> <>
{isAARegion ? ( {isAARegion ? (
@@ -191,8 +180,8 @@ export function NoAccessScreen() {
<Text style={[textStyles]}> <Text style={[textStyles]}>
<Trans> <Trans>
You are accessing Bluesky from a region that legally You are accessing Bluesky from a region that legally
requires us to verify your age before allowing you to requires us to verify your age before allowing you
access the app. to access the app.
</Trans> </Trans>
</Text> </Text>
@@ -229,7 +218,7 @@ export function NoAccessScreen() {
})} })}
style={[textStyles]}> style={[textStyles]}>
<Trans> <Trans>
Tap here to update your location via GPS. Tap here to update your location with GPS.
</Trans> </Trans>
</SimpleInlineLinkText> </SimpleInlineLinkText>
<DeviceLocationRequestDialog <DeviceLocationRequestDialog
@@ -268,8 +257,9 @@ export function NoAccessScreen() {
{!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
region.
</Trans> </Trans>
</Text> </Text>
)} )}
@@ -323,6 +313,7 @@ export function NoAccessScreen() {
{orgAdmonition} {orgAdmonition}
</View> </View>
)} )}
</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,11 +235,9 @@ 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 && ( {IS_NATIVE && (
<Text style={[a.text_sm, a.leading_snug]}> <Text style={[a.text_sm, a.leading_snug]}>
{' '}
<Trans> <Trans>
<InlineLinkText <InlineLinkText
label={l`Update your location`} label={l`Update your location`}
@@ -251,6 +249,8 @@ function RegionNotice() {
</Trans> </Trans>
</Text> </Text>
)} )}
</Text>
)}
</> </>
) )
} }