From c2e5a18e42365b0861b461718c2cda750f54f0fa Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 8 Jan 2026 17:35:13 +0200 Subject: [PATCH 1/5] Unify build concurrency to one build per platform (#9655) Configure concurrency groups so only one iOS build and one Android build can run at a time across all workflows. This prevents manual builds from conflicting with automatic builds triggered by fingerprint changes. Co-authored-by: Claude Opus 4.5 --- .github/workflows/build-submit-android.yml | 3 +++ .github/workflows/build-submit-ios.yml | 3 +++ .github/workflows/bundle-deploy-eas-update.yml | 6 +++--- 3 files changed, 9 insertions(+), 3 deletions(-) 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 From dae03312c94c8caaf7a8982a395064a9872414db Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 8 Jan 2026 21:35:42 +0200 Subject: [PATCH 2/5] Bump version to v1.114 (#9661) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2ce5cc0243..7d201e90a5 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" From d82592f0727db8e6a5a7a7d2d226a8853f755826 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 8 Jan 2026 21:36:01 +0200 Subject: [PATCH 3/5] Fix full-screen back gesture over PagerView on iOS 26 (#9659) Patches react-native-pager-view to handle iOS 26's interactiveContentPopGestureRecognizer, using the same logic that already exists for RNSPanGestureRecognizer: on the leftmost page, disable the scrollview's pan gesture to let the back gesture through. Co-authored-by: Claude Opus 4.5 --- patches/react-native-pager-view+6.8.0.patch | 30 +++++++++++++++++++ .../react-native-pager-view+6.8.0.patch.md | 11 +++++++ 2 files changed, 41 insertions(+) create mode 100644 patches/react-native-pager-view+6.8.0.patch create mode 100644 patches/react-native-pager-view+6.8.0.patch.md 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 From a1857d62bd5fef23b7e8a8a184ed19d02a95f604 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 8 Jan 2026 14:35:43 -0600 Subject: [PATCH 4/5] Handle AA config failure (#9660) * Clarify some comments * Add error state in case of config failure * Add retry button and relayout to accommodate --- src/ageAssurance/data.tsx | 13 +++++ src/ageAssurance/state.ts | 11 +++- src/ageAssurance/types.ts | 1 + .../ageAssurance/AgeAssuranceAccountCard.tsx | 8 +++ .../ageAssurance/AgeAssuranceAdmonition.tsx | 4 ++ .../AgeAssuranceDismissibleFeedBanner.tsx | 1 + .../AgeAssuranceDismissibleNotice.tsx | 57 ++++++++++--------- .../ageAssurance/AgeAssuranceErrors.tsx | 37 ++++++++++++ .../ageAssurance/AgeRestrictedScreen.tsx | 7 +++ 9 files changed, 111 insertions(+), 28 deletions(-) create mode 100644 src/components/ageAssurance/AgeAssuranceErrors.tsx 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/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/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' && ( + + + + )} + From b3f775d1d88957b8cb3f21934c9f70eebb008764 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 8 Jan 2026 15:36:26 -0600 Subject: [PATCH 5/5] [AAv2] Improve minimum age handling (#9650) * Improve miminum age handling * Update api sdk * Fix bad import --- package.json | 2 +- .../components/NoAccessScreen.tsx | 11 +- src/ageAssurance/debug.ts | 28 +++-- src/ageAssurance/index.tsx | 28 ++++- src/ageAssurance/util.ts | 56 ++++----- src/geolocation/debug.ts | 18 ++- src/geolocation/device.ts | 8 +- src/geolocation/index.tsx | 5 +- src/screens/Signup/StepInfo/Policies.tsx | 31 +---- src/screens/Signup/StepInfo/index.tsx | 108 ++++++++++++++++-- yarn.lock | 56 ++++++--- 11 files changed, 253 insertions(+), 98 deletions(-) diff --git a/package.json b/package.json index 7d201e90a5..92b0374720 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { - "@atproto/api": "^0.18.8", + "@atproto/api": "^0.18.11", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", "@bsky.app/alf": "^0.1.6", 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/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/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/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}