···33333434# Bitdrift API key. If undefined, Bitdrift will be disabled.
3535EXPO_PUBLIC_BITDRIFT_API_KEY=
3636+3737+# bapp-config web worker URL
3838+BAPP_CONFIG_DEV_URL=
3939+4040+# Dev-only passthrough value for bapp-config web worker
4141+BAPP_CONFIG_DEV_BYPASS_SECRET=
···2424 ProgressGuideAction,
2525 useProgressGuideControls,
2626} from '#/state/shell/progress-guide'
2727-import {formatCount} from '#/view/com/util/numeric/format'
2827import * as Toast from '#/view/com/util/Toast'
2929-import {atoms as a, useBreakpoints} from '#/alf'
3030-import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
2828+import {atoms as a, flatten, useBreakpoints} from '#/alf'
2929+import {Reply as Bubble} from '#/components/icons/Reply'
3030+import {useFormatPostStatCount} from '#/components/PostControls/util'
3131+import {BookmarkButton} from './BookmarkButton'
3132import {
3233 PostControlButton,
3334 PostControlButtonIcon,
···5152 threadgateRecord,
5253 onShowLess,
5354 viaRepost,
5555+ variant,
5456}: {
5557 big?: boolean
5658 post: Shadow<AppBskyFeedDefs.PostView>
···6567 threadgateRecord?: AppBskyFeedThreadgate.Record
6668 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
6769 viaRepost?: {uri: string; cid: string}
7070+ variant?: 'compact' | 'normal' | 'large'
6871}): React.ReactNode => {
6969- const {_, i18n} = useLingui()
7070- const {gtMobile} = useBreakpoints()
7272+ const {_} = useLingui()
7173 const {openComposer} = useOpenComposer()
7274 const {feedDescriptor} = useFeedFeedbackContext()
7375 const [queueLike, queueUnlike] = usePostLikeMutationQueue(
···9294 post.author.viewer?.blockingByList,
9395 )
9496 const replyDisabled = post.viewer?.replyDisabled
9797+ const {gtPhone} = useBreakpoints()
9898+ const formatPostStatCount = useFormatPostStatCount()
959996100 const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] = useState(false)
97101···184188 })
185189 }
186190191191+ const secondaryControlSpacingStyles = flatten([
192192+ {gap: 0}, // default, we want `gap` to be defined on the resulting object
193193+ variant !== 'compact' && a.gap_xs,
194194+ (big || gtPhone) && a.gap_sm,
195195+ ])
196196+187197 return (
188198 <View
189199 style={[
···191201 a.justify_between,
192202 a.align_center,
193203 !big && a.pt_2xs,
204204+ a.gap_md,
194205 style,
195206 ]}>
196196- <View
197197- style={[
198198- big ? a.align_center : [a.flex_1, a.align_start, {marginLeft: -6}],
199199- replyDisabled ? {opacity: 0.5} : undefined,
200200- ]}>
201201- <PostControlButton
202202- testID="replyBtn"
203203- onPress={
204204- !replyDisabled ? () => requireAuth(() => onPressReply()) : undefined
205205- }
206206- label={_(
207207- msg({
208208- message: `Reply (${plural(post.replyCount || 0, {
209209- one: '# reply',
210210- other: '# replies',
211211- })})`,
212212- comment:
213213- 'Accessibility label for the reply button, verb form followed by number of replies and noun form',
214214- }),
215215- )}
216216- big={big}>
217217- <PostControlButtonIcon icon={Bubble} />
218218- {typeof post.replyCount !== 'undefined' && post.replyCount > 0 && (
219219- <PostControlButtonText>
220220- {formatCount(i18n, post.replyCount)}
221221- </PostControlButtonText>
222222- )}
223223- </PostControlButton>
224224- </View>
225225- <View style={big ? a.align_center : [a.flex_1, a.align_start]}>
226226- <RepostButton
227227- isReposted={!!post.viewer?.repost}
228228- repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)}
229229- onRepost={onRepost}
230230- onQuote={onQuote}
231231- big={big}
232232- embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
233233- />
234234- </View>
235235- <View style={big ? a.align_center : [a.flex_1, a.align_start]}>
236236- <PostControlButton
237237- testID="likeBtn"
238238- big={big}
239239- onPress={() => requireAuth(() => onPressToggleLike())}
240240- label={
241241- post.viewer?.like
242242- ? _(
243243- msg({
244244- message: `Unlike (${plural(post.likeCount || 0, {
245245- one: '# like',
246246- other: '# likes',
247247- })})`,
248248- comment:
249249- 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun',
250250- }),
251251- )
252252- : _(
253253- msg({
254254- message: `Like (${plural(post.likeCount || 0, {
255255- one: '# like',
256256- other: '# likes',
257257- })})`,
258258- comment:
259259- 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form',
260260- }),
261261- )
262262- }>
263263- <AnimatedLikeIcon
264264- isLiked={Boolean(post.viewer?.like)}
207207+ <View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}>
208208+ <View
209209+ style={[
210210+ a.flex_1,
211211+ a.align_start,
212212+ {marginLeft: big ? -2 : -6},
213213+ replyDisabled ? {opacity: 0.5} : undefined,
214214+ ]}>
215215+ <PostControlButton
216216+ testID="replyBtn"
217217+ onPress={
218218+ !replyDisabled
219219+ ? () => requireAuth(() => onPressReply())
220220+ : undefined
221221+ }
222222+ label={_(
223223+ msg({
224224+ message: `Reply (${plural(post.replyCount || 0, {
225225+ one: '# reply',
226226+ other: '# replies',
227227+ })})`,
228228+ comment:
229229+ 'Accessibility label for the reply button, verb form followed by number of replies and noun form',
230230+ }),
231231+ )}
232232+ big={big}>
233233+ <PostControlButtonIcon icon={Bubble} />
234234+ {typeof post.replyCount !== 'undefined' && post.replyCount > 0 && (
235235+ <PostControlButtonText>
236236+ {formatPostStatCount(post.replyCount)}
237237+ </PostControlButtonText>
238238+ )}
239239+ </PostControlButton>
240240+ </View>
241241+ <View style={[a.flex_1, a.align_start]}>
242242+ <RepostButton
243243+ isReposted={!!post.viewer?.repost}
244244+ repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)}
245245+ onRepost={onRepost}
246246+ onQuote={onQuote}
265247 big={big}
266266- hasBeenToggled={hasLikeIconBeenToggled}
248248+ embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
267249 />
268268- <CountWheel
269269- likeCount={post.likeCount ?? 0}
250250+ </View>
251251+ <View style={[a.flex_1, a.align_start]}>
252252+ <PostControlButton
253253+ testID="likeBtn"
270254 big={big}
271271- isLiked={Boolean(post.viewer?.like)}
272272- hasBeenToggled={hasLikeIconBeenToggled}
273273- />
274274- </PostControlButton>
275275- </View>
276276- <View style={big ? a.align_center : [a.flex_1, a.align_start]}>
277277- <View style={[!big && a.ml_sm]}>
278278- <ShareMenuButton
279279- testID="postShareBtn"
280280- post={post}
281281- big={big}
282282- record={record}
283283- richText={richText}
284284- timestamp={post.indexedAt}
285285- threadgateRecord={threadgateRecord}
286286- onShare={onShare}
287287- />
255255+ onPress={() => requireAuth(() => onPressToggleLike())}
256256+ label={
257257+ post.viewer?.like
258258+ ? _(
259259+ msg({
260260+ message: `Unlike (${plural(post.likeCount || 0, {
261261+ one: '# like',
262262+ other: '# likes',
263263+ })})`,
264264+ comment:
265265+ 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun',
266266+ }),
267267+ )
268268+ : _(
269269+ msg({
270270+ message: `Like (${plural(post.likeCount || 0, {
271271+ one: '# like',
272272+ other: '# likes',
273273+ })})`,
274274+ comment:
275275+ 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form',
276276+ }),
277277+ )
278278+ }>
279279+ <AnimatedLikeIcon
280280+ isLiked={Boolean(post.viewer?.like)}
281281+ big={big}
282282+ hasBeenToggled={hasLikeIconBeenToggled}
283283+ />
284284+ <CountWheel
285285+ likeCount={post.likeCount ?? 0}
286286+ big={big}
287287+ isLiked={Boolean(post.viewer?.like)}
288288+ hasBeenToggled={hasLikeIconBeenToggled}
289289+ />
290290+ </PostControlButton>
288291 </View>
292292+ {/* Spacer! */}
293293+ <View />
289294 </View>
290290- <View
291291- style={big ? a.align_center : [gtMobile && a.flex_1, a.align_start]}>
295295+ <View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}>
296296+ <BookmarkButton
297297+ post={post}
298298+ big={big}
299299+ logContext={logContext}
300300+ hitSlop={{
301301+ right: secondaryControlSpacingStyles.gap / 2,
302302+ }}
303303+ />
304304+ <ShareMenuButton
305305+ testID="postShareBtn"
306306+ post={post}
307307+ big={big}
308308+ record={record}
309309+ richText={richText}
310310+ timestamp={post.indexedAt}
311311+ threadgateRecord={threadgateRecord}
312312+ onShare={onShare}
313313+ hitSlop={{
314314+ left: secondaryControlSpacingStyles.gap / 2,
315315+ right: secondaryControlSpacingStyles.gap / 2,
316316+ }}
317317+ />
292318 <PostMenuButton
293319 testID="postDropdownBtn"
294320 post={post}
···300326 timestamp={post.indexedAt}
301327 threadgateRecord={threadgateRecord}
302328 onShowLess={onShowLess}
329329+ hitSlop={{
330330+ left: secondaryControlSpacingStyles.gap / 2,
331331+ }}
303332 />
304333 </View>
305334 </View>
+48
src/components/PostControls/util.ts
···11+import {useCallback} from 'react'
22+import {msg} from '@lingui/macro'
33+import {useLingui} from '@lingui/react'
44+55+/**
66+ * This matches `formatCount` from `view/com/util/numeric/format.ts`, but has
77+ * additional truncation logic for large numbers. `roundingMode` should always
88+ * match the original impl, regardless of if we add more formatting here.
99+ */
1010+export function useFormatPostStatCount() {
1111+ const {i18n} = useLingui()
1212+1313+ return useCallback(
1414+ (postStatCount: number) => {
1515+ const isOver1k = postStatCount >= 1_000
1616+ const isOver10k = postStatCount >= 10_000
1717+ const isOver1M = postStatCount >= 1_000_000
1818+ const formatted = i18n.number(postStatCount, {
1919+ notation: 'compact',
2020+ maximumFractionDigits: isOver10k ? 0 : 1,
2121+ // @ts-expect-error - roundingMode not in the types
2222+ roundingMode: 'trunc',
2323+ })
2424+ const count = formatted.replace(/\D+$/g, '')
2525+2626+ if (isOver1M) {
2727+ return i18n._(
2828+ msg({
2929+ message: `${count}M`,
3030+ comment:
3131+ 'For post statistics. Indicates a number in the millions. Please use the shortest format appropriate for your language.',
3232+ }),
3333+ )
3434+ } else if (isOver1k) {
3535+ return i18n._(
3636+ msg({
3737+ message: `${count}K`,
3838+ comment:
3939+ 'For post statistics. Indicates a number in the thousands. Please use the shortest format appropriate for your language.',
4040+ }),
4141+ )
4242+ } else {
4343+ return count
4444+ }
4545+ },
4646+ [i18n],
4747+ )
4848+}
···9393 process.env.EXPO_PUBLIC_GCP_PROJECT_ID === undefined
9494 ? 0
9595 : Number(process.env.EXPO_PUBLIC_GCP_PROJECT_ID)
9696+9797+/**
9898+ * URL for the bapp-config web worker _development_ environment. Can be a
9999+ * locally running server, see `env.example` for more.
100100+ */
101101+export const BAPP_CONFIG_DEV_URL = process.env.BAPP_CONFIG_DEV_URL
102102+103103+/**
104104+ * Dev environment passthrough value for bapp-config web worker. Allows local
105105+ * dev access to the web worker running in `development` mode.
106106+ */
107107+export const BAPP_CONFIG_DEV_BYPASS_SECRET: string =
108108+ process.env.BAPP_CONFIG_DEV_BYPASS_SECRET
···11import React from 'react'
2233import {deviceLocales} from '#/locale/deviceLocales'
44-import {useGeolocation} from '#/state/geolocation'
44+import {useGeolocationStatus} from '#/state/geolocation'
55import {useLanguagePrefs} from '#/state/preferences'
6677/**
···275275export function useFormatCurrency(
276276 options?: Parameters<typeof Intl.NumberFormat>[1],
277277) {
278278- const {geolocation} = useGeolocation()
278278+ const {location: geolocation} = useGeolocationStatus()
279279 const {appLanguage} = useLanguagePrefs()
280280 return React.useMemo(() => {
281281 const locale = deviceLocales.at(0)
+4-4
src/lib/custom-animations/CountWheel.tsx
···66 useReducedMotion,
77 withTiming,
88} from 'react-native-reanimated'
99-import {i18n} from '@lingui/core'
1091110import {decideShouldRoll} from '#/lib/custom-animations/util'
1211import {s} from '#/lib/styles'
1313-import {formatCount} from '#/view/com/util/numeric/format'
1412import {Text} from '#/view/com/util/text/Text'
1513import {atoms as a, useTheme} from '#/alf'
1414+import {useFormatPostStatCount} from '#/components/PostControls/util'
16151716const animationConfig = {
1817 duration: 400,
···109108 const [key, setKey] = React.useState(0)
110109 const [prevCount, setPrevCount] = React.useState(likeCount)
111110 const prevIsLiked = React.useRef(isLiked)
112112- const formattedCount = formatCount(i18n, likeCount)
113113- const formattedPrevCount = formatCount(i18n, prevCount)
111111+ const formatPostStatCount = useFormatPostStatCount()
112112+ const formattedCount = formatPostStatCount(likeCount)
113113+ const formattedPrevCount = formatPostStatCount(prevCount)
114114115115 React.useEffect(() => {
116116 if (isLiked === prevIsLiked.current) {
+4-4
src/lib/custom-animations/CountWheel.web.tsx
···11import React from 'react'
22import {View} from 'react-native'
33import {useReducedMotion} from 'react-native-reanimated'
44-import {i18n} from '@lingui/core'
5465import {decideShouldRoll} from '#/lib/custom-animations/util'
76import {s} from '#/lib/styles'
88-import {formatCount} from '#/view/com/util/numeric/format'
97import {Text} from '#/view/com/util/text/Text'
108import {atoms as a, useTheme} from '#/alf'
99+import {useFormatPostStatCount} from '#/components/PostControls/util'
11101211const animationConfig = {
1312 duration: 400,
···55545655 const [prevCount, setPrevCount] = React.useState(likeCount)
5756 const prevIsLiked = React.useRef(isLiked)
5858- const formattedCount = formatCount(i18n, likeCount)
5959- const formattedPrevCount = formatCount(i18n, prevCount)
5757+ const formatPostStatCount = useFormatPostStatCount()
5858+ const formattedCount = formatPostStatCount(likeCount)
5959+ const formattedPrevCount = formatPostStatCount(prevCount)
60606161 React.useEffect(() => {
6262 if (isLiked === prevIsLiked.current) {
+1
src/lib/hooks/useNavigationTabState.ts
···99 isAtSearch: getTabState(state, 'Search') !== TabState.Outside,
1010 // FeedsTab no longer exists, but this check works for `Feeds` screen as well
1111 isAtFeeds: getTabState(state, 'Feeds') !== TabState.Outside,
1212+ isAtBookmarks: getTabState(state, 'Bookmarks') !== TabState.Outside,
1213 isAtNotifications:
1314 getTabState(state, 'Notifications') !== TabState.Outside,
1415 isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside,
+3
src/lib/hooks/useWebMediaQueries.tsx
···2233import {isNative} from '#/platform/detection'
4455+/**
66+ * @deprecated use `useBreakpoints` from `#/alf` instead
77+ */
58export function useWebMediaQueries() {
69 const isDesktop = useMediaQuery({minWidth: 1300})
710 const isTablet = useMediaQuery({minWidth: 800, maxWidth: 1300 - 1})
+2-1
src/lib/notifications/notifications.ts
···1111import {useAgeAssuranceContext} from '#/state/ageAssurance'
1212import {type SessionAccount, useAgent, useSession} from '#/state/session'
1313import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler'
1414+import {IS_DEV} from '#/env'
14151516/**
1617 * @private
···129130 }: {
130131 isAgeRestricted?: boolean
131132 } = {}) => {
132132- if (!isNative) return
133133+ if (!isNative || IS_DEV) return
133134134135 /**
135136 * This will also fire the listener added via `addPushTokenListener`. That
···1414 PostSource = 'post-source',
1515 AgeAssurance = 'age-assurance',
1616 PolicyUpdate = 'policy-update',
1717+ Geolocation = 'geolocation',
17181819 /**
1920 * METRIC IS FOR INTERNAL USE ONLY, don't create any other loggers using this
···11+import {useEffect, useRef} from 'react'
22+import * as Location from 'expo-location'
33+44+import {logger} from '#/state/geolocation/logger'
55+import {getDeviceGeolocation} from '#/state/geolocation/util'
66+import {device, useStorage} from '#/storage'
77+88+/**
99+ * Hook to get and sync the device geolocation from the device GPS and store it
1010+ * using device storage. If permissions are not granted, it will clear any cached
1111+ * storage value.
1212+ */
1313+export function useSyncedDeviceGeolocation() {
1414+ const synced = useRef(false)
1515+ const [status] = Location.useForegroundPermissions()
1616+ const [deviceGeolocation, setDeviceGeolocation] = useStorage(device, [
1717+ 'deviceGeolocation',
1818+ ])
1919+2020+ useEffect(() => {
2121+ async function get() {
2222+ // no need to set this more than once per session
2323+ if (synced.current) return
2424+2525+ logger.debug('useSyncedDeviceGeolocation: checking perms')
2626+2727+ if (status?.granted) {
2828+ const location = await getDeviceGeolocation()
2929+ if (location) {
3030+ logger.debug('useSyncedDeviceGeolocation: syncing location')
3131+ setDeviceGeolocation(location)
3232+ synced.current = true
3333+ }
3434+ } else {
3535+ const hasCachedValue = device.get(['deviceGeolocation']) !== undefined
3636+3737+ /**
3838+ * If we have a cached value, but user has revoked permissions,
3939+ * quietly (will take effect lazily) clear this out.
4040+ */
4141+ if (hasCachedValue) {
4242+ logger.debug(
4343+ 'useSyncedDeviceGeolocation: clearing cached location, perms revoked',
4444+ )
4545+ device.set(['deviceGeolocation'], undefined)
4646+ }
4747+ }
4848+ }
4949+5050+ get().catch(e => {
5151+ logger.error('useSyncedDeviceGeolocation: failed to sync', {
5252+ safeMessage: e,
5353+ })
5454+ })
5555+ }, [status, setDeviceGeolocation])
5656+5757+ return [deviceGeolocation, setDeviceGeolocation] as const
5858+}
+180
src/state/geolocation/util.ts
···11+import {
22+ getCurrentPositionAsync,
33+ type LocationGeocodedAddress,
44+ reverseGeocodeAsync,
55+} from 'expo-location'
66+77+import {logger} from '#/state/geolocation/logger'
88+import {type DeviceLocation} from '#/state/geolocation/types'
99+import {type Device} from '#/storage'
1010+1111+/**
1212+ * Maps full US region names to their short codes.
1313+ *
1414+ * Context: in some cases, like on Android, we get the full region name instead
1515+ * of the short code. We may need to expand this in the future to other
1616+ * countries, hence the prefix.
1717+ */
1818+export const USRegionNameToRegionCode: {
1919+ [regionName: string]: string
2020+} = {
2121+ Alabama: 'AL',
2222+ Alaska: 'AK',
2323+ Arizona: 'AZ',
2424+ Arkansas: 'AR',
2525+ California: 'CA',
2626+ Colorado: 'CO',
2727+ Connecticut: 'CT',
2828+ Delaware: 'DE',
2929+ Florida: 'FL',
3030+ Georgia: 'GA',
3131+ Hawaii: 'HI',
3232+ Idaho: 'ID',
3333+ Illinois: 'IL',
3434+ Indiana: 'IN',
3535+ Iowa: 'IA',
3636+ Kansas: 'KS',
3737+ Kentucky: 'KY',
3838+ Louisiana: 'LA',
3939+ Maine: 'ME',
4040+ Maryland: 'MD',
4141+ Massachusetts: 'MA',
4242+ Michigan: 'MI',
4343+ Minnesota: 'MN',
4444+ Mississippi: 'MS',
4545+ Missouri: 'MO',
4646+ Montana: 'MT',
4747+ Nebraska: 'NE',
4848+ Nevada: 'NV',
4949+ ['New Hampshire']: 'NH',
5050+ ['New Jersey']: 'NJ',
5151+ ['New Mexico']: 'NM',
5252+ ['New York']: 'NY',
5353+ ['North Carolina']: 'NC',
5454+ ['North Dakota']: 'ND',
5555+ Ohio: 'OH',
5656+ Oklahoma: 'OK',
5757+ Oregon: 'OR',
5858+ Pennsylvania: 'PA',
5959+ ['Rhode Island']: 'RI',
6060+ ['South Carolina']: 'SC',
6161+ ['South Dakota']: 'SD',
6262+ Tennessee: 'TN',
6363+ Texas: 'TX',
6464+ Utah: 'UT',
6565+ Vermont: 'VT',
6666+ Virginia: 'VA',
6767+ Washington: 'WA',
6868+ ['West Virginia']: 'WV',
6969+ Wisconsin: 'WI',
7070+ Wyoming: 'WY',
7171+}
7272+7373+/**
7474+ * Normalizes a `LocationGeocodedAddress` into a `DeviceLocation`.
7575+ *
7676+ * We don't want or care about the full location data, so we trim it down and
7777+ * normalize certain fields, like region, into the format we need.
7878+ */
7979+export function normalizeDeviceLocation(
8080+ location: LocationGeocodedAddress,
8181+): DeviceLocation {
8282+ let {isoCountryCode, region} = location
8383+8484+ if (region) {
8585+ if (isoCountryCode === 'US') {
8686+ region = USRegionNameToRegionCode[region] ?? region
8787+ }
8888+ }
8989+9090+ return {
9191+ countryCode: isoCountryCode ?? undefined,
9292+ regionCode: region ?? undefined,
9393+ }
9494+}
9595+9696+/**
9797+ * Combines precise location data with the geolocation config fetched from the
9898+ * IP service, with preference to the precise data.
9999+ */
100100+export function mergeGeolocation(
101101+ location?: DeviceLocation,
102102+ config?: Device['geolocation'],
103103+): DeviceLocation {
104104+ if (location?.countryCode) return location
105105+ return {
106106+ countryCode: config?.countryCode,
107107+ regionCode: config?.regionCode,
108108+ }
109109+}
110110+111111+/**
112112+ * Computes the geolocation status (age-restricted, age-blocked) based on the
113113+ * given location and geolocation config. `location` here should be merged with
114114+ * `mergeGeolocation()` ahead of time if needed.
115115+ */
116116+export function computeGeolocationStatus(
117117+ location: DeviceLocation,
118118+ config: Device['geolocation'],
119119+) {
120120+ /**
121121+ * We can't do anything if we don't have this data.
122122+ */
123123+ if (!location.countryCode) {
124124+ return {
125125+ ...location,
126126+ isAgeRestrictedGeo: false,
127127+ isAgeBlockedGeo: false,
128128+ }
129129+ }
130130+131131+ const isAgeRestrictedGeo = config?.ageRestrictedGeos?.some(rule => {
132132+ if (rule.countryCode === location.countryCode) {
133133+ if (!rule.regionCode) {
134134+ return true // whole country is blocked
135135+ } else if (rule.regionCode === location.regionCode) {
136136+ return true
137137+ }
138138+ }
139139+ })
140140+141141+ const isAgeBlockedGeo = config?.ageBlockedGeos?.some(rule => {
142142+ if (rule.countryCode === location.countryCode) {
143143+ if (!rule.regionCode) {
144144+ return true // whole country is blocked
145145+ } else if (rule.regionCode === location.regionCode) {
146146+ return true
147147+ }
148148+ }
149149+ })
150150+151151+ return {
152152+ ...location,
153153+ isAgeRestrictedGeo: !!isAgeRestrictedGeo,
154154+ isAgeBlockedGeo: !!isAgeBlockedGeo,
155155+ }
156156+}
157157+158158+export async function getDeviceGeolocation(): Promise<DeviceLocation> {
159159+ try {
160160+ const geocode = await getCurrentPositionAsync()
161161+ const locations = await reverseGeocodeAsync({
162162+ latitude: geocode.coords.latitude,
163163+ longitude: geocode.coords.longitude,
164164+ })
165165+ const location = locations.at(0)
166166+ const normalized = location ? normalizeDeviceLocation(location) : undefined
167167+ return {
168168+ countryCode: normalized?.countryCode ?? undefined,
169169+ regionCode: normalized?.regionCode ?? undefined,
170170+ }
171171+ } catch (e) {
172172+ logger.error('getDeviceGeolocation: failed', {
173173+ safeMessage: e,
174174+ })
175175+ return {
176176+ countryCode: undefined,
177177+ regionCode: undefined,
178178+ }
179179+ }
180180+}