mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at sonner 327 lines 9.8 kB view raw
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}