my fork of the bluesky client
1import React from 'react'
2import {Platform} from 'react-native'
3import {AppState, AppStateStatus} from 'react-native'
4import {sha256} from 'js-sha256'
5import {Statsig, StatsigProvider} from 'statsig-react-native-expo'
6
7import {BUNDLE_DATE, BUNDLE_IDENTIFIER, IS_TESTFLIGHT} from '#/lib/app-info'
8import {logger} from '#/logger'
9import {isWeb} from '#/platform/detection'
10import * as persisted from '#/state/persisted'
11import {useSession} from '../../state/session'
12import {timeout} from '../async/timeout'
13import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback'
14import {LogEvents} from './events'
15import {Gate} from './gates'
16
17const SDK_KEY = 'client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV'
18
19type StatsigUser = {
20 userID: string | undefined
21 // TODO: Remove when enough users have custom.platform:
22 platform: 'ios' | 'android' | 'web'
23 custom: {
24 // This is the place where we can add our own stuff.
25 // Fields here have to be non-optional to be visible in the UI.
26 platform: 'ios' | 'android' | 'web'
27 bundleIdentifier: string
28 bundleDate: number
29 refSrc: string
30 refUrl: string
31 appLanguage: string
32 contentLanguages: string[]
33 }
34}
35
36let refSrc = ''
37let refUrl = ''
38if (isWeb && typeof window !== 'undefined') {
39 const params = new URLSearchParams(window.location.search)
40 refSrc = params.get('ref_src') ?? ''
41 refUrl = decodeURIComponent(params.get('ref_url') ?? '')
42}
43
44export type {LogEvents}
45
46function createStatsigOptions(prefetchUsers: StatsigUser[]) {
47 return {
48 environment: {
49 tier:
50 process.env.NODE_ENV === 'development'
51 ? 'development'
52 : IS_TESTFLIGHT
53 ? 'staging'
54 : 'production',
55 },
56 // Don't block on waiting for network. The fetched config will kick in on next load.
57 // This ensures the UI is always consistent and doesn't update mid-session.
58 // Note this makes cold load (no local storage) and private mode return `false` for all gates.
59 initTimeoutMs: 1,
60 // Get fresh flags for other accounts as well, if any.
61 prefetchUsers,
62 api: 'https://events.bsky.app/v2',
63 }
64}
65
66type FlatJSONRecord = Record<
67 string,
68 | string
69 | number
70 | boolean
71 | null
72 | undefined
73 // Technically not scalar but Statsig will stringify it which works for us:
74 | string[]
75>
76
77let getCurrentRouteName: () => string | null | undefined = () => null
78
79export function attachRouteToLogEvents(
80 getRouteName: () => string | null | undefined,
81) {
82 getCurrentRouteName = getRouteName
83}
84
85export function toClout(n: number | null | undefined): number | undefined {
86 if (n == null) {
87 return undefined
88 } else {
89 return Math.max(0, Math.round(Math.log(n)))
90 }
91}
92
93export function logEvent<E extends keyof LogEvents>(
94 eventName: E & string,
95 rawMetadata: LogEvents[E] & FlatJSONRecord,
96) {
97 try {
98 const fullMetadata = {
99 ...rawMetadata,
100 } as Record<string, string> // Statsig typings are unnecessarily strict here.
101 fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)'
102 if (Statsig.initializeCalled()) {
103 Statsig.logEvent(eventName, null, fullMetadata)
104 }
105 } catch (e) {
106 // A log should never interrupt the calling code, whatever happens.
107 logger.error('Failed to log an event', {message: e})
108 }
109}
110
111// We roll our own cache in front of Statsig because it is a singleton
112// and it's been difficult to get it to behave in a predictable way.
113// Our own cache ensures consistent evaluation within a single session.
114const GateCache = React.createContext<Map<string, boolean> | null>(null)
115
116type GateOptions = {
117 dangerouslyDisableExposureLogging?: boolean
118}
119
120export function useGate(): (gateName: Gate, options?: GateOptions) => boolean {
121 const cache = React.useContext(GateCache)
122 if (!cache) {
123 throw Error('useGate() cannot be called outside StatsigProvider.')
124 }
125 const gate = React.useCallback(
126 (gateName: Gate, options: GateOptions = {}): boolean => {
127 const cachedValue = cache.get(gateName)
128 if (cachedValue !== undefined) {
129 return cachedValue
130 }
131 let value = false
132 if (Statsig.initializeCalled()) {
133 if (options.dangerouslyDisableExposureLogging) {
134 value = Statsig.checkGateWithExposureLoggingDisabled(gateName)
135 } else {
136 value = Statsig.checkGate(gateName)
137 }
138 }
139 cache.set(gateName, value)
140 return value
141 },
142 [cache],
143 )
144 return gate
145}
146
147/**
148 * Debugging tool to override a gate. USE ONLY IN E2E TESTS!
149 */
150export function useDangerousSetGate(): (
151 gateName: Gate,
152 value: boolean,
153) => void {
154 const cache = React.useContext(GateCache)
155 if (!cache) {
156 throw Error(
157 'useDangerousSetGate() cannot be called outside StatsigProvider.',
158 )
159 }
160 const dangerousSetGate = React.useCallback(
161 (gateName: Gate, value: boolean) => {
162 cache.set(gateName, value)
163 },
164 [cache],
165 )
166 return dangerousSetGate
167}
168
169function toStatsigUser(did: string | undefined): StatsigUser {
170 let userID: string | undefined
171 if (did) {
172 userID = sha256(did)
173 }
174 const languagePrefs = persisted.get('languagePrefs')
175 return {
176 userID,
177 platform: Platform.OS as 'ios' | 'android' | 'web',
178 custom: {
179 refSrc,
180 refUrl,
181 platform: Platform.OS as 'ios' | 'android' | 'web',
182 bundleIdentifier: BUNDLE_IDENTIFIER,
183 bundleDate: BUNDLE_DATE,
184 appLanguage: languagePrefs.appLanguage,
185 contentLanguages: languagePrefs.contentLanguages,
186 },
187 }
188}
189
190let lastState: AppStateStatus = AppState.currentState
191let lastActive = lastState === 'active' ? performance.now() : null
192AppState.addEventListener('change', (state: AppStateStatus) => {
193 if (state === lastState) {
194 return
195 }
196 lastState = state
197 if (state === 'active') {
198 lastActive = performance.now()
199 logEvent('state:foreground', {})
200 } else {
201 let secondsActive = 0
202 if (lastActive != null) {
203 secondsActive = Math.round((performance.now() - lastActive) / 1e3)
204 lastActive = null
205 logEvent('state:background', {
206 secondsActive,
207 })
208 }
209 }
210})
211
212export async function tryFetchGates(
213 did: string | undefined,
214 strategy: 'prefer-low-latency' | 'prefer-fresh-gates',
215) {
216 try {
217 let timeoutMs = 250 // Don't block the UI if we can't do this fast.
218 if (strategy === 'prefer-fresh-gates') {
219 // Use this for less common operations where the user would be OK with a delay.
220 timeoutMs = 1500
221 }
222 // Note: This condition is currently false the very first render because
223 // Statsig has not initialized yet. In the future, we can fix this by
224 // doing the initialization ourselves instead of relying on the provider.
225 if (Statsig.initializeCalled()) {
226 await Promise.race([
227 timeout(timeoutMs),
228 Statsig.prefetchUsers([toStatsigUser(did)]),
229 ])
230 }
231 } catch (e) {
232 // Don't leak errors to the calling code, this is meant to be always safe.
233 console.error(e)
234 }
235}
236
237export function initialize() {
238 return Statsig.initialize(SDK_KEY, null, createStatsigOptions([]))
239}
240
241export function Provider({children}: {children: React.ReactNode}) {
242 const {currentAccount, accounts} = useSession()
243 const did = currentAccount?.did
244 const currentStatsigUser = React.useMemo(() => toStatsigUser(did), [did])
245
246 const otherDidsConcatenated = accounts
247 .map(account => account.did)
248 .filter(accountDid => accountDid !== did)
249 .join(' ') // We're only interested in DID changes.
250 const otherStatsigUsers = React.useMemo(
251 () => otherDidsConcatenated.split(' ').map(toStatsigUser),
252 [otherDidsConcatenated],
253 )
254 const statsigOptions = React.useMemo(
255 () => createStatsigOptions(otherStatsigUsers),
256 [otherStatsigUsers],
257 )
258
259 // Have our own cache in front of Statsig.
260 // This ensures the results remain stable until the active DID changes.
261 const [gateCache, setGateCache] = React.useState(() => new Map())
262 const [prevDid, setPrevDid] = React.useState(did)
263 if (did !== prevDid) {
264 setPrevDid(did)
265 setGateCache(new Map())
266 }
267
268 // Periodically poll Statsig to get the current rule evaluations for all stored accounts.
269 // These changes are prefetched and stored, but don't get applied until the active DID changes.
270 // This ensures that when you switch an account, it already has fresh results by then.
271 const handleIntervalTick = useNonReactiveCallback(() => {
272 if (Statsig.initializeCalled()) {
273 // Note: Only first five will be taken into account by Statsig.
274 Statsig.prefetchUsers([currentStatsigUser, ...otherStatsigUsers])
275 }
276 })
277 React.useEffect(() => {
278 const id = setInterval(handleIntervalTick, 60e3 /* 1 min */)
279 return () => clearInterval(id)
280 }, [handleIntervalTick])
281
282 return (
283 <GateCache.Provider value={gateCache}>
284 <StatsigProvider
285 key={did}
286 sdkKey={SDK_KEY}
287 mountKey={currentStatsigUser.userID}
288 user={currentStatsigUser}
289 // This isn't really blocking due to short initTimeoutMs above.
290 // However, it ensures `isLoading` is always `false`.
291 waitForInitialization={true}
292 options={statsigOptions}>
293 {children}
294 </StatsigProvider>
295 </GateCache.Provider>
296 )
297}