my fork of the bluesky client
at main 169 lines 4.6 kB view raw
1import React from 'react' 2import EventEmitter from 'eventemitter3' 3 4import {networkRetry} from '#/lib/async/retry' 5import {logger} from '#/logger' 6import {IS_DEV} from '#/env' 7import {Device, device} from '#/storage' 8 9const events = new EventEmitter() 10const EVENT = 'geolocation-updated' 11const emitGeolocationUpdate = (geolocation: Device['geolocation']) => { 12 events.emit(EVENT, geolocation) 13} 14const onGeolocationUpdate = ( 15 listener: (geolocation: Device['geolocation']) => void, 16) => { 17 events.on(EVENT, listener) 18 return () => { 19 events.off(EVENT, listener) 20 } 21} 22 23/** 24 * Default geolocation value. IF undefined, we fail closed and apply all 25 * additional mod authorities. 26 */ 27export const DEFAULT_GEOLOCATION: Device['geolocation'] = { 28 countryCode: undefined, 29} 30 31async function getGeolocation(): Promise<Device['geolocation']> { 32 const res = await fetch(`https://bsky.app/ipcc`) 33 34 if (!res.ok) { 35 throw new Error(`geolocation: lookup failed ${res.status}`) 36 } 37 38 const json = await res.json() 39 40 if (json.countryCode) { 41 return { 42 countryCode: json.countryCode, 43 } 44 } else { 45 return undefined 46 } 47} 48 49/** 50 * Local promise used within this file only. 51 */ 52let geolocationResolution: Promise<void> | undefined 53 54/** 55 * Begin the process of resolving geolocation. This should be called once at 56 * app start. 57 * 58 * THIS METHOD SHOULD NEVER THROW. 59 * 60 * This method is otherwise not used for any purpose. To ensure geolocation is 61 * resolved, use {@link ensureGeolocationResolved} 62 */ 63export function beginResolveGeolocation() { 64 /** 65 * In dev, IP server is unavailable, so we just set the default geolocation 66 * and fail closed. 67 */ 68 if (IS_DEV) { 69 geolocationResolution = new Promise(y => y()) 70 device.set(['geolocation'], DEFAULT_GEOLOCATION) 71 return 72 } 73 74 geolocationResolution = new Promise(async resolve => { 75 try { 76 // Try once, fail fast 77 const geolocation = await getGeolocation() 78 if (geolocation) { 79 device.set(['geolocation'], geolocation) 80 emitGeolocationUpdate(geolocation) 81 logger.debug(`geolocation: success`, {geolocation}) 82 } else { 83 // endpoint should throw on all failures, this is insurance 84 throw new Error(`geolocation: nothing returned from initial request`) 85 } 86 } catch (e: any) { 87 logger.error(`geolocation: failed initial request`, { 88 safeMessage: e.message, 89 }) 90 91 // set to default 92 device.set(['geolocation'], DEFAULT_GEOLOCATION) 93 94 // retry 3 times, but don't await, proceed with default 95 networkRetry(3, getGeolocation) 96 .then(geolocation => { 97 if (geolocation) { 98 device.set(['geolocation'], geolocation) 99 emitGeolocationUpdate(geolocation) 100 logger.debug(`geolocation: success`, {geolocation}) 101 } else { 102 // endpoint should throw on all failures, this is insurance 103 throw new Error(`geolocation: nothing returned from retries`) 104 } 105 }) 106 .catch((e: any) => { 107 // complete fail closed 108 logger.error(`geolocation: failed retries`, {safeMessage: e.message}) 109 }) 110 } finally { 111 resolve(undefined) 112 } 113 }) 114} 115 116/** 117 * Ensure that geolocation has been resolved, or at the very least attempted 118 * once. Subsequent retries will not be captured by this `await`. Those will be 119 * reported via {@link events}. 120 */ 121export async function ensureGeolocationResolved() { 122 if (!geolocationResolution) { 123 throw new Error(`geolocation: beginResolveGeolocation not called yet`) 124 } 125 126 const cached = device.get(['geolocation']) 127 if (cached) { 128 logger.debug(`geolocation: using cache`, {cached}) 129 } else { 130 logger.debug(`geolocation: no cache`) 131 await geolocationResolution 132 logger.debug(`geolocation: resolved`, { 133 resolved: device.get(['geolocation']), 134 }) 135 } 136} 137 138type Context = { 139 geolocation: Device['geolocation'] 140} 141 142const context = React.createContext<Context>({ 143 geolocation: DEFAULT_GEOLOCATION, 144}) 145 146export function Provider({children}: {children: React.ReactNode}) { 147 const [geolocation, setGeolocation] = React.useState(() => { 148 const initial = device.get(['geolocation']) || DEFAULT_GEOLOCATION 149 return initial 150 }) 151 152 React.useEffect(() => { 153 return onGeolocationUpdate(geolocation => { 154 setGeolocation(geolocation!) 155 }) 156 }, []) 157 158 const ctx = React.useMemo(() => { 159 return { 160 geolocation, 161 } 162 }, [geolocation]) 163 164 return <context.Provider value={ctx}>{children}</context.Provider> 165} 166 167export function useGeolocation() { 168 return React.useContext(context) 169}