···33333434# Bitdrift API key. If undefined, Bitdrift will be disabled.
3535EXPO_PUBLIC_BITDRIFT_API_KEY=
3636+3737+# bapp-config web worker URL
3838+BAPP_CONFIG_DEV_URL=
3939+4040+# Dev-only passthrough value for bapp-config web worker
4141+BAPP_CONFIG_DEV_BYPASS_SECRET=
···9393 process.env.EXPO_PUBLIC_GCP_PROJECT_ID === undefined
9494 ? 0
9595 : Number(process.env.EXPO_PUBLIC_GCP_PROJECT_ID)
9696+9797+/**
9898+ * URL for the bapp-config web worker _development_ environment. Can be a
9999+ * locally running server, see `env.example` for more.
100100+ */
101101+export const BAPP_CONFIG_DEV_URL = process.env.BAPP_CONFIG_DEV_URL
102102+103103+/**
104104+ * Dev environment passthrough value for bapp-config web worker. Allows local
105105+ * dev access to the web worker running in `development` mode.
106106+ */
107107+export const BAPP_CONFIG_DEV_BYPASS_SECRET: string =
108108+ process.env.BAPP_CONFIG_DEV_BYPASS_SECRET
+2-2
src/lib/currency.ts
···11import React from 'react'
2233import {deviceLocales} from '#/locale/deviceLocales'
44-import {useGeolocation} from '#/state/geolocation'
44+import {useGeolocationStatus} from '#/state/geolocation'
55import {useLanguagePrefs} from '#/state/preferences'
6677/**
···275275export function useFormatCurrency(
276276 options?: Parameters<typeof Intl.NumberFormat>[1],
277277) {
278278- const {geolocation} = useGeolocation()
278278+ const {location: geolocation} = useGeolocationStatus()
279279 const {appLanguage} = useLanguagePrefs()
280280 return React.useMemo(() => {
281281 const locale = deviceLocales.at(0)
+1
src/logger/types.ts
···1414 PostSource = 'post-source',
1515 AgeAssurance = 'age-assurance',
1616 PolicyUpdate = 'policy-update',
1717+ Geolocation = 'geolocation',
17181819 /**
1920 * METRIC IS FOR INTERNAL USE ONLY, don't create any other loggers using this
+2-2
src/state/ageAssurance/index.tsx
···1111} from '#/state/ageAssurance/types'
1212import {useIsAgeAssuranceEnabled} from '#/state/ageAssurance/useIsAgeAssuranceEnabled'
1313import {logger} from '#/state/ageAssurance/util'
1414-import {useGeolocation} from '#/state/geolocation'
1414+import {useGeolocationStatus} from '#/state/geolocation'
1515import {useAgent} from '#/state/session'
16161717export const createAgeAssuranceQueryKey = (did: string) =>
···4343 */
4444export function Provider({children}: {children: React.ReactNode}) {
4545 const agent = useAgent()
4646- const {geolocation} = useGeolocation()
4646+ const {status: geolocation} = useGeolocationStatus()
4747 const isAgeAssuranceEnabled = useIsAgeAssuranceEnabled()
4848 const getAndRegisterPushToken = useGetAndRegisterPushToken()
4949 const [refetchWhilePending, setRefetchWhilePending] = useState(false)
+2-2
src/state/ageAssurance/useInitAgeAssurance.ts
···1414import {isNetworkError} from '#/lib/hooks/useCleanError'
1515import {logger} from '#/logger'
1616import {createAgeAssuranceQueryKey} from '#/state/ageAssurance'
1717-import {useGeolocation} from '#/state/geolocation'
1717+import {useGeolocationStatus} from '#/state/geolocation'
1818import {useAgent} from '#/state/session'
19192020let APPVIEW = PUBLIC_APPVIEW
···3636export function useInitAgeAssurance() {
3737 const qc = useQueryClient()
3838 const agent = useAgent()
3939- const {geolocation} = useGeolocation()
3939+ const {status: geolocation} = useGeolocationStatus()
4040 return useMutation({
4141 async mutationFn(
4242 props: Omit<AppBskyUnspeccedInitAgeAssurance.InputSchema, 'countryCode'>,
···11+import {useEffect, useRef} from 'react'
22+import * as Location from 'expo-location'
33+44+import {logger} from '#/state/geolocation/logger'
55+import {getDeviceGeolocation} from '#/state/geolocation/util'
66+import {device, useStorage} from '#/storage'
77+88+/**
99+ * Hook to get and sync the device geolocation from the device GPS and store it
1010+ * using device storage. If permissions are not granted, it will clear any cached
1111+ * storage value.
1212+ */
1313+export function useSyncedDeviceGeolocation() {
1414+ const synced = useRef(false)
1515+ const [status] = Location.useForegroundPermissions()
1616+ const [deviceGeolocation, setDeviceGeolocation] = useStorage(device, [
1717+ 'deviceGeolocation',
1818+ ])
1919+2020+ useEffect(() => {
2121+ async function get() {
2222+ // no need to set this more than once per session
2323+ if (synced.current) return
2424+2525+ logger.debug('useSyncedDeviceGeolocation: checking perms')
2626+2727+ if (status?.granted) {
2828+ const location = await getDeviceGeolocation()
2929+ if (location) {
3030+ logger.debug('useSyncedDeviceGeolocation: syncing location')
3131+ setDeviceGeolocation(location)
3232+ synced.current = true
3333+ }
3434+ } else {
3535+ const hasCachedValue = device.get(['deviceGeolocation']) !== undefined
3636+3737+ /**
3838+ * If we have a cached value, but user has revoked permissions,
3939+ * quietly (will take effect lazily) clear this out.
4040+ */
4141+ if (hasCachedValue) {
4242+ logger.debug(
4343+ 'useSyncedDeviceGeolocation: clearing cached location, perms revoked',
4444+ )
4545+ device.set(['deviceGeolocation'], undefined)
4646+ }
4747+ }
4848+ }
4949+5050+ get().catch(e => {
5151+ logger.error('useSyncedDeviceGeolocation: failed to sync', {
5252+ safeMessage: e,
5353+ })
5454+ })
5555+ }, [status, setDeviceGeolocation])
5656+5757+ return [deviceGeolocation, setDeviceGeolocation] as const
5858+}
+180
src/state/geolocation/util.ts
···11+import {
22+ getCurrentPositionAsync,
33+ type LocationGeocodedAddress,
44+ reverseGeocodeAsync,
55+} from 'expo-location'
66+77+import {logger} from '#/state/geolocation/logger'
88+import {type DeviceLocation} from '#/state/geolocation/types'
99+import {type Device} from '#/storage'
1010+1111+/**
1212+ * Maps full US region names to their short codes.
1313+ *
1414+ * Context: in some cases, like on Android, we get the full region name instead
1515+ * of the short code. We may need to expand this in the future to other
1616+ * countries, hence the prefix.
1717+ */
1818+export const USRegionNameToRegionCode: {
1919+ [regionName: string]: string
2020+} = {
2121+ Alabama: 'AL',
2222+ Alaska: 'AK',
2323+ Arizona: 'AZ',
2424+ Arkansas: 'AR',
2525+ California: 'CA',
2626+ Colorado: 'CO',
2727+ Connecticut: 'CT',
2828+ Delaware: 'DE',
2929+ Florida: 'FL',
3030+ Georgia: 'GA',
3131+ Hawaii: 'HI',
3232+ Idaho: 'ID',
3333+ Illinois: 'IL',
3434+ Indiana: 'IN',
3535+ Iowa: 'IA',
3636+ Kansas: 'KS',
3737+ Kentucky: 'KY',
3838+ Louisiana: 'LA',
3939+ Maine: 'ME',
4040+ Maryland: 'MD',
4141+ Massachusetts: 'MA',
4242+ Michigan: 'MI',
4343+ Minnesota: 'MN',
4444+ Mississippi: 'MS',
4545+ Missouri: 'MO',
4646+ Montana: 'MT',
4747+ Nebraska: 'NE',
4848+ Nevada: 'NV',
4949+ ['New Hampshire']: 'NH',
5050+ ['New Jersey']: 'NJ',
5151+ ['New Mexico']: 'NM',
5252+ ['New York']: 'NY',
5353+ ['North Carolina']: 'NC',
5454+ ['North Dakota']: 'ND',
5555+ Ohio: 'OH',
5656+ Oklahoma: 'OK',
5757+ Oregon: 'OR',
5858+ Pennsylvania: 'PA',
5959+ ['Rhode Island']: 'RI',
6060+ ['South Carolina']: 'SC',
6161+ ['South Dakota']: 'SD',
6262+ Tennessee: 'TN',
6363+ Texas: 'TX',
6464+ Utah: 'UT',
6565+ Vermont: 'VT',
6666+ Virginia: 'VA',
6767+ Washington: 'WA',
6868+ ['West Virginia']: 'WV',
6969+ Wisconsin: 'WI',
7070+ Wyoming: 'WY',
7171+}
7272+7373+/**
7474+ * Normalizes a `LocationGeocodedAddress` into a `DeviceLocation`.
7575+ *
7676+ * We don't want or care about the full location data, so we trim it down and
7777+ * normalize certain fields, like region, into the format we need.
7878+ */
7979+export function normalizeDeviceLocation(
8080+ location: LocationGeocodedAddress,
8181+): DeviceLocation {
8282+ let {isoCountryCode, region} = location
8383+8484+ if (region) {
8585+ if (isoCountryCode === 'US') {
8686+ region = USRegionNameToRegionCode[region] ?? region
8787+ }
8888+ }
8989+9090+ return {
9191+ countryCode: isoCountryCode ?? undefined,
9292+ regionCode: region ?? undefined,
9393+ }
9494+}
9595+9696+/**
9797+ * Combines precise location data with the geolocation config fetched from the
9898+ * IP service, with preference to the precise data.
9999+ */
100100+export function mergeGeolocation(
101101+ location?: DeviceLocation,
102102+ config?: Device['geolocation'],
103103+): DeviceLocation {
104104+ if (location?.countryCode) return location
105105+ return {
106106+ countryCode: config?.countryCode,
107107+ regionCode: config?.regionCode,
108108+ }
109109+}
110110+111111+/**
112112+ * Computes the geolocation status (age-restricted, age-blocked) based on the
113113+ * given location and geolocation config. `location` here should be merged with
114114+ * `mergeGeolocation()` ahead of time if needed.
115115+ */
116116+export function computeGeolocationStatus(
117117+ location: DeviceLocation,
118118+ config: Device['geolocation'],
119119+) {
120120+ /**
121121+ * We can't do anything if we don't have this data.
122122+ */
123123+ if (!location.countryCode) {
124124+ return {
125125+ ...location,
126126+ isAgeRestrictedGeo: false,
127127+ isAgeBlockedGeo: false,
128128+ }
129129+ }
130130+131131+ const isAgeRestrictedGeo = config?.ageRestrictedGeos?.some(rule => {
132132+ if (rule.countryCode === location.countryCode) {
133133+ if (!rule.regionCode) {
134134+ return true // whole country is blocked
135135+ } else if (rule.regionCode === location.regionCode) {
136136+ return true
137137+ }
138138+ }
139139+ })
140140+141141+ const isAgeBlockedGeo = config?.ageBlockedGeos?.some(rule => {
142142+ if (rule.countryCode === location.countryCode) {
143143+ if (!rule.regionCode) {
144144+ return true // whole country is blocked
145145+ } else if (rule.regionCode === location.regionCode) {
146146+ return true
147147+ }
148148+ }
149149+ })
150150+151151+ return {
152152+ ...location,
153153+ isAgeRestrictedGeo: !!isAgeRestrictedGeo,
154154+ isAgeBlockedGeo: !!isAgeBlockedGeo,
155155+ }
156156+}
157157+158158+export async function getDeviceGeolocation(): Promise<DeviceLocation> {
159159+ try {
160160+ const geocode = await getCurrentPositionAsync()
161161+ const locations = await reverseGeocodeAsync({
162162+ latitude: geocode.coords.latitude,
163163+ longitude: geocode.coords.longitude,
164164+ })
165165+ const location = locations.at(0)
166166+ const normalized = location ? normalizeDeviceLocation(location) : undefined
167167+ return {
168168+ countryCode: normalized?.countryCode ?? undefined,
169169+ regionCode: normalized?.regionCode ?? undefined,
170170+ }
171171+ } catch (e) {
172172+ logger.error('getDeviceGeolocation: failed', {
173173+ safeMessage: e,
174174+ })
175175+ return {
176176+ countryCode: undefined,
177177+ regionCode: undefined,
178178+ }
179179+ }
180180+}
+23-2
src/storage/schema.ts
···77 fontScale: '-2' | '-1' | '0' | '1' | '2'
88 fontFamily: 'system' | 'theme'
99 lastNuxDialog: string | undefined
1010+1111+ /**
1212+ * Geolocation config, fetched from the IP service. This previously did
1313+ * double duty as the "status" for geolocation state, but that has since
1414+ * moved here to the client.
1515+ */
1016 geolocation?: {
1117 countryCode: string | undefined
1212- isAgeRestrictedGeo: boolean | undefined
1313- isAgeBlockedGeo: boolean | undefined
1818+ regionCode: string | undefined
1919+ ageRestrictedGeos: {
2020+ countryCode: string
2121+ regionCode: string | undefined
2222+ }[]
2323+ ageBlockedGeos: {
2424+ countryCode: string
2525+ regionCode: string | undefined
2626+ }[]
1427 }
2828+ /**
2929+ * The GPS-based geolocation, if the user has granted permission.
3030+ */
3131+ deviceGeolocation?: {
3232+ countryCode: string | undefined
3333+ regionCode: string | undefined
3434+ }
3535+1536 trendingBetaEnabled: boolean
1637 devMode: boolean
1738 demoMode: boolean
+2-2
src/view/shell/index.tsx
···1313import {isStateAtTabRoot} from '#/lib/routes/helpers'
1414import {isAndroid, isIOS} from '#/platform/detection'
1515import {useDialogFullyExpandedCountContext} from '#/state/dialogs'
1616-import {useGeolocation} from '#/state/geolocation'
1616+import {useGeolocationStatus} from '#/state/geolocation'
1717import {useSession} from '#/state/session'
1818import {
1919 useIsDrawerOpen,
···184184185185export function Shell() {
186186 const t = useTheme()
187187- const {geolocation} = useGeolocation()
187187+ const {status: geolocation} = useGeolocationStatus()
188188 const fullyExpandedCount = useDialogFullyExpandedCountContext()
189189190190 useIntentHandler()
+2-2
src/view/shell/index.web.tsx
···99import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
1010import {type NavigationProp} from '#/lib/routes/types'
1111import {useGate} from '#/lib/statsig/statsig'
1212-import {useGeolocation} from '#/state/geolocation'
1212+import {useGeolocationStatus} from '#/state/geolocation'
1313import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
1414import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
1515import {useCloseAllActiveElements} from '#/state/util'
···142142143143export function Shell() {
144144 const t = useTheme()
145145- const {geolocation} = useGeolocation()
145145+ const {status: geolocation} = useGeolocationStatus()
146146 return (
147147 <View style={[a.util_screen_outer, t.atoms.bg]}>
148148 {geolocation?.isAgeBlockedGeo ? (