Bluesky app fork with some witchin' additions 💫

[APP-1782] Analytics migration (#9734)

* WIP

* Clean up growthbook code, integrate into init and sessions

* Move everything out of React

* Add metrics client

* Move to separate file

* Shared metadata cache

* Ensure we update metadata when session ID changes

* Ensure userMetadata is cleared when logging out

* WIP revamp

* Integrate feature gates into analytics context

* Clean up old code

* Fix useMeta util

* Some comments and cleanup

* Add logger to base analytics context

* Refactor current route handling

* Rip out LogEvent from navigation

* Update tracking endpoint

* Migrate toClout

* Clear out statsig client

* Add todo, reset logger readme

* Ope fix statsig noop

* Refactor logging in feed-feedback, add debug logging to metrics client

* Remove LogEvents alias for Metrics

* Prefer root package export

* Remove Metrics alias from logger

* [APP-1782] Migrate to new analytics APIs (#9735)

* Migrate logEvent to useAnalytics

* Migrate logger.metric to useAnalytics

* Migrate tricky spot, fix types

* Migrate remaining tricky spot

* Missed one

* Remove metric() from logger

* Migrate useGate to useAnalytics

* Remove all other StatSig mentions

* Update event payload

* Update logger tests

* Mock expo method

* Fix session ID bug

* Add session ID test

* Add test for metrics client

* Clarify intent

* Clean up core analytics file

* Clean up the call once utils

* Fix TODO

* Fix TODO

* Fix TODO

* Fix TODO

* Fix TODO

* Remove debug code

* Fix navigation context

* OK nav context is not working, todo

* Checkpoint: works but feels hacky

* Fix navigation context issue

* Improve feature API

* Improve metric logging

* Update logger tests

authored by

Eric Bailey and committed by
GitHub
25a1ed12 8614e5e1

+2358 -1689
+7
.env.example
··· 28 # 29 # 30 31 # Sentry DSN for telemetry 32 EXPO_PUBLIC_SENTRY_DSN= 33
··· 28 # 29 # 30 31 + # Bluesky's metrics API 32 + EXPO_PUBLIC_METRICS_API_HOST= 33 + 34 + # Growthbook config 35 + EXPO_PUBLIC_GROWTHBOOK_API_HOST= 36 + EXPO_PUBLIC_GROWTHBOOK_CLIENT_KEY= 37 + 38 # Sentry DSN for telemetry 39 EXPO_PUBLIC_SENTRY_DSN= 40
-1
eslint.config.mjs
··· 119 }, 120 ], 121 'bsky-internal/use-exact-imports': 'error', 122 - 'bsky-internal/use-typed-gates': 'error', 123 'bsky-internal/use-prefixed-imports': 'error', 124 125 /**
··· 119 }, 120 ], 121 'bsky-internal/use-exact-imports': 'error', 122 'bsky-internal/use-prefixed-imports': 'error', 123 124 /**
-1
eslint/index.js
··· 8 rules: { 9 'avoid-unwrapped-text': require('./avoid-unwrapped-text'), 10 'use-exact-imports': require('./use-exact-imports'), 11 - 'use-typed-gates': require('./use-typed-gates'), 12 'use-prefixed-imports': require('./use-prefixed-imports'), 13 }, 14 }
··· 8 rules: { 9 'avoid-unwrapped-text': require('./avoid-unwrapped-text'), 10 'use-exact-imports': require('./use-exact-imports'), 11 'use-prefixed-imports': require('./use-prefixed-imports'), 12 }, 13 }
-41
eslint/use-typed-gates.js
··· 1 - 'use strict' 2 - 3 - module.exports = { 4 - meta: { 5 - type: 'suggestion', 6 - docs: { 7 - description: 8 - 'Enforce using internal statsig wrapper instead of npm package', 9 - }, 10 - schema: [], 11 - }, 12 - create(context) { 13 - return { 14 - ImportSpecifier(node) { 15 - if ( 16 - !node.local || 17 - node.local.type !== 'Identifier' || 18 - node.local.name !== 'useGate' 19 - ) { 20 - return 21 - } 22 - if ( 23 - node.parent.type !== 'ImportDeclaration' || 24 - !node.parent.source || 25 - node.parent.source.type !== 'Literal' 26 - ) { 27 - return 28 - } 29 - const source = node.parent.source.value 30 - if (source.startsWith('statsig') || source.startsWith('@statsig')) { 31 - context.report({ 32 - node, 33 - message: 34 - "Use useGate() from '#/lib/statsig/statsig' instead of the one on npm.", 35 - }) 36 - } 37 - // TODO: Verify gate() call results aren't stored in variables. 38 - }, 39 - } 40 - }, 41 - }
···
+1 -11
jest/jestSetup.js
··· 99 requireNativeViewManager: jest.fn().mockImplementation(_ => { 100 return () => null 101 }), 102 })) 103 104 jest.mock('expo-localization', () => ({ 105 getLocales: () => [], 106 })) 107 - 108 - jest.mock('statsig-react-native-expo', () => ({ 109 - Statsig: { 110 - initialize() {}, 111 - initializeCalled() { 112 - return false 113 - }, 114 - }, 115 - })) 116 - 117 - jest.mock('../src/lib/statsig/statsig', () => ({}))
··· 99 requireNativeViewManager: jest.fn().mockImplementation(_ => { 100 return () => null 101 }), 102 + createPermissionHook: () => () => [true], 103 })) 104 105 jest.mock('expo-localization', () => ({ 106 getLocales: () => [], 107 }))
+1 -1
package.json
··· 93 "@fortawesome/free-regular-svg-icons": "^6.1.1", 94 "@fortawesome/free-solid-svg-icons": "^6.1.1", 95 "@fortawesome/react-native-fontawesome": "^0.3.2", 96 "@haileyok/bluesky-video": "0.3.2", 97 "@ipld/dag-cbor": "^9.2.0", 98 "@lingui/react": "^4.14.1", ··· 219 "react-textarea-autosize": "^8.5.3", 220 "sonner": "^2.0.7", 221 "sonner-native": "^0.21.0", 222 - "statsig-react-native-expo": "^4.6.1", 223 "tippy.js": "^6.3.7", 224 "tlds": "^1.234.0", 225 "tldts": "^6.1.46",
··· 93 "@fortawesome/free-regular-svg-icons": "^6.1.1", 94 "@fortawesome/free-solid-svg-icons": "^6.1.1", 95 "@fortawesome/react-native-fontawesome": "^0.3.2", 96 + "@growthbook/growthbook-react": "^1.6.2", 97 "@haileyok/bluesky-video": "0.3.2", 98 "@ipld/dag-cbor": "^9.2.0", 99 "@lingui/react": "^4.14.1", ··· 220 "react-textarea-autosize": "^8.5.3", 221 "sonner": "^2.0.7", 222 "sonner-native": "^0.21.0", 223 "tippy.js": "^6.3.7", 224 "tlds": "^1.234.0", 225 "tldts": "^6.1.46",
+40 -33
src/App.native.tsx
··· 17 import {KeyboardControllerProvider} from '#/lib/hooks/useEnableKeyboardController' 18 import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder' 19 import {QueryProvider} from '#/lib/react-query' 20 - import {Provider as StatsigProvider, tryFetchGates} from '#/lib/statsig/statsig' 21 import {s} from '#/lib/styles' 22 import {ThemeProvider} from '#/lib/ThemeContext' 23 import I18nProvider from '#/locale/i18nProvider' ··· 69 prefetchAgeAssuranceConfig, 70 Provider as AgeAssuranceV2Provider, 71 } from '#/ageAssurance' 72 import {IS_ANDROID, IS_IOS} from '#/env' 73 import { 74 prefetchLiveEvents, ··· 114 if (account) { 115 await resumeSession(account) 116 } else { 117 - await tryFetchGates(undefined, 'prefer-fresh-gates') 118 } 119 } catch (e) { 120 logger.error(`session: resume failed`, {message: e}) ··· 144 <React.Fragment 145 // Resets the entire tree below when it changes: 146 key={currentAccount?.did}> 147 - <QueryProvider currentDid={currentAccount?.did}> 148 - <PolicyUpdateOverlayProvider> 149 - <StatsigProvider> 150 <LiveEventsProvider> 151 <AgeAssuranceV2Provider> 152 <ComposerProvider> ··· 192 </ComposerProvider> 193 </AgeAssuranceV2Provider> 194 </LiveEventsProvider> 195 - </StatsigProvider> 196 - </PolicyUpdateOverlayProvider> 197 - </QueryProvider> 198 </React.Fragment> 199 </VideoVolumeProvider> 200 </Splash> ··· 208 const [isReady, setReady] = useState(false) 209 210 React.useEffect(() => { 211 - Promise.all([initPersistedState(), Geo.resolve()]).then(() => 212 setReady(true), 213 ) 214 }, []) ··· 226 <A11yProvider> 227 <KeyboardControllerProvider> 228 <OnboardingProvider> 229 - <SessionProvider> 230 - <PrefsStateProvider> 231 - <I18nProvider> 232 - <ShellStateProvider> 233 - <ModalStateProvider> 234 - <DialogStateProvider> 235 - <LightboxStateProvider> 236 - <PortalProvider> 237 - <BottomSheetProvider> 238 - <StarterPackProvider> 239 - <SafeAreaProvider 240 - initialMetrics={initialWindowMetrics}> 241 - <InnerApp /> 242 - </SafeAreaProvider> 243 - </StarterPackProvider> 244 - </BottomSheetProvider> 245 - </PortalProvider> 246 - </LightboxStateProvider> 247 - </DialogStateProvider> 248 - </ModalStateProvider> 249 - </ShellStateProvider> 250 - </I18nProvider> 251 - </PrefsStateProvider> 252 - </SessionProvider> 253 </OnboardingProvider> 254 </KeyboardControllerProvider> 255 </A11yProvider>
··· 17 import {KeyboardControllerProvider} from '#/lib/hooks/useEnableKeyboardController' 18 import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder' 19 import {QueryProvider} from '#/lib/react-query' 20 import {s} from '#/lib/styles' 21 import {ThemeProvider} from '#/lib/ThemeContext' 22 import I18nProvider from '#/locale/i18nProvider' ··· 68 prefetchAgeAssuranceConfig, 69 Provider as AgeAssuranceV2Provider, 70 } from '#/ageAssurance' 71 + import { 72 + AnalyticsContext, 73 + AnalyticsFeaturesContext, 74 + features, 75 + setupDeviceId, 76 + } from '#/analytics' 77 import {IS_ANDROID, IS_IOS} from '#/env' 78 import { 79 prefetchLiveEvents, ··· 119 if (account) { 120 await resumeSession(account) 121 } else { 122 + await features.init 123 } 124 } catch (e) { 125 logger.error(`session: resume failed`, {message: e}) ··· 149 <React.Fragment 150 // Resets the entire tree below when it changes: 151 key={currentAccount?.did}> 152 + <AnalyticsFeaturesContext> 153 + <QueryProvider currentDid={currentAccount?.did}> 154 + <PolicyUpdateOverlayProvider> 155 <LiveEventsProvider> 156 <AgeAssuranceV2Provider> 157 <ComposerProvider> ··· 197 </ComposerProvider> 198 </AgeAssuranceV2Provider> 199 </LiveEventsProvider> 200 + </PolicyUpdateOverlayProvider> 201 + </QueryProvider> 202 + </AnalyticsFeaturesContext> 203 </React.Fragment> 204 </VideoVolumeProvider> 205 </Splash> ··· 213 const [isReady, setReady] = useState(false) 214 215 React.useEffect(() => { 216 + Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() => 217 setReady(true), 218 ) 219 }, []) ··· 231 <A11yProvider> 232 <KeyboardControllerProvider> 233 <OnboardingProvider> 234 + <AnalyticsContext> 235 + <SessionProvider> 236 + <PrefsStateProvider> 237 + <I18nProvider> 238 + <ShellStateProvider> 239 + <ModalStateProvider> 240 + <DialogStateProvider> 241 + <LightboxStateProvider> 242 + <PortalProvider> 243 + <BottomSheetProvider> 244 + <StarterPackProvider> 245 + <SafeAreaProvider 246 + initialMetrics={initialWindowMetrics}> 247 + <InnerApp /> 248 + </SafeAreaProvider> 249 + </StarterPackProvider> 250 + </BottomSheetProvider> 251 + </PortalProvider> 252 + </LightboxStateProvider> 253 + </DialogStateProvider> 254 + </ModalStateProvider> 255 + </ShellStateProvider> 256 + </I18nProvider> 257 + </PrefsStateProvider> 258 + </SessionProvider> 259 + </AnalyticsContext> 260 </OnboardingProvider> 261 </KeyboardControllerProvider> 262 </A11yProvider>
+40 -29
src/App.web.tsx
··· 9 import * as Sentry from '@sentry/react-native' 10 11 import {QueryProvider} from '#/lib/react-query' 12 - import {Provider as StatsigProvider} from '#/lib/statsig/statsig' 13 import {ThemeProvider} from '#/lib/ThemeContext' 14 import I18nProvider from '#/locale/i18nProvider' 15 import {logger} from '#/logger' ··· 55 import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext' 56 import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' 57 import {ToastOutlet} from '#/components/Toast' 58 - import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance' 59 - import {prefetchAgeAssuranceConfig} from '#/ageAssurance' 60 import { 61 prefetchLiveEvents, 62 Provider as LiveEventsProvider, ··· 87 try { 88 if (account) { 89 await resumeSession(account) 90 } 91 } catch (e) { 92 logger.error(`session: resumeSession failed`, {message: e}) ··· 119 <React.Fragment 120 // Resets the entire tree below when it changes: 121 key={currentAccount?.did}> 122 - <QueryProvider currentDid={currentAccount?.did}> 123 - <PolicyUpdateOverlayProvider> 124 - <StatsigProvider> 125 <LiveEventsProvider> 126 <AgeAssuranceV2Provider> 127 <ComposerProvider> ··· 163 </ComposerProvider> 164 </AgeAssuranceV2Provider> 165 </LiveEventsProvider> 166 - </StatsigProvider> 167 - </PolicyUpdateOverlayProvider> 168 - </QueryProvider> 169 </React.Fragment> 170 </ActiveVideoProvider> 171 </VideoVolumeProvider> ··· 179 const [isReady, setReady] = useState(false) 180 181 React.useEffect(() => { 182 - Promise.all([initPersistedState(), Geo.resolve()]).then(() => 183 setReady(true), 184 ) 185 }, []) ··· 196 <Geo.Provider> 197 <A11yProvider> 198 <OnboardingProvider> 199 - <SessionProvider> 200 - <PrefsStateProvider> 201 - <I18nProvider> 202 - <ShellStateProvider> 203 - <ModalStateProvider> 204 - <DialogStateProvider> 205 - <LightboxStateProvider> 206 - <PortalProvider> 207 - <StarterPackProvider> 208 - <InnerApp /> 209 - </StarterPackProvider> 210 - </PortalProvider> 211 - </LightboxStateProvider> 212 - </DialogStateProvider> 213 - </ModalStateProvider> 214 - </ShellStateProvider> 215 - </I18nProvider> 216 - </PrefsStateProvider> 217 - </SessionProvider> 218 </OnboardingProvider> 219 </A11yProvider> 220 </Geo.Provider>
··· 9 import * as Sentry from '@sentry/react-native' 10 11 import {QueryProvider} from '#/lib/react-query' 12 import {ThemeProvider} from '#/lib/ThemeContext' 13 import I18nProvider from '#/locale/i18nProvider' 14 import {logger} from '#/logger' ··· 54 import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext' 55 import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' 56 import {ToastOutlet} from '#/components/Toast' 57 + import { 58 + prefetchAgeAssuranceConfig, 59 + Provider as AgeAssuranceV2Provider, 60 + } from '#/ageAssurance' 61 + import { 62 + AnalyticsContext, 63 + AnalyticsFeaturesContext, 64 + features, 65 + setupDeviceId, 66 + } from '#/analytics' 67 import { 68 prefetchLiveEvents, 69 Provider as LiveEventsProvider, ··· 94 try { 95 if (account) { 96 await resumeSession(account) 97 + } else { 98 + await features.init 99 } 100 } catch (e) { 101 logger.error(`session: resumeSession failed`, {message: e}) ··· 128 <React.Fragment 129 // Resets the entire tree below when it changes: 130 key={currentAccount?.did}> 131 + <AnalyticsFeaturesContext> 132 + <QueryProvider currentDid={currentAccount?.did}> 133 + <PolicyUpdateOverlayProvider> 134 <LiveEventsProvider> 135 <AgeAssuranceV2Provider> 136 <ComposerProvider> ··· 172 </ComposerProvider> 173 </AgeAssuranceV2Provider> 174 </LiveEventsProvider> 175 + </PolicyUpdateOverlayProvider> 176 + </QueryProvider> 177 + </AnalyticsFeaturesContext> 178 </React.Fragment> 179 </ActiveVideoProvider> 180 </VideoVolumeProvider> ··· 188 const [isReady, setReady] = useState(false) 189 190 React.useEffect(() => { 191 + Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() => 192 setReady(true), 193 ) 194 }, []) ··· 205 <Geo.Provider> 206 <A11yProvider> 207 <OnboardingProvider> 208 + <AnalyticsContext> 209 + <SessionProvider> 210 + <PrefsStateProvider> 211 + <I18nProvider> 212 + <ShellStateProvider> 213 + <ModalStateProvider> 214 + <DialogStateProvider> 215 + <LightboxStateProvider> 216 + <PortalProvider> 217 + <StarterPackProvider> 218 + <InnerApp /> 219 + </StarterPackProvider> 220 + </PortalProvider> 221 + </LightboxStateProvider> 222 + </DialogStateProvider> 223 + </ModalStateProvider> 224 + </ShellStateProvider> 225 + </I18nProvider> 226 + </PrefsStateProvider> 227 + </SessionProvider> 228 + </AnalyticsContext> 229 </OnboardingProvider> 230 </A11yProvider> 231 </Geo.Provider>
+67 -81
src/Navigation.tsx
··· 28 storePayloadForAccountSwitch, 29 } from '#/lib/hooks/useNotificationHandler' 30 import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration' 31 - import {logger as notyLogger} from '#/lib/notifications/util' 32 import {buildStateObject} from '#/lib/routes/helpers' 33 import { 34 type AllNavigatorParams, ··· 38 type MessagesTabNavigatorParams, 39 type MyProfileTabNavigatorParams, 40 type NotificationsTabNavigatorParams, 41 type SearchTabNavigatorParams, 42 } from '#/lib/routes/types' 43 - import {type RouteParams, type State} from '#/lib/routes/types' 44 - import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig' 45 import {bskyTitle} from '#/lib/strings/headings' 46 - import {logger} from '#/logger' 47 import {useUnreadNotifications} from '#/state/queries/notifications/unread' 48 import {useSession} from '#/state/session' 49 import {useLoggedOutViewControls} from '#/state/shell/logged-out' ··· 137 EmailDialogScreenID, 138 useEmailDialogControl, 139 } from '#/components/dialogs/EmailDialog' 140 import {IS_NATIVE, IS_WEB} from '#/env' 141 import {router} from '#/routes' 142 import {Referrer} from '../modules/expo-bluesky-swiss-army' ··· 879 let lastHandledNotificationDateDedupe: number | undefined 880 881 function RoutesContainer({children}: React.PropsWithChildren<{}>) { 882 const theme = useColorSchemeStyle(DefaultTheme, DarkTheme) 883 const {currentAccount, accounts} = useSession() 884 const {onPressSwitchAccount} = useAccountSwitcher() 885 const {setShowLoggedOut} = useLoggedOutViewControls() 886 - const prevLoggedRouteName = useRef<string | undefined>(undefined) 887 const emailDialogControl = useEmailDialogControl() 888 const closeAllActiveElements = useCloseAllActiveElements() 889 ··· 945 const payload = getNotificationPayload(response.notification) 946 947 if (payload) { 948 - notyLogger.metric( 949 - 'notifications:openApp', 950 - {reason: payload.reason, causedBoot: true}, 951 - {statsig: false}, 952 - ) 953 954 if (payload.reason === 'chat-message') { 955 handleChatMessage(payload) ··· 973 } 974 } 975 976 - function onReady() { 977 - prevLoggedRouteName.current = getCurrentRouteName() 978 if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) { 979 emailDialogControl.open({ 980 id: EmailDialogScreenID.VerificationReminder, 981 }) 982 snoozeEmailConfirmationPrompt() 983 } 984 - } 985 986 return ( 987 - <> 988 - <NavigationContainer 989 - ref={navigationRef} 990 - linking={LINKING} 991 - theme={theme} 992 - onStateChange={() => { 993 - logger.metric( 994 - 'router:navigate', 995 - {from: prevLoggedRouteName.current}, 996 - {statsig: false}, 997 - ) 998 - prevLoggedRouteName.current = getCurrentRouteName() 999 - }} 1000 - onReady={() => { 1001 - attachRouteToLogEvents(getCurrentRouteName) 1002 - logModuleInitTime() 1003 - onReady() 1004 - logger.metric('router:navigate', {}, {statsig: false}) 1005 - handlePushNotificationEntry() 1006 - }} 1007 - // WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x 1008 - // However, there's a fair amount of places we do that, especially in when popping to the top of stacks. 1009 - // See BottomBar.tsx for an example of how to handle nested navigators in the tabs correctly. 1010 - // I'm scared of missing a spot (esp. with push notifications etc) so let's enable this legacy behaviour for now. 1011 - // We will need to confirm we handle nested navigators correctly by the time we migrate to React Navigation 8.x 1012 - // -sfn 1013 - navigationInChildEnabled> 1014 - {children} 1015 - </NavigationContainer> 1016 - </> 1017 ) 1018 } 1019 ··· 1084 ]) 1085 } else { 1086 return Promise.resolve() 1087 - } 1088 - } 1089 - 1090 - let didInit = false 1091 - function logModuleInitTime() { 1092 - if (didInit) { 1093 - return 1094 - } 1095 - didInit = true 1096 - 1097 - const initMs = Math.round( 1098 - // @ts-ignore Emitted by Metro in the bundle prelude 1099 - performance.now() - global.__BUNDLE_START_TIME__, 1100 - ) 1101 - console.log(`Time to first paint: ${initMs} ms`) 1102 - logEvent('init', { 1103 - initMs, 1104 - }) 1105 - 1106 - if (IS_WEB) { 1107 - const referrerInfo = Referrer.getReferrerInfo() 1108 - if (referrerInfo && referrerInfo.hostname !== 'bsky.app') { 1109 - logEvent('deepLink:referrerReceived', { 1110 - to: window.location.href, 1111 - referrer: referrerInfo?.referrer, 1112 - hostname: referrerInfo?.hostname, 1113 - }) 1114 - } 1115 - } 1116 - 1117 - if (__DEV__) { 1118 - // This log is noisy, so keep false committed 1119 - const shouldLog = false 1120 - // Relies on our patch to polyfill.js in metro-runtime 1121 - const initLogs = (global as any).__INIT_LOGS__ 1122 - if (shouldLog && Array.isArray(initLogs)) { 1123 - console.log(initLogs.join('\n')) 1124 - } 1125 } 1126 } 1127
··· 28 storePayloadForAccountSwitch, 29 } from '#/lib/hooks/useNotificationHandler' 30 import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration' 31 + import {useCallOnce} from '#/lib/once' 32 import {buildStateObject} from '#/lib/routes/helpers' 33 import { 34 type AllNavigatorParams, ··· 38 type MessagesTabNavigatorParams, 39 type MyProfileTabNavigatorParams, 40 type NotificationsTabNavigatorParams, 41 + type RouteParams, 42 type SearchTabNavigatorParams, 43 + type State, 44 } from '#/lib/routes/types' 45 import {bskyTitle} from '#/lib/strings/headings' 46 import {useUnreadNotifications} from '#/state/queries/notifications/unread' 47 import {useSession} from '#/state/session' 48 import {useLoggedOutViewControls} from '#/state/shell/logged-out' ··· 136 EmailDialogScreenID, 137 useEmailDialogControl, 138 } from '#/components/dialogs/EmailDialog' 139 + import {useAnalytics} from '#/analytics' 140 + import {setNavigationMetadata} from '#/analytics/metadata' 141 import {IS_NATIVE, IS_WEB} from '#/env' 142 import {router} from '#/routes' 143 import {Referrer} from '../modules/expo-bluesky-swiss-army' ··· 880 let lastHandledNotificationDateDedupe: number | undefined 881 882 function RoutesContainer({children}: React.PropsWithChildren<{}>) { 883 + const ax = useAnalytics() 884 + const notyLogger = ax.logger.useChild(ax.logger.Context.Notifications) 885 const theme = useColorSchemeStyle(DefaultTheme, DarkTheme) 886 const {currentAccount, accounts} = useSession() 887 const {onPressSwitchAccount} = useAccountSwitcher() 888 const {setShowLoggedOut} = useLoggedOutViewControls() 889 + const previousScreen = useRef<string | undefined>(undefined) 890 const emailDialogControl = useEmailDialogControl() 891 const closeAllActiveElements = useCloseAllActiveElements() 892 ··· 948 const payload = getNotificationPayload(response.notification) 949 950 if (payload) { 951 + ax.metric('notifications:openApp', { 952 + reason: payload.reason, 953 + causedBoot: true, 954 + }) 955 956 if (payload.reason === 'chat-message') { 957 handleChatMessage(payload) ··· 975 } 976 } 977 978 + const onNavigationReady = useCallOnce(() => { 979 + const currentScreen = getCurrentRouteName() 980 + setNavigationMetadata({ 981 + previousScreen: currentScreen, 982 + currentScreen, 983 + }) 984 + previousScreen.current = currentScreen 985 + 986 + handlePushNotificationEntry() 987 + 988 + ax.metric('router:navigate', {}) 989 + 990 if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) { 991 emailDialogControl.open({ 992 id: EmailDialogScreenID.VerificationReminder, 993 }) 994 snoozeEmailConfirmationPrompt() 995 } 996 + 997 + ax.metric('init', { 998 + initMs: Math.round( 999 + // @ts-ignore Emitted by Metro in the bundle prelude 1000 + performance.now() - global.__BUNDLE_START_TIME__, 1001 + ), 1002 + }) 1003 + 1004 + if (IS_WEB) { 1005 + const referrerInfo = Referrer.getReferrerInfo() 1006 + if (referrerInfo && referrerInfo.hostname !== 'bsky.app') { 1007 + ax.metric('deepLink:referrerReceived', { 1008 + to: window.location.href, 1009 + referrer: referrerInfo?.referrer, 1010 + hostname: referrerInfo?.hostname, 1011 + }) 1012 + } 1013 + } 1014 + }) 1015 1016 return ( 1017 + <NavigationContainer 1018 + ref={navigationRef} 1019 + linking={LINKING} 1020 + theme={theme} 1021 + onStateChange={() => { 1022 + const currentScreen = getCurrentRouteName() 1023 + // do this before metric 1024 + setNavigationMetadata({ 1025 + previousScreen: previousScreen.current, 1026 + currentScreen, 1027 + }) 1028 + ax.metric('router:navigate', {from: previousScreen.current}) 1029 + previousScreen.current = currentScreen 1030 + }} 1031 + onReady={onNavigationReady} 1032 + // WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x 1033 + // However, there's a fair amount of places we do that, especially in when popping to the top of stacks. 1034 + // See BottomBar.tsx for an example of how to handle nested navigators in the tabs correctly. 1035 + // I'm scared of missing a spot (esp. with push notifications etc) so let's enable this legacy behaviour for now. 1036 + // We will need to confirm we handle nested navigators correctly by the time we migrate to React Navigation 8.x 1037 + // -sfn 1038 + navigationInChildEnabled> 1039 + {children} 1040 + </NavigationContainer> 1041 ) 1042 } 1043 ··· 1108 ]) 1109 } else { 1110 return Promise.resolve() 1111 } 1112 } 1113
+9 -11
src/ageAssurance/components/NoAccessScreen.tsx
··· 9 useCreateSupportLink, 10 } from '#/lib/hooks/useCreateSupportLink' 11 import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo' 12 - import {logger} from '#/logger' 13 import {useIsBirthdateUpdateAllowed} from '#/state/birthdate' 14 import {useSessionApi} from '#/state/session' 15 import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' ··· 36 isLegacyBirthdateBug, 37 useAgeAssuranceRegionConfig, 38 } from '#/ageAssurance/util' 39 - import {IS_WEB} from '#/env' 40 - import {IS_NATIVE} from '#/env' 41 import {useDeviceGeolocationApi} from '#/geolocation' 42 43 const textStyles = [a.text_md, a.leading_snug] ··· 45 export function NoAccessScreen() { 46 const t = useTheme() 47 const {_} = useLingui() 48 const {gtPhone} = useBreakpoints() 49 const insets = useSafeAreaInsets() 50 const birthdateControl = useDialogControl() ··· 63 64 useEffect(() => { 65 // just counting overall hits here 66 - logger.metric(`blockedGeoOverlay:shown`, {}) 67 - logger.metric(`ageAssurance:noAccessScreen:shown`, { 68 accountCreatedAt: data?.accountCreatedAt || 'unknown', 69 isAARegion, 70 hasDeclaredAge, ··· 103 label={_(msg`Click here to update your birthdate`)} 104 style={[textStyles]} 105 {...createStaticClick(() => { 106 - logger.metric( 107 - 'ageAssurance:noAccessScreen:openBirthdateDialog', 108 - {}, 109 - ) 110 birthdateControl.open() 111 })}> 112 clicking here ··· 272 function AccessSection() { 273 const t = useTheme() 274 const {_, i18n} = useLingui() 275 const control = useDialogControl() 276 const appealControl = Dialog.useDialogControl() 277 const locationControl = Dialog.useDialogControl() ··· 305 label={_(msg`Contact our moderation team`)} 306 {...createStaticClick(() => { 307 appealControl.open() 308 - logger.metric('ageAssurance:appealDialogOpen', {}) 309 })}> 310 contact our moderation team 311 </SimpleInlineLinkText>{' '} ··· 321 color={hasInitiated ? 'secondary' : 'primary'} 322 onPress={() => { 323 control.open() 324 - logger.metric('ageAssurance:initDialogOpen', { 325 hasInitiatedPreviously: hasInitiated, 326 }) 327 }}>
··· 9 useCreateSupportLink, 10 } from '#/lib/hooks/useCreateSupportLink' 11 import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo' 12 import {useIsBirthdateUpdateAllowed} from '#/state/birthdate' 13 import {useSessionApi} from '#/state/session' 14 import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' ··· 35 isLegacyBirthdateBug, 36 useAgeAssuranceRegionConfig, 37 } from '#/ageAssurance/util' 38 + import {useAnalytics} from '#/analytics' 39 + import {IS_NATIVE, IS_WEB} from '#/env' 40 import {useDeviceGeolocationApi} from '#/geolocation' 41 42 const textStyles = [a.text_md, a.leading_snug] ··· 44 export function NoAccessScreen() { 45 const t = useTheme() 46 const {_} = useLingui() 47 + const ax = useAnalytics() 48 const {gtPhone} = useBreakpoints() 49 const insets = useSafeAreaInsets() 50 const birthdateControl = useDialogControl() ··· 63 64 useEffect(() => { 65 // just counting overall hits here 66 + ax.metric(`blockedGeoOverlay:shown`, {}) 67 + ax.metric(`ageAssurance:noAccessScreen:shown`, { 68 accountCreatedAt: data?.accountCreatedAt || 'unknown', 69 isAARegion, 70 hasDeclaredAge, ··· 103 label={_(msg`Click here to update your birthdate`)} 104 style={[textStyles]} 105 {...createStaticClick(() => { 106 + ax.metric('ageAssurance:noAccessScreen:openBirthdateDialog', {}) 107 birthdateControl.open() 108 })}> 109 clicking here ··· 269 function AccessSection() { 270 const t = useTheme() 271 const {_, i18n} = useLingui() 272 + const ax = useAnalytics() 273 const control = useDialogControl() 274 const appealControl = Dialog.useDialogControl() 275 const locationControl = Dialog.useDialogControl() ··· 303 label={_(msg`Contact our moderation team`)} 304 {...createStaticClick(() => { 305 appealControl.open() 306 + ax.metric('ageAssurance:appealDialogOpen', {}) 307 })}> 308 contact our moderation team 309 </SimpleInlineLinkText>{' '} ··· 319 color={hasInitiated ? 'secondary' : 'primary'} 320 onPress={() => { 321 control.open() 322 + ax.metric('ageAssurance:initDialogOpen', { 323 hasInitiatedPreviously: hasInitiated, 324 }) 325 }}>
+7 -7
src/ageAssurance/components/RedirectOverlay.tsx
··· 25 import {Loader} from '#/components/Loader' 26 import {Text} from '#/components/Typography' 27 import {refetchAgeAssuranceServerState} from '#/ageAssurance' 28 - import {logger} from '#/ageAssurance' 29 - import {IS_WEB} from '#/env' 30 - import {IS_IOS} from '#/env' 31 32 export type RedirectOverlayState = { 33 result: 'success' | 'unknown' ··· 174 175 function Inner() { 176 const t = useTheme() 177 const {_} = useLingui() 178 const agent = useAgent() 179 const polling = useRef(false) ··· 187 188 polling.current = true 189 190 - logger.metric('ageAssurance:redirectDialogOpen', {}) 191 192 wait( 193 3e3, ··· 218 219 setSuccess(true) 220 221 - logger.metric('ageAssurance:redirectDialogSuccess', {}) 222 }) 223 .catch(() => { 224 if (unmounted.current) return 225 setError(true) 226 - logger.metric('ageAssurance:redirectDialogFail', {}) 227 }) 228 229 return () => { 230 unmounted.current = true 231 } 232 - }, [agent]) 233 234 if (success) { 235 return (
··· 25 import {Loader} from '#/components/Loader' 26 import {Text} from '#/components/Typography' 27 import {refetchAgeAssuranceServerState} from '#/ageAssurance' 28 + import {useAnalytics} from '#/analytics' 29 + import {IS_IOS, IS_WEB} from '#/env' 30 31 export type RedirectOverlayState = { 32 result: 'success' | 'unknown' ··· 173 174 function Inner() { 175 const t = useTheme() 176 + const ax = useAnalytics() 177 const {_} = useLingui() 178 const agent = useAgent() 179 const polling = useRef(false) ··· 187 188 polling.current = true 189 190 + ax.metric('ageAssurance:redirectDialogOpen', {}) 191 192 wait( 193 3e3, ··· 218 219 setSuccess(true) 220 221 + ax.metric('ageAssurance:redirectDialogSuccess', {}) 222 }) 223 .catch(() => { 224 if (unmounted.current) return 225 setError(true) 226 + ax.metric('ageAssurance:redirectDialogFail', {}) 227 }) 228 229 return () => { 230 unmounted.current = true 231 } 232 + }, [ax, agent]) 233 234 if (success) { 235 return (
+7 -9
src/ageAssurance/useBeginAgeAssurance.ts
··· 12 import {useAgent} from '#/state/session' 13 import {usePatchAgeAssuranceServerState} from '#/ageAssurance' 14 import {logger} from '#/ageAssurance/logger' 15 import {BLUESKY_PROXY_DID} from '#/env' 16 import {useGeolocation} from '#/geolocation' 17 ··· 19 const APPVIEW = IS_DEV_ENV ? DEV_ENV_APPVIEW : PUBLIC_APPVIEW 20 21 export function useBeginAgeAssurance() { 22 const agent = useAgent() 23 const geolocation = useGeolocation() 24 const patchAgeAssuranceStateResponse = usePatchAgeAssuranceServerState() ··· 48 appView.sessionManager.session.accessJwt = token 49 appView.sessionManager.session.refreshJwt = '' 50 51 - logger.metric( 52 - 'ageAssurance:api:begin', 53 - { 54 - platform: Platform.OS, 55 - countryCode, 56 - regionCode, 57 - }, 58 - {statsig: false}, 59 - ) 60 61 /* 62 * 2s wait is good actually. Email sending takes a hot sec and this helps
··· 12 import {useAgent} from '#/state/session' 13 import {usePatchAgeAssuranceServerState} from '#/ageAssurance' 14 import {logger} from '#/ageAssurance/logger' 15 + import {useAnalytics} from '#/analytics' 16 import {BLUESKY_PROXY_DID} from '#/env' 17 import {useGeolocation} from '#/geolocation' 18 ··· 20 const APPVIEW = IS_DEV_ENV ? DEV_ENV_APPVIEW : PUBLIC_APPVIEW 21 22 export function useBeginAgeAssurance() { 23 + const ax = useAnalytics() 24 const agent = useAgent() 25 const geolocation = useGeolocation() 26 const patchAgeAssuranceStateResponse = usePatchAgeAssuranceServerState() ··· 50 appView.sessionManager.session.accessJwt = token 51 appView.sessionManager.session.refreshJwt = '' 52 53 + ax.metric('ageAssurance:api:begin', { 54 + platform: Platform.OS, 55 + countryCode, 56 + regionCode, 57 + }) 58 59 /* 60 * 2s wait is good actually. Email sending takes a hot sec and this helps
+32
src/analytics/PassiveAnalytics.tsx
···
··· 1 + import {useEffect, useRef} from 'react' 2 + 3 + import {getCurrentState, onAppStateChange} from '#/lib/appState' 4 + import {useAnalytics} from '#/analytics' 5 + 6 + /** 7 + * Tracks passive analytics like app foreground/background time. 8 + */ 9 + export function PassiveAnalytics() { 10 + const ax = useAnalytics() 11 + const lastActive = useRef( 12 + getCurrentState() === 'active' ? performance.now() : null, 13 + ) 14 + 15 + useEffect(() => { 16 + const sub = onAppStateChange(state => { 17 + if (state === 'active') { 18 + lastActive.current = performance.now() 19 + ax.metric('state:foreground', {}) 20 + } else if (lastActive.current !== null) { 21 + ax.metric('state:background', { 22 + secondsActive: Math.round( 23 + (performance.now() - lastActive.current) / 1e3, 24 + ), 25 + }) 26 + } 27 + }) 28 + return () => sub.remove() 29 + }, [ax]) 30 + 31 + return null 32 + }
+62
src/analytics/features/index.ts
···
··· 1 + import {GrowthBook} from '@growthbook/growthbook-react' 2 + 3 + import {type Metadata} from '#/analytics/metadata' 4 + import * as env from '#/env' 5 + 6 + export {Features} from '#/analytics/features/types' 7 + 8 + /** 9 + * We vary the amount of time we wait for GrowthBook to fetch feature 10 + * gates based on the strategy specified. 11 + */ 12 + export type FeatureFetchStrategy = 'prefer-low-latency' | 'prefer-fresh-gates' 13 + 14 + const TIMEOUT_INIT = 500 // TODO should base on p99 or something 15 + const TIMEOUT_PREFER_LOW_LATENCY = 250 16 + const TIMEOUT_PREFER_FRESH_GATES = 1500 17 + 18 + export const features = new GrowthBook({ 19 + apiHost: env.GROWTHBOOK_API_HOST, 20 + clientKey: env.GROWTHBOOK_CLIENT_KEY, 21 + }) 22 + 23 + /** 24 + * Initializer promise that must be awaited before using the GrowthBook 25 + * instance or rendering the `AnalyticsFeaturesContext`. Note: this may not be 26 + * fully initialized if it takes longer than `TIMEOUT_INIT` to initialize. In 27 + * that case, we may see a flash of uncustomized content until the 28 + * initialization completes. 29 + */ 30 + export const init = new Promise<void>(async y => { 31 + await features.init({timeout: TIMEOUT_INIT}) 32 + y() 33 + }) 34 + 35 + /** 36 + * Refresh feature gates from GrowthBook. Updates attributes based on the 37 + * provided account, if any. 38 + */ 39 + export async function refresh({strategy}: {strategy: FeatureFetchStrategy}) { 40 + await features.refreshFeatures({ 41 + timeout: 42 + strategy === 'prefer-low-latency' 43 + ? TIMEOUT_PREFER_LOW_LATENCY 44 + : TIMEOUT_PREFER_FRESH_GATES, 45 + }) 46 + } 47 + 48 + /** 49 + * Converts our metadata into GrowthBook attributes and sets them. 50 + */ 51 + export function setAttributes({base, session, preferences}: Metadata) { 52 + const {deviceId, sessionId, ...br} = base 53 + features.setAttributes({ 54 + device_id: deviceId, // GrowthBook special field 55 + session_id: sessionId, // GrowthBook special field 56 + user_id: session?.did, // GrowthBook special field 57 + id: session?.did, // GrowthBook special field 58 + ...br, 59 + ...(session || {}), 60 + ...(preferences || {}), 61 + }) 62 + }
+7
src/analytics/features/types.ts
···
··· 1 + export enum Features { 2 + DebugFeedContext = 'debug_show_feedcontext', 3 + IsBskyTeam = 'is_bsky_team_member', 4 + DisableOnboardingFindContacts = 'disable_onboarding_find_contacts', 5 + DisableSettingsFindContacts = 'disable_settings_find_contacts', 6 + DisableLiveNowBeta = 'disable_live_now_beta', 7 + }
+26
src/analytics/identifiers/device.ts
···
··· 1 + import uuid from 'react-native-uuid' 2 + import AsyncStorage from '@react-native-async-storage/async-storage' 3 + 4 + import {device} from '#/storage' 5 + 6 + const LEGACY_STABLE_ID = 'STATSIG_LOCAL_STORAGE_STABLE_ID' 7 + 8 + export async function getAndMigrateDeviceId() { 9 + const migrated = getDeviceId() 10 + if (migrated) return migrated 11 + const id = (await AsyncStorage.getItem(LEGACY_STABLE_ID)) || uuid.v4() 12 + device.set(['deviceId'], id) 13 + return id 14 + } 15 + 16 + export function getDeviceId() { 17 + return device.get(['deviceId']) 18 + } 19 + 20 + export function getDeviceIdOrThrow() { 21 + const id = device.get(['deviceId']) 22 + if (!id) { 23 + throw new Error(`deviceId is not set, call getAndMigrateDeviceId first`) 24 + } 25 + return id 26 + }
+2
src/analytics/identifiers/index.ts
···
··· 1 + export * from '#/analytics/identifiers/device' 2 + export * from '#/analytics/identifiers/session'
+79
src/analytics/identifiers/session.test.ts
···
··· 1 + jest.mock('#/storage', () => ({ 2 + device: { 3 + get: jest.fn(), 4 + set: jest.fn(), 5 + }, 6 + })) 7 + 8 + jest.mock('#/analytics/identifiers/util', () => ({ 9 + isSessionIdExpired: jest.fn(), 10 + })) 11 + 12 + jest.mock('#/lib/appState', () => ({ 13 + onAppStateChange: jest.fn(() => ({remove: jest.fn()})), 14 + })) 15 + 16 + beforeEach(() => { 17 + jest.resetModules() 18 + jest.clearAllMocks() 19 + }) 20 + 21 + function getMocks() { 22 + const {device} = require('#/storage') 23 + const {isSessionIdExpired} = require('#/analytics/identifiers/util') 24 + return { 25 + device: jest.mocked(device), 26 + isSessionIdExpired: jest.mocked(isSessionIdExpired), 27 + } 28 + } 29 + 30 + describe('session initialization', () => { 31 + it('creates new session and sets timestamp when none exists', () => { 32 + const {device, isSessionIdExpired} = getMocks() 33 + device.get.mockReturnValue(undefined) 34 + isSessionIdExpired.mockReturnValue(false) 35 + 36 + const {getInitialSessionId} = require('./session') 37 + const id = getInitialSessionId() 38 + 39 + expect(id).toBeDefined() 40 + expect(typeof id).toBe('string') 41 + expect(device.set).toHaveBeenCalledWith(['nativeSessionId'], id) 42 + expect(device.set).toHaveBeenCalledWith( 43 + ['nativeSessionIdLastEventAt'], 44 + expect.any(Number), 45 + ) 46 + }) 47 + 48 + it('reuses existing session when not expired', () => { 49 + const {device, isSessionIdExpired} = getMocks() 50 + const existingId = 'existing-session-id' 51 + device.get.mockImplementation((key: string[]) => { 52 + if (key[0] === 'nativeSessionId') return existingId 53 + if (key[0] === 'nativeSessionIdLastEventAt') return Date.now() 54 + return undefined 55 + }) 56 + isSessionIdExpired.mockReturnValue(false) 57 + 58 + const {getInitialSessionId} = require('./session') 59 + 60 + expect(getInitialSessionId()).toBe(existingId) 61 + }) 62 + 63 + it('creates new session when existing is expired', () => { 64 + const {device, isSessionIdExpired} = getMocks() 65 + const existingId = 'existing-session-id' 66 + device.get.mockImplementation((key: string[]) => { 67 + if (key[0] === 'nativeSessionId') return existingId 68 + if (key[0] === 'nativeSessionIdLastEventAt') return Date.now() - 999999 69 + return undefined 70 + }) 71 + isSessionIdExpired.mockReturnValue(true) 72 + 73 + const {getInitialSessionId} = require('./session') 74 + const id = getInitialSessionId() 75 + 76 + expect(id).not.toBe(existingId) 77 + expect(device.set).toHaveBeenCalledWith(['nativeSessionId'], id) 78 + }) 79 + })
+40
src/analytics/identifiers/session.ts
···
··· 1 + import {useEffect, useState} from 'react' 2 + import uuid from 'react-native-uuid' 3 + 4 + import {onAppStateChange} from '#/lib/appState' 5 + import {isSessionIdExpired} from '#/analytics/identifiers/util' 6 + import {device} from '#/storage' 7 + 8 + let sessionId = (() => { 9 + const existing = device.get(['nativeSessionId']) 10 + const lastEvent = device.get(['nativeSessionIdLastEventAt']) 11 + const id = existing && !isSessionIdExpired(lastEvent) ? existing : uuid.v4() 12 + device.set(['nativeSessionId'], id) 13 + device.set(['nativeSessionIdLastEventAt'], Date.now()) 14 + return id 15 + })() 16 + 17 + export function getInitialSessionId() { 18 + return sessionId 19 + } 20 + 21 + export function useSessionId() { 22 + const [id, setId] = useState(() => sessionId) 23 + 24 + useEffect(() => { 25 + const sub = onAppStateChange(state => { 26 + if (state === 'active') { 27 + const lastEvent = device.get(['nativeSessionIdLastEventAt']) 28 + if (isSessionIdExpired(lastEvent)) { 29 + sessionId = uuid.v4() 30 + device.set(['nativeSessionId'], sessionId) 31 + setId(sessionId) 32 + } 33 + } 34 + device.set(['nativeSessionIdLastEventAt'], Date.now()) 35 + }) 36 + return () => sub.remove() 37 + }, []) 38 + 39 + return id 40 + }
+44
src/analytics/identifiers/session.web.ts
···
··· 1 + import {useEffect, useState} from 'react' 2 + import uuid from 'react-native-uuid' 3 + 4 + import {onAppStateChange} from '#/lib/appState' 5 + import {isSessionIdExpired} from '#/analytics/identifiers/util' 6 + 7 + const SESSION_ID_KEY = 'bsky_session_id' 8 + const LAST_EVENT_KEY = 'bsky_session_id_last_event_at' 9 + 10 + let sessionId = (() => { 11 + const existing = window.sessionStorage.getItem(SESSION_ID_KEY) 12 + const lastEventStr = window.sessionStorage.getItem(LAST_EVENT_KEY) 13 + const lastEvent = lastEventStr ? Number(lastEventStr) : undefined 14 + const id = existing && !isSessionIdExpired(lastEvent) ? existing : uuid.v4() 15 + window.sessionStorage.setItem(SESSION_ID_KEY, id) 16 + window.sessionStorage.setItem(LAST_EVENT_KEY, String(Date.now())) 17 + return id 18 + })() 19 + 20 + export function getInitialSessionId() { 21 + return sessionId 22 + } 23 + 24 + export function useSessionId() { 25 + const [id, setId] = useState(() => sessionId) 26 + 27 + useEffect(() => { 28 + const sub = onAppStateChange(state => { 29 + if (state === 'active') { 30 + const lastEventStr = window.sessionStorage.getItem(LAST_EVENT_KEY) 31 + const lastEvent = lastEventStr ? Number(lastEventStr) : undefined 32 + if (isSessionIdExpired(lastEvent)) { 33 + sessionId = uuid.v4() 34 + window.sessionStorage.setItem(SESSION_ID_KEY, sessionId) 35 + setId(sessionId) 36 + } 37 + } 38 + window.sessionStorage.setItem(LAST_EVENT_KEY, String(Date.now())) 39 + }) 40 + return () => sub.remove() 41 + }, []) 42 + 43 + return id 44 + }
+9
src/analytics/identifiers/util.ts
···
··· 1 + import * as env from '#/env' 2 + 3 + const ONE_MIN = 60 * 1e3 4 + const TTL = (env.IS_NATIVE ? 5 : 30) * ONE_MIN // 5 min on native 5 + 6 + export function isSessionIdExpired(since: number | undefined) { 7 + if (since === undefined) return false 8 + return Date.now() - since >= TTL 9 + }
+229
src/analytics/index.tsx
···
··· 1 + import {createContext, useContext, useEffect, useMemo} from 'react' 2 + import {Platform} from 'react-native' 3 + 4 + import {Logger} from '#/logger' 5 + import { 6 + Features, 7 + features as feats, 8 + init, 9 + refresh, 10 + setAttributes, 11 + } from '#/analytics/features' 12 + import { 13 + getAndMigrateDeviceId, 14 + getDeviceId, 15 + getInitialSessionId, 16 + useSessionId, 17 + } from '#/analytics/identifiers' 18 + import { 19 + getNavigationMetadata, 20 + type MergeableMetadata, 21 + type Metadata, 22 + } from '#/analytics/metadata' 23 + import {type Metrics, metrics} from '#/analytics/metrics' 24 + import * as refParams from '#/analytics/misc/refParams' 25 + import {getMetadataForLogger} from '#/analytics/utils' 26 + import * as env from '#/env' 27 + import {useGeolocation} from '#/geolocation' 28 + import {device} from '#/storage' 29 + 30 + export * as utils from '#/analytics/utils' 31 + export const features = {init, refresh} 32 + export {Features} from '#/analytics/features' 33 + export {type Metrics} from '#/analytics/metrics' 34 + 35 + type LoggerType = { 36 + debug: Logger['debug'] 37 + info: Logger['info'] 38 + log: Logger['log'] 39 + warn: Logger['warn'] 40 + error: Logger['error'] 41 + /** 42 + * Clones the existing logger and overrides the `context` value. Existing 43 + * metadata is inherited. 44 + * 45 + * ```ts 46 + * const ax = useAnalytics() 47 + * const logger = ax.logger.useChild(ax.logger.Context.Notifications) 48 + * ``` 49 + */ 50 + useChild: (context: Exclude<Logger['context'], undefined>) => LoggerType 51 + Context: typeof Logger.Context 52 + } 53 + export type AnalyticsContextType = { 54 + metadata: Metadata 55 + logger: LoggerType 56 + metric: <E extends keyof Metrics>( 57 + event: E, 58 + payload: Metrics[E], 59 + metadata?: MergeableMetadata, 60 + ) => void 61 + features: typeof Features & { 62 + enabled(feature: Features): boolean 63 + } 64 + } 65 + export type AnalyticsBaseContextType = Omit<AnalyticsContextType, 'features'> 66 + 67 + function createLogger( 68 + context: Logger['context'], 69 + metadata: Partial<Metadata>, 70 + ): LoggerType { 71 + const logger = Logger.create(context, metadata) 72 + return { 73 + debug: logger.debug.bind(logger), 74 + info: logger.info.bind(logger), 75 + log: logger.log.bind(logger), 76 + warn: logger.warn.bind(logger), 77 + error: logger.error.bind(logger), 78 + useChild: (context: Exclude<Logger['context'], undefined>) => { 79 + return useMemo(() => createLogger(context, metadata), [context, metadata]) 80 + }, 81 + Context: Logger.Context, 82 + } 83 + } 84 + 85 + const Context = createContext<AnalyticsBaseContextType>({ 86 + logger: createLogger(Logger.Context.Default, {}), 87 + metric: (event, payload, metadata) => { 88 + if (metadata && '__meta' in metadata) { 89 + delete metadata.__meta 90 + } 91 + metrics.track(event, payload, { 92 + ...metadata, 93 + navigation: getNavigationMetadata(), 94 + }) 95 + }, 96 + metadata: { 97 + base: { 98 + deviceId: getDeviceId() ?? 'unknown', 99 + sessionId: getInitialSessionId(), 100 + platform: Platform.OS, 101 + appVersion: env.APP_VERSION, 102 + bundleIdentifier: env.BUNDLE_IDENTIFIER, 103 + bundleDate: env.BUNDLE_DATE, 104 + referrerSrc: refParams.src, 105 + referrerUrl: refParams.url, 106 + }, 107 + geolocation: device.get(['mergedGeolocation']) || { 108 + countryCode: '', 109 + regionCode: '', 110 + }, 111 + }, 112 + }) 113 + 114 + /** 115 + * Ensures that deviceId is set and migrated from legacy storage. Handled on 116 + * startup in `App.<platform>.tsx`. This must be awaited prior to the app 117 + * booting up. 118 + */ 119 + export const setupDeviceId = getAndMigrateDeviceId() 120 + 121 + /** 122 + * Analytics context provider. Decorates the parent analytics context with 123 + * additional metadata. Nesting should be done carefully and sparingly. 124 + */ 125 + export function AnalyticsContext({ 126 + children, 127 + metadata, 128 + }: { 129 + children: React.ReactNode 130 + metadata?: MergeableMetadata 131 + }) { 132 + if (metadata) { 133 + if (!('__meta' in metadata)) { 134 + throw new Error( 135 + 'Use the useMeta() helper when passing metadata to AnalyticsContext', 136 + ) 137 + } 138 + } 139 + const sessionId = useSessionId() 140 + const geolocation = useGeolocation() 141 + const parentContext = useContext(Context) 142 + const childContext = useMemo(() => { 143 + const combinedMetadata = { 144 + ...parentContext.metadata, 145 + ...metadata, 146 + base: { 147 + ...parentContext.metadata.base, 148 + sessionId, 149 + }, 150 + geolocation, 151 + } 152 + const context: AnalyticsBaseContextType = { 153 + ...parentContext, 154 + logger: createLogger( 155 + Logger.Context.Default, 156 + getMetadataForLogger(combinedMetadata), 157 + ), 158 + metadata: combinedMetadata, 159 + metric: (event, payload, extraMetadata) => { 160 + parentContext.metric(event, payload, { 161 + ...combinedMetadata, 162 + ...extraMetadata, 163 + }) 164 + }, 165 + } 166 + return context 167 + }, [sessionId, geolocation, parentContext, metadata]) 168 + return <Context.Provider value={childContext}>{children}</Context.Provider> 169 + } 170 + 171 + /** 172 + * Feature gates provider. Decorates the parent analytics context with 173 + * feature gate capabilities. Should be mounted within `AnalyticsContext`, 174 + * and below the `<Fragment key={did} />` breaker in `App.<platform>.tsx`. 175 + */ 176 + export function AnalyticsFeaturesContext({ 177 + children, 178 + }: { 179 + children: React.ReactNode 180 + }) { 181 + const parentContext = useContext(Context) 182 + 183 + useEffect(() => { 184 + feats.setTrackingCallback((experiment, result) => { 185 + parentContext.metric('experiment:viewed', { 186 + experimentId: experiment.key, 187 + variationId: result.key, 188 + }) 189 + }) 190 + }, [parentContext.metric]) 191 + 192 + useEffect(() => { 193 + setAttributes(parentContext.metadata) 194 + }, [parentContext.metadata]) 195 + 196 + const childContext = useMemo<AnalyticsContextType>(() => { 197 + return { 198 + ...parentContext, 199 + features: { 200 + enabled: feats.isOn.bind(feats), 201 + ...Features, 202 + }, 203 + } 204 + }, [parentContext]) 205 + 206 + return <Context.Provider value={childContext}>{children}</Context.Provider> 207 + } 208 + 209 + /** 210 + * Basic analytics context without feature gates. Should really only be used 211 + * above the `AnalyticsFeaturesContext` provider. 212 + */ 213 + export function useAnalyticsBase() { 214 + return useContext(Context) 215 + } 216 + 217 + /** 218 + * The main analytics context, including feature gates. Use this everywhere you 219 + * need metrics, features, or logging within the React tree. 220 + */ 221 + export function useAnalytics() { 222 + const ctx = useContext(Context) 223 + if (!('features' in ctx)) { 224 + throw new Error( 225 + 'useAnalytics must be used within an AnalyticsFeaturesContext', 226 + ) 227 + } 228 + return ctx as AnalyticsContextType 229 + }
+61
src/analytics/metadata.ts
···
··· 1 + import {type Geolocation} from '#/geolocation' 2 + 3 + export type BaseMetadata = { 4 + deviceId: string 5 + sessionId: string 6 + platform: string 7 + appVersion: string 8 + bundleIdentifier: string 9 + bundleDate: number 10 + referrerSrc: string 11 + referrerUrl: string 12 + } 13 + 14 + export type GeolocationMetadata = Geolocation 15 + 16 + export type SessionMetadata = { 17 + did: string 18 + isBskyPds: boolean 19 + } 20 + 21 + export type PreferencesMetadata = { 22 + appLanguage: string 23 + contentLanguages: string[] 24 + } 25 + 26 + export type MergeableMetadata = { 27 + session?: SessionMetadata 28 + preferences?: PreferencesMetadata 29 + /** 30 + * Navigation metadata is not actually available on this object, instead it's 31 + * merged in at time-of-log/metric. See `#/analytics/metadata.ts` for details. 32 + */ 33 + navigation?: NavigationMetadata 34 + } 35 + 36 + export type Metadata = { 37 + base: BaseMetadata 38 + geolocation: GeolocationMetadata 39 + } & MergeableMetadata 40 + 41 + /* 42 + * Navigation metadata is handle out-of-band from React, since we don't want to 43 + * slow down screen transitions in any way, and there doesn't seem to be a nice 44 + * way to get current navigation state without an additional re-render between 45 + * navigations. 46 + * 47 + * So instead of this data being available on the Metadata object, it's stored 48 + * here and merged in at time-of-log/metric. 49 + */ 50 + export type NavigationMetadata = { 51 + previousScreen?: string 52 + currentScreen?: string 53 + } 54 + let navigationMetadata: NavigationMetadata | undefined 55 + export function getNavigationMetadata() { 56 + console.log('metadata', JSON.stringify(navigationMetadata, null, 2)) 57 + return navigationMetadata 58 + } 59 + export function setNavigationMetadata(meta: NavigationMetadata | undefined) { 60 + navigationMetadata = meta 61 + }
+176
src/analytics/metrics/client.test.ts
···
··· 1 + import {MetricsClient} from './client' 2 + 3 + let appStateCallback: (state: string) => void 4 + 5 + jest.mock('#/lib/appState', () => ({ 6 + onAppStateChange: jest.fn(cb => { 7 + appStateCallback = cb 8 + return {remove: jest.fn()} 9 + }), 10 + })) 11 + 12 + jest.mock('#/logger', () => ({ 13 + Logger: { 14 + create: () => ({ 15 + info: jest.fn(), 16 + debug: jest.fn(), 17 + error: jest.fn(), 18 + }), 19 + Context: {Metric: 'metric'}, 20 + }, 21 + })) 22 + 23 + jest.mock('#/env', () => ({ 24 + METRICS_API_HOST: 'https://test.metrics.api', 25 + IS_WEB: false, 26 + })) 27 + 28 + type TestEvents = { 29 + click: {button: string} 30 + view: {screen: string} 31 + } 32 + 33 + describe('MetricsClient', () => { 34 + let fetchMock: jest.Mock 35 + let fetchRequests: {body: any}[] 36 + 37 + beforeEach(() => { 38 + jest.useFakeTimers({advanceTimers: true}) 39 + fetchRequests = [] 40 + fetchMock = jest.fn().mockImplementation(async (_url, options) => { 41 + const body = JSON.parse(options.body) 42 + fetchRequests.push({body}) 43 + return {ok: true, status: 200} 44 + }) 45 + global.fetch = fetchMock 46 + }) 47 + 48 + afterEach(() => { 49 + jest.useRealTimers() 50 + jest.clearAllMocks() 51 + }) 52 + 53 + it('flushes events on interval', async () => { 54 + const client = new MetricsClient<TestEvents>() 55 + client.track('click', {button: 'submit'}) 56 + client.track('view', {screen: 'home'}) 57 + 58 + expect(fetchRequests).toHaveLength(0) 59 + 60 + // Advance past the 10 second interval 61 + await jest.advanceTimersByTimeAsync(10_000) 62 + 63 + expect(fetchRequests).toHaveLength(1) 64 + expect(fetchRequests[0].body.events).toHaveLength(2) 65 + expect(fetchRequests[0].body.events[0].event).toBe('click') 66 + expect(fetchRequests[0].body.events[1].event).toBe('view') 67 + }) 68 + 69 + it('flushes when maxBatchSize is exceeded', async () => { 70 + const client = new MetricsClient<TestEvents>() 71 + client.maxBatchSize = 5 72 + 73 + // Add events up to maxBatchSize (should not flush yet) 74 + for (let i = 0; i < 5; i++) { 75 + client.track('click', {button: `btn-${i}`}) 76 + } 77 + 78 + expect(fetchRequests).toHaveLength(0) 79 + 80 + // One more event should trigger flush (> maxBatchSize) 81 + client.track('click', {button: 'btn-trigger'}) 82 + 83 + // Allow microtasks to run 84 + await jest.advanceTimersByTimeAsync(0) 85 + 86 + expect(fetchRequests).toHaveLength(1) 87 + expect(fetchRequests[0].body.events).toHaveLength(6) 88 + }) 89 + 90 + it('retries failed events once on 500 response', async () => { 91 + let requestCount = 0 92 + 93 + fetchMock.mockImplementation(async (_url, options) => { 94 + requestCount++ 95 + const body = JSON.parse(options.body) 96 + 97 + if (requestCount === 1) { 98 + // First request fails with 500 - "Failed to fetch" triggers isNetworkError 99 + return { 100 + ok: false, 101 + status: 500, 102 + text: async () => 'Internal Server Error', 103 + } 104 + } 105 + 106 + // Retry succeeds 107 + fetchRequests.push({body}) 108 + return {ok: true, status: 200} 109 + }) 110 + 111 + const client = new MetricsClient<TestEvents>() 112 + client.track('click', {button: 'submit'}) 113 + 114 + // Trigger flush via interval 115 + await jest.advanceTimersByTimeAsync(10_000) 116 + 117 + expect(requestCount).toBe(1) 118 + expect(fetchRequests).toHaveLength(0) 119 + 120 + // Simulate app coming to foreground to trigger retry 121 + appStateCallback('active') 122 + await jest.advanceTimersByTimeAsync(0) 123 + 124 + expect(requestCount).toBe(2) 125 + expect(fetchRequests).toHaveLength(1) 126 + expect(fetchRequests[0].body.events).toHaveLength(1) 127 + expect(fetchRequests[0].body.events[0].event).toBe('click') 128 + }) 129 + 130 + it('does not retry more than once', async () => { 131 + let requestCount = 0 132 + 133 + fetchMock.mockImplementation(async () => { 134 + requestCount++ 135 + // Always fail with network-like error 136 + return { 137 + ok: false, 138 + status: 500, 139 + text: async () => 'Internal Server Error', 140 + } 141 + }) 142 + 143 + const client = new MetricsClient<TestEvents>() 144 + client.track('click', {button: 'submit'}) 145 + 146 + // First flush fails 147 + await jest.advanceTimersByTimeAsync(10_000) 148 + 149 + expect(requestCount).toBe(1) 150 + 151 + // Retry also fails 152 + appStateCallback('active') 153 + await jest.advanceTimersByTimeAsync(0) 154 + 155 + expect(requestCount).toBe(2) 156 + 157 + // Another foreground event should not retry again (events are dropped) 158 + appStateCallback('active') 159 + await jest.advanceTimersByTimeAsync(0) 160 + 161 + expect(requestCount).toBe(2) // No additional requests 162 + }) 163 + 164 + it('flushes when app goes to background', async () => { 165 + const client = new MetricsClient<TestEvents>() 166 + client.track('click', {button: 'submit'}) 167 + 168 + expect(fetchRequests).toHaveLength(0) 169 + 170 + // Simulate app going to background 171 + appStateCallback('background') 172 + await jest.advanceTimersByTimeAsync(0) 173 + 174 + expect(fetchRequests).toHaveLength(1) 175 + }) 176 + })
+116
src/analytics/metrics/client.ts
···
··· 1 + import {onAppStateChange} from '#/lib/appState' 2 + import {isNetworkError} from '#/lib/strings/errors' 3 + import {Logger} from '#/logger' 4 + import * as env from '#/env' 5 + 6 + type Event<M extends Record<string, any>> = { 7 + time: number 8 + event: keyof M 9 + payload: M[keyof M] 10 + metadata: Record<string, any> 11 + } 12 + 13 + const TRACKING_ENDPOINT = env.METRICS_API_HOST + '/t' 14 + const logger = Logger.create(Logger.Context.Metric, {}) 15 + 16 + export class MetricsClient<M extends Record<string, any>> { 17 + maxBatchSize = 100 18 + 19 + private started: boolean = false 20 + private queue: Event<M>[] = [] 21 + private failedQueue: Event<M>[] = [] 22 + private flushInterval: NodeJS.Timeout | null = null 23 + 24 + start() { 25 + if (this.started) return 26 + this.started = true 27 + this.flushInterval = setInterval(() => { 28 + this.flush() 29 + }, 10_000) 30 + onAppStateChange(state => { 31 + if (state === 'active') { 32 + this.retryFailedLogs() 33 + } else { 34 + this.flush() 35 + } 36 + }) 37 + } 38 + 39 + track<E extends keyof M>( 40 + event: E, 41 + payload: M[E], 42 + metadata: Record<string, any> = {}, 43 + ) { 44 + this.start() 45 + 46 + const e = { 47 + time: Date.now(), 48 + event, 49 + payload, 50 + metadata, 51 + } 52 + this.queue.push(e) 53 + 54 + logger.info(`event: ${e.event as string}`, e) 55 + 56 + if (this.queue.length > this.maxBatchSize) { 57 + this.flush() 58 + } 59 + } 60 + 61 + flush() { 62 + if (!this.queue.length) return 63 + const events = this.queue.splice(0, this.queue.length) 64 + this.sendBatch(events) 65 + } 66 + 67 + private async sendBatch(events: Event<M>[], isRetry: boolean = false) { 68 + logger.debug(`sendBatch: ${events.length}`, { 69 + isRetry, 70 + }) 71 + 72 + try { 73 + const body = JSON.stringify({events}) 74 + if (env.IS_WEB && 'navigator' in globalThis && navigator.sendBeacon) { 75 + const success = navigator.sendBeacon( 76 + TRACKING_ENDPOINT, 77 + new Blob([body], {type: 'application/json'}), 78 + ) 79 + if (!success) { 80 + // construct a "network error" for `isNetworkError` to work 81 + throw new Error(`Failed to fetch: sendBeacon returned false`) 82 + } 83 + } else { 84 + const res = await fetch(TRACKING_ENDPOINT, { 85 + method: 'POST', 86 + headers: { 87 + 'Content-Type': 'application/json', 88 + }, 89 + body: JSON.stringify({events}), 90 + keepalive: true, 91 + }) 92 + 93 + if (!res.ok) { 94 + const error = await res.text().catch(() => 'Unknown error') 95 + // construct a "network error" for `isNetworkError` to work 96 + throw new Error(`${res.status} Failed to fetch — ${error}`) 97 + } 98 + } 99 + } catch (e: any) { 100 + if (isNetworkError(e)) { 101 + if (isRetry) return // retry once 102 + this.failedQueue.push(...events) 103 + return 104 + } 105 + logger.error(`Failed to send metrics`, { 106 + safeMessage: e.toString(), 107 + }) 108 + } 109 + } 110 + 111 + private retryFailedLogs() { 112 + if (!this.failedQueue.length) return 113 + const events = this.failedQueue.splice(0, this.failedQueue.length) 114 + this.sendBatch(events, true) 115 + } 116 + }
+6
src/analytics/metrics/index.ts
···
··· 1 + import {MetricsClient} from '#/analytics/metrics/client' 2 + import {type Events} from '#/analytics/metrics/types' 3 + 4 + export type {Events as Metrics} from '#/analytics/metrics/types' 5 + export * from '#/analytics/metrics/utils' 6 + export const metrics = new MetricsClient<Events>()
+7
src/analytics/metrics/utils.ts
···
··· 1 + export function toClout(n: number | null | undefined): number | undefined { 2 + if (n == null) { 3 + return undefined 4 + } else { 5 + return Math.max(0, Math.round(Math.log(n))) 6 + } 7 + }
+18
src/analytics/misc/refParams.ts
···
··· 1 + /** 2 + * This is used for our own Bluesky post embeds, and maybe other things. 3 + * 4 + * In the case of our embeds, `ref_src=embed`. Not sure if `ref_url` is used. 5 + */ 6 + 7 + import * as env from '#/env' 8 + 9 + let refSrc = '' 10 + let refUrl = '' 11 + if (env.IS_WEB) { 12 + const params = new URLSearchParams(window.location.search) 13 + refSrc = params.get('ref_src') ?? '' 14 + refUrl = decodeURIComponent(params.get('ref_url') ?? '') 15 + } 16 + 17 + export const src = refSrc 18 + export const url = refUrl
+50
src/analytics/utils.ts
···
··· 1 + import {useMemo} from 'react' 2 + 3 + import {BSKY_SERVICE} from '#/lib/constants' 4 + import {type SessionAccount} from '#/state/session' 5 + import { 6 + type MergeableMetadata, 7 + type Metadata, 8 + type SessionMetadata, 9 + } from '#/analytics/metadata' 10 + 11 + /** 12 + * Thin `useMemo` wrapper that marks the metadata as memoized and provides a 13 + * type guard. 14 + */ 15 + export function useMeta(metadata?: MergeableMetadata) { 16 + const m = useMemo(() => metadata, [metadata]) 17 + if (!m) return 18 + // @ts-ignore 19 + m.__meta = true 20 + return m 21 + } 22 + 23 + export function accountToSessionMetadata( 24 + account: SessionAccount | undefined, 25 + ): SessionMetadata | undefined { 26 + if (!account) { 27 + return 28 + } else { 29 + return { 30 + did: account.did, 31 + isBskyPds: account.service.startsWith(BSKY_SERVICE), 32 + } 33 + } 34 + } 35 + 36 + export function getMetadataForLogger({ 37 + base, 38 + geolocation, 39 + session, 40 + }: Metadata): Record<string, any> { 41 + return { 42 + deviceId: base.deviceId, 43 + sessionId: base.sessionId, 44 + platform: base.platform, 45 + appVersion: base.appVersion, 46 + countryCode: geolocation.countryCode, 47 + regionCode: geolocation.regionCode, 48 + isBskyPds: session?.isBskyPds || 'anonymous', 49 + } 50 + }
+24 -29
src/components/FeedInterstitials.tsx
··· 7 import {useNavigation} from '@react-navigation/native' 8 9 import {type NavigationProp} from '#/lib/routes/types' 10 - import {logEvent} from '#/lib/statsig/statsig' 11 - import {logger} from '#/logger' 12 - import {type MetricEvents} from '#/logger/metrics' 13 import {useModerationOpts} from '#/state/preferences/moderation-opts' 14 import {useGetPopularFeedsQuery} from '#/state/queries/feed' 15 import {type FeedDescriptor} from '#/state/queries/post-feed' ··· 38 import {InlineLinkText} from '#/components/Link' 39 import * as ProfileCard from '#/components/ProfileCard' 40 import {Text} from '#/components/Typography' 41 import {IS_IOS} from '#/env' 42 import type * as bsky from '#/types/bsky' 43 import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog' ··· 434 isVisible?: boolean 435 }) { 436 const t = useTheme() 437 const {_} = useLingui() 438 const moderationOpts = useModerationOpts() 439 const {gtMobile} = useBreakpoints() ··· 450 const seenProfilesRef = useRef<Set<string>>(new Set()) 451 const containerRef = useRef<View>(null) 452 const hasTrackedRef = useRef(false) 453 - const logContext: MetricEvents['suggestedUser:seen']['logContext'] = 454 - isFeedContext 455 - ? 'InterstitialDiscover' 456 - : isProfileHeaderContext 457 - ? 'Profile' 458 - : 'InterstitialProfile' 459 460 // Callback to fire seen events 461 const fireSeen = useCallback(() => { ··· 467 profilesToShow.forEach((profile, index) => { 468 if (!seenProfilesRef.current.has(profile.did)) { 469 seenProfilesRef.current.add(profile.did) 470 - logger.metric( 471 - 'suggestedUser:seen', 472 - { 473 - logContext, 474 - recId, 475 - position: index, 476 - suggestedDid: profile.did, 477 - category: null, 478 - }, 479 - {statsig: true}, 480 - ) 481 } 482 }) 483 - }, [isLoading, error, profiles, maxLength, logContext, recId]) 484 485 // For profile header, fire when isVisible becomes true 486 useEffect(() => { ··· 565 <ProfileCard.Link 566 profile={profile} 567 onPress={() => { 568 - logEvent('suggestedUser:press', { 569 logContext: isFeedContext 570 ? 'InterstitialDiscover' 571 : 'InterstitialProfile', ··· 588 onPress={e => { 589 e.preventDefault() 590 onDismiss(profile.did) 591 - logEvent('suggestedUser:dismiss', { 592 logContext: isFeedContext 593 ? 'InterstitialDiscover' 594 : 'InterstitialProfile', ··· 656 withIcon={false} 657 style={[a.rounded_sm]} 658 onFollow={() => { 659 - logEvent('suggestedUser:follow', { 660 logContext: isFeedContext 661 ? 'InterstitialDiscover' 662 : 'InterstitialProfile', ··· 678 // Use totalProfileCount (before dismissals) for minLength check on initial render. 679 const profileCountForMinCheck = totalProfileCount ?? profiles.length 680 if (error || (!isLoading && profileCountForMinCheck < minLength)) { 681 - logger.debug(`Not enough profiles to show suggested follows`) 682 return null 683 } 684 ··· 712 label={_(msg`See more suggested profiles`)} 713 onPress={() => { 714 followDialogControl.open() 715 - logEvent('suggestedUser:seeMore', { 716 logContext: isFeedContext ? 'Explore' : 'Profile', 717 }) 718 }}> ··· 756 <SeeMoreSuggestedProfilesCard 757 onPress={() => { 758 followDialogControl.open() 759 - logger.metric('suggestedUser:seeMore', { 760 logContext: 'Explore', 761 }) 762 }} ··· 794 ) 795 } 796 797 export function SuggestedFeeds() { 798 - const numFeedsToDisplay = 3 799 const t = useTheme() 800 const {_} = useLingui() 801 const {data, isLoading, error} = useGetPopularFeedsQuery({ 802 limit: numFeedsToDisplay, ··· 829 key={feed.uri} 830 view={feed} 831 onPress={() => { 832 - logEvent('feed:interstitial:feedCard:press', {}) 833 }}> 834 {({hovered, pressed}) => ( 835 <CardOuter
··· 7 import {useNavigation} from '@react-navigation/native' 8 9 import {type NavigationProp} from '#/lib/routes/types' 10 import {useModerationOpts} from '#/state/preferences/moderation-opts' 11 import {useGetPopularFeedsQuery} from '#/state/queries/feed' 12 import {type FeedDescriptor} from '#/state/queries/post-feed' ··· 35 import {InlineLinkText} from '#/components/Link' 36 import * as ProfileCard from '#/components/ProfileCard' 37 import {Text} from '#/components/Typography' 38 + import {type Metrics, useAnalytics} from '#/analytics' 39 import {IS_IOS} from '#/env' 40 import type * as bsky from '#/types/bsky' 41 import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog' ··· 432 isVisible?: boolean 433 }) { 434 const t = useTheme() 435 + const ax = useAnalytics() 436 const {_} = useLingui() 437 const moderationOpts = useModerationOpts() 438 const {gtMobile} = useBreakpoints() ··· 449 const seenProfilesRef = useRef<Set<string>>(new Set()) 450 const containerRef = useRef<View>(null) 451 const hasTrackedRef = useRef(false) 452 + const logContext: Metrics['suggestedUser:seen']['logContext'] = isFeedContext 453 + ? 'InterstitialDiscover' 454 + : isProfileHeaderContext 455 + ? 'Profile' 456 + : 'InterstitialProfile' 457 458 // Callback to fire seen events 459 const fireSeen = useCallback(() => { ··· 465 profilesToShow.forEach((profile, index) => { 466 if (!seenProfilesRef.current.has(profile.did)) { 467 seenProfilesRef.current.add(profile.did) 468 + ax.metric('suggestedUser:seen', { 469 + logContext, 470 + recId, 471 + position: index, 472 + suggestedDid: profile.did, 473 + category: null, 474 + }) 475 } 476 }) 477 + }, [ax, isLoading, error, profiles, maxLength, logContext, recId]) 478 479 // For profile header, fire when isVisible becomes true 480 useEffect(() => { ··· 559 <ProfileCard.Link 560 profile={profile} 561 onPress={() => { 562 + ax.metric('suggestedUser:press', { 563 logContext: isFeedContext 564 ? 'InterstitialDiscover' 565 : 'InterstitialProfile', ··· 582 onPress={e => { 583 e.preventDefault() 584 onDismiss(profile.did) 585 + ax.metric('suggestedUser:dismiss', { 586 logContext: isFeedContext 587 ? 'InterstitialDiscover' 588 : 'InterstitialProfile', ··· 650 withIcon={false} 651 style={[a.rounded_sm]} 652 onFollow={() => { 653 + ax.metric('suggestedUser:follow', { 654 logContext: isFeedContext 655 ? 'InterstitialDiscover' 656 : 'InterstitialProfile', ··· 672 // Use totalProfileCount (before dismissals) for minLength check on initial render. 673 const profileCountForMinCheck = totalProfileCount ?? profiles.length 674 if (error || (!isLoading && profileCountForMinCheck < minLength)) { 675 + ax.logger.debug(`Not enough profiles to show suggested follows`) 676 return null 677 } 678 ··· 706 label={_(msg`See more suggested profiles`)} 707 onPress={() => { 708 followDialogControl.open() 709 + ax.metric('suggestedUser:seeMore', { 710 logContext: isFeedContext ? 'Explore' : 'Profile', 711 }) 712 }}> ··· 750 <SeeMoreSuggestedProfilesCard 751 onPress={() => { 752 followDialogControl.open() 753 + ax.metric('suggestedUser:seeMore', { 754 logContext: 'Explore', 755 }) 756 }} ··· 788 ) 789 } 790 791 + const numFeedsToDisplay = 3 792 export function SuggestedFeeds() { 793 const t = useTheme() 794 + const ax = useAnalytics() 795 const {_} = useLingui() 796 const {data, isLoading, error} = useGetPopularFeedsQuery({ 797 limit: numFeedsToDisplay, ··· 824 key={feed.uri} 825 view={feed} 826 onPress={() => { 827 + ax.metric('feed:interstitial:feedCard:press', {}) 828 }}> 829 {({hovered, pressed}) => ( 830 <CardOuter
+4 -3
src/components/PostControls/BookmarkButton.tsx
··· 6 import type React from 'react' 7 8 import {useCleanError} from '#/lib/hooks/useCleanError' 9 - import {logger} from '#/logger' 10 import {type Shadow} from '#/state/cache/post-shadow' 11 import {useFeedFeedbackContext} from '#/state/feed-feedback' 12 import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation' ··· 15 import {Bookmark, BookmarkFilled} from '#/components/icons/Bookmark' 16 import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 17 import * as toast from '#/components/Toast' 18 import {PostControlButton, PostControlButtonIcon} from './PostControlButton' 19 20 export const BookmarkButton = memo(function BookmarkButton({ ··· 29 hitSlop?: Insets 30 }): React.ReactNode { 31 const t = useTheme() 32 const {_} = useLingui() 33 const {mutateAsync: bookmark} = useBookmarkMutation() 34 const cleanError = useCleanError() ··· 52 post, 53 }) 54 55 - logger.metric('post:bookmark', { 56 uri: post.uri, 57 authorDid: post.author.did, 58 logContext, ··· 92 uri: post.uri, 93 }) 94 95 - logger.metric('post:unbookmark', { 96 uri: post.uri, 97 authorDid: post.author.did, 98 logContext,
··· 6 import type React from 'react' 7 8 import {useCleanError} from '#/lib/hooks/useCleanError' 9 import {type Shadow} from '#/state/cache/post-shadow' 10 import {useFeedFeedbackContext} from '#/state/feed-feedback' 11 import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation' ··· 14 import {Bookmark, BookmarkFilled} from '#/components/icons/Bookmark' 15 import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 16 import * as toast from '#/components/Toast' 17 + import {useAnalytics} from '#/analytics' 18 import {PostControlButton, PostControlButtonIcon} from './PostControlButton' 19 20 export const BookmarkButton = memo(function BookmarkButton({ ··· 29 hitSlop?: Insets 30 }): React.ReactNode { 31 const t = useTheme() 32 + const ax = useAnalytics() 33 const {_} = useLingui() 34 const {mutateAsync: bookmark} = useBookmarkMutation() 35 const cleanError = useCleanError() ··· 53 post, 54 }) 55 56 + ax.metric('post:bookmark', { 57 uri: post.uri, 58 authorDid: post.author.did, 59 logContext, ··· 93 uri: post.uri, 94 }) 95 96 + ax.metric('post:unbookmark', { 97 uri: post.uri, 98 authorDid: post.author.did, 99 logContext,
+3 -3
src/components/PostControls/DiscoverDebug.tsx
··· 3 import {t} from '@lingui/macro' 4 5 import {DISCOVER_DEBUG_DIDS} from '#/lib/constants' 6 - import {useGate} from '#/lib/statsig/statsig' 7 import {useSession} from '#/state/session' 8 import {atoms as a, useTheme} from '#/alf' 9 import * as Toast from '#/components/Toast' 10 import {Text} from '#/components/Typography' 11 import {IS_INTERNAL} from '#/env' 12 13 export function DiscoverDebug({ ··· 15 }: { 16 feedContext: string | undefined 17 }) { 18 const {currentAccount} = useSession() 19 - const gate = useGate() 20 const isDiscoverDebugUser = 21 IS_INTERNAL || 22 DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] || 23 - gate('debug_show_feedcontext') 24 const theme = useTheme() 25 26 return (
··· 3 import {t} from '@lingui/macro' 4 5 import {DISCOVER_DEBUG_DIDS} from '#/lib/constants' 6 import {useSession} from '#/state/session' 7 import {atoms as a, useTheme} from '#/alf' 8 import * as Toast from '#/components/Toast' 9 import {Text} from '#/components/Typography' 10 + import {useAnalytics} from '#/analytics' 11 import {IS_INTERNAL} from '#/env' 12 13 export function DiscoverDebug({ ··· 15 }: { 16 feedContext: string | undefined 17 }) { 18 + const ax = useAnalytics() 19 const {currentAccount} = useSession() 20 const isDiscoverDebugUser = 21 IS_INTERNAL || 22 DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] || 23 + ax.features.enabled(ax.features.DebugFeedContext) 24 const theme = useTheme() 25 26 return (
+28 -25
src/components/PostControls/PostMenu/PostMenuItems.tsx
··· 26 type CommonNavigatorParams, 27 type NavigationProp, 28 } from '#/lib/routes/types' 29 - import {logEvent, useGate} from '#/lib/statsig/statsig' 30 import {richTextToString} from '#/lib/strings/rich-text-helpers' 31 import {toShareUrl} from '#/lib/strings/url-helpers' 32 import {logger} from '#/logger' 33 import {type Shadow} from '#/state/cache/post-shadow' 34 import {useProfileShadow} from '#/state/cache/profile-shadow' 35 import {useFeedFeedbackContext} from '#/state/feed-feedback' 36 - import {useLanguagePrefs} from '#/state/preferences' 37 - import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences' 38 import {usePinnedPostMutation} from '#/state/queries/pinned-post' 39 import { 40 usePostDeleteMutation, ··· 71 import {Eye_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/Eye' 72 import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' 73 import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' 74 - import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' 75 - import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' 76 import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/Person' 77 import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' 78 import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2' 79 - import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' 80 - import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' 81 import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 82 import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' 83 import {Loader} from '#/components/Loader' ··· 87 useReportDialogControl, 88 } from '#/components/moderation/ReportDialog' 89 import * as Prompt from '#/components/Prompt' 90 import {IS_INTERNAL} from '#/env' 91 import * as bsky from '#/types/bsky' 92 ··· 116 }): React.ReactNode => { 117 const {hasSession, currentAccount} = useSession() 118 const {_} = useLingui() 119 const langPrefs = useLanguagePrefs() 120 const {mutateAsync: deletePostMutate} = usePostDeleteMutation() 121 const {mutateAsync: pinPostMutate, isPending: isPinPending} = ··· 212 try { 213 if (isThreadMuted) { 214 unmuteThread() 215 - logger.metric('post:unmute', { 216 uri: postUri, 217 authorDid: postAuthor.did, 218 logContext, ··· 221 Toast.show(_(msg`You will now receive notifications for this thread`)) 222 } else { 223 muteThread() 224 - logger.metric('post:mute', { 225 uri: postUri, 226 authorDid: postAuthor.did, 227 logContext, ··· 258 AppBskyFeedPost.isRecord, 259 ) 260 ) { 261 - logger.metric( 262 - 'translate', 263 - { 264 - sourceLanguages: post.record.langs ?? [], 265 - targetLanguage: langPrefs.primaryLanguage, 266 - textLength: post.record.text.length, 267 - }, 268 - {statsig: false}, 269 - ) 270 } 271 } 272 273 const onHidePost = () => { 274 hidePost({uri: postUri}) 275 - logEvent('thread:click:hideReplyForMe', {}) 276 } 277 278 const hideInPWI = !!postAuthor.labels?.find( ··· 286 feedContext: postFeedContext, 287 reqId: postReqId, 288 }) 289 - logger.metric('post:showMore', { 290 uri: postUri, 291 authorDid: postAuthor.did, 292 logContext, ··· 304 feedContext: postFeedContext, 305 reqId: postReqId, 306 }) 307 - logger.metric('post:showLess', { 308 uri: postUri, 309 authorDid: postAuthor.did, 310 logContext, ··· 368 369 // Log metric only when hiding (not when showing) 370 if (isHide) { 371 - logEvent('thread:click:hideReplyForEveryone', {}) 372 } 373 374 Toast.show( ··· 405 } 406 407 const onPressPin = () => { 408 - logEvent(isPinned ? 'post:unpin' : 'post:pin', {}) 409 pinPostMutate({ 410 postUri, 411 postCid, ··· 458 459 const onSignIn = () => requireSignIn(() => {}) 460 461 - const gate = useGate() 462 const isDiscoverDebugUser = 463 IS_INTERNAL || 464 DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] || 465 - gate('debug_show_feedcontext') 466 467 return ( 468 <>
··· 26 type CommonNavigatorParams, 27 type NavigationProp, 28 } from '#/lib/routes/types' 29 import {richTextToString} from '#/lib/strings/rich-text-helpers' 30 import {toShareUrl} from '#/lib/strings/url-helpers' 31 import {logger} from '#/logger' 32 import {type Shadow} from '#/state/cache/post-shadow' 33 import {useProfileShadow} from '#/state/cache/profile-shadow' 34 import {useFeedFeedbackContext} from '#/state/feed-feedback' 35 + import { 36 + useHiddenPosts, 37 + useHiddenPostsApi, 38 + useLanguagePrefs, 39 + } from '#/state/preferences' 40 import {usePinnedPostMutation} from '#/state/queries/pinned-post' 41 import { 42 usePostDeleteMutation, ··· 73 import {Eye_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/Eye' 74 import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' 75 import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' 76 + import { 77 + Mute_Stroke2_Corner0_Rounded as Mute, 78 + Mute_Stroke2_Corner0_Rounded as MuteIcon, 79 + } from '#/components/icons/Mute' 80 import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/Person' 81 import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' 82 import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2' 83 + import { 84 + SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute, 85 + SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon, 86 + } from '#/components/icons/Speaker' 87 import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 88 import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' 89 import {Loader} from '#/components/Loader' ··· 93 useReportDialogControl, 94 } from '#/components/moderation/ReportDialog' 95 import * as Prompt from '#/components/Prompt' 96 + import {useAnalytics} from '#/analytics' 97 import {IS_INTERNAL} from '#/env' 98 import * as bsky from '#/types/bsky' 99 ··· 123 }): React.ReactNode => { 124 const {hasSession, currentAccount} = useSession() 125 const {_} = useLingui() 126 + const ax = useAnalytics() 127 const langPrefs = useLanguagePrefs() 128 const {mutateAsync: deletePostMutate} = usePostDeleteMutation() 129 const {mutateAsync: pinPostMutate, isPending: isPinPending} = ··· 220 try { 221 if (isThreadMuted) { 222 unmuteThread() 223 + ax.metric('post:unmute', { 224 uri: postUri, 225 authorDid: postAuthor.did, 226 logContext, ··· 229 Toast.show(_(msg`You will now receive notifications for this thread`)) 230 } else { 231 muteThread() 232 + ax.metric('post:mute', { 233 uri: postUri, 234 authorDid: postAuthor.did, 235 logContext, ··· 266 AppBskyFeedPost.isRecord, 267 ) 268 ) { 269 + ax.metric('translate', { 270 + sourceLanguages: post.record.langs ?? [], 271 + targetLanguage: langPrefs.primaryLanguage, 272 + textLength: post.record.text.length, 273 + }) 274 } 275 } 276 277 const onHidePost = () => { 278 hidePost({uri: postUri}) 279 + ax.metric('thread:click:hideReplyForMe', {}) 280 } 281 282 const hideInPWI = !!postAuthor.labels?.find( ··· 290 feedContext: postFeedContext, 291 reqId: postReqId, 292 }) 293 + ax.metric('post:showMore', { 294 uri: postUri, 295 authorDid: postAuthor.did, 296 logContext, ··· 308 feedContext: postFeedContext, 309 reqId: postReqId, 310 }) 311 + ax.metric('post:showLess', { 312 uri: postUri, 313 authorDid: postAuthor.did, 314 logContext, ··· 372 373 // Log metric only when hiding (not when showing) 374 if (isHide) { 375 + ax.metric('thread:click:hideReplyForEveryone', {}) 376 } 377 378 Toast.show( ··· 409 } 410 411 const onPressPin = () => { 412 + ax.metric(isPinned ? 'post:unpin' : 'post:pin', {}) 413 pinPostMutate({ 414 postUri, 415 postCid, ··· 462 463 const onSignIn = () => requireSignIn(() => {}) 464 465 const isDiscoverDebugUser = 466 IS_INTERNAL || 467 DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] || 468 + ax.features.enabled(ax.features.DebugFeedContext) 469 470 return ( 471 <>
+3 -2
src/components/PostControls/ShareMenu/RecentChats.tsx
··· 8 import {type NavigationProp} from '#/lib/routes/types' 9 import {sanitizeDisplayName} from '#/lib/strings/display-names' 10 import {sanitizeHandle} from '#/lib/strings/handles' 11 - import {logger} from '#/logger' 12 import {useProfileShadow} from '#/state/cache/profile-shadow' 13 import {useModerationOpts} from '#/state/preferences/moderation-opts' 14 import {useListConvosQuery} from '#/state/queries/messages/list-conversations' ··· 20 import {Text} from '#/components/Typography' 21 import {useSimpleVerificationState} from '#/components/verification' 22 import {VerificationCheck} from '#/components/verification/VerificationCheck' 23 import type * as bsky from '#/types/bsky' 24 25 export function RecentChats({postUri}: {postUri: string}) { 26 const control = useDialogContext() 27 const {currentAccount} = useSession() 28 const {data} = useListConvosQuery({status: 'accepted'}) ··· 32 33 const onSelectChat = (convoId: string) => { 34 control.close(() => { 35 - logger.metric('share:press:recentDm', {}, {statsig: true}) 36 navigation.navigate('MessagesConversation', { 37 conversation: convoId, 38 embed: postUri,
··· 8 import {type NavigationProp} from '#/lib/routes/types' 9 import {sanitizeDisplayName} from '#/lib/strings/display-names' 10 import {sanitizeHandle} from '#/lib/strings/handles' 11 import {useProfileShadow} from '#/state/cache/profile-shadow' 12 import {useModerationOpts} from '#/state/preferences/moderation-opts' 13 import {useListConvosQuery} from '#/state/queries/messages/list-conversations' ··· 19 import {Text} from '#/components/Typography' 20 import {useSimpleVerificationState} from '#/components/verification' 21 import {VerificationCheck} from '#/components/verification/VerificationCheck' 22 + import {useAnalytics} from '#/analytics' 23 import type * as bsky from '#/types/bsky' 24 25 export function RecentChats({postUri}: {postUri: string}) { 26 + const ax = useAnalytics() 27 const control = useDialogContext() 28 const {currentAccount} = useSession() 29 const {data} = useListConvosQuery({status: 'accepted'}) ··· 33 34 const onSelectChat = (convoId: string) => { 35 control.close(() => { 36 + ax.metric('share:press:recentDm', {}) 37 navigation.navigate('MessagesConversation', { 38 conversation: convoId, 39 embed: postUri,
+5 -4
src/components/PostControls/ShareMenu/ShareMenuItems.tsx
··· 9 import {type NavigationProp} from '#/lib/routes/types' 10 import {shareText, shareUrl} from '#/lib/sharing' 11 import {toShareUrl} from '#/lib/strings/url-helpers' 12 - import {logger} from '#/logger' 13 import {useProfileShadow} from '#/state/cache/profile-shadow' 14 import {useSession} from '#/state/session' 15 import * as Toast from '#/view/com/util/Toast' ··· 23 import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' 24 import * as Menu from '#/components/Menu' 25 import {useAgeAssurance} from '#/ageAssurance' 26 import {IS_IOS} from '#/env' 27 import {useDevMode} from '#/storage/hooks/dev-mode' 28 import {RecentChats} from './RecentChats' ··· 32 post, 33 onShare: onShareProp, 34 }: ShareMenuItemsProps): React.ReactNode => { 35 const {hasSession} = useSession() 36 const {_} = useLingui() 37 const navigation = useNavigation<NavigationProp>() ··· 54 }, [postAuthor]) 55 56 const onSharePost = () => { 57 - logger.metric('share:press:nativeShare', {}, {statsig: true}) 58 const url = toShareUrl(href) 59 shareUrl(url) 60 onShareProp() 61 } 62 63 const onCopyLink = async () => { 64 - logger.metric('share:press:copyLink', {}, {statsig: true}) 65 const url = toShareUrl(href) 66 if (IS_IOS) { 67 // iOS only ··· 100 testID="postDropdownSendViaDMBtn" 101 label={_(msg`Send via direct message`)} 102 onPress={() => { 103 - logger.metric('share:press:openDmSearch', {}, {statsig: true}) 104 sendViaChatControl.open() 105 }}> 106 <Menu.ItemText>
··· 9 import {type NavigationProp} from '#/lib/routes/types' 10 import {shareText, shareUrl} from '#/lib/sharing' 11 import {toShareUrl} from '#/lib/strings/url-helpers' 12 import {useProfileShadow} from '#/state/cache/profile-shadow' 13 import {useSession} from '#/state/session' 14 import * as Toast from '#/view/com/util/Toast' ··· 22 import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' 23 import * as Menu from '#/components/Menu' 24 import {useAgeAssurance} from '#/ageAssurance' 25 + import {useAnalytics} from '#/analytics' 26 import {IS_IOS} from '#/env' 27 import {useDevMode} from '#/storage/hooks/dev-mode' 28 import {RecentChats} from './RecentChats' ··· 32 post, 33 onShare: onShareProp, 34 }: ShareMenuItemsProps): React.ReactNode => { 35 + const ax = useAnalytics() 36 const {hasSession} = useSession() 37 const {_} = useLingui() 38 const navigation = useNavigation<NavigationProp>() ··· 55 }, [postAuthor]) 56 57 const onSharePost = () => { 58 + ax.metric('share:press:nativeShare', {}) 59 const url = toShareUrl(href) 60 shareUrl(url) 61 onShareProp() 62 } 63 64 const onCopyLink = async () => { 65 + ax.metric('share:press:copyLink', {}) 66 const url = toShareUrl(href) 67 if (IS_IOS) { 68 // iOS only ··· 101 testID="postDropdownSendViaDMBtn" 102 label={_(msg`Send via direct message`)} 103 onPress={() => { 104 + ax.metric('share:press:openDmSearch', {}) 105 sendViaChatControl.open() 106 }}> 107 <Menu.ItemText>
+6 -5
src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx
··· 8 import {type NavigationProp} from '#/lib/routes/types' 9 import {shareText, shareUrl} from '#/lib/sharing' 10 import {toShareUrl} from '#/lib/strings/url-helpers' 11 - import {logger} from '#/logger' 12 import {useProfileShadow} from '#/state/cache/profile-shadow' 13 import {useSession} from '#/state/session' 14 import {useBreakpoints} from '#/alf' ··· 21 import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' 22 import * as Menu from '#/components/Menu' 23 import {useAgeAssurance} from '#/ageAssurance' 24 import {IS_WEB} from '#/env' 25 import {useDevMode} from '#/storage/hooks/dev-mode' 26 import {type ShareMenuItemsProps} from './ShareMenuItems.types' ··· 31 timestamp, 32 onShare: onShareProp, 33 }: ShareMenuItemsProps): React.ReactNode => { 34 const {hasSession} = useSession() 35 const {gtMobile} = useBreakpoints() 36 const {_} = useLingui() ··· 56 }, [postAuthor]) 57 58 const onCopyLink = () => { 59 - logger.metric('share:press:copyLink', {}, {statsig: true}) 60 const url = toShareUrl(href) 61 shareUrl(url) 62 onShareProp() 63 } 64 65 const onSelectChatToShareTo = (conversation: string) => { 66 - logger.metric('share:press:dmSelected', {}, {statsig: true}) 67 navigation.navigate('MessagesConversation', { 68 conversation, 69 embed: postUri, ··· 102 testID="postDropdownSendViaDMBtn" 103 label={_(msg`Send via direct message`)} 104 onPress={() => { 105 - logger.metric('share:press:openDmSearch', {}, {statsig: true}) 106 sendViaChatControl.open() 107 }}> 108 <Menu.ItemText> ··· 117 testID="postDropdownEmbedBtn" 118 label={_(msg`Embed post`)} 119 onPress={() => { 120 - logger.metric('share:press:embed', {}, {statsig: true}) 121 embedPostControl.open() 122 }}> 123 <Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText>
··· 8 import {type NavigationProp} from '#/lib/routes/types' 9 import {shareText, shareUrl} from '#/lib/sharing' 10 import {toShareUrl} from '#/lib/strings/url-helpers' 11 import {useProfileShadow} from '#/state/cache/profile-shadow' 12 import {useSession} from '#/state/session' 13 import {useBreakpoints} from '#/alf' ··· 20 import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' 21 import * as Menu from '#/components/Menu' 22 import {useAgeAssurance} from '#/ageAssurance' 23 + import {useAnalytics} from '#/analytics' 24 import {IS_WEB} from '#/env' 25 import {useDevMode} from '#/storage/hooks/dev-mode' 26 import {type ShareMenuItemsProps} from './ShareMenuItems.types' ··· 31 timestamp, 32 onShare: onShareProp, 33 }: ShareMenuItemsProps): React.ReactNode => { 34 + const ax = useAnalytics() 35 const {hasSession} = useSession() 36 const {gtMobile} = useBreakpoints() 37 const {_} = useLingui() ··· 57 }, [postAuthor]) 58 59 const onCopyLink = () => { 60 + ax.metric('share:press:copyLink', {}) 61 const url = toShareUrl(href) 62 shareUrl(url) 63 onShareProp() 64 } 65 66 const onSelectChatToShareTo = (conversation: string) => { 67 + ax.metric('share:press:dmSelected', {}) 68 navigation.navigate('MessagesConversation', { 69 conversation, 70 embed: postUri, ··· 103 testID="postDropdownSendViaDMBtn" 104 label={_(msg`Send via direct message`)} 105 onPress={() => { 106 + ax.metric('share:press:openDmSearch', {}) 107 sendViaChatControl.open() 108 }}> 109 <Menu.ItemText> ··· 118 testID="postDropdownEmbedBtn" 119 label={_(msg`Embed post`)} 120 onPress={() => { 121 + ax.metric('share:press:embed', {}) 122 embedPostControl.open() 123 }}> 124 <Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText>
+11 -13
src/components/PostControls/ShareMenu/index.tsx
··· 13 import {makeProfileLink} from '#/lib/routes/links' 14 import {shareUrl} from '#/lib/sharing' 15 import {toShareUrl} from '#/lib/strings/url-helpers' 16 - import {logger} from '#/logger' 17 import {type Shadow} from '#/state/cache/post-shadow' 18 import {useFeedFeedbackContext} from '#/state/feed-feedback' 19 import {EventStopper} from '#/view/com/util/EventStopper' ··· 21 import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight' 22 import {useMenuControl} from '#/components/Menu' 23 import * as Menu from '#/components/Menu' 24 import {PostControlButton, PostControlButtonIcon} from '../PostControlButton' 25 import {ShareMenuItems} from './ShareMenuItems' 26 ··· 47 hitSlop?: Insets 48 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 49 }): React.ReactNode => { 50 const {_} = useLingui() 51 const {feedDescriptor} = useFeedFeedbackContext() 52 ··· 61 // menuControl.open() fires but RN doesn't expose flushSync. 62 setTimeout(menuControl.open) 63 64 - logger.metric( 65 - 'post:share', 66 - { 67 - uri: post.uri, 68 - authorDid: post.author.did, 69 - logContext, 70 - feedDescriptor, 71 - postContext: big ? 'thread' : 'feed', 72 - }, 73 - {statsig: true}, 74 - ) 75 }, 76 }), 77 [ 78 menuControl, 79 setHasBeenOpen, 80 big, ··· 86 ) 87 88 const onNativeLongPress = () => { 89 - logger.metric('share:press:nativeShare', {}, {statsig: true}) 90 const urip = new AtUri(post.uri) 91 const href = makeProfileLink(post.author, 'post', urip.rkey) 92 const url = toShareUrl(href)
··· 13 import {makeProfileLink} from '#/lib/routes/links' 14 import {shareUrl} from '#/lib/sharing' 15 import {toShareUrl} from '#/lib/strings/url-helpers' 16 import {type Shadow} from '#/state/cache/post-shadow' 17 import {useFeedFeedbackContext} from '#/state/feed-feedback' 18 import {EventStopper} from '#/view/com/util/EventStopper' ··· 20 import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight' 21 import {useMenuControl} from '#/components/Menu' 22 import * as Menu from '#/components/Menu' 23 + import {useAnalytics} from '#/analytics' 24 import {PostControlButton, PostControlButtonIcon} from '../PostControlButton' 25 import {ShareMenuItems} from './ShareMenuItems' 26 ··· 47 hitSlop?: Insets 48 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 49 }): React.ReactNode => { 50 + const ax = useAnalytics() 51 const {_} = useLingui() 52 const {feedDescriptor} = useFeedFeedbackContext() 53 ··· 62 // menuControl.open() fires but RN doesn't expose flushSync. 63 setTimeout(menuControl.open) 64 65 + ax.metric('post:share', { 66 + uri: post.uri, 67 + authorDid: post.author.did, 68 + logContext, 69 + feedDescriptor, 70 + postContext: big ? 'thread' : 'feed', 71 + }) 72 }, 73 }), 74 [ 75 + ax, 76 menuControl, 77 setHasBeenOpen, 78 big, ··· 84 ) 85 86 const onNativeLongPress = () => { 87 + ax.metric('share:press:nativeShare', {}) 88 const urip = new AtUri(post.uri) 89 const href = makeProfileLink(post.author, 'post', urip.rkey) 90 const url = toShareUrl(href)
+4 -3
src/components/PostControls/index.tsx
··· 13 import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' 14 import {useHaptics} from '#/lib/haptics' 15 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 16 - import {logger} from '#/logger' 17 import {type Shadow} from '#/state/cache/types' 18 import {useFeedFeedbackContext} from '#/state/feed-feedback' 19 import { ··· 30 import {Reply as Bubble} from '#/components/icons/Reply' 31 import {useFormatPostStatCount} from '#/components/PostControls/util' 32 import * as Skele from '#/components/Skeleton' 33 import {BookmarkButton} from './BookmarkButton' 34 import { 35 PostControlButton, ··· 71 viaRepost?: {uri: string; cid: string} 72 variant?: 'compact' | 'normal' | 'large' 73 }): React.ReactNode => { 74 const {_} = useLingui() 75 const {openComposer} = useOpenComposer() 76 const {feedDescriptor} = useFeedFeedbackContext() ··· 175 feedContext, 176 reqId, 177 }) 178 - logger.metric('post:clickQuotePost', { 179 uri: post.uri, 180 authorDid: post.author.did, 181 logContext, ··· 226 !replyDisabled 227 ? () => 228 requireAuth(() => { 229 - logger.metric('post:clickReply', { 230 uri: post.uri, 231 authorDid: post.author.did, 232 logContext,
··· 13 import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' 14 import {useHaptics} from '#/lib/haptics' 15 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 16 import {type Shadow} from '#/state/cache/types' 17 import {useFeedFeedbackContext} from '#/state/feed-feedback' 18 import { ··· 29 import {Reply as Bubble} from '#/components/icons/Reply' 30 import {useFormatPostStatCount} from '#/components/PostControls/util' 31 import * as Skele from '#/components/Skeleton' 32 + import {useAnalytics} from '#/analytics' 33 import {BookmarkButton} from './BookmarkButton' 34 import { 35 PostControlButton, ··· 71 viaRepost?: {uri: string; cid: string} 72 variant?: 'compact' | 'normal' | 'large' 73 }): React.ReactNode => { 74 + const ax = useAnalytics() 75 const {_} = useLingui() 76 const {openComposer} = useOpenComposer() 77 const {feedDescriptor} = useFeedFeedbackContext() ··· 176 feedContext, 177 reqId, 178 }) 179 + ax.metric('post:clickQuotePost', { 180 uri: post.uri, 181 authorDid: post.author.did, 182 logContext, ··· 227 !replyDisabled 228 ? () => 229 requireAuth(() => { 230 + ax.metric('post:clickReply', { 231 uri: post.uri, 232 authorDid: post.author.did, 233 logContext,
+3 -3
src/components/ProfileCard.tsx
··· 16 17 import {useActorStatus} from '#/lib/actor-status' 18 import {getModerationCauseKey} from '#/lib/moderation' 19 - import {type LogEvents} from '#/lib/statsig/statsig' 20 import {forceLTR} from '#/lib/strings/bidi' 21 import {NON_BREAKING_SPACE} from '#/lib/strings/constants' 22 import {sanitizeDisplayName} from '#/lib/strings/display-names' ··· 47 import {Text} from '#/components/Typography' 48 import {useSimpleVerificationState} from '#/components/verification' 49 import {VerificationCheck} from '#/components/verification/VerificationCheck' 50 import type * as bsky from '#/types/bsky' 51 52 export function Default({ ··· 461 export type FollowButtonProps = { 462 profile: bsky.profile.AnyProfileView 463 moderationOpts: ModerationOpts 464 - logContext: LogEvents['profile:follow']['logContext'] & 465 - LogEvents['profile:unfollow']['logContext'] 466 colorInverted?: boolean 467 onFollow?: () => void 468 withIcon?: boolean
··· 16 17 import {useActorStatus} from '#/lib/actor-status' 18 import {getModerationCauseKey} from '#/lib/moderation' 19 import {forceLTR} from '#/lib/strings/bidi' 20 import {NON_BREAKING_SPACE} from '#/lib/strings/constants' 21 import {sanitizeDisplayName} from '#/lib/strings/display-names' ··· 46 import {Text} from '#/components/Typography' 47 import {useSimpleVerificationState} from '#/components/verification' 48 import {VerificationCheck} from '#/components/verification/VerificationCheck' 49 + import {type Metrics} from '#/analytics' 50 import type * as bsky from '#/types/bsky' 51 52 export function Default({ ··· 461 export type FollowButtonProps = { 462 profile: bsky.profile.AnyProfileView 463 moderationOpts: ModerationOpts 464 + logContext: Metrics['profile:follow']['logContext'] & 465 + Metrics['profile:unfollow']['logContext'] 466 colorInverted?: boolean 467 onFollow?: () => void 468 withIcon?: boolean
+11 -14
src/components/ProgressGuide/FollowDialog.tsx
··· 10 import {useLingui} from '@lingui/react' 11 12 import {popularInterests, useInterestsDisplayNames} from '#/lib/interests' 13 - import {logEvent} from '#/lib/statsig/statsig' 14 - import {logger} from '#/logger' 15 import {useModerationOpts} from '#/state/preferences/moderation-opts' 16 import {useActorSearch} from '#/state/queries/actor-search' 17 import {usePreferencesQuery} from '#/state/queries/preferences' ··· 36 import {boostInterests, InterestTabs} from '#/components/InterestTabs' 37 import * as ProfileCard from '#/components/ProfileCard' 38 import {Text} from '#/components/Typography' 39 import {IS_WEB} from '#/env' 40 import type * as bsky from '#/types/bsky' 41 import {ProgressGuideTask} from './Task' ··· 67 guide: Follow10ProgressGuide 68 showArrow?: boolean 69 }) { 70 const {_} = useLingui() 71 const control = Dialog.useDialogControl() 72 const {gtPhone} = useBreakpoints() ··· 78 label={_(msg`Find people to follow`)} 79 onPress={() => { 80 control.open() 81 - logEvent('progressGuide:followDialog:open', {}) 82 }} 83 size={gtPhone ? 'small' : 'large'} 84 color="primary"> ··· 118 119 function DialogInner({guide}: {guide?: Follow10ProgressGuide}) { 120 const {_} = useLingui() 121 const interestsDisplayNames = useInterestsDisplayNames() 122 const {data: preferences} = usePreferencesQuery() 123 const personalizedInterests = preferences?.interests?.tags ··· 271 const position = itemsRef.current.findIndex( 272 i => i.type === 'profile' && i.profile.did === item.profile.did, 273 ) 274 - logger.metric( 275 - 'suggestedUser:seen', 276 - { 277 - logContext: 'ProgressGuide', 278 - recId: undefined, 279 - position: position !== -1 ? position : 0, 280 - suggestedDid: item.profile.did, 281 - category: selectedInterestRef.current, 282 - }, 283 - {statsig: true}, 284 - ) 285 } 286 } 287 }
··· 10 import {useLingui} from '@lingui/react' 11 12 import {popularInterests, useInterestsDisplayNames} from '#/lib/interests' 13 import {useModerationOpts} from '#/state/preferences/moderation-opts' 14 import {useActorSearch} from '#/state/queries/actor-search' 15 import {usePreferencesQuery} from '#/state/queries/preferences' ··· 34 import {boostInterests, InterestTabs} from '#/components/InterestTabs' 35 import * as ProfileCard from '#/components/ProfileCard' 36 import {Text} from '#/components/Typography' 37 + import {useAnalytics} from '#/analytics' 38 import {IS_WEB} from '#/env' 39 import type * as bsky from '#/types/bsky' 40 import {ProgressGuideTask} from './Task' ··· 66 guide: Follow10ProgressGuide 67 showArrow?: boolean 68 }) { 69 + const ax = useAnalytics() 70 const {_} = useLingui() 71 const control = Dialog.useDialogControl() 72 const {gtPhone} = useBreakpoints() ··· 78 label={_(msg`Find people to follow`)} 79 onPress={() => { 80 control.open() 81 + ax.metric('progressGuide:followDialog:open', {}) 82 }} 83 size={gtPhone ? 'small' : 'large'} 84 color="primary"> ··· 118 119 function DialogInner({guide}: {guide?: Follow10ProgressGuide}) { 120 const {_} = useLingui() 121 + const ax = useAnalytics() 122 const interestsDisplayNames = useInterestsDisplayNames() 123 const {data: preferences} = usePreferencesQuery() 124 const personalizedInterests = preferences?.interests?.tags ··· 272 const position = itemsRef.current.findIndex( 273 i => i.type === 'profile' && i.profile.did === item.profile.did, 274 ) 275 + ax.metric('suggestedUser:seen', { 276 + logContext: 'ProgressGuide', 277 + recId: undefined, 278 + position: position !== -1 ? position : 0, 279 + suggestedDid: item.profile.did, 280 + category: selectedInterestRef.current, 281 + }) 282 } 283 } 284 }
+5 -3
src/components/StarterPack/QrCodeDialog.tsx
··· 19 import {Loader} from '#/components/Loader' 20 import {QrCode} from '#/components/StarterPack/QrCode' 21 import * as Toast from '#/components/Toast' 22 import {IS_NATIVE, IS_WEB} from '#/env' 23 import * as bsky from '#/types/bsky' 24 ··· 32 control: DialogControlProps 33 }) { 34 const {_} = useLingui() 35 const {gtMobile} = useBreakpoints() 36 const [isSaveProcessing, setIsSaveProcessing] = useState(false) 37 const [isCopyProcessing, setIsCopyProcessing] = useState(false) ··· 104 link.click() 105 } 106 107 - logger.metric('starterPack:share', { 108 starterPack: starterPack.uri, 109 shareType: 'qrcode', 110 qrShareType: 'save', ··· 129 navigator.clipboard.write([item]) 130 }) 131 132 - logger.metric('starterPack:share', { 133 starterPack: starterPack.uri, 134 shareType: 'qrcode', 135 qrShareType: 'copy', ··· 145 control.close(() => { 146 Sharing.shareAsync(uri, {mimeType: 'image/png', UTI: 'image/png'}).then( 147 () => { 148 - logger.metric('starterPack:share', { 149 starterPack: starterPack.uri, 150 shareType: 'qrcode', 151 qrShareType: 'share',
··· 19 import {Loader} from '#/components/Loader' 20 import {QrCode} from '#/components/StarterPack/QrCode' 21 import * as Toast from '#/components/Toast' 22 + import {useAnalytics} from '#/analytics' 23 import {IS_NATIVE, IS_WEB} from '#/env' 24 import * as bsky from '#/types/bsky' 25 ··· 33 control: DialogControlProps 34 }) { 35 const {_} = useLingui() 36 + const ax = useAnalytics() 37 const {gtMobile} = useBreakpoints() 38 const [isSaveProcessing, setIsSaveProcessing] = useState(false) 39 const [isCopyProcessing, setIsCopyProcessing] = useState(false) ··· 106 link.click() 107 } 108 109 + ax.metric('starterPack:share', { 110 starterPack: starterPack.uri, 111 shareType: 'qrcode', 112 qrShareType: 'save', ··· 131 navigator.clipboard.write([item]) 132 }) 133 134 + ax.metric('starterPack:share', { 135 starterPack: starterPack.uri, 136 shareType: 'qrcode', 137 qrShareType: 'copy', ··· 147 control.close(() => { 148 Sharing.shareAsync(uri, {mimeType: 'image/png', UTI: 'image/png'}).then( 149 () => { 150 + ax.metric('starterPack:share', { 151 starterPack: starterPack.uri, 152 shareType: 'qrcode', 153 qrShareType: 'share',
+3 -2
src/components/StarterPack/ShareDialog.tsx
··· 7 import {useSaveImageToMediaLibrary} from '#/lib/media/save-image' 8 import {shareUrl} from '#/lib/sharing' 9 import {getStarterPackOgCard} from '#/lib/strings/starter-pack' 10 - import {logger} from '#/logger' 11 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 12 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 13 import {type DialogControlProps} from '#/components/Dialog' ··· 17 import {QrCode_Stroke2_Corner0_Rounded as QrCodeIcon} from '#/components/icons/QrCode' 18 import {Loader} from '#/components/Loader' 19 import {Text} from '#/components/Typography' 20 import {IS_NATIVE, IS_WEB} from '#/env' 21 22 interface Props { ··· 46 control, 47 }: Props) { 48 const {_} = useLingui() 49 const t = useTheme() 50 const {gtMobile} = useBreakpoints() 51 ··· 54 const onShareLink = async () => { 55 if (!link) return 56 shareUrl(link) 57 - logger.metric('starterPack:share', { 58 starterPack: starterPack.uri, 59 shareType: 'link', 60 })
··· 7 import {useSaveImageToMediaLibrary} from '#/lib/media/save-image' 8 import {shareUrl} from '#/lib/sharing' 9 import {getStarterPackOgCard} from '#/lib/strings/starter-pack' 10 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 11 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 12 import {type DialogControlProps} from '#/components/Dialog' ··· 16 import {QrCode_Stroke2_Corner0_Rounded as QrCodeIcon} from '#/components/icons/QrCode' 17 import {Loader} from '#/components/Loader' 18 import {Text} from '#/components/Typography' 19 + import {useAnalytics} from '#/analytics' 20 import {IS_NATIVE, IS_WEB} from '#/env' 21 22 interface Props { ··· 46 control, 47 }: Props) { 48 const {_} = useLingui() 49 + const ax = useAnalytics() 50 const t = useTheme() 51 const {gtMobile} = useBreakpoints() 52 ··· 55 const onShareLink = async () => { 56 if (!link) return 57 shareUrl(link) 58 + ax.metric('starterPack:share', { 59 starterPack: starterPack.uri, 60 shareType: 'link', 61 })
+4 -3
src/components/StarterPack/Wizard/WizardListCard.tsx
··· 13 import {DISCOVER_FEED_URI, STARTER_PACK_MAX_SIZE} from '#/lib/constants' 14 import {sanitizeDisplayName} from '#/lib/strings/display-names' 15 import {sanitizeHandle} from '#/lib/strings/handles' 16 - import {logger} from '#/logger' 17 import {useSession} from '#/state/session' 18 import {UserAvatar} from '#/view/com/util/UserAvatar' 19 import { ··· 25 import * as Toggle from '#/components/forms/Toggle' 26 import {Checkbox} from '#/components/forms/Toggle' 27 import {Text} from '#/components/Typography' 28 import type * as bsky from '#/types/bsky' 29 30 function WizardListCard({ ··· 130 profile: bsky.profile.AnyProfileView 131 moderationOpts: ModerationOpts 132 }) { 133 const {currentAccount} = useSession() 134 135 // Determine the "main" profile for this starter pack - either targetDid or current account ··· 151 if (profile.did === targetProfileDid) return 152 153 if (!included) { 154 - logger.metric('starterPack:addUser', {}) 155 dispatch({type: 'AddProfile', profile}) 156 } else { 157 - logger.metric('starterPack:removeUser', {}) 158 dispatch({type: 'RemoveProfile', profileDid: profile.did}) 159 } 160 }
··· 13 import {DISCOVER_FEED_URI, STARTER_PACK_MAX_SIZE} from '#/lib/constants' 14 import {sanitizeDisplayName} from '#/lib/strings/display-names' 15 import {sanitizeHandle} from '#/lib/strings/handles' 16 import {useSession} from '#/state/session' 17 import {UserAvatar} from '#/view/com/util/UserAvatar' 18 import { ··· 24 import * as Toggle from '#/components/forms/Toggle' 25 import {Checkbox} from '#/components/forms/Toggle' 26 import {Text} from '#/components/Typography' 27 + import {useAnalytics} from '#/analytics' 28 import type * as bsky from '#/types/bsky' 29 30 function WizardListCard({ ··· 130 profile: bsky.profile.AnyProfileView 131 moderationOpts: ModerationOpts 132 }) { 133 + const ax = useAnalytics() 134 const {currentAccount} = useSession() 135 136 // Determine the "main" profile for this starter pack - either targetDid or current account ··· 152 if (profile.did === targetProfileDid) return 153 154 if (!included) { 155 + ax.metric('starterPack:addUser', {}) 156 dispatch({type: 'AddProfile', profile}) 157 } else { 158 + ax.metric('starterPack:removeUser', {}) 159 dispatch({type: 'RemoveProfile', profileDid: profile.did}) 160 } 161 }
+8 -6
src/components/WelcomeModal.tsx
··· 5 import {useLingui} from '@lingui/react' 6 import {FocusGuards, FocusScope} from 'radix-ui/internal' 7 8 - import {logger} from '#/logger' 9 import {useLoggedOutViewControls} from '#/state/shell/logged-out' 10 import {Logo} from '#/view/icons/Logo' 11 import {atoms as a, flatten, useBreakpoints, web} from '#/alf' 12 import {Button, ButtonText} from '#/components/Button' 13 import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 14 import {Text} from '#/components/Typography' 15 16 const welcomeModalBg = require('../../assets/images/welcome-modal-bg.jpg') 17 ··· 25 26 export function WelcomeModal({control}: WelcomeModalProps) { 27 const {_} = useLingui() 28 const {requestSwitchToAccount} = useLoggedOutViewControls() 29 const {gtMobile} = useBreakpoints() 30 const [isExiting, setIsExiting] = useState(false) ··· 40 41 useEffect(() => { 42 if (control.isOpen) { 43 - logger.metric('welcomeModal:presented', {}) 44 } 45 }, [control.isOpen]) 46 47 const onPressCreateAccount = () => { 48 - logger.metric('welcomeModal:signupClicked', {}) 49 control.close() 50 requestSwitchToAccount({requestedAccount: 'new'}) 51 } 52 53 const onPressExplore = () => { 54 - logger.metric('welcomeModal:exploreClicked', {}) 55 fadeOutAndClose() 56 } 57 58 const onPressSignIn = () => { 59 - logger.metric('welcomeModal:signinClicked', {}) 60 control.close() 61 requestSwitchToAccount({requestedAccount: 'existing'}) 62 } ··· 222 ]} 223 hoverStyle={[a.bg_transparent]} 224 onPress={() => { 225 - logger.metric('welcomeModal:dismissed', {}) 226 fadeOutAndClose() 227 }} 228 color="secondary"
··· 5 import {useLingui} from '@lingui/react' 6 import {FocusGuards, FocusScope} from 'radix-ui/internal' 7 8 import {useLoggedOutViewControls} from '#/state/shell/logged-out' 9 import {Logo} from '#/view/icons/Logo' 10 import {atoms as a, flatten, useBreakpoints, web} from '#/alf' 11 import {Button, ButtonText} from '#/components/Button' 12 import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 13 import {Text} from '#/components/Typography' 14 + import {useAnalytics} from '#/analytics' 15 16 const welcomeModalBg = require('../../assets/images/welcome-modal-bg.jpg') 17 ··· 25 26 export function WelcomeModal({control}: WelcomeModalProps) { 27 const {_} = useLingui() 28 + const ax = useAnalytics() 29 const {requestSwitchToAccount} = useLoggedOutViewControls() 30 const {gtMobile} = useBreakpoints() 31 const [isExiting, setIsExiting] = useState(false) ··· 41 42 useEffect(() => { 43 if (control.isOpen) { 44 + ax.metric('welcomeModal:presented', {}) 45 } 46 + // eslint-disable-next-line react-hooks/exhaustive-deps 47 }, [control.isOpen]) 48 49 const onPressCreateAccount = () => { 50 + ax.metric('welcomeModal:signupClicked', {}) 51 control.close() 52 requestSwitchToAccount({requestedAccount: 'new'}) 53 } 54 55 const onPressExplore = () => { 56 + ax.metric('welcomeModal:exploreClicked', {}) 57 fadeOutAndClose() 58 } 59 60 const onPressSignIn = () => { 61 + ax.metric('welcomeModal:signinClicked', {}) 62 control.close() 63 requestSwitchToAccount({requestedAccount: 'existing'}) 64 } ··· 224 ]} 225 hoverStyle={[a.bg_transparent]} 226 onPress={() => { 227 + ax.metric('welcomeModal:dismissed', {}) 228 fadeOutAndClose() 229 }} 230 color="secondary"
+5 -4
src/components/WhoCanReply.tsx
··· 17 18 import {HITSLOP_10} from '#/lib/constants' 19 import {makeListLink, makeProfileLink} from '#/lib/routes/links' 20 - import {logger} from '#/logger' 21 import { 22 type ThreadgateAllowUISetting, 23 threadgateViewToAllowUISetting, ··· 36 import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Group' 37 import {InlineLinkText} from '#/components/Link' 38 import {Text} from '#/components/Typography' 39 import {IS_NATIVE} from '#/env' 40 import * as bsky from '#/types/bsky' 41 ··· 46 } 47 48 export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { 49 const {_} = useLingui() 50 - const t = useTheme() 51 const infoDialogControl = useDialogControl() 52 const editDialogControl = useDialogControl() 53 ··· 90 Keyboard.dismiss() 91 } 92 if (isThreadAuthor) { 93 - logger.metric('thread:click:editOwnThreadgate', {}) 94 95 // wait on prefetch if it manages to resolve in under 200ms 96 // otherwise, proceed immediately and show the spinner -sfn ··· 101 editDialogControl.open() 102 }) 103 } else { 104 - logger.metric('thread:click:viewSomeoneElsesThreadgate', {}) 105 106 infoDialogControl.open() 107 }
··· 17 18 import {HITSLOP_10} from '#/lib/constants' 19 import {makeListLink, makeProfileLink} from '#/lib/routes/links' 20 import { 21 type ThreadgateAllowUISetting, 22 threadgateViewToAllowUISetting, ··· 35 import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Group' 36 import {InlineLinkText} from '#/components/Link' 37 import {Text} from '#/components/Typography' 38 + import {useAnalytics} from '#/analytics' 39 import {IS_NATIVE} from '#/env' 40 import * as bsky from '#/types/bsky' 41 ··· 46 } 47 48 export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { 49 + const t = useTheme() 50 + const ax = useAnalytics() 51 const {_} = useLingui() 52 const infoDialogControl = useDialogControl() 53 const editDialogControl = useDialogControl() 54 ··· 91 Keyboard.dismiss() 92 } 93 if (isThreadAuthor) { 94 + ax.metric('thread:click:editOwnThreadgate', {}) 95 96 // wait on prefetch if it manages to resolve in under 200ms 97 // otherwise, proceed immediately and show the spinner -sfn ··· 102 editDialogControl.open() 103 }) 104 } else { 105 + ax.metric('thread:click:viewSomeoneElsesThreadgate', {}) 106 107 infoDialogControl.open() 108 }
+6 -6
src/components/activity-notifications/SubscribeProfileDialog.tsx
··· 17 import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' 18 import {cleanError} from '#/lib/strings/errors' 19 import {sanitizeHandle} from '#/lib/strings/handles' 20 - import {logger} from '#/logger' 21 import {updateProfileShadow} from '#/state/cache/profile-shadow' 22 import {RQKEY_getActivitySubscriptions} from '#/state/queries/activity-subscriptions' 23 import {useAgent} from '#/state/session' 24 import * as Toast from '#/view/com/util/Toast' 25 - import {platform, useTheme, web} from '#/alf' 26 - import {atoms as a} from '#/alf' 27 import {Admonition} from '#/components/Admonition' 28 import { 29 Button, ··· 36 import {Loader} from '#/components/Loader' 37 import * as ProfileCard from '#/components/ProfileCard' 38 import {Text} from '#/components/Typography' 39 import {IS_WEB} from '#/env' 40 import type * as bsky from '#/types/bsky' 41 ··· 71 moderationOpts: ModerationOpts 72 includeProfile?: boolean 73 }) { 74 const {_} = useLingui() 75 const t = useTheme() 76 const agent = useAgent() ··· 133 }) 134 135 if (!activitySubscription.post && !activitySubscription.reply) { 136 - logger.metric('activitySubscription:disable', {}) 137 Toast.show( 138 _( 139 msg`You will no longer receive notifications for ${sanitizeHandle(profile.handle, '@')}`, ··· 160 }, 161 ) 162 } else { 163 - logger.metric('activitySubscription:enable', { 164 setting: activitySubscription.reply ? 'posts_and_replies' : 'posts', 165 }) 166 if (!initialState.post && !initialState.reply) { ··· 177 }) 178 }, 179 onError: err => { 180 - logger.error('Could not save activity subscription', {message: err}) 181 }, 182 }) 183
··· 17 import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' 18 import {cleanError} from '#/lib/strings/errors' 19 import {sanitizeHandle} from '#/lib/strings/handles' 20 import {updateProfileShadow} from '#/state/cache/profile-shadow' 21 import {RQKEY_getActivitySubscriptions} from '#/state/queries/activity-subscriptions' 22 import {useAgent} from '#/state/session' 23 import * as Toast from '#/view/com/util/Toast' 24 + import {atoms as a, platform, useTheme, web} from '#/alf' 25 import {Admonition} from '#/components/Admonition' 26 import { 27 Button, ··· 34 import {Loader} from '#/components/Loader' 35 import * as ProfileCard from '#/components/ProfileCard' 36 import {Text} from '#/components/Typography' 37 + import {useAnalytics} from '#/analytics' 38 import {IS_WEB} from '#/env' 39 import type * as bsky from '#/types/bsky' 40 ··· 70 moderationOpts: ModerationOpts 71 includeProfile?: boolean 72 }) { 73 + const ax = useAnalytics() 74 const {_} = useLingui() 75 const t = useTheme() 76 const agent = useAgent() ··· 133 }) 134 135 if (!activitySubscription.post && !activitySubscription.reply) { 136 + ax.metric('activitySubscription:disable', {}) 137 Toast.show( 138 _( 139 msg`You will no longer receive notifications for ${sanitizeHandle(profile.handle, '@')}`, ··· 160 }, 161 ) 162 } else { 163 + ax.metric('activitySubscription:enable', { 164 setting: activitySubscription.reply ? 'posts_and_replies' : 'posts', 165 }) 166 if (!initialState.post && !initialState.reply) { ··· 177 }) 178 }, 179 onError: err => { 180 + ax.logger.error('Could not save activity subscription', {message: err}) 181 }, 182 }) 183
+5 -3
src/components/ageAssurance/AgeAssuranceAccountCard.tsx
··· 20 import {createStaticClick, InlineLinkText} from '#/components/Link' 21 import * as Toast from '#/components/Toast' 22 import {Text} from '#/components/Typography' 23 - import {logger, useAgeAssurance} from '#/ageAssurance' 24 import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess' 25 import {IS_NATIVE} from '#/env' 26 import {useDeviceGeolocationApi} from '#/geolocation' 27 ··· 41 function Inner({style}: ViewStyleProp & {}) { 42 const t = useTheme() 43 const {_, i18n} = useLingui() 44 const control = useDialogControl() 45 const appealControl = Dialog.useDialogControl() 46 const locationControl = Dialog.useDialogControl() ··· 138 label={_(msg`Contact our moderation team`)} 139 {...createStaticClick(() => { 140 appealControl.open() 141 - logger.metric('ageAssurance:appealDialogOpen', {}) 142 })}> 143 contact our moderation team 144 </InlineLinkText>{' '} ··· 167 color={hasInitiated ? 'secondary' : 'primary'} 168 onPress={() => { 169 control.open() 170 - logger.metric('ageAssurance:initDialogOpen', { 171 hasInitiatedPreviously: hasInitiated, 172 }) 173 }}>
··· 20 import {createStaticClick, InlineLinkText} from '#/components/Link' 21 import * as Toast from '#/components/Toast' 22 import {Text} from '#/components/Typography' 23 + import {useAgeAssurance} from '#/ageAssurance' 24 import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess' 25 + import {useAnalytics} from '#/analytics' 26 import {IS_NATIVE} from '#/env' 27 import {useDeviceGeolocationApi} from '#/geolocation' 28 ··· 42 function Inner({style}: ViewStyleProp & {}) { 43 const t = useTheme() 44 const {_, i18n} = useLingui() 45 + const ax = useAnalytics() 46 const control = useDialogControl() 47 const appealControl = Dialog.useDialogControl() 48 const locationControl = Dialog.useDialogControl() ··· 140 label={_(msg`Contact our moderation team`)} 141 {...createStaticClick(() => { 142 appealControl.open() 143 + ax.metric('ageAssurance:appealDialogOpen', {}) 144 })}> 145 contact our moderation team 146 </InlineLinkText>{' '} ··· 169 color={hasInitiated ? 'secondary' : 'primary'} 170 onPress={() => { 171 control.open() 172 + ax.metric('ageAssurance:initDialogOpen', { 173 hasInitiatedPreviously: hasInitiated, 174 }) 175 }}>
+3 -2
src/components/ageAssurance/AgeAssuranceAdmonition.tsx
··· 10 import {InlineLinkText} from '#/components/Link' 11 import {Text} from '#/components/Typography' 12 import {useAgeAssurance} from '#/ageAssurance' 13 - import {logger} from '#/ageAssurance' 14 15 export function AgeAssuranceAdmonition({ 16 children, ··· 40 }) { 41 const t = useTheme() 42 const {_} = useLingui() 43 44 return ( 45 <> ··· 92 to={'/settings/account'} 93 style={[a.text_sm, a.leading_snug, a.font_semi_bold]} 94 onPress={() => { 95 - logger.metric('ageAssurance:navigateToSettings', {}) 96 }}> 97 account settings. 98 </InlineLinkText>
··· 10 import {InlineLinkText} from '#/components/Link' 11 import {Text} from '#/components/Typography' 12 import {useAgeAssurance} from '#/ageAssurance' 13 + import {useAnalytics} from '#/analytics' 14 15 export function AgeAssuranceAdmonition({ 16 children, ··· 40 }) { 41 const t = useTheme() 42 const {_} = useLingui() 43 + const ax = useAnalytics() 44 45 return ( 46 <> ··· 93 to={'/settings/account'} 94 style={[a.text_sm, a.leading_snug, a.font_semi_bold]} 95 onPress={() => { 96 + ax.metric('ageAssurance:navigateToSettings', {}) 97 }}> 98 account settings. 99 </InlineLinkText>
+3 -1
src/components/ageAssurance/AgeAssuranceAppealDialog.tsx
··· 15 import {Loader} from '#/components/Loader' 16 import {Text} from '#/components/Typography' 17 import {logger} from '#/ageAssurance' 18 19 export function AgeAssuranceAppealDialog({ 20 control, ··· 37 38 function Inner({control}: {control: Dialog.DialogControlProps}) { 39 const {_} = useLingui() 40 const {currentAccount} = useSession() 41 const {gtPhone} = useBreakpoints() 42 const agent = useAgent() ··· 46 47 const {mutate, isPending} = useMutation({ 48 mutationFn: async () => { 49 - logger.metric('ageAssurance:appealDialogSubmit', {}) 50 51 await agent.createModerationReport( 52 {
··· 15 import {Loader} from '#/components/Loader' 16 import {Text} from '#/components/Typography' 17 import {logger} from '#/ageAssurance' 18 + import {useAnalytics} from '#/analytics' 19 20 export function AgeAssuranceAppealDialog({ 21 control, ··· 38 39 function Inner({control}: {control: Dialog.DialogControlProps}) { 40 const {_} = useLingui() 41 + const ax = useAnalytics() 42 const {currentAccount} = useSession() 43 const {gtPhone} = useBreakpoints() 44 const agent = useAgent() ··· 48 49 const {mutate, isPending} = useMutation({ 50 mutationFn: async () => { 51 + ax.metric('ageAssurance:appealDialogSubmit', {}) 52 53 await agent.createModerationReport( 54 {
+4 -3
src/components/ageAssurance/AgeAssuranceDismissibleFeedBanner.tsx
··· 12 import {Link} from '#/components/Link' 13 import {Text} from '#/components/Typography' 14 import {useAgeAssurance} from '#/ageAssurance' 15 - import {logger} from '#/ageAssurance' 16 17 export function useInternalState() { 18 const aa = useAgeAssurance() ··· 42 43 export function AgeAssuranceDismissibleFeedBanner() { 44 const t = useTheme() 45 const {_} = useLingui() 46 const {visible, close} = useInternalState() 47 const copy = useAgeAssuranceCopy() ··· 66 to="/settings/account" 67 onPress={() => { 68 close() 69 - logger.metric('ageAssurance:navigateToSettings', {}) 70 }} 71 style={[a.w_full, a.justify_between, a.align_center, a.gap_md]}> 72 <View ··· 105 size="small" 106 onPress={() => { 107 close() 108 - logger.metric('ageAssurance:dismissFeedBanner', {}) 109 }} 110 style={[ 111 a.absolute,
··· 12 import {Link} from '#/components/Link' 13 import {Text} from '#/components/Typography' 14 import {useAgeAssurance} from '#/ageAssurance' 15 + import {useAnalytics} from '#/analytics' 16 17 export function useInternalState() { 18 const aa = useAgeAssurance() ··· 42 43 export function AgeAssuranceDismissibleFeedBanner() { 44 const t = useTheme() 45 + const ax = useAnalytics() 46 const {_} = useLingui() 47 const {visible, close} = useInternalState() 48 const copy = useAgeAssuranceCopy() ··· 67 to="/settings/account" 68 onPress={() => { 69 close() 70 + ax.metric('ageAssurance:navigateToSettings', {}) 71 }} 72 style={[a.w_full, a.justify_between, a.align_center, a.gap_md]}> 73 <View ··· 106 size="small" 107 onPress={() => { 108 close() 109 + ax.metric('ageAssurance:dismissFeedBanner', {}) 110 }} 111 style={[ 112 a.absolute,
+3 -2
src/components/ageAssurance/AgeAssuranceDismissibleNotice.tsx
··· 10 import {Button, ButtonIcon} from '#/components/Button' 11 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 12 import {useAgeAssurance} from '#/ageAssurance' 13 - import {logger} from '#/ageAssurance' 14 15 export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) { 16 const {_} = useLingui() 17 const aa = useAgeAssurance() 18 const {nux} = useNux(Nux.AgeAssuranceDismissibleNotice) 19 const copy = useAgeAssuranceCopy() ··· 45 completed: true, 46 data: undefined, 47 }) 48 - logger.metric('ageAssurance:dismissSettingsNotice', {}) 49 }} 50 style={[ 51 a.absolute,
··· 10 import {Button, ButtonIcon} from '#/components/Button' 11 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 12 import {useAgeAssurance} from '#/ageAssurance' 13 + import {useAnalytics} from '#/analytics' 14 15 export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) { 16 const {_} = useLingui() 17 + const ax = useAnalytics() 18 const aa = useAgeAssurance() 19 const {nux} = useNux(Nux.AgeAssuranceDismissibleNotice) 20 const copy = useAgeAssuranceCopy() ··· 46 completed: true, 47 data: undefined, 48 }) 49 + ax.metric('ageAssurance:dismissSettingsNotice', {}) 50 }} 51 style={[ 52 a.absolute,
+8 -8
src/components/ageAssurance/AgeAssuranceInitDialog.tsx
··· 19 import {atoms as a, web} from '#/alf' 20 import {Admonition} from '#/components/Admonition' 21 import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' 22 - import {urls} from '#/components/ageAssurance/const' 23 - import {KWS_SUPPORTED_LANGS} from '#/components/ageAssurance/const' 24 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 25 import * as Dialog from '#/components/Dialog' 26 import {Divider} from '#/components/Divider' ··· 30 import {SimpleInlineLinkText} from '#/components/Link' 31 import {Loader} from '#/components/Loader' 32 import {Text} from '#/components/Typography' 33 - import {logger} from '#/ageAssurance' 34 import {useAgeAssurance} from '#/ageAssurance' 35 import {useBeginAgeAssurance} from '#/ageAssurance/useBeginAgeAssurance' 36 37 export {useDialogControl} from '#/components/Dialog/context' 38 ··· 64 65 function Inner() { 66 const {_} = useLingui() 67 const {currentAccount} = useSession() 68 const langPrefs = useLanguagePrefs() 69 const cleanError = useCleanError() ··· 116 const onSubmit = async () => { 117 setLanguageError(false) 118 119 - logger.metric('ageAssurance:initDialogSubmit', {}) 120 121 try { 122 const {status} = runEmailValidation() ··· 143 error = _( 144 msg`Please enter a valid, non-temporary email address. You may need to access this email in the future.`, 145 ) 146 - logger.metric('ageAssurance:initDialogError', {code: 'InvalidEmail'}) 147 } else if (e.error === 'DidTooLong') { 148 error = ( 149 <> ··· 159 </Trans> 160 </> 161 ) 162 - logger.metric('ageAssurance:initDialogError', {code: 'DidTooLong'}) 163 } else { 164 - logger.metric('ageAssurance:initDialogError', {code: 'other'}) 165 } 166 } else { 167 const {clean, raw} = cleanError(e) 168 error = clean || raw || error 169 - logger.metric('ageAssurance:initDialogError', {code: 'other'}) 170 } 171 172 setError(error)
··· 19 import {atoms as a, web} from '#/alf' 20 import {Admonition} from '#/components/Admonition' 21 import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' 22 + import {KWS_SUPPORTED_LANGS, urls} from '#/components/ageAssurance/const' 23 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 24 import * as Dialog from '#/components/Dialog' 25 import {Divider} from '#/components/Divider' ··· 29 import {SimpleInlineLinkText} from '#/components/Link' 30 import {Loader} from '#/components/Loader' 31 import {Text} from '#/components/Typography' 32 import {useAgeAssurance} from '#/ageAssurance' 33 import {useBeginAgeAssurance} from '#/ageAssurance/useBeginAgeAssurance' 34 + import {useAnalytics} from '#/analytics' 35 36 export {useDialogControl} from '#/components/Dialog/context' 37 ··· 63 64 function Inner() { 65 const {_} = useLingui() 66 + const ax = useAnalytics() 67 const {currentAccount} = useSession() 68 const langPrefs = useLanguagePrefs() 69 const cleanError = useCleanError() ··· 116 const onSubmit = async () => { 117 setLanguageError(false) 118 119 + ax.metric('ageAssurance:initDialogSubmit', {}) 120 121 try { 122 const {status} = runEmailValidation() ··· 143 error = _( 144 msg`Please enter a valid, non-temporary email address. You may need to access this email in the future.`, 145 ) 146 + ax.metric('ageAssurance:initDialogError', {code: 'InvalidEmail'}) 147 } else if (e.error === 'DidTooLong') { 148 error = ( 149 <> ··· 159 </Trans> 160 </> 161 ) 162 + ax.metric('ageAssurance:initDialogError', {code: 'DidTooLong'}) 163 } else { 164 + ax.metric('ageAssurance:initDialogError', {code: 'other'}) 165 } 166 } else { 167 const {clean, raw} = cleanError(e) 168 error = clean || raw || error 169 + ax.metric('ageAssurance:initDialogError', {code: 'other'}) 170 } 171 172 setError(error)
+6 -5
src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx
··· 16 import {Loader} from '#/components/Loader' 17 import {Text} from '#/components/Typography' 18 import {refetchAgeAssuranceServerState} from '#/ageAssurance' 19 - import {logger} from '#/ageAssurance' 20 import {IS_NATIVE} from '#/env' 21 22 export type AgeAssuranceRedirectDialogState = { ··· 81 82 export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) { 83 const t = useTheme() 84 const {_} = useLingui() 85 const agent = useAgent() 86 const polling = useRef(false) ··· 94 95 polling.current = true 96 97 - logger.metric('ageAssurance:redirectDialogOpen', {}) 98 99 wait( 100 3e3, ··· 125 126 setSuccess(true) 127 128 - logger.metric('ageAssurance:redirectDialogSuccess', {}) 129 }) 130 .catch(() => { 131 if (unmounted.current) return 132 setError(true) 133 - logger.metric('ageAssurance:redirectDialogFail', {}) 134 }) 135 136 return () => { 137 unmounted.current = true 138 } 139 - }, [agent, control]) 140 141 if (success) { 142 return (
··· 16 import {Loader} from '#/components/Loader' 17 import {Text} from '#/components/Typography' 18 import {refetchAgeAssuranceServerState} from '#/ageAssurance' 19 + import {useAnalytics} from '#/analytics' 20 import {IS_NATIVE} from '#/env' 21 22 export type AgeAssuranceRedirectDialogState = { ··· 81 82 export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) { 83 const t = useTheme() 84 + const ax = useAnalytics() 85 const {_} = useLingui() 86 const agent = useAgent() 87 const polling = useRef(false) ··· 95 96 polling.current = true 97 98 + ax.metric('ageAssurance:redirectDialogOpen', {}) 99 100 wait( 101 3e3, ··· 126 127 setSuccess(true) 128 129 + ax.metric('ageAssurance:redirectDialogSuccess', {}) 130 }) 131 .catch(() => { 132 if (unmounted.current) return 133 setError(true) 134 + ax.metric('ageAssurance:redirectDialogFail', {}) 135 }) 136 137 return () => { 138 unmounted.current = true 139 } 140 + }, [ax, agent, control]) 141 142 if (success) { 143 return (
+3 -2
src/components/ageAssurance/AgeRestrictedScreen.tsx
··· 13 import {Link} from '#/components/Link' 14 import {Text} from '#/components/Typography' 15 import {useAgeAssurance} from '#/ageAssurance' 16 - import {logger} from '#/ageAssurance' 17 18 export function AgeRestrictedScreen({ 19 children, ··· 27 rightHeaderSlot?: React.ReactNode 28 }) { 29 const {_} = useLingui() 30 const copy = useAgeAssuranceCopy() 31 const aa = useAgeAssurance() 32 ··· 74 variant="solid" 75 color="primary" 76 onPress={() => { 77 - logger.metric('ageAssurance:navigateToSettings', {}) 78 }}> 79 <ButtonText> 80 <Trans>Go to account settings</Trans>
··· 13 import {Link} from '#/components/Link' 14 import {Text} from '#/components/Typography' 15 import {useAgeAssurance} from '#/ageAssurance' 16 + import {useAnalytics} from '#/analytics' 17 18 export function AgeRestrictedScreen({ 19 children, ··· 27 rightHeaderSlot?: React.ReactNode 28 }) { 29 const {_} = useLingui() 30 + const ax = useAnalytics() 31 const copy = useAgeAssuranceCopy() 32 const aa = useAgeAssurance() 33 ··· 75 variant="solid" 76 color="primary" 77 onPress={() => { 78 + ax.metric('ageAssurance:navigateToSettings', {}) 79 }}> 80 <ButtonText> 81 <Trans>Go to account settings</Trans>
+5 -3
src/components/contacts/FindContactsBannerNUX.tsx
··· 6 import {useLingui} from '@lingui/react' 7 8 import {HITSLOP_10} from '#/lib/constants' 9 - import {logger} from '#/logger' 10 import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' 11 import {atoms as a, useTheme} from '#/alf' 12 import {Button} from '#/components/Button' 13 import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 14 import {Text} from '#/components/Typography' 15 import {IS_WEB} from '#/env' 16 import {Link} from '../Link' 17 import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from './country-allowlist' ··· 19 export function FindContactsBannerNUX() { 20 const t = useTheme() 21 const {_} = useLingui() 22 const {visible, close} = useInternalState() 23 24 if (!visible) return null ··· 30 to={{screen: 'FindContactsFlow'}} 31 label={_(msg`Import contacts to find your friends`)} 32 onPress={() => { 33 - logger.metric('contacts:nux:bannerPressed', {}) 34 }} 35 style={[ 36 a.w_full, ··· 84 ) 85 } 86 function useInternalState() { 87 const {nux} = useNux(Nux.FindContactsDismissibleBanner) 88 const {mutate: save, variables} = useSaveNux() 89 const hidden = !!variables ··· 103 completed: true, 104 data: undefined, 105 }) 106 - logger.metric('contacts:nux:bannerDismissed', {}) 107 } 108 109 return {visible, close}
··· 6 import {useLingui} from '@lingui/react' 7 8 import {HITSLOP_10} from '#/lib/constants' 9 import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' 10 import {atoms as a, useTheme} from '#/alf' 11 import {Button} from '#/components/Button' 12 import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 13 import {Text} from '#/components/Typography' 14 + import {useAnalytics} from '#/analytics' 15 import {IS_WEB} from '#/env' 16 import {Link} from '../Link' 17 import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from './country-allowlist' ··· 19 export function FindContactsBannerNUX() { 20 const t = useTheme() 21 const {_} = useLingui() 22 + const ax = useAnalytics() 23 const {visible, close} = useInternalState() 24 25 if (!visible) return null ··· 31 to={{screen: 'FindContactsFlow'}} 32 label={_(msg`Import contacts to find your friends`)} 33 onPress={() => { 34 + ax.metric('contacts:nux:bannerPressed', {}) 35 }} 36 style={[ 37 a.w_full, ··· 85 ) 86 } 87 function useInternalState() { 88 + const ax = useAnalytics() 89 const {nux} = useNux(Nux.FindContactsDismissibleBanner) 90 const {mutate: save, variables} = useSaveNux() 91 const hidden = !!variables ··· 105 completed: true, 106 data: undefined, 107 }) 108 + ax.metric('contacts:nux:bannerDismissed', {}) 109 } 110 111 return {visible, close}
+7 -5
src/components/contacts/screens/GetContacts.tsx
··· 28 import {Loader} from '#/components/Loader' 29 import * as Toast from '#/components/Toast' 30 import {Text} from '#/components/Typography' 31 import { 32 contactsWithPhoneNumbersOnly, 33 filterMatchedNumbers, ··· 51 context: 'Onboarding' | 'Standalone' 52 }) { 53 const {_} = useLingui() 54 const agent = useAgent() 55 const insets = useSafeAreaInsets() 56 const gutters = useGutters([0, 'wide']) ··· 100 }, 101 onSuccess: (result, contacts) => { 102 if (context === 'Onboarding') { 103 - logger.metric('onboarding:contacts:contactsShared', {}) 104 } 105 if (result.matches.length > 0) { 106 - logger.metric('contacts:import:success', { 107 contactCount: contacts.length, 108 matchCount: result.matches.length, 109 entryPoint: context, 110 }) 111 } else { 112 - logger.metric('contacts:import:failure', { 113 reason: 'noValidNumbers', 114 entryPoint: context, 115 }) ··· 134 }) 135 }, 136 onError: err => { 137 - logger.metric('contacts:import:failure', { 138 reason: isNetworkError(err) ? 'networkError' : 'unknown', 139 entryPoint: context, 140 }) ··· 180 permissions = await Contacts.requestPermissionsAsync() 181 } 182 183 - logger.metric('contacts:permission:request', { 184 status: permissions.granted ? 'granted' : 'denied', 185 accessLevelIOS: ios(permissions.accessPrivileges), 186 })
··· 28 import {Loader} from '#/components/Loader' 29 import * as Toast from '#/components/Toast' 30 import {Text} from '#/components/Typography' 31 + import {useAnalytics} from '#/analytics' 32 import { 33 contactsWithPhoneNumbersOnly, 34 filterMatchedNumbers, ··· 52 context: 'Onboarding' | 'Standalone' 53 }) { 54 const {_} = useLingui() 55 + const ax = useAnalytics() 56 const agent = useAgent() 57 const insets = useSafeAreaInsets() 58 const gutters = useGutters([0, 'wide']) ··· 102 }, 103 onSuccess: (result, contacts) => { 104 if (context === 'Onboarding') { 105 + ax.metric('onboarding:contacts:contactsShared', {}) 106 } 107 if (result.matches.length > 0) { 108 + ax.metric('contacts:import:success', { 109 contactCount: contacts.length, 110 matchCount: result.matches.length, 111 entryPoint: context, 112 }) 113 } else { 114 + ax.metric('contacts:import:failure', { 115 reason: 'noValidNumbers', 116 entryPoint: context, 117 }) ··· 136 }) 137 }, 138 onError: err => { 139 + ax.metric('contacts:import:failure', { 140 reason: isNetworkError(err) ? 'networkError' : 'unknown', 141 entryPoint: context, 142 }) ··· 182 permissions = await Contacts.requestPermissionsAsync() 183 } 184 185 + ax.metric('contacts:permission:request', { 186 status: permissions.granted ? 'granted' : 'denied', 187 accessLevelIOS: ios(permissions.accessPrivileges), 188 })
+3 -1
src/components/contacts/screens/PhoneInput.tsx
··· 31 import {InlineLinkText} from '#/components/Link' 32 import {Loader} from '#/components/Loader' 33 import {Text} from '#/components/Typography' 34 import {useGeolocation} from '#/geolocation' 35 import {isFindContactsFeatureEnabled} from '../country-allowlist' 36 import { ··· 52 onSkip: () => void 53 }) { 54 const {_} = useLingui() 55 const t = useTheme() 56 const agent = useAgent() 57 const location = useGeolocation() ··· 85 payload: {phoneCountryCode, phoneNumber}, 86 }) 87 88 - logger.metric('contacts:phone:phoneEntered', {entryPoint: context}) 89 }, 90 onMutate: () => { 91 Keyboard.dismiss()
··· 31 import {InlineLinkText} from '#/components/Link' 32 import {Loader} from '#/components/Loader' 33 import {Text} from '#/components/Typography' 34 + import {useAnalytics} from '#/analytics' 35 import {useGeolocation} from '#/geolocation' 36 import {isFindContactsFeatureEnabled} from '../country-allowlist' 37 import { ··· 53 onSkip: () => void 54 }) { 55 const {_} = useLingui() 56 + const ax = useAnalytics() 57 const t = useTheme() 58 const agent = useAgent() 59 const location = useGeolocation() ··· 87 payload: {phoneCountryCode, phoneNumber}, 88 }) 89 90 + ax.metric('contacts:phone:phoneEntered', {entryPoint: context}) 91 }, 92 onMutate: () => { 93 Keyboard.dismiss()
+3 -1
src/components/contacts/screens/VerifyNumber.tsx
··· 23 import {Loader} from '#/components/Loader' 24 import * as Toast from '#/components/Toast' 25 import {Text} from '#/components/Typography' 26 import {OTPInput} from '../components/OTPInput' 27 import {constructFullPhoneNumber, prettyPhoneNumber} from '../phone-number' 28 import {type Action, type State, useOnPressBackButton} from '../state' ··· 40 }) { 41 const t = useTheme() 42 const {_} = useLingui() 43 const agent = useAgent() 44 const gutters = useGutters([0, 'wide']) 45 ··· 83 }) 84 }, 1000) 85 86 - logger.metric('contacts:phone:phoneVerified', {entryPoint: context}) 87 }, 88 onMutate: () => setError(null), 89 onError: err => {
··· 23 import {Loader} from '#/components/Loader' 24 import * as Toast from '#/components/Toast' 25 import {Text} from '#/components/Typography' 26 + import {useAnalytics} from '#/analytics' 27 import {OTPInput} from '../components/OTPInput' 28 import {constructFullPhoneNumber, prettyPhoneNumber} from '../phone-number' 29 import {type Action, type State, useOnPressBackButton} from '../state' ··· 41 }) { 42 const t = useTheme() 43 const {_} = useLingui() 44 + const ax = useAnalytics() 45 const agent = useAgent() 46 const gutters = useGutters([0, 'wide']) 47 ··· 85 }) 86 }, 1000) 87 88 + ax.metric('contacts:phone:phoneVerified', {entryPoint: context}) 89 }, 90 onMutate: () => setError(null), 91 onError: err => {
+9 -6
src/components/contacts/screens/ViewMatches.tsx
··· 39 import * as ProfileCard from '#/components/ProfileCard' 40 import * as Toast from '#/components/Toast' 41 import {Text} from '#/components/Typography' 42 import type * as bsky from '#/types/bsky' 43 import {InviteInfo} from '../components/InviteInfo' 44 import {type Action, type Contact, type Match, type State} from '../state' ··· 83 }) { 84 const t = useTheme() 85 const {_} = useLingui() 86 const gutter = useGutters([0, 'wide']) 87 const moderationOpts = useModerationOpts() 88 const queryClient = useQueryClient() ··· 109 110 const cumulativeFollowCount = useRef(0) 111 const onFollow = useCallback(() => { 112 - logger.metric('contacts:matches:follow', {entryPoint: context}) 113 cumulativeFollowCount.current += 1 114 - }, [context]) 115 116 const {mutate: followAll, isPending: isFollowingAll} = useMutation({ 117 mutationFn: async () => { ··· 132 return followableDids 133 }, 134 onMutate: () => 135 - logger.metric('contacts:matches:followAll', { 136 followCount: followableDids.length, 137 entryPoint: context, 138 }), ··· 218 await agent.app.bsky.contact.dismissMatch({subject: did}) 219 }, 220 onMutate: did => { 221 - logger.metric('contacts:matches:dismiss', {entryPoint: context}) 222 dispatch({type: 'DISMISS_MATCH', payload: {did}}) 223 }, 224 onSuccess: (_res, did) => { ··· 392 label={context === 'Onboarding' ? _(msg`Next`) : _(msg`Done`)} 393 onPress={() => { 394 if (context === 'Onboarding') { 395 - logger.metric('onboarding:contacts:nextPressed', { 396 matchCount: allMatches.length, 397 followCount: cumulativeFollowCount.current, 398 dismissedMatchCount: state.dismissedMatches.length, ··· 516 const gutter = useGutters([0, 'wide']) 517 const t = useTheme() 518 const {_} = useLingui() 519 const {currentAccount} = useSession() 520 521 const name = contact.name ?? contact.firstName ?? contact.lastName ··· 564 color="secondary" 565 size="small" 566 onPress={async () => { 567 - logger.metric('contacts:matches:invite', { 568 entryPoint: context, 569 }) 570 try {
··· 39 import * as ProfileCard from '#/components/ProfileCard' 40 import * as Toast from '#/components/Toast' 41 import {Text} from '#/components/Typography' 42 + import {useAnalytics} from '#/analytics' 43 import type * as bsky from '#/types/bsky' 44 import {InviteInfo} from '../components/InviteInfo' 45 import {type Action, type Contact, type Match, type State} from '../state' ··· 84 }) { 85 const t = useTheme() 86 const {_} = useLingui() 87 + const ax = useAnalytics() 88 const gutter = useGutters([0, 'wide']) 89 const moderationOpts = useModerationOpts() 90 const queryClient = useQueryClient() ··· 111 112 const cumulativeFollowCount = useRef(0) 113 const onFollow = useCallback(() => { 114 + ax.metric('contacts:matches:follow', {entryPoint: context}) 115 cumulativeFollowCount.current += 1 116 + }, [ax, context]) 117 118 const {mutate: followAll, isPending: isFollowingAll} = useMutation({ 119 mutationFn: async () => { ··· 134 return followableDids 135 }, 136 onMutate: () => 137 + ax.metric('contacts:matches:followAll', { 138 followCount: followableDids.length, 139 entryPoint: context, 140 }), ··· 220 await agent.app.bsky.contact.dismissMatch({subject: did}) 221 }, 222 onMutate: did => { 223 + ax.metric('contacts:matches:dismiss', {entryPoint: context}) 224 dispatch({type: 'DISMISS_MATCH', payload: {did}}) 225 }, 226 onSuccess: (_res, did) => { ··· 394 label={context === 'Onboarding' ? _(msg`Next`) : _(msg`Done`)} 395 onPress={() => { 396 if (context === 'Onboarding') { 397 + ax.metric('onboarding:contacts:nextPressed', { 398 matchCount: allMatches.length, 399 followCount: cumulativeFollowCount.current, 400 dismissedMatchCount: state.dismissedMatches.length, ··· 518 const gutter = useGutters([0, 'wide']) 519 const t = useTheme() 520 const {_} = useLingui() 521 + const ax = useAnalytics() 522 const {currentAccount} = useSession() 523 524 const name = contact.name ?? contact.firstName ?? contact.lastName ··· 567 color="secondary" 568 size="small" 569 onPress={async () => { 570 + ax.metric('contacts:matches:invite', { 571 entryPoint: context, 572 }) 573 try {
+4 -3
src/components/dialogs/GifSelect.tsx
··· 11 import {msg, Trans} from '@lingui/macro' 12 import {useLingui} from '@lingui/react' 13 14 - import {logEvent} from '#/lib/statsig/statsig' 15 import {cleanError} from '#/lib/strings/errors' 16 import { 17 type Gif, ··· 30 import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow' 31 import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass' 32 import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 33 import {IS_WEB} from '#/env' 34 35 export function GifSelectDialog({ ··· 280 gif: Gif 281 onSelectGif: (gif: Gif) => void 282 }) { 283 const {gtTablet} = useBreakpoints() 284 const {_} = useLingui() 285 const t = useTheme() 286 287 const onPress = useCallback(() => { 288 - logEvent('composer:gif:select', {}) 289 onSelectGif(gif) 290 - }, [onSelectGif, gif]) 291 292 return ( 293 <Button
··· 11 import {msg, Trans} from '@lingui/macro' 12 import {useLingui} from '@lingui/react' 13 14 import {cleanError} from '#/lib/strings/errors' 15 import { 16 type Gif, ··· 29 import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow' 30 import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass' 31 import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 32 + import {useAnalytics} from '#/analytics' 33 import {IS_WEB} from '#/env' 34 35 export function GifSelectDialog({ ··· 280 gif: Gif 281 onSelectGif: (gif: Gif) => void 282 }) { 283 + const ax = useAnalytics() 284 const {gtTablet} = useBreakpoints() 285 const {_} = useLingui() 286 const t = useTheme() 287 288 const onPress = useCallback(() => { 289 + ax.metric('composer:gif:select', {}) 290 onSelectGif(gif) 291 + }, [ax, onSelectGif, gif]) 292 293 return ( 294 <Button
+9 -5
src/components/dialogs/PostInteractionSettingsDialog.tsx
··· 11 12 import {useHaptics} from '#/lib/haptics' 13 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 14 - import {logger} from '#/logger' 15 import {STALE} from '#/state/queries' 16 import {useMyListsQuery} from '#/state/queries/my-lists' 17 import {useGetPost} from '#/state/queries/post' ··· 51 import {CloseQuote_Stroke2_Corner1_Rounded as QuoteIcon} from '#/components/icons/Quote' 52 import {Loader} from '#/components/Loader' 53 import {Text} from '#/components/Typography' 54 import {IS_IOS} from '#/env' 55 56 export type PostInteractionSettingsFormProps = { ··· 80 }: PostInteractionSettingsFormProps & { 81 control: Dialog.DialogControlProps 82 }) { 83 const onClose = useNonReactiveCallback(() => { 84 - logger.metric('composer:threadgate:save', { 85 hasChanged: !!rest.isDirty, 86 persist: !!rest.persist, 87 replyOptions: ··· 161 export function PostInteractionSettingsDialogControlledInner( 162 props: PostInteractionSettingsDialogProps, 163 ) { 164 const {_} = useLingui() 165 const {currentAccount} = useSession() 166 const [isSaving, setIsSaving] = useState(false) ··· 229 230 props.control.close() 231 } catch (e: any) { 232 - logger.error(`Failed to save post interaction settings`, { 233 source: 'PostInteractionSettingsDialogControlledInner', 234 safeMessage: e.message, 235 }) ··· 244 } 245 }, [ 246 _, 247 props.postUri, 248 props.rootPostUri, 249 props.control, ··· 689 postUri: string 690 rootPostUri: string 691 }) { 692 const queryClient = useQueryClient() 693 const agent = useAgent() 694 const getPost = useGetPost() ··· 712 }), 713 ]) 714 } catch (e: any) { 715 - logger.error(`Failed to prefetch post interaction settings`, { 716 safeMessage: e.message, 717 }) 718 } 719 - }, [queryClient, agent, postUri, rootPostUri, getPost]) 720 }
··· 11 12 import {useHaptics} from '#/lib/haptics' 13 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 14 import {STALE} from '#/state/queries' 15 import {useMyListsQuery} from '#/state/queries/my-lists' 16 import {useGetPost} from '#/state/queries/post' ··· 50 import {CloseQuote_Stroke2_Corner1_Rounded as QuoteIcon} from '#/components/icons/Quote' 51 import {Loader} from '#/components/Loader' 52 import {Text} from '#/components/Typography' 53 + import {useAnalytics} from '#/analytics' 54 import {IS_IOS} from '#/env' 55 56 export type PostInteractionSettingsFormProps = { ··· 80 }: PostInteractionSettingsFormProps & { 81 control: Dialog.DialogControlProps 82 }) { 83 + const ax = useAnalytics() 84 const onClose = useNonReactiveCallback(() => { 85 + ax.metric('composer:threadgate:save', { 86 hasChanged: !!rest.isDirty, 87 persist: !!rest.persist, 88 replyOptions: ··· 162 export function PostInteractionSettingsDialogControlledInner( 163 props: PostInteractionSettingsDialogProps, 164 ) { 165 + const ax = useAnalytics() 166 const {_} = useLingui() 167 const {currentAccount} = useSession() 168 const [isSaving, setIsSaving] = useState(false) ··· 231 232 props.control.close() 233 } catch (e: any) { 234 + ax.logger.error(`Failed to save post interaction settings`, { 235 source: 'PostInteractionSettingsDialogControlledInner', 236 safeMessage: e.message, 237 }) ··· 246 } 247 }, [ 248 _, 249 + ax, 250 props.postUri, 251 props.rootPostUri, 252 props.control, ··· 692 postUri: string 693 rootPostUri: string 694 }) { 695 + const ax = useAnalytics() 696 const queryClient = useQueryClient() 697 const agent = useAgent() 698 const getPost = useGetPost() ··· 716 }), 717 ]) 718 } catch (e: any) { 719 + ax.logger.error(`Failed to prefetch post interaction settings`, { 720 safeMessage: e.message, 721 }) 722 } 723 + }, [ax, queryClient, agent, postUri, rootPostUri, getPost]) 724 }
+5 -5
src/components/dialogs/ServerInput.tsx
··· 1 import {useCallback, useImperativeHandle, useRef, useState} from 'react' 2 - import {View} from 'react-native' 3 - import {useWindowDimensions} from 'react-native' 4 import {msg, Trans} from '@lingui/macro' 5 import {useLingui} from '@lingui/react' 6 7 import {BSKY_SERVICE} from '#/lib/constants' 8 - import {logger} from '#/logger' 9 import * as persisted from '#/state/persisted' 10 import {useSession} from '#/state/session' 11 import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf' ··· 17 import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' 18 import {InlineLinkText} from '#/components/Link' 19 import {Text} from '#/components/Typography' 20 21 type SegmentedControlOptions = typeof BSKY_SERVICE | 'custom' 22 ··· 27 control: Dialog.DialogOuterProps['control'] 28 onSelect: (url: string) => void 29 }) { 30 const {height} = useWindowDimensions() 31 const formRef = useRef<DialogInnerRef>(null) 32 ··· 43 setPreviousCustomAddress(result) 44 } 45 } 46 - logger.metric('signin:hostingProviderPressed', { 47 hostingProviderDidChange: fixedOption !== BSKY_SERVICE, 48 }) 49 - }, [onSelect, fixedOption]) 50 51 return ( 52 <Dialog.Outer
··· 1 import {useCallback, useImperativeHandle, useRef, useState} from 'react' 2 + import {useWindowDimensions, View} from 'react-native' 3 import {msg, Trans} from '@lingui/macro' 4 import {useLingui} from '@lingui/react' 5 6 import {BSKY_SERVICE} from '#/lib/constants' 7 import * as persisted from '#/state/persisted' 8 import {useSession} from '#/state/session' 9 import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf' ··· 15 import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' 16 import {InlineLinkText} from '#/components/Link' 17 import {Text} from '#/components/Typography' 18 + import {useAnalytics} from '#/analytics' 19 20 type SegmentedControlOptions = typeof BSKY_SERVICE | 'custom' 21 ··· 26 control: Dialog.DialogOuterProps['control'] 27 onSelect: (url: string) => void 28 }) { 29 + const ax = useAnalytics() 30 const {height} = useWindowDimensions() 31 const formRef = useRef<DialogInnerRef>(null) 32 ··· 43 setPreviousCustomAddress(result) 44 } 45 } 46 + ax.metric('signin:hostingProviderPressed', { 47 hostingProviderDidChange: fixedOption !== BSKY_SERVICE, 48 }) 49 + }, [ax, onSelect, fixedOption]) 50 51 return ( 52 <Dialog.Outer
+5 -4
src/components/dialogs/StarterPackDialog.tsx
··· 11 12 import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 13 import {type NavigationProp} from '#/lib/routes/types' 14 - import {logger} from '#/logger' 15 import { 16 invalidateActorStarterPacksWithMembershipQuery, 17 useActorStarterPacksWithMembershipsQuery, ··· 31 import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 32 import {Loader} from '#/components/Loader' 33 import {Text} from '#/components/Typography' 34 import {IS_WEB} from '#/env' 35 import * as bsky from '#/types/bsky' 36 ··· 244 starterPackWithMembership: StarterPackWithMembership 245 targetDid: string 246 }) { 247 const {_} = useLingui() 248 - const t = useTheme() 249 const queryClient = useQueryClient() 250 251 const starterPack = starterPackWithMembership.starterPack ··· 304 listUri: listUri, 305 actorDid: targetDid, 306 }) 307 - logger.metric('starterPack:addUser', {starterPack: starterPackUri}) 308 } else { 309 if (!starterPackWithMembership.listItem?.uri) { 310 console.error('Cannot remove: missing membership URI') ··· 316 actorDid: targetDid, 317 membershipUri: starterPackWithMembership.listItem.uri, 318 }) 319 - logger.metric('starterPack:removeUser', {starterPack: starterPackUri}) 320 } 321 } 322
··· 11 12 import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 13 import {type NavigationProp} from '#/lib/routes/types' 14 import { 15 invalidateActorStarterPacksWithMembershipQuery, 16 useActorStarterPacksWithMembershipsQuery, ··· 30 import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 31 import {Loader} from '#/components/Loader' 32 import {Text} from '#/components/Typography' 33 + import {useAnalytics} from '#/analytics' 34 import {IS_WEB} from '#/env' 35 import * as bsky from '#/types/bsky' 36 ··· 244 starterPackWithMembership: StarterPackWithMembership 245 targetDid: string 246 }) { 247 + const t = useTheme() 248 + const ax = useAnalytics() 249 const {_} = useLingui() 250 const queryClient = useQueryClient() 251 252 const starterPack = starterPackWithMembership.starterPack ··· 305 listUri: listUri, 306 actorDid: targetDid, 307 }) 308 + ax.metric('starterPack:addUser', {starterPack: starterPackUri}) 309 } else { 310 if (!starterPackWithMembership.listItem?.uri) { 311 console.error('Cannot remove: missing membership URI') ··· 317 actorDid: targetDid, 318 membershipUri: starterPackWithMembership.listItem.uri, 319 }) 320 + ax.metric('starterPack:removeUser', {starterPack: starterPackUri}) 321 } 322 } 323
+4 -4
src/components/dialogs/nuxs/FindContactsAnnouncement.tsx
··· 5 import {msg, Trans} from '@lingui/macro' 6 import {useLingui} from '@lingui/react' 7 8 - import {logger} from '#/logger' 9 import {atoms as a, useTheme, web} from '#/alf' 10 import {Button, ButtonText} from '#/components/Button' 11 import {isFindContactsFeatureEnabled} from '#/components/contacts/country-allowlist' ··· 16 isExistingUserAsOf, 17 } from '#/components/dialogs/nuxs/utils' 18 import {Text} from '#/components/Typography' 19 - import {IS_NATIVE, IS_WEB} from '#/env' 20 - import {IS_E2E} from '#/env' 21 import {navigate} from '#/Navigation' 22 23 export const enabled = createIsEnabledCheck(props => { ··· 35 export function FindContactsAnnouncement() { 36 const t = useTheme() 37 const {_} = useLingui() 38 const nuxDialogs = useNuxDialogContext() 39 const control = Dialog.useDialogControl() 40 ··· 115 size="large" 116 color="primary" 117 onPress={() => { 118 - logger.metric('contacts:nux:ctaPressed', {}) 119 control.close(() => { 120 navigate('FindContactsFlow') 121 })
··· 5 import {msg, Trans} from '@lingui/macro' 6 import {useLingui} from '@lingui/react' 7 8 import {atoms as a, useTheme, web} from '#/alf' 9 import {Button, ButtonText} from '#/components/Button' 10 import {isFindContactsFeatureEnabled} from '#/components/contacts/country-allowlist' ··· 15 isExistingUserAsOf, 16 } from '#/components/dialogs/nuxs/utils' 17 import {Text} from '#/components/Typography' 18 + import {useAnalytics} from '#/analytics' 19 + import {IS_E2E, IS_NATIVE, IS_WEB} from '#/env' 20 import {navigate} from '#/Navigation' 21 22 export const enabled = createIsEnabledCheck(props => { ··· 34 export function FindContactsAnnouncement() { 35 const t = useTheme() 36 const {_} = useLingui() 37 + const ax = useAnalytics() 38 const nuxDialogs = useNuxDialogContext() 39 const control = Dialog.useDialogControl() 40 ··· 115 size="large" 116 color="primary" 117 onPress={() => { 118 + ax.metric('contacts:nux:ctaPressed', {}) 119 control.close(() => { 120 navigate('FindContactsFlow') 121 })
+5 -8
src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx
··· 5 import {useLingui} from '@lingui/react' 6 7 import {urls} from '#/lib/constants' 8 - import {logger} from '#/logger' 9 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 10 import {Button, ButtonText} from '#/components/Button' 11 import * as Dialog from '#/components/Dialog' ··· 14 import {VerifierCheck} from '#/components/icons/VerifierCheck' 15 import {Link} from '#/components/Link' 16 import {Span, Text} from '#/components/Typography' 17 import {IS_NATIVE} from '#/env' 18 19 export function InitialVerificationAnnouncement() { 20 const t = useTheme() 21 const {_} = useLingui() 22 const {gtMobile} = useBreakpoints() 23 const nuxDialogs = useNuxDialogContext() 24 const control = Dialog.useDialogControl() ··· 161 color="primary" 162 style={[a.justify_center, a.w_full]} 163 onPress={() => { 164 - logger.metric( 165 - 'verification:learn-more', 166 - { 167 - location: 'initialAnnouncementeNux', 168 - }, 169 - {statsig: false}, 170 - ) 171 }}> 172 <ButtonText> 173 <Trans>Read blog post</Trans>
··· 5 import {useLingui} from '@lingui/react' 6 7 import {urls} from '#/lib/constants' 8 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 9 import {Button, ButtonText} from '#/components/Button' 10 import * as Dialog from '#/components/Dialog' ··· 13 import {VerifierCheck} from '#/components/icons/VerifierCheck' 14 import {Link} from '#/components/Link' 15 import {Span, Text} from '#/components/Typography' 16 + import {useAnalytics} from '#/analytics' 17 import {IS_NATIVE} from '#/env' 18 19 export function InitialVerificationAnnouncement() { 20 const t = useTheme() 21 const {_} = useLingui() 22 + const ax = useAnalytics() 23 const {gtMobile} = useBreakpoints() 24 const nuxDialogs = useNuxDialogContext() 25 const control = Dialog.useDialogControl() ··· 162 color="primary" 163 style={[a.justify_center, a.w_full]} 164 onPress={() => { 165 + ax.metric('verification:learn-more', { 166 + location: 'initialAnnouncementeNux', 167 + }) 168 }}> 169 <ButtonText> 170 <Trans>Read blog post</Trans>
+1 -1
src/components/dialogs/nuxs/LiveNowBetaDialog.tsx
··· 24 '2026-01-16T00:00:00.000Z', 25 props.currentProfile.createdAt, 26 ) && 27 - !props.gate('disable_live_now_beta') 28 ) 29 }) 30
··· 24 '2026-01-16T00:00:00.000Z', 25 props.currentProfile.createdAt, 26 ) && 27 + !props.features.enabled(props.features.DisableLiveNowBeta) 28 ) 29 }) 30
+4 -4
src/components/dialogs/nuxs/index.tsx
··· 8 } from 'react' 9 import {type AppBskyActorDefs} from '@atproto/api' 10 11 - import {useGate} from '#/lib/statsig/statsig' 12 import {logger} from '#/logger' 13 import {STALE} from '#/state/queries' 14 import {Nux, useNuxs, useResetNuxs, useSaveNux} from '#/state/queries/nuxs' ··· 25 } from '#/components/dialogs/nuxs/LiveNowBetaDialog' 26 import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing' 27 import {type EnabledCheckProps} from '#/components/dialogs/nuxs/utils' 28 import {useGeolocation} from '#/geolocation' 29 30 type Context = { ··· 88 currentProfile: AppBskyActorDefs.ProfileViewDetailed 89 preferences: UsePreferencesQueryResponse 90 }) { 91 - const gate = useGate() 92 const geolocation = useGeolocation() 93 const {nuxs} = useNuxs() 94 const [snoozed, setSnoozed] = useState(() => { ··· 133 if ( 134 enabled && 135 !enabled({ 136 - gate, 137 currentAccount, 138 currentProfile, 139 preferences, ··· 165 break 166 } 167 }, [ 168 nuxs, 169 snoozed, 170 snoozeNuxDialog, 171 saveNux, 172 - gate, 173 currentAccount, 174 currentProfile, 175 preferences,
··· 8 } from 'react' 9 import {type AppBskyActorDefs} from '@atproto/api' 10 11 import {logger} from '#/logger' 12 import {STALE} from '#/state/queries' 13 import {Nux, useNuxs, useResetNuxs, useSaveNux} from '#/state/queries/nuxs' ··· 24 } from '#/components/dialogs/nuxs/LiveNowBetaDialog' 25 import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing' 26 import {type EnabledCheckProps} from '#/components/dialogs/nuxs/utils' 27 + import {useAnalytics} from '#/analytics' 28 import {useGeolocation} from '#/geolocation' 29 30 type Context = { ··· 88 currentProfile: AppBskyActorDefs.ProfileViewDetailed 89 preferences: UsePreferencesQueryResponse 90 }) { 91 + const ax = useAnalytics() 92 const geolocation = useGeolocation() 93 const {nuxs} = useNuxs() 94 const [snoozed, setSnoozed] = useState(() => { ··· 133 if ( 134 enabled && 135 !enabled({ 136 + features: ax.features, 137 currentAccount, 138 currentProfile, 139 preferences, ··· 165 break 166 } 167 }, [ 168 + ax.features, 169 nuxs, 170 snoozed, 171 snoozeNuxDialog, 172 saveNux, 173 currentAccount, 174 currentProfile, 175 preferences,
+2 -2
src/components/dialogs/nuxs/utils.ts
··· 1 import {type AppBskyActorDefs} from '@atproto/api' 2 3 - import {type useGate} from '#/lib/statsig/statsig' 4 import {type UsePreferencesQueryResponse} from '#/state/queries/preferences' 5 import {type SessionAccount} from '#/state/session' 6 import {type Geolocation} from '#/geolocation' 7 8 export type EnabledCheckProps = { 9 - gate: ReturnType<typeof useGate> 10 currentAccount: SessionAccount 11 currentProfile: AppBskyActorDefs.ProfileViewDetailed 12 preferences: UsePreferencesQueryResponse
··· 1 import {type AppBskyActorDefs} from '@atproto/api' 2 3 import {type UsePreferencesQueryResponse} from '#/state/queries/preferences' 4 import {type SessionAccount} from '#/state/session' 5 + import {type AnalyticsContextType} from '#/analytics' 6 import {type Geolocation} from '#/geolocation' 7 8 export type EnabledCheckProps = { 9 + features: AnalyticsContextType['features'] 10 currentAccount: SessionAccount 11 currentProfile: AppBskyActorDefs.ProfileViewDetailed 12 preferences: UsePreferencesQueryResponse
+8 -11
src/components/dms/MessageContextMenu.tsx
··· 7 8 import {useTranslate} from '#/lib/hooks/useTranslate' 9 import {richTextToString} from '#/lib/strings/rich-text-helpers' 10 - import {logger} from '#/logger' 11 import {useConvoActive} from '#/state/messages/convo' 12 import {useLanguagePrefs} from '#/state/preferences' 13 import {useSession} from '#/state/session' ··· 22 import {ReportDialog} from '#/components/moderation/ReportDialog' 23 import * as Prompt from '#/components/Prompt' 24 import {usePromptControl} from '#/components/Prompt' 25 import {IS_NATIVE} from '#/env' 26 import {EmojiReactionPicker} from './EmojiReactionPicker' 27 import {hasReachedReactionLimit} from './util' ··· 34 children: TriggerProps['children'] 35 }): React.ReactNode => { 36 const {_} = useLingui() 37 const {currentAccount} = useSession() 38 const convo = useConvoActive() 39 const deleteControl = usePromptControl() ··· 60 const onPressTranslateMessage = useCallback(() => { 61 translate(message.text, langPrefs.primaryLanguage) 62 63 - logger.metric( 64 - 'translate', 65 - { 66 - sourceLanguages: [], 67 - targetLanguage: langPrefs.primaryLanguage, 68 - textLength: message.text.length, 69 - }, 70 - {statsig: false}, 71 - ) 72 - }, [langPrefs.primaryLanguage, message.text, translate]) 73 74 const onDelete = useCallback(() => { 75 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
··· 7 8 import {useTranslate} from '#/lib/hooks/useTranslate' 9 import {richTextToString} from '#/lib/strings/rich-text-helpers' 10 import {useConvoActive} from '#/state/messages/convo' 11 import {useLanguagePrefs} from '#/state/preferences' 12 import {useSession} from '#/state/session' ··· 21 import {ReportDialog} from '#/components/moderation/ReportDialog' 22 import * as Prompt from '#/components/Prompt' 23 import {usePromptControl} from '#/components/Prompt' 24 + import {useAnalytics} from '#/analytics' 25 import {IS_NATIVE} from '#/env' 26 import {EmojiReactionPicker} from './EmojiReactionPicker' 27 import {hasReachedReactionLimit} from './util' ··· 34 children: TriggerProps['children'] 35 }): React.ReactNode => { 36 const {_} = useLingui() 37 + const ax = useAnalytics() 38 const {currentAccount} = useSession() 39 const convo = useConvoActive() 40 const deleteControl = usePromptControl() ··· 61 const onPressTranslateMessage = useCallback(() => { 62 translate(message.text, langPrefs.primaryLanguage) 63 64 + ax.metric('translate', { 65 + sourceLanguages: [], 66 + targetLanguage: langPrefs.primaryLanguage, 67 + textLength: message.text.length, 68 + }) 69 + }, [ax, langPrefs.primaryLanguage, message.text, translate]) 70 71 const onDelete = useCallback(() => { 72 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+6 -5
src/components/dms/MessageProfileButton.tsx
··· 7 8 import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 9 import {type NavigationProp} from '#/lib/routes/types' 10 - import {logEvent} from '#/lib/statsig/statsig' 11 import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability' 12 import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' 13 import * as Toast from '#/view/com/util/Toast' ··· 15 import {Button, ButtonIcon} from '#/components/Button' 16 import {canBeMessaged} from '#/components/dms/util' 17 import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message' 18 19 export function MessageProfileButton({ 20 profile, ··· 23 }) { 24 const {_} = useLingui() 25 const t = useTheme() 26 const navigation = useNavigation<NavigationProp>() 27 const requireEmailVerification = useRequireEmailVerification() 28 29 const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did) 30 const {mutate: initiateConvo} = useGetConvoForMembers({ 31 onSuccess: ({convo}) => { 32 - logEvent('chat:open', {logContext: 'ProfileHeader'}) 33 navigation.navigate('MessagesConversation', {conversation: convo.id}) 34 }, 35 onError: () => { ··· 43 } 44 45 if (convoAvailability.convo) { 46 - logEvent('chat:open', {logContext: 'ProfileHeader'}) 47 navigation.navigate('MessagesConversation', { 48 conversation: convoAvailability.convo.id, 49 }) 50 } else { 51 - logEvent('chat:create', {logContext: 'ProfileHeader'}) 52 initiateConvo([profile.did]) 53 } 54 - }, [navigation, profile.did, initiateConvo, convoAvailability]) 55 56 const wrappedOnPress = requireEmailVerification(onPress, { 57 instructions: [
··· 7 8 import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 9 import {type NavigationProp} from '#/lib/routes/types' 10 import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability' 11 import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' 12 import * as Toast from '#/view/com/util/Toast' ··· 14 import {Button, ButtonIcon} from '#/components/Button' 15 import {canBeMessaged} from '#/components/dms/util' 16 import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message' 17 + import {useAnalytics} from '#/analytics' 18 19 export function MessageProfileButton({ 20 profile, ··· 23 }) { 24 const {_} = useLingui() 25 const t = useTheme() 26 + const ax = useAnalytics() 27 const navigation = useNavigation<NavigationProp>() 28 const requireEmailVerification = useRequireEmailVerification() 29 30 const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did) 31 const {mutate: initiateConvo} = useGetConvoForMembers({ 32 onSuccess: ({convo}) => { 33 + ax.metric('chat:open', {logContext: 'ProfileHeader'}) 34 navigation.navigate('MessagesConversation', {conversation: convo.id}) 35 }, 36 onError: () => { ··· 44 } 45 46 if (convoAvailability.convo) { 47 + ax.metric('chat:open', {logContext: 'ProfileHeader'}) 48 navigation.navigate('MessagesConversation', { 49 conversation: convoAvailability.convo.id, 50 }) 51 } else { 52 + ax.metric('chat:create', {logContext: 'ProfileHeader'}) 53 initiateConvo([profile.did]) 54 } 55 + }, [ax, navigation, profile.did, initiateConvo, convoAvailability]) 56 57 const wrappedOnPress = requireEmailVerification(onPress, { 58 instructions: [
+4 -3
src/components/dms/dialogs/NewChatDialog.tsx
··· 3 import {useLingui} from '@lingui/react' 4 5 import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 6 - import {logEvent} from '#/lib/statsig/statsig' 7 import {logger} from '#/logger' 8 import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' 9 import {FAB} from '#/view/com/util/fab/FAB' ··· 12 import * as Dialog from '#/components/Dialog' 13 import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList' 14 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 15 16 export function NewChat({ 17 control, ··· 22 }) { 23 const t = useTheme() 24 const {_} = useLingui() 25 const requireEmailVerification = useRequireEmailVerification() 26 27 const {mutate: createChat} = useGetConvoForMembers({ ··· 29 onNewChat(data.convo.id) 30 31 if (!data.convo.lastMessage) { 32 - logEvent('chat:create', {logContext: 'NewChatDialog'}) 33 } 34 - logEvent('chat:open', {logContext: 'NewChatDialog'}) 35 }, 36 onError: error => { 37 logger.error('Failed to create chat', {safeMessage: error})
··· 3 import {useLingui} from '@lingui/react' 4 5 import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 6 import {logger} from '#/logger' 7 import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' 8 import {FAB} from '#/view/com/util/fab/FAB' ··· 11 import * as Dialog from '#/components/Dialog' 12 import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList' 13 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 14 + import {useAnalytics} from '#/analytics' 15 16 export function NewChat({ 17 control, ··· 22 }) { 23 const t = useTheme() 24 const {_} = useLingui() 25 + const ax = useAnalytics() 26 const requireEmailVerification = useRequireEmailVerification() 27 28 const {mutate: createChat} = useGetConvoForMembers({ ··· 30 onNewChat(data.convo.id) 31 32 if (!data.convo.lastMessage) { 33 + ax.metric('chat:create', {logContext: 'NewChatDialog'}) 34 } 35 + ax.metric('chat:open', {logContext: 'NewChatDialog'}) 36 }, 37 onError: error => { 38 logger.error('Failed to create chat', {safeMessage: error})
+4 -3
src/components/dms/dialogs/ShareViaChatDialog.tsx
··· 2 import {msg} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 - import {logEvent} from '#/lib/statsig/statsig' 6 import {logger} from '#/logger' 7 import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' 8 import * as Toast from '#/view/com/util/Toast' 9 import * as Dialog from '#/components/Dialog' 10 import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList' 11 12 export function SendViaChatDialog({ 13 control, ··· 32 onSelectChat: (chatId: string) => void 33 }) { 34 const {_} = useLingui() 35 const {mutate: createChat} = useGetConvoForMembers({ 36 onSuccess: data => { 37 onSelectChat(data.convo.id) 38 39 if (!data.convo.lastMessage) { 40 - logEvent('chat:create', {logContext: 'SendViaChatDialog'}) 41 } 42 - logEvent('chat:open', {logContext: 'SendViaChatDialog'}) 43 }, 44 onError: error => { 45 logger.error('Failed to share post to chat', {message: error})
··· 2 import {msg} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 import {logger} from '#/logger' 6 import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' 7 import * as Toast from '#/view/com/util/Toast' 8 import * as Dialog from '#/components/Dialog' 9 import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList' 10 + import {useAnalytics} from '#/analytics' 11 12 export function SendViaChatDialog({ 13 control, ··· 32 onSelectChat: (chatId: string) => void 33 }) { 34 const {_} = useLingui() 35 + const ax = useAnalytics() 36 const {mutate: createChat} = useGetConvoForMembers({ 37 onSuccess: data => { 38 onSelectChat(data.convo.id) 39 40 if (!data.convo.lastMessage) { 41 + ax.metric('chat:create', {logContext: 'SendViaChatDialog'}) 42 } 43 + ax.metric('chat:open', {logContext: 'SendViaChatDialog'}) 44 }, 45 onError: error => { 46 logger.error('Failed to share post to chat', {message: error})
+3 -2
src/components/feeds/PostFeedVideoGridRow.tsx
··· 1 import {View} from 'react-native' 2 import {AppBskyEmbedVideo} from '@atproto/api' 3 4 - import {logEvent} from '#/lib/statsig/statsig' 5 import {type FeedPostSliceItem} from '#/state/queries/post-feed' 6 import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types' 7 import {atoms as a, useGutters} from '#/alf' ··· 10 VideoPostCard, 11 VideoPostCardPlaceholder, 12 } from '#/components/VideoPostCard' 13 14 export function PostFeedVideoGridRow({ 15 items: slices, ··· 18 items: FeedPostSliceItem[] 19 sourceContext: VideoFeedSourceContext 20 }) { 21 const gutters = useGutters(['base', 'base', 0, 'base']) 22 const posts = slices 23 .filter(slice => AppBskyEmbedVideo.isView(slice.post.embed)) ··· 43 sourceContext={sourceContext} 44 moderation={post.moderation} 45 onInteract={() => { 46 - logEvent('videoCard:click', {context: 'feed'}) 47 }} 48 /> 49 </Grid.Col>
··· 1 import {View} from 'react-native' 2 import {AppBskyEmbedVideo} from '@atproto/api' 3 4 import {type FeedPostSliceItem} from '#/state/queries/post-feed' 5 import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types' 6 import {atoms as a, useGutters} from '#/alf' ··· 9 VideoPostCard, 10 VideoPostCardPlaceholder, 11 } from '#/components/VideoPostCard' 12 + import {useAnalytics} from '#/analytics' 13 14 export function PostFeedVideoGridRow({ 15 items: slices, ··· 18 items: FeedPostSliceItem[] 19 sourceContext: VideoFeedSourceContext 20 }) { 21 + const ax = useAnalytics() 22 const gutters = useGutters(['base', 'base', 0, 'base']) 23 const posts = slices 24 .filter(slice => AppBskyEmbedVideo.isView(slice.post.embed)) ··· 44 sourceContext={sourceContext} 45 moderation={post.moderation} 46 onInteract={() => { 47 + ax.metric('videoCard:click', {context: 'feed'}) 48 }} 49 /> 50 </Grid.Col>
+3 -3
src/components/hooks/useFollowMethods.ts
··· 2 import {msg} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 - import {type LogEvents} from '#/lib/statsig/statsig' 6 import {logger} from '#/logger' 7 import {type Shadow} from '#/state/cache/types' 8 import {useProfileFollowMutationQueue} from '#/state/queries/profile' 9 import {useRequireAuth} from '#/state/session' 10 import * as Toast from '#/view/com/util/Toast' 11 import type * as bsky from '#/types/bsky' 12 13 export function useFollowMethods({ ··· 15 logContext, 16 }: { 17 profile: Shadow<bsky.profile.AnyProfileView> 18 - logContext: LogEvents['profile:follow']['logContext'] & 19 - LogEvents['profile:unfollow']['logContext'] 20 }) { 21 const {_} = useLingui() 22 const requireAuth = useRequireAuth()
··· 2 import {msg} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 import {logger} from '#/logger' 6 import {type Shadow} from '#/state/cache/types' 7 import {useProfileFollowMutationQueue} from '#/state/queries/profile' 8 import {useRequireAuth} from '#/state/session' 9 import * as Toast from '#/view/com/util/Toast' 10 + import {type Metrics} from '#/analytics/metrics' 11 import type * as bsky from '#/types/bsky' 12 13 export function useFollowMethods({ ··· 15 logContext, 16 }: { 17 profile: Shadow<bsky.profile.AnyProfileView> 18 + logContext: Metrics['profile:follow']['logContext'] & 19 + Metrics['profile:unfollow']['logContext'] 20 }) { 21 const {_} = useLingui() 22 const requireAuth = useRequireAuth()
+7 -4
src/components/interstitials/Trending.tsx
··· 3 import {msg} from '@lingui/macro' 4 import {useLingui} from '@lingui/react' 5 6 - import {logEvent} from '#/lib/statsig/statsig' 7 import { 8 useTrendingSettings, 9 useTrendingSettingsApi, ··· 19 import * as Prompt from '#/components/Prompt' 20 import {TrendingTopicLink} from '#/components/TrendingTopics' 21 import {Text} from '#/components/Typography' 22 23 export function TrendingInterstitial() { 24 const {enabled} = useTrendingConfig() ··· 29 export function Inner() { 30 const t = useTheme() 31 const {_} = useLingui() 32 const gutters = useGutters([0, 'base', 0, 'base']) 33 const trendingPrompt = Prompt.usePromptControl() 34 const {setTrendingDisabled} = useTrendingSettingsApi() ··· 36 const noTopics = !isLoading && !error && !trending?.topics?.length 37 38 const onConfirmHide = React.useCallback(() => { 39 - logEvent('trendingTopics:hide', {context: 'interstitial'}) 40 setTrendingDisabled(true) 41 - }, [setTrendingDisabled]) 42 43 return error || noTopics ? null : ( 44 <View style={[t.atoms.border_contrast_low, a.border_t, a.border_b]}> ··· 94 key={topic.link} 95 topic={topic} 96 onPress={() => { 97 - logEvent('trendingTopic:click', {context: 'interstitial'}) 98 }}> 99 <View style={[a.py_lg]}> 100 <Text
··· 3 import {msg} from '@lingui/macro' 4 import {useLingui} from '@lingui/react' 5 6 import { 7 useTrendingSettings, 8 useTrendingSettingsApi, ··· 18 import * as Prompt from '#/components/Prompt' 19 import {TrendingTopicLink} from '#/components/TrendingTopics' 20 import {Text} from '#/components/Typography' 21 + import {useAnalytics} from '#/analytics' 22 23 export function TrendingInterstitial() { 24 const {enabled} = useTrendingConfig() ··· 29 export function Inner() { 30 const t = useTheme() 31 const {_} = useLingui() 32 + const ax = useAnalytics() 33 const gutters = useGutters([0, 'base', 0, 'base']) 34 const trendingPrompt = Prompt.usePromptControl() 35 const {setTrendingDisabled} = useTrendingSettingsApi() ··· 37 const noTopics = !isLoading && !error && !trending?.topics?.length 38 39 const onConfirmHide = React.useCallback(() => { 40 + ax.metric('trendingTopics:hide', {context: 'interstitial'}) 41 setTrendingDisabled(true) 42 + }, [ax, setTrendingDisabled]) 43 44 return error || noTopics ? null : ( 45 <View style={[t.atoms.border_contrast_low, a.border_t, a.border_b]}> ··· 95 key={topic.link} 96 topic={topic} 97 onPress={() => { 98 + ax.metric('trendingTopic:click', { 99 + context: 'interstitial', 100 + }) 101 }}> 102 <View style={[a.py_lg]}> 103 <Text
+7 -6
src/components/interstitials/TrendingVideos.tsx
··· 7 8 import {VIDEO_FEED_URI} from '#/lib/constants' 9 import {makeCustomFeedLink} from '#/lib/routes/links' 10 - import {logEvent} from '#/lib/statsig/statsig' 11 import {useTrendingSettingsApi} from '#/state/preferences/trending' 12 - import {usePostFeedQuery} from '#/state/queries/post-feed' 13 - import {RQKEY} from '#/state/queries/post-feed' 14 import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' 15 import {atoms as a, useGutters, useTheme} from '#/alf' 16 import {Button, ButtonIcon} from '#/components/Button' ··· 23 CompactVideoPostCard, 24 CompactVideoPostCardPlaceholder, 25 } from '#/components/VideoPostCard' 26 27 const CARD_WIDTH = 108 28 ··· 36 export function TrendingVideos() { 37 const t = useTheme() 38 const {_} = useLingui() 39 const gutters = useGutters([0, 'base']) 40 const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS) 41 ··· 57 58 const onConfirmHide = useCallback(() => { 59 setTrendingVideoDisabled(true) 60 - logEvent('trendingVideos:hide', {context: 'interstitial:discover'}) 61 - }, [setTrendingVideoDisabled]) 62 63 if (error) { 64 return null ··· 147 }: { 148 data: Exclude<ReturnType<typeof usePostFeedQuery>['data'], undefined> 149 }) { 150 const items = useMemo(() => { 151 return data.pages 152 .flatMap(page => page.slices) ··· 169 sourceInterstitial: 'discover', 170 }} 171 onInteract={() => { 172 - logEvent('videoCard:click', { 173 context: 'interstitial:discover', 174 }) 175 }}
··· 7 8 import {VIDEO_FEED_URI} from '#/lib/constants' 9 import {makeCustomFeedLink} from '#/lib/routes/links' 10 import {useTrendingSettingsApi} from '#/state/preferences/trending' 11 + import {RQKEY, usePostFeedQuery} from '#/state/queries/post-feed' 12 import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' 13 import {atoms as a, useGutters, useTheme} from '#/alf' 14 import {Button, ButtonIcon} from '#/components/Button' ··· 21 CompactVideoPostCard, 22 CompactVideoPostCardPlaceholder, 23 } from '#/components/VideoPostCard' 24 + import {useAnalytics} from '#/analytics' 25 26 const CARD_WIDTH = 108 27 ··· 35 export function TrendingVideos() { 36 const t = useTheme() 37 const {_} = useLingui() 38 + const ax = useAnalytics() 39 const gutters = useGutters([0, 'base']) 40 const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS) 41 ··· 57 58 const onConfirmHide = useCallback(() => { 59 setTrendingVideoDisabled(true) 60 + ax.metric('trendingVideos:hide', {context: 'interstitial:discover'}) 61 + }, [ax, setTrendingVideoDisabled]) 62 63 if (error) { 64 return null ··· 147 }: { 148 data: Exclude<ReturnType<typeof usePostFeedQuery>['data'], undefined> 149 }) { 150 + const ax = useAnalytics() 151 const items = useMemo(() => { 152 return data.pages 153 .flatMap(page => page.slices) ··· 170 sourceInterstitial: 'discover', 171 }} 172 onInteract={() => { 173 + ax.metric('videoCard:click', { 174 context: 'interstitial:discover', 175 }) 176 }}
+4 -11
src/components/live/LiveStatusDialog.tsx
··· 11 import {type NavigationProp} from '#/lib/routes/types' 12 import {sanitizeHandle} from '#/lib/strings/handles' 13 import {toNiceDomain} from '#/lib/strings/url-helpers' 14 - import {logger} from '#/logger' 15 import {useModerationOpts} from '#/state/preferences/moderation-opts' 16 import {unstableCacheProfileView} from '#/state/queries/profile' 17 import {android, atoms as a, platform, tokens, useTheme, web} from '#/alf' ··· 22 import {useGlobalReportDialogControl} from '#/components/moderation/ReportDialog' 23 import * as ProfileCard from '#/components/ProfileCard' 24 import {Text} from '#/components/Typography' 25 import type * as bsky from '#/types/bsky' 26 import {Globe_Stroke2_Corner0_Rounded} from '../icons/Globe' 27 import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '../icons/SquareArrowTopRight' ··· 103 padding?: 'lg' | 'xl' 104 onPressOpenProfile: () => void 105 }) { 106 const {_} = useLingui() 107 const t = useTheme() 108 const queryClient = useQueryClient() ··· 174 color="primary" 175 variant="solid" 176 onPress={() => { 177 - logger.metric( 178 - 'live:card:watch', 179 - {subject: profile.did}, 180 - {statsig: true}, 181 - ) 182 openLink(embed.external.uri, false) 183 }}> 184 <ButtonText> ··· 207 color="secondary" 208 variant="solid" 209 onPress={() => { 210 - logger.metric( 211 - 'live:card:openProfile', 212 - {subject: profile.did}, 213 - {statsig: true}, 214 - ) 215 unstableCacheProfileView(queryClient, profile) 216 onPressOpenProfile() 217 }}>
··· 11 import {type NavigationProp} from '#/lib/routes/types' 12 import {sanitizeHandle} from '#/lib/strings/handles' 13 import {toNiceDomain} from '#/lib/strings/url-helpers' 14 import {useModerationOpts} from '#/state/preferences/moderation-opts' 15 import {unstableCacheProfileView} from '#/state/queries/profile' 16 import {android, atoms as a, platform, tokens, useTheme, web} from '#/alf' ··· 21 import {useGlobalReportDialogControl} from '#/components/moderation/ReportDialog' 22 import * as ProfileCard from '#/components/ProfileCard' 23 import {Text} from '#/components/Typography' 24 + import {useAnalytics} from '#/analytics' 25 import type * as bsky from '#/types/bsky' 26 import {Globe_Stroke2_Corner0_Rounded} from '../icons/Globe' 27 import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '../icons/SquareArrowTopRight' ··· 103 padding?: 'lg' | 'xl' 104 onPressOpenProfile: () => void 105 }) { 106 + const ax = useAnalytics() 107 const {_} = useLingui() 108 const t = useTheme() 109 const queryClient = useQueryClient() ··· 175 color="primary" 176 variant="solid" 177 onPress={() => { 178 + ax.metric('live:card:watch', {subject: profile.did}) 179 openLink(embed.external.uri, false) 180 }}> 181 <ButtonText> ··· 204 color="secondary" 205 variant="solid" 206 onPress={() => { 207 + ax.metric('live:card:openProfile', {subject: profile.did}) 208 unstableCacheProfileView(queryClient, profile) 209 onPressOpenProfile() 210 }}>
+9 -15
src/components/live/queries.ts
··· 12 import {uploadBlob} from '#/lib/api' 13 import {imageToThumb} from '#/lib/api/resolve' 14 import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta' 15 - import {logger} from '#/logger' 16 import {updateProfileShadow} from '#/state/cache/profile-shadow' 17 import {useLiveNowConfig} from '#/state/service-config' 18 import {useAgent, useSession} from '#/state/session' 19 import * as Toast from '#/view/com/util/Toast' 20 import {useDialogContext} from '#/components/Dialog' 21 import {getLiveServiceNames} from '#/components/live/utils' 22 23 export function useLiveLinkMetaQuery(url: string | null) { 24 const liveNowConfig = useLiveNowConfig() ··· 50 linkMeta: LinkMeta | null | undefined, 51 createdAt?: string, 52 ) { 53 const {currentAccount} = useSession() 54 const agent = useAgent() 55 const queryClient = useQueryClient() ··· 77 thumb = blob.data.blob 78 } 79 } catch (e: any) { 80 - logger.error(`Failed to upload thumbnail for live status`, { 81 url: linkMeta.url, 82 image: linkMeta.image, 83 safeMessage: e, ··· 133 } 134 }, 135 onError: (e: any) => { 136 - logger.error(`Failed to upsert live status`, { 137 url: linkMeta?.url, 138 image: linkMeta?.image, 139 safeMessage: e, ··· 141 }, 142 onSuccess: ({record, image}) => { 143 if (createdAt) { 144 - logger.metric( 145 - 'live:edit', 146 - {duration: record.durationMinutes}, 147 - {statsig: true}, 148 - ) 149 } else { 150 - logger.metric( 151 - 'live:create', 152 - {duration: record.durationMinutes}, 153 - {statsig: true}, 154 - ) 155 } 156 157 Toast.show(_(msg`You are now live!`)) ··· 187 } 188 189 export function useRemoveLiveStatusMutation() { 190 const {currentAccount} = useSession() 191 const agent = useAgent() 192 const queryClient = useQueryClient() ··· 203 }) 204 }, 205 onError: (e: any) => { 206 - logger.error(`Failed to remove live status`, { 207 safeMessage: e, 208 }) 209 }, 210 onSuccess: () => { 211 - logger.metric('live:remove', {}, {statsig: true}) 212 Toast.show(_(msg`You are no longer live`)) 213 control.close(() => { 214 if (!currentAccount) return
··· 12 import {uploadBlob} from '#/lib/api' 13 import {imageToThumb} from '#/lib/api/resolve' 14 import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta' 15 import {updateProfileShadow} from '#/state/cache/profile-shadow' 16 import {useLiveNowConfig} from '#/state/service-config' 17 import {useAgent, useSession} from '#/state/session' 18 import * as Toast from '#/view/com/util/Toast' 19 import {useDialogContext} from '#/components/Dialog' 20 import {getLiveServiceNames} from '#/components/live/utils' 21 + import {useAnalytics} from '#/analytics' 22 23 export function useLiveLinkMetaQuery(url: string | null) { 24 const liveNowConfig = useLiveNowConfig() ··· 50 linkMeta: LinkMeta | null | undefined, 51 createdAt?: string, 52 ) { 53 + const ax = useAnalytics() 54 const {currentAccount} = useSession() 55 const agent = useAgent() 56 const queryClient = useQueryClient() ··· 78 thumb = blob.data.blob 79 } 80 } catch (e: any) { 81 + ax.logger.error(`Failed to upload thumbnail for live status`, { 82 url: linkMeta.url, 83 image: linkMeta.image, 84 safeMessage: e, ··· 134 } 135 }, 136 onError: (e: any) => { 137 + ax.logger.error(`Failed to upsert live status`, { 138 url: linkMeta?.url, 139 image: linkMeta?.image, 140 safeMessage: e, ··· 142 }, 143 onSuccess: ({record, image}) => { 144 if (createdAt) { 145 + ax.metric('live:edit', {duration: record.durationMinutes}) 146 } else { 147 + ax.metric('live:create', {duration: record.durationMinutes}) 148 } 149 150 Toast.show(_(msg`You are now live!`)) ··· 180 } 181 182 export function useRemoveLiveStatusMutation() { 183 + const ax = useAnalytics() 184 const {currentAccount} = useSession() 185 const agent = useAgent() 186 const queryClient = useQueryClient() ··· 197 }) 198 }, 199 onError: (e: any) => { 200 + ax.logger.error(`Failed to remove live status`, { 201 safeMessage: e, 202 }) 203 }, 204 onSuccess: () => { 205 + ax.metric('live:remove', {}) 206 Toast.show(_(msg`You are no longer live`)) 207 control.close(() => { 208 if (!currentAccount) return
+18 -24
src/components/moderation/ReportDialog/index.tsx
··· 6 7 import {wait} from '#/lib/async/wait' 8 import {getLabelingServiceTitle} from '#/lib/moderation' 9 import {sanitizeHandle} from '#/lib/strings/handles' 10 - import {Logger} from '#/logger' 11 import {useMyLabelersQuery} from '#/state/queries/preferences' 12 import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' 13 import {UserAvatar} from '#/view/com/util/UserAvatar' ··· 28 import {createStaticClick, InlineLinkText, Link} from '#/components/Link' 29 import {Loader} from '#/components/Loader' 30 import {Text} from '#/components/Typography' 31 import {IS_NATIVE} from '#/env' 32 import {useSubmitReportMutation} from './action' 33 import { ··· 53 return useGlobalDialogsControlContext().reportDialogControl 54 } 55 56 - const logger = Logger.create(Logger.Context.ReportDialog) 57 - 58 export function GlobalReportDialog() { 59 const {value, control} = useGlobalReportDialogControl() 60 return <ReportDialog control={control} subject={value?.subject} /> ··· 65 subject?: ReportSubject 66 }, 67 ) { 68 const subject = React.useMemo( 69 () => (props.subject ? parseReportSubject(props.subject) : undefined), 70 [props.subject], 71 ) 72 const onClose = React.useCallback(() => { 73 - logger.metric('reportDialog:close', {}, {statsig: false}) 74 - }, []) 75 return ( 76 <Dialog.Outer control={props.control} onClose={onClose}> 77 <Dialog.Handle /> ··· 103 } 104 105 function Inner(props: ReportDialogProps) { 106 const t = useTheme() 107 const {_} = useLingui() 108 const ref = React.useRef<ScrollView>(null) ··· 208 }), 209 ) 210 setSuccess(true) 211 - logger.metric( 212 - 'reportDialog:success', 213 - { 214 - reason: state.selectedOption?.reason ?? '', 215 - labeler: state.selectedLabeler?.creator.handle ?? '', 216 - details: !!state.details, 217 - }, 218 - {statsig: false}, 219 - ) 220 // give time for user feedback 221 setTimeout(() => { 222 props.control.close(() => { ··· 224 }) 225 }, 1e3) 226 } catch (e: any) { 227 - logger.metric('reportDialog:failure', {}, {statsig: false}) 228 logger.error(e, { 229 source: 'ReportDialog', 230 }) ··· 237 } 238 }, [_, submitReport, state, dispatch, props, setPending, setSuccess]) 239 240 - React.useEffect(() => { 241 - logger.metric( 242 - 'reportDialog:open', 243 - { 244 - subjectType: props.subject.type, 245 - }, 246 - {statsig: false}, 247 - ) 248 - }, [props.subject]) 249 250 return ( 251 <Dialog.ScrollableInner
··· 6 7 import {wait} from '#/lib/async/wait' 8 import {getLabelingServiceTitle} from '#/lib/moderation' 9 + import {useCallOnce} from '#/lib/once' 10 import {sanitizeHandle} from '#/lib/strings/handles' 11 import {useMyLabelersQuery} from '#/state/queries/preferences' 12 import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' 13 import {UserAvatar} from '#/view/com/util/UserAvatar' ··· 28 import {createStaticClick, InlineLinkText, Link} from '#/components/Link' 29 import {Loader} from '#/components/Loader' 30 import {Text} from '#/components/Typography' 31 + import {useAnalytics} from '#/analytics' 32 import {IS_NATIVE} from '#/env' 33 import {useSubmitReportMutation} from './action' 34 import { ··· 54 return useGlobalDialogsControlContext().reportDialogControl 55 } 56 57 export function GlobalReportDialog() { 58 const {value, control} = useGlobalReportDialogControl() 59 return <ReportDialog control={control} subject={value?.subject} /> ··· 64 subject?: ReportSubject 65 }, 66 ) { 67 + const ax = useAnalytics() 68 const subject = React.useMemo( 69 () => (props.subject ? parseReportSubject(props.subject) : undefined), 70 [props.subject], 71 ) 72 const onClose = React.useCallback(() => { 73 + ax.metric('reportDialog:close', {}) 74 + }, [ax]) 75 return ( 76 <Dialog.Outer control={props.control} onClose={onClose}> 77 <Dialog.Handle /> ··· 103 } 104 105 function Inner(props: ReportDialogProps) { 106 + const ax = useAnalytics() 107 + const logger = ax.logger.useChild(ax.logger.Context.ReportDialog) 108 const t = useTheme() 109 const {_} = useLingui() 110 const ref = React.useRef<ScrollView>(null) ··· 210 }), 211 ) 212 setSuccess(true) 213 + ax.metric('reportDialog:success', { 214 + reason: state.selectedOption?.reason ?? '', 215 + labeler: state.selectedLabeler?.creator.handle ?? '', 216 + details: !!state.details, 217 + }) 218 // give time for user feedback 219 setTimeout(() => { 220 props.control.close(() => { ··· 222 }) 223 }, 1e3) 224 } catch (e: any) { 225 + ax.metric('reportDialog:failure', {}) 226 logger.error(e, { 227 source: 'ReportDialog', 228 }) ··· 235 } 236 }, [_, submitReport, state, dispatch, props, setPending, setSuccess]) 237 238 + useCallOnce(() => { 239 + ax.metric('reportDialog:open', { 240 + subjectType: props.subject.type, 241 + }) 242 + })() 243 244 return ( 245 <Dialog.ScrollableInner
+3 -2
src/components/verification/VerificationCheckButton.tsx
··· 2 import {msg} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 - import {logger} from '#/logger' 6 import {type Shadow} from '#/state/cache/types' 7 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 8 import {Button} from '#/components/Button' ··· 12 import {VerificationCheck} from '#/components/verification/VerificationCheck' 13 import {VerificationsDialog} from '#/components/verification/VerificationsDialog' 14 import {VerifierDialog} from '#/components/verification/VerifierDialog' 15 import type * as bsky from '#/types/bsky' 16 17 export function shouldShowVerificationCheckButton( ··· 77 size: 'lg' | 'md' | 'sm' 78 }) { 79 const t = useTheme() 80 const {_} = useLingui() 81 const verificationsDialogControl = useDialogControl() 82 const verifierDialogControl = useDialogControl() ··· 101 hitSlop={20} 102 onPress={evt => { 103 evt.preventDefault() 104 - logger.metric('verification:badge:click', {}, {statsig: true}) 105 if (state.profile.role === 'verifier') { 106 verifierDialogControl.open() 107 } else {
··· 2 import {msg} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 import {type Shadow} from '#/state/cache/types' 6 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 7 import {Button} from '#/components/Button' ··· 11 import {VerificationCheck} from '#/components/verification/VerificationCheck' 12 import {VerificationsDialog} from '#/components/verification/VerificationsDialog' 13 import {VerifierDialog} from '#/components/verification/VerifierDialog' 14 + import {useAnalytics} from '#/analytics' 15 import type * as bsky from '#/types/bsky' 16 17 export function shouldShowVerificationCheckButton( ··· 77 size: 'lg' | 'md' | 'sm' 78 }) { 79 const t = useTheme() 80 + const ax = useAnalytics() 81 const {_} = useLingui() 82 const verificationsDialogControl = useDialogControl() 83 const verifierDialogControl = useDialogControl() ··· 102 hitSlop={20} 103 onPress={evt => { 104 evt.preventDefault() 105 + ax.metric('verification:badge:click', {}) 106 if (state.profile.role === 'verifier') { 107 verifierDialogControl.open() 108 } else {
+5 -8
src/components/verification/VerificationsDialog.tsx
··· 5 6 import {urls} from '#/lib/constants' 7 import {getUserDisplayName} from '#/lib/getUserDisplayName' 8 - import {logger} from '#/logger' 9 import {useModerationOpts} from '#/state/preferences/moderation-opts' 10 import {useProfileQuery} from '#/state/queries/profile' 11 import {useSession} from '#/state/session' ··· 20 import {Text} from '#/components/Typography' 21 import {type FullVerificationState} from '#/components/verification' 22 import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt' 23 import type * as bsky from '#/types/bsky' 24 25 export {useDialogControl} from '#/components/Dialog' ··· 55 verificationState: FullVerificationState 56 }) { 57 const t = useTheme() 58 const {_} = useLingui() 59 const {gtMobile} = useBreakpoints() 60 ··· 158 color="secondary" 159 style={[a.justify_center]} 160 onPress={() => { 161 - logger.metric( 162 - 'verification:learn-more', 163 - { 164 - location: 'verificationsDialog', 165 - }, 166 - {statsig: true}, 167 - ) 168 }}> 169 <ButtonText> 170 <Trans context="english-only-resource">Learn more</Trans>
··· 5 6 import {urls} from '#/lib/constants' 7 import {getUserDisplayName} from '#/lib/getUserDisplayName' 8 import {useModerationOpts} from '#/state/preferences/moderation-opts' 9 import {useProfileQuery} from '#/state/queries/profile' 10 import {useSession} from '#/state/session' ··· 19 import {Text} from '#/components/Typography' 20 import {type FullVerificationState} from '#/components/verification' 21 import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt' 22 + import {useAnalytics} from '#/analytics' 23 import type * as bsky from '#/types/bsky' 24 25 export {useDialogControl} from '#/components/Dialog' ··· 55 verificationState: FullVerificationState 56 }) { 57 const t = useTheme() 58 + const ax = useAnalytics() 59 const {_} = useLingui() 60 const {gtMobile} = useBreakpoints() 61 ··· 159 color="secondary" 160 style={[a.justify_center]} 161 onPress={() => { 162 + ax.metric('verification:learn-more', { 163 + location: 'verificationsDialog', 164 + }) 165 }}> 166 <ButtonText> 167 <Trans context="english-only-resource">Learn more</Trans>
+5 -8
src/components/verification/VerifierDialog.tsx
··· 5 6 import {urls} from '#/lib/constants' 7 import {getUserDisplayName} from '#/lib/getUserDisplayName' 8 - import {logger} from '#/logger' 9 import {useSession} from '#/state/session' 10 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 11 import {Button, ButtonText} from '#/components/Button' ··· 14 import {Link} from '#/components/Link' 15 import {Text} from '#/components/Typography' 16 import {type FullVerificationState} from '#/components/verification' 17 import type * as bsky from '#/types/bsky' 18 19 export {useDialogControl} from '#/components/Dialog' ··· 49 verificationState: FullVerificationState 50 }) { 51 const t = useTheme() 52 const {_} = useLingui() 53 const {gtMobile} = useBreakpoints() 54 const {currentAccount} = useSession() ··· 126 color="primary" 127 style={[a.justify_center]} 128 onPress={() => { 129 - logger.metric( 130 - 'verification:learn-more', 131 - { 132 - location: 'verifierDialog', 133 - }, 134 - {statsig: true}, 135 - ) 136 }}> 137 <ButtonText> 138 <Trans context="english-only-resource">Learn more</Trans>
··· 5 6 import {urls} from '#/lib/constants' 7 import {getUserDisplayName} from '#/lib/getUserDisplayName' 8 import {useSession} from '#/state/session' 9 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 10 import {Button, ButtonText} from '#/components/Button' ··· 13 import {Link} from '#/components/Link' 14 import {Text} from '#/components/Typography' 15 import {type FullVerificationState} from '#/components/verification' 16 + import {useAnalytics} from '#/analytics' 17 import type * as bsky from '#/types/bsky' 18 19 export {useDialogControl} from '#/components/Dialog' ··· 49 verificationState: FullVerificationState 50 }) { 51 const t = useTheme() 52 + const ax = useAnalytics() 53 const {_} = useLingui() 54 const {gtMobile} = useBreakpoints() 55 const {currentAccount} = useSession() ··· 127 color="primary" 128 style={[a.justify_center]} 129 onPress={() => { 130 + ax.metric('verification:learn-more', { 131 + location: 'verifierDialog', 132 + }) 133 }}> 134 <ButtonText> 135 <Trans context="english-only-resource">Learn more</Trans>
+19 -1
src/env/common.ts
··· 50 51 /** 52 * This will always be in the format of YYMMDDHH, so that it always increases 53 - * for each build. This should only be used for StatSig reporting and shouldn't 54 * be used to identify a specific bundle. 55 */ 56 export const BUNDLE_DATE: number = ··· 83 */ 84 export const CHAT_PROXY_DID: Did = 85 process.env.EXPO_PUBLIC_CHAT_PROXY_DID || 'did:web:api.bsky.chat' 86 87 /** 88 * Sentry DSN for telemetry
··· 50 51 /** 52 * This will always be in the format of YYMMDDHH, so that it always increases 53 + * for each build. This should only be used for analytics reporting and shouldn't 54 * be used to identify a specific bundle. 55 */ 56 export const BUNDLE_DATE: number = ··· 83 */ 84 export const CHAT_PROXY_DID: Did = 85 process.env.EXPO_PUBLIC_CHAT_PROXY_DID || 'did:web:api.bsky.chat' 86 + 87 + /** 88 + * Metrics API host 89 + */ 90 + export const METRICS_API_HOST: string = 91 + process.env.EXPO_PUBLIC_METRICS_API_HOST || 'https://events.bsky.app' 92 + 93 + /** 94 + * Growthbook API host 95 + */ 96 + export const GROWTHBOOK_API_HOST: string = 97 + process.env.EXPO_PUBLIC_GROWTHBOOK_API_HOST || `${METRICS_API_HOST}/gb` 98 + 99 + /** 100 + * Growthbook client key 101 + */ 102 + export const GROWTHBOOK_CLIENT_KEY: string = 103 + process.env.EXPO_PUBLIC_GROWTHBOOK_CLIENT_KEY || 'sdk-7gkUkGy9wguUjyFe' 104 105 /** 106 * Sentry DSN for telemetry
+4 -3
src/features/liveEvents/components/LiveEventFeedCardCompact.tsx
··· 6 import {useLingui} from '@lingui/react' 7 8 import {isBskyCustomFeedUrl} from '#/lib/strings/url-helpers' 9 - import {logger} from '#/logger' 10 import {atoms as a, utils} from '#/alf' 11 import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live' 12 import {Link} from '#/components/Link' 13 import {Text} from '#/components/Typography' 14 import { 15 type LiveEventFeed, 16 type LiveEventFeedMetricContext, ··· 26 metricContext: LiveEventFeedMetricContext 27 }) { 28 const {_} = useLingui() 29 30 const layout = feed.layouts.compact 31 const overlayColor = layout.overlayColor ··· 39 }, [feed.url]) 40 41 useEffect(() => { 42 - logger.metric('liveEvents:feedBanner:seen', { 43 feed: feed.url, 44 context: metricContext, 45 }) ··· 52 label={_(msg`Live event happening now: ${feed.title}`)} 53 style={[a.w_full]} 54 onPress={() => { 55 - logger.metric('liveEvents:feedBanner:click', { 56 feed: feed.url, 57 context: metricContext, 58 })
··· 6 import {useLingui} from '@lingui/react' 7 8 import {isBskyCustomFeedUrl} from '#/lib/strings/url-helpers' 9 import {atoms as a, utils} from '#/alf' 10 import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live' 11 import {Link} from '#/components/Link' 12 import {Text} from '#/components/Typography' 13 + import {useAnalytics} from '#/analytics' 14 import { 15 type LiveEventFeed, 16 type LiveEventFeedMetricContext, ··· 26 metricContext: LiveEventFeedMetricContext 27 }) { 28 const {_} = useLingui() 29 + const ax = useAnalytics() 30 31 const layout = feed.layouts.compact 32 const overlayColor = layout.overlayColor ··· 40 }, [feed.url]) 41 42 useEffect(() => { 43 + ax.metric('liveEvents:feedBanner:seen', { 44 feed: feed.url, 45 context: metricContext, 46 }) ··· 53 label={_(msg`Live event happening now: ${feed.title}`)} 54 style={[a.w_full]} 55 onPress={() => { 56 + ax.metric('liveEvents:feedBanner:click', { 57 feed: feed.url, 58 context: metricContext, 59 })
+8 -7
src/features/liveEvents/components/LiveEventFeedCardWide.tsx
··· 1 - import {useEffect, useMemo} from 'react' 2 import {View} from 'react-native' 3 import {Image} from 'expo-image' 4 import {LinearGradient} from 'expo-linear-gradient' 5 import {msg, Trans} from '@lingui/macro' 6 import {useLingui} from '@lingui/react' 7 8 import {isBskyCustomFeedUrl} from '#/lib/strings/url-helpers' 9 - import {logger} from '#/logger' 10 import {atoms as a, useBreakpoints, utils} from '#/alf' 11 import {Link} from '#/components/Link' 12 import {Text} from '#/components/Typography' 13 import { 14 type LiveEventFeed, 15 type LiveEventFeedMetricContext, ··· 24 feed: LiveEventFeed 25 metricContext: LiveEventFeedMetricContext 26 }) { 27 const {_} = useLingui() 28 const {gtPhone} = useBreakpoints() 29 ··· 38 return '/' 39 }, [feed.url]) 40 41 - useEffect(() => { 42 - logger.metric('liveEvents:feedBanner:seen', { 43 feed: feed.url, 44 context: metricContext, 45 }) 46 - // eslint-disable-next-line react-hooks/exhaustive-deps 47 - }, []) 48 49 return ( 50 <Link ··· 52 label={_(msg`Live event happening now: ${feed.title}`)} 53 style={[a.w_full]} 54 onPress={() => { 55 - logger.metric('liveEvents:feedBanner:click', { 56 feed: feed.url, 57 context: metricContext, 58 })
··· 1 + import {useMemo} from 'react' 2 import {View} from 'react-native' 3 import {Image} from 'expo-image' 4 import {LinearGradient} from 'expo-linear-gradient' 5 import {msg, Trans} from '@lingui/macro' 6 import {useLingui} from '@lingui/react' 7 8 + import {useCallOnce} from '#/lib/once' 9 import {isBskyCustomFeedUrl} from '#/lib/strings/url-helpers' 10 import {atoms as a, useBreakpoints, utils} from '#/alf' 11 import {Link} from '#/components/Link' 12 import {Text} from '#/components/Typography' 13 + import {useAnalytics} from '#/analytics' 14 import { 15 type LiveEventFeed, 16 type LiveEventFeedMetricContext, ··· 25 feed: LiveEventFeed 26 metricContext: LiveEventFeedMetricContext 27 }) { 28 + const ax = useAnalytics() 29 const {_} = useLingui() 30 const {gtPhone} = useBreakpoints() 31 ··· 40 return '/' 41 }, [feed.url]) 42 43 + useCallOnce(() => { 44 + ax.metric('liveEvents:feedBanner:seen', { 45 feed: feed.url, 46 context: metricContext, 47 }) 48 + })() 49 50 return ( 51 <Link ··· 53 label={_(msg`Live event happening now: ${feed.title}`)} 54 style={[a.w_full]} 55 onPress={() => { 56 + ax.metric('liveEvents:feedBanner:click', { 57 feed: feed.url, 58 context: metricContext, 59 })
+6 -5
src/features/liveEvents/preferences.ts
··· 2 import {type Agent, AppBskyActorDefs, asPredicate} from '@atproto/api' 3 import {useMutation, useQueryClient} from '@tanstack/react-query' 4 5 - import {logger} from '#/logger' 6 import { 7 preferencesQueryKey, 8 usePreferencesQuery, 9 } from '#/state/queries/preferences' 10 import {useAgent} from '#/state/session' 11 import {IS_WEB} from '#/env' 12 import * as env from '#/env' 13 import { ··· 63 undoAction: LiveEventPreferencesAction | null 64 }) => void 65 }) { 66 const queryClient = useQueryClient() 67 const agent = useAgent() 68 ··· 116 case 'hideFeed': 117 case 'unhideFeed': { 118 if (!props.feed) { 119 - logger.error( 120 `useUpdateLiveEventPreferences: feed is missing, but required for hiding/unhiding`, 121 { 122 action, ··· 125 break 126 } 127 128 - logger.metric( 129 action.type === 'hideFeed' 130 ? 'liveEvents:feedBanner:hide' 131 : 'liveEvents:feedBanner:unhide', ··· 138 } 139 case 'toggleHideAllFeeds': { 140 if (prefs!.hideAllFeeds) { 141 - logger.metric('liveEvents:hideAllFeedBanners', { 142 context: props.metricContext, 143 }) 144 } else { 145 - logger.metric('liveEvents:unhideAllFeedBanners', { 146 context: props.metricContext, 147 }) 148 }
··· 2 import {type Agent, AppBskyActorDefs, asPredicate} from '@atproto/api' 3 import {useMutation, useQueryClient} from '@tanstack/react-query' 4 5 import { 6 preferencesQueryKey, 7 usePreferencesQuery, 8 } from '#/state/queries/preferences' 9 import {useAgent} from '#/state/session' 10 + import {useAnalytics} from '#/analytics' 11 import {IS_WEB} from '#/env' 12 import * as env from '#/env' 13 import { ··· 63 undoAction: LiveEventPreferencesAction | null 64 }) => void 65 }) { 66 + const ax = useAnalytics() 67 const queryClient = useQueryClient() 68 const agent = useAgent() 69 ··· 117 case 'hideFeed': 118 case 'unhideFeed': { 119 if (!props.feed) { 120 + ax.logger.error( 121 `useUpdateLiveEventPreferences: feed is missing, but required for hiding/unhiding`, 122 { 123 action, ··· 126 break 127 } 128 129 + ax.metric( 130 action.type === 'hideFeed' 131 ? 'liveEvents:feedBanner:hide' 132 : 'liveEvents:feedBanner:unhide', ··· 139 } 140 case 'toggleHideAllFeeds': { 141 if (prefs!.hideAllFeeds) { 142 + ax.metric('liveEvents:hideAllFeedBanners', { 143 context: props.metricContext, 144 }) 145 } else { 146 + ax.metric('liveEvents:unhideAllFeedBanners', { 147 context: props.metricContext, 148 }) 149 }
+26
src/lib/appState.ts
···
··· 1 + import {useEffect, useState} from 'react' 2 + import {AppState, type AppStateStatus} from 'react-native' 3 + 4 + export const getCurrentState = () => AppState.currentState 5 + 6 + export function onAppStateChange(cb: (state: AppStateStatus) => void) { 7 + let prev = AppState.currentState 8 + return AppState.addEventListener('change', next => { 9 + if (next === prev) return 10 + prev = next 11 + cb(next) 12 + }) 13 + } 14 + 15 + export function useAppState() { 16 + const [state, setState] = useState(AppState.currentState) 17 + 18 + useEffect(() => { 19 + const sub = onAppStateChange(next => { 20 + setState(next) 21 + }) 22 + return () => sub.remove() 23 + }, []) 24 + 25 + return state 26 + }
+6 -5
src/lib/hooks/useAccountSwitcher.ts
··· 6 import {type SessionAccount, useSessionApi} from '#/state/session' 7 import {useLoggedOutViewControls} from '#/state/shell/logged-out' 8 import * as Toast from '#/view/com/util/Toast' 9 import {IS_WEB} from '#/env' 10 - import {logEvent} from '../statsig/statsig' 11 - import {type LogEvents} from '../statsig/statsig' 12 13 export function useAccountSwitcher() { 14 const [pendingDid, setPendingDid] = useState<string | null>(null) 15 const {_} = useLingui() 16 const {resumeSession} = useSessionApi() ··· 19 const onPressSwitchAccount = useCallback( 20 async ( 21 account: SessionAccount, 22 - logContext: LogEvents['account:loggedIn']['logContext'], 23 ) => { 24 if (pendingDid) { 25 // The session API isn't resilient to race conditions so let's just ignore this. ··· 37 history.pushState(null, '', '/') 38 } 39 await resumeSession(account, true) 40 - logEvent('account:loggedIn', {logContext, withPassword: false}) 41 Toast.show(_(msg`Signed in as @${account.handle}`)) 42 } else { 43 requestSwitchToAccount({requestedAccount: account.did}) ··· 59 setPendingDid(null) 60 } 61 }, 62 - [_, resumeSession, requestSwitchToAccount, pendingDid], 63 ) 64 65 return {onPressSwitchAccount, pendingDid}
··· 6 import {type SessionAccount, useSessionApi} from '#/state/session' 7 import {useLoggedOutViewControls} from '#/state/shell/logged-out' 8 import * as Toast from '#/view/com/util/Toast' 9 + import {useAnalytics} from '#/analytics' 10 + import {type Metrics} from '#/analytics/metrics' 11 import {IS_WEB} from '#/env' 12 13 export function useAccountSwitcher() { 14 + const ax = useAnalytics() 15 const [pendingDid, setPendingDid] = useState<string | null>(null) 16 const {_} = useLingui() 17 const {resumeSession} = useSessionApi() ··· 20 const onPressSwitchAccount = useCallback( 21 async ( 22 account: SessionAccount, 23 + logContext: Metrics['account:loggedIn']['logContext'], 24 ) => { 25 if (pendingDid) { 26 // The session API isn't resilient to race conditions so let's just ignore this. ··· 38 history.pushState(null, '', '/') 39 } 40 await resumeSession(account, true) 41 + ax.metric('account:loggedIn', {logContext, withPassword: false}) 42 Toast.show(_(msg`Signed in as @${account.handle}`)) 43 } else { 44 requestSwitchToAccount({requestedAccount: account.did}) ··· 60 setPendingDid(null) 61 } 62 }, 63 + [_, ax, resumeSession, requestSwitchToAccount, pendingDid], 64 ) 65 66 return {onPressSwitchAccount, pendingDid}
-15
src/lib/hooks/useAppState.ts
··· 1 - import {useEffect, useState} from 'react' 2 - import {AppState} from 'react-native' 3 - 4 - export function useAppState() { 5 - const [state, setState] = useState(AppState.currentState) 6 - 7 - useEffect(() => { 8 - const sub = AppState.addEventListener('change', nextAppState => { 9 - setState(nextAppState) 10 - }) 11 - return () => sub.remove() 12 - }, []) 13 - 14 - return state 15 - }
···
-20
src/lib/hooks/useCallOnce.ts
··· 1 - import {useCallback} from 'react' 2 - 3 - export enum OnceKey { 4 - PreferencesThread = 'preferences:thread', 5 - } 6 - 7 - const called: Record<OnceKey, boolean> = { 8 - [OnceKey.PreferencesThread]: false, 9 - } 10 - 11 - export function useCallOnce(key: OnceKey) { 12 - return useCallback( 13 - (cb: () => void) => { 14 - if (called[key] === true) return 15 - called[key] = true 16 - cb() 17 - }, 18 - [key], 19 - ) 20 - }
···
+4 -2
src/lib/hooks/useIntentHandler.ts
··· 5 6 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 7 import {parseLinkingUrl} from '#/lib/parseLinkingUrl' 8 - import {logger} from '#/logger' 9 import {useSession} from '#/state/session' 10 import {useCloseAllActiveElements} from '#/state/util' 11 import {useIntentDialogs} from '#/components/intents/IntentDialogs' 12 import {IS_IOS, IS_NATIVE} from '#/env' 13 import {Referrer} from '../../../modules/expo-bluesky-swiss-army' 14 import {useApplyPullRequestOTAUpdate} from './useOTAUpdates' ··· 22 23 export function useIntentHandler() { 24 const incomingUrl = Linking.useLinkingURL() 25 const composeIntent = useComposeIntent() 26 const verifyEmailIntent = useVerifyEmailIntent() 27 const {currentAccount} = useSession() ··· 36 37 const referrerInfo = Referrer.getReferrerInfo() 38 if (referrerInfo && referrerInfo.hostname !== 'bsky.app') { 39 - logger.metric('deepLink:referrerReceived', { 40 to: url, 41 referrer: referrerInfo?.referrer, 42 hostname: referrerInfo?.hostname, ··· 95 } 96 }, [ 97 incomingUrl, 98 composeIntent, 99 verifyEmailIntent, 100 currentAccount,
··· 5 6 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 7 import {parseLinkingUrl} from '#/lib/parseLinkingUrl' 8 import {useSession} from '#/state/session' 9 import {useCloseAllActiveElements} from '#/state/util' 10 import {useIntentDialogs} from '#/components/intents/IntentDialogs' 11 + import {useAnalytics} from '#/analytics' 12 import {IS_IOS, IS_NATIVE} from '#/env' 13 import {Referrer} from '../../../modules/expo-bluesky-swiss-army' 14 import {useApplyPullRequestOTAUpdate} from './useOTAUpdates' ··· 22 23 export function useIntentHandler() { 24 const incomingUrl = Linking.useLinkingURL() 25 + const ax = useAnalytics() 26 const composeIntent = useComposeIntent() 27 const verifyEmailIntent = useVerifyEmailIntent() 28 const {currentAccount} = useSession() ··· 37 38 const referrerInfo = Referrer.getReferrerInfo() 39 if (referrerInfo && referrerInfo.hostname !== 'bsky.app') { 40 + ax.metric('deepLink:referrerReceived', { 41 to: url, 42 referrer: referrerInfo?.referrer, 43 hostname: referrerInfo?.hostname, ··· 96 } 97 }, [ 98 incomingUrl, 99 + ax, 100 composeIntent, 101 verifyEmailIntent, 102 currentAccount,
+3 -3
src/lib/hooks/useIsBskyTeam.ts
··· 1 import {useMemo} from 'react' 2 3 - import {useGate} from '#/lib/statsig/statsig' 4 5 export function useIsBskyTeam() { 6 - const gate = useGate() 7 - return useMemo(() => gate('is_bsky_team_member'), [gate]) 8 }
··· 1 import {useMemo} from 'react' 2 3 + import {useAnalytics} from '#/analytics' 4 5 export function useIsBskyTeam() { 6 + const ax = useAnalytics() 7 + return useMemo(() => ax.features.enabled(ax.features.IsBskyTeam), [ax]) 8 }
+16 -12
src/lib/hooks/useNotificationHandler.ts
··· 16 import {useSession} from '#/state/session' 17 import {useLoggedOutViewControls} from '#/state/shell/logged-out' 18 import {useCloseAllActiveElements} from '#/state/util' 19 import {IS_ANDROID, IS_IOS} from '#/env' 20 import {resetToTab} from '#/Navigation' 21 import {router} from '#/routes' ··· 75 let lastHandledNotificationDateDedupe = 0 76 77 export function useNotificationsHandler() { 78 const queryClient = useQueryClient() 79 const {currentAccount, accounts} = useSession() 80 const {onPressSwitchAccount} = useAccountSwitcher() ··· 190 if (!payload) return 191 192 if (payload.reason === 'chat-message') { 193 - notyLogger.debug(`useNotificationsHandler: handling chat message`, { 194 payload, 195 }) 196 ··· 250 const [screen, params] = router.matchPath(url) 251 // @ts-expect-error router is not typed :/ -sfn 252 navigation.navigate('HomeTab', {screen, params}) 253 - notyLogger.debug(`useNotificationsHandler: navigate`, { 254 screen, 255 params, 256 }) ··· 264 265 if (!payload) return DEFAULT_HANDLER_OPTIONS 266 267 - notyLogger.debug('useNotificationsHandler: incoming', {e, payload}) 268 269 if ( 270 payload.reason === 'chat-message' && ··· 290 if (e.notification.date === lastHandledNotificationDateDedupe) return 291 lastHandledNotificationDateDedupe = e.notification.date 292 293 - notyLogger.debug('useNotificationsHandler: response received', { 294 actionIdentifier: e.actionIdentifier, 295 }) 296 ··· 301 const payload = getNotificationPayload(e.notification) 302 303 if (payload) { 304 - notyLogger.debug( 305 'User pressed a notification, opening notifications tab', 306 {}, 307 ) 308 - notyLogger.metric( 309 - 'notifications:openApp', 310 - {reason: payload.reason, causedBoot: false}, 311 - {statsig: false}, 312 - ) 313 314 invalidateCachedUnreadPage() 315 truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all')) ··· 322 truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions')) 323 } 324 325 - notyLogger.debug('Notifications: handleNotification', { 326 content: e.notification.request.content, 327 payload: payload, 328 }) ··· 330 handleNotification(payload) 331 Notifications.dismissAllNotificationsAsync() 332 } else { 333 - notyLogger.error('useNotificationsHandler: received no payload', { 334 identifier: e.notification.request.identifier, 335 }) 336 } ··· 350 responseReceivedListener.remove() 351 } 352 }, [ 353 queryClient, 354 currentAccount, 355 currentConvoId,
··· 16 import {useSession} from '#/state/session' 17 import {useLoggedOutViewControls} from '#/state/shell/logged-out' 18 import {useCloseAllActiveElements} from '#/state/util' 19 + import {useAnalytics} from '#/analytics' 20 import {IS_ANDROID, IS_IOS} from '#/env' 21 import {resetToTab} from '#/Navigation' 22 import {router} from '#/routes' ··· 76 let lastHandledNotificationDateDedupe = 0 77 78 export function useNotificationsHandler() { 79 + const ax = useAnalytics() 80 + const logger = ax.logger.useChild(ax.logger.Context.Notifications) 81 const queryClient = useQueryClient() 82 const {currentAccount, accounts} = useSession() 83 const {onPressSwitchAccount} = useAccountSwitcher() ··· 193 if (!payload) return 194 195 if (payload.reason === 'chat-message') { 196 + logger.debug(`useNotificationsHandler: handling chat message`, { 197 payload, 198 }) 199 ··· 253 const [screen, params] = router.matchPath(url) 254 // @ts-expect-error router is not typed :/ -sfn 255 navigation.navigate('HomeTab', {screen, params}) 256 + logger.debug(`useNotificationsHandler: navigate`, { 257 screen, 258 params, 259 }) ··· 267 268 if (!payload) return DEFAULT_HANDLER_OPTIONS 269 270 + logger.debug('useNotificationsHandler: incoming', {e, payload}) 271 272 if ( 273 payload.reason === 'chat-message' && ··· 293 if (e.notification.date === lastHandledNotificationDateDedupe) return 294 lastHandledNotificationDateDedupe = e.notification.date 295 296 + logger.debug('useNotificationsHandler: response received', { 297 actionIdentifier: e.actionIdentifier, 298 }) 299 ··· 304 const payload = getNotificationPayload(e.notification) 305 306 if (payload) { 307 + logger.debug( 308 'User pressed a notification, opening notifications tab', 309 {}, 310 ) 311 + ax.metric('notifications:openApp', { 312 + reason: payload.reason, 313 + causedBoot: false, 314 + }) 315 316 invalidateCachedUnreadPage() 317 truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all')) ··· 324 truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions')) 325 } 326 327 + logger.debug('Notifications: handleNotification', { 328 content: e.notification.request.content, 329 payload: payload, 330 }) ··· 332 handleNotification(payload) 333 Notifications.dismissAllNotificationsAsync() 334 } else { 335 + logger.error('useNotificationsHandler: received no payload', { 336 identifier: e.notification.request.identifier, 337 }) 338 } ··· 352 responseReceivedListener.remove() 353 } 354 }, [ 355 + ax, 356 + logger, 357 queryClient, 358 currentAccount, 359 currentConvoId,
+2 -3
src/lib/hooks/useOTAUpdates.ts
··· 12 13 import {isNetworkError} from '#/lib/strings/errors' 14 import {logger} from '#/logger' 15 - import {IS_ANDROID, IS_IOS} from '#/env' 16 - import {IS_TESTFLIGHT} from '#/env' 17 18 const MINIMUM_MINIMIZE_TIME = 15 * 60e3 19 ··· 170 return 171 } 172 173 - // We use this setTimeout to allow Statsig to initialize before we check for an update 174 // For Testflight users, we can prompt the user to update immediately whenever there's an available update. This 175 // is suspect however with the Apple App Store guidelines, so we don't want to prompt production users to update 176 // immediately.
··· 12 13 import {isNetworkError} from '#/lib/strings/errors' 14 import {logger} from '#/logger' 15 + import {IS_ANDROID, IS_IOS, IS_TESTFLIGHT} from '#/env' 16 17 const MINIMUM_MINIMIZE_TIME = 15 * 60e3 18 ··· 169 return 170 } 171 172 + // We use this setTimeout to allow analytics to initialize before we check for an update 173 // For Testflight users, we can prompt the user to update immediately whenever there's an available update. This 174 // is suspect however with the Apple App Store guidelines, so we don't want to prompt production users to update 175 // immediately.
+4 -3
src/lib/hooks/useOpenLink.ts
··· 2 import {Linking} from 'react-native' 3 import * as WebBrowser from 'expo-web-browser' 4 5 - import {logEvent} from '#/lib/statsig/statsig' 6 import { 7 createBskyAppAbsoluteUrl, 8 createProxiedUrl, ··· 16 import {useTheme} from '#/alf' 17 import {useDialogContext} from '#/components/Dialog' 18 import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 19 import {IS_NATIVE} from '#/env' 20 21 export function useOpenLink() { 22 const enabled = useInAppBrowser() 23 const t = useTheme() 24 const dialogContext = useDialogContext() ··· 31 } 32 33 if (!isBskyAppUrl(url)) { 34 - logEvent('link:clicked', { 35 domain: toNiceDomain(url), 36 url, 37 }) ··· 72 } 73 Linking.openURL(url) 74 }, 75 - [enabled, inAppBrowserConsentControl, t, dialogContext], 76 ) 77 78 return openLink
··· 2 import {Linking} from 'react-native' 3 import * as WebBrowser from 'expo-web-browser' 4 5 import { 6 createBskyAppAbsoluteUrl, 7 createProxiedUrl, ··· 15 import {useTheme} from '#/alf' 16 import {useDialogContext} from '#/components/Dialog' 17 import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 18 + import {useAnalytics} from '#/analytics' 19 import {IS_NATIVE} from '#/env' 20 21 export function useOpenLink() { 22 + const ax = useAnalytics() 23 const enabled = useInAppBrowser() 24 const t = useTheme() 25 const dialogContext = useDialogContext() ··· 32 } 33 34 if (!isBskyAppUrl(url)) { 35 + ax.metric('link:clicked', { 36 domain: toNiceDomain(url), 37 url, 38 }) ··· 73 } 74 Linking.openURL(url) 75 }, 76 + [ax, enabled, inAppBrowserConsentControl, t, dialogContext], 77 ) 78 79 return openLink
+9 -13
src/lib/hooks/usePostViewTracking.ts
··· 1 import {useCallback, useRef} from 'react' 2 import {type AppBskyFeedDefs} from '@atproto/api' 3 4 - import {logger} from '#/logger' 5 - import {type MetricEvents} from '#/logger/metrics' 6 7 /** 8 * Hook that returns a callback to track post:view events. ··· 12 * @returns A callback that accepts a post and logs the view event 13 */ 14 export function usePostViewTracking( 15 - logContext: MetricEvents['post:view']['logContext'], 16 ) { 17 const seenUrisRef = useRef(new Set<string>()) 18 19 const trackPostView = useCallback( ··· 21 if (seenUrisRef.current.has(post.uri)) return 22 seenUrisRef.current.add(post.uri) 23 24 - logger.metric( 25 - 'post:view', 26 - { 27 - uri: post.uri, 28 - authorDid: post.author.did, 29 - logContext, 30 - }, 31 - {statsig: false}, 32 - ) 33 }, 34 - [logContext], 35 ) 36 37 return trackPostView
··· 1 import {useCallback, useRef} from 'react' 2 import {type AppBskyFeedDefs} from '@atproto/api' 3 4 + import {type Metrics, useAnalytics} from '#/analytics' 5 6 /** 7 * Hook that returns a callback to track post:view events. ··· 11 * @returns A callback that accepts a post and logs the view event 12 */ 13 export function usePostViewTracking( 14 + logContext: Metrics['post:view']['logContext'], 15 ) { 16 + const ax = useAnalytics() 17 const seenUrisRef = useRef(new Set<string>()) 18 19 const trackPostView = useCallback( ··· 21 if (seenUrisRef.current.has(post.uri)) return 22 seenUrisRef.current.add(post.uri) 23 24 + ax.metric('post:view', { 25 + uri: post.uri, 26 + authorDid: post.author.did, 27 + logContext, 28 + }) 29 }, 30 + [ax, logContext], 31 ) 32 33 return trackPostView
+5 -5
src/lib/notifications/notifications.ts
··· 2 import {Platform} from 'react-native' 3 import * as Notifications from 'expo-notifications' 4 import {getBadgeCountAsync, setBadgeCountAsync} from 'expo-notifications' 5 - import {type AtpAgent} from '@atproto/api' 6 - import {type AppBskyNotificationRegisterPush} from '@atproto/api' 7 import debounce from 'lodash.debounce' 8 9 import { ··· 16 import {type SessionAccount, useAgent, useSession} from '#/state/session' 17 import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler' 18 import {useAgeAssurance} from '#/ageAssurance' 19 - import {IS_NATIVE} from '#/env' 20 - import {IS_DEV} from '#/env' 21 22 /** 23 * @private ··· 227 } 228 229 export function useRequestNotificationsPermission() { 230 const {currentAccount} = useSession() 231 const getAndRegisterPushToken = useGetAndRegisterPushToken() 232 ··· 251 252 const res = await Notifications.requestPermissionsAsync() 253 254 - notyLogger.metric(`notifications:request`, { 255 context: context, 256 status: res.status, 257 })
··· 2 import {Platform} from 'react-native' 3 import * as Notifications from 'expo-notifications' 4 import {getBadgeCountAsync, setBadgeCountAsync} from 'expo-notifications' 5 + import {type AppBskyNotificationRegisterPush, type AtpAgent} from '@atproto/api' 6 import debounce from 'lodash.debounce' 7 8 import { ··· 15 import {type SessionAccount, useAgent, useSession} from '#/state/session' 16 import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler' 17 import {useAgeAssurance} from '#/ageAssurance' 18 + import {useAnalytics} from '#/analytics' 19 + import {IS_DEV, IS_NATIVE} from '#/env' 20 21 /** 22 * @private ··· 226 } 227 228 export function useRequestNotificationsPermission() { 229 + const ax = useAnalytics() 230 const {currentAccount} = useSession() 231 const getAndRegisterPushToken = useGetAndRegisterPushToken() 232 ··· 251 252 const res = await Notifications.requestPermissionsAsync() 253 254 + ax.metric(`notifications:request`, { 255 context: context, 256 status: res.status, 257 })
+27
src/lib/once.ts
···
··· 1 + import {useCallback, useRef} from 'react' 2 + 3 + type Cb = () => void 4 + 5 + export function callOnce() { 6 + let ran = false 7 + return function runCallbackOnce(cb: Cb) { 8 + if (ran) return 9 + ran = true 10 + cb() 11 + } 12 + } 13 + 14 + export function useCallOnce(cb: Cb): () => void 15 + export function useCallOnce(cb?: undefined): (cb: Cb) => void 16 + export function useCallOnce(cb?: Cb) { 17 + const ran = useRef(false) 18 + return useCallback( 19 + (icb: Cb) => { 20 + if (ran.current) return 21 + ran.current = true 22 + if (icb) icb() 23 + else if (cb) cb() 24 + }, 25 + [cb], 26 + ) 27 + }
-7
src/lib/statsig/gates.ts
··· 1 - export type Gate = 2 - // Keep this alphabetic please. 3 - | 'debug_show_feedcontext' 4 - | 'is_bsky_team_member' // special, do not remove 5 - | 'disable_onboarding_find_contacts' 6 - | 'disable_settings_find_contacts' 7 - | 'disable_live_now_beta'
···
-327
src/lib/statsig/statsig.tsx
··· 1 - import React from 'react' 2 - import {Platform} from 'react-native' 3 - import {AppState, type AppStateStatus} from 'react-native' 4 - import {Statsig, StatsigProvider} from 'statsig-react-native-expo' 5 - 6 - import {logger} from '#/logger' 7 - import {type MetricEvents} from '#/logger/metrics' 8 - import * as persisted from '#/state/persisted' 9 - import {IS_WEB} from '#/env' 10 - import * as env from '#/env' 11 - import {useSession} from '../../state/session' 12 - import {timeout} from '../async/timeout' 13 - import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback' 14 - import {type Gate} from './gates' 15 - 16 - const SDK_KEY = 'client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV' 17 - 18 - export const initPromise = initialize() 19 - 20 - type 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 - 38 - let refSrc = '' 39 - let refUrl = '' 40 - if (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 - 46 - export type {MetricEvents as LogEvents} 47 - 48 - function 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 - 67 - type 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 - 78 - let getCurrentRouteName: () => string | null | undefined = () => null 79 - 80 - export function attachRouteToLogEvents( 81 - getRouteName: () => string | null | undefined, 82 - ) { 83 - getCurrentRouteName = getRouteName 84 - } 85 - 86 - export 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 - */ 97 - export 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 - 130 - function 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. 149 - const GateCache = React.createContext<Map<string, boolean> | null>(null) 150 - GateCache.displayName = 'StatsigGateCacheContext' 151 - 152 - type GateOptions = { 153 - dangerouslyDisableExposureLogging?: boolean 154 - } 155 - 156 - export 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 - */ 186 - export 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 - 205 - function 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 - 223 - let lastState: AppStateStatus = AppState.currentState 224 - let lastActive = lastState === 'active' ? performance.now() : null 225 - AppState.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 - 245 - export 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 - 267 - export function initialize() { 268 - return Statsig.initialize(SDK_KEY, null, createStatsigOptions([])) 269 - } 270 - 271 - export 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 - }
···
+38 -16
src/logger/__tests__/logger.test.ts
··· 45 46 logger.addTransport(mockTransport) 47 48 - const extra = {foo: true} 49 logger.warn('message', extra) 50 51 expect(mockTransport).toHaveBeenCalledWith( ··· 71 LogLevel.Warn, 72 undefined, 73 'a', 74 - {}, 75 timestamp, 76 ) 77 ··· 81 LogLevel.Warn, 82 undefined, 83 'b', 84 - {}, 85 timestamp, 86 ) 87 ··· 91 LogLevel.Warn, 92 undefined, 93 'c', 94 - {}, 95 timestamp, 96 ) 97 ··· 256 LogLevel.Warn, 257 undefined, 258 'warn', 259 - {}, 260 timestamp, 261 ) 262 }) ··· 276 LogLevel.Info, 277 Logger.Context.Default, 278 message, 279 - {}, 280 timestamp, 281 ) 282 }) ··· 300 LogLevel.Debug, 301 'specific', 302 message, 303 - {}, 304 timestamp, 305 ) 306 }) ··· 323 LogLevel.Debug, 324 'namespace:foo', 325 message, 326 - {}, 327 timestamp, 328 ) 329 }) ··· 345 LogLevel.Debug, 346 'namespace:bar:baz', 347 message, 348 - {}, 349 timestamp, 350 ) 351 }) ··· 367 LogLevel.Debug, 368 undefined, 369 message, 370 - {}, 371 timestamp, 372 ) 373 ··· 376 LogLevel.Info, 377 undefined, 378 message, 379 - {}, 380 timestamp, 381 ) 382 ··· 385 LogLevel.Warn, 386 undefined, 387 message, 388 - {}, 389 timestamp, 390 ) 391 ··· 395 LogLevel.Error, 396 undefined, 397 e, 398 - {}, 399 timestamp, 400 ) 401 }) ··· 418 LogLevel.Info, 419 undefined, 420 message, 421 - {}, 422 timestamp, 423 ) 424 }) ··· 444 LogLevel.Warn, 445 undefined, 446 message, 447 - {}, 448 timestamp, 449 ) 450 }) ··· 474 LogLevel.Error, 475 undefined, 476 e, 477 - {}, 478 timestamp, 479 ) 480 })
··· 45 46 logger.addTransport(mockTransport) 47 48 + const extra = {foo: true, __metadata__: {}} 49 + logger.warn('message', extra) 50 + 51 + expect(mockTransport).toHaveBeenCalledWith( 52 + LogLevel.Warn, 53 + undefined, 54 + 'message', 55 + extra, 56 + timestamp, 57 + ) 58 + }) 59 + 60 + test('supports inherited metadata', () => { 61 + const timestamp = Date.now() 62 + const logger = new Logger({ 63 + metadata: {bar: true}, 64 + }) 65 + 66 + const mockTransport = jest.fn() 67 + 68 + logger.addTransport(mockTransport) 69 + 70 + const extra = {foo: true, __metadata__: {bar: true}} 71 logger.warn('message', extra) 72 73 expect(mockTransport).toHaveBeenCalledWith( ··· 93 LogLevel.Warn, 94 undefined, 95 'a', 96 + {__metadata__: {}}, 97 timestamp, 98 ) 99 ··· 103 LogLevel.Warn, 104 undefined, 105 'b', 106 + {__metadata__: {}}, 107 timestamp, 108 ) 109 ··· 113 LogLevel.Warn, 114 undefined, 115 'c', 116 + {__metadata__: {}}, 117 timestamp, 118 ) 119 ··· 278 LogLevel.Warn, 279 undefined, 280 'warn', 281 + {__metadata__: {}}, 282 timestamp, 283 ) 284 }) ··· 298 LogLevel.Info, 299 Logger.Context.Default, 300 message, 301 + {__metadata__: {}}, 302 timestamp, 303 ) 304 }) ··· 322 LogLevel.Debug, 323 'specific', 324 message, 325 + {__metadata__: {}}, 326 timestamp, 327 ) 328 }) ··· 345 LogLevel.Debug, 346 'namespace:foo', 347 message, 348 + {__metadata__: {}}, 349 timestamp, 350 ) 351 }) ··· 367 LogLevel.Debug, 368 'namespace:bar:baz', 369 message, 370 + {__metadata__: {}}, 371 timestamp, 372 ) 373 }) ··· 389 LogLevel.Debug, 390 undefined, 391 message, 392 + {__metadata__: {}}, 393 timestamp, 394 ) 395 ··· 398 LogLevel.Info, 399 undefined, 400 message, 401 + {__metadata__: {}}, 402 timestamp, 403 ) 404 ··· 407 LogLevel.Warn, 408 undefined, 409 message, 410 + {__metadata__: {}}, 411 timestamp, 412 ) 413 ··· 417 LogLevel.Error, 418 undefined, 419 e, 420 + {__metadata__: {}}, 421 timestamp, 422 ) 423 }) ··· 440 LogLevel.Info, 441 undefined, 442 message, 443 + {__metadata__: {}}, 444 timestamp, 445 ) 446 }) ··· 466 LogLevel.Warn, 467 undefined, 468 message, 469 + {__metadata__: {}}, 470 timestamp, 471 ) 472 }) ··· 496 LogLevel.Error, 497 undefined, 498 e, 499 + {__metadata__: {}}, 500 timestamp, 501 ) 502 })
+10 -25
src/logger/index.ts src/logger/index.tsx
··· 1 import {nanoid} from 'nanoid/non-secure' 2 3 - import {logEvent} from '#/lib/statsig/statsig' 4 import {add} from '#/logger/logDump' 5 - import {type MetricEvents} from '#/logger/metrics' 6 import {consoleTransport} from '#/logger/transports/console' 7 import {sentryTransport} from '#/logger/transports/sentry' 8 import { ··· 13 } from '#/logger/types' 14 import {enabledLogLevels} from '#/logger/util' 15 import {ENV} from '#/env' 16 - 17 - export {type MetricEvents as Metrics} from '#/logger/metrics' 18 19 const TRANSPORTS: Transport[] = (function configureTransports() { 20 switch (ENV) { ··· 37 level: LogLevel 38 context: LogContext | undefined = undefined 39 contextFilter: string = '' 40 41 protected debugContextRegexes: RegExp[] = [] 42 protected transports: Transport[] = [] 43 44 - static create(context?: LogContext) { 45 const logger = new Logger({ 46 level: process.env.EXPO_PUBLIC_LOG_LEVEL as LogLevel, 47 context, 48 contextFilter: process.env.EXPO_PUBLIC_LOG_DEBUG || '', 49 }) 50 for (const transport of TRANSPORTS) { 51 logger.addTransport(transport) ··· 57 level, 58 context, 59 contextFilter, 60 }: { 61 level?: LogLevel 62 context?: LogContext 63 contextFilter?: string 64 } = {}) { 65 this.context = context 66 this.level = level || LogLevel.Info 67 this.contextFilter = contextFilter || '' 68 if (this.contextFilter) { 69 this.level = LogLevel.Debug 70 } ··· 95 this.transport({level: LogLevel.Error, message: error, metadata}) 96 } 97 98 - metric<E extends keyof MetricEvents>( 99 - event: E & string, 100 - metadata: MetricEvents[E], 101 - options: { 102 - /** 103 - * Optionally also send to StatSig 104 - */ 105 - statsig?: boolean 106 - } = {statsig: true}, 107 - ) { 108 - logEvent(event, metadata, { 109 - lake: !options.statsig, 110 - }) 111 - 112 - for (const transport of this.transports) { 113 - transport(LogLevel.Info, LogContext.Metric, event, metadata, Date.now()) 114 - } 115 - } 116 - 117 addTransport(transport: Transport) { 118 this.transports.push(transport) 119 return () => { ··· 139 return 140 141 const timestamp = Date.now() 142 - const meta = metadata || {} 143 144 // send every log to syslog 145 add({
··· 1 import {nanoid} from 'nanoid/non-secure' 2 3 import {add} from '#/logger/logDump' 4 import {consoleTransport} from '#/logger/transports/console' 5 import {sentryTransport} from '#/logger/transports/sentry' 6 import { ··· 11 } from '#/logger/types' 12 import {enabledLogLevels} from '#/logger/util' 13 import {ENV} from '#/env' 14 15 const TRANSPORTS: Transport[] = (function configureTransports() { 16 switch (ENV) { ··· 33 level: LogLevel 34 context: LogContext | undefined = undefined 35 contextFilter: string = '' 36 + ambientMetadata: Record<string, unknown> = {} 37 38 protected debugContextRegexes: RegExp[] = [] 39 protected transports: Transport[] = [] 40 41 + static create(context?: LogContext, metadata: Record<string, unknown> = {}) { 42 const logger = new Logger({ 43 level: process.env.EXPO_PUBLIC_LOG_LEVEL as LogLevel, 44 context, 45 contextFilter: process.env.EXPO_PUBLIC_LOG_DEBUG || '', 46 + metadata, 47 }) 48 for (const transport of TRANSPORTS) { 49 logger.addTransport(transport) ··· 55 level, 56 context, 57 contextFilter, 58 + metadata: ambientMetadata = {}, 59 }: { 60 level?: LogLevel 61 context?: LogContext 62 contextFilter?: string 63 + metadata?: Record<string, unknown> 64 } = {}) { 65 this.context = context 66 this.level = level || LogLevel.Info 67 this.contextFilter = contextFilter || '' 68 + this.ambientMetadata = ambientMetadata 69 if (this.contextFilter) { 70 this.level = LogLevel.Debug 71 } ··· 96 this.transport({level: LogLevel.Error, message: error, metadata}) 97 } 98 99 addTransport(transport: Transport) { 100 this.transports.push(transport) 101 return () => { ··· 121 return 122 123 const timestamp = Date.now() 124 + const meta: Metadata = { 125 + __metadata__: this.ambientMetadata, 126 + ...metadata, 127 + } 128 129 // send every log to syslog 130 add({
+11 -3
src/logger/metrics.ts src/analytics/metrics/types.ts
··· 1 import {type NotificationReason} from '#/lib/hooks/useNotificationHandler' 2 import {type FeedDescriptor} from '#/state/queries/post-feed' 3 import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types' 4 5 - export type MetricEvents = { 6 // App events 7 init: { 8 initMs: number 9 } 10 'account:loggedIn': { 11 logContext: 12 | 'LoginForm' ··· 139 feedUrl: string 140 feedType: string 141 index: number 142 } 143 'feed:endReached': { 144 feedUrl: string ··· 374 | 'AvatarButton' 375 | 'StarterPackProfilesList' 376 | 'FeedInterstitial' 377 - | 'ProfileHeaderSuggestedFollows' 378 | 'PostOnboardingFindFollows' 379 | 'ImmersiveVideo' 380 | 'ExploreSuggestedAccounts' ··· 468 | 'AvatarButton' 469 | 'StarterPackProfilesList' 470 | 'FeedInterstitial' 471 - | 'ProfileHeaderSuggestedFollows' 472 | 'PostOnboardingFindFollows' 473 | 'ImmersiveVideo' 474 | 'ExploreSuggestedAccounts'
··· 1 + /* 2 + * Do not import runtime code into this file 3 + */ 4 + 5 import {type NotificationReason} from '#/lib/hooks/useNotificationHandler' 6 import {type FeedDescriptor} from '#/state/queries/post-feed' 7 import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types' 8 9 + export type Events = { 10 // App events 11 init: { 12 initMs: number 13 } 14 + 'experiment:viewed': { 15 + experimentId: string 16 + variationId: string 17 + } 18 + 19 'account:loggedIn': { 20 logContext: 21 | 'LoginForm' ··· 148 feedUrl: string 149 feedType: string 150 index: number 151 + reason?: string 152 } 153 'feed:endReached': { 154 feedUrl: string ··· 384 | 'AvatarButton' 385 | 'StarterPackProfilesList' 386 | 'FeedInterstitial' 387 | 'PostOnboardingFindFollows' 388 | 'ImmersiveVideo' 389 | 'ExploreSuggestedAccounts' ··· 477 | 'AvatarButton' 478 | 'StarterPackProfilesList' 479 | 'FeedInterstitial' 480 | 'PostOnboardingFindFollows' 481 | 'ImmersiveVideo' 482 | 'ExploreSuggestedAccounts'
+1 -1
src/logger/transports/console.ts
··· 36 if (IS_WEB) { 37 if (hasMetadata) { 38 console.groupCollapsed(msg) 39 - console.log(metadata) 40 console.groupEnd() 41 } else { 42 console.log(msg)
··· 36 if (IS_WEB) { 37 if (hasMetadata) { 38 console.groupCollapsed(msg) 39 + console.log(prepareMetadata(metadata)) 40 console.groupEnd() 41 } else { 42 console.log(msg)
+5
src/logger/types.ts
··· 50 __context__?: undefined 51 52 /** 53 * Applied as Sentry breadcrumb types. Defaults to `default`. 54 * 55 * @see https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/#breadcrumb-types
··· 50 __context__?: undefined 51 52 /** 53 + * Reserved for inherited metadata gathered in ambient context 54 + */ 55 + __metadata__?: Record<string, unknown> 56 + 57 + /** 58 * Applied as Sentry breadcrumb types. Defaults to `default`. 59 * 60 * @see https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/#breadcrumb-types
+8
src/logger/util.ts
··· 24 if (value instanceof Error) { 25 value = value.toString() 26 } 27 return {...acc, [key]: value} 28 }, {}) 29 }
··· 24 if (value instanceof Error) { 25 value = value.toString() 26 } 27 + if ( 28 + typeof value === 'object' && 29 + value !== null && 30 + Object.keys(value).length === 0 && 31 + value.constructor === Object 32 + ) { 33 + return acc 34 + } 35 return {...acc, [key]: value} 36 }, {}) 37 }
+26 -14
src/screens/Bookmarks/index.tsx
··· 20 type CommonNavigatorParams, 21 type NativeStackScreenProps, 22 } from '#/lib/routes/types' 23 - import {logger} from '#/logger' 24 import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation' 25 import {useBookmarksQuery} from '#/state/queries/bookmarks/useBookmarksQuery' 26 import {useSetMinimalShellMode} from '#/state/shell' ··· 37 import * as Skele from '#/components/Skeleton' 38 import * as toast from '#/components/Toast' 39 import {Text} from '#/components/Typography' 40 import {IS_IOS} from '#/env' 41 42 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Bookmarks'> 43 44 export function BookmarksScreen({}: Props) { 45 const setMinimalShellMode = useSetMinimalShellMode() 46 47 useFocusEffect( 48 useCallback(() => { 49 setMinimalShellMode(false) 50 - logger.metric('bookmarks:view', {}) 51 - }, [setMinimalShellMode]), 52 ) 53 54 return ( ··· 144 key: bookmark.item.uri, 145 bookmark: { 146 ...bookmark, 147 - item: bookmark.item as $Typed<AppBskyFeedDefs.NotFoundPost>, 148 }, 149 }) 150 } ··· 154 key: bookmark.item.uri, 155 bookmark: { 156 ...bookmark, 157 - item: bookmark.item as $Typed<AppBskyFeedDefs.PostView>, 158 }, 159 }) 160 } ··· 270 ) 271 } 272 273 function BookmarksEmpty() { 274 const t = useTheme() 275 const {_} = useLingui() ··· 301 return <BookmarksEmpty /> 302 } 303 case 'bookmark': { 304 - return ( 305 - <Post 306 - post={item.bookmark.item} 307 - hideTopBorder={index === 0} 308 - onBeforePress={() => { 309 - logger.metric('bookmarks:post-clicked', {}) 310 - }} 311 - /> 312 - ) 313 } 314 case 'bookmarkNotFound': { 315 return (
··· 20 type CommonNavigatorParams, 21 type NativeStackScreenProps, 22 } from '#/lib/routes/types' 23 import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation' 24 import {useBookmarksQuery} from '#/state/queries/bookmarks/useBookmarksQuery' 25 import {useSetMinimalShellMode} from '#/state/shell' ··· 36 import * as Skele from '#/components/Skeleton' 37 import * as toast from '#/components/Toast' 38 import {Text} from '#/components/Typography' 39 + import {useAnalytics} from '#/analytics' 40 import {IS_IOS} from '#/env' 41 42 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Bookmarks'> 43 44 export function BookmarksScreen({}: Props) { 45 const setMinimalShellMode = useSetMinimalShellMode() 46 + const ax = useAnalytics() 47 48 useFocusEffect( 49 useCallback(() => { 50 setMinimalShellMode(false) 51 + ax.metric('bookmarks:view', {}) 52 + }, [setMinimalShellMode, ax]), 53 ) 54 55 return ( ··· 145 key: bookmark.item.uri, 146 bookmark: { 147 ...bookmark, 148 + item: bookmark.item, 149 }, 150 }) 151 } ··· 155 key: bookmark.item.uri, 156 bookmark: { 157 ...bookmark, 158 + item: bookmark.item, 159 }, 160 }) 161 } ··· 271 ) 272 } 273 274 + function BookmarkItem({ 275 + item, 276 + hideTopBorder, 277 + }: { 278 + item: Extract<ListItem, {type: 'bookmark'}> 279 + hideTopBorder: boolean 280 + }) { 281 + const ax = useAnalytics() 282 + return ( 283 + <Post 284 + post={item.bookmark.item} 285 + hideTopBorder={hideTopBorder} 286 + onBeforePress={() => { 287 + ax.metric('bookmarks:post-clicked', {}) 288 + }} 289 + /> 290 + ) 291 + } 292 + 293 function BookmarksEmpty() { 294 const t = useTheme() 295 const {_} = useLingui() ··· 321 return <BookmarksEmpty /> 322 } 323 case 'bookmark': { 324 + return <BookmarkItem item={item} hideTopBorder={index === 0} /> 325 } 326 case 'bookmarkNotFound': { 327 return (
+3 -2
src/screens/Login/ChooseAccountForm.tsx
··· 3 import {msg, Trans} from '@lingui/macro' 4 import {useLingui} from '@lingui/react' 5 6 - import {logEvent} from '#/lib/statsig/statsig' 7 import {logger} from '#/logger' 8 import {type SessionAccount, useSession, useSessionApi} from '#/state/session' 9 import {useLoggedOutViewControls} from '#/state/shell/logged-out' ··· 12 import {AccountList} from '#/components/AccountList' 13 import {Button, ButtonText} from '#/components/Button' 14 import * as TextField from '#/components/forms/TextField' 15 import {FormContainer} from './FormContainer' 16 17 export const ChooseAccountForm = ({ ··· 23 }) => { 24 const [pendingDid, setPendingDid] = React.useState<string | null>(null) 25 const {_} = useLingui() 26 const {currentAccount} = useSession() 27 const {resumeSession} = useSessionApi() 28 const {setShowLoggedOut} = useLoggedOutViewControls() ··· 46 try { 47 setPendingDid(account.did) 48 await resumeSession(account, true) 49 - logEvent('account:loggedIn', { 50 logContext: 'ChooseAccountForm', 51 withPassword: false, 52 })
··· 3 import {msg, Trans} from '@lingui/macro' 4 import {useLingui} from '@lingui/react' 5 6 import {logger} from '#/logger' 7 import {type SessionAccount, useSession, useSessionApi} from '#/state/session' 8 import {useLoggedOutViewControls} from '#/state/shell/logged-out' ··· 11 import {AccountList} from '#/components/AccountList' 12 import {Button, ButtonText} from '#/components/Button' 13 import * as TextField from '#/components/forms/TextField' 14 + import {useAnalytics} from '#/analytics' 15 import {FormContainer} from './FormContainer' 16 17 export const ChooseAccountForm = ({ ··· 23 }) => { 24 const [pendingDid, setPendingDid] = React.useState<string | null>(null) 25 const {_} = useLingui() 26 + const ax = useAnalytics() 27 const {currentAccount} = useSession() 28 const {resumeSession} = useSessionApi() 29 const {setShowLoggedOut} = useLoggedOutViewControls() ··· 47 try { 48 setPendingDid(account.did) 49 await resumeSession(account, true) 50 + ax.metric('account:loggedIn', { 51 logContext: 'ChooseAccountForm', 52 withPassword: false, 53 })
+6 -6
src/screens/Login/SetNewPasswordForm.tsx
··· 3 import {msg, Trans} from '@lingui/macro' 4 import {useLingui} from '@lingui/react' 5 6 - import {logEvent} from '#/lib/statsig/statsig' 7 - import {isNetworkError} from '#/lib/strings/errors' 8 - import {cleanError} from '#/lib/strings/errors' 9 import {checkAndFormatResetCode} from '#/lib/strings/password' 10 import {logger} from '#/logger' 11 import {Agent} from '#/state/session/agent' ··· 16 import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' 17 import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' 18 import {Text} from '#/components/Typography' 19 import {FormContainer} from './FormContainer' 20 21 export const SetNewPasswordForm = ({ ··· 33 }) => { 34 const {_} = useLingui() 35 const t = useTheme() 36 37 const [isProcessing, setIsProcessing] = useState<boolean>(false) 38 const [resetCode, setResetCode] = useState<string>('') ··· 49 msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, 50 ), 51 ) 52 - logEvent('signin:passwordResetFailure', {}) 53 return 54 } 55 ··· 69 password, 70 }) 71 onPasswordSet() 72 - logEvent('signin:passwordResetSuccess', {}) 73 } catch (e: any) { 74 const errMsg = e.toString() 75 logger.warn('Failed to set new password', {error: e}) 76 - logEvent('signin:passwordResetFailure', {}) 77 setIsProcessing(false) 78 if (isNetworkError(e)) { 79 setError(
··· 3 import {msg, Trans} from '@lingui/macro' 4 import {useLingui} from '@lingui/react' 5 6 + import {cleanError, isNetworkError} from '#/lib/strings/errors' 7 import {checkAndFormatResetCode} from '#/lib/strings/password' 8 import {logger} from '#/logger' 9 import {Agent} from '#/state/session/agent' ··· 14 import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' 15 import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' 16 import {Text} from '#/components/Typography' 17 + import {useAnalytics} from '#/analytics' 18 import {FormContainer} from './FormContainer' 19 20 export const SetNewPasswordForm = ({ ··· 32 }) => { 33 const {_} = useLingui() 34 const t = useTheme() 35 + const ax = useAnalytics() 36 37 const [isProcessing, setIsProcessing] = useState<boolean>(false) 38 const [resetCode, setResetCode] = useState<string>('') ··· 49 msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, 50 ), 51 ) 52 + ax.metric('signin:passwordResetFailure', {}) 53 return 54 } 55 ··· 69 password, 70 }) 71 onPasswordSet() 72 + ax.metric('signin:passwordResetSuccess', {}) 73 } catch (e: any) { 74 const errMsg = e.toString() 75 logger.warn('Failed to set new password', {error: e}) 76 + ax.metric('signin:passwordResetFailure', {}) 77 setIsProcessing(false) 78 if (isNetworkError(e)) { 79 setError(
+6 -5
src/screens/Login/index.tsx
··· 5 import {useLingui} from '@lingui/react' 6 7 import {DEFAULT_SERVICE} from '#/lib/constants' 8 - import {logEvent} from '#/lib/statsig/statsig' 9 import {logger} from '#/logger' 10 import {useServiceQuery} from '#/state/queries/service' 11 import {type SessionAccount, useSession} from '#/state/session' ··· 17 import {SetNewPasswordForm} from '#/screens/Login/SetNewPasswordForm' 18 import {atoms as a, native} from '#/alf' 19 import {ScreenTransition} from '#/components/ScreenTransition' 20 import {ChooseAccountForm} from './ChooseAccountForm' 21 22 enum Forms { ··· 64 'Forward' | 'Backward' 65 >('Forward') 66 67 const { 68 data: serviceDescription, 69 error: serviceError, ··· 96 logger.warn(`Failed to fetch service description for ${serviceUrl}`, { 97 error: String(serviceError), 98 }) 99 - logEvent('signin:hostingProviderFailedResolution', {}) 100 } else { 101 setError('') 102 } ··· 104 105 const onPressForgotPassword = () => { 106 gotoForm(Forms.ForgotPassword) 107 - logEvent('signin:forgotPasswordPressed', {}) 108 } 109 110 const handlePressBack = () => { 111 onPressBack() 112 setScreenTransitionDirection('Backward') 113 - logEvent('signin:backPressed', { 114 failedAttemptsCount: failedAttemptCountRef.current, 115 }) 116 } 117 118 const onAttemptSuccess = () => { 119 - logEvent('signin:success', { 120 isUsingCustomProvider: serviceUrl !== DEFAULT_SERVICE, 121 timeTakenSeconds: Math.round((Date.now() - startTimeRef.current) / 1000), 122 failedAttemptsCount: failedAttemptCountRef.current,
··· 5 import {useLingui} from '@lingui/react' 6 7 import {DEFAULT_SERVICE} from '#/lib/constants' 8 import {logger} from '#/logger' 9 import {useServiceQuery} from '#/state/queries/service' 10 import {type SessionAccount, useSession} from '#/state/session' ··· 16 import {SetNewPasswordForm} from '#/screens/Login/SetNewPasswordForm' 17 import {atoms as a, native} from '#/alf' 18 import {ScreenTransition} from '#/components/ScreenTransition' 19 + import {useAnalytics} from '#/analytics' 20 import {ChooseAccountForm} from './ChooseAccountForm' 21 22 enum Forms { ··· 64 'Forward' | 'Backward' 65 >('Forward') 66 67 + const ax = useAnalytics() 68 const { 69 data: serviceDescription, 70 error: serviceError, ··· 97 logger.warn(`Failed to fetch service description for ${serviceUrl}`, { 98 error: String(serviceError), 99 }) 100 + ax.metric('signin:hostingProviderFailedResolution', {}) 101 } else { 102 setError('') 103 } ··· 105 106 const onPressForgotPassword = () => { 107 gotoForm(Forms.ForgotPassword) 108 + ax.metric('signin:forgotPasswordPressed', {}) 109 } 110 111 const handlePressBack = () => { 112 onPressBack() 113 setScreenTransitionDirection('Backward') 114 + ax.metric('signin:backPressed', { 115 failedAttemptsCount: failedAttemptCountRef.current, 116 }) 117 } 118 119 const onAttemptSuccess = () => { 120 + ax.metric('signin:success', { 121 isUsingCustomProvider: serviceUrl !== DEFAULT_SERVICE, 122 timeTakenSeconds: Math.round((Date.now() - startTimeRef.current) / 1000), 123 failedAttemptsCount: failedAttemptCountRef.current,
+1 -1
src/screens/Messages/ChatList.tsx
··· 7 import {useFocusEffect, useIsFocused} from '@react-navigation/native' 8 import {type NativeStackScreenProps} from '@react-navigation/native-stack' 9 10 - import {useAppState} from '#/lib/hooks/useAppState' 11 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 12 import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 13 import {type MessagesTabNavigatorParams} from '#/lib/routes/types'
··· 7 import {useFocusEffect, useIsFocused} from '@react-navigation/native' 8 import {type NativeStackScreenProps} from '@react-navigation/native-stack' 9 10 + import {useAppState} from '#/lib/appState' 11 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 12 import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 13 import {type MessagesTabNavigatorParams} from '#/lib/routes/types'
+1 -1
src/screens/Messages/Inbox.tsx
··· 12 type UseInfiniteQueryResult, 13 } from '@tanstack/react-query' 14 15 - import {useAppState} from '#/lib/hooks/useAppState' 16 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 17 import { 18 type CommonNavigatorParams,
··· 12 type UseInfiniteQueryResult, 13 } from '@tanstack/react-query' 14 15 + import {useAppState} from '#/lib/appState' 16 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 17 import { 18 type CommonNavigatorParams,
+4 -3
src/screens/Messages/components/ChatListItem.tsx
··· 13 import {GestureActionView} from '#/lib/custom-animations/GestureActionView' 14 import {useHaptics} from '#/lib/haptics' 15 import {decrementBadgeCount} from '#/lib/notifications/notifications' 16 - import {logEvent} from '#/lib/statsig/statsig' 17 import {sanitizeDisplayName} from '#/lib/strings/display-names' 18 import { 19 postUriToRelativePath, ··· 45 import {Text} from '#/components/Typography' 46 import {useSimpleVerificationState} from '#/components/verification' 47 import {VerificationCheck} from '#/components/verification/VerificationCheck' 48 import {IS_NATIVE} from '#/env' 49 import type * as bsky from '#/types/bsky' 50 ··· 96 showMenu?: boolean 97 children?: React.ReactNode 98 }) { 99 const t = useTheme() 100 const {_} = useLingui() 101 const {currentAccount} = useSession() ··· 290 menuControl.open() 291 return false 292 } else { 293 - logEvent('chat:open', {logContext: 'ChatsList'}) 294 } 295 }, 296 - [isDeletedAccount, menuControl, queryClient, profile, convo], 297 ) 298 299 const onLongPress = useCallback(() => {
··· 13 import {GestureActionView} from '#/lib/custom-animations/GestureActionView' 14 import {useHaptics} from '#/lib/haptics' 15 import {decrementBadgeCount} from '#/lib/notifications/notifications' 16 import {sanitizeDisplayName} from '#/lib/strings/display-names' 17 import { 18 postUriToRelativePath, ··· 44 import {Text} from '#/components/Typography' 45 import {useSimpleVerificationState} from '#/components/verification' 46 import {VerificationCheck} from '#/components/verification/VerificationCheck' 47 + import {useAnalytics} from '#/analytics' 48 import {IS_NATIVE} from '#/env' 49 import type * as bsky from '#/types/bsky' 50 ··· 96 showMenu?: boolean 97 children?: React.ReactNode 98 }) { 99 + const ax = useAnalytics() 100 const t = useTheme() 101 const {_} = useLingui() 102 const {currentAccount} = useSession() ··· 291 menuControl.open() 292 return false 293 } else { 294 + ax.metric('chat:open', {logContext: 'ChatsList'}) 295 } 296 }, 297 + [ax, isDeletedAccount, menuControl, queryClient, profile, convo], 298 ) 299 300 const onLongPress = useCallback(() => {
+5 -8
src/screens/Moderation/VerificationSettings.tsx
··· 3 import {useLingui} from '@lingui/react' 4 5 import {urls} from '#/lib/constants' 6 - import {logger} from '#/logger' 7 import { 8 usePreferencesQuery, 9 type UsePreferencesQueryResponse, ··· 17 import * as Layout from '#/components/Layout' 18 import {InlineLinkText} from '#/components/Link' 19 import {Loader} from '#/components/Loader' 20 21 export function Screen() { 22 const {_} = useLingui() 23 const gutters = useGutters(['base']) 24 const {data: preferences} = usePreferencesQuery() 25 ··· 51 }), 52 )} 53 onPress={() => { 54 - logger.metric( 55 - 'verification:learn-more', 56 - { 57 - location: 'verificationSettings', 58 - }, 59 - {statsig: true}, 60 - ) 61 }}> 62 Learn more here. 63 </InlineLinkText>
··· 3 import {useLingui} from '@lingui/react' 4 5 import {urls} from '#/lib/constants' 6 import { 7 usePreferencesQuery, 8 type UsePreferencesQueryResponse, ··· 16 import * as Layout from '#/components/Layout' 17 import {InlineLinkText} from '#/components/Link' 18 import {Loader} from '#/components/Loader' 19 + import {useAnalytics} from '#/analytics' 20 21 export function Screen() { 22 const {_} = useLingui() 23 + const ax = useAnalytics() 24 const gutters = useGutters(['base']) 25 const {data: preferences} = usePreferencesQuery() 26 ··· 52 }), 53 )} 54 onPress={() => { 55 + ax.metric('verification:learn-more', { 56 + location: 'verificationSettings', 57 + }) 58 }}> 59 Learn more here. 60 </InlineLinkText>
+9 -3
src/screens/Onboarding/StepFindContacts/index.tsx
··· 2 import {LayoutAnimationConfig} from 'react-native-reanimated' 3 import {SafeAreaView} from 'react-native-safe-area-context' 4 5 - import {logger} from '#/logger' 6 import {FindContactsFlow} from '#/components/contacts/FindContactsFlow' 7 import {type Action, type State} from '#/components/contacts/state' 8 import {ScreenTransition} from '#/components/ScreenTransition' 9 import {useOnboardingInternalState} from '../state' 10 11 export function StepFindContacts({ ··· 16 flowDispatch: React.ActionDispatch<[Action]> 17 }) { 18 const {dispatch} = useOnboardingInternalState() 19 20 const [transitionDirection, setTransitionDirection] = useState< 21 'Forward' | 'Backward' ··· 24 const isFinalStep = flowState.step === '4: view matches' 25 const onSkip = useCallback(() => { 26 if (!isFinalStep) { 27 - logger.metric('onboarding:contacts:skipPressed', {}) 28 } 29 dispatch({type: 'next'}) 30 - }, [dispatch, isFinalStep]) 31 32 const canGoBack = flowState.step === '2: verify number' 33 const onBack = useCallback(() => {
··· 2 import {LayoutAnimationConfig} from 'react-native-reanimated' 3 import {SafeAreaView} from 'react-native-safe-area-context' 4 5 + import {useCallOnce} from '#/lib/once' 6 import {FindContactsFlow} from '#/components/contacts/FindContactsFlow' 7 import {type Action, type State} from '#/components/contacts/state' 8 import {ScreenTransition} from '#/components/ScreenTransition' 9 + import {useAnalytics} from '#/analytics' 10 import {useOnboardingInternalState} from '../state' 11 12 export function StepFindContacts({ ··· 17 flowDispatch: React.ActionDispatch<[Action]> 18 }) { 19 const {dispatch} = useOnboardingInternalState() 20 + const ax = useAnalytics() 21 + 22 + useCallOnce(() => { 23 + ax.metric('onboarding:contacts:begin', {}) 24 + })() 25 26 const [transitionDirection, setTransitionDirection] = useState< 27 'Forward' | 'Backward' ··· 30 const isFinalStep = flowState.step === '4: view matches' 31 const onSkip = useCallback(() => { 32 if (!isFinalStep) { 33 + ax.metric('onboarding:contacts:skipPressed', {}) 34 } 35 dispatch({type: 'next'}) 36 + }, [dispatch, isFinalStep, ax]) 37 38 const canGoBack = flowState.step === '2: verify number' 39 const onBack = useCallback(() => {
+7
src/screens/Onboarding/StepFindContactsIntro/index.tsx
··· 5 import {useQuery} from '@tanstack/react-query' 6 7 import {urls} from '#/lib/constants' 8 import {atoms as a} from '#/alf' 9 import {Admonition} from '#/components/Admonition' 10 import {Button, ButtonText} from '#/components/Button' 11 import {ContactsHeroImage} from '#/components/contacts/components/HeroImage' 12 import {InlineLinkText} from '#/components/Link' 13 import { 14 OnboardingControls, 15 OnboardingDescriptionText, ··· 19 import {useOnboardingInternalState} from '../state' 20 21 export function StepFindContactsIntro() { 22 const {_} = useLingui() 23 const {dispatch} = useOnboardingInternalState() 24 25 const {data: isAvailable, isSuccess} = useQuery({ 26 queryKey: ['contacts-available'],
··· 5 import {useQuery} from '@tanstack/react-query' 6 7 import {urls} from '#/lib/constants' 8 + import {useCallOnce} from '#/lib/once' 9 import {atoms as a} from '#/alf' 10 import {Admonition} from '#/components/Admonition' 11 import {Button, ButtonText} from '#/components/Button' 12 import {ContactsHeroImage} from '#/components/contacts/components/HeroImage' 13 import {InlineLinkText} from '#/components/Link' 14 + import {useAnalytics} from '#/analytics' 15 import { 16 OnboardingControls, 17 OnboardingDescriptionText, ··· 21 import {useOnboardingInternalState} from '../state' 22 23 export function StepFindContactsIntro() { 24 + const ax = useAnalytics() 25 const {_} = useLingui() 26 const {dispatch} = useOnboardingInternalState() 27 + 28 + useCallOnce(() => { 29 + ax.metric('onboarding:contacts:presented', {}) 30 + })() 31 32 const {data: isAvailable, isSuccess} = useQuery({ 33 queryKey: ['contacts-available'],
+10 -7
src/screens/Onboarding/StepFinished/index.tsx
··· 20 VIDEO_SAVED_FEED, 21 } from '#/lib/constants' 22 import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' 23 - import {logEvent} from '#/lib/statsig/statsig' 24 import {logger} from '#/logger' 25 import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' 26 import {getAllListMembers} from '#/state/queries/list-members' ··· 46 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 47 import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow' 48 import {Loader} from '#/components/Loader' 49 import {IS_WEB} from '#/env' 50 import * as bsky from '#/types/bsky' 51 import {ValuePropositionPager} from './ValuePropositionPager' 52 53 export function StepFinished() { 54 const {state, dispatch} = useOnboardingInternalState() 55 const onboardDispatch = useOnboardingDispatch() 56 const [saving, setSaving] = useState(false) 57 const queryClient = useQueryClient() ··· 165 return next 166 }) 167 168 - logEvent('onboarding:finished:avatarResult', { 169 avatarResult: profileStepResults.isCreatedAvatar 170 ? 'created' 171 : profileStepResults.image ··· 200 startProgressGuide('follow-10') 201 dispatch({type: 'finish'}) 202 onboardDispatch({type: 'finish'}) 203 - logEvent('onboarding:finished:nextPressed', { 204 usedStarterPack: Boolean(starterPack), 205 starterPackName: 206 starterPack && ··· 216 feedsPinned: starterPack?.feeds?.length ?? 0, 217 }) 218 if (starterPack && listItems?.length) { 219 - logEvent('starterPack:followAll', { 220 logContext: 'Onboarding', 221 starterPack: starterPack.uri, 222 count: listItems?.length, 223 }) 224 } 225 }, [ 226 queryClient, 227 agent, 228 dispatch, ··· 255 }) { 256 const [subStep, setSubStep] = useState<0 | 1 | 2>(0) 257 const {_} = useLingui() 258 const {gtMobile} = useBreakpoints() 259 260 const onPress = () => { ··· 262 finishOnboarding() // has its own metrics 263 } else if (subStep === 1) { 264 setSubStep(2) 265 - logger.metric('onboarding:valueProp:stepTwo:nextPressed', {}) 266 } else if (subStep === 0) { 267 setSubStep(1) 268 - logger.metric('onboarding:valueProp:stepOne:nextPressed', {}) 269 } 270 } 271 ··· 280 size="small" 281 label={_(msg`Skip introduction and start using your account`)} 282 onPress={() => { 283 - logger.metric('onboarding:valueProp:skipPressed', {}) 284 finishOnboarding() 285 }} 286 style={[a.bg_transparent]}>
··· 20 VIDEO_SAVED_FEED, 21 } from '#/lib/constants' 22 import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' 23 import {logger} from '#/logger' 24 import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' 25 import {getAllListMembers} from '#/state/queries/list-members' ··· 45 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 46 import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow' 47 import {Loader} from '#/components/Loader' 48 + import {useAnalytics} from '#/analytics' 49 import {IS_WEB} from '#/env' 50 import * as bsky from '#/types/bsky' 51 import {ValuePropositionPager} from './ValuePropositionPager' 52 53 export function StepFinished() { 54 const {state, dispatch} = useOnboardingInternalState() 55 + const ax = useAnalytics() 56 const onboardDispatch = useOnboardingDispatch() 57 const [saving, setSaving] = useState(false) 58 const queryClient = useQueryClient() ··· 166 return next 167 }) 168 169 + ax.metric('onboarding:finished:avatarResult', { 170 avatarResult: profileStepResults.isCreatedAvatar 171 ? 'created' 172 : profileStepResults.image ··· 201 startProgressGuide('follow-10') 202 dispatch({type: 'finish'}) 203 onboardDispatch({type: 'finish'}) 204 + ax.metric('onboarding:finished:nextPressed', { 205 usedStarterPack: Boolean(starterPack), 206 starterPackName: 207 starterPack && ··· 217 feedsPinned: starterPack?.feeds?.length ?? 0, 218 }) 219 if (starterPack && listItems?.length) { 220 + ax.metric('starterPack:followAll', { 221 logContext: 'Onboarding', 222 starterPack: starterPack.uri, 223 count: listItems?.length, 224 }) 225 } 226 }, [ 227 + ax, 228 queryClient, 229 agent, 230 dispatch, ··· 257 }) { 258 const [subStep, setSubStep] = useState<0 | 1 | 2>(0) 259 const {_} = useLingui() 260 + const ax = useAnalytics() 261 const {gtMobile} = useBreakpoints() 262 263 const onPress = () => { ··· 265 finishOnboarding() // has its own metrics 266 } else if (subStep === 1) { 267 setSubStep(2) 268 + ax.metric('onboarding:valueProp:stepTwo:nextPressed', {}) 269 } else if (subStep === 0) { 270 setSubStep(1) 271 + ax.metric('onboarding:valueProp:stepOne:nextPressed', {}) 272 } 273 } 274 ··· 283 size="small" 284 label={_(msg`Skip introduction and start using your account`)} 285 onPress={() => { 286 + ax.metric('onboarding:valueProp:skipPressed', {}) 287 finishOnboarding() 288 }} 289 style={[a.bg_transparent]}>
+4 -3
src/screens/Onboarding/StepInterests/index.tsx
··· 4 import {useLingui} from '@lingui/react' 5 6 import {interests, useInterestsDisplayNames} from '#/lib/interests' 7 - import {logEvent} from '#/lib/statsig/statsig' 8 import {capitalize} from '#/lib/strings/capitalize' 9 import {logger} from '#/logger' 10 import { ··· 19 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 20 import * as Toggle from '#/components/forms/Toggle' 21 import {Loader} from '#/components/Loader' 22 23 export function StepInterests() { 24 const {_} = useLingui() 25 const interestsDisplayNames = useInterestsDisplayNames() 26 27 const {state, dispatch} = useOnboardingInternalState() ··· 40 selectedInterests, 41 }) 42 dispatch({type: 'next'}) 43 - logEvent('onboarding:interests:nextPressed', { 44 selectedInterests, 45 selectedInterestsLength: selectedInterests.length, 46 }) ··· 48 logger.info(`onboading: error saving interests`) 49 logger.error(e) 50 } 51 - }, [selectedInterests, setSaving, dispatch]) 52 53 return ( 54 <View style={[a.align_start, a.gap_sm]} testID="onboardingInterests">
··· 4 import {useLingui} from '@lingui/react' 5 6 import {interests, useInterestsDisplayNames} from '#/lib/interests' 7 import {capitalize} from '#/lib/strings/capitalize' 8 import {logger} from '#/logger' 9 import { ··· 18 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 19 import * as Toggle from '#/components/forms/Toggle' 20 import {Loader} from '#/components/Loader' 21 + import {useAnalytics} from '#/analytics' 22 23 export function StepInterests() { 24 const {_} = useLingui() 25 + const ax = useAnalytics() 26 const interestsDisplayNames = useInterestsDisplayNames() 27 28 const {state, dispatch} = useOnboardingInternalState() ··· 41 selectedInterests, 42 }) 43 dispatch({type: 'next'}) 44 + ax.metric('onboarding:interests:nextPressed', { 45 selectedInterests, 46 selectedInterestsLength: selectedInterests.length, 47 }) ··· 49 logger.info(`onboading: error saving interests`) 50 logger.error(e) 51 } 52 + }, [ax, selectedInterests, setSaving, dispatch]) 53 54 return ( 55 <View style={[a.align_start, a.gap_sm]} testID="onboardingInterests">
+5 -5
src/screens/Onboarding/StepProfile/index.tsx
··· 14 import {openCropper} from '#/lib/media/picker' 15 import {getDataUriSize} from '#/lib/media/util' 16 import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' 17 - import {logEvent, useGate} from '#/lib/statsig/statsig' 18 import {isCancelledError} from '#/lib/strings/errors' 19 import {logger} from '#/logger' 20 import { ··· 37 import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' 38 import {CircleInfo_Stroke2_Corner0_Rounded} from '#/components/icons/CircleInfo' 39 import {Text} from '#/components/Typography' 40 import {IS_NATIVE, IS_WEB} from '#/env' 41 import {type AvatarColor, avatarColors, type Emoji, emojiItems} from './types' 42 ··· 66 avatarColors[Math.floor(Math.random() * avatarColors.length)] 67 68 export function StepProfile() { 69 const {_} = useLingui() 70 const t = useTheme() 71 const {gtMobile} = useBreakpoints() 72 const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() 73 - const gate = useGate() 74 const requestNotificationsPermission = useRequestNotificationsPermission() 75 76 const creatorControl = Dialog.useDialogControl() ··· 89 90 React.useEffect(() => { 91 requestNotificationsPermission('StartOnboarding') 92 - }, [gate, requestNotificationsPermission]) 93 94 const sheetWrapper = useSheetWrapper() 95 const openPicker = React.useCallback( ··· 156 } 157 158 dispatch({type: 'next'}) 159 - logEvent('onboarding:profile:nextPressed', {}) 160 - }, [avatar, dispatch]) 161 162 const onDoneCreating = React.useCallback(() => { 163 setAvatar(prev => ({
··· 14 import {openCropper} from '#/lib/media/picker' 15 import {getDataUriSize} from '#/lib/media/util' 16 import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' 17 import {isCancelledError} from '#/lib/strings/errors' 18 import {logger} from '#/logger' 19 import { ··· 36 import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' 37 import {CircleInfo_Stroke2_Corner0_Rounded} from '#/components/icons/CircleInfo' 38 import {Text} from '#/components/Typography' 39 + import {useAnalytics} from '#/analytics' 40 import {IS_NATIVE, IS_WEB} from '#/env' 41 import {type AvatarColor, avatarColors, type Emoji, emojiItems} from './types' 42 ··· 66 avatarColors[Math.floor(Math.random() * avatarColors.length)] 67 68 export function StepProfile() { 69 + const ax = useAnalytics() 70 const {_} = useLingui() 71 const t = useTheme() 72 const {gtMobile} = useBreakpoints() 73 const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() 74 const requestNotificationsPermission = useRequestNotificationsPermission() 75 76 const creatorControl = Dialog.useDialogControl() ··· 89 90 React.useEffect(() => { 91 requestNotificationsPermission('StartOnboarding') 92 + }, [requestNotificationsPermission]) 93 94 const sheetWrapper = useSheetWrapper() 95 const openPicker = React.useCallback( ··· 156 } 157 158 dispatch({type: 'next'}) 159 + ax.metric('onboarding:profile:nextPressed', {}) 160 + }, [ax, avatar, dispatch]) 161 162 const onDoneCreating = React.useCallback(() => { 163 setAvatar(prev => ({
+22 -31
src/screens/Onboarding/StepSuggestedAccounts/index.tsx
··· 9 import {wait} from '#/lib/async/wait' 10 import {popularInterests, useInterestsDisplayNames} from '#/lib/interests' 11 import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted' 12 - import {logger} from '#/logger' 13 import {updateProfileShadow} from '#/state/cache/profile-shadow' 14 import {useLanguagePrefs} from '#/state/preferences' 15 import {useModerationOpts} from '#/state/preferences/moderation-opts' ··· 30 import {Loader} from '#/components/Loader' 31 import * as ProfileCard from '#/components/ProfileCard' 32 import * as toast from '#/components/Toast' 33 import {IS_WEB} from '#/env' 34 import type * as bsky from '#/types/bsky' 35 import {bulkWriteFollows} from '../util' 36 37 export function StepSuggestedAccounts() { 38 const {_} = useLingui() 39 const t = useTheme() 40 const {gtMobile} = useBreakpoints() 41 const moderationOpts = useModerationOpts() ··· 92 93 const {mutate: followAll, isPending: isFollowingAll} = useMutation({ 94 onMutate: () => { 95 - logger.metric('onboarding:suggestedAccounts:followAllPressed', { 96 tab: selectedInterest ?? 'all', 97 numAccounts: followableDids.length, 98 }) ··· 132 (did: string, position: number) => { 133 if (!seenProfilesRef.current.has(did)) { 134 seenProfilesRef.current.add(did) 135 - logger.metric( 136 - 'suggestedUser:seen', 137 - { 138 - logContext: 'Onboarding', 139 - recId: undefined, 140 - position, 141 - suggestedDid: did, 142 - category: selectedInterest, 143 - }, 144 - {statsig: true}, 145 - ) 146 } 147 }, 148 - [selectedInterest], 149 ) 150 151 return ( ··· 297 defaultTabLabel?: string 298 }) { 299 const {_} = useLingui() 300 const interestsDisplayNames = useInterestsDisplayNames() 301 const interests = Object.keys(interestsDisplayNames) 302 .sort(boostInterests(popularInterests)) ··· 309 selectedInterest || (hideDefaultTab ? interests[0] : 'all') 310 } 311 onSelectTab={tab => { 312 - logger.metric( 313 - 'onboarding:suggestedAccounts:tabPressed', 314 - {tab: tab}, 315 - {statsig: true}, 316 - ) 317 onSelectInterest(tab === 'all' ? null : tab) 318 }} 319 interestsDisplayNames={ ··· 343 onSeen: (did: string, position: number) => void 344 }) { 345 const t = useTheme() 346 const cardRef = useRef<View>(null) 347 const hasTrackedRef = useRef(false) 348 ··· 403 withIcon={false} 404 logContext="OnboardingSuggestedAccounts" 405 onFollow={() => { 406 - logger.metric( 407 - 'suggestedUser:follow', 408 - { 409 - logContext: 'Onboarding', 410 - location: 'Card', 411 - recId: undefined, 412 - position, 413 - suggestedDid: profile.did, 414 - category, 415 - }, 416 - {statsig: true}, 417 - ) 418 }} 419 /> 420 </ProfileCard.Header>
··· 9 import {wait} from '#/lib/async/wait' 10 import {popularInterests, useInterestsDisplayNames} from '#/lib/interests' 11 import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted' 12 import {updateProfileShadow} from '#/state/cache/profile-shadow' 13 import {useLanguagePrefs} from '#/state/preferences' 14 import {useModerationOpts} from '#/state/preferences/moderation-opts' ··· 29 import {Loader} from '#/components/Loader' 30 import * as ProfileCard from '#/components/ProfileCard' 31 import * as toast from '#/components/Toast' 32 + import {useAnalytics} from '#/analytics' 33 import {IS_WEB} from '#/env' 34 import type * as bsky from '#/types/bsky' 35 import {bulkWriteFollows} from '../util' 36 37 export function StepSuggestedAccounts() { 38 const {_} = useLingui() 39 + const ax = useAnalytics() 40 const t = useTheme() 41 const {gtMobile} = useBreakpoints() 42 const moderationOpts = useModerationOpts() ··· 93 94 const {mutate: followAll, isPending: isFollowingAll} = useMutation({ 95 onMutate: () => { 96 + ax.metric('onboarding:suggestedAccounts:followAllPressed', { 97 tab: selectedInterest ?? 'all', 98 numAccounts: followableDids.length, 99 }) ··· 133 (did: string, position: number) => { 134 if (!seenProfilesRef.current.has(did)) { 135 seenProfilesRef.current.add(did) 136 + ax.metric('suggestedUser:seen', { 137 + logContext: 'Onboarding', 138 + recId: undefined, 139 + position, 140 + suggestedDid: did, 141 + category: selectedInterest, 142 + }) 143 } 144 }, 145 + [ax, selectedInterest], 146 ) 147 148 return ( ··· 294 defaultTabLabel?: string 295 }) { 296 const {_} = useLingui() 297 + const ax = useAnalytics() 298 const interestsDisplayNames = useInterestsDisplayNames() 299 const interests = Object.keys(interestsDisplayNames) 300 .sort(boostInterests(popularInterests)) ··· 307 selectedInterest || (hideDefaultTab ? interests[0] : 'all') 308 } 309 onSelectTab={tab => { 310 + ax.metric('onboarding:suggestedAccounts:tabPressed', {tab: tab}) 311 onSelectInterest(tab === 'all' ? null : tab) 312 }} 313 interestsDisplayNames={ ··· 337 onSeen: (did: string, position: number) => void 338 }) { 339 const t = useTheme() 340 + const ax = useAnalytics() 341 const cardRef = useRef<View>(null) 342 const hasTrackedRef = useRef(false) 343 ··· 398 withIcon={false} 399 logContext="OnboardingSuggestedAccounts" 400 onFollow={() => { 401 + ax.metric('suggestedUser:follow', { 402 + logContext: 'Onboarding', 403 + location: 'Card', 404 + recId: undefined, 405 + position, 406 + suggestedDid: profile.did, 407 + category, 408 + }) 409 }} 410 /> 411 </ProfileCard.Header>
+3 -1
src/screens/Onboarding/StepSuggestedStarterpacks/StarterPackCard.tsx
··· 19 import {Loader} from '#/components/Loader' 20 import * as Toast from '#/components/Toast' 21 import {Text} from '#/components/Typography' 22 import * as bsky from '#/types/bsky' 23 24 const IGNORED_ACCOUNT = 'did:plc:pifkcjimdcfwaxkanzhwxufp' ··· 30 }) { 31 const t = useTheme() 32 const {_} = useLingui() 33 const {currentAccount} = useSession() 34 const {gtPhone} = useBreakpoints() 35 const agent = useAgent() ··· 89 } 90 }) 91 Toast.show(_(msg`All accounts have been followed!`), {type: 'success'}) 92 - logger.metric('starterPack:followAll', { 93 logContext: 'Onboarding', 94 starterPack: view.uri, 95 count: dids.length,
··· 19 import {Loader} from '#/components/Loader' 20 import * as Toast from '#/components/Toast' 21 import {Text} from '#/components/Typography' 22 + import {useAnalytics} from '#/analytics' 23 import * as bsky from '#/types/bsky' 24 25 const IGNORED_ACCOUNT = 'did:plc:pifkcjimdcfwaxkanzhwxufp' ··· 31 }) { 32 const t = useTheme() 33 const {_} = useLingui() 34 + const ax = useAnalytics() 35 const {currentAccount} = useSession() 36 const {gtPhone} = useBreakpoints() 37 const agent = useAgent() ··· 91 } 92 }) 93 Toast.show(_(msg`All accounts have been followed!`), {type: 'success'}) 94 + ax.metric('starterPack:followAll', { 95 logContext: 'Onboarding', 96 starterPack: view.uri, 97 count: dids.length,
+3 -3
src/screens/Onboarding/index.tsx
··· 3 import * as bcp47Match from 'bcp-47-match' 4 5 import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController' 6 - import {useGate} from '#/lib/statsig/statsig' 7 import {useLanguagePrefs} from '#/state/preferences' 8 import { 9 Layout, ··· 23 import {useFindContactsFlowState} from '#/components/contacts/state' 24 import {Portal} from '#/components/Portal' 25 import {ScreenTransition} from '#/components/ScreenTransition' 26 import {ENV, IS_NATIVE} from '#/env' 27 import {StepFindContacts} from './StepFindContacts' 28 import {StepFindContactsIntro} from './StepFindContactsIntro' ··· 31 32 export function Onboarding() { 33 const t = useTheme() 34 - const gate = useGate() 35 36 const {contentLanguages} = useLanguagePrefs() 37 const probablySpeaksEnglish = useMemo(() => { ··· 48 ENV !== 'e2e' && 49 IS_NATIVE && 50 findContactsEnabled && 51 - !gate('disable_onboarding_find_contacts') 52 53 const [state, dispatch] = useReducer( 54 reducer,
··· 3 import * as bcp47Match from 'bcp-47-match' 4 5 import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController' 6 import {useLanguagePrefs} from '#/state/preferences' 7 import { 8 Layout, ··· 22 import {useFindContactsFlowState} from '#/components/contacts/state' 23 import {Portal} from '#/components/Portal' 24 import {ScreenTransition} from '#/components/ScreenTransition' 25 + import {useAnalytics} from '#/analytics' 26 import {ENV, IS_NATIVE} from '#/env' 27 import {StepFindContacts} from './StepFindContacts' 28 import {StepFindContactsIntro} from './StepFindContactsIntro' ··· 31 32 export function Onboarding() { 33 const t = useTheme() 34 + const ax = useAnalytics() 35 36 const {contentLanguages} = useLanguagePrefs() 37 const probablySpeaksEnglish = useMemo(() => { ··· 48 ENV !== 'e2e' && 49 IS_NATIVE && 50 findContactsEnabled && 51 + !ax.features.enabled(ax.features.DisableOnboardingFindContacts) 52 53 const [state, dispatch] = useReducer( 54 reducer,
-9
src/screens/Onboarding/state.ts
··· 172 } 173 } 174 175 - if (a.type === 'next') { 176 - if (next.activeStep === 'find-contacts-intro') { 177 - logger.metric('onboarding:contacts:presented', {}) 178 - } 179 - if (next.activeStep === 'find-contacts') { 180 - logger.metric('onboarding:contacts:begin', {}) 181 - } 182 - } 183 - 184 const state = { 185 ...next, 186 hasPrev: next.activeStep !== 'profile',
··· 172 } 173 } 174 175 const state = { 176 ...next, 177 hasPrev: next.activeStep !== 'profile',
+1 -1
src/screens/PostThread/components/GrowthHack.tsx
··· 2 import {View} from 'react-native' 3 import {PrivacySensitive} from 'expo-privacy-sensitive' 4 5 - import {useAppState} from '#/lib/hooks/useAppState' 6 import {atoms as a, useTheme} from '#/alf' 7 import {sizes as iconSizes} from '#/components/icons/common' 8 import {Mark as Logo} from '#/components/icons/Logo'
··· 2 import {View} from 'react-native' 3 import {PrivacySensitive} from 'expo-privacy-sensitive' 4 5 + import {useAppState} from '#/lib/appState' 6 import {atoms as a, useTheme} from '#/alf' 7 import {sizes as iconSizes} from '#/components/icons/common' 8 import {Mark as Logo} from '#/components/icons/Logo'
+3 -2
src/screens/PostThread/components/HeaderDropdown.tsx
··· 2 import {useLingui} from '@lingui/react' 3 4 import {HITSLOP_10} from '#/lib/constants' 5 - import {logger} from '#/logger' 6 import {type ThreadPreferences} from '#/state/queries/preferences/useThreadPreferences' 7 import {Button, ButtonIcon} from '#/components/Button' 8 import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider' 9 import * as Menu from '#/components/Menu' 10 11 export function HeaderDropdown({ 12 sort, ··· 17 ThreadPreferences, 18 'sort' | 'setSort' | 'view' | 'setView' 19 >): React.ReactNode { 20 const {_} = useLingui() 21 return ( 22 <Menu.Root> ··· 30 shape="round" 31 hitSlop={HITSLOP_10} 32 onPress={() => { 33 - logger.metric('thread:click:headerMenuOpen', {}) 34 onPress() 35 }} 36 {...props}>
··· 2 import {useLingui} from '@lingui/react' 3 4 import {HITSLOP_10} from '#/lib/constants' 5 import {type ThreadPreferences} from '#/state/queries/preferences/useThreadPreferences' 6 import {Button, ButtonIcon} from '#/components/Button' 7 import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider' 8 import * as Menu from '#/components/Menu' 9 + import {useAnalytics} from '#/analytics' 10 11 export function HeaderDropdown({ 12 sort, ··· 17 ThreadPreferences, 18 'sort' | 'setSort' | 'view' | 'setView' 19 >): React.ReactNode { 20 + const ax = useAnalytics() 21 const {_} = useLingui() 22 return ( 23 <Menu.Root> ··· 31 shape="round" 32 hitSlop={HITSLOP_10} 33 onPress={() => { 34 + ax.metric('thread:click:headerMenuOpen', {}) 35 onPress() 36 }} 37 {...props}>
+7 -5
src/screens/PostThread/components/ThreadItemAnchor.tsx
··· 18 import {sanitizeHandle} from '#/lib/strings/handles' 19 import {niceDate} from '#/lib/strings/time' 20 import {getTranslatorLink, isPostInLanguage} from '#/locale/helpers' 21 - import {logger} from '#/logger' 22 import { 23 POST_TOMBSTONE, 24 type Shadow, ··· 60 import {Text} from '#/components/Typography' 61 import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' 62 import {WhoCanReply} from '#/components/WhoCanReply' 63 import * as bsky from '#/types/bsky' 64 65 export function ThreadItemAnchor({ ··· 177 postSource?: PostSource 178 }) { 179 const t = useTheme() 180 const {_} = useLingui() 181 const {openComposer} = useOpenComposer() 182 const {currentAccount, hasSession} = useSession() ··· 281 ]) 282 283 const onOpenAuthor = () => { 284 - logger.metric('post:clickthroughAuthor', { 285 uri: post.uri, 286 authorDid: post.author.did, 287 logContext: 'PostThreadItem', ··· 298 } 299 300 const onOpenEmbed = () => { 301 - logger.metric('post:clickthroughEmbed', { 302 uri: post.uri, 303 authorDid: post.author.did, 304 logContext: 'PostThreadItem', ··· 540 isThreadAuthor: boolean 541 }) { 542 const t = useTheme() 543 const {_, i18n} = useLingui() 544 const translate = useTranslate() 545 const isRootPost = !('reply' in post.record) ··· 565 AppBskyFeedPost.isRecord, 566 ) 567 ) { 568 - logger.metric('translate', { 569 sourceLanguages: post.record.langs ?? [], 570 targetLanguage: langPrefs.primaryLanguage, 571 textLength: post.record.text.length, ··· 574 575 return false 576 }, 577 - [translate, langPrefs, post], 578 ) 579 580 return (
··· 18 import {sanitizeHandle} from '#/lib/strings/handles' 19 import {niceDate} from '#/lib/strings/time' 20 import {getTranslatorLink, isPostInLanguage} from '#/locale/helpers' 21 import { 22 POST_TOMBSTONE, 23 type Shadow, ··· 59 import {Text} from '#/components/Typography' 60 import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' 61 import {WhoCanReply} from '#/components/WhoCanReply' 62 + import {useAnalytics} from '#/analytics' 63 import * as bsky from '#/types/bsky' 64 65 export function ThreadItemAnchor({ ··· 177 postSource?: PostSource 178 }) { 179 const t = useTheme() 180 + const ax = useAnalytics() 181 const {_} = useLingui() 182 const {openComposer} = useOpenComposer() 183 const {currentAccount, hasSession} = useSession() ··· 282 ]) 283 284 const onOpenAuthor = () => { 285 + ax.metric('post:clickthroughAuthor', { 286 uri: post.uri, 287 authorDid: post.author.did, 288 logContext: 'PostThreadItem', ··· 299 } 300 301 const onOpenEmbed = () => { 302 + ax.metric('post:clickthroughEmbed', { 303 uri: post.uri, 304 authorDid: post.author.did, 305 logContext: 'PostThreadItem', ··· 541 isThreadAuthor: boolean 542 }) { 543 const t = useTheme() 544 + const ax = useAnalytics() 545 const {_, i18n} = useLingui() 546 const translate = useTranslate() 547 const isRootPost = !('reply' in post.record) ··· 567 AppBskyFeedPost.isRecord, 568 ) 569 ) { 570 + ax.metric('translate', { 571 sourceLanguages: post.record.langs ?? [], 572 targetLanguage: langPrefs.primaryLanguage, 573 textLength: post.record.text.length, ··· 576 577 return false 578 }, 579 + [ax, translate, langPrefs, post], 580 ) 581 582 return (
+4 -3
src/screens/PostThread/components/ThreadItemShowOtherReplies.tsx
··· 2 import {msg} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 - import {logger} from '#/logger' 6 import {atoms as a, useTheme} from '#/alf' 7 import {Button} from '#/components/Button' 8 import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' 9 import {Text} from '#/components/Typography' 10 11 export function ThreadItemShowOtherReplies({onPress}: {onPress: () => void}) { 12 const {_} = useLingui() 13 - const t = useTheme() 14 const label = _(msg`Show more replies`) 15 16 return ( 17 <Button 18 onPress={() => { 19 onPress() 20 - logger.metric('thread:click:showOtherReplies', {}) 21 }} 22 label={label}> 23 {({hovered, pressed}) => (
··· 2 import {msg} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 import {atoms as a, useTheme} from '#/alf' 6 import {Button} from '#/components/Button' 7 import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' 8 import {Text} from '#/components/Typography' 9 + import {useAnalytics} from '#/analytics' 10 11 export function ThreadItemShowOtherReplies({onPress}: {onPress: () => void}) { 12 + const t = useTheme() 13 + const ax = useAnalytics() 14 const {_} = useLingui() 15 const label = _(msg`Show more replies`) 16 17 return ( 18 <Button 19 onPress={() => { 20 onPress() 21 + ax.metric('thread:click:showOtherReplies', {}) 22 }} 23 label={label}> 24 {({hovered, pressed}) => (
+9 -12
src/screens/PostThread/index.tsx
··· 6 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 7 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 8 import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' 9 - import {logger} from '#/logger' 10 import {useFeedFeedback} from '#/state/feed-feedback' 11 import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences' 12 import { ··· 44 import {atoms as a, native, platform, useBreakpoints, web} from '#/alf' 45 import * as Layout from '#/components/Layout' 46 import {ListFooter} from '#/components/Lists' 47 48 const PARENT_CHUNK_SIZE = 5 49 const CHILDREN_CHUNK_SIZE = 50 50 51 export function PostThread({uri}: {uri: string}) { 52 const {gtMobile} = useBreakpoints() 53 const {hasSession} = useSession() 54 const initialNumToRender = useInitialNumToRender() ··· 84 const post = anchor.value.post 85 seenPostUriRef.current = post.uri 86 87 - logger.metric( 88 - 'post:view', 89 - { 90 - uri: post.uri, 91 - authorDid: post.author.did, 92 - logContext: 'Post', 93 - feedDescriptor: feedFeedback.feedDescriptor, 94 - }, 95 - {statsig: false}, 96 - ) 97 } 98 - }, [anchor, feedFeedback.feedDescriptor]) 99 100 // Track post:view events for parent posts and replies (non-anchor posts) 101 const trackThreadItemView = usePostViewTracking('PostThreadItem')
··· 6 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 7 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 8 import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' 9 import {useFeedFeedback} from '#/state/feed-feedback' 10 import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences' 11 import { ··· 43 import {atoms as a, native, platform, useBreakpoints, web} from '#/alf' 44 import * as Layout from '#/components/Layout' 45 import {ListFooter} from '#/components/Lists' 46 + import {useAnalytics} from '#/analytics' 47 48 const PARENT_CHUNK_SIZE = 5 49 const CHILDREN_CHUNK_SIZE = 50 50 51 export function PostThread({uri}: {uri: string}) { 52 + const ax = useAnalytics() 53 const {gtMobile} = useBreakpoints() 54 const {hasSession} = useSession() 55 const initialNumToRender = useInitialNumToRender() ··· 85 const post = anchor.value.post 86 seenPostUriRef.current = post.uri 87 88 + ax.metric('post:view', { 89 + uri: post.uri, 90 + authorDid: post.author.did, 91 + logContext: 'Post', 92 + feedDescriptor: feedFeedback.feedDescriptor, 93 + }) 94 } 95 + }, [ax, anchor, feedFeedback.feedDescriptor]) 96 97 // Track post:view events for parent posts and replies (non-anchor posts) 98 const trackThreadItemView = usePostViewTracking('PostThreadItem')
+8 -7
src/screens/Profile/Header/ProfileHeaderLabeler.tsx
··· 14 import {MAX_LABELERS} from '#/lib/constants' 15 import {useHaptics} from '#/lib/haptics' 16 import {isAppLabeler} from '#/lib/moderation' 17 - import {logger} from '#/logger' 18 import {useProfileShadow} from '#/state/cache/profile-shadow' 19 import {type Shadow} from '#/state/cache/types' 20 import {useLabelerSubscriptionMutation} from '#/state/queries/labeler' ··· 34 import {RichText} from '#/components/RichText' 35 import * as Toast from '#/components/Toast' 36 import {Text} from '#/components/Typography' 37 import {IS_IOS} from '#/env' 38 import {ProfileHeaderDisplayName} from './DisplayName' 39 import {EditProfileDialog} from './EditProfileDialog' ··· 61 const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> = 62 useProfileShadow(profileUnshadowed) 63 const t = useTheme() 64 const {_} = useLingui() 65 const {currentAccount, hasSession} = useSession() 66 const playHaptic = useHaptics() ··· 99 ), 100 {type: 'error'}, 101 ) 102 - logger.error(`Failed to toggle labeler like`, {message: e.message}) 103 } 104 - }, [labeler, playHaptic, likeUri, unlikeMod, likeMod, _]) 105 106 return ( 107 <ProfileHeaderShell ··· 233 /** disable the subscribe button */ 234 minimal?: boolean 235 }) { 236 const {_} = useLingui() 237 - const t = useTheme() 238 const {currentAccount} = useSession() 239 const requireAuth = useRequireAuth() 240 const playHaptic = useHaptics() ··· 264 subscribe, 265 }) 266 267 - logger.metric( 268 subscribe 269 ? 'moderation:subscribedToLabeler' 270 : 'moderation:unsubscribedFromLabeler', 271 {}, 272 - {statsig: true}, 273 ) 274 } catch (e: any) { 275 reset() ··· 277 cantSubscribePrompt.open() 278 return 279 } 280 - logger.error(`Failed to subscribe to labeler`, {message: e.message}) 281 } 282 }) 283 return (
··· 14 import {MAX_LABELERS} from '#/lib/constants' 15 import {useHaptics} from '#/lib/haptics' 16 import {isAppLabeler} from '#/lib/moderation' 17 import {useProfileShadow} from '#/state/cache/profile-shadow' 18 import {type Shadow} from '#/state/cache/types' 19 import {useLabelerSubscriptionMutation} from '#/state/queries/labeler' ··· 33 import {RichText} from '#/components/RichText' 34 import * as Toast from '#/components/Toast' 35 import {Text} from '#/components/Typography' 36 + import {useAnalytics} from '#/analytics' 37 import {IS_IOS} from '#/env' 38 import {ProfileHeaderDisplayName} from './DisplayName' 39 import {EditProfileDialog} from './EditProfileDialog' ··· 61 const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> = 62 useProfileShadow(profileUnshadowed) 63 const t = useTheme() 64 + const ax = useAnalytics() 65 const {_} = useLingui() 66 const {currentAccount, hasSession} = useSession() 67 const playHaptic = useHaptics() ··· 100 ), 101 {type: 'error'}, 102 ) 103 + ax.logger.error(`Failed to toggle labeler like`, {message: e.message}) 104 } 105 + }, [ax, labeler, playHaptic, likeUri, unlikeMod, likeMod, _]) 106 107 return ( 108 <ProfileHeaderShell ··· 234 /** disable the subscribe button */ 235 minimal?: boolean 236 }) { 237 + const t = useTheme() 238 + const ax = useAnalytics() 239 const {_} = useLingui() 240 const {currentAccount} = useSession() 241 const requireAuth = useRequireAuth() 242 const playHaptic = useHaptics() ··· 266 subscribe, 267 }) 268 269 + ax.metric( 270 subscribe 271 ? 'moderation:subscribedToLabeler' 272 : 'moderation:unsubscribedFromLabeler', 273 {}, 274 ) 275 } catch (e: any) { 276 reset() ··· 278 cantSubscribePrompt.open() 279 return 280 } 281 + ax.logger.error(`Failed to subscribe to labeler`, {message: e.message}) 282 } 283 }) 284 return (
+6 -12
src/screens/Profile/Header/Shell.tsx
··· 18 import {BACK_HITSLOP} from '#/lib/constants' 19 import {useHaptics} from '#/lib/haptics' 20 import {type NavigationProp} from '#/lib/routes/types' 21 - import {logger} from '#/logger' 22 import {type Shadow} from '#/state/cache/types' 23 import {useLightboxControls} from '#/state/lightbox' 24 import {useSession} from '#/state/session' ··· 34 import {LiveStatusDialog} from '#/components/live/LiveStatusDialog' 35 import {LabelsOnMe} from '#/components/moderation/LabelsOnMe' 36 import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts' 37 import {IS_IOS} from '#/env' 38 import {GrowableAvatar} from './GrowableAvatar' 39 import {GrowableBanner} from './GrowableBanner' ··· 54 isPlaceholderProfile, 55 }: React.PropsWithChildren<Props>): React.ReactNode => { 56 const t = useTheme() 57 const {currentAccount} = useSession() 58 const {_} = useLingui() 59 const {openLightbox} = useLightboxControls() ··· 116 117 useEffect(() => { 118 if (live.isActive) { 119 - logger.metric( 120 - 'live:view:profile', 121 - {subject: profile.did}, 122 - {statsig: true}, 123 - ) 124 } 125 - }, [live.isActive, profile.did]) 126 127 const onPressAvi = useCallback(() => { 128 if (live.isActive) { 129 playHaptic('Light') 130 - logger.metric( 131 - 'live:card:open', 132 - {subject: profile.did, from: 'profile'}, 133 - {statsig: true}, 134 - ) 135 liveStatusControl.open() 136 } else { 137 const modui = moderation.ui('avatar') ··· 145 } 146 } 147 }, [ 148 profile, 149 moderation, 150 _openLightbox,
··· 18 import {BACK_HITSLOP} from '#/lib/constants' 19 import {useHaptics} from '#/lib/haptics' 20 import {type NavigationProp} from '#/lib/routes/types' 21 import {type Shadow} from '#/state/cache/types' 22 import {useLightboxControls} from '#/state/lightbox' 23 import {useSession} from '#/state/session' ··· 33 import {LiveStatusDialog} from '#/components/live/LiveStatusDialog' 34 import {LabelsOnMe} from '#/components/moderation/LabelsOnMe' 35 import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts' 36 + import {useAnalytics} from '#/analytics' 37 import {IS_IOS} from '#/env' 38 import {GrowableAvatar} from './GrowableAvatar' 39 import {GrowableBanner} from './GrowableBanner' ··· 54 isPlaceholderProfile, 55 }: React.PropsWithChildren<Props>): React.ReactNode => { 56 const t = useTheme() 57 + const ax = useAnalytics() 58 const {currentAccount} = useSession() 59 const {_} = useLingui() 60 const {openLightbox} = useLightboxControls() ··· 117 118 useEffect(() => { 119 if (live.isActive) { 120 + ax.metric('live:view:profile', {subject: profile.did}) 121 } 122 + }, [ax, live.isActive, profile.did]) 123 124 const onPressAvi = useCallback(() => { 125 if (live.isActive) { 126 playHaptic('Light') 127 + ax.metric('live:card:open', {subject: profile.did, from: 'profile'}) 128 liveStatusControl.open() 129 } else { 130 const modui = moderation.ui('avatar') ··· 138 } 139 } 140 }, [ 141 + ax, 142 profile, 143 moderation, 144 _openLightbox,
+12 -10
src/screens/Profile/components/ProfileFeedHeader.tsx
··· 5 import {useLingui} from '@lingui/react' 6 7 import {useHaptics} from '#/lib/haptics' 8 - import {makeProfileLink} from '#/lib/routes/links' 9 - import {makeCustomFeedLink} from '#/lib/routes/links' 10 import {shareUrl} from '#/lib/sharing' 11 import {sanitizeHandle} from '#/lib/strings/handles' 12 import {toShareUrl} from '#/lib/strings/url-helpers' ··· 51 } from '#/components/moderation/ReportDialog' 52 import {RichText} from '#/components/RichText' 53 import {Text} from '#/components/Typography' 54 import {IS_WEB} from '#/env' 55 56 export function ProfileFeedHeaderSkeleton() { ··· 86 export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) { 87 const t = useTheme() 88 const {_, i18n} = useLingui() 89 const {hasSession} = useSession() 90 const {gtMobile} = useBreakpoints() 91 const infoControl = Dialog.useDialogControl() ··· 120 if (savedFeedConfig) { 121 await removeFeed(savedFeedConfig) 122 Toast.show(_(msg`Removed from your feeds`)) 123 - logger.metric('feed:unsave', {feedUrl: info.uri}) 124 } else { 125 await addSavedFeeds([ 126 { ··· 130 }, 131 ]) 132 Toast.show(_(msg`Saved to your feeds`)) 133 - logger.metric('feed:save', {feedUrl: info.uri}) 134 } 135 } catch (err) { 136 Toast.show( ··· 158 159 if (pinned) { 160 Toast.show(_(msg`Pinned ${info.displayName} to Home`)) 161 - logger.metric('feed:pin', {feedUrl: info.uri}) 162 } else { 163 Toast.show(_(msg`Unpinned ${info.displayName} from Home`)) 164 - logger.metric('feed:unpin', {feedUrl: info.uri}) 165 } 166 } else { 167 await addSavedFeeds([ ··· 172 }, 173 ]) 174 Toast.show(_(msg`Pinned ${info.displayName} to Home`)) 175 - logger.metric('feed:pin', {feedUrl: info.uri}) 176 } 177 } catch (e) { 178 Toast.show(_(msg`There was an issue contacting the server`), 'xmark') ··· 388 }) { 389 const t = useTheme() 390 const {_} = useLingui() 391 const {hasSession} = useSession() 392 const playHaptic = useHaptics() 393 const control = Dialog.useDialogContext() ··· 407 if (isLiked && likeUri) { 408 await unlikeFeed({uri: likeUri}) 409 setLikeUri('') 410 - logger.metric('feed:unlike', {feedUrl: info.uri}) 411 } else { 412 const res = await likeFeed({uri: info.uri, cid: info.cid}) 413 setLikeUri(res.uri) 414 - logger.metric('feed:like', {feedUrl: info.uri}) 415 } 416 } catch (err) { 417 Toast.show( ··· 428 playHaptic() 429 const url = toShareUrl(info.route.href) 430 shareUrl(url) 431 - logger.metric('feed:share', {feedUrl: info.uri}) 432 }, [info, playHaptic]) 433 434 const onPressReport = React.useCallback(() => {
··· 5 import {useLingui} from '@lingui/react' 6 7 import {useHaptics} from '#/lib/haptics' 8 + import {makeCustomFeedLink, makeProfileLink} from '#/lib/routes/links' 9 import {shareUrl} from '#/lib/sharing' 10 import {sanitizeHandle} from '#/lib/strings/handles' 11 import {toShareUrl} from '#/lib/strings/url-helpers' ··· 50 } from '#/components/moderation/ReportDialog' 51 import {RichText} from '#/components/RichText' 52 import {Text} from '#/components/Typography' 53 + import {useAnalytics} from '#/analytics' 54 import {IS_WEB} from '#/env' 55 56 export function ProfileFeedHeaderSkeleton() { ··· 86 export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) { 87 const t = useTheme() 88 const {_, i18n} = useLingui() 89 + const ax = useAnalytics() 90 const {hasSession} = useSession() 91 const {gtMobile} = useBreakpoints() 92 const infoControl = Dialog.useDialogControl() ··· 121 if (savedFeedConfig) { 122 await removeFeed(savedFeedConfig) 123 Toast.show(_(msg`Removed from your feeds`)) 124 + ax.metric('feed:unsave', {feedUrl: info.uri}) 125 } else { 126 await addSavedFeeds([ 127 { ··· 131 }, 132 ]) 133 Toast.show(_(msg`Saved to your feeds`)) 134 + ax.metric('feed:save', {feedUrl: info.uri}) 135 } 136 } catch (err) { 137 Toast.show( ··· 159 160 if (pinned) { 161 Toast.show(_(msg`Pinned ${info.displayName} to Home`)) 162 + ax.metric('feed:pin', {feedUrl: info.uri}) 163 } else { 164 Toast.show(_(msg`Unpinned ${info.displayName} from Home`)) 165 + ax.metric('feed:unpin', {feedUrl: info.uri}) 166 } 167 } else { 168 await addSavedFeeds([ ··· 173 }, 174 ]) 175 Toast.show(_(msg`Pinned ${info.displayName} to Home`)) 176 + ax.metric('feed:pin', {feedUrl: info.uri}) 177 } 178 } catch (e) { 179 Toast.show(_(msg`There was an issue contacting the server`), 'xmark') ··· 389 }) { 390 const t = useTheme() 391 const {_} = useLingui() 392 + const ax = useAnalytics() 393 const {hasSession} = useSession() 394 const playHaptic = useHaptics() 395 const control = Dialog.useDialogContext() ··· 409 if (isLiked && likeUri) { 410 await unlikeFeed({uri: likeUri}) 411 setLikeUri('') 412 + ax.metric('feed:unlike', {feedUrl: info.uri}) 413 } else { 414 const res = await likeFeed({uri: info.uri, cid: info.cid}) 415 setLikeUri(res.uri) 416 + ax.metric('feed:like', {feedUrl: info.uri}) 417 } 418 } catch (err) { 419 Toast.show( ··· 430 playHaptic() 431 const url = toShareUrl(info.route.href) 432 shareUrl(url) 433 + ax.metric('feed:share', {feedUrl: info.uri}) 434 }, [info, playHaptic]) 435 436 const onPressReport = React.useCallback(() => {
+4 -10
src/screens/ProfileList/components/Header.tsx
··· 21 import {Loader} from '#/components/Loader' 22 import {RichText} from '#/components/RichText' 23 import * as Toast from '#/components/Toast' 24 import {MoreOptionsMenu} from './MoreOptionsMenu' 25 import {SubscribeMenu} from './SubscribeMenu' 26 ··· 34 preferences: UsePreferencesQueryResponse 35 }) { 36 const {_} = useLingui() 37 const {currentAccount} = useSession() 38 const isCurateList = list.purpose === AppBskyGraphDefs.CURATELIST 39 const isModList = list.purpose === AppBskyGraphDefs.MODLIST ··· 96 try { 97 await muteList({uri: list.uri, mute: false}) 98 Toast.show(_(msg({message: 'List unmuted', context: 'toast'}))) 99 - logger.metric( 100 - 'moderation:unsubscribedFromList', 101 - {listType: 'mute'}, 102 - {statsig: true}, 103 - ) 104 } catch { 105 Toast.show( 106 _( ··· 114 try { 115 await blockList({uri: list.uri, block: false}) 116 Toast.show(_(msg({message: 'List unblocked', context: 'toast'}))) 117 - logger.metric( 118 - 'moderation:unsubscribedFromList', 119 - {listType: 'block'}, 120 - {statsig: true}, 121 - ) 122 } catch { 123 Toast.show( 124 _(
··· 21 import {Loader} from '#/components/Loader' 22 import {RichText} from '#/components/RichText' 23 import * as Toast from '#/components/Toast' 24 + import {useAnalytics} from '#/analytics' 25 import {MoreOptionsMenu} from './MoreOptionsMenu' 26 import {SubscribeMenu} from './SubscribeMenu' 27 ··· 35 preferences: UsePreferencesQueryResponse 36 }) { 37 const {_} = useLingui() 38 + const ax = useAnalytics() 39 const {currentAccount} = useSession() 40 const isCurateList = list.purpose === AppBskyGraphDefs.CURATELIST 41 const isModList = list.purpose === AppBskyGraphDefs.MODLIST ··· 98 try { 99 await muteList({uri: list.uri, mute: false}) 100 Toast.show(_(msg({message: 'List unmuted', context: 'toast'}))) 101 + ax.metric('moderation:unsubscribedFromList', {listType: 'mute'}) 102 } catch { 103 Toast.show( 104 _( ··· 112 try { 113 await blockList({uri: list.uri, block: false}) 114 Toast.show(_(msg({message: 'List unblocked', context: 'toast'}))) 115 + ax.metric('moderation:unsubscribedFromList', {listType: 'block'}) 116 } catch { 117 Toast.show( 118 _(
+4 -10
src/screens/ProfileList/components/MoreOptionsMenu.tsx
··· 33 } from '#/components/moderation/ReportDialog' 34 import * as Prompt from '#/components/Prompt' 35 import * as Toast from '#/components/Toast' 36 import {IS_WEB} from '#/env' 37 38 export function MoreOptionsMenu({ ··· 43 savedFeedConfig?: AppBskyActorDefs.SavedFeed 44 }) { 45 const {_} = useLingui() 46 const {currentAccount} = useSession() 47 const editListDialogControl = useDialogControl() 48 const deleteListPromptControl = useDialogControl() ··· 111 try { 112 await muteList({uri: list.uri, mute: false}) 113 Toast.show(_(msg({message: 'List unmuted', context: 'toast'}))) 114 - logger.metric( 115 - 'moderation:unsubscribedFromList', 116 - {listType: 'mute'}, 117 - {statsig: true}, 118 - ) 119 } catch { 120 Toast.show( 121 _( ··· 129 try { 130 await blockList({uri: list.uri, block: false}) 131 Toast.show(_(msg({message: 'List unblocked', context: 'toast'}))) 132 - logger.metric( 133 - 'moderation:unsubscribedFromList', 134 - {listType: 'block'}, 135 - {statsig: true}, 136 - ) 137 } catch { 138 Toast.show( 139 _(
··· 33 } from '#/components/moderation/ReportDialog' 34 import * as Prompt from '#/components/Prompt' 35 import * as Toast from '#/components/Toast' 36 + import {useAnalytics} from '#/analytics' 37 import {IS_WEB} from '#/env' 38 39 export function MoreOptionsMenu({ ··· 44 savedFeedConfig?: AppBskyActorDefs.SavedFeed 45 }) { 46 const {_} = useLingui() 47 + const ax = useAnalytics() 48 const {currentAccount} = useSession() 49 const editListDialogControl = useDialogControl() 50 const deleteListPromptControl = useDialogControl() ··· 113 try { 114 await muteList({uri: list.uri, mute: false}) 115 Toast.show(_(msg({message: 'List unmuted', context: 'toast'}))) 116 + ax.metric('moderation:unsubscribedFromList', {listType: 'mute'}) 117 } catch { 118 Toast.show( 119 _( ··· 127 try { 128 await blockList({uri: list.uri, block: false}) 129 Toast.show(_(msg({message: 'List unblocked', context: 'toast'}))) 130 + ax.metric('moderation:unsubscribedFromList', {listType: 'block'}) 131 } catch { 132 Toast.show( 133 _(
+4 -11
src/screens/ProfileList/components/SubscribeMenu.tsx
··· 2 import {msg, Trans} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 - import {logger} from '#/logger' 6 import {useListBlockMutation, useListMuteMutation} from '#/state/queries/list' 7 import {atoms as a} from '#/alf' 8 import {Button, ButtonIcon, ButtonText} from '#/components/Button' ··· 12 import * as Menu from '#/components/Menu' 13 import * as Prompt from '#/components/Prompt' 14 import * as Toast from '#/components/Toast' 15 16 export function SubscribeMenu({list}: {list: AppBskyGraphDefs.ListView}) { 17 const {_} = useLingui() 18 const subscribeMutePromptControl = Prompt.usePromptControl() 19 const subscribeBlockPromptControl = Prompt.usePromptControl() 20 ··· 29 try { 30 await muteList({uri: list.uri, mute: true}) 31 Toast.show(_(msg({message: 'List muted', context: 'toast'}))) 32 - logger.metric( 33 - 'moderation:subscribedToList', 34 - {listType: 'mute'}, 35 - {statsig: true}, 36 - ) 37 } catch { 38 Toast.show( 39 _( ··· 48 try { 49 await blockList({uri: list.uri, block: true}) 50 Toast.show(_(msg({message: 'List blocked', context: 'toast'}))) 51 - logger.metric( 52 - 'moderation:subscribedToList', 53 - {listType: 'block'}, 54 - {statsig: true}, 55 - ) 56 } catch { 57 Toast.show( 58 _(
··· 2 import {msg, Trans} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 import {useListBlockMutation, useListMuteMutation} from '#/state/queries/list' 6 import {atoms as a} from '#/alf' 7 import {Button, ButtonIcon, ButtonText} from '#/components/Button' ··· 11 import * as Menu from '#/components/Menu' 12 import * as Prompt from '#/components/Prompt' 13 import * as Toast from '#/components/Toast' 14 + import {useAnalytics} from '#/analytics' 15 16 export function SubscribeMenu({list}: {list: AppBskyGraphDefs.ListView}) { 17 const {_} = useLingui() 18 + const ax = useAnalytics() 19 const subscribeMutePromptControl = Prompt.usePromptControl() 20 const subscribeBlockPromptControl = Prompt.usePromptControl() 21 ··· 30 try { 31 await muteList({uri: list.uri, mute: true}) 32 Toast.show(_(msg({message: 'List muted', context: 'toast'}))) 33 + ax.metric('moderation:subscribedToList', {listType: 'mute'}) 34 } catch { 35 Toast.show( 36 _( ··· 45 try { 46 await blockList({uri: list.uri, block: true}) 47 Toast.show(_(msg({message: 'List blocked', context: 'toast'}))) 48 + ax.metric('moderation:subscribedToList', {listType: 'block'}) 49 } catch { 50 Toast.show( 51 _(
+24 -38
src/screens/Search/Explore.tsx
··· 13 import {popularInterests, useInterestsDisplayNames} from '#/lib/interests' 14 import {cleanError} from '#/lib/strings/errors' 15 import {sanitizeHandle} from '#/lib/strings/handles' 16 - import {logger} from '#/logger' 17 - import {type MetricEvents} from '#/logger/metrics' 18 import {useLanguagePrefs} from '#/state/preferences/languages' 19 import {useModerationOpts} from '#/state/preferences/moderation-opts' 20 import {RQKEY_ROOT as useActorSearchQueryKeyRoot} from '#/state/queries/actor-search' ··· 68 import * as ProfileCard from '#/components/ProfileCard' 69 import {SubtleHover} from '#/components/SubtleHover' 70 import {Text} from '#/components/Typography' 71 import {ExploreScreenLiveEventFeedsBanner} from '#/features/liveEvents/components/ExploreScreenLiveEventFeedsBanner' 72 import * as ModuleHeader from './components/ModuleHeader' 73 import { ··· 124 bottomBorder?: boolean 125 searchButton?: { 126 label: string 127 - metricsTag: MetricEvents['explore:module:searchButtonPress']['module'] 128 tab: 'user' | 'profile' | 'feed' 129 } 130 } ··· 135 icon: React.ComponentType<SVGIconProps> 136 searchButton?: { 137 label: string 138 - metricsTag: MetricEvents['explore:module:searchButtonPress']['module'] 139 tab: 'user' | 'profile' | 'feed' 140 } 141 hideDefaultTab?: boolean ··· 213 focusSearchInput: (tab: 'user' | 'profile' | 'feed') => void 214 headerHeight: number 215 }) { 216 const {_} = useLingui() 217 const t = useTheme() 218 const {data: preferences, error: preferencesError} = usePreferencesQuery() ··· 273 try { 274 await fetchNextFeedsPage() 275 } catch (err) { 276 - logger.error('Failed to load more suggested follows', {message: err}) 277 } 278 }, [ 279 isFetchingNextFeedsPage, 280 hasNextFeedsPage, 281 feedsError, ··· 333 try { 334 await fetchNextPageFeedPreviews() 335 } catch (err) { 336 - logger.error('Failed to load more feed previews', {message: err}) 337 } 338 }, [ 339 isPendingFeedPreviews, 340 isFetchingNextPageFeedPreviews, 341 hasNextPageFeedPreviews, ··· 492 if (hasPressedLoadMoreFeeds && index < 6) { 493 continue 494 } 495 - logger.metric( 496 - 'feed:suggestion:seen', 497 - {feedUrl: item.feed.uri}, 498 - {statsig: false}, 499 - ) 500 } 501 } 502 if (!hasPressedLoadMoreFeeds) { ··· 609 return i 610 }, [ 611 _, 612 useFullExperience, 613 suggestedFeeds, 614 preferences, ··· 729 <ModuleHeader.SearchButton 730 {...item.searchButton} 731 onPress={() => 732 - focusSearchInput( 733 - (item.searchButton?.tab || 'user') as 734 - | 'user' 735 - | 'profile' 736 - | 'feed', 737 - ) 738 } 739 /> 740 )} ··· 751 <ModuleHeader.SearchButton 752 {...item.searchButton} 753 onPress={() => 754 - focusSearchInput( 755 - (item.searchButton?.tab || 'user') as 756 - | 'user' 757 - | 'profile' 758 - | 'feed', 759 - ) 760 } 761 /> 762 )} ··· 822 if (!useFullExperience) { 823 return 824 } 825 - logger.metric('feed:suggestion:press', { 826 feedUrl: item.feed.uri, 827 }) 828 }} ··· 1011 } 1012 }, 1013 [ 1014 t.atoms.border_contrast_low, 1015 t.atoms.bg_contrast_25, 1016 t.atoms.text_contrast_medium, ··· 1043 const seenProfilesRef = useRef<Set<string>>(new Set()) 1044 const onItemSeen = useCallback( 1045 (item: ExploreScreenItems) => { 1046 - let module: MetricEvents['explore:module:seen']['module'] 1047 if (item.type === 'trendingTopics' || item.type === 'trendingVideos') { 1048 module = item.type 1049 } else if (item.type === 'profile') { ··· 1054 const position = suggestedFollowsModule.findIndex( 1055 i => i.type === 'profile' && i.profile.did === item.profile.did, 1056 ) 1057 - logger.metric( 1058 - 'suggestedUser:seen', 1059 - { 1060 - logContext: 'Explore', 1061 - recId: item.recId, 1062 - position: position !== -1 ? position - 1 : 0, // -1 to account for header 1063 - suggestedDid: item.profile.did, 1064 - category: null, 1065 - }, 1066 - {statsig: true}, 1067 - ) 1068 } 1069 } else if (item.type === 'feed') { 1070 module = 'suggestedFeeds' ··· 1077 } 1078 if (!alreadyReportedRef.current.has(module)) { 1079 alreadyReportedRef.current.set(module, module) 1080 - logger.metric('explore:module:seen', {module}, {statsig: false}) 1081 } 1082 }, 1083 - [suggestedFollowsModule], 1084 ) 1085 1086 return (
··· 13 import {popularInterests, useInterestsDisplayNames} from '#/lib/interests' 14 import {cleanError} from '#/lib/strings/errors' 15 import {sanitizeHandle} from '#/lib/strings/handles' 16 import {useLanguagePrefs} from '#/state/preferences/languages' 17 import {useModerationOpts} from '#/state/preferences/moderation-opts' 18 import {RQKEY_ROOT as useActorSearchQueryKeyRoot} from '#/state/queries/actor-search' ··· 66 import * as ProfileCard from '#/components/ProfileCard' 67 import {SubtleHover} from '#/components/SubtleHover' 68 import {Text} from '#/components/Typography' 69 + import {type Metrics, useAnalytics} from '#/analytics' 70 import {ExploreScreenLiveEventFeedsBanner} from '#/features/liveEvents/components/ExploreScreenLiveEventFeedsBanner' 71 import * as ModuleHeader from './components/ModuleHeader' 72 import { ··· 123 bottomBorder?: boolean 124 searchButton?: { 125 label: string 126 + metricsTag: Metrics['explore:module:searchButtonPress']['module'] 127 tab: 'user' | 'profile' | 'feed' 128 } 129 } ··· 134 icon: React.ComponentType<SVGIconProps> 135 searchButton?: { 136 label: string 137 + metricsTag: Metrics['explore:module:searchButtonPress']['module'] 138 tab: 'user' | 'profile' | 'feed' 139 } 140 hideDefaultTab?: boolean ··· 212 focusSearchInput: (tab: 'user' | 'profile' | 'feed') => void 213 headerHeight: number 214 }) { 215 + const ax = useAnalytics() 216 const {_} = useLingui() 217 const t = useTheme() 218 const {data: preferences, error: preferencesError} = usePreferencesQuery() ··· 273 try { 274 await fetchNextFeedsPage() 275 } catch (err) { 276 + ax.logger.error('Failed to load more suggested follows', {message: err}) 277 } 278 }, [ 279 + ax, 280 isFetchingNextFeedsPage, 281 hasNextFeedsPage, 282 feedsError, ··· 334 try { 335 await fetchNextPageFeedPreviews() 336 } catch (err) { 337 + ax.logger.error('Failed to load more feed previews', {message: err}) 338 } 339 }, [ 340 + ax, 341 isPendingFeedPreviews, 342 isFetchingNextPageFeedPreviews, 343 hasNextPageFeedPreviews, ··· 494 if (hasPressedLoadMoreFeeds && index < 6) { 495 continue 496 } 497 + ax.metric('feed:suggestion:seen', {feedUrl: item.feed.uri}) 498 } 499 } 500 if (!hasPressedLoadMoreFeeds) { ··· 607 return i 608 }, [ 609 _, 610 + ax, 611 useFullExperience, 612 suggestedFeeds, 613 preferences, ··· 728 <ModuleHeader.SearchButton 729 {...item.searchButton} 730 onPress={() => 731 + focusSearchInput(item.searchButton?.tab || 'user') 732 } 733 /> 734 )} ··· 745 <ModuleHeader.SearchButton 746 {...item.searchButton} 747 onPress={() => 748 + focusSearchInput(item.searchButton?.tab || 'user') 749 } 750 /> 751 )} ··· 811 if (!useFullExperience) { 812 return 813 } 814 + ax.metric('feed:suggestion:press', { 815 feedUrl: item.feed.uri, 816 }) 817 }} ··· 1000 } 1001 }, 1002 [ 1003 + ax, 1004 t.atoms.border_contrast_low, 1005 t.atoms.bg_contrast_25, 1006 t.atoms.text_contrast_medium, ··· 1033 const seenProfilesRef = useRef<Set<string>>(new Set()) 1034 const onItemSeen = useCallback( 1035 (item: ExploreScreenItems) => { 1036 + let module: Metrics['explore:module:seen']['module'] 1037 if (item.type === 'trendingTopics' || item.type === 'trendingVideos') { 1038 module = item.type 1039 } else if (item.type === 'profile') { ··· 1044 const position = suggestedFollowsModule.findIndex( 1045 i => i.type === 'profile' && i.profile.did === item.profile.did, 1046 ) 1047 + ax.metric('suggestedUser:seen', { 1048 + logContext: 'Explore', 1049 + recId: item.recId, 1050 + position: position !== -1 ? position - 1 : 0, // -1 to account for header 1051 + suggestedDid: item.profile.did, 1052 + category: null, 1053 + }) 1054 } 1055 } else if (item.type === 'feed') { 1056 module = 'suggestedFeeds' ··· 1063 } 1064 if (!alreadyReportedRef.current.has(module)) { 1065 alreadyReportedRef.current.set(module, module) 1066 + ax.metric('explore:module:seen', {module}) 1067 } 1068 }, 1069 + [ax, suggestedFollowsModule], 1070 ) 1071 1072 return (
+3 -6
src/screens/Search/components/ModuleHeader.tsx
··· 4 5 import {PressableScale} from '#/lib/custom-animations/PressableScale' 6 import {makeCustomFeedLink} from '#/lib/routes/links' 7 - import {logger} from '#/logger' 8 import {UserAvatar} from '#/view/com/util/UserAvatar' 9 import {atoms as a, native, useTheme, type ViewStyleProp} from '#/alf' 10 import {Button, ButtonIcon} from '#/components/Button' ··· 13 import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' 14 import {Link} from '#/components/Link' 15 import {Text, type TextProps} from '#/components/Typography' 16 17 export function Container({ 18 style, ··· 126 metricsTag: 'suggestedAccounts' | 'suggestedFeeds' 127 onPress?: () => void 128 }) { 129 return ( 130 <Button 131 label={label} ··· 135 shape="round" 136 PressableComponent={native(PressableScale)} 137 onPress={() => { 138 - logger.metric( 139 - 'explore:module:searchButtonPress', 140 - {module: metricsTag}, 141 - {statsig: true}, 142 - ) 143 onPress?.() 144 }} 145 style={[
··· 4 5 import {PressableScale} from '#/lib/custom-animations/PressableScale' 6 import {makeCustomFeedLink} from '#/lib/routes/links' 7 import {UserAvatar} from '#/view/com/util/UserAvatar' 8 import {atoms as a, native, useTheme, type ViewStyleProp} from '#/alf' 9 import {Button, ButtonIcon} from '#/components/Button' ··· 12 import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' 13 import {Link} from '#/components/Link' 14 import {Text, type TextProps} from '#/components/Typography' 15 + import {useAnalytics} from '#/analytics' 16 17 export function Container({ 18 style, ··· 126 metricsTag: 'suggestedAccounts' | 'suggestedFeeds' 127 onPress?: () => void 128 }) { 129 + const ax = useAnalytics() 130 return ( 131 <Button 132 label={label} ··· 136 shape="round" 137 PressableComponent={native(PressableScale)} 138 onPress={() => { 139 + ax.metric('explore:module:searchButtonPress', {module: metricsTag}) 140 onPress?.() 141 }} 142 style={[
+3 -6
src/screens/Search/modules/ExploreRecommendations.tsx
··· 2 import {type AppBskyUnspeccedDefs} from '@atproto/api' 3 import {Trans} from '@lingui/macro' 4 5 - import {logger} from '#/logger' 6 import { 7 DEFAULT_LIMIT as RECOMMENDATIONS_COUNT, 8 useTrendingTopics, ··· 16 TrendingTopicSkeleton, 17 } from '#/components/TrendingTopics' 18 import {Text} from '#/components/Typography' 19 import {IS_WEB} from '#/env' 20 21 // Note: This module is not currently used and may be removed in the future. ··· 27 28 function Inner() { 29 const t = useTheme() 30 const gutters = useGutters([0, 'compact']) 31 const {data: trending, error, isLoading} = useTrendingTopics() 32 const noRecs = !isLoading && !error && !trending?.suggested?.length ··· 88 key={topic.link} 89 topic={topic} 90 onPress={() => { 91 - logger.metric( 92 - 'recommendedTopic:click', 93 - {context: 'explore'}, 94 - {statsig: true}, 95 - ) 96 }}> 97 {({hovered}) => ( 98 <TrendingTopic
··· 2 import {type AppBskyUnspeccedDefs} from '@atproto/api' 3 import {Trans} from '@lingui/macro' 4 5 import { 6 DEFAULT_LIMIT as RECOMMENDATIONS_COUNT, 7 useTrendingTopics, ··· 15 TrendingTopicSkeleton, 16 } from '#/components/TrendingTopics' 17 import {Text} from '#/components/Typography' 18 + import {useAnalytics} from '#/analytics' 19 import {IS_WEB} from '#/env' 20 21 // Note: This module is not currently used and may be removed in the future. ··· 27 28 function Inner() { 29 const t = useTheme() 30 + const ax = useAnalytics() 31 const gutters = useGutters([0, 'compact']) 32 const {data: trending, error, isLoading} = useTrendingTopics() 33 const noRecs = !isLoading && !error && !trending?.suggested?.length ··· 89 key={topic.link} 90 topic={topic} 91 onPress={() => { 92 + ax.metric('recommendedTopic:click', {context: 'explore'}) 93 }}> 94 {({hovered}) => ( 95 <TrendingTopic
+20 -30
src/screens/Search/modules/ExploreSuggestedAccounts.tsx
··· 9 import {logger} from '#/logger' 10 import {usePreferencesQuery} from '#/state/queries/preferences' 11 import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' 12 - import {useTheme} from '#/alf' 13 - import {atoms as a} from '#/alf' 14 import {boostInterests, InterestTabs} from '#/components/InterestTabs' 15 import * as ProfileCard from '#/components/ProfileCard' 16 import {SubtleHover} from '#/components/SubtleHover' 17 import type * as bsky from '#/types/bsky' 18 19 export function useLoadEnoughProfiles({ ··· 62 defaultTabLabel?: string 63 }) { 64 const {_} = useLingui() 65 const interestsDisplayNames = useInterestsDisplayNames() 66 const {data: preferences} = usePreferencesQuery() 67 const personalizedInterests = preferences?.interests?.tags ··· 77 selectedInterest || (hideDefaultTab ? interests[0] : 'all') 78 } 79 onSelectTab={tab => { 80 - logger.metric( 81 - 'explore:suggestedAccounts:tabPressed', 82 - {tab: tab}, 83 - {statsig: true}, 84 - ) 85 onSelectInterest(tab === 'all' ? null : tab) 86 }} 87 interestsDisplayNames={ ··· 112 position: number 113 }): React.ReactNode => { 114 const t = useTheme() 115 return ( 116 <ProfileCard.Link 117 profile={profile} 118 style={[a.flex_1]} 119 onPress={() => { 120 - logger.metric( 121 - 'suggestedUser:press', 122 - { 123 - logContext: 'Explore', 124 - recId, 125 - position, 126 - suggestedDid: profile.did, 127 - category: null, 128 - }, 129 - {statsig: true}, 130 - ) 131 }}> 132 {s => ( 133 <> ··· 157 withIcon={false} 158 logContext="ExploreSuggestedAccounts" 159 onFollow={() => { 160 - logger.metric( 161 - 'suggestedUser:follow', 162 - { 163 - logContext: 'Explore', 164 - location: 'Card', 165 - recId, 166 - position, 167 - suggestedDid: profile.did, 168 - category: null, 169 - }, 170 - {statsig: true}, 171 - ) 172 }} 173 /> 174 </ProfileCard.Header>
··· 9 import {logger} from '#/logger' 10 import {usePreferencesQuery} from '#/state/queries/preferences' 11 import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' 12 + import {atoms as a, useTheme} from '#/alf' 13 import {boostInterests, InterestTabs} from '#/components/InterestTabs' 14 import * as ProfileCard from '#/components/ProfileCard' 15 import {SubtleHover} from '#/components/SubtleHover' 16 + import {useAnalytics} from '#/analytics' 17 import type * as bsky from '#/types/bsky' 18 19 export function useLoadEnoughProfiles({ ··· 62 defaultTabLabel?: string 63 }) { 64 const {_} = useLingui() 65 + const ax = useAnalytics() 66 const interestsDisplayNames = useInterestsDisplayNames() 67 const {data: preferences} = usePreferencesQuery() 68 const personalizedInterests = preferences?.interests?.tags ··· 78 selectedInterest || (hideDefaultTab ? interests[0] : 'all') 79 } 80 onSelectTab={tab => { 81 + ax.metric('explore:suggestedAccounts:tabPressed', {tab: tab}) 82 onSelectInterest(tab === 'all' ? null : tab) 83 }} 84 interestsDisplayNames={ ··· 109 position: number 110 }): React.ReactNode => { 111 const t = useTheme() 112 + const ax = useAnalytics() 113 return ( 114 <ProfileCard.Link 115 profile={profile} 116 style={[a.flex_1]} 117 onPress={() => { 118 + ax.metric('suggestedUser:press', { 119 + logContext: 'Explore', 120 + recId, 121 + position, 122 + suggestedDid: profile.did, 123 + category: null, 124 + }) 125 }}> 126 {s => ( 127 <> ··· 151 withIcon={false} 152 logContext="ExploreSuggestedAccounts" 153 onFollow={() => { 154 + ax.metric('suggestedUser:follow', { 155 + logContext: 'Explore', 156 + location: 'Card', 157 + recId, 158 + position, 159 + suggestedDid: profile.did, 160 + category: null, 161 + }) 162 }} 163 /> 164 </ProfileCard.Header>
+3 -6
src/screens/Search/modules/ExploreTrendingTopics.tsx
··· 4 import {msg, plural, Trans} from '@lingui/macro' 5 import {useLingui} from '@lingui/react' 6 7 - import {logger} from '#/logger' 8 import {useModerationOpts} from '#/state/preferences/moderation-opts' 9 import {useTrendingSettings} from '#/state/preferences/trending' 10 import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery' ··· 19 import {Link} from '#/components/Link' 20 import {SubtleHover} from '#/components/SubtleHover' 21 import {Text} from '#/components/Typography' 22 23 const TOPIC_COUNT = 5 24 ··· 29 } 30 31 function Inner() { 32 const {data: trending, error, isLoading, isRefetching} = useGetTrendsQuery() 33 const noTopics = !isLoading && !error && !trending?.trends?.length 34 ··· 44 trend={trend} 45 rank={index + 1} 46 onPress={() => { 47 - logger.metric( 48 - 'trendingTopic:click', 49 - {context: 'explore'}, 50 - {statsig: true}, 51 - ) 52 }} 53 /> 54 ))}
··· 4 import {msg, plural, Trans} from '@lingui/macro' 5 import {useLingui} from '@lingui/react' 6 7 import {useModerationOpts} from '#/state/preferences/moderation-opts' 8 import {useTrendingSettings} from '#/state/preferences/trending' 9 import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery' ··· 18 import {Link} from '#/components/Link' 19 import {SubtleHover} from '#/components/SubtleHover' 20 import {Text} from '#/components/Typography' 21 + import {useAnalytics} from '#/analytics' 22 23 const TOPIC_COUNT = 5 24 ··· 29 } 30 31 function Inner() { 32 + const ax = useAnalytics() 33 const {data: trending, error, isLoading, isRefetching} = useGetTrendsQuery() 34 const noTopics = !isLoading && !error && !trending?.trends?.length 35 ··· 45 trend={trend} 46 rank={index + 1} 47 onPress={() => { 48 + ax.metric('trendingTopic:click', {context: 'explore'}) 49 }} 50 /> 51 ))}
+3 -6
src/screens/Search/modules/ExploreTrendingVideos.tsx
··· 8 9 import {VIDEO_FEED_URI} from '#/lib/constants' 10 import {makeCustomFeedLink} from '#/lib/routes/links' 11 - import {logger} from '#/logger' 12 import {RQKEY, usePostFeedQuery} from '#/state/queries/post-feed' 13 import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' 14 import {atoms as a, tokens, useGutters, useTheme} from '#/alf' ··· 20 CompactVideoPostCard, 21 CompactVideoPostCardPlaceholder, 22 } from '#/components/VideoPostCard' 23 24 const CARD_WIDTH = 100 25 ··· 151 }) { 152 const t = useTheme() 153 const {_} = useLingui() 154 const items = useMemo(() => { 155 return data.pages 156 .flatMap(page => page.slices) ··· 177 sourceInterstitial: 'explore', 178 }} 179 onInteract={() => { 180 - logger.metric( 181 - 'videoCard:click', 182 - {context: 'interstitial:explore'}, 183 - {statsig: true}, 184 - ) 185 }} 186 /> 187 </View>
··· 8 9 import {VIDEO_FEED_URI} from '#/lib/constants' 10 import {makeCustomFeedLink} from '#/lib/routes/links' 11 import {RQKEY, usePostFeedQuery} from '#/state/queries/post-feed' 12 import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' 13 import {atoms as a, tokens, useGutters, useTheme} from '#/alf' ··· 19 CompactVideoPostCard, 20 CompactVideoPostCardPlaceholder, 21 } from '#/components/VideoPostCard' 22 + import {useAnalytics} from '#/analytics' 23 24 const CARD_WIDTH = 100 25 ··· 151 }) { 152 const t = useTheme() 153 const {_} = useLingui() 154 + const ax = useAnalytics() 155 const items = useMemo(() => { 156 return data.pages 157 .flatMap(page => page.slices) ··· 178 sourceInterstitial: 'explore', 179 }} 180 onInteract={() => { 181 + ax.metric('videoCard:click', {context: 'interstitial:explore'}) 182 }} 183 /> 184 </View>
+2 -4
src/screens/Settings/AboutSettings.tsx
··· 1 - import {useMemo} from 'react' 2 import {Platform} from 'react-native' 3 import {setStringAsync} from 'expo-clipboard' 4 import * as FileSystem from 'expo-file-system/legacy' ··· 7 import {useLingui} from '@lingui/react' 8 import {type NativeStackScreenProps} from '@react-navigation/native-stack' 9 import {useMutation} from '@tanstack/react-query' 10 - import {Statsig} from 'statsig-react-native-expo' 11 12 import {STATUS_PAGE_URL} from '#/lib/constants' 13 import {type CommonNavigatorParams} from '#/lib/routes/types' ··· 21 import {Wrench_Stroke2_Corner2_Rounded as WrenchIcon} from '#/components/icons/Wrench' 22 import * as Layout from '#/components/Layout' 23 import {Loader} from '#/components/Loader' 24 import {IS_ANDROID, IS_IOS, IS_NATIVE} from '#/env' 25 import * as env from '#/env' 26 import {useDemoMode} from '#/storage/hooks/demo-mode' ··· 32 const {_, i18n} = useLingui() 33 const [devModeEnabled, setDevModeEnabled] = useDevMode() 34 const [demoModeEnabled, setDemoModeEnabled] = useDemoMode() 35 - const stableID = useMemo(() => Statsig.getStableID(), []) 36 37 const {mutate: onClearImageCache, isPending: isClearingImageCache} = 38 useMutation({ ··· 146 }} 147 onPress={() => { 148 setStringAsync( 149 - `Build version: ${env.APP_VERSION}; Bundle info: ${env.APP_METADATA}; Bundle date: ${env.BUNDLE_DATE}; Platform: ${Platform.OS}; Platform version: ${Platform.Version}; Anonymous ID: ${stableID}`, 150 ) 151 Toast.show(_(msg`Copied build version to clipboard`)) 152 }}>
··· 1 import {Platform} from 'react-native' 2 import {setStringAsync} from 'expo-clipboard' 3 import * as FileSystem from 'expo-file-system/legacy' ··· 6 import {useLingui} from '@lingui/react' 7 import {type NativeStackScreenProps} from '@react-navigation/native-stack' 8 import {useMutation} from '@tanstack/react-query' 9 10 import {STATUS_PAGE_URL} from '#/lib/constants' 11 import {type CommonNavigatorParams} from '#/lib/routes/types' ··· 19 import {Wrench_Stroke2_Corner2_Rounded as WrenchIcon} from '#/components/icons/Wrench' 20 import * as Layout from '#/components/Layout' 21 import {Loader} from '#/components/Loader' 22 + import {getDeviceId} from '#/analytics/identifiers' 23 import {IS_ANDROID, IS_IOS, IS_NATIVE} from '#/env' 24 import * as env from '#/env' 25 import {useDemoMode} from '#/storage/hooks/demo-mode' ··· 31 const {_, i18n} = useLingui() 32 const [devModeEnabled, setDevModeEnabled] = useDevMode() 33 const [demoModeEnabled, setDemoModeEnabled] = useDemoMode() 34 35 const {mutate: onClearImageCache, isPending: isClearingImageCache} = 36 useMutation({ ··· 144 }} 145 onPress={() => { 146 setStringAsync( 147 + `Build version: ${env.APP_VERSION}; Bundle info: ${env.APP_METADATA}; Bundle date: ${env.BUNDLE_DATE}; Platform: ${Platform.OS}; Platform version: ${Platform.Version}; Device ID: ${getDeviceId() ?? 'N/A'}`, 148 ) 149 Toast.show(_(msg`Copied build version to clipboard`)) 150 }}>
+6 -5
src/screens/Settings/ContentAndMediaSettings.tsx
··· 3 import {type NativeStackScreenProps} from '@react-navigation/native-stack' 4 5 import {type CommonNavigatorParams} from '#/lib/routes/types' 6 - import {logEvent} from '#/lib/statsig/statsig' 7 import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences' 8 import { 9 useInAppBrowser, ··· 25 import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending' 26 import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window' 27 import * as Layout from '#/components/Layout' 28 import {IS_NATIVE} from '#/env' 29 import {LiveEventFeedsSettingsToggle} from '#/features/liveEvents/components/LiveEventFeedsSettingsToggle' 30 ··· 34 > 35 export function ContentAndMediaSettingsScreen({}: Props) { 36 const {_} = useLingui() 37 const autoplayDisabledPref = useAutoplayDisabled() 38 const setAutoplayDisabledPref = useSetAutoplayDisabled() 39 const inAppBrowserPref = useInAppBrowser() ··· 135 onChange={value => { 136 const hide = Boolean(!value) 137 if (hide) { 138 - logEvent('trendingTopics:hide', {context: 'settings'}) 139 } else { 140 - logEvent('trendingTopics:show', {context: 'settings'}) 141 } 142 setTrendingDisabled(hide) 143 }}> ··· 157 onChange={value => { 158 const hide = Boolean(!value) 159 if (hide) { 160 - logEvent('trendingVideos:hide', {context: 'settings'}) 161 } else { 162 - logEvent('trendingVideos:show', {context: 'settings'}) 163 } 164 setTrendingVideoDisabled(hide) 165 }}>
··· 3 import {type NativeStackScreenProps} from '@react-navigation/native-stack' 4 5 import {type CommonNavigatorParams} from '#/lib/routes/types' 6 import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences' 7 import { 8 useInAppBrowser, ··· 24 import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending' 25 import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window' 26 import * as Layout from '#/components/Layout' 27 + import {useAnalytics} from '#/analytics' 28 import {IS_NATIVE} from '#/env' 29 import {LiveEventFeedsSettingsToggle} from '#/features/liveEvents/components/LiveEventFeedsSettingsToggle' 30 ··· 34 > 35 export function ContentAndMediaSettingsScreen({}: Props) { 36 const {_} = useLingui() 37 + const ax = useAnalytics() 38 const autoplayDisabledPref = useAutoplayDisabled() 39 const setAutoplayDisabledPref = useSetAutoplayDisabled() 40 const inAppBrowserPref = useInAppBrowser() ··· 136 onChange={value => { 137 const hide = Boolean(!value) 138 if (hide) { 139 + ax.metric('trendingTopics:hide', {context: 'settings'}) 140 } else { 141 + ax.metric('trendingTopics:show', {context: 'settings'}) 142 } 143 setTrendingDisabled(hide) 144 }}> ··· 158 onChange={value => { 159 const hide = Boolean(!value) 160 if (hide) { 161 + ax.metric('trendingVideos:hide', {context: 'settings'}) 162 } else { 163 + ax.metric('trendingVideos:show', {context: 'settings'}) 164 } 165 setTrendingVideoDisabled(hide) 166 }}>
+12 -6
src/screens/Settings/FindContactsSettings.tsx
··· 47 import * as ProfileCard from '#/components/ProfileCard' 48 import * as Toast from '#/components/Toast' 49 import {Text} from '#/components/Typography' 50 import {IS_NATIVE} from '#/env' 51 import type * as bsky from '#/types/bsky' 52 import {bulkWriteFollows} from '../Onboarding/util' ··· 54 type Props = NativeStackScreenProps<AllNavigatorParams, 'FindContactsSettings'> 55 export function FindContactsSettingsScreen({}: Props) { 56 const {_} = useLingui() 57 58 const {data, error, refetch} = useContactsSyncStatusQuery() 59 60 const isFocused = useIsFocused() 61 useEffect(() => { 62 if (data && isFocused) { 63 - logger.metric('contacts:settings:presented', { 64 hasPreviouslySynced: !!data.syncStatus, 65 matchCount: data.syncStatus?.matchesCount, 66 }) ··· 169 info: AppBskyContactDefs.SyncStatus 170 refetchStatus: () => Promise<any> 171 }) { 172 const agent = useAgent() 173 const queryClient = useQueryClient() 174 const {_} = useLingui() ··· 197 await agent.app.bsky.contact.dismissMatch({subject: did}) 198 }, 199 onMutate: async (did: string) => { 200 - logger.metric('contacts:settings:dismiss', {}) 201 optimisticRemoveMatch(queryClient, did) 202 }, 203 onError: err => { ··· 278 }) { 279 const t = useTheme() 280 const {_} = useLingui() 281 const shadow = useProfileShadow(profile) 282 283 return ( ··· 314 profile={profile} 315 moderationOpts={moderationOpts} 316 logContext="FindContacts" 317 - onFollow={() => logger.metric('contacts:settings:follow', {})} 318 /> 319 {!shadow.viewer?.following && ( 320 <Button ··· 343 isAnyUnfollowed: boolean 344 }) { 345 const {_} = useLingui() 346 const agent = useAgent() 347 const queryClient = useQueryClient() 348 const {currentAccount} = useSession() ··· 374 } 375 } while (cursor) 376 377 - logger.metric('contacts:settings:followAll', { 378 followCount: didsToFollow.length, 379 }) 380 ··· 459 function StatusFooter({syncedAt}: {syncedAt: string}) { 460 const {_, i18n} = useLingui() 461 const t = useTheme() 462 const agent = useAgent() 463 const queryClient = useQueryClient() 464 ··· 466 mutationFn: async () => { 467 await agent.app.bsky.contact.removeData({}) 468 }, 469 - onMutate: () => logger.metric('contacts:settings:removeData', {}), 470 onSuccess: () => { 471 Toast.show(_(msg`Contacts removed`)) 472 queryClient.setQueryData<AppBskyContactGetSyncStatus.OutputSchema>( ··· 520 (Date.now() - new Date(syncedAt).getTime()) / 521 (1000 * 60 * 60 * 24), 522 ) 523 - logger.metric('contacts:settings:resync', { 524 daysSinceLastSync, 525 }) 526 }}
··· 47 import * as ProfileCard from '#/components/ProfileCard' 48 import * as Toast from '#/components/Toast' 49 import {Text} from '#/components/Typography' 50 + import {useAnalytics} from '#/analytics' 51 import {IS_NATIVE} from '#/env' 52 import type * as bsky from '#/types/bsky' 53 import {bulkWriteFollows} from '../Onboarding/util' ··· 55 type Props = NativeStackScreenProps<AllNavigatorParams, 'FindContactsSettings'> 56 export function FindContactsSettingsScreen({}: Props) { 57 const {_} = useLingui() 58 + const ax = useAnalytics() 59 60 const {data, error, refetch} = useContactsSyncStatusQuery() 61 62 const isFocused = useIsFocused() 63 useEffect(() => { 64 if (data && isFocused) { 65 + ax.metric('contacts:settings:presented', { 66 hasPreviouslySynced: !!data.syncStatus, 67 matchCount: data.syncStatus?.matchesCount, 68 }) ··· 171 info: AppBskyContactDefs.SyncStatus 172 refetchStatus: () => Promise<any> 173 }) { 174 + const ax = useAnalytics() 175 const agent = useAgent() 176 const queryClient = useQueryClient() 177 const {_} = useLingui() ··· 200 await agent.app.bsky.contact.dismissMatch({subject: did}) 201 }, 202 onMutate: async (did: string) => { 203 + ax.metric('contacts:settings:dismiss', {}) 204 optimisticRemoveMatch(queryClient, did) 205 }, 206 onError: err => { ··· 281 }) { 282 const t = useTheme() 283 const {_} = useLingui() 284 + const ax = useAnalytics() 285 const shadow = useProfileShadow(profile) 286 287 return ( ··· 318 profile={profile} 319 moderationOpts={moderationOpts} 320 logContext="FindContacts" 321 + onFollow={() => ax.metric('contacts:settings:follow', {})} 322 /> 323 {!shadow.viewer?.following && ( 324 <Button ··· 347 isAnyUnfollowed: boolean 348 }) { 349 const {_} = useLingui() 350 + const ax = useAnalytics() 351 const agent = useAgent() 352 const queryClient = useQueryClient() 353 const {currentAccount} = useSession() ··· 379 } 380 } while (cursor) 381 382 + ax.metric('contacts:settings:followAll', { 383 followCount: didsToFollow.length, 384 }) 385 ··· 464 function StatusFooter({syncedAt}: {syncedAt: string}) { 465 const {_, i18n} = useLingui() 466 const t = useTheme() 467 + const ax = useAnalytics() 468 const agent = useAgent() 469 const queryClient = useQueryClient() 470 ··· 472 mutationFn: async () => { 473 await agent.app.bsky.contact.removeData({}) 474 }, 475 + onMutate: () => ax.metric('contacts:settings:removeData', {}), 476 onSuccess: () => { 477 Toast.show(_(msg`Contacts removed`)) 478 queryClient.setQueryData<AppBskyContactGetSyncStatus.OutputSchema>( ··· 526 (Date.now() - new Date(syncedAt).getTime()) / 527 (1000 * 60 * 60 * 24), 528 ) 529 + ax.metric('contacts:settings:resync', { 530 daysSinceLastSync, 531 }) 532 }}
+4 -3
src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx
··· 4 import {msg, Trans} from '@lingui/macro' 5 import {useLingui} from '@lingui/react' 6 7 - import {logger} from '#/logger' 8 import {useNotificationSettingsUpdateMutation} from '#/state/queries/notifications/settings' 9 import {atoms as a, platform, useTheme} from '#/alf' 10 import * as Toggle from '#/components/forms/Toggle' 11 import {Loader} from '#/components/Loader' 12 import {Text} from '#/components/Typography' 13 import {Divider} from '../../components/SettingsList' 14 15 export function PreferenceControls({ ··· 61 }) { 62 const t = useTheme() 63 const {_} = useLingui() 64 const {mutate} = useNotificationSettingsUpdateMutation() 65 66 const channels = useMemo(() => { ··· 77 push: change.includes('push'), 78 } satisfies typeof preference 79 80 - logger.metric('activityPreference:changeChannels', { 81 name, 82 push: newPreference.push, 83 list: newPreference.list, ··· 98 include: change, 99 } satisfies typeof preference 100 101 - logger.metric('activityPreference:changeFilter', {name, value: change}) 102 103 mutate({ 104 [name]: newPreference,
··· 4 import {msg, Trans} from '@lingui/macro' 5 import {useLingui} from '@lingui/react' 6 7 import {useNotificationSettingsUpdateMutation} from '#/state/queries/notifications/settings' 8 import {atoms as a, platform, useTheme} from '#/alf' 9 import * as Toggle from '#/components/forms/Toggle' 10 import {Loader} from '#/components/Loader' 11 import {Text} from '#/components/Typography' 12 + import {useAnalytics} from '#/analytics' 13 import {Divider} from '../../components/SettingsList' 14 15 export function PreferenceControls({ ··· 61 }) { 62 const t = useTheme() 63 const {_} = useLingui() 64 + const ax = useAnalytics() 65 const {mutate} = useNotificationSettingsUpdateMutation() 66 67 const channels = useMemo(() => { ··· 78 push: change.includes('push'), 79 } satisfies typeof preference 80 81 + ax.metric('activityPreference:changeChannels', { 82 name, 83 push: newPreference.push, 84 list: newPreference.list, ··· 99 include: change, 100 } satisfies typeof preference 101 102 + ax.metric('activityPreference:changeFilter', {name, value: change}) 103 104 mutate({ 105 [name]: newPreference,
+1 -1
src/screens/Settings/NotificationSettings/index.tsx
··· 6 import {useLingui} from '@lingui/react' 7 import {useQuery, useQueryClient} from '@tanstack/react-query' 8 9 - import {useAppState} from '#/lib/hooks/useAppState' 10 import { 11 type AllNavigatorParams, 12 type NativeStackScreenProps,
··· 6 import {useLingui} from '@lingui/react' 7 import {useQuery, useQueryClient} from '@tanstack/react-query' 8 9 + import {useAppState} from '#/lib/appState' 10 import { 11 type AllNavigatorParams, 12 type NativeStackScreenProps,
+3 -3
src/screens/Settings/Settings.tsx
··· 15 type CommonNavigatorParams, 16 type NavigationProp, 17 } from '#/lib/routes/types' 18 - import {useGate} from '#/lib/statsig/statsig' 19 import {sanitizeDisplayName} from '#/lib/strings/display-names' 20 import {sanitizeHandle} from '#/lib/strings/handles' 21 import {useProfileShadow} from '#/state/cache/profile-shadow' ··· 69 shouldShowVerificationCheckButton, 70 VerificationCheckButton, 71 } from '#/components/verification/VerificationCheckButton' 72 import {IS_INTERNAL, IS_IOS, IS_NATIVE} from '#/env' 73 import {device, useStorage} from '#/storage' 74 import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged' 75 76 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'> 77 export function SettingsScreen({}: Props) { 78 const {_} = useLingui() 79 const reducedMotion = useReducedMotion() 80 const {logoutEveryAccount} = useSessionApi() ··· 92 const [showDevOptions, setShowDevOptions] = useState(false) 93 const findContactsEnabled = 94 useIsFindContactsFeatureEnabledBasedOnGeolocation() 95 - const gate = useGate() 96 97 return ( 98 <Layout.Screen> ··· 213 </SettingsList.LinkItem> 214 {IS_NATIVE && 215 findContactsEnabled && 216 - !gate('disable_settings_find_contacts') && ( 217 <SettingsList.LinkItem 218 to="/settings/find-contacts" 219 label={_(msg`Find friends from contacts`)}>
··· 15 type CommonNavigatorParams, 16 type NavigationProp, 17 } from '#/lib/routes/types' 18 import {sanitizeDisplayName} from '#/lib/strings/display-names' 19 import {sanitizeHandle} from '#/lib/strings/handles' 20 import {useProfileShadow} from '#/state/cache/profile-shadow' ··· 68 shouldShowVerificationCheckButton, 69 VerificationCheckButton, 70 } from '#/components/verification/VerificationCheckButton' 71 + import {useAnalytics} from '#/analytics' 72 import {IS_INTERNAL, IS_IOS, IS_NATIVE} from '#/env' 73 import {device, useStorage} from '#/storage' 74 import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged' 75 76 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'> 77 export function SettingsScreen({}: Props) { 78 + const ax = useAnalytics() 79 const {_} = useLingui() 80 const reducedMotion = useReducedMotion() 81 const {logoutEveryAccount} = useSessionApi() ··· 93 const [showDevOptions, setShowDevOptions] = useState(false) 94 const findContactsEnabled = 95 useIsFindContactsFeatureEnabledBasedOnGeolocation() 96 97 return ( 98 <Layout.Screen> ··· 213 </SettingsList.LinkItem> 214 {IS_NATIVE && 215 findContactsEnabled && 216 + !ax.features.enabled(ax.features.DisableSettingsFindContacts) && ( 217 <SettingsList.LinkItem 218 to="/settings/find-contacts" 219 label={_(msg`Find friends from contacts`)}>
+7 -6
src/screens/Signup/StepCaptcha/index.tsx
··· 11 import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView' 12 import {atoms as a, useTheme} from '#/alf' 13 import {FormError} from '#/components/forms/FormError' 14 - import {IS_ANDROID, IS_IOS, IS_NATIVE, IS_WEB} from '#/env' 15 - import {GCP_PROJECT_ID} from '#/env' 16 import {BackNextButtons} from '../BackNextButtons' 17 18 const CAPTCHA_PATH = ··· 71 payload?: string 72 }) { 73 const {_} = useLingui() 74 const theme = useTheme() 75 const {state, dispatch} = useSignupContext() 76 ··· 109 const onSuccess = React.useCallback( 110 (code: string) => { 111 setCompleted(true) 112 - logger.metric('signup:captchaSuccess', {}, {statsig: true}) 113 dispatch({ 114 type: 'submit', 115 task: {verificationCode: code, mutableProcessed: false}, 116 }) 117 }, 118 - [dispatch], 119 ) 120 121 const onError = React.useCallback( ··· 124 type: 'setError', 125 value: _(msg`Error receiving captcha response.`), 126 }) 127 - logger.metric('signup:captchaFailure', {}, {statsig: true}) 128 logger.error('Signup Flow Error', { 129 registrationHandle: state.handle, 130 error, 131 }) 132 }, 133 - [_, dispatch, state.handle], 134 ) 135 136 const onBackPress = React.useCallback(() => {
··· 11 import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView' 12 import {atoms as a, useTheme} from '#/alf' 13 import {FormError} from '#/components/forms/FormError' 14 + import {useAnalytics} from '#/analytics' 15 + import {GCP_PROJECT_ID, IS_ANDROID, IS_IOS, IS_NATIVE, IS_WEB} from '#/env' 16 import {BackNextButtons} from '../BackNextButtons' 17 18 const CAPTCHA_PATH = ··· 71 payload?: string 72 }) { 73 const {_} = useLingui() 74 + const ax = useAnalytics() 75 const theme = useTheme() 76 const {state, dispatch} = useSignupContext() 77 ··· 110 const onSuccess = React.useCallback( 111 (code: string) => { 112 setCompleted(true) 113 + ax.metric('signup:captchaSuccess', {}) 114 dispatch({ 115 type: 'submit', 116 task: {verificationCode: code, mutableProcessed: false}, 117 }) 118 }, 119 + [ax, dispatch], 120 ) 121 122 const onError = React.useCallback( ··· 125 type: 'setError', 126 value: _(msg`Error receiving captcha response.`), 127 }) 128 + ax.metric('signup:captchaFailure', {}) 129 logger.error('Signup Flow Error', { 130 registrationHandle: state.handle, 131 error, 132 }) 133 }, 134 + [_, ax, dispatch, state.handle], 135 ) 136 137 const onBackPress = React.useCallback(() => {
+13 -16
src/screens/Signup/StepHandle/index.tsx
··· 26 import {At_Stroke2_Corner0_Rounded as AtIcon} from '#/components/icons/At' 27 import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' 28 import {Text} from '#/components/Typography' 29 import {BackNextButtons} from '../BackNextButtons' 30 import {HandleSuggestions} from './HandleSuggestions' 31 32 export function StepHandle() { 33 const {_} = useLingui() 34 const t = useTheme() 35 const {state, dispatch} = useSignupContext() 36 const [draftValue, setDraftValue] = useState(state.handle) ··· 68 const {available: handleAvailable} = await checkHandleAvailability( 69 createFullHandle(handle, state.userDomain), 70 state.serviceDescription?.did ?? 'UNKNOWN', 71 - {typeahead: false}, 72 ) 73 74 if (!handleAvailable) { 75 dispatch({ 76 type: 'setError', 77 value: _(msg`That username is already taken`), 78 field: 'handle', 79 }) 80 return 81 } 82 } catch (error) { 83 logger.error('Failed to check handle availability on next press', { ··· 88 dispatch({type: 'setIsLoading', value: false}) 89 } 90 91 - logger.metric( 92 - 'signup:nextPressed', 93 - { 94 - activeStep: state.activeStep, 95 - phoneVerificationRequired: 96 - state.serviceDescription?.phoneVerificationRequired, 97 - }, 98 - {statsig: true}, 99 - ) 100 // phoneVerificationRequired is actually whether a captcha is required 101 if (!state.serviceDescription?.phoneVerificationRequired) { 102 dispatch({ ··· 115 value: handle, 116 }) 117 dispatch({type: 'prev'}) 118 - logger.metric( 119 - 'signup:backPressed', 120 - {activeStep: state.activeStep}, 121 - {statsig: true}, 122 - ) 123 } 124 125 const hasDebounceSettled = draftValue === debouncedDraftValue ··· 202 state.userDomain.length * -1, 203 ), 204 ) 205 - logger.metric('signup:handleSuggestionSelected', { 206 method: suggestion.method, 207 }) 208 }}
··· 26 import {At_Stroke2_Corner0_Rounded as AtIcon} from '#/components/icons/At' 27 import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' 28 import {Text} from '#/components/Typography' 29 + import {useAnalytics} from '#/analytics' 30 import {BackNextButtons} from '../BackNextButtons' 31 import {HandleSuggestions} from './HandleSuggestions' 32 33 export function StepHandle() { 34 const {_} = useLingui() 35 + const ax = useAnalytics() 36 const t = useTheme() 37 const {state, dispatch} = useSignupContext() 38 const [draftValue, setDraftValue] = useState(state.handle) ··· 70 const {available: handleAvailable} = await checkHandleAvailability( 71 createFullHandle(handle, state.userDomain), 72 state.serviceDescription?.did ?? 'UNKNOWN', 73 + {}, 74 ) 75 76 if (!handleAvailable) { 77 + ax.metric('signup:handleTaken', {typeahead: false}) 78 dispatch({ 79 type: 'setError', 80 value: _(msg`That username is already taken`), 81 field: 'handle', 82 }) 83 return 84 + } else { 85 + ax.metric('signup:handleAvailable', {typeahead: false}) 86 } 87 } catch (error) { 88 logger.error('Failed to check handle availability on next press', { ··· 93 dispatch({type: 'setIsLoading', value: false}) 94 } 95 96 + ax.metric('signup:nextPressed', { 97 + activeStep: state.activeStep, 98 + phoneVerificationRequired: 99 + state.serviceDescription?.phoneVerificationRequired, 100 + }) 101 // phoneVerificationRequired is actually whether a captcha is required 102 if (!state.serviceDescription?.phoneVerificationRequired) { 103 dispatch({ ··· 116 value: handle, 117 }) 118 dispatch({type: 'prev'}) 119 + ax.metric('signup:backPressed', {activeStep: state.activeStep}) 120 } 121 122 const hasDebounceSettled = draftValue === debouncedDraftValue ··· 199 state.userDomain.length * -1, 200 ), 201 ) 202 + ax.metric('signup:handleSuggestionSelected', { 203 method: suggestion.method, 204 }) 205 }}
+5 -7
src/screens/Signup/StepInfo/index.tsx
··· 30 MIN_ACCESS_AGE, 31 useAgeAssuranceRegionConfigWithFallback, 32 } from '#/ageAssurance/util' 33 import {IS_NATIVE} from '#/env' 34 import { 35 useDeviceGeolocationApi, ··· 59 isLoadingStarterPack: boolean 60 }) { 61 const {_} = useLingui() 62 const {state, dispatch} = useSignupContext() 63 const preemptivelyCompleteActivePolicyUpdate = 64 usePreemptivelyCompleteActivePolicyUpdate() ··· 165 dispatch({type: 'setEmail', value: email}) 166 dispatch({type: 'setPassword', value: password}) 167 dispatch({type: 'next'}) 168 - logger.metric( 169 - 'signup:nextPressed', 170 - { 171 - activeStep: state.activeStep, 172 - }, 173 - {statsig: true}, 174 - ) 175 } 176 177 return (
··· 30 MIN_ACCESS_AGE, 31 useAgeAssuranceRegionConfigWithFallback, 32 } from '#/ageAssurance/util' 33 + import {useAnalytics} from '#/analytics' 34 import {IS_NATIVE} from '#/env' 35 import { 36 useDeviceGeolocationApi, ··· 60 isLoadingStarterPack: boolean 61 }) { 62 const {_} = useLingui() 63 + const ax = useAnalytics() 64 const {state, dispatch} = useSignupContext() 65 const preemptivelyCompleteActivePolicyUpdate = 66 usePreemptivelyCompleteActivePolicyUpdate() ··· 167 dispatch({type: 'setEmail', value: email}) 168 dispatch({type: 'setPassword', value: password}) 169 dispatch({type: 'next'}) 170 + ax.metric('signup:nextPressed', { 171 + activeStep: state.activeStep, 172 + }) 173 } 174 175 return (
+14 -3
src/screens/Signup/index.tsx
··· 29 import {InlineLinkText} from '#/components/Link' 30 import {ScreenTransition} from '#/components/ScreenTransition' 31 import {Text} from '#/components/Typography' 32 - import {IS_ANDROID} from '#/env' 33 - import {GCP_PROJECT_ID} from '#/env' 34 import * as bsky from '#/types/bsky' 35 36 export function Signup({onPressBack}: {onPressBack: () => void}) { 37 const {_} = useLingui() 38 const t = useTheme() 39 - const [state, dispatch] = useReducer(reducer, initialState) 40 const {gtMobile} = useBreakpoints() 41 const submit = useSubmitSignup() 42 43 const activeStarterPack = useActiveStarterPack() 44 const {
··· 29 import {InlineLinkText} from '#/components/Link' 30 import {ScreenTransition} from '#/components/ScreenTransition' 31 import {Text} from '#/components/Typography' 32 + import {useAnalytics} from '#/analytics' 33 + import {GCP_PROJECT_ID, IS_ANDROID} from '#/env' 34 import * as bsky from '#/types/bsky' 35 36 export function Signup({onPressBack}: {onPressBack: () => void}) { 37 + const ax = useAnalytics() 38 const {_} = useLingui() 39 const t = useTheme() 40 + const [state, dispatch] = useReducer(reducer, { 41 + ...initialState, 42 + analytics: ax, 43 + }) 44 const {gtMobile} = useBreakpoints() 45 const submit = useSubmitSignup() 46 + 47 + useEffect(() => { 48 + dispatch({ 49 + type: 'setAnalytics', 50 + value: ax, 51 + }) 52 + }, [ax]) 53 54 const activeStarterPack = useActiveStarterPack() 55 const {
+27 -23
src/screens/Signup/state.ts
··· 12 import {cleanError} from '#/lib/strings/errors' 13 import {createFullHandle} from '#/lib/strings/handles' 14 import {getAge} from '#/lib/strings/time' 15 - import {logger} from '#/logger' 16 import {useSessionApi} from '#/state/session' 17 import {useOnboardingDispatch} from '#/state/shell' 18 19 export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema 20 ··· 39 | 'date-of-birth' 40 41 export type SignupState = { 42 hasPrev: boolean 43 activeStep: SignupStep 44 screenTransitionDirection: 'Forward' | 'Backward' ··· 65 } 66 67 export type SignupAction = 68 | {type: 'prev'} 69 | {type: 'next'} 70 | {type: 'finish'} ··· 83 | {type: 'incrementBackgroundCount'} 84 85 export const initialState: SignupState = { 86 hasPrev: false, 87 activeStep: SignupStep.INFO, 88 screenTransitionDirection: 'Forward', ··· 126 let next = {...s} 127 128 switch (a.type) { 129 case 'prev': { 130 if (s.activeStep !== SignupStep.INFO) { 131 next.screenTransitionDirection = 'Backward' ··· 194 next.fieldErrors[a.field] = (next.fieldErrors[a.field] || 0) + 1 195 196 // Log the field error 197 - logger.metric( 198 - 'signup:fieldError', 199 - { 200 - field: a.field, 201 - errorCount: next.fieldErrors[a.field], 202 - errorMessage: a.value, 203 - activeStep: next.activeStep, 204 - }, 205 - {statsig: true}, 206 - ) 207 } 208 break 209 } ··· 220 next.backgroundCount = s.backgroundCount + 1 221 222 // Log background/foreground event during signup 223 - logger.metric( 224 - 'signup:backgrounded', 225 - { 226 - activeStep: next.activeStep, 227 - backgroundCount: next.backgroundCount, 228 - }, 229 - {statsig: true}, 230 - ) 231 break 232 } 233 } 234 235 next.hasPrev = next.activeStep !== SignupStep.INFO 236 237 - logger.debug('signup', next) 238 239 if (s.activeStep !== next.activeStep) { 240 - logger.debug('signup: step changed', {activeStep: next.activeStep}) 241 } 242 243 return next ··· 252 export const useSignupContext = () => React.useContext(SignupContext) 253 254 export function useSubmitSignup() { 255 const {_} = useLingui() 256 const {createAccount} = useSessionApi() 257 const onboardingDispatch = useOnboardingDispatch() ··· 295 !state.pendingSubmit?.verificationCode 296 ) { 297 dispatch({type: 'setStep', value: SignupStep.CAPTCHA}) 298 - logger.error('Signup Flow Error', { 299 errorMessage: 'Verification captcha code was not set.', 300 registrationHandle: state.handle, 301 }) ··· 358 }) 359 dispatch({type: 'setStep', value: isHandleError ? 2 : 1}) 360 361 - logger.error('Signup Flow Error', { 362 errorMessage: error, 363 registrationHandle: state.handle, 364 })
··· 12 import {cleanError} from '#/lib/strings/errors' 13 import {createFullHandle} from '#/lib/strings/handles' 14 import {getAge} from '#/lib/strings/time' 15 import {useSessionApi} from '#/state/session' 16 import {useOnboardingDispatch} from '#/state/shell' 17 + import {type AnalyticsContextType, useAnalytics} from '#/analytics' 18 19 export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema 20 ··· 39 | 'date-of-birth' 40 41 export type SignupState = { 42 + analytics?: AnalyticsContextType 43 + 44 hasPrev: boolean 45 activeStep: SignupStep 46 screenTransitionDirection: 'Forward' | 'Backward' ··· 67 } 68 69 export type SignupAction = 70 + | {type: 'setAnalytics'; value: AnalyticsContextType} 71 | {type: 'prev'} 72 | {type: 'next'} 73 | {type: 'finish'} ··· 86 | {type: 'incrementBackgroundCount'} 87 88 export const initialState: SignupState = { 89 + analytics: undefined, 90 + 91 hasPrev: false, 92 activeStep: SignupStep.INFO, 93 screenTransitionDirection: 'Forward', ··· 131 let next = {...s} 132 133 switch (a.type) { 134 + case 'setAnalytics': { 135 + next.analytics = a.value 136 + break 137 + } 138 case 'prev': { 139 if (s.activeStep !== SignupStep.INFO) { 140 next.screenTransitionDirection = 'Backward' ··· 203 next.fieldErrors[a.field] = (next.fieldErrors[a.field] || 0) + 1 204 205 // Log the field error 206 + s.analytics?.metric('signup:fieldError', { 207 + field: a.field, 208 + errorCount: next.fieldErrors[a.field], 209 + errorMessage: a.value, 210 + activeStep: next.activeStep, 211 + }) 212 } 213 break 214 } ··· 225 next.backgroundCount = s.backgroundCount + 1 226 227 // Log background/foreground event during signup 228 + s.analytics?.metric('signup:backgrounded', { 229 + activeStep: next.activeStep, 230 + backgroundCount: next.backgroundCount, 231 + }) 232 break 233 } 234 } 235 236 next.hasPrev = next.activeStep !== SignupStep.INFO 237 238 + s.analytics?.logger.debug('signup', next) 239 240 if (s.activeStep !== next.activeStep) { 241 + s.analytics?.logger.debug('signup: step changed', { 242 + activeStep: next.activeStep, 243 + }) 244 } 245 246 return next ··· 255 export const useSignupContext = () => React.useContext(SignupContext) 256 257 export function useSubmitSignup() { 258 + const ax = useAnalytics() 259 const {_} = useLingui() 260 const {createAccount} = useSessionApi() 261 const onboardingDispatch = useOnboardingDispatch() ··· 299 !state.pendingSubmit?.verificationCode 300 ) { 301 dispatch({type: 'setStep', value: SignupStep.CAPTCHA}) 302 + ax.logger.error('Signup Flow Error', { 303 errorMessage: 'Verification captcha code was not set.', 304 registrationHandle: state.handle, 305 }) ··· 362 }) 363 dispatch({type: 'setStep', value: isHandleError ? 2 : 1}) 364 365 + ax.logger.error('Signup Flow Error', { 366 errorMessage: error, 367 registrationHandle: state.handle, 368 })
+3 -2
src/screens/StarterPack/StarterPackLandingScreen.tsx
··· 13 14 import {JOINED_THIS_WEEK} from '#/lib/constants' 15 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 16 - import {logEvent} from '#/lib/statsig/statsig' 17 import {createStarterPackGooglePlayUri} from '#/lib/strings/starter-pack' 18 import {useModerationOpts} from '#/state/preferences/moderation-opts' 19 import {useStarterPackQuery} from '#/state/queries/starter-packs' ··· 36 import * as Prompt from '#/components/Prompt' 37 import {RichText} from '#/components/RichText' 38 import {Text} from '#/components/Typography' 39 import {IS_WEB, IS_WEB_MOBILE_ANDROID} from '#/env' 40 import * as bsky from '#/types/bsky' 41 ··· 119 }) { 120 const {creator, listItemsSample, feeds} = starterPack 121 const {_, i18n} = useLingui() 122 const t = useTheme() 123 const activeStarterPack = useActiveStarterPack() 124 const setActiveStarterPack = useSetActiveStarterPack() ··· 146 } else { 147 onContinue() 148 } 149 - logEvent('starterPack:ctaPress', { 150 starterPack: starterPack.uri, 151 }) 152 }
··· 13 14 import {JOINED_THIS_WEEK} from '#/lib/constants' 15 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 16 import {createStarterPackGooglePlayUri} from '#/lib/strings/starter-pack' 17 import {useModerationOpts} from '#/state/preferences/moderation-opts' 18 import {useStarterPackQuery} from '#/state/queries/starter-packs' ··· 35 import * as Prompt from '#/components/Prompt' 36 import {RichText} from '#/components/RichText' 37 import {Text} from '#/components/Typography' 38 + import {useAnalytics} from '#/analytics' 39 import {IS_WEB, IS_WEB_MOBILE_ANDROID} from '#/env' 40 import * as bsky from '#/types/bsky' 41 ··· 119 }) { 120 const {creator, listItemsSample, feeds} = starterPack 121 const {_, i18n} = useLingui() 122 + const ax = useAnalytics() 123 const t = useTheme() 124 const activeStarterPack = useActiveStarterPack() 125 const setActiveStarterPack = useSetActiveStarterPack() ··· 147 } else { 148 onContinue() 149 } 150 + ax.metric('starterPack:ctaPress', { 151 starterPack: starterPack.uri, 152 }) 153 }
+15 -10
src/screens/StarterPack/StarterPackScreen.tsx
··· 23 type CommonNavigatorParams, 24 type NavigationProp, 25 } from '#/lib/routes/types' 26 - import {logEvent} from '#/lib/statsig/statsig' 27 import {cleanError} from '#/lib/strings/errors' 28 import {getStarterPackOgCard} from '#/lib/strings/starter-pack' 29 import {logger} from '#/logger' ··· 33 import {useResolvedStarterPackShortLink} from '#/state/queries/resolve-short-link' 34 import {useResolveDidQuery} from '#/state/queries/resolve-uri' 35 import {useShortenLink} from '#/state/queries/shorten-link' 36 - import {useDeleteStarterPackMutation} from '#/state/queries/starter-packs' 37 - import {useStarterPackQuery} from '#/state/queries/starter-packs' 38 import {useAgent, useSession} from '#/state/session' 39 import {useLoggedOutViewControls} from '#/state/shell/logged-out' 40 import { ··· 71 import {QrCodeDialog} from '#/components/StarterPack/QrCodeDialog' 72 import {ShareDialog} from '#/components/StarterPack/ShareDialog' 73 import {Text} from '#/components/Typography' 74 import {IS_WEB} from '#/env' 75 import * as bsky from '#/types/bsky' 76 ··· 184 const showFeedsTab = Boolean(starterPack.feeds?.length) 185 const showPostsTab = Boolean(starterPack.list) 186 const {_} = useLingui() 187 188 const tabs = [ 189 ...(showPeopleTab ? [_(msg`People`)] : []), ··· 199 const [imageLoaded, setImageLoaded] = React.useState(false) 200 201 React.useEffect(() => { 202 - logEvent('starterPack:opened', { 203 starterPack: starterPack.uri, 204 }) 205 - }, [starterPack.uri]) 206 207 const onOpenShareDialog = React.useCallback(() => { 208 const rkey = new AtUri(starterPack.uri).rkey ··· 243 ? ({headerHeight, scrollElRef}) => ( 244 <ProfilesList 245 // Validated above 246 - listUri={starterPack!.list!.uri} 247 headerHeight={headerHeight} 248 // @ts-expect-error 249 scrollElRef={scrollElRef} ··· 266 ? ({headerHeight, scrollElRef}) => ( 267 <PostsList 268 // Validated above 269 - listUri={starterPack!.list!.uri} 270 headerHeight={headerHeight} 271 // @ts-expect-error 272 scrollElRef={scrollElRef} ··· 315 const {record, creator} = starterPack 316 const isOwn = creator?.did === currentAccount?.did 317 const joinedAllTimeCount = starterPack.joinedAllTimeCount ?? 0 318 319 const navigation = useNavigation<NavigationProp>() 320 ··· 385 }) 386 Toast.show(_(msg`All accounts have been followed!`)) 387 captureAction(ProgressGuideAction.Follow, dids.length) 388 - logEvent('starterPack:followAll', { 389 logContext: 'StarterPackProfilesList', 390 starterPack: starterPack.uri, 391 count: dids.length, ··· 516 }) { 517 const t = useTheme() 518 const {_} = useLingui() 519 const {gtMobile} = useBreakpoints() 520 const {currentAccount} = useSession() 521 const reportDialogControl = useReportDialogControl() ··· 528 error: deleteError, 529 } = useDeleteStarterPackMutation({ 530 onSuccess: () => { 531 - logEvent('starterPack:delete', {}) 532 deleteDialogControl.close(() => { 533 if (navigation.canGoBack()) { 534 navigation.popToTop() ··· 554 rkey: routeParams.rkey, 555 listUri: starterPack.list.uri, 556 }) 557 - logEvent('starterPack:delete', {}) 558 } 559 560 return (
··· 23 type CommonNavigatorParams, 24 type NavigationProp, 25 } from '#/lib/routes/types' 26 import {cleanError} from '#/lib/strings/errors' 27 import {getStarterPackOgCard} from '#/lib/strings/starter-pack' 28 import {logger} from '#/logger' ··· 32 import {useResolvedStarterPackShortLink} from '#/state/queries/resolve-short-link' 33 import {useResolveDidQuery} from '#/state/queries/resolve-uri' 34 import {useShortenLink} from '#/state/queries/shorten-link' 35 + import { 36 + useDeleteStarterPackMutation, 37 + useStarterPackQuery, 38 + } from '#/state/queries/starter-packs' 39 import {useAgent, useSession} from '#/state/session' 40 import {useLoggedOutViewControls} from '#/state/shell/logged-out' 41 import { ··· 72 import {QrCodeDialog} from '#/components/StarterPack/QrCodeDialog' 73 import {ShareDialog} from '#/components/StarterPack/ShareDialog' 74 import {Text} from '#/components/Typography' 75 + import {useAnalytics} from '#/analytics' 76 import {IS_WEB} from '#/env' 77 import * as bsky from '#/types/bsky' 78 ··· 186 const showFeedsTab = Boolean(starterPack.feeds?.length) 187 const showPostsTab = Boolean(starterPack.list) 188 const {_} = useLingui() 189 + const ax = useAnalytics() 190 191 const tabs = [ 192 ...(showPeopleTab ? [_(msg`People`)] : []), ··· 202 const [imageLoaded, setImageLoaded] = React.useState(false) 203 204 React.useEffect(() => { 205 + ax.metric('starterPack:opened', { 206 starterPack: starterPack.uri, 207 }) 208 + }, [ax, starterPack.uri]) 209 210 const onOpenShareDialog = React.useCallback(() => { 211 const rkey = new AtUri(starterPack.uri).rkey ··· 246 ? ({headerHeight, scrollElRef}) => ( 247 <ProfilesList 248 // Validated above 249 + listUri={starterPack.list!.uri} 250 headerHeight={headerHeight} 251 // @ts-expect-error 252 scrollElRef={scrollElRef} ··· 269 ? ({headerHeight, scrollElRef}) => ( 270 <PostsList 271 // Validated above 272 + listUri={starterPack.list!.uri} 273 headerHeight={headerHeight} 274 // @ts-expect-error 275 scrollElRef={scrollElRef} ··· 318 const {record, creator} = starterPack 319 const isOwn = creator?.did === currentAccount?.did 320 const joinedAllTimeCount = starterPack.joinedAllTimeCount ?? 0 321 + const ax = useAnalytics() 322 323 const navigation = useNavigation<NavigationProp>() 324 ··· 389 }) 390 Toast.show(_(msg`All accounts have been followed!`)) 391 captureAction(ProgressGuideAction.Follow, dids.length) 392 + ax.metric('starterPack:followAll', { 393 logContext: 'StarterPackProfilesList', 394 starterPack: starterPack.uri, 395 count: dids.length, ··· 520 }) { 521 const t = useTheme() 522 const {_} = useLingui() 523 + const ax = useAnalytics() 524 const {gtMobile} = useBreakpoints() 525 const {currentAccount} = useSession() 526 const reportDialogControl = useReportDialogControl() ··· 533 error: deleteError, 534 } = useDeleteStarterPackMutation({ 535 onSuccess: () => { 536 + ax.metric('starterPack:delete', {}) 537 deleteDialogControl.close(() => { 538 if (navigation.canGoBack()) { 539 navigation.popToTop() ··· 559 rkey: routeParams.rkey, 560 listUri: starterPack.list.uri, 561 }) 562 + ax.metric('starterPack:delete', {}) 563 } 564 565 return (
+4 -3
src/screens/StarterPack/Wizard/index.tsx
··· 22 type CommonNavigatorParams, 23 type NavigationProp, 24 } from '#/lib/routes/types' 25 - import {logEvent} from '#/lib/statsig/statsig' 26 import {sanitizeDisplayName} from '#/lib/strings/display-names' 27 import {sanitizeHandle} from '#/lib/strings/handles' 28 import {enforceLen} from '#/lib/strings/helpers' ··· 58 import {Loader} from '#/components/Loader' 59 import {WizardEditListDialog} from '#/components/StarterPack/Wizard/WizardEditListDialog' 60 import {Text} from '#/components/Typography' 61 import {IS_NATIVE} from '#/env' 62 import type * as bsky from '#/types/bsky' 63 import {Provider} from './State' ··· 167 onSuccess?: () => void 168 }) { 169 const navigation = useNavigation<NavigationProp>() 170 const {_} = useLingui() 171 const setMinimalShellMode = useSetMinimalShellMode() 172 const [state, dispatch] = useWizardState() ··· 222 223 const onSuccessCreate = (data: {uri: string; cid: string}) => { 224 const rkey = new AtUri(data.uri).rkey 225 - logEvent('starterPack:create', { 226 setName: state.name != null, 227 setDescription: state.description != null, 228 profilesCount: state.profiles.length, ··· 236 onSuccess?.() 237 } else { 238 navigation.replace('StarterPack', { 239 - name: profile!.handle, 240 rkey, 241 new: true, 242 })
··· 22 type CommonNavigatorParams, 23 type NavigationProp, 24 } from '#/lib/routes/types' 25 import {sanitizeDisplayName} from '#/lib/strings/display-names' 26 import {sanitizeHandle} from '#/lib/strings/handles' 27 import {enforceLen} from '#/lib/strings/helpers' ··· 57 import {Loader} from '#/components/Loader' 58 import {WizardEditListDialog} from '#/components/StarterPack/Wizard/WizardEditListDialog' 59 import {Text} from '#/components/Typography' 60 + import {useAnalytics} from '#/analytics' 61 import {IS_NATIVE} from '#/env' 62 import type * as bsky from '#/types/bsky' 63 import {Provider} from './State' ··· 167 onSuccess?: () => void 168 }) { 169 const navigation = useNavigation<NavigationProp>() 170 + const ax = useAnalytics() 171 const {_} = useLingui() 172 const setMinimalShellMode = useSetMinimalShellMode() 173 const [state, dispatch] = useWizardState() ··· 223 224 const onSuccessCreate = (data: {uri: string; cid: string}) => { 225 const rkey = new AtUri(data.uri).rkey 226 + ax.metric('starterPack:create', { 227 setName: state.name != null, 228 setDescription: state.description != null, 229 profilesCount: state.profiles.length, ··· 237 onSuccess?.() 238 } else { 239 navigation.replace('StarterPack', { 240 + name: profile.handle, 241 rkey, 242 new: true, 243 })
+12 -18
src/screens/VideoFeed/index.tsx
··· 21 useSafeAreaFrame, 22 useSafeAreaInsets, 23 } from 'react-native-safe-area-context' 24 - import {useEvent} from 'expo' 25 - import {useEventListener} from 'expo' 26 import {Image, type ImageStyle} from 'expo-image' 27 import {LinearGradient} from 'expo-linear-gradient' 28 import {createVideoPlayer, type VideoPlayer, VideoView} from 'expo-video' ··· 66 import {useProfileShadow} from '#/state/cache/profile-shadow' 67 import { 68 FeedFeedbackProvider, 69 useFeedFeedbackContext, 70 } from '#/state/feed-feedback' 71 - import {useFeedFeedback} from '#/state/feed-feedback' 72 import {useFeedInfo} from '#/state/queries/feed' 73 import {usePostLikeMutationQueue} from '#/state/queries/post' 74 import { 75 - type AuthorFilter, 76 type FeedPostSliceItem, 77 usePostFeedQuery, 78 } from '#/state/queries/post-feed' ··· 100 import {PostControls} from '#/components/PostControls' 101 import {RichText} from '#/components/RichText' 102 import {Text} from '#/components/Typography' 103 import {IS_ANDROID} from '#/env' 104 import * as bsky from '#/types/bsky' 105 import {Scrubber, VIDEO_PLAYER_BOTTOM_INSET} from './components/Scrubber' ··· 192 const feedDesc = useMemo(() => { 193 switch (params.type) { 194 case 'feedgen': 195 - return `feedgen|${params.uri as string}` as const 196 case 'author': 197 - return `author|${params.did as string}|${ 198 - params.filter as AuthorFilter 199 - }` as const 200 default: 201 throw new Error(`Invalid video feed params ${JSON.stringify(params)}`) 202 } ··· 490 feedContext: string | undefined 491 reqId: string | undefined 492 }): React.ReactNode => { 493 const postShadow = usePostShadow(post) 494 const {width, height} = useSafeAreaFrame() 495 const {sendInteraction, feedDescriptor} = useFeedFeedbackContext() ··· 507 // Track post:view event 508 if (!hasTrackedView.current) { 509 hasTrackedView.current = true 510 - logger.metric( 511 - 'post:view', 512 - { 513 - uri: post.uri, 514 - authorDid: post.author.did, 515 - logContext: 'ImmersiveVideo', 516 - feedDescriptor, 517 - }, 518 - {statsig: false}, 519 - ) 520 } 521 } 522 }, [
··· 21 useSafeAreaFrame, 22 useSafeAreaInsets, 23 } from 'react-native-safe-area-context' 24 + import {useEvent, useEventListener} from 'expo' 25 import {Image, type ImageStyle} from 'expo-image' 26 import {LinearGradient} from 'expo-linear-gradient' 27 import {createVideoPlayer, type VideoPlayer, VideoView} from 'expo-video' ··· 65 import {useProfileShadow} from '#/state/cache/profile-shadow' 66 import { 67 FeedFeedbackProvider, 68 + useFeedFeedback, 69 useFeedFeedbackContext, 70 } from '#/state/feed-feedback' 71 import {useFeedInfo} from '#/state/queries/feed' 72 import {usePostLikeMutationQueue} from '#/state/queries/post' 73 import { 74 type FeedPostSliceItem, 75 usePostFeedQuery, 76 } from '#/state/queries/post-feed' ··· 98 import {PostControls} from '#/components/PostControls' 99 import {RichText} from '#/components/RichText' 100 import {Text} from '#/components/Typography' 101 + import {useAnalytics} from '#/analytics' 102 import {IS_ANDROID} from '#/env' 103 import * as bsky from '#/types/bsky' 104 import {Scrubber, VIDEO_PLAYER_BOTTOM_INSET} from './components/Scrubber' ··· 191 const feedDesc = useMemo(() => { 192 switch (params.type) { 193 case 'feedgen': 194 + return `feedgen|${params.uri}` as const 195 case 'author': 196 + return `author|${params.did}|${params.filter}` as const 197 default: 198 throw new Error(`Invalid video feed params ${JSON.stringify(params)}`) 199 } ··· 487 feedContext: string | undefined 488 reqId: string | undefined 489 }): React.ReactNode => { 490 + const ax = useAnalytics() 491 const postShadow = usePostShadow(post) 492 const {width, height} = useSafeAreaFrame() 493 const {sendInteraction, feedDescriptor} = useFeedFeedbackContext() ··· 505 // Track post:view event 506 if (!hasTrackedView.current) { 507 hasTrackedView.current = true 508 + ax.metric('post:view', { 509 + uri: post.uri, 510 + authorDid: post.author.did, 511 + logContext: 'ImmersiveVideo', 512 + feedDescriptor, 513 + }) 514 } 515 } 516 }, [
+38 -40
src/state/feed-feedback.tsx
··· 11 import throttle from 'lodash.throttle' 12 13 import {PROD_FEEDS, STAGING_FEEDS} from '#/lib/constants' 14 - import {Logger} from '#/logger' 15 import { 16 type FeedSourceFeedInfo, 17 type FeedSourceInfo, ··· 22 type FeedPostSliceItem, 23 } from '#/state/queries/post-feed' 24 import {getItemsForFeedback} from '#/view/com/posts/PostFeed' 25 import {useAgent} from './session' 26 27 export const FEEDBACK_FEEDS = [...PROD_FEEDS, ...STAGING_FEEDS] ··· 42 'app.bsky.feed.defs#interactionSeen', 43 ]) 44 45 - const logger = Logger.create(Logger.Context.FeedFeedback) 46 - 47 export type StateContext = { 48 enabled: boolean 49 onItemSeen: (item: any) => void ··· 65 feedSourceInfo: FeedSourceInfo | undefined, 66 hasSession: boolean, 67 ) { 68 const agent = useAgent() 69 70 const feed = ··· 85 WeakSet<FeedPostSliceItem | AppBskyFeedDefs.Interaction> 86 >(new WeakSet()) 87 88 const aggregatedStats = useRef<AggregatedStats | null>(null) 89 const throttledFlushAggregatedStats = useMemo( 90 () => 91 throttle( 92 () => 93 - flushToStatsig( 94 aggregatedStats.current, 95 feed?.feedDescriptor ?? 'unknown', 96 ), ··· 100 trailing: true, 101 }, 102 ), 103 - [feed?.feedDescriptor], 104 ) 105 106 const sendToFeedNoDelay = useCallback(() => { ··· 130 ) 131 .catch(() => {}) // ignore upstream errors 132 133 - // Send to Statsig 134 if (aggregatedStats.current === null) { 135 aggregatedStats.current = createAggregatedStats() 136 } ··· 299 } 300 } 301 } 302 - 303 - function flushToStatsig(stats: AggregatedStats | null, feedDescriptor: string) { 304 - if (stats === null) { 305 - return 306 - } 307 - 308 - if (stats.clickthroughCount > 0) { 309 - logger.metric('feed:clickthrough', { 310 - count: stats.clickthroughCount, 311 - feed: feedDescriptor, 312 - }) 313 - stats.clickthroughCount = 0 314 - } 315 - 316 - if (stats.engagedCount > 0) { 317 - logger.metric('feed:engaged', { 318 - count: stats.engagedCount, 319 - feed: feedDescriptor, 320 - }) 321 - stats.engagedCount = 0 322 - } 323 - 324 - if (stats.seenCount > 0) { 325 - logger.metric( 326 - 'feed:seen', 327 - { 328 - count: stats.seenCount, 329 - feed: feedDescriptor, 330 - }, 331 - {statsig: false}, 332 - ) 333 - stats.seenCount = 0 334 - } 335 - }
··· 11 import throttle from 'lodash.throttle' 12 13 import {PROD_FEEDS, STAGING_FEEDS} from '#/lib/constants' 14 import { 15 type FeedSourceFeedInfo, 16 type FeedSourceInfo, ··· 21 type FeedPostSliceItem, 22 } from '#/state/queries/post-feed' 23 import {getItemsForFeedback} from '#/view/com/posts/PostFeed' 24 + import {useAnalytics} from '#/analytics' 25 import {useAgent} from './session' 26 27 export const FEEDBACK_FEEDS = [...PROD_FEEDS, ...STAGING_FEEDS] ··· 42 'app.bsky.feed.defs#interactionSeen', 43 ]) 44 45 export type StateContext = { 46 enabled: boolean 47 onItemSeen: (item: any) => void ··· 63 feedSourceInfo: FeedSourceInfo | undefined, 64 hasSession: boolean, 65 ) { 66 + const ax = useAnalytics() 67 + const logger = ax.logger.useChild(ax.logger.Context.FeedFeedback) 68 const agent = useAgent() 69 70 const feed = ··· 85 WeakSet<FeedPostSliceItem | AppBskyFeedDefs.Interaction> 86 >(new WeakSet()) 87 88 + const flushEvents = useCallback( 89 + (stats: AggregatedStats | null, feedDescriptor: string) => { 90 + if (stats === null) { 91 + return 92 + } 93 + 94 + if (stats.clickthroughCount > 0) { 95 + ax.metric('feed:clickthrough', { 96 + count: stats.clickthroughCount, 97 + feed: feedDescriptor, 98 + }) 99 + stats.clickthroughCount = 0 100 + } 101 + 102 + if (stats.engagedCount > 0) { 103 + ax.metric('feed:engaged', { 104 + count: stats.engagedCount, 105 + feed: feedDescriptor, 106 + }) 107 + stats.engagedCount = 0 108 + } 109 + 110 + if (stats.seenCount > 0) { 111 + ax.metric('feed:seen', { 112 + count: stats.seenCount, 113 + feed: feedDescriptor, 114 + }) 115 + stats.seenCount = 0 116 + } 117 + }, 118 + [ax], 119 + ) 120 + 121 const aggregatedStats = useRef<AggregatedStats | null>(null) 122 const throttledFlushAggregatedStats = useMemo( 123 () => 124 throttle( 125 () => 126 + flushEvents( 127 aggregatedStats.current, 128 feed?.feedDescriptor ?? 'unknown', 129 ), ··· 133 trailing: true, 134 }, 135 ), 136 + [feed?.feedDescriptor, flushEvents], 137 ) 138 139 const sendToFeedNoDelay = useCallback(() => { ··· 163 ) 164 .catch(() => {}) // ignore upstream errors 165 166 if (aggregatedStats.current === null) { 167 aggregatedStats.current = createAggregatedStats() 168 } ··· 331 } 332 } 333 }
+1 -1
src/state/messages/convo/index.tsx
··· 3 import {useFocusEffect} from '@react-navigation/native' 4 import {useQueryClient} from '@tanstack/react-query' 5 6 - import {useAppState} from '#/lib/hooks/useAppState' 7 import {Convo} from '#/state/messages/convo/agent' 8 import { 9 type ConvoParams,
··· 3 import {useFocusEffect} from '@react-navigation/native' 4 import {useQueryClient} from '@tanstack/react-query' 5 6 + import {useAppState} from '#/lib/appState' 7 import {Convo} from '#/state/messages/convo/agent' 8 import { 9 type ConvoParams,
+12 -1
src/state/preferences/languages.tsx
··· 2 3 import {type AppLanguage} from '#/locale/languages' 4 import * as persisted from '#/state/persisted' 5 6 type SetStateCb = ( 7 s: persisted.Schema['languagePrefs'], ··· 124 125 return ( 126 <stateContext.Provider value={state}> 127 - <apiContext.Provider value={api}>{children}</apiContext.Provider> 128 </stateContext.Provider> 129 ) 130 }
··· 2 3 import {type AppLanguage} from '#/locale/languages' 4 import * as persisted from '#/state/persisted' 5 + import {AnalyticsContext, utils} from '#/analytics' 6 7 type SetStateCb = ( 8 s: persisted.Schema['languagePrefs'], ··· 125 126 return ( 127 <stateContext.Provider value={state}> 128 + <apiContext.Provider value={api}> 129 + <AnalyticsContext 130 + metadata={utils.useMeta({ 131 + preferences: { 132 + appLanguage: state.appLanguage, 133 + contentLanguages: state.contentLanguages, 134 + }, 135 + })}> 136 + {children} 137 + </AnalyticsContext> 138 + </apiContext.Provider> 139 </stateContext.Provider> 140 ) 141 }
+9 -10
src/state/queries/handle-availability.ts
··· 7 PUBLIC_BSKY_SERVICE, 8 } from '#/lib/constants' 9 import {createFullHandle} from '#/lib/strings/handles' 10 - import {logger} from '#/logger' 11 import {useDebouncedValue} from '#/components/live/utils' 12 import * as bsky from '#/types/bsky' 13 import {Agent} from '../session/agent' 14 ··· 36 }, 37 debounceDelayMs = 500, 38 ) { 39 const name = username.trim() 40 const debouncedHandle = useDebouncedValue(name, debounceDelayMs) 41 ··· 51 ), 52 queryFn: async () => { 53 const handle = createFullHandle(name, serviceDomain) 54 - return await checkHandleAvailability(handle, serviceDid, { 55 email, 56 birthDate, 57 - typeahead: true, 58 }) 59 }, 60 }), 61 } ··· 67 { 68 email, 69 birthDate, 70 - typeahead, 71 }: { 72 email?: string 73 birthDate?: string 74 - typeahead?: boolean 75 }, 76 ) { 77 if (serviceDid === BSKY_SERVICE_DID) { ··· 89 ComAtprotoTempCheckHandleAvailability.isResultAvailable, 90 ) 91 ) { 92 - logger.metric('signup:handleAvailable', {typeahead}, {statsig: true}) 93 - 94 return {available: true} as const 95 } else if ( 96 bsky.dangerousIsType<ComAtprotoTempCheckHandleAvailability.ResultUnavailable>( ··· 98 ComAtprotoTempCheckHandleAvailability.isResultUnavailable, 99 ) 100 ) { 101 - logger.metric('signup:handleTaken', {typeahead}, {statsig: true}) 102 return { 103 available: false, 104 suggestions: data.result.suggestions, ··· 117 }) 118 119 if (res.data.did) { 120 - logger.metric('signup:handleTaken', {typeahead}, {statsig: true}) 121 return {available: false} as const 122 } 123 } catch {} 124 - logger.metric('signup:handleAvailable', {typeahead}, {statsig: true}) 125 return {available: true} as const 126 } 127 }
··· 7 PUBLIC_BSKY_SERVICE, 8 } from '#/lib/constants' 9 import {createFullHandle} from '#/lib/strings/handles' 10 import {useDebouncedValue} from '#/components/live/utils' 11 + import {useAnalytics} from '#/analytics' 12 import * as bsky from '#/types/bsky' 13 import {Agent} from '../session/agent' 14 ··· 36 }, 37 debounceDelayMs = 500, 38 ) { 39 + const ax = useAnalytics() 40 const name = username.trim() 41 const debouncedHandle = useDebouncedValue(name, debounceDelayMs) 42 ··· 52 ), 53 queryFn: async () => { 54 const handle = createFullHandle(name, serviceDomain) 55 + const res = await checkHandleAvailability(handle, serviceDid, { 56 email, 57 birthDate, 58 }) 59 + if (res.available) { 60 + ax.metric('signup:handleAvailable', {typeahead: true}) 61 + } else { 62 + ax.metric('signup:handleTaken', {typeahead: true}) 63 + } 64 + return res 65 }, 66 }), 67 } ··· 73 { 74 email, 75 birthDate, 76 }: { 77 email?: string 78 birthDate?: string 79 }, 80 ) { 81 if (serviceDid === BSKY_SERVICE_DID) { ··· 93 ComAtprotoTempCheckHandleAvailability.isResultAvailable, 94 ) 95 ) { 96 return {available: true} as const 97 } else if ( 98 bsky.dangerousIsType<ComAtprotoTempCheckHandleAvailability.ResultUnavailable>( ··· 100 ComAtprotoTempCheckHandleAvailability.isResultUnavailable, 101 ) 102 ) { 103 return { 104 available: false, 105 suggestions: data.result.suggestions, ··· 118 }) 119 120 if (res.data.did) { 121 return {available: false} as const 122 } 123 } catch {} 124 return {available: true} as const 125 } 126 }
+16 -14
src/state/queries/post.ts
··· 3 import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' 4 5 import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' 6 - import {type LogEvents, toClout} from '#/lib/statsig/statsig' 7 - import {logger} from '#/logger' 8 import {updatePostShadow} from '#/state/cache/post-shadow' 9 import {type Shadow} from '#/state/cache/types' 10 import {useAgent, useSession} from '#/state/session' 11 import * as userActionHistory from '#/state/userActionHistory' 12 import {useIsThreadMuted, useSetThreadMute} from '../cache/thread-mutes' 13 import {findProfileQueryData} from './profile' 14 ··· 103 post: Shadow<AppBskyFeedDefs.PostView>, 104 viaRepost: {uri: string; cid: string} | undefined, 105 feedDescriptor: string | undefined, 106 - logContext: LogEvents['post:like']['logContext'] & 107 - LogEvents['post:unlike']['logContext'], 108 ) { 109 const queryClient = useQueryClient() 110 const postUri = post.uri ··· 164 165 function usePostLikeMutation( 166 feedDescriptor: string | undefined, 167 - logContext: LogEvents['post:like']['logContext'], 168 post: Shadow<AppBskyFeedDefs.PostView>, 169 ) { 170 const {currentAccount} = useSession() 171 const queryClient = useQueryClient() 172 const postAuthor = post.author 173 const agent = useAgent() 174 return useMutation< 175 {uri: string}, // responds with the uri of the like 176 Error, ··· 181 if (currentAccount) { 182 ownProfile = findProfileQueryData(queryClient, currentAccount.did) 183 } 184 - logger.metric('post:like', { 185 uri, 186 authorDid: postAuthor.did, 187 logContext, ··· 207 208 function usePostUnlikeMutation( 209 feedDescriptor: string | undefined, 210 - logContext: LogEvents['post:unlike']['logContext'], 211 post: Shadow<AppBskyFeedDefs.PostView>, 212 ) { 213 const agent = useAgent() 214 return useMutation<void, Error, {postUri: string; likeUri: string}>({ 215 mutationFn: ({postUri, likeUri}) => { 216 - logger.metric('post:unlike', { 217 uri: postUri, 218 authorDid: post.author.did, 219 logContext, ··· 228 post: Shadow<AppBskyFeedDefs.PostView>, 229 viaRepost: {uri: string; cid: string} | undefined, 230 feedDescriptor: string | undefined, 231 - logContext: LogEvents['post:repost']['logContext'] & 232 - LogEvents['post:unrepost']['logContext'], 233 ) { 234 const queryClient = useQueryClient() 235 const postUri = post.uri ··· 291 292 function usePostRepostMutation( 293 feedDescriptor: string | undefined, 294 - logContext: LogEvents['post:repost']['logContext'], 295 post: Shadow<AppBskyFeedDefs.PostView>, 296 ) { 297 const agent = useAgent() 298 return useMutation< 299 {uri: string}, // responds with the uri of the repost 300 Error, 301 {uri: string; cid: string; via?: {uri: string; cid: string}} // the post's uri and cid, and the repost uri/cid if present 302 >({ 303 mutationFn: ({uri, cid, via}) => { 304 - logger.metric('post:repost', { 305 uri, 306 authorDid: post.author.did, 307 logContext, ··· 314 315 function usePostUnrepostMutation( 316 feedDescriptor: string | undefined, 317 - logContext: LogEvents['post:unrepost']['logContext'], 318 post: Shadow<AppBskyFeedDefs.PostView>, 319 ) { 320 const agent = useAgent() 321 return useMutation<void, Error, {postUri: string; repostUri: string}>({ 322 mutationFn: ({postUri, repostUri}) => { 323 - logger.metric('post:unrepost', { 324 uri: postUri, 325 authorDid: post.author.did, 326 logContext,
··· 3 import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' 4 5 import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' 6 import {updatePostShadow} from '#/state/cache/post-shadow' 7 import {type Shadow} from '#/state/cache/types' 8 import {useAgent, useSession} from '#/state/session' 9 import * as userActionHistory from '#/state/userActionHistory' 10 + import {useAnalytics} from '#/analytics' 11 + import {type Metrics, toClout} from '#/analytics/metrics' 12 import {useIsThreadMuted, useSetThreadMute} from '../cache/thread-mutes' 13 import {findProfileQueryData} from './profile' 14 ··· 103 post: Shadow<AppBskyFeedDefs.PostView>, 104 viaRepost: {uri: string; cid: string} | undefined, 105 feedDescriptor: string | undefined, 106 + logContext: Metrics['post:like']['logContext'], 107 ) { 108 const queryClient = useQueryClient() 109 const postUri = post.uri ··· 163 164 function usePostLikeMutation( 165 feedDescriptor: string | undefined, 166 + logContext: Metrics['post:like']['logContext'], 167 post: Shadow<AppBskyFeedDefs.PostView>, 168 ) { 169 const {currentAccount} = useSession() 170 const queryClient = useQueryClient() 171 const postAuthor = post.author 172 const agent = useAgent() 173 + const ax = useAnalytics() 174 return useMutation< 175 {uri: string}, // responds with the uri of the like 176 Error, ··· 181 if (currentAccount) { 182 ownProfile = findProfileQueryData(queryClient, currentAccount.did) 183 } 184 + ax.metric('post:like', { 185 uri, 186 authorDid: postAuthor.did, 187 logContext, ··· 207 208 function usePostUnlikeMutation( 209 feedDescriptor: string | undefined, 210 + logContext: Metrics['post:unlike']['logContext'], 211 post: Shadow<AppBskyFeedDefs.PostView>, 212 ) { 213 const agent = useAgent() 214 + const ax = useAnalytics() 215 return useMutation<void, Error, {postUri: string; likeUri: string}>({ 216 mutationFn: ({postUri, likeUri}) => { 217 + ax.metric('post:unlike', { 218 uri: postUri, 219 authorDid: post.author.did, 220 logContext, ··· 229 post: Shadow<AppBskyFeedDefs.PostView>, 230 viaRepost: {uri: string; cid: string} | undefined, 231 feedDescriptor: string | undefined, 232 + logContext: Metrics['post:repost']['logContext'], 233 ) { 234 const queryClient = useQueryClient() 235 const postUri = post.uri ··· 291 292 function usePostRepostMutation( 293 feedDescriptor: string | undefined, 294 + logContext: Metrics['post:repost']['logContext'], 295 post: Shadow<AppBskyFeedDefs.PostView>, 296 ) { 297 const agent = useAgent() 298 + const ax = useAnalytics() 299 return useMutation< 300 {uri: string}, // responds with the uri of the repost 301 Error, 302 {uri: string; cid: string; via?: {uri: string; cid: string}} // the post's uri and cid, and the repost uri/cid if present 303 >({ 304 mutationFn: ({uri, cid, via}) => { 305 + ax.metric('post:repost', { 306 uri, 307 authorDid: post.author.did, 308 logContext, ··· 315 316 function usePostUnrepostMutation( 317 feedDescriptor: string | undefined, 318 + logContext: Metrics['post:unrepost']['logContext'], 319 post: Shadow<AppBskyFeedDefs.PostView>, 320 ) { 321 const agent = useAgent() 322 + const ax = useAnalytics() 323 return useMutation<void, Error, {postUri: string; repostUri: string}>({ 324 mutationFn: ({postUri, repostUri}) => { 325 + ax.metric('post:unrepost', { 326 uri: postUri, 327 authorDid: post.author.did, 328 logContext,
+6 -8
src/state/queries/preferences/index.ts
··· 9 import {PROD_DEFAULT_FEED} from '#/lib/constants' 10 import {replaceEqualDeep} from '#/lib/functions' 11 import {getAge} from '#/lib/strings/time' 12 - import {logger} from '#/logger' 13 import {STALE} from '#/state/queries' 14 import { 15 DEFAULT_HOME_FEED_PREFS, ··· 24 import {saveLabelers} from '#/state/session/agent-config' 25 import {useAgeAssurance} from '#/ageAssurance' 26 import {makeAgeRestrictedModerationPrefs} from '#/ageAssurance/util' 27 28 export * from '#/state/queries/preferences/const' 29 export * from '#/state/queries/preferences/moderation' ··· 110 } 111 112 export function usePreferencesSetContentLabelMutation() { 113 const agent = useAgent() 114 const queryClient = useQueryClient() 115 ··· 120 >({ 121 mutationFn: async ({label, visibility, labelerDid}) => { 122 await agent.setContentLabelPref(label, visibility, labelerDid) 123 - logger.metric( 124 - 'moderation:changeLabelPreference', 125 - {preference: visibility}, 126 - {statsig: true}, 127 - ) 128 // triggers a refetch 129 await queryClient.invalidateQueries({ 130 queryKey: preferencesQueryKey, ··· 417 } 418 419 export function useSetVerificationPrefsMutation() { 420 const queryClient = useQueryClient() 421 const agent = useAgent() 422 ··· 424 mutationFn: async prefs => { 425 await agent.setVerificationPrefs(prefs) 426 if (prefs.hideBadges) { 427 - logger.metric('verification:settings:hideBadges', {}, {statsig: true}) 428 } else { 429 - logger.metric('verification:settings:unHideBadges', {}, {statsig: true}) 430 } 431 // triggers a refetch 432 await queryClient.invalidateQueries({
··· 9 import {PROD_DEFAULT_FEED} from '#/lib/constants' 10 import {replaceEqualDeep} from '#/lib/functions' 11 import {getAge} from '#/lib/strings/time' 12 import {STALE} from '#/state/queries' 13 import { 14 DEFAULT_HOME_FEED_PREFS, ··· 23 import {saveLabelers} from '#/state/session/agent-config' 24 import {useAgeAssurance} from '#/ageAssurance' 25 import {makeAgeRestrictedModerationPrefs} from '#/ageAssurance/util' 26 + import {useAnalytics} from '#/analytics' 27 28 export * from '#/state/queries/preferences/const' 29 export * from '#/state/queries/preferences/moderation' ··· 110 } 111 112 export function usePreferencesSetContentLabelMutation() { 113 + const ax = useAnalytics() 114 const agent = useAgent() 115 const queryClient = useQueryClient() 116 ··· 121 >({ 122 mutationFn: async ({label, visibility, labelerDid}) => { 123 await agent.setContentLabelPref(label, visibility, labelerDid) 124 + ax.metric('moderation:changeLabelPreference', {preference: visibility}) 125 // triggers a refetch 126 await queryClient.invalidateQueries({ 127 queryKey: preferencesQueryKey, ··· 414 } 415 416 export function useSetVerificationPrefsMutation() { 417 + const ax = useAnalytics() 418 const queryClient = useQueryClient() 419 const agent = useAgent() 420 ··· 422 mutationFn: async prefs => { 423 await agent.setVerificationPrefs(prefs) 424 if (prefs.hideBadges) { 425 + ax.metric('verification:settings:hideBadges', {}) 426 } else { 427 + ax.metric('verification:settings:unHideBadges', {}) 428 } 429 // triggers a refetch 430 await queryClient.invalidateQueries({
+7 -6
src/state/queries/preferences/useThreadPreferences.ts
··· 2 import {type AppBskyUnspeccedGetPostThreadV2} from '@atproto/api' 3 import debounce from 'lodash.debounce' 4 5 - import {OnceKey, useCallOnce} from '#/lib/hooks/useCallOnce' 6 - import {logger} from '#/logger' 7 import { 8 usePreferencesQuery, 9 useSetThreadViewPreferencesMutation, 10 } from '#/state/queries/preferences' 11 import {type ThreadViewPreferences} from '#/state/queries/preferences/types' 12 import {type Literal} from '#/types/utils' 13 14 export type ThreadSortOption = Literal< ··· 28 export function useThreadPreferences({ 29 save, 30 }: {save?: boolean} = {}): ThreadPreferences { 31 const {data: preferences} = usePreferencesQuery() 32 const serverPrefs = preferences?.threadViewPrefs 33 - const once = useCallOnce(OnceKey.PreferencesThread) 34 35 /* 36 * Create local state representations of server state ··· 61 ) 62 63 once(() => { 64 - logger.metric('thread:preferences:load', { 65 sort: serverPrefs.sort, 66 view: serverPrefs.lab_treeViewEnabled ? 'tree' : 'linear', 67 }) ··· 76 try { 77 setIsSaving(true) 78 await mutateAsync(prefs) 79 - logger.metric('thread:preferences:update', { 80 sort: prefs.sort, 81 view: prefs.lab_treeViewEnabled ? 'tree' : 'linear', 82 }) 83 } catch (e) { 84 - logger.error('useThreadPreferences failed to save', { 85 safeMessage: e, 86 }) 87 } finally {
··· 2 import {type AppBskyUnspeccedGetPostThreadV2} from '@atproto/api' 3 import debounce from 'lodash.debounce' 4 5 + import {useCallOnce} from '#/lib/once' 6 import { 7 usePreferencesQuery, 8 useSetThreadViewPreferencesMutation, 9 } from '#/state/queries/preferences' 10 import {type ThreadViewPreferences} from '#/state/queries/preferences/types' 11 + import {useAnalytics} from '#/analytics' 12 import {type Literal} from '#/types/utils' 13 14 export type ThreadSortOption = Literal< ··· 28 export function useThreadPreferences({ 29 save, 30 }: {save?: boolean} = {}): ThreadPreferences { 31 + const ax = useAnalytics() 32 const {data: preferences} = usePreferencesQuery() 33 const serverPrefs = preferences?.threadViewPrefs 34 + const once = useCallOnce() 35 36 /* 37 * Create local state representations of server state ··· 62 ) 63 64 once(() => { 65 + ax.metric('thread:preferences:load', { 66 sort: serverPrefs.sort, 67 view: serverPrefs.lab_treeViewEnabled ? 'tree' : 'linear', 68 }) ··· 77 try { 78 setIsSaving(true) 79 await mutateAsync(prefs) 80 + ax.metric('thread:preferences:update', { 81 sort: prefs.sort, 82 view: prefs.lab_treeViewEnabled ? 'tree' : 'linear', 83 }) 84 } catch (e) { 85 + ax.logger.error('useThreadPreferences failed to save', { 86 safeMessage: e, 87 }) 88 } finally {
+9 -7
src/state/queries/profile.ts
··· 22 import {uploadBlob} from '#/lib/api' 23 import {until} from '#/lib/async/until' 24 import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' 25 - import {logEvent, type LogEvents, toClout} from '#/lib/statsig/statsig' 26 import {updateProfileShadow} from '#/state/cache/profile-shadow' 27 import {type Shadow} from '#/state/cache/types' 28 import {type ImageMeta} from '#/state/gallery' ··· 36 import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache' 37 import {useAgent, useSession} from '#/state/session' 38 import * as userActionHistory from '#/state/userActionHistory' 39 import type * as bsky from '#/types/bsky' 40 import { 41 ProgressGuideAction, ··· 243 244 export function useProfileFollowMutationQueue( 245 profile: Shadow<bsky.profile.AnyProfileView>, 246 - logContext: LogEvents['profile:follow']['logContext'] & 247 - LogEvents['profile:follow']['logContext'], 248 position?: number, 249 contextProfileDid?: string, 250 ) { ··· 364 } 365 366 function useProfileFollowMutation( 367 - logContext: LogEvents['profile:follow']['logContext'], 368 profile: Shadow<bsky.profile.AnyProfileView>, 369 position?: number, 370 contextProfileDid?: string, 371 ) { 372 const {currentAccount} = useSession() 373 const agent = useAgent() 374 const queryClient = useQueryClient() ··· 381 ownProfile = findProfileQueryData(queryClient, currentAccount.did) 382 } 383 captureAction(ProgressGuideAction.Follow) 384 - logEvent('profile:follow', { 385 logContext, 386 didBecomeMutual: profile.viewer 387 ? Boolean(profile.viewer.followedBy) ··· 401 } 402 403 function useProfileUnfollowMutation( 404 - logContext: LogEvents['profile:unfollow']['logContext'], 405 ) { 406 const agent = useAgent() 407 return useMutation<void, Error, {did: string; followUri: string}>({ 408 mutationFn: async ({followUri}) => { 409 - logEvent('profile:unfollow', {logContext}) 410 return await agent.deleteFollow(followUri) 411 }, 412 })
··· 22 import {uploadBlob} from '#/lib/api' 23 import {until} from '#/lib/async/until' 24 import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' 25 import {updateProfileShadow} from '#/state/cache/profile-shadow' 26 import {type Shadow} from '#/state/cache/types' 27 import {type ImageMeta} from '#/state/gallery' ··· 35 import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache' 36 import {useAgent, useSession} from '#/state/session' 37 import * as userActionHistory from '#/state/userActionHistory' 38 + import {useAnalytics} from '#/analytics' 39 + import {type Metrics, toClout} from '#/analytics/metrics' 40 import type * as bsky from '#/types/bsky' 41 import { 42 ProgressGuideAction, ··· 244 245 export function useProfileFollowMutationQueue( 246 profile: Shadow<bsky.profile.AnyProfileView>, 247 + logContext: Metrics['profile:follow']['logContext'], 248 position?: number, 249 contextProfileDid?: string, 250 ) { ··· 364 } 365 366 function useProfileFollowMutation( 367 + logContext: Metrics['profile:follow']['logContext'], 368 profile: Shadow<bsky.profile.AnyProfileView>, 369 position?: number, 370 contextProfileDid?: string, 371 ) { 372 + const ax = useAnalytics() 373 const {currentAccount} = useSession() 374 const agent = useAgent() 375 const queryClient = useQueryClient() ··· 382 ownProfile = findProfileQueryData(queryClient, currentAccount.did) 383 } 384 captureAction(ProgressGuideAction.Follow) 385 + ax.metric('profile:follow', { 386 logContext, 387 didBecomeMutual: profile.viewer 388 ? Boolean(profile.viewer.followedBy) ··· 402 } 403 404 function useProfileUnfollowMutation( 405 + logContext: Metrics['profile:unfollow']['logContext'], 406 ) { 407 + const ax = useAnalytics() 408 const agent = useAgent() 409 return useMutation<void, Error, {did: string; followUri: string}>({ 410 mutationFn: async ({followUri}) => { 411 + ax.metric('profile:unfollow', {logContext}) 412 return await agent.deleteFollow(followUri) 413 }, 414 })
+3 -2
src/state/queries/verification/useVerificationCreateMutation.tsx
··· 2 import {useMutation} from '@tanstack/react-query' 3 4 import {until} from '#/lib/async/until' 5 - import {logger} from '#/logger' 6 import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache' 7 import {useAgent, useSession} from '#/state/session' 8 import type * as bsky from '#/types/bsky' 9 10 export function useVerificationCreateMutation() { 11 const agent = useAgent() 12 const {currentAccount} = useSession() 13 const updateProfileVerificationCache = useUpdateProfileVerificationCache() ··· 46 ) 47 }, 48 async onSuccess(_, {profile}) { 49 - logger.metric('verification:create', {}, {statsig: true}) 50 await updateProfileVerificationCache({profile}) 51 }, 52 })
··· 2 import {useMutation} from '@tanstack/react-query' 3 4 import {until} from '#/lib/async/until' 5 import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache' 6 import {useAgent, useSession} from '#/state/session' 7 + import {useAnalytics} from '#/analytics' 8 import type * as bsky from '#/types/bsky' 9 10 export function useVerificationCreateMutation() { 11 + const ax = useAnalytics() 12 const agent = useAgent() 13 const {currentAccount} = useSession() 14 const updateProfileVerificationCache = useUpdateProfileVerificationCache() ··· 47 ) 48 }, 49 async onSuccess(_, {profile}) { 50 + ax.metric('verification:create', {}) 51 await updateProfileVerificationCache({profile}) 52 }, 53 })
+3 -2
src/state/queries/verification/useVerificationsRemoveMutation.tsx
··· 6 import {useMutation} from '@tanstack/react-query' 7 8 import {until} from '#/lib/async/until' 9 - import {logger} from '#/logger' 10 import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache' 11 import {useAgent, useSession} from '#/state/session' 12 import type * as bsky from '#/types/bsky' 13 14 export function useVerificationsRemoveMutation() { 15 const agent = useAgent() 16 const {currentAccount} = useSession() 17 const updateProfileVerificationCache = useUpdateProfileVerificationCache() ··· 56 ) 57 }, 58 async onSuccess(_, {profile}) { 59 - logger.metric('verification:revoke', {}, {statsig: true}) 60 await updateProfileVerificationCache({profile}) 61 }, 62 })
··· 6 import {useMutation} from '@tanstack/react-query' 7 8 import {until} from '#/lib/async/until' 9 import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache' 10 import {useAgent, useSession} from '#/state/session' 11 + import {useAnalytics} from '#/analytics' 12 import type * as bsky from '#/types/bsky' 13 14 export function useVerificationsRemoveMutation() { 15 + const ax = useAnalytics() 16 const agent = useAgent() 17 const {currentAccount} = useSession() 18 const updateProfileVerificationCache = useUpdateProfileVerificationCache() ··· 57 ) 58 }, 59 async onSuccess(_, {profile}) { 60 + ax.metric('verification:revoke', {}) 61 await updateProfileVerificationCache({profile}) 62 }, 63 })
+3 -7
src/state/service-config.tsx
··· 1 import {createContext, useContext, useMemo} from 'react' 2 3 - import {useGate} from '#/lib/statsig/statsig' 4 import {useLanguagePrefs} from '#/state/preferences/languages' 5 import {useServiceConfigQuery} from '#/state/queries/service-config' 6 import {useSession} from '#/state/session' 7 import {IS_DEV} from '#/env' 8 import {device} from '#/storage' 9 ··· 52 return {enabled: Boolean(cachedEnabled)} 53 } 54 55 - /* 56 - * Doing an extra check here to reduce hits to statsig. If it's disabled on 57 - * the server, we can exit early. 58 - */ 59 const enabled = Boolean(config?.topicsEnabled) 60 61 // update cache ··· 107 } 108 109 export function useCanGoLive() { 110 - const gate = useGate() 111 const {hasSession} = useSession() 112 if (!hasSession) return false 113 - return IS_DEV ? true : !gate('disable_live_now_beta') 114 } 115 116 export function useCheckEmailConfirmed() {
··· 1 import {createContext, useContext, useMemo} from 'react' 2 3 import {useLanguagePrefs} from '#/state/preferences/languages' 4 import {useServiceConfigQuery} from '#/state/queries/service-config' 5 import {useSession} from '#/state/session' 6 + import {useAnalytics} from '#/analytics' 7 import {IS_DEV} from '#/env' 8 import {device} from '#/storage' 9 ··· 52 return {enabled: Boolean(cachedEnabled)} 53 } 54 55 const enabled = Boolean(config?.topicsEnabled) 56 57 // update cache ··· 103 } 104 105 export function useCanGoLive() { 106 + const ax = useAnalytics() 107 const {hasSession} = useSession() 108 if (!hasSession) return false 109 + return IS_DEV ? true : !ax.features.enabled(ax.features.DisableLiveNowBeta) 110 } 111 112 export function useCheckEmailConfirmed() {
+8 -6
src/state/session/agent.ts
··· 22 PUBLIC_BSKY_SERVICE, 23 TIMELINE_SAVED_FEED, 24 } from '#/lib/constants' 25 - import {tryFetchGates} from '#/lib/statsig/statsig' 26 import {getAge} from '#/lib/strings/time' 27 import {logger} from '#/logger' 28 import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate' ··· 32 setBirthdateForDid, 33 setCreatedAtForDid, 34 } from '#/ageAssurance/data' 35 import {emitNetworkConfirmed, emitNetworkLost} from '../events' 36 import {addSessionErrorLog} from './logging' 37 import { ··· 63 if (storedAccount.pdsUrl) { 64 agent.sessionManager.pdsUrl = new URL(storedAccount.pdsUrl) 65 } 66 - const gates = tryFetchGates(storedAccount.did, 'prefer-low-latency') 67 const moderation = configureModerationForAccount(agent, storedAccount) 68 const prevSession: AtpSessionData = sessionAccountToSession(storedAccount) 69 if (isSessionExpired(storedAccount)) { ··· 123 }) 124 125 const account = agentToSessionAccountOrThrow(agent) 126 - const gates = tryFetchGates(account.did, 'prefer-fresh-gates') 127 const moderation = configureModerationForAccount(agent, account) 128 const aa = prefetchAgeAssuranceData({agent}) 129 ··· 171 verificationCode, 172 }) 173 const account = agentToSessionAccountOrThrow(agent) 174 - const gates = tryFetchGates(account.did, 'prefer-fresh-gates') 175 const moderation = configureModerationForAccount(agent, account) 176 177 const createdAt = new Date().toISOString() ··· 322 return undefined 323 } 324 return { 325 - service: agent.service.toString(), 326 did: agent.session.did, 327 handle: agent.session.handle, 328 email: agent.session.email, ··· 332 accessJwt: agent.session.accessJwt, 333 signupQueued: isSignupQueued(agent.session.accessJwt), 334 active: agent.session.active, 335 - status: agent.session.status as SessionAccount['status'], 336 pdsUrl: agent.pdsUrl?.toString(), 337 isSelfHosted: !agent.serviceUrl.toString().startsWith(BSKY_SERVICE), 338 }
··· 22 PUBLIC_BSKY_SERVICE, 23 TIMELINE_SAVED_FEED, 24 } from '#/lib/constants' 25 import {getAge} from '#/lib/strings/time' 26 import {logger} from '#/logger' 27 import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate' ··· 31 setBirthdateForDid, 32 setCreatedAtForDid, 33 } from '#/ageAssurance/data' 34 + import {features} from '#/analytics' 35 import {emitNetworkConfirmed, emitNetworkLost} from '../events' 36 import {addSessionErrorLog} from './logging' 37 import { ··· 63 if (storedAccount.pdsUrl) { 64 agent.sessionManager.pdsUrl = new URL(storedAccount.pdsUrl) 65 } 66 + const gates = features.refresh({ 67 + strategy: 'prefer-low-latency', 68 + }) 69 const moderation = configureModerationForAccount(agent, storedAccount) 70 const prevSession: AtpSessionData = sessionAccountToSession(storedAccount) 71 if (isSessionExpired(storedAccount)) { ··· 125 }) 126 127 const account = agentToSessionAccountOrThrow(agent) 128 + const gates = features.refresh({strategy: 'prefer-fresh-gates'}) 129 const moderation = configureModerationForAccount(agent, account) 130 const aa = prefetchAgeAssuranceData({agent}) 131 ··· 173 verificationCode, 174 }) 175 const account = agentToSessionAccountOrThrow(agent) 176 + const gates = features.refresh({strategy: 'prefer-fresh-gates'}) 177 const moderation = configureModerationForAccount(agent, account) 178 179 const createdAt = new Date().toISOString() ··· 324 return undefined 325 } 326 return { 327 + service: agent.serviceUrl.toString(), 328 did: agent.session.did, 329 handle: agent.session.handle, 330 email: agent.session.email, ··· 334 accessJwt: agent.session.accessJwt, 335 signupQueued: isSignupQueued(agent.session.accessJwt), 336 active: agent.session.active, 337 + status: agent.session.status, 338 pdsUrl: agent.pdsUrl?.toString(), 339 isSelfHosted: !agent.serviceUrl.toString().startsWith(BSKY_SERVICE), 340 }
+38 -14
src/state/session/index.tsx
··· 4 import * as persisted from '#/state/persisted' 5 import {useCloseAllActiveElements} from '#/state/util' 6 import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 7 import {IS_WEB} from '#/env' 8 import {emitSessionDropped} from '../events' 9 import { ··· 15 sessionAccountToSession, 16 } from './agent' 17 import {type Action, getInitialState, reducer, type State} from './reducer' 18 - 19 export {isSignupQueued} from './util' 20 import {addSessionDebugLog} from './logging' 21 export type {SessionAccount} from '#/state/session/types' 22 - import {logger} from '#/logger' 23 import { 24 type SessionApiContext, 25 type SessionStateContext, ··· 93 } 94 95 export function Provider({children}: React.PropsWithChildren<{}>) { 96 const cancelPendingTask = useOneTaskAtATime() 97 const [store] = React.useState(() => new SessionStore()) 98 const state = React.useSyncExternalStore(store.subscribe, store.getState) ··· 119 async (params, metrics) => { 120 addSessionDebugLog({type: 'method:start', method: 'createAccount'}) 121 const signal = cancelPendingTask() 122 - logger.metric('account:create:begin', {}, {statsig: true}) 123 const {agent, account} = await createAgentAndCreateAccount( 124 params, 125 onAgentSessionChange, ··· 133 newAgent: agent, 134 newAccount: account, 135 }) 136 - logger.metric('account:create:success', metrics, {statsig: true}) 137 addSessionDebugLog({type: 'method:end', method: 'createAccount', account}) 138 }, 139 - [store, onAgentSessionChange, cancelPendingTask], 140 ) 141 142 const login = React.useCallback<SessionApiContext['login']>( ··· 156 newAgent: agent, 157 newAccount: account, 158 }) 159 - logger.metric( 160 'account:loggedIn', 161 {logContext, withPassword: true}, 162 - {statsig: true}, 163 ) 164 addSessionDebugLog({type: 'method:end', method: 'login', account}) 165 }, 166 - [store, onAgentSessionChange, cancelPendingTask], 167 ) 168 169 const logoutCurrentAccount = React.useCallback< ··· 176 store.dispatch({ 177 type: 'logged-out-current-account', 178 }) 179 - logger.metric( 180 'account:loggedOut', 181 {logContext, scope: 'current'}, 182 - {statsig: true}, 183 ) 184 addSessionDebugLog({type: 'method:end', method: 'logout'}) 185 if (prevState.currentAgentState.did) { ··· 188 // reset onboarding flow on logout 189 onboardingDispatch({type: 'skip'}) 190 }, 191 - [store, cancelPendingTask, onboardingDispatch], 192 ) 193 194 const logoutEveryAccount = React.useCallback< ··· 197 logContext => { 198 addSessionDebugLog({type: 'method:start', method: 'logout'}) 199 cancelPendingTask() 200 store.dispatch({ 201 type: 'logged-out-every-account', 202 }) 203 - logger.metric( 204 'account:loggedOut', 205 {logContext, scope: 'every'}, 206 - {statsig: true}, 207 ) 208 addSessionDebugLog({type: 'method:end', method: 'logout'}) 209 clearAgeAssuranceData() ··· 359 return ( 360 <AgentContext.Provider value={agent}> 361 <StateContext.Provider value={stateContext}> 362 - <ApiContext.Provider value={api}>{children}</ApiContext.Provider> 363 </StateContext.Provider> 364 </AgentContext.Provider> 365 )
··· 4 import * as persisted from '#/state/persisted' 5 import {useCloseAllActiveElements} from '#/state/util' 6 import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 7 + import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics' 8 import {IS_WEB} from '#/env' 9 import {emitSessionDropped} from '../events' 10 import { ··· 16 sessionAccountToSession, 17 } from './agent' 18 import {type Action, getInitialState, reducer, type State} from './reducer' 19 export {isSignupQueued} from './util' 20 import {addSessionDebugLog} from './logging' 21 export type {SessionAccount} from '#/state/session/types' 22 import { 23 type SessionApiContext, 24 type SessionStateContext, ··· 92 } 93 94 export function Provider({children}: React.PropsWithChildren<{}>) { 95 + const ax = useAnalyticsBase() 96 const cancelPendingTask = useOneTaskAtATime() 97 const [store] = React.useState(() => new SessionStore()) 98 const state = React.useSyncExternalStore(store.subscribe, store.getState) ··· 119 async (params, metrics) => { 120 addSessionDebugLog({type: 'method:start', method: 'createAccount'}) 121 const signal = cancelPendingTask() 122 + ax.metric('account:create:begin', {}) 123 const {agent, account} = await createAgentAndCreateAccount( 124 params, 125 onAgentSessionChange, ··· 133 newAgent: agent, 134 newAccount: account, 135 }) 136 + ax.metric('account:create:success', metrics, { 137 + session: utils.accountToSessionMetadata(account), 138 + }) 139 addSessionDebugLog({type: 'method:end', method: 'createAccount', account}) 140 }, 141 + [ax, store, onAgentSessionChange, cancelPendingTask], 142 ) 143 144 const login = React.useCallback<SessionApiContext['login']>( ··· 158 newAgent: agent, 159 newAccount: account, 160 }) 161 + ax.metric( 162 'account:loggedIn', 163 {logContext, withPassword: true}, 164 + {session: utils.accountToSessionMetadata(account)}, 165 ) 166 addSessionDebugLog({type: 'method:end', method: 'login', account}) 167 }, 168 + [ax, store, onAgentSessionChange, cancelPendingTask], 169 ) 170 171 const logoutCurrentAccount = React.useCallback< ··· 178 store.dispatch({ 179 type: 'logged-out-current-account', 180 }) 181 + ax.metric( 182 'account:loggedOut', 183 {logContext, scope: 'current'}, 184 + { 185 + session: utils.accountToSessionMetadata( 186 + prevState.accounts.find( 187 + a => a.did === prevState.currentAgentState.did, 188 + ), 189 + ), 190 + }, 191 ) 192 addSessionDebugLog({type: 'method:end', method: 'logout'}) 193 if (prevState.currentAgentState.did) { ··· 196 // reset onboarding flow on logout 197 onboardingDispatch({type: 'skip'}) 198 }, 199 + [ax, store, cancelPendingTask, onboardingDispatch], 200 ) 201 202 const logoutEveryAccount = React.useCallback< ··· 205 logContext => { 206 addSessionDebugLog({type: 'method:start', method: 'logout'}) 207 cancelPendingTask() 208 + const prevState = store.getState() 209 store.dispatch({ 210 type: 'logged-out-every-account', 211 }) 212 + ax.metric( 213 'account:loggedOut', 214 {logContext, scope: 'every'}, 215 + { 216 + session: utils.accountToSessionMetadata( 217 + prevState.accounts.find( 218 + a => a.did === prevState.currentAgentState.did, 219 + ), 220 + ), 221 + }, 222 ) 223 addSessionDebugLog({type: 'method:end', method: 'logout'}) 224 clearAgeAssuranceData() ··· 374 return ( 375 <AgentContext.Provider value={agent}> 376 <StateContext.Provider value={stateContext}> 377 + <ApiContext.Provider value={api}> 378 + <AnalyticsContext 379 + metadata={utils.useMeta({ 380 + session: utils.accountToSessionMetadata( 381 + stateContext.currentAccount, 382 + ), 383 + })}> 384 + {children} 385 + </AnalyticsContext> 386 + </ApiContext.Provider> 387 </StateContext.Provider> 388 </AgentContext.Provider> 389 )
+5 -5
src/state/session/types.ts
··· 1 - import {type LogEvents} from '#/lib/statsig/statsig' 2 import {type PersistedAccount} from '#/state/persisted' 3 4 export type SessionAccount = PersistedAccount 5 ··· 21 verificationPhone?: string 22 verificationCode?: string 23 }, 24 - metrics: LogEvents['account:create:success'], 25 ) => Promise<void> 26 login: ( 27 props: { ··· 30 password: string 31 authFactorToken?: string | undefined 32 }, 33 - logContext: LogEvents['account:loggedIn']['logContext'], 34 ) => Promise<void> 35 logoutCurrentAccount: ( 36 - logContext: LogEvents['account:loggedOut']['logContext'], 37 ) => void 38 logoutEveryAccount: ( 39 - logContext: LogEvents['account:loggedOut']['logContext'], 40 ) => void 41 resumeSession: ( 42 account: SessionAccount,
··· 1 import {type PersistedAccount} from '#/state/persisted' 2 + import {type Metrics} from '#/analytics/metrics' 3 4 export type SessionAccount = PersistedAccount 5 ··· 21 verificationPhone?: string 22 verificationCode?: string 23 }, 24 + metrics: Metrics['account:create:success'], 25 ) => Promise<void> 26 login: ( 27 props: { ··· 30 password: string 31 authFactorToken?: string | undefined 32 }, 33 + logContext: Metrics['account:loggedIn']['logContext'], 34 ) => Promise<void> 35 logoutCurrentAccount: ( 36 + logContext: Metrics['account:loggedOut']['logContext'], 37 ) => void 38 logoutEveryAccount: ( 39 + logContext: Metrics['account:loggedOut']['logContext'], 40 ) => void 41 resumeSession: ( 42 account: SessionAccount,
+4 -3
src/state/shell/progress-guide.tsx
··· 2 import {msg} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 - import {logEvent} from '#/lib/statsig/statsig' 6 import { 7 ProgressGuideToast, 8 type ProgressGuideToastRef, 9 } from '#/components/ProgressGuide/Toast' 10 import { 11 usePreferencesQuery, 12 useSetActiveProgressGuideMutation, ··· 71 } 72 73 export function Provider({children}: React.PropsWithChildren<{}>) { 74 const {_} = useLingui() 75 const {data: preferences} = usePreferencesQuery() 76 const {mutateAsync, variables, isPending} = ··· 140 endProgressGuide() { 141 setLocalGuideState(undefined) 142 mutateAsync(undefined) 143 - logEvent('progressGuide:hide', {}) 144 }, 145 146 captureAction(action: ProgressGuideAction, count = 1) { ··· 202 mutateAsync(guide?.isComplete ? undefined : guide) 203 }, 204 } 205 - }, [activeProgressGuide, mutateAsync, setLocalGuideState]) 206 207 return ( 208 <ProgressGuideContext.Provider value={localGuideState}>
··· 2 import {msg} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 import { 6 ProgressGuideToast, 7 type ProgressGuideToastRef, 8 } from '#/components/ProgressGuide/Toast' 9 + import {useAnalytics} from '#/analytics' 10 import { 11 usePreferencesQuery, 12 useSetActiveProgressGuideMutation, ··· 71 } 72 73 export function Provider({children}: React.PropsWithChildren<{}>) { 74 + const ax = useAnalytics() 75 const {_} = useLingui() 76 const {data: preferences} = usePreferencesQuery() 77 const {mutateAsync, variables, isPending} = ··· 141 endProgressGuide() { 142 setLocalGuideState(undefined) 143 mutateAsync(undefined) 144 + ax.metric('progressGuide:hide', {}) 145 }, 146 147 captureAction(action: ProgressGuideAction, count = 1) { ··· 203 mutateAsync(guide?.isComplete ? undefined : guide) 204 }, 205 } 206 + }, [ax, activeProgressGuide, mutateAsync, setLocalGuideState]) 207 208 return ( 209 <ProgressGuideContext.Provider value={localGuideState}>
+11
src/storage/schema.ts
··· 5 * Device data that's specific to the device and does not vary based account 6 */ 7 export type Device = { 8 fontScale: '-2' | '-1' | '0' | '1' | '2' 9 fontFamily: 'system' | 'theme' 10 lastNuxDialog: string | undefined
··· 5 * Device data that's specific to the device and does not vary based account 6 */ 7 export type Device = { 8 + /** 9 + * Formerly managed by StatSig, this is the migrated stable ID for the 10 + * device, used with our logging and metrics tracking. 11 + */ 12 + deviceId?: string 13 + /** 14 + * Session ID storage for _native only_. On web, use we `sessionStorage` 15 + */ 16 + nativeSessionId?: string 17 + nativeSessionIdLastEventAt?: number 18 + 19 fontScale: '-2' | '-1' | '0' | '1' | '2' 20 fontFamily: 'system' | 'theme' 21 lastNuxDialog: string | undefined
+4 -3
src/view/com/auth/LoggedOut.tsx
··· 5 import {useLingui} from '@lingui/react' 6 7 import {PressableScale} from '#/lib/custom-animations/PressableScale' 8 - import {logEvent} from '#/lib/statsig/statsig' 9 import { 10 useLoggedOutView, 11 useLoggedOutViewControls, ··· 18 import {atoms as a, native, tokens, useTheme} from '#/alf' 19 import {Button, ButtonIcon} from '#/components/Button' 20 import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 21 import {SplashScreen} from './SplashScreen' 22 23 enum ScreenState { ··· 30 31 export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { 32 const {_} = useLingui() 33 const t = useTheme() 34 const insets = useSafeAreaInsets() 35 const setMinimalShellMode = useSetMinimalShellMode() ··· 94 <SplashScreen 95 onPressSignin={() => { 96 setScreenState(ScreenState.S_Login) 97 - logEvent('splash:signInPressed', {}) 98 }} 99 onPressCreateAccount={() => { 100 setScreenState(ScreenState.S_CreateAccount) 101 - logEvent('splash:createAccountPressed', {}) 102 }} 103 /> 104 ) : undefined}
··· 5 import {useLingui} from '@lingui/react' 6 7 import {PressableScale} from '#/lib/custom-animations/PressableScale' 8 import { 9 useLoggedOutView, 10 useLoggedOutViewControls, ··· 17 import {atoms as a, native, tokens, useTheme} from '#/alf' 18 import {Button, ButtonIcon} from '#/components/Button' 19 import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 20 + import {useAnalytics} from '#/analytics' 21 import {SplashScreen} from './SplashScreen' 22 23 enum ScreenState { ··· 30 31 export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { 32 const {_} = useLingui() 33 + const ax = useAnalytics() 34 const t = useTheme() 35 const insets = useSafeAreaInsets() 36 const setMinimalShellMode = useSetMinimalShellMode() ··· 95 <SplashScreen 96 onPressSignin={() => { 97 setScreenState(ScreenState.S_Login) 98 + ax.metric('splash:signInPressed', {}) 99 }} 100 onPressCreateAccount={() => { 101 setScreenState(ScreenState.S_CreateAccount) 102 + ax.metric('splash:createAccountPressed', {}) 103 }} 104 /> 105 ) : undefined}
+6 -4
src/view/com/composer/Composer.tsx
··· 58 59 import * as apilib from '#/lib/api/index' 60 import {EmbeddingDisabledError} from '#/lib/api/resolve' 61 import {retry} from '#/lib/async/retry' 62 import {until} from '#/lib/async/until' 63 import { ··· 65 SUPPORTED_MIME_TYPES, 66 type SupportedMimeTypes, 67 } from '#/lib/constants' 68 - import {useAppState} from '#/lib/hooks/useAppState' 69 import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' 70 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 71 import {usePalette} from '#/lib/hooks/usePalette' 72 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 73 import {mimeToExt} from '#/lib/media/video/util' 74 import {type NavigationProp} from '#/lib/routes/types' 75 - import {logEvent} from '#/lib/statsig/statsig' 76 import {cleanError} from '#/lib/strings/errors' 77 import {colors} from '#/lib/styles' 78 import {logger} from '#/logger' ··· 129 import * as Prompt from '#/components/Prompt' 130 import * as Toast from '#/components/Toast' 131 import {Text as NewText} from '#/components/Typography' 132 import {IS_ANDROID, IS_IOS, IS_NATIVE, IS_WEB} from '#/env' 133 import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' 134 import {PostLanguageSelect} from './select-language/PostLanguageSelect' ··· 178 cancelRef?: React.RefObject<CancelRef | null> 179 }) => { 180 const {currentAccount} = useSession() 181 const agent = useAgent() 182 const queryClient = useQueryClient() 183 const currentDid = currentAccount!.did ··· 520 if (postUri) { 521 let index = 0 522 for (let post of thread.posts) { 523 - logEvent('post:create', { 524 imageCount: 525 post.embed.media?.type === 'images' 526 ? post.embed.media.images.length ··· 536 } 537 } 538 if (thread.posts.length > 1) { 539 - logEvent('thread:create', { 540 postCount: thread.posts.length, 541 isReply: !!replyTo, 542 }) ··· 594 }, 500) 595 }, [ 596 _, 597 agent, 598 thread, 599 canPost,
··· 58 59 import * as apilib from '#/lib/api/index' 60 import {EmbeddingDisabledError} from '#/lib/api/resolve' 61 + import {useAppState} from '#/lib/appState' 62 import {retry} from '#/lib/async/retry' 63 import {until} from '#/lib/async/until' 64 import { ··· 66 SUPPORTED_MIME_TYPES, 67 type SupportedMimeTypes, 68 } from '#/lib/constants' 69 import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' 70 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 71 import {usePalette} from '#/lib/hooks/usePalette' 72 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 73 import {mimeToExt} from '#/lib/media/video/util' 74 import {type NavigationProp} from '#/lib/routes/types' 75 import {cleanError} from '#/lib/strings/errors' 76 import {colors} from '#/lib/styles' 77 import {logger} from '#/logger' ··· 128 import * as Prompt from '#/components/Prompt' 129 import * as Toast from '#/components/Toast' 130 import {Text as NewText} from '#/components/Typography' 131 + import {useAnalytics} from '#/analytics' 132 import {IS_ANDROID, IS_IOS, IS_NATIVE, IS_WEB} from '#/env' 133 import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' 134 import {PostLanguageSelect} from './select-language/PostLanguageSelect' ··· 178 cancelRef?: React.RefObject<CancelRef | null> 179 }) => { 180 const {currentAccount} = useSession() 181 + const ax = useAnalytics() 182 const agent = useAgent() 183 const queryClient = useQueryClient() 184 const currentDid = currentAccount!.did ··· 521 if (postUri) { 522 let index = 0 523 for (let post of thread.posts) { 524 + ax.metric('post:create', { 525 imageCount: 526 post.embed.media?.type === 'images' 527 ? post.embed.media.images.length ··· 537 } 538 } 539 if (thread.posts.length > 1) { 540 + ax.metric('thread:create', { 541 postCount: thread.posts.length, 542 isReply: !!replyTo, 543 }) ··· 595 }, 500) 596 }, [ 597 _, 598 + ax, 599 agent, 600 thread, 601 canPost,
+4 -3
src/view/com/composer/photos/SelectGifBtn.tsx
··· 3 import {msg} from '@lingui/macro' 4 import {useLingui} from '@lingui/react' 5 6 - import {logEvent} from '#/lib/statsig/statsig' 7 import {type Gif} from '#/state/queries/tenor' 8 import {atoms as a, useTheme} from '#/alf' 9 import {Button} from '#/components/Button' 10 import {GifSelectDialog} from '#/components/dialogs/GifSelect' 11 import {GifSquare_Stroke2_Corner0_Rounded as GifIcon} from '#/components/icons/Gif' 12 13 type Props = { 14 onClose?: () => void ··· 17 } 18 19 export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) { 20 const {_} = useLingui() 21 const ref = useRef<{open: () => void}>(null) 22 const t = useTheme() 23 24 const onPressSelectGif = useCallback(async () => { 25 - logEvent('composer:gif:open', {}) 26 Keyboard.dismiss() 27 ref.current?.open() 28 - }, []) 29 30 return ( 31 <>
··· 3 import {msg} from '@lingui/macro' 4 import {useLingui} from '@lingui/react' 5 6 import {type Gif} from '#/state/queries/tenor' 7 import {atoms as a, useTheme} from '#/alf' 8 import {Button} from '#/components/Button' 9 import {GifSelectDialog} from '#/components/dialogs/GifSelect' 10 import {GifSquare_Stroke2_Corner0_Rounded as GifIcon} from '#/components/icons/Gif' 11 + import {useAnalytics} from '#/analytics' 12 13 type Props = { 14 onClose?: () => void ··· 17 } 18 19 export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) { 20 + const ax = useAnalytics() 21 const {_} = useLingui() 22 const ref = useRef<{open: () => void}>(null) 23 const t = useTheme() 24 25 const onPressSelectGif = useCallback(async () => { 26 + ax.metric('composer:gif:open', {}) 27 Keyboard.dismiss() 28 ref.current?.open() 29 + }, [ax]) 30 31 return ( 32 <>
+3 -1
src/view/com/composer/threadgate/ThreadgateBtn.tsx
··· 24 import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Group' 25 import * as Tooltip from '#/components/Tooltip' 26 import {Text} from '#/components/Typography' 27 import {IS_NATIVE} from '#/env' 28 import {useThreadgateNudged} from '#/storage/hooks/threadgate-nudged' 29 ··· 42 style?: StyleProp<AnimatedStyle<ViewStyle>> 43 }) { 44 const {_} = useLingui() 45 const control = Dialog.useDialogControl() 46 const [threadgateNudged, setThreadgateNudged] = useThreadgateNudged() 47 const [showTooltip, setShowTooltip] = useState(false) ··· 66 const [persist, setPersist] = useState(false) 67 68 const onPress = () => { 69 - logger.metric('composer:threadgate:open', { 70 nudged: tooltipWasShown, 71 }) 72
··· 24 import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Group' 25 import * as Tooltip from '#/components/Tooltip' 26 import {Text} from '#/components/Typography' 27 + import {useAnalytics} from '#/analytics' 28 import {IS_NATIVE} from '#/env' 29 import {useThreadgateNudged} from '#/storage/hooks/threadgate-nudged' 30 ··· 43 style?: StyleProp<AnimatedStyle<ViewStyle>> 44 }) { 45 const {_} = useLingui() 46 + const ax = useAnalytics() 47 const control = Dialog.useDialogControl() 48 const [threadgateNudged, setThreadgateNudged] = useThreadgateNudged() 49 const [showTooltip, setShowTooltip] = useState(false) ··· 68 const [persist, setPersist] = useState(false) 69 70 const onPress = () => { 71 + ax.metric('composer:threadgate:open', { 72 nudged: tooltipWasShown, 73 }) 74
+11 -9
src/view/com/feeds/ComposerPrompt.tsx
··· 10 useVideoLibraryPermission, 11 } from '#/lib/hooks/usePermissions' 12 import {openCamera, openUnifiedPicker} from '#/lib/media/picker' 13 - import {logger} from '#/logger' 14 import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile' 15 import {MAX_IMAGES} from '#/view/com/composer/state/composer' 16 import {UserAvatar} from '#/view/com/util/UserAvatar' ··· 21 import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' 22 import {SubtleHover} from '#/components/SubtleHover' 23 import {Text} from '#/components/Typography' 24 import {IS_NATIVE} from '#/env' 25 26 export function ComposerPrompt() { 27 - const {_} = useLingui() 28 const t = useTheme() 29 const {openComposer} = useOpenComposer() 30 const profile = useCurrentAccountProfile() 31 const [hover, setHover] = useState(false) ··· 35 const sheetWrapper = useSheetWrapper() 36 37 const onPress = useCallback(() => { 38 - logger.metric('composerPrompt:press', {}) 39 openComposer({}) 40 - }, [openComposer]) 41 42 const onPressImage = useCallback(async () => { 43 - logger.metric('composerPrompt:gallery:press', {}) 44 45 // On web, open the composer with the gallery picker auto-opening 46 if (!IS_NATIVE) { ··· 87 } 88 } catch (err: any) { 89 if (!String(err).toLowerCase().includes('cancel')) { 90 - logger.warn('Error opening image picker', {error: err}) 91 } 92 } 93 }, [ 94 openComposer, 95 requestPhotoAccessIfNeeded, 96 requestVideoAccessIfNeeded, ··· 98 ]) 99 100 const onPressCamera = useCallback(async () => { 101 - logger.metric('composerPrompt:camera:press', {}) 102 103 try { 104 if (!(await requestCameraAccessIfNeeded())) { ··· 126 }) 127 } catch (err: any) { 128 if (!String(err).toLowerCase().includes('cancel')) { 129 - logger.warn('Error opening camera', {error: err}) 130 } 131 } 132 - }, [openComposer, requestCameraAccessIfNeeded]) 133 134 if (!profile) { 135 return null
··· 10 useVideoLibraryPermission, 11 } from '#/lib/hooks/usePermissions' 12 import {openCamera, openUnifiedPicker} from '#/lib/media/picker' 13 import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile' 14 import {MAX_IMAGES} from '#/view/com/composer/state/composer' 15 import {UserAvatar} from '#/view/com/util/UserAvatar' ··· 20 import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' 21 import {SubtleHover} from '#/components/SubtleHover' 22 import {Text} from '#/components/Typography' 23 + import {useAnalytics} from '#/analytics' 24 import {IS_NATIVE} from '#/env' 25 26 export function ComposerPrompt() { 27 const t = useTheme() 28 + const ax = useAnalytics() 29 + const {_} = useLingui() 30 const {openComposer} = useOpenComposer() 31 const profile = useCurrentAccountProfile() 32 const [hover, setHover] = useState(false) ··· 36 const sheetWrapper = useSheetWrapper() 37 38 const onPress = useCallback(() => { 39 + ax.metric('composerPrompt:press', {}) 40 openComposer({}) 41 + }, [ax, openComposer]) 42 43 const onPressImage = useCallback(async () => { 44 + ax.metric('composerPrompt:gallery:press', {}) 45 46 // On web, open the composer with the gallery picker auto-opening 47 if (!IS_NATIVE) { ··· 88 } 89 } catch (err: any) { 90 if (!String(err).toLowerCase().includes('cancel')) { 91 + ax.logger.error('Error opening image picker', {error: err}) 92 } 93 } 94 }, [ 95 + ax, 96 openComposer, 97 requestPhotoAccessIfNeeded, 98 requestVideoAccessIfNeeded, ··· 100 ]) 101 102 const onPressCamera = useCallback(async () => { 103 + ax.metric('composerPrompt:camera:press', {}) 104 105 try { 106 if (!(await requestCameraAccessIfNeeded())) { ··· 128 }) 129 } catch (err: any) { 130 if (!String(err).toLowerCase().includes('cancel')) { 131 + ax.logger.error('Error opening camera', {error: err}) 132 } 133 } 134 + }, [ax, openComposer, requestCameraAccessIfNeeded]) 135 136 if (!profile) { 137 return null
+6 -5
src/view/com/feeds/FeedPage.tsx
··· 18 import {ComposeIcon2} from '#/lib/icons' 19 import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers' 20 import {type AllNavigatorParams} from '#/lib/routes/types' 21 - import {logEvent} from '#/lib/statsig/statsig' 22 import {s} from '#/lib/styles' 23 import {listenSoftReset} from '#/state/events' 24 import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' ··· 33 import {useSession} from '#/state/session' 34 import {useSetMinimalShellMode} from '#/state/shell' 35 import {useHeaderOffset} from '#/components/hooks/useHeaderOffset' 36 import {IS_NATIVE} from '#/env' 37 import {PostFeed} from '../posts/PostFeed' 38 import {FAB} from '../util/fab/FAB' ··· 63 savedFeedConfig?: AppBskyActorDefs.SavedFeed 64 feedInfo: FeedSourceInfo 65 }) { 66 const {hasSession} = useSession() 67 const {_} = useLingui() 68 const navigation = useNavigation<NavigationProp<AllNavigatorParams>>() ··· 105 scrollToTop() 106 truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) 107 setHasNew(false) 108 - logEvent('feed:refresh', { 109 feedType: feed.split('|')[0], 110 feedUrl: feed, 111 reason: 'soft-reset', 112 }) 113 } 114 - }, [navigation, isPageFocused, scrollToTop, queryClient, feed]) 115 116 // fires when page within screen is activated/deactivated 117 useEffect(() => { ··· 129 scrollToTop() 130 truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) 131 setHasNew(false) 132 - logEvent('feed:refresh', { 133 feedType: feed.split('|')[0], 134 feedUrl: feed, 135 reason: 'load-latest', 136 }) 137 - }, [scrollToTop, feed, queryClient]) 138 139 const shouldPrefetch = IS_NATIVE && isPageAdjacent 140 const isDiscoverFeed = feedInfo.uri === DISCOVER_FEED_URI
··· 18 import {ComposeIcon2} from '#/lib/icons' 19 import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers' 20 import {type AllNavigatorParams} from '#/lib/routes/types' 21 import {s} from '#/lib/styles' 22 import {listenSoftReset} from '#/state/events' 23 import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' ··· 32 import {useSession} from '#/state/session' 33 import {useSetMinimalShellMode} from '#/state/shell' 34 import {useHeaderOffset} from '#/components/hooks/useHeaderOffset' 35 + import {useAnalytics} from '#/analytics' 36 import {IS_NATIVE} from '#/env' 37 import {PostFeed} from '../posts/PostFeed' 38 import {FAB} from '../util/fab/FAB' ··· 63 savedFeedConfig?: AppBskyActorDefs.SavedFeed 64 feedInfo: FeedSourceInfo 65 }) { 66 + const ax = useAnalytics() 67 const {hasSession} = useSession() 68 const {_} = useLingui() 69 const navigation = useNavigation<NavigationProp<AllNavigatorParams>>() ··· 106 scrollToTop() 107 truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) 108 setHasNew(false) 109 + ax.metric('feed:refresh', { 110 feedType: feed.split('|')[0], 111 feedUrl: feed, 112 reason: 'soft-reset', 113 }) 114 } 115 + }, [ax, navigation, isPageFocused, scrollToTop, queryClient, feed]) 116 117 // fires when page within screen is activated/deactivated 118 useEffect(() => { ··· 130 scrollToTop() 131 truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) 132 setHasNew(false) 133 + ax.metric('feed:refresh', { 134 feedType: feed.split('|')[0], 135 feedUrl: feed, 136 reason: 'load-latest', 137 }) 138 + }, [ax, scrollToTop, feed, queryClient]) 139 140 const shouldPrefetch = IS_NATIVE && isPageAdjacent 141 const isDiscoverFeed = feedInfo.uri === DISCOVER_FEED_URI
+3 -2
src/view/com/posts/CustomFeedEmptyState.tsx
··· 12 import {MagnifyingGlassIcon} from '#/lib/icons' 13 import {type NavigationProp} from '#/lib/routes/types' 14 import {s} from '#/lib/styles' 15 - import {logger} from '#/logger' 16 import {useFeedFeedbackContext} from '#/state/feed-feedback' 17 import {useSession} from '#/state/session' 18 import {IS_WEB} from '#/env' 19 import {Button} from '../util/forms/Button' 20 import {Text} from '../util/text/Text' 21 22 export function CustomFeedEmptyState() { 23 const feedFeedback = useFeedFeedbackContext() 24 const {currentAccount} = useSession() 25 const hasLoggedDiscoverEmptyErrorRef = React.useRef(false) ··· 33 !hasLoggedDiscoverEmptyErrorRef.current 34 ) { 35 hasLoggedDiscoverEmptyErrorRef.current = true 36 - logger.metric('feed:discover:emptyError', { 37 userDid: currentAccount.did, 38 }) 39 }
··· 12 import {MagnifyingGlassIcon} from '#/lib/icons' 13 import {type NavigationProp} from '#/lib/routes/types' 14 import {s} from '#/lib/styles' 15 import {useFeedFeedbackContext} from '#/state/feed-feedback' 16 import {useSession} from '#/state/session' 17 + import {useAnalytics} from '#/analytics' 18 import {IS_WEB} from '#/env' 19 import {Button} from '../util/forms/Button' 20 import {Text} from '../util/text/Text' 21 22 export function CustomFeedEmptyState() { 23 + const ax = useAnalytics() 24 const feedFeedback = useFeedFeedbackContext() 25 const {currentAccount} = useSession() 26 const hasLoggedDiscoverEmptyErrorRef = React.useRef(false) ··· 34 !hasLoggedDiscoverEmptyErrorRef.current 35 ) { 36 hasLoggedDiscoverEmptyErrorRef.current = true 37 + ax.metric('feed:discover:emptyError', { 38 userDid: currentAccount.did, 39 }) 40 }
+24 -34
src/view/com/posts/PostFeed.tsx
··· 31 import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' 32 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 33 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 34 - import {logEvent} from '#/lib/statsig/statsig' 35 import {isNetworkError} from '#/lib/strings/errors' 36 import {logger} from '#/logger' 37 import {usePostAuthorShadowFilter} from '#/state/cache/profile-shadow' ··· 69 } from '#/components/feeds/PostFeedVideoGridRow' 70 import {TrendingInterstitial} from '#/components/interstitials/Trending' 71 import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos' 72 import {IS_IOS, IS_NATIVE, IS_WEB} from '#/env' 73 import {DiscoverFeedLiveEventFeedsAndTrendingBanner} from '#/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner' 74 import {ComposerPrompt} from '../feeds/ComposerPrompt' ··· 232 initialNumToRender?: number 233 isVideoFeed?: boolean 234 }): React.ReactNode => { 235 const {_} = useLingui() 236 const queryClient = useQueryClient() 237 const {currentAccount, hasSession} = useSession() ··· 685 // = 686 687 const onRefresh = useCallback(async () => { 688 - logEvent('feed:refresh', { 689 feedType: feedType, 690 feedUrl: feed, 691 reason: 'pull-to-refresh', ··· 698 logger.error('Failed to refresh posts feed', {message: err}) 699 } 700 setIsPTRing(false) 701 - }, [refetch, setIsPTRing, onHasNew, feed, feedType]) 702 703 const onEndReached = useCallback(async () => { 704 if (isFetching || !hasNextPage || isError) return 705 706 - logEvent('feed:endReached', { 707 feedType: feedType, 708 feedUrl: feed, 709 itemCount: feedItems.length, ··· 714 logger.error('Failed to load more posts', {message: err}) 715 } 716 }, [ 717 isFetching, 718 hasNextPage, 719 isError, ··· 933 934 const position = getPostPosition('sliceItem', item.key) 935 936 - logger.metric( 937 - 'post:view', 938 - { 939 - uri: post.uri, 940 - authorDid: post.author.did, 941 - logContext: 'FeedItem', 942 - feedDescriptor: feedFeedback.feedDescriptor || feed, 943 - position, 944 - }, 945 - {statsig: false}, 946 - ) 947 } 948 949 // Live status tracking (existing code) ··· 955 ) { 956 if (!seenActorWithStatusRef.current.has(actor.did)) { 957 seenActorWithStatusRef.current.add(actor.did) 958 - logger.metric( 959 - 'live:view:post', 960 - { 961 - subject: actor.did, 962 - feed, 963 - }, 964 - {statsig: false}, 965 - ) 966 } 967 } 968 } else if (item.type === 'videoGridRow') { ··· 976 977 const position = getPostPosition('videoGridRow', item.key) 978 979 - logger.metric( 980 - 'post:view', 981 - { 982 - uri: post.uri, 983 - authorDid: post.author.did, 984 - logContext: 'FeedItem', 985 - feedDescriptor: feedFeedback.feedDescriptor || feed, 986 - position, 987 - }, 988 - {statsig: false}, 989 - ) 990 } 991 } 992 }
··· 31 import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' 32 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 33 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 34 import {isNetworkError} from '#/lib/strings/errors' 35 import {logger} from '#/logger' 36 import {usePostAuthorShadowFilter} from '#/state/cache/profile-shadow' ··· 68 } from '#/components/feeds/PostFeedVideoGridRow' 69 import {TrendingInterstitial} from '#/components/interstitials/Trending' 70 import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos' 71 + import {useAnalytics} from '#/analytics' 72 import {IS_IOS, IS_NATIVE, IS_WEB} from '#/env' 73 import {DiscoverFeedLiveEventFeedsAndTrendingBanner} from '#/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner' 74 import {ComposerPrompt} from '../feeds/ComposerPrompt' ··· 232 initialNumToRender?: number 233 isVideoFeed?: boolean 234 }): React.ReactNode => { 235 + const ax = useAnalytics() 236 const {_} = useLingui() 237 const queryClient = useQueryClient() 238 const {currentAccount, hasSession} = useSession() ··· 686 // = 687 688 const onRefresh = useCallback(async () => { 689 + ax.metric('feed:refresh', { 690 feedType: feedType, 691 feedUrl: feed, 692 reason: 'pull-to-refresh', ··· 699 logger.error('Failed to refresh posts feed', {message: err}) 700 } 701 setIsPTRing(false) 702 + }, [ax, refetch, setIsPTRing, onHasNew, feed, feedType]) 703 704 const onEndReached = useCallback(async () => { 705 if (isFetching || !hasNextPage || isError) return 706 707 + ax.metric('feed:endReached', { 708 feedType: feedType, 709 feedUrl: feed, 710 itemCount: feedItems.length, ··· 715 logger.error('Failed to load more posts', {message: err}) 716 } 717 }, [ 718 + ax, 719 isFetching, 720 hasNextPage, 721 isError, ··· 935 936 const position = getPostPosition('sliceItem', item.key) 937 938 + ax.metric('post:view', { 939 + uri: post.uri, 940 + authorDid: post.author.did, 941 + logContext: 'FeedItem', 942 + feedDescriptor: feedFeedback.feedDescriptor || feed, 943 + position, 944 + }) 945 } 946 947 // Live status tracking (existing code) ··· 953 ) { 954 if (!seenActorWithStatusRef.current.has(actor.did)) { 955 seenActorWithStatusRef.current.add(actor.did) 956 + ax.metric('live:view:post', { 957 + subject: actor.did, 958 + feed, 959 + }) 960 } 961 } 962 } else if (item.type === 'videoGridRow') { ··· 970 971 const position = getPostPosition('videoGridRow', item.key) 972 973 + ax.metric('post:view', { 974 + uri: post.uri, 975 + authorDid: post.author.did, 976 + logContext: 'FeedItem', 977 + feedDescriptor: feedFeedback.feedDescriptor || feed, 978 + position, 979 + }) 980 } 981 } 982 }
+5 -4
src/view/com/posts/PostFeedItem.tsx
··· 18 import {usePalette} from '#/lib/hooks/usePalette' 19 import {makeProfileLink} from '#/lib/routes/links' 20 import {countLines} from '#/lib/strings/helpers' 21 - import {logger} from '#/logger' 22 import { 23 POST_TOMBSTONE, 24 type Shadow, ··· 48 import {DiscoverDebug} from '#/components/PostControls/DiscoverDebug' 49 import {RichText} from '#/components/RichText' 50 import {SubtleHover} from '#/components/SubtleHover' 51 import * as bsky from '#/types/bsky' 52 import {PostFeedReason} from './PostFeedReason' 53 ··· 158 rootPost: AppBskyFeedDefs.PostView 159 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 160 }): React.ReactNode => { 161 const queryClient = useQueryClient() 162 const {openComposer} = useOpenComposer() 163 const pal = usePalette('default') ··· 198 feedContext, 199 reqId, 200 }) 201 - logger.metric('post:clickthroughAuthor', { 202 uri: post.uri, 203 authorDid: post.author.did, 204 logContext: 'FeedItem', ··· 222 feedContext, 223 reqId, 224 }) 225 - logger.metric('post:clickthroughEmbed', { 226 uri: post.uri, 227 authorDid: post.author.did, 228 logContext: 'FeedItem', ··· 237 feedContext, 238 reqId, 239 }) 240 - logger.metric('post:clickthroughItem', { 241 uri: post.uri, 242 authorDid: post.author.did, 243 logContext: 'FeedItem',
··· 18 import {usePalette} from '#/lib/hooks/usePalette' 19 import {makeProfileLink} from '#/lib/routes/links' 20 import {countLines} from '#/lib/strings/helpers' 21 import { 22 POST_TOMBSTONE, 23 type Shadow, ··· 47 import {DiscoverDebug} from '#/components/PostControls/DiscoverDebug' 48 import {RichText} from '#/components/RichText' 49 import {SubtleHover} from '#/components/SubtleHover' 50 + import {useAnalytics} from '#/analytics' 51 import * as bsky from '#/types/bsky' 52 import {PostFeedReason} from './PostFeedReason' 53 ··· 158 rootPost: AppBskyFeedDefs.PostView 159 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 160 }): React.ReactNode => { 161 + const ax = useAnalytics() 162 const queryClient = useQueryClient() 163 const {openComposer} = useOpenComposer() 164 const pal = usePalette('default') ··· 199 feedContext, 200 reqId, 201 }) 202 + ax.metric('post:clickthroughAuthor', { 203 uri: post.uri, 204 authorDid: post.author.did, 205 logContext: 'FeedItem', ··· 223 feedContext, 224 reqId, 225 }) 226 + ax.metric('post:clickthroughEmbed', { 227 uri: post.uri, 228 authorDid: post.author.did, 229 logContext: 'FeedItem', ··· 238 feedContext, 239 reqId, 240 }) 241 + ax.metric('post:clickthroughItem', { 242 uri: post.uri, 243 authorDid: post.author.did, 244 logContext: 'FeedItem',
+12 -14
src/view/com/profile/ProfileFollowers.tsx
··· 12 import {useSession} from '#/state/session' 13 import {PeopleRemove2_Stroke1_Corner0_Rounded as PeopleRemoveIcon} from '#/components/icons/PeopleRemove2' 14 import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 15 import {List} from '../util/List' 16 import {ProfileCardWithFollowBtn} from './ProfileCard' 17 ··· 41 42 export function ProfileFollowers({name}: {name: string}) { 43 const {_} = useLingui() 44 const navigation = useNavigation() 45 const initialNumToRender = useInitialNumToRender() 46 const {currentAccount} = useSession() ··· 88 currentPageCount >= 3 && 89 currentPageCount > paginationTrackingRef.current.page 90 ) { 91 - logger.metric('profile:followers:paginate', { 92 contextProfileDid: resolvedDid, 93 itemCount: followers.length, 94 page: currentPageCount, 95 }) 96 } 97 paginationTrackingRef.current.page = currentPageCount 98 - }, [data?.pages?.length, resolvedDid, followers.length]) 99 100 const onRefresh = React.useCallback(async () => { 101 setIsPTRing(true) ··· 125 // track pageview 126 React.useEffect(() => { 127 if (resolvedDid) { 128 - logger.metric('profile:followers:view', { 129 contextProfileDid: resolvedDid, 130 isOwnProfile: isMe, 131 }) 132 } 133 - }, [resolvedDid, isMe]) 134 135 // track seen items 136 const seenItemsRef = React.useRef<Set<string>>(new Set()) ··· 147 if (position === 0) { 148 return 149 } 150 - logger.metric( 151 - 'profileCard:seen', 152 - { 153 - profileDid: item.did, 154 - position, 155 - ...(resolvedDid !== undefined && {contextProfileDid: resolvedDid}), 156 - }, 157 - {statsig: false}, 158 - ) 159 }, 160 - [followers, resolvedDid], 161 ) 162 163 if (followers.length < 1) {
··· 12 import {useSession} from '#/state/session' 13 import {PeopleRemove2_Stroke1_Corner0_Rounded as PeopleRemoveIcon} from '#/components/icons/PeopleRemove2' 14 import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 15 + import {useAnalytics} from '#/analytics' 16 import {List} from '../util/List' 17 import {ProfileCardWithFollowBtn} from './ProfileCard' 18 ··· 42 43 export function ProfileFollowers({name}: {name: string}) { 44 const {_} = useLingui() 45 + const ax = useAnalytics() 46 const navigation = useNavigation() 47 const initialNumToRender = useInitialNumToRender() 48 const {currentAccount} = useSession() ··· 90 currentPageCount >= 3 && 91 currentPageCount > paginationTrackingRef.current.page 92 ) { 93 + ax.metric('profile:followers:paginate', { 94 contextProfileDid: resolvedDid, 95 itemCount: followers.length, 96 page: currentPageCount, 97 }) 98 } 99 paginationTrackingRef.current.page = currentPageCount 100 + }, [ax, data?.pages?.length, resolvedDid, followers.length]) 101 102 const onRefresh = React.useCallback(async () => { 103 setIsPTRing(true) ··· 127 // track pageview 128 React.useEffect(() => { 129 if (resolvedDid) { 130 + ax.metric('profile:followers:view', { 131 contextProfileDid: resolvedDid, 132 isOwnProfile: isMe, 133 }) 134 } 135 + }, [ax, resolvedDid, isMe]) 136 137 // track seen items 138 const seenItemsRef = React.useRef<Set<string>>(new Set()) ··· 149 if (position === 0) { 150 return 151 } 152 + ax.metric('profileCard:seen', { 153 + profileDid: item.did, 154 + position, 155 + ...(resolvedDid !== undefined && {contextProfileDid: resolvedDid}), 156 + }) 157 }, 158 + [ax, followers, resolvedDid], 159 ) 160 161 if (followers.length < 1) {
+12 -14
src/view/com/profile/ProfileFollows.tsx
··· 14 import {FindContactsBannerNUX} from '#/components/contacts/FindContactsBannerNUX' 15 import {PeopleRemove2_Stroke1_Corner0_Rounded as PeopleRemoveIcon} from '#/components/icons/PeopleRemove2' 16 import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 17 import {IS_WEB} from '#/env' 18 import {List} from '../util/List' 19 import {ProfileCardWithFollowBtn} from './ProfileCard' ··· 44 45 export function ProfileFollows({name}: {name: string}) { 46 const {_} = useLingui() 47 const initialNumToRender = useInitialNumToRender() 48 const {currentAccount} = useSession() 49 const navigation = useNavigation<NavigationProp>() ··· 100 currentPageCount >= 3 && 101 currentPageCount > paginationTrackingRef.current.page 102 ) { 103 - logger.metric('profile:following:paginate', { 104 contextProfileDid: resolvedDid, 105 itemCount: follows.length, 106 page: currentPageCount, 107 }) 108 } 109 paginationTrackingRef.current.page = currentPageCount 110 - }, [data?.pages?.length, resolvedDid, follows.length]) 111 112 const onRefresh = React.useCallback(async () => { 113 setIsPTRing(true) ··· 137 // track pageview 138 React.useEffect(() => { 139 if (resolvedDid) { 140 - logger.metric('profile:following:view', { 141 contextProfileDid: resolvedDid, 142 isOwnProfile: isMe, 143 }) 144 } 145 - }, [resolvedDid, isMe]) 146 147 // track seen items 148 const seenItemsRef = React.useRef<Set<string>>(new Set()) ··· 159 if (position === 0) { 160 return 161 } 162 - logger.metric( 163 - 'profileCard:seen', 164 - { 165 - profileDid: item.did, 166 - position, 167 - ...(resolvedDid !== undefined && {contextProfileDid: resolvedDid}), 168 - }, 169 - {statsig: false}, 170 - ) 171 }, 172 - [follows, resolvedDid], 173 ) 174 175 if (follows.length < 1) {
··· 14 import {FindContactsBannerNUX} from '#/components/contacts/FindContactsBannerNUX' 15 import {PeopleRemove2_Stroke1_Corner0_Rounded as PeopleRemoveIcon} from '#/components/icons/PeopleRemove2' 16 import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 17 + import {useAnalytics} from '#/analytics' 18 import {IS_WEB} from '#/env' 19 import {List} from '../util/List' 20 import {ProfileCardWithFollowBtn} from './ProfileCard' ··· 45 46 export function ProfileFollows({name}: {name: string}) { 47 const {_} = useLingui() 48 + const ax = useAnalytics() 49 const initialNumToRender = useInitialNumToRender() 50 const {currentAccount} = useSession() 51 const navigation = useNavigation<NavigationProp>() ··· 102 currentPageCount >= 3 && 103 currentPageCount > paginationTrackingRef.current.page 104 ) { 105 + ax.metric('profile:following:paginate', { 106 contextProfileDid: resolvedDid, 107 itemCount: follows.length, 108 page: currentPageCount, 109 }) 110 } 111 paginationTrackingRef.current.page = currentPageCount 112 + }, [ax, data?.pages?.length, resolvedDid, follows.length]) 113 114 const onRefresh = React.useCallback(async () => { 115 setIsPTRing(true) ··· 139 // track pageview 140 React.useEffect(() => { 141 if (resolvedDid) { 142 + ax.metric('profile:following:view', { 143 contextProfileDid: resolvedDid, 144 isOwnProfile: isMe, 145 }) 146 } 147 + }, [ax, resolvedDid, isMe]) 148 149 // track seen items 150 const seenItemsRef = React.useRef<Set<string>>(new Set()) ··· 161 if (position === 0) { 162 return 163 } 164 + ax.metric('profileCard:seen', { 165 + profileDid: item.did, 166 + position, 167 + ...(resolvedDid !== undefined && {contextProfileDid: resolvedDid}), 168 + }) 169 }, 170 + [ax, follows, resolvedDid], 171 ) 172 173 if (follows.length < 1) {
+13 -12
src/view/com/profile/ProfileMenu.tsx
··· 11 import {type NavigationProp} from '#/lib/routes/types' 12 import {shareText, shareUrl} from '#/lib/sharing' 13 import {toShareUrl} from '#/lib/strings/url-helpers' 14 - import {logger} from '#/logger' 15 import {type Shadow} from '#/state/cache/types' 16 import {useModalControls} from '#/state/modals' 17 import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' ··· 60 import {useFullVerificationState} from '#/components/verification' 61 import {VerificationCreatePrompt} from '#/components/verification/VerificationCreatePrompt' 62 import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt' 63 import {IS_WEB} from '#/env' 64 import {Dot} from '#/features/nuxs/components/Dot' 65 import {Gradient} from '#/features/nuxs/components/Gradient' ··· 71 profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> 72 }): React.ReactNode => { 73 const t = useTheme() 74 const {_} = useLingui() 75 const {currentAccount, hasSession} = useSession() 76 const {openModal} = useModalControls() ··· 121 }, [queryClient, profile.did]) 122 123 const onPressAddToStarterPacks = React.useCallback(() => { 124 - logger.metric('profile:addToStarterPack', {}) 125 addToStarterPacksDialogControl.open() 126 }, [addToStarterPacksDialogControl]) 127 ··· 147 Toast.show(_(msg({message: 'Account unmuted', context: 'toast'}))) 148 } catch (e: any) { 149 if (e?.name !== 'AbortError') { 150 - logger.error('Failed to unmute account', {message: e}) 151 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 152 } 153 } ··· 157 Toast.show(_(msg({message: 'Account muted', context: 'toast'}))) 158 } catch (e: any) { 159 if (e?.name !== 'AbortError') { 160 - logger.error('Failed to mute account', {message: e}) 161 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 162 } 163 } 164 } 165 - }, [profile.viewer?.muted, queueUnmute, _, queueMute]) 166 167 const blockAccount = React.useCallback(async () => { 168 if (profile.viewer?.blocking) { ··· 171 Toast.show(_(msg({message: 'Account unblocked', context: 'toast'}))) 172 } catch (e: any) { 173 if (e?.name !== 'AbortError') { 174 - logger.error('Failed to unblock account', {message: e}) 175 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 176 } 177 } ··· 181 Toast.show(_(msg({message: 'Account blocked', context: 'toast'}))) 182 } catch (e: any) { 183 if (e?.name !== 'AbortError') { 184 - logger.error('Failed to block account', {message: e}) 185 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 186 } 187 } 188 } 189 - }, [profile.viewer?.blocking, _, queueUnblock, queueBlock]) 190 191 const onPressFollowAccount = React.useCallback(async () => { 192 try { ··· 194 Toast.show(_(msg({message: 'Account followed', context: 'toast'}))) 195 } catch (e: any) { 196 if (e?.name !== 'AbortError') { 197 - logger.error('Failed to follow account', {message: e}) 198 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 199 } 200 } 201 - }, [_, queueFollow]) 202 203 const onPressUnfollowAccount = React.useCallback(async () => { 204 try { ··· 206 Toast.show(_(msg({message: 'Account unfollowed', context: 'toast'}))) 207 } catch (e: any) { 208 if (e?.name !== 'AbortError') { 209 - logger.error('Failed to unfollow account', {message: e}) 210 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 211 } 212 } 213 - }, [_, queueUnfollow]) 214 215 const onPressReportAccount = React.useCallback(() => { 216 reportDialogControl.open()
··· 11 import {type NavigationProp} from '#/lib/routes/types' 12 import {shareText, shareUrl} from '#/lib/sharing' 13 import {toShareUrl} from '#/lib/strings/url-helpers' 14 import {type Shadow} from '#/state/cache/types' 15 import {useModalControls} from '#/state/modals' 16 import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' ··· 59 import {useFullVerificationState} from '#/components/verification' 60 import {VerificationCreatePrompt} from '#/components/verification/VerificationCreatePrompt' 61 import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt' 62 + import {useAnalytics} from '#/analytics' 63 import {IS_WEB} from '#/env' 64 import {Dot} from '#/features/nuxs/components/Dot' 65 import {Gradient} from '#/features/nuxs/components/Gradient' ··· 71 profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> 72 }): React.ReactNode => { 73 const t = useTheme() 74 + const ax = useAnalytics() 75 const {_} = useLingui() 76 const {currentAccount, hasSession} = useSession() 77 const {openModal} = useModalControls() ··· 122 }, [queryClient, profile.did]) 123 124 const onPressAddToStarterPacks = React.useCallback(() => { 125 + ax.metric('profile:addToStarterPack', {}) 126 addToStarterPacksDialogControl.open() 127 }, [addToStarterPacksDialogControl]) 128 ··· 148 Toast.show(_(msg({message: 'Account unmuted', context: 'toast'}))) 149 } catch (e: any) { 150 if (e?.name !== 'AbortError') { 151 + ax.logger.error('Failed to unmute account', {message: e}) 152 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 153 } 154 } ··· 158 Toast.show(_(msg({message: 'Account muted', context: 'toast'}))) 159 } catch (e: any) { 160 if (e?.name !== 'AbortError') { 161 + ax.logger.error('Failed to mute account', {message: e}) 162 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 163 } 164 } 165 } 166 + }, [ax, profile.viewer?.muted, queueUnmute, _, queueMute]) 167 168 const blockAccount = React.useCallback(async () => { 169 if (profile.viewer?.blocking) { ··· 172 Toast.show(_(msg({message: 'Account unblocked', context: 'toast'}))) 173 } catch (e: any) { 174 if (e?.name !== 'AbortError') { 175 + ax.logger.error('Failed to unblock account', {message: e}) 176 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 177 } 178 } ··· 182 Toast.show(_(msg({message: 'Account blocked', context: 'toast'}))) 183 } catch (e: any) { 184 if (e?.name !== 'AbortError') { 185 + ax.logger.error('Failed to block account', {message: e}) 186 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 187 } 188 } 189 } 190 + }, [ax, profile.viewer?.blocking, _, queueUnblock, queueBlock]) 191 192 const onPressFollowAccount = React.useCallback(async () => { 193 try { ··· 195 Toast.show(_(msg({message: 'Account followed', context: 'toast'}))) 196 } catch (e: any) { 197 if (e?.name !== 'AbortError') { 198 + ax.logger.error('Failed to follow account', {message: e}) 199 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 200 } 201 } 202 + }, [_, ax, queueFollow]) 203 204 const onPressUnfollowAccount = React.useCallback(async () => { 205 try { ··· 207 Toast.show(_(msg({message: 'Account unfollowed', context: 'toast'}))) 208 } catch (e: any) { 209 if (e?.name !== 'AbortError') { 210 + ax.logger.error('Failed to unfollow account', {message: e}) 211 Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 212 } 213 } 214 + }, [_, ax, queueUnfollow]) 215 216 const onPressReportAccount = React.useCallback(() => { 217 reportDialogControl.open()
+3 -5
src/view/com/util/UserAvatar.tsx
··· 52 import {MediaInsetBorder} from '#/components/MediaInsetBorder' 53 import * as Menu from '#/components/Menu' 54 import {ProfileHoverCard} from '#/components/ProfileHoverCard' 55 import {IS_ANDROID, IS_NATIVE, IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env' 56 import type * as bsky from '#/types/bsky' 57 ··· 530 live, 531 ...props 532 }: PreviewableUserAvatarProps): React.ReactNode => { 533 const {_} = useLingui() 534 const queryClient = useQueryClient() 535 const status = useActorStatus(profile) ··· 543 544 const onOpenLiveStatus = useCallback(() => { 545 playHaptic('Light') 546 - logger.metric( 547 - 'live:card:open', 548 - {subject: profile.did, from: 'post'}, 549 - {statsig: true}, 550 - ) 551 liveControl.open() 552 }, [liveControl, playHaptic, profile.did]) 553
··· 52 import {MediaInsetBorder} from '#/components/MediaInsetBorder' 53 import * as Menu from '#/components/Menu' 54 import {ProfileHoverCard} from '#/components/ProfileHoverCard' 55 + import {useAnalytics} from '#/analytics' 56 import {IS_ANDROID, IS_NATIVE, IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env' 57 import type * as bsky from '#/types/bsky' 58 ··· 531 live, 532 ...props 533 }: PreviewableUserAvatarProps): React.ReactNode => { 534 + const ax = useAnalytics() 535 const {_} = useLingui() 536 const queryClient = useQueryClient() 537 const status = useActorStatus(profile) ··· 545 546 const onOpenLiveStatus = useCallback(() => { 547 playHaptic('Light') 548 + ax.metric('live:card:open', {subject: profile.did, from: 'post'}) 549 liveControl.open() 550 }, [liveControl, playHaptic, profile.did]) 551
+5 -4
src/view/screens/Home.tsx
··· 11 type HomeTabNavigatorParams, 12 type NativeStackScreenProps, 13 } from '#/lib/routes/types' 14 - import {logEvent} from '#/lib/statsig/statsig' 15 import {emitSoftReset} from '#/state/events' 16 import { 17 type SavedFeedSourceInfo, ··· 36 import {FollowingEndOfFeed} from '#/view/com/posts/FollowingEndOfFeed' 37 import {NoFeedsPinned} from '#/screens/Home/NoFeedsPinned' 38 import * as Layout from '#/components/Layout' 39 import {IS_WEB} from '#/env' 40 import {useDemoMode} from '#/storage/hooks/demo-mode' 41 ··· 105 preferences: UsePreferencesQueryResponse 106 pinnedFeedInfos: SavedFeedSourceInfo[] 107 }) { 108 const allFeeds = React.useMemo( 109 () => pinnedFeedInfos.map(f => f.feedDescriptor), 110 [pinnedFeedInfos], ··· 147 useFocusEffect( 148 useNonReactiveCallback(() => { 149 if (maybeSelectedFeed) { 150 - logEvent('home:feedDisplayed', { 151 index: selectedIndex, 152 feedType: maybeSelectedFeed.split('|')[0], 153 feedUrl: maybeSelectedFeed, ··· 168 setSelectedFeed(maybeFeed) 169 170 if (maybeFeed) { 171 - logEvent('home:feedDisplayed', { 172 index, 173 feedType: maybeFeed.split('|')[0], 174 feedUrl: maybeFeed, 175 }) 176 } 177 }, 178 - [setSelectedFeed, setMinimalShellMode, allFeeds], 179 ) 180 181 const onPressSelected = React.useCallback(() => {
··· 11 type HomeTabNavigatorParams, 12 type NativeStackScreenProps, 13 } from '#/lib/routes/types' 14 import {emitSoftReset} from '#/state/events' 15 import { 16 type SavedFeedSourceInfo, ··· 35 import {FollowingEndOfFeed} from '#/view/com/posts/FollowingEndOfFeed' 36 import {NoFeedsPinned} from '#/screens/Home/NoFeedsPinned' 37 import * as Layout from '#/components/Layout' 38 + import {useAnalytics} from '#/analytics' 39 import {IS_WEB} from '#/env' 40 import {useDemoMode} from '#/storage/hooks/demo-mode' 41 ··· 105 preferences: UsePreferencesQueryResponse 106 pinnedFeedInfos: SavedFeedSourceInfo[] 107 }) { 108 + const ax = useAnalytics() 109 const allFeeds = React.useMemo( 110 () => pinnedFeedInfos.map(f => f.feedDescriptor), 111 [pinnedFeedInfos], ··· 148 useFocusEffect( 149 useNonReactiveCallback(() => { 150 if (maybeSelectedFeed) { 151 + ax.metric('home:feedDisplayed', { 152 index: selectedIndex, 153 feedType: maybeSelectedFeed.split('|')[0], 154 feedUrl: maybeSelectedFeed, ··· 169 setSelectedFeed(maybeFeed) 170 171 if (maybeFeed) { 172 + ax.metric('home:feedDisplayed', { 173 index, 174 feedType: maybeFeed.split('|')[0], 175 feedUrl: maybeFeed, 176 }) 177 } 178 }, 179 + [ax, setSelectedFeed, setMinimalShellMode, allFeeds], 180 ) 181 182 const onPressSelected = React.useCallback(() => {
+6 -9
src/view/shell/desktop/Feeds.tsx
··· 5 6 import {getCurrentRoute} from '#/lib/routes/helpers' 7 import {type NavigationProp} from '#/lib/routes/types' 8 - import {logger} from '#/logger' 9 import {emitSoftReset} from '#/state/events' 10 import { 11 type SavedFeedSourceInfo, ··· 19 import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 20 import {Link} from '#/components/Link' 21 import {Text} from '#/components/Typography' 22 23 export function DesktopFeeds() { 24 const t = useTheme() 25 const {_} = useLingui() 26 const {data: pinnedFeedInfos, error, isLoading} = usePinnedFeedsInfos() 27 const selectedFeed = useSelectedFeed() 28 const setSelectedFeed = useSetSelectedFeed() ··· 86 feedInfo={feedInfo} 87 current={current} 88 onPress={() => { 89 - logger.metric( 90 - 'desktopFeeds:feed:click', 91 - { 92 - feedUri: feedInfo.uri, 93 - feedDescriptor: feed, 94 - }, 95 - {statsig: false}, 96 - ) 97 setSelectedFeed(feed) 98 navigation.navigate('Home') 99 if (route.name === 'Home' && feed === selectedFeed) {
··· 5 6 import {getCurrentRoute} from '#/lib/routes/helpers' 7 import {type NavigationProp} from '#/lib/routes/types' 8 import {emitSoftReset} from '#/state/events' 9 import { 10 type SavedFeedSourceInfo, ··· 18 import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 19 import {Link} from '#/components/Link' 20 import {Text} from '#/components/Typography' 21 + import {useAnalytics} from '#/analytics' 22 23 export function DesktopFeeds() { 24 const t = useTheme() 25 const {_} = useLingui() 26 + const ax = useAnalytics() 27 const {data: pinnedFeedInfos, error, isLoading} = usePinnedFeedsInfos() 28 const selectedFeed = useSelectedFeed() 29 const setSelectedFeed = useSetSelectedFeed() ··· 87 feedInfo={feedInfo} 88 current={current} 89 onPress={() => { 90 + ax.metric('desktopFeeds:feed:click', { 91 + feedUri: feedInfo.uri, 92 + feedDescriptor: feed, 93 + }) 94 setSelectedFeed(feed) 95 navigation.navigate('Home') 96 if (route.name === 'Home' && feed === selectedFeed) {
+4 -3
src/view/shell/desktop/SidebarTrendingTopics.tsx
··· 2 import {msg, Trans} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 - import {logger} from '#/logger' 6 import { 7 useTrendingSettings, 8 useTrendingSettingsApi, ··· 16 import * as Prompt from '#/components/Prompt' 17 import {TrendingTopicLink} from '#/components/TrendingTopics' 18 import {Text} from '#/components/Typography' 19 20 const TRENDING_LIMIT = 5 21 ··· 28 function Inner() { 29 const t = useTheme() 30 const {_} = useLingui() 31 const trendingPrompt = Prompt.usePromptControl() 32 const {setTrendingDisabled} = useTrendingSettingsApi() 33 const {data: trending, error, isLoading} = useTrendingTopics() 34 const noTopics = !isLoading && !error && !trending?.topics?.length 35 36 const onConfirmHide = () => { 37 - logger.metric('trendingTopics:hide', {context: 'sidebar'}) 38 setTrendingDisabled(true) 39 } 40 ··· 90 topic={topic} 91 style={[a.self_start]} 92 onPress={() => { 93 - logger.metric('trendingTopic:click', {context: 'sidebar'}) 94 }}> 95 {({hovered}) => ( 96 <View style={[a.flex_row, a.align_center, a.gap_xs]}>
··· 2 import {msg, Trans} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 import { 6 useTrendingSettings, 7 useTrendingSettingsApi, ··· 15 import * as Prompt from '#/components/Prompt' 16 import {TrendingTopicLink} from '#/components/TrendingTopics' 17 import {Text} from '#/components/Typography' 18 + import {useAnalytics} from '#/analytics' 19 20 const TRENDING_LIMIT = 5 21 ··· 28 function Inner() { 29 const t = useTheme() 30 const {_} = useLingui() 31 + const ax = useAnalytics() 32 const trendingPrompt = Prompt.usePromptControl() 33 const {setTrendingDisabled} = useTrendingSettingsApi() 34 const {data: trending, error, isLoading} = useTrendingTopics() 35 const noTopics = !isLoading && !error && !trending?.topics?.length 36 37 const onConfirmHide = () => { 38 + ax.metric('trendingTopics:hide', {context: 'sidebar'}) 39 setTrendingDisabled(true) 40 } 41 ··· 91 topic={topic} 92 style={[a.self_start]} 93 onPress={() => { 94 + ax.metric('trendingTopic:click', {context: 'sidebar'}) 95 }}> 96 {({hovered}) => ( 97 <View style={[a.flex_row, a.align_center, a.gap_xs]}>
+3
src/view/shell/index.tsx
··· 42 import {useAgeAssurance} from '#/ageAssurance' 43 import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen' 44 import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay' 45 import {IS_ANDROID, IS_IOS} from '#/env' 46 import {RoutesContainer, TabsNavigator} from '#/Navigation' 47 import {BottomSheetOutlet} from '../../../modules/bottom-sheet' ··· 245 <RedirectOverlay /> 246 </> 247 )} 248 </View> 249 ) 250 }
··· 42 import {useAgeAssurance} from '#/ageAssurance' 43 import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen' 44 import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay' 45 + import {PassiveAnalytics} from '#/analytics/PassiveAnalytics' 46 import {IS_ANDROID, IS_IOS} from '#/env' 47 import {RoutesContainer, TabsNavigator} from '#/Navigation' 48 import {BottomSheetOutlet} from '../../../modules/bottom-sheet' ··· 246 <RedirectOverlay /> 247 </> 248 )} 249 + 250 + <PassiveAnalytics /> 251 </View> 252 ) 253 }
+3
src/view/shell/index.web.tsx
··· 34 import {useAgeAssurance} from '#/ageAssurance' 35 import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen' 36 import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay' 37 import {FlatNavigator, RoutesContainer} from '#/Navigation' 38 import {Composer} from './Composer.web' 39 import {DrawerContent} from './Drawer' ··· 181 <RedirectOverlay /> 182 </> 183 )} 184 </View> 185 ) 186 }
··· 34 import {useAgeAssurance} from '#/ageAssurance' 35 import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen' 36 import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay' 37 + import {PassiveAnalytics} from '#/analytics/PassiveAnalytics' 38 import {FlatNavigator, RoutesContainer} from '#/Navigation' 39 import {Composer} from './Composer.web' 40 import {DrawerContent} from './Drawer' ··· 182 <RedirectOverlay /> 183 </> 184 )} 185 + 186 + <PassiveAnalytics /> 187 </View> 188 ) 189 }
+22 -38
yarn.lock
··· 4678 dependencies: 4679 nanoid "^3.3.1" 4680 4681 "@grpc/grpc-js@^1.8.20": 4682 version "1.13.3" 4683 resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.13.3.tgz#6ad08d186c2a8651697085f790c5c68eaca45904" ··· 6262 resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.1.tgz#78244efe12930c56fd255d7923865857c41ac8cb" 6263 integrity sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw== 6264 6265 - "@react-native-async-storage/async-storage@2.2.0", "@react-native-async-storage/async-storage@^1.15.2": 6266 version "2.2.0" 6267 resolved "https://registry.yarnpkg.com/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz#a3aa565253e46286655560172f4e366e8969f5ad" 6268 integrity sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw== ··· 10517 dependencies: 10518 utila "~0.4" 10519 10520 dom-serializer@^1.0.1: 10521 version "1.4.1" 10522 resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30" ··· 11604 resolved "https://registry.yarnpkg.com/expo-clipboard/-/expo-clipboard-8.0.8.tgz#5e52054a4bbaebef090ec6fe5eaa200072ff94f7" 11605 integrity sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA== 11606 11607 - expo-constants@18.0.8, expo-constants@^13.0.2, expo-constants@~18.0.11: 11608 version "18.0.8" 11609 resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-18.0.8.tgz#14f8388136de6e83d651bd68b326a675dfb7051c" 11610 integrity sha512-Tetphsx6RVImCTZeBAclRQMy0WOODY3y6qrUoc88YGUBVm8fAKkErCSWxLTCc6nFcJxdoOMYi62LgNIUFjZCLA== ··· 11649 dependencies: 11650 expo-dev-menu-interface "2.0.0" 11651 11652 - expo-device@7.1.4, expo-device@~4.1.1: 11653 version "7.1.4" 11654 resolved "https://registry.yarnpkg.com/expo-device/-/expo-device-7.1.4.tgz#84ae7c2520cc45f15a9cb0433ae1226c33f7a8ef" 11655 integrity sha512-HS04IiE1Fy0FRjBLurr9e5A6yj3kbmQB+2jCZvbSGpsjBnCLdSk/LCii4f5VFhPIBWJLyYuN5QqJyEAw6BcS4Q== ··· 17271 hoist-non-react-statics "^3.3.0" 17272 invariant "^2.2.4" 17273 17274 - react-native-get-random-values@^1.6.0: 17275 - version "1.10.0" 17276 - resolved "https://registry.yarnpkg.com/react-native-get-random-values/-/react-native-get-random-values-1.10.0.tgz#c2c5f12a4ef8b1175145347b4a4b9f9a40d9ffc8" 17277 - integrity sha512-gZ1zbXhbb8+Jy9qYTV8c4Nf45/VB4g1jmXuavY5rPfUn7x3ok9Vl3FTl0dnE92Z4FFtfbUNNwtSfcmomdtWg+A== 17278 - dependencies: 17279 - fast-base64-decode "^1.0.0" 17280 - 17281 react-native-get-random-values@~1.11.0: 17282 version "1.11.0" 17283 resolved "https://registry.yarnpkg.com/react-native-get-random-values/-/react-native-get-random-values-1.11.0.tgz#1ca70d1271f4b08af92958803b89dccbda78728d" ··· 18716 resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz#8953fc05359868a77b5b9739a665c5977bb7df45" 18717 integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A== 18718 18719 - statsig-js@4.45.1: 18720 - version "4.45.1" 18721 - resolved "https://registry.yarnpkg.com/statsig-js/-/statsig-js-4.45.1.tgz#b1f5b9c52adc4a8aece376fb011416c89227932f" 18722 - integrity sha512-h94RzFQsJCQCNwQXpZ9OBXcvCxDnkXF6OrCekd81ySvY2l4JSowpxMWX3Iw6IDFzfTfKdER9JQzFLhMSQbT+YQ== 18723 - dependencies: 18724 - js-sha256 "^0.10.1" 18725 - uuid "^8.3.2" 18726 - 18727 statsig-node@^5.23.1: 18728 version "5.25.1" 18729 resolved "https://registry.yarnpkg.com/statsig-node/-/statsig-node-5.25.1.tgz#6d8ea9ecaad6c09250e5ff7d33eda9fd0f9c05f4" ··· 18733 node-fetch "^2.6.13" 18734 ua-parser-js "^1.0.2" 18735 uuid "^8.3.2" 18736 - 18737 - statsig-react-native-expo@^4.6.1: 18738 - version "4.6.1" 18739 - resolved "https://registry.yarnpkg.com/statsig-react-native-expo/-/statsig-react-native-expo-4.6.1.tgz#0bdf49fee7112f7f28bff2405f4ba0c1727bb3d6" 18740 - integrity sha512-rB60c+WSrQPmjW9j75d+acUtwSOe38PE2KTDHiOv1Mf+0TCcFtGYlJmKCibWvbeXR7ZAyjjGeroh23bCSEZauQ== 18741 - dependencies: 18742 - "@react-native-async-storage/async-storage" "^1.15.2" 18743 - expo-constants "^13.0.2" 18744 - expo-device "~4.1.1" 18745 - js-sha256 "^0.9.0" 18746 - react-native-get-random-values "^1.6.0" 18747 - statsig-react "^1.21.1" 18748 - uuid "^8.3.2" 18749 - 18750 - statsig-react@^1.21.1: 18751 - version "1.35.0" 18752 - resolved "https://registry.yarnpkg.com/statsig-react/-/statsig-react-1.35.0.tgz#ad5730b83f564c640623e954fcbcbe848e939946" 18753 - integrity sha512-KLN7dhq6FvAl25Z0QN6IINFBgM3yn0GMafoE698tYZqRf911xvevFaR7qUXiTz3W9vmFYrmFRouqVMfCv7DW0A== 18754 - dependencies: 18755 - statsig-js "4.45.1" 18756 18757 statuses@2.0.1: 18758 version "2.0.1"
··· 4678 dependencies: 4679 nanoid "^3.3.1" 4680 4681 + "@growthbook/growthbook-react@^1.6.2": 4682 + version "1.6.2" 4683 + resolved "https://registry.yarnpkg.com/@growthbook/growthbook-react/-/growthbook-react-1.6.2.tgz#847135be0c46b167f980dbe6015e5f4ed010475c" 4684 + integrity sha512-96Bo2Jwd4NBn/kBLN4ceF299PhTw8fQltRykD32hu2xMW8/LXhB8swxbPshGK+Xfa2gjgt24kpZ5oSvaVLLT7w== 4685 + dependencies: 4686 + "@growthbook/growthbook" "^1.6.2" 4687 + 4688 + "@growthbook/growthbook@^1.6.2": 4689 + version "1.6.2" 4690 + resolved "https://registry.yarnpkg.com/@growthbook/growthbook/-/growthbook-1.6.2.tgz#6a25122deac8a09955f6bddeb134af62b209809b" 4691 + integrity sha512-x3sK6Lff4BVusIzcdBeHZqA3B4kLs3kM/pJ7vvLTJwb6N/+Yn99EF1yc0XU6cfDVqFq6uFkvIFhMwDWUaKD73g== 4692 + dependencies: 4693 + dom-mutator "^0.6.0" 4694 + 4695 "@grpc/grpc-js@^1.8.20": 4696 version "1.13.3" 4697 resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.13.3.tgz#6ad08d186c2a8651697085f790c5c68eaca45904" ··· 6276 resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.1.tgz#78244efe12930c56fd255d7923865857c41ac8cb" 6277 integrity sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw== 6278 6279 + "@react-native-async-storage/async-storage@2.2.0": 6280 version "2.2.0" 6281 resolved "https://registry.yarnpkg.com/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz#a3aa565253e46286655560172f4e366e8969f5ad" 6282 integrity sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw== ··· 10531 dependencies: 10532 utila "~0.4" 10533 10534 + dom-mutator@^0.6.0: 10535 + version "0.6.0" 10536 + resolved "https://registry.yarnpkg.com/dom-mutator/-/dom-mutator-0.6.0.tgz#079d7a4b3e8981a562cd777548b99baab51d65c5" 10537 + integrity sha512-iCt9o0aYfXMUkz/43ZOAUFQYotjGB+GNbYJiJdz4TgXkyToXbbRy5S6FbTp72lRBtfpUMwEc1KmpFEU4CZeoNg== 10538 + 10539 dom-serializer@^1.0.1: 10540 version "1.4.1" 10541 resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30" ··· 11623 resolved "https://registry.yarnpkg.com/expo-clipboard/-/expo-clipboard-8.0.8.tgz#5e52054a4bbaebef090ec6fe5eaa200072ff94f7" 11624 integrity sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA== 11625 11626 + expo-constants@18.0.8, expo-constants@~18.0.11: 11627 version "18.0.8" 11628 resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-18.0.8.tgz#14f8388136de6e83d651bd68b326a675dfb7051c" 11629 integrity sha512-Tetphsx6RVImCTZeBAclRQMy0WOODY3y6qrUoc88YGUBVm8fAKkErCSWxLTCc6nFcJxdoOMYi62LgNIUFjZCLA== ··· 11668 dependencies: 11669 expo-dev-menu-interface "2.0.0" 11670 11671 + expo-device@7.1.4: 11672 version "7.1.4" 11673 resolved "https://registry.yarnpkg.com/expo-device/-/expo-device-7.1.4.tgz#84ae7c2520cc45f15a9cb0433ae1226c33f7a8ef" 11674 integrity sha512-HS04IiE1Fy0FRjBLurr9e5A6yj3kbmQB+2jCZvbSGpsjBnCLdSk/LCii4f5VFhPIBWJLyYuN5QqJyEAw6BcS4Q== ··· 17290 hoist-non-react-statics "^3.3.0" 17291 invariant "^2.2.4" 17292 17293 react-native-get-random-values@~1.11.0: 17294 version "1.11.0" 17295 resolved "https://registry.yarnpkg.com/react-native-get-random-values/-/react-native-get-random-values-1.11.0.tgz#1ca70d1271f4b08af92958803b89dccbda78728d" ··· 18728 resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz#8953fc05359868a77b5b9739a665c5977bb7df45" 18729 integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A== 18730 18731 statsig-node@^5.23.1: 18732 version "5.25.1" 18733 resolved "https://registry.yarnpkg.com/statsig-node/-/statsig-node-5.25.1.tgz#6d8ea9ecaad6c09250e5ff7d33eda9fd0f9c05f4" ··· 18737 node-fetch "^2.6.13" 18738 ua-parser-js "^1.0.2" 18739 uuid "^8.3.2" 18740 18741 statuses@2.0.1: 18742 version "2.0.1"