my fork of the bluesky client
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}