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 next/base 311 lines 9.5 kB view raw
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}