[AAv2] Integrate on-device APIs (#9564)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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: [
|
||||
|
||||
+3
-2
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
Generated
+51
-36
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
<Trans>
|
||||
If you believe your birthdate is incorrect, you can update it by{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Click here to update your birthdate`)}
|
||||
label={l`Click here to update your birthdate`}
|
||||
style={[textStyles]}
|
||||
{...createStaticClick(() => {
|
||||
ax.metric('ageAssurance:noAccessScreen:openBirthdateDialog', {})
|
||||
@@ -120,20 +129,7 @@ export function NoAccessScreen() {
|
||||
|
||||
{orgAdmonition}
|
||||
</>
|
||||
) : (
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
If you believe your birthdate is incorrect, please{' '}
|
||||
<SimpleInlineLinkText
|
||||
to={createSupportLink({code: SupportCode.AA_BIRTHDATE})}
|
||||
label={_(msg`Click here to contact our support team`)}
|
||||
style={[textStyles]}>
|
||||
contact our support team
|
||||
</SimpleInlineLinkText>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
)
|
||||
) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -164,80 +160,147 @@ export function NoAccessScreen() {
|
||||
<AgeAssuranceBadge />
|
||||
</View>
|
||||
|
||||
{hasDeclaredAge ? (
|
||||
<>
|
||||
{isAARegion ? (
|
||||
<>
|
||||
<View style={[a.gap_lg]}>
|
||||
{hasDeclaredAge ? (
|
||||
<>
|
||||
{isAARegion ? (
|
||||
<>
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>Hey there!</Trans>
|
||||
</Text>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
You are accessing Bluesky from a region that legally
|
||||
requires us to verify your age before allowing you
|
||||
to access the app.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{region && geolocationString && (
|
||||
<Text style={[textStyles]}>
|
||||
{isUsingGPS ? (
|
||||
<Trans>
|
||||
Based on your device's location, we think you're
|
||||
in{' '}
|
||||
<Text style={[textStyles, a.font_bold]}>
|
||||
{geolocationString}
|
||||
</Text>
|
||||
.
|
||||
</Trans>
|
||||
) : (
|
||||
<>
|
||||
<Trans>
|
||||
Based on your network, we think you're in{' '}
|
||||
<Text style={[textStyles, a.font_bold]}>
|
||||
{geolocationString}
|
||||
</Text>
|
||||
. This estimate may be inaccurate if you're
|
||||
using a VPN.
|
||||
</Trans>
|
||||
</>
|
||||
)}
|
||||
{IS_NATIVE && (
|
||||
<>
|
||||
{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={l`Update your location`}
|
||||
{...createStaticClick(() => {
|
||||
locationControl.open()
|
||||
})}
|
||||
style={[textStyles]}>
|
||||
<Trans>
|
||||
Tap here to update your location with GPS.
|
||||
</Trans>
|
||||
</SimpleInlineLinkText>
|
||||
<DeviceLocationRequestDialog
|
||||
control={locationControl}
|
||||
onLocationAcquired={props => {
|
||||
const access =
|
||||
computeAgeAssuranceRegionAccess(
|
||||
props.geolocation,
|
||||
)
|
||||
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',
|
||||
})
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!aa.flags.isOverRegionMinAccessAge && (
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
Unfortunately, your declared age indicates that
|
||||
you are not old enough to access Bluesky in your
|
||||
region.
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!isBlocked && birthdateUpdateText}
|
||||
</View>
|
||||
|
||||
{aa.flags.isOverRegionMinAccessAge && <AccessSection />}
|
||||
</>
|
||||
) : (
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>Hey there!</Trans>
|
||||
</Text>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
You are accessing Bluesky from a region that legally
|
||||
requires us to verify your age before allowing you to
|
||||
access the app.
|
||||
Unfortunately, the birthdate you have saved to your
|
||||
profile makes you too young to access Bluesky.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{!aa.flags.isOverRegionMinAccessAge && (
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
Unfortunately, your declared age indicates that you
|
||||
are not old enough to access Bluesky in your region.
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!isBlocked && birthdateUpdateText}
|
||||
{birthdateUpdateText}
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>Hi there!</Trans>
|
||||
</Text>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
In order to provide an age-appropriate experience, we need
|
||||
to know your birthdate. This is a one-time thing, and your
|
||||
data will be kept private.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
Set your birthdate below and we'll get you back to posting
|
||||
and exploring in no time!
|
||||
</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
color="primary"
|
||||
size="large"
|
||||
label={l`Click here to update your birthdate`}
|
||||
onPress={() => birthdateControl.open()}>
|
||||
<ButtonText>
|
||||
<Trans>Add your birthdate</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
{aa.flags.isOverRegionMinAccessAge && <AccessSection />}
|
||||
</>
|
||||
) : (
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
Unfortunately, the birthdate you have saved to your
|
||||
profile makes you too young to access Bluesky.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{birthdateUpdateText}
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>Hi there!</Trans>
|
||||
</Text>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
In order to provide an age-appropriate experience, we need
|
||||
to know your birthdate. This is a one-time thing, and your
|
||||
data will be kept private.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
Set your birthdate below and we'll get you back to posting
|
||||
and exploring in no time!
|
||||
</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
color="primary"
|
||||
size="large"
|
||||
label={_(msg`Click here to update your birthdate`)}
|
||||
onPress={() => birthdateControl.open()}>
|
||||
<ButtonText>
|
||||
<Trans>Add your birthdate</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
{orgAdmonition}
|
||||
</View>
|
||||
)}
|
||||
{orgAdmonition}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[a.pt_lg, a.gap_xl, {maxWidth: 280}]}>
|
||||
<Logo width={120} textFill={t.atoms.text.color} />
|
||||
@@ -251,7 +314,7 @@ export function NoAccessScreen() {
|
||||
<Trans>
|
||||
To log out,{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Click here to log out`)}
|
||||
label={l`Click here to log out`}
|
||||
{...createStaticClick(() => {
|
||||
onPressLogout()
|
||||
})}
|
||||
@@ -260,7 +323,7 @@ export function NoAccessScreen() {
|
||||
</SimpleInlineLinkText>
|
||||
. Or if you’d prefer, you can{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Click here to delete your account`)}
|
||||
label={l`Click here to delete your account`}
|
||||
{...createStaticClick(() => {
|
||||
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{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Contact our moderation team`)}
|
||||
label={l`Contact our moderation team`}
|
||||
{...createStaticClick(() => {
|
||||
appealControl.open()
|
||||
ax.metric('ageAssurance:appealDialogOpen', {})
|
||||
@@ -345,26 +414,38 @@ function AccessSection() {
|
||||
<>
|
||||
<View style={[a.gap_md]}>
|
||||
<Button
|
||||
label={_(msg`Verify now`)}
|
||||
label={verifyCta}
|
||||
size="large"
|
||||
color={hasInitiated ? 'secondary' : 'primary'}
|
||||
onPress={() => {
|
||||
control.open()
|
||||
ax.metric('ageAssurance:initDialogOpen', {
|
||||
hasInitiatedPreviously: hasInitiated,
|
||||
})
|
||||
}}>
|
||||
<ButtonIcon icon={ShieldIcon} />
|
||||
<ButtonText>
|
||||
{hasInitiated ? (
|
||||
<Trans>Verify again</Trans>
|
||||
) : (
|
||||
<Trans>Verify now</Trans>
|
||||
)}
|
||||
</ButtonText>
|
||||
color={
|
||||
hasInitiated || aa.flags.hasSharedDeviceSignals
|
||||
? 'secondary'
|
||||
: 'primary'
|
||||
}
|
||||
disabled={isVerifying}
|
||||
onPress={() => void onPressVerify()}>
|
||||
<ButtonIcon icon={isVerifying ? Loader : ShieldIcon} />
|
||||
<ButtonText>{verifyCta}</ButtonText>
|
||||
</Button>
|
||||
|
||||
{lastInitiatedAt && timeAgo && diff ? (
|
||||
{useDeviceSignals ? (
|
||||
<Text
|
||||
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
Sharing your age data uses information stored on your
|
||||
device, and will therefore only work on this device.
|
||||
Alternatively,{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={l`Verify now using KWS`}
|
||||
{...createStaticClick(() => {
|
||||
openInitDialog()
|
||||
})}>
|
||||
you can use our trusted partner, KWS
|
||||
</SimpleInlineLinkText>
|
||||
, to complete your verification and enable access on all
|
||||
platforms.
|
||||
</Trans>
|
||||
</Text>
|
||||
) : lastInitiatedAt && timeAgo && diff ? (
|
||||
<Text
|
||||
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}
|
||||
title={i18n.date(lastInitiatedAt, {
|
||||
@@ -380,56 +461,12 @@ function AccessSection() {
|
||||
) : (
|
||||
<Text
|
||||
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Age assurance only takes a few minutes</Trans>
|
||||
<Trans>Age assurance only takes a few minutes.</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
<View style={[a.gap_xs]}>
|
||||
{IS_NATIVE && (
|
||||
<>
|
||||
<Admonition>
|
||||
<Trans>
|
||||
Is your location not accurate?{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Update your location`)}
|
||||
{...createStaticClick(() => {
|
||||
locationControl.open()
|
||||
})}>
|
||||
Tap here to update your location with GPS.
|
||||
</SimpleInlineLinkText>
|
||||
</Trans>
|
||||
</Admonition>
|
||||
|
||||
<DeviceLocationRequestDialog
|
||||
control={locationControl}
|
||||
onLocationAcquired={props => {
|
||||
const access = computeAgeAssuranceRegionAccess(
|
||||
props.geolocation,
|
||||
)
|
||||
if (access !== aa.Access.Full) {
|
||||
props.disableDialogAction()
|
||||
props.setDialogError(
|
||||
_(
|
||||
msg`We're sorry, but based on your device's location, you are currently located in a region that requires age assurance.`,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
props.closeDialog(() => {
|
||||
// set this after close!
|
||||
setDeviceGeolocation(props.geolocation)
|
||||
Toast.show(_(msg`Thanks! You're all set.`), {
|
||||
type: 'success',
|
||||
})
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
+191
-7
@@ -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<AgeAssuranceDeviceSignals>(
|
||||
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<AgeAssuranceDeviceSignals | undefined>(
|
||||
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<AgeAssuranceServerData>({
|
||||
config: undefined,
|
||||
@@ -534,6 +714,7 @@ const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
|
||||
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 (
|
||||
<AgeAssuranceServerDataContext.Provider value={ctx}>
|
||||
|
||||
+49
-12
@@ -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<T>(data: T) {
|
||||
await new Promise(y => setTimeout(y, 500)) // simulate network
|
||||
return data
|
||||
|
||||
@@ -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}
|
||||
</AgeAssuranceStateContext.Provider>
|
||||
)
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
+238
-13
@@ -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<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
|
||||
* 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<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,
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
]}>
|
||||
<Button
|
||||
label={l`Verify now`}
|
||||
label={verifyCta}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color={hasInitiated ? 'secondary' : 'primary'}
|
||||
onPress={() => {
|
||||
control.open()
|
||||
ax.metric('ageAssurance:initDialogOpen', {
|
||||
hasInitiatedPreviously: hasInitiated,
|
||||
})
|
||||
}}>
|
||||
<ButtonText>
|
||||
{hasInitiated ? (
|
||||
<Trans>Verify again</Trans>
|
||||
) : (
|
||||
<Trans>Verify now</Trans>
|
||||
)}
|
||||
</ButtonText>
|
||||
color={
|
||||
hasInitiated || aa.flags.hasSharedDeviceSignals
|
||||
? 'secondary'
|
||||
: 'primary'
|
||||
}
|
||||
disabled={isVerifying}
|
||||
onPress={() => void onPressVerify()}>
|
||||
<ButtonIcon icon={isVerifying ? Loader : ShieldIcon} />
|
||||
<ButtonText>{verifyCta}</ButtonText>
|
||||
</Button>
|
||||
|
||||
{lastInitiatedAt && timeAgo && diff ? (
|
||||
{useDeviceSignals ? (
|
||||
<Text
|
||||
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
Sharing your age data uses information stored on your
|
||||
device, and will therefore only work on this device.
|
||||
Alternatively,{' '}
|
||||
<InlineLinkText
|
||||
label={l`Verify now using KWS`}
|
||||
{...createStaticClick(() => {
|
||||
openInitDialog()
|
||||
})}>
|
||||
you can use our trusted partner, KWS
|
||||
</InlineLinkText>
|
||||
, to complete your verification and enable access on all
|
||||
platforms.
|
||||
</Trans>
|
||||
</Text>
|
||||
) : lastInitiatedAt && timeAgo && diff ? (
|
||||
<Text
|
||||
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}
|
||||
title={i18n.date(lastInitiatedAt, {
|
||||
@@ -220,13 +213,12 @@ function RegionNotice() {
|
||||
const {t: l, i18n} = useLingui()
|
||||
const aa = useAgeAssurance()
|
||||
const geolocation = useGeolocation()
|
||||
const [deviceGeolocation] = useStorage(device, ['deviceGeolocation'])
|
||||
const {setDeviceGeolocation} = useDeviceGeolocationApi()
|
||||
const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess()
|
||||
const locationControl = Dialog.useDialogControl()
|
||||
|
||||
const region = formatRegion(geolocation, i18n.locale)
|
||||
const isGPS = !!deviceGeolocation?.countryCode && IS_NATIVE
|
||||
const region = createGeolocationString(geolocation, i18n.locale)
|
||||
const isGPS = !!geolocation.deviceGeolocation?.countryCode && IS_NATIVE
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -258,8 +250,7 @@ function RegionNotice() {
|
||||
{isGPS ? (
|
||||
<Trans>
|
||||
Based on your device's location, we think you're in{' '}
|
||||
<Text style={[a.text_sm, a.font_bold]}>{region}</Text>. This
|
||||
estimate may be inaccurate if you're using a VPN.
|
||||
<Text style={[a.text_sm, a.font_bold]}>{region}</Text>.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
@@ -268,20 +259,20 @@ function RegionNotice() {
|
||||
estimate may be inaccurate if you're using a VPN.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{IS_NATIVE && (
|
||||
<Text style={[a.text_sm, a.leading_snug]}>
|
||||
<Trans>
|
||||
<InlineLinkText
|
||||
label={l`Update your location`}
|
||||
{...createStaticClick(() => {
|
||||
locationControl.open()
|
||||
})}>
|
||||
Tap here to update your location with GPS.
|
||||
</InlineLinkText>
|
||||
</Trans>
|
||||
{IS_NATIVE && (
|
||||
<Text style={[a.text_sm, a.leading_snug]}>
|
||||
{' '}
|
||||
<Trans>
|
||||
<InlineLinkText
|
||||
label={l`Update your location`}
|
||||
{...createStaticClick(() => {
|
||||
locationControl.open()
|
||||
})}>
|
||||
Tap here to update your location with GPS.
|
||||
</InlineLinkText>
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
|
||||
Vendored
+16
-6
@@ -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)
|
||||
|
||||
Vendored
+11
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ export * from '#/geolocation/types'
|
||||
const GeolocationContext = createContext<Geolocation>({
|
||||
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(() => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export type Geolocation = {
|
||||
countryCode: string | undefined
|
||||
regionCode: string | undefined
|
||||
serviceGeolocation?: Geolocation
|
||||
deviceGeolocation?: Geolocation
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user