about summary refs log tree commit diff
path: root/src/components
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2025-09-04 11:07:12 -0500
committerGitHub <noreply@github.com>2025-09-04 11:07:12 -0500
commitf8ae0540a062e6346baf9fbf0481f769fb23a120 (patch)
treefc888e55258169e8e7246a1c099aab78fc7d9c99 /src/components
parent625b4e61dbf11c1d485bf8e8265df4d5af0c9657 (diff)
downloadvoidsky-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.tsx175
-rw-r--r--src/components/ageAssurance/AgeAssuranceAccountCard.tsx50
-rw-r--r--src/components/dialogs/DeviceLocationRequestDialog.tsx171
-rw-r--r--src/components/icons/PinLocation.tsx9
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',
+})