diff options
author | Eric Bailey <git@esb.lol> | 2025-09-04 11:07:12 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-09-04 11:07:12 -0500 |
commit | f8ae0540a062e6346baf9fbf0481f769fb23a120 (patch) | |
tree | fc888e55258169e8e7246a1c099aab78fc7d9c99 | |
parent | 625b4e61dbf11c1d485bf8e8265df4d5af0c9657 (diff) | |
download | voidsky-f8ae0540a062e6346baf9fbf0481f769fb23a120.tar.zst |
Provide geo-gated users optional GPS fallback for precise location data (#8973)
31 files changed, 1075 insertions, 298 deletions
diff --git a/.env.example b/.env.example index 4aefa3320..ac8dcab1f 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 8a0e6e48c..0b469a986 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 000000000..becc18b12 --- /dev/null +++ b/assets/icons/pinLocationFilled_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12.591 21.806h.002l.001-.002.006-.004.018-.014a10 10 0 0 0 .304-.235 26 26 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.355a26 26 0 0 0 3.333 3.196 16 16 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" clip-rule="evenodd"/></svg> diff --git a/assets/icons/pinLocation_stroke2_corner0_rounded.svg b/assets/icons/pinLocation_stroke2_corner0_rounded.svg new file mode 100644 index 000000000..11674c912 --- /dev/null +++ b/assets/icons/pinLocation_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a8 8 0 0 1 8 8c0 3.305-1.953 6.29-3.745 8.355a26 26 0 0 1-3.333 3.197q-.152.12-.237.184l-.067.05-.018.014-.005.004-.002.002h-.001c-.003-.004-.042-.055-.592-.806l.592.807a1 1 0 0 1-1.184 0v-.001l-.003-.002-.005-.004-.018-.014-.067-.05a24 24 0 0 1-1.066-.877 26 26 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.044A24 24 0 0 0 12 19.723a24 24 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"/></svg> diff --git a/package.json b/package.json index 323fdce78..9e9c6d7c5 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 e22ab3f0e..036ecff60 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 17434bfcd..c86960172 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 ae5790da9..df8ed63d4 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 ( - <ScrollView - contentContainerStyle={[ - a.px_2xl, - { - paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding, - paddingBottom: 100, - }, - ]}> - <View - style={[ - a.mx_auto, - web({ - maxWidth: 440, - paddingTop: gtPhone ? '8vh' : undefined, - }), + <> + <ScrollView + contentContainerStyle={[ + a.px_2xl, + { + paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding, + paddingBottom: 100, + }, ]}> - <View style={[a.align_start]}> - <View - style={[ - a.pl_md, - a.pr_lg, - a.py_sm, - a.rounded_full, - a.flex_row, - a.align_center, - a.gap_xs, - { - backgroundColor: t.palette.primary_25, - }, - ]}> - <Mark fill={t.palette.primary_600} width={14} /> - <Text + <View + style={[ + a.mx_auto, + web({ + maxWidth: 380, + paddingTop: gtPhone ? '8vh' : undefined, + }), + ]}> + <View style={[a.align_start]}> + <View style={[ - a.font_bold, + a.pl_md, + a.pr_lg, + a.py_sm, + a.rounded_full, + a.flex_row, + a.align_center, + a.gap_xs, { - color: t.palette.primary_600, + backgroundColor: t.palette.primary_25, }, ]}> - <Trans>Announcement</Trans> - </Text> + <Mark fill={t.palette.primary_600} width={14} /> + <Text + style={[ + a.font_bold, + { + color: t.palette.primary_600, + }, + ]}> + <Trans>Announcement</Trans> + </Text> + </View> + </View> + + <View style={[a.gap_lg, {paddingTop: 32}]}> + {blocks.map((block, index) => ( + <Text key={index} style={[textStyles]}> + {block} + </Text> + ))} </View> - </View> - <View style={[a.gap_lg, {paddingTop: 32, paddingBottom: 48}]}> - {blocks.map((block, index) => ( - <Text key={index} style={[textStyles]}> - {block} - </Text> - ))} + {!isWeb && ( + <> + <View style={[a.pt_2xl]}> + <Divider /> + </View> + + <View style={[a.mt_xl, a.align_start]}> + <Text + style={[a.text_lg, a.font_heavy, a.leading_snug, a.pb_xs]}> + <Trans>Not in Mississippi?</Trans> + </Text> + <Text + style={[ + a.text_sm, + a.leading_snug, + t.atoms.text_contrast_medium, + a.pb_md, + ]}> + <Trans> + Confirm your location with GPS. Your location data is not + tracked and does not leave your device. + </Trans> + </Text> + <Button + label={_(msg`Confirm your location`)} + onPress={() => geoDialog.open()} + size="small" + color="primary_subtle"> + <ButtonIcon icon={LocationIcon} /> + <ButtonText> + <Trans>Confirm your location</Trans> + </ButtonText> + </Button> + </View> + + <DeviceLocationRequestDialog + control={geoDialog} + onLocationAcquired={props => { + 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', + }) + }) + } + }} + /> + </> + )} + + <View style={[{paddingTop: 48}]}> + <Logo width={120} textFill={t.atoms.text.color} /> + </View> </View> + </ScrollView> - <Logo width={120} textFill={t.atoms.text.color} /> - </View> - </ScrollView> + {/* + * 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. + */} + <BottomSheetOutlet /> + <PortalOutlet /> + </> ) } diff --git a/src/components/ageAssurance/AgeAssuranceAccountCard.tsx b/src/components/ageAssurance/AgeAssuranceAccountCard.tsx index a00a8c71a..be9935d9f 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 & {}) { </View> </View> - <View style={[a.pb_md]}> + <View style={[a.pb_md, a.gap_xs]}> <Text style={[a.text_sm, a.leading_snug]}>{copy.notice}</Text> + + {isNative && ( + <> + <Text style={[a.text_sm, a.leading_snug]}> + <Trans> + Is your location not accurate?{' '} + <InlineLinkText + label={_(msg`Confirm your location`)} + {...createStaticClick(() => { + locationControl.open() + })}> + Click here to confirm your location. + </InlineLinkText>{' '} + </Trans> + </Text> + + <DeviceLocationRequestDialog + control={locationControl} + onLocationAcquired={props => { + 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', + }) + }) + } + }} + /> + </> + )} </View> {isBlocked ? ( diff --git a/src/components/dialogs/DeviceLocationRequestDialog.tsx b/src/components/dialogs/DeviceLocationRequestDialog.tsx new file mode 100644 index 000000000..6c82dc0e9 --- /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 ( + <Dialog.Outer control={control}> + <Dialog.Handle /> + + <Dialog.ScrollableInner + label={_(msg`Confirm your location`)} + style={[web({maxWidth: 380})]}> + <DeviceLocationRequestDialogInner + onLocationAcquired={onLocationAcquired} + /> + <Dialog.Close /> + </Dialog.ScrollableInner> + </Dialog.Outer> + ) +} + +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<string>('') + 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 ( + <View style={[a.gap_md]}> + <Text style={[a.text_xl, a.font_heavy]}> + <Trans>Confirm your location</Trans> + </Text> + <View style={[a.gap_sm, a.pb_xs]}> + <Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + 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. + </Trans> + </Text> + + <Text + style={[ + a.text_md, + a.leading_snug, + t.atoms.text_contrast_medium, + a.pb_xs, + ]}> + <Trans> + Your location data is not tracked and does not leave your device. + </Trans> + </Text> + </View> + + {error && ( + <View style={[a.pb_xs]}> + <Admonition type="error">{error}</Admonition> + </View> + )} + + <View style={[a.gap_sm]}> + {!dialogDisabled && ( + <Button + disabled={isRequesting} + label={_(msg`Confirm your location`)} + onPress={onPressConfirm} + size={isWeb ? 'small' : 'large'} + color="primary"> + <ButtonIcon icon={isRequesting ? Loader : LocationIcon} /> + <ButtonText> + <Trans>Allow location access</Trans> + </ButtonText> + </Button> + )} + + {!isWeb && ( + <Button + label={_(msg`Confirm your location`)} + onPress={() => close()} + size={isWeb ? 'small' : 'large'} + color="secondary"> + <ButtonText> + <Trans>Cancel</Trans> + </ButtonText> + </Button> + )} + </View> + </View> + ) +} diff --git a/src/components/icons/PinLocation.tsx b/src/components/icons/PinLocation.tsx new file mode 100644 index 000000000..9673995d7 --- /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 7b64c35a6..210ad037c 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 c43078a24..cc5a9a7b0 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<string, string> = { export function useFormatCurrency( options?: Parameters<typeof Intl.NumberFormat>[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 ee3069a08..19e12c504 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 23133046a..e85672b7c 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 8776dd29c..c8aaf70a3 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<AppBskyUnspeccedInitAgeAssurance.InputSchema, 'countryCode'>, diff --git a/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts b/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts index b020e3c57..6e85edd0b 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 c4d8cb946..000000000 --- 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<Device['geolocation']> { - 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<Context>({ - 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 <context.Provider value={ctx}>{children}</context.Provider> -} - -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 000000000..1f7f2daf2 --- /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<Device['geolocation']> { + 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 000000000..789d001aa --- /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 000000000..61433bb2a --- /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 000000000..d01e3f262 --- /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<DeviceGeolocationContext>({ + deviceGeolocation: undefined, +}) +DeviceGeolocationContext.displayName = 'DeviceGeolocationContext' + +const DeviceGeolocationAPIContext = + React.createContext<DeviceGeolocationAPIContext>({ + setDeviceGeolocation: () => {}, + }) +DeviceGeolocationAPIContext.displayName = 'DeviceGeolocationAPIContext' + +const GeolocationConfigContext = React.createContext<GeolocationConfigContext>({ + config: DEFAULT_GEOLOCATION_CONFIG, +}) +GeolocationConfigContext.displayName = 'GeolocationConfigContext' + +const GeolocationStatusContext = React.createContext<GeolocationStatusContext>({ + 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 ( + <GeolocationConfigContext.Provider value={configContext}> + <GeolocationStatusContext.Provider value={statusContext}> + {children} + </GeolocationStatusContext.Provider> + </GeolocationConfigContext.Provider> + ) +} + +/** + * 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 ( + <DeviceGeolocationAPIContext.Provider + value={React.useMemo( + () => ({setDeviceGeolocation: handleSetDeviceGeolocation}), + [handleSetDeviceGeolocation], + )}> + <DeviceGeolocationContext.Provider + value={React.useMemo(() => ({deviceGeolocation}), [deviceGeolocation])}> + <GeolocationStatusProvider>{children}</GeolocationStatusProvider> + </DeviceGeolocationContext.Provider> + </DeviceGeolocationAPIContext.Provider> + ) +} + +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 000000000..afda81136 --- /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 000000000..174761649 --- /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 000000000..64e05b056 --- /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 000000000..602f29a30 --- /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 000000000..c92b42b13 --- /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<DeviceLocation> { + 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 a3f2336cf..d562d9fae 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 8b4c65b8f..277e5c523 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 bb09b5f62..268e77eae 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 ( <View style={[a.util_screen_outer, t.atoms.bg]}> {geolocation?.isAgeBlockedGeo ? ( diff --git a/yarn.lock b/yarn.lock index 20020f85d..c4b296d5d 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" |