forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import React from 'react'
2import {Platform} from 'react-native'
3import {AppState, type AppStateStatus} from 'react-native'
4import {Statsig} from 'statsig-react-native-expo'
5
6import {logger} from '#/logger'
7import {type MetricEvents} from '#/logger/metrics'
8import * as persisted from '#/state/persisted'
9import {IS_WEB} from '#/env'
10import * as env from '#/env'
11import {device} from '#/storage'
12import {timeout} from '../async/timeout'
13// import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback'
14import {type Gate} from './gates'
15
16// const 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 (IS_WEB && 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
48// function 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
95/**
96 * @deprecated use `logger.metric()` instead
97 */
98export function logEvent<E extends keyof MetricEvents>(
99 eventName: E & string,
100 rawMetadata: MetricEvents[E] & FlatJSONRecord,
101 options: {
102 /**
103 * Send to our data lake only, not to StatSig
104 */
105 lake?: boolean
106 } = {lake: false},
107) {
108 try {
109 const fullMetadata = toStringRecord(rawMetadata)
110 fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)'
111 if (Statsig.initializeCalled()) {
112 let ev: string = eventName
113 if (options.lake) {
114 ev = `lake:${ev}`
115 }
116 Statsig.logEvent(ev, null, fullMetadata)
117 }
118 /**
119 * All datalake events should be sent using `logger.metric`, and we don't
120 * want to double-emit logs to other transports.
121 */
122 if (!options.lake) {
123 logger.info(eventName, fullMetadata)
124 }
125 } catch (e) {
126 // A log should never interrupt the calling code, whatever happens.
127 logger.error('Failed to log an event', {message: e})
128 }
129}
130
131function toStringRecord<E extends keyof MetricEvents>(
132 metadata: MetricEvents[E] & FlatJSONRecord,
133): Record<string, string> {
134 const record: Record<string, string> = {}
135 for (let key in metadata) {
136 if (metadata.hasOwnProperty(key)) {
137 if (typeof metadata[key] === 'string') {
138 record[key] = metadata[key]
139 } else {
140 record[key] = JSON.stringify(metadata[key])
141 }
142 }
143 }
144 return record
145}
146
147// We roll our own cache in front of Statsig because it is a singleton
148// and it's been difficult to get it to behave in a predictable way.
149// Our own cache ensures consistent evaluation within a single session.
150const GateCache = React.createContext<Map<string, boolean> | null>(null)
151GateCache.displayName = 'StatsigGateCacheContext'
152
153type GateOptions = {
154 dangerouslyDisableExposureLogging?: boolean
155}
156
157export function useGatesCache(): Map<string, boolean> {
158 const cache = React.useContext(GateCache)
159 if (!cache) {
160 throw Error('useGate() cannot be called outside StatsigProvider.')
161 }
162 return cache
163}
164
165function writeDeerGateCache(cache: Map<string, boolean>) {
166 device.set(['deerGateCache'], JSON.stringify(Object.fromEntries(cache)))
167}
168
169export function resetDeerGateCache() {
170 writeDeerGateCache(new Map())
171}
172
173export function useGate(): (gateName: Gate, options?: GateOptions) => boolean {
174 const cache = React.useContext(GateCache)
175 if (!cache) {
176 throw Error('useGate() cannot be called outside StatsigProvider.')
177 }
178 const gate = React.useCallback(
179 (gateName: Gate, options: GateOptions = {}): boolean => {
180 const cachedValue = cache.get(gateName)
181 if (cachedValue !== undefined) {
182 return cachedValue
183 }
184 let value = false
185 if (Statsig.initializeCalled()) {
186 if (options.dangerouslyDisableExposureLogging) {
187 value = Statsig.checkGateWithExposureLoggingDisabled(gateName)
188 } else {
189 value = Statsig.checkGate(gateName)
190 }
191 }
192 cache.set(gateName, value)
193 writeDeerGateCache(cache)
194 return value
195 },
196 [cache],
197 )
198 return gate
199}
200
201/**
202 * Debugging tool to override a gate. USE ONLY IN E2E TESTS!
203 */
204export function useDangerousSetGate(): (
205 gateName: Gate,
206 value: boolean,
207) => void {
208 const cache = React.useContext(GateCache)
209 if (!cache) {
210 throw Error(
211 'useDangerousSetGate() cannot be called outside StatsigProvider.',
212 )
213 }
214 const dangerousSetGate = React.useCallback(
215 (gateName: Gate, value: boolean) => {
216 cache.set(gateName, value)
217 writeDeerGateCache(cache)
218 },
219 [cache],
220 )
221 return dangerousSetGate
222}
223
224function toStatsigUser(did: string | undefined): StatsigUser {
225 const languagePrefs = persisted.get('languagePrefs')
226 return {
227 userID: did,
228 platform: Platform.OS as 'ios' | 'android' | 'web',
229 custom: {
230 refSrc,
231 refUrl,
232 platform: Platform.OS as 'ios' | 'android' | 'web',
233 appVersion: env.RELEASE_VERSION,
234 bundleIdentifier: env.BUNDLE_IDENTIFIER,
235 bundleDate: env.BUNDLE_DATE,
236 appLanguage: languagePrefs.appLanguage,
237 contentLanguages: languagePrefs.contentLanguages,
238 },
239 }
240}
241
242let lastState: AppStateStatus = AppState.currentState
243let lastActive = lastState === 'active' ? performance.now() : null
244AppState.addEventListener('change', (state: AppStateStatus) => {
245 if (state === lastState) {
246 return
247 }
248 lastState = state
249 if (state === 'active') {
250 lastActive = performance.now()
251 logEvent('state:foreground', {})
252 } else {
253 let secondsActive = 0
254 if (lastActive != null) {
255 secondsActive = Math.round((performance.now() - lastActive) / 1e3)
256 lastActive = null
257 logEvent('state:background', {
258 secondsActive,
259 })
260 }
261 }
262})
263
264export async function tryFetchGates(
265 did: string | undefined,
266 strategy: 'prefer-low-latency' | 'prefer-fresh-gates',
267) {
268 try {
269 let timeoutMs = 250 // Don't block the UI if we can't do this fast.
270 if (strategy === 'prefer-fresh-gates') {
271 // Use this for less common operations where the user would be OK with a delay.
272 timeoutMs = 1500
273 }
274 if (Statsig.initializeCalled()) {
275 await Promise.race([
276 timeout(timeoutMs),
277 Statsig.prefetchUsers([toStatsigUser(did)]),
278 ])
279 }
280 } catch (e) {
281 // Don't leak errors to the calling code, this is meant to be always safe.
282 console.error(e)
283 }
284}
285
286export function initialize() {
287 // return Statsig.initialize(SDK_KEY, null, createStatsigOptions([]))
288 return new Promise(() => {})
289}
290
291export function Provider({children}: {children: React.ReactNode}) {
292 const gateCache = new Map<string, boolean>(
293 Object.entries(JSON.parse(device.get(['deerGateCache']) ?? '{}')),
294 )
295
296 return <GateCache.Provider value={gateCache}>{children}</GateCache.Provider>
297}