diff --git a/.github/workflows/build-submit-android.yml b/.github/workflows/build-submit-android.yml
index e76eacbc22..ac8900e665 100644
--- a/.github/workflows/build-submit-android.yml
+++ b/.github/workflows/build-submit-android.yml
@@ -16,6 +16,9 @@ jobs:
if: github.repository == 'bluesky-social/social-app'
name: Build and Submit Android
runs-on: Linux-x64-32core
+ concurrency:
+ group: android-build
+ cancel-in-progress: false
steps:
- name: Check for EXPO_TOKEN
run: >
diff --git a/.github/workflows/build-submit-ios.yml b/.github/workflows/build-submit-ios.yml
index 391f95131f..2ebbd66ec9 100644
--- a/.github/workflows/build-submit-ios.yml
+++ b/.github/workflows/build-submit-ios.yml
@@ -16,6 +16,9 @@ jobs:
if: github.repository == 'bluesky-social/social-app'
name: Build and Submit iOS
runs-on: macos-26-xlarge
+ concurrency:
+ group: ios-build
+ cancel-in-progress: false
steps:
- name: Check for EXPO_TOKEN
run: >
diff --git a/.github/workflows/bundle-deploy-eas-update.yml b/.github/workflows/bundle-deploy-eas-update.yml
index af629dd559..ee59cad977 100644
--- a/.github/workflows/bundle-deploy-eas-update.yml
+++ b/.github/workflows/bundle-deploy-eas-update.yml
@@ -157,8 +157,8 @@ jobs:
name: Build and Submit iOS
runs-on: macos-26
concurrency:
- group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-ios
- cancel-in-progress: true
+ group: ios-build
+ cancel-in-progress: false
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here
@@ -262,7 +262,7 @@ jobs:
name: Build and Submit Android
runs-on: ubuntu-latest
concurrency:
- group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-android
+ group: android-build
cancel-in-progress: false
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
diff --git a/package.json b/package.json
index 2b8d64867e..185e530bd3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
- "version": "1.113.1",
+ "version": "1.114.0",
"private": true,
"engines": {
"node": ">=20"
@@ -226,7 +226,7 @@
"zod": "^3.20.2"
},
"devDependencies": {
- "@atproto/dev-env": "^0.3.201",
+ "@atproto/dev-env": "^0.3.196",
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0",
diff --git a/patches/react-native-pager-view+6.8.0.patch b/patches/react-native-pager-view+6.8.0.patch
new file mode 100644
index 0000000000..5d7a3dffce
--- /dev/null
+++ b/patches/react-native-pager-view+6.8.0.patch
@@ -0,0 +1,30 @@
+diff --git a/node_modules/react-native-pager-view/ios/RNCPagerView.m b/node_modules/react-native-pager-view/ios/RNCPagerView.m
+index adfc7c6..366df60 100644
+--- a/node_modules/react-native-pager-view/ios/RNCPagerView.m
++++ b/node_modules/react-native-pager-view/ios/RNCPagerView.m
+@@ -498,6 +498,25 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecogni
+ return YES;
+ }
+
++ // iOS 26+ full-screen back gesture (interactiveContentPopGestureRecognizer)
++ if (@available(iOS 26, *)) {
++ if (gestureRecognizer == self.panGestureRecognizer &&
++ otherGestureRecognizer == self.reactViewController.navigationController.interactiveContentPopGestureRecognizer) {
++ UIPanGestureRecognizer* panGestureRecognizer = (UIPanGestureRecognizer*) gestureRecognizer;
++ CGPoint velocity = [panGestureRecognizer velocityInView:self];
++ BOOL isLTR = [self isLtrLayout];
++ BOOL isBackGesture = (isLTR && velocity.x > 0) || (!isLTR && velocity.x < 0);
++
++ if (self.currentIndex == 0 && isBackGesture) {
++ self.scrollView.panGestureRecognizer.enabled = false;
++ } else {
++ self.scrollView.panGestureRecognizer.enabled = self.scrollEnabled;
++ }
++
++ return YES;
++ }
++ }
++
+ self.scrollView.panGestureRecognizer.enabled = self.scrollEnabled;
+ return NO;
+ }
diff --git a/patches/react-native-pager-view+6.8.0.patch.md b/patches/react-native-pager-view+6.8.0.patch.md
new file mode 100644
index 0000000000..fd22b8376e
--- /dev/null
+++ b/patches/react-native-pager-view+6.8.0.patch.md
@@ -0,0 +1,11 @@
+# react-native-pager-view+6.8.0.patch
+
+Adds support for iOS 26's `interactiveContentPopGestureRecognizer` (full-screen back gesture).
+
+The pager already handles `RNSPanGestureRecognizer` (react-native-screens' custom full-screen gesture for pre-iOS 26) in `shouldRecognizeSimultaneouslyWithGestureRecognizer:`. It checks if the user is on the leftmost page and swiping right - if so, it disables the scrollview's pan gesture to let the back gesture through.
+
+This patch adds the same logic for iOS 26's native `interactiveContentPopGestureRecognizer`, so the back gesture works on the leftmost page while the pager still handles swipes on other pages.
+
+Related issues:
+- https://github.com/software-mansion/react-native-screens/issues/3512
+- https://github.com/software-mansion/react-native-screens/pull/3420
diff --git a/src/ageAssurance/components/NoAccessScreen.tsx b/src/ageAssurance/components/NoAccessScreen.tsx
index 3aa69dc789..8b8ad0ccc1 100644
--- a/src/ageAssurance/components/NoAccessScreen.tsx
+++ b/src/ageAssurance/components/NoAccessScreen.tsx
@@ -177,10 +177,19 @@ export function NoAccessScreen() {
+ {!aa.flags.isOverRegionMinAccessAge && (
+
+
+ Unfortunately, your declared age indicates that you
+ are not old enough to access Bluesky in your region.
+
+
+ )}
+
{!isBlocked && birthdateUpdateText}
-
+ {aa.flags.isOverRegionMinAccessAge && }
>
) : (
diff --git a/src/ageAssurance/data.tsx b/src/ageAssurance/data.tsx
index e06e291052..f6353a899b 100644
--- a/src/ageAssurance/data.tsx
+++ b/src/ageAssurance/data.tsx
@@ -136,6 +136,15 @@ export async function prefetchConfig() {
}
})
}
+export async function refetchConfig() {
+ logger.debug(`refetchConfig: fetching...`)
+ const res = await getConfig()
+ qc.setQueryData(
+ configQueryKey,
+ res,
+ )
+ return res
+}
export function useConfigQuery() {
return useQuery(
{
@@ -146,6 +155,10 @@ export function useConfigQuery() {
* @see https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#initial-data-from-the-cache-with-initialdataupdatedat
*/
staleTime: IS_DEV ? 5e3 : 1000 * 60 * 60,
+ /**
+ * N.B. if prefetch failed above, we'll have no `initialData`, and this
+ * query will run on startup.
+ */
initialData: getConfigFromCache(),
initialDataUpdatedAt: () =>
qc.getQueryState(configQueryKey)?.dataUpdatedAt,
diff --git a/src/ageAssurance/debug.ts b/src/ageAssurance/debug.ts
index 20ed6174eb..8a97cf84ee 100644
--- a/src/ageAssurance/debug.ts
+++ b/src/ageAssurance/debug.ts
@@ -12,23 +12,26 @@ export const enabled = (IS_DEV && false) || IS_E2E
export const geolocation: Geolocation | undefined = enabled
? {
- countryCode: 'AA',
+ countryCode: 'BB',
regionCode: undefined,
}
: undefined
-export const deviceGeolocation: Geolocation | undefined = enabled
- ? {
- countryCode: 'AA',
- regionCode: undefined,
- }
- : undefined
+const deviceGeolocationEnabled = false
+export const deviceGeolocation: Geolocation | undefined =
+ enabled && deviceGeolocationEnabled
+ ? {
+ countryCode: 'AA',
+ regionCode: undefined,
+ }
+ : undefined
export const config: AppBskyAgeassuranceDefs.Config = {
regions: [
{
countryCode: 'AA',
regionCode: undefined,
+ minAccessAge: 13,
rules: [
{
$type: ids.Default,
@@ -36,6 +39,17 @@ export const config: AppBskyAgeassuranceDefs.Config = {
},
],
},
+ {
+ countryCode: 'BB',
+ regionCode: undefined,
+ minAccessAge: 16,
+ rules: [
+ {
+ $type: ids.Default,
+ access: 'none',
+ },
+ ],
+ },
],
}
diff --git a/src/ageAssurance/index.tsx b/src/ageAssurance/index.tsx
index 9a0a9c9d51..68654b384b 100644
--- a/src/ageAssurance/index.tsx
+++ b/src/ageAssurance/index.tsx
@@ -14,7 +14,11 @@ import {
type AgeAssuranceState,
AgeAssuranceStatus,
} from '#/ageAssurance/types'
-import {isUserUnderAdultAge} from '#/ageAssurance/util'
+import {
+ isUnderAge,
+ MIN_ACCESS_AGE,
+ useAgeAssuranceRegionConfigWithFallback,
+} from '#/ageAssurance/util'
export {
prefetchConfig as prefetchAgeAssuranceConfig,
@@ -24,6 +28,7 @@ export {
usePatchServerState as usePatchAgeAssuranceServerState,
} from '#/ageAssurance/data'
export {logger} from '#/ageAssurance/logger'
+export {MIN_ACCESS_AGE} from '#/ageAssurance/util'
const AgeAssuranceStateContext = createContext<{
Access: typeof AgeAssuranceAccess
@@ -32,6 +37,8 @@ const AgeAssuranceStateContext = createContext<{
flags: {
adultContentDisabled: boolean
chatDisabled: boolean
+ isOverRegionMinAccessAge: boolean
+ isOverAppMinAccessAge: boolean
}
}>({
Access: AgeAssuranceAccess,
@@ -44,6 +51,8 @@ const AgeAssuranceStateContext = createContext<{
flags: {
adultContentDisabled: false,
chatDisabled: false,
+ isOverRegionMinAccessAge: false,
+ isOverAppMinAccessAge: false,
},
})
@@ -69,6 +78,7 @@ export function Provider({children}: {children: React.ReactNode}) {
function InnerProvider({children}: {children: React.ReactNode}) {
const state = useAgeAssuranceState()
const {data} = useAgeAssuranceDataContext()
+ const config = useAgeAssuranceRegionConfigWithFallback()
const getAndRegisterPushToken = useGetAndRegisterPushToken()
const handleAccessUpdate = useCallback(
@@ -89,11 +99,17 @@ function InnerProvider({children}: {children: React.ReactNode}) {
{
const chatDisabled = state.access !== AgeAssuranceAccess.Full
- const isUnderage = data?.birthdate
- ? isUserUnderAdultAge(data.birthdate)
+ const isUnderAdultAge = data?.birthdate
+ ? isUnderAge(data.birthdate, 18)
: true
+ const isOverRegionMinAccessAge = data?.birthdate
+ ? !isUnderAge(data.birthdate, config.minAccessAge)
+ : false
+ const isOverAppMinAccessAge = data?.birthdate
+ ? !isUnderAge(data.birthdate, MIN_ACCESS_AGE)
+ : false
const adultContentDisabled =
- state.access !== AgeAssuranceAccess.Full || isUnderage
+ state.access !== AgeAssuranceAccess.Full || isUnderAdultAge
return {
Access: AgeAssuranceAccess,
Status: AgeAssuranceStatus,
@@ -101,9 +117,11 @@ function InnerProvider({children}: {children: React.ReactNode}) {
flags: {
adultContentDisabled,
chatDisabled,
+ isOverRegionMinAccessAge,
+ isOverAppMinAccessAge,
},
}
- }, [state, data])}>
+ }, [state, data, config])}>
{children}
)
diff --git a/src/ageAssurance/state.ts b/src/ageAssurance/state.ts
index cc8b60ac52..f4d1e41e3a 100644
--- a/src/ageAssurance/state.ts
+++ b/src/ageAssurance/state.ts
@@ -30,12 +30,19 @@ export function useAgeAssuranceState(): AgeAssuranceState {
access: AgeAssuranceAccess.Safe,
}
- // should never happen, but need to guard
+ /**
+ * This can happen if the prefetch fails (such as due to network issues).
+ * The query handler will try it again, but if it continues to fail, of
+ * course we won't have config.
+ *
+ * In this case, fail open to avoid blocking users.
+ */
if (!config) {
logger.warn('useAgeAssuranceState: missing config')
return {
status: AgeAssuranceStatus.Unknown,
- access: AgeAssuranceAccess.Unknown,
+ access: AgeAssuranceAccess.Safe,
+ error: 'config',
}
}
diff --git a/src/ageAssurance/types.ts b/src/ageAssurance/types.ts
index 9f83975d3e..f34ed10aea 100644
--- a/src/ageAssurance/types.ts
+++ b/src/ageAssurance/types.ts
@@ -18,6 +18,7 @@ export type AgeAssuranceState = {
lastInitiatedAt?: string
status: AgeAssuranceStatus
access: AgeAssuranceAccess
+ error?: 'config' // maybe other specific cases in the future
}
export function parseStatusFromString(raw: string) {
diff --git a/src/ageAssurance/util.ts b/src/ageAssurance/util.ts
index e31f89b14e..d55ec61762 100644
--- a/src/ageAssurance/util.ts
+++ b/src/ageAssurance/util.ts
@@ -12,7 +12,23 @@ import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
import {AgeAssuranceAccess} from '#/ageAssurance/types'
import {type Geolocation, useGeolocation} from '#/geolocation'
-const DEFAULT_MIN_AGE = 13
+export const MIN_ACCESS_AGE = 13
+const FALLBACK_REGION_CONFIG: AppBskyAgeassuranceDefs.ConfigRegion = {
+ countryCode: '*',
+ regionCode: undefined,
+ minAccessAge: MIN_ACCESS_AGE,
+ rules: [
+ {
+ $type: ids.IfDeclaredOverAge,
+ age: MIN_ACCESS_AGE,
+ access: AgeAssuranceAccess.Full,
+ },
+ {
+ $type: ids.Default,
+ access: AgeAssuranceAccess.None,
+ },
+ ],
+}
/**
* Get age assurance region config based on geolocation, with fallback to
@@ -30,23 +46,7 @@ export function getAgeAssuranceRegionConfigWithFallback(
regionCode: geolocation.regionCode,
})
- return (
- region || {
- countryCode: '*',
- regionCode: undefined,
- rules: [
- {
- $type: ids.IfDeclaredOverAge,
- age: DEFAULT_MIN_AGE,
- access: AgeAssuranceAccess.Full,
- },
- {
- $type: ids.Default,
- access: AgeAssuranceAccess.None,
- },
- ],
- }
- )
+ return region || FALLBACK_REGION_CONFIG
}
/**
@@ -67,6 +67,14 @@ export function useAgeAssuranceRegionConfig() {
}, [config, geolocation])
}
+/**
+ * Hook to get the age assurance region config based on current geolocation.
+ * Falls back to our app defaults if no region config is found.
+ */
+export function useAgeAssuranceRegionConfigWithFallback() {
+ return useAgeAssuranceRegionConfig() || FALLBACK_REGION_CONFIG
+}
+
/**
* Some users may have erroneously set their birth date to the current date
* if one wasn't set on their account. We previously didn't do validation on
@@ -78,15 +86,11 @@ export function isLegacyBirthdateBug(birthDate: string) {
}
/**
- * Returns whether the user is under the minimum age required to use the app.
- * This applies to all regions.
+ * Returns whether the date (converted to an age as a whole integer) is under
+ * the provided minimum age.
*/
-export function isUserUnderMinimumAge(birthDate: string) {
- return getAge(new Date(birthDate)) < DEFAULT_MIN_AGE
-}
-
-export function isUserUnderAdultAge(birthDate: string) {
- return getAge(new Date(birthDate)) < 18
+export function isUnderAge(birthDate: string, age: number) {
+ return getAge(new Date(birthDate)) < age
}
export function getBirthdateStringFromAge(age: number) {
diff --git a/src/components/ageAssurance/AgeAssuranceAccountCard.tsx b/src/components/ageAssurance/AgeAssuranceAccountCard.tsx
index a75e75d133..ecdff5b381 100644
--- a/src/components/ageAssurance/AgeAssuranceAccountCard.tsx
+++ b/src/components/ageAssurance/AgeAssuranceAccountCard.tsx
@@ -8,6 +8,7 @@ import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
+import {AgeAssuranceConfigUnavailableError} from '#/components/ageAssurance/AgeAssuranceErrors'
import {
AgeAssuranceInitDialog,
useDialogControl,
@@ -27,6 +28,13 @@ import {useDeviceGeolocationApi} from '#/geolocation'
export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) {
const aa = useAgeAssurance()
if (aa.state.access === aa.Access.Full) return null
+ if (aa.state.error === 'config') {
+ return (
+
+
+
+ )
+ }
return
}
diff --git a/src/components/ageAssurance/AgeAssuranceAdmonition.tsx b/src/components/ageAssurance/AgeAssuranceAdmonition.tsx
index 7889070c09..1ef29dfbbb 100644
--- a/src/components/ageAssurance/AgeAssuranceAdmonition.tsx
+++ b/src/components/ageAssurance/AgeAssuranceAdmonition.tsx
@@ -3,6 +3,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf'
+import {AgeAssuranceConfigUnavailableError} from '#/components/ageAssurance/AgeAssuranceErrors'
import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog'
import type * as Dialog from '#/components/Dialog'
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
@@ -19,6 +20,9 @@ export function AgeAssuranceAdmonition({
const aa = useAgeAssurance()
if (aa.state.access === aa.Access.Full) return null
+ if (aa.state.error === 'config') {
+ return
+ }
return (
diff --git a/src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx b/src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx
index 3471393451..aa1d527f62 100644
--- a/src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx
+++ b/src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx
@@ -23,6 +23,7 @@ export function useInternalState() {
const visible = useMemo(() => {
if (aa.state.access === aa.Access.Full) return false
if (aa.state.lastInitiatedAt) return false
+ if (aa.state.error === 'config') return false
if (hidden) return false
if (nux && nux.completed) return false
return true
diff --git a/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx b/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx
index 934ac8d14a..14c7ed300f 100644
--- a/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx
+++ b/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx
@@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {atoms as a, type ViewStyleProp} from '#/alf'
import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
+import {AgeAssuranceConfigUnavailableError} from '#/components/ageAssurance/AgeAssuranceErrors'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
@@ -26,33 +27,37 @@ export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) {
return (
-
- {copy.notice}
+ {aa.state.error === 'config' ? (
+
+ ) : (
+
+ {copy.notice}
-
-
+
+
+ )}
)
}
diff --git a/src/components/ageAssurance/AgeAssuranceErrors.tsx b/src/components/ageAssurance/AgeAssuranceErrors.tsx
new file mode 100644
index 0000000000..628153f084
--- /dev/null
+++ b/src/components/ageAssurance/AgeAssuranceErrors.tsx
@@ -0,0 +1,37 @@
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {type ViewStyleProp} from '#/alf'
+import * as Admonition from '#/components/Admonition'
+import {ButtonIcon, ButtonText} from '#/components/Button'
+import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
+import {refetchConfig} from '#/ageAssurance/data'
+
+export function AgeAssuranceConfigUnavailableError(props: ViewStyleProp) {
+ const {_} = useLingui()
+ return (
+
+
+
+
+
+
+ We were unable to load the age assurance configuration for your
+ region, probably due to a network error. Some content and features
+ may be unavailable temporarily. Please try again later.
+
+
+
+ refetchConfig().catch(() => {})}>
+
+ Retry
+
+
+
+
+
+ )
+}
diff --git a/src/components/ageAssurance/AgeRestrictedScreen.tsx b/src/components/ageAssurance/AgeRestrictedScreen.tsx
index 85881a3ada..9f9c4c4d5b 100644
--- a/src/components/ageAssurance/AgeRestrictedScreen.tsx
+++ b/src/components/ageAssurance/AgeRestrictedScreen.tsx
@@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react'
import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
+import {AgeAssuranceConfigUnavailableError} from '#/components/ageAssurance/AgeAssuranceErrors'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {ButtonIcon, ButtonText} from '#/components/Button'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
@@ -44,6 +45,12 @@ export function AgeRestrictedScreen({
+ {aa.state.error === 'config' && (
+
+
+
+ )}
+
diff --git a/src/geolocation/debug.ts b/src/geolocation/debug.ts
index f062a94cef..0d2564c305 100644
--- a/src/geolocation/debug.ts
+++ b/src/geolocation/debug.ts
@@ -5,14 +5,20 @@ import {type Geolocation} from '#/geolocation/types'
const localEnabled = false
export const enabled = IS_DEV && (localEnabled || aaDebug.geolocation)
export const geolocation: Geolocation = aaDebug.geolocation ?? {
- countryCode: 'AU',
- regionCode: undefined,
-}
-export const deviceGeolocation: Geolocation = aaDebug.deviceGeolocation ?? {
- countryCode: 'AU',
- regionCode: undefined,
+ countryCode: 'US',
+ regionCode: 'TX',
}
+const deviceLocalEnabled = false
+export const deviceGeolocation: Geolocation | undefined =
+ aaDebug.deviceGeolocation ||
+ (deviceLocalEnabled
+ ? {
+ countryCode: 'US',
+ regionCode: 'TX',
+ }
+ : undefined)
+
export async function resolve(data: T) {
await new Promise(y => setTimeout(y, 500)) // simulate network
return data
diff --git a/src/geolocation/device.ts b/src/geolocation/device.ts
index d14d4eb677..b98e6a2363 100644
--- a/src/geolocation/device.ts
+++ b/src/geolocation/device.ts
@@ -46,7 +46,8 @@ const useForegroundPermissions = createPermissionHook({
})
export async function getDeviceGeolocation(): Promise {
- if (debug.enabled) return debug.resolve(debug.deviceGeolocation)
+ if (debug.enabled && debug.deviceGeolocation)
+ return debug.resolve(debug.deviceGeolocation)
try {
const geocode = await Location.getCurrentPositionAsync()
@@ -142,3 +143,8 @@ export function useSyncDeviceGeolocationOnStartup(
})
}, [status, sync])
}
+
+export function useIsDeviceGeolocationGranted() {
+ const [status] = useForegroundPermissions()
+ return status?.granted === true
+}
diff --git a/src/geolocation/index.tsx b/src/geolocation/index.tsx
index 231182a579..c9dc0cb1ed 100644
--- a/src/geolocation/index.tsx
+++ b/src/geolocation/index.tsx
@@ -12,7 +12,10 @@ import {type Geolocation} from '#/geolocation/types'
import {mergeGeolocations} from '#/geolocation/util'
import {device, useStorage} from '#/storage'
-export {useRequestDeviceGeolocation} from '#/geolocation/device'
+export {
+ useIsDeviceGeolocationGranted,
+ useRequestDeviceGeolocation,
+} from '#/geolocation/device'
export {resolve} from '#/geolocation/service'
export * from '#/geolocation/types'
diff --git a/src/screens/Signup/StepInfo/Policies.tsx b/src/screens/Signup/StepInfo/Policies.tsx
index 2c609cfc4d..565c2d4cd1 100644
--- a/src/screens/Signup/StepInfo/Policies.tsx
+++ b/src/screens/Signup/StepInfo/Policies.tsx
@@ -11,12 +11,8 @@ import {Text} from '#/components/Typography'
export const Policies = ({
serviceDescription,
- needsGuardian,
- under13,
}: {
serviceDescription: ComAtprotoServerDescribeServer.OutputSchema
- needsGuardian: boolean
- under13: boolean
}) => {
const t = useTheme()
const {_} = useLingui()
@@ -91,30 +87,9 @@ export const Policies = ({
return null
}
- return (
-
- {els ? (
-
- {els}
-
- ) : null}
-
- {under13 ? (
-
-
- You must be 13 years of age or older to create an account.
-
-
- ) : needsGuardian ? (
-
-
- If you are not yet an adult according to the laws of your country,
- your parent or legal guardian must read these Terms on your behalf.
-
-
- ) : undefined}
-
- )
+ return els ? (
+ {els}
+ ) : null
}
function validWebLink(url?: string): string | undefined {
diff --git a/src/screens/Signup/StepInfo/index.tsx b/src/screens/Signup/StepInfo/index.tsx
index e3664c0194..4beced1852 100644
--- a/src/screens/Signup/StepInfo/index.tsx
+++ b/src/screens/Signup/StepInfo/index.tsx
@@ -7,9 +7,13 @@ import type tldts from 'tldts'
import {isEmailMaybeInvalid} from '#/lib/strings/email'
import {logger} from '#/logger'
-import {is13, is18, useSignupContext} from '#/screens/Signup/state'
+import {isNative} from '#/platform/detection'
+import {useSignupContext} from '#/screens/Signup/state'
import {Policies} from '#/screens/Signup/StepInfo/Policies'
import {atoms as a, native} from '#/alf'
+import * as Admonition from '#/components/Admonition'
+import * as Dialog from '#/components/Dialog'
+import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog'
import * as DateField from '#/components/forms/DateField'
import {type DateFieldRef} from '#/components/forms/DateField/types'
import {FormError} from '#/components/forms/FormError'
@@ -18,8 +22,19 @@ import * as TextField from '#/components/forms/TextField'
import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope'
import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
+import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {usePreemptivelyCompleteActivePolicyUpdate} from '#/components/PolicyUpdateOverlay/usePreemptivelyCompleteActivePolicyUpdate'
+import * as Toast from '#/components/Toast'
+import {
+ isUnderAge,
+ MIN_ACCESS_AGE,
+ useAgeAssuranceRegionConfigWithFallback,
+} from '#/ageAssurance/util'
+import {
+ useDeviceGeolocationApi,
+ useIsDeviceGeolocationGranted,
+} from '#/geolocation'
import {BackNextButtons} from '../BackNextButtons'
function sanitizeDate(date: Date): Date {
@@ -57,6 +72,20 @@ export function StepInfo({
const passwordInputRef = useRef(null)
const birthdateInputRef = useRef(null)
+ const aaRegionConfig = useAgeAssuranceRegionConfigWithFallback()
+ const {setDeviceGeolocation} = useDeviceGeolocationApi()
+ const locationControl = Dialog.useDialogControl()
+ const isOverRegionMinAccessAge = state.dateOfBirth
+ ? !isUnderAge(state.dateOfBirth.toISOString(), aaRegionConfig.minAccessAge)
+ : true
+ const isOverAppMinAccessAge = state.dateOfBirth
+ ? !isUnderAge(state.dateOfBirth.toISOString(), MIN_ACCESS_AGE)
+ : true
+ const isOverMinAdultAge = state.dateOfBirth
+ ? !isUnderAge(state.dateOfBirth.toISOString(), 18)
+ : true
+ const isDeviceGeolocationGranted = useIsDeviceGeolocationGranted()
+
const [hasWarnedEmail, setHasWarnedEmail] = React.useState(false)
const tldtsRef = React.useRef(undefined)
@@ -76,7 +105,7 @@ export function StepInfo({
const emailChanged = prevEmailValueRef.current !== email
const password = passwordValueRef.current
- if (!is13(state.dateOfBirth)) {
+ if (!isOverRegionMinAccessAge) {
return
}
@@ -274,16 +303,79 @@ export function StepInfo({
maximumDate={new Date()}
/>
-
+
+
+
+
+ {!isOverRegionMinAccessAge || !isOverAppMinAccessAge ? (
+
+
+
+
+
+ {!isOverAppMinAccessAge ? (
+
+ You must be {MIN_ACCESS_AGE} years of age or older
+ to create an account.
+
+ ) : (
+
+ You must be {aaRegionConfig.minAccessAge} years of
+ age or older to create an account in your region.
+
+ )}
+
+ {isNative &&
+ !isDeviceGeolocationGranted &&
+ isOverAppMinAccessAge && (
+
+
+ Have we got your location wrong?{' '}
+ {
+ locationControl.open()
+ })}>
+ Tap here to confirm your location with GPS.
+
+
+
+ )}
+
+
+
+ ) : !isOverMinAdultAge ? (
+
+
+ If you are not yet an adult according to the laws of your
+ country, your parent or legal guardian must read these Terms
+ on your behalf.
+
+
+ ) : undefined}
+
+
+ {isNative && (
+ {
+ props.closeDialog(() => {
+ // set this after close!
+ setDeviceGeolocation(props.geolocation)
+ Toast.show(_(msg`Your location has been updated.`), {
+ type: 'success',
+ })
+ })
+ }}
+ />
+ )}
>
) : undefined}