Provide geo-gated users optional GPS fallback for precise location data
This commit is contained in:
@@ -33,3 +33,9 @@ EXPO_PUBLIC_SENTRY_DSN=
|
|||||||
|
|
||||||
# Bitdrift API key. If undefined, Bitdrift will be disabled.
|
# Bitdrift API key. If undefined, Bitdrift will be disabled.
|
||||||
EXPO_PUBLIC_BITDRIFT_API_KEY=
|
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=
|
||||||
|
|||||||
@@ -360,6 +360,7 @@ module.exports = function (_config) {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
['expo-screen-orientation', {initialOrientation: 'PORTRAIT_UP'}],
|
['expo-screen-orientation', {initialOrientation: 'PORTRAIT_UP'}],
|
||||||
|
['expo-location'],
|
||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
extra: {
|
extra: {
|
||||||
eas: {
|
eas: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
After Width: | Height: | Size: 449 B |
@@ -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>
|
||||||
|
After Width: | Height: | Size: 656 B |
@@ -151,6 +151,7 @@
|
|||||||
"expo-linear-gradient": "~14.1.5",
|
"expo-linear-gradient": "~14.1.5",
|
||||||
"expo-linking": "~7.1.5",
|
"expo-linking": "~7.1.5",
|
||||||
"expo-localization": "~16.1.5",
|
"expo-localization": "~16.1.5",
|
||||||
|
"expo-location": "~18.1.6",
|
||||||
"expo-media-library": "~17.1.7",
|
"expo-media-library": "~17.1.7",
|
||||||
"expo-notifications": "~0.31.3",
|
"expo-notifications": "~0.31.3",
|
||||||
"expo-screen-orientation": "~8.1.7",
|
"expo-screen-orientation": "~8.1.7",
|
||||||
|
|||||||
+7
-6
@@ -32,8 +32,8 @@ import {Provider as DialogStateProvider} from '#/state/dialogs'
|
|||||||
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
|
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
|
||||||
import {listenSessionDropped} from '#/state/events'
|
import {listenSessionDropped} from '#/state/events'
|
||||||
import {
|
import {
|
||||||
beginResolveGeolocation,
|
beginResolveGeolocationConfig,
|
||||||
ensureGeolocationResolved,
|
ensureGeolocationConfigIsResolved,
|
||||||
Provider as GeolocationProvider,
|
Provider as GeolocationProvider,
|
||||||
} from '#/state/geolocation'
|
} from '#/state/geolocation'
|
||||||
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
|
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
|
||||||
@@ -91,7 +91,7 @@ if (isAndroid) {
|
|||||||
/**
|
/**
|
||||||
* Begin geolocation ASAP
|
* Begin geolocation ASAP
|
||||||
*/
|
*/
|
||||||
beginResolveGeolocation()
|
beginResolveGeolocationConfig()
|
||||||
|
|
||||||
function InnerApp() {
|
function InnerApp() {
|
||||||
const [isReady, setIsReady] = React.useState(false)
|
const [isReady, setIsReady] = React.useState(false)
|
||||||
@@ -203,9 +203,10 @@ function App() {
|
|||||||
const [isReady, setReady] = useState(false)
|
const [isReady, setReady] = useState(false)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(() =>
|
Promise.all([
|
||||||
setReady(true),
|
initPersistedState(),
|
||||||
)
|
ensureGeolocationConfigIsResolved(),
|
||||||
|
]).then(() => setReady(true))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (!isReady) {
|
if (!isReady) {
|
||||||
|
|||||||
+7
-6
@@ -21,8 +21,8 @@ import {Provider as DialogStateProvider} from '#/state/dialogs'
|
|||||||
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
|
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
|
||||||
import {listenSessionDropped} from '#/state/events'
|
import {listenSessionDropped} from '#/state/events'
|
||||||
import {
|
import {
|
||||||
beginResolveGeolocation,
|
beginResolveGeolocationConfig,
|
||||||
ensureGeolocationResolved,
|
ensureGeolocationConfigIsResolved,
|
||||||
Provider as GeolocationProvider,
|
Provider as GeolocationProvider,
|
||||||
} from '#/state/geolocation'
|
} from '#/state/geolocation'
|
||||||
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
|
import {Provider as HomeBadgeProvider} from '#/state/home-badge'
|
||||||
@@ -69,7 +69,7 @@ import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottom
|
|||||||
/**
|
/**
|
||||||
* Begin geolocation ASAP
|
* Begin geolocation ASAP
|
||||||
*/
|
*/
|
||||||
beginResolveGeolocation()
|
beginResolveGeolocationConfig()
|
||||||
|
|
||||||
function InnerApp() {
|
function InnerApp() {
|
||||||
const [isReady, setIsReady] = React.useState(false)
|
const [isReady, setIsReady] = React.useState(false)
|
||||||
@@ -178,9 +178,10 @@ function App() {
|
|||||||
const [isReady, setReady] = useState(false)
|
const [isReady, setReady] = useState(false)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(() =>
|
Promise.all([
|
||||||
setReady(true),
|
initPersistedState(),
|
||||||
)
|
ensureGeolocationConfigIsResolved(),
|
||||||
|
]).then(() => setReady(true))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (!isReady) {
|
if (!isReady) {
|
||||||
|
|||||||
@@ -6,16 +6,27 @@ import {useLingui} from '@lingui/react'
|
|||||||
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isWeb} from '#/platform/detection'
|
||||||
|
import {useDeviceGeolocationApi} from '#/state/geolocation'
|
||||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
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 {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 {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 {Text} from '#/components/Typography'
|
||||||
|
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
|
||||||
|
|
||||||
export function BlockedGeoOverlay() {
|
export function BlockedGeoOverlay() {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {gtPhone} = useBreakpoints()
|
const {gtPhone} = useBreakpoints()
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
|
const geoDialog = Dialog.useDialogControl()
|
||||||
|
const {setDeviceGeolocation} = useDeviceGeolocationApi()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// just counting overall hits here
|
// just counting overall hits here
|
||||||
@@ -51,59 +62,133 @@ export function BlockedGeoOverlay() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<>
|
||||||
contentContainerStyle={[
|
<ScrollView
|
||||||
a.px_2xl,
|
contentContainerStyle={[
|
||||||
{
|
a.px_2xl,
|
||||||
paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding,
|
{
|
||||||
paddingBottom: 100,
|
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,
|
|
||||||
}),
|
|
||||||
]}>
|
]}>
|
||||||
<View style={[a.align_start]}>
|
<View
|
||||||
<View
|
style={[
|
||||||
style={[
|
a.mx_auto,
|
||||||
a.pl_md,
|
web({
|
||||||
a.pr_lg,
|
maxWidth: 380,
|
||||||
a.py_sm,
|
paddingTop: gtPhone ? '8vh' : undefined,
|
||||||
a.rounded_full,
|
}),
|
||||||
a.flex_row,
|
]}>
|
||||||
a.align_center,
|
<View style={[a.align_start]}>
|
||||||
a.gap_xs,
|
<View
|
||||||
{
|
|
||||||
backgroundColor: t.palette.primary_25,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<Mark fill={t.palette.primary_600} width={14} />
|
|
||||||
<Text
|
|
||||||
style={[
|
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>
|
<Mark fill={t.palette.primary_600} width={14} />
|
||||||
</Text>
|
<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>
|
||||||
|
|
||||||
|
{!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>
|
||||||
</View>
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
<View style={[a.gap_lg, {paddingTop: 32, paddingBottom: 48}]}>
|
{/*
|
||||||
{blocks.map((block, index) => (
|
* While this blocking overlay is up, other dialogs in the shell
|
||||||
<Text key={index} style={[textStyles]}>
|
* are not mounted, so it _should_ be safe to use these here
|
||||||
{block}
|
* without fear of other modals showing up.
|
||||||
</Text>
|
*/}
|
||||||
))}
|
<BottomSheetOutlet />
|
||||||
</View>
|
<PortalOutlet />
|
||||||
|
</>
|
||||||
<Logo width={120} textFill={t.atoms.text.color} />
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import {msg, Trans} from '@lingui/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
||||||
|
import {isNative} from '#/platform/detection'
|
||||||
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
|
import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
|
||||||
import {logger} from '#/state/ageAssurance/util'
|
import {logger} from '#/state/ageAssurance/util'
|
||||||
|
import {useDeviceGeolocationApi} from '#/state/geolocation'
|
||||||
import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
|
import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
|
||||||
import {Admonition} from '#/components/Admonition'
|
import {Admonition} from '#/components/Admonition'
|
||||||
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
|
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
|
||||||
@@ -16,8 +18,10 @@ import {
|
|||||||
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog'
|
||||||
import {Divider} from '#/components/Divider'
|
import {Divider} from '#/components/Divider'
|
||||||
import {createStaticClick, InlineLinkText} from '#/components/Link'
|
import {createStaticClick, InlineLinkText} from '#/components/Link'
|
||||||
|
import * as Toast from '#/components/Toast'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) {
|
export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) {
|
||||||
@@ -35,8 +39,10 @@ function Inner({style}: ViewStyleProp & {}) {
|
|||||||
const {_, i18n} = useLingui()
|
const {_, i18n} = useLingui()
|
||||||
const control = useDialogControl()
|
const control = useDialogControl()
|
||||||
const appealControl = Dialog.useDialogControl()
|
const appealControl = Dialog.useDialogControl()
|
||||||
|
const locationControl = Dialog.useDialogControl()
|
||||||
const getTimeAgo = useGetTimeAgo()
|
const getTimeAgo = useGetTimeAgo()
|
||||||
const {gtPhone} = useBreakpoints()
|
const {gtPhone} = useBreakpoints()
|
||||||
|
const {setDeviceGeolocation} = useDeviceGeolocationApi()
|
||||||
|
|
||||||
const copy = useAgeAssuranceCopy()
|
const copy = useAgeAssuranceCopy()
|
||||||
const {status, lastInitiatedAt} = useAgeAssurance()
|
const {status, lastInitiatedAt} = useAgeAssurance()
|
||||||
@@ -71,8 +77,50 @@ function Inner({style}: ViewStyleProp & {}) {
|
|||||||
</View>
|
</View>
|
||||||
</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>
|
<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>
|
</View>
|
||||||
|
|
||||||
{isBlocked ? (
|
{isBlocked ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
})
|
||||||
Vendored
+13
@@ -93,3 +93,16 @@ export const GCP_PROJECT_ID: number =
|
|||||||
process.env.EXPO_PUBLIC_GCP_PROJECT_ID === undefined
|
process.env.EXPO_PUBLIC_GCP_PROJECT_ID === undefined
|
||||||
? 0
|
? 0
|
||||||
: Number(process.env.EXPO_PUBLIC_GCP_PROJECT_ID)
|
: 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
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import {deviceLocales} from '#/locale/deviceLocales'
|
import {deviceLocales} from '#/locale/deviceLocales'
|
||||||
import {useGeolocation} from '#/state/geolocation'
|
import {useGeolocationStatus} from '#/state/geolocation'
|
||||||
import {useLanguagePrefs} from '#/state/preferences'
|
import {useLanguagePrefs} from '#/state/preferences'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -275,7 +275,7 @@ export const countryCodeToCurrency: Record<string, string> = {
|
|||||||
export function useFormatCurrency(
|
export function useFormatCurrency(
|
||||||
options?: Parameters<typeof Intl.NumberFormat>[1],
|
options?: Parameters<typeof Intl.NumberFormat>[1],
|
||||||
) {
|
) {
|
||||||
const {geolocation} = useGeolocation()
|
const {location: geolocation} = useGeolocationStatus()
|
||||||
const {appLanguage} = useLanguagePrefs()
|
const {appLanguage} = useLanguagePrefs()
|
||||||
return React.useMemo(() => {
|
return React.useMemo(() => {
|
||||||
const locale = deviceLocales.at(0)
|
const locale = deviceLocales.at(0)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export enum LogContext {
|
|||||||
PostSource = 'post-source',
|
PostSource = 'post-source',
|
||||||
AgeAssurance = 'age-assurance',
|
AgeAssurance = 'age-assurance',
|
||||||
PolicyUpdate = 'policy-update',
|
PolicyUpdate = 'policy-update',
|
||||||
|
Geolocation = 'geolocation',
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* METRIC IS FOR INTERNAL USE ONLY, don't create any other loggers using this
|
* METRIC IS FOR INTERNAL USE ONLY, don't create any other loggers using this
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from '#/state/ageAssurance/types'
|
} from '#/state/ageAssurance/types'
|
||||||
import {useIsAgeAssuranceEnabled} from '#/state/ageAssurance/useIsAgeAssuranceEnabled'
|
import {useIsAgeAssuranceEnabled} from '#/state/ageAssurance/useIsAgeAssuranceEnabled'
|
||||||
import {logger} from '#/state/ageAssurance/util'
|
import {logger} from '#/state/ageAssurance/util'
|
||||||
import {useGeolocation} from '#/state/geolocation'
|
import {useGeolocationStatus} from '#/state/geolocation'
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
|
|
||||||
export const createAgeAssuranceQueryKey = (did: string) =>
|
export const createAgeAssuranceQueryKey = (did: string) =>
|
||||||
@@ -43,7 +43,7 @@ AgeAssuranceAPIContext.displayName = 'AgeAssuranceAPIContext'
|
|||||||
*/
|
*/
|
||||||
export function Provider({children}: {children: React.ReactNode}) {
|
export function Provider({children}: {children: React.ReactNode}) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const {geolocation} = useGeolocation()
|
const {status: geolocation} = useGeolocationStatus()
|
||||||
const isAgeAssuranceEnabled = useIsAgeAssuranceEnabled()
|
const isAgeAssuranceEnabled = useIsAgeAssuranceEnabled()
|
||||||
const getAndRegisterPushToken = useGetAndRegisterPushToken()
|
const getAndRegisterPushToken = useGetAndRegisterPushToken()
|
||||||
const [refetchWhilePending, setRefetchWhilePending] = useState(false)
|
const [refetchWhilePending, setRefetchWhilePending] = useState(false)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import {isNetworkError} from '#/lib/hooks/useCleanError'
|
import {isNetworkError} from '#/lib/hooks/useCleanError'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {createAgeAssuranceQueryKey} from '#/state/ageAssurance'
|
import {createAgeAssuranceQueryKey} from '#/state/ageAssurance'
|
||||||
import {useGeolocation} from '#/state/geolocation'
|
import {useGeolocationStatus} from '#/state/geolocation'
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
|
|
||||||
let APPVIEW = PUBLIC_APPVIEW
|
let APPVIEW = PUBLIC_APPVIEW
|
||||||
@@ -36,7 +36,7 @@ let APPVIEW_DID = PUBLIC_APPVIEW_DID
|
|||||||
export function useInitAgeAssurance() {
|
export function useInitAgeAssurance() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const {geolocation} = useGeolocation()
|
const {status: geolocation} = useGeolocationStatus()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
async mutationFn(
|
async mutationFn(
|
||||||
props: Omit<AppBskyUnspeccedInitAgeAssurance.InputSchema, 'countryCode'>,
|
props: Omit<AppBskyUnspeccedInitAgeAssurance.InputSchema, 'countryCode'>,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {useMemo} from 'react'
|
import {useMemo} from 'react'
|
||||||
|
|
||||||
import {useGeolocation} from '#/state/geolocation'
|
import {useGeolocationStatus} from '#/state/geolocation'
|
||||||
|
|
||||||
export function useIsAgeAssuranceEnabled() {
|
export function useIsAgeAssuranceEnabled() {
|
||||||
const {geolocation} = useGeolocation()
|
const {status: geolocation} = useGeolocationStatus()
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
return !!geolocation?.isAgeRestrictedGeo
|
return !!geolocation?.isAgeRestrictedGeo
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import {Logger} from '#/logger'
|
||||||
|
|
||||||
|
export const logger = Logger.create(Logger.Context.Geolocation)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export type DeviceLocation = {
|
||||||
|
countryCode: string | undefined
|
||||||
|
regionCode: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GeolocationStatus = DeviceLocation & {
|
||||||
|
isAgeRestrictedGeo: boolean
|
||||||
|
isAgeBlockedGeo: boolean
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-2
@@ -7,11 +7,32 @@ export type Device = {
|
|||||||
fontScale: '-2' | '-1' | '0' | '1' | '2'
|
fontScale: '-2' | '-1' | '0' | '1' | '2'
|
||||||
fontFamily: 'system' | 'theme'
|
fontFamily: 'system' | 'theme'
|
||||||
lastNuxDialog: string | undefined
|
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?: {
|
geolocation?: {
|
||||||
countryCode: string | undefined
|
countryCode: string | undefined
|
||||||
isAgeRestrictedGeo: boolean | undefined
|
regionCode: string | undefined
|
||||||
isAgeBlockedGeo: boolean | 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
|
trendingBetaEnabled: boolean
|
||||||
devMode: boolean
|
devMode: boolean
|
||||||
demoMode: boolean
|
demoMode: boolean
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {useNotificationsRegistration} from '#/lib/notifications/notifications'
|
|||||||
import {isStateAtTabRoot} from '#/lib/routes/helpers'
|
import {isStateAtTabRoot} from '#/lib/routes/helpers'
|
||||||
import {isAndroid, isIOS} from '#/platform/detection'
|
import {isAndroid, isIOS} from '#/platform/detection'
|
||||||
import {useDialogFullyExpandedCountContext} from '#/state/dialogs'
|
import {useDialogFullyExpandedCountContext} from '#/state/dialogs'
|
||||||
import {useGeolocation} from '#/state/geolocation'
|
import {useGeolocationStatus} from '#/state/geolocation'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {
|
import {
|
||||||
useIsDrawerOpen,
|
useIsDrawerOpen,
|
||||||
@@ -184,7 +184,7 @@ function ShellInner() {
|
|||||||
|
|
||||||
export function Shell() {
|
export function Shell() {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {geolocation} = useGeolocation()
|
const {status: geolocation} = useGeolocationStatus()
|
||||||
const fullyExpandedCount = useDialogFullyExpandedCountContext()
|
const fullyExpandedCount = useDialogFullyExpandedCountContext()
|
||||||
|
|
||||||
useIntentHandler()
|
useIntentHandler()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
|
|||||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||||
import {type NavigationProp} from '#/lib/routes/types'
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
import {useGate} from '#/lib/statsig/statsig'
|
||||||
import {useGeolocation} from '#/state/geolocation'
|
import {useGeolocationStatus} from '#/state/geolocation'
|
||||||
import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
|
import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
|
||||||
import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
|
import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
|
||||||
import {useCloseAllActiveElements} from '#/state/util'
|
import {useCloseAllActiveElements} from '#/state/util'
|
||||||
@@ -142,7 +142,7 @@ function ShellInner() {
|
|||||||
|
|
||||||
export function Shell() {
|
export function Shell() {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {geolocation} = useGeolocation()
|
const {status: geolocation} = useGeolocationStatus()
|
||||||
return (
|
return (
|
||||||
<View style={[a.util_screen_outer, t.atoms.bg]}>
|
<View style={[a.util_screen_outer, t.atoms.bg]}>
|
||||||
{geolocation?.isAgeBlockedGeo ? (
|
{geolocation?.isAgeBlockedGeo ? (
|
||||||
|
|||||||
@@ -11386,6 +11386,11 @@ expo-localization@~16.1.5:
|
|||||||
dependencies:
|
dependencies:
|
||||||
rtl-detect "^1.0.2"
|
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:
|
expo-manifests@~0.16.5:
|
||||||
version "0.16.5"
|
version "0.16.5"
|
||||||
resolved "https://registry.yarnpkg.com/expo-manifests/-/expo-manifests-0.16.5.tgz#bb57ceff3db4eb74679d4a155b2ca2050375ce10"
|
resolved "https://registry.yarnpkg.com/expo-manifests/-/expo-manifests-0.16.5.tgz#bb57ceff3db4eb74679d4a155b2ca2050375ce10"
|
||||||
|
|||||||
Reference in New Issue
Block a user