Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client

[AAv2] Improve minimum age handling (#9650)

* Improve miminum age handling

* Update api sdk

* Fix bad import

authored by

Eric Bailey and committed by
GitHub
b3f775d1 a1857d62

+253 -98
+1 -1
package.json
··· 73 73 "icons:optimize": "svgo -f ./assets/icons" 74 74 }, 75 75 "dependencies": { 76 - "@atproto/api": "^0.18.8", 76 + "@atproto/api": "^0.18.11", 77 77 "@bitdrift/react-native": "^0.6.8", 78 78 "@braintree/sanitize-url": "^6.0.2", 79 79 "@bsky.app/alf": "^0.1.6",
+10 -1
src/ageAssurance/components/NoAccessScreen.tsx
··· 177 177 </Trans> 178 178 </Text> 179 179 180 + {!aa.flags.isOverRegionMinAccessAge && ( 181 + <Text style={[textStyles]}> 182 + <Trans> 183 + Unfortunately, your declared age indicates that you 184 + are not old enough to access Bluesky in your region. 185 + </Trans> 186 + </Text> 187 + )} 188 + 180 189 {!isBlocked && birthdateUpdateText} 181 190 </View> 182 191 183 - <AccessSection /> 192 + {aa.flags.isOverRegionMinAccessAge && <AccessSection />} 184 193 </> 185 194 ) : ( 186 195 <View style={[a.gap_lg]}>
+21 -7
src/ageAssurance/debug.ts
··· 12 12 13 13 export const geolocation: Geolocation | undefined = enabled 14 14 ? { 15 - countryCode: 'AA', 15 + countryCode: 'BB', 16 16 regionCode: undefined, 17 17 } 18 18 : undefined 19 19 20 - export const deviceGeolocation: Geolocation | undefined = enabled 21 - ? { 22 - countryCode: 'AA', 23 - regionCode: undefined, 24 - } 25 - : undefined 20 + const deviceGeolocationEnabled = false 21 + export const deviceGeolocation: Geolocation | undefined = 22 + enabled && deviceGeolocationEnabled 23 + ? { 24 + countryCode: 'AA', 25 + regionCode: undefined, 26 + } 27 + : undefined 26 28 27 29 export const config: AppBskyAgeassuranceDefs.Config = { 28 30 regions: [ 29 31 { 30 32 countryCode: 'AA', 31 33 regionCode: undefined, 34 + minAccessAge: 13, 32 35 rules: [ 33 36 { 34 37 $type: ids.Default, 35 38 access: 'full', 39 + }, 40 + ], 41 + }, 42 + { 43 + countryCode: 'BB', 44 + regionCode: undefined, 45 + minAccessAge: 16, 46 + rules: [ 47 + { 48 + $type: ids.Default, 49 + access: 'none', 36 50 }, 37 51 ], 38 52 },
+23 -5
src/ageAssurance/index.tsx
··· 14 14 type AgeAssuranceState, 15 15 AgeAssuranceStatus, 16 16 } from '#/ageAssurance/types' 17 - import {isUserUnderAdultAge} from '#/ageAssurance/util' 17 + import { 18 + isUnderAge, 19 + MIN_ACCESS_AGE, 20 + useAgeAssuranceRegionConfigWithFallback, 21 + } from '#/ageAssurance/util' 18 22 19 23 export { 20 24 prefetchConfig as prefetchAgeAssuranceConfig, ··· 24 28 usePatchServerState as usePatchAgeAssuranceServerState, 25 29 } from '#/ageAssurance/data' 26 30 export {logger} from '#/ageAssurance/logger' 31 + export {MIN_ACCESS_AGE} from '#/ageAssurance/util' 27 32 28 33 const AgeAssuranceStateContext = createContext<{ 29 34 Access: typeof AgeAssuranceAccess ··· 32 37 flags: { 33 38 adultContentDisabled: boolean 34 39 chatDisabled: boolean 40 + isOverRegionMinAccessAge: boolean 41 + isOverAppMinAccessAge: boolean 35 42 } 36 43 }>({ 37 44 Access: AgeAssuranceAccess, ··· 44 51 flags: { 45 52 adultContentDisabled: false, 46 53 chatDisabled: false, 54 + isOverRegionMinAccessAge: false, 55 + isOverAppMinAccessAge: false, 47 56 }, 48 57 }) 49 58 ··· 69 78 function InnerProvider({children}: {children: React.ReactNode}) { 70 79 const state = useAgeAssuranceState() 71 80 const {data} = useAgeAssuranceDataContext() 81 + const config = useAgeAssuranceRegionConfigWithFallback() 72 82 const getAndRegisterPushToken = useGetAndRegisterPushToken() 73 83 74 84 const handleAccessUpdate = useCallback( ··· 89 99 <AgeAssuranceStateContext.Provider 90 100 value={useMemo(() => { 91 101 const chatDisabled = state.access !== AgeAssuranceAccess.Full 92 - const isUnderage = data?.birthdate 93 - ? isUserUnderAdultAge(data.birthdate) 102 + const isUnderAdultAge = data?.birthdate 103 + ? isUnderAge(data.birthdate, 18) 94 104 : true 105 + const isOverRegionMinAccessAge = data?.birthdate 106 + ? !isUnderAge(data.birthdate, config.minAccessAge) 107 + : false 108 + const isOverAppMinAccessAge = data?.birthdate 109 + ? !isUnderAge(data.birthdate, MIN_ACCESS_AGE) 110 + : false 95 111 const adultContentDisabled = 96 - state.access !== AgeAssuranceAccess.Full || isUnderage 112 + state.access !== AgeAssuranceAccess.Full || isUnderAdultAge 97 113 return { 98 114 Access: AgeAssuranceAccess, 99 115 Status: AgeAssuranceStatus, ··· 101 117 flags: { 102 118 adultContentDisabled, 103 119 chatDisabled, 120 + isOverRegionMinAccessAge, 121 + isOverAppMinAccessAge, 104 122 }, 105 123 } 106 - }, [state, data])}> 124 + }, [state, data, config])}> 107 125 {children} 108 126 </AgeAssuranceStateContext.Provider> 109 127 )
+30 -26
src/ageAssurance/util.ts
··· 12 12 import {AgeAssuranceAccess} from '#/ageAssurance/types' 13 13 import {type Geolocation, useGeolocation} from '#/geolocation' 14 14 15 - const DEFAULT_MIN_AGE = 13 15 + export const MIN_ACCESS_AGE = 13 16 + const FALLBACK_REGION_CONFIG: AppBskyAgeassuranceDefs.ConfigRegion = { 17 + countryCode: '*', 18 + regionCode: undefined, 19 + minAccessAge: MIN_ACCESS_AGE, 20 + rules: [ 21 + { 22 + $type: ids.IfDeclaredOverAge, 23 + age: MIN_ACCESS_AGE, 24 + access: AgeAssuranceAccess.Full, 25 + }, 26 + { 27 + $type: ids.Default, 28 + access: AgeAssuranceAccess.None, 29 + }, 30 + ], 31 + } 16 32 17 33 /** 18 34 * Get age assurance region config based on geolocation, with fallback to ··· 30 46 regionCode: geolocation.regionCode, 31 47 }) 32 48 33 - return ( 34 - region || { 35 - countryCode: '*', 36 - regionCode: undefined, 37 - rules: [ 38 - { 39 - $type: ids.IfDeclaredOverAge, 40 - age: DEFAULT_MIN_AGE, 41 - access: AgeAssuranceAccess.Full, 42 - }, 43 - { 44 - $type: ids.Default, 45 - access: AgeAssuranceAccess.None, 46 - }, 47 - ], 48 - } 49 - ) 49 + return region || FALLBACK_REGION_CONFIG 50 50 } 51 51 52 52 /** ··· 68 68 } 69 69 70 70 /** 71 + * Hook to get the age assurance region config based on current geolocation. 72 + * Falls back to our app defaults if no region config is found. 73 + */ 74 + export function useAgeAssuranceRegionConfigWithFallback() { 75 + return useAgeAssuranceRegionConfig() || FALLBACK_REGION_CONFIG 76 + } 77 + 78 + /** 71 79 * Some users may have erroneously set their birth date to the current date 72 80 * if one wasn't set on their account. We previously didn't do validation on 73 81 * the bday dialog, and it defaulted to the current date. This bug _has_ been ··· 78 86 } 79 87 80 88 /** 81 - * Returns whether the user is under the minimum age required to use the app. 82 - * This applies to all regions. 89 + * Returns whether the date (converted to an age as a whole integer) is under 90 + * the provided minimum age. 83 91 */ 84 - export function isUserUnderMinimumAge(birthDate: string) { 85 - return getAge(new Date(birthDate)) < DEFAULT_MIN_AGE 86 - } 87 - 88 - export function isUserUnderAdultAge(birthDate: string) { 89 - return getAge(new Date(birthDate)) < 18 92 + export function isUnderAge(birthDate: string, age: number) { 93 + return getAge(new Date(birthDate)) < age 90 94 } 91 95 92 96 export function getBirthdateStringFromAge(age: number) {
+12 -6
src/geolocation/debug.ts
··· 5 5 const localEnabled = false 6 6 export const enabled = IS_DEV && (localEnabled || aaDebug.geolocation) 7 7 export const geolocation: Geolocation = aaDebug.geolocation ?? { 8 - countryCode: 'AU', 9 - regionCode: undefined, 8 + countryCode: 'US', 9 + regionCode: 'TX', 10 10 } 11 - export const deviceGeolocation: Geolocation = aaDebug.deviceGeolocation ?? { 12 - countryCode: 'AU', 13 - regionCode: undefined, 14 - } 11 + 12 + const deviceLocalEnabled = false 13 + export const deviceGeolocation: Geolocation | undefined = 14 + aaDebug.deviceGeolocation || 15 + (deviceLocalEnabled 16 + ? { 17 + countryCode: 'US', 18 + regionCode: 'TX', 19 + } 20 + : undefined) 15 21 16 22 export async function resolve<T>(data: T) { 17 23 await new Promise(y => setTimeout(y, 500)) // simulate network
+7 -1
src/geolocation/device.ts
··· 46 46 }) 47 47 48 48 export async function getDeviceGeolocation(): Promise<Geolocation> { 49 - if (debug.enabled) return debug.resolve(debug.deviceGeolocation) 49 + if (debug.enabled && debug.deviceGeolocation) 50 + return debug.resolve(debug.deviceGeolocation) 50 51 51 52 try { 52 53 const geocode = await Location.getCurrentPositionAsync() ··· 142 143 }) 143 144 }, [status, sync]) 144 145 } 146 + 147 + export function useIsDeviceGeolocationGranted() { 148 + const [status] = useForegroundPermissions() 149 + return status?.granted === true 150 + }
+4 -1
src/geolocation/index.tsx
··· 12 12 import {mergeGeolocations} from '#/geolocation/util' 13 13 import {device, useStorage} from '#/storage' 14 14 15 - export {useRequestDeviceGeolocation} from '#/geolocation/device' 15 + export { 16 + useIsDeviceGeolocationGranted, 17 + useRequestDeviceGeolocation, 18 + } from '#/geolocation/device' 16 19 export {resolve} from '#/geolocation/service' 17 20 export * from '#/geolocation/types' 18 21
+3 -28
src/screens/Signup/StepInfo/Policies.tsx
··· 11 11 12 12 export const Policies = ({ 13 13 serviceDescription, 14 - needsGuardian, 15 - under13, 16 14 }: { 17 15 serviceDescription: ComAtprotoServerDescribeServer.OutputSchema 18 - needsGuardian: boolean 19 - under13: boolean 20 16 }) => { 21 17 const t = useTheme() 22 18 const {_} = useLingui() ··· 91 87 return null 92 88 } 93 89 94 - return ( 95 - <View style={[a.gap_sm]}> 96 - {els ? ( 97 - <Text style={[a.leading_snug, t.atoms.text_contrast_medium]}> 98 - {els} 99 - </Text> 100 - ) : null} 101 - 102 - {under13 ? ( 103 - <Admonition type="error"> 104 - <Trans> 105 - You must be 13 years of age or older to create an account. 106 - </Trans> 107 - </Admonition> 108 - ) : needsGuardian ? ( 109 - <Admonition type="warning"> 110 - <Trans> 111 - If you are not yet an adult according to the laws of your country, 112 - your parent or legal guardian must read these Terms on your behalf. 113 - </Trans> 114 - </Admonition> 115 - ) : undefined} 116 - </View> 117 - ) 90 + return els ? ( 91 + <Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>{els}</Text> 92 + ) : null 118 93 } 119 94 120 95 function validWebLink(url?: string): string | undefined {
+100 -8
src/screens/Signup/StepInfo/index.tsx
··· 7 7 8 8 import {isEmailMaybeInvalid} from '#/lib/strings/email' 9 9 import {logger} from '#/logger' 10 - import {is13, is18, useSignupContext} from '#/screens/Signup/state' 10 + import {isNative} from '#/platform/detection' 11 + import {useSignupContext} from '#/screens/Signup/state' 11 12 import {Policies} from '#/screens/Signup/StepInfo/Policies' 12 13 import {atoms as a, native} from '#/alf' 14 + import * as Admonition from '#/components/Admonition' 15 + import * as Dialog from '#/components/Dialog' 16 + import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog' 13 17 import * as DateField from '#/components/forms/DateField' 14 18 import {type DateFieldRef} from '#/components/forms/DateField/types' 15 19 import {FormError} from '#/components/forms/FormError' ··· 18 22 import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope' 19 23 import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' 20 24 import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' 25 + import {createStaticClick, SimpleInlineLinkText} from '#/components/Link' 21 26 import {Loader} from '#/components/Loader' 22 27 import {usePreemptivelyCompleteActivePolicyUpdate} from '#/components/PolicyUpdateOverlay/usePreemptivelyCompleteActivePolicyUpdate' 28 + import * as Toast from '#/components/Toast' 29 + import { 30 + isUnderAge, 31 + MIN_ACCESS_AGE, 32 + useAgeAssuranceRegionConfigWithFallback, 33 + } from '#/ageAssurance/util' 34 + import { 35 + useDeviceGeolocationApi, 36 + useIsDeviceGeolocationGranted, 37 + } from '#/geolocation' 23 38 import {BackNextButtons} from '../BackNextButtons' 24 39 25 40 function sanitizeDate(date: Date): Date { ··· 57 72 const passwordInputRef = useRef<TextInput>(null) 58 73 const birthdateInputRef = useRef<DateFieldRef>(null) 59 74 75 + const aaRegionConfig = useAgeAssuranceRegionConfigWithFallback() 76 + const {setDeviceGeolocation} = useDeviceGeolocationApi() 77 + const locationControl = Dialog.useDialogControl() 78 + const isOverRegionMinAccessAge = state.dateOfBirth 79 + ? !isUnderAge(state.dateOfBirth.toISOString(), aaRegionConfig.minAccessAge) 80 + : true 81 + const isOverAppMinAccessAge = state.dateOfBirth 82 + ? !isUnderAge(state.dateOfBirth.toISOString(), MIN_ACCESS_AGE) 83 + : true 84 + const isOverMinAdultAge = state.dateOfBirth 85 + ? !isUnderAge(state.dateOfBirth.toISOString(), 18) 86 + : true 87 + const isDeviceGeolocationGranted = useIsDeviceGeolocationGranted() 88 + 60 89 const [hasWarnedEmail, setHasWarnedEmail] = React.useState<boolean>(false) 61 90 62 91 const tldtsRef = React.useRef<typeof tldts>(undefined) ··· 76 105 const emailChanged = prevEmailValueRef.current !== email 77 106 const password = passwordValueRef.current 78 107 79 - if (!is13(state.dateOfBirth)) { 108 + if (!isOverRegionMinAccessAge) { 80 109 return 81 110 } 82 111 ··· 274 303 maximumDate={new Date()} 275 304 /> 276 305 </View> 277 - <Policies 278 - serviceDescription={state.serviceDescription} 279 - needsGuardian={!is18(state.dateOfBirth)} 280 - under13={!is13(state.dateOfBirth)} 281 - /> 306 + 307 + <View style={[a.gap_sm]}> 308 + <Policies serviceDescription={state.serviceDescription} /> 309 + 310 + {!isOverRegionMinAccessAge || !isOverAppMinAccessAge ? ( 311 + <Admonition.Outer type="error"> 312 + <Admonition.Row> 313 + <Admonition.Icon /> 314 + <Admonition.Content> 315 + <Admonition.Text> 316 + {!isOverAppMinAccessAge ? ( 317 + <Trans> 318 + You must be {MIN_ACCESS_AGE} years of age or older 319 + to create an account. 320 + </Trans> 321 + ) : ( 322 + <Trans> 323 + You must be {aaRegionConfig.minAccessAge} years of 324 + age or older to create an account in your region. 325 + </Trans> 326 + )} 327 + </Admonition.Text> 328 + {isNative && 329 + !isDeviceGeolocationGranted && 330 + isOverAppMinAccessAge && ( 331 + <Admonition.Text> 332 + <Trans> 333 + Have we got your location wrong?{' '} 334 + <SimpleInlineLinkText 335 + label={_( 336 + msg`Tap here to confirm your location with GPS.`, 337 + )} 338 + {...createStaticClick(() => { 339 + locationControl.open() 340 + })}> 341 + Tap here to confirm your location with GPS. 342 + </SimpleInlineLinkText> 343 + </Trans> 344 + </Admonition.Text> 345 + )} 346 + </Admonition.Content> 347 + </Admonition.Row> 348 + </Admonition.Outer> 349 + ) : !isOverMinAdultAge ? ( 350 + <Admonition.Admonition type="warning"> 351 + <Trans> 352 + If you are not yet an adult according to the laws of your 353 + country, your parent or legal guardian must read these Terms 354 + on your behalf. 355 + </Trans> 356 + </Admonition.Admonition> 357 + ) : undefined} 358 + </View> 359 + 360 + {isNative && ( 361 + <DeviceLocationRequestDialog 362 + control={locationControl} 363 + onLocationAcquired={props => { 364 + props.closeDialog(() => { 365 + // set this after close! 366 + setDeviceGeolocation(props.geolocation) 367 + Toast.show(_(msg`Your location has been updated.`), { 368 + type: 'success', 369 + }) 370 + }) 371 + }} 372 + /> 373 + )} 282 374 </> 283 375 ) : undefined} 284 376 </View> 285 377 <BackNextButtons 286 - hideNext={!is13(state.dateOfBirth)} 378 + hideNext={!isOverRegionMinAccessAge} 287 379 showRetry={isServerError} 288 380 isLoading={state.isLoading} 289 381 onBackPress={onPressBack}
+42 -14
yarn.lock
··· 82 82 "@atproto/xrpc" "^0.7.6" 83 83 "@atproto/xrpc-server" "^0.10.0" 84 84 85 + "@atproto/api@^0.18.11": 86 + version "0.18.11" 87 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.11.tgz#38b8bacecaae4c24bc29bd98b34f270d8be675b3" 88 + integrity sha512-YhgyL4rOBFOIs+e/8s5S+EjwM3T5q65IKh30ZoIrWcQS/2uteiIzg1xB+iXmexZq4Cwug0NLfRUJDViDK/ut0w== 89 + dependencies: 90 + "@atproto/common-web" "^0.4.10" 91 + "@atproto/lexicon" "^0.6.0" 92 + "@atproto/syntax" "^0.4.2" 93 + "@atproto/xrpc" "^0.7.7" 94 + await-lock "^2.2.2" 95 + multiformats "^9.9.0" 96 + tlds "^1.234.0" 97 + zod "^3.23.8" 98 + 85 99 "@atproto/api@^0.18.5", "@atproto/api@^0.18.7": 86 100 version "0.18.7" 87 101 resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.7.tgz#3175ec8f1909ddcae488183a2180de234e7acce4" ··· 100 114 version "0.18.6" 101 115 resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.6.tgz#04c26b97bda01cbe276dea523de6e4a184894c18" 102 116 integrity sha512-dkzy2OHSAGgzG9GExvOiwRY73EzVD2AiD3nksng+V6erG0kwLfbmVYjoP9mq9Y16BCXr/7q9lekfogthqU614Q== 103 - dependencies: 104 - "@atproto/common-web" "^0.4.7" 105 - "@atproto/lexicon" "^0.6.0" 106 - "@atproto/syntax" "^0.4.2" 107 - "@atproto/xrpc" "^0.7.7" 108 - await-lock "^2.2.2" 109 - multiformats "^9.9.0" 110 - tlds "^1.234.0" 111 - zod "^3.23.8" 112 - 113 - "@atproto/api@^0.18.8": 114 - version "0.18.8" 115 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.8.tgz#6df69731005413d8507345829a12abeda787c32d" 116 - integrity sha512-Qo3sGd1N5hdHTaEWUBgptvPkULt2SXnMcWRhveSyctSd/IQwTMyaIH6E62A1SU+8xBSN5QLpoUJNE7iSrYM2Zg== 117 117 dependencies: 118 118 "@atproto/common-web" "^0.4.7" 119 119 "@atproto/lexicon" "^0.6.0" ··· 207 207 pg "^8.10.0" 208 208 pino-http "^8.2.1" 209 209 typed-emitter "^2.1.0" 210 + 211 + "@atproto/common-web@^0.4.10": 212 + version "0.4.10" 213 + resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.10.tgz#3d29df4dc3ea8f5c149161209f55e146f27867c1" 214 + integrity sha512-TLDZSgSKzT8ZgOrBrTGK87J1CXve9TEuY6NVVUBRkOMzRRtQzpFb9/ih5WVS/hnaWVvE30CfuyaetRoma+WKNw== 215 + dependencies: 216 + "@atproto/lex-data" "0.0.6" 217 + "@atproto/lex-json" "0.0.6" 218 + zod "^3.23.8" 210 219 211 220 "@atproto/common-web@^0.4.4", "@atproto/common-web@^0.4.6": 212 221 version "0.4.6" ··· 406 415 uint8arrays "3.0.0" 407 416 unicode-segmenter "^0.14.0" 408 417 418 + "@atproto/lex-data@0.0.6": 419 + version "0.0.6" 420 + resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.6.tgz#280d05ec9579ab091dc4a8b696ebbeddcb8ee37d" 421 + integrity sha512-MBNB4ghRJQzuXK1zlUPljpPbQcF1LZ5dzxy274KqPt4p3uPuRw0mHjgcCoWzRUNBQC685WMQR4IN9DHtsnG57A== 422 + dependencies: 423 + "@atproto/syntax" "0.4.2" 424 + multiformats "^9.9.0" 425 + tslib "^2.8.1" 426 + uint8arrays "3.0.0" 427 + unicode-segmenter "^0.14.0" 428 + 409 429 "@atproto/lex-document@0.0.5": 410 430 version "0.0.5" 411 431 resolved "https://registry.yarnpkg.com/@atproto/lex-document/-/lex-document-0.0.5.tgz#8d4851b351149ba673c1de9c1898c3c9ebd8b4b3" ··· 429 449 integrity sha512-ZVcY7XlRfdPYvQQ2WroKUepee0+NCovrSXgXURM3Xv+n5jflJCoczguROeRr8sN0xvT0ZbzMrDNHCUYKNnxcjw== 430 450 dependencies: 431 451 "@atproto/lex-data" "0.0.3" 452 + tslib "^2.8.1" 453 + 454 + "@atproto/lex-json@0.0.6": 455 + version "0.0.6" 456 + resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.6.tgz#50618efb1cc11708b3897de14dcd3bd9c06e064d" 457 + integrity sha512-EILnN5cditPvf+PCNjXt7reMuzjugxAL1fpSzmzJbEMGMUwxOf5pPWxRsaA/M3Boip4NQZ+6DVrPOGUMlnqceg== 458 + dependencies: 459 + "@atproto/lex-data" "0.0.6" 432 460 tslib "^2.8.1" 433 461 434 462 "@atproto/lex-resolver@0.0.5":