mirror of https://git.lenooby09.tech/LeNooby09/social-app.git

Provide geo-gated users optional GPS fallback for precise location data (#8973)

authored by Eric Bailey and committed by GitHub f8ae0540 625b4e61

+6
.env.example
··· 33 33 34 34 # Bitdrift API key. If undefined, Bitdrift will be disabled. 35 35 EXPO_PUBLIC_BITDRIFT_API_KEY= 36 + 37 + # bapp-config web worker URL 38 + BAPP_CONFIG_DEV_URL= 39 + 40 + # Dev-only passthrough value for bapp-config web worker 41 + BAPP_CONFIG_DEV_BYPASS_SECRET=
+1
app.config.js
··· 360 360 }, 361 361 ], 362 362 ['expo-screen-orientation', {initialOrientation: 'PORTRAIT_UP'}], 363 + ['expo-location'], 363 364 ].filter(Boolean), 364 365 extra: { 365 366 eas: {
+1
assets/icons/pinLocationFilled_stroke2_corner0_rounded.svg
··· 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>
+1
assets/icons/pinLocation_stroke2_corner0_rounded.svg
··· 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>
+1
package.json
··· 151 151 "expo-linear-gradient": "~14.1.5", 152 152 "expo-linking": "~7.1.5", 153 153 "expo-localization": "~16.1.5", 154 + "expo-location": "~18.1.6", 154 155 "expo-media-library": "~17.1.7", 155 156 "expo-notifications": "~0.31.3", 156 157 "expo-screen-orientation": "~8.1.7",
+7 -6
src/App.native.tsx
··· 32 32 import {Provider as EmailVerificationProvider} from '#/state/email-verification' 33 33 import {listenSessionDropped} from '#/state/events' 34 34 import { 35 - beginResolveGeolocation, 36 - ensureGeolocationResolved, 35 + beginResolveGeolocationConfig, 36 + ensureGeolocationConfigIsResolved, 37 37 Provider as GeolocationProvider, 38 38 } from '#/state/geolocation' 39 39 import {GlobalGestureEventsProvider} from '#/state/global-gesture-events' ··· 91 91 /** 92 92 * Begin geolocation ASAP 93 93 */ 94 - beginResolveGeolocation() 94 + beginResolveGeolocationConfig() 95 95 96 96 function InnerApp() { 97 97 const [isReady, setIsReady] = React.useState(false) ··· 203 203 const [isReady, setReady] = useState(false) 204 204 205 205 React.useEffect(() => { 206 - Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(() => 207 - setReady(true), 208 - ) 206 + Promise.all([ 207 + initPersistedState(), 208 + ensureGeolocationConfigIsResolved(), 209 + ]).then(() => setReady(true)) 209 210 }, []) 210 211 211 212 if (!isReady) {
+7 -6
src/App.web.tsx
··· 21 21 import {Provider as EmailVerificationProvider} from '#/state/email-verification' 22 22 import {listenSessionDropped} from '#/state/events' 23 23 import { 24 - beginResolveGeolocation, 25 - ensureGeolocationResolved, 24 + beginResolveGeolocationConfig, 25 + ensureGeolocationConfigIsResolved, 26 26 Provider as GeolocationProvider, 27 27 } from '#/state/geolocation' 28 28 import {Provider as HomeBadgeProvider} from '#/state/home-badge' ··· 69 69 /** 70 70 * Begin geolocation ASAP 71 71 */ 72 - beginResolveGeolocation() 72 + beginResolveGeolocationConfig() 73 73 74 74 function InnerApp() { 75 75 const [isReady, setIsReady] = React.useState(false) ··· 178 178 const [isReady, setReady] = useState(false) 179 179 180 180 React.useEffect(() => { 181 - Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(() => 182 - setReady(true), 183 - ) 181 + Promise.all([ 182 + initPersistedState(), 183 + ensureGeolocationConfigIsResolved(), 184 + ]).then(() => setReady(true)) 184 185 }, []) 185 186 186 187 if (!isReady) {
+130 -45
src/components/BlockedGeoOverlay.tsx
··· 6 6 7 7 import {logger} from '#/logger' 8 8 import {isWeb} from '#/platform/detection' 9 + import {useDeviceGeolocationApi} from '#/state/geolocation' 9 10 import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 11 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 12 + import * as Dialog from '#/components/Dialog' 13 + import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog' 14 + import {Divider} from '#/components/Divider' 10 15 import {Full as Logo, Mark} from '#/components/icons/Logo' 16 + import {PinLocation_Stroke2_Corner0_Rounded as LocationIcon} from '#/components/icons/PinLocation' 11 17 import {SimpleInlineLinkText as InlineLinkText} from '#/components/Link' 18 + import {Outlet as PortalOutlet} from '#/components/Portal' 19 + import * as Toast from '#/components/Toast' 12 20 import {Text} from '#/components/Typography' 21 + import {BottomSheetOutlet} from '#/../modules/bottom-sheet' 13 22 14 23 export function BlockedGeoOverlay() { 15 24 const t = useTheme() 16 25 const {_} = useLingui() 17 26 const {gtPhone} = useBreakpoints() 18 27 const insets = useSafeAreaInsets() 28 + const geoDialog = Dialog.useDialogControl() 29 + const {setDeviceGeolocation} = useDeviceGeolocationApi() 19 30 20 31 useEffect(() => { 21 32 // just counting overall hits here ··· 51 62 ] 52 63 53 64 return ( 54 - <ScrollView 55 - contentContainerStyle={[ 56 - a.px_2xl, 57 - { 58 - paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding, 59 - paddingBottom: 100, 60 - }, 61 - ]}> 62 - <View 63 - style={[ 64 - a.mx_auto, 65 - web({ 66 - maxWidth: 440, 67 - paddingTop: gtPhone ? '8vh' : undefined, 68 - }), 65 + <> 66 + <ScrollView 67 + contentContainerStyle={[ 68 + a.px_2xl, 69 + { 70 + paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding, 71 + paddingBottom: 100, 72 + }, 69 73 ]}> 70 - <View style={[a.align_start]}> 71 - <View 72 - style={[ 73 - a.pl_md, 74 - a.pr_lg, 75 - a.py_sm, 76 - a.rounded_full, 77 - a.flex_row, 78 - a.align_center, 79 - a.gap_xs, 80 - { 81 - backgroundColor: t.palette.primary_25, 82 - }, 83 - ]}> 84 - <Mark fill={t.palette.primary_600} width={14} /> 85 - <Text 74 + <View 75 + style={[ 76 + a.mx_auto, 77 + web({ 78 + maxWidth: 380, 79 + paddingTop: gtPhone ? '8vh' : undefined, 80 + }), 81 + ]}> 82 + <View style={[a.align_start]}> 83 + <View 86 84 style={[ 87 - a.font_bold, 85 + a.pl_md, 86 + a.pr_lg, 87 + a.py_sm, 88 + a.rounded_full, 89 + a.flex_row, 90 + a.align_center, 91 + a.gap_xs, 88 92 { 89 - color: t.palette.primary_600, 93 + backgroundColor: t.palette.primary_25, 90 94 }, 91 95 ]}> 92 - <Trans>Announcement</Trans> 93 - </Text> 96 + <Mark fill={t.palette.primary_600} width={14} /> 97 + <Text 98 + style={[ 99 + a.font_bold, 100 + { 101 + color: t.palette.primary_600, 102 + }, 103 + ]}> 104 + <Trans>Announcement</Trans> 105 + </Text> 106 + </View> 107 + </View> 108 + 109 + <View style={[a.gap_lg, {paddingTop: 32}]}> 110 + {blocks.map((block, index) => ( 111 + <Text key={index} style={[textStyles]}> 112 + {block} 113 + </Text> 114 + ))} 94 115 </View> 95 - </View> 116 + 117 + {!isWeb && ( 118 + <> 119 + <View style={[a.pt_2xl]}> 120 + <Divider /> 121 + </View> 122 + 123 + <View style={[a.mt_xl, a.align_start]}> 124 + <Text 125 + style={[a.text_lg, a.font_heavy, a.leading_snug, a.pb_xs]}> 126 + <Trans>Not in Mississippi?</Trans> 127 + </Text> 128 + <Text 129 + style={[ 130 + a.text_sm, 131 + a.leading_snug, 132 + t.atoms.text_contrast_medium, 133 + a.pb_md, 134 + ]}> 135 + <Trans> 136 + Confirm your location with GPS. Your location data is not 137 + tracked and does not leave your device. 138 + </Trans> 139 + </Text> 140 + <Button 141 + label={_(msg`Confirm your location`)} 142 + onPress={() => geoDialog.open()} 143 + size="small" 144 + color="primary_subtle"> 145 + <ButtonIcon icon={LocationIcon} /> 146 + <ButtonText> 147 + <Trans>Confirm your location</Trans> 148 + </ButtonText> 149 + </Button> 150 + </View> 151 + 152 + <DeviceLocationRequestDialog 153 + control={geoDialog} 154 + onLocationAcquired={props => { 155 + if (props.geolocationStatus.isAgeBlockedGeo) { 156 + props.disableDialogAction() 157 + props.setDialogError( 158 + _( 159 + 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.`, 160 + ), 161 + ) 162 + } else { 163 + props.closeDialog(() => { 164 + // set this after close! 165 + setDeviceGeolocation({ 166 + countryCode: props.geolocationStatus.countryCode, 167 + regionCode: props.geolocationStatus.regionCode, 168 + }) 169 + Toast.show(_(msg`Thanks! You're all set.`), { 170 + type: 'success', 171 + }) 172 + }) 173 + } 174 + }} 175 + /> 176 + </> 177 + )} 96 178 97 - <View style={[a.gap_lg, {paddingTop: 32, paddingBottom: 48}]}> 98 - {blocks.map((block, index) => ( 99 - <Text key={index} style={[textStyles]}> 100 - {block} 101 - </Text> 102 - ))} 179 + <View style={[{paddingTop: 48}]}> 180 + <Logo width={120} textFill={t.atoms.text.color} /> 181 + </View> 103 182 </View> 183 + </ScrollView> 104 184 105 - <Logo width={120} textFill={t.atoms.text.color} /> 106 - </View> 107 - </ScrollView> 185 + {/* 186 + * While this blocking overlay is up, other dialogs in the shell 187 + * are not mounted, so it _should_ be safe to use these here 188 + * without fear of other modals showing up. 189 + */} 190 + <BottomSheetOutlet /> 191 + <PortalOutlet /> 192 + </> 108 193 ) 109 194 }
+49 -1
src/components/ageAssurance/AgeAssuranceAccountCard.tsx
··· 3 3 import {useLingui} from '@lingui/react' 4 4 5 5 import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo' 6 + import {isNative} from '#/platform/detection' 6 7 import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 7 8 import {logger} from '#/state/ageAssurance/util' 9 + import {useDeviceGeolocationApi} from '#/state/geolocation' 8 10 import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf' 9 11 import {Admonition} from '#/components/Admonition' 10 12 import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog' ··· 16 18 import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' 17 19 import {Button, ButtonText} from '#/components/Button' 18 20 import * as Dialog from '#/components/Dialog' 21 + import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog' 19 22 import {Divider} from '#/components/Divider' 20 23 import {createStaticClick, InlineLinkText} from '#/components/Link' 24 + import * as Toast from '#/components/Toast' 21 25 import {Text} from '#/components/Typography' 22 26 23 27 export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) { ··· 35 39 const {_, i18n} = useLingui() 36 40 const control = useDialogControl() 37 41 const appealControl = Dialog.useDialogControl() 42 + const locationControl = Dialog.useDialogControl() 38 43 const getTimeAgo = useGetTimeAgo() 39 44 const {gtPhone} = useBreakpoints() 45 + const {setDeviceGeolocation} = useDeviceGeolocationApi() 40 46 41 47 const copy = useAgeAssuranceCopy() 42 48 const {status, lastInitiatedAt} = useAgeAssurance() ··· 71 77 </View> 72 78 </View> 73 79 74 - <View style={[a.pb_md]}> 80 + <View style={[a.pb_md, a.gap_xs]}> 75 81 <Text style={[a.text_sm, a.leading_snug]}>{copy.notice}</Text> 82 + 83 + {isNative && ( 84 + <> 85 + <Text style={[a.text_sm, a.leading_snug]}> 86 + <Trans> 87 + Is your location not accurate?{' '} 88 + <InlineLinkText 89 + label={_(msg`Confirm your location`)} 90 + {...createStaticClick(() => { 91 + locationControl.open() 92 + })}> 93 + Click here to confirm your location. 94 + </InlineLinkText>{' '} 95 + </Trans> 96 + </Text> 97 + 98 + <DeviceLocationRequestDialog 99 + control={locationControl} 100 + onLocationAcquired={props => { 101 + if (props.geolocationStatus.isAgeRestrictedGeo) { 102 + props.disableDialogAction() 103 + props.setDialogError( 104 + _( 105 + msg`We're sorry, but based on your device's location, you are currently located in a region that requires age assurance.`, 106 + ), 107 + ) 108 + } else { 109 + props.closeDialog(() => { 110 + // set this after close! 111 + setDeviceGeolocation({ 112 + countryCode: props.geolocationStatus.countryCode, 113 + regionCode: props.geolocationStatus.regionCode, 114 + }) 115 + Toast.show(_(msg`Thanks! You're all set.`), { 116 + type: 'success', 117 + }) 118 + }) 119 + } 120 + }} 121 + /> 122 + </> 123 + )} 76 124 </View> 77 125 78 126 {isBlocked ? (
+171
src/components/dialogs/DeviceLocationRequestDialog.tsx
··· 1 + import {useState} from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {wait} from '#/lib/async/wait' 7 + import {isNetworkError, useCleanError} from '#/lib/hooks/useCleanError' 8 + import {logger} from '#/logger' 9 + import {isWeb} from '#/platform/detection' 10 + import { 11 + computeGeolocationStatus, 12 + type GeolocationStatus, 13 + useGeolocationConfig, 14 + } from '#/state/geolocation' 15 + import {useRequestDeviceLocation} from '#/state/geolocation/useRequestDeviceLocation' 16 + import {atoms as a, useTheme, web} from '#/alf' 17 + import {Admonition} from '#/components/Admonition' 18 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 19 + import * as Dialog from '#/components/Dialog' 20 + import {PinLocation_Stroke2_Corner0_Rounded as LocationIcon} from '#/components/icons/PinLocation' 21 + import {Loader} from '#/components/Loader' 22 + import {Text} from '#/components/Typography' 23 + 24 + export type Props = { 25 + onLocationAcquired?: (props: { 26 + geolocationStatus: GeolocationStatus 27 + setDialogError: (error: string) => void 28 + disableDialogAction: () => void 29 + closeDialog: (callback?: () => void) => void 30 + }) => void 31 + } 32 + 33 + export function DeviceLocationRequestDialog({ 34 + control, 35 + onLocationAcquired, 36 + }: Props & { 37 + control: Dialog.DialogOuterProps['control'] 38 + }) { 39 + const {_} = useLingui() 40 + return ( 41 + <Dialog.Outer control={control}> 42 + <Dialog.Handle /> 43 + 44 + <Dialog.ScrollableInner 45 + label={_(msg`Confirm your location`)} 46 + style={[web({maxWidth: 380})]}> 47 + <DeviceLocationRequestDialogInner 48 + onLocationAcquired={onLocationAcquired} 49 + /> 50 + <Dialog.Close /> 51 + </Dialog.ScrollableInner> 52 + </Dialog.Outer> 53 + ) 54 + } 55 + 56 + function DeviceLocationRequestDialogInner({onLocationAcquired}: Props) { 57 + const t = useTheme() 58 + const {_} = useLingui() 59 + const {close} = Dialog.useDialogContext() 60 + const requestDeviceLocation = useRequestDeviceLocation() 61 + const {config} = useGeolocationConfig() 62 + const cleanError = useCleanError() 63 + 64 + const [isRequesting, setIsRequesting] = useState(false) 65 + const [error, setError] = useState<string>('') 66 + const [dialogDisabled, setDialogDisabled] = useState(false) 67 + 68 + const onPressConfirm = async () => { 69 + setError('') 70 + setIsRequesting(true) 71 + 72 + try { 73 + const req = await wait(1e3, requestDeviceLocation()) 74 + 75 + if (req.granted) { 76 + const location = req.location 77 + 78 + if (location && location.countryCode) { 79 + const geolocationStatus = computeGeolocationStatus(location, config) 80 + onLocationAcquired?.({ 81 + geolocationStatus, 82 + setDialogError: setError, 83 + disableDialogAction: () => setDialogDisabled(true), 84 + closeDialog: close, 85 + }) 86 + } else { 87 + setError(_(msg`Failed to resolve location. Please try again.`)) 88 + } 89 + } else { 90 + setError( 91 + _( 92 + msg`Unable to access location. You'll need to visit your system settings to enable location services for Bluesky`, 93 + ), 94 + ) 95 + } 96 + } catch (e: any) { 97 + const {clean, raw} = cleanError(e) 98 + setError(clean || raw || e.message) 99 + if (!isNetworkError(e)) { 100 + logger.error(`blockedGeoOverlay: unexpected error`, { 101 + safeMessage: e.message, 102 + }) 103 + } 104 + } finally { 105 + setIsRequesting(false) 106 + } 107 + } 108 + 109 + return ( 110 + <View style={[a.gap_md]}> 111 + <Text style={[a.text_xl, a.font_heavy]}> 112 + <Trans>Confirm your location</Trans> 113 + </Text> 114 + <View style={[a.gap_sm, a.pb_xs]}> 115 + <Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}> 116 + <Trans> 117 + Click below to allow Bluesky to access your GPS location. We will 118 + then use that data to more accurately determine the content and 119 + features available in your region. 120 + </Trans> 121 + </Text> 122 + 123 + <Text 124 + style={[ 125 + a.text_md, 126 + a.leading_snug, 127 + t.atoms.text_contrast_medium, 128 + a.pb_xs, 129 + ]}> 130 + <Trans> 131 + Your location data is not tracked and does not leave your device. 132 + </Trans> 133 + </Text> 134 + </View> 135 + 136 + {error && ( 137 + <View style={[a.pb_xs]}> 138 + <Admonition type="error">{error}</Admonition> 139 + </View> 140 + )} 141 + 142 + <View style={[a.gap_sm]}> 143 + {!dialogDisabled && ( 144 + <Button 145 + disabled={isRequesting} 146 + label={_(msg`Confirm your location`)} 147 + onPress={onPressConfirm} 148 + size={isWeb ? 'small' : 'large'} 149 + color="primary"> 150 + <ButtonIcon icon={isRequesting ? Loader : LocationIcon} /> 151 + <ButtonText> 152 + <Trans>Allow location access</Trans> 153 + </ButtonText> 154 + </Button> 155 + )} 156 + 157 + {!isWeb && ( 158 + <Button 159 + label={_(msg`Confirm your location`)} 160 + onPress={() => close()} 161 + size={isWeb ? 'small' : 'large'} 162 + color="secondary"> 163 + <ButtonText> 164 + <Trans>Cancel</Trans> 165 + </ButtonText> 166 + </Button> 167 + )} 168 + </View> 169 + </View> 170 + ) 171 + }
+9
src/components/icons/PinLocation.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const PinLocation_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + }) 6 + 7 + export const PinLocationFilled_Stroke2_Corner0_Rounded = createSinglePathSVG({ 8 + 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', 9 + })
+13
src/env/common.ts
··· 93 93 process.env.EXPO_PUBLIC_GCP_PROJECT_ID === undefined 94 94 ? 0 95 95 : Number(process.env.EXPO_PUBLIC_GCP_PROJECT_ID) 96 + 97 + /** 98 + * URL for the bapp-config web worker _development_ environment. Can be a 99 + * locally running server, see `env.example` for more. 100 + */ 101 + export const BAPP_CONFIG_DEV_URL = process.env.BAPP_CONFIG_DEV_URL 102 + 103 + /** 104 + * Dev environment passthrough value for bapp-config web worker. Allows local 105 + * dev access to the web worker running in `development` mode. 106 + */ 107 + export const BAPP_CONFIG_DEV_BYPASS_SECRET: string = 108 + process.env.BAPP_CONFIG_DEV_BYPASS_SECRET
+2 -2
src/lib/currency.ts
··· 1 1 import React from 'react' 2 2 3 3 import {deviceLocales} from '#/locale/deviceLocales' 4 - import {useGeolocation} from '#/state/geolocation' 4 + import {useGeolocationStatus} from '#/state/geolocation' 5 5 import {useLanguagePrefs} from '#/state/preferences' 6 6 7 7 /** ··· 275 275 export function useFormatCurrency( 276 276 options?: Parameters<typeof Intl.NumberFormat>[1], 277 277 ) { 278 - const {geolocation} = useGeolocation() 278 + const {location: geolocation} = useGeolocationStatus() 279 279 const {appLanguage} = useLanguagePrefs() 280 280 return React.useMemo(() => { 281 281 const locale = deviceLocales.at(0)
+1
src/logger/types.ts
··· 14 14 PostSource = 'post-source', 15 15 AgeAssurance = 'age-assurance', 16 16 PolicyUpdate = 'policy-update', 17 + Geolocation = 'geolocation', 17 18 18 19 /** 19 20 * METRIC IS FOR INTERNAL USE ONLY, don't create any other loggers using this
+2 -2
src/state/ageAssurance/index.tsx
··· 11 11 } from '#/state/ageAssurance/types' 12 12 import {useIsAgeAssuranceEnabled} from '#/state/ageAssurance/useIsAgeAssuranceEnabled' 13 13 import {logger} from '#/state/ageAssurance/util' 14 - import {useGeolocation} from '#/state/geolocation' 14 + import {useGeolocationStatus} from '#/state/geolocation' 15 15 import {useAgent} from '#/state/session' 16 16 17 17 export const createAgeAssuranceQueryKey = (did: string) => ··· 43 43 */ 44 44 export function Provider({children}: {children: React.ReactNode}) { 45 45 const agent = useAgent() 46 - const {geolocation} = useGeolocation() 46 + const {status: geolocation} = useGeolocationStatus() 47 47 const isAgeAssuranceEnabled = useIsAgeAssuranceEnabled() 48 48 const getAndRegisterPushToken = useGetAndRegisterPushToken() 49 49 const [refetchWhilePending, setRefetchWhilePending] = useState(false)
+2 -2
src/state/ageAssurance/useInitAgeAssurance.ts
··· 14 14 import {isNetworkError} from '#/lib/hooks/useCleanError' 15 15 import {logger} from '#/logger' 16 16 import {createAgeAssuranceQueryKey} from '#/state/ageAssurance' 17 - import {useGeolocation} from '#/state/geolocation' 17 + import {useGeolocationStatus} from '#/state/geolocation' 18 18 import {useAgent} from '#/state/session' 19 19 20 20 let APPVIEW = PUBLIC_APPVIEW ··· 36 36 export function useInitAgeAssurance() { 37 37 const qc = useQueryClient() 38 38 const agent = useAgent() 39 - const {geolocation} = useGeolocation() 39 + const {status: geolocation} = useGeolocationStatus() 40 40 return useMutation({ 41 41 async mutationFn( 42 42 props: Omit<AppBskyUnspeccedInitAgeAssurance.InputSchema, 'countryCode'>,
+2 -2
src/state/ageAssurance/useIsAgeAssuranceEnabled.ts
··· 1 1 import {useMemo} from 'react' 2 2 3 - import {useGeolocation} from '#/state/geolocation' 3 + import {useGeolocationStatus} from '#/state/geolocation' 4 4 5 5 export function useIsAgeAssuranceEnabled() { 6 - const {geolocation} = useGeolocation() 6 + const {status: geolocation} = useGeolocationStatus() 7 7 8 8 return useMemo(() => { 9 9 return !!geolocation?.isAgeRestrictedGeo
-226
src/state/geolocation.tsx
··· 1 - import React from 'react' 2 - import EventEmitter from 'eventemitter3' 3 - 4 - import {networkRetry} from '#/lib/async/retry' 5 - import {logger} from '#/logger' 6 - import {type Device, device} from '#/storage' 7 - 8 - const IPCC_URL = `https://bsky.app/ipcc` 9 - const BAPP_CONFIG_URL = `https://ip.bsky.app/config` 10 - 11 - const events = new EventEmitter() 12 - const EVENT = 'geolocation-updated' 13 - const emitGeolocationUpdate = (geolocation: Device['geolocation']) => { 14 - events.emit(EVENT, geolocation) 15 - } 16 - const onGeolocationUpdate = ( 17 - listener: (geolocation: Device['geolocation']) => void, 18 - ) => { 19 - events.on(EVENT, listener) 20 - return () => { 21 - events.off(EVENT, listener) 22 - } 23 - } 24 - 25 - /** 26 - * Default geolocation value. IF undefined, we fail closed and apply all 27 - * additional mod authorities. 28 - */ 29 - export const DEFAULT_GEOLOCATION: Device['geolocation'] = { 30 - countryCode: undefined, 31 - isAgeBlockedGeo: undefined, 32 - isAgeRestrictedGeo: false, 33 - } 34 - 35 - function sanitizeGeolocation( 36 - geolocation: Device['geolocation'], 37 - ): Device['geolocation'] { 38 - return { 39 - countryCode: geolocation?.countryCode ?? undefined, 40 - isAgeBlockedGeo: geolocation?.isAgeBlockedGeo ?? false, 41 - isAgeRestrictedGeo: geolocation?.isAgeRestrictedGeo ?? false, 42 - } 43 - } 44 - 45 - async function getGeolocation(url: string): Promise<Device['geolocation']> { 46 - const res = await fetch(url) 47 - 48 - if (!res.ok) { 49 - throw new Error(`geolocation: lookup failed ${res.status}`) 50 - } 51 - 52 - const json = await res.json() 53 - 54 - if (json.countryCode) { 55 - return { 56 - countryCode: json.countryCode, 57 - isAgeBlockedGeo: json.isAgeBlockedGeo ?? false, 58 - isAgeRestrictedGeo: json.isAgeRestrictedGeo ?? false, 59 - // @ts-ignore 60 - regionCode: json.regionCode ?? undefined, 61 - } 62 - } else { 63 - return undefined 64 - } 65 - } 66 - 67 - async function compareWithIPCC(bapp: Device['geolocation']) { 68 - try { 69 - const ipcc = await getGeolocation(IPCC_URL) 70 - 71 - if (!ipcc || !bapp) return 72 - 73 - logger.metric( 74 - 'geo:debug', 75 - { 76 - bappCountryCode: bapp.countryCode, 77 - // @ts-ignore 78 - bappRegionCode: bapp.regionCode, 79 - bappIsAgeBlockedGeo: bapp.isAgeBlockedGeo, 80 - bappIsAgeRestrictedGeo: bapp.isAgeRestrictedGeo, 81 - ipccCountryCode: ipcc.countryCode, 82 - ipccIsAgeBlockedGeo: ipcc.isAgeBlockedGeo, 83 - ipccIsAgeRestrictedGeo: ipcc.isAgeRestrictedGeo, 84 - }, 85 - { 86 - statsig: false, 87 - }, 88 - ) 89 - } catch {} 90 - } 91 - 92 - /** 93 - * Local promise used within this file only. 94 - */ 95 - let geolocationResolution: Promise<{success: boolean}> | undefined 96 - 97 - /** 98 - * Begin the process of resolving geolocation. This should be called once at 99 - * app start. 100 - * 101 - * THIS METHOD SHOULD NEVER THROW. 102 - * 103 - * This method is otherwise not used for any purpose. To ensure geolocation is 104 - * resolved, use {@link ensureGeolocationResolved} 105 - */ 106 - export function beginResolveGeolocation() { 107 - /** 108 - * In dev, IP server is unavailable, so we just set the default geolocation 109 - * and fail closed. 110 - */ 111 - if (__DEV__) { 112 - geolocationResolution = new Promise(y => y({success: true})) 113 - if (!device.get(['geolocation'])) { 114 - device.set(['geolocation'], DEFAULT_GEOLOCATION) 115 - } 116 - return 117 - } 118 - 119 - geolocationResolution = new Promise(async resolve => { 120 - let success = true 121 - 122 - try { 123 - // Try once, fail fast 124 - const geolocation = await getGeolocation(BAPP_CONFIG_URL) 125 - if (geolocation) { 126 - device.set(['geolocation'], sanitizeGeolocation(geolocation)) 127 - emitGeolocationUpdate(geolocation) 128 - logger.debug(`geolocation: success`, {geolocation}) 129 - compareWithIPCC(geolocation) 130 - } else { 131 - // endpoint should throw on all failures, this is insurance 132 - throw new Error(`geolocation: nothing returned from initial request`) 133 - } 134 - } catch (e: any) { 135 - success = false 136 - 137 - logger.debug(`geolocation: failed initial request`, { 138 - safeMessage: e.message, 139 - }) 140 - 141 - // set to default 142 - device.set(['geolocation'], DEFAULT_GEOLOCATION) 143 - 144 - // retry 3 times, but don't await, proceed with default 145 - networkRetry(3, () => getGeolocation(BAPP_CONFIG_URL)) 146 - .then(geolocation => { 147 - if (geolocation) { 148 - device.set(['geolocation'], sanitizeGeolocation(geolocation)) 149 - emitGeolocationUpdate(geolocation) 150 - logger.debug(`geolocation: success`, {geolocation}) 151 - success = true 152 - compareWithIPCC(geolocation) 153 - } else { 154 - // endpoint should throw on all failures, this is insurance 155 - throw new Error(`geolocation: nothing returned from retries`) 156 - } 157 - }) 158 - .catch((e: any) => { 159 - // complete fail closed 160 - logger.debug(`geolocation: failed retries`, {safeMessage: e.message}) 161 - }) 162 - } finally { 163 - resolve({success}) 164 - } 165 - }) 166 - } 167 - 168 - /** 169 - * Ensure that geolocation has been resolved, or at the very least attempted 170 - * once. Subsequent retries will not be captured by this `await`. Those will be 171 - * reported via {@link events}. 172 - */ 173 - export async function ensureGeolocationResolved() { 174 - if (!geolocationResolution) { 175 - throw new Error(`geolocation: beginResolveGeolocation not called yet`) 176 - } 177 - 178 - const cached = device.get(['geolocation']) 179 - if (cached) { 180 - logger.debug(`geolocation: using cache`, {cached}) 181 - } else { 182 - logger.debug(`geolocation: no cache`) 183 - const {success} = await geolocationResolution 184 - if (success) { 185 - logger.debug(`geolocation: resolved`, { 186 - resolved: device.get(['geolocation']), 187 - }) 188 - } else { 189 - logger.error(`geolocation: failed to resolve`) 190 - } 191 - } 192 - } 193 - 194 - type Context = { 195 - geolocation: Device['geolocation'] 196 - } 197 - 198 - const context = React.createContext<Context>({ 199 - geolocation: DEFAULT_GEOLOCATION, 200 - }) 201 - context.displayName = 'GeolocationContext' 202 - 203 - export function Provider({children}: {children: React.ReactNode}) { 204 - const [geolocation, setGeolocation] = React.useState(() => { 205 - const initial = device.get(['geolocation']) || DEFAULT_GEOLOCATION 206 - return initial 207 - }) 208 - 209 - React.useEffect(() => { 210 - return onGeolocationUpdate(geolocation => { 211 - setGeolocation(geolocation!) 212 - }) 213 - }, []) 214 - 215 - const ctx = React.useMemo(() => { 216 - return { 217 - geolocation, 218 - } 219 - }, [geolocation]) 220 - 221 - return <context.Provider value={ctx}>{children}</context.Provider> 222 - } 223 - 224 - export function useGeolocation() { 225 - return React.useContext(context) 226 - }
+143
src/state/geolocation/config.ts
··· 1 + import {networkRetry} from '#/lib/async/retry' 2 + import { 3 + DEFAULT_GEOLOCATION_CONFIG, 4 + GEOLOCATION_CONFIG_URL, 5 + } from '#/state/geolocation/const' 6 + import {emitGeolocationConfigUpdate} from '#/state/geolocation/events' 7 + import {logger} from '#/state/geolocation/logger' 8 + import {BAPP_CONFIG_DEV_BYPASS_SECRET, IS_DEV} from '#/env' 9 + import {type Device, device} from '#/storage' 10 + 11 + async function getGeolocationConfig( 12 + url: string, 13 + ): Promise<Device['geolocation']> { 14 + const res = await fetch(url, { 15 + headers: IS_DEV 16 + ? { 17 + 'x-dev-bypass-secret': BAPP_CONFIG_DEV_BYPASS_SECRET, 18 + } 19 + : undefined, 20 + }) 21 + 22 + if (!res.ok) { 23 + throw new Error(`geolocation config: fetch failed ${res.status}`) 24 + } 25 + 26 + const json = await res.json() 27 + 28 + if (json.countryCode) { 29 + /** 30 + * Only construct known values here, ignore any extras. 31 + */ 32 + const config: Device['geolocation'] = { 33 + countryCode: json.countryCode, 34 + regionCode: json.regionCode ?? undefined, 35 + ageRestrictedGeos: json.ageRestrictedGeos ?? [], 36 + ageBlockedGeos: json.ageBlockedGeos ?? [], 37 + } 38 + logger.debug(`geolocation config: success`) 39 + return config 40 + } else { 41 + return undefined 42 + } 43 + } 44 + 45 + /** 46 + * Local promise used within this file only. 47 + */ 48 + let geolocationConfigResolution: Promise<{success: boolean}> | undefined 49 + 50 + /** 51 + * Begin the process of resolving geolocation config. This should be called 52 + * once at app start. 53 + * 54 + * THIS METHOD SHOULD NEVER THROW. 55 + * 56 + * This method is otherwise not used for any purpose. To ensure geolocation 57 + * config is resolved, use {@link ensureGeolocationConfigIsResolved} 58 + */ 59 + export function beginResolveGeolocationConfig() { 60 + /** 61 + * Here for debug purposes. Uncomment to prevent hitting the remote geo service, and apply whatever data you require for testing. 62 + */ 63 + // if (__DEV__) { 64 + // geolocationConfigResolution = new Promise(y => y({success: true})) 65 + // device.set(['deviceGeolocation'], undefined) // clears GPS data 66 + // device.set(['geolocation'], DEFAULT_GEOLOCATION_CONFIG) // clears bapp-config data 67 + // return 68 + // } 69 + 70 + geolocationConfigResolution = new Promise(async resolve => { 71 + let success = true 72 + 73 + try { 74 + // Try once, fail fast 75 + const config = await getGeolocationConfig(GEOLOCATION_CONFIG_URL) 76 + if (config) { 77 + device.set(['geolocation'], config) 78 + emitGeolocationConfigUpdate(config) 79 + } else { 80 + // endpoint should throw on all failures, this is insurance 81 + throw new Error( 82 + `geolocation config: nothing returned from initial request`, 83 + ) 84 + } 85 + } catch (e: any) { 86 + success = false 87 + 88 + logger.debug(`geolocation config: failed initial request`, { 89 + safeMessage: e.message, 90 + }) 91 + 92 + // set to default 93 + device.set(['geolocation'], DEFAULT_GEOLOCATION_CONFIG) 94 + 95 + // retry 3 times, but don't await, proceed with default 96 + networkRetry(3, () => getGeolocationConfig(GEOLOCATION_CONFIG_URL)) 97 + .then(config => { 98 + if (config) { 99 + device.set(['geolocation'], config) 100 + emitGeolocationConfigUpdate(config) 101 + success = true 102 + } else { 103 + // endpoint should throw on all failures, this is insurance 104 + throw new Error(`geolocation config: nothing returned from retries`) 105 + } 106 + }) 107 + .catch((e: any) => { 108 + // complete fail closed 109 + logger.debug(`geolocation config: failed retries`, { 110 + safeMessage: e.message, 111 + }) 112 + }) 113 + } finally { 114 + resolve({success}) 115 + } 116 + }) 117 + } 118 + 119 + /** 120 + * Ensure that geolocation config has been resolved, or at the very least attempted 121 + * once. Subsequent retries will not be captured by this `await`. Those will be 122 + * reported via {@link emitGeolocationConfigUpdate}. 123 + */ 124 + export async function ensureGeolocationConfigIsResolved() { 125 + if (!geolocationConfigResolution) { 126 + throw new Error( 127 + `geolocation config: beginResolveGeolocationConfig not called yet`, 128 + ) 129 + } 130 + 131 + const cached = device.get(['geolocation']) 132 + if (cached) { 133 + logger.debug(`geolocation config: using cache`) 134 + } else { 135 + logger.debug(`geolocation config: no cache`) 136 + const {success} = await geolocationConfigResolution 137 + if (success) { 138 + logger.debug(`geolocation config: resolved`) 139 + } else { 140 + logger.info(`geolocation config: failed to resolve`) 141 + } 142 + } 143 + }
+30
src/state/geolocation/const.ts
··· 1 + import {type GeolocationStatus} from '#/state/geolocation/types' 2 + import {BAPP_CONFIG_DEV_URL, IS_DEV} from '#/env' 3 + import {type Device} from '#/storage' 4 + 5 + export const IPCC_URL = `https://bsky.app/ipcc` 6 + export const BAPP_CONFIG_URL_PROD = `https://ip.bsky.app/config` 7 + export const BAPP_CONFIG_URL = IS_DEV 8 + ? (BAPP_CONFIG_DEV_URL ?? BAPP_CONFIG_URL_PROD) 9 + : BAPP_CONFIG_URL_PROD 10 + export const GEOLOCATION_CONFIG_URL = BAPP_CONFIG_URL 11 + 12 + /** 13 + * Default geolocation config. 14 + */ 15 + export const DEFAULT_GEOLOCATION_CONFIG: Device['geolocation'] = { 16 + countryCode: undefined, 17 + regionCode: undefined, 18 + ageRestrictedGeos: [], 19 + ageBlockedGeos: [], 20 + } 21 + 22 + /** 23 + * Default geolocation status. 24 + */ 25 + export const DEFAULT_GEOLOCATION_STATUS: GeolocationStatus = { 26 + countryCode: undefined, 27 + regionCode: undefined, 28 + isAgeRestrictedGeo: false, 29 + isAgeBlockedGeo: false, 30 + }
+19
src/state/geolocation/events.ts
··· 1 + import EventEmitter from 'eventemitter3' 2 + 3 + import {type Device} from '#/storage' 4 + 5 + const events = new EventEmitter() 6 + const EVENT = 'geolocation-config-updated' 7 + 8 + export const emitGeolocationConfigUpdate = (config: Device['geolocation']) => { 9 + events.emit(EVENT, config) 10 + } 11 + 12 + export const onGeolocationConfigUpdate = ( 13 + listener: (config: Device['geolocation']) => void, 14 + ) => { 15 + events.on(EVENT, listener) 16 + return () => { 17 + events.off(EVENT, listener) 18 + } 19 + }
+153
src/state/geolocation/index.tsx
··· 1 + import React from 'react' 2 + 3 + import { 4 + DEFAULT_GEOLOCATION_CONFIG, 5 + DEFAULT_GEOLOCATION_STATUS, 6 + } from '#/state/geolocation/const' 7 + import {onGeolocationConfigUpdate} from '#/state/geolocation/events' 8 + import {logger} from '#/state/geolocation/logger' 9 + import { 10 + type DeviceLocation, 11 + type GeolocationStatus, 12 + } from '#/state/geolocation/types' 13 + import {useSyncedDeviceGeolocation} from '#/state/geolocation/useSyncedDeviceGeolocation' 14 + import { 15 + computeGeolocationStatus, 16 + mergeGeolocation, 17 + } from '#/state/geolocation/util' 18 + import {type Device, device} from '#/storage' 19 + 20 + export * from '#/state/geolocation/config' 21 + export * from '#/state/geolocation/types' 22 + export * from '#/state/geolocation/util' 23 + 24 + type DeviceGeolocationContext = { 25 + deviceGeolocation: DeviceLocation | undefined 26 + } 27 + 28 + type DeviceGeolocationAPIContext = { 29 + setDeviceGeolocation(deviceGeolocation: DeviceLocation): void 30 + } 31 + 32 + type GeolocationConfigContext = { 33 + config: Device['geolocation'] 34 + } 35 + 36 + type GeolocationStatusContext = { 37 + /** 38 + * Merged geolocation from config and device GPS (if available). 39 + */ 40 + location: DeviceLocation 41 + /** 42 + * Computed geolocation status based on the merged location and config. 43 + */ 44 + status: GeolocationStatus 45 + } 46 + 47 + const DeviceGeolocationContext = React.createContext<DeviceGeolocationContext>({ 48 + deviceGeolocation: undefined, 49 + }) 50 + DeviceGeolocationContext.displayName = 'DeviceGeolocationContext' 51 + 52 + const DeviceGeolocationAPIContext = 53 + React.createContext<DeviceGeolocationAPIContext>({ 54 + setDeviceGeolocation: () => {}, 55 + }) 56 + DeviceGeolocationAPIContext.displayName = 'DeviceGeolocationAPIContext' 57 + 58 + const GeolocationConfigContext = React.createContext<GeolocationConfigContext>({ 59 + config: DEFAULT_GEOLOCATION_CONFIG, 60 + }) 61 + GeolocationConfigContext.displayName = 'GeolocationConfigContext' 62 + 63 + const GeolocationStatusContext = React.createContext<GeolocationStatusContext>({ 64 + location: { 65 + countryCode: undefined, 66 + regionCode: undefined, 67 + }, 68 + status: DEFAULT_GEOLOCATION_STATUS, 69 + }) 70 + GeolocationStatusContext.displayName = 'GeolocationStatusContext' 71 + 72 + /** 73 + * Provider of geolocation config and computed geolocation status. 74 + */ 75 + export function GeolocationStatusProvider({ 76 + children, 77 + }: { 78 + children: React.ReactNode 79 + }) { 80 + const {deviceGeolocation} = React.useContext(DeviceGeolocationContext) 81 + const [config, setConfig] = React.useState(() => { 82 + const initial = device.get(['geolocation']) || DEFAULT_GEOLOCATION_CONFIG 83 + return initial 84 + }) 85 + 86 + React.useEffect(() => { 87 + return onGeolocationConfigUpdate(config => { 88 + setConfig(config!) 89 + }) 90 + }, []) 91 + 92 + const configContext = React.useMemo(() => ({config}), [config]) 93 + const statusContext = React.useMemo(() => { 94 + if (deviceGeolocation) { 95 + logger.debug('geolocation: has device geolocation available') 96 + } 97 + const geolocation = mergeGeolocation(deviceGeolocation, config) 98 + const status = computeGeolocationStatus(geolocation, config) 99 + return {location: geolocation, status} 100 + }, [config, deviceGeolocation]) 101 + 102 + return ( 103 + <GeolocationConfigContext.Provider value={configContext}> 104 + <GeolocationStatusContext.Provider value={statusContext}> 105 + {children} 106 + </GeolocationStatusContext.Provider> 107 + </GeolocationConfigContext.Provider> 108 + ) 109 + } 110 + 111 + /** 112 + * Provider of providers. Provides device geolocation data to lower-level 113 + * `GeolocationStatusProvider`, and device geolocation APIs to children. 114 + */ 115 + export function Provider({children}: {children: React.ReactNode}) { 116 + const [deviceGeolocation, setDeviceGeolocation] = useSyncedDeviceGeolocation() 117 + 118 + const handleSetDeviceGeolocation = React.useCallback( 119 + (location: DeviceLocation) => { 120 + logger.debug('geolocation: setting device geolocation') 121 + setDeviceGeolocation({ 122 + countryCode: location.countryCode ?? undefined, 123 + regionCode: location.regionCode ?? undefined, 124 + }) 125 + }, 126 + [setDeviceGeolocation], 127 + ) 128 + 129 + return ( 130 + <DeviceGeolocationAPIContext.Provider 131 + value={React.useMemo( 132 + () => ({setDeviceGeolocation: handleSetDeviceGeolocation}), 133 + [handleSetDeviceGeolocation], 134 + )}> 135 + <DeviceGeolocationContext.Provider 136 + value={React.useMemo(() => ({deviceGeolocation}), [deviceGeolocation])}> 137 + <GeolocationStatusProvider>{children}</GeolocationStatusProvider> 138 + </DeviceGeolocationContext.Provider> 139 + </DeviceGeolocationAPIContext.Provider> 140 + ) 141 + } 142 + 143 + export function useDeviceGeolocationApi() { 144 + return React.useContext(DeviceGeolocationAPIContext) 145 + } 146 + 147 + export function useGeolocationConfig() { 148 + return React.useContext(GeolocationConfigContext) 149 + } 150 + 151 + export function useGeolocationStatus() { 152 + return React.useContext(GeolocationStatusContext) 153 + }
+3
src/state/geolocation/logger.ts
··· 1 + import {Logger} from '#/logger' 2 + 3 + export const logger = Logger.create(Logger.Context.Geolocation)
+9
src/state/geolocation/types.ts
··· 1 + export type DeviceLocation = { 2 + countryCode: string | undefined 3 + regionCode: string | undefined 4 + } 5 + 6 + export type GeolocationStatus = DeviceLocation & { 7 + isAgeRestrictedGeo: boolean 8 + isAgeBlockedGeo: boolean 9 + }
+43
src/state/geolocation/useRequestDeviceLocation.ts
··· 1 + import {useCallback} from 'react' 2 + import * as Location from 'expo-location' 3 + 4 + import {type DeviceLocation} from '#/state/geolocation/types' 5 + import {getDeviceGeolocation} from '#/state/geolocation/util' 6 + 7 + export {PermissionStatus} from 'expo-location' 8 + 9 + export function useRequestDeviceLocation(): () => Promise< 10 + | { 11 + granted: true 12 + location: DeviceLocation | undefined 13 + } 14 + | { 15 + granted: false 16 + status: { 17 + canAskAgain: boolean 18 + /** 19 + * Enum, use `PermissionStatus` export for comparisons 20 + */ 21 + permissionStatus: Location.PermissionStatus 22 + } 23 + } 24 + > { 25 + return useCallback(async () => { 26 + const status = await Location.requestForegroundPermissionsAsync() 27 + 28 + if (status.granted) { 29 + return { 30 + granted: true, 31 + location: await getDeviceGeolocation(), 32 + } 33 + } else { 34 + return { 35 + granted: false, 36 + status: { 37 + canAskAgain: status.canAskAgain, 38 + permissionStatus: status.status, 39 + }, 40 + } 41 + } 42 + }, []) 43 + }
+58
src/state/geolocation/useSyncedDeviceGeolocation.ts
··· 1 + import {useEffect, useRef} from 'react' 2 + import * as Location from 'expo-location' 3 + 4 + import {logger} from '#/state/geolocation/logger' 5 + import {getDeviceGeolocation} from '#/state/geolocation/util' 6 + import {device, useStorage} from '#/storage' 7 + 8 + /** 9 + * Hook to get and sync the device geolocation from the device GPS and store it 10 + * using device storage. If permissions are not granted, it will clear any cached 11 + * storage value. 12 + */ 13 + export function useSyncedDeviceGeolocation() { 14 + const synced = useRef(false) 15 + const [status] = Location.useForegroundPermissions() 16 + const [deviceGeolocation, setDeviceGeolocation] = useStorage(device, [ 17 + 'deviceGeolocation', 18 + ]) 19 + 20 + useEffect(() => { 21 + async function get() { 22 + // no need to set this more than once per session 23 + if (synced.current) return 24 + 25 + logger.debug('useSyncedDeviceGeolocation: checking perms') 26 + 27 + if (status?.granted) { 28 + const location = await getDeviceGeolocation() 29 + if (location) { 30 + logger.debug('useSyncedDeviceGeolocation: syncing location') 31 + setDeviceGeolocation(location) 32 + synced.current = true 33 + } 34 + } else { 35 + const hasCachedValue = device.get(['deviceGeolocation']) !== undefined 36 + 37 + /** 38 + * If we have a cached value, but user has revoked permissions, 39 + * quietly (will take effect lazily) clear this out. 40 + */ 41 + if (hasCachedValue) { 42 + logger.debug( 43 + 'useSyncedDeviceGeolocation: clearing cached location, perms revoked', 44 + ) 45 + device.set(['deviceGeolocation'], undefined) 46 + } 47 + } 48 + } 49 + 50 + get().catch(e => { 51 + logger.error('useSyncedDeviceGeolocation: failed to sync', { 52 + safeMessage: e, 53 + }) 54 + }) 55 + }, [status, setDeviceGeolocation]) 56 + 57 + return [deviceGeolocation, setDeviceGeolocation] as const 58 + }
+180
src/state/geolocation/util.ts
··· 1 + import { 2 + getCurrentPositionAsync, 3 + type LocationGeocodedAddress, 4 + reverseGeocodeAsync, 5 + } from 'expo-location' 6 + 7 + import {logger} from '#/state/geolocation/logger' 8 + import {type DeviceLocation} from '#/state/geolocation/types' 9 + import {type Device} from '#/storage' 10 + 11 + /** 12 + * Maps full US region names to their short codes. 13 + * 14 + * Context: in some cases, like on Android, we get the full region name instead 15 + * of the short code. We may need to expand this in the future to other 16 + * countries, hence the prefix. 17 + */ 18 + export const USRegionNameToRegionCode: { 19 + [regionName: string]: string 20 + } = { 21 + Alabama: 'AL', 22 + Alaska: 'AK', 23 + Arizona: 'AZ', 24 + Arkansas: 'AR', 25 + California: 'CA', 26 + Colorado: 'CO', 27 + Connecticut: 'CT', 28 + Delaware: 'DE', 29 + Florida: 'FL', 30 + Georgia: 'GA', 31 + Hawaii: 'HI', 32 + Idaho: 'ID', 33 + Illinois: 'IL', 34 + Indiana: 'IN', 35 + Iowa: 'IA', 36 + Kansas: 'KS', 37 + Kentucky: 'KY', 38 + Louisiana: 'LA', 39 + Maine: 'ME', 40 + Maryland: 'MD', 41 + Massachusetts: 'MA', 42 + Michigan: 'MI', 43 + Minnesota: 'MN', 44 + Mississippi: 'MS', 45 + Missouri: 'MO', 46 + Montana: 'MT', 47 + Nebraska: 'NE', 48 + Nevada: 'NV', 49 + ['New Hampshire']: 'NH', 50 + ['New Jersey']: 'NJ', 51 + ['New Mexico']: 'NM', 52 + ['New York']: 'NY', 53 + ['North Carolina']: 'NC', 54 + ['North Dakota']: 'ND', 55 + Ohio: 'OH', 56 + Oklahoma: 'OK', 57 + Oregon: 'OR', 58 + Pennsylvania: 'PA', 59 + ['Rhode Island']: 'RI', 60 + ['South Carolina']: 'SC', 61 + ['South Dakota']: 'SD', 62 + Tennessee: 'TN', 63 + Texas: 'TX', 64 + Utah: 'UT', 65 + Vermont: 'VT', 66 + Virginia: 'VA', 67 + Washington: 'WA', 68 + ['West Virginia']: 'WV', 69 + Wisconsin: 'WI', 70 + Wyoming: 'WY', 71 + } 72 + 73 + /** 74 + * Normalizes a `LocationGeocodedAddress` into a `DeviceLocation`. 75 + * 76 + * We don't want or care about the full location data, so we trim it down and 77 + * normalize certain fields, like region, into the format we need. 78 + */ 79 + export function normalizeDeviceLocation( 80 + location: LocationGeocodedAddress, 81 + ): DeviceLocation { 82 + let {isoCountryCode, region} = location 83 + 84 + if (region) { 85 + if (isoCountryCode === 'US') { 86 + region = USRegionNameToRegionCode[region] ?? region 87 + } 88 + } 89 + 90 + return { 91 + countryCode: isoCountryCode ?? undefined, 92 + regionCode: region ?? undefined, 93 + } 94 + } 95 + 96 + /** 97 + * Combines precise location data with the geolocation config fetched from the 98 + * IP service, with preference to the precise data. 99 + */ 100 + export function mergeGeolocation( 101 + location?: DeviceLocation, 102 + config?: Device['geolocation'], 103 + ): DeviceLocation { 104 + if (location?.countryCode) return location 105 + return { 106 + countryCode: config?.countryCode, 107 + regionCode: config?.regionCode, 108 + } 109 + } 110 + 111 + /** 112 + * Computes the geolocation status (age-restricted, age-blocked) based on the 113 + * given location and geolocation config. `location` here should be merged with 114 + * `mergeGeolocation()` ahead of time if needed. 115 + */ 116 + export function computeGeolocationStatus( 117 + location: DeviceLocation, 118 + config: Device['geolocation'], 119 + ) { 120 + /** 121 + * We can't do anything if we don't have this data. 122 + */ 123 + if (!location.countryCode) { 124 + return { 125 + ...location, 126 + isAgeRestrictedGeo: false, 127 + isAgeBlockedGeo: false, 128 + } 129 + } 130 + 131 + const isAgeRestrictedGeo = config?.ageRestrictedGeos?.some(rule => { 132 + if (rule.countryCode === location.countryCode) { 133 + if (!rule.regionCode) { 134 + return true // whole country is blocked 135 + } else if (rule.regionCode === location.regionCode) { 136 + return true 137 + } 138 + } 139 + }) 140 + 141 + const isAgeBlockedGeo = config?.ageBlockedGeos?.some(rule => { 142 + if (rule.countryCode === location.countryCode) { 143 + if (!rule.regionCode) { 144 + return true // whole country is blocked 145 + } else if (rule.regionCode === location.regionCode) { 146 + return true 147 + } 148 + } 149 + }) 150 + 151 + return { 152 + ...location, 153 + isAgeRestrictedGeo: !!isAgeRestrictedGeo, 154 + isAgeBlockedGeo: !!isAgeBlockedGeo, 155 + } 156 + } 157 + 158 + export async function getDeviceGeolocation(): Promise<DeviceLocation> { 159 + try { 160 + const geocode = await getCurrentPositionAsync() 161 + const locations = await reverseGeocodeAsync({ 162 + latitude: geocode.coords.latitude, 163 + longitude: geocode.coords.longitude, 164 + }) 165 + const location = locations.at(0) 166 + const normalized = location ? normalizeDeviceLocation(location) : undefined 167 + return { 168 + countryCode: normalized?.countryCode ?? undefined, 169 + regionCode: normalized?.regionCode ?? undefined, 170 + } 171 + } catch (e) { 172 + logger.error('getDeviceGeolocation: failed', { 173 + safeMessage: e, 174 + }) 175 + return { 176 + countryCode: undefined, 177 + regionCode: undefined, 178 + } 179 + } 180 + }
+23 -2
src/storage/schema.ts
··· 7 7 fontScale: '-2' | '-1' | '0' | '1' | '2' 8 8 fontFamily: 'system' | 'theme' 9 9 lastNuxDialog: string | undefined 10 + 11 + /** 12 + * Geolocation config, fetched from the IP service. This previously did 13 + * double duty as the "status" for geolocation state, but that has since 14 + * moved here to the client. 15 + */ 10 16 geolocation?: { 11 17 countryCode: string | undefined 12 - isAgeRestrictedGeo: boolean | undefined 13 - isAgeBlockedGeo: boolean | undefined 18 + regionCode: string | undefined 19 + ageRestrictedGeos: { 20 + countryCode: string 21 + regionCode: string | undefined 22 + }[] 23 + ageBlockedGeos: { 24 + countryCode: string 25 + regionCode: string | undefined 26 + }[] 14 27 } 28 + /** 29 + * The GPS-based geolocation, if the user has granted permission. 30 + */ 31 + deviceGeolocation?: { 32 + countryCode: string | undefined 33 + regionCode: string | undefined 34 + } 35 + 15 36 trendingBetaEnabled: boolean 16 37 devMode: boolean 17 38 demoMode: boolean
+2 -2
src/view/shell/index.tsx
··· 13 13 import {isStateAtTabRoot} from '#/lib/routes/helpers' 14 14 import {isAndroid, isIOS} from '#/platform/detection' 15 15 import {useDialogFullyExpandedCountContext} from '#/state/dialogs' 16 - import {useGeolocation} from '#/state/geolocation' 16 + import {useGeolocationStatus} from '#/state/geolocation' 17 17 import {useSession} from '#/state/session' 18 18 import { 19 19 useIsDrawerOpen, ··· 184 184 185 185 export function Shell() { 186 186 const t = useTheme() 187 - const {geolocation} = useGeolocation() 187 + const {status: geolocation} = useGeolocationStatus() 188 188 const fullyExpandedCount = useDialogFullyExpandedCountContext() 189 189 190 190 useIntentHandler()
+2 -2
src/view/shell/index.web.tsx
··· 9 9 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 10 10 import {type NavigationProp} from '#/lib/routes/types' 11 11 import {useGate} from '#/lib/statsig/statsig' 12 - import {useGeolocation} from '#/state/geolocation' 12 + import {useGeolocationStatus} from '#/state/geolocation' 13 13 import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell' 14 14 import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut' 15 15 import {useCloseAllActiveElements} from '#/state/util' ··· 142 142 143 143 export function Shell() { 144 144 const t = useTheme() 145 - const {geolocation} = useGeolocation() 145 + const {status: geolocation} = useGeolocationStatus() 146 146 return ( 147 147 <View style={[a.util_screen_outer, t.atoms.bg]}> 148 148 {geolocation?.isAgeBlockedGeo ? (
+5
yarn.lock
··· 11386 11386 dependencies: 11387 11387 rtl-detect "^1.0.2" 11388 11388 11389 + expo-location@~18.1.6: 11390 + version "18.1.6" 11391 + resolved "https://registry.yarnpkg.com/expo-location/-/expo-location-18.1.6.tgz#b855e14e8b4e29a1bde470fc4dc2a341abecf631" 11392 + integrity sha512-l5dQQ2FYkrBgNzaZN1BvSmdhhcztFOUucu2kEfDBMV4wSIuTIt/CKsho+F3RnAiWgvui1wb1WTTf80E8zq48hA== 11393 + 11389 11394 expo-manifests@~0.16.5: 11390 11395 version "0.16.5" 11391 11396 resolved "https://registry.yarnpkg.com/expo-manifests/-/expo-manifests-0.16.5.tgz#bb57ceff3db4eb74679d4a155b2ca2050375ce10"