Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

at 8c3553cd66ad07ef8c8c4e760b495cf6ce08cc8d 297 lines 8.5 kB view raw
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}