my fork of the bluesky client
at main 297 lines 9.2 kB view raw
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}