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