[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.security.application-groups': 'group.app.bsky',
|
||||||
'com.apple.developer.usernotifications.communication': true,
|
'com.apple.developer.usernotifications.communication': true,
|
||||||
// 'com.apple.developer.device-information.user-assigned-device-name': true,
|
// 'com.apple.developer.device-information.user-assigned-device-name': true,
|
||||||
|
'com.apple.developer.declared-age-range': true,
|
||||||
},
|
},
|
||||||
privacyManifests: {
|
privacyManifests: {
|
||||||
NSPrivacyCollectedDataTypes: [
|
NSPrivacyCollectedDataTypes: [
|
||||||
|
|||||||
+3
-2
@@ -93,8 +93,8 @@
|
|||||||
"prettier": "prettier --check ."
|
"prettier": "prettier --check ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "0.20.22",
|
"@atproto/api": "0.20.25",
|
||||||
"@atproto/syntax": "0.6.3",
|
"@atproto/syntax": "0.6.4",
|
||||||
"@bitdrift/react-native": "^0.6.8",
|
"@bitdrift/react-native": "^0.6.8",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@bsky.app/alf": "^0.1.14",
|
"@bsky.app/alf": "^0.1.14",
|
||||||
@@ -156,6 +156,7 @@
|
|||||||
"emoji-regex": "^10.4.0",
|
"emoji-regex": "^10.4.0",
|
||||||
"eventemitter3": "^5.0.1",
|
"eventemitter3": "^5.0.1",
|
||||||
"expo": "54.0.34",
|
"expo": "54.0.34",
|
||||||
|
"expo-age-range": "0.2.18",
|
||||||
"expo-application": "~7.0.8",
|
"expo-application": "~7.0.8",
|
||||||
"expo-blur": "~15.0.8",
|
"expo-blur": "~15.0.8",
|
||||||
"expo-build-properties": "~1.0.10",
|
"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:
|
patchedDependencies:
|
||||||
'@sentry/react-native@6.20.0': 1d48e4d5178f5684eb33262299e7eed283bf9601f79b9ae1af63a7f607d3483a
|
'@sentry/react-native@6.20.0': 1d48e4d5178f5684eb33262299e7eed283bf9601f79b9ae1af63a7f607d3483a
|
||||||
|
expo-age-range@0.2.18: c325225749993424461eeece1d97a99b19c00da2ebc958a73c12e4eca0c39306
|
||||||
expo-glass-effect@55.0.8: 6c9fa5b104e53b87df4e17b320acb3b94d12ac90c0101190045dd620681efff0
|
expo-glass-effect@55.0.8: 6c9fa5b104e53b87df4e17b320acb3b94d12ac90c0101190045dd620681efff0
|
||||||
expo-haptics@15.0.8: b33910ba2753c4eccdc885b449e6e33aa90b9cefa75ca8639762a26013141410
|
expo-haptics@15.0.8: b33910ba2753c4eccdc885b449e6e33aa90b9cefa75ca8639762a26013141410
|
||||||
expo-image-picker@17.0.11: 47b28f6ddb8bcaa6483f8714c82daaa0001122f2b0dac6c05d19e98a61a6ceab
|
expo-image-picker@17.0.11: 47b28f6ddb8bcaa6483f8714c82daaa0001122f2b0dac6c05d19e98a61a6ceab
|
||||||
@@ -241,11 +242,11 @@ importers:
|
|||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@atproto/api':
|
'@atproto/api':
|
||||||
specifier: 0.20.22
|
specifier: 0.20.25
|
||||||
version: 0.20.22
|
version: 0.20.25
|
||||||
'@atproto/syntax':
|
'@atproto/syntax':
|
||||||
specifier: 0.6.3
|
specifier: 0.6.4
|
||||||
version: 0.6.3
|
version: 0.6.4
|
||||||
'@bitdrift/react-native':
|
'@bitdrift/react-native':
|
||||||
specifier: ^0.6.8
|
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)
|
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:
|
expo:
|
||||||
specifier: 54.0.34
|
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)
|
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:
|
expo-application:
|
||||||
specifier: ~7.0.8
|
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))
|
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:
|
graphql:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@atproto/api@0.20.22':
|
'@atproto/api@0.20.25':
|
||||||
resolution: {integrity: sha512-TdT9ktYc0FMmMZ8HjkMz13QcSCcYiesCGN81mFZd5owQASHVM9GBtAAmzpkrPyZhUkiIjgh/nUHk8xWVu+4dVA==}
|
resolution: {integrity: sha512-PTwt6X0U45C9vikr8NRi0Qzcep9VqJet52HtfcxgG9R7ofRHxqLVPzfVuN/f2xOaYM6H5IG/Nk1E9kXZcI0mSQ==}
|
||||||
engines: {node: '>=22'}
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
'@atproto/common-web@0.5.2':
|
'@atproto/common-web@0.5.3':
|
||||||
resolution: {integrity: sha512-oO0JEvM7MM7iXMngq6V51IJlzBZFhFJoDjnGnQT75EO94/8Q5hnM/LP+a8KyWjcBcpcSZwQ0bukNv9phZONdGA==}
|
resolution: {integrity: sha512-FkMhOcNv1y7r5984+zXB+uMN4zJ4QFLEbZrYyQo6bNCf/Kfav/pEHXXENlaZEOweq2NYXf+tr2giMssBuM9u5A==}
|
||||||
engines: {node: '>=22'}
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
'@atproto/lex-data@0.1.3':
|
'@atproto/lex-data@0.1.4':
|
||||||
resolution: {integrity: sha512-ysqMYW6cIKce52/+EIbTa+I4pLqZQSBP9aGImN88vAQtd1oZBKPmut6dQk00xSQ8PW9REJRuIHNSFAF4HD+fsw==}
|
resolution: {integrity: sha512-f9U95sk0zUtxHktvK59peU+Shd0cIUYV68p//GS7sfdkAxUIeaspvX6CY+Quv9Oa4aozmsXI52SjaNn1wuU8RQ==}
|
||||||
engines: {node: '>=22'}
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
'@atproto/lex-json@0.1.2':
|
'@atproto/lex-json@0.1.3':
|
||||||
resolution: {integrity: sha512-58nIjoWX0c8T5fcoPPmIaShxKZgfPMhNmrprtR7GuN4PzyMwm59A3CLlKAzzR+vjI3IidEMU+enpLuwUT8L+Nw==}
|
resolution: {integrity: sha512-Ch2w9bCLFOwWINFFxpZo6DBW7+ZxkehciU3P8N94gSyENLe3/5WWXHdHvkiH7EXZrpI7fKY8nVWn4GIL0ttqxw==}
|
||||||
engines: {node: '>=22'}
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
'@atproto/lexicon@0.7.3':
|
'@atproto/lexicon@0.7.4':
|
||||||
resolution: {integrity: sha512-WP6ct2rjNCKSJN/VFc+8x6JQ7PvRMlfHUlmQM5hzvE3a6nOjm6kwSicjSIV/QldurdRGJ4FCQZ3uZu9Wqt4SxQ==}
|
resolution: {integrity: sha512-ulk4RGwMBp4vbkTOZcIwZJeTEPlj3PImOnx9TqxSxMDnBdySxarpd0Aprg7+iAvGyKTsMcrYHpeoLukZaquk0A==}
|
||||||
engines: {node: '>=22'}
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
'@atproto/syntax@0.6.3':
|
'@atproto/syntax@0.6.4':
|
||||||
resolution: {integrity: sha512-io7Ck4o+40iFXhetHYoEtok2gZ8cWcJ1yRftEHe5AmAF0dSGcYmclbx5GatVew59taw+eSG7Ty5D3llop/0BhA==}
|
resolution: {integrity: sha512-ELgpShRGMF65cvLXcoMCZFL0HBzy67yz/Nlhox3yOtTh120B09KjjvZYXhMysVog3nUJqv2EW5rf1VRYCeM/hw==}
|
||||||
engines: {node: '>=22'}
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
'@atproto/xrpc@0.8.2':
|
'@atproto/xrpc@0.8.3':
|
||||||
resolution: {integrity: sha512-geLqJwazZuCGnae68KZppciS8DujhGvYtpc/aAj16XpHkUCf5Ds64H1IPrV0uv7SFvd/IAuMZAXZS4GIpfp1iw==}
|
resolution: {integrity: sha512-0gUGN71+bSqV3PI5U4cQ4fdnw4nI0eD6czHDQ6jB7hMYBwNcJpwAyfA9W+C1G1wayIvP0SIap/M0H/pSKDWFIQ==}
|
||||||
engines: {node: '>=22'}
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
'@babel/code-frame@7.10.4':
|
'@babel/code-frame@7.10.4':
|
||||||
@@ -5278,6 +5282,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==}
|
resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==}
|
||||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
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:
|
expo-application@7.0.8:
|
||||||
resolution: {integrity: sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==}
|
resolution: {integrity: sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -9464,51 +9474,51 @@ snapshots:
|
|||||||
|
|
||||||
'@0no-co/graphql.web@1.2.0': {}
|
'@0no-co/graphql.web@1.2.0': {}
|
||||||
|
|
||||||
'@atproto/api@0.20.22':
|
'@atproto/api@0.20.25':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@atproto/common-web': 0.5.2
|
'@atproto/common-web': 0.5.3
|
||||||
'@atproto/lexicon': 0.7.3
|
'@atproto/lexicon': 0.7.4
|
||||||
'@atproto/syntax': 0.6.3
|
'@atproto/syntax': 0.6.4
|
||||||
'@atproto/xrpc': 0.8.2
|
'@atproto/xrpc': 0.8.3
|
||||||
await-lock: 3.0.0
|
await-lock: 3.0.0
|
||||||
multiformats: 13.4.2
|
multiformats: 13.4.2
|
||||||
tlds: 1.261.0
|
tlds: 1.261.0
|
||||||
zod: 3.25.76
|
zod: 3.25.76
|
||||||
|
|
||||||
'@atproto/common-web@0.5.2':
|
'@atproto/common-web@0.5.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@atproto/lex-data': 0.1.3
|
'@atproto/lex-data': 0.1.4
|
||||||
'@atproto/lex-json': 0.1.2
|
'@atproto/lex-json': 0.1.3
|
||||||
'@atproto/syntax': 0.6.3
|
'@atproto/syntax': 0.6.4
|
||||||
zod: 3.25.76
|
zod: 3.25.76
|
||||||
|
|
||||||
'@atproto/lex-data@0.1.3':
|
'@atproto/lex-data@0.1.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
multiformats: 13.4.2
|
multiformats: 13.4.2
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
uint8arrays: 5.1.1
|
uint8arrays: 5.1.1
|
||||||
unicode-segmenter: 0.14.5
|
unicode-segmenter: 0.14.5
|
||||||
|
|
||||||
'@atproto/lex-json@0.1.2':
|
'@atproto/lex-json@0.1.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@atproto/lex-data': 0.1.3
|
'@atproto/lex-data': 0.1.4
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@atproto/lexicon@0.7.3':
|
'@atproto/lexicon@0.7.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@atproto/common-web': 0.5.2
|
'@atproto/common-web': 0.5.3
|
||||||
'@atproto/syntax': 0.6.3
|
'@atproto/syntax': 0.6.4
|
||||||
multiformats: 13.4.2
|
multiformats: 13.4.2
|
||||||
zod: 3.25.76
|
zod: 3.25.76
|
||||||
|
|
||||||
'@atproto/syntax@0.6.3':
|
'@atproto/syntax@0.6.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
iso-datestring-validator: 2.2.2
|
iso-datestring-validator: 2.2.2
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@atproto/xrpc@0.8.2':
|
'@atproto/xrpc@0.8.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@atproto/lexicon': 0.7.3
|
'@atproto/lexicon': 0.7.4
|
||||||
zod: 3.25.76
|
zod: 3.25.76
|
||||||
|
|
||||||
'@babel/code-frame@7.10.4':
|
'@babel/code-frame@7.10.4':
|
||||||
@@ -14773,6 +14783,11 @@ snapshots:
|
|||||||
jest-message-util: 29.7.0
|
jest-message-util: 29.7.0
|
||||||
jest-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)):
|
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:
|
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)
|
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
|
'unrs-resolver': true
|
||||||
patchedDependencies:
|
patchedDependencies:
|
||||||
'@sentry/react-native@6.20.0': patches/@sentry__react-native@6.20.0.patch
|
'@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-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-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
|
'expo-image-picker@17.0.11': patches/expo-image-picker@17.0.11.patch
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import {useCallback, useEffect} from 'react'
|
import {useCallback, useEffect} from 'react'
|
||||||
import {ScrollView, View} from 'react-native'
|
import {ScrollView, View} from 'react-native'
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {Trans} from '@lingui/react/macro'
|
|
||||||
|
|
||||||
import {
|
|
||||||
SupportCode,
|
|
||||||
useCreateSupportLink,
|
|
||||||
} from '#/lib/hooks/useCreateSupportLink'
|
|
||||||
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
||||||
import {useIsBirthdateUpdateAllowed} from '#/state/birthdate'
|
import {useIsBirthdateUpdateAllowed} from '#/state/birthdate'
|
||||||
import {useSessionApi} from '#/state/session'
|
import {useSessionApi} from '#/state/session'
|
||||||
@@ -27,6 +21,7 @@ import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRe
|
|||||||
import {Full as Logo} from '#/components/icons/Logo'
|
import {Full as Logo} from '#/components/icons/Logo'
|
||||||
import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield'
|
import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield'
|
||||||
import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
|
import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||||
import * as Toast from '#/components/Toast'
|
import * as Toast from '#/components/Toast'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
@@ -34,37 +29,51 @@ import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
|
|||||||
import {useAgeAssurance} from '#/ageAssurance'
|
import {useAgeAssurance} from '#/ageAssurance'
|
||||||
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
|
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
|
||||||
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
|
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
|
||||||
|
import {useAgeAssuranceVerificationFlow} from '#/ageAssurance/useVerificationFlow'
|
||||||
import {
|
import {
|
||||||
|
canBirthdateUpdateIncreaseAccess,
|
||||||
|
createGeolocationString,
|
||||||
isLegacyBirthdateBug,
|
isLegacyBirthdateBug,
|
||||||
useAgeAssuranceRegionConfig,
|
useAgeAssuranceRegionConfig,
|
||||||
} from '#/ageAssurance/util'
|
} from '#/ageAssurance/util'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||||
import {useDeviceGeolocationApi} from '#/geolocation'
|
import {useDeviceGeolocationApi, useGeolocation} from '#/geolocation'
|
||||||
|
|
||||||
const textStyles = [a.text_md, a.leading_snug]
|
const textStyles = [a.text_md, a.leading_snug]
|
||||||
|
|
||||||
export function NoAccessScreen() {
|
export function NoAccessScreen() {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {t: l, i18n} = useLingui()
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const {gtPhone} = useBreakpoints()
|
const {gtPhone} = useBreakpoints()
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const birthdateControl = useDialogControl()
|
const birthdateControl = useDialogControl()
|
||||||
const deactivateAccountControl = useDialogControl()
|
const deactivateAccountControl = useDialogControl()
|
||||||
const deleteAccountControl = useDialogControl()
|
const deleteAccountControl = useDialogControl()
|
||||||
const {metadata} = useAgeAssuranceServerDataContext()
|
const {metadata, deviceSignals} = useAgeAssuranceServerDataContext()
|
||||||
const region = useAgeAssuranceRegionConfig()
|
const region = useAgeAssuranceRegionConfig()
|
||||||
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
|
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
|
||||||
const {logoutCurrentAccount} = useSessionApi()
|
const {logoutCurrentAccount} = useSessionApi()
|
||||||
const createSupportLink = useCreateSupportLink()
|
const geolocation = useGeolocation()
|
||||||
|
const {setDeviceGeolocation} = useDeviceGeolocationApi()
|
||||||
|
const locationControl = Dialog.useDialogControl()
|
||||||
|
const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess()
|
||||||
|
|
||||||
const aa = useAgeAssurance()
|
const aa = useAgeAssurance()
|
||||||
const isBlocked = aa.state.status === aa.Status.Blocked
|
const isBlocked = aa.state.status === aa.Status.Blocked
|
||||||
const isAARegion = !!region
|
const isAARegion = !!region
|
||||||
const hasDeclaredAge = metadata?.declaredAge !== undefined
|
const hasDeclaredAge = aa.flags.hasDeclaredAge
|
||||||
|
const birthdateMightIncreaseAccess = Boolean(
|
||||||
|
region &&
|
||||||
|
canBirthdateUpdateIncreaseAccess({region, metadata, deviceSignals}),
|
||||||
|
)
|
||||||
const canUpdateBirthday =
|
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(() => {
|
useEffect(() => {
|
||||||
// just counting overall hits here
|
// just counting overall hits here
|
||||||
@@ -106,7 +115,7 @@ export function NoAccessScreen() {
|
|||||||
<Trans>
|
<Trans>
|
||||||
If you believe your birthdate is incorrect, you can update it by{' '}
|
If you believe your birthdate is incorrect, you can update it by{' '}
|
||||||
<SimpleInlineLinkText
|
<SimpleInlineLinkText
|
||||||
label={_(msg`Click here to update your birthdate`)}
|
label={l`Click here to update your birthdate`}
|
||||||
style={[textStyles]}
|
style={[textStyles]}
|
||||||
{...createStaticClick(() => {
|
{...createStaticClick(() => {
|
||||||
ax.metric('ageAssurance:noAccessScreen:openBirthdateDialog', {})
|
ax.metric('ageAssurance:noAccessScreen:openBirthdateDialog', {})
|
||||||
@@ -120,20 +129,7 @@ export function NoAccessScreen() {
|
|||||||
|
|
||||||
{orgAdmonition}
|
{orgAdmonition}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : null
|
||||||
<Text style={[textStyles]}>
|
|
||||||
<Trans>
|
|
||||||
If you believe your birthdate is incorrect, please{' '}
|
|
||||||
<SimpleInlineLinkText
|
|
||||||
to={createSupportLink({code: SupportCode.AA_BIRTHDATE})}
|
|
||||||
label={_(msg`Click here to contact our support team`)}
|
|
||||||
style={[textStyles]}>
|
|
||||||
contact our support team
|
|
||||||
</SimpleInlineLinkText>
|
|
||||||
.
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -164,80 +160,147 @@ export function NoAccessScreen() {
|
|||||||
<AgeAssuranceBadge />
|
<AgeAssuranceBadge />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{hasDeclaredAge ? (
|
<View style={[a.gap_lg]}>
|
||||||
<>
|
{hasDeclaredAge ? (
|
||||||
{isAARegion ? (
|
<>
|
||||||
<>
|
{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]}>
|
<View style={[a.gap_lg]}>
|
||||||
<Text style={[textStyles]}>
|
|
||||||
<Trans>Hey there!</Trans>
|
|
||||||
</Text>
|
|
||||||
<Text style={[textStyles]}>
|
<Text style={[textStyles]}>
|
||||||
<Trans>
|
<Trans>
|
||||||
You are accessing Bluesky from a region that legally
|
Unfortunately, the birthdate you have saved to your
|
||||||
requires us to verify your age before allowing you to
|
profile makes you too young to access Bluesky.
|
||||||
access the app.
|
|
||||||
</Trans>
|
</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{!aa.flags.isOverRegionMinAccessAge && (
|
{birthdateUpdateText}
|
||||||
<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>
|
</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 />}
|
{orgAdmonition}
|
||||||
</>
|
</View>
|
||||||
) : (
|
)}
|
||||||
<View style={[a.gap_lg]}>
|
</View>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={[a.pt_lg, a.gap_xl, {maxWidth: 280}]}>
|
<View style={[a.pt_lg, a.gap_xl, {maxWidth: 280}]}>
|
||||||
<Logo width={120} textFill={t.atoms.text.color} />
|
<Logo width={120} textFill={t.atoms.text.color} />
|
||||||
@@ -251,7 +314,7 @@ export function NoAccessScreen() {
|
|||||||
<Trans>
|
<Trans>
|
||||||
To log out,{' '}
|
To log out,{' '}
|
||||||
<SimpleInlineLinkText
|
<SimpleInlineLinkText
|
||||||
label={_(msg`Click here to log out`)}
|
label={l`Click here to log out`}
|
||||||
{...createStaticClick(() => {
|
{...createStaticClick(() => {
|
||||||
onPressLogout()
|
onPressLogout()
|
||||||
})}
|
})}
|
||||||
@@ -260,7 +323,7 @@ export function NoAccessScreen() {
|
|||||||
</SimpleInlineLinkText>
|
</SimpleInlineLinkText>
|
||||||
. Or if you’d prefer, you can{' '}
|
. Or if you’d prefer, you can{' '}
|
||||||
<SimpleInlineLinkText
|
<SimpleInlineLinkText
|
||||||
label={_(msg`Click here to delete your account`)}
|
label={l`Click here to delete your account`}
|
||||||
{...createStaticClick(() => {
|
{...createStaticClick(() => {
|
||||||
ax.metric(
|
ax.metric(
|
||||||
'ageAssurance:noAccessScreen:openDeleteAccountDialog',
|
'ageAssurance:noAccessScreen:openDeleteAccountDialog',
|
||||||
@@ -299,14 +362,11 @@ export function NoAccessScreen() {
|
|||||||
|
|
||||||
function AccessSection() {
|
function AccessSection() {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_, i18n} = useLingui()
|
const {t: l, i18n} = useLingui()
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const control = useDialogControl()
|
const control = useDialogControl()
|
||||||
const appealControl = Dialog.useDialogControl()
|
const appealControl = Dialog.useDialogControl()
|
||||||
const locationControl = Dialog.useDialogControl()
|
|
||||||
const getTimeAgo = useGetTimeAgo()
|
const getTimeAgo = useGetTimeAgo()
|
||||||
const {setDeviceGeolocation} = useDeviceGeolocationApi()
|
|
||||||
const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess()
|
|
||||||
|
|
||||||
const aa = useAgeAssurance()
|
const aa = useAgeAssurance()
|
||||||
const {status, lastInitiatedAt} = aa.state
|
const {status, lastInitiatedAt} = aa.state
|
||||||
@@ -318,6 +378,15 @@ function AccessSection() {
|
|||||||
const diff = lastInitiatedAt
|
const diff = lastInitiatedAt
|
||||||
? dateDiff(lastInitiatedAt, new Date(), 'down')
|
? dateDiff(lastInitiatedAt, new Date(), 'down')
|
||||||
: null
|
: null
|
||||||
|
const {
|
||||||
|
onPressVerify,
|
||||||
|
openInitDialog,
|
||||||
|
isVerifying,
|
||||||
|
verifyCta,
|
||||||
|
deviceSignalsFailed,
|
||||||
|
} = useAgeAssuranceVerificationFlow({initDialogControl: control})
|
||||||
|
const useDeviceSignals =
|
||||||
|
aa.flags.allowsDeviceVerification && !deviceSignalsFailed
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -331,7 +400,7 @@ function AccessSection() {
|
|||||||
You are currently unable to access Bluesky's Age Assurance flow.
|
You are currently unable to access Bluesky's Age Assurance flow.
|
||||||
Please{' '}
|
Please{' '}
|
||||||
<SimpleInlineLinkText
|
<SimpleInlineLinkText
|
||||||
label={_(msg`Contact our moderation team`)}
|
label={l`Contact our moderation team`}
|
||||||
{...createStaticClick(() => {
|
{...createStaticClick(() => {
|
||||||
appealControl.open()
|
appealControl.open()
|
||||||
ax.metric('ageAssurance:appealDialogOpen', {})
|
ax.metric('ageAssurance:appealDialogOpen', {})
|
||||||
@@ -345,26 +414,38 @@ function AccessSection() {
|
|||||||
<>
|
<>
|
||||||
<View style={[a.gap_md]}>
|
<View style={[a.gap_md]}>
|
||||||
<Button
|
<Button
|
||||||
label={_(msg`Verify now`)}
|
label={verifyCta}
|
||||||
size="large"
|
size="large"
|
||||||
color={hasInitiated ? 'secondary' : 'primary'}
|
color={
|
||||||
onPress={() => {
|
hasInitiated || aa.flags.hasSharedDeviceSignals
|
||||||
control.open()
|
? 'secondary'
|
||||||
ax.metric('ageAssurance:initDialogOpen', {
|
: 'primary'
|
||||||
hasInitiatedPreviously: hasInitiated,
|
}
|
||||||
})
|
disabled={isVerifying}
|
||||||
}}>
|
onPress={() => void onPressVerify()}>
|
||||||
<ButtonIcon icon={ShieldIcon} />
|
<ButtonIcon icon={isVerifying ? Loader : ShieldIcon} />
|
||||||
<ButtonText>
|
<ButtonText>{verifyCta}</ButtonText>
|
||||||
{hasInitiated ? (
|
|
||||||
<Trans>Verify again</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>Verify now</Trans>
|
|
||||||
)}
|
|
||||||
</ButtonText>
|
|
||||||
</Button>
|
</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
|
<Text
|
||||||
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}
|
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}
|
||||||
title={i18n.date(lastInitiatedAt, {
|
title={i18n.date(lastInitiatedAt, {
|
||||||
@@ -380,56 +461,12 @@ function AccessSection() {
|
|||||||
) : (
|
) : (
|
||||||
<Text
|
<Text
|
||||||
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
|
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
|
||||||
<Trans>Age assurance only takes a few minutes</Trans>
|
<Trans>Age assurance only takes a few minutes.</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</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>
|
</View>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,12 +4,29 @@ import {
|
|||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
|
||||||
import {AgeAssuranceAccess} from '#/ageAssurance/types'
|
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.
|
* Minimum age required to access the app at all.
|
||||||
*/
|
*/
|
||||||
export const MIN_ACCESS_AGE = 13
|
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 = {
|
export const FALLBACK_REGION_CONFIG: AppBskyAgeassuranceDefs.ConfigRegion = {
|
||||||
countryCode: '*',
|
countryCode: '*',
|
||||||
regionCode: undefined,
|
regionCode: undefined,
|
||||||
|
|||||||
+191
-7
@@ -1,11 +1,11 @@
|
|||||||
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
|
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
|
||||||
|
import * as AgeRange from 'expo-age-range'
|
||||||
import {
|
import {
|
||||||
type AppBskyAgeassuranceDefs,
|
type AppBskyAgeassuranceDefs,
|
||||||
type AppBskyAgeassuranceGetConfig,
|
type AppBskyAgeassuranceGetConfig,
|
||||||
type AppBskyAgeassuranceGetState,
|
type AppBskyAgeassuranceGetState,
|
||||||
AtpAgent,
|
AtpAgent,
|
||||||
type ChatBskyActorDeclaration,
|
type ChatBskyActorDeclaration,
|
||||||
getAgeAssuranceRegionConfig,
|
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
|
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
|
||||||
import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
|
import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
|
||||||
@@ -22,14 +22,21 @@ import {
|
|||||||
} from '#/state/birthdate'
|
} from '#/state/birthdate'
|
||||||
import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declaration'
|
import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declaration'
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
|
import {DEVICE_SIGNALS_SUPPORTED} from '#/ageAssurance/const'
|
||||||
import * as debug from '#/ageAssurance/debug'
|
import * as debug from '#/ageAssurance/debug'
|
||||||
import {logger} from '#/ageAssurance/logger'
|
import {logger} from '#/ageAssurance/logger'
|
||||||
import {type AgeAssuranceMetadata} from '#/ageAssurance/types'
|
|
||||||
import {
|
import {
|
||||||
|
type AgeAssuranceDeviceSignals,
|
||||||
|
type AgeAssuranceMetadata,
|
||||||
|
} from '#/ageAssurance/types'
|
||||||
|
import {
|
||||||
|
createRegionKey,
|
||||||
|
getAgeAssuranceRegionConfigForGeolocation,
|
||||||
getBirthdateStringFromAge,
|
getBirthdateStringFromAge,
|
||||||
isLegacyBirthdateBug,
|
isLegacyBirthdateBug,
|
||||||
} from '#/ageAssurance/util'
|
} from '#/ageAssurance/util'
|
||||||
import {IS_DEV} from '#/env'
|
import {IS_DEV} from '#/env'
|
||||||
|
import {useGeolocation} from '#/geolocation'
|
||||||
import {device} from '#/storage'
|
import {device} from '#/storage'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -310,10 +317,7 @@ export function useServerStateQuery() {
|
|||||||
const isAArequired = Boolean(
|
const isAArequired = Boolean(
|
||||||
config &&
|
config &&
|
||||||
geolocation &&
|
geolocation &&
|
||||||
!!getAgeAssuranceRegionConfig(config, {
|
getAgeAssuranceRegionConfigForGeolocation(config, geolocation),
|
||||||
countryCode: geolocation?.countryCode ?? '',
|
|
||||||
regionCode: geolocation?.regionCode,
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// only refetch when needed
|
// 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.
|
* Helper to prefetch all age assurance data from the server.
|
||||||
*/
|
*/
|
||||||
@@ -494,6 +665,7 @@ export function prefetchAgeAssuranceServerData({agent}: {agent: AtpAgent}) {
|
|||||||
configPrefetchPromise,
|
configPrefetchPromise,
|
||||||
prefetchServerState({agent}),
|
prefetchServerState({agent}),
|
||||||
prefetchOtherRequiredData({agent}),
|
prefetchOtherRequiredData({agent}),
|
||||||
|
prefetchDeviceSignals({agent}),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,6 +697,14 @@ export type AgeAssuranceServerData = {
|
|||||||
*/
|
*/
|
||||||
state: AppBskyAgeassuranceDefs.State | undefined
|
state: AppBskyAgeassuranceDefs.State | undefined
|
||||||
metadata: AgeAssuranceMetadata | 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>({
|
const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
|
||||||
config: undefined,
|
config: undefined,
|
||||||
@@ -534,6 +714,7 @@ const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
|
|||||||
declaredAge: undefined,
|
declaredAge: undefined,
|
||||||
birthdate: undefined,
|
birthdate: undefined,
|
||||||
},
|
},
|
||||||
|
deviceSignals: undefined,
|
||||||
})
|
})
|
||||||
export function useAgeAssuranceServerDataContext() {
|
export function useAgeAssuranceServerDataContext() {
|
||||||
return useContext(AgeAssuranceServerDataContext)
|
return useContext(AgeAssuranceServerDataContext)
|
||||||
@@ -547,6 +728,8 @@ export function AgeAssuranceServerDataProvider({
|
|||||||
const serverState = useServerStateQuery()
|
const serverState = useServerStateQuery()
|
||||||
const {state, metadata} = serverState.data || {}
|
const {state, metadata} = serverState.data || {}
|
||||||
const {data} = useOtherRequiredDataQuery()
|
const {data} = useOtherRequiredDataQuery()
|
||||||
|
// `select` resolves the cached region-keyed map to the current region.
|
||||||
|
const {data: deviceSignals} = useDeviceSignalsQuery()
|
||||||
const ctx = useMemo(
|
const ctx = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
config,
|
config,
|
||||||
@@ -559,8 +742,9 @@ export function AgeAssuranceServerDataProvider({
|
|||||||
: undefined,
|
: undefined,
|
||||||
birthdate: data?.birthdate,
|
birthdate: data?.birthdate,
|
||||||
},
|
},
|
||||||
|
deviceSignals,
|
||||||
}),
|
}),
|
||||||
[config, state, data, metadata],
|
[config, state, data, metadata, deviceSignals],
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<AgeAssuranceServerDataContext.Provider value={ctx}>
|
<AgeAssuranceServerDataContext.Provider value={ctx}>
|
||||||
|
|||||||
+49
-12
@@ -1,3 +1,4 @@
|
|||||||
|
import type * as AgeRange from 'expo-age-range'
|
||||||
import {
|
import {
|
||||||
ageAssuranceRuleIDs as ids,
|
ageAssuranceRuleIDs as ids,
|
||||||
type AppBskyAgeassuranceDefs,
|
type AppBskyAgeassuranceDefs,
|
||||||
@@ -17,17 +18,16 @@ export const geolocation: Geolocation | undefined = enabled
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const deviceGeolocationEnabled = false || IS_E2E
|
export const deviceGeolocation: Geolocation | undefined = enabled
|
||||||
export const deviceGeolocation: Geolocation | undefined =
|
? {
|
||||||
enabled && deviceGeolocationEnabled
|
countryCode: 'AA',
|
||||||
? {
|
regionCode: undefined,
|
||||||
countryCode: 'AA',
|
...geolocation,
|
||||||
regionCode: undefined,
|
}
|
||||||
}
|
: undefined
|
||||||
: undefined
|
|
||||||
|
|
||||||
export const otherRequiredData: OtherRequiredData = {
|
export const otherRequiredData: OtherRequiredData = {
|
||||||
birthdate: new Date(2010, 12, 1).toISOString(),
|
birthdate: new Date(2000, 12, 1).toISOString(),
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverStateEnabled = false || IS_E2E
|
const serverStateEnabled = false || IS_E2E
|
||||||
@@ -35,9 +35,9 @@ export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined =
|
|||||||
serverStateEnabled
|
serverStateEnabled
|
||||||
? {
|
? {
|
||||||
state: {
|
state: {
|
||||||
lastInitiatedAt: new Date(2025, 1, 1).toISOString(),
|
lastInitiatedAt: undefined, // new Date(2025, 1, 1).toISOString(),
|
||||||
status: 'assured',
|
status: 'unknown',
|
||||||
access: 'full',
|
access: 'unknown',
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
accountCreatedAt: new Date(2023, 1, 1).toISOString(),
|
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',
|
countryCode: 'GB',
|
||||||
minAccessAge: 13,
|
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) {
|
export async function resolve<T>(data: T) {
|
||||||
await new Promise(y => setTimeout(y, 500)) // simulate network
|
await new Promise(y => setTimeout(y, 500)) // simulate network
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -51,9 +51,12 @@ const AgeAssuranceStateContext = createContext<{
|
|||||||
adultContentDisabled: false,
|
adultContentDisabled: false,
|
||||||
chatDisabled: false,
|
chatDisabled: false,
|
||||||
groupChatDisabled: false,
|
groupChatDisabled: false,
|
||||||
|
hasDeclaredAge: false,
|
||||||
isDeclaredUnderAdultAge: false,
|
isDeclaredUnderAdultAge: false,
|
||||||
isOverRegionMinAccessAge: false,
|
isOverRegionMinAccessAge: false,
|
||||||
isOverAppMinAccessAge: false,
|
isOverAppMinAccessAge: false,
|
||||||
|
allowsDeviceVerification: false,
|
||||||
|
hasSharedDeviceSignals: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -79,7 +82,7 @@ export function Provider({children}: {children: React.ReactNode}) {
|
|||||||
function InnerProvider({children}: {children: React.ReactNode}) {
|
function InnerProvider({children}: {children: React.ReactNode}) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const state = useAgeAssuranceState()
|
const state = useAgeAssuranceState()
|
||||||
const {metadata} = useAgeAssuranceServerDataContext()
|
const {metadata, deviceSignals} = useAgeAssuranceServerDataContext()
|
||||||
const regionConfig = useAgeAssuranceRegionConfigWithFallback()
|
const regionConfig = useAgeAssuranceRegionConfigWithFallback()
|
||||||
const getAndRegisterPushToken = useGetAndRegisterPushToken()
|
const getAndRegisterPushToken = useGetAndRegisterPushToken()
|
||||||
|
|
||||||
@@ -89,6 +92,7 @@ function InnerProvider({children}: {children: React.ReactNode}) {
|
|||||||
state: s,
|
state: s,
|
||||||
regionConfig,
|
regionConfig,
|
||||||
metadata,
|
metadata,
|
||||||
|
deviceSignals,
|
||||||
})
|
})
|
||||||
if (flags.isAgeRestricted) {
|
if (flags.isAgeRestricted) {
|
||||||
void getAndRegisterPushToken({
|
void getAndRegisterPushToken({
|
||||||
@@ -103,7 +107,7 @@ function InnerProvider({children}: {children: React.ReactNode}) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[agent, getAndRegisterPushToken, regionConfig, metadata],
|
[agent, getAndRegisterPushToken, regionConfig, metadata, deviceSignals],
|
||||||
)
|
)
|
||||||
useOnAgeAssuranceAccessUpdate(handleAccessUpdate)
|
useOnAgeAssuranceAccessUpdate(handleAccessUpdate)
|
||||||
|
|
||||||
@@ -118,11 +122,12 @@ function InnerProvider({children}: {children: React.ReactNode}) {
|
|||||||
state,
|
state,
|
||||||
regionConfig,
|
regionConfig,
|
||||||
metadata,
|
metadata,
|
||||||
|
deviceSignals,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
logger.debug(`useAgeAssurance`, res)
|
logger.debug(`useAgeAssurance`, res)
|
||||||
return res
|
return res
|
||||||
}, [state, metadata, regionConfig])}>
|
}, [state, metadata, regionConfig, deviceSignals])}>
|
||||||
{children}
|
{children}
|
||||||
</AgeAssuranceStateContext.Provider>
|
</AgeAssuranceStateContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {useEffect, useMemo, useState} from 'react'
|
import {useEffect, useMemo, useState} from 'react'
|
||||||
|
import type * as AgeRange from 'expo-age-range'
|
||||||
import {
|
import {
|
||||||
type AppBskyAgeassuranceDefs,
|
type AppBskyAgeassuranceDefs,
|
||||||
computeAgeAssuranceRegionAccess,
|
computeAgeAssuranceRegionAccess,
|
||||||
@@ -8,6 +9,7 @@ import {getAge} from '#/lib/strings/time'
|
|||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {
|
import {
|
||||||
getConfigFromCache,
|
getConfigFromCache,
|
||||||
|
getDeviceSignalsFromCacheForRegion,
|
||||||
getOtherRequiredDataFromCache,
|
getOtherRequiredDataFromCache,
|
||||||
getServerStateFromCache,
|
getServerStateFromCache,
|
||||||
useAgeAssuranceServerDataContext,
|
useAgeAssuranceServerDataContext,
|
||||||
@@ -23,6 +25,8 @@ import {
|
|||||||
} from '#/ageAssurance/types'
|
} from '#/ageAssurance/types'
|
||||||
import {
|
import {
|
||||||
computeAgeAssuranceFlags,
|
computeAgeAssuranceFlags,
|
||||||
|
getAgeAssuranceDataFromDeviceSignals,
|
||||||
|
getAgeAssuranceRegionConfigForGeolocation,
|
||||||
getAgeAssuranceRegionConfigWithFallback,
|
getAgeAssuranceRegionConfigWithFallback,
|
||||||
} from '#/ageAssurance/util'
|
} from '#/ageAssurance/util'
|
||||||
import {type Geolocation, useGeolocation} from '#/geolocation'
|
import {type Geolocation, useGeolocation} from '#/geolocation'
|
||||||
@@ -39,12 +43,14 @@ function computeAgeAssuranceState({
|
|||||||
config,
|
config,
|
||||||
state,
|
state,
|
||||||
metadata,
|
metadata,
|
||||||
|
deviceSignals,
|
||||||
}: {
|
}: {
|
||||||
hasSession: boolean
|
hasSession: boolean
|
||||||
geolocation: Geolocation
|
geolocation: Geolocation
|
||||||
config?: AppBskyAgeassuranceDefs.Config
|
config?: AppBskyAgeassuranceDefs.Config
|
||||||
state?: AppBskyAgeassuranceDefs.State
|
state?: AppBskyAgeassuranceDefs.State
|
||||||
metadata?: AgeAssuranceMetadata
|
metadata?: AgeAssuranceMetadata
|
||||||
|
deviceSignals?: AgeRange.AgeRangeResponse
|
||||||
}) {
|
}) {
|
||||||
/**
|
/**
|
||||||
* This is where we control logged-out moderation prefs. It's all
|
* 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
|
* Otherwise, we need to compute the access based on the latest data. For
|
||||||
* accounts with an accurate birthdate, our default fallback rules should
|
* accounts with an accurate birthdate, our default fallback rules should
|
||||||
* ensure correct access.
|
* 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, {
|
const result = computeAgeAssuranceRegionAccess(region, {
|
||||||
accountCreatedAt: metadata?.accountCreatedAt,
|
accountCreatedAt: metadata?.accountCreatedAt,
|
||||||
declaredAge: metadata?.declaredAge,
|
declaredAge: metadata?.declaredAge,
|
||||||
|
assuredAge,
|
||||||
})
|
})
|
||||||
const computed = {
|
const computed = {
|
||||||
lastInitiatedAt: state?.lastInitiatedAt,
|
lastInitiatedAt: state?.lastInitiatedAt,
|
||||||
@@ -138,6 +153,19 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation)
|
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 = {
|
const metadata: AgeAssuranceMetadata = {
|
||||||
accountCreatedAt: state.metadata?.accountCreatedAt,
|
accountCreatedAt: state.metadata?.accountCreatedAt,
|
||||||
declaredAge: requiredData?.birthdate
|
declaredAge: requiredData?.birthdate
|
||||||
@@ -151,6 +179,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
|
|||||||
geolocation,
|
geolocation,
|
||||||
state: state.state,
|
state: state.state,
|
||||||
metadata,
|
metadata,
|
||||||
|
deviceSignals,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -159,6 +188,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
|
|||||||
state: computed,
|
state: computed,
|
||||||
regionConfig: region,
|
regionConfig: region,
|
||||||
metadata,
|
metadata,
|
||||||
|
deviceSignals,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,7 +196,8 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
|
|||||||
export function useAgeAssuranceState(): AgeAssuranceState {
|
export function useAgeAssuranceState(): AgeAssuranceState {
|
||||||
const {hasSession} = useSession()
|
const {hasSession} = useSession()
|
||||||
const geolocation = useGeolocation()
|
const geolocation = useGeolocation()
|
||||||
const {config, state, metadata} = useAgeAssuranceServerDataContext()
|
const {config, state, metadata, deviceSignals} =
|
||||||
|
useAgeAssuranceServerDataContext()
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -176,8 +207,9 @@ export function useAgeAssuranceState(): AgeAssuranceState {
|
|||||||
geolocation,
|
geolocation,
|
||||||
state,
|
state,
|
||||||
metadata,
|
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 {type computeAgeAssuranceRegionAccess} from '@atproto/api'
|
||||||
|
|
||||||
import {logger} from '#/ageAssurance/logger'
|
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 {
|
export enum AgeAssuranceAccess {
|
||||||
Unknown = 'unknown',
|
Unknown = 'unknown',
|
||||||
None = 'none',
|
None = 'none',
|
||||||
@@ -34,9 +51,12 @@ export type AgeAssuranceFlags = {
|
|||||||
adultContentDisabled: boolean
|
adultContentDisabled: boolean
|
||||||
chatDisabled: boolean
|
chatDisabled: boolean
|
||||||
groupChatDisabled: boolean
|
groupChatDisabled: boolean
|
||||||
|
hasDeclaredAge: boolean
|
||||||
isDeclaredUnderAdultAge: boolean
|
isDeclaredUnderAdultAge: boolean
|
||||||
isOverRegionMinAccessAge: boolean
|
isOverRegionMinAccessAge: boolean
|
||||||
isOverAppMinAccessAge: boolean
|
isOverAppMinAccessAge: boolean
|
||||||
|
allowsDeviceVerification: boolean
|
||||||
|
hasSharedDeviceSignals: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseStatusFromString(raw: string) {
|
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 {useMemo} from 'react'
|
||||||
|
import type * as AgeRange from 'expo-age-range'
|
||||||
import {
|
import {
|
||||||
type AppBskyAgeassuranceDefs,
|
AppBskyAgeassuranceDefs,
|
||||||
|
computeAgeAssuranceRegionAccess,
|
||||||
getAgeAssuranceRegionConfig,
|
getAgeAssuranceRegionConfig,
|
||||||
type ModerationPrefs,
|
type ModerationPrefs,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
|
||||||
import {getAge} from '#/lib/strings/time'
|
import {getAge} from '#/lib/strings/time'
|
||||||
|
import {regionName} from '#/locale/helpers'
|
||||||
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/const'
|
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 {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
|
||||||
import {
|
import {
|
||||||
AgeAssuranceAccess,
|
AgeAssuranceAccess,
|
||||||
@@ -16,24 +23,213 @@ import {
|
|||||||
type AgeAssuranceState,
|
type AgeAssuranceState,
|
||||||
} from '#/ageAssurance/types'
|
} from '#/ageAssurance/types'
|
||||||
import {type Geolocation, useGeolocation} from '#/geolocation'
|
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
|
* Get age assurance region config based on geolocation, with fallback to
|
||||||
* app defaults if no region config is found.
|
* app defaults if no region config is found.
|
||||||
*
|
*
|
||||||
* See {@link getAgeAssuranceRegionConfig} for the generic option, which can
|
* See {@link getAgeAssuranceRegionConfigForGeolocation} for the generic option,
|
||||||
* return undefined if the geolocation does not match any AA region.
|
* which can return undefined if the geolocation does not match any AA region.
|
||||||
*/
|
*/
|
||||||
export function getAgeAssuranceRegionConfigWithFallback(
|
export function getAgeAssuranceRegionConfigWithFallback(
|
||||||
config: AppBskyAgeassuranceDefs.Config,
|
config: AppBskyAgeassuranceDefs.Config,
|
||||||
geolocation: Geolocation,
|
geolocation: Geolocation,
|
||||||
): AppBskyAgeassuranceDefs.ConfigRegion {
|
): AppBskyAgeassuranceDefs.ConfigRegion {
|
||||||
const region = getAgeAssuranceRegionConfig(config, {
|
return (
|
||||||
countryCode: geolocation.countryCode ?? '',
|
getAgeAssuranceRegionConfigForGeolocation(config, geolocation) ||
|
||||||
regionCode: geolocation.regionCode,
|
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(() => {
|
return useMemo(() => {
|
||||||
if (!config) return
|
if (!config) return
|
||||||
// use generic helper, we want to potentially return undefined
|
// use generic helper, we want to potentially return undefined
|
||||||
return getAgeAssuranceRegionConfig(config, {
|
return getAgeAssuranceRegionConfigForGeolocation(config, geolocation)
|
||||||
countryCode: geolocation.countryCode ?? '',
|
|
||||||
regionCode: geolocation.regionCode,
|
|
||||||
})
|
|
||||||
}, [config, geolocation])
|
}, [config, geolocation])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,10 +294,12 @@ export function computeAgeAssuranceFlags({
|
|||||||
state,
|
state,
|
||||||
regionConfig,
|
regionConfig,
|
||||||
metadata,
|
metadata,
|
||||||
|
deviceSignals,
|
||||||
}: {
|
}: {
|
||||||
state: AgeAssuranceState
|
state: AgeAssuranceState
|
||||||
regionConfig: AppBskyAgeassuranceDefs.ConfigRegion
|
regionConfig: AppBskyAgeassuranceDefs.ConfigRegion
|
||||||
metadata?: AgeAssuranceMetadata
|
metadata?: AgeAssuranceMetadata
|
||||||
|
deviceSignals?: AgeRange.AgeRangeResponse
|
||||||
}): AgeAssuranceFlags {
|
}): AgeAssuranceFlags {
|
||||||
const isAgeRestricted = state.access !== AgeAssuranceAccess.Full
|
const isAgeRestricted = state.access !== AgeAssuranceAccess.Full
|
||||||
const chatDisabled = isAgeRestricted
|
const chatDisabled = isAgeRestricted
|
||||||
@@ -120,14 +315,44 @@ export function computeAgeAssuranceFlags({
|
|||||||
: false
|
: false
|
||||||
const adultContentDisabled =
|
const adultContentDisabled =
|
||||||
state.access !== AgeAssuranceAccess.Full || isDeclaredUnderAdultAge
|
state.access !== AgeAssuranceAccess.Full || isDeclaredUnderAdultAge
|
||||||
|
const allowsDeviceVerification =
|
||||||
|
DEVICE_SIGNALS_SUPPORTED && regionAllowsDeviceVerification(regionConfig)
|
||||||
|
const hasSharedDeviceSignals = deviceSignals?.lowerBound !== undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isAgeRestricted,
|
isAgeRestricted,
|
||||||
adultContentDisabled,
|
adultContentDisabled,
|
||||||
chatDisabled,
|
chatDisabled,
|
||||||
groupChatDisabled,
|
groupChatDisabled,
|
||||||
|
hasDeclaredAge: metadata?.declaredAge !== undefined,
|
||||||
isDeclaredUnderAdultAge,
|
isDeclaredUnderAdultAge,
|
||||||
isOverRegionMinAccessAge,
|
isOverRegionMinAccessAge,
|
||||||
isOverAppMinAccessAge,
|
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 {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
||||||
import {regionName} from '#/locale/helpers'
|
|
||||||
import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
|
import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
|
||||||
import {Admonition} from '#/components/Admonition'
|
import {Admonition} from '#/components/Admonition'
|
||||||
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
|
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
|
||||||
@@ -13,49 +12,22 @@ import {
|
|||||||
useDialogControl,
|
useDialogControl,
|
||||||
} from '#/components/ageAssurance/AgeAssuranceInitDialog'
|
} from '#/components/ageAssurance/AgeAssuranceInitDialog'
|
||||||
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
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 * as Dialog from '#/components/Dialog'
|
||||||
import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog'
|
import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog'
|
||||||
import {Divider} from '#/components/Divider'
|
import {Divider} from '#/components/Divider'
|
||||||
|
import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield'
|
||||||
import {createStaticClick, InlineLinkText} from '#/components/Link'
|
import {createStaticClick, InlineLinkText} from '#/components/Link'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
import * as Toast from '#/components/Toast'
|
import * as Toast from '#/components/Toast'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {useAgeAssurance} from '#/ageAssurance'
|
import {useAgeAssurance} from '#/ageAssurance'
|
||||||
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
|
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
|
||||||
|
import {useAgeAssuranceVerificationFlow} from '#/ageAssurance/useVerificationFlow'
|
||||||
|
import {createGeolocationString} from '#/ageAssurance/util'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {IS_NATIVE} from '#/env'
|
import {IS_NATIVE} from '#/env'
|
||||||
import {
|
import {useDeviceGeolocationApi, useGeolocation} from '#/geolocation'
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) {
|
export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) {
|
||||||
const aa = useAgeAssurance()
|
const aa = useAgeAssurance()
|
||||||
@@ -91,6 +63,15 @@ function Inner({style}: ViewStyleProp & {}) {
|
|||||||
const diff = lastInitiatedAt
|
const diff = lastInitiatedAt
|
||||||
? dateDiff(lastInitiatedAt, new Date(), 'down')
|
? dateDiff(lastInitiatedAt, new Date(), 'down')
|
||||||
: null
|
: null
|
||||||
|
const {
|
||||||
|
onPressVerify,
|
||||||
|
openInitDialog,
|
||||||
|
isVerifying,
|
||||||
|
verifyCta,
|
||||||
|
deviceSignalsFailed,
|
||||||
|
} = useAgeAssuranceVerificationFlow({initDialogControl: control})
|
||||||
|
const useDeviceSignals =
|
||||||
|
aa.flags.allowsDeviceVerification && !deviceSignalsFailed
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -169,26 +150,38 @@ function Inner({style}: ViewStyleProp & {}) {
|
|||||||
: [a.gap_md],
|
: [a.gap_md],
|
||||||
]}>
|
]}>
|
||||||
<Button
|
<Button
|
||||||
label={l`Verify now`}
|
label={verifyCta}
|
||||||
size="small"
|
size="small"
|
||||||
variant="solid"
|
color={
|
||||||
color={hasInitiated ? 'secondary' : 'primary'}
|
hasInitiated || aa.flags.hasSharedDeviceSignals
|
||||||
onPress={() => {
|
? 'secondary'
|
||||||
control.open()
|
: 'primary'
|
||||||
ax.metric('ageAssurance:initDialogOpen', {
|
}
|
||||||
hasInitiatedPreviously: hasInitiated,
|
disabled={isVerifying}
|
||||||
})
|
onPress={() => void onPressVerify()}>
|
||||||
}}>
|
<ButtonIcon icon={isVerifying ? Loader : ShieldIcon} />
|
||||||
<ButtonText>
|
<ButtonText>{verifyCta}</ButtonText>
|
||||||
{hasInitiated ? (
|
|
||||||
<Trans>Verify again</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>Verify now</Trans>
|
|
||||||
)}
|
|
||||||
</ButtonText>
|
|
||||||
</Button>
|
</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
|
<Text
|
||||||
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}
|
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}
|
||||||
title={i18n.date(lastInitiatedAt, {
|
title={i18n.date(lastInitiatedAt, {
|
||||||
@@ -220,13 +213,12 @@ function RegionNotice() {
|
|||||||
const {t: l, i18n} = useLingui()
|
const {t: l, i18n} = useLingui()
|
||||||
const aa = useAgeAssurance()
|
const aa = useAgeAssurance()
|
||||||
const geolocation = useGeolocation()
|
const geolocation = useGeolocation()
|
||||||
const [deviceGeolocation] = useStorage(device, ['deviceGeolocation'])
|
|
||||||
const {setDeviceGeolocation} = useDeviceGeolocationApi()
|
const {setDeviceGeolocation} = useDeviceGeolocationApi()
|
||||||
const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess()
|
const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess()
|
||||||
const locationControl = Dialog.useDialogControl()
|
const locationControl = Dialog.useDialogControl()
|
||||||
|
|
||||||
const region = formatRegion(geolocation, i18n.locale)
|
const region = createGeolocationString(geolocation, i18n.locale)
|
||||||
const isGPS = !!deviceGeolocation?.countryCode && IS_NATIVE
|
const isGPS = !!geolocation.deviceGeolocation?.countryCode && IS_NATIVE
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -258,8 +250,7 @@ function RegionNotice() {
|
|||||||
{isGPS ? (
|
{isGPS ? (
|
||||||
<Trans>
|
<Trans>
|
||||||
Based on your device's location, we think you're in{' '}
|
Based on your device's location, we think you're in{' '}
|
||||||
<Text style={[a.text_sm, a.font_bold]}>{region}</Text>. This
|
<Text style={[a.text_sm, a.font_bold]}>{region}</Text>.
|
||||||
estimate may be inaccurate if you're using a VPN.
|
|
||||||
</Trans>
|
</Trans>
|
||||||
) : (
|
) : (
|
||||||
<Trans>
|
<Trans>
|
||||||
@@ -268,20 +259,20 @@ function RegionNotice() {
|
|||||||
estimate may be inaccurate if you're using a VPN.
|
estimate may be inaccurate if you're using a VPN.
|
||||||
</Trans>
|
</Trans>
|
||||||
)}
|
)}
|
||||||
</Text>
|
{IS_NATIVE && (
|
||||||
)}
|
<Text style={[a.text_sm, a.leading_snug]}>
|
||||||
|
{' '}
|
||||||
{IS_NATIVE && (
|
<Trans>
|
||||||
<Text style={[a.text_sm, a.leading_snug]}>
|
<InlineLinkText
|
||||||
<Trans>
|
label={l`Update your location`}
|
||||||
<InlineLinkText
|
{...createStaticClick(() => {
|
||||||
label={l`Update your location`}
|
locationControl.open()
|
||||||
{...createStaticClick(() => {
|
})}>
|
||||||
locationControl.open()
|
Tap here to update your location with GPS.
|
||||||
})}>
|
</InlineLinkText>
|
||||||
Tap here to update your location with GPS.
|
</Trans>
|
||||||
</InlineLinkText>
|
</Text>
|
||||||
</Trans>
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Vendored
+16
-6
@@ -5,12 +5,23 @@ import {BUNDLE_IDENTIFIER, IS_TESTFLIGHT, RELEASE_VERSION} from '#/env/common'
|
|||||||
|
|
||||||
export * 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'
|
Platform.OS === 'ios' && typeof Platform.Version === 'string'
|
||||||
? parseInt(Platform.Version.split('.')[0], 10)
|
? parseInt(Platform.Version.split('.')[0], 10)
|
||||||
: 0
|
: 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.OS === 'android' && typeof Platform.Version === 'number'
|
||||||
? Platform.Version
|
? Platform.Version
|
||||||
: 0
|
: 0
|
||||||
@@ -52,8 +63,7 @@ export const IS_WEB_FIREFOX: boolean = false
|
|||||||
*/
|
*/
|
||||||
export const IS_HIGH_DPI: boolean = true
|
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
|
// 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.
|
// So we can avoid attempting on-device translation when we know it's unsupported.
|
||||||
export const IS_TRANSLATION_SUPPORTED: boolean =
|
export const IS_TRANSLATION_SUPPORTED: boolean =
|
||||||
(IS_IOS && iOSMajorVersion >= 18) ||
|
(IS_IOS && IOS_MAJOR_VERSION >= 18) || (IS_ANDROID && ANDROID_API_LEVEL > 22)
|
||||||
(IS_ANDROID && androidPlatformVersion > 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'})`
|
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
|
* Platform detection
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {type Geolocation} from '#/geolocation/types'
|
|||||||
const localEnabled = false
|
const localEnabled = false
|
||||||
export const enabled = IS_DEV && (localEnabled || aaDebug.geolocation)
|
export const enabled = IS_DEV && (localEnabled || aaDebug.geolocation)
|
||||||
export const geolocation: Geolocation = aaDebug.geolocation ?? {
|
export const geolocation: Geolocation = aaDebug.geolocation ?? {
|
||||||
countryCode: 'US',
|
countryCode: 'AA',
|
||||||
regionCode: 'TX',
|
regionCode: undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
const deviceLocalEnabled = false
|
const deviceLocalEnabled = false
|
||||||
@@ -14,8 +14,8 @@ export const deviceGeolocation: Geolocation | undefined =
|
|||||||
aaDebug.deviceGeolocation ||
|
aaDebug.deviceGeolocation ||
|
||||||
(deviceLocalEnabled
|
(deviceLocalEnabled
|
||||||
? {
|
? {
|
||||||
countryCode: 'US',
|
countryCode: 'AA',
|
||||||
regionCode: 'TX',
|
regionCode: undefined,
|
||||||
}
|
}
|
||||||
: undefined)
|
: undefined)
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export * from '#/geolocation/types'
|
|||||||
const GeolocationContext = createContext<Geolocation>({
|
const GeolocationContext = createContext<Geolocation>({
|
||||||
countryCode: undefined,
|
countryCode: undefined,
|
||||||
regionCode: undefined,
|
regionCode: undefined,
|
||||||
|
serviceGeolocation: undefined,
|
||||||
|
deviceGeolocation: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const DeviceGeolocationAPIContext = createContext<{
|
const DeviceGeolocationAPIContext = createContext<{
|
||||||
@@ -44,7 +46,17 @@ export function Provider({children}: {children: ReactNode}) {
|
|||||||
'deviceGeolocation',
|
'deviceGeolocation',
|
||||||
])
|
])
|
||||||
const geolocation = useMemo(() => {
|
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])
|
}, [deviceGeolocation, geolocationService])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export type Geolocation = {
|
export type Geolocation = {
|
||||||
countryCode: string | undefined
|
countryCode: string | undefined
|
||||||
regionCode: string | undefined
|
regionCode: string | undefined
|
||||||
|
serviceGeolocation?: Geolocation
|
||||||
|
deviceGeolocation?: Geolocation
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user