diff --git a/.env.example b/.env.example
index ac8dcab1f8..96a1548d66 100644
--- a/.env.example
+++ b/.env.example
@@ -36,6 +36,3 @@ EXPO_PUBLIC_BITDRIFT_API_KEY=
# bapp-config web worker URL
BAPP_CONFIG_DEV_URL=
-
-# Dev-only passthrough value for bapp-config web worker
-BAPP_CONFIG_DEV_BYPASS_SECRET=
diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts
index 6087ba5b14..c59c8b3e34 100644
--- a/__tests__/lib/string.test.ts
+++ b/__tests__/lib/string.test.ts
@@ -957,20 +957,20 @@ describe('parseStarterPackHttpUri', () => {
})
it('returns the at uri when the input is a valid starterpack at uri', () => {
- const validAtUri = 'at://did:123/app.bsky.graph.starterpack/rkey'
+ const validAtUri = 'at://did:plc:123/app.bsky.graph.starterpack/rkey'
expect(parseStarterPackUri(validAtUri)).toEqual({
- name: 'did:123',
+ name: 'did:plc:123',
rkey: 'rkey',
})
})
it('returns null when the at uri has no rkey', () => {
- const validAtUri = 'at://did:123/app.bsky.graph.starterpack'
+ const validAtUri = 'at://did:plc:123/app.bsky.graph.starterpack'
expect(parseStarterPackUri(validAtUri)).toEqual(null)
})
it('returns null when the collection is not app.bsky.graph.starterpack', () => {
- const validAtUri = 'at://did:123/app.bsky.graph.list/rkey'
+ const validAtUri = 'at://did:plc:123/app.bsky.graph.list/rkey'
expect(parseStarterPackUri(validAtUri)).toEqual(null)
})
diff --git a/bskyweb/cmd/bskyweb/renderer.go b/bskyweb/cmd/bskyweb/renderer.go
index 4bf8b80c5c..6e02456f53 100644
--- a/bskyweb/cmd/bskyweb/renderer.go
+++ b/bskyweb/cmd/bskyweb/renderer.go
@@ -71,7 +71,7 @@ func (r Renderer) Render(w io.Writer, name string, data interface{}, c echo.Cont
if r.Debug {
t, err = pongo2.FromFile(name)
} else {
- t, err = r.TemplateSet.FromFile(name)
+ t, err = r.TemplateSet.FromCache(name)
}
if err != nil {
diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html
index 0bde22e77c..b5f504904f 100644
--- a/bskyweb/templates/base.html
+++ b/bskyweb/templates/base.html
@@ -68,11 +68,19 @@
width: 100%;
}
#splash {
+ display: flex;
position: fixed;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ align-items: center;
+ justify-content: center;
+ }
+ #splash svg {
+ position: relative;
+ top: -50px;
width: 100px;
- left: 50%;
- top: 50%;
- transform: translateX(-50%) translateY(-50%) translateY(-50px);
}
/**
* We need these styles to prevent shifting due to scrollbar show/hide on
@@ -106,7 +114,7 @@
diff --git a/package.json b/package.json
index e2653d5885..f7be70a77b 100644
--- a/package.json
+++ b/package.json
@@ -72,7 +72,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
- "@atproto/api": "^0.18.0",
+ "@atproto/api": "^0.18.4",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.5",
@@ -103,7 +103,7 @@
"@react-navigation/native-stack": "^7.3.13",
"@sentry/react-native": "~6.20.0",
"@tanstack/query-async-storage-persister": "^5.25.0",
- "@tanstack/react-query": "^5.8.1",
+ "@tanstack/react-query": "5.25.0",
"@tanstack/react-query-persist-client": "^5.25.0",
"@tiptap/core": "^2.9.1",
"@tiptap/extension-document": "^2.9.1",
diff --git a/patches/expo-modules-core+3.0.24.patch b/patches/expo-modules-core+3.0.24.patch
index f3d9bfd149..d396099932 100644
--- a/patches/expo-modules-core+3.0.24.patch
+++ b/patches/expo-modules-core+3.0.24.patch
@@ -1,3 +1,32 @@
+diff --git a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/activityresult/AppContextActivityResultLauncher.kt b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/activityresult/AppContextActivityResultLauncher.kt
+index d300fc2..0890878 100644
+--- a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/activityresult/AppContextActivityResultLauncher.kt
++++ b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/activityresult/AppContextActivityResultLauncher.kt
+@@ -3,8 +3,8 @@ package expo.modules.kotlin.activityresult
+ import androidx.activity.result.ActivityResultCallback
+ import androidx.activity.result.contract.ActivityResultContract
+ import java.io.Serializable
++import kotlinx.coroutines.suspendCancellableCoroutine
+ import kotlin.coroutines.resume
+-import kotlin.coroutines.suspendCoroutine
+
+ /**
+ * A launcher for a previously-[AppContextActivityResultCaller.registerForActivityResult] prepared call
+@@ -22,8 +22,12 @@ abstract class AppContextActivityResultLauncher {
+ */
+ abstract fun launch(input: I, callback: ActivityResultCallback)
+
+- suspend fun launch(input: I): O = suspendCoroutine { continuation ->
+- launch(input) { output -> continuation.resume(output) }
++ suspend fun launch(input: I): O = suspendCancellableCoroutine { continuation ->
++ launch(input) { output ->
++ if (continuation.isActive) {
++ continuation.resume(output)
++ }
++ }
+ }
+
+ abstract val contract: AppContextActivityResultContract
diff --git a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt
index 47c4d15..afe138d 100644
--- a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt
diff --git a/patches/react-native+0.81.5+004+IdleCallbackRunnable-crash-fix.patch b/patches/react-native+0.81.5+004+IdleCallbackRunnable-crash-fix.patch
new file mode 100644
index 0000000000..b3954c14f1
--- /dev/null
+++ b/patches/react-native+0.81.5+004+IdleCallbackRunnable-crash-fix.patch
@@ -0,0 +1,16 @@
+diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.kt b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.kt
+index 8b65716..27c97bf 100644
+--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.kt
++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.kt
+@@ -313,8 +313,9 @@ public open class JavaTimerManager(
+ // We also capture the idleCallbackRunnable to tentatively fix:
+ // https://github.com/facebook/react-native/issues/44842
+ currentIdleCallbackRunnable?.cancel()
+- currentIdleCallbackRunnable = IdleCallbackRunnable(frameTimeNanos)
+- reactApplicationContext.runOnJSQueueThread(currentIdleCallbackRunnable)
++ val idleCallbackRunnable = IdleCallbackRunnable(frameTimeNanos)
++ currentIdleCallbackRunnable = idleCallbackRunnable
++ reactApplicationContext.runOnJSQueueThread(idleCallbackRunnable)
+ reactChoreographer.postFrameCallback(ReactChoreographer.CallbackType.IDLE_EVENT, this)
+ }
+ }
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 30a5e81296..fb30086273 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -25,16 +25,10 @@ import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {isAndroid, isIOS} from '#/platform/detection'
import {Provider as A11yProvider} from '#/state/a11y'
-import {Provider as AgeAssuranceProvider} from '#/state/ageAssurance'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
import {listenSessionDropped} from '#/state/events'
-import {
- beginResolveGeolocationConfig,
- ensureGeolocationConfigIsResolved,
- Provider as GeolocationProvider,
-} from '#/state/geolocation'
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
import {Provider as LightboxStateProvider} from '#/state/lightbox'
@@ -56,6 +50,7 @@ import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as ComposerProvider} from '#/state/shell/composer'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
+import {Provider as OnboardingProvider} from '#/state/shell/onboarding'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
@@ -73,6 +68,9 @@ import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdate
import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import {ToastOutlet} from '#/components/Toast'
+import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
+import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
+import * as Geo from '#/geolocation'
import {Splash} from '#/Splash'
import {BottomSheetProvider} from '../modules/bottom-sheet'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
@@ -93,7 +91,8 @@ if (isAndroid) {
/**
* Begin geolocation ASAP
*/
-beginResolveGeolocationConfig()
+Geo.resolve()
+prefetchAgeAssuranceConfig()
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
@@ -143,7 +142,7 @@ function InnerApp() {
-
+
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
@@ -186,7 +185,7 @@ function InnerApp() {
-
+
@@ -203,10 +202,9 @@ function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
- Promise.all([
- initPersistedState(),
- ensureGeolocationConfigIsResolved(),
- ]).then(() => setReady(true))
+ Promise.all([initPersistedState(), Geo.resolve()]).then(() =>
+ setReady(true),
+ )
}, [])
if (!isReady) {
@@ -218,36 +216,38 @@ function App() {
* that is set up in the InnerApp component above.
*/
return (
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
)
}
diff --git a/src/App.web.tsx b/src/App.web.tsx
index b7cba6122e..f4b514dfc1 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -14,16 +14,10 @@ import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
-import {Provider as AgeAssuranceProvider} from '#/state/ageAssurance'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
import {listenSessionDropped} from '#/state/events'
-import {
- beginResolveGeolocationConfig,
- ensureGeolocationConfigIsResolved,
- Provider as GeolocationProvider,
-} from '#/state/geolocation'
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
import {Provider as LightboxStateProvider} from '#/state/lightbox'
import {MessagesProvider} from '#/state/messages'
@@ -44,6 +38,7 @@ import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as ComposerProvider} from '#/state/shell/composer'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
+import {Provider as OnboardingProvider} from '#/state/shell/onboarding'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
@@ -61,13 +56,18 @@ import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext'
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import {ToastOutlet} from '#/components/Toast'
+import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
+import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
+import * as Geo from '#/geolocation'
+import {Splash} from '#/Splash'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder'
/**
* Begin geolocation ASAP
*/
-beginResolveGeolocationConfig()
+Geo.resolve()
+prefetchAgeAssuranceConfig()
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
@@ -104,7 +104,7 @@ function InnerApp() {
}, [_])
// wait for session to resume
- if (!isReady || !hasCheckedReferrer) return null
+ if (!isReady || !hasCheckedReferrer) return
return (
@@ -118,7 +118,7 @@ function InnerApp() {
-
+
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
@@ -157,7 +157,7 @@ function InnerApp() {
-
+
@@ -174,14 +174,13 @@ function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
- Promise.all([
- initPersistedState(),
- ensureGeolocationConfigIsResolved(),
- ]).then(() => setReady(true))
+ Promise.all([initPersistedState(), Geo.resolve()]).then(() =>
+ setReady(true),
+ )
}, [])
if (!isReady) {
- return null
+ return
}
/*
@@ -189,29 +188,31 @@ function App() {
* that is set up in the InnerApp component above.
*/
return (
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
)
}
diff --git a/src/Splash.web.tsx b/src/Splash.web.tsx
new file mode 100644
index 0000000000..eb4a405c85
--- /dev/null
+++ b/src/Splash.web.tsx
@@ -0,0 +1,29 @@
+/*
+ * This is a reimplementation of what exists in our HTML template files
+ * already. Once the React tree mounts, this is what gets rendered first, until
+ * the app is ready to go.
+ */
+
+import {View} from 'react-native'
+import Svg, {Path} from 'react-native-svg'
+
+import {atoms as a} from '#/alf'
+
+const size = 100
+const ratio = 57 / 64
+
+export function Splash() {
+ return (
+
+
+
+ )
+}
diff --git a/src/ageAssurance/__mocks__/data.tsx b/src/ageAssurance/__mocks__/data.tsx
new file mode 100644
index 0000000000..b548a2f866
--- /dev/null
+++ b/src/ageAssurance/__mocks__/data.tsx
@@ -0,0 +1,3 @@
+export const prefetchAgeAssuranceData = () => {}
+export const setBirthdateForDid = () => {}
+export const setCreatedAtForDid = () => {}
diff --git a/src/ageAssurance/components/NoAccessScreen.tsx b/src/ageAssurance/components/NoAccessScreen.tsx
new file mode 100644
index 0000000000..3b4f7d7088
--- /dev/null
+++ b/src/ageAssurance/components/NoAccessScreen.tsx
@@ -0,0 +1,355 @@
+import {useCallback, useEffect} from 'react'
+import {ScrollView, View} from 'react-native'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {
+ SupportCode,
+ useCreateSupportLink,
+} from '#/lib/hooks/useCreateSupportLink'
+import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
+import {logger} from '#/logger'
+import {isWeb} from '#/platform/detection'
+import {isNative} from '#/platform/detection'
+import {useIsBirthdateUpdateAllowed} from '#/state/birthdate'
+import {useSessionApi} from '#/state/session'
+import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
+import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
+import {AgeAssuranceInitDialog} from '#/components/ageAssurance/AgeAssuranceInitDialog'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {useDialogControl} from '#/components/Dialog'
+import * as Dialog from '#/components/Dialog'
+import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
+import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog'
+import {Full as Logo} from '#/components/icons/Logo'
+import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield'
+import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
+import {Outlet as PortalOutlet} from '#/components/Portal'
+import * as Toast from '#/components/Toast'
+import {Text} from '#/components/Typography'
+import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
+import {useAgeAssurance} from '#/ageAssurance'
+import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
+import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
+import {
+ isLegacyBirthdateBug,
+ useAgeAssuranceRegionConfig,
+} from '#/ageAssurance/util'
+import {useDeviceGeolocationApi} from '#/geolocation'
+
+const textStyles = [a.text_md, a.leading_snug]
+
+export function NoAccessScreen() {
+ const t = useTheme()
+ const {_} = useLingui()
+ const {gtPhone} = useBreakpoints()
+ const insets = useSafeAreaInsets()
+ const birthdateControl = useDialogControl()
+ const {data} = useAgeAssuranceDataContext()
+ const region = useAgeAssuranceRegionConfig()
+ const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
+ const {logoutCurrentAccount} = useSessionApi()
+ const createSupportLink = useCreateSupportLink()
+
+ const aa = useAgeAssurance()
+ const isBlocked = aa.state.status === aa.Status.Blocked
+ const isAARegion = !!region
+ const hasDeclaredAge = data?.declaredAge !== undefined
+ const canUpdateBirthday =
+ isBirthdateUpdateAllowed || isLegacyBirthdateBug(data?.birthdate || '')
+
+ useEffect(() => {
+ // just counting overall hits here
+ logger.metric(`blockedGeoOverlay:shown`, {})
+ }, [])
+
+ const onPressLogout = useCallback(() => {
+ if (isWeb) {
+ // We're switching accounts, which remounts the entire app.
+ // On mobile, this gets us Home, but on the web we also need reset the URL.
+ // We can't change the URL via a navigate() call because the navigator
+ // itself is about to unmount, and it calls pushState() too late.
+ // So we change the URL ourselves. The navigator will pick it up on remount.
+ history.pushState(null, '', '/')
+ }
+ logoutCurrentAccount('AgeAssuranceNoAccessScreen')
+ }, [logoutCurrentAccount])
+
+ const birthdateUpdateText = canUpdateBirthday ? (
+
+
+ If you believe your birthdate is incorrect, you can update it by{' '}
+ {
+ birthdateControl.open()
+ })}>
+ clicking here
+
+ .
+
+
+ ) : (
+
+
+ If you believe your birthdate is incorrect, please{' '}
+
+ contact our support team
+
+ .
+
+
+ )
+
+ return (
+ <>
+
+
+
+
+
+
+ {hasDeclaredAge ? (
+ <>
+ {isAARegion ? (
+ <>
+
+
+
+ You are accessing Bluesky from a region that legally
+ requires us to verify your age before allowing you to
+ access the app.
+
+
+
+ {!isBlocked && birthdateUpdateText}
+
+
+
+ >
+ ) : (
+
+
+
+ Unfortunately, the birthdate you have saved to your
+ profile makes you too young to access Bluesky.
+
+
+
+ {birthdateUpdateText}
+
+ )}
+ >
+ ) : (
+
+
+
+ It looks like you haven't added your birthdate. You must
+ provide an accurate date of birth to use Bluesky.
+
+
+
+
+ )}
+
+
+
+
+
+ To log out,{' '}
+ {
+ onPressLogout()
+ })}>
+ click here
+
+ .
+
+
+
+
+
+
+
+
+ {/*
+ * While this blocking overlay is up, other dialogs in the shell
+ * are not mounted, so it _should_ be safe to use these here
+ * without fear of other modals showing up.
+ */}
+
+
+ >
+ )
+}
+
+function AccessSection() {
+ const t = useTheme()
+ const {_, i18n} = useLingui()
+ const control = useDialogControl()
+ const appealControl = Dialog.useDialogControl()
+ const locationControl = Dialog.useDialogControl()
+ const getTimeAgo = useGetTimeAgo()
+ const {setDeviceGeolocation} = useDeviceGeolocationApi()
+ const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess()
+
+ const aa = useAgeAssurance()
+ const {status, lastInitiatedAt} = aa.state
+ const isBlocked = status === aa.Status.Blocked
+ const hasInitiated = !!lastInitiatedAt
+ const timeAgo = lastInitiatedAt
+ ? getTimeAgo(lastInitiatedAt, new Date())
+ : null
+ const diff = lastInitiatedAt
+ ? dateDiff(lastInitiatedAt, new Date(), 'down')
+ : null
+
+ return (
+ <>
+
+
+
+
+ {isBlocked ? (
+
+
+ You are currently unable to access Bluesky's Age Assurance flow.
+ Please{' '}
+ {
+ appealControl.open()
+ logger.metric('ageAssurance:appealDialogOpen', {})
+ })}>
+ contact our moderation team
+ {' '}
+ if you believe this is an error.
+
+
+ ) : (
+ <>
+
+
+
+ {lastInitiatedAt && timeAgo && diff ? (
+
+ {diff.value === 0 ? (
+ Last initiated just now
+ ) : (
+ Last initiated {timeAgo} ago
+ )}
+
+ ) : (
+
+ Age assurance only takes a few minutes
+
+ )}
+
+ >
+ )}
+
+
+ {isNative && (
+ <>
+
+
+ Is your location not accurate?{' '}
+ {
+ locationControl.open()
+ })}>
+ Tap here to confirm your location.
+ {' '}
+
+
+
+ {
+ const access = computeAgeAssuranceRegionAccess(
+ props.geolocation,
+ )
+ if (access !== aa.Access.Full) {
+ props.disableDialogAction()
+ props.setDialogError(
+ _(
+ msg`We're sorry, but based on your device's location, you are currently located in a region that requires age assurance.`,
+ ),
+ )
+ } else {
+ props.closeDialog(() => {
+ // set this after close!
+ setDeviceGeolocation(props.geolocation)
+ Toast.show(_(msg`Thanks! You're all set.`), {
+ type: 'success',
+ })
+ })
+ }
+ }}
+ />
+ >
+ )}
+
+
+ >
+ )
+}
diff --git a/src/ageAssurance/components/RedirectOverlay.tsx b/src/ageAssurance/components/RedirectOverlay.tsx
new file mode 100644
index 0000000000..dc4475187f
--- /dev/null
+++ b/src/ageAssurance/components/RedirectOverlay.tsx
@@ -0,0 +1,334 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
+import {Dimensions, View} from 'react-native'
+import * as Linking from 'expo-linking'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {retry} from '#/lib/async/retry'
+import {wait} from '#/lib/async/wait'
+import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
+import {isWeb} from '#/platform/detection'
+import {isIOS} from '#/platform/detection'
+import {useAgent, useSession} from '#/state/session'
+import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf'
+import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
+import {Button, ButtonText} from '#/components/Button'
+import {FullWindowOverlay} from '#/components/FullWindowOverlay'
+import {CheckThick_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/icons/Check'
+import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+import {refetchAgeAssuranceServerState} from '#/ageAssurance'
+import {logger} from '#/ageAssurance'
+
+export type RedirectOverlayState = {
+ result: 'success' | 'unknown'
+ actorDid: string
+}
+
+/**
+ * Validate and parse the query parameters returned from the age assurance
+ * redirect. If not valid, returns `undefined` and the dialog will not open.
+ */
+export function parseRedirectOverlayState(
+ state: {
+ result?: string
+ actorDid?: string
+ } = {},
+): RedirectOverlayState | undefined {
+ let result: RedirectOverlayState['result'] = 'unknown'
+ const actorDid = state.actorDid
+
+ switch (state.result) {
+ case 'success':
+ result = 'success'
+ break
+ case 'unknown':
+ default:
+ result = 'unknown'
+ break
+ }
+
+ if (actorDid) {
+ return {
+ result,
+ actorDid,
+ }
+ }
+}
+
+const Context = createContext<{
+ isOpen: boolean
+ open: (state: RedirectOverlayState) => void
+ close: () => void
+}>({
+ isOpen: false,
+ open: () => {},
+ close: () => {},
+})
+
+export function useRedirectOverlayContext() {
+ return useContext(Context)
+}
+
+export function Provider({children}: {children?: React.ReactNode}) {
+ const {currentAccount} = useSession()
+ const incomingUrl = Linking.useLinkingURL()
+ const [state, setState] = useState(() => {
+ if (!incomingUrl) return null
+ const url = parseLinkingUrl(incomingUrl)
+ if (url.pathname !== '/intent/age-assurance') return null
+ const params = url.searchParams
+ const state = parseRedirectOverlayState({
+ result: params.get('result') ?? undefined,
+ actorDid: params.get('actorDid') ?? undefined,
+ })
+
+ if (isWeb) {
+ // Clear the URL parameters so they don't re-trigger
+ history.pushState(null, '', '/')
+ }
+
+ /*
+ * If we don't have an account or the account doesn't match, do
+ * nothing. By the time the user switches to their other account, AA
+ * state should be ready for them.
+ */
+ if (state && currentAccount && state.actorDid === currentAccount.did) {
+ return state
+ }
+
+ return null
+ })
+ const open = useCallback((state: RedirectOverlayState) => {
+ setState(state)
+ }, [])
+ const close = useCallback(() => {
+ setState(null)
+ }, [])
+
+ return (
+ ({
+ isOpen: state !== null,
+ open,
+ close,
+ }),
+ [state, open, close],
+ )}>
+ {children}
+
+ )
+}
+
+export function RedirectOverlay() {
+ const t = useTheme()
+ const {_} = useLingui()
+ const {isOpen} = useRedirectOverlayContext()
+ const {gtMobile} = useBreakpoints()
+
+ return isOpen ? (
+
+
+
+
+
+
+
+
+
+ ) : null
+}
+
+function Inner() {
+ const t = useTheme()
+ const {_} = useLingui()
+ const agent = useAgent()
+ const polling = useRef(false)
+ const unmounted = useRef(false)
+ const [error, setError] = useState(false)
+ const [success, setSuccess] = useState(false)
+ const {close} = useRedirectOverlayContext()
+
+ useEffect(() => {
+ if (polling.current) return
+
+ polling.current = true
+
+ logger.metric('ageAssurance:redirectDialogOpen', {})
+
+ wait(
+ 3e3,
+ retry(
+ 5,
+ () => true,
+ async () => {
+ if (!agent.session) return
+ if (unmounted.current) return
+
+ const data = await refetchAgeAssuranceServerState({agent})
+
+ if (data?.state.status !== 'assured') {
+ throw new Error(
+ `Polling for age assurance state did not receive assured status`,
+ )
+ }
+
+ return data
+ },
+ 1e3,
+ ),
+ )
+ .then(async data => {
+ if (!data) return
+ if (!agent.session) return
+ if (unmounted.current) return
+
+ setSuccess(true)
+
+ logger.metric('ageAssurance:redirectDialogSuccess', {})
+ })
+ .catch(() => {
+ if (unmounted.current) return
+ setError(true)
+ logger.metric('ageAssurance:redirectDialogFail', {})
+ })
+
+ return () => {
+ unmounted.current = true
+ }
+ }, [agent])
+
+ if (success) {
+ return (
+ <>
+
+
+
+
+
+
+ Success
+
+
+
+
+
+ We've confirmed your age assurance status. You can now close this
+ dialog.
+
+
+
+
+
+
+
+ >
+ )
+ }
+
+ return (
+ <>
+
+
+
+
+ {error && }
+
+
+ {error ? Connection issue : Verifying}
+
+
+ {!error && }
+
+
+
+ {error ? (
+
+ We were unable to receive the verification due to a connection
+ issue. It may arrive later. If it does, your account will update
+ automatically.
+
+ ) : (
+
+ We're confirming your age assurance status with our servers. This
+ should only take a few seconds.
+
+ )}
+
+
+ {error && (
+
+
+
+ )}
+
+ >
+ )
+}
diff --git a/src/ageAssurance/data.tsx b/src/ageAssurance/data.tsx
new file mode 100644
index 0000000000..24619890c8
--- /dev/null
+++ b/src/ageAssurance/data.tsx
@@ -0,0 +1,495 @@
+import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
+import {
+ type AppBskyAgeassuranceDefs,
+ type AppBskyAgeassuranceGetConfig,
+ type AppBskyAgeassuranceGetState,
+ AtpAgent,
+ getAgeAssuranceRegionConfig,
+} from '@atproto/api'
+import AsyncStorage from '@react-native-async-storage/async-storage'
+import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
+import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
+import {persistQueryClient} from '@tanstack/react-query-persist-client'
+import debounce from 'lodash.debounce'
+
+import {networkRetry} from '#/lib/async/retry'
+import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
+import {getAge} from '#/lib/strings/time'
+import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
+import {useAgent, useSession} from '#/state/session'
+import * as debug from '#/ageAssurance/debug'
+import {logger} from '#/ageAssurance/logger'
+import {isLegacyBirthdateBug} from '#/ageAssurance/util'
+import {IS_DEV} from '#/env'
+import {device} from '#/storage'
+
+/**
+ * Special query client for age assurance data so we can prefetch on app
+ * load without interfering with other queries.
+ */
+const qc = new QueryClient({
+ defaultOptions: {
+ queries: {
+ /**
+ * We clear this manually, so disable automatic garbage collection.
+ * @see https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient#how-it-works
+ */
+ gcTime: Infinity,
+ },
+ },
+})
+const persister = createAsyncStoragePersister({
+ storage: AsyncStorage,
+ key: 'age-assurance-query-client',
+})
+const [, cacheHydrationPromise] = persistQueryClient({
+ queryClient: qc,
+ persister,
+})
+
+function getDidFromAgentSession(agent: AtpAgent) {
+ const sessionManager = agent.sessionManager
+ if (!sessionManager || !sessionManager.did) return
+ return sessionManager.did
+}
+
+/*
+ * Optimistic data
+ */
+
+const createdAtCache = new Map()
+export function setCreatedAtForDid({
+ did,
+ createdAt,
+}: {
+ did: string
+ createdAt: string
+}) {
+ createdAtCache.set(did, createdAt)
+}
+const birthdateCache = new Map()
+export function setBirthdateForDid({
+ did,
+ birthdate,
+}: {
+ did: string
+ birthdate: string
+}) {
+ birthdateCache.set(did, birthdate)
+}
+
+/*
+ * Config
+ */
+
+export const configQueryKey = ['config']
+export async function getConfig() {
+ if (debug.enabled) return debug.resolve(debug.config)
+ const agent = new AtpAgent({
+ service: PUBLIC_BSKY_SERVICE,
+ })
+ const res = await agent.app.bsky.ageassurance.getConfig()
+ return res.data
+}
+export function getConfigFromCache():
+ | AppBskyAgeassuranceGetConfig.OutputSchema
+ | undefined {
+ return qc.getQueryData(
+ configQueryKey,
+ )
+}
+let configPrefetchPromise: Promise | undefined
+export async function prefetchConfig() {
+ if (configPrefetchPromise) {
+ logger.debug(`prefetchAgeAssuranceConfig: already in progress`)
+ return
+ }
+
+ configPrefetchPromise = new Promise(async resolve => {
+ await cacheHydrationPromise
+ const cached = getConfigFromCache()
+
+ if (cached) {
+ logger.debug(`prefetchAgeAssuranceConfig: using cache`)
+ resolve()
+ } else {
+ try {
+ logger.debug(`prefetchAgeAssuranceConfig: resolving...`)
+ const res = await networkRetry(3, () => getConfig())
+ qc.setQueryData(
+ configQueryKey,
+ res,
+ )
+ } catch (e: any) {
+ logger.warn(`prefetchAgeAssuranceConfig: failed`, {
+ safeMessage: e.message,
+ })
+ } finally {
+ resolve()
+ }
+ }
+ })
+}
+export function useConfigQuery() {
+ return useQuery(
+ {
+ /**
+ * Will re-fetch when stale, at most every hour (or 5s in dev for easier
+ * testing).
+ *
+ * @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,
+ initialData: getConfigFromCache(),
+ initialDataUpdatedAt: () =>
+ qc.getQueryState(configQueryKey)?.dataUpdatedAt,
+ queryKey: configQueryKey,
+ async queryFn() {
+ logger.debug(`useConfigQuery: fetching config`)
+ return getConfig()
+ },
+ },
+ qc,
+ )
+}
+
+/*
+ * Server state
+ */
+
+export function createServerStateQueryKey({did}: {did: string}) {
+ return ['serverState', did]
+}
+export async function getServerState({agent}: {agent: AtpAgent}) {
+ if (debug.enabled && debug.serverState)
+ return debug.resolve(debug.serverState)
+ const geolocation = device.get(['mergedGeolocation'])
+ if (!geolocation || !geolocation.countryCode) {
+ logger.error(`getServerState: missing geolocation countryCode`)
+ return
+ }
+ const {data} = await agent.app.bsky.ageassurance.getState({
+ countryCode: geolocation.countryCode,
+ regionCode: geolocation.regionCode,
+ })
+ const did = getDidFromAgentSession(agent)
+ if (data && did && createdAtCache.has(did)) {
+ /*
+ * If account was just created, just use the local cache if available. On
+ * subsequent reloads, the server should have the correct value.
+ */
+ data.metadata.accountCreatedAt = createdAtCache.get(did)
+ }
+ return data ?? null
+}
+export function getServerStateFromCache({
+ did,
+}: {
+ did: string
+}): AppBskyAgeassuranceGetState.OutputSchema | undefined {
+ return qc.getQueryData(
+ createServerStateQueryKey({did}),
+ )
+}
+export async function prefetchServerState({agent}: {agent: AtpAgent}) {
+ const did = getDidFromAgentSession(agent)
+
+ if (!did) return
+
+ await cacheHydrationPromise
+ const qk = createServerStateQueryKey({did})
+ const cached = getServerStateFromCache({did})
+
+ if (cached) {
+ logger.debug(`prefetchServerState: using cache`)
+ return
+ }
+
+ try {
+ logger.debug(`prefetchServerState: resolving...`)
+ const res = await networkRetry(3, () => getServerState({agent}))
+ qc.setQueryData(qk, res)
+ } catch (e: any) {
+ logger.warn(`prefetchServerState: failed`, {
+ safeMessage: e.message,
+ })
+ }
+}
+export async function refetchServerState({agent}: {agent: AtpAgent}) {
+ const did = getDidFromAgentSession(agent)
+ if (!did) return
+ logger.debug(`refetchServerState: fetching...`)
+ const res = await networkRetry(3, () => getServerState({agent}))
+ qc.setQueryData(
+ createServerStateQueryKey({did}),
+ res,
+ )
+ return res
+}
+export function usePatchServerState() {
+ const {currentAccount} = useSession()
+ return useCallback(
+ async (next: AppBskyAgeassuranceDefs.State) => {
+ if (!currentAccount) return
+ const did = currentAccount.did
+ const prev = getServerStateFromCache({did})
+ const merged: AppBskyAgeassuranceGetState.OutputSchema = {
+ metadata: {},
+ ...(prev || {}),
+ state: next,
+ }
+ qc.setQueryData(
+ createServerStateQueryKey({did}),
+ merged,
+ )
+ },
+ [currentAccount],
+ )
+}
+export function useServerStateQuery() {
+ const agent = useAgent()
+ const did = getDidFromAgentSession(agent)
+ const query = useQuery(
+ {
+ enabled: !!did,
+ initialData: () => {
+ if (!did) return
+ return getServerStateFromCache({did})
+ },
+ queryKey: createServerStateQueryKey({did: did!}),
+ async queryFn() {
+ return getServerState({agent})
+ },
+ },
+ qc,
+ )
+ const refetch = useMemo(() => debounce(query.refetch, 100), [query.refetch])
+
+ const isAssured = query.data?.state?.status === 'assured'
+
+ /**
+ * `refetchOnWindowFocus` doesn't seem to want to work for this custom query
+ * client, so we manually subscribe to focus changes.
+ */
+ useEffect(() => {
+ return focusManager.subscribe(() => {
+ // logged out
+ if (!did) return
+
+ const isFocused = focusManager.isFocused()
+
+ if (!isFocused) return
+
+ const config = getConfigFromCache()
+ const geolocation = device.get(['mergedGeolocation'])
+ const isAArequired = Boolean(
+ config &&
+ geolocation &&
+ !!getAgeAssuranceRegionConfig(config, {
+ countryCode: geolocation?.countryCode ?? '',
+ regionCode: geolocation?.regionCode,
+ }),
+ )
+
+ // only refetch when needed
+ if (isAssured || !isAArequired) return
+
+ refetch()
+ })
+ }, [did, refetch, isAssured])
+
+ return query
+}
+
+/*
+ * Other required data
+ */
+
+export type OtherRequiredData = {
+ birthdate: string | undefined
+}
+export function createOtherRequiredDataQueryKey({did}: {did: string}) {
+ return ['otherRequiredData', did]
+}
+export async function getOtherRequiredData({
+ agent,
+}: {
+ agent: AtpAgent
+}): Promise {
+ if (debug.enabled) return debug.resolve(debug.otherRequiredData)
+ const [prefs] = await Promise.all([agent.getPreferences()])
+ const data: OtherRequiredData = {
+ birthdate: prefs.birthDate ? prefs.birthDate.toISOString() : undefined,
+ }
+ const did = getDidFromAgentSession(agent)
+ if (data && did && birthdateCache.has(did)) {
+ /*
+ * If birthdate was just set, use the local cache value. On subsequent
+ * reloads, the server should have the correct value.
+ */
+ data.birthdate = birthdateCache.get(did)
+ }
+
+ /**
+ * If the user is under the minimum age, and the birthdate is not due to
+ * the legacy bug, snooze further birthdate updates for this user.
+ */
+ if (data.birthdate && !isLegacyBirthdateBug(data.birthdate)) {
+ snoozeBirthdateUpdateAllowedForDid(did!)
+ }
+
+ return data
+}
+export function getOtherRequiredDataFromCache({
+ did,
+}: {
+ did: string
+}): OtherRequiredData | undefined {
+ return qc.getQueryData(
+ createOtherRequiredDataQueryKey({did}),
+ )
+}
+export async function prefetchOtherRequiredData({agent}: {agent: AtpAgent}) {
+ const did = getDidFromAgentSession(agent)
+
+ if (!did) return
+
+ await cacheHydrationPromise
+ const qk = createOtherRequiredDataQueryKey({did})
+ const cached = getOtherRequiredDataFromCache({did})
+
+ if (cached) {
+ logger.debug(`prefetchOtherRequiredData: using cache`)
+ return
+ }
+
+ try {
+ logger.debug(`prefetchOtherRequiredData: resolving...`)
+ const res = await networkRetry(3, () => getOtherRequiredData({agent}))
+ qc.setQueryData(qk, res)
+ } catch (e: any) {
+ logger.warn(`prefetchOtherRequiredData: failed`, {
+ safeMessage: e.message,
+ })
+ }
+}
+export function usePatchOtherRequiredData() {
+ const {currentAccount} = useSession()
+ return useCallback(
+ async (next: OtherRequiredData) => {
+ if (!currentAccount) return
+ const did = currentAccount.did
+ const prev = getOtherRequiredDataFromCache({did})
+ const merged: OtherRequiredData = {
+ ...(prev || {}),
+ ...next,
+ }
+ qc.setQueryData(
+ createOtherRequiredDataQueryKey({did}),
+ merged,
+ )
+ },
+ [currentAccount],
+ )
+}
+export function useOtherRequiredDataQuery() {
+ const agent = useAgent()
+ const did = getDidFromAgentSession(agent)
+ return useQuery(
+ {
+ enabled: !!did,
+ initialData: () => {
+ if (!did) return
+ return getOtherRequiredDataFromCache({did})
+ },
+ queryKey: createOtherRequiredDataQueryKey({did: did!}),
+ async queryFn() {
+ return getOtherRequiredData({agent})
+ },
+ },
+ qc,
+ )
+}
+
+/**
+ * Helper to prefetch all age assurance data.
+ */
+export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) {
+ return Promise.allSettled([
+ // config fetch initiated at the top of the App.platform.tsx files, awaited here
+ configPrefetchPromise,
+ prefetchServerState({agent}),
+ prefetchOtherRequiredData({agent}),
+ ])
+}
+
+export function clearAgeAssuranceDataForDid({did}: {did: string}) {
+ logger.debug(`clearAgeAssuranceDataForDid: ${did}`)
+ qc.removeQueries({queryKey: createServerStateQueryKey({did}), exact: true})
+ qc.removeQueries({
+ queryKey: createOtherRequiredDataQueryKey({did}),
+ exact: true,
+ })
+}
+
+export function clearAgeAssuranceData() {
+ logger.debug(`clearAgeAssuranceData`)
+ qc.clear()
+}
+
+/*
+ * Context
+ */
+
+export type AgeAssuranceData = {
+ config: AppBskyAgeassuranceDefs.Config | undefined
+ state: AppBskyAgeassuranceDefs.State | undefined
+ data:
+ | {
+ accountCreatedAt: AppBskyAgeassuranceDefs.StateMetadata['accountCreatedAt']
+ declaredAge: number | undefined
+ birthdate: string | undefined
+ }
+ | undefined
+}
+export const AgeAssuranceDataContext = createContext({
+ config: undefined,
+ state: undefined,
+ data: {
+ accountCreatedAt: undefined,
+ declaredAge: undefined,
+ birthdate: undefined,
+ },
+})
+export function useAgeAssuranceDataContext() {
+ return useContext(AgeAssuranceDataContext)
+}
+export function AgeAssuranceDataProvider({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ const {data: config} = useConfigQuery()
+ const serverState = useServerStateQuery()
+ const {state, metadata} = serverState.data || {}
+ const {data} = useOtherRequiredDataQuery()
+ const ctx = useMemo(
+ () => ({
+ config,
+ state,
+ data: {
+ accountCreatedAt: metadata?.accountCreatedAt,
+ declaredAge: data?.birthdate
+ ? getAge(new Date(data.birthdate))
+ : undefined,
+ birthdate: data?.birthdate,
+ },
+ }),
+ [config, state, data, metadata],
+ )
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/ageAssurance/debug.ts b/src/ageAssurance/debug.ts
new file mode 100644
index 0000000000..d31024755c
--- /dev/null
+++ b/src/ageAssurance/debug.ts
@@ -0,0 +1,84 @@
+import {
+ ageAssuranceRuleIDs as ids,
+ type AppBskyAgeassuranceDefs,
+ type AppBskyAgeassuranceGetState,
+} from '@atproto/api'
+
+import {type OtherRequiredData} from '#/ageAssurance/data'
+import {IS_DEV} from '#/env'
+import {type Geolocation} from '#/geolocation'
+
+export const enabled = IS_DEV && false
+
+export const geolocation: Geolocation | undefined = enabled
+ ? {
+ countryCode: 'AA',
+ regionCode: undefined,
+ }
+ : undefined
+
+export const deviceGeolocation: Geolocation | undefined = enabled
+ ? {
+ countryCode: 'AA',
+ regionCode: undefined,
+ }
+ : undefined
+
+export const config: AppBskyAgeassuranceDefs.Config = {
+ regions: [
+ {
+ countryCode: 'AA',
+ regionCode: undefined,
+ rules: [
+ {
+ $type: ids.IfAccountNewerThan,
+ date: '2025-12-01T00:00:00Z',
+ access: 'none',
+ },
+ {
+ $type: ids.IfAssuredOverAge,
+ age: 18,
+ access: 'full',
+ },
+ {
+ $type: ids.IfAssuredOverAge,
+ age: 16,
+ access: 'safe',
+ },
+ {
+ $type: ids.IfDeclaredUnderAge,
+ age: 16,
+ access: 'none',
+ },
+ {
+ $type: ids.Default,
+ access: 'safe',
+ },
+ ],
+ },
+ ],
+}
+
+export const otherRequiredData: OtherRequiredData = {
+ birthdate: new Date(2000, 1, 1).toISOString(),
+}
+
+const serverStateEnabled = false
+export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined =
+ serverStateEnabled
+ ? {
+ state: {
+ lastInitiatedAt: new Date(2023, 5, 1).toISOString(),
+ status: 'assured',
+ access: 'safe',
+ },
+ metadata: {
+ accountCreatedAt: new Date(2023, 11, 1).toISOString(),
+ },
+ }
+ : undefined
+
+export async function resolve(data: T) {
+ await new Promise(y => setTimeout(y, 2000)) // simulate network
+ return data
+}
diff --git a/src/ageAssurance/index.tsx b/src/ageAssurance/index.tsx
new file mode 100644
index 0000000000..9a0a9c9d51
--- /dev/null
+++ b/src/ageAssurance/index.tsx
@@ -0,0 +1,110 @@
+import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
+
+import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
+import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay'
+import {AgeAssuranceDataProvider} from '#/ageAssurance/data'
+import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
+import {logger} from '#/ageAssurance/logger'
+import {
+ useAgeAssuranceState,
+ useOnAgeAssuranceAccessUpdate,
+} from '#/ageAssurance/state'
+import {
+ AgeAssuranceAccess,
+ type AgeAssuranceState,
+ AgeAssuranceStatus,
+} from '#/ageAssurance/types'
+import {isUserUnderAdultAge} from '#/ageAssurance/util'
+
+export {
+ prefetchConfig as prefetchAgeAssuranceConfig,
+ prefetchAgeAssuranceData,
+ refetchServerState as refetchAgeAssuranceServerState,
+ usePatchOtherRequiredData as usePatchAgeAssuranceOtherRequiredData,
+ usePatchServerState as usePatchAgeAssuranceServerState,
+} from '#/ageAssurance/data'
+export {logger} from '#/ageAssurance/logger'
+
+const AgeAssuranceStateContext = createContext<{
+ Access: typeof AgeAssuranceAccess
+ Status: typeof AgeAssuranceStatus
+ state: AgeAssuranceState
+ flags: {
+ adultContentDisabled: boolean
+ chatDisabled: boolean
+ }
+}>({
+ Access: AgeAssuranceAccess,
+ Status: AgeAssuranceStatus,
+ state: {
+ lastInitiatedAt: undefined,
+ status: AgeAssuranceStatus.Unknown,
+ access: AgeAssuranceAccess.Full,
+ },
+ flags: {
+ adultContentDisabled: false,
+ chatDisabled: false,
+ },
+})
+
+/**
+ * THE MAIN AGE ASSURANCE CONTEXT HOOK
+ *
+ * Prefer this to using any of the lower-level data-provider hooks.
+ */
+export function useAgeAssurance() {
+ return useContext(AgeAssuranceStateContext)
+}
+
+export function Provider({children}: {children: React.ReactNode}) {
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+function InnerProvider({children}: {children: React.ReactNode}) {
+ const state = useAgeAssuranceState()
+ const {data} = useAgeAssuranceDataContext()
+ const getAndRegisterPushToken = useGetAndRegisterPushToken()
+
+ const handleAccessUpdate = useCallback(
+ (s: AgeAssuranceState) => {
+ getAndRegisterPushToken({
+ isAgeRestricted: s.access !== AgeAssuranceAccess.Full,
+ })
+ },
+ [getAndRegisterPushToken],
+ )
+ useOnAgeAssuranceAccessUpdate(handleAccessUpdate)
+
+ useEffect(() => {
+ logger.debug(`useAgeAssuranceState`, {state})
+ }, [state])
+
+ return (
+ {
+ const chatDisabled = state.access !== AgeAssuranceAccess.Full
+ const isUnderage = data?.birthdate
+ ? isUserUnderAdultAge(data.birthdate)
+ : true
+ const adultContentDisabled =
+ state.access !== AgeAssuranceAccess.Full || isUnderage
+ return {
+ Access: AgeAssuranceAccess,
+ Status: AgeAssuranceStatus,
+ state,
+ flags: {
+ adultContentDisabled,
+ chatDisabled,
+ },
+ }
+ }, [state, data])}>
+ {children}
+
+ )
+}
diff --git a/src/state/ageAssurance/util.ts b/src/ageAssurance/logger.ts
similarity index 100%
rename from src/state/ageAssurance/util.ts
rename to src/ageAssurance/logger.ts
diff --git a/src/ageAssurance/state.ts b/src/ageAssurance/state.ts
new file mode 100644
index 0000000000..cc8b60ac52
--- /dev/null
+++ b/src/ageAssurance/state.ts
@@ -0,0 +1,100 @@
+import {useEffect, useMemo, useState} from 'react'
+import {computeAgeAssuranceRegionAccess} from '@atproto/api'
+
+import {useSession} from '#/state/session'
+import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
+import {logger} from '#/ageAssurance/logger'
+import {
+ AgeAssuranceAccess,
+ type AgeAssuranceState,
+ AgeAssuranceStatus,
+ parseAccessFromString,
+ parseStatusFromString,
+} from '#/ageAssurance/types'
+import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
+import {useGeolocation} from '#/geolocation'
+
+export function useAgeAssuranceState(): AgeAssuranceState {
+ const {hasSession} = useSession()
+ const geolocation = useGeolocation()
+ const {config, state, data} = useAgeAssuranceDataContext()
+
+ return useMemo(() => {
+ /**
+ * This is where we control logged-out moderation prefs. It's all
+ * downstream of AA now.
+ */
+ if (!hasSession)
+ return {
+ status: AgeAssuranceStatus.Unknown,
+ access: AgeAssuranceAccess.Safe,
+ }
+
+ // should never happen, but need to guard
+ if (!config) {
+ logger.warn('useAgeAssuranceState: missing config')
+ return {
+ status: AgeAssuranceStatus.Unknown,
+ access: AgeAssuranceAccess.Unknown,
+ }
+ }
+
+ const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation)
+ const isAARequired = region.countryCode !== '*'
+ const isTerminalState =
+ state?.status === 'assured' || state?.status === 'blocked'
+
+ /*
+ * If we are in a terminal state and AA is required for this region,
+ * we can trust the server state completely and avoid recomputing.
+ */
+ if (isTerminalState && isAARequired) {
+ return {
+ lastInitiatedAt: state.lastInitiatedAt,
+ status: parseStatusFromString(state.status),
+ access: parseAccessFromString(state.access),
+ }
+ }
+
+ /*
+ * Otherwise, we need to compute the access based on the latest data. For
+ * accounts with an accurate birthdate, our default fallback rules should
+ * ensure correct access.
+ */
+ const result = computeAgeAssuranceRegionAccess(region, data)
+ const computed = {
+ lastInitiatedAt: state?.lastInitiatedAt,
+ // prefer server state
+ status: state?.status
+ ? parseStatusFromString(state?.status)
+ : AgeAssuranceStatus.Unknown,
+ // prefer server state
+ access: result
+ ? parseAccessFromString(result.access)
+ : AgeAssuranceAccess.Full,
+ }
+ logger.debug('debug useAgeAssuranceState', {
+ region,
+ state,
+ data,
+ computed,
+ })
+ return computed
+ }, [hasSession, geolocation, config, state, data])
+}
+
+export function useOnAgeAssuranceAccessUpdate(
+ cb: (state: AgeAssuranceState) => void,
+) {
+ const state = useAgeAssuranceState()
+ // start with null to ensure callback is called on first render
+ const [prevAccess, setPrevAccess] = useState(null)
+
+ useEffect(() => {
+ if (prevAccess !== state.access) {
+ setPrevAccess(state.access)
+ cb(state)
+ logger.debug(`useOnAgeAssuranceAccessUpdate`, {state})
+ }
+ }, [cb, state, prevAccess])
+}
diff --git a/src/ageAssurance/types.ts b/src/ageAssurance/types.ts
new file mode 100644
index 0000000000..9f83975d3e
--- /dev/null
+++ b/src/ageAssurance/types.ts
@@ -0,0 +1,53 @@
+import {logger} from '#/ageAssurance/logger'
+
+export enum AgeAssuranceAccess {
+ Unknown = 'unknown',
+ None = 'none',
+ Safe = 'safe',
+ Full = 'full',
+}
+
+export enum AgeAssuranceStatus {
+ Unknown = 'unknown',
+ Pending = 'pending',
+ Assured = 'assured',
+ Blocked = 'blocked',
+}
+
+export type AgeAssuranceState = {
+ lastInitiatedAt?: string
+ status: AgeAssuranceStatus
+ access: AgeAssuranceAccess
+}
+
+export function parseStatusFromString(raw: string) {
+ switch (raw) {
+ case 'unknown':
+ return AgeAssuranceStatus.Unknown
+ case 'pending':
+ return AgeAssuranceStatus.Pending
+ case 'assured':
+ return AgeAssuranceStatus.Assured
+ case 'blocked':
+ return AgeAssuranceStatus.Blocked
+ default:
+ logger.error(`parseStatusFromString: unknown status value: ${raw}`)
+ return AgeAssuranceStatus.Unknown
+ }
+}
+
+export function parseAccessFromString(raw: string) {
+ switch (raw) {
+ case 'unknown':
+ return AgeAssuranceAccess.Unknown
+ case 'none':
+ return AgeAssuranceAccess.None
+ case 'safe':
+ return AgeAssuranceAccess.Safe
+ case 'full':
+ return AgeAssuranceAccess.Full
+ default:
+ logger.error(`parseAccessFromString: unknown access value: ${raw}`)
+ return AgeAssuranceAccess.Full
+ }
+}
diff --git a/src/ageAssurance/useBeginAgeAssurance.ts b/src/ageAssurance/useBeginAgeAssurance.ts
new file mode 100644
index 0000000000..9614155698
--- /dev/null
+++ b/src/ageAssurance/useBeginAgeAssurance.ts
@@ -0,0 +1,74 @@
+import {type AppBskyAgeassuranceBegin, AtpAgent} from '@atproto/api'
+import {useMutation} from '@tanstack/react-query'
+
+import {wait} from '#/lib/async/wait'
+import {
+ DEV_ENV_APPVIEW,
+ PUBLIC_APPVIEW,
+ PUBLIC_APPVIEW_DID,
+} from '#/lib/constants'
+import {isNetworkError} from '#/lib/hooks/useCleanError'
+import {logger} from '#/logger'
+import {useAgent} from '#/state/session'
+import {usePatchAgeAssuranceServerState} from '#/ageAssurance'
+import {BLUESKY_PROXY_DID} from '#/env'
+import {useGeolocation} from '#/geolocation'
+
+const IS_DEV_ENV = BLUESKY_PROXY_DID !== PUBLIC_APPVIEW_DID
+const APPVIEW = IS_DEV_ENV ? DEV_ENV_APPVIEW : PUBLIC_APPVIEW
+
+export function useBeginAgeAssurance() {
+ const agent = useAgent()
+ const geolocation = useGeolocation()
+ const patchAgeAssuranceStateResponse = usePatchAgeAssuranceServerState()
+
+ return useMutation({
+ async mutationFn(
+ props: Omit<
+ AppBskyAgeassuranceBegin.InputSchema,
+ 'countryCode' | 'regionCode'
+ >,
+ ) {
+ const countryCode = geolocation?.countryCode
+ const regionCode = geolocation?.regionCode
+ if (!countryCode) {
+ throw new Error(`Geolocation not available, cannot init age assurance.`)
+ }
+
+ const {
+ data: {token},
+ } = await agent.com.atproto.server.getServiceAuth({
+ aud: BLUESKY_PROXY_DID,
+ lxm: `app.bsky.ageassurance.begin`,
+ })
+
+ const appView = new AtpAgent({service: APPVIEW})
+ appView.sessionManager.session = {...agent.session!}
+ appView.sessionManager.session.accessJwt = token
+ appView.sessionManager.session.refreshJwt = ''
+
+ /*
+ * 2s wait is good actually. Email sending takes a hot sec and this helps
+ * ensure the email is ready for the user once they open their inbox.
+ */
+ const {data} = await wait(
+ 2e3,
+ appView.app.bsky.ageassurance.begin({
+ ...props,
+ countryCode: countryCode.toUpperCase(),
+ regionCode: regionCode ? regionCode.toUpperCase() : undefined,
+ }),
+ )
+
+ // Just keeps this in sync, not necessarily used right now
+ patchAgeAssuranceStateResponse(data)
+ },
+ onError(e) {
+ if (!isNetworkError(e)) {
+ logger.error(`useBeginAgeAssurance failed`, {
+ safeMessage: e,
+ })
+ }
+ },
+ })
+}
diff --git a/src/ageAssurance/useComputeAgeAssuranceRegionAccess.ts b/src/ageAssurance/useComputeAgeAssuranceRegionAccess.ts
new file mode 100644
index 0000000000..e3ea48860f
--- /dev/null
+++ b/src/ageAssurance/useComputeAgeAssuranceRegionAccess.ts
@@ -0,0 +1,29 @@
+import {useCallback} from 'react'
+import {computeAgeAssuranceRegionAccess} from '@atproto/api'
+
+import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
+import {logger} from '#/ageAssurance/logger'
+import {AgeAssuranceAccess, parseAccessFromString} from '#/ageAssurance/types'
+import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
+import {type Geolocation} from '#/geolocation'
+
+export function useComputeAgeAssuranceRegionAccess() {
+ const {config, data} = useAgeAssuranceDataContext()
+ return useCallback(
+ (geolocation: Geolocation) => {
+ if (!config) {
+ logger.warn('useComputeAgeAssuranceRegionAccess: missing config')
+ return AgeAssuranceAccess.Unknown
+ }
+ const region = getAgeAssuranceRegionConfigWithFallback(
+ config,
+ geolocation,
+ )
+ const result = computeAgeAssuranceRegionAccess(region, data)
+ return result
+ ? parseAccessFromString(result.access)
+ : AgeAssuranceAccess.Full
+ },
+ [config, data],
+ )
+}
diff --git a/src/ageAssurance/util.ts b/src/ageAssurance/util.ts
new file mode 100644
index 0000000000..1043283305
--- /dev/null
+++ b/src/ageAssurance/util.ts
@@ -0,0 +1,88 @@
+import {useMemo} from 'react'
+import {
+ ageAssuranceRuleIDs as ids,
+ type AppBskyAgeassuranceDefs,
+ getAgeAssuranceRegionConfig,
+} from '@atproto/api'
+
+import {getAge} from '#/lib/strings/time'
+import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
+import {AgeAssuranceAccess} from '#/ageAssurance/types'
+import {type Geolocation, useGeolocation} from '#/geolocation'
+
+const DEFAULT_MIN_AGE = 13
+
+/**
+ * Get age assurance region config based on geolocation, with fallback to
+ * app defaults if no region config is found.
+ *
+ * See {@link getAgeAssuranceRegionConfig} for the generic option, which can
+ * return undefined if the geolocation does not match any AA region.
+ */
+export function getAgeAssuranceRegionConfigWithFallback(
+ config: AppBskyAgeassuranceDefs.Config,
+ geolocation: Geolocation,
+): AppBskyAgeassuranceDefs.ConfigRegion {
+ const region = getAgeAssuranceRegionConfig(config, {
+ countryCode: geolocation.countryCode ?? '',
+ 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,
+ },
+ ],
+ }
+ )
+}
+
+/**
+ * Hook to get the age assurance region config based on current geolocation.
+ * Does not fall-back to our app defaults. If no config is found, returns
+ * undefined, which indicates no regional age assurance rules apply.
+ */
+export function useAgeAssuranceRegionConfig() {
+ const geolocation = useGeolocation()
+ const {config} = useAgeAssuranceDataContext()
+ return useMemo(() => {
+ if (!config) return
+ // use generic helper, we want to potentially return undefined
+ return getAgeAssuranceRegionConfig(config, {
+ countryCode: geolocation.countryCode ?? '',
+ regionCode: geolocation.regionCode,
+ })
+ }, [config, geolocation])
+}
+
+/**
+ * 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
+ * the bday dialog, and it defaulted to the current date. This bug _has_ been
+ * seen in production, so we need to check for it where possible.
+ */
+export function isLegacyBirthdateBug(birthDate: string) {
+ return ['2025', '2024', '2023'].includes((birthDate || '').slice(0, 4))
+}
+
+/**
+ * Returns whether the user is under the minimum age required to use the app.
+ * This applies to all regions.
+ */
+export function isUserUnderMinimumAge(birthDate: string) {
+ return getAge(new Date(birthDate)) < DEFAULT_MIN_AGE
+}
+
+export function isUserUnderAdultAge(birthDate: string) {
+ return getAge(new Date(birthDate)) < 18
+}
diff --git a/src/components/BlockedGeoOverlay.tsx b/src/components/BlockedGeoOverlay.tsx
deleted file mode 100644
index d6e2626875..0000000000
--- a/src/components/BlockedGeoOverlay.tsx
+++ /dev/null
@@ -1,193 +0,0 @@
-import {useEffect} from 'react'
-import {ScrollView, View} from 'react-native'
-import {useSafeAreaInsets} from 'react-native-safe-area-context'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {logger} from '#/logger'
-import {isWeb} from '#/platform/detection'
-import {useDeviceGeolocationApi} from '#/state/geolocation'
-import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
-import {Button, ButtonIcon, ButtonText} from '#/components/Button'
-import * as Dialog from '#/components/Dialog'
-import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog'
-import {Divider} from '#/components/Divider'
-import {Full as Logo, Mark} from '#/components/icons/Logo'
-import {PinLocation_Stroke2_Corner0_Rounded as LocationIcon} from '#/components/icons/PinLocation'
-import {SimpleInlineLinkText as InlineLinkText} from '#/components/Link'
-import {Outlet as PortalOutlet} from '#/components/Portal'
-import * as Toast from '#/components/Toast'
-import {Text} from '#/components/Typography'
-import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
-
-export function BlockedGeoOverlay() {
- const t = useTheme()
- const {_} = useLingui()
- const {gtPhone} = useBreakpoints()
- const insets = useSafeAreaInsets()
- const geoDialog = Dialog.useDialogControl()
- const {setDeviceGeolocation} = useDeviceGeolocationApi()
-
- useEffect(() => {
- // just counting overall hits here
- logger.metric(`blockedGeoOverlay:shown`, {})
- }, [])
-
- const textStyles = [a.text_md, a.leading_normal]
- const links = {
- blog: {
- to: `https://bsky.social/about/blog/08-22-2025-mississippi-hb1126`,
- label: _(msg`Read our blog post`),
- overridePresentation: false,
- disableMismatchWarning: true,
- style: textStyles,
- },
- }
-
- const blocks = [
- _(msg`Unfortunately, Bluesky is unavailable in Mississippi right now.`),
- _(
- msg`A new Mississippi law requires us to implement age verification for all users before they can access Bluesky. We think this law creates challenges that go beyond its child safety goals, and creates significant barriers that limit free speech and disproportionately harm smaller platforms and emerging technologies.`,
- ),
- _(
- msg`As a small team, we cannot justify building the expensive infrastructure this requirement demands while legal challenges to this law are pending.`,
- ),
- _(
- msg`For now, we have made the difficult decision to block access to Bluesky in the state of Mississippi.`,
- ),
- <>
- To learn more, read our{' '}
- blog post.
- >,
- ]
-
- return (
- <>
-
-
-
-
-
-
- Announcement
-
-
-
-
-
- {blocks.map((block, index) => (
-
- {block}
-
- ))}
-
-
- {!isWeb && (
- <>
-
-
-
-
-
-
- Not in Mississippi?
-
-
-
- Confirm your location with GPS. Your location data is not
- tracked and does not leave your device.
-
-
-
-
-
- {
- if (props.geolocationStatus.isAgeBlockedGeo) {
- props.disableDialogAction()
- props.setDialogError(
- _(
- msg`We're sorry, but based on your device's location, you are currently located in a region where we cannot provide access at this time.`,
- ),
- )
- } else {
- props.closeDialog(() => {
- // set this after close!
- setDeviceGeolocation({
- countryCode: props.geolocationStatus.countryCode,
- regionCode: props.geolocationStatus.regionCode,
- })
- Toast.show(_(msg`Thanks! You're all set.`), {
- type: 'success',
- })
- })
- }
- }}
- />
- >
- )}
-
-
-
-
-
-
-
- {/*
- * While this blocking overlay is up, other dialogs in the shell
- * are not mounted, so it _should_ be safe to use these here
- * without fear of other modals showing up.
- */}
-
-
- >
- )
-}
diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx
index add8ffecd4..7418c8d766 100644
--- a/src/components/FeedInterstitials.tsx
+++ b/src/components/FeedInterstitials.tsx
@@ -27,13 +27,15 @@ import {
web,
} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {useDialogControl} from '#/components/Dialog'
import * as FeedCard from '#/components/FeedCard'
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow'
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
-import {InlineLinkText, Link} from '#/components/Link'
+import {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
+import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog'
import {ProgressGuideList} from './ProgressGuide/List'
const MOBILE_CARD_WIDTH = 165
@@ -253,6 +255,7 @@ export function ProfileGrid({
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const {gtMobile} = useBreakpoints()
+ const followDialogControl = useDialogControl()
const isLoading = isSuggestionsLoading || !moderationOpts
const isProfileHeaderContext = viewContext === 'profileHeader'
@@ -474,19 +477,34 @@ export function ProfileGrid({
)}
{!isProfileHeaderContext && (
- {
- logger.metric('suggestedUser:seeMore', {
+ followDialogControl.open()
+ logEvent('suggestedUser:seeMore', {
logContext: isFeedContext ? 'Explore' : 'Profile',
})
}}>
- See more
-
+ {({hovered}) => (
+
+ See more
+
+ )}
+
)}
+
+
{gtMobile ? (
@@ -503,7 +521,16 @@ export function ProfileGrid({
decelerationRate="fast">
{content}
- {!isProfileHeaderContext && }
+ {!isProfileHeaderContext && (
+ {
+ followDialogControl.open()
+ logger.metric('suggestedUser:seeMore', {
+ logContext: 'Explore',
+ })
+ }}
+ />
+ )}
)}
@@ -511,20 +538,14 @@ export function ProfileGrid({
)
}
-function SeeMoreSuggestedProfilesCard() {
+function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) {
const t = useTheme()
const {_} = useLingui()
return (
- {
- logger.metric('suggestedUser:seeMore', {
- logContext: 'Explore',
- })
- }}
+
)
}
diff --git a/src/components/Link.tsx b/src/components/Link.tsx
index b075fa2e0a..8618364281 100644
--- a/src/components/Link.tsx
+++ b/src/components/Link.tsx
@@ -421,6 +421,7 @@ export function SimpleInlineLinkText({
label,
disableUnderline,
shouldProxy,
+ onPress: outerOnPress,
...rest
}: Omit<
InlineLinkProps,
@@ -428,7 +429,6 @@ export function SimpleInlineLinkText({
| 'action'
| 'disableMismatchWarning'
| 'overridePresentation'
- | 'onPress'
| 'onLongPress'
| 'shareOnLongPress'
> & {
@@ -448,7 +448,9 @@ export function SimpleInlineLinkText({
href = createProxiedUrl(href)
}
- const onPress = () => {
+ const onPress = (e: GestureResponderEvent) => {
+ const exitEarlyIfFalse = outerOnPress?.(e)
+ if (exitEarlyIfFalse === false) return
Linking.openURL(href)
}
@@ -517,7 +519,7 @@ export function WebOnlyInlineLinkText({
export function createStaticClick(
onPressHandler: Exclude,
): {
- to: BaseLinkProps['to']
+ to: string
onPress: Exclude
} {
return {
diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx
index 2a70a248e5..d3ec490d12 100644
--- a/src/components/PostControls/ShareMenu/ShareMenuItems.tsx
+++ b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx
@@ -11,7 +11,6 @@ import {shareText, shareUrl} from '#/lib/sharing'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {isIOS} from '#/platform/detection'
-import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
@@ -24,6 +23,7 @@ import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/i
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
import * as Menu from '#/components/Menu'
+import {useAgeAssurance} from '#/ageAssurance'
import {useDevMode} from '#/storage/hooks/dev-mode'
import {RecentChats} from './RecentChats'
import {type ShareMenuItemsProps} from './ShareMenuItems.types'
@@ -37,7 +37,7 @@ let ShareMenuItems = ({
const navigation = useNavigation()
const sendViaChatControl = useDialogControl()
const [devModeEnabled] = useDevMode()
- const {isAgeRestricted} = useAgeAssurance()
+ const aa = useAgeAssurance()
const postUri = post.uri
const postAuthor = useProfileShadow(post.author)
@@ -91,7 +91,7 @@ let ShareMenuItems = ({
return (
<>
- {hasSession && !isAgeRestricted && (
+ {hasSession && aa.state.access === aa.Access.Full && (
diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx
index ac424c37a0..e8657dac29 100644
--- a/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx
+++ b/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx
@@ -10,7 +10,6 @@ import {shareText, shareUrl} from '#/lib/sharing'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
-import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession} from '#/state/session'
import {useBreakpoints} from '#/alf'
@@ -22,6 +21,7 @@ import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/i
import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets'
import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane'
import * as Menu from '#/components/Menu'
+import {useAgeAssurance} from '#/ageAssurance'
import {useDevMode} from '#/storage/hooks/dev-mode'
import {type ShareMenuItemsProps} from './ShareMenuItems.types'
@@ -38,7 +38,7 @@ let ShareMenuItems = ({
const embedPostControl = useDialogControl()
const sendViaChatControl = useDialogControl()
const [devModeEnabled] = useDevMode()
- const {isAgeRestricted} = useAgeAssurance()
+ const aa = useAgeAssurance()
const postUri = post.uri
const postCid = post.cid
@@ -97,7 +97,7 @@ let ShareMenuItems = ({
{!hideInPWI && copyLinkItem}
- {hasSession && !isAgeRestricted && (
+ {hasSession && aa.state.access === aa.Access.Full && (
+
+
+
+ )
+}
+
// Fine to keep this top-level.
let lastSelectedInterest = ''
let lastSearchText = ''
-function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
+function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
const {_} = useLingui()
const interestsDisplayNames = useInterestsDisplayNames()
const {data: preferences} = usePreferencesQuery()
@@ -334,7 +351,7 @@ let Header = ({
selectedInterest,
interestsDisplayNames,
}: {
- guide: Follow10ProgressGuide
+ guide?: Follow10ProgressGuide
inputRef: React.RefObject
listRef: React.RefObject
onSelectTab: (v: string) => void
@@ -385,7 +402,7 @@ let Header = ({
}
Header = memo(Header)
-function HeaderTop({guide}: {guide: Follow10ProgressGuide}) {
+function HeaderTop({guide}: {guide?: Follow10ProgressGuide}) {
const {_} = useLingui()
const t = useTheme()
const control = Dialog.useDialogContext()
@@ -408,14 +425,16 @@ function HeaderTop({guide}: {guide: Follow10ProgressGuide}) {
]}>
Find people to follow
-
-
-
+ {guide && (
+
+
+
+ )}
{isWeb ? (
}
@@ -43,10 +39,12 @@ function Inner({style}: ViewStyleProp & {}) {
const getTimeAgo = useGetTimeAgo()
const {gtPhone} = useBreakpoints()
const {setDeviceGeolocation} = useDeviceGeolocationApi()
+ const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess()
const copy = useAgeAssuranceCopy()
- const {status, lastInitiatedAt} = useAgeAssurance()
- const isBlocked = status === 'blocked'
+ const aa = useAgeAssurance()
+ const {status, lastInitiatedAt} = aa.state
+ const isBlocked = status === aa.Status.Blocked
const hasInitiated = !!lastInitiatedAt
const timeAgo = lastInitiatedAt
? getTimeAgo(lastInitiatedAt, new Date())
@@ -98,7 +96,10 @@ function Inner({style}: ViewStyleProp & {}) {
{
- if (props.geolocationStatus.isAgeRestrictedGeo) {
+ const access = computeAgeAssuranceRegionAccess(
+ props.geolocation,
+ )
+ if (access !== aa.Access.Full) {
props.disableDialogAction()
props.setDialogError(
_(
@@ -108,10 +109,7 @@ function Inner({style}: ViewStyleProp & {}) {
} else {
props.closeDialog(() => {
// set this after close!
- setDeviceGeolocation({
- countryCode: props.geolocationStatus.countryCode,
- regionCode: props.geolocationStatus.regionCode,
- })
+ setDeviceGeolocation(props.geolocation)
Toast.show(_(msg`Thanks! You're all set.`), {
type: 'success',
})
diff --git a/src/components/ageAssurance/AgeAssuranceAdmonition.tsx b/src/components/ageAssurance/AgeAssuranceAdmonition.tsx
index 028e1dad52..7889070c09 100644
--- a/src/components/ageAssurance/AgeAssuranceAdmonition.tsx
+++ b/src/components/ageAssurance/AgeAssuranceAdmonition.tsx
@@ -2,25 +2,23 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
-import {logger} from '#/state/ageAssurance/util'
import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf'
import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog'
import type * as Dialog from '#/components/Dialog'
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
+import {useAgeAssurance} from '#/ageAssurance'
+import {logger} from '#/ageAssurance'
export function AgeAssuranceAdmonition({
children,
style,
}: ViewStyleProp & {children: React.ReactNode}) {
const control = useDialogControl()
- const {isReady, isDeclaredUnderage, isAgeRestricted} = useAgeAssurance()
+ const aa = useAgeAssurance()
- if (!isReady) return null
- if (isDeclaredUnderage) return null
- if (!isAgeRestricted) return null
+ if (aa.state.access === aa.Access.Full) return null
return (
diff --git a/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx b/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx
index 9fbe0c428d..d8330a94c3 100644
--- a/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx
+++ b/src/components/ageAssurance/AgeAssuranceAppealDialog.tsx
@@ -6,7 +6,6 @@ import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {BLUESKY_MOD_SERVICE_HEADERS} from '#/lib/constants'
-import {logger} from '#/state/ageAssurance/util'
import {useAgent, useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints, web} from '#/alf'
@@ -15,6 +14,7 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
+import {logger} from '#/ageAssurance'
export function AgeAssuranceAppealDialog({
control,
diff --git a/src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx b/src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx
index cad7e2dc87..3471393451 100644
--- a/src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx
+++ b/src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx
@@ -3,8 +3,6 @@ import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
-import {logger} from '#/state/ageAssurance/util'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {atoms as a, select, useTheme} from '#/alf'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
@@ -13,30 +11,22 @@ import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
+import {useAgeAssurance} from '#/ageAssurance'
+import {logger} from '#/ageAssurance'
export function useInternalState() {
- const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} =
- useAgeAssurance()
+ const aa = useAgeAssurance()
const {nux} = useNux(Nux.AgeAssuranceDismissibleFeedBanner)
const {mutate: save, variables} = useSaveNux()
const hidden = !!variables
const visible = useMemo(() => {
- if (!isReady) return false
- if (isDeclaredUnderage) return false
- if (!isAgeRestricted) return false
- if (lastInitiatedAt) return false
+ if (aa.state.access === aa.Access.Full) return false
+ if (aa.state.lastInitiatedAt) return false
if (hidden) return false
if (nux && nux.completed) return false
return true
- }, [
- isReady,
- isDeclaredUnderage,
- isAgeRestricted,
- lastInitiatedAt,
- hidden,
- nux,
- ])
+ }, [aa, hidden, nux])
const close = () => {
save({
diff --git a/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx b/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx
index c9f242ca88..934ac8d14a 100644
--- a/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx
+++ b/src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx
@@ -2,28 +2,25 @@ import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
-import {logger} from '#/state/ageAssurance/util'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {atoms as a, type ViewStyleProp} from '#/alf'
import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
+import {useAgeAssurance} from '#/ageAssurance'
+import {logger} from '#/ageAssurance'
export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) {
const {_} = useLingui()
- const {isReady, isDeclaredUnderage, isAgeRestricted, lastInitiatedAt} =
- useAgeAssurance()
+ const aa = useAgeAssurance()
const {nux} = useNux(Nux.AgeAssuranceDismissibleNotice)
const copy = useAgeAssuranceCopy()
const {mutate: save, variables} = useSaveNux()
const hidden = !!variables
- if (!isReady) return null
- if (isDeclaredUnderage) return null
- if (!isAgeRestricted) return null
- if (lastInitiatedAt) return null
+ if (aa.state.access === aa.Access.Full) return null
+ if (aa.state.lastInitiatedAt) return null
if (hidden) return null
if (nux && nux.completed) return null
diff --git a/src/components/ageAssurance/AgeAssuranceInitDialog.tsx b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx
index 2f6c041dc2..bce3bdc0f8 100644
--- a/src/components/ageAssurance/AgeAssuranceInitDialog.tsx
+++ b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx
@@ -14,9 +14,6 @@ import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useTLDs} from '#/lib/hooks/useTLDs'
import {isEmailMaybeInvalid} from '#/lib/strings/email'
import {type AppLanguage} from '#/locale/languages'
-import {useAgeAssuranceContext} from '#/state/ageAssurance'
-import {useInitAgeAssurance} from '#/state/ageAssurance/useInitAgeAssurance'
-import {logger} from '#/state/ageAssurance/util'
import {useLanguagePrefs} from '#/state/preferences'
import {useSession} from '#/state/session'
import {atoms as a, useTheme, web} from '#/alf'
@@ -30,9 +27,12 @@ import {Divider} from '#/components/Divider'
import * as TextField from '#/components/forms/TextField'
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
import {LanguageSelect} from '#/components/LanguageSelect'
-import {InlineLinkText} from '#/components/Link'
+import {SimpleInlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
+import {logger} from '#/ageAssurance'
+import {useAgeAssurance} from '#/ageAssurance'
+import {useBeginAgeAssurance} from '#/ageAssurance/useBeginAgeAssurance'
export {useDialogControl} from '#/components/Dialog/context'
@@ -69,7 +69,8 @@ function Inner() {
const langPrefs = useLanguagePrefs()
const cleanError = useCleanError()
const {close} = Dialog.useDialogContext()
- const {lastInitiatedAt} = useAgeAssuranceContext()
+ const aa = useAgeAssurance()
+ const lastInitiatedAt = aa.state.lastInitiatedAt
const getTimeAgo = useGetTimeAgo()
const tlds = useTLDs()
const createSupportLink = useCreateSupportLink()
@@ -88,7 +89,7 @@ function Inner() {
)
const [error, setError] = useState(null)
- const {mutateAsync: init, isPending} = useInitAgeAssurance()
+ const {mutateAsync: begin, isPending} = useBeginAgeAssurance()
const runEmailValidation = () => {
if (validateEmail(email)) {
@@ -127,7 +128,7 @@ function Inner() {
return
}
- await init({
+ await begin({
email,
language,
})
@@ -150,11 +151,11 @@ function Inner() {
We're having issues initializing the age assurance process for
your account. Please{' '}
-
contact support
- {' '}
+ {' '}
for assistance.
>
@@ -195,14 +196,12 @@ function Inner() {
We have partnered with{' '}
-
KWS
- {' '}
+ {' '}
to verify that you’re an adult. When you click "Begin" below,
KWS will check if you have previously verified your age using
this email address for other games/services powered by KWS
@@ -328,24 +327,20 @@ function Inner() {
style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_medium]}>
By continuing, you agree to the{' '}
-
KWS Terms of Use
- {' '}
+ {' '}
and acknowledge that KWS will store your verified status with
your hashed email address in accordance with the{' '}
-
KWS Privacy Policy
-
+
. This means you won’t need to verify again the next time you
use this email for other apps, games, and services powered by
KWS technology.
diff --git a/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx b/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx
index 3146ddc80e..a28b1aeac9 100644
--- a/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx
+++ b/src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx
@@ -6,8 +6,6 @@ import {useLingui} from '@lingui/react'
import {retry} from '#/lib/async/retry'
import {wait} from '#/lib/async/wait'
import {isNative} from '#/platform/detection'
-import {useAgeAssuranceAPIContext} from '#/state/ageAssurance'
-import {logger} from '#/state/ageAssurance/util'
import {useAgent} from '#/state/session'
import {atoms as a, useTheme, web} from '#/alf'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
@@ -18,6 +16,8 @@ import {CheckThick_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/ic
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
+import {refetchAgeAssuranceServerState} from '#/ageAssurance'
+import {logger} from '#/ageAssurance'
export type AgeAssuranceRedirectDialogState = {
result: 'success' | 'unknown'
@@ -63,7 +63,7 @@ export function AgeAssuranceRedirectDialog() {
const {_} = useLingui()
const control = useAgeAssuranceRedirectDialogControl()
- // TODO for testing
+ // for testing
// Dialog.useAutoOpen(control.control, 3e3)
return (
@@ -88,7 +88,6 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
const control = useAgeAssuranceRedirectDialogControl()
const [error, setError] = useState(false)
const [success, setSuccess] = useState(false)
- const {refetch: refreshAgeAssuranceState} = useAgeAssuranceAPIContext()
useEffect(() => {
if (polling.current) return
@@ -106,9 +105,9 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
if (!agent.session) return
if (unmounted.current) return
- const {data} = await agent.app.bsky.unspecced.getAgeAssuranceState()
+ const data = await refetchAgeAssuranceServerState({agent})
- if (data.status !== 'assured') {
+ if (data?.state.status !== 'assured') {
throw new Error(
`Polling for age assurance state did not receive assured status`,
)
@@ -124,9 +123,6 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
if (!agent.session) return
if (unmounted.current) return
- // success! update state
- await refreshAgeAssuranceState()
-
setSuccess(true)
logger.metric('ageAssurance:redirectDialogSuccess', {})
@@ -134,15 +130,13 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
.catch(() => {
if (unmounted.current) return
setError(true)
- // try a refetch anyway
- refreshAgeAssuranceState()
logger.metric('ageAssurance:redirectDialogFail', {})
})
return () => {
unmounted.current = true
}
- }, [agent, control, refreshAgeAssuranceState])
+ }, [agent, control])
if (success) {
return (
diff --git a/src/components/ageAssurance/AgeRestrictedScreen.tsx b/src/components/ageAssurance/AgeRestrictedScreen.tsx
index b6a8c26a36..85881a3ada 100644
--- a/src/components/ageAssurance/AgeRestrictedScreen.tsx
+++ b/src/components/ageAssurance/AgeRestrictedScreen.tsx
@@ -2,8 +2,6 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
-import {logger} from '#/state/ageAssurance/util'
import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
@@ -13,6 +11,8 @@ import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components
import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
+import {useAgeAssurance} from '#/ageAssurance'
+import {logger} from '#/ageAssurance'
export function AgeRestrictedScreen({
children,
@@ -27,22 +27,9 @@ export function AgeRestrictedScreen({
}) {
const {_} = useLingui()
const copy = useAgeAssuranceCopy()
- const {isReady, isAgeRestricted} = useAgeAssurance()
+ const aa = useAgeAssurance()
- if (!isReady) {
- return (
-
-
-
-
-
-
-
-
-
- )
- }
- if (!isAgeRestricted) return children
+ if (aa.state.access === aa.Access.Full) return children
return (
diff --git a/src/components/ageAssurance/useAgeAssuranceCopy.ts b/src/components/ageAssurance/useAgeAssuranceCopy.ts
index c861f8336b..e75f84feed 100644
--- a/src/components/ageAssurance/useAgeAssuranceCopy.ts
+++ b/src/components/ageAssurance/useAgeAssuranceCopy.ts
@@ -8,7 +8,7 @@ export function useAgeAssuranceCopy() {
return useMemo(() => {
return {
notice: _(
- msg`The laws in your location require you to verify you're an adult before accessing certain features on Bluesky, like adult content and direct messaging.`,
+ msg`Due to laws in your region, certain features on Bluesky are currently restricted until you're able to verify you're an adult.`,
),
banner: _(
msg`The laws in your location require you to verify you're an adult to access certain features. Tap to learn more.`,
diff --git a/src/components/dialogs/BirthDateSettings.tsx b/src/components/dialogs/BirthDateSettings.tsx
index e1c73b67cb..9915d0a2d7 100644
--- a/src/components/dialogs/BirthDateSettings.tsx
+++ b/src/components/dialogs/BirthDateSettings.tsx
@@ -7,10 +7,13 @@ import {cleanError} from '#/lib/strings/errors'
import {getAge, getDateAgo} from '#/lib/strings/time'
import {logger} from '#/logger'
import {isIOS, isWeb} from '#/platform/detection'
+import {
+ useBirthdateMutation,
+ useIsBirthdateUpdateAllowed,
+} from '#/state/birthdate'
import {
usePreferencesQuery,
type UsePreferencesQueryResponse,
- usePreferencesSetBirthDateMutation,
} from '#/state/queries/preferences'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {atoms as a, useTheme, web} from '#/alf'
@@ -18,7 +21,7 @@ import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {DateField} from '#/components/forms/DateField'
-import {InlineLinkText} from '#/components/Link'
+import {SimpleInlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
@@ -30,42 +33,71 @@ export function BirthDateSettingsDialog({
const t = useTheme()
const {_} = useLingui()
const {isLoading, error, data: preferences} = usePreferencesQuery()
+ const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
return (
-
-
-
- My Birthday
-
-
-
- This information is private and not shared with other users.
-
-
+ {isBirthdateUpdateAllowed ? (
+
+
+
+ My Birthdate
+
+
+
+ This information is private and not shared with other users.
+
+
- {isLoading ? (
-
- ) : error || !preferences ? (
-
- ) : (
-
- )}
-
+ {isLoading ? (
+
+ ) : error || !preferences ? (
+
+ ) : (
+
+ )}
+
-
-
+
+
+ ) : (
+
+
+
+ You recently changed your birthdate
+
+
+
+ There is a limit to how often you can change your birthdate. You
+ may need to wait a day or two before updating it again.
+
+
+
+
+
+
+ )}
)
}
@@ -86,7 +118,7 @@ function BirthdayInner({
isError,
error,
mutateAsync: setBirthDate,
- } = usePreferencesSetBirthDateMutation()
+ } = useBirthdateMutation()
const hasChanged = date !== preferences.birthDate
const age = getAge(new Date(date))
@@ -112,8 +144,8 @@ function BirthdayInner({
testID="birthdayInput"
value={date}
onChangeDate={newDate => setDate(new Date(newDate))}
- label={_(msg`Birthday`)}
- accessibilityHint={_(msg`Enter your birth date`)}
+ label={_(msg`Birthdate`)}
+ accessibilityHint={_(msg`Enter your birthdate`)}
/>
@@ -130,11 +162,11 @@ function BirthdayInner({
You must be at least 13 years old to use Bluesky. Read our{' '}
-
Terms of Service
- {' '}
+ {' '}
for more information.
@@ -146,7 +178,7 @@ function BirthdayInner({