diff --git a/app.config.js b/app.config.js index d4812da866..afbf998019 100644 --- a/app.config.js +++ b/app.config.js @@ -128,6 +128,7 @@ module.exports = function (_config) { 'com.apple.security.application-groups': 'group.app.bsky', 'com.apple.developer.usernotifications.communication': true, // 'com.apple.developer.device-information.user-assigned-device-name': true, + 'com.apple.developer.declared-age-range': true, }, privacyManifests: { NSPrivacyCollectedDataTypes: [ diff --git a/package.json b/package.json index 8103b2739a..6eed0047ea 100644 --- a/package.json +++ b/package.json @@ -93,8 +93,8 @@ "prettier": "prettier --check ." }, "dependencies": { - "@atproto/api": "0.20.22", - "@atproto/syntax": "0.6.3", + "@atproto/api": "0.20.25", + "@atproto/syntax": "0.6.4", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", "@bsky.app/alf": "^0.1.14", @@ -156,6 +156,7 @@ "emoji-regex": "^10.4.0", "eventemitter3": "^5.0.1", "expo": "54.0.34", + "expo-age-range": "0.2.18", "expo-application": "~7.0.8", "expo-blur": "~15.0.8", "expo-build-properties": "~1.0.10", diff --git a/patches/expo-age-range@0.2.18.patch b/patches/expo-age-range@0.2.18.patch new file mode 100644 index 0000000000..d8082149b8 --- /dev/null +++ b/patches/expo-age-range@0.2.18.patch @@ -0,0 +1,16 @@ +diff --git a/ios/AgeRangeModule.swift b/ios/AgeRangeModule.swift +index faf1089f4ddfb290105e959bb1f96a7250586f54..dafd2addb0c8d39a4a453bdeecbe27f7278e20f7 100644 +--- a/ios/AgeRangeModule.swift ++++ b/ios/AgeRangeModule.swift +@@ -11,8 +11,9 @@ public class AgeRangeModule: Module { + return AgeRangeResponse() + } + +- let currentVc: UIViewController? = await MainActor.run { [appContext] in +- appContext?.utilities?.currentViewController() ++ nonisolated(unsafe) let utilities = appContext?.utilities ++ let currentVc: UIViewController? = await MainActor.run { ++ utilities?.currentViewController() + } + + guard let currentVc else { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34adb7514d..8609e9ac6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -216,6 +216,7 @@ overrides: patchedDependencies: '@sentry/react-native@6.20.0': 1d48e4d5178f5684eb33262299e7eed283bf9601f79b9ae1af63a7f607d3483a + expo-age-range@0.2.18: c325225749993424461eeece1d97a99b19c00da2ebc958a73c12e4eca0c39306 expo-glass-effect@55.0.8: 6c9fa5b104e53b87df4e17b320acb3b94d12ac90c0101190045dd620681efff0 expo-haptics@15.0.8: b33910ba2753c4eccdc885b449e6e33aa90b9cefa75ca8639762a26013141410 expo-image-picker@17.0.11: 47b28f6ddb8bcaa6483f8714c82daaa0001122f2b0dac6c05d19e98a61a6ceab @@ -241,11 +242,11 @@ importers: .: dependencies: '@atproto/api': - specifier: 0.20.22 - version: 0.20.22 + specifier: 0.20.25 + version: 0.20.25 '@atproto/syntax': - specifier: 0.6.3 - version: 0.6.3 + specifier: 0.6.4 + version: 0.6.4 '@bitdrift/react-native': specifier: ^0.6.8 version: 0.6.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -429,6 +430,9 @@ importers: expo: specifier: 54.0.34 version: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-age-range: + specifier: 0.2.18 + version: 0.2.18(patch_hash=c325225749993424461eeece1d97a99b19c00da2ebc958a73c12e4eca0c39306)(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) expo-application: specifier: ~7.0.8 version: 7.0.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)) @@ -873,32 +877,32 @@ packages: graphql: optional: true - '@atproto/api@0.20.22': - resolution: {integrity: sha512-TdT9ktYc0FMmMZ8HjkMz13QcSCcYiesCGN81mFZd5owQASHVM9GBtAAmzpkrPyZhUkiIjgh/nUHk8xWVu+4dVA==} + '@atproto/api@0.20.25': + resolution: {integrity: sha512-PTwt6X0U45C9vikr8NRi0Qzcep9VqJet52HtfcxgG9R7ofRHxqLVPzfVuN/f2xOaYM6H5IG/Nk1E9kXZcI0mSQ==} engines: {node: '>=22'} - '@atproto/common-web@0.5.2': - resolution: {integrity: sha512-oO0JEvM7MM7iXMngq6V51IJlzBZFhFJoDjnGnQT75EO94/8Q5hnM/LP+a8KyWjcBcpcSZwQ0bukNv9phZONdGA==} + '@atproto/common-web@0.5.3': + resolution: {integrity: sha512-FkMhOcNv1y7r5984+zXB+uMN4zJ4QFLEbZrYyQo6bNCf/Kfav/pEHXXENlaZEOweq2NYXf+tr2giMssBuM9u5A==} engines: {node: '>=22'} - '@atproto/lex-data@0.1.3': - resolution: {integrity: sha512-ysqMYW6cIKce52/+EIbTa+I4pLqZQSBP9aGImN88vAQtd1oZBKPmut6dQk00xSQ8PW9REJRuIHNSFAF4HD+fsw==} + '@atproto/lex-data@0.1.4': + resolution: {integrity: sha512-f9U95sk0zUtxHktvK59peU+Shd0cIUYV68p//GS7sfdkAxUIeaspvX6CY+Quv9Oa4aozmsXI52SjaNn1wuU8RQ==} engines: {node: '>=22'} - '@atproto/lex-json@0.1.2': - resolution: {integrity: sha512-58nIjoWX0c8T5fcoPPmIaShxKZgfPMhNmrprtR7GuN4PzyMwm59A3CLlKAzzR+vjI3IidEMU+enpLuwUT8L+Nw==} + '@atproto/lex-json@0.1.3': + resolution: {integrity: sha512-Ch2w9bCLFOwWINFFxpZo6DBW7+ZxkehciU3P8N94gSyENLe3/5WWXHdHvkiH7EXZrpI7fKY8nVWn4GIL0ttqxw==} engines: {node: '>=22'} - '@atproto/lexicon@0.7.3': - resolution: {integrity: sha512-WP6ct2rjNCKSJN/VFc+8x6JQ7PvRMlfHUlmQM5hzvE3a6nOjm6kwSicjSIV/QldurdRGJ4FCQZ3uZu9Wqt4SxQ==} + '@atproto/lexicon@0.7.4': + resolution: {integrity: sha512-ulk4RGwMBp4vbkTOZcIwZJeTEPlj3PImOnx9TqxSxMDnBdySxarpd0Aprg7+iAvGyKTsMcrYHpeoLukZaquk0A==} engines: {node: '>=22'} - '@atproto/syntax@0.6.3': - resolution: {integrity: sha512-io7Ck4o+40iFXhetHYoEtok2gZ8cWcJ1yRftEHe5AmAF0dSGcYmclbx5GatVew59taw+eSG7Ty5D3llop/0BhA==} + '@atproto/syntax@0.6.4': + resolution: {integrity: sha512-ELgpShRGMF65cvLXcoMCZFL0HBzy67yz/Nlhox3yOtTh120B09KjjvZYXhMysVog3nUJqv2EW5rf1VRYCeM/hw==} engines: {node: '>=22'} - '@atproto/xrpc@0.8.2': - resolution: {integrity: sha512-geLqJwazZuCGnae68KZppciS8DujhGvYtpc/aAj16XpHkUCf5Ds64H1IPrV0uv7SFvd/IAuMZAXZS4GIpfp1iw==} + '@atproto/xrpc@0.8.3': + resolution: {integrity: sha512-0gUGN71+bSqV3PI5U4cQ4fdnw4nI0eD6czHDQ6jB7hMYBwNcJpwAyfA9W+C1G1wayIvP0SIap/M0H/pSKDWFIQ==} engines: {node: '>=22'} '@babel/code-frame@7.10.4': @@ -5278,6 +5282,12 @@ packages: resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + expo-age-range@0.2.18: + resolution: {integrity: sha512-WThjp0/islDlysxRkamXmS9D2zU/T7P5fIizIBPaajfG4C+tjL7psFZp6R6l0eDkYJkYO+VPJRMr5NrQQAu6nQ==} + peerDependencies: + expo: '*' + react-native: '*' + expo-application@7.0.8: resolution: {integrity: sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==} peerDependencies: @@ -9464,51 +9474,51 @@ snapshots: '@0no-co/graphql.web@1.2.0': {} - '@atproto/api@0.20.22': + '@atproto/api@0.20.25': dependencies: - '@atproto/common-web': 0.5.2 - '@atproto/lexicon': 0.7.3 - '@atproto/syntax': 0.6.3 - '@atproto/xrpc': 0.8.2 + '@atproto/common-web': 0.5.3 + '@atproto/lexicon': 0.7.4 + '@atproto/syntax': 0.6.4 + '@atproto/xrpc': 0.8.3 await-lock: 3.0.0 multiformats: 13.4.2 tlds: 1.261.0 zod: 3.25.76 - '@atproto/common-web@0.5.2': + '@atproto/common-web@0.5.3': dependencies: - '@atproto/lex-data': 0.1.3 - '@atproto/lex-json': 0.1.2 - '@atproto/syntax': 0.6.3 + '@atproto/lex-data': 0.1.4 + '@atproto/lex-json': 0.1.3 + '@atproto/syntax': 0.6.4 zod: 3.25.76 - '@atproto/lex-data@0.1.3': + '@atproto/lex-data@0.1.4': dependencies: multiformats: 13.4.2 tslib: 2.8.1 uint8arrays: 5.1.1 unicode-segmenter: 0.14.5 - '@atproto/lex-json@0.1.2': + '@atproto/lex-json@0.1.3': dependencies: - '@atproto/lex-data': 0.1.3 + '@atproto/lex-data': 0.1.4 tslib: 2.8.1 - '@atproto/lexicon@0.7.3': + '@atproto/lexicon@0.7.4': dependencies: - '@atproto/common-web': 0.5.2 - '@atproto/syntax': 0.6.3 + '@atproto/common-web': 0.5.3 + '@atproto/syntax': 0.6.4 multiformats: 13.4.2 zod: 3.25.76 - '@atproto/syntax@0.6.3': + '@atproto/syntax@0.6.4': dependencies: iso-datestring-validator: 2.2.2 tslib: 2.8.1 - '@atproto/xrpc@0.8.2': + '@atproto/xrpc@0.8.3': dependencies: - '@atproto/lexicon': 0.7.3 + '@atproto/lexicon': 0.7.4 zod: 3.25.76 '@babel/code-frame@7.10.4': @@ -14773,6 +14783,11 @@ snapshots: jest-message-util: 29.7.0 jest-util: 29.7.0 + expo-age-range@0.2.18(patch_hash=c325225749993424461eeece1d97a99b19c00da2ebc958a73c12e4eca0c39306)(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)): + dependencies: + expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + expo-application@7.0.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)): dependencies: expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 182228486d..688f5f058b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -20,6 +20,7 @@ allowBuilds: 'unrs-resolver': true patchedDependencies: '@sentry/react-native@6.20.0': patches/@sentry__react-native@6.20.0.patch + expo-age-range@0.2.18: patches/expo-age-range@0.2.18.patch 'expo-glass-effect@55.0.8': patches/expo-glass-effect@55.0.8.patch 'expo-haptics@15.0.8': patches/expo-haptics@15.0.8.patch 'expo-image-picker@17.0.11': patches/expo-image-picker@17.0.11.patch diff --git a/src/ageAssurance/components/NoAccessScreen.tsx b/src/ageAssurance/components/NoAccessScreen.tsx index 44870dcffd..29d9abc5b2 100644 --- a/src/ageAssurance/components/NoAccessScreen.tsx +++ b/src/ageAssurance/components/NoAccessScreen.tsx @@ -1,14 +1,8 @@ import {useCallback, useEffect} from 'react' import {ScrollView, View} from 'react-native' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' +import {Trans, useLingui} from '@lingui/react/macro' -import { - SupportCode, - useCreateSupportLink, -} from '#/lib/hooks/useCreateSupportLink' import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo' import {useIsBirthdateUpdateAllowed} from '#/state/birthdate' import {useSessionApi} from '#/state/session' @@ -27,6 +21,7 @@ import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRe import {Full as Logo} from '#/components/icons/Logo' import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield' import {createStaticClick, SimpleInlineLinkText} from '#/components/Link' +import {Loader} from '#/components/Loader' import {Outlet as PortalOutlet} from '#/components/Portal' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' @@ -34,37 +29,51 @@ import {BottomSheetOutlet} from '#/../modules/bottom-sheet' import {useAgeAssurance} from '#/ageAssurance' import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data' import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess' +import {useAgeAssuranceVerificationFlow} from '#/ageAssurance/useVerificationFlow' import { + canBirthdateUpdateIncreaseAccess, + createGeolocationString, isLegacyBirthdateBug, useAgeAssuranceRegionConfig, } from '#/ageAssurance/util' import {useAnalytics} from '#/analytics' import {IS_NATIVE, IS_WEB} from '#/env' -import {useDeviceGeolocationApi} from '#/geolocation' +import {useDeviceGeolocationApi, useGeolocation} from '#/geolocation' const textStyles = [a.text_md, a.leading_snug] export function NoAccessScreen() { const t = useTheme() - const {_} = useLingui() + const {t: l, i18n} = useLingui() const ax = useAnalytics() const {gtPhone} = useBreakpoints() const insets = useSafeAreaInsets() const birthdateControl = useDialogControl() const deactivateAccountControl = useDialogControl() const deleteAccountControl = useDialogControl() - const {metadata} = useAgeAssuranceServerDataContext() + const {metadata, deviceSignals} = useAgeAssuranceServerDataContext() const region = useAgeAssuranceRegionConfig() const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed() const {logoutCurrentAccount} = useSessionApi() - const createSupportLink = useCreateSupportLink() + const geolocation = useGeolocation() + const {setDeviceGeolocation} = useDeviceGeolocationApi() + const locationControl = Dialog.useDialogControl() + const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess() const aa = useAgeAssurance() const isBlocked = aa.state.status === aa.Status.Blocked const isAARegion = !!region - const hasDeclaredAge = metadata?.declaredAge !== undefined + const hasDeclaredAge = aa.flags.hasDeclaredAge + const birthdateMightIncreaseAccess = Boolean( + region && + canBirthdateUpdateIncreaseAccess({region, metadata, deviceSignals}), + ) const canUpdateBirthday = - isBirthdateUpdateAllowed || isLegacyBirthdateBug(metadata?.birthdate || '') + (isBirthdateUpdateAllowed || + isLegacyBirthdateBug(metadata?.birthdate || '')) && + birthdateMightIncreaseAccess + const geolocationString = createGeolocationString(geolocation, i18n.locale) + const isUsingGPS = !!geolocation.deviceGeolocation?.countryCode && IS_NATIVE useEffect(() => { // just counting overall hits here @@ -106,7 +115,7 @@ export function NoAccessScreen() { If you believe your birthdate is incorrect, you can update it by{' '} { ax.metric('ageAssurance:noAccessScreen:openBirthdateDialog', {}) @@ -120,20 +129,7 @@ export function NoAccessScreen() { {orgAdmonition} - ) : ( - - - If you believe your birthdate is incorrect, please{' '} - - contact our support team - - . - - - ) + ) : null return ( <> @@ -164,80 +160,147 @@ export function NoAccessScreen() { - {hasDeclaredAge ? ( - <> - {isAARegion ? ( - <> + + {hasDeclaredAge ? ( + <> + {isAARegion ? ( + <> + + + Hey there! + + + + You are accessing Bluesky from a region that legally + requires us to verify your age before allowing you + to access the app. + + + + {region && geolocationString && ( + + {isUsingGPS ? ( + + Based on your device's location, we think you're + in{' '} + + {geolocationString} + + . + + ) : ( + <> + + Based on your network, we think you're in{' '} + + {geolocationString} + + . This estimate may be inaccurate if you're + using a VPN. + + + )} + {IS_NATIVE && ( + <> + {' '} + { + locationControl.open() + })} + style={[textStyles]}> + + Tap here to update your location with GPS. + + + { + const access = + computeAgeAssuranceRegionAccess( + props.geolocation, + ) + if (access !== aa.Access.Full) { + props.disableDialogAction() + props.setDialogError( + l`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(l`Thanks! You're all set.`, { + type: 'success', + }) + }) + } + }} + /> + + )} + + )} + + {!aa.flags.isOverRegionMinAccessAge && ( + + + Unfortunately, your declared age indicates that + you are not old enough to access Bluesky in your + region. + + + )} + + {!isBlocked && birthdateUpdateText} + + + {aa.flags.isOverRegionMinAccessAge && } + + ) : ( - - Hey there! - - You are accessing Bluesky from a region that legally - requires us to verify your age before allowing you to - access the app. + Unfortunately, the birthdate you have saved to your + profile makes you too young to access Bluesky. - {!aa.flags.isOverRegionMinAccessAge && ( - - - Unfortunately, your declared age indicates that you - are not old enough to access Bluesky in your region. - - - )} - - {!isBlocked && birthdateUpdateText} + {birthdateUpdateText} + )} + + ) : ( + + + Hi there! + + + + 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. + + + + + Set your birthdate below and we'll get you back to posting + and exploring in no time! + + + - {aa.flags.isOverRegionMinAccessAge && } - - ) : ( - - - - Unfortunately, the birthdate you have saved to your - profile makes you too young to access Bluesky. - - - - {birthdateUpdateText} - - )} - - ) : ( - - - Hi there! - - - - 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. - - - - - Set your birthdate below and we'll get you back to posting - and exploring in no time! - - - - - {orgAdmonition} - - )} + {orgAdmonition} + + )} + @@ -251,7 +314,7 @@ export function NoAccessScreen() { To log out,{' '} { onPressLogout() })} @@ -260,7 +323,7 @@ export function NoAccessScreen() { . Or if you’d prefer, you can{' '} { ax.metric( 'ageAssurance:noAccessScreen:openDeleteAccountDialog', @@ -299,14 +362,11 @@ export function NoAccessScreen() { function AccessSection() { const t = useTheme() - const {_, i18n} = useLingui() + const {t: l, i18n} = useLingui() const ax = useAnalytics() const control = useDialogControl() const appealControl = Dialog.useDialogControl() - const locationControl = Dialog.useDialogControl() const getTimeAgo = useGetTimeAgo() - const {setDeviceGeolocation} = useDeviceGeolocationApi() - const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess() const aa = useAgeAssurance() const {status, lastInitiatedAt} = aa.state @@ -318,6 +378,15 @@ function AccessSection() { const diff = lastInitiatedAt ? dateDiff(lastInitiatedAt, new Date(), 'down') : null + const { + onPressVerify, + openInitDialog, + isVerifying, + verifyCta, + deviceSignalsFailed, + } = useAgeAssuranceVerificationFlow({initDialogControl: control}) + const useDeviceSignals = + aa.flags.allowsDeviceVerification && !deviceSignalsFailed return ( <> @@ -331,7 +400,7 @@ function AccessSection() { You are currently unable to access Bluesky's Age Assurance flow. Please{' '} { appealControl.open() ax.metric('ageAssurance:appealDialogOpen', {}) @@ -345,26 +414,38 @@ function AccessSection() { <> - {lastInitiatedAt && timeAgo && diff ? ( + {useDeviceSignals ? ( + + + Sharing your age data uses information stored on your + device, and will therefore only work on this device. + Alternatively,{' '} + { + openInitDialog() + })}> + you can use our trusted partner, KWS + + , to complete your verification and enable access on all + platforms. + + + ) : lastInitiatedAt && timeAgo && diff ? ( - Age assurance only takes a few minutes + Age assurance only takes a few minutes. )} )} - - - {IS_NATIVE && ( - <> - - - Is your location not accurate?{' '} - { - locationControl.open() - })}> - Tap here to update your location with GPS. - - - - - { - 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', - }) - }) - } - }} - /> - - )} - ) diff --git a/src/ageAssurance/const.ts b/src/ageAssurance/const.ts index bd79353b51..f410d63489 100644 --- a/src/ageAssurance/const.ts +++ b/src/ageAssurance/const.ts @@ -4,12 +4,29 @@ import { } from '@atproto/api' import {AgeAssuranceAccess} from '#/ageAssurance/types' +import {ANDROID_API_LEVEL, IOS_MAJOR_VERSION, IS_ANDROID, IS_IOS} from '#/env' /** * Minimum age required to access the app at all. */ export const MIN_ACCESS_AGE = 13 +/** + * Whether the current device can provide the native on-device age signals we + * use for age assurance (via `expo-age-range`). We gate on OS version because + * the underlying platform APIs are only available on: + * + * - iOS 26.0+ (Declared Age Range API) + * https://developer.apple.com/documentation/declaredagerange/ + * - Android 6.0 / API level 23+ (Play Age Signals API) + * https://developer.android.com/google/play/age-signals/use-age-signals-api + * + * On unsupported platforms/versions (including web) this is `false`, so we skip + * the device flow and fall back to KWS. + */ +export const DEVICE_SIGNALS_SUPPORTED: boolean = + (IS_IOS && IOS_MAJOR_VERSION >= 26) || (IS_ANDROID && ANDROID_API_LEVEL >= 23) + export const FALLBACK_REGION_CONFIG: AppBskyAgeassuranceDefs.ConfigRegion = { countryCode: '*', regionCode: undefined, diff --git a/src/ageAssurance/data.tsx b/src/ageAssurance/data.tsx index 114d946602..9cf6703dfc 100644 --- a/src/ageAssurance/data.tsx +++ b/src/ageAssurance/data.tsx @@ -1,11 +1,11 @@ import {createContext, useCallback, useContext, useEffect, useMemo} from 'react' +import * as AgeRange from 'expo-age-range' import { type AppBskyAgeassuranceDefs, type AppBskyAgeassuranceGetConfig, type AppBskyAgeassuranceGetState, AtpAgent, type ChatBskyActorDeclaration, - getAgeAssuranceRegionConfig, } from '@atproto/api' import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' import {focusManager, QueryClient, useQuery} from '@tanstack/react-query' @@ -22,14 +22,21 @@ import { } from '#/state/birthdate' import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declaration' import {useAgent, useSession} from '#/state/session' +import {DEVICE_SIGNALS_SUPPORTED} from '#/ageAssurance/const' import * as debug from '#/ageAssurance/debug' import {logger} from '#/ageAssurance/logger' -import {type AgeAssuranceMetadata} from '#/ageAssurance/types' import { + type AgeAssuranceDeviceSignals, + type AgeAssuranceMetadata, +} from '#/ageAssurance/types' +import { + createRegionKey, + getAgeAssuranceRegionConfigForGeolocation, getBirthdateStringFromAge, isLegacyBirthdateBug, } from '#/ageAssurance/util' import {IS_DEV} from '#/env' +import {useGeolocation} from '#/geolocation' import {device} from '#/storage' /** @@ -310,10 +317,7 @@ export function useServerStateQuery() { const isAArequired = Boolean( config && geolocation && - !!getAgeAssuranceRegionConfig(config, { - countryCode: geolocation?.countryCode ?? '', - regionCode: geolocation?.regionCode, - }), + getAgeAssuranceRegionConfigForGeolocation(config, geolocation), ) // only refetch when needed @@ -485,6 +489,173 @@ export function useOtherRequiredDataQuery() { ) } +export function createDeviceSignalsQueryKey({did}: {did: string}) { + return ['device-signals', did] +} +/** + * Prompts the native OS age API. Returns the raw response, or undefined if the + * platform can't provide one. + * + * Native-only: on web `expo-age-range` returns a misleading default (e.g. + * `{lowerBound: 18}`), so we never call it there — web users fall back to KWS. + * + * If this method throws for whatever reason, we catch and log the error and + * return undefined. The caller should treat undefined as "no device signals + * available" and fall back to KWS. + */ +export async function getDeviceSignals(): Promise< + AgeRange.AgeRangeResponse | undefined +> { + if (debug.enabled && debug.useMockDeviceSignalsAPIResponse) + return debug.resolve(debug.deviceSignals) + if (!DEVICE_SIGNALS_SUPPORTED) return undefined + try { + return await AgeRange.requestAgeRangeAsync({ + threshold1: 13, + threshold2: 16, + threshold3: 18, + }) + } catch (err) { + const e = err as Error + logger.error(`getDeviceSignals: failed to get device signals`, { + safeMessage: e.message, + }) + return undefined + } +} +/** + * The raw region-keyed map of device signals (all regions). Used internally by + * the query + writer, which operate on the full map. Most consumers want + * {@link getDeviceSignalsFromCacheForRegion}, which resolves to the + * current region. + */ +export function getDeviceSignalsMapFromCache({ + did, +}: { + did: string +}): AgeAssuranceDeviceSignals | undefined { + return qc.getQueryData( + createDeviceSignalsQueryKey({did}), + ) +} +/** + * Returns the device signals for the region the user is currently in, or + * undefined. + */ +export function getDeviceSignalsFromCacheForRegion({ + did, + region, +}: { + did: string + region: AppBskyAgeassuranceDefs.ConfigRegion +}): AgeRange.AgeRangeResponse | undefined { + const regionKey = createRegionKey(region) + return getDeviceSignalsMapFromCache({did})?.[regionKey] +} +/** + * Stores freshly granted device signals into the (persisted) cache under the + * region they were captured in, merging with any signals already stored for + * other regions. Notifies the disabled `useDeviceSignalsQuery` observer so the + * AA state recomputes. + * + * Device assurance is client-side only (it can't be verified server-side) and + * region-bound — keying by region is what binds it. See + * {@link AgeAssuranceDeviceSignals}. + */ +export function setDeviceSignalsForRegion({ + did, + region, + signals, +}: { + did: string + region: {countryCode: string; regionCode?: string} + signals: AgeRange.AgeRangeResponse +}) { + const regionKey = createRegionKey(region) + qc.setQueryData( + createDeviceSignalsQueryKey({did}), + prev => ({...prev, [regionKey]: signals}), + ) +} +export async function prefetchDeviceSignals({agent}: {agent: AtpAgent}) { + const did = getDidFromAgentSession(agent) + if (!did) return + + /** + * Device signals are restored from the persisted cache only — we never call + * the native age API during prefetch, since that would prompt the OS for + * users who haven't opted in. Awaiting cache hydration ensures any previously + * granted signals are available before the AA state is first computed. The + * user can (re)grant access later via the NoAccessScreen verify flow. + */ + await cacheHydrationPromise + const cached = getDeviceSignalsMapFromCache({did}) + logger.debug( + `prefetchDeviceSignals: ${cached ? 'restored from cache' : 'no cache'}`, + cached, + ) + + /* + * Future silent-refresh path (intentionally left commented out). The OS + * returns a previously granted age range without re-prompting, so once a + * region is already cached we could refresh it on load instead of waiting for + * the user to re-verify. We don't do this yet because the native call can + * still surface a prompt for users who never opted in, so it must stay gated + * behind an explicit grant for now. + * + * const geolocation = device.get(['mergedGeolocation']) + * if (cached && geolocation?.countryCode) { + * const signals = await getDeviceSignals() + * if (signals) { + * setDeviceSignalsForRegion({did, region: geolocation, signals}) + * } + * } + */ +} +export function useDeviceSignalsQuery() { + const agent = useAgent() + const did = getDidFromAgentSession(agent) + const {data: config} = useConfigQuery() + const geolocation = useGeolocation() + /* + * Resolve the matched config region (no fallback) and key off it, so the read + * stays symmetric with the write (see `setDeviceSignalsForRegion`). When + * geolocation matches no AA region there's no device grant to surface. + */ + const regionConfig = config + ? getAgeAssuranceRegionConfigForGeolocation(config, geolocation) + : undefined + const regionKey = regionConfig ? createRegionKey(regionConfig) : undefined + + return useQuery( + { + /** + * Disabled so we never auto-call the native age API on load — that would + * prompt the OS for every logged-in user. We restore from the persisted + * cache (via `initialData`) and otherwise only update reactively when the + * user explicitly verifies (see `getDeviceSignals` + + * `setDeviceSignalsForRegion` in the NoAccessScreen verify flow). + * + * A future enhancement could silently refresh here when already cached, + * since the OS returns the granted result without re-prompting. + */ + enabled: false, + initialData: getDeviceSignalsMapFromCache({did: did!}), + queryKey: createDeviceSignalsQueryKey({did: did!}), + queryFn() { + // Never auto-fetches (see `enabled: false`); the verify flow writes the + // region-keyed signals directly via `setDeviceSignalsForRegion`. + return getDeviceSignalsMapFromCache({did: did!}) + }, + // The cache holds the full region-keyed map (the writer merges into it); + // `select` resolves it to the current region for consumers without + // mutating the cached value. + select: map => (map && regionKey ? map[regionKey] : undefined), + }, + qc, + ) +} + /** * Helper to prefetch all age assurance data from the server. */ @@ -494,6 +665,7 @@ export function prefetchAgeAssuranceServerData({agent}: {agent: AtpAgent}) { configPrefetchPromise, prefetchServerState({agent}), prefetchOtherRequiredData({agent}), + prefetchDeviceSignals({agent}), ]) } @@ -525,6 +697,14 @@ export type AgeAssuranceServerData = { */ state: AppBskyAgeassuranceDefs.State | undefined metadata: AgeAssuranceMetadata | undefined + /** + * The native on-device age signals for the region the user is currently in, + * if they've granted access there. Already resolved from the region-keyed + * cache (see `getDeviceSignalsFromCacheForRegion`), so a grant from + * another region won't appear here. Only consumed for regions that permit + * device verification. + */ + deviceSignals: AgeRange.AgeRangeResponse | undefined } const AgeAssuranceServerDataContext = createContext({ config: undefined, @@ -534,6 +714,7 @@ const AgeAssuranceServerDataContext = createContext({ declaredAge: undefined, birthdate: undefined, }, + deviceSignals: undefined, }) export function useAgeAssuranceServerDataContext() { return useContext(AgeAssuranceServerDataContext) @@ -547,6 +728,8 @@ export function AgeAssuranceServerDataProvider({ const serverState = useServerStateQuery() const {state, metadata} = serverState.data || {} const {data} = useOtherRequiredDataQuery() + // `select` resolves the cached region-keyed map to the current region. + const {data: deviceSignals} = useDeviceSignalsQuery() const ctx = useMemo( () => ({ config, @@ -559,8 +742,9 @@ export function AgeAssuranceServerDataProvider({ : undefined, birthdate: data?.birthdate, }, + deviceSignals, }), - [config, state, data, metadata], + [config, state, data, metadata, deviceSignals], ) return ( diff --git a/src/ageAssurance/debug.ts b/src/ageAssurance/debug.ts index 3368ddf147..8a828a00e0 100644 --- a/src/ageAssurance/debug.ts +++ b/src/ageAssurance/debug.ts @@ -1,3 +1,4 @@ +import type * as AgeRange from 'expo-age-range' import { ageAssuranceRuleIDs as ids, type AppBskyAgeassuranceDefs, @@ -17,17 +18,16 @@ export const geolocation: Geolocation | undefined = enabled } : undefined -const deviceGeolocationEnabled = false || IS_E2E -export const deviceGeolocation: Geolocation | undefined = - enabled && deviceGeolocationEnabled - ? { - countryCode: 'AA', - regionCode: undefined, - } - : undefined +export const deviceGeolocation: Geolocation | undefined = enabled + ? { + countryCode: 'AA', + regionCode: undefined, + ...geolocation, + } + : undefined export const otherRequiredData: OtherRequiredData = { - birthdate: new Date(2010, 12, 1).toISOString(), + birthdate: new Date(2000, 12, 1).toISOString(), } const serverStateEnabled = false || IS_E2E @@ -35,9 +35,9 @@ export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined = serverStateEnabled ? { state: { - lastInitiatedAt: new Date(2025, 1, 1).toISOString(), - status: 'assured', - access: 'full', + lastInitiatedAt: undefined, // new Date(2025, 1, 1).toISOString(), + status: 'unknown', + access: 'unknown', }, metadata: { accountCreatedAt: new Date(2023, 1, 1).toISOString(), @@ -58,6 +58,26 @@ export const config: AppBskyAgeassuranceDefs.Config = { }, ], }, + { + // On-device verification region. KWS is included as a fallback for + // platforms without the native age API (e.g. web) or when the device + // result is insufficient. + countryCode: 'US', + regionCode: 'TX', + minAccessAge: 18, + additionalVerificationMethods: ['device'], + rules: [ + { + age: 18, + access: 'full', + $type: ids.IfAssuredOverAge, + }, + { + access: 'none', + $type: ids.Default, + }, + ], + }, { countryCode: 'GB', minAccessAge: 13, @@ -257,6 +277,23 @@ export const config: AppBskyAgeassuranceDefs.Config = { ], } +/** + * When debug is enabled we mock the `deviceSignals` response by default. Set + * this to `false` to hit the real native age API (`expo-age-range`) so the OS + * age prompt actually shows — useful for testing the device flow on a physical + * device. + */ +export const useMockDeviceSignalsAPIResponse = true +export const deviceSignals: AgeRange.AgeRangeResponse | undefined = + useMockDeviceSignalsAPIResponse + ? { + // Simulates the OS reporting the user is at least 18. Lower this below + // a region's IfAssuredOverAge threshold to exercise the KWS fallback. + lowerBound: 16, + upperBound: null, + } + : undefined + export async function resolve(data: T) { await new Promise(y => setTimeout(y, 500)) // simulate network return data diff --git a/src/ageAssurance/index.tsx b/src/ageAssurance/index.tsx index 20bcc2bdb8..1bfc58c3fc 100644 --- a/src/ageAssurance/index.tsx +++ b/src/ageAssurance/index.tsx @@ -51,9 +51,12 @@ const AgeAssuranceStateContext = createContext<{ adultContentDisabled: false, chatDisabled: false, groupChatDisabled: false, + hasDeclaredAge: false, isDeclaredUnderAdultAge: false, isOverRegionMinAccessAge: false, isOverAppMinAccessAge: false, + allowsDeviceVerification: false, + hasSharedDeviceSignals: false, }, }) @@ -79,7 +82,7 @@ export function Provider({children}: {children: React.ReactNode}) { function InnerProvider({children}: {children: React.ReactNode}) { const agent = useAgent() const state = useAgeAssuranceState() - const {metadata} = useAgeAssuranceServerDataContext() + const {metadata, deviceSignals} = useAgeAssuranceServerDataContext() const regionConfig = useAgeAssuranceRegionConfigWithFallback() const getAndRegisterPushToken = useGetAndRegisterPushToken() @@ -89,6 +92,7 @@ function InnerProvider({children}: {children: React.ReactNode}) { state: s, regionConfig, metadata, + deviceSignals, }) if (flags.isAgeRestricted) { void getAndRegisterPushToken({ @@ -103,7 +107,7 @@ function InnerProvider({children}: {children: React.ReactNode}) { }) } }, - [agent, getAndRegisterPushToken, regionConfig, metadata], + [agent, getAndRegisterPushToken, regionConfig, metadata, deviceSignals], ) useOnAgeAssuranceAccessUpdate(handleAccessUpdate) @@ -118,11 +122,12 @@ function InnerProvider({children}: {children: React.ReactNode}) { state, regionConfig, metadata, + deviceSignals, }), } logger.debug(`useAgeAssurance`, res) return res - }, [state, metadata, regionConfig])}> + }, [state, metadata, regionConfig, deviceSignals])}> {children} ) diff --git a/src/ageAssurance/state.ts b/src/ageAssurance/state.ts index ff80bca725..2a0b76446b 100644 --- a/src/ageAssurance/state.ts +++ b/src/ageAssurance/state.ts @@ -1,4 +1,5 @@ import {useEffect, useMemo, useState} from 'react' +import type * as AgeRange from 'expo-age-range' import { type AppBskyAgeassuranceDefs, computeAgeAssuranceRegionAccess, @@ -8,6 +9,7 @@ import {getAge} from '#/lib/strings/time' import {useSession} from '#/state/session' import { getConfigFromCache, + getDeviceSignalsFromCacheForRegion, getOtherRequiredDataFromCache, getServerStateFromCache, useAgeAssuranceServerDataContext, @@ -23,6 +25,8 @@ import { } from '#/ageAssurance/types' import { computeAgeAssuranceFlags, + getAgeAssuranceDataFromDeviceSignals, + getAgeAssuranceRegionConfigForGeolocation, getAgeAssuranceRegionConfigWithFallback, } from '#/ageAssurance/util' import {type Geolocation, useGeolocation} from '#/geolocation' @@ -39,12 +43,14 @@ function computeAgeAssuranceState({ config, state, metadata, + deviceSignals, }: { hasSession: boolean geolocation: Geolocation config?: AppBskyAgeassuranceDefs.Config state?: AppBskyAgeassuranceDefs.State metadata?: AgeAssuranceMetadata + deviceSignals?: AgeRange.AgeRangeResponse }) { /** * This is where we control logged-out moderation prefs. It's all @@ -93,10 +99,19 @@ function computeAgeAssuranceState({ * Otherwise, we need to compute the access based on the latest data. For * accounts with an accurate birthdate, our default fallback rules should * ensure correct access. + * + * In regions that permit on-device verification, the OS-provided age range + * is treated as an assured age and fed into the rule engine, where it + * matches `IfAssuredOverAge`/`IfAssuredUnderAge` rules. */ + const {assuredAge} = getAgeAssuranceDataFromDeviceSignals( + region, + deviceSignals, + ) const result = computeAgeAssuranceRegionAccess(region, { accountCreatedAt: metadata?.accountCreatedAt, declaredAge: metadata?.declaredAge, + assuredAge, }) const computed = { lastInitiatedAt: state?.lastInitiatedAt, @@ -138,6 +153,19 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) { } const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation) + /* + * Device signals are keyed off the matched config region (no fallback): if + * geolocation matches no AA region there's no device grant to read, so we + * skip the lookup rather than keying off FALLBACK_REGION_CONFIG. This keeps + * the read key symmetric with the write (see `setDeviceSignalsForRegion`). + */ + const deviceRegion = getAgeAssuranceRegionConfigForGeolocation( + config, + geolocation, + ) + const deviceSignals = deviceRegion + ? getDeviceSignalsFromCacheForRegion({did, region: deviceRegion}) + : undefined const metadata: AgeAssuranceMetadata = { accountCreatedAt: state.metadata?.accountCreatedAt, declaredAge: requiredData?.birthdate @@ -151,6 +179,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) { geolocation, state: state.state, metadata, + deviceSignals, }) return { @@ -159,6 +188,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) { state: computed, regionConfig: region, metadata, + deviceSignals, }), } } @@ -166,7 +196,8 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) { export function useAgeAssuranceState(): AgeAssuranceState { const {hasSession} = useSession() const geolocation = useGeolocation() - const {config, state, metadata} = useAgeAssuranceServerDataContext() + const {config, state, metadata, deviceSignals} = + useAgeAssuranceServerDataContext() return useMemo( () => @@ -176,8 +207,9 @@ export function useAgeAssuranceState(): AgeAssuranceState { geolocation, state, metadata, + deviceSignals, }), - [hasSession, geolocation, config, state, metadata], + [hasSession, geolocation, config, state, metadata, deviceSignals], ) } diff --git a/src/ageAssurance/types.ts b/src/ageAssurance/types.ts index 21abff89f0..5dcabeb268 100644 --- a/src/ageAssurance/types.ts +++ b/src/ageAssurance/types.ts @@ -1,7 +1,24 @@ +import type * as AgeRange from 'expo-age-range' import {type computeAgeAssuranceRegionAccess} from '@atproto/api' import {logger} from '#/ageAssurance/logger' +/** + * Native on-device age signals, keyed by the region (a `country[-region]` + * string, see `createRegionKey`) they were captured in. We keep one entry per + * region so multiple regions with differing criteria can each retain their own + * grant. + * + * Device assurance can't be verified server-side (the OS gives us no signed + * attestation, only age bounds), so we persist it client-side only and bind it + * to its capture region via the key. A grant captured in TX is only ever read + * back for TX — it can't silently unlock another region. See + * `getAgeAssuranceDataFromDeviceSignals`. + */ +export type AgeAssuranceDeviceSignals = { + [regionKey: string]: AgeRange.AgeRangeResponse +} + export enum AgeAssuranceAccess { Unknown = 'unknown', None = 'none', @@ -34,9 +51,12 @@ export type AgeAssuranceFlags = { adultContentDisabled: boolean chatDisabled: boolean groupChatDisabled: boolean + hasDeclaredAge: boolean isDeclaredUnderAdultAge: boolean isOverRegionMinAccessAge: boolean isOverAppMinAccessAge: boolean + allowsDeviceVerification: boolean + hasSharedDeviceSignals: boolean } export function parseStatusFromString(raw: string) { diff --git a/src/ageAssurance/useVerificationFlow.ts b/src/ageAssurance/useVerificationFlow.ts new file mode 100644 index 0000000000..247da65b0c --- /dev/null +++ b/src/ageAssurance/useVerificationFlow.ts @@ -0,0 +1,171 @@ +import {useCallback, useState} from 'react' +import type * as AgeRange from 'expo-age-range' +import {useLingui} from '@lingui/react/macro' + +import {useSession} from '#/state/session' +import {type DialogControlProps} from '#/components/Dialog' +import * as Toast from '#/components/Toast' +import {useAgeAssurance} from '#/ageAssurance' +import {getDeviceSignals, setDeviceSignalsForRegion} from '#/ageAssurance/data' +import {logger} from '#/ageAssurance/logger' +import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state' +import { + getAgeAssuranceDataFromDeviceSignals, + useAgeAssuranceRegionConfig, +} from '#/ageAssurance/util' +import {useAnalytics} from '#/analytics' + +/** + * Shared "Verify now" flow for the age assurance surfaces (the no-access screen + * and the account settings card). Handles the device-first verification path + * with a KWS fallback, keeping the two entry points in sync. + * + * The caller owns the KWS dialog (`AgeAssuranceInitDialog`) and passes its + * `initDialogControl` here so we can open it for the fallback / opt-in cases. + * + * Returns: + * - `onPressVerify`: the button handler. In device-verification regions it + * prompts the OS age API, persists a sufficient result (region-bound), and + * toasts the outcome; otherwise (or on any missing/failed device response) it + * opens the KWS dialog. + * - `openInitDialog`: opens the KWS dialog directly (for the inline "use KWS" + * link), emitting the same metric as the fallback path. + * - `isVerifying`: true while the OS age prompt is up, for a button + * loading state. + * - `verifyCta`: the localized button label for the current mode. + */ +export function useAgeAssuranceVerificationFlow({ + initDialogControl, +}: { + initDialogControl: DialogControlProps +}) { + const {t: l} = useLingui() + const ax = useAnalytics() + const {currentAccount} = useSession() + const region = useAgeAssuranceRegionConfig() + const aa = useAgeAssurance() + const hasInitiated = !!aa.state.lastInitiatedAt + const allowsDeviceVerification = region && aa.flags.allowsDeviceVerification + + const [isVerifying, setIsVerifying] = useState(false) + /* + * In-memory only, and scoped to this hook instance: each surface using this + * hook tracks its own failure, and a remount (or another surface) starts + * fresh and will offer the device flow again. That's intentional for now - + * it's a soft "don't immediately re-offer" signal, not a persisted judgment + * that the device can't provide signals. + */ + const [deviceSignalsFailed, setDeviceSignalsFailed] = useState(false) + + const verifyCta = + allowsDeviceVerification && !deviceSignalsFailed + ? l`Share age data` + : hasInitiated + ? l`Verify again` + : l`Verify now` + + const openInitDialog = useCallback(() => { + initDialogControl.open() + ax.metric('ageAssurance:initDialogOpen', { + hasInitiatedPreviously: hasInitiated, + }) + }, [initDialogControl, ax, hasInitiated]) + + const onPressVerify = useCallback(async () => { + const did = currentAccount?.did + + // Just for typescript, these surfaces aren't shown without a logged in user + if (!did) return + + /* + * In regions that permit on-device verification, try the native age API + * first. We tag the result with the current region (device assurance is + * region-bound — a TX grant only counts in TX) and, if it's sufficient, + * persist it client-side so the AA state recompute lifts the gate. + * + * Once the OS returns a response we stay on the device path and report the + * outcome via a toast (sufficient, under-age, or no usable data) rather than + * silently falling back — users can still opt into KWS via the inline link. + * We only fall through to the KWS dialog below when the device can't give us + * a response at all: `getDeviceSignals` handles its own errors and returns + * undefined (e.g. on web or failure). + */ + if (allowsDeviceVerification && !deviceSignalsFailed) { + // Show a loading state while the OS age prompt is up. + setIsVerifying(true) + let signals: AgeRange.AgeRangeResponse | undefined + try { + signals = await getDeviceSignals() + } finally { + setIsVerifying(false) + } + if (signals) { + const {assuredAge} = getAgeAssuranceDataFromDeviceSignals( + region, + signals, + ) + if (assuredAge !== undefined) { + // Persist (keyed by this region) so the AA state recomputes from the + // cache write. Recompute here too so we can react to the outcome: a + // sufficient age lifts the gate (nothing more to do), but the device + // may report an age below the region's threshold, in which case + // access stays `none` and we tell the user. + setDeviceSignalsForRegion({did, region, signals}) + const {state} = unsafeGetAndComputeAgeAssurance({did}) + if (state.access === aa.Access.None) { + Toast.show( + l`We're sorry, but based on the data shared by your device, you are not old enough to access Bluesky.`, + {type: 'info'}, + ) + } else if (state.access === aa.Access.Unknown) { + Toast.show( + l`Hmm, it seems we weren't able to compute your level of access. Please try again.`, + {type: 'warning'}, + ) + } else if (state.access === aa.Access.Safe) { + Toast.show( + l`You're all set! However, certain features may still be restricted until you're able to verify you're an adult.`, + { + type: 'success', + }, + ) + } else { + Toast.show(l`You're all set!`, { + type: 'success', + }) + } + return + } + // We got a device response but it carried no usable age information. + Toast.show( + l`Hmm, it seems your device was unable to share age information with us.`, + {type: 'warning'}, + ) + setDeviceSignalsFailed(true) + return + } + logger.debug( + `onPressVerify: no device signals available (web/error), falling back to KWS`, + ) + } + + openInitDialog() + }, [ + region, + currentAccount?.did, + openInitDialog, + allowsDeviceVerification, + aa, + l, + deviceSignalsFailed, + setDeviceSignalsFailed, + ]) + + return { + onPressVerify, + openInitDialog, + isVerifying, + verifyCta, + deviceSignalsFailed, + } +} diff --git a/src/ageAssurance/util.ts b/src/ageAssurance/util.ts index 0d493f1130..ecd6468339 100644 --- a/src/ageAssurance/util.ts +++ b/src/ageAssurance/util.ts @@ -1,13 +1,20 @@ import {useMemo} from 'react' +import type * as AgeRange from 'expo-age-range' import { - type AppBskyAgeassuranceDefs, + AppBskyAgeassuranceDefs, + computeAgeAssuranceRegionAccess, getAgeAssuranceRegionConfig, type ModerationPrefs, } from '@atproto/api' import {getAge} from '#/lib/strings/time' +import {regionName} from '#/locale/helpers' import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/const' -import {FALLBACK_REGION_CONFIG, MIN_ACCESS_AGE} from '#/ageAssurance/const' +import { + DEVICE_SIGNALS_SUPPORTED, + FALLBACK_REGION_CONFIG, + MIN_ACCESS_AGE, +} from '#/ageAssurance/const' import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data' import { AgeAssuranceAccess, @@ -16,24 +23,213 @@ import { type AgeAssuranceState, } from '#/ageAssurance/types' import {type Geolocation, useGeolocation} from '#/geolocation' +import {USRegionNameToRegionCode} from '#/geolocation/util' + +/** + * Resolves a geolocation to its matched age assurance region config, or + * undefined when the geolocation matches no AA region. + * + * This is the single source of truth for geolocation -> region resolution. + * Device signals are written and read back under a key derived from the + * matched region (see `createRegionKey`), so every site that resolves a region + * for that purpose MUST go through this helper - independent re-implementations + * risk desyncing the write and read keys and silently losing grants. + */ +export function getAgeAssuranceRegionConfigForGeolocation( + config: AppBskyAgeassuranceDefs.Config, + geolocation: Geolocation, +): AppBskyAgeassuranceDefs.ConfigRegion | undefined { + return getAgeAssuranceRegionConfig(config, { + countryCode: geolocation.countryCode ?? '', + regionCode: geolocation.regionCode, + }) +} /** * Get age assurance region config based on geolocation, with fallback to * app defaults if no region config is found. * - * See {@link getAgeAssuranceRegionConfig} for the generic option, which can - * return undefined if the geolocation does not match any AA region. + * See {@link getAgeAssuranceRegionConfigForGeolocation} for the generic option, + * which can return undefined if the geolocation does not match any AA region. */ export function getAgeAssuranceRegionConfigWithFallback( config: AppBskyAgeassuranceDefs.Config, geolocation: Geolocation, ): AppBskyAgeassuranceDefs.ConfigRegion { - const region = getAgeAssuranceRegionConfig(config, { - countryCode: geolocation.countryCode ?? '', - regionCode: geolocation.regionCode, - }) + return ( + getAgeAssuranceRegionConfigForGeolocation(config, geolocation) || + FALLBACK_REGION_CONFIG + ) +} - return region || FALLBACK_REGION_CONFIG +/** + * Returns the verification methods permitted for a region *in addition to* the + * always-supported KWS flow. Empty when the region doesn't specify any (the + * historical KWS-only behavior). + */ +export function getRegionAdditionalVerificationMethods( + region: AppBskyAgeassuranceDefs.ConfigRegion, +): NonNullable< + AppBskyAgeassuranceDefs.ConfigRegion['additionalVerificationMethods'] +> { + return region.additionalVerificationMethods ?? [] +} + +/** + * Whether a region permits satisfying age assurance via the native on-device + * age APIs (Apple Declared Age Range / Google Play Age Signals). + */ +export function regionAllowsDeviceVerification( + region: AppBskyAgeassuranceDefs.ConfigRegion, +): boolean { + return getRegionAdditionalVerificationMethods(region).includes('device') +} + +/** + * Builds the cache key for a region's device signals — a `country[-region]` + * string (e.g. `US-TX` or `GB`). This is the key under which on-device + * assurance is stored and read back, which is what binds a grant to its capture + * region. + */ +export function createRegionKey(region: { + countryCode: string + regionCode?: string +}): string { + return region.regionCode + ? `${region.countryCode}-${region.regionCode}` + : region.countryCode +} + +/** + * Derives age assurance data from native device signals for the given region, + * but only when the region permits device verification. The signals are + * expected to already be resolved to the user's current region (see + * `getDeviceSignalsFromCacheForRegion`), so a grant captured in another region + * won't reach here. + * + * The OS-provided `lowerBound` is the minimum age the platform will attest to, + * which maps onto the `assuredAge` input of the rule engine (i.e. + * `IfAssuredOverAge`/`IfAssuredUnderAge` rules). + * + * Always returns an object (so callers can spread it unconditionally); fields + * are populated only when device verification applies and the OS provided + * usable data. + */ +export function getAgeAssuranceDataFromDeviceSignals( + region: AppBskyAgeassuranceDefs.ConfigRegion, + deviceSignals: AgeRange.AgeRangeResponse | undefined, +): { + assuredAge?: number +} { + if (!regionAllowsDeviceVerification(region)) return {} + const lowerBound = deviceSignals?.lowerBound + return { + assuredAge: typeof lowerBound === 'number' ? lowerBound : undefined, + } +} + +/** + * Ranks access levels from most to least restrictive so we can compare two + * outcomes. Higher number = more access. + */ +const ACCESS_RANK: Record = { + [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 + * the device-derived `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. + * + * `deviceSignals` must be passed so the baseline matches the user's *real* + * computed access (see `computeAgeAssuranceState`, which derives `assuredAge` + * the same way). Without it, a device-assured user's baseline would compute + * lower than their actual access and we'd claim a birthdate update helps when + * it can't - and account-date rules would flip on when they're really skipped + * (the engine bypasses them whenever `assuredAge` is set). + */ +export function canBirthdateUpdateIncreaseAccess({ + region, + metadata, + deviceSignals, +}: { + region: AppBskyAgeassuranceDefs.ConfigRegion + metadata?: AgeAssuranceMetadata + deviceSignals?: AgeRange.AgeRangeResponse +}): boolean { + const {assuredAge} = getAgeAssuranceDataFromDeviceSignals( + region, + deviceSignals, + ) + const baseline = computeAgeAssuranceRegionAccess(region, { + accountCreatedAt: metadata?.accountCreatedAt, + declaredAge: metadata?.declaredAge, + 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([region.minAccessAge]) + for (const rule of region.rules) { + if ( + AppBskyAgeassuranceDefs.isConfigRegionRuleIfDeclaredOverAge(rule) || + AppBskyAgeassuranceDefs.isConfigRegionRuleIfDeclaredUnderAge(rule) + ) { + thresholds.add(rule.age) + } + } + const candidates = new Set() + 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, + }) + const rank = ACCESS_RANK[result?.access ?? AgeAssuranceAccess.Unknown] + const overMin = declaredAge >= region.minAccessAge + if (rank > baselineRank || (overMin && !baselineOverMin)) return true + } + + return false } /** @@ -47,10 +243,7 @@ export function useAgeAssuranceRegionConfig() { return useMemo(() => { if (!config) return // use generic helper, we want to potentially return undefined - return getAgeAssuranceRegionConfig(config, { - countryCode: geolocation.countryCode ?? '', - regionCode: geolocation.regionCode, - }) + return getAgeAssuranceRegionConfigForGeolocation(config, geolocation) }, [config, geolocation]) } @@ -101,10 +294,12 @@ export function computeAgeAssuranceFlags({ state, regionConfig, metadata, + deviceSignals, }: { state: AgeAssuranceState regionConfig: AppBskyAgeassuranceDefs.ConfigRegion metadata?: AgeAssuranceMetadata + deviceSignals?: AgeRange.AgeRangeResponse }): AgeAssuranceFlags { const isAgeRestricted = state.access !== AgeAssuranceAccess.Full const chatDisabled = isAgeRestricted @@ -120,14 +315,44 @@ export function computeAgeAssuranceFlags({ : false const adultContentDisabled = state.access !== AgeAssuranceAccess.Full || isDeclaredUnderAdultAge + const allowsDeviceVerification = + DEVICE_SIGNALS_SUPPORTED && regionAllowsDeviceVerification(regionConfig) + const hasSharedDeviceSignals = deviceSignals?.lowerBound !== undefined return { isAgeRestricted, adultContentDisabled, chatDisabled, groupChatDisabled, + hasDeclaredAge: metadata?.declaredAge !== undefined, isDeclaredUnderAdultAge, isOverRegionMinAccessAge, isOverAppMinAccessAge, + allowsDeviceVerification, + hasSharedDeviceSignals, } } + +const USRegionCodeToRegionName: {[regionCode: string]: string} = + Object.fromEntries( + Object.entries(USRegionNameToRegionCode).map(([name, code]) => [ + code, + name, + ]), + ) +export function createGeolocationString( + geolocation: Geolocation, + appLang: string, +): string | undefined { + const {countryCode, regionCode} = geolocation + if (!countryCode) return undefined + const country = regionName(countryCode, appLang) + // If `regionName` couldn't resolve a real name and fell through to the raw + // code, we'd rather show nothing than a bare ISO code in the prose. + if (country === countryCode) return undefined + if (regionCode && countryCode === 'US') { + const state = USRegionCodeToRegionName[regionCode] + if (state) return `${state}, ${country}` + } + return country +} diff --git a/src/components/ageAssurance/AgeAssuranceAccountCard.tsx b/src/components/ageAssurance/AgeAssuranceAccountCard.tsx index 3f4f1f98f6..8833e75a00 100644 --- a/src/components/ageAssurance/AgeAssuranceAccountCard.tsx +++ b/src/components/ageAssurance/AgeAssuranceAccountCard.tsx @@ -2,7 +2,6 @@ import {View} from 'react-native' import {Trans, useLingui} from '@lingui/react/macro' import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo' -import {regionName} from '#/locale/helpers' import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf' import {Admonition} from '#/components/Admonition' import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog' @@ -13,49 +12,22 @@ import { useDialogControl, } from '#/components/ageAssurance/AgeAssuranceInitDialog' import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' -import {Button, ButtonText} from '#/components/Button' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog' import {Divider} from '#/components/Divider' +import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield' import {createStaticClick, InlineLinkText} from '#/components/Link' +import {Loader} from '#/components/Loader' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' import {useAgeAssurance} from '#/ageAssurance' import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess' +import {useAgeAssuranceVerificationFlow} from '#/ageAssurance/useVerificationFlow' +import {createGeolocationString} from '#/ageAssurance/util' import {useAnalytics} from '#/analytics' import {IS_NATIVE} from '#/env' -import { - type Geolocation, - useDeviceGeolocationApi, - useGeolocation, -} from '#/geolocation' -import {USRegionNameToRegionCode} from '#/geolocation/util' -import {device, useStorage} from '#/storage' - -const USRegionCodeToRegionName: {[regionCode: string]: string} = - Object.fromEntries( - Object.entries(USRegionNameToRegionCode).map(([name, code]) => [ - code, - name, - ]), - ) - -function formatRegion( - geolocation: Geolocation, - appLang: string, -): string | undefined { - const {countryCode, regionCode} = geolocation - if (!countryCode) return undefined - const country = regionName(countryCode, appLang) - // If `regionName` couldn't resolve a real name and fell through to the raw - // code, we'd rather show nothing than a bare ISO code in the prose. - if (country === countryCode) return undefined - if (regionCode && countryCode === 'US') { - const state = USRegionCodeToRegionName[regionCode] - if (state) return `${state}, ${country}` - } - return country -} +import {useDeviceGeolocationApi, useGeolocation} from '#/geolocation' export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) { const aa = useAgeAssurance() @@ -91,6 +63,15 @@ function Inner({style}: ViewStyleProp & {}) { const diff = lastInitiatedAt ? dateDiff(lastInitiatedAt, new Date(), 'down') : null + const { + onPressVerify, + openInitDialog, + isVerifying, + verifyCta, + deviceSignalsFailed, + } = useAgeAssuranceVerificationFlow({initDialogControl: control}) + const useDeviceSignals = + aa.flags.allowsDeviceVerification && !deviceSignalsFailed return ( <> @@ -169,26 +150,38 @@ function Inner({style}: ViewStyleProp & {}) { : [a.gap_md], ]}> - {lastInitiatedAt && timeAgo && diff ? ( + {useDeviceSignals ? ( + + + Sharing your age data uses information stored on your + device, and will therefore only work on this device. + Alternatively,{' '} + { + openInitDialog() + })}> + you can use our trusted partner, KWS + + , to complete your verification and enable access on all + platforms. + + + ) : lastInitiatedAt && timeAgo && diff ? ( @@ -258,8 +250,7 @@ function RegionNotice() { {isGPS ? ( Based on your device's location, we think you're in{' '} - {region}. This - estimate may be inaccurate if you're using a VPN. + {region}. ) : ( @@ -268,20 +259,20 @@ function RegionNotice() { estimate may be inaccurate if you're using a VPN. )} - - )} - - {IS_NATIVE && ( - - - { - locationControl.open() - })}> - Tap here to update your location with GPS. - - + {IS_NATIVE && ( + + {' '} + + { + locationControl.open() + })}> + Tap here to update your location with GPS. + + + + )} )} diff --git a/src/env/index.ts b/src/env/index.ts index 13ae3dcc7d..994f3002ab 100644 --- a/src/env/index.ts +++ b/src/env/index.ts @@ -5,12 +5,23 @@ import {BUNDLE_IDENTIFIER, IS_TESTFLIGHT, RELEASE_VERSION} from '#/env/common' export * from '#/env/common' -// for some reason Platform.OS === 'ios' AND Platform.Version is undefined in our CI unit tests -sfn -const iOSMajorVersion = +/** + * The major version number of the current iOS device (e.g. `26` for iOS 26.x), + * or `0` on non-iOS platforms. + * + * Note: for some reason Platform.OS === 'ios' AND Platform.Version is undefined + * in our CI unit tests -sfn + */ +export const IOS_MAJOR_VERSION: number = Platform.OS === 'ios' && typeof Platform.Version === 'string' ? parseInt(Platform.Version.split('.')[0], 10) : 0 -const androidPlatformVersion = +/** + * The Android API level of the current device (e.g. `23` for Android 6.0), or + * `0` on non-Android platforms. Note this is the API level, not the marketing + * version. + */ +export const ANDROID_API_LEVEL: number = Platform.OS === 'android' && typeof Platform.Version === 'number' ? Platform.Version : 0 @@ -52,8 +63,7 @@ export const IS_WEB_FIREFOX: boolean = false */ export const IS_HIGH_DPI: boolean = true // ideally we'd use isLiquidGlassAvailable() from expo-glass-effect but checking iOS version is good enough for now -export const IS_LIQUID_GLASS: boolean = iOSMajorVersion >= 26 +export const IS_LIQUID_GLASS: boolean = IOS_MAJOR_VERSION >= 26 // So we can avoid attempting on-device translation when we know it's unsupported. export const IS_TRANSLATION_SUPPORTED: boolean = - (IS_IOS && iOSMajorVersion >= 18) || - (IS_ANDROID && androidPlatformVersion > 22) + (IS_IOS && IOS_MAJOR_VERSION >= 18) || (IS_ANDROID && ANDROID_API_LEVEL > 22) diff --git a/src/env/index.web.ts b/src/env/index.web.ts index 68604a2e5b..c130ab8146 100644 --- a/src/env/index.web.ts +++ b/src/env/index.web.ts @@ -14,6 +14,17 @@ export const APP_VERSION = RELEASE_VERSION */ export const APP_METADATA = `${BUNDLE_IDENTIFIER.slice(0, 7)} (${__DEV__ ? 'dev' : 'prod'})` +/** + * The major version number of the current iOS device, or `0` on non-iOS + * platforms (always `0` on web). + */ +export const IOS_MAJOR_VERSION: number = 0 +/** + * The Android API level of the current device, or `0` on non-Android platforms + * (always `0` on web). + */ +export const ANDROID_API_LEVEL: number = 0 + /** * Platform detection */ diff --git a/src/geolocation/debug.ts b/src/geolocation/debug.ts index 0d2564c305..1b50343cac 100644 --- a/src/geolocation/debug.ts +++ b/src/geolocation/debug.ts @@ -5,8 +5,8 @@ import {type Geolocation} from '#/geolocation/types' const localEnabled = false export const enabled = IS_DEV && (localEnabled || aaDebug.geolocation) export const geolocation: Geolocation = aaDebug.geolocation ?? { - countryCode: 'US', - regionCode: 'TX', + countryCode: 'AA', + regionCode: undefined, } const deviceLocalEnabled = false @@ -14,8 +14,8 @@ export const deviceGeolocation: Geolocation | undefined = aaDebug.deviceGeolocation || (deviceLocalEnabled ? { - countryCode: 'US', - regionCode: 'TX', + countryCode: 'AA', + regionCode: undefined, } : undefined) diff --git a/src/geolocation/index.tsx b/src/geolocation/index.tsx index c9dc0cb1ed..37d9d4b155 100644 --- a/src/geolocation/index.tsx +++ b/src/geolocation/index.tsx @@ -22,6 +22,8 @@ export * from '#/geolocation/types' const GeolocationContext = createContext({ countryCode: undefined, regionCode: undefined, + serviceGeolocation: undefined, + deviceGeolocation: undefined, }) const DeviceGeolocationAPIContext = createContext<{ @@ -44,7 +46,17 @@ export function Provider({children}: {children: ReactNode}) { 'deviceGeolocation', ]) const geolocation = useMemo(() => { - return mergeGeolocations(deviceGeolocation, geolocationService) + const geo = mergeGeolocations(deviceGeolocation, geolocationService) + // create new objects to avoid cyclical references + geo.serviceGeolocation = { + countryCode: geolocationService?.countryCode, + regionCode: geolocationService?.regionCode, + } + geo.deviceGeolocation = { + countryCode: deviceGeolocation?.countryCode, + regionCode: deviceGeolocation?.regionCode, + } + return geo }, [deviceGeolocation, geolocationService]) useEffect(() => { diff --git a/src/geolocation/types.ts b/src/geolocation/types.ts index 80848730c9..979b4dd742 100644 --- a/src/geolocation/types.ts +++ b/src/geolocation/types.ts @@ -1,4 +1,6 @@ export type Geolocation = { countryCode: string | undefined regionCode: string | undefined + serviceGeolocation?: Geolocation + deviceGeolocation?: Geolocation }