Provide geo-gated users optional GPS fallback for precise location data
This commit is contained in:
@@ -11,7 +11,7 @@ import {
|
||||
} from '#/state/ageAssurance/types'
|
||||
import {useIsAgeAssuranceEnabled} from '#/state/ageAssurance/useIsAgeAssuranceEnabled'
|
||||
import {logger} from '#/state/ageAssurance/util'
|
||||
import {useGeolocation} from '#/state/geolocation'
|
||||
import {useGeolocationStatus} from '#/state/geolocation'
|
||||
import {useAgent} from '#/state/session'
|
||||
|
||||
export const createAgeAssuranceQueryKey = (did: string) =>
|
||||
@@ -43,7 +43,7 @@ AgeAssuranceAPIContext.displayName = 'AgeAssuranceAPIContext'
|
||||
*/
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
const agent = useAgent()
|
||||
const {geolocation} = useGeolocation()
|
||||
const {status: geolocation} = useGeolocationStatus()
|
||||
const isAgeAssuranceEnabled = useIsAgeAssuranceEnabled()
|
||||
const getAndRegisterPushToken = useGetAndRegisterPushToken()
|
||||
const [refetchWhilePending, setRefetchWhilePending] = useState(false)
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import {isNetworkError} from '#/lib/hooks/useCleanError'
|
||||
import {logger} from '#/logger'
|
||||
import {createAgeAssuranceQueryKey} from '#/state/ageAssurance'
|
||||
import {useGeolocation} from '#/state/geolocation'
|
||||
import {useGeolocationStatus} from '#/state/geolocation'
|
||||
import {useAgent} from '#/state/session'
|
||||
|
||||
let APPVIEW = PUBLIC_APPVIEW
|
||||
@@ -36,7 +36,7 @@ let APPVIEW_DID = PUBLIC_APPVIEW_DID
|
||||
export function useInitAgeAssurance() {
|
||||
const qc = useQueryClient()
|
||||
const agent = useAgent()
|
||||
const {geolocation} = useGeolocation()
|
||||
const {status: geolocation} = useGeolocationStatus()
|
||||
return useMutation({
|
||||
async mutationFn(
|
||||
props: Omit<AppBskyUnspeccedInitAgeAssurance.InputSchema, 'countryCode'>,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {useMemo} from 'react'
|
||||
|
||||
import {useGeolocation} from '#/state/geolocation'
|
||||
import {useGeolocationStatus} from '#/state/geolocation'
|
||||
|
||||
export function useIsAgeAssuranceEnabled() {
|
||||
const {geolocation} = useGeolocation()
|
||||
const {status: geolocation} = useGeolocationStatus()
|
||||
|
||||
return useMemo(() => {
|
||||
return !!geolocation?.isAgeRestrictedGeo
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
import React from 'react'
|
||||
import EventEmitter from 'eventemitter3'
|
||||
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {logger} from '#/logger'
|
||||
import {type Device, device} from '#/storage'
|
||||
|
||||
const IPCC_URL = `https://bsky.app/ipcc`
|
||||
const BAPP_CONFIG_URL = `https://ip.bsky.app/config`
|
||||
|
||||
const events = new EventEmitter()
|
||||
const EVENT = 'geolocation-updated'
|
||||
const emitGeolocationUpdate = (geolocation: Device['geolocation']) => {
|
||||
events.emit(EVENT, geolocation)
|
||||
}
|
||||
const onGeolocationUpdate = (
|
||||
listener: (geolocation: Device['geolocation']) => void,
|
||||
) => {
|
||||
events.on(EVENT, listener)
|
||||
return () => {
|
||||
events.off(EVENT, listener)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default geolocation value. IF undefined, we fail closed and apply all
|
||||
* additional mod authorities.
|
||||
*/
|
||||
export const DEFAULT_GEOLOCATION: Device['geolocation'] = {
|
||||
countryCode: undefined,
|
||||
isAgeBlockedGeo: undefined,
|
||||
isAgeRestrictedGeo: false,
|
||||
}
|
||||
|
||||
function sanitizeGeolocation(
|
||||
geolocation: Device['geolocation'],
|
||||
): Device['geolocation'] {
|
||||
return {
|
||||
countryCode: geolocation?.countryCode ?? undefined,
|
||||
isAgeBlockedGeo: geolocation?.isAgeBlockedGeo ?? false,
|
||||
isAgeRestrictedGeo: geolocation?.isAgeRestrictedGeo ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
async function getGeolocation(url: string): Promise<Device['geolocation']> {
|
||||
const res = await fetch(url)
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`geolocation: lookup failed ${res.status}`)
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
|
||||
if (json.countryCode) {
|
||||
return {
|
||||
countryCode: json.countryCode,
|
||||
isAgeBlockedGeo: json.isAgeBlockedGeo ?? false,
|
||||
isAgeRestrictedGeo: json.isAgeRestrictedGeo ?? false,
|
||||
// @ts-ignore
|
||||
regionCode: json.regionCode ?? undefined,
|
||||
}
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function compareWithIPCC(bapp: Device['geolocation']) {
|
||||
try {
|
||||
const ipcc = await getGeolocation(IPCC_URL)
|
||||
|
||||
if (!ipcc || !bapp) return
|
||||
|
||||
logger.metric(
|
||||
'geo:debug',
|
||||
{
|
||||
bappCountryCode: bapp.countryCode,
|
||||
// @ts-ignore
|
||||
bappRegionCode: bapp.regionCode,
|
||||
bappIsAgeBlockedGeo: bapp.isAgeBlockedGeo,
|
||||
bappIsAgeRestrictedGeo: bapp.isAgeRestrictedGeo,
|
||||
ipccCountryCode: ipcc.countryCode,
|
||||
ipccIsAgeBlockedGeo: ipcc.isAgeBlockedGeo,
|
||||
ipccIsAgeRestrictedGeo: ipcc.isAgeRestrictedGeo,
|
||||
},
|
||||
{
|
||||
statsig: false,
|
||||
},
|
||||
)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local promise used within this file only.
|
||||
*/
|
||||
let geolocationResolution: Promise<{success: boolean}> | undefined
|
||||
|
||||
/**
|
||||
* Begin the process of resolving geolocation. This should be called once at
|
||||
* app start.
|
||||
*
|
||||
* THIS METHOD SHOULD NEVER THROW.
|
||||
*
|
||||
* This method is otherwise not used for any purpose. To ensure geolocation is
|
||||
* resolved, use {@link ensureGeolocationResolved}
|
||||
*/
|
||||
export function beginResolveGeolocation() {
|
||||
/**
|
||||
* In dev, IP server is unavailable, so we just set the default geolocation
|
||||
* and fail closed.
|
||||
*/
|
||||
if (__DEV__) {
|
||||
geolocationResolution = new Promise(y => y({success: true}))
|
||||
if (!device.get(['geolocation'])) {
|
||||
device.set(['geolocation'], DEFAULT_GEOLOCATION)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
geolocationResolution = new Promise(async resolve => {
|
||||
let success = true
|
||||
|
||||
try {
|
||||
// Try once, fail fast
|
||||
const geolocation = await getGeolocation(BAPP_CONFIG_URL)
|
||||
if (geolocation) {
|
||||
device.set(['geolocation'], sanitizeGeolocation(geolocation))
|
||||
emitGeolocationUpdate(geolocation)
|
||||
logger.debug(`geolocation: success`, {geolocation})
|
||||
compareWithIPCC(geolocation)
|
||||
} else {
|
||||
// endpoint should throw on all failures, this is insurance
|
||||
throw new Error(`geolocation: nothing returned from initial request`)
|
||||
}
|
||||
} catch (e: any) {
|
||||
success = false
|
||||
|
||||
logger.debug(`geolocation: failed initial request`, {
|
||||
safeMessage: e.message,
|
||||
})
|
||||
|
||||
// set to default
|
||||
device.set(['geolocation'], DEFAULT_GEOLOCATION)
|
||||
|
||||
// retry 3 times, but don't await, proceed with default
|
||||
networkRetry(3, () => getGeolocation(BAPP_CONFIG_URL))
|
||||
.then(geolocation => {
|
||||
if (geolocation) {
|
||||
device.set(['geolocation'], sanitizeGeolocation(geolocation))
|
||||
emitGeolocationUpdate(geolocation)
|
||||
logger.debug(`geolocation: success`, {geolocation})
|
||||
success = true
|
||||
compareWithIPCC(geolocation)
|
||||
} else {
|
||||
// endpoint should throw on all failures, this is insurance
|
||||
throw new Error(`geolocation: nothing returned from retries`)
|
||||
}
|
||||
})
|
||||
.catch((e: any) => {
|
||||
// complete fail closed
|
||||
logger.debug(`geolocation: failed retries`, {safeMessage: e.message})
|
||||
})
|
||||
} finally {
|
||||
resolve({success})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that geolocation has been resolved, or at the very least attempted
|
||||
* once. Subsequent retries will not be captured by this `await`. Those will be
|
||||
* reported via {@link events}.
|
||||
*/
|
||||
export async function ensureGeolocationResolved() {
|
||||
if (!geolocationResolution) {
|
||||
throw new Error(`geolocation: beginResolveGeolocation not called yet`)
|
||||
}
|
||||
|
||||
const cached = device.get(['geolocation'])
|
||||
if (cached) {
|
||||
logger.debug(`geolocation: using cache`, {cached})
|
||||
} else {
|
||||
logger.debug(`geolocation: no cache`)
|
||||
const {success} = await geolocationResolution
|
||||
if (success) {
|
||||
logger.debug(`geolocation: resolved`, {
|
||||
resolved: device.get(['geolocation']),
|
||||
})
|
||||
} else {
|
||||
logger.error(`geolocation: failed to resolve`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Context = {
|
||||
geolocation: Device['geolocation']
|
||||
}
|
||||
|
||||
const context = React.createContext<Context>({
|
||||
geolocation: DEFAULT_GEOLOCATION,
|
||||
})
|
||||
context.displayName = 'GeolocationContext'
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
const [geolocation, setGeolocation] = React.useState(() => {
|
||||
const initial = device.get(['geolocation']) || DEFAULT_GEOLOCATION
|
||||
return initial
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
return onGeolocationUpdate(geolocation => {
|
||||
setGeolocation(geolocation!)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const ctx = React.useMemo(() => {
|
||||
return {
|
||||
geolocation,
|
||||
}
|
||||
}, [geolocation])
|
||||
|
||||
return <context.Provider value={ctx}>{children}</context.Provider>
|
||||
}
|
||||
|
||||
export function useGeolocation() {
|
||||
return React.useContext(context)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {
|
||||
DEFAULT_GEOLOCATION_CONFIG,
|
||||
GEOLOCATION_CONFIG_URL,
|
||||
} from '#/state/geolocation/const'
|
||||
import {emitGeolocationConfigUpdate} from '#/state/geolocation/events'
|
||||
import {logger} from '#/state/geolocation/logger'
|
||||
import {BAPP_CONFIG_DEV_BYPASS_SECRET, IS_DEV} from '#/env'
|
||||
import {type Device, device} from '#/storage'
|
||||
|
||||
async function getGeolocationConfig(
|
||||
url: string,
|
||||
): Promise<Device['geolocation']> {
|
||||
const res = await fetch(url, {
|
||||
headers: IS_DEV
|
||||
? {
|
||||
'x-dev-bypass-secret': BAPP_CONFIG_DEV_BYPASS_SECRET,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`geolocation config: fetch failed ${res.status}`)
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
|
||||
if (json.countryCode) {
|
||||
/**
|
||||
* Only construct known values here, ignore any extras.
|
||||
*/
|
||||
const config: Device['geolocation'] = {
|
||||
countryCode: json.countryCode,
|
||||
regionCode: json.regionCode ?? undefined,
|
||||
ageRestrictedGeos: json.ageRestrictedGeos ?? [],
|
||||
ageBlockedGeos: json.ageBlockedGeos ?? [],
|
||||
}
|
||||
logger.debug(`geolocation config: success`)
|
||||
return config
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local promise used within this file only.
|
||||
*/
|
||||
let geolocationConfigResolution: Promise<{success: boolean}> | undefined
|
||||
|
||||
/**
|
||||
* Begin the process of resolving geolocation config. This should be called
|
||||
* once at app start.
|
||||
*
|
||||
* THIS METHOD SHOULD NEVER THROW.
|
||||
*
|
||||
* This method is otherwise not used for any purpose. To ensure geolocation
|
||||
* config is resolved, use {@link ensureGeolocationConfigIsResolved}
|
||||
*/
|
||||
export function beginResolveGeolocationConfig() {
|
||||
/**
|
||||
* Here for debug purposes. Uncomment to prevent hitting the remote geo service, and apply whatever data you require for testing.
|
||||
*/
|
||||
// if (__DEV__) {
|
||||
// geolocationConfigResolution = new Promise(y => y({success: true}))
|
||||
// device.set(['deviceGeolocation'], undefined) // clears GPS data
|
||||
// device.set(['geolocation'], DEFAULT_GEOLOCATION_CONFIG) // clears bapp-config data
|
||||
// return
|
||||
// }
|
||||
|
||||
geolocationConfigResolution = new Promise(async resolve => {
|
||||
let success = true
|
||||
|
||||
try {
|
||||
// Try once, fail fast
|
||||
const config = await getGeolocationConfig(GEOLOCATION_CONFIG_URL)
|
||||
if (config) {
|
||||
device.set(['geolocation'], config)
|
||||
emitGeolocationConfigUpdate(config)
|
||||
} else {
|
||||
// endpoint should throw on all failures, this is insurance
|
||||
throw new Error(
|
||||
`geolocation config: nothing returned from initial request`,
|
||||
)
|
||||
}
|
||||
} catch (e: any) {
|
||||
success = false
|
||||
|
||||
logger.debug(`geolocation config: failed initial request`, {
|
||||
safeMessage: e.message,
|
||||
})
|
||||
|
||||
// set to default
|
||||
device.set(['geolocation'], DEFAULT_GEOLOCATION_CONFIG)
|
||||
|
||||
// retry 3 times, but don't await, proceed with default
|
||||
networkRetry(3, () => getGeolocationConfig(GEOLOCATION_CONFIG_URL))
|
||||
.then(config => {
|
||||
if (config) {
|
||||
device.set(['geolocation'], config)
|
||||
emitGeolocationConfigUpdate(config)
|
||||
success = true
|
||||
} else {
|
||||
// endpoint should throw on all failures, this is insurance
|
||||
throw new Error(`geolocation config: nothing returned from retries`)
|
||||
}
|
||||
})
|
||||
.catch((e: any) => {
|
||||
// complete fail closed
|
||||
logger.debug(`geolocation config: failed retries`, {
|
||||
safeMessage: e.message,
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
resolve({success})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that geolocation config has been resolved, or at the very least attempted
|
||||
* once. Subsequent retries will not be captured by this `await`. Those will be
|
||||
* reported via {@link emitGeolocationConfigUpdate}.
|
||||
*/
|
||||
export async function ensureGeolocationConfigIsResolved() {
|
||||
if (!geolocationConfigResolution) {
|
||||
throw new Error(
|
||||
`geolocation config: beginResolveGeolocationConfig not called yet`,
|
||||
)
|
||||
}
|
||||
|
||||
const cached = device.get(['geolocation'])
|
||||
if (cached) {
|
||||
logger.debug(`geolocation config: using cache`)
|
||||
} else {
|
||||
logger.debug(`geolocation config: no cache`)
|
||||
const {success} = await geolocationConfigResolution
|
||||
if (success) {
|
||||
logger.debug(`geolocation config: resolved`)
|
||||
} else {
|
||||
logger.info(`geolocation config: failed to resolve`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {type GeolocationStatus} from '#/state/geolocation/types'
|
||||
import {BAPP_CONFIG_DEV_URL, IS_DEV} from '#/env'
|
||||
import {type Device} from '#/storage'
|
||||
|
||||
export const IPCC_URL = `https://bsky.app/ipcc`
|
||||
export const BAPP_CONFIG_URL_PROD = `https://ip.bsky.app/config`
|
||||
export const BAPP_CONFIG_URL = IS_DEV
|
||||
? (BAPP_CONFIG_DEV_URL ?? BAPP_CONFIG_URL_PROD)
|
||||
: BAPP_CONFIG_URL_PROD
|
||||
export const GEOLOCATION_CONFIG_URL = BAPP_CONFIG_URL
|
||||
|
||||
/**
|
||||
* Default geolocation config.
|
||||
*/
|
||||
export const DEFAULT_GEOLOCATION_CONFIG: Device['geolocation'] = {
|
||||
countryCode: undefined,
|
||||
regionCode: undefined,
|
||||
ageRestrictedGeos: [],
|
||||
ageBlockedGeos: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* Default geolocation status.
|
||||
*/
|
||||
export const DEFAULT_GEOLOCATION_STATUS: GeolocationStatus = {
|
||||
countryCode: undefined,
|
||||
regionCode: undefined,
|
||||
isAgeRestrictedGeo: false,
|
||||
isAgeBlockedGeo: false,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import EventEmitter from 'eventemitter3'
|
||||
|
||||
import {type Device} from '#/storage'
|
||||
|
||||
const events = new EventEmitter()
|
||||
const EVENT = 'geolocation-config-updated'
|
||||
|
||||
export const emitGeolocationConfigUpdate = (config: Device['geolocation']) => {
|
||||
events.emit(EVENT, config)
|
||||
}
|
||||
|
||||
export const onGeolocationConfigUpdate = (
|
||||
listener: (config: Device['geolocation']) => void,
|
||||
) => {
|
||||
events.on(EVENT, listener)
|
||||
return () => {
|
||||
events.off(EVENT, listener)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import React from 'react'
|
||||
|
||||
import {
|
||||
DEFAULT_GEOLOCATION_CONFIG,
|
||||
DEFAULT_GEOLOCATION_STATUS,
|
||||
} from '#/state/geolocation/const'
|
||||
import {onGeolocationConfigUpdate} from '#/state/geolocation/events'
|
||||
import {logger} from '#/state/geolocation/logger'
|
||||
import {
|
||||
type DeviceLocation,
|
||||
type GeolocationStatus,
|
||||
} from '#/state/geolocation/types'
|
||||
import {useSyncedDeviceGeolocation} from '#/state/geolocation/useSyncedDeviceGeolocation'
|
||||
import {
|
||||
computeGeolocationStatus,
|
||||
mergeGeolocation,
|
||||
} from '#/state/geolocation/util'
|
||||
import {type Device, device} from '#/storage'
|
||||
|
||||
export * from '#/state/geolocation/config'
|
||||
export * from '#/state/geolocation/types'
|
||||
export * from '#/state/geolocation/util'
|
||||
|
||||
type DeviceGeolocationContext = {
|
||||
deviceGeolocation: DeviceLocation | undefined
|
||||
}
|
||||
|
||||
type DeviceGeolocationAPIContext = {
|
||||
setDeviceGeolocation(deviceGeolocation: DeviceLocation): void
|
||||
}
|
||||
|
||||
type GeolocationConfigContext = {
|
||||
config: Device['geolocation']
|
||||
}
|
||||
|
||||
type GeolocationStatusContext = {
|
||||
/**
|
||||
* Merged geolocation from config and device GPS (if available).
|
||||
*/
|
||||
location: DeviceLocation
|
||||
/**
|
||||
* Computed geolocation status based on the merged location and config.
|
||||
*/
|
||||
status: GeolocationStatus
|
||||
}
|
||||
|
||||
const DeviceGeolocationContext = React.createContext<DeviceGeolocationContext>({
|
||||
deviceGeolocation: undefined,
|
||||
})
|
||||
DeviceGeolocationContext.displayName = 'DeviceGeolocationContext'
|
||||
|
||||
const DeviceGeolocationAPIContext =
|
||||
React.createContext<DeviceGeolocationAPIContext>({
|
||||
setDeviceGeolocation: () => {},
|
||||
})
|
||||
DeviceGeolocationAPIContext.displayName = 'DeviceGeolocationAPIContext'
|
||||
|
||||
const GeolocationConfigContext = React.createContext<GeolocationConfigContext>({
|
||||
config: DEFAULT_GEOLOCATION_CONFIG,
|
||||
})
|
||||
GeolocationConfigContext.displayName = 'GeolocationConfigContext'
|
||||
|
||||
const GeolocationStatusContext = React.createContext<GeolocationStatusContext>({
|
||||
location: {
|
||||
countryCode: undefined,
|
||||
regionCode: undefined,
|
||||
},
|
||||
status: DEFAULT_GEOLOCATION_STATUS,
|
||||
})
|
||||
GeolocationStatusContext.displayName = 'GeolocationStatusContext'
|
||||
|
||||
/**
|
||||
* Provider of geolocation config and computed geolocation status.
|
||||
*/
|
||||
export function GeolocationStatusProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const {deviceGeolocation} = React.useContext(DeviceGeolocationContext)
|
||||
const [config, setConfig] = React.useState(() => {
|
||||
const initial = device.get(['geolocation']) || DEFAULT_GEOLOCATION_CONFIG
|
||||
return initial
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
return onGeolocationConfigUpdate(config => {
|
||||
setConfig(config!)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const configContext = React.useMemo(() => ({config}), [config])
|
||||
const statusContext = React.useMemo(() => {
|
||||
if (deviceGeolocation) {
|
||||
logger.debug('geolocation: has device geolocation available')
|
||||
}
|
||||
const geolocation = mergeGeolocation(deviceGeolocation, config)
|
||||
const status = computeGeolocationStatus(geolocation, config)
|
||||
return {location: geolocation, status}
|
||||
}, [config, deviceGeolocation])
|
||||
|
||||
return (
|
||||
<GeolocationConfigContext.Provider value={configContext}>
|
||||
<GeolocationStatusContext.Provider value={statusContext}>
|
||||
{children}
|
||||
</GeolocationStatusContext.Provider>
|
||||
</GeolocationConfigContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider of providers. Provides device geolocation data to lower-level
|
||||
* `GeolocationStatusProvider`, and device geolocation APIs to children.
|
||||
*/
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
const [deviceGeolocation, setDeviceGeolocation] = useSyncedDeviceGeolocation()
|
||||
|
||||
const handleSetDeviceGeolocation = React.useCallback(
|
||||
(location: DeviceLocation) => {
|
||||
logger.debug('geolocation: setting device geolocation')
|
||||
setDeviceGeolocation({
|
||||
countryCode: location.countryCode ?? undefined,
|
||||
regionCode: location.regionCode ?? undefined,
|
||||
})
|
||||
},
|
||||
[setDeviceGeolocation],
|
||||
)
|
||||
|
||||
return (
|
||||
<DeviceGeolocationAPIContext.Provider
|
||||
value={React.useMemo(
|
||||
() => ({setDeviceGeolocation: handleSetDeviceGeolocation}),
|
||||
[handleSetDeviceGeolocation],
|
||||
)}>
|
||||
<DeviceGeolocationContext.Provider
|
||||
value={React.useMemo(() => ({deviceGeolocation}), [deviceGeolocation])}>
|
||||
<GeolocationStatusProvider>{children}</GeolocationStatusProvider>
|
||||
</DeviceGeolocationContext.Provider>
|
||||
</DeviceGeolocationAPIContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDeviceGeolocationApi() {
|
||||
return React.useContext(DeviceGeolocationAPIContext)
|
||||
}
|
||||
|
||||
export function useGeolocationConfig() {
|
||||
return React.useContext(GeolocationConfigContext)
|
||||
}
|
||||
|
||||
export function useGeolocationStatus() {
|
||||
return React.useContext(GeolocationStatusContext)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import {Logger} from '#/logger'
|
||||
|
||||
export const logger = Logger.create(Logger.Context.Geolocation)
|
||||
@@ -0,0 +1,9 @@
|
||||
export type DeviceLocation = {
|
||||
countryCode: string | undefined
|
||||
regionCode: string | undefined
|
||||
}
|
||||
|
||||
export type GeolocationStatus = DeviceLocation & {
|
||||
isAgeRestrictedGeo: boolean
|
||||
isAgeBlockedGeo: boolean
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {useCallback} from 'react'
|
||||
import * as Location from 'expo-location'
|
||||
|
||||
import {type DeviceLocation} from '#/state/geolocation/types'
|
||||
import {getDeviceGeolocation} from '#/state/geolocation/util'
|
||||
|
||||
export {PermissionStatus} from 'expo-location'
|
||||
|
||||
export function useRequestDeviceLocation(): () => Promise<
|
||||
| {
|
||||
granted: true
|
||||
location: DeviceLocation | undefined
|
||||
}
|
||||
| {
|
||||
granted: false
|
||||
status: {
|
||||
canAskAgain: boolean
|
||||
/**
|
||||
* Enum, use `PermissionStatus` export for comparisons
|
||||
*/
|
||||
permissionStatus: Location.PermissionStatus
|
||||
}
|
||||
}
|
||||
> {
|
||||
return useCallback(async () => {
|
||||
const status = await Location.requestForegroundPermissionsAsync()
|
||||
|
||||
if (status.granted) {
|
||||
return {
|
||||
granted: true,
|
||||
location: await getDeviceGeolocation(),
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
granted: false,
|
||||
status: {
|
||||
canAskAgain: status.canAskAgain,
|
||||
permissionStatus: status.status,
|
||||
},
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {useEffect, useRef} from 'react'
|
||||
import * as Location from 'expo-location'
|
||||
|
||||
import {logger} from '#/state/geolocation/logger'
|
||||
import {getDeviceGeolocation} from '#/state/geolocation/util'
|
||||
import {device, useStorage} from '#/storage'
|
||||
|
||||
/**
|
||||
* Hook to get and sync the device geolocation from the device GPS and store it
|
||||
* using device storage. If permissions are not granted, it will clear any cached
|
||||
* storage value.
|
||||
*/
|
||||
export function useSyncedDeviceGeolocation() {
|
||||
const synced = useRef(false)
|
||||
const [status] = Location.useForegroundPermissions()
|
||||
const [deviceGeolocation, setDeviceGeolocation] = useStorage(device, [
|
||||
'deviceGeolocation',
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
async function get() {
|
||||
// no need to set this more than once per session
|
||||
if (synced.current) return
|
||||
|
||||
logger.debug('useSyncedDeviceGeolocation: checking perms')
|
||||
|
||||
if (status?.granted) {
|
||||
const location = await getDeviceGeolocation()
|
||||
if (location) {
|
||||
logger.debug('useSyncedDeviceGeolocation: syncing location')
|
||||
setDeviceGeolocation(location)
|
||||
synced.current = true
|
||||
}
|
||||
} else {
|
||||
const hasCachedValue = device.get(['deviceGeolocation']) !== undefined
|
||||
|
||||
/**
|
||||
* If we have a cached value, but user has revoked permissions,
|
||||
* quietly (will take effect lazily) clear this out.
|
||||
*/
|
||||
if (hasCachedValue) {
|
||||
logger.debug(
|
||||
'useSyncedDeviceGeolocation: clearing cached location, perms revoked',
|
||||
)
|
||||
device.set(['deviceGeolocation'], undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get().catch(e => {
|
||||
logger.error('useSyncedDeviceGeolocation: failed to sync', {
|
||||
safeMessage: e,
|
||||
})
|
||||
})
|
||||
}, [status, setDeviceGeolocation])
|
||||
|
||||
return [deviceGeolocation, setDeviceGeolocation] as const
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
getCurrentPositionAsync,
|
||||
type LocationGeocodedAddress,
|
||||
reverseGeocodeAsync,
|
||||
} from 'expo-location'
|
||||
|
||||
import {logger} from '#/state/geolocation/logger'
|
||||
import {type DeviceLocation} from '#/state/geolocation/types'
|
||||
import {type Device} from '#/storage'
|
||||
|
||||
/**
|
||||
* Maps full US region names to their short codes.
|
||||
*
|
||||
* Context: in some cases, like on Android, we get the full region name instead
|
||||
* of the short code. We may need to expand this in the future to other
|
||||
* countries, hence the prefix.
|
||||
*/
|
||||
export const USRegionNameToRegionCode: {
|
||||
[regionName: string]: string
|
||||
} = {
|
||||
Alabama: 'AL',
|
||||
Alaska: 'AK',
|
||||
Arizona: 'AZ',
|
||||
Arkansas: 'AR',
|
||||
California: 'CA',
|
||||
Colorado: 'CO',
|
||||
Connecticut: 'CT',
|
||||
Delaware: 'DE',
|
||||
Florida: 'FL',
|
||||
Georgia: 'GA',
|
||||
Hawaii: 'HI',
|
||||
Idaho: 'ID',
|
||||
Illinois: 'IL',
|
||||
Indiana: 'IN',
|
||||
Iowa: 'IA',
|
||||
Kansas: 'KS',
|
||||
Kentucky: 'KY',
|
||||
Louisiana: 'LA',
|
||||
Maine: 'ME',
|
||||
Maryland: 'MD',
|
||||
Massachusetts: 'MA',
|
||||
Michigan: 'MI',
|
||||
Minnesota: 'MN',
|
||||
Mississippi: 'MS',
|
||||
Missouri: 'MO',
|
||||
Montana: 'MT',
|
||||
Nebraska: 'NE',
|
||||
Nevada: 'NV',
|
||||
['New Hampshire']: 'NH',
|
||||
['New Jersey']: 'NJ',
|
||||
['New Mexico']: 'NM',
|
||||
['New York']: 'NY',
|
||||
['North Carolina']: 'NC',
|
||||
['North Dakota']: 'ND',
|
||||
Ohio: 'OH',
|
||||
Oklahoma: 'OK',
|
||||
Oregon: 'OR',
|
||||
Pennsylvania: 'PA',
|
||||
['Rhode Island']: 'RI',
|
||||
['South Carolina']: 'SC',
|
||||
['South Dakota']: 'SD',
|
||||
Tennessee: 'TN',
|
||||
Texas: 'TX',
|
||||
Utah: 'UT',
|
||||
Vermont: 'VT',
|
||||
Virginia: 'VA',
|
||||
Washington: 'WA',
|
||||
['West Virginia']: 'WV',
|
||||
Wisconsin: 'WI',
|
||||
Wyoming: 'WY',
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a `LocationGeocodedAddress` into a `DeviceLocation`.
|
||||
*
|
||||
* We don't want or care about the full location data, so we trim it down and
|
||||
* normalize certain fields, like region, into the format we need.
|
||||
*/
|
||||
export function normalizeDeviceLocation(
|
||||
location: LocationGeocodedAddress,
|
||||
): DeviceLocation {
|
||||
let {isoCountryCode, region} = location
|
||||
|
||||
if (region) {
|
||||
if (isoCountryCode === 'US') {
|
||||
region = USRegionNameToRegionCode[region] ?? region
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
countryCode: isoCountryCode ?? undefined,
|
||||
regionCode: region ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines precise location data with the geolocation config fetched from the
|
||||
* IP service, with preference to the precise data.
|
||||
*/
|
||||
export function mergeGeolocation(
|
||||
location?: DeviceLocation,
|
||||
config?: Device['geolocation'],
|
||||
): DeviceLocation {
|
||||
if (location?.countryCode) return location
|
||||
return {
|
||||
countryCode: config?.countryCode,
|
||||
regionCode: config?.regionCode,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the geolocation status (age-restricted, age-blocked) based on the
|
||||
* given location and geolocation config. `location` here should be merged with
|
||||
* `mergeGeolocation()` ahead of time if needed.
|
||||
*/
|
||||
export function computeGeolocationStatus(
|
||||
location: DeviceLocation,
|
||||
config: Device['geolocation'],
|
||||
) {
|
||||
/**
|
||||
* We can't do anything if we don't have this data.
|
||||
*/
|
||||
if (!location.countryCode) {
|
||||
return {
|
||||
...location,
|
||||
isAgeRestrictedGeo: false,
|
||||
isAgeBlockedGeo: false,
|
||||
}
|
||||
}
|
||||
|
||||
const isAgeRestrictedGeo = config?.ageRestrictedGeos?.some(rule => {
|
||||
if (rule.countryCode === location.countryCode) {
|
||||
if (!rule.regionCode) {
|
||||
return true // whole country is blocked
|
||||
} else if (rule.regionCode === location.regionCode) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const isAgeBlockedGeo = config?.ageBlockedGeos?.some(rule => {
|
||||
if (rule.countryCode === location.countryCode) {
|
||||
if (!rule.regionCode) {
|
||||
return true // whole country is blocked
|
||||
} else if (rule.regionCode === location.regionCode) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
...location,
|
||||
isAgeRestrictedGeo: !!isAgeRestrictedGeo,
|
||||
isAgeBlockedGeo: !!isAgeBlockedGeo,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDeviceGeolocation(): Promise<DeviceLocation> {
|
||||
try {
|
||||
const geocode = await getCurrentPositionAsync()
|
||||
const locations = await reverseGeocodeAsync({
|
||||
latitude: geocode.coords.latitude,
|
||||
longitude: geocode.coords.longitude,
|
||||
})
|
||||
const location = locations.at(0)
|
||||
const normalized = location ? normalizeDeviceLocation(location) : undefined
|
||||
return {
|
||||
countryCode: normalized?.countryCode ?? undefined,
|
||||
regionCode: normalized?.regionCode ?? undefined,
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('getDeviceGeolocation: failed', {
|
||||
safeMessage: e,
|
||||
})
|
||||
return {
|
||||
countryCode: undefined,
|
||||
regionCode: undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user