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