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 /src/components | |
parent | 625b4e61dbf11c1d485bf8e8265df4d5af0c9657 (diff) | |
download | voidsky-f8ae0540a062e6346baf9fbf0481f769fb23a120.tar.zst |
Provide geo-gated users optional GPS fallback for precise location data (#8973)
Diffstat (limited to 'src/components')
-rw-r--r-- | src/components/BlockedGeoOverlay.tsx | 175 | ||||
-rw-r--r-- | src/components/ageAssurance/AgeAssuranceAccountCard.tsx | 50 | ||||
-rw-r--r-- | src/components/dialogs/DeviceLocationRequestDialog.tsx | 171 | ||||
-rw-r--r-- | src/components/icons/PinLocation.tsx | 9 |
4 files changed, 359 insertions, 46 deletions
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', +}) |