+6
.env.example
+6
.env.example
+1
app.config.js
+1
app.config.js
+1
assets/icons/pinLocationFilled_stroke2_corner0_rounded.svg
+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
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
+1
package.json
+7
-6
src/App.native.tsx
+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
+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
+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
+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
+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
+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
+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
+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
+1
src/logger/types.ts
+2
-2
src/state/ageAssurance/index.tsx
+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
+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
+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
-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
+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
+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
+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
+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
+3
src/state/geolocation/logger.ts
+9
src/state/geolocation/types.ts
+9
src/state/geolocation/types.ts
+43
src/state/geolocation/useRequestDeviceLocation.ts
+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
+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
+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
+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
+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
+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
+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"