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/package.json b/package.json
index e2653d5885..6a247dff4a 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",
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..edfa3497fb
--- /dev/null
+++ b/src/Splash.web.tsx
@@ -0,0 +1,23 @@
+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/components/NoAccessScreen.tsx b/src/ageAssurance/components/NoAccessScreen.tsx
new file mode 100644
index 0000000000..d55fa5846f
--- /dev/null
+++ b/src/ageAssurance/components/NoAccessScreen.tsx
@@ -0,0 +1,340 @@
+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 {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 {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
+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'
+
+export function NoAccessScreen() {
+ const t = useTheme()
+ const {_} = useLingui()
+ const {gtPhone} = useBreakpoints()
+ const insets = useSafeAreaInsets()
+ const birthdayControl = useDialogControl()
+ const {data} = useAgeAssuranceDataContext()
+ const region = useAgeAssuranceRegionConfig()
+ const isBirthdateUpdateAllowed = useIsBirthDateUpdateAllowed()
+ const {logoutCurrentAccount} = useSessionApi()
+
+ 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 textStyles = [a.text_md, a.leading_normal]
+
+ const blocks = [
+ _(
+ msg`You are accessing Bluesky from a region that legally requires us to verify your age before allowing you to access the app.`,
+ ),
+ ]
+
+ 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 birthdayUpdateText = canUpdateBirthday ? (
+
+
+ If your birth date is not accurate, you can update it by{' '}
+ {
+ birthdayControl.open()
+ })}>
+ clicking here
+
+ .
+
+
+ ) : null
+
+ return (
+ <>
+
+
+
+
+
+
+ {hasDeclaredAge ? (
+ <>
+ {isAARegion ? (
+ <>
+
+ {blocks.map((block, index) => (
+
+ {block}
+
+ ))}
+
+ {birthdayUpdateText}
+
+
+
+ >
+ ) : (
+
+
+
+ Unfortunately, the birth date you have saved to your
+ profile makes you too young to access Bluesky.
+
+
+
+ {birthdayUpdateText}
+
+ )}
+ >
+ ) : (
+
+
+
+ Looks like you haven't added your birth date. 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.
+ {' '}
+
+
+
+ {
+ // TODO test this
+ 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..8d573a80ea
--- /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 (result && 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..eb37af8bf2
--- /dev/null
+++ b/src/ageAssurance/data.tsx
@@ -0,0 +1,497 @@
+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, isUserUnderMinimumAge} 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({
+ // @ts-ignore TODO
+ 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']) // TODO can I improve this, dislike reading from storage
+ 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(() => {
+ 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,
+ }),
+ )
+
+ 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 &&
+ isUserUnderMinimumAge(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.all([
+ 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..3c8d5b9356
--- /dev/null
+++ b/src/ageAssurance/index.tsx
@@ -0,0 +1,82 @@
+import {createContext, 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 {logger} from '#/ageAssurance/logger'
+import {
+ useAgeAssuranceState,
+ useOnAgeAssuranceAccessUpdate,
+} from '#/ageAssurance/state'
+import {
+ AgeAssuranceAccess,
+ type AgeAssuranceState,
+ AgeAssuranceStatus,
+} from '#/ageAssurance/types'
+
+export {logger} from '#/ageAssurance/logger'
+// TODO just import from file
+export {
+ prefetchConfig as prefetchAgeAssuranceConfig,
+ prefetchAgeAssuranceData,
+ refetchServerState as refetchAgeAssuranceServerState,
+ usePatchOtherRequiredData as usePatchAgeAssuranceOtherRequiredData,
+ usePatchServerState as usePatchAgeAssuranceServerState,
+} from '#/ageAssurance/data'
+
+const AgeAssuranceStateContext = createContext<{
+ Access: typeof AgeAssuranceAccess
+ Status: typeof AgeAssuranceStatus
+ state: AgeAssuranceState
+}>({
+ Access: AgeAssuranceAccess,
+ Status: AgeAssuranceStatus,
+ state: {
+ lastInitiatedAt: undefined,
+ status: AgeAssuranceStatus.Unknown,
+ access: AgeAssuranceAccess.Full,
+ },
+})
+
+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 getAndRegisterPushToken = useGetAndRegisterPushToken()
+
+ useEffect(() => {
+ logger.debug(`useAgeAssuranceState`, {state})
+ }, [state])
+
+ useOnAgeAssuranceAccessUpdate(state => {
+ getAndRegisterPushToken({
+ isAgeRestricted: state.access !== AgeAssuranceAccess.Full,
+ })
+ })
+
+ return (
+ ({
+ Access: AgeAssuranceAccess,
+ Status: AgeAssuranceStatus,
+ state,
+ }),
+ [state],
+ )}>
+ {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..513b4d0a6d
--- /dev/null
+++ b/src/ageAssurance/state.ts
@@ -0,0 +1,91 @@
+import {useMemo, useRef} 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(() => {
+ if (!hasSession)
+ return {
+ status: AgeAssuranceStatus.Unknown,
+ access: AgeAssuranceAccess.Safe,
+ }
+ 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()
+ const prevState = useRef(null)
+
+ if (prevState.current !== state.access) {
+ prevState.current = state.access
+ cb(state)
+ logger.debug(`useOnAgeAssuranceAccessUpdate`, {state})
+ }
+}
diff --git a/src/ageAssurance/types.ts b/src/ageAssurance/types.ts
new file mode 100644
index 0000000000..4007d13d95
--- /dev/null
+++ b/src/ageAssurance/types.ts
@@ -0,0 +1,65 @@
+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) {
+ let status = AgeAssuranceStatus.Unknown
+ switch (raw) {
+ case 'unknown':
+ status = AgeAssuranceStatus.Unknown
+ break
+ case 'pending':
+ status = AgeAssuranceStatus.Pending
+ break
+ case 'assured':
+ status = AgeAssuranceStatus.Assured
+ break
+ case 'blocked':
+ status = AgeAssuranceStatus.Blocked
+ break
+ default:
+ logger.error(`parseStatusFromString: unknown status value: ${raw}`)
+ status = AgeAssuranceStatus.Unknown
+ }
+ return status
+}
+
+export function parseAccessFromString(raw: string) {
+ let access = AgeAssuranceAccess.Full
+ switch (raw) {
+ case 'unknown':
+ access = AgeAssuranceAccess.Unknown
+ break
+ case 'none':
+ access = AgeAssuranceAccess.None
+ break
+ case 'safe':
+ access = AgeAssuranceAccess.Safe
+ break
+ case 'full':
+ access = AgeAssuranceAccess.Full
+ break
+ default:
+ logger.error(`parseAccessFromString: unknown access value: ${raw}`)
+ access = AgeAssuranceAccess.Full
+ }
+ return access
+}
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..bf1248fc19
--- /dev/null
+++ b/src/ageAssurance/util.ts
@@ -0,0 +1,84 @@
+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
+}
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/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 && (
}
@@ -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,11 @@ function Inner({style}: ViewStyleProp & {}) {
{
- if (props.geolocationStatus.isAgeRestrictedGeo) {
+ // TODO test this
+ const access = computeAgeAssuranceRegionAccess(
+ props.geolocation,
+ )
+ if (access !== aa.Access.Full) {
props.disableDialogAction()
props.setDialogError(
_(
@@ -108,10 +110,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..ebc873da14 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'
@@ -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,10 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
if (!agent.session) return
if (unmounted.current) return
- const {data} = await agent.app.bsky.unspecced.getAgeAssuranceState()
+ // TODO test
+ 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 +124,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 +131,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..f773349167 100644
--- a/src/components/ageAssurance/useAgeAssuranceCopy.ts
+++ b/src/components/ageAssurance/useAgeAssuranceCopy.ts
@@ -2,14 +2,22 @@ import {useMemo} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
+import {useAgeAssurance} from '#/ageAssurance'
+
export function useAgeAssuranceCopy() {
const {_} = useLingui()
+ const aa = useAgeAssurance()
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.`,
- ),
+ notice:
+ aa.state.access === aa.Access.Safe
+ ? _(
+ msg`Due to laws in your region, certain features on Bluesky are currently restricted until you're able to verify you're an adult.`,
+ )
+ : _(
+ 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.`,
+ ),
banner: _(
msg`The laws in your location require you to verify you're an adult to access certain features. Tap to learn more.`,
),
@@ -17,5 +25,5 @@ export function useAgeAssuranceCopy() {
msg`Don't worry! All existing messages and settings are saved and will be available after you verify you're an adult.`,
),
}
- }, [_])
+ }, [_, aa])
}
diff --git a/src/components/dialogs/BirthDateSettings.tsx b/src/components/dialogs/BirthDateSettings.tsx
index e1c73b67cb..4d593b53de 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,65 @@ 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 Birthday
+
+
+
+ This information is private and not shared with other users.
+
+
- {isLoading ? (
-
- ) : error || !preferences ? (
-
- ) : (
-
- )}
-
+ {isLoading ? (
+
+ ) : error || !preferences ? (
+
+ ) : (
+
+ )}
+
-
-
+
+
+ ) : (
+
+
+
+ You recently changed your birthday
+
+
+
+ There is a limit to how often you can change your birth date.
+ You may need to wait a day or two before updating it again.
+
+
+
+
+
+
+ )}
)
}
@@ -86,7 +112,7 @@ function BirthdayInner({
isError,
error,
mutateAsync: setBirthDate,
- } = usePreferencesSetBirthDateMutation()
+ } = useBirthDateMutation()
const hasChanged = date !== preferences.birthDate
const age = getAge(new Date(date))
@@ -130,11 +156,11 @@ function BirthdayInner({
You must be at least 13 years old to use Bluesky. Read our{' '}
-
Terms of Service
- {' '}
+ {' '}
for more information.
diff --git a/src/components/dialogs/DeviceLocationRequestDialog.tsx b/src/components/dialogs/DeviceLocationRequestDialog.tsx
index b6547d4f06..7ec5bf2749 100644
--- a/src/components/dialogs/DeviceLocationRequestDialog.tsx
+++ b/src/components/dialogs/DeviceLocationRequestDialog.tsx
@@ -7,12 +7,6 @@ import {wait} from '#/lib/async/wait'
import {isNetworkError, useCleanError} from '#/lib/hooks/useCleanError'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
-import {
- computeGeolocationStatus,
- type GeolocationStatus,
- useGeolocationConfig,
-} from '#/state/geolocation'
-import {useRequestDeviceLocation} from '#/state/geolocation/useRequestDeviceLocation'
import {atoms as a, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -20,10 +14,11 @@ import * as Dialog from '#/components/Dialog'
import {PinLocation_Stroke2_Corner0_Rounded as LocationIcon} from '#/components/icons/PinLocation'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
+import {type Geolocation, useRequestDeviceGeolocation} from '#/geolocation'
export type Props = {
onLocationAcquired?: (props: {
- geolocationStatus: GeolocationStatus
+ geolocation: Geolocation
setDialogError: (error: string) => void
disableDialogAction: () => void
closeDialog: (callback?: () => void) => void
@@ -57,8 +52,7 @@ function DeviceLocationRequestDialogInner({onLocationAcquired}: Props) {
const t = useTheme()
const {_} = useLingui()
const {close} = Dialog.useDialogContext()
- const requestDeviceLocation = useRequestDeviceLocation()
- const {config} = useGeolocationConfig()
+ const requestDeviceLocation = useRequestDeviceGeolocation()
const cleanError = useCleanError()
const [isRequesting, setIsRequesting] = useState(false)
@@ -76,9 +70,8 @@ function DeviceLocationRequestDialogInner({onLocationAcquired}: Props) {
const location = req.location
if (location && location.countryCode) {
- const geolocationStatus = computeGeolocationStatus(location, config)
onLocationAcquired?.({
- geolocationStatus,
+ geolocation: location,
setDialogError: setError,
disableDialogAction: () => setDialogDisabled(true),
closeDialog: close,
diff --git a/src/env/common.ts b/src/env/common.ts
index c6ce4b2974..621e8076ef 100644
--- a/src/env/common.ts
+++ b/src/env/common.ts
@@ -100,14 +100,11 @@ export const GCP_PROJECT_ID: number =
: Number(process.env.EXPO_PUBLIC_GCP_PROJECT_ID)
/**
- * URL for the bapp-config web worker _development_ environment. Can be a
+ * URLs for the app config web worker. Can be a
* locally running server, see `env.example` for more.
*/
export const BAPP_CONFIG_DEV_URL = process.env.BAPP_CONFIG_DEV_URL
-
-/**
- * Dev environment passthrough value for bapp-config web worker. Allows local
- * dev access to the web worker running in `development` mode.
- */
-export const BAPP_CONFIG_DEV_BYPASS_SECRET: string =
- process.env.BAPP_CONFIG_DEV_BYPASS_SECRET
+export const BAPP_CONFIG_PROD_URL = `https://ip.bsky.app`
+export const BAPP_CONFIG_URL = IS_DEV
+ ? (BAPP_CONFIG_DEV_URL ?? BAPP_CONFIG_PROD_URL)
+ : BAPP_CONFIG_PROD_URL
diff --git a/src/geolocation/const.ts b/src/geolocation/const.ts
new file mode 100644
index 0000000000..b12f37140f
--- /dev/null
+++ b/src/geolocation/const.ts
@@ -0,0 +1,12 @@
+import {BAPP_CONFIG_URL} from '#/env'
+import {type Geolocation} from '#/geolocation/types'
+
+export const GEOLOCATION_SERVICE_URL = `${BAPP_CONFIG_URL}/geolocation`
+
+/**
+ * Default geolocation config.
+ */
+export const FALLBACK_GEOLOCATION_SERVICE_RESPONSE: Geolocation = {
+ countryCode: undefined,
+ regionCode: undefined,
+}
diff --git a/src/geolocation/debug.ts b/src/geolocation/debug.ts
new file mode 100644
index 0000000000..58fe05e6f2
--- /dev/null
+++ b/src/geolocation/debug.ts
@@ -0,0 +1,19 @@
+import * as aaDebug from '#/ageAssurance/debug'
+import {IS_DEV} from '#/env'
+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,
+}
+
+export async function resolve(data: T) {
+ await new Promise(y => setTimeout(y, 2000)) // simulate network
+ return data
+}
diff --git a/src/geolocation/device.ts b/src/geolocation/device.ts
new file mode 100644
index 0000000000..d14d4eb677
--- /dev/null
+++ b/src/geolocation/device.ts
@@ -0,0 +1,144 @@
+import {useCallback, useEffect, useRef} from 'react'
+import * as Location from 'expo-location'
+import {createPermissionHook} from 'expo-modules-core'
+
+import {isNative} from '#/platform/detection'
+import * as debug from '#/geolocation/debug'
+import {logger} from '#/geolocation/logger'
+import {type Geolocation} from '#/geolocation/types'
+import {normalizeDeviceLocation} from '#/geolocation/util'
+import {device} from '#/storage'
+
+/**
+ * Location.useForegroundPermissions on web just errors if the
+ * navigator.permissions API is not available. We need to catch and ignore it,
+ * since it's effectively denied.
+ *
+ * @see https://github.com/expo/expo/blob/72f1562ed9cce5ff6dfe04aa415b71632a3d4b87/packages/expo-location/src/Location.ts#L290-L293
+ */
+const useForegroundPermissions = createPermissionHook({
+ getMethod: () =>
+ Location.getForegroundPermissionsAsync().catch(error => {
+ logger.debug(
+ 'useForegroundPermission: error getting location permissions',
+ {safeMessage: error},
+ )
+ return {
+ status: Location.PermissionStatus.DENIED,
+ granted: false,
+ canAskAgain: false,
+ expires: 0,
+ }
+ }),
+ requestMethod: () =>
+ Location.requestForegroundPermissionsAsync().catch(error => {
+ logger.debug(
+ 'useForegroundPermission: error requesting location permissions',
+ {safeMessage: error},
+ )
+ return {
+ status: Location.PermissionStatus.DENIED,
+ granted: false,
+ canAskAgain: false,
+ expires: 0,
+ }
+ }),
+})
+
+export async function getDeviceGeolocation(): Promise {
+ if (debug.enabled) return debug.resolve(debug.deviceGeolocation)
+
+ try {
+ const geocode = await Location.getCurrentPositionAsync()
+ const locations = await Location.reverseGeocodeAsync({
+ latitude: geocode.coords.latitude,
+ longitude: geocode.coords.longitude,
+ })
+ const location = locations.at(0)
+ const normalized = location ? normalizeDeviceLocation(location) : undefined
+ return {
+ countryCode: normalized?.countryCode ?? undefined,
+ regionCode: normalized?.regionCode ?? undefined,
+ }
+ } catch (e) {
+ logger.error('getDeviceGeolocation: failed', {safeMessage: e})
+ return {
+ countryCode: undefined,
+ regionCode: undefined,
+ }
+ }
+}
+
+export function useRequestDeviceGeolocation(): () => Promise<
+ | {
+ granted: true
+ location: Geolocation | undefined
+ }
+ | {
+ granted: false
+ }
+> {
+ return useCallback(async () => {
+ const status = await Location.requestForegroundPermissionsAsync()
+ if (status.granted) {
+ return {
+ granted: true,
+ location: await getDeviceGeolocation(),
+ }
+ } else {
+ return {
+ granted: false,
+ }
+ }
+ }, [])
+}
+
+/**
+ * Hook to get and sync the device geolocation from the device GPS and store it
+ * using device storage. If permissions are not granted, it will clear any cached
+ * storage value.
+ */
+export function useSyncDeviceGeolocationOnStartup(
+ sync: (location: Geolocation | undefined) => void,
+) {
+ const synced = useRef(false)
+ const [status] = useForegroundPermissions()
+ useEffect(() => {
+ if (!isNative) return
+
+ async function get() {
+ // no need to set this more than once per session
+ if (synced.current) return
+ logger.debug('useSyncDeviceGeolocationOnStartup: checking perms')
+ if (status?.granted) {
+ const location = await getDeviceGeolocation()
+ if (location) {
+ logger.debug('useSyncDeviceGeolocationOnStartup: got location')
+ sync(location)
+ synced.current = true
+ }
+ } else {
+ const hasCachedValue = device.get(['deviceGeolocation']) !== undefined
+ /**
+ * If we have a cached value, but user has revoked permissions,
+ * quietly (will take effect lazily) clear this out.
+ */
+ if (hasCachedValue) {
+ logger.debug(
+ 'useSyncDeviceGeolocationOnStartup: clearing cached location, perms revoked',
+ )
+ device.set(['deviceGeolocation'], undefined)
+ }
+ }
+ }
+
+ get().catch(e => {
+ logger.error(
+ 'useSyncDeviceGeolocationOnStartup: failed to get location',
+ {
+ safeMessage: e,
+ },
+ )
+ })
+ }, [status, sync])
+}
diff --git a/src/geolocation/index.tsx b/src/geolocation/index.tsx
new file mode 100644
index 0000000000..06d386750b
--- /dev/null
+++ b/src/geolocation/index.tsx
@@ -0,0 +1,53 @@
+import {createContext, type ReactNode, useContext, useMemo} from 'react'
+
+import {useSyncDeviceGeolocationOnStartup} from '#/geolocation/device'
+import {useGeolocationServiceResponse} from '#/geolocation/service'
+import {type Geolocation} from '#/geolocation/types'
+import {mergeGeolocations} from '#/geolocation/util'
+import {device, useStorage} from '#/storage'
+
+export {useRequestDeviceGeolocation} from '#/geolocation/device'
+export {resolve} from '#/geolocation/service'
+export * from '#/geolocation/types'
+
+const GeolocationContext = createContext({
+ countryCode: undefined,
+ regionCode: undefined,
+})
+
+const DeviceGeolocationAPIContext = createContext<{
+ setDeviceGeolocation(deviceGeolocation: Geolocation): void
+}>({
+ setDeviceGeolocation: () => {},
+})
+
+export function useGeolocation() {
+ return useContext(GeolocationContext)
+}
+
+export function useDeviceGeolocationApi() {
+ return useContext(DeviceGeolocationAPIContext)
+}
+
+export function Provider({children}: {children: ReactNode}) {
+ const geolocationService = useGeolocationServiceResponse()
+ const [deviceGeolocation, setDeviceGeolocation] = useStorage(device, [
+ 'deviceGeolocation',
+ ])
+ const geolocation = useMemo(() => {
+ const merged = mergeGeolocations(deviceGeolocation, geolocationService)
+ device.set(['mergedGeolocation'], merged)
+ return merged
+ }, [deviceGeolocation, geolocationService])
+
+ useSyncDeviceGeolocationOnStartup(setDeviceGeolocation)
+
+ return (
+
+ ({setDeviceGeolocation}), [setDeviceGeolocation])}>
+ {children}
+
+
+ )
+}
diff --git a/src/state/geolocation/logger.ts b/src/geolocation/logger.ts
similarity index 100%
rename from src/state/geolocation/logger.ts
rename to src/geolocation/logger.ts
diff --git a/src/geolocation/service.ts b/src/geolocation/service.ts
new file mode 100644
index 0000000000..40f0413c61
--- /dev/null
+++ b/src/geolocation/service.ts
@@ -0,0 +1,137 @@
+import {useEffect, useState} from 'react'
+import EventEmitter from 'eventemitter3'
+
+import {networkRetry} from '#/lib/async/retry'
+import {
+ FALLBACK_GEOLOCATION_SERVICE_RESPONSE,
+ GEOLOCATION_SERVICE_URL,
+} from '#/geolocation/const'
+import * as debug from '#/geolocation/debug'
+import {logger} from '#/geolocation/logger'
+import {type Geolocation} from '#/geolocation/types'
+import {device} from '#/storage'
+
+const events = new EventEmitter()
+const EVENT = 'geolocation-service-response-updated'
+const emitGeolocationServiceResponseUpdate = (data: Geolocation) => {
+ events.emit(EVENT, data)
+}
+const onGeolocationServiceResponseUpdate = (
+ listener: (data: Geolocation) => void,
+) => {
+ events.on(EVENT, listener)
+ return () => {
+ events.off(EVENT, listener)
+ }
+}
+
+async function fetchGeolocationServiceData(
+ url: string,
+): Promise {
+ if (debug.enabled) return debug.resolve(debug.geolocation)
+ const res = await fetch(url)
+ if (!res.ok) {
+ throw new Error(`fetchGeolocationServiceData failed ${res.status}`)
+ }
+ return res.json() as Promise
+}
+
+/**
+ * Local promise used within this file only.
+ */
+let geolocationServicePromise: Promise<{success: boolean}> | undefined
+
+/**
+ * Begin the process of resolving geolocation config. This is called right away
+ * at app start, and the promise is awaited later before proceeding with app
+ * startup.
+ */
+export async function resolve() {
+ if (geolocationServicePromise) {
+ const cached = device.get(['geolocationServiceResponse'])
+ if (cached) {
+ logger.debug(`resolve(): using cache`)
+ } else {
+ logger.debug(`resolve(): no cache`)
+ const {success} = await geolocationServicePromise
+ if (success) {
+ logger.debug(`resolve(): resolved`)
+ } else {
+ logger.info(`resolve(): failed`)
+ }
+ }
+ } else {
+ logger.debug(`resolve(): initiating`)
+
+ /**
+ * THIS PROMISE SHOULD NEVER `reject()`! We want the app to proceed with
+ * startup, even if geolocation resolution fails.
+ */
+ geolocationServicePromise = new Promise(async resolve => {
+ let success = false
+
+ function cacheResponseOrThrow(response: Geolocation | undefined) {
+ if (response) {
+ device.set(['geolocationServiceResponse'], response)
+ emitGeolocationServiceResponseUpdate(response)
+ } else {
+ // endpoint should throw on all failures, this is insurance
+ throw new Error(`fetchGeolocationServiceData returned no data`)
+ }
+ }
+
+ try {
+ // Try once, fail fast
+ const config = await fetchGeolocationServiceData(
+ GEOLOCATION_SERVICE_URL,
+ )
+ cacheResponseOrThrow(config)
+ success = true
+ } catch (e: any) {
+ logger.debug(
+ `resolve(): fetchGeolocationServiceData failed initial request`,
+ {
+ safeMessage: e.message,
+ },
+ )
+
+ // retry 3 times, but don't await, proceed with default
+ networkRetry(3, () =>
+ fetchGeolocationServiceData(GEOLOCATION_SERVICE_URL),
+ )
+ .then(config => {
+ cacheResponseOrThrow(config)
+ success = true
+ })
+ .catch((e: any) => {
+ // complete fail closed
+ logger.debug(
+ `resolve(): fetchGeolocationServiceData failed retries`,
+ {
+ safeMessage: e.message,
+ },
+ )
+ })
+ } finally {
+ resolve({success})
+ }
+ })
+ }
+}
+
+export function useGeolocationServiceResponse() {
+ const [config, setConfig] = useState(() => {
+ const initial =
+ device.get(['geolocationServiceResponse']) ||
+ FALLBACK_GEOLOCATION_SERVICE_RESPONSE
+ return initial
+ })
+
+ useEffect(() => {
+ return onGeolocationServiceResponseUpdate(config => {
+ setConfig(config!)
+ })
+ }, [])
+
+ return config
+}
diff --git a/src/geolocation/types.ts b/src/geolocation/types.ts
new file mode 100644
index 0000000000..80848730c9
--- /dev/null
+++ b/src/geolocation/types.ts
@@ -0,0 +1,4 @@
+export type Geolocation = {
+ countryCode: string | undefined
+ regionCode: string | undefined
+}
diff --git a/src/geolocation/util.ts b/src/geolocation/util.ts
new file mode 100644
index 0000000000..9e842f5935
--- /dev/null
+++ b/src/geolocation/util.ts
@@ -0,0 +1,113 @@
+import {type LocationGeocodedAddress} from 'expo-location'
+
+import {logger} from '#/geolocation/logger'
+import {type Geolocation} from '#/geolocation/types'
+
+/**
+ * Maps full US region names to their short codes.
+ *
+ * Context: in some cases, like on Android, we get the full region name instead
+ * of the short code. We may need to expand this in the future to other
+ * countries, hence the prefix.
+ */
+export const USRegionNameToRegionCode: {
+ [regionName: string]: string
+} = {
+ Alabama: 'AL',
+ Alaska: 'AK',
+ Arizona: 'AZ',
+ Arkansas: 'AR',
+ California: 'CA',
+ Colorado: 'CO',
+ Connecticut: 'CT',
+ Delaware: 'DE',
+ Florida: 'FL',
+ Georgia: 'GA',
+ Hawaii: 'HI',
+ Idaho: 'ID',
+ Illinois: 'IL',
+ Indiana: 'IN',
+ Iowa: 'IA',
+ Kansas: 'KS',
+ Kentucky: 'KY',
+ Louisiana: 'LA',
+ Maine: 'ME',
+ Maryland: 'MD',
+ Massachusetts: 'MA',
+ Michigan: 'MI',
+ Minnesota: 'MN',
+ Mississippi: 'MS',
+ Missouri: 'MO',
+ Montana: 'MT',
+ Nebraska: 'NE',
+ Nevada: 'NV',
+ ['New Hampshire']: 'NH',
+ ['New Jersey']: 'NJ',
+ ['New Mexico']: 'NM',
+ ['New York']: 'NY',
+ ['North Carolina']: 'NC',
+ ['North Dakota']: 'ND',
+ Ohio: 'OH',
+ Oklahoma: 'OK',
+ Oregon: 'OR',
+ Pennsylvania: 'PA',
+ ['Rhode Island']: 'RI',
+ ['South Carolina']: 'SC',
+ ['South Dakota']: 'SD',
+ Tennessee: 'TN',
+ Texas: 'TX',
+ Utah: 'UT',
+ Vermont: 'VT',
+ Virginia: 'VA',
+ Washington: 'WA',
+ ['West Virginia']: 'WV',
+ Wisconsin: 'WI',
+ Wyoming: 'WY',
+}
+
+/**
+ * Normalizes a `LocationGeocodedAddress` into a `Geolocation`.
+ *
+ * We don't want or care about the full location data, so we trim it down and
+ * normalize certain fields, like region, into the format we need.
+ */
+export function normalizeDeviceLocation(
+ location: LocationGeocodedAddress,
+): Geolocation {
+ let {isoCountryCode, region} = location
+
+ if (region) {
+ if (isoCountryCode === 'US') {
+ region = USRegionNameToRegionCode[region] ?? region
+ }
+ }
+
+ return {
+ countryCode: isoCountryCode ?? undefined,
+ regionCode: region ?? undefined,
+ }
+}
+
+/**
+ * Combines precise location data with the geolocation config fetched from the
+ * IP service, with preference to the precise data.
+ */
+export function mergeGeolocations(
+ device?: Geolocation,
+ geolocationService?: Geolocation,
+): Geolocation {
+ let geolocation: Geolocation = {
+ countryCode: geolocationService?.countryCode ?? undefined,
+ regionCode: geolocationService?.regionCode ?? undefined,
+ }
+ // prefer GPS
+ if (device?.countryCode) {
+ geolocation = device
+ }
+ logger.debug('merged geolocation data', {
+ device,
+ service: geolocationService,
+ merged: geolocation,
+ })
+ return geolocation
+}
diff --git a/src/lib/__tests__/parseLinkingUrl.test.ts b/src/lib/__tests__/parseLinkingUrl.test.ts
new file mode 100644
index 0000000000..48bb927e01
--- /dev/null
+++ b/src/lib/__tests__/parseLinkingUrl.test.ts
@@ -0,0 +1,23 @@
+import {describe, expect, it} from '@jest/globals'
+
+import {parseLinkingUrl} from '../parseLinkingUrl'
+
+describe('parseLinkingUrl', () => {
+ it('should correctly parse bluesky:// URLs', () => {
+ const url =
+ 'bluesky://intent/age-assurance?result=success&actorDid=did:example:123'
+ const urlp = parseLinkingUrl(url)
+ expect(urlp.protocol).toBe('bluesky:')
+ expect(urlp.host).toBe('')
+ expect(urlp.pathname).toBe('/intent/age-assurance')
+ })
+
+ it('should correctly parse standard URLs', () => {
+ const url =
+ 'https://bsky.app/intent/age-assurance?result=success&actorDid=did:example:123'
+ const urlp = parseLinkingUrl(url)
+ expect(urlp.protocol).toBe('https:')
+ expect(urlp.host).toBe('bsky.app')
+ expect(urlp.pathname).toBe('/intent/age-assurance')
+ })
+})
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 97a7679615..231447b4f2 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -214,6 +214,7 @@ export const PUBLIC_APPVIEW_DID = 'did:web:api.bsky.app'
export const PUBLIC_STAGING_APPVIEW_DID = 'did:web:api.staging.bsky.dev'
export const DEV_ENV_APPVIEW = `http://localhost:2584` // always the same
+export const DEV_ENV_APPVIEW_DID = `did:plc:dw4kbjf5mn7nhenabiqpkyh3` // always the same
// temp hack for e2e - esb
export const BLUESKY_PROXY_HEADER = {
diff --git a/src/lib/currency.ts b/src/lib/currency.ts
index cc5a9a7b03..8b019fc6da 100644
--- a/src/lib/currency.ts
+++ b/src/lib/currency.ts
@@ -1,8 +1,8 @@
import React from 'react'
import {deviceLocales} from '#/locale/deviceLocales'
-import {useGeolocationStatus} from '#/state/geolocation'
import {useLanguagePrefs} from '#/state/preferences'
+import {useGeolocation} from '#/geolocation'
/**
* From react-native-localize
@@ -275,7 +275,7 @@ export const countryCodeToCurrency: Record = {
export function useFormatCurrency(
options?: Parameters[1],
) {
- const {location: geolocation} = useGeolocationStatus()
+ const geolocation = useGeolocation()
const {appLanguage} = useLanguagePrefs()
return React.useMemo(() => {
const locale = deviceLocales.at(0)
diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts
index 11c2e40d07..b663030a3d 100644
--- a/src/lib/hooks/useIntentHandler.ts
+++ b/src/lib/hooks/useIntentHandler.ts
@@ -4,14 +4,11 @@ import * as Linking from 'expo-linking'
import * as WebBrowser from 'expo-web-browser'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
+import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
import {logger} from '#/logger'
import {isIOS, isNative} from '#/platform/detection'
import {useSession} from '#/state/session'
import {useCloseAllActiveElements} from '#/state/util'
-import {
- parseAgeAssuranceRedirectDialogState,
- useAgeAssuranceRedirectDialogControl,
-} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
import {useApplyPullRequestOTAUpdate} from './useOTAUpdates'
@@ -27,8 +24,6 @@ export function useIntentHandler() {
const incomingUrl = Linking.useLinkingURL()
const composeIntent = useComposeIntent()
const verifyEmailIntent = useVerifyEmailIntent()
- const ageAssuranceRedirectDialogControl =
- useAgeAssuranceRedirectDialogControl()
const {currentAccount} = useSession()
const {tryApplyUpdate} = useApplyPullRequestOTAUpdate()
@@ -47,17 +42,8 @@ export function useIntentHandler() {
hostname: referrerInfo?.hostname,
})
}
-
- // We want to be able to support bluesky:// deeplinks. It's unnatural for someone to use a deeplink with three
- // slashes, like bluesky:///intent/follow. However, supporting just two slashes causes us to have to take care
- // of two cases when parsing the url. If we ensure there is a third slash, we can always ensure the first
- // path parameter is in pathname rather than in hostname.
- if (url.startsWith('bluesky://') && !url.startsWith('bluesky:///')) {
- url = url.replace('bluesky://', 'bluesky:///')
- }
-
- const urlp = new URL(url)
- const [__, intent, intentType] = urlp.pathname.split('/')
+ const urlp = parseLinkingUrl(url)
+ const [, intent, intentType] = urlp.pathname.split('/')
// On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the
// intent check. On web, we have to check the first part of the path since we have an actual hostname
@@ -82,23 +68,7 @@ export function useIntentHandler() {
return
}
case 'age-assurance': {
- const state = parseAgeAssuranceRedirectDialogState({
- result: params.get('result') ?? undefined,
- actorDid: params.get('actorDid') ?? undefined,
- })
-
- /*
- * 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
- ) {
- ageAssuranceRedirectDialogControl.open(state)
- }
+ // Handled in `#/ageAssurance/components/RedirectOverlay.tsx`
return
}
case 'apply-ota': {
@@ -127,7 +97,6 @@ export function useIntentHandler() {
incomingUrl,
composeIntent,
verifyEmailIntent,
- ageAssuranceRedirectDialogControl,
currentAccount,
tryApplyUpdate,
])
diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts
index 02eae36693..a1c45b9786 100644
--- a/src/lib/notifications/notifications.ts
+++ b/src/lib/notifications/notifications.ts
@@ -9,9 +9,9 @@ import {PUBLIC_APPVIEW_DID, PUBLIC_STAGING_APPVIEW_DID} from '#/lib/constants'
import {logger as notyLogger} from '#/lib/notifications/util'
import {isNetworkError} from '#/lib/strings/errors'
import {isNative} from '#/platform/detection'
-import {useAgeAssuranceContext} from '#/state/ageAssurance'
import {type SessionAccount, useAgent, useSession} from '#/state/session'
import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler'
+import {useAgeAssurance} from '#/ageAssurance'
import {IS_DEV} from '#/env'
/**
@@ -125,7 +125,7 @@ async function getPushToken() {
* @see https://github.com/bluesky-social/social-app/pull/4467
*/
export function useGetAndRegisterPushToken() {
- const {isAgeRestricted} = useAgeAssuranceContext()
+ const aa = useAgeAssurance()
const registerPushToken = useRegisterPushToken()
return useCallback(
async ({
@@ -152,13 +152,14 @@ export function useGetAndRegisterPushToken() {
*/
registerPushToken({
token,
- isAgeRestricted: isAgeRestrictedOverride ?? isAgeRestricted,
+ isAgeRestricted:
+ isAgeRestrictedOverride ?? aa.state.access !== aa.Access.Full,
})
}
return token
},
- [registerPushToken, isAgeRestricted],
+ [registerPushToken, aa],
)
}
@@ -173,15 +174,14 @@ export function useNotificationsRegistration() {
const {currentAccount} = useSession()
const registerPushToken = useRegisterPushToken()
const getAndRegisterPushToken = useGetAndRegisterPushToken()
- const {isReady: isAgeRestrictionReady, isAgeRestricted} =
- useAgeAssuranceContext()
+ const aa = useAgeAssurance()
useEffect(() => {
/**
* We want this to init right away _after_ we have a logged in user, and
* _after_ we've loaded their age assurance state.
*/
- if (!currentAccount || !isAgeRestrictionReady) return
+ if (!currentAccount) return
notyLogger.debug(`useNotificationsRegistration`)
@@ -206,20 +206,17 @@ export function useNotificationsRegistration() {
* @see https://docs.expo.dev/versions/latest/sdk/notifications/#addpushtokenlistenerlistener
*/
const subscription = Notifications.addPushTokenListener(async token => {
- registerPushToken({token, isAgeRestricted: isAgeRestricted})
+ registerPushToken({
+ token,
+ isAgeRestricted: aa.state.access !== aa.Access.Full,
+ })
notyLogger.debug(`addPushTokenListener callback`, {token})
})
return () => {
subscription.remove()
}
- }, [
- currentAccount,
- getAndRegisterPushToken,
- registerPushToken,
- isAgeRestrictionReady,
- isAgeRestricted,
- ])
+ }, [currentAccount, getAndRegisterPushToken, registerPushToken, aa])
}
export function useRequestNotificationsPermission() {
diff --git a/src/lib/parseLinkingUrl.ts b/src/lib/parseLinkingUrl.ts
new file mode 100644
index 0000000000..3ecd65178a
--- /dev/null
+++ b/src/lib/parseLinkingUrl.ts
@@ -0,0 +1,10 @@
+export function parseLinkingUrl(url: string): URL {
+ /*
+ * Hack: add a third slash to bluesky:// urls so that `URL.host` is empty and
+ * `URL.pathname` has the full path.
+ */
+ if (url.startsWith('bluesky://') && !url.startsWith('bluesky:///')) {
+ url = url.replace('bluesky://', 'bluesky:///')
+ }
+ return new URL(url)
+}
diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts
index 288f428c10..398faf473d 100644
--- a/src/lib/strings/url-helpers.ts
+++ b/src/lib/strings/url-helpers.ts
@@ -41,7 +41,7 @@ export function makeRecordUri(
collection: string,
rkey: string,
) {
- const urip = new AtUri('at://host/')
+ const urip = new AtUri('at://placeholder.placeholder/')
urip.host = didOrName
urip.collection = collection
urip.rkey = rkey
diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts
index dc38453d81..57b03169d0 100644
--- a/src/logger/metrics.ts
+++ b/src/logger/metrics.ts
@@ -22,6 +22,7 @@ export type MetricEvents = {
| 'SignupQueued'
| 'Deactivated'
| 'Takendown'
+ | 'AgeAssuranceNoAccessScreen'
scope: 'current' | 'every'
}
'notifications:openApp': {
diff --git a/src/logger/types.ts b/src/logger/types.ts
index 19e12c5045..f9b428739e 100644
--- a/src/logger/types.ts
+++ b/src/logger/types.ts
@@ -13,8 +13,10 @@ export enum LogContext {
FeedFeedback = 'feed-feedback',
PostSource = 'post-source',
AgeAssurance = 'age-assurance',
+ AgeAssuranceV2 = 'age-assurance-v2',
PolicyUpdate = 'policy-update',
Geolocation = 'geolocation',
+ GeolocationV2 = 'geolocation-v2',
/**
* METRIC IS FOR INTERNAL USE ONLY, don't create any other loggers using this
diff --git a/src/screens/Moderation/index.tsx b/src/screens/Moderation/index.tsx
index d53eef1d4d..6fed53c510 100644
--- a/src/screens/Moderation/index.tsx
+++ b/src/screens/Moderation/index.tsx
@@ -12,7 +12,6 @@ import {
} from '#/lib/routes/types'
import {logger} from '#/logger'
import {isIOS} from '#/platform/detection'
-import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {
useMyLabelersQuery,
usePreferencesQuery,
@@ -22,11 +21,8 @@ import {
import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities'
import {useSetMinimalShellMode} from '#/state/shell'
import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
-import {Admonition} from '#/components/Admonition'
import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
-import {Button, ButtonText} from '#/components/Button'
-import * as Dialog from '#/components/Dialog'
-import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
+import {Button} from '#/components/Button'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {Divider} from '#/components/Divider'
import * as Toggle from '#/components/forms/Toggle'
@@ -45,6 +41,7 @@ import {ListMaybePlaceholder} from '#/components/Lists'
import {Loader} from '#/components/Loader'
import {GlobalLabelPreference} from '#/components/moderation/LabelPreference'
import {Text} from '#/components/Typography'
+import {useAgeAssurance} from '#/ageAssurance'
function ErrorState({error}: {error: string}) {
const t = useTheme()
@@ -86,9 +83,8 @@ export function ModerationScreen(
error: preferencesError,
data: preferences,
} = usePreferencesQuery()
- const {isReady: isAgeInfoReady} = useAgeAssurance()
- const isLoading = isPreferencesLoading || !isAgeInfoReady
+ const isLoading = isPreferencesLoading
const error = preferencesError
return (
@@ -162,13 +158,12 @@ export function ModerationScreenInner({
const setMinimalShellMode = useSetMinimalShellMode()
const {gtMobile} = useBreakpoints()
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
- const birthdateDialogControl = Dialog.useDialogControl()
const {
isLoading: isLabelersLoading,
data: labelers,
error: labelersError,
} = useMyLabelersQuery()
- const {declaredAge, isDeclaredUnderage, isAgeRestricted} = useAgeAssurance()
+ const aa = useAgeAssurance()
useFocusEffect(
useCallback(() => {
@@ -202,24 +197,6 @@ export function ModerationScreenInner({
return (
- {isDeclaredUnderage && (
-
-
-
- Your declared age is under 18. Some settings below may be
- disabled. If this was a mistake, you may edit your birthdate in
- your{' '}
-
- account settings
-
- .
-
-
-
- )}
-
- {(!isDeclaredUnderage || declaredAge === undefined) && (
-
+ Content filters
+
+
+
+
+ You must complete age assurance in order to access content filters.
+
+
+
+
+
- Content filters
-
- )}
-
- {declaredAge === undefined ? (
- <>
-
-
-
- >
- ) : !isDeclaredUnderage ? (
- <>
-
-
- You must complete age assurance in order to access the settings
- below.
-
-
-
-
-
- {!isDeclaredUnderage && (
- <>
-
-
- Enable adult content
+ {aa.state.access === aa.Access.Full && (
+ <>
+
+
+ Enable adult content
+
+
+
+
+ {adultContentEnabled ? (
+ Enabled
+ ) : (
+ Disabled
+ )}
-
-
-
- {adultContentEnabled ? (
- Enabled
- ) : (
- Disabled
- )}
-
-
-
-
+
- {disabledOnIOS && (
-
-
-
- Adult content can only be enabled via the Web at{' '}
- {
- evt.preventDefault()
- Linking.openURL('https://bsky.app/')
- return false
- }}>
- bsky.app
-
- .
-
-
-
- )}
+
+
+ {disabledOnIOS && (
+
+
+
+ Adult content can only be enabled via the Web at{' '}
+ {
+ evt.preventDefault()
+ Linking.openURL('https://bsky.app/')
+ return false
+ }}>
+ bsky.app
+
+ .
+
+
+
+ )}
- {adultContentEnabled && (
- <>
-
-
-
-
-
-
-
-
- >
- )}
+ {adultContentEnabled && (
+ <>
+
+
+
+
+
+
+
+
>
)}
-
-
- >
- ) : null}
+ >
+ )}
+
+
({
- ...prefs,
- adultContentEnabled: false,
- labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
-})
diff --git a/src/state/ageAssurance/index.tsx b/src/state/ageAssurance/index.tsx
deleted file mode 100644
index e85672b7c8..0000000000
--- a/src/state/ageAssurance/index.tsx
+++ /dev/null
@@ -1,156 +0,0 @@
-import {createContext, useContext, useMemo, useState} from 'react'
-import {type AppBskyUnspeccedDefs} from '@atproto/api'
-import {useQuery} from '@tanstack/react-query'
-
-import {networkRetry} from '#/lib/async/retry'
-import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
-import {isNetworkError} from '#/lib/strings/errors'
-import {
- type AgeAssuranceAPIContextType,
- type AgeAssuranceContextType,
-} from '#/state/ageAssurance/types'
-import {useIsAgeAssuranceEnabled} from '#/state/ageAssurance/useIsAgeAssuranceEnabled'
-import {logger} from '#/state/ageAssurance/util'
-import {useGeolocationStatus} from '#/state/geolocation'
-import {useAgent} from '#/state/session'
-
-export const createAgeAssuranceQueryKey = (did: string) =>
- ['ageAssurance', did] as const
-
-const DEFAULT_AGE_ASSURANCE_STATE: AppBskyUnspeccedDefs.AgeAssuranceState = {
- lastInitiatedAt: undefined,
- status: 'unknown',
-}
-
-const AgeAssuranceContext = createContext({
- status: 'unknown',
- isReady: false,
- lastInitiatedAt: undefined,
- isAgeRestricted: false,
-})
-AgeAssuranceContext.displayName = 'AgeAssuranceContext'
-
-const AgeAssuranceAPIContext = createContext({
- // @ts-ignore can't be bothered to type this
- refetch: () => Promise.resolve(),
-})
-AgeAssuranceAPIContext.displayName = 'AgeAssuranceAPIContext'
-
-/**
- * Low-level provider for fetching age assurance state on app load. Do not add
- * any other data fetching in here to avoid complications and reduced
- * performance.
- */
-export function Provider({children}: {children: React.ReactNode}) {
- const agent = useAgent()
- const {status: geolocation} = useGeolocationStatus()
- const isAgeAssuranceEnabled = useIsAgeAssuranceEnabled()
- const getAndRegisterPushToken = useGetAndRegisterPushToken()
- const [refetchWhilePending, setRefetchWhilePending] = useState(false)
-
- const {data, isFetched, refetch} = useQuery({
- /**
- * This is load bearing. We always want this query to run and end in a
- * "fetched" state, even if we fall back to defaults. This lets the rest of
- * the app know that we've at least attempted to load the AA state.
- *
- * However, it only needs to run if AA is enabled.
- */
- enabled: isAgeAssuranceEnabled,
- refetchOnWindowFocus: refetchWhilePending,
- queryKey: createAgeAssuranceQueryKey(agent.session?.did ?? 'never'),
- async queryFn() {
- if (!agent.session) return null
-
- try {
- const {data} = await networkRetry(3, () =>
- agent.app.bsky.unspecced.getAgeAssuranceState(),
- )
- // const {data} = {
- // data: {
- // lastInitiatedAt: new Date().toISOString(),
- // status: 'pending',
- // } as AppBskyUnspeccedDefs.AgeAssuranceState,
- // }
-
- logger.debug(`fetch`, {
- data,
- account: agent.session?.did,
- })
-
- await getAndRegisterPushToken({
- isAgeRestricted:
- !!geolocation?.isAgeRestrictedGeo && data.status !== 'assured',
- })
-
- return data
- } catch (e) {
- if (!isNetworkError(e)) {
- logger.error(`ageAssurance: failed to fetch`, {safeMessage: e})
- }
- // don't re-throw error, we'll just fall back to defaults
- return null
- }
- },
- })
-
- /**
- * Derive state, or fall back to defaults
- */
- const ageAssuranceContext = useMemo(() => {
- const {status, lastInitiatedAt} = data || DEFAULT_AGE_ASSURANCE_STATE
- const ctx: AgeAssuranceContextType = {
- isReady: isFetched || !isAgeAssuranceEnabled,
- status,
- lastInitiatedAt,
- isAgeRestricted: isAgeAssuranceEnabled ? status !== 'assured' : false,
- }
- logger.debug(`context`, ctx)
- return ctx
- }, [isFetched, data, isAgeAssuranceEnabled])
-
- if (
- !!ageAssuranceContext.lastInitiatedAt &&
- ageAssuranceContext.status === 'pending' &&
- !refetchWhilePending
- ) {
- /*
- * If we have a pending state, we want to refetch on window focus to ensure
- * that we get the latest state when the user returns to the app.
- */
- setRefetchWhilePending(true)
- } else if (
- !!ageAssuranceContext.lastInitiatedAt &&
- ageAssuranceContext.status !== 'pending' &&
- refetchWhilePending
- ) {
- setRefetchWhilePending(false)
- }
-
- const ageAssuranceAPIContext = useMemo(
- () => ({
- refetch,
- }),
- [refetch],
- )
-
- return (
-
-
- {children}
-
-
- )
-}
-
-/**
- * Access to low-level AA state. Prefer using {@link useAgeInfo} for a
- * more user-friendly interface.
- */
-export function useAgeAssuranceContext() {
- return useContext(AgeAssuranceContext)
-}
-
-export function useAgeAssuranceAPIContext() {
- return useContext(AgeAssuranceAPIContext)
-}
diff --git a/src/state/ageAssurance/types.ts b/src/state/ageAssurance/types.ts
deleted file mode 100644
index 63febb3cff..0000000000
--- a/src/state/ageAssurance/types.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import {type AppBskyUnspeccedDefs} from '@atproto/api'
-import {type QueryObserverBaseResult} from '@tanstack/react-query'
-
-export type AgeAssuranceContextType = {
- /**
- * Whether the age assurance state has been fetched from the server. If user
- * is not in a region that requires AA, or AA is otherwise disabled, this
- * will always be `true`.
- */
- isReady: boolean
- /**
- * The server-reported status of the user's age verification process.
- */
- status: AppBskyUnspeccedDefs.AgeAssuranceState['status']
- /**
- * The last time the age assurance state was attempted by the user.
- */
- lastInitiatedAt: AppBskyUnspeccedDefs.AgeAssuranceState['lastInitiatedAt']
- /**
- * Indicates the user is age restricted based on the requirements of their
- * region, and their server-provided age assurance status. Does not factor in
- * the user's declared age. If AA is otherise disabled, this will always be
- * `false`.
- */
- isAgeRestricted: boolean
-}
-
-export type AgeAssuranceAPIContextType = {
- /**
- * Refreshes the age assurance state by fetching it from the server.
- */
- refetch: QueryObserverBaseResult['refetch']
-}
diff --git a/src/state/ageAssurance/useAgeAssurance.ts b/src/state/ageAssurance/useAgeAssurance.ts
deleted file mode 100644
index 0613848687..0000000000
--- a/src/state/ageAssurance/useAgeAssurance.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import {useMemo} from 'react'
-
-import {useAgeAssuranceContext} from '#/state/ageAssurance'
-import {logger} from '#/state/ageAssurance/util'
-import {usePreferencesQuery} from '#/state/queries/preferences'
-
-type AgeAssurance = ReturnType & {
- /**
- * The age the user has declared in their preferences, if any.
- */
- declaredAge: number | undefined
- /**
- * Indicates whether the user has declared an age under 18.
- */
- isDeclaredUnderage: boolean
-}
-
-/**
- * Computed age information based on age assurance status and the user's
- * declared age. Use this instead of {@link useAgeAssuranceContext} to get a
- * more user-friendly interface.
- */
-export function useAgeAssurance(): AgeAssurance {
- const aa = useAgeAssuranceContext()
- const {isFetched: preferencesLoaded, data: preferences} =
- usePreferencesQuery()
- const declaredAge = preferences?.userAge
-
- return useMemo(() => {
- const isReady = aa.isReady && preferencesLoaded
- const isDeclaredUnderage =
- declaredAge !== undefined ? declaredAge < 18 : false
- const state: AgeAssurance = {
- isReady,
- status: aa.status,
- lastInitiatedAt: aa.lastInitiatedAt,
- isAgeRestricted: aa.isAgeRestricted,
- declaredAge,
- isDeclaredUnderage,
- }
- logger.debug(`state`, state)
- return state
- }, [aa, preferencesLoaded, declaredAge])
-}
diff --git a/src/state/ageAssurance/useInitAgeAssurance.ts b/src/state/ageAssurance/useInitAgeAssurance.ts
deleted file mode 100644
index b658afb893..0000000000
--- a/src/state/ageAssurance/useInitAgeAssurance.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import {
- type AppBskyUnspeccedDefs,
- type AppBskyUnspeccedInitAgeAssurance,
- AtpAgent,
-} from '@atproto/api'
-import {useMutation, useQueryClient} 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 {createAgeAssuranceQueryKey} from '#/state/ageAssurance'
-import {type DeviceLocation, useGeolocationStatus} from '#/state/geolocation'
-import {useAgent} from '#/state/session'
-
-let APPVIEW = PUBLIC_APPVIEW
-let APPVIEW_DID = PUBLIC_APPVIEW_DID
-
-/*
- * Uncomment if using the local dev-env
- */
-// if (__DEV__) {
-// APPVIEW = DEV_ENV_APPVIEW
-// /*
-// * IMPORTANT: you need to get this value from `http://localhost:2581`
-// * introspection endpoint and updated in `constants`, since it changes
-// * every time you run the dev-env.
-// */
-// APPVIEW_DID = ``
-// }
-
-/**
- * Creates an ISO country code string from the given geolocation data.
- * Examples: `GB` or `GB-ENG`
- */
-function createISOCountryCode(
- geolocation: Omit & {
- countryCode: string
- },
-): string {
- return geolocation.countryCode.toUpperCase()
-}
-
-export function useInitAgeAssurance() {
- const qc = useQueryClient()
- const agent = useAgent()
- const {status: geolocation} = useGeolocationStatus()
- return useMutation({
- async mutationFn(
- props: Omit,
- ) {
- 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: APPVIEW_DID,
- lxm: `app.bsky.unspecced.initAgeAssurance`,
- })
-
- 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.unspecced.initAgeAssurance({
- ...props,
- countryCode: createISOCountryCode({
- countryCode,
- regionCode,
- }),
- }),
- )
-
- qc.setQueryData(
- createAgeAssuranceQueryKey(agent.session?.did ?? 'never'),
- () => data,
- )
- },
- onError(e) {
- if (!isNetworkError(e)) {
- logger.error(`useInitAgeAssurance failed`, {
- safeMessage: e,
- })
- }
- },
- })
-}
diff --git a/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts b/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts
deleted file mode 100644
index 6e85edd0b8..0000000000
--- a/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import {useMemo} from 'react'
-
-import {useGeolocationStatus} from '#/state/geolocation'
-
-export function useIsAgeAssuranceEnabled() {
- const {status: geolocation} = useGeolocationStatus()
-
- return useMemo(() => {
- return !!geolocation?.isAgeRestrictedGeo
- }, [geolocation])
-}
diff --git a/src/state/birthDate.ts b/src/state/birthDate.ts
new file mode 100644
index 0000000000..2814a1be46
--- /dev/null
+++ b/src/state/birthDate.ts
@@ -0,0 +1,64 @@
+import {useMemo} from 'react'
+import {useMutation, useQueryClient} from '@tanstack/react-query'
+
+import {preferencesQueryKey} from '#/state/queries/preferences'
+import {useAgent, useSession} from '#/state/session'
+import {usePatchAgeAssuranceOtherRequiredData} from '#/ageAssurance'
+import {IS_DEV} from '#/env'
+import {account} from '#/storage'
+
+// 6s in dev, 48h in prod
+const BIRTHDATE_DELAY_HOURS = IS_DEV ? 0.001 : 48
+
+/**
+ * Stores the timestamp of the birthday update locally. This is used to
+ * debounce birthday updates globally.
+ *
+ * Use {@link useIsBirthDateUpdateAllowed} to check if an update is allowed.
+ */
+export function snoozeBirthDateUpdateAllowedForDid(did: string) {
+ account.set([did, 'birthDateLastUpdatedAt'], new Date().toISOString())
+}
+
+/**
+ * Returns whether a birthdate update is currently allowed, based on the
+ * last update timestamp stored locally.
+ */
+export function useIsBirthDateUpdateAllowed() {
+ const {currentAccount} = useSession()
+ return useMemo(() => {
+ if (!currentAccount) return false
+ const lastUpdated = account.get([
+ currentAccount.did,
+ 'birthDateLastUpdatedAt',
+ ])
+ if (!lastUpdated) return true
+ const lastUpdatedDate = new Date(lastUpdated)
+ const diffMs = Date.now() - lastUpdatedDate.getTime()
+ const diffHours = diffMs / (1000 * 60 * 60)
+ return diffHours >= BIRTHDATE_DELAY_HOURS
+ }, [currentAccount])
+}
+
+export function useBirthDateMutation() {
+ const queryClient = useQueryClient()
+ const agent = useAgent()
+ const patchOtherRequiredData = usePatchAgeAssuranceOtherRequiredData()
+
+ return useMutation({
+ mutationFn: async ({birthDate}: {birthDate: Date}) => {
+ const bday = birthDate.toISOString()
+ await agent.setPersonalDetails({birthDate: bday})
+ // triggers a refetch
+ await queryClient.invalidateQueries({
+ queryKey: preferencesQueryKey,
+ })
+ /**
+ * Also patch the age assurance other required data with the new
+ * birthdate, which may change the user's age assurance access level.
+ */
+ patchOtherRequiredData({birthdate: bday})
+ snoozeBirthDateUpdateAllowedForDid(agent.sessionManager.did!)
+ },
+ })
+}
diff --git a/src/state/geolocation/config.ts b/src/state/geolocation/config.ts
deleted file mode 100644
index 913b674cbb..0000000000
--- a/src/state/geolocation/config.ts
+++ /dev/null
@@ -1,141 +0,0 @@
-import {networkRetry} from '#/lib/async/retry'
-import {
- DEFAULT_GEOLOCATION_CONFIG,
- GEOLOCATION_CONFIG_URL,
-} from '#/state/geolocation/const'
-import {emitGeolocationConfigUpdate} from '#/state/geolocation/events'
-import {logger} from '#/state/geolocation/logger'
-import {BAPP_CONFIG_DEV_BYPASS_SECRET, IS_DEV} from '#/env'
-import {type Device, device} from '#/storage'
-
-async function getGeolocationConfig(
- url: string,
-): Promise {
- const res = await fetch(url, {
- headers: IS_DEV
- ? {
- 'x-dev-bypass-secret': BAPP_CONFIG_DEV_BYPASS_SECRET,
- }
- : undefined,
- })
-
- if (!res.ok) {
- throw new Error(`config: fetch failed ${res.status}`)
- }
-
- const json = await res.json()
-
- if (json.countryCode) {
- /**
- * Only construct known values here, ignore any extras.
- */
- const config: Device['geolocation'] = {
- countryCode: json.countryCode,
- regionCode: json.regionCode ?? undefined,
- ageRestrictedGeos: json.ageRestrictedGeos ?? [],
- ageBlockedGeos: json.ageBlockedGeos ?? [],
- }
- logger.debug(`config: success`)
- return config
- } else {
- return undefined
- }
-}
-
-/**
- * Local promise used within this file only.
- */
-let geolocationConfigResolution: Promise<{success: boolean}> | undefined
-
-/**
- * Begin the process of resolving geolocation config. This should be called
- * once at app start.
- *
- * THIS METHOD SHOULD NEVER THROW.
- *
- * This method is otherwise not used for any purpose. To ensure geolocation
- * config is resolved, use {@link ensureGeolocationConfigIsResolved}
- */
-export function beginResolveGeolocationConfig() {
- /**
- * Here for debug purposes. Uncomment to prevent hitting the remote geo service, and apply whatever data you require for testing.
- */
- // if (__DEV__) {
- // geolocationConfigResolution = new Promise(y => y({success: true}))
- // device.set(['deviceGeolocation'], undefined) // clears GPS data
- // device.set(['geolocation'], DEFAULT_GEOLOCATION_CONFIG) // clears bapp-config data
- // return
- // }
-
- geolocationConfigResolution = new Promise(async resolve => {
- let success = true
-
- try {
- // Try once, fail fast
- const config = await getGeolocationConfig(GEOLOCATION_CONFIG_URL)
- if (config) {
- device.set(['geolocation'], config)
- emitGeolocationConfigUpdate(config)
- } else {
- // endpoint should throw on all failures, this is insurance
- throw new Error(
- `geolocation config: nothing returned from initial request`,
- )
- }
- } catch (e: any) {
- success = false
-
- logger.debug(`config: failed initial request`, {
- safeMessage: e.message,
- })
-
- // set to default
- device.set(['geolocation'], DEFAULT_GEOLOCATION_CONFIG)
-
- // retry 3 times, but don't await, proceed with default
- networkRetry(3, () => getGeolocationConfig(GEOLOCATION_CONFIG_URL))
- .then(config => {
- if (config) {
- device.set(['geolocation'], config)
- emitGeolocationConfigUpdate(config)
- success = true
- } else {
- // endpoint should throw on all failures, this is insurance
- throw new Error(`config: nothing returned from retries`)
- }
- })
- .catch((e: any) => {
- // complete fail closed
- logger.debug(`config: failed retries`, {
- safeMessage: e.message,
- })
- })
- } finally {
- resolve({success})
- }
- })
-}
-
-/**
- * Ensure that geolocation config has been resolved, or at the very least attempted
- * once. Subsequent retries will not be captured by this `await`. Those will be
- * reported via {@link emitGeolocationConfigUpdate}.
- */
-export async function ensureGeolocationConfigIsResolved() {
- if (!geolocationConfigResolution) {
- throw new Error(`config: beginResolveGeolocationConfig not called yet`)
- }
-
- const cached = device.get(['geolocation'])
- if (cached) {
- logger.debug(`config: using cache`)
- } else {
- logger.debug(`config: no cache`)
- const {success} = await geolocationConfigResolution
- if (success) {
- logger.debug(`config: resolved`)
- } else {
- logger.info(`config: failed to resolve`)
- }
- }
-}
diff --git a/src/state/geolocation/const.ts b/src/state/geolocation/const.ts
deleted file mode 100644
index 789d001aa5..0000000000
--- a/src/state/geolocation/const.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import {type GeolocationStatus} from '#/state/geolocation/types'
-import {BAPP_CONFIG_DEV_URL, IS_DEV} from '#/env'
-import {type Device} from '#/storage'
-
-export const IPCC_URL = `https://bsky.app/ipcc`
-export const BAPP_CONFIG_URL_PROD = `https://ip.bsky.app/config`
-export const BAPP_CONFIG_URL = IS_DEV
- ? (BAPP_CONFIG_DEV_URL ?? BAPP_CONFIG_URL_PROD)
- : BAPP_CONFIG_URL_PROD
-export const GEOLOCATION_CONFIG_URL = BAPP_CONFIG_URL
-
-/**
- * Default geolocation config.
- */
-export const DEFAULT_GEOLOCATION_CONFIG: Device['geolocation'] = {
- countryCode: undefined,
- regionCode: undefined,
- ageRestrictedGeos: [],
- ageBlockedGeos: [],
-}
-
-/**
- * Default geolocation status.
- */
-export const DEFAULT_GEOLOCATION_STATUS: GeolocationStatus = {
- countryCode: undefined,
- regionCode: undefined,
- isAgeRestrictedGeo: false,
- isAgeBlockedGeo: false,
-}
diff --git a/src/state/geolocation/events.ts b/src/state/geolocation/events.ts
deleted file mode 100644
index 61433bb2a8..0000000000
--- a/src/state/geolocation/events.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import EventEmitter from 'eventemitter3'
-
-import {type Device} from '#/storage'
-
-const events = new EventEmitter()
-const EVENT = 'geolocation-config-updated'
-
-export const emitGeolocationConfigUpdate = (config: Device['geolocation']) => {
- events.emit(EVENT, config)
-}
-
-export const onGeolocationConfigUpdate = (
- listener: (config: Device['geolocation']) => void,
-) => {
- events.on(EVENT, listener)
- return () => {
- events.off(EVENT, listener)
- }
-}
diff --git a/src/state/geolocation/index.tsx b/src/state/geolocation/index.tsx
deleted file mode 100644
index 8bddb23fb6..0000000000
--- a/src/state/geolocation/index.tsx
+++ /dev/null
@@ -1,155 +0,0 @@
-import React from 'react'
-
-import {
- DEFAULT_GEOLOCATION_CONFIG,
- DEFAULT_GEOLOCATION_STATUS,
-} from '#/state/geolocation/const'
-import {onGeolocationConfigUpdate} from '#/state/geolocation/events'
-import {logger} from '#/state/geolocation/logger'
-import {
- type DeviceLocation,
- type GeolocationStatus,
-} from '#/state/geolocation/types'
-import {useSyncedDeviceGeolocation} from '#/state/geolocation/useSyncedDeviceGeolocation'
-import {
- computeGeolocationStatus,
- mergeGeolocation,
-} from '#/state/geolocation/util'
-import {type Device, device} from '#/storage'
-
-export * from '#/state/geolocation/config'
-export * from '#/state/geolocation/types'
-export * from '#/state/geolocation/util'
-
-type DeviceGeolocationContext = {
- deviceGeolocation: DeviceLocation | undefined
-}
-
-type DeviceGeolocationAPIContext = {
- setDeviceGeolocation(deviceGeolocation: DeviceLocation): void
-}
-
-type GeolocationConfigContext = {
- config: Device['geolocation']
-}
-
-type GeolocationStatusContext = {
- /**
- * Merged geolocation from config and device GPS (if available).
- */
- location: DeviceLocation
- /**
- * Computed geolocation status based on the merged location and config.
- */
- status: GeolocationStatus
-}
-
-const DeviceGeolocationContext = React.createContext({
- deviceGeolocation: undefined,
-})
-DeviceGeolocationContext.displayName = 'DeviceGeolocationContext'
-
-const DeviceGeolocationAPIContext =
- React.createContext({
- setDeviceGeolocation: () => {},
- })
-DeviceGeolocationAPIContext.displayName = 'DeviceGeolocationAPIContext'
-
-const GeolocationConfigContext = React.createContext({
- config: DEFAULT_GEOLOCATION_CONFIG,
-})
-GeolocationConfigContext.displayName = 'GeolocationConfigContext'
-
-const GeolocationStatusContext = React.createContext({
- location: {
- countryCode: undefined,
- regionCode: undefined,
- },
- status: DEFAULT_GEOLOCATION_STATUS,
-})
-GeolocationStatusContext.displayName = 'GeolocationStatusContext'
-
-/**
- * Provider of geolocation config and computed geolocation status.
- */
-export function GeolocationStatusProvider({
- children,
-}: {
- children: React.ReactNode
-}) {
- const {deviceGeolocation} = React.useContext(DeviceGeolocationContext)
- const [config, setConfig] = React.useState(() => {
- const initial = device.get(['geolocation']) || DEFAULT_GEOLOCATION_CONFIG
- return initial
- })
-
- React.useEffect(() => {
- return onGeolocationConfigUpdate(config => {
- setConfig(config!)
- })
- }, [])
-
- const configContext = React.useMemo(() => ({config}), [config])
- const statusContext = React.useMemo(() => {
- if (deviceGeolocation?.countryCode) {
- logger.debug('has device geolocation available')
- }
- const geolocation = mergeGeolocation(deviceGeolocation, config)
- const status = computeGeolocationStatus(geolocation, config)
- // ensure this remains debug and never leaves device
- logger.debug('result', {deviceGeolocation, geolocation, status, config})
- return {location: geolocation, status}
- }, [config, deviceGeolocation])
-
- return (
-
-
- {children}
-
-
- )
-}
-
-/**
- * Provider of providers. Provides device geolocation data to lower-level
- * `GeolocationStatusProvider`, and device geolocation APIs to children.
- */
-export function Provider({children}: {children: React.ReactNode}) {
- const [deviceGeolocation, setDeviceGeolocation] = useSyncedDeviceGeolocation()
-
- const handleSetDeviceGeolocation = React.useCallback(
- (location: DeviceLocation) => {
- logger.debug('setting device geolocation')
- setDeviceGeolocation({
- countryCode: location.countryCode ?? undefined,
- regionCode: location.regionCode ?? undefined,
- })
- },
- [setDeviceGeolocation],
- )
-
- return (
- ({setDeviceGeolocation: handleSetDeviceGeolocation}),
- [handleSetDeviceGeolocation],
- )}>
- ({deviceGeolocation}), [deviceGeolocation])}>
- {children}
-
-
- )
-}
-
-export function useDeviceGeolocationApi() {
- return React.useContext(DeviceGeolocationAPIContext)
-}
-
-export function useGeolocationConfig() {
- return React.useContext(GeolocationConfigContext)
-}
-
-export function useGeolocationStatus() {
- return React.useContext(GeolocationStatusContext)
-}
diff --git a/src/state/geolocation/types.ts b/src/state/geolocation/types.ts
deleted file mode 100644
index 174761649f..0000000000
--- a/src/state/geolocation/types.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export type DeviceLocation = {
- countryCode: string | undefined
- regionCode: string | undefined
-}
-
-export type GeolocationStatus = DeviceLocation & {
- isAgeRestrictedGeo: boolean
- isAgeBlockedGeo: boolean
-}
diff --git a/src/state/geolocation/useRequestDeviceLocation.ts b/src/state/geolocation/useRequestDeviceLocation.ts
deleted file mode 100644
index 64e05b056a..0000000000
--- a/src/state/geolocation/useRequestDeviceLocation.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import {useCallback} from 'react'
-import * as Location from 'expo-location'
-
-import {type DeviceLocation} from '#/state/geolocation/types'
-import {getDeviceGeolocation} from '#/state/geolocation/util'
-
-export {PermissionStatus} from 'expo-location'
-
-export function useRequestDeviceLocation(): () => Promise<
- | {
- granted: true
- location: DeviceLocation | undefined
- }
- | {
- granted: false
- status: {
- canAskAgain: boolean
- /**
- * Enum, use `PermissionStatus` export for comparisons
- */
- permissionStatus: Location.PermissionStatus
- }
- }
-> {
- return useCallback(async () => {
- const status = await Location.requestForegroundPermissionsAsync()
-
- if (status.granted) {
- return {
- granted: true,
- location: await getDeviceGeolocation(),
- }
- } else {
- return {
- granted: false,
- status: {
- canAskAgain: status.canAskAgain,
- permissionStatus: status.status,
- },
- }
- }
- }, [])
-}
diff --git a/src/state/geolocation/useSyncedDeviceGeolocation.ts b/src/state/geolocation/useSyncedDeviceGeolocation.ts
deleted file mode 100644
index fea6198d46..0000000000
--- a/src/state/geolocation/useSyncedDeviceGeolocation.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import {useEffect, useRef} from 'react'
-import * as Location from 'expo-location'
-import {createPermissionHook} from 'expo-modules-core'
-
-import {logger} from '#/state/geolocation/logger'
-import {getDeviceGeolocation} from '#/state/geolocation/util'
-import {device, useStorage} from '#/storage'
-
-/**
- * Location.useForegroundPermissions on web just errors if the navigator.permissions API is not available.
- * We need to catch and ignore it, since it's effectively denied.
- * @see https://github.com/expo/expo/blob/72f1562ed9cce5ff6dfe04aa415b71632a3d4b87/packages/expo-location/src/Location.ts#L290-L293
- */
-const useForegroundPermissions = createPermissionHook({
- getMethod: () =>
- Location.getForegroundPermissionsAsync().catch(error => {
- logger.debug(
- 'useForegroundPermission: error getting location permissions',
- {safeMessage: error},
- )
- return {
- status: Location.PermissionStatus.DENIED,
- granted: false,
- canAskAgain: false,
- expires: 0,
- }
- }),
- requestMethod: () =>
- Location.requestForegroundPermissionsAsync().catch(error => {
- logger.debug(
- 'useForegroundPermission: error requesting location permissions',
- {safeMessage: error},
- )
- return {
- status: Location.PermissionStatus.DENIED,
- granted: false,
- canAskAgain: false,
- expires: 0,
- }
- }),
-})
-
-/**
- * Hook to get and sync the device geolocation from the device GPS and store it
- * using device storage. If permissions are not granted, it will clear any cached
- * storage value.
- */
-export function useSyncedDeviceGeolocation() {
- const synced = useRef(false)
- const [status] = useForegroundPermissions()
- const [deviceGeolocation, setDeviceGeolocation] = useStorage(device, [
- 'deviceGeolocation',
- ])
-
- useEffect(() => {
- async function get() {
- // no need to set this more than once per session
- if (synced.current) return
-
- logger.debug('useSyncedDeviceGeolocation: checking perms')
-
- if (status?.granted) {
- const location = await getDeviceGeolocation()
- if (location) {
- logger.debug('useSyncedDeviceGeolocation: syncing location')
- setDeviceGeolocation(location)
- synced.current = true
- }
- } else {
- const hasCachedValue = device.get(['deviceGeolocation']) !== undefined
-
- /**
- * If we have a cached value, but user has revoked permissions,
- * quietly (will take effect lazily) clear this out.
- */
- if (hasCachedValue) {
- logger.debug(
- 'useSyncedDeviceGeolocation: clearing cached location, perms revoked',
- )
- device.set(['deviceGeolocation'], undefined)
- }
- }
- }
-
- get().catch(e => {
- logger.error('useSyncedDeviceGeolocation: failed to sync', {
- safeMessage: e,
- })
- })
- }, [status, setDeviceGeolocation])
-
- return [deviceGeolocation, setDeviceGeolocation] as const
-}
diff --git a/src/state/geolocation/util.ts b/src/state/geolocation/util.ts
deleted file mode 100644
index c92b42b133..0000000000
--- a/src/state/geolocation/util.ts
+++ /dev/null
@@ -1,180 +0,0 @@
-import {
- getCurrentPositionAsync,
- type LocationGeocodedAddress,
- reverseGeocodeAsync,
-} from 'expo-location'
-
-import {logger} from '#/state/geolocation/logger'
-import {type DeviceLocation} from '#/state/geolocation/types'
-import {type Device} from '#/storage'
-
-/**
- * Maps full US region names to their short codes.
- *
- * Context: in some cases, like on Android, we get the full region name instead
- * of the short code. We may need to expand this in the future to other
- * countries, hence the prefix.
- */
-export const USRegionNameToRegionCode: {
- [regionName: string]: string
-} = {
- Alabama: 'AL',
- Alaska: 'AK',
- Arizona: 'AZ',
- Arkansas: 'AR',
- California: 'CA',
- Colorado: 'CO',
- Connecticut: 'CT',
- Delaware: 'DE',
- Florida: 'FL',
- Georgia: 'GA',
- Hawaii: 'HI',
- Idaho: 'ID',
- Illinois: 'IL',
- Indiana: 'IN',
- Iowa: 'IA',
- Kansas: 'KS',
- Kentucky: 'KY',
- Louisiana: 'LA',
- Maine: 'ME',
- Maryland: 'MD',
- Massachusetts: 'MA',
- Michigan: 'MI',
- Minnesota: 'MN',
- Mississippi: 'MS',
- Missouri: 'MO',
- Montana: 'MT',
- Nebraska: 'NE',
- Nevada: 'NV',
- ['New Hampshire']: 'NH',
- ['New Jersey']: 'NJ',
- ['New Mexico']: 'NM',
- ['New York']: 'NY',
- ['North Carolina']: 'NC',
- ['North Dakota']: 'ND',
- Ohio: 'OH',
- Oklahoma: 'OK',
- Oregon: 'OR',
- Pennsylvania: 'PA',
- ['Rhode Island']: 'RI',
- ['South Carolina']: 'SC',
- ['South Dakota']: 'SD',
- Tennessee: 'TN',
- Texas: 'TX',
- Utah: 'UT',
- Vermont: 'VT',
- Virginia: 'VA',
- Washington: 'WA',
- ['West Virginia']: 'WV',
- Wisconsin: 'WI',
- Wyoming: 'WY',
-}
-
-/**
- * Normalizes a `LocationGeocodedAddress` into a `DeviceLocation`.
- *
- * We don't want or care about the full location data, so we trim it down and
- * normalize certain fields, like region, into the format we need.
- */
-export function normalizeDeviceLocation(
- location: LocationGeocodedAddress,
-): DeviceLocation {
- let {isoCountryCode, region} = location
-
- if (region) {
- if (isoCountryCode === 'US') {
- region = USRegionNameToRegionCode[region] ?? region
- }
- }
-
- return {
- countryCode: isoCountryCode ?? undefined,
- regionCode: region ?? undefined,
- }
-}
-
-/**
- * Combines precise location data with the geolocation config fetched from the
- * IP service, with preference to the precise data.
- */
-export function mergeGeolocation(
- location?: DeviceLocation,
- config?: Device['geolocation'],
-): DeviceLocation {
- if (location?.countryCode) return location
- return {
- countryCode: config?.countryCode,
- regionCode: config?.regionCode,
- }
-}
-
-/**
- * Computes the geolocation status (age-restricted, age-blocked) based on the
- * given location and geolocation config. `location` here should be merged with
- * `mergeGeolocation()` ahead of time if needed.
- */
-export function computeGeolocationStatus(
- location: DeviceLocation,
- config: Device['geolocation'],
-) {
- /**
- * We can't do anything if we don't have this data.
- */
- if (!location.countryCode) {
- return {
- ...location,
- isAgeRestrictedGeo: false,
- isAgeBlockedGeo: false,
- }
- }
-
- const isAgeRestrictedGeo = config?.ageRestrictedGeos?.some(rule => {
- if (rule.countryCode === location.countryCode) {
- if (!rule.regionCode) {
- return true // whole country is blocked
- } else if (rule.regionCode === location.regionCode) {
- return true
- }
- }
- })
-
- const isAgeBlockedGeo = config?.ageBlockedGeos?.some(rule => {
- if (rule.countryCode === location.countryCode) {
- if (!rule.regionCode) {
- return true // whole country is blocked
- } else if (rule.regionCode === location.regionCode) {
- return true
- }
- }
- })
-
- return {
- ...location,
- isAgeRestrictedGeo: !!isAgeRestrictedGeo,
- isAgeBlockedGeo: !!isAgeBlockedGeo,
- }
-}
-
-export async function getDeviceGeolocation(): Promise {
- try {
- const geocode = await getCurrentPositionAsync()
- const locations = await reverseGeocodeAsync({
- latitude: geocode.coords.latitude,
- longitude: geocode.coords.longitude,
- })
- const location = locations.at(0)
- const normalized = location ? normalizeDeviceLocation(location) : undefined
- return {
- countryCode: normalized?.countryCode ?? undefined,
- regionCode: normalized?.regionCode ?? undefined,
- }
- } catch (e) {
- logger.error('getDeviceGeolocation: failed', {
- safeMessage: e,
- })
- return {
- countryCode: undefined,
- regionCode: undefined,
- }
- }
-}
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index 3a1b4d24a5..0a3cfb6b47 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -31,7 +31,6 @@ import {aggregateUserInterests} from '#/lib/api/feed/utils'
import {FeedTuner, type FeedTunerFn} from '#/lib/api/feed-manip'
import {DISCOVER_FEED_URI} from '#/lib/constants'
import {logger} from '#/logger'
-import {useAgeAssuranceContext} from '#/state/ageAssurance'
import {STALE} from '#/state/queries'
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
import {useAgent} from '#/state/session'
@@ -141,12 +140,8 @@ export function usePostFeedQuery(
* available for the remainder of the session, so this delay only affects cold
* loads. -esb
*/
- const {isReady: isAgeAssuranceReady} = useAgeAssuranceContext()
const enabled =
- opts?.enabled !== false &&
- Boolean(moderationOpts) &&
- Boolean(preferences) &&
- isAgeAssuranceReady
+ opts?.enabled !== false && Boolean(moderationOpts) && Boolean(preferences)
const userInterests = aggregateUserInterests(preferences)
const followingPinnedIndex =
preferences?.savedFeeds?.findIndex(
diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts
index fd1d70d7de..b457e89e8a 100644
--- a/src/state/queries/preferences/index.ts
+++ b/src/state/queries/preferences/index.ts
@@ -10,8 +10,6 @@ import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {replaceEqualDeep} from '#/lib/functions'
import {getAge} from '#/lib/strings/time'
import {logger} from '#/logger'
-import {useAgeAssuranceContext} from '#/state/ageAssurance'
-import {makeAgeRestrictedModerationPrefs} from '#/state/ageAssurance/const'
import {STALE} from '#/state/queries'
import {
DEFAULT_HOME_FEED_PREFS,
@@ -24,6 +22,7 @@ import {
} from '#/state/queries/preferences/types'
import {useAgent} from '#/state/session'
import {saveLabelers} from '#/state/session/agent-config'
+import {useAgeAssurance} from '#/ageAssurance'
export * from '#/state/queries/preferences/const'
export * from '#/state/queries/preferences/moderation'
@@ -34,7 +33,7 @@ export const preferencesQueryKey = [preferencesQueryKeyRoot]
export function usePreferencesQuery() {
const agent = useAgent()
- const {isAgeRestricted} = useAgeAssuranceContext()
+ const aa = useAgeAssurance()
return useQuery({
staleTime: STALE.SECONDS.FIFTEEN,
@@ -75,18 +74,15 @@ export function usePreferencesQuery() {
},
select: useCallback(
(data: UsePreferencesQueryResponse) => {
- const isUnderage = (data.userAge || 0) < 18
- if (isUnderage || isAgeRestricted) {
+ if (aa.state.access !== aa.Access.Full) {
data = {
...data,
- moderationPrefs: makeAgeRestrictedModerationPrefs(
- data.moderationPrefs,
- ),
+ moderationPrefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
}
}
return data
},
- [isAgeRestricted],
+ [aa],
),
})
}
@@ -168,21 +164,6 @@ export function usePreferencesSetAdultContentMutation() {
})
}
-export function usePreferencesSetBirthDateMutation() {
- const queryClient = useQueryClient()
- const agent = useAgent()
-
- return useMutation({
- mutationFn: async ({birthDate}: {birthDate: Date}) => {
- await agent.setPersonalDetails({birthDate: birthDate.toISOString()})
- // triggers a refetch
- await queryClient.invalidateQueries({
- queryKey: preferencesQueryKey,
- })
- },
- })
-}
-
export function useSetFeedViewPreferencesMutation() {
const queryClient = useQueryClient()
const agent = useAgent()
diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts
index 36d19299b9..c391171542 100644
--- a/src/state/session/agent.ts
+++ b/src/state/session/agent.ts
@@ -1,10 +1,12 @@
import {
Agent as BaseAgent,
+ type AppBskyActorProfile,
type AtprotoServiceType,
type AtpSessionData,
type AtpSessionEvent,
BskyAgent,
type Did,
+ type Un$Typed,
} from '@atproto/api'
import {type FetchHandler} from '@atproto/api/dist/agent'
import {type SessionManager} from '@atproto/api/dist/session-manager'
@@ -23,7 +25,13 @@ import {
import {tryFetchGates} from '#/lib/statsig/statsig'
import {getAge} from '#/lib/strings/time'
import {logger} from '#/logger'
+import {snoozeBirthDateUpdateAllowedForDid} from '#/state/birthDate'
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
+import {
+ prefetchAgeAssuranceData,
+ setBirthdateForDid,
+ setCreatedAtForDid,
+} from '#/ageAssurance/data'
import {emitNetworkConfirmed, emitNetworkLost} from '../events'
import {addSessionErrorLog} from './logging'
import {
@@ -77,9 +85,15 @@ export async function createAgentAndResume(
}
}
+ // after session is attached
+ const aa = prefetchAgeAssuranceData({agent})
+
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
- return agent.prepare(gates, moderation, onSessionChange)
+ return agent.prepare({
+ resolvers: [gates, moderation, aa],
+ onSessionChange,
+ })
}
export async function createAgentAndLogin(
@@ -111,10 +125,14 @@ export async function createAgentAndLogin(
const account = agentToSessionAccountOrThrow(agent)
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
const moderation = configureModerationForAccount(agent, account)
+ const aa = prefetchAgeAssuranceData({agent})
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
- return agent.prepare(gates, moderation, onSessionChange)
+ return agent.prepare({
+ resolvers: [gates, moderation, aa],
+ onSessionChange,
+ })
}
export async function createAgentAndCreateAccount(
@@ -156,42 +174,122 @@ export async function createAgentAndCreateAccount(
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
const moderation = configureModerationForAccount(agent, account)
+ const createdAt = new Date().toISOString()
+ const birthdate = birthDate.toISOString()
+
+ /*
+ * Since we have a race with account creation, profile creation, and AA
+ * state, set these values locally to ensure sync reads. Values are written
+ * to the server in the next step, so on subsequent reloads, the server will
+ * be the source of truth.
+ */
+ setCreatedAtForDid({did: account.did, createdAt})
+ setBirthdateForDid({did: account.did, birthdate})
+ snoozeBirthDateUpdateAllowedForDid(account.did)
+ // do this last
+ const aa = prefetchAgeAssuranceData({agent})
+
// Not awaited so that we can still get into onboarding.
// This is OK because we won't let you toggle adult stuff until you set the date.
if (IS_PROD_SERVICE(service)) {
- try {
- networkRetry(1, async () => {
- await agent.setPersonalDetails({birthDate: birthDate.toISOString()})
- await agent.overwriteSavedFeeds([
- {
- ...DISCOVER_SAVED_FEED,
- id: TID.nextStr(),
- },
- {
- ...TIMELINE_SAVED_FEED,
- id: TID.nextStr(),
- },
- ])
-
- if (getAge(birthDate) < 18) {
- await agent.api.com.atproto.repo.putRecord({
- repo: account.did,
- collection: 'chat.bsky.actor.declaration',
- rkey: 'self',
- record: {
- $type: 'chat.bsky.actor.declaration',
- allowIncoming: 'none',
- },
+ Promise.allSettled(
+ [
+ networkRetry(3, () => {
+ return agent.setPersonalDetails({
+ birthDate: birthdate,
})
- }
- })
- } catch (e: any) {
- logger.error(e, {
- message: `session: createAgentAndCreateAccount failed to save personal details and feeds`,
- })
- }
+ }).catch(e => {
+ logger.info(`createAgentAndCreateAccount: failed to set birthDate`)
+ throw e
+ }),
+ networkRetry(3, () => {
+ return agent.upsertProfile(prev => {
+ const next: Un$Typed = prev || {}
+ next.displayName = handle
+ next.createdAt = createdAt
+ return next
+ })
+ }).catch(e => {
+ logger.info(
+ `createAgentAndCreateAccount: failed to set initial profile`,
+ )
+ throw e
+ }),
+ networkRetry(1, () => {
+ return agent.overwriteSavedFeeds([
+ {
+ ...DISCOVER_SAVED_FEED,
+ id: TID.nextStr(),
+ },
+ {
+ ...TIMELINE_SAVED_FEED,
+ id: TID.nextStr(),
+ },
+ ])
+ }).catch(e => {
+ logger.info(
+ `createAgentAndCreateAccount: failed to set initial feeds`,
+ )
+ throw e
+ }),
+ getAge(birthDate) < 18 &&
+ networkRetry(3, () => {
+ return agent.com.atproto.repo.putRecord({
+ repo: account.did,
+ collection: 'chat.bsky.actor.declaration',
+ rkey: 'self',
+ record: {
+ $type: 'chat.bsky.actor.declaration',
+ allowIncoming: 'none',
+ },
+ })
+ }).catch(e => {
+ logger.info(
+ `createAgentAndCreateAccount: failed to set chat declaration`,
+ )
+ throw e
+ }),
+ ].filter(Boolean),
+ ).then(promises => {
+ const rejected = promises.filter(p => p.status === 'rejected')
+ if (rejected.length > 0) {
+ logger.error(
+ `session: createAgentAndCreateAccount failed to save personal details and feeds`,
+ )
+ }
+ })
} else {
- agent.setPersonalDetails({birthDate: birthDate.toISOString()})
+ Promise.allSettled(
+ [
+ networkRetry(3, () => {
+ return agent.setPersonalDetails({
+ birthDate: birthDate.toISOString(),
+ })
+ }).catch(e => {
+ logger.info(`createAgentAndCreateAccount: failed to set birthDate`)
+ throw e
+ }),
+ networkRetry(3, () => {
+ return agent.upsertProfile(prev => {
+ const next: Un$Typed = prev || {}
+ next.createdAt = prev?.createdAt || new Date().toISOString()
+ return next
+ })
+ }).catch(e => {
+ logger.info(
+ `createAgentAndCreateAccount: failed to set initial profile`,
+ )
+ throw e
+ }),
+ ].filter(Boolean),
+ ).then(promises => {
+ const rejected = promises.filter(p => p.status === 'rejected')
+ if (rejected.length > 0) {
+ logger.error(
+ `session: createAgentAndCreateAccount failed to save personal details and feeds`,
+ )
+ }
+ })
}
try {
@@ -203,7 +301,10 @@ export async function createAgentAndCreateAccount(
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
- return agent.prepare(gates, moderation, onSessionChange)
+ return agent.prepare({
+ resolvers: [gates, moderation, aa],
+ onSessionChange,
+ })
}
export function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount {
@@ -306,18 +407,20 @@ class BskyAppAgent extends BskyAgent {
})
}
- async prepare(
+ async prepare({
+ resolvers,
+ onSessionChange,
+ }: {
// Not awaited in the calling code so we can delay blocking on them.
- gates: Promise,
- moderation: Promise,
+ resolvers: Promise[]
onSessionChange: (
agent: BskyAgent,
did: string,
event: AtpSessionEvent,
- ) => void,
- ) {
+ ) => void
+ }) {
// There's nothing else left to do, so block on them here.
- await Promise.all([gates, moderation])
+ await Promise.all(resolvers)
// Now the agent is ready.
const account = agentToSessionAccountOrThrow(this)
diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx
index 71c9fbb7a3..b5a6c7f6b7 100644
--- a/src/state/session/index.tsx
+++ b/src/state/session/index.tsx
@@ -24,6 +24,11 @@ import {
type SessionApiContext,
type SessionStateContext,
} from '#/state/session/types'
+import {useOnboardingDispatch} from '#/state/shell/onboarding'
+import {
+ clearAgeAssuranceData,
+ clearAgeAssuranceDataForDid,
+} from '#/ageAssurance/data'
const StateContext = React.createContext({
accounts: [],
@@ -91,6 +96,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const cancelPendingTask = useOneTaskAtATime()
const [store] = React.useState(() => new SessionStore())
const state = React.useSyncExternalStore(store.subscribe, store.getState)
+ const onboardingDispatch = useOnboardingDispatch()
const onAgentSessionChange = React.useCallback(
(agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => {
@@ -166,6 +172,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
logContext => {
addSessionDebugLog({type: 'method:start', method: 'logout'})
cancelPendingTask()
+ const prevState = store.getState()
store.dispatch({
type: 'logged-out-current-account',
})
@@ -175,8 +182,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
{statsig: true},
)
addSessionDebugLog({type: 'method:end', method: 'logout'})
+ if (prevState.currentAgentState.did) {
+ clearAgeAssuranceDataForDid({did: prevState.currentAgentState.did})
+ }
+ onboardingDispatch({type: 'skip'})
},
- [store, cancelPendingTask],
+ [store, cancelPendingTask, onboardingDispatch],
)
const logoutEveryAccount = React.useCallback<
@@ -194,8 +205,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
{statsig: true},
)
addSessionDebugLog({type: 'method:end', method: 'logout'})
+ clearAgeAssuranceData()
+ onboardingDispatch({type: 'skip'})
},
- [store, cancelPendingTask],
+ [store, cancelPendingTask, onboardingDispatch],
)
const resumeSession = React.useCallback(
@@ -220,8 +233,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
newAccount: account,
})
addSessionDebugLog({type: 'method:end', method: 'resumeSession', account})
+ onboardingDispatch({type: 'skip'})
},
- [store, onAgentSessionChange, cancelPendingTask],
+ [store, onAgentSessionChange, cancelPendingTask, onboardingDispatch],
)
const partialRefreshSession = React.useCallback<
@@ -254,6 +268,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
accountDid: account.did,
})
addSessionDebugLog({type: 'method:end', method: 'removeAccount', account})
+ clearAgeAssuranceDataForDid({did: account.did})
},
[store, cancelPendingTask],
)
diff --git a/src/state/shell/index.tsx b/src/state/shell/index.tsx
index 809a521bf7..4615514909 100644
--- a/src/state/shell/index.tsx
+++ b/src/state/shell/index.tsx
@@ -2,7 +2,6 @@ import {Provider as ColorModeProvider} from './color-mode'
import {Provider as DrawerOpenProvider} from './drawer-open'
import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled'
import {Provider as MinimalModeProvider} from './minimal-mode'
-import {Provider as OnboardingProvider} from './onboarding'
import {Provider as ShellLayoutProvder} from './shell-layout'
import {Provider as TickEveryMinuteProvider} from './tick-every-minute'
@@ -23,9 +22,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
-
- {children}
-
+ {children}
diff --git a/src/storage/schema.ts b/src/storage/schema.ts
index 02923436a5..ac3c1054d7 100644
--- a/src/storage/schema.ts
+++ b/src/storage/schema.ts
@@ -1,4 +1,5 @@
import {type ID as PolicyUpdate202508} from '#/components/PolicyUpdateOverlay/updates/202508/config'
+import {type Geolocation} from '#/geolocation/types'
/**
* Device data that's specific to the device and does not vary based account
@@ -25,13 +26,21 @@ export type Device = {
regionCode: string | undefined
}[]
}
+
+ /**
+ * The raw response from the geolocation service, if available. We
+ * cache this here and update it lazily on session start.
+ */
+ geolocationServiceResponse?: Geolocation
/**
* The GPS-based geolocation, if the user has granted permission.
*/
- deviceGeolocation?: {
- countryCode: string | undefined
- regionCode: string | undefined
- }
+ deviceGeolocation?: Geolocation
+ /**
+ * The merged geolocation, combining `geolocationServiceResponse` and
+ * `deviceGeolocation`, with preference to `deviceGeolocation`.
+ */
+ mergedGeolocation?: Geolocation
trendingBetaEnabled: boolean
devMode: boolean
@@ -49,4 +58,9 @@ export type Device = {
export type Account = {
searchTermHistory?: string[]
searchAccountHistory?: string[]
+
+ /**
+ * The ISO string of the user's birthday as last set locally.
+ */
+ birthDateLastUpdatedAt?: string
}
diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx
index f7eee5fee9..e8c972c667 100644
--- a/src/view/screens/Storybook/index.tsx
+++ b/src/view/screens/Storybook/index.tsx
@@ -3,12 +3,15 @@ import {View} from 'react-native'
import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
-import {Sentry} from '#/logger/sentry/lib'
import {useSetThemePrefs} from '#/state/shell'
import {ListContained} from '#/view/screens/Storybook/ListContained'
import {atoms as a, ThemeProvider} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Layout from '#/components/Layout'
+import {
+ useDeviceGeolocationApi,
+ useRequestDeviceGeolocation,
+} from '#/geolocation'
import {Admonitions} from './Admonitions'
import {Breakpoints} from './Breakpoints'
import {Buttons} from './Buttons'
@@ -45,6 +48,8 @@ function StorybookInner() {
const {setColorMode, setDarkTheme} = useSetThemePrefs()
const [showContainedList, setShowContainedList] = React.useState(false)
const navigation = useNavigation()
+ const requestDeviceGeolocation = useRequestDeviceGeolocation()
+ const {setDeviceGeolocation} = useDeviceGeolocationApi()
return (
<>
@@ -97,11 +102,17 @@ function StorybookInner() {
Open Shared Prefs Tester
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 5075f05cb5..c12141adb2 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -13,7 +13,6 @@ import {useNotificationsRegistration} from '#/lib/notifications/notifications'
import {isStateAtTabRoot} from '#/lib/routes/helpers'
import {isAndroid, isIOS} from '#/platform/detection'
import {useDialogFullyExpandedCountContext} from '#/state/dialogs'
-import {useGeolocationStatus} from '#/state/geolocation'
import {useSession} from '#/state/session'
import {
useIsDrawerOpen,
@@ -27,7 +26,6 @@ import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {atoms as a, select, useTheme} from '#/alf'
import {setSystemUITheme} from '#/alf/util/systemUI'
import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
-import {BlockedGeoOverlay} from '#/components/BlockedGeoOverlay'
import {EmailDialog} from '#/components/dialogs/EmailDialog'
import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent'
import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
@@ -38,6 +36,9 @@ import {
usePolicyUpdateContext,
} from '#/components/PolicyUpdateOverlay'
import {Outlet as PortalOutlet} from '#/components/Portal'
+import {useAgeAssurance} from '#/ageAssurance'
+import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen'
+import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay'
import {RoutesContainer, TabsNavigator} from '#/Navigation'
import {BottomSheetOutlet} from '../../../modules/bottom-sheet'
import {updateActiveViewAsync} from '../../../modules/expo-bluesky-swiss-army/src/VisibilityView'
@@ -193,7 +194,7 @@ function DrawerLayout({children}: {children: React.ReactNode}) {
export function Shell() {
const t = useTheme()
- const {status: geolocation} = useGeolocationStatus()
+ const aa = useAgeAssurance()
const fullyExpandedCount = useDialogFullyExpandedCountContext()
useIntentHandler()
@@ -213,13 +214,15 @@ export function Shell() {
navigationBar: t.name !== 'light' ? 'light' : 'dark',
}}
/>
- {geolocation?.isAgeBlockedGeo ? (
-
+ {aa.state.access === aa.Access.None ? (
+
) : (
)}
+
+
)
}
diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx
index 4b8b47acd7..354432f076 100644
--- a/src/view/shell/index.web.tsx
+++ b/src/view/shell/index.web.tsx
@@ -8,7 +8,6 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar'
import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {type NavigationProp} from '#/lib/routes/types'
-import {useGeolocationStatus} from '#/state/geolocation'
import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
import {useCloseAllActiveElements} from '#/state/util'
@@ -17,7 +16,6 @@ import {ModalsContainer} from '#/view/com/modals/Modal'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {atoms as a, select, useTheme} from '#/alf'
import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
-import {BlockedGeoOverlay} from '#/components/BlockedGeoOverlay'
import {EmailDialog} from '#/components/dialogs/EmailDialog'
import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
@@ -29,6 +27,9 @@ import {
} from '#/components/PolicyUpdateOverlay'
import {Outlet as PortalOutlet} from '#/components/Portal'
import {WelcomeModal} from '#/components/WelcomeModal'
+import {useAgeAssurance} from '#/ageAssurance'
+import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen'
+import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay'
import {FlatNavigator, RoutesContainer} from '#/Navigation'
import {Composer} from './Composer.web'
import {DrawerContent} from './Drawer'
@@ -60,7 +61,6 @@ function ShellInner() {
}, [showDrawer, showDrawerDelayedExit])
useComposerKeyboardShortcut()
- useIntentHandler()
useEffect(() => {
const unsubscribe = navigator.addListener('state', () => {
@@ -139,16 +139,19 @@ function ShellInner() {
export function Shell() {
const t = useTheme()
- const {status: geolocation} = useGeolocationStatus()
+ const aa = useAgeAssurance()
+ useIntentHandler()
return (
- {geolocation?.isAgeBlockedGeo ? (
-
+ {aa.state.access === aa.Access.None ? (
+
) : (
)}
+
+
)
}
diff --git a/web/index.html b/web/index.html
index 2077530fe0..5458b57a36 100644
--- a/web/index.html
+++ b/web/index.html
@@ -73,11 +73,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
@@ -146,7 +154,7 @@