From f8ae0540a062e6346baf9fbf0481f769fb23a120 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 4 Sep 2025 11:07:12 -0500 Subject: Provide geo-gated users optional GPS fallback for precise location data (#8973) --- src/components/BlockedGeoOverlay.tsx | 175 +++++++++++++++------ .../ageAssurance/AgeAssuranceAccountCard.tsx | 50 +++++- .../dialogs/DeviceLocationRequestDialog.tsx | 171 ++++++++++++++++++++ src/components/icons/PinLocation.tsx | 9 ++ 4 files changed, 359 insertions(+), 46 deletions(-) create mode 100644 src/components/dialogs/DeviceLocationRequestDialog.tsx create mode 100644 src/components/icons/PinLocation.tsx (limited to 'src/components') 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 ( - - + - - - - + + - Announcement - + + + Announcement + + + + + + {blocks.map((block, index) => ( + + {block} + + ))} - - - {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', + }) + }) + } + }} + /> + + )} + + + + + - - - + {/* + * 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 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 & {}) { - + {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 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 ( + + + + + + + + + ) +} + +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 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', +}) -- cgit 1.4.1