diff --git a/.env.example b/.env.example
index 4aefa33202..ac8dcab1f8 100644
--- a/.env.example
+++ b/.env.example
@@ -33,3 +33,9 @@ EXPO_PUBLIC_SENTRY_DSN=
# Bitdrift API key. If undefined, Bitdrift will be disabled.
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/app.config.js b/app.config.js
index 8a0e6e48c1..0b469a986b 100644
--- a/app.config.js
+++ b/app.config.js
@@ -360,6 +360,7 @@ module.exports = function (_config) {
},
],
['expo-screen-orientation', {initialOrientation: 'PORTRAIT_UP'}],
+ ['expo-location'],
].filter(Boolean),
extra: {
eas: {
diff --git a/assets/icons/pinLocationFilled_stroke2_corner0_rounded.svg b/assets/icons/pinLocationFilled_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..becc18b128
--- /dev/null
+++ b/assets/icons/pinLocationFilled_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/pinLocation_stroke2_corner0_rounded.svg b/assets/icons/pinLocation_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..11674c912d
--- /dev/null
+++ b/assets/icons/pinLocation_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/package.json b/package.json
index 323fdce78d..9e9c6d7c5e 100644
--- a/package.json
+++ b/package.json
@@ -151,6 +151,7 @@
"expo-linear-gradient": "~14.1.5",
"expo-linking": "~7.1.5",
"expo-localization": "~16.1.5",
+ "expo-location": "~18.1.6",
"expo-media-library": "~17.1.7",
"expo-notifications": "~0.31.3",
"expo-screen-orientation": "~8.1.7",
diff --git a/src/App.native.tsx b/src/App.native.tsx
index e22ab3f0e2..036ecff60e 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -32,8 +32,8 @@ import {Provider as DialogStateProvider} from '#/state/dialogs'
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
import {listenSessionDropped} from '#/state/events'
import {
- beginResolveGeolocation,
- ensureGeolocationResolved,
+ beginResolveGeolocationConfig,
+ ensureGeolocationConfigIsResolved,
Provider as GeolocationProvider,
} from '#/state/geolocation'
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
@@ -91,7 +91,7 @@ if (isAndroid) {
/**
* Begin geolocation ASAP
*/
-beginResolveGeolocation()
+beginResolveGeolocationConfig()
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
@@ -203,9 +203,10 @@ function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
- Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(() =>
- setReady(true),
- )
+ Promise.all([
+ initPersistedState(),
+ ensureGeolocationConfigIsResolved(),
+ ]).then(() => setReady(true))
}, [])
if (!isReady) {
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 17434bfcdc..c86960172a 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -21,8 +21,8 @@ import {Provider as DialogStateProvider} from '#/state/dialogs'
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
import {listenSessionDropped} from '#/state/events'
import {
- beginResolveGeolocation,
- ensureGeolocationResolved,
+ beginResolveGeolocationConfig,
+ ensureGeolocationConfigIsResolved,
Provider as GeolocationProvider,
} from '#/state/geolocation'
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
@@ -69,7 +69,7 @@ import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottom
/**
* Begin geolocation ASAP
*/
-beginResolveGeolocation()
+beginResolveGeolocationConfig()
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
@@ -178,9 +178,10 @@ function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
- Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(() =>
- setReady(true),
- )
+ Promise.all([
+ initPersistedState(),
+ ensureGeolocationConfigIsResolved(),
+ ]).then(() => setReady(true))
}, [])
if (!isReady) {
diff --git a/src/components/BlockedGeoOverlay.tsx b/src/components/BlockedGeoOverlay.tsx
index ae5790da92..df8ed63d4d 100644
--- a/src/components/BlockedGeoOverlay.tsx
+++ b/src/components/BlockedGeoOverlay.tsx
@@ -6,16 +6,27 @@ 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
@@ -51,59 +62,133 @@ export function BlockedGeoOverlay() {
]
return (
-
-
+
-
-
-
-
+
+
- Announcement
-
+
+
+ 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 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',
+ })
+ })
+ }
+ }}
+ />
+ >
+ )}
+
+
+
+
-
- {blocks.map((block, index) => (
-
- {block}
-
- ))}
-
-
-
-
-
+ {/*
+ * 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/ageAssurance/AgeAssuranceAccountCard.tsx b/src/components/ageAssurance/AgeAssuranceAccountCard.tsx
index a00a8c71ae..be9935d9f1 100644
--- a/src/components/ageAssurance/AgeAssuranceAccountCard.tsx
+++ b/src/components/ageAssurance/AgeAssuranceAccountCard.tsx
@@ -3,8 +3,10 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
+import {isNative} from '#/platform/detection'
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
import {logger} from '#/state/ageAssurance/util'
+import {useDeviceGeolocationApi} from '#/state/geolocation'
import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
@@ -16,8 +18,10 @@ import {
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
+import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog'
import {Divider} from '#/components/Divider'
import {createStaticClick, InlineLinkText} from '#/components/Link'
+import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) {
@@ -35,8 +39,10 @@ function Inner({style}: ViewStyleProp & {}) {
const {_, i18n} = useLingui()
const control = useDialogControl()
const appealControl = Dialog.useDialogControl()
+ const locationControl = Dialog.useDialogControl()
const getTimeAgo = useGetTimeAgo()
const {gtPhone} = useBreakpoints()
+ const {setDeviceGeolocation} = useDeviceGeolocationApi()
const copy = useAgeAssuranceCopy()
const {status, lastInitiatedAt} = useAgeAssurance()
@@ -71,8 +77,50 @@ function Inner({style}: ViewStyleProp & {}) {
-
+
{copy.notice}
+
+ {isNative && (
+ <>
+
+
+ Is your location not accurate?{' '}
+ {
+ locationControl.open()
+ })}>
+ Click here to confirm your location.
+ {' '}
+
+
+
+ {
+ if (props.geolocationStatus.isAgeRestrictedGeo) {
+ 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({
+ countryCode: props.geolocationStatus.countryCode,
+ regionCode: props.geolocationStatus.regionCode,
+ })
+ Toast.show(_(msg`Thanks! You're all set.`), {
+ type: 'success',
+ })
+ })
+ }
+ }}
+ />
+ >
+ )}
{isBlocked ? (
diff --git a/src/components/dialogs/DeviceLocationRequestDialog.tsx b/src/components/dialogs/DeviceLocationRequestDialog.tsx
new file mode 100644
index 0000000000..6c82dc0e95
--- /dev/null
+++ b/src/components/dialogs/DeviceLocationRequestDialog.tsx
@@ -0,0 +1,171 @@
+import {useState} from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+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'
+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'
+
+export type Props = {
+ onLocationAcquired?: (props: {
+ geolocationStatus: GeolocationStatus
+ setDialogError: (error: string) => void
+ disableDialogAction: () => void
+ closeDialog: (callback?: () => void) => void
+ }) => void
+}
+
+export function DeviceLocationRequestDialog({
+ control,
+ onLocationAcquired,
+}: Props & {
+ control: Dialog.DialogOuterProps['control']
+}) {
+ const {_} = useLingui()
+ return (
+
+
+
+
+
+
+
+
+ )
+}
+
+function DeviceLocationRequestDialogInner({onLocationAcquired}: Props) {
+ const t = useTheme()
+ const {_} = useLingui()
+ const {close} = Dialog.useDialogContext()
+ const requestDeviceLocation = useRequestDeviceLocation()
+ const {config} = useGeolocationConfig()
+ const cleanError = useCleanError()
+
+ const [isRequesting, setIsRequesting] = useState(false)
+ const [error, setError] = useState('')
+ const [dialogDisabled, setDialogDisabled] = useState(false)
+
+ const onPressConfirm = async () => {
+ setError('')
+ setIsRequesting(true)
+
+ try {
+ const req = await wait(1e3, requestDeviceLocation())
+
+ if (req.granted) {
+ const location = req.location
+
+ if (location && location.countryCode) {
+ const geolocationStatus = computeGeolocationStatus(location, config)
+ onLocationAcquired?.({
+ geolocationStatus,
+ setDialogError: setError,
+ disableDialogAction: () => setDialogDisabled(true),
+ closeDialog: close,
+ })
+ } else {
+ setError(_(msg`Failed to resolve location. Please try again.`))
+ }
+ } else {
+ setError(
+ _(
+ msg`Unable to access location. You'll need to visit your system settings to enable location services for Bluesky`,
+ ),
+ )
+ }
+ } catch (e: any) {
+ const {clean, raw} = cleanError(e)
+ setError(clean || raw || e.message)
+ if (!isNetworkError(e)) {
+ logger.error(`blockedGeoOverlay: unexpected error`, {
+ safeMessage: e.message,
+ })
+ }
+ } finally {
+ setIsRequesting(false)
+ }
+ }
+
+ return (
+
+
+ Confirm your location
+
+
+
+
+ Click below to allow Bluesky to access your GPS location. We will
+ then use that data to more accurately determine the content and
+ features available in your region.
+
+
+
+
+
+ Your location data is not tracked and does not leave your device.
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {!dialogDisabled && (
+
+ )}
+
+ {!isWeb && (
+
+ )}
+
+
+ )
+}
diff --git a/src/components/icons/PinLocation.tsx b/src/components/icons/PinLocation.tsx
new file mode 100644
index 0000000000..9673995d77
--- /dev/null
+++ b/src/components/icons/PinLocation.tsx
@@ -0,0 +1,9 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const PinLocation_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M12 2a8 8 0 0 1 8 8c0 3.305-1.953 6.29-3.745 8.355a25.964 25.964 0 0 1-3.333 3.197c-.101.08-.181.142-.237.184l-.067.05-.018.014-.005.004-.002.002h-.001c-.003-.004-.042-.055-.592-.806l.592.807a1.001 1.001 0 0 1-1.184 0v-.001l-.003-.002-.005-.004-.018-.014-.067-.05a23.449 23.449 0 0 1-1.066-.877 25.973 25.973 0 0 1-2.504-2.503C5.953 16.29 4 13.305 4 10a8 8 0 0 1 8-8Zm0 2a6 6 0 0 0-6 6c0 2.56 1.547 5.076 3.255 7.044A23.978 23.978 0 0 0 12 19.723a23.976 23.976 0 0 0 2.745-2.679C16.453 15.076 18 12.56 18 10a6 6 0 0 0-6-6Zm-.002 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7Zm0 2a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z',
+})
+
+export const PinLocationFilled_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M12.591 21.806h.002l.001-.002.006-.004.018-.014a10.028 10.028 0 0 0 .304-.235 25.952 25.952 0 0 0 3.333-3.196C18.048 16.29 20 13.305 20 10a8 8 0 1 0-16 0c0 3.305 1.952 6.29 3.745 8.355a25.955 25.955 0 0 0 3.333 3.196 15.733 15.733 0 0 0 .304.235l.018.014.006.004.002.002a1 1 0 0 0 1.183 0Zm-.593-9.306a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z',
+})
diff --git a/src/env/common.ts b/src/env/common.ts
index 7b64c35a64..210ad037c8 100644
--- a/src/env/common.ts
+++ b/src/env/common.ts
@@ -93,3 +93,16 @@ export const GCP_PROJECT_ID: number =
process.env.EXPO_PUBLIC_GCP_PROJECT_ID === undefined
? 0
: Number(process.env.EXPO_PUBLIC_GCP_PROJECT_ID)
+
+/**
+ * URL for the bapp-config web worker _development_ environment. 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
diff --git a/src/lib/currency.ts b/src/lib/currency.ts
index c43078a247..cc5a9a7b03 100644
--- a/src/lib/currency.ts
+++ b/src/lib/currency.ts
@@ -1,7 +1,7 @@
import React from 'react'
import {deviceLocales} from '#/locale/deviceLocales'
-import {useGeolocation} from '#/state/geolocation'
+import {useGeolocationStatus} from '#/state/geolocation'
import {useLanguagePrefs} from '#/state/preferences'
/**
@@ -275,7 +275,7 @@ export const countryCodeToCurrency: Record = {
export function useFormatCurrency(
options?: Parameters[1],
) {
- const {geolocation} = useGeolocation()
+ const {location: geolocation} = useGeolocationStatus()
const {appLanguage} = useLanguagePrefs()
return React.useMemo(() => {
const locale = deviceLocales.at(0)
diff --git a/src/logger/types.ts b/src/logger/types.ts
index ee3069a083..19e12c5045 100644
--- a/src/logger/types.ts
+++ b/src/logger/types.ts
@@ -14,6 +14,7 @@ export enum LogContext {
PostSource = 'post-source',
AgeAssurance = 'age-assurance',
PolicyUpdate = 'policy-update',
+ Geolocation = 'geolocation',
/**
* METRIC IS FOR INTERNAL USE ONLY, don't create any other loggers using this
diff --git a/src/state/ageAssurance/index.tsx b/src/state/ageAssurance/index.tsx
index 23133046a5..e85672b7c8 100644
--- a/src/state/ageAssurance/index.tsx
+++ b/src/state/ageAssurance/index.tsx
@@ -11,7 +11,7 @@ import {
} from '#/state/ageAssurance/types'
import {useIsAgeAssuranceEnabled} from '#/state/ageAssurance/useIsAgeAssuranceEnabled'
import {logger} from '#/state/ageAssurance/util'
-import {useGeolocation} from '#/state/geolocation'
+import {useGeolocationStatus} from '#/state/geolocation'
import {useAgent} from '#/state/session'
export const createAgeAssuranceQueryKey = (did: string) =>
@@ -43,7 +43,7 @@ AgeAssuranceAPIContext.displayName = 'AgeAssuranceAPIContext'
*/
export function Provider({children}: {children: React.ReactNode}) {
const agent = useAgent()
- const {geolocation} = useGeolocation()
+ const {status: geolocation} = useGeolocationStatus()
const isAgeAssuranceEnabled = useIsAgeAssuranceEnabled()
const getAndRegisterPushToken = useGetAndRegisterPushToken()
const [refetchWhilePending, setRefetchWhilePending] = useState(false)
diff --git a/src/state/ageAssurance/useInitAgeAssurance.ts b/src/state/ageAssurance/useInitAgeAssurance.ts
index 8776dd29cc..c8aaf70a30 100644
--- a/src/state/ageAssurance/useInitAgeAssurance.ts
+++ b/src/state/ageAssurance/useInitAgeAssurance.ts
@@ -14,7 +14,7 @@ import {
import {isNetworkError} from '#/lib/hooks/useCleanError'
import {logger} from '#/logger'
import {createAgeAssuranceQueryKey} from '#/state/ageAssurance'
-import {useGeolocation} from '#/state/geolocation'
+import {useGeolocationStatus} from '#/state/geolocation'
import {useAgent} from '#/state/session'
let APPVIEW = PUBLIC_APPVIEW
@@ -36,7 +36,7 @@ let APPVIEW_DID = PUBLIC_APPVIEW_DID
export function useInitAgeAssurance() {
const qc = useQueryClient()
const agent = useAgent()
- const {geolocation} = useGeolocation()
+ const {status: geolocation} = useGeolocationStatus()
return useMutation({
async mutationFn(
props: Omit,
diff --git a/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts b/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts
index b020e3c573..6e85edd0b8 100644
--- a/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts
+++ b/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts
@@ -1,9 +1,9 @@
import {useMemo} from 'react'
-import {useGeolocation} from '#/state/geolocation'
+import {useGeolocationStatus} from '#/state/geolocation'
export function useIsAgeAssuranceEnabled() {
- const {geolocation} = useGeolocation()
+ const {status: geolocation} = useGeolocationStatus()
return useMemo(() => {
return !!geolocation?.isAgeRestrictedGeo
diff --git a/src/state/geolocation.tsx b/src/state/geolocation.tsx
deleted file mode 100644
index c4d8cb9467..0000000000
--- a/src/state/geolocation.tsx
+++ /dev/null
@@ -1,226 +0,0 @@
-import React from 'react'
-import EventEmitter from 'eventemitter3'
-
-import {networkRetry} from '#/lib/async/retry'
-import {logger} from '#/logger'
-import {type Device, device} from '#/storage'
-
-const IPCC_URL = `https://bsky.app/ipcc`
-const BAPP_CONFIG_URL = `https://ip.bsky.app/config`
-
-const events = new EventEmitter()
-const EVENT = 'geolocation-updated'
-const emitGeolocationUpdate = (geolocation: Device['geolocation']) => {
- events.emit(EVENT, geolocation)
-}
-const onGeolocationUpdate = (
- listener: (geolocation: Device['geolocation']) => void,
-) => {
- events.on(EVENT, listener)
- return () => {
- events.off(EVENT, listener)
- }
-}
-
-/**
- * Default geolocation value. IF undefined, we fail closed and apply all
- * additional mod authorities.
- */
-export const DEFAULT_GEOLOCATION: Device['geolocation'] = {
- countryCode: undefined,
- isAgeBlockedGeo: undefined,
- isAgeRestrictedGeo: false,
-}
-
-function sanitizeGeolocation(
- geolocation: Device['geolocation'],
-): Device['geolocation'] {
- return {
- countryCode: geolocation?.countryCode ?? undefined,
- isAgeBlockedGeo: geolocation?.isAgeBlockedGeo ?? false,
- isAgeRestrictedGeo: geolocation?.isAgeRestrictedGeo ?? false,
- }
-}
-
-async function getGeolocation(url: string): Promise {
- const res = await fetch(url)
-
- if (!res.ok) {
- throw new Error(`geolocation: lookup failed ${res.status}`)
- }
-
- const json = await res.json()
-
- if (json.countryCode) {
- return {
- countryCode: json.countryCode,
- isAgeBlockedGeo: json.isAgeBlockedGeo ?? false,
- isAgeRestrictedGeo: json.isAgeRestrictedGeo ?? false,
- // @ts-ignore
- regionCode: json.regionCode ?? undefined,
- }
- } else {
- return undefined
- }
-}
-
-async function compareWithIPCC(bapp: Device['geolocation']) {
- try {
- const ipcc = await getGeolocation(IPCC_URL)
-
- if (!ipcc || !bapp) return
-
- logger.metric(
- 'geo:debug',
- {
- bappCountryCode: bapp.countryCode,
- // @ts-ignore
- bappRegionCode: bapp.regionCode,
- bappIsAgeBlockedGeo: bapp.isAgeBlockedGeo,
- bappIsAgeRestrictedGeo: bapp.isAgeRestrictedGeo,
- ipccCountryCode: ipcc.countryCode,
- ipccIsAgeBlockedGeo: ipcc.isAgeBlockedGeo,
- ipccIsAgeRestrictedGeo: ipcc.isAgeRestrictedGeo,
- },
- {
- statsig: false,
- },
- )
- } catch {}
-}
-
-/**
- * Local promise used within this file only.
- */
-let geolocationResolution: Promise<{success: boolean}> | undefined
-
-/**
- * Begin the process of resolving geolocation. 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 is
- * resolved, use {@link ensureGeolocationResolved}
- */
-export function beginResolveGeolocation() {
- /**
- * In dev, IP server is unavailable, so we just set the default geolocation
- * and fail closed.
- */
- if (__DEV__) {
- geolocationResolution = new Promise(y => y({success: true}))
- if (!device.get(['geolocation'])) {
- device.set(['geolocation'], DEFAULT_GEOLOCATION)
- }
- return
- }
-
- geolocationResolution = new Promise(async resolve => {
- let success = true
-
- try {
- // Try once, fail fast
- const geolocation = await getGeolocation(BAPP_CONFIG_URL)
- if (geolocation) {
- device.set(['geolocation'], sanitizeGeolocation(geolocation))
- emitGeolocationUpdate(geolocation)
- logger.debug(`geolocation: success`, {geolocation})
- compareWithIPCC(geolocation)
- } else {
- // endpoint should throw on all failures, this is insurance
- throw new Error(`geolocation: nothing returned from initial request`)
- }
- } catch (e: any) {
- success = false
-
- logger.debug(`geolocation: failed initial request`, {
- safeMessage: e.message,
- })
-
- // set to default
- device.set(['geolocation'], DEFAULT_GEOLOCATION)
-
- // retry 3 times, but don't await, proceed with default
- networkRetry(3, () => getGeolocation(BAPP_CONFIG_URL))
- .then(geolocation => {
- if (geolocation) {
- device.set(['geolocation'], sanitizeGeolocation(geolocation))
- emitGeolocationUpdate(geolocation)
- logger.debug(`geolocation: success`, {geolocation})
- success = true
- compareWithIPCC(geolocation)
- } else {
- // endpoint should throw on all failures, this is insurance
- throw new Error(`geolocation: nothing returned from retries`)
- }
- })
- .catch((e: any) => {
- // complete fail closed
- logger.debug(`geolocation: failed retries`, {safeMessage: e.message})
- })
- } finally {
- resolve({success})
- }
- })
-}
-
-/**
- * Ensure that geolocation 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 events}.
- */
-export async function ensureGeolocationResolved() {
- if (!geolocationResolution) {
- throw new Error(`geolocation: beginResolveGeolocation not called yet`)
- }
-
- const cached = device.get(['geolocation'])
- if (cached) {
- logger.debug(`geolocation: using cache`, {cached})
- } else {
- logger.debug(`geolocation: no cache`)
- const {success} = await geolocationResolution
- if (success) {
- logger.debug(`geolocation: resolved`, {
- resolved: device.get(['geolocation']),
- })
- } else {
- logger.error(`geolocation: failed to resolve`)
- }
- }
-}
-
-type Context = {
- geolocation: Device['geolocation']
-}
-
-const context = React.createContext({
- geolocation: DEFAULT_GEOLOCATION,
-})
-context.displayName = 'GeolocationContext'
-
-export function Provider({children}: {children: React.ReactNode}) {
- const [geolocation, setGeolocation] = React.useState(() => {
- const initial = device.get(['geolocation']) || DEFAULT_GEOLOCATION
- return initial
- })
-
- React.useEffect(() => {
- return onGeolocationUpdate(geolocation => {
- setGeolocation(geolocation!)
- })
- }, [])
-
- const ctx = React.useMemo(() => {
- return {
- geolocation,
- }
- }, [geolocation])
-
- return {children}
-}
-
-export function useGeolocation() {
- return React.useContext(context)
-}
diff --git a/src/state/geolocation/config.ts b/src/state/geolocation/config.ts
new file mode 100644
index 0000000000..1f7f2daf22
--- /dev/null
+++ b/src/state/geolocation/config.ts
@@ -0,0 +1,143 @@
+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(`geolocation 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(`geolocation 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(`geolocation 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(`geolocation config: nothing returned from retries`)
+ }
+ })
+ .catch((e: any) => {
+ // complete fail closed
+ logger.debug(`geolocation 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(
+ `geolocation config: beginResolveGeolocationConfig not called yet`,
+ )
+ }
+
+ const cached = device.get(['geolocation'])
+ if (cached) {
+ logger.debug(`geolocation config: using cache`)
+ } else {
+ logger.debug(`geolocation config: no cache`)
+ const {success} = await geolocationConfigResolution
+ if (success) {
+ logger.debug(`geolocation config: resolved`)
+ } else {
+ logger.info(`geolocation config: failed to resolve`)
+ }
+ }
+}
diff --git a/src/state/geolocation/const.ts b/src/state/geolocation/const.ts
new file mode 100644
index 0000000000..789d001aa5
--- /dev/null
+++ b/src/state/geolocation/const.ts
@@ -0,0 +1,30 @@
+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
new file mode 100644
index 0000000000..61433bb2a8
--- /dev/null
+++ b/src/state/geolocation/events.ts
@@ -0,0 +1,19 @@
+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
new file mode 100644
index 0000000000..d01e3f2623
--- /dev/null
+++ b/src/state/geolocation/index.tsx
@@ -0,0 +1,153 @@
+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) {
+ logger.debug('geolocation: has device geolocation available')
+ }
+ const geolocation = mergeGeolocation(deviceGeolocation, config)
+ const status = computeGeolocationStatus(geolocation, 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('geolocation: 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/logger.ts b/src/state/geolocation/logger.ts
new file mode 100644
index 0000000000..afda81136a
--- /dev/null
+++ b/src/state/geolocation/logger.ts
@@ -0,0 +1,3 @@
+import {Logger} from '#/logger'
+
+export const logger = Logger.create(Logger.Context.Geolocation)
diff --git a/src/state/geolocation/types.ts b/src/state/geolocation/types.ts
new file mode 100644
index 0000000000..174761649f
--- /dev/null
+++ b/src/state/geolocation/types.ts
@@ -0,0 +1,9 @@
+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
new file mode 100644
index 0000000000..64e05b056a
--- /dev/null
+++ b/src/state/geolocation/useRequestDeviceLocation.ts
@@ -0,0 +1,43 @@
+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
new file mode 100644
index 0000000000..602f29a30d
--- /dev/null
+++ b/src/state/geolocation/useSyncedDeviceGeolocation.ts
@@ -0,0 +1,58 @@
+import {useEffect, useRef} from 'react'
+import * as Location from 'expo-location'
+
+import {logger} from '#/state/geolocation/logger'
+import {getDeviceGeolocation} from '#/state/geolocation/util'
+import {device, useStorage} from '#/storage'
+
+/**
+ * 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] = Location.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
new file mode 100644
index 0000000000..c92b42b133
--- /dev/null
+++ b/src/state/geolocation/util.ts
@@ -0,0 +1,180 @@
+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/storage/schema.ts b/src/storage/schema.ts
index a3f2336cf4..d562d9fae4 100644
--- a/src/storage/schema.ts
+++ b/src/storage/schema.ts
@@ -7,11 +7,32 @@ export type Device = {
fontScale: '-2' | '-1' | '0' | '1' | '2'
fontFamily: 'system' | 'theme'
lastNuxDialog: string | undefined
+
+ /**
+ * Geolocation config, fetched from the IP service. This previously did
+ * double duty as the "status" for geolocation state, but that has since
+ * moved here to the client.
+ */
geolocation?: {
countryCode: string | undefined
- isAgeRestrictedGeo: boolean | undefined
- isAgeBlockedGeo: boolean | undefined
+ regionCode: string | undefined
+ ageRestrictedGeos: {
+ countryCode: string
+ regionCode: string | undefined
+ }[]
+ ageBlockedGeos: {
+ countryCode: string
+ regionCode: string | undefined
+ }[]
}
+ /**
+ * The GPS-based geolocation, if the user has granted permission.
+ */
+ deviceGeolocation?: {
+ countryCode: string | undefined
+ regionCode: string | undefined
+ }
+
trendingBetaEnabled: boolean
devMode: boolean
demoMode: boolean
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 8b4c65b8fc..277e5c523f 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -13,7 +13,7 @@ import {useNotificationsRegistration} from '#/lib/notifications/notifications'
import {isStateAtTabRoot} from '#/lib/routes/helpers'
import {isAndroid, isIOS} from '#/platform/detection'
import {useDialogFullyExpandedCountContext} from '#/state/dialogs'
-import {useGeolocation} from '#/state/geolocation'
+import {useGeolocationStatus} from '#/state/geolocation'
import {useSession} from '#/state/session'
import {
useIsDrawerOpen,
@@ -184,7 +184,7 @@ function ShellInner() {
export function Shell() {
const t = useTheme()
- const {geolocation} = useGeolocation()
+ const {status: geolocation} = useGeolocationStatus()
const fullyExpandedCount = useDialogFullyExpandedCountContext()
useIntentHandler()
diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx
index bb09b5f62d..268e77eae6 100644
--- a/src/view/shell/index.web.tsx
+++ b/src/view/shell/index.web.tsx
@@ -9,7 +9,7 @@ import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {type NavigationProp} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig'
-import {useGeolocation} from '#/state/geolocation'
+import {useGeolocationStatus} from '#/state/geolocation'
import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
import {useCloseAllActiveElements} from '#/state/util'
@@ -142,7 +142,7 @@ function ShellInner() {
export function Shell() {
const t = useTheme()
- const {geolocation} = useGeolocation()
+ const {status: geolocation} = useGeolocationStatus()
return (
{geolocation?.isAgeBlockedGeo ? (
diff --git a/yarn.lock b/yarn.lock
index 20020f85d1..c4b296d5d1 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -11386,6 +11386,11 @@ expo-localization@~16.1.5:
dependencies:
rtl-detect "^1.0.2"
+expo-location@~18.1.6:
+ version "18.1.6"
+ resolved "https://registry.yarnpkg.com/expo-location/-/expo-location-18.1.6.tgz#b855e14e8b4e29a1bde470fc4dc2a341abecf631"
+ integrity sha512-l5dQQ2FYkrBgNzaZN1BvSmdhhcztFOUucu2kEfDBMV4wSIuTIt/CKsho+F3RnAiWgvui1wb1WTTf80E8zq48hA==
+
expo-manifests@~0.16.5:
version "0.16.5"
resolved "https://registry.yarnpkg.com/expo-manifests/-/expo-manifests-0.16.5.tgz#bb57ceff3db4eb74679d4a155b2ca2050375ce10"