[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
This commit is contained in:
Eric Bailey
2026-01-22 15:33:45 -06:00
committed by GitHub
parent 8614e5e105
commit 25a1ed1209
190 changed files with 2358 additions and 1689 deletions
+7
View File
@@ -28,6 +28,13 @@ EXPO_PUBLIC_CHAT_PROXY_DID=
# #
# #
# Bluesky's metrics API
EXPO_PUBLIC_METRICS_API_HOST=
# Growthbook config
EXPO_PUBLIC_GROWTHBOOK_API_HOST=
EXPO_PUBLIC_GROWTHBOOK_CLIENT_KEY=
# Sentry DSN for telemetry # Sentry DSN for telemetry
EXPO_PUBLIC_SENTRY_DSN= EXPO_PUBLIC_SENTRY_DSN=
-1
View File
@@ -119,7 +119,6 @@ export default defineConfig(
}, },
], ],
'bsky-internal/use-exact-imports': 'error', 'bsky-internal/use-exact-imports': 'error',
'bsky-internal/use-typed-gates': 'error',
'bsky-internal/use-prefixed-imports': 'error', 'bsky-internal/use-prefixed-imports': 'error',
/** /**
-1
View File
@@ -8,7 +8,6 @@ const plugin = {
rules: { rules: {
'avoid-unwrapped-text': require('./avoid-unwrapped-text'), 'avoid-unwrapped-text': require('./avoid-unwrapped-text'),
'use-exact-imports': require('./use-exact-imports'), 'use-exact-imports': require('./use-exact-imports'),
'use-typed-gates': require('./use-typed-gates'),
'use-prefixed-imports': require('./use-prefixed-imports'), 'use-prefixed-imports': require('./use-prefixed-imports'),
}, },
} }
-41
View File
@@ -1,41 +0,0 @@
'use strict'
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'Enforce using internal statsig wrapper instead of npm package',
},
schema: [],
},
create(context) {
return {
ImportSpecifier(node) {
if (
!node.local ||
node.local.type !== 'Identifier' ||
node.local.name !== 'useGate'
) {
return
}
if (
node.parent.type !== 'ImportDeclaration' ||
!node.parent.source ||
node.parent.source.type !== 'Literal'
) {
return
}
const source = node.parent.source.value
if (source.startsWith('statsig') || source.startsWith('@statsig')) {
context.report({
node,
message:
"Use useGate() from '#/lib/statsig/statsig' instead of the one on npm.",
})
}
// TODO: Verify gate() call results aren't stored in variables.
},
}
},
}
+1 -11
View File
@@ -99,19 +99,9 @@ jest.mock('expo-modules-core', () => ({
requireNativeViewManager: jest.fn().mockImplementation(_ => { requireNativeViewManager: jest.fn().mockImplementation(_ => {
return () => null return () => null
}), }),
createPermissionHook: () => () => [true],
})) }))
jest.mock('expo-localization', () => ({ jest.mock('expo-localization', () => ({
getLocales: () => [], getLocales: () => [],
})) }))
jest.mock('statsig-react-native-expo', () => ({
Statsig: {
initialize() {},
initializeCalled() {
return false
},
},
}))
jest.mock('../src/lib/statsig/statsig', () => ({}))
+1 -1
View File
@@ -93,6 +93,7 @@
"@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-native-fontawesome": "^0.3.2", "@fortawesome/react-native-fontawesome": "^0.3.2",
"@growthbook/growthbook-react": "^1.6.2",
"@haileyok/bluesky-video": "0.3.2", "@haileyok/bluesky-video": "0.3.2",
"@ipld/dag-cbor": "^9.2.0", "@ipld/dag-cbor": "^9.2.0",
"@lingui/react": "^4.14.1", "@lingui/react": "^4.14.1",
@@ -219,7 +220,6 @@
"react-textarea-autosize": "^8.5.3", "react-textarea-autosize": "^8.5.3",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"sonner-native": "^0.21.0", "sonner-native": "^0.21.0",
"statsig-react-native-expo": "^4.6.1",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"tlds": "^1.234.0", "tlds": "^1.234.0",
"tldts": "^6.1.46", "tldts": "^6.1.46",
+40 -33
View File
@@ -17,7 +17,6 @@ import * as Sentry from '@sentry/react-native'
import {KeyboardControllerProvider} from '#/lib/hooks/useEnableKeyboardController' import {KeyboardControllerProvider} from '#/lib/hooks/useEnableKeyboardController'
import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder' import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder'
import {QueryProvider} from '#/lib/react-query' import {QueryProvider} from '#/lib/react-query'
import {Provider as StatsigProvider, tryFetchGates} from '#/lib/statsig/statsig'
import {s} from '#/lib/styles' import {s} from '#/lib/styles'
import {ThemeProvider} from '#/lib/ThemeContext' import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider' import I18nProvider from '#/locale/i18nProvider'
@@ -69,6 +68,12 @@ import {
prefetchAgeAssuranceConfig, prefetchAgeAssuranceConfig,
Provider as AgeAssuranceV2Provider, Provider as AgeAssuranceV2Provider,
} from '#/ageAssurance' } from '#/ageAssurance'
import {
AnalyticsContext,
AnalyticsFeaturesContext,
features,
setupDeviceId,
} from '#/analytics'
import {IS_ANDROID, IS_IOS} from '#/env' import {IS_ANDROID, IS_IOS} from '#/env'
import { import {
prefetchLiveEvents, prefetchLiveEvents,
@@ -114,7 +119,7 @@ function InnerApp() {
if (account) { if (account) {
await resumeSession(account) await resumeSession(account)
} else { } else {
await tryFetchGates(undefined, 'prefer-fresh-gates') await features.init
} }
} catch (e) { } catch (e) {
logger.error(`session: resume failed`, {message: e}) logger.error(`session: resume failed`, {message: e})
@@ -144,9 +149,9 @@ function InnerApp() {
<React.Fragment <React.Fragment
// Resets the entire tree below when it changes: // Resets the entire tree below when it changes:
key={currentAccount?.did}> key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}> <AnalyticsFeaturesContext>
<PolicyUpdateOverlayProvider> <QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider> <PolicyUpdateOverlayProvider>
<LiveEventsProvider> <LiveEventsProvider>
<AgeAssuranceV2Provider> <AgeAssuranceV2Provider>
<ComposerProvider> <ComposerProvider>
@@ -192,9 +197,9 @@ function InnerApp() {
</ComposerProvider> </ComposerProvider>
</AgeAssuranceV2Provider> </AgeAssuranceV2Provider>
</LiveEventsProvider> </LiveEventsProvider>
</StatsigProvider> </PolicyUpdateOverlayProvider>
</PolicyUpdateOverlayProvider> </QueryProvider>
</QueryProvider> </AnalyticsFeaturesContext>
</React.Fragment> </React.Fragment>
</VideoVolumeProvider> </VideoVolumeProvider>
</Splash> </Splash>
@@ -208,7 +213,7 @@ function App() {
const [isReady, setReady] = useState(false) const [isReady, setReady] = useState(false)
React.useEffect(() => { React.useEffect(() => {
Promise.all([initPersistedState(), Geo.resolve()]).then(() => Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() =>
setReady(true), setReady(true),
) )
}, []) }, [])
@@ -226,30 +231,32 @@ function App() {
<A11yProvider> <A11yProvider>
<KeyboardControllerProvider> <KeyboardControllerProvider>
<OnboardingProvider> <OnboardingProvider>
<SessionProvider> <AnalyticsContext>
<PrefsStateProvider> <SessionProvider>
<I18nProvider> <PrefsStateProvider>
<ShellStateProvider> <I18nProvider>
<ModalStateProvider> <ShellStateProvider>
<DialogStateProvider> <ModalStateProvider>
<LightboxStateProvider> <DialogStateProvider>
<PortalProvider> <LightboxStateProvider>
<BottomSheetProvider> <PortalProvider>
<StarterPackProvider> <BottomSheetProvider>
<SafeAreaProvider <StarterPackProvider>
initialMetrics={initialWindowMetrics}> <SafeAreaProvider
<InnerApp /> initialMetrics={initialWindowMetrics}>
</SafeAreaProvider> <InnerApp />
</StarterPackProvider> </SafeAreaProvider>
</BottomSheetProvider> </StarterPackProvider>
</PortalProvider> </BottomSheetProvider>
</LightboxStateProvider> </PortalProvider>
</DialogStateProvider> </LightboxStateProvider>
</ModalStateProvider> </DialogStateProvider>
</ShellStateProvider> </ModalStateProvider>
</I18nProvider> </ShellStateProvider>
</PrefsStateProvider> </I18nProvider>
</SessionProvider> </PrefsStateProvider>
</SessionProvider>
</AnalyticsContext>
</OnboardingProvider> </OnboardingProvider>
</KeyboardControllerProvider> </KeyboardControllerProvider>
</A11yProvider> </A11yProvider>
+40 -29
View File
@@ -9,7 +9,6 @@ import {useLingui} from '@lingui/react'
import * as Sentry from '@sentry/react-native' import * as Sentry from '@sentry/react-native'
import {QueryProvider} from '#/lib/react-query' import {QueryProvider} from '#/lib/react-query'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {ThemeProvider} from '#/lib/ThemeContext' import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider' import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger' import {logger} from '#/logger'
@@ -55,8 +54,16 @@ import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext' import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext'
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import {ToastOutlet} from '#/components/Toast' import {ToastOutlet} from '#/components/Toast'
import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance' import {
import {prefetchAgeAssuranceConfig} from '#/ageAssurance' prefetchAgeAssuranceConfig,
Provider as AgeAssuranceV2Provider,
} from '#/ageAssurance'
import {
AnalyticsContext,
AnalyticsFeaturesContext,
features,
setupDeviceId,
} from '#/analytics'
import { import {
prefetchLiveEvents, prefetchLiveEvents,
Provider as LiveEventsProvider, Provider as LiveEventsProvider,
@@ -87,6 +94,8 @@ function InnerApp() {
try { try {
if (account) { if (account) {
await resumeSession(account) await resumeSession(account)
} else {
await features.init
} }
} catch (e) { } catch (e) {
logger.error(`session: resumeSession failed`, {message: e}) logger.error(`session: resumeSession failed`, {message: e})
@@ -119,9 +128,9 @@ function InnerApp() {
<React.Fragment <React.Fragment
// Resets the entire tree below when it changes: // Resets the entire tree below when it changes:
key={currentAccount?.did}> key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}> <AnalyticsFeaturesContext>
<PolicyUpdateOverlayProvider> <QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider> <PolicyUpdateOverlayProvider>
<LiveEventsProvider> <LiveEventsProvider>
<AgeAssuranceV2Provider> <AgeAssuranceV2Provider>
<ComposerProvider> <ComposerProvider>
@@ -163,9 +172,9 @@ function InnerApp() {
</ComposerProvider> </ComposerProvider>
</AgeAssuranceV2Provider> </AgeAssuranceV2Provider>
</LiveEventsProvider> </LiveEventsProvider>
</StatsigProvider> </PolicyUpdateOverlayProvider>
</PolicyUpdateOverlayProvider> </QueryProvider>
</QueryProvider> </AnalyticsFeaturesContext>
</React.Fragment> </React.Fragment>
</ActiveVideoProvider> </ActiveVideoProvider>
</VideoVolumeProvider> </VideoVolumeProvider>
@@ -179,7 +188,7 @@ function App() {
const [isReady, setReady] = useState(false) const [isReady, setReady] = useState(false)
React.useEffect(() => { React.useEffect(() => {
Promise.all([initPersistedState(), Geo.resolve()]).then(() => Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() =>
setReady(true), setReady(true),
) )
}, []) }, [])
@@ -196,25 +205,27 @@ function App() {
<Geo.Provider> <Geo.Provider>
<A11yProvider> <A11yProvider>
<OnboardingProvider> <OnboardingProvider>
<SessionProvider> <AnalyticsContext>
<PrefsStateProvider> <SessionProvider>
<I18nProvider> <PrefsStateProvider>
<ShellStateProvider> <I18nProvider>
<ModalStateProvider> <ShellStateProvider>
<DialogStateProvider> <ModalStateProvider>
<LightboxStateProvider> <DialogStateProvider>
<PortalProvider> <LightboxStateProvider>
<StarterPackProvider> <PortalProvider>
<InnerApp /> <StarterPackProvider>
</StarterPackProvider> <InnerApp />
</PortalProvider> </StarterPackProvider>
</LightboxStateProvider> </PortalProvider>
</DialogStateProvider> </LightboxStateProvider>
</ModalStateProvider> </DialogStateProvider>
</ShellStateProvider> </ModalStateProvider>
</I18nProvider> </ShellStateProvider>
</PrefsStateProvider> </I18nProvider>
</SessionProvider> </PrefsStateProvider>
</SessionProvider>
</AnalyticsContext>
</OnboardingProvider> </OnboardingProvider>
</A11yProvider> </A11yProvider>
</Geo.Provider> </Geo.Provider>
+67 -81
View File
@@ -28,7 +28,7 @@ import {
storePayloadForAccountSwitch, storePayloadForAccountSwitch,
} from '#/lib/hooks/useNotificationHandler' } from '#/lib/hooks/useNotificationHandler'
import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration' import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
import {logger as notyLogger} from '#/lib/notifications/util' import {useCallOnce} from '#/lib/once'
import {buildStateObject} from '#/lib/routes/helpers' import {buildStateObject} from '#/lib/routes/helpers'
import { import {
type AllNavigatorParams, type AllNavigatorParams,
@@ -38,12 +38,11 @@ import {
type MessagesTabNavigatorParams, type MessagesTabNavigatorParams,
type MyProfileTabNavigatorParams, type MyProfileTabNavigatorParams,
type NotificationsTabNavigatorParams, type NotificationsTabNavigatorParams,
type RouteParams,
type SearchTabNavigatorParams, type SearchTabNavigatorParams,
type State,
} from '#/lib/routes/types' } from '#/lib/routes/types'
import {type RouteParams, type State} from '#/lib/routes/types'
import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig'
import {bskyTitle} from '#/lib/strings/headings' import {bskyTitle} from '#/lib/strings/headings'
import {logger} from '#/logger'
import {useUnreadNotifications} from '#/state/queries/notifications/unread' import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useLoggedOutViewControls} from '#/state/shell/logged-out'
@@ -137,6 +136,8 @@ import {
EmailDialogScreenID, EmailDialogScreenID,
useEmailDialogControl, useEmailDialogControl,
} from '#/components/dialogs/EmailDialog' } from '#/components/dialogs/EmailDialog'
import {useAnalytics} from '#/analytics'
import {setNavigationMetadata} from '#/analytics/metadata'
import {IS_NATIVE, IS_WEB} from '#/env' import {IS_NATIVE, IS_WEB} from '#/env'
import {router} from '#/routes' import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army' import {Referrer} from '../modules/expo-bluesky-swiss-army'
@@ -879,11 +880,13 @@ const LINKING = {
let lastHandledNotificationDateDedupe: number | undefined let lastHandledNotificationDateDedupe: number | undefined
function RoutesContainer({children}: React.PropsWithChildren<{}>) { function RoutesContainer({children}: React.PropsWithChildren<{}>) {
const ax = useAnalytics()
const notyLogger = ax.logger.useChild(ax.logger.Context.Notifications)
const theme = useColorSchemeStyle(DefaultTheme, DarkTheme) const theme = useColorSchemeStyle(DefaultTheme, DarkTheme)
const {currentAccount, accounts} = useSession() const {currentAccount, accounts} = useSession()
const {onPressSwitchAccount} = useAccountSwitcher() const {onPressSwitchAccount} = useAccountSwitcher()
const {setShowLoggedOut} = useLoggedOutViewControls() const {setShowLoggedOut} = useLoggedOutViewControls()
const prevLoggedRouteName = useRef<string | undefined>(undefined) const previousScreen = useRef<string | undefined>(undefined)
const emailDialogControl = useEmailDialogControl() const emailDialogControl = useEmailDialogControl()
const closeAllActiveElements = useCloseAllActiveElements() const closeAllActiveElements = useCloseAllActiveElements()
@@ -945,11 +948,10 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
const payload = getNotificationPayload(response.notification) const payload = getNotificationPayload(response.notification)
if (payload) { if (payload) {
notyLogger.metric( ax.metric('notifications:openApp', {
'notifications:openApp', reason: payload.reason,
{reason: payload.reason, causedBoot: true}, causedBoot: true,
{statsig: false}, })
)
if (payload.reason === 'chat-message') { if (payload.reason === 'chat-message') {
handleChatMessage(payload) handleChatMessage(payload)
@@ -973,47 +975,69 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
} }
} }
function onReady() { const onNavigationReady = useCallOnce(() => {
prevLoggedRouteName.current = getCurrentRouteName() const currentScreen = getCurrentRouteName()
setNavigationMetadata({
previousScreen: currentScreen,
currentScreen,
})
previousScreen.current = currentScreen
handlePushNotificationEntry()
ax.metric('router:navigate', {})
if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) { if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) {
emailDialogControl.open({ emailDialogControl.open({
id: EmailDialogScreenID.VerificationReminder, id: EmailDialogScreenID.VerificationReminder,
}) })
snoozeEmailConfirmationPrompt() snoozeEmailConfirmationPrompt()
} }
}
ax.metric('init', {
initMs: Math.round(
// @ts-ignore Emitted by Metro in the bundle prelude
performance.now() - global.__BUNDLE_START_TIME__,
),
})
if (IS_WEB) {
const referrerInfo = Referrer.getReferrerInfo()
if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
ax.metric('deepLink:referrerReceived', {
to: window.location.href,
referrer: referrerInfo?.referrer,
hostname: referrerInfo?.hostname,
})
}
}
})
return ( return (
<> <NavigationContainer
<NavigationContainer ref={navigationRef}
ref={navigationRef} linking={LINKING}
linking={LINKING} theme={theme}
theme={theme} onStateChange={() => {
onStateChange={() => { const currentScreen = getCurrentRouteName()
logger.metric( // do this before metric
'router:navigate', setNavigationMetadata({
{from: prevLoggedRouteName.current}, previousScreen: previousScreen.current,
{statsig: false}, currentScreen,
) })
prevLoggedRouteName.current = getCurrentRouteName() ax.metric('router:navigate', {from: previousScreen.current})
}} previousScreen.current = currentScreen
onReady={() => { }}
attachRouteToLogEvents(getCurrentRouteName) onReady={onNavigationReady}
logModuleInitTime() // WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x
onReady() // However, there's a fair amount of places we do that, especially in when popping to the top of stacks.
logger.metric('router:navigate', {}, {statsig: false}) // See BottomBar.tsx for an example of how to handle nested navigators in the tabs correctly.
handlePushNotificationEntry() // I'm scared of missing a spot (esp. with push notifications etc) so let's enable this legacy behaviour for now.
}} // We will need to confirm we handle nested navigators correctly by the time we migrate to React Navigation 8.x
// WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x // -sfn
// However, there's a fair amount of places we do that, especially in when popping to the top of stacks. navigationInChildEnabled>
// See BottomBar.tsx for an example of how to handle nested navigators in the tabs correctly. {children}
// I'm scared of missing a spot (esp. with push notifications etc) so let's enable this legacy behaviour for now. </NavigationContainer>
// We will need to confirm we handle nested navigators correctly by the time we migrate to React Navigation 8.x
// -sfn
navigationInChildEnabled>
{children}
</NavigationContainer>
</>
) )
} }
@@ -1087,44 +1111,6 @@ function reset(): Promise<void> {
} }
} }
let didInit = false
function logModuleInitTime() {
if (didInit) {
return
}
didInit = true
const initMs = Math.round(
// @ts-ignore Emitted by Metro in the bundle prelude
performance.now() - global.__BUNDLE_START_TIME__,
)
console.log(`Time to first paint: ${initMs} ms`)
logEvent('init', {
initMs,
})
if (IS_WEB) {
const referrerInfo = Referrer.getReferrerInfo()
if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
logEvent('deepLink:referrerReceived', {
to: window.location.href,
referrer: referrerInfo?.referrer,
hostname: referrerInfo?.hostname,
})
}
}
if (__DEV__) {
// This log is noisy, so keep false committed
const shouldLog = false
// Relies on our patch to polyfill.js in metro-runtime
const initLogs = (global as any).__INIT_LOGS__
if (shouldLog && Array.isArray(initLogs)) {
console.log(initLogs.join('\n'))
}
}
}
export { export {
FlatNavigator, FlatNavigator,
navigate, navigate,
+9 -11
View File
@@ -9,7 +9,6 @@ import {
useCreateSupportLink, useCreateSupportLink,
} from '#/lib/hooks/useCreateSupportLink' } from '#/lib/hooks/useCreateSupportLink'
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo' import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {logger} from '#/logger'
import {useIsBirthdateUpdateAllowed} from '#/state/birthdate' import {useIsBirthdateUpdateAllowed} from '#/state/birthdate'
import {useSessionApi} from '#/state/session' import {useSessionApi} from '#/state/session'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
@@ -36,8 +35,8 @@ import {
isLegacyBirthdateBug, isLegacyBirthdateBug,
useAgeAssuranceRegionConfig, useAgeAssuranceRegionConfig,
} from '#/ageAssurance/util' } from '#/ageAssurance/util'
import {IS_WEB} from '#/env' import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env' import {IS_NATIVE, IS_WEB} from '#/env'
import {useDeviceGeolocationApi} from '#/geolocation' import {useDeviceGeolocationApi} from '#/geolocation'
const textStyles = [a.text_md, a.leading_snug] const textStyles = [a.text_md, a.leading_snug]
@@ -45,6 +44,7 @@ const textStyles = [a.text_md, a.leading_snug]
export function NoAccessScreen() { export function NoAccessScreen() {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const {gtPhone} = useBreakpoints() const {gtPhone} = useBreakpoints()
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const birthdateControl = useDialogControl() const birthdateControl = useDialogControl()
@@ -63,8 +63,8 @@ export function NoAccessScreen() {
useEffect(() => { useEffect(() => {
// just counting overall hits here // just counting overall hits here
logger.metric(`blockedGeoOverlay:shown`, {}) ax.metric(`blockedGeoOverlay:shown`, {})
logger.metric(`ageAssurance:noAccessScreen:shown`, { ax.metric(`ageAssurance:noAccessScreen:shown`, {
accountCreatedAt: data?.accountCreatedAt || 'unknown', accountCreatedAt: data?.accountCreatedAt || 'unknown',
isAARegion, isAARegion,
hasDeclaredAge, hasDeclaredAge,
@@ -103,10 +103,7 @@ export function NoAccessScreen() {
label={_(msg`Click here to update your birthdate`)} label={_(msg`Click here to update your birthdate`)}
style={[textStyles]} style={[textStyles]}
{...createStaticClick(() => { {...createStaticClick(() => {
logger.metric( ax.metric('ageAssurance:noAccessScreen:openBirthdateDialog', {})
'ageAssurance:noAccessScreen:openBirthdateDialog',
{},
)
birthdateControl.open() birthdateControl.open()
})}> })}>
clicking here clicking here
@@ -272,6 +269,7 @@ export function NoAccessScreen() {
function AccessSection() { function AccessSection() {
const t = useTheme() const t = useTheme()
const {_, i18n} = useLingui() const {_, i18n} = useLingui()
const ax = useAnalytics()
const control = useDialogControl() const control = useDialogControl()
const appealControl = Dialog.useDialogControl() const appealControl = Dialog.useDialogControl()
const locationControl = Dialog.useDialogControl() const locationControl = Dialog.useDialogControl()
@@ -305,7 +303,7 @@ function AccessSection() {
label={_(msg`Contact our moderation team`)} label={_(msg`Contact our moderation team`)}
{...createStaticClick(() => { {...createStaticClick(() => {
appealControl.open() appealControl.open()
logger.metric('ageAssurance:appealDialogOpen', {}) ax.metric('ageAssurance:appealDialogOpen', {})
})}> })}>
contact our moderation team contact our moderation team
</SimpleInlineLinkText>{' '} </SimpleInlineLinkText>{' '}
@@ -321,7 +319,7 @@ function AccessSection() {
color={hasInitiated ? 'secondary' : 'primary'} color={hasInitiated ? 'secondary' : 'primary'}
onPress={() => { onPress={() => {
control.open() control.open()
logger.metric('ageAssurance:initDialogOpen', { ax.metric('ageAssurance:initDialogOpen', {
hasInitiatedPreviously: hasInitiated, hasInitiatedPreviously: hasInitiated,
}) })
}}> }}>
@@ -25,9 +25,8 @@ import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icon
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {refetchAgeAssuranceServerState} from '#/ageAssurance' import {refetchAgeAssuranceServerState} from '#/ageAssurance'
import {logger} from '#/ageAssurance' import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env' import {IS_IOS, IS_WEB} from '#/env'
import {IS_IOS} from '#/env'
export type RedirectOverlayState = { export type RedirectOverlayState = {
result: 'success' | 'unknown' result: 'success' | 'unknown'
@@ -174,6 +173,7 @@ export function RedirectOverlay() {
function Inner() { function Inner() {
const t = useTheme() const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui() const {_} = useLingui()
const agent = useAgent() const agent = useAgent()
const polling = useRef(false) const polling = useRef(false)
@@ -187,7 +187,7 @@ function Inner() {
polling.current = true polling.current = true
logger.metric('ageAssurance:redirectDialogOpen', {}) ax.metric('ageAssurance:redirectDialogOpen', {})
wait( wait(
3e3, 3e3,
@@ -218,18 +218,18 @@ function Inner() {
setSuccess(true) setSuccess(true)
logger.metric('ageAssurance:redirectDialogSuccess', {}) ax.metric('ageAssurance:redirectDialogSuccess', {})
}) })
.catch(() => { .catch(() => {
if (unmounted.current) return if (unmounted.current) return
setError(true) setError(true)
logger.metric('ageAssurance:redirectDialogFail', {}) ax.metric('ageAssurance:redirectDialogFail', {})
}) })
return () => { return () => {
unmounted.current = true unmounted.current = true
} }
}, [agent]) }, [ax, agent])
if (success) { if (success) {
return ( return (
+7 -9
View File
@@ -12,6 +12,7 @@ import {isNetworkError} from '#/lib/hooks/useCleanError'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {usePatchAgeAssuranceServerState} from '#/ageAssurance' import {usePatchAgeAssuranceServerState} from '#/ageAssurance'
import {logger} from '#/ageAssurance/logger' import {logger} from '#/ageAssurance/logger'
import {useAnalytics} from '#/analytics'
import {BLUESKY_PROXY_DID} from '#/env' import {BLUESKY_PROXY_DID} from '#/env'
import {useGeolocation} from '#/geolocation' import {useGeolocation} from '#/geolocation'
@@ -19,6 +20,7 @@ const IS_DEV_ENV = BLUESKY_PROXY_DID !== PUBLIC_APPVIEW_DID
const APPVIEW = IS_DEV_ENV ? DEV_ENV_APPVIEW : PUBLIC_APPVIEW const APPVIEW = IS_DEV_ENV ? DEV_ENV_APPVIEW : PUBLIC_APPVIEW
export function useBeginAgeAssurance() { export function useBeginAgeAssurance() {
const ax = useAnalytics()
const agent = useAgent() const agent = useAgent()
const geolocation = useGeolocation() const geolocation = useGeolocation()
const patchAgeAssuranceStateResponse = usePatchAgeAssuranceServerState() const patchAgeAssuranceStateResponse = usePatchAgeAssuranceServerState()
@@ -48,15 +50,11 @@ export function useBeginAgeAssurance() {
appView.sessionManager.session.accessJwt = token appView.sessionManager.session.accessJwt = token
appView.sessionManager.session.refreshJwt = '' appView.sessionManager.session.refreshJwt = ''
logger.metric( ax.metric('ageAssurance:api:begin', {
'ageAssurance:api:begin', platform: Platform.OS,
{ countryCode,
platform: Platform.OS, regionCode,
countryCode, })
regionCode,
},
{statsig: false},
)
/* /*
* 2s wait is good actually. Email sending takes a hot sec and this helps * 2s wait is good actually. Email sending takes a hot sec and this helps
+32
View File
@@ -0,0 +1,32 @@
import {useEffect, useRef} from 'react'
import {getCurrentState, onAppStateChange} from '#/lib/appState'
import {useAnalytics} from '#/analytics'
/**
* Tracks passive analytics like app foreground/background time.
*/
export function PassiveAnalytics() {
const ax = useAnalytics()
const lastActive = useRef(
getCurrentState() === 'active' ? performance.now() : null,
)
useEffect(() => {
const sub = onAppStateChange(state => {
if (state === 'active') {
lastActive.current = performance.now()
ax.metric('state:foreground', {})
} else if (lastActive.current !== null) {
ax.metric('state:background', {
secondsActive: Math.round(
(performance.now() - lastActive.current) / 1e3,
),
})
}
})
return () => sub.remove()
}, [ax])
return null
}
+62
View File
@@ -0,0 +1,62 @@
import {GrowthBook} from '@growthbook/growthbook-react'
import {type Metadata} from '#/analytics/metadata'
import * as env from '#/env'
export {Features} from '#/analytics/features/types'
/**
* We vary the amount of time we wait for GrowthBook to fetch feature
* gates based on the strategy specified.
*/
export type FeatureFetchStrategy = 'prefer-low-latency' | 'prefer-fresh-gates'
const TIMEOUT_INIT = 500 // TODO should base on p99 or something
const TIMEOUT_PREFER_LOW_LATENCY = 250
const TIMEOUT_PREFER_FRESH_GATES = 1500
export const features = new GrowthBook({
apiHost: env.GROWTHBOOK_API_HOST,
clientKey: env.GROWTHBOOK_CLIENT_KEY,
})
/**
* Initializer promise that must be awaited before using the GrowthBook
* instance or rendering the `AnalyticsFeaturesContext`. Note: this may not be
* fully initialized if it takes longer than `TIMEOUT_INIT` to initialize. In
* that case, we may see a flash of uncustomized content until the
* initialization completes.
*/
export const init = new Promise<void>(async y => {
await features.init({timeout: TIMEOUT_INIT})
y()
})
/**
* Refresh feature gates from GrowthBook. Updates attributes based on the
* provided account, if any.
*/
export async function refresh({strategy}: {strategy: FeatureFetchStrategy}) {
await features.refreshFeatures({
timeout:
strategy === 'prefer-low-latency'
? TIMEOUT_PREFER_LOW_LATENCY
: TIMEOUT_PREFER_FRESH_GATES,
})
}
/**
* Converts our metadata into GrowthBook attributes and sets them.
*/
export function setAttributes({base, session, preferences}: Metadata) {
const {deviceId, sessionId, ...br} = base
features.setAttributes({
device_id: deviceId, // GrowthBook special field
session_id: sessionId, // GrowthBook special field
user_id: session?.did, // GrowthBook special field
id: session?.did, // GrowthBook special field
...br,
...(session || {}),
...(preferences || {}),
})
}
+7
View File
@@ -0,0 +1,7 @@
export enum Features {
DebugFeedContext = 'debug_show_feedcontext',
IsBskyTeam = 'is_bsky_team_member',
DisableOnboardingFindContacts = 'disable_onboarding_find_contacts',
DisableSettingsFindContacts = 'disable_settings_find_contacts',
DisableLiveNowBeta = 'disable_live_now_beta',
}
+26
View File
@@ -0,0 +1,26 @@
import uuid from 'react-native-uuid'
import AsyncStorage from '@react-native-async-storage/async-storage'
import {device} from '#/storage'
const LEGACY_STABLE_ID = 'STATSIG_LOCAL_STORAGE_STABLE_ID'
export async function getAndMigrateDeviceId() {
const migrated = getDeviceId()
if (migrated) return migrated
const id = (await AsyncStorage.getItem(LEGACY_STABLE_ID)) || uuid.v4()
device.set(['deviceId'], id)
return id
}
export function getDeviceId() {
return device.get(['deviceId'])
}
export function getDeviceIdOrThrow() {
const id = device.get(['deviceId'])
if (!id) {
throw new Error(`deviceId is not set, call getAndMigrateDeviceId first`)
}
return id
}
+2
View File
@@ -0,0 +1,2 @@
export * from '#/analytics/identifiers/device'
export * from '#/analytics/identifiers/session'
+79
View File
@@ -0,0 +1,79 @@
jest.mock('#/storage', () => ({
device: {
get: jest.fn(),
set: jest.fn(),
},
}))
jest.mock('#/analytics/identifiers/util', () => ({
isSessionIdExpired: jest.fn(),
}))
jest.mock('#/lib/appState', () => ({
onAppStateChange: jest.fn(() => ({remove: jest.fn()})),
}))
beforeEach(() => {
jest.resetModules()
jest.clearAllMocks()
})
function getMocks() {
const {device} = require('#/storage')
const {isSessionIdExpired} = require('#/analytics/identifiers/util')
return {
device: jest.mocked(device),
isSessionIdExpired: jest.mocked(isSessionIdExpired),
}
}
describe('session initialization', () => {
it('creates new session and sets timestamp when none exists', () => {
const {device, isSessionIdExpired} = getMocks()
device.get.mockReturnValue(undefined)
isSessionIdExpired.mockReturnValue(false)
const {getInitialSessionId} = require('./session')
const id = getInitialSessionId()
expect(id).toBeDefined()
expect(typeof id).toBe('string')
expect(device.set).toHaveBeenCalledWith(['nativeSessionId'], id)
expect(device.set).toHaveBeenCalledWith(
['nativeSessionIdLastEventAt'],
expect.any(Number),
)
})
it('reuses existing session when not expired', () => {
const {device, isSessionIdExpired} = getMocks()
const existingId = 'existing-session-id'
device.get.mockImplementation((key: string[]) => {
if (key[0] === 'nativeSessionId') return existingId
if (key[0] === 'nativeSessionIdLastEventAt') return Date.now()
return undefined
})
isSessionIdExpired.mockReturnValue(false)
const {getInitialSessionId} = require('./session')
expect(getInitialSessionId()).toBe(existingId)
})
it('creates new session when existing is expired', () => {
const {device, isSessionIdExpired} = getMocks()
const existingId = 'existing-session-id'
device.get.mockImplementation((key: string[]) => {
if (key[0] === 'nativeSessionId') return existingId
if (key[0] === 'nativeSessionIdLastEventAt') return Date.now() - 999999
return undefined
})
isSessionIdExpired.mockReturnValue(true)
const {getInitialSessionId} = require('./session')
const id = getInitialSessionId()
expect(id).not.toBe(existingId)
expect(device.set).toHaveBeenCalledWith(['nativeSessionId'], id)
})
})
+40
View File
@@ -0,0 +1,40 @@
import {useEffect, useState} from 'react'
import uuid from 'react-native-uuid'
import {onAppStateChange} from '#/lib/appState'
import {isSessionIdExpired} from '#/analytics/identifiers/util'
import {device} from '#/storage'
let sessionId = (() => {
const existing = device.get(['nativeSessionId'])
const lastEvent = device.get(['nativeSessionIdLastEventAt'])
const id = existing && !isSessionIdExpired(lastEvent) ? existing : uuid.v4()
device.set(['nativeSessionId'], id)
device.set(['nativeSessionIdLastEventAt'], Date.now())
return id
})()
export function getInitialSessionId() {
return sessionId
}
export function useSessionId() {
const [id, setId] = useState(() => sessionId)
useEffect(() => {
const sub = onAppStateChange(state => {
if (state === 'active') {
const lastEvent = device.get(['nativeSessionIdLastEventAt'])
if (isSessionIdExpired(lastEvent)) {
sessionId = uuid.v4()
device.set(['nativeSessionId'], sessionId)
setId(sessionId)
}
}
device.set(['nativeSessionIdLastEventAt'], Date.now())
})
return () => sub.remove()
}, [])
return id
}
+44
View File
@@ -0,0 +1,44 @@
import {useEffect, useState} from 'react'
import uuid from 'react-native-uuid'
import {onAppStateChange} from '#/lib/appState'
import {isSessionIdExpired} from '#/analytics/identifiers/util'
const SESSION_ID_KEY = 'bsky_session_id'
const LAST_EVENT_KEY = 'bsky_session_id_last_event_at'
let sessionId = (() => {
const existing = window.sessionStorage.getItem(SESSION_ID_KEY)
const lastEventStr = window.sessionStorage.getItem(LAST_EVENT_KEY)
const lastEvent = lastEventStr ? Number(lastEventStr) : undefined
const id = existing && !isSessionIdExpired(lastEvent) ? existing : uuid.v4()
window.sessionStorage.setItem(SESSION_ID_KEY, id)
window.sessionStorage.setItem(LAST_EVENT_KEY, String(Date.now()))
return id
})()
export function getInitialSessionId() {
return sessionId
}
export function useSessionId() {
const [id, setId] = useState(() => sessionId)
useEffect(() => {
const sub = onAppStateChange(state => {
if (state === 'active') {
const lastEventStr = window.sessionStorage.getItem(LAST_EVENT_KEY)
const lastEvent = lastEventStr ? Number(lastEventStr) : undefined
if (isSessionIdExpired(lastEvent)) {
sessionId = uuid.v4()
window.sessionStorage.setItem(SESSION_ID_KEY, sessionId)
setId(sessionId)
}
}
window.sessionStorage.setItem(LAST_EVENT_KEY, String(Date.now()))
})
return () => sub.remove()
}, [])
return id
}
+9
View File
@@ -0,0 +1,9 @@
import * as env from '#/env'
const ONE_MIN = 60 * 1e3
const TTL = (env.IS_NATIVE ? 5 : 30) * ONE_MIN // 5 min on native
export function isSessionIdExpired(since: number | undefined) {
if (since === undefined) return false
return Date.now() - since >= TTL
}
+229
View File
@@ -0,0 +1,229 @@
import {createContext, useContext, useEffect, useMemo} from 'react'
import {Platform} from 'react-native'
import {Logger} from '#/logger'
import {
Features,
features as feats,
init,
refresh,
setAttributes,
} from '#/analytics/features'
import {
getAndMigrateDeviceId,
getDeviceId,
getInitialSessionId,
useSessionId,
} from '#/analytics/identifiers'
import {
getNavigationMetadata,
type MergeableMetadata,
type Metadata,
} from '#/analytics/metadata'
import {type Metrics, metrics} from '#/analytics/metrics'
import * as refParams from '#/analytics/misc/refParams'
import {getMetadataForLogger} from '#/analytics/utils'
import * as env from '#/env'
import {useGeolocation} from '#/geolocation'
import {device} from '#/storage'
export * as utils from '#/analytics/utils'
export const features = {init, refresh}
export {Features} from '#/analytics/features'
export {type Metrics} from '#/analytics/metrics'
type LoggerType = {
debug: Logger['debug']
info: Logger['info']
log: Logger['log']
warn: Logger['warn']
error: Logger['error']
/**
* Clones the existing logger and overrides the `context` value. Existing
* metadata is inherited.
*
* ```ts
* const ax = useAnalytics()
* const logger = ax.logger.useChild(ax.logger.Context.Notifications)
* ```
*/
useChild: (context: Exclude<Logger['context'], undefined>) => LoggerType
Context: typeof Logger.Context
}
export type AnalyticsContextType = {
metadata: Metadata
logger: LoggerType
metric: <E extends keyof Metrics>(
event: E,
payload: Metrics[E],
metadata?: MergeableMetadata,
) => void
features: typeof Features & {
enabled(feature: Features): boolean
}
}
export type AnalyticsBaseContextType = Omit<AnalyticsContextType, 'features'>
function createLogger(
context: Logger['context'],
metadata: Partial<Metadata>,
): LoggerType {
const logger = Logger.create(context, metadata)
return {
debug: logger.debug.bind(logger),
info: logger.info.bind(logger),
log: logger.log.bind(logger),
warn: logger.warn.bind(logger),
error: logger.error.bind(logger),
useChild: (context: Exclude<Logger['context'], undefined>) => {
return useMemo(() => createLogger(context, metadata), [context, metadata])
},
Context: Logger.Context,
}
}
const Context = createContext<AnalyticsBaseContextType>({
logger: createLogger(Logger.Context.Default, {}),
metric: (event, payload, metadata) => {
if (metadata && '__meta' in metadata) {
delete metadata.__meta
}
metrics.track(event, payload, {
...metadata,
navigation: getNavigationMetadata(),
})
},
metadata: {
base: {
deviceId: getDeviceId() ?? 'unknown',
sessionId: getInitialSessionId(),
platform: Platform.OS,
appVersion: env.APP_VERSION,
bundleIdentifier: env.BUNDLE_IDENTIFIER,
bundleDate: env.BUNDLE_DATE,
referrerSrc: refParams.src,
referrerUrl: refParams.url,
},
geolocation: device.get(['mergedGeolocation']) || {
countryCode: '',
regionCode: '',
},
},
})
/**
* Ensures that deviceId is set and migrated from legacy storage. Handled on
* startup in `App.<platform>.tsx`. This must be awaited prior to the app
* booting up.
*/
export const setupDeviceId = getAndMigrateDeviceId()
/**
* Analytics context provider. Decorates the parent analytics context with
* additional metadata. Nesting should be done carefully and sparingly.
*/
export function AnalyticsContext({
children,
metadata,
}: {
children: React.ReactNode
metadata?: MergeableMetadata
}) {
if (metadata) {
if (!('__meta' in metadata)) {
throw new Error(
'Use the useMeta() helper when passing metadata to AnalyticsContext',
)
}
}
const sessionId = useSessionId()
const geolocation = useGeolocation()
const parentContext = useContext(Context)
const childContext = useMemo(() => {
const combinedMetadata = {
...parentContext.metadata,
...metadata,
base: {
...parentContext.metadata.base,
sessionId,
},
geolocation,
}
const context: AnalyticsBaseContextType = {
...parentContext,
logger: createLogger(
Logger.Context.Default,
getMetadataForLogger(combinedMetadata),
),
metadata: combinedMetadata,
metric: (event, payload, extraMetadata) => {
parentContext.metric(event, payload, {
...combinedMetadata,
...extraMetadata,
})
},
}
return context
}, [sessionId, geolocation, parentContext, metadata])
return <Context.Provider value={childContext}>{children}</Context.Provider>
}
/**
* Feature gates provider. Decorates the parent analytics context with
* feature gate capabilities. Should be mounted within `AnalyticsContext`,
* and below the `<Fragment key={did} />` breaker in `App.<platform>.tsx`.
*/
export function AnalyticsFeaturesContext({
children,
}: {
children: React.ReactNode
}) {
const parentContext = useContext(Context)
useEffect(() => {
feats.setTrackingCallback((experiment, result) => {
parentContext.metric('experiment:viewed', {
experimentId: experiment.key,
variationId: result.key,
})
})
}, [parentContext.metric])
useEffect(() => {
setAttributes(parentContext.metadata)
}, [parentContext.metadata])
const childContext = useMemo<AnalyticsContextType>(() => {
return {
...parentContext,
features: {
enabled: feats.isOn.bind(feats),
...Features,
},
}
}, [parentContext])
return <Context.Provider value={childContext}>{children}</Context.Provider>
}
/**
* Basic analytics context without feature gates. Should really only be used
* above the `AnalyticsFeaturesContext` provider.
*/
export function useAnalyticsBase() {
return useContext(Context)
}
/**
* The main analytics context, including feature gates. Use this everywhere you
* need metrics, features, or logging within the React tree.
*/
export function useAnalytics() {
const ctx = useContext(Context)
if (!('features' in ctx)) {
throw new Error(
'useAnalytics must be used within an AnalyticsFeaturesContext',
)
}
return ctx as AnalyticsContextType
}
+61
View File
@@ -0,0 +1,61 @@
import {type Geolocation} from '#/geolocation'
export type BaseMetadata = {
deviceId: string
sessionId: string
platform: string
appVersion: string
bundleIdentifier: string
bundleDate: number
referrerSrc: string
referrerUrl: string
}
export type GeolocationMetadata = Geolocation
export type SessionMetadata = {
did: string
isBskyPds: boolean
}
export type PreferencesMetadata = {
appLanguage: string
contentLanguages: string[]
}
export type MergeableMetadata = {
session?: SessionMetadata
preferences?: PreferencesMetadata
/**
* Navigation metadata is not actually available on this object, instead it's
* merged in at time-of-log/metric. See `#/analytics/metadata.ts` for details.
*/
navigation?: NavigationMetadata
}
export type Metadata = {
base: BaseMetadata
geolocation: GeolocationMetadata
} & MergeableMetadata
/*
* Navigation metadata is handle out-of-band from React, since we don't want to
* slow down screen transitions in any way, and there doesn't seem to be a nice
* way to get current navigation state without an additional re-render between
* navigations.
*
* So instead of this data being available on the Metadata object, it's stored
* here and merged in at time-of-log/metric.
*/
export type NavigationMetadata = {
previousScreen?: string
currentScreen?: string
}
let navigationMetadata: NavigationMetadata | undefined
export function getNavigationMetadata() {
console.log('metadata', JSON.stringify(navigationMetadata, null, 2))
return navigationMetadata
}
export function setNavigationMetadata(meta: NavigationMetadata | undefined) {
navigationMetadata = meta
}
+176
View File
@@ -0,0 +1,176 @@
import {MetricsClient} from './client'
let appStateCallback: (state: string) => void
jest.mock('#/lib/appState', () => ({
onAppStateChange: jest.fn(cb => {
appStateCallback = cb
return {remove: jest.fn()}
}),
}))
jest.mock('#/logger', () => ({
Logger: {
create: () => ({
info: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
}),
Context: {Metric: 'metric'},
},
}))
jest.mock('#/env', () => ({
METRICS_API_HOST: 'https://test.metrics.api',
IS_WEB: false,
}))
type TestEvents = {
click: {button: string}
view: {screen: string}
}
describe('MetricsClient', () => {
let fetchMock: jest.Mock
let fetchRequests: {body: any}[]
beforeEach(() => {
jest.useFakeTimers({advanceTimers: true})
fetchRequests = []
fetchMock = jest.fn().mockImplementation(async (_url, options) => {
const body = JSON.parse(options.body)
fetchRequests.push({body})
return {ok: true, status: 200}
})
global.fetch = fetchMock
})
afterEach(() => {
jest.useRealTimers()
jest.clearAllMocks()
})
it('flushes events on interval', async () => {
const client = new MetricsClient<TestEvents>()
client.track('click', {button: 'submit'})
client.track('view', {screen: 'home'})
expect(fetchRequests).toHaveLength(0)
// Advance past the 10 second interval
await jest.advanceTimersByTimeAsync(10_000)
expect(fetchRequests).toHaveLength(1)
expect(fetchRequests[0].body.events).toHaveLength(2)
expect(fetchRequests[0].body.events[0].event).toBe('click')
expect(fetchRequests[0].body.events[1].event).toBe('view')
})
it('flushes when maxBatchSize is exceeded', async () => {
const client = new MetricsClient<TestEvents>()
client.maxBatchSize = 5
// Add events up to maxBatchSize (should not flush yet)
for (let i = 0; i < 5; i++) {
client.track('click', {button: `btn-${i}`})
}
expect(fetchRequests).toHaveLength(0)
// One more event should trigger flush (> maxBatchSize)
client.track('click', {button: 'btn-trigger'})
// Allow microtasks to run
await jest.advanceTimersByTimeAsync(0)
expect(fetchRequests).toHaveLength(1)
expect(fetchRequests[0].body.events).toHaveLength(6)
})
it('retries failed events once on 500 response', async () => {
let requestCount = 0
fetchMock.mockImplementation(async (_url, options) => {
requestCount++
const body = JSON.parse(options.body)
if (requestCount === 1) {
// First request fails with 500 - "Failed to fetch" triggers isNetworkError
return {
ok: false,
status: 500,
text: async () => 'Internal Server Error',
}
}
// Retry succeeds
fetchRequests.push({body})
return {ok: true, status: 200}
})
const client = new MetricsClient<TestEvents>()
client.track('click', {button: 'submit'})
// Trigger flush via interval
await jest.advanceTimersByTimeAsync(10_000)
expect(requestCount).toBe(1)
expect(fetchRequests).toHaveLength(0)
// Simulate app coming to foreground to trigger retry
appStateCallback('active')
await jest.advanceTimersByTimeAsync(0)
expect(requestCount).toBe(2)
expect(fetchRequests).toHaveLength(1)
expect(fetchRequests[0].body.events).toHaveLength(1)
expect(fetchRequests[0].body.events[0].event).toBe('click')
})
it('does not retry more than once', async () => {
let requestCount = 0
fetchMock.mockImplementation(async () => {
requestCount++
// Always fail with network-like error
return {
ok: false,
status: 500,
text: async () => 'Internal Server Error',
}
})
const client = new MetricsClient<TestEvents>()
client.track('click', {button: 'submit'})
// First flush fails
await jest.advanceTimersByTimeAsync(10_000)
expect(requestCount).toBe(1)
// Retry also fails
appStateCallback('active')
await jest.advanceTimersByTimeAsync(0)
expect(requestCount).toBe(2)
// Another foreground event should not retry again (events are dropped)
appStateCallback('active')
await jest.advanceTimersByTimeAsync(0)
expect(requestCount).toBe(2) // No additional requests
})
it('flushes when app goes to background', async () => {
const client = new MetricsClient<TestEvents>()
client.track('click', {button: 'submit'})
expect(fetchRequests).toHaveLength(0)
// Simulate app going to background
appStateCallback('background')
await jest.advanceTimersByTimeAsync(0)
expect(fetchRequests).toHaveLength(1)
})
})
+116
View File
@@ -0,0 +1,116 @@
import {onAppStateChange} from '#/lib/appState'
import {isNetworkError} from '#/lib/strings/errors'
import {Logger} from '#/logger'
import * as env from '#/env'
type Event<M extends Record<string, any>> = {
time: number
event: keyof M
payload: M[keyof M]
metadata: Record<string, any>
}
const TRACKING_ENDPOINT = env.METRICS_API_HOST + '/t'
const logger = Logger.create(Logger.Context.Metric, {})
export class MetricsClient<M extends Record<string, any>> {
maxBatchSize = 100
private started: boolean = false
private queue: Event<M>[] = []
private failedQueue: Event<M>[] = []
private flushInterval: NodeJS.Timeout | null = null
start() {
if (this.started) return
this.started = true
this.flushInterval = setInterval(() => {
this.flush()
}, 10_000)
onAppStateChange(state => {
if (state === 'active') {
this.retryFailedLogs()
} else {
this.flush()
}
})
}
track<E extends keyof M>(
event: E,
payload: M[E],
metadata: Record<string, any> = {},
) {
this.start()
const e = {
time: Date.now(),
event,
payload,
metadata,
}
this.queue.push(e)
logger.info(`event: ${e.event as string}`, e)
if (this.queue.length > this.maxBatchSize) {
this.flush()
}
}
flush() {
if (!this.queue.length) return
const events = this.queue.splice(0, this.queue.length)
this.sendBatch(events)
}
private async sendBatch(events: Event<M>[], isRetry: boolean = false) {
logger.debug(`sendBatch: ${events.length}`, {
isRetry,
})
try {
const body = JSON.stringify({events})
if (env.IS_WEB && 'navigator' in globalThis && navigator.sendBeacon) {
const success = navigator.sendBeacon(
TRACKING_ENDPOINT,
new Blob([body], {type: 'application/json'}),
)
if (!success) {
// construct a "network error" for `isNetworkError` to work
throw new Error(`Failed to fetch: sendBeacon returned false`)
}
} else {
const res = await fetch(TRACKING_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({events}),
keepalive: true,
})
if (!res.ok) {
const error = await res.text().catch(() => 'Unknown error')
// construct a "network error" for `isNetworkError` to work
throw new Error(`${res.status} Failed to fetch — ${error}`)
}
}
} catch (e: any) {
if (isNetworkError(e)) {
if (isRetry) return // retry once
this.failedQueue.push(...events)
return
}
logger.error(`Failed to send metrics`, {
safeMessage: e.toString(),
})
}
}
private retryFailedLogs() {
if (!this.failedQueue.length) return
const events = this.failedQueue.splice(0, this.failedQueue.length)
this.sendBatch(events, true)
}
}
+6
View File
@@ -0,0 +1,6 @@
import {MetricsClient} from '#/analytics/metrics/client'
import {type Events} from '#/analytics/metrics/types'
export type {Events as Metrics} from '#/analytics/metrics/types'
export * from '#/analytics/metrics/utils'
export const metrics = new MetricsClient<Events>()
@@ -1,12 +1,21 @@
/*
* Do not import runtime code into this file
*/
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler' import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
import {type FeedDescriptor} from '#/state/queries/post-feed' import {type FeedDescriptor} from '#/state/queries/post-feed'
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types' import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
export type MetricEvents = { export type Events = {
// App events // App events
init: { init: {
initMs: number initMs: number
} }
'experiment:viewed': {
experimentId: string
variationId: string
}
'account:loggedIn': { 'account:loggedIn': {
logContext: logContext:
| 'LoginForm' | 'LoginForm'
@@ -139,6 +148,7 @@ export type MetricEvents = {
feedUrl: string feedUrl: string
feedType: string feedType: string
index: number index: number
reason?: string
} }
'feed:endReached': { 'feed:endReached': {
feedUrl: string feedUrl: string
@@ -374,7 +384,6 @@ export type MetricEvents = {
| 'AvatarButton' | 'AvatarButton'
| 'StarterPackProfilesList' | 'StarterPackProfilesList'
| 'FeedInterstitial' | 'FeedInterstitial'
| 'ProfileHeaderSuggestedFollows'
| 'PostOnboardingFindFollows' | 'PostOnboardingFindFollows'
| 'ImmersiveVideo' | 'ImmersiveVideo'
| 'ExploreSuggestedAccounts' | 'ExploreSuggestedAccounts'
@@ -468,7 +477,6 @@ export type MetricEvents = {
| 'AvatarButton' | 'AvatarButton'
| 'StarterPackProfilesList' | 'StarterPackProfilesList'
| 'FeedInterstitial' | 'FeedInterstitial'
| 'ProfileHeaderSuggestedFollows'
| 'PostOnboardingFindFollows' | 'PostOnboardingFindFollows'
| 'ImmersiveVideo' | 'ImmersiveVideo'
| 'ExploreSuggestedAccounts' | 'ExploreSuggestedAccounts'
+7
View File
@@ -0,0 +1,7 @@
export function toClout(n: number | null | undefined): number | undefined {
if (n == null) {
return undefined
} else {
return Math.max(0, Math.round(Math.log(n)))
}
}
+18
View File
@@ -0,0 +1,18 @@
/**
* This is used for our own Bluesky post embeds, and maybe other things.
*
* In the case of our embeds, `ref_src=embed`. Not sure if `ref_url` is used.
*/
import * as env from '#/env'
let refSrc = ''
let refUrl = ''
if (env.IS_WEB) {
const params = new URLSearchParams(window.location.search)
refSrc = params.get('ref_src') ?? ''
refUrl = decodeURIComponent(params.get('ref_url') ?? '')
}
export const src = refSrc
export const url = refUrl
+50
View File
@@ -0,0 +1,50 @@
import {useMemo} from 'react'
import {BSKY_SERVICE} from '#/lib/constants'
import {type SessionAccount} from '#/state/session'
import {
type MergeableMetadata,
type Metadata,
type SessionMetadata,
} from '#/analytics/metadata'
/**
* Thin `useMemo` wrapper that marks the metadata as memoized and provides a
* type guard.
*/
export function useMeta(metadata?: MergeableMetadata) {
const m = useMemo(() => metadata, [metadata])
if (!m) return
// @ts-ignore
m.__meta = true
return m
}
export function accountToSessionMetadata(
account: SessionAccount | undefined,
): SessionMetadata | undefined {
if (!account) {
return
} else {
return {
did: account.did,
isBskyPds: account.service.startsWith(BSKY_SERVICE),
}
}
}
export function getMetadataForLogger({
base,
geolocation,
session,
}: Metadata): Record<string, any> {
return {
deviceId: base.deviceId,
sessionId: base.sessionId,
platform: base.platform,
appVersion: base.appVersion,
countryCode: geolocation.countryCode,
regionCode: geolocation.regionCode,
isBskyPds: session?.isBskyPds || 'anonymous',
}
}
+24 -29
View File
@@ -7,9 +7,6 @@ import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types' import {type NavigationProp} from '#/lib/routes/types'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {type MetricEvents} from '#/logger/metrics'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetPopularFeedsQuery} from '#/state/queries/feed' import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {type FeedDescriptor} from '#/state/queries/post-feed' import {type FeedDescriptor} from '#/state/queries/post-feed'
@@ -38,6 +35,7 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {type Metrics, useAnalytics} from '#/analytics'
import {IS_IOS} from '#/env' import {IS_IOS} from '#/env'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog' import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog'
@@ -434,6 +432,7 @@ export function ProfileGrid({
isVisible?: boolean isVisible?: boolean
}) { }) {
const t = useTheme() const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui() const {_} = useLingui()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
@@ -450,12 +449,11 @@ export function ProfileGrid({
const seenProfilesRef = useRef<Set<string>>(new Set()) const seenProfilesRef = useRef<Set<string>>(new Set())
const containerRef = useRef<View>(null) const containerRef = useRef<View>(null)
const hasTrackedRef = useRef(false) const hasTrackedRef = useRef(false)
const logContext: MetricEvents['suggestedUser:seen']['logContext'] = const logContext: Metrics['suggestedUser:seen']['logContext'] = isFeedContext
isFeedContext ? 'InterstitialDiscover'
? 'InterstitialDiscover' : isProfileHeaderContext
: isProfileHeaderContext ? 'Profile'
? 'Profile' : 'InterstitialProfile'
: 'InterstitialProfile'
// Callback to fire seen events // Callback to fire seen events
const fireSeen = useCallback(() => { const fireSeen = useCallback(() => {
@@ -467,20 +465,16 @@ export function ProfileGrid({
profilesToShow.forEach((profile, index) => { profilesToShow.forEach((profile, index) => {
if (!seenProfilesRef.current.has(profile.did)) { if (!seenProfilesRef.current.has(profile.did)) {
seenProfilesRef.current.add(profile.did) seenProfilesRef.current.add(profile.did)
logger.metric( ax.metric('suggestedUser:seen', {
'suggestedUser:seen', logContext,
{ recId,
logContext, position: index,
recId, suggestedDid: profile.did,
position: index, category: null,
suggestedDid: profile.did, })
category: null,
},
{statsig: true},
)
} }
}) })
}, [isLoading, error, profiles, maxLength, logContext, recId]) }, [ax, isLoading, error, profiles, maxLength, logContext, recId])
// For profile header, fire when isVisible becomes true // For profile header, fire when isVisible becomes true
useEffect(() => { useEffect(() => {
@@ -565,7 +559,7 @@ export function ProfileGrid({
<ProfileCard.Link <ProfileCard.Link
profile={profile} profile={profile}
onPress={() => { onPress={() => {
logEvent('suggestedUser:press', { ax.metric('suggestedUser:press', {
logContext: isFeedContext logContext: isFeedContext
? 'InterstitialDiscover' ? 'InterstitialDiscover'
: 'InterstitialProfile', : 'InterstitialProfile',
@@ -588,7 +582,7 @@ export function ProfileGrid({
onPress={e => { onPress={e => {
e.preventDefault() e.preventDefault()
onDismiss(profile.did) onDismiss(profile.did)
logEvent('suggestedUser:dismiss', { ax.metric('suggestedUser:dismiss', {
logContext: isFeedContext logContext: isFeedContext
? 'InterstitialDiscover' ? 'InterstitialDiscover'
: 'InterstitialProfile', : 'InterstitialProfile',
@@ -656,7 +650,7 @@ export function ProfileGrid({
withIcon={false} withIcon={false}
style={[a.rounded_sm]} style={[a.rounded_sm]}
onFollow={() => { onFollow={() => {
logEvent('suggestedUser:follow', { ax.metric('suggestedUser:follow', {
logContext: isFeedContext logContext: isFeedContext
? 'InterstitialDiscover' ? 'InterstitialDiscover'
: 'InterstitialProfile', : 'InterstitialProfile',
@@ -678,7 +672,7 @@ export function ProfileGrid({
// Use totalProfileCount (before dismissals) for minLength check on initial render. // Use totalProfileCount (before dismissals) for minLength check on initial render.
const profileCountForMinCheck = totalProfileCount ?? profiles.length const profileCountForMinCheck = totalProfileCount ?? profiles.length
if (error || (!isLoading && profileCountForMinCheck < minLength)) { if (error || (!isLoading && profileCountForMinCheck < minLength)) {
logger.debug(`Not enough profiles to show suggested follows`) ax.logger.debug(`Not enough profiles to show suggested follows`)
return null return null
} }
@@ -712,7 +706,7 @@ export function ProfileGrid({
label={_(msg`See more suggested profiles`)} label={_(msg`See more suggested profiles`)}
onPress={() => { onPress={() => {
followDialogControl.open() followDialogControl.open()
logEvent('suggestedUser:seeMore', { ax.metric('suggestedUser:seeMore', {
logContext: isFeedContext ? 'Explore' : 'Profile', logContext: isFeedContext ? 'Explore' : 'Profile',
}) })
}}> }}>
@@ -756,7 +750,7 @@ export function ProfileGrid({
<SeeMoreSuggestedProfilesCard <SeeMoreSuggestedProfilesCard
onPress={() => { onPress={() => {
followDialogControl.open() followDialogControl.open()
logger.metric('suggestedUser:seeMore', { ax.metric('suggestedUser:seeMore', {
logContext: 'Explore', logContext: 'Explore',
}) })
}} }}
@@ -794,9 +788,10 @@ function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) {
) )
} }
const numFeedsToDisplay = 3
export function SuggestedFeeds() { export function SuggestedFeeds() {
const numFeedsToDisplay = 3
const t = useTheme() const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui() const {_} = useLingui()
const {data, isLoading, error} = useGetPopularFeedsQuery({ const {data, isLoading, error} = useGetPopularFeedsQuery({
limit: numFeedsToDisplay, limit: numFeedsToDisplay,
@@ -829,7 +824,7 @@ export function SuggestedFeeds() {
key={feed.uri} key={feed.uri}
view={feed} view={feed}
onPress={() => { onPress={() => {
logEvent('feed:interstitial:feedCard:press', {}) ax.metric('feed:interstitial:feedCard:press', {})
}}> }}>
{({hovered, pressed}) => ( {({hovered, pressed}) => (
<CardOuter <CardOuter
@@ -6,7 +6,6 @@ import {useLingui} from '@lingui/react'
import type React from 'react' import type React from 'react'
import {useCleanError} from '#/lib/hooks/useCleanError' import {useCleanError} from '#/lib/hooks/useCleanError'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/post-shadow' import {type Shadow} from '#/state/cache/post-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback' import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation' import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation'
@@ -15,6 +14,7 @@ import {useTheme} from '#/alf'
import {Bookmark, BookmarkFilled} from '#/components/icons/Bookmark' import {Bookmark, BookmarkFilled} from '#/components/icons/Bookmark'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import * as toast from '#/components/Toast' import * as toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
import {PostControlButton, PostControlButtonIcon} from './PostControlButton' import {PostControlButton, PostControlButtonIcon} from './PostControlButton'
export const BookmarkButton = memo(function BookmarkButton({ export const BookmarkButton = memo(function BookmarkButton({
@@ -29,6 +29,7 @@ export const BookmarkButton = memo(function BookmarkButton({
hitSlop?: Insets hitSlop?: Insets
}): React.ReactNode { }): React.ReactNode {
const t = useTheme() const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui() const {_} = useLingui()
const {mutateAsync: bookmark} = useBookmarkMutation() const {mutateAsync: bookmark} = useBookmarkMutation()
const cleanError = useCleanError() const cleanError = useCleanError()
@@ -52,7 +53,7 @@ export const BookmarkButton = memo(function BookmarkButton({
post, post,
}) })
logger.metric('post:bookmark', { ax.metric('post:bookmark', {
uri: post.uri, uri: post.uri,
authorDid: post.author.did, authorDid: post.author.did,
logContext, logContext,
@@ -92,7 +93,7 @@ export const BookmarkButton = memo(function BookmarkButton({
uri: post.uri, uri: post.uri,
}) })
logger.metric('post:unbookmark', { ax.metric('post:unbookmark', {
uri: post.uri, uri: post.uri,
authorDid: post.author.did, authorDid: post.author.did,
logContext, logContext,
@@ -3,11 +3,11 @@ import * as Clipboard from 'expo-clipboard'
import {t} from '@lingui/macro' import {t} from '@lingui/macro'
import {DISCOVER_DEBUG_DIDS} from '#/lib/constants' import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
import {useGate} from '#/lib/statsig/statsig'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_INTERNAL} from '#/env' import {IS_INTERNAL} from '#/env'
export function DiscoverDebug({ export function DiscoverDebug({
@@ -15,12 +15,12 @@ export function DiscoverDebug({
}: { }: {
feedContext: string | undefined feedContext: string | undefined
}) { }) {
const ax = useAnalytics()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const gate = useGate()
const isDiscoverDebugUser = const isDiscoverDebugUser =
IS_INTERNAL || IS_INTERNAL ||
DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] || DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] ||
gate('debug_show_feedcontext') ax.features.enabled(ax.features.DebugFeedContext)
const theme = useTheme() const theme = useTheme()
return ( return (
@@ -26,15 +26,17 @@ import {
type CommonNavigatorParams, type CommonNavigatorParams,
type NavigationProp, type NavigationProp,
} from '#/lib/routes/types' } from '#/lib/routes/types'
import {logEvent, useGate} from '#/lib/statsig/statsig'
import {richTextToString} from '#/lib/strings/rich-text-helpers' import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {toShareUrl} from '#/lib/strings/url-helpers' import {toShareUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger' import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/post-shadow' import {type Shadow} from '#/state/cache/post-shadow'
import {useProfileShadow} from '#/state/cache/profile-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback' import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {useLanguagePrefs} from '#/state/preferences' import {
import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences' useHiddenPosts,
useHiddenPostsApi,
useLanguagePrefs,
} from '#/state/preferences'
import {usePinnedPostMutation} from '#/state/queries/pinned-post' import {usePinnedPostMutation} from '#/state/queries/pinned-post'
import { import {
usePostDeleteMutation, usePostDeleteMutation,
@@ -71,13 +73,17 @@ import {
import {Eye_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/Eye' import {Eye_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/Eye'
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' import {
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' Mute_Stroke2_Corner0_Rounded as Mute,
Mute_Stroke2_Corner0_Rounded as MuteIcon,
} from '#/components/icons/Mute'
import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/Person' import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/Person'
import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2' import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' import {
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute,
SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon,
} from '#/components/icons/Speaker'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
@@ -87,6 +93,7 @@ import {
useReportDialogControl, useReportDialogControl,
} from '#/components/moderation/ReportDialog' } from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {useAnalytics} from '#/analytics'
import {IS_INTERNAL} from '#/env' import {IS_INTERNAL} from '#/env'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
@@ -116,6 +123,7 @@ let PostMenuItems = ({
}): React.ReactNode => { }): React.ReactNode => {
const {hasSession, currentAccount} = useSession() const {hasSession, currentAccount} = useSession()
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
const {mutateAsync: deletePostMutate} = usePostDeleteMutation() const {mutateAsync: deletePostMutate} = usePostDeleteMutation()
const {mutateAsync: pinPostMutate, isPending: isPinPending} = const {mutateAsync: pinPostMutate, isPending: isPinPending} =
@@ -212,7 +220,7 @@ let PostMenuItems = ({
try { try {
if (isThreadMuted) { if (isThreadMuted) {
unmuteThread() unmuteThread()
logger.metric('post:unmute', { ax.metric('post:unmute', {
uri: postUri, uri: postUri,
authorDid: postAuthor.did, authorDid: postAuthor.did,
logContext, logContext,
@@ -221,7 +229,7 @@ let PostMenuItems = ({
Toast.show(_(msg`You will now receive notifications for this thread`)) Toast.show(_(msg`You will now receive notifications for this thread`))
} else { } else {
muteThread() muteThread()
logger.metric('post:mute', { ax.metric('post:mute', {
uri: postUri, uri: postUri,
authorDid: postAuthor.did, authorDid: postAuthor.did,
logContext, logContext,
@@ -258,21 +266,17 @@ let PostMenuItems = ({
AppBskyFeedPost.isRecord, AppBskyFeedPost.isRecord,
) )
) { ) {
logger.metric( ax.metric('translate', {
'translate', sourceLanguages: post.record.langs ?? [],
{ targetLanguage: langPrefs.primaryLanguage,
sourceLanguages: post.record.langs ?? [], textLength: post.record.text.length,
targetLanguage: langPrefs.primaryLanguage, })
textLength: post.record.text.length,
},
{statsig: false},
)
} }
} }
const onHidePost = () => { const onHidePost = () => {
hidePost({uri: postUri}) hidePost({uri: postUri})
logEvent('thread:click:hideReplyForMe', {}) ax.metric('thread:click:hideReplyForMe', {})
} }
const hideInPWI = !!postAuthor.labels?.find( const hideInPWI = !!postAuthor.labels?.find(
@@ -286,7 +290,7 @@ let PostMenuItems = ({
feedContext: postFeedContext, feedContext: postFeedContext,
reqId: postReqId, reqId: postReqId,
}) })
logger.metric('post:showMore', { ax.metric('post:showMore', {
uri: postUri, uri: postUri,
authorDid: postAuthor.did, authorDid: postAuthor.did,
logContext, logContext,
@@ -304,7 +308,7 @@ let PostMenuItems = ({
feedContext: postFeedContext, feedContext: postFeedContext,
reqId: postReqId, reqId: postReqId,
}) })
logger.metric('post:showLess', { ax.metric('post:showLess', {
uri: postUri, uri: postUri,
authorDid: postAuthor.did, authorDid: postAuthor.did,
logContext, logContext,
@@ -368,7 +372,7 @@ let PostMenuItems = ({
// Log metric only when hiding (not when showing) // Log metric only when hiding (not when showing)
if (isHide) { if (isHide) {
logEvent('thread:click:hideReplyForEveryone', {}) ax.metric('thread:click:hideReplyForEveryone', {})
} }
Toast.show( Toast.show(
@@ -405,7 +409,7 @@ let PostMenuItems = ({
} }
const onPressPin = () => { const onPressPin = () => {
logEvent(isPinned ? 'post:unpin' : 'post:pin', {}) ax.metric(isPinned ? 'post:unpin' : 'post:pin', {})
pinPostMutate({ pinPostMutate({
postUri, postUri,
postCid, postCid,
@@ -458,11 +462,10 @@ let PostMenuItems = ({
const onSignIn = () => requireSignIn(() => {}) const onSignIn = () => requireSignIn(() => {})
const gate = useGate()
const isDiscoverDebugUser = const isDiscoverDebugUser =
IS_INTERNAL || IS_INTERNAL ||
DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] || DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] ||
gate('debug_show_feedcontext') ax.features.enabled(ax.features.DebugFeedContext)
return ( return (
<> <>
@@ -8,7 +8,6 @@ import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted'
import {type NavigationProp} from '#/lib/routes/types' import {type NavigationProp} from '#/lib/routes/types'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {useProfileShadow} from '#/state/cache/profile-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useListConvosQuery} from '#/state/queries/messages/list-conversations' import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
@@ -20,9 +19,11 @@ import {useDialogContext} from '#/components/Dialog'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification' import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck' import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
export function RecentChats({postUri}: {postUri: string}) { export function RecentChats({postUri}: {postUri: string}) {
const ax = useAnalytics()
const control = useDialogContext() const control = useDialogContext()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {data} = useListConvosQuery({status: 'accepted'}) const {data} = useListConvosQuery({status: 'accepted'})
@@ -32,7 +33,7 @@ export function RecentChats({postUri}: {postUri: string}) {
const onSelectChat = (convoId: string) => { const onSelectChat = (convoId: string) => {
control.close(() => { control.close(() => {
logger.metric('share:press:recentDm', {}, {statsig: true}) ax.metric('share:press:recentDm', {})
navigation.navigate('MessagesConversation', { navigation.navigate('MessagesConversation', {
conversation: convoId, conversation: convoId,
embed: postUri, embed: postUri,
@@ -9,7 +9,6 @@ import {makeProfileLink} from '#/lib/routes/links'
import {type NavigationProp} from '#/lib/routes/types' import {type NavigationProp} from '#/lib/routes/types'
import {shareText, shareUrl} from '#/lib/sharing' import {shareText, shareUrl} from '#/lib/sharing'
import {toShareUrl} from '#/lib/strings/url-helpers' import {toShareUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {useProfileShadow} from '#/state/cache/profile-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
@@ -23,6 +22,7 @@ import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/i
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
import * as Menu from '#/components/Menu' import * as Menu from '#/components/Menu'
import {useAgeAssurance} from '#/ageAssurance' import {useAgeAssurance} from '#/ageAssurance'
import {useAnalytics} from '#/analytics'
import {IS_IOS} from '#/env' import {IS_IOS} from '#/env'
import {useDevMode} from '#/storage/hooks/dev-mode' import {useDevMode} from '#/storage/hooks/dev-mode'
import {RecentChats} from './RecentChats' import {RecentChats} from './RecentChats'
@@ -32,6 +32,7 @@ let ShareMenuItems = ({
post, post,
onShare: onShareProp, onShare: onShareProp,
}: ShareMenuItemsProps): React.ReactNode => { }: ShareMenuItemsProps): React.ReactNode => {
const ax = useAnalytics()
const {hasSession} = useSession() const {hasSession} = useSession()
const {_} = useLingui() const {_} = useLingui()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
@@ -54,14 +55,14 @@ let ShareMenuItems = ({
}, [postAuthor]) }, [postAuthor])
const onSharePost = () => { const onSharePost = () => {
logger.metric('share:press:nativeShare', {}, {statsig: true}) ax.metric('share:press:nativeShare', {})
const url = toShareUrl(href) const url = toShareUrl(href)
shareUrl(url) shareUrl(url)
onShareProp() onShareProp()
} }
const onCopyLink = async () => { const onCopyLink = async () => {
logger.metric('share:press:copyLink', {}, {statsig: true}) ax.metric('share:press:copyLink', {})
const url = toShareUrl(href) const url = toShareUrl(href)
if (IS_IOS) { if (IS_IOS) {
// iOS only // iOS only
@@ -100,7 +101,7 @@ let ShareMenuItems = ({
testID="postDropdownSendViaDMBtn" testID="postDropdownSendViaDMBtn"
label={_(msg`Send via direct message`)} label={_(msg`Send via direct message`)}
onPress={() => { onPress={() => {
logger.metric('share:press:openDmSearch', {}, {statsig: true}) ax.metric('share:press:openDmSearch', {})
sendViaChatControl.open() sendViaChatControl.open()
}}> }}>
<Menu.ItemText> <Menu.ItemText>
@@ -8,7 +8,6 @@ import {makeProfileLink} from '#/lib/routes/links'
import {type NavigationProp} from '#/lib/routes/types' import {type NavigationProp} from '#/lib/routes/types'
import {shareText, shareUrl} from '#/lib/sharing' import {shareText, shareUrl} from '#/lib/sharing'
import {toShareUrl} from '#/lib/strings/url-helpers' import {toShareUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {useProfileShadow} from '#/state/cache/profile-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useBreakpoints} from '#/alf' import {useBreakpoints} from '#/alf'
@@ -21,6 +20,7 @@ import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBracketsIcon} from '#/compon
import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane'
import * as Menu from '#/components/Menu' import * as Menu from '#/components/Menu'
import {useAgeAssurance} from '#/ageAssurance' import {useAgeAssurance} from '#/ageAssurance'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env' import {IS_WEB} from '#/env'
import {useDevMode} from '#/storage/hooks/dev-mode' import {useDevMode} from '#/storage/hooks/dev-mode'
import {type ShareMenuItemsProps} from './ShareMenuItems.types' import {type ShareMenuItemsProps} from './ShareMenuItems.types'
@@ -31,6 +31,7 @@ let ShareMenuItems = ({
timestamp, timestamp,
onShare: onShareProp, onShare: onShareProp,
}: ShareMenuItemsProps): React.ReactNode => { }: ShareMenuItemsProps): React.ReactNode => {
const ax = useAnalytics()
const {hasSession} = useSession() const {hasSession} = useSession()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {_} = useLingui() const {_} = useLingui()
@@ -56,14 +57,14 @@ let ShareMenuItems = ({
}, [postAuthor]) }, [postAuthor])
const onCopyLink = () => { const onCopyLink = () => {
logger.metric('share:press:copyLink', {}, {statsig: true}) ax.metric('share:press:copyLink', {})
const url = toShareUrl(href) const url = toShareUrl(href)
shareUrl(url) shareUrl(url)
onShareProp() onShareProp()
} }
const onSelectChatToShareTo = (conversation: string) => { const onSelectChatToShareTo = (conversation: string) => {
logger.metric('share:press:dmSelected', {}, {statsig: true}) ax.metric('share:press:dmSelected', {})
navigation.navigate('MessagesConversation', { navigation.navigate('MessagesConversation', {
conversation, conversation,
embed: postUri, embed: postUri,
@@ -102,7 +103,7 @@ let ShareMenuItems = ({
testID="postDropdownSendViaDMBtn" testID="postDropdownSendViaDMBtn"
label={_(msg`Send via direct message`)} label={_(msg`Send via direct message`)}
onPress={() => { onPress={() => {
logger.metric('share:press:openDmSearch', {}, {statsig: true}) ax.metric('share:press:openDmSearch', {})
sendViaChatControl.open() sendViaChatControl.open()
}}> }}>
<Menu.ItemText> <Menu.ItemText>
@@ -117,7 +118,7 @@ let ShareMenuItems = ({
testID="postDropdownEmbedBtn" testID="postDropdownEmbedBtn"
label={_(msg`Embed post`)} label={_(msg`Embed post`)}
onPress={() => { onPress={() => {
logger.metric('share:press:embed', {}, {statsig: true}) ax.metric('share:press:embed', {})
embedPostControl.open() embedPostControl.open()
}}> }}>
<Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText> <Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText>
+11 -13
View File
@@ -13,7 +13,6 @@ import {useLingui} from '@lingui/react'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {shareUrl} from '#/lib/sharing' import {shareUrl} from '#/lib/sharing'
import {toShareUrl} from '#/lib/strings/url-helpers' import {toShareUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/post-shadow' import {type Shadow} from '#/state/cache/post-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback' import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {EventStopper} from '#/view/com/util/EventStopper' import {EventStopper} from '#/view/com/util/EventStopper'
@@ -21,6 +20,7 @@ import {native} from '#/alf'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight' import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight'
import {useMenuControl} from '#/components/Menu' import {useMenuControl} from '#/components/Menu'
import * as Menu from '#/components/Menu' import * as Menu from '#/components/Menu'
import {useAnalytics} from '#/analytics'
import {PostControlButton, PostControlButtonIcon} from '../PostControlButton' import {PostControlButton, PostControlButtonIcon} from '../PostControlButton'
import {ShareMenuItems} from './ShareMenuItems' import {ShareMenuItems} from './ShareMenuItems'
@@ -47,6 +47,7 @@ let ShareMenuButton = ({
hitSlop?: Insets hitSlop?: Insets
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
}): React.ReactNode => { }): React.ReactNode => {
const ax = useAnalytics()
const {_} = useLingui() const {_} = useLingui()
const {feedDescriptor} = useFeedFeedbackContext() const {feedDescriptor} = useFeedFeedbackContext()
@@ -61,20 +62,17 @@ let ShareMenuButton = ({
// menuControl.open() fires but RN doesn't expose flushSync. // menuControl.open() fires but RN doesn't expose flushSync.
setTimeout(menuControl.open) setTimeout(menuControl.open)
logger.metric( ax.metric('post:share', {
'post:share', uri: post.uri,
{ authorDid: post.author.did,
uri: post.uri, logContext,
authorDid: post.author.did, feedDescriptor,
logContext, postContext: big ? 'thread' : 'feed',
feedDescriptor, })
postContext: big ? 'thread' : 'feed',
},
{statsig: true},
)
}, },
}), }),
[ [
ax,
menuControl, menuControl,
setHasBeenOpen, setHasBeenOpen,
big, big,
@@ -86,7 +84,7 @@ let ShareMenuButton = ({
) )
const onNativeLongPress = () => { const onNativeLongPress = () => {
logger.metric('share:press:nativeShare', {}, {statsig: true}) ax.metric('share:press:nativeShare', {})
const urip = new AtUri(post.uri) const urip = new AtUri(post.uri)
const href = makeProfileLink(post.author, 'post', urip.rkey) const href = makeProfileLink(post.author, 'post', urip.rkey)
const url = toShareUrl(href) const url = toShareUrl(href)
+4 -3
View File
@@ -13,7 +13,6 @@ import {CountWheel} from '#/lib/custom-animations/CountWheel'
import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon'
import {useHaptics} from '#/lib/haptics' import {useHaptics} from '#/lib/haptics'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/types' import {type Shadow} from '#/state/cache/types'
import {useFeedFeedbackContext} from '#/state/feed-feedback' import {useFeedFeedbackContext} from '#/state/feed-feedback'
import { import {
@@ -30,6 +29,7 @@ import {atoms as a, useBreakpoints} from '#/alf'
import {Reply as Bubble} from '#/components/icons/Reply' import {Reply as Bubble} from '#/components/icons/Reply'
import {useFormatPostStatCount} from '#/components/PostControls/util' import {useFormatPostStatCount} from '#/components/PostControls/util'
import * as Skele from '#/components/Skeleton' import * as Skele from '#/components/Skeleton'
import {useAnalytics} from '#/analytics'
import {BookmarkButton} from './BookmarkButton' import {BookmarkButton} from './BookmarkButton'
import { import {
PostControlButton, PostControlButton,
@@ -71,6 +71,7 @@ let PostControls = ({
viaRepost?: {uri: string; cid: string} viaRepost?: {uri: string; cid: string}
variant?: 'compact' | 'normal' | 'large' variant?: 'compact' | 'normal' | 'large'
}): React.ReactNode => { }): React.ReactNode => {
const ax = useAnalytics()
const {_} = useLingui() const {_} = useLingui()
const {openComposer} = useOpenComposer() const {openComposer} = useOpenComposer()
const {feedDescriptor} = useFeedFeedbackContext() const {feedDescriptor} = useFeedFeedbackContext()
@@ -175,7 +176,7 @@ let PostControls = ({
feedContext, feedContext,
reqId, reqId,
}) })
logger.metric('post:clickQuotePost', { ax.metric('post:clickQuotePost', {
uri: post.uri, uri: post.uri,
authorDid: post.author.did, authorDid: post.author.did,
logContext, logContext,
@@ -226,7 +227,7 @@ let PostControls = ({
!replyDisabled !replyDisabled
? () => ? () =>
requireAuth(() => { requireAuth(() => {
logger.metric('post:clickReply', { ax.metric('post:clickReply', {
uri: post.uri, uri: post.uri,
authorDid: post.author.did, authorDid: post.author.did,
logContext, logContext,
+3 -3
View File
@@ -16,7 +16,6 @@ import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status' import {useActorStatus} from '#/lib/actor-status'
import {getModerationCauseKey} from '#/lib/moderation' import {getModerationCauseKey} from '#/lib/moderation'
import {type LogEvents} from '#/lib/statsig/statsig'
import {forceLTR} from '#/lib/strings/bidi' import {forceLTR} from '#/lib/strings/bidi'
import {NON_BREAKING_SPACE} from '#/lib/strings/constants' import {NON_BREAKING_SPACE} from '#/lib/strings/constants'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
@@ -47,6 +46,7 @@ import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification' import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck' import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {type Metrics} from '#/analytics'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
export function Default({ export function Default({
@@ -461,8 +461,8 @@ export function DescriptionPlaceholder({
export type FollowButtonProps = { export type FollowButtonProps = {
profile: bsky.profile.AnyProfileView profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
logContext: LogEvents['profile:follow']['logContext'] & logContext: Metrics['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext'] Metrics['profile:unfollow']['logContext']
colorInverted?: boolean colorInverted?: boolean
onFollow?: () => void onFollow?: () => void
withIcon?: boolean withIcon?: boolean
+11 -14
View File
@@ -10,8 +10,6 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {popularInterests, useInterestsDisplayNames} from '#/lib/interests' import {popularInterests, useInterestsDisplayNames} from '#/lib/interests'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorSearch} from '#/state/queries/actor-search' import {useActorSearch} from '#/state/queries/actor-search'
import {usePreferencesQuery} from '#/state/queries/preferences' import {usePreferencesQuery} from '#/state/queries/preferences'
@@ -36,6 +34,7 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {boostInterests, InterestTabs} from '#/components/InterestTabs' import {boostInterests, InterestTabs} from '#/components/InterestTabs'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env' import {IS_WEB} from '#/env'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import {ProgressGuideTask} from './Task' import {ProgressGuideTask} from './Task'
@@ -67,6 +66,7 @@ export function FollowDialog({
guide: Follow10ProgressGuide guide: Follow10ProgressGuide
showArrow?: boolean showArrow?: boolean
}) { }) {
const ax = useAnalytics()
const {_} = useLingui() const {_} = useLingui()
const control = Dialog.useDialogControl() const control = Dialog.useDialogControl()
const {gtPhone} = useBreakpoints() const {gtPhone} = useBreakpoints()
@@ -78,7 +78,7 @@ export function FollowDialog({
label={_(msg`Find people to follow`)} label={_(msg`Find people to follow`)}
onPress={() => { onPress={() => {
control.open() control.open()
logEvent('progressGuide:followDialog:open', {}) ax.metric('progressGuide:followDialog:open', {})
}} }}
size={gtPhone ? 'small' : 'large'} size={gtPhone ? 'small' : 'large'}
color="primary"> color="primary">
@@ -118,6 +118,7 @@ let lastSearchText = ''
function DialogInner({guide}: {guide?: Follow10ProgressGuide}) { function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const interestsDisplayNames = useInterestsDisplayNames() const interestsDisplayNames = useInterestsDisplayNames()
const {data: preferences} = usePreferencesQuery() const {data: preferences} = usePreferencesQuery()
const personalizedInterests = preferences?.interests?.tags const personalizedInterests = preferences?.interests?.tags
@@ -271,17 +272,13 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
const position = itemsRef.current.findIndex( const position = itemsRef.current.findIndex(
i => i.type === 'profile' && i.profile.did === item.profile.did, i => i.type === 'profile' && i.profile.did === item.profile.did,
) )
logger.metric( ax.metric('suggestedUser:seen', {
'suggestedUser:seen', logContext: 'ProgressGuide',
{ recId: undefined,
logContext: 'ProgressGuide', position: position !== -1 ? position : 0,
recId: undefined, suggestedDid: item.profile.did,
position: position !== -1 ? position : 0, category: selectedInterestRef.current,
suggestedDid: item.profile.did, })
category: selectedInterestRef.current,
},
{statsig: true},
)
} }
} }
} }
+5 -3
View File
@@ -19,6 +19,7 @@ import {FloppyDisk_Stroke2_Corner0_Rounded as FloppyDiskIcon} from '#/components
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {QrCode} from '#/components/StarterPack/QrCode' import {QrCode} from '#/components/StarterPack/QrCode'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE, IS_WEB} from '#/env' import {IS_NATIVE, IS_WEB} from '#/env'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
@@ -32,6 +33,7 @@ export function QrCodeDialog({
control: DialogControlProps control: DialogControlProps
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const [isSaveProcessing, setIsSaveProcessing] = useState(false) const [isSaveProcessing, setIsSaveProcessing] = useState(false)
const [isCopyProcessing, setIsCopyProcessing] = useState(false) const [isCopyProcessing, setIsCopyProcessing] = useState(false)
@@ -104,7 +106,7 @@ export function QrCodeDialog({
link.click() link.click()
} }
logger.metric('starterPack:share', { ax.metric('starterPack:share', {
starterPack: starterPack.uri, starterPack: starterPack.uri,
shareType: 'qrcode', shareType: 'qrcode',
qrShareType: 'save', qrShareType: 'save',
@@ -129,7 +131,7 @@ export function QrCodeDialog({
navigator.clipboard.write([item]) navigator.clipboard.write([item])
}) })
logger.metric('starterPack:share', { ax.metric('starterPack:share', {
starterPack: starterPack.uri, starterPack: starterPack.uri,
shareType: 'qrcode', shareType: 'qrcode',
qrShareType: 'copy', qrShareType: 'copy',
@@ -145,7 +147,7 @@ export function QrCodeDialog({
control.close(() => { control.close(() => {
Sharing.shareAsync(uri, {mimeType: 'image/png', UTI: 'image/png'}).then( Sharing.shareAsync(uri, {mimeType: 'image/png', UTI: 'image/png'}).then(
() => { () => {
logger.metric('starterPack:share', { ax.metric('starterPack:share', {
starterPack: starterPack.uri, starterPack: starterPack.uri,
shareType: 'qrcode', shareType: 'qrcode',
qrShareType: 'share', qrShareType: 'share',
+3 -2
View File
@@ -7,7 +7,6 @@ import {useLingui} from '@lingui/react'
import {useSaveImageToMediaLibrary} from '#/lib/media/save-image' import {useSaveImageToMediaLibrary} from '#/lib/media/save-image'
import {shareUrl} from '#/lib/sharing' import {shareUrl} from '#/lib/sharing'
import {getStarterPackOgCard} from '#/lib/strings/starter-pack' import {getStarterPackOgCard} from '#/lib/strings/starter-pack'
import {logger} from '#/logger'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {type DialogControlProps} from '#/components/Dialog' import {type DialogControlProps} from '#/components/Dialog'
@@ -17,6 +16,7 @@ import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/ico
import {QrCode_Stroke2_Corner0_Rounded as QrCodeIcon} from '#/components/icons/QrCode' import {QrCode_Stroke2_Corner0_Rounded as QrCodeIcon} from '#/components/icons/QrCode'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE, IS_WEB} from '#/env' import {IS_NATIVE, IS_WEB} from '#/env'
interface Props { interface Props {
@@ -46,6 +46,7 @@ function ShareDialogInner({
control, control,
}: Props) { }: Props) {
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const t = useTheme() const t = useTheme()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
@@ -54,7 +55,7 @@ function ShareDialogInner({
const onShareLink = async () => { const onShareLink = async () => {
if (!link) return if (!link) return
shareUrl(link) shareUrl(link)
logger.metric('starterPack:share', { ax.metric('starterPack:share', {
starterPack: starterPack.uri, starterPack: starterPack.uri,
shareType: 'link', shareType: 'link',
}) })
@@ -13,7 +13,6 @@ import {useLingui} from '@lingui/react'
import {DISCOVER_FEED_URI, STARTER_PACK_MAX_SIZE} from '#/lib/constants' import {DISCOVER_FEED_URI, STARTER_PACK_MAX_SIZE} from '#/lib/constants'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserAvatar} from '#/view/com/util/UserAvatar'
import { import {
@@ -25,6 +24,7 @@ import {Button, ButtonText} from '#/components/Button'
import * as Toggle from '#/components/forms/Toggle' import * as Toggle from '#/components/forms/Toggle'
import {Checkbox} from '#/components/forms/Toggle' import {Checkbox} from '#/components/forms/Toggle'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
function WizardListCard({ function WizardListCard({
@@ -130,6 +130,7 @@ export function WizardProfileCard({
profile: bsky.profile.AnyProfileView profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
}) { }) {
const ax = useAnalytics()
const {currentAccount} = useSession() const {currentAccount} = useSession()
// Determine the "main" profile for this starter pack - either targetDid or current account // Determine the "main" profile for this starter pack - either targetDid or current account
@@ -151,10 +152,10 @@ export function WizardProfileCard({
if (profile.did === targetProfileDid) return if (profile.did === targetProfileDid) return
if (!included) { if (!included) {
logger.metric('starterPack:addUser', {}) ax.metric('starterPack:addUser', {})
dispatch({type: 'AddProfile', profile}) dispatch({type: 'AddProfile', profile})
} else { } else {
logger.metric('starterPack:removeUser', {}) ax.metric('starterPack:removeUser', {})
dispatch({type: 'RemoveProfile', profileDid: profile.did}) dispatch({type: 'RemoveProfile', profileDid: profile.did})
} }
} }
+8 -6
View File
@@ -5,13 +5,13 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {FocusGuards, FocusScope} from 'radix-ui/internal' import {FocusGuards, FocusScope} from 'radix-ui/internal'
import {logger} from '#/logger'
import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {Logo} from '#/view/icons/Logo' import {Logo} from '#/view/icons/Logo'
import {atoms as a, flatten, useBreakpoints, web} from '#/alf' import {atoms as a, flatten, useBreakpoints, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
const welcomeModalBg = require('../../assets/images/welcome-modal-bg.jpg') const welcomeModalBg = require('../../assets/images/welcome-modal-bg.jpg')
@@ -25,6 +25,7 @@ interface WelcomeModalProps {
export function WelcomeModal({control}: WelcomeModalProps) { export function WelcomeModal({control}: WelcomeModalProps) {
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const {requestSwitchToAccount} = useLoggedOutViewControls() const {requestSwitchToAccount} = useLoggedOutViewControls()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const [isExiting, setIsExiting] = useState(false) const [isExiting, setIsExiting] = useState(false)
@@ -40,23 +41,24 @@ export function WelcomeModal({control}: WelcomeModalProps) {
useEffect(() => { useEffect(() => {
if (control.isOpen) { if (control.isOpen) {
logger.metric('welcomeModal:presented', {}) ax.metric('welcomeModal:presented', {})
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [control.isOpen]) }, [control.isOpen])
const onPressCreateAccount = () => { const onPressCreateAccount = () => {
logger.metric('welcomeModal:signupClicked', {}) ax.metric('welcomeModal:signupClicked', {})
control.close() control.close()
requestSwitchToAccount({requestedAccount: 'new'}) requestSwitchToAccount({requestedAccount: 'new'})
} }
const onPressExplore = () => { const onPressExplore = () => {
logger.metric('welcomeModal:exploreClicked', {}) ax.metric('welcomeModal:exploreClicked', {})
fadeOutAndClose() fadeOutAndClose()
} }
const onPressSignIn = () => { const onPressSignIn = () => {
logger.metric('welcomeModal:signinClicked', {}) ax.metric('welcomeModal:signinClicked', {})
control.close() control.close()
requestSwitchToAccount({requestedAccount: 'existing'}) requestSwitchToAccount({requestedAccount: 'existing'})
} }
@@ -222,7 +224,7 @@ export function WelcomeModal({control}: WelcomeModalProps) {
]} ]}
hoverStyle={[a.bg_transparent]} hoverStyle={[a.bg_transparent]}
onPress={() => { onPress={() => {
logger.metric('welcomeModal:dismissed', {}) ax.metric('welcomeModal:dismissed', {})
fadeOutAndClose() fadeOutAndClose()
}} }}
color="secondary" color="secondary"
+5 -4
View File
@@ -17,7 +17,6 @@ import {useLingui} from '@lingui/react'
import {HITSLOP_10} from '#/lib/constants' import {HITSLOP_10} from '#/lib/constants'
import {makeListLink, makeProfileLink} from '#/lib/routes/links' import {makeListLink, makeProfileLink} from '#/lib/routes/links'
import {logger} from '#/logger'
import { import {
type ThreadgateAllowUISetting, type ThreadgateAllowUISetting,
threadgateViewToAllowUISetting, threadgateViewToAllowUISetting,
@@ -36,6 +35,7 @@ import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Glo
import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Group' import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Group'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env' import {IS_NATIVE} from '#/env'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
@@ -46,8 +46,9 @@ interface WhoCanReplyProps {
} }
export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
const {_} = useLingui()
const t = useTheme() const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui()
const infoDialogControl = useDialogControl() const infoDialogControl = useDialogControl()
const editDialogControl = useDialogControl() const editDialogControl = useDialogControl()
@@ -90,7 +91,7 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
Keyboard.dismiss() Keyboard.dismiss()
} }
if (isThreadAuthor) { if (isThreadAuthor) {
logger.metric('thread:click:editOwnThreadgate', {}) ax.metric('thread:click:editOwnThreadgate', {})
// wait on prefetch if it manages to resolve in under 200ms // wait on prefetch if it manages to resolve in under 200ms
// otherwise, proceed immediately and show the spinner -sfn // otherwise, proceed immediately and show the spinner -sfn
@@ -101,7 +102,7 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
editDialogControl.open() editDialogControl.open()
}) })
} else { } else {
logger.metric('thread:click:viewSomeoneElsesThreadgate', {}) ax.metric('thread:click:viewSomeoneElsesThreadgate', {})
infoDialogControl.open() infoDialogControl.open()
} }
@@ -17,13 +17,11 @@ import {
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {updateProfileShadow} from '#/state/cache/profile-shadow' import {updateProfileShadow} from '#/state/cache/profile-shadow'
import {RQKEY_getActivitySubscriptions} from '#/state/queries/activity-subscriptions' import {RQKEY_getActivitySubscriptions} from '#/state/queries/activity-subscriptions'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {platform, useTheme, web} from '#/alf' import {atoms as a, platform, useTheme, web} from '#/alf'
import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition' import {Admonition} from '#/components/Admonition'
import { import {
Button, Button,
@@ -36,6 +34,7 @@ import * as Toggle from '#/components/forms/Toggle'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env' import {IS_WEB} from '#/env'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
@@ -71,6 +70,7 @@ function DialogInner({
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
includeProfile?: boolean includeProfile?: boolean
}) { }) {
const ax = useAnalytics()
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const agent = useAgent() const agent = useAgent()
@@ -133,7 +133,7 @@ function DialogInner({
}) })
if (!activitySubscription.post && !activitySubscription.reply) { if (!activitySubscription.post && !activitySubscription.reply) {
logger.metric('activitySubscription:disable', {}) ax.metric('activitySubscription:disable', {})
Toast.show( Toast.show(
_( _(
msg`You will no longer receive notifications for ${sanitizeHandle(profile.handle, '@')}`, msg`You will no longer receive notifications for ${sanitizeHandle(profile.handle, '@')}`,
@@ -160,7 +160,7 @@ function DialogInner({
}, },
) )
} else { } else {
logger.metric('activitySubscription:enable', { ax.metric('activitySubscription:enable', {
setting: activitySubscription.reply ? 'posts_and_replies' : 'posts', setting: activitySubscription.reply ? 'posts_and_replies' : 'posts',
}) })
if (!initialState.post && !initialState.reply) { if (!initialState.post && !initialState.reply) {
@@ -177,7 +177,7 @@ function DialogInner({
}) })
}, },
onError: err => { onError: err => {
logger.error('Could not save activity subscription', {message: err}) ax.logger.error('Could not save activity subscription', {message: err})
}, },
}) })
@@ -20,8 +20,9 @@ import {Divider} from '#/components/Divider'
import {createStaticClick, InlineLinkText} from '#/components/Link' import {createStaticClick, InlineLinkText} from '#/components/Link'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {logger, useAgeAssurance} from '#/ageAssurance' import {useAgeAssurance} from '#/ageAssurance'
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess' import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env' import {IS_NATIVE} from '#/env'
import {useDeviceGeolocationApi} from '#/geolocation' import {useDeviceGeolocationApi} from '#/geolocation'
@@ -41,6 +42,7 @@ export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) {
function Inner({style}: ViewStyleProp & {}) { function Inner({style}: ViewStyleProp & {}) {
const t = useTheme() const t = useTheme()
const {_, i18n} = useLingui() const {_, i18n} = useLingui()
const ax = useAnalytics()
const control = useDialogControl() const control = useDialogControl()
const appealControl = Dialog.useDialogControl() const appealControl = Dialog.useDialogControl()
const locationControl = Dialog.useDialogControl() const locationControl = Dialog.useDialogControl()
@@ -138,7 +140,7 @@ function Inner({style}: ViewStyleProp & {}) {
label={_(msg`Contact our moderation team`)} label={_(msg`Contact our moderation team`)}
{...createStaticClick(() => { {...createStaticClick(() => {
appealControl.open() appealControl.open()
logger.metric('ageAssurance:appealDialogOpen', {}) ax.metric('ageAssurance:appealDialogOpen', {})
})}> })}>
contact our moderation team contact our moderation team
</InlineLinkText>{' '} </InlineLinkText>{' '}
@@ -167,7 +169,7 @@ function Inner({style}: ViewStyleProp & {}) {
color={hasInitiated ? 'secondary' : 'primary'} color={hasInitiated ? 'secondary' : 'primary'}
onPress={() => { onPress={() => {
control.open() control.open()
logger.metric('ageAssurance:initDialogOpen', { ax.metric('ageAssurance:initDialogOpen', {
hasInitiatedPreviously: hasInitiated, hasInitiatedPreviously: hasInitiated,
}) })
}}> }}>
@@ -10,7 +10,7 @@ import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance' import {useAgeAssurance} from '#/ageAssurance'
import {logger} from '#/ageAssurance' import {useAnalytics} from '#/analytics'
export function AgeAssuranceAdmonition({ export function AgeAssuranceAdmonition({
children, children,
@@ -40,6 +40,7 @@ function Inner({
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
return ( return (
<> <>
@@ -92,7 +93,7 @@ function Inner({
to={'/settings/account'} to={'/settings/account'}
style={[a.text_sm, a.leading_snug, a.font_semi_bold]} style={[a.text_sm, a.leading_snug, a.font_semi_bold]}
onPress={() => { onPress={() => {
logger.metric('ageAssurance:navigateToSettings', {}) ax.metric('ageAssurance:navigateToSettings', {})
}}> }}>
account settings. account settings.
</InlineLinkText> </InlineLinkText>
@@ -15,6 +15,7 @@ import * as Dialog from '#/components/Dialog'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {logger} from '#/ageAssurance' import {logger} from '#/ageAssurance'
import {useAnalytics} from '#/analytics'
export function AgeAssuranceAppealDialog({ export function AgeAssuranceAppealDialog({
control, control,
@@ -37,6 +38,7 @@ export function AgeAssuranceAppealDialog({
function Inner({control}: {control: Dialog.DialogControlProps}) { function Inner({control}: {control: Dialog.DialogControlProps}) {
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {gtPhone} = useBreakpoints() const {gtPhone} = useBreakpoints()
const agent = useAgent() const agent = useAgent()
@@ -46,7 +48,7 @@ function Inner({control}: {control: Dialog.DialogControlProps}) {
const {mutate, isPending} = useMutation({ const {mutate, isPending} = useMutation({
mutationFn: async () => { mutationFn: async () => {
logger.metric('ageAssurance:appealDialogSubmit', {}) ax.metric('ageAssurance:appealDialogSubmit', {})
await agent.createModerationReport( await agent.createModerationReport(
{ {
@@ -12,7 +12,7 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Link} from '#/components/Link' import {Link} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance' import {useAgeAssurance} from '#/ageAssurance'
import {logger} from '#/ageAssurance' import {useAnalytics} from '#/analytics'
export function useInternalState() { export function useInternalState() {
const aa = useAgeAssurance() const aa = useAgeAssurance()
@@ -42,6 +42,7 @@ export function useInternalState() {
export function AgeAssuranceDismissibleFeedBanner() { export function AgeAssuranceDismissibleFeedBanner() {
const t = useTheme() const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui() const {_} = useLingui()
const {visible, close} = useInternalState() const {visible, close} = useInternalState()
const copy = useAgeAssuranceCopy() const copy = useAgeAssuranceCopy()
@@ -66,7 +67,7 @@ export function AgeAssuranceDismissibleFeedBanner() {
to="/settings/account" to="/settings/account"
onPress={() => { onPress={() => {
close() close()
logger.metric('ageAssurance:navigateToSettings', {}) ax.metric('ageAssurance:navigateToSettings', {})
}} }}
style={[a.w_full, a.justify_between, a.align_center, a.gap_md]}> style={[a.w_full, a.justify_between, a.align_center, a.gap_md]}>
<View <View
@@ -105,7 +106,7 @@ export function AgeAssuranceDismissibleFeedBanner() {
size="small" size="small"
onPress={() => { onPress={() => {
close() close()
logger.metric('ageAssurance:dismissFeedBanner', {}) ax.metric('ageAssurance:dismissFeedBanner', {})
}} }}
style={[ style={[
a.absolute, a.absolute,
@@ -10,10 +10,11 @@ import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy
import {Button, ButtonIcon} from '#/components/Button' import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {useAgeAssurance} from '#/ageAssurance' import {useAgeAssurance} from '#/ageAssurance'
import {logger} from '#/ageAssurance' import {useAnalytics} from '#/analytics'
export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) { export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) {
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const aa = useAgeAssurance() const aa = useAgeAssurance()
const {nux} = useNux(Nux.AgeAssuranceDismissibleNotice) const {nux} = useNux(Nux.AgeAssuranceDismissibleNotice)
const copy = useAgeAssuranceCopy() const copy = useAgeAssuranceCopy()
@@ -45,7 +46,7 @@ export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) {
completed: true, completed: true,
data: undefined, data: undefined,
}) })
logger.metric('ageAssurance:dismissSettingsNotice', {}) ax.metric('ageAssurance:dismissSettingsNotice', {})
}} }}
style={[ style={[
a.absolute, a.absolute,
@@ -19,8 +19,7 @@ import {useSession} from '#/state/session'
import {atoms as a, web} from '#/alf' import {atoms as a, web} from '#/alf'
import {Admonition} from '#/components/Admonition' import {Admonition} from '#/components/Admonition'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
import {urls} from '#/components/ageAssurance/const' import {KWS_SUPPORTED_LANGS, urls} from '#/components/ageAssurance/const'
import {KWS_SUPPORTED_LANGS} from '#/components/ageAssurance/const'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider' import {Divider} from '#/components/Divider'
@@ -30,9 +29,9 @@ import {LanguageSelect} from '#/components/LanguageSelect'
import {SimpleInlineLinkText} from '#/components/Link' import {SimpleInlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {logger} from '#/ageAssurance'
import {useAgeAssurance} from '#/ageAssurance' import {useAgeAssurance} from '#/ageAssurance'
import {useBeginAgeAssurance} from '#/ageAssurance/useBeginAgeAssurance' import {useBeginAgeAssurance} from '#/ageAssurance/useBeginAgeAssurance'
import {useAnalytics} from '#/analytics'
export {useDialogControl} from '#/components/Dialog/context' export {useDialogControl} from '#/components/Dialog/context'
@@ -64,6 +63,7 @@ export function AgeAssuranceInitDialog({
function Inner() { function Inner() {
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
const cleanError = useCleanError() const cleanError = useCleanError()
@@ -116,7 +116,7 @@ function Inner() {
const onSubmit = async () => { const onSubmit = async () => {
setLanguageError(false) setLanguageError(false)
logger.metric('ageAssurance:initDialogSubmit', {}) ax.metric('ageAssurance:initDialogSubmit', {})
try { try {
const {status} = runEmailValidation() const {status} = runEmailValidation()
@@ -143,7 +143,7 @@ function Inner() {
error = _( error = _(
msg`Please enter a valid, non-temporary email address. You may need to access this email in the future.`, msg`Please enter a valid, non-temporary email address. You may need to access this email in the future.`,
) )
logger.metric('ageAssurance:initDialogError', {code: 'InvalidEmail'}) ax.metric('ageAssurance:initDialogError', {code: 'InvalidEmail'})
} else if (e.error === 'DidTooLong') { } else if (e.error === 'DidTooLong') {
error = ( error = (
<> <>
@@ -159,14 +159,14 @@ function Inner() {
</Trans> </Trans>
</> </>
) )
logger.metric('ageAssurance:initDialogError', {code: 'DidTooLong'}) ax.metric('ageAssurance:initDialogError', {code: 'DidTooLong'})
} else { } else {
logger.metric('ageAssurance:initDialogError', {code: 'other'}) ax.metric('ageAssurance:initDialogError', {code: 'other'})
} }
} else { } else {
const {clean, raw} = cleanError(e) const {clean, raw} = cleanError(e)
error = clean || raw || error error = clean || raw || error
logger.metric('ageAssurance:initDialogError', {code: 'other'}) ax.metric('ageAssurance:initDialogError', {code: 'other'})
} }
setError(error) setError(error)
@@ -16,7 +16,7 @@ import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icon
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {refetchAgeAssuranceServerState} from '#/ageAssurance' import {refetchAgeAssuranceServerState} from '#/ageAssurance'
import {logger} from '#/ageAssurance' import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env' import {IS_NATIVE} from '#/env'
export type AgeAssuranceRedirectDialogState = { export type AgeAssuranceRedirectDialogState = {
@@ -81,6 +81,7 @@ export function AgeAssuranceRedirectDialog() {
export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) { export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
const t = useTheme() const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui() const {_} = useLingui()
const agent = useAgent() const agent = useAgent()
const polling = useRef(false) const polling = useRef(false)
@@ -94,7 +95,7 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
polling.current = true polling.current = true
logger.metric('ageAssurance:redirectDialogOpen', {}) ax.metric('ageAssurance:redirectDialogOpen', {})
wait( wait(
3e3, 3e3,
@@ -125,18 +126,18 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
setSuccess(true) setSuccess(true)
logger.metric('ageAssurance:redirectDialogSuccess', {}) ax.metric('ageAssurance:redirectDialogSuccess', {})
}) })
.catch(() => { .catch(() => {
if (unmounted.current) return if (unmounted.current) return
setError(true) setError(true)
logger.metric('ageAssurance:redirectDialogFail', {}) ax.metric('ageAssurance:redirectDialogFail', {})
}) })
return () => { return () => {
unmounted.current = true unmounted.current = true
} }
}, [agent, control]) }, [ax, agent, control])
if (success) { if (success) {
return ( return (
@@ -13,7 +13,7 @@ import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link' import {Link} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance' import {useAgeAssurance} from '#/ageAssurance'
import {logger} from '#/ageAssurance' import {useAnalytics} from '#/analytics'
export function AgeRestrictedScreen({ export function AgeRestrictedScreen({
children, children,
@@ -27,6 +27,7 @@ export function AgeRestrictedScreen({
rightHeaderSlot?: React.ReactNode rightHeaderSlot?: React.ReactNode
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const copy = useAgeAssuranceCopy() const copy = useAgeAssuranceCopy()
const aa = useAgeAssurance() const aa = useAgeAssurance()
@@ -74,7 +75,7 @@ export function AgeRestrictedScreen({
variant="solid" variant="solid"
color="primary" color="primary"
onPress={() => { onPress={() => {
logger.metric('ageAssurance:navigateToSettings', {}) ax.metric('ageAssurance:navigateToSettings', {})
}}> }}>
<ButtonText> <ButtonText>
<Trans>Go to account settings</Trans> <Trans>Go to account settings</Trans>
@@ -6,12 +6,12 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {HITSLOP_10} from '#/lib/constants' import {HITSLOP_10} from '#/lib/constants'
import {logger} from '#/logger'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env' import {IS_WEB} from '#/env'
import {Link} from '../Link' import {Link} from '../Link'
import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from './country-allowlist' import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from './country-allowlist'
@@ -19,6 +19,7 @@ import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from './country-allow
export function FindContactsBannerNUX() { export function FindContactsBannerNUX() {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const {visible, close} = useInternalState() const {visible, close} = useInternalState()
if (!visible) return null if (!visible) return null
@@ -30,7 +31,7 @@ export function FindContactsBannerNUX() {
to={{screen: 'FindContactsFlow'}} to={{screen: 'FindContactsFlow'}}
label={_(msg`Import contacts to find your friends`)} label={_(msg`Import contacts to find your friends`)}
onPress={() => { onPress={() => {
logger.metric('contacts:nux:bannerPressed', {}) ax.metric('contacts:nux:bannerPressed', {})
}} }}
style={[ style={[
a.w_full, a.w_full,
@@ -84,6 +85,7 @@ export function FindContactsBannerNUX() {
) )
} }
function useInternalState() { function useInternalState() {
const ax = useAnalytics()
const {nux} = useNux(Nux.FindContactsDismissibleBanner) const {nux} = useNux(Nux.FindContactsDismissibleBanner)
const {mutate: save, variables} = useSaveNux() const {mutate: save, variables} = useSaveNux()
const hidden = !!variables const hidden = !!variables
@@ -103,7 +105,7 @@ function useInternalState() {
completed: true, completed: true,
data: undefined, data: undefined,
}) })
logger.metric('contacts:nux:bannerDismissed', {}) ax.metric('contacts:nux:bannerDismissed', {})
} }
return {visible, close} return {visible, close}
@@ -28,6 +28,7 @@ import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import { import {
contactsWithPhoneNumbersOnly, contactsWithPhoneNumbersOnly,
filterMatchedNumbers, filterMatchedNumbers,
@@ -51,6 +52,7 @@ export function GetContacts({
context: 'Onboarding' | 'Standalone' context: 'Onboarding' | 'Standalone'
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const agent = useAgent() const agent = useAgent()
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const gutters = useGutters([0, 'wide']) const gutters = useGutters([0, 'wide'])
@@ -100,16 +102,16 @@ export function GetContacts({
}, },
onSuccess: (result, contacts) => { onSuccess: (result, contacts) => {
if (context === 'Onboarding') { if (context === 'Onboarding') {
logger.metric('onboarding:contacts:contactsShared', {}) ax.metric('onboarding:contacts:contactsShared', {})
} }
if (result.matches.length > 0) { if (result.matches.length > 0) {
logger.metric('contacts:import:success', { ax.metric('contacts:import:success', {
contactCount: contacts.length, contactCount: contacts.length,
matchCount: result.matches.length, matchCount: result.matches.length,
entryPoint: context, entryPoint: context,
}) })
} else { } else {
logger.metric('contacts:import:failure', { ax.metric('contacts:import:failure', {
reason: 'noValidNumbers', reason: 'noValidNumbers',
entryPoint: context, entryPoint: context,
}) })
@@ -134,7 +136,7 @@ export function GetContacts({
}) })
}, },
onError: err => { onError: err => {
logger.metric('contacts:import:failure', { ax.metric('contacts:import:failure', {
reason: isNetworkError(err) ? 'networkError' : 'unknown', reason: isNetworkError(err) ? 'networkError' : 'unknown',
entryPoint: context, entryPoint: context,
}) })
@@ -180,7 +182,7 @@ export function GetContacts({
permissions = await Contacts.requestPermissionsAsync() permissions = await Contacts.requestPermissionsAsync()
} }
logger.metric('contacts:permission:request', { ax.metric('contacts:permission:request', {
status: permissions.granted ? 'granted' : 'denied', status: permissions.granted ? 'granted' : 'denied',
accessLevelIOS: ios(permissions.accessPrivileges), accessLevelIOS: ios(permissions.accessPrivileges),
}) })
@@ -31,6 +31,7 @@ import * as Layout from '#/components/Layout'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {useGeolocation} from '#/geolocation' import {useGeolocation} from '#/geolocation'
import {isFindContactsFeatureEnabled} from '../country-allowlist' import {isFindContactsFeatureEnabled} from '../country-allowlist'
import { import {
@@ -52,6 +53,7 @@ export function PhoneInput({
onSkip: () => void onSkip: () => void
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const t = useTheme() const t = useTheme()
const agent = useAgent() const agent = useAgent()
const location = useGeolocation() const location = useGeolocation()
@@ -85,7 +87,7 @@ export function PhoneInput({
payload: {phoneCountryCode, phoneNumber}, payload: {phoneCountryCode, phoneNumber},
}) })
logger.metric('contacts:phone:phoneEntered', {entryPoint: context}) ax.metric('contacts:phone:phoneEntered', {entryPoint: context})
}, },
onMutate: () => { onMutate: () => {
Keyboard.dismiss() Keyboard.dismiss()
@@ -23,6 +23,7 @@ import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {OTPInput} from '../components/OTPInput' import {OTPInput} from '../components/OTPInput'
import {constructFullPhoneNumber, prettyPhoneNumber} from '../phone-number' import {constructFullPhoneNumber, prettyPhoneNumber} from '../phone-number'
import {type Action, type State, useOnPressBackButton} from '../state' import {type Action, type State, useOnPressBackButton} from '../state'
@@ -40,6 +41,7 @@ export function VerifyNumber({
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const agent = useAgent() const agent = useAgent()
const gutters = useGutters([0, 'wide']) const gutters = useGutters([0, 'wide'])
@@ -83,7 +85,7 @@ export function VerifyNumber({
}) })
}, 1000) }, 1000)
logger.metric('contacts:phone:phoneVerified', {entryPoint: context}) ax.metric('contacts:phone:phoneVerified', {entryPoint: context})
}, },
onMutate: () => setError(null), onMutate: () => setError(null),
onError: err => { onError: err => {
@@ -39,6 +39,7 @@ import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import {InviteInfo} from '../components/InviteInfo' import {InviteInfo} from '../components/InviteInfo'
import {type Action, type Contact, type Match, type State} from '../state' import {type Action, type Contact, type Match, type State} from '../state'
@@ -83,6 +84,7 @@ export function ViewMatches({
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const gutter = useGutters([0, 'wide']) const gutter = useGutters([0, 'wide'])
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -109,9 +111,9 @@ export function ViewMatches({
const cumulativeFollowCount = useRef(0) const cumulativeFollowCount = useRef(0)
const onFollow = useCallback(() => { const onFollow = useCallback(() => {
logger.metric('contacts:matches:follow', {entryPoint: context}) ax.metric('contacts:matches:follow', {entryPoint: context})
cumulativeFollowCount.current += 1 cumulativeFollowCount.current += 1
}, [context]) }, [ax, context])
const {mutate: followAll, isPending: isFollowingAll} = useMutation({ const {mutate: followAll, isPending: isFollowingAll} = useMutation({
mutationFn: async () => { mutationFn: async () => {
@@ -132,7 +134,7 @@ export function ViewMatches({
return followableDids return followableDids
}, },
onMutate: () => onMutate: () =>
logger.metric('contacts:matches:followAll', { ax.metric('contacts:matches:followAll', {
followCount: followableDids.length, followCount: followableDids.length,
entryPoint: context, entryPoint: context,
}), }),
@@ -218,7 +220,7 @@ export function ViewMatches({
await agent.app.bsky.contact.dismissMatch({subject: did}) await agent.app.bsky.contact.dismissMatch({subject: did})
}, },
onMutate: did => { onMutate: did => {
logger.metric('contacts:matches:dismiss', {entryPoint: context}) ax.metric('contacts:matches:dismiss', {entryPoint: context})
dispatch({type: 'DISMISS_MATCH', payload: {did}}) dispatch({type: 'DISMISS_MATCH', payload: {did}})
}, },
onSuccess: (_res, did) => { onSuccess: (_res, did) => {
@@ -392,7 +394,7 @@ export function ViewMatches({
label={context === 'Onboarding' ? _(msg`Next`) : _(msg`Done`)} label={context === 'Onboarding' ? _(msg`Next`) : _(msg`Done`)}
onPress={() => { onPress={() => {
if (context === 'Onboarding') { if (context === 'Onboarding') {
logger.metric('onboarding:contacts:nextPressed', { ax.metric('onboarding:contacts:nextPressed', {
matchCount: allMatches.length, matchCount: allMatches.length,
followCount: cumulativeFollowCount.current, followCount: cumulativeFollowCount.current,
dismissedMatchCount: state.dismissedMatches.length, dismissedMatchCount: state.dismissedMatches.length,
@@ -516,6 +518,7 @@ function ContactItem({
const gutter = useGutters([0, 'wide']) const gutter = useGutters([0, 'wide'])
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const name = contact.name ?? contact.firstName ?? contact.lastName const name = contact.name ?? contact.firstName ?? contact.lastName
@@ -564,7 +567,7 @@ function ContactItem({
color="secondary" color="secondary"
size="small" size="small"
onPress={async () => { onPress={async () => {
logger.metric('contacts:matches:invite', { ax.metric('contacts:matches:invite', {
entryPoint: context, entryPoint: context,
}) })
try { try {
+4 -3
View File
@@ -11,7 +11,6 @@ import {Image} from 'expo-image'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import { import {
type Gif, type Gif,
@@ -30,6 +29,7 @@ import {useThrottledValue} from '#/components/hooks/useThrottledValue'
import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow' import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass' import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env' import {IS_WEB} from '#/env'
export function GifSelectDialog({ export function GifSelectDialog({
@@ -280,14 +280,15 @@ export function GifPreview({
gif: Gif gif: Gif
onSelectGif: (gif: Gif) => void onSelectGif: (gif: Gif) => void
}) { }) {
const ax = useAnalytics()
const {gtTablet} = useBreakpoints() const {gtTablet} = useBreakpoints()
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const onPress = useCallback(() => { const onPress = useCallback(() => {
logEvent('composer:gif:select', {}) ax.metric('composer:gif:select', {})
onSelectGif(gif) onSelectGif(gif)
}, [onSelectGif, gif]) }, [ax, onSelectGif, gif])
return ( return (
<Button <Button
@@ -11,7 +11,6 @@ import {useQueryClient} from '@tanstack/react-query'
import {useHaptics} from '#/lib/haptics' import {useHaptics} from '#/lib/haptics'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {logger} from '#/logger'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {useMyListsQuery} from '#/state/queries/my-lists' import {useMyListsQuery} from '#/state/queries/my-lists'
import {useGetPost} from '#/state/queries/post' import {useGetPost} from '#/state/queries/post'
@@ -51,6 +50,7 @@ import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/ico
import {CloseQuote_Stroke2_Corner1_Rounded as QuoteIcon} from '#/components/icons/Quote' import {CloseQuote_Stroke2_Corner1_Rounded as QuoteIcon} from '#/components/icons/Quote'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_IOS} from '#/env' import {IS_IOS} from '#/env'
export type PostInteractionSettingsFormProps = { export type PostInteractionSettingsFormProps = {
@@ -80,8 +80,9 @@ export function PostInteractionSettingsControlledDialog({
}: PostInteractionSettingsFormProps & { }: PostInteractionSettingsFormProps & {
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
}) { }) {
const ax = useAnalytics()
const onClose = useNonReactiveCallback(() => { const onClose = useNonReactiveCallback(() => {
logger.metric('composer:threadgate:save', { ax.metric('composer:threadgate:save', {
hasChanged: !!rest.isDirty, hasChanged: !!rest.isDirty,
persist: !!rest.persist, persist: !!rest.persist,
replyOptions: replyOptions:
@@ -161,6 +162,7 @@ export function PostInteractionSettingsDialog(
export function PostInteractionSettingsDialogControlledInner( export function PostInteractionSettingsDialogControlledInner(
props: PostInteractionSettingsDialogProps, props: PostInteractionSettingsDialogProps,
) { ) {
const ax = useAnalytics()
const {_} = useLingui() const {_} = useLingui()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
@@ -229,7 +231,7 @@ export function PostInteractionSettingsDialogControlledInner(
props.control.close() props.control.close()
} catch (e: any) { } catch (e: any) {
logger.error(`Failed to save post interaction settings`, { ax.logger.error(`Failed to save post interaction settings`, {
source: 'PostInteractionSettingsDialogControlledInner', source: 'PostInteractionSettingsDialogControlledInner',
safeMessage: e.message, safeMessage: e.message,
}) })
@@ -244,6 +246,7 @@ export function PostInteractionSettingsDialogControlledInner(
} }
}, [ }, [
_, _,
ax,
props.postUri, props.postUri,
props.rootPostUri, props.rootPostUri,
props.control, props.control,
@@ -689,6 +692,7 @@ export function usePrefetchPostInteractionSettings({
postUri: string postUri: string
rootPostUri: string rootPostUri: string
}) { }) {
const ax = useAnalytics()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const agent = useAgent() const agent = useAgent()
const getPost = useGetPost() const getPost = useGetPost()
@@ -712,9 +716,9 @@ export function usePrefetchPostInteractionSettings({
}), }),
]) ])
} catch (e: any) { } catch (e: any) {
logger.error(`Failed to prefetch post interaction settings`, { ax.logger.error(`Failed to prefetch post interaction settings`, {
safeMessage: e.message, safeMessage: e.message,
}) })
} }
}, [queryClient, agent, postUri, rootPostUri, getPost]) }, [ax, queryClient, agent, postUri, rootPostUri, getPost])
} }
+5 -5
View File
@@ -1,11 +1,9 @@
import {useCallback, useImperativeHandle, useRef, useState} from 'react' import {useCallback, useImperativeHandle, useRef, useState} from 'react'
import {View} from 'react-native' import {useWindowDimensions, View} from 'react-native'
import {useWindowDimensions} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {BSKY_SERVICE} from '#/lib/constants' import {BSKY_SERVICE} from '#/lib/constants'
import {logger} from '#/logger'
import * as persisted from '#/state/persisted' import * as persisted from '#/state/persisted'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf' import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf'
@@ -17,6 +15,7 @@ import * as TextField from '#/components/forms/TextField'
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
type SegmentedControlOptions = typeof BSKY_SERVICE | 'custom' type SegmentedControlOptions = typeof BSKY_SERVICE | 'custom'
@@ -27,6 +26,7 @@ export function ServerInputDialog({
control: Dialog.DialogOuterProps['control'] control: Dialog.DialogOuterProps['control']
onSelect: (url: string) => void onSelect: (url: string) => void
}) { }) {
const ax = useAnalytics()
const {height} = useWindowDimensions() const {height} = useWindowDimensions()
const formRef = useRef<DialogInnerRef>(null) const formRef = useRef<DialogInnerRef>(null)
@@ -43,10 +43,10 @@ export function ServerInputDialog({
setPreviousCustomAddress(result) setPreviousCustomAddress(result)
} }
} }
logger.metric('signin:hostingProviderPressed', { ax.metric('signin:hostingProviderPressed', {
hostingProviderDidChange: fixedOption !== BSKY_SERVICE, hostingProviderDidChange: fixedOption !== BSKY_SERVICE,
}) })
}, [onSelect, fixedOption]) }, [ax, onSelect, fixedOption])
return ( return (
<Dialog.Outer <Dialog.Outer
+5 -4
View File
@@ -11,7 +11,6 @@ import {useQueryClient} from '@tanstack/react-query'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {type NavigationProp} from '#/lib/routes/types' import {type NavigationProp} from '#/lib/routes/types'
import {logger} from '#/logger'
import { import {
invalidateActorStarterPacksWithMembershipQuery, invalidateActorStarterPacksWithMembershipQuery,
useActorStarterPacksWithMembershipsQuery, useActorStarterPacksWithMembershipsQuery,
@@ -31,6 +30,7 @@ import {StarterPack} from '#/components/icons/StarterPack'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env' import {IS_WEB} from '#/env'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
@@ -244,8 +244,9 @@ function StarterPackItem({
starterPackWithMembership: StarterPackWithMembership starterPackWithMembership: StarterPackWithMembership
targetDid: string targetDid: string
}) { }) {
const {_} = useLingui()
const t = useTheme() const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const starterPack = starterPackWithMembership.starterPack const starterPack = starterPackWithMembership.starterPack
@@ -304,7 +305,7 @@ function StarterPackItem({
listUri: listUri, listUri: listUri,
actorDid: targetDid, actorDid: targetDid,
}) })
logger.metric('starterPack:addUser', {starterPack: starterPackUri}) ax.metric('starterPack:addUser', {starterPack: starterPackUri})
} else { } else {
if (!starterPackWithMembership.listItem?.uri) { if (!starterPackWithMembership.listItem?.uri) {
console.error('Cannot remove: missing membership URI') console.error('Cannot remove: missing membership URI')
@@ -316,7 +317,7 @@ function StarterPackItem({
actorDid: targetDid, actorDid: targetDid,
membershipUri: starterPackWithMembership.listItem.uri, membershipUri: starterPackWithMembership.listItem.uri,
}) })
logger.metric('starterPack:removeUser', {starterPack: starterPackUri}) ax.metric('starterPack:removeUser', {starterPack: starterPackUri})
} }
} }
@@ -5,7 +5,6 @@ import {LinearGradient} from 'expo-linear-gradient'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {atoms as a, useTheme, web} from '#/alf' import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import {isFindContactsFeatureEnabled} from '#/components/contacts/country-allowlist' import {isFindContactsFeatureEnabled} from '#/components/contacts/country-allowlist'
@@ -16,8 +15,8 @@ import {
isExistingUserAsOf, isExistingUserAsOf,
} from '#/components/dialogs/nuxs/utils' } from '#/components/dialogs/nuxs/utils'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {IS_NATIVE, IS_WEB} from '#/env' import {useAnalytics} from '#/analytics'
import {IS_E2E} from '#/env' import {IS_E2E, IS_NATIVE, IS_WEB} from '#/env'
import {navigate} from '#/Navigation' import {navigate} from '#/Navigation'
export const enabled = createIsEnabledCheck(props => { export const enabled = createIsEnabledCheck(props => {
@@ -35,6 +34,7 @@ export const enabled = createIsEnabledCheck(props => {
export function FindContactsAnnouncement() { export function FindContactsAnnouncement() {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const nuxDialogs = useNuxDialogContext() const nuxDialogs = useNuxDialogContext()
const control = Dialog.useDialogControl() const control = Dialog.useDialogControl()
@@ -115,7 +115,7 @@ export function FindContactsAnnouncement() {
size="large" size="large"
color="primary" color="primary"
onPress={() => { onPress={() => {
logger.metric('contacts:nux:ctaPressed', {}) ax.metric('contacts:nux:ctaPressed', {})
control.close(() => { control.close(() => {
navigate('FindContactsFlow') navigate('FindContactsFlow')
}) })
@@ -5,7 +5,6 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {urls} from '#/lib/constants' import {urls} from '#/lib/constants'
import {logger} from '#/logger'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
@@ -14,11 +13,13 @@ import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons
import {VerifierCheck} from '#/components/icons/VerifierCheck' import {VerifierCheck} from '#/components/icons/VerifierCheck'
import {Link} from '#/components/Link' import {Link} from '#/components/Link'
import {Span, Text} from '#/components/Typography' import {Span, Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env' import {IS_NATIVE} from '#/env'
export function InitialVerificationAnnouncement() { export function InitialVerificationAnnouncement() {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const nuxDialogs = useNuxDialogContext() const nuxDialogs = useNuxDialogContext()
const control = Dialog.useDialogControl() const control = Dialog.useDialogControl()
@@ -161,13 +162,9 @@ export function InitialVerificationAnnouncement() {
color="primary" color="primary"
style={[a.justify_center, a.w_full]} style={[a.justify_center, a.w_full]}
onPress={() => { onPress={() => {
logger.metric( ax.metric('verification:learn-more', {
'verification:learn-more', location: 'initialAnnouncementeNux',
{ })
location: 'initialAnnouncementeNux',
},
{statsig: false},
)
}}> }}>
<ButtonText> <ButtonText>
<Trans>Read blog post</Trans> <Trans>Read blog post</Trans>
@@ -24,7 +24,7 @@ export const enabled = createIsEnabledCheck(props => {
'2026-01-16T00:00:00.000Z', '2026-01-16T00:00:00.000Z',
props.currentProfile.createdAt, props.currentProfile.createdAt,
) && ) &&
!props.gate('disable_live_now_beta') !props.features.enabled(props.features.DisableLiveNowBeta)
) )
}) })
+4 -4
View File
@@ -8,7 +8,6 @@ import {
} from 'react' } from 'react'
import {type AppBskyActorDefs} from '@atproto/api' import {type AppBskyActorDefs} from '@atproto/api'
import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {Nux, useNuxs, useResetNuxs, useSaveNux} from '#/state/queries/nuxs' import {Nux, useNuxs, useResetNuxs, useSaveNux} from '#/state/queries/nuxs'
@@ -25,6 +24,7 @@ import {
} from '#/components/dialogs/nuxs/LiveNowBetaDialog' } from '#/components/dialogs/nuxs/LiveNowBetaDialog'
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing' import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
import {type EnabledCheckProps} from '#/components/dialogs/nuxs/utils' import {type EnabledCheckProps} from '#/components/dialogs/nuxs/utils'
import {useAnalytics} from '#/analytics'
import {useGeolocation} from '#/geolocation' import {useGeolocation} from '#/geolocation'
type Context = { type Context = {
@@ -88,7 +88,7 @@ function Inner({
currentProfile: AppBskyActorDefs.ProfileViewDetailed currentProfile: AppBskyActorDefs.ProfileViewDetailed
preferences: UsePreferencesQueryResponse preferences: UsePreferencesQueryResponse
}) { }) {
const gate = useGate() const ax = useAnalytics()
const geolocation = useGeolocation() const geolocation = useGeolocation()
const {nuxs} = useNuxs() const {nuxs} = useNuxs()
const [snoozed, setSnoozed] = useState(() => { const [snoozed, setSnoozed] = useState(() => {
@@ -133,7 +133,7 @@ function Inner({
if ( if (
enabled && enabled &&
!enabled({ !enabled({
gate, features: ax.features,
currentAccount, currentAccount,
currentProfile, currentProfile,
preferences, preferences,
@@ -165,11 +165,11 @@ function Inner({
break break
} }
}, [ }, [
ax.features,
nuxs, nuxs,
snoozed, snoozed,
snoozeNuxDialog, snoozeNuxDialog,
saveNux, saveNux,
gate,
currentAccount, currentAccount,
currentProfile, currentProfile,
preferences, preferences,
+2 -2
View File
@@ -1,12 +1,12 @@
import {type AppBskyActorDefs} from '@atproto/api' import {type AppBskyActorDefs} from '@atproto/api'
import {type useGate} from '#/lib/statsig/statsig'
import {type UsePreferencesQueryResponse} from '#/state/queries/preferences' import {type UsePreferencesQueryResponse} from '#/state/queries/preferences'
import {type SessionAccount} from '#/state/session' import {type SessionAccount} from '#/state/session'
import {type AnalyticsContextType} from '#/analytics'
import {type Geolocation} from '#/geolocation' import {type Geolocation} from '#/geolocation'
export type EnabledCheckProps = { export type EnabledCheckProps = {
gate: ReturnType<typeof useGate> features: AnalyticsContextType['features']
currentAccount: SessionAccount currentAccount: SessionAccount
currentProfile: AppBskyActorDefs.ProfileViewDetailed currentProfile: AppBskyActorDefs.ProfileViewDetailed
preferences: UsePreferencesQueryResponse preferences: UsePreferencesQueryResponse
+8 -11
View File
@@ -7,7 +7,6 @@ import {useLingui} from '@lingui/react'
import {useTranslate} from '#/lib/hooks/useTranslate' import {useTranslate} from '#/lib/hooks/useTranslate'
import {richTextToString} from '#/lib/strings/rich-text-helpers' import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {logger} from '#/logger'
import {useConvoActive} from '#/state/messages/convo' import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences' import {useLanguagePrefs} from '#/state/preferences'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
@@ -22,6 +21,7 @@ import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/War
import {ReportDialog} from '#/components/moderation/ReportDialog' import {ReportDialog} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {usePromptControl} from '#/components/Prompt' import {usePromptControl} from '#/components/Prompt'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env' import {IS_NATIVE} from '#/env'
import {EmojiReactionPicker} from './EmojiReactionPicker' import {EmojiReactionPicker} from './EmojiReactionPicker'
import {hasReachedReactionLimit} from './util' import {hasReachedReactionLimit} from './util'
@@ -34,6 +34,7 @@ export let MessageContextMenu = ({
children: TriggerProps['children'] children: TriggerProps['children']
}): React.ReactNode => { }): React.ReactNode => {
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const convo = useConvoActive() const convo = useConvoActive()
const deleteControl = usePromptControl() const deleteControl = usePromptControl()
@@ -60,16 +61,12 @@ export let MessageContextMenu = ({
const onPressTranslateMessage = useCallback(() => { const onPressTranslateMessage = useCallback(() => {
translate(message.text, langPrefs.primaryLanguage) translate(message.text, langPrefs.primaryLanguage)
logger.metric( ax.metric('translate', {
'translate', sourceLanguages: [],
{ targetLanguage: langPrefs.primaryLanguage,
sourceLanguages: [], textLength: message.text.length,
targetLanguage: langPrefs.primaryLanguage, })
textLength: message.text.length, }, [ax, langPrefs.primaryLanguage, message.text, translate])
},
{statsig: false},
)
}, [langPrefs.primaryLanguage, message.text, translate])
const onDelete = useCallback(() => { const onDelete = useCallback(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+6 -5
View File
@@ -7,7 +7,6 @@ import {useNavigation} from '@react-navigation/native'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {type NavigationProp} from '#/lib/routes/types' import {type NavigationProp} from '#/lib/routes/types'
import {logEvent} from '#/lib/statsig/statsig'
import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability' import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
@@ -15,6 +14,7 @@ import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button' import {Button, ButtonIcon} from '#/components/Button'
import {canBeMessaged} from '#/components/dms/util' import {canBeMessaged} from '#/components/dms/util'
import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message' import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message'
import {useAnalytics} from '#/analytics'
export function MessageProfileButton({ export function MessageProfileButton({
profile, profile,
@@ -23,13 +23,14 @@ export function MessageProfileButton({
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const ax = useAnalytics()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const requireEmailVerification = useRequireEmailVerification() const requireEmailVerification = useRequireEmailVerification()
const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did) const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did)
const {mutate: initiateConvo} = useGetConvoForMembers({ const {mutate: initiateConvo} = useGetConvoForMembers({
onSuccess: ({convo}) => { onSuccess: ({convo}) => {
logEvent('chat:open', {logContext: 'ProfileHeader'}) ax.metric('chat:open', {logContext: 'ProfileHeader'})
navigation.navigate('MessagesConversation', {conversation: convo.id}) navigation.navigate('MessagesConversation', {conversation: convo.id})
}, },
onError: () => { onError: () => {
@@ -43,15 +44,15 @@ export function MessageProfileButton({
} }
if (convoAvailability.convo) { if (convoAvailability.convo) {
logEvent('chat:open', {logContext: 'ProfileHeader'}) ax.metric('chat:open', {logContext: 'ProfileHeader'})
navigation.navigate('MessagesConversation', { navigation.navigate('MessagesConversation', {
conversation: convoAvailability.convo.id, conversation: convoAvailability.convo.id,
}) })
} else { } else {
logEvent('chat:create', {logContext: 'ProfileHeader'}) ax.metric('chat:create', {logContext: 'ProfileHeader'})
initiateConvo([profile.did]) initiateConvo([profile.did])
} }
}, [navigation, profile.did, initiateConvo, convoAvailability]) }, [ax, navigation, profile.did, initiateConvo, convoAvailability])
const wrappedOnPress = requireEmailVerification(onPress, { const wrappedOnPress = requireEmailVerification(onPress, {
instructions: [ instructions: [
+4 -3
View File
@@ -3,7 +3,6 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {FAB} from '#/view/com/util/fab/FAB' import {FAB} from '#/view/com/util/fab/FAB'
@@ -12,6 +11,7 @@ import {useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList' import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {useAnalytics} from '#/analytics'
export function NewChat({ export function NewChat({
control, control,
@@ -22,6 +22,7 @@ export function NewChat({
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const requireEmailVerification = useRequireEmailVerification() const requireEmailVerification = useRequireEmailVerification()
const {mutate: createChat} = useGetConvoForMembers({ const {mutate: createChat} = useGetConvoForMembers({
@@ -29,9 +30,9 @@ export function NewChat({
onNewChat(data.convo.id) onNewChat(data.convo.id)
if (!data.convo.lastMessage) { if (!data.convo.lastMessage) {
logEvent('chat:create', {logContext: 'NewChatDialog'}) ax.metric('chat:create', {logContext: 'NewChatDialog'})
} }
logEvent('chat:open', {logContext: 'NewChatDialog'}) ax.metric('chat:open', {logContext: 'NewChatDialog'})
}, },
onError: error => { onError: error => {
logger.error('Failed to create chat', {safeMessage: error}) logger.error('Failed to create chat', {safeMessage: error})
@@ -2,12 +2,12 @@ import {useCallback} from 'react'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList' import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList'
import {useAnalytics} from '#/analytics'
export function SendViaChatDialog({ export function SendViaChatDialog({
control, control,
@@ -32,14 +32,15 @@ function SendViaChatDialogInner({
onSelectChat: (chatId: string) => void onSelectChat: (chatId: string) => void
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const {mutate: createChat} = useGetConvoForMembers({ const {mutate: createChat} = useGetConvoForMembers({
onSuccess: data => { onSuccess: data => {
onSelectChat(data.convo.id) onSelectChat(data.convo.id)
if (!data.convo.lastMessage) { if (!data.convo.lastMessage) {
logEvent('chat:create', {logContext: 'SendViaChatDialog'}) ax.metric('chat:create', {logContext: 'SendViaChatDialog'})
} }
logEvent('chat:open', {logContext: 'SendViaChatDialog'}) ax.metric('chat:open', {logContext: 'SendViaChatDialog'})
}, },
onError: error => { onError: error => {
logger.error('Failed to share post to chat', {message: error}) logger.error('Failed to share post to chat', {message: error})
@@ -1,7 +1,6 @@
import {View} from 'react-native' import {View} from 'react-native'
import {AppBskyEmbedVideo} from '@atproto/api' import {AppBskyEmbedVideo} from '@atproto/api'
import {logEvent} from '#/lib/statsig/statsig'
import {type FeedPostSliceItem} from '#/state/queries/post-feed' import {type FeedPostSliceItem} from '#/state/queries/post-feed'
import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types' import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types'
import {atoms as a, useGutters} from '#/alf' import {atoms as a, useGutters} from '#/alf'
@@ -10,6 +9,7 @@ import {
VideoPostCard, VideoPostCard,
VideoPostCardPlaceholder, VideoPostCardPlaceholder,
} from '#/components/VideoPostCard' } from '#/components/VideoPostCard'
import {useAnalytics} from '#/analytics'
export function PostFeedVideoGridRow({ export function PostFeedVideoGridRow({
items: slices, items: slices,
@@ -18,6 +18,7 @@ export function PostFeedVideoGridRow({
items: FeedPostSliceItem[] items: FeedPostSliceItem[]
sourceContext: VideoFeedSourceContext sourceContext: VideoFeedSourceContext
}) { }) {
const ax = useAnalytics()
const gutters = useGutters(['base', 'base', 0, 'base']) const gutters = useGutters(['base', 'base', 0, 'base'])
const posts = slices const posts = slices
.filter(slice => AppBskyEmbedVideo.isView(slice.post.embed)) .filter(slice => AppBskyEmbedVideo.isView(slice.post.embed))
@@ -43,7 +44,7 @@ export function PostFeedVideoGridRow({
sourceContext={sourceContext} sourceContext={sourceContext}
moderation={post.moderation} moderation={post.moderation}
onInteract={() => { onInteract={() => {
logEvent('videoCard:click', {context: 'feed'}) ax.metric('videoCard:click', {context: 'feed'})
}} }}
/> />
</Grid.Col> </Grid.Col>
+3 -3
View File
@@ -2,12 +2,12 @@ import React from 'react'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {type LogEvents} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/types' import {type Shadow} from '#/state/cache/types'
import {useProfileFollowMutationQueue} from '#/state/queries/profile' import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {useRequireAuth} from '#/state/session' import {useRequireAuth} from '#/state/session'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {type Metrics} from '#/analytics/metrics'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
export function useFollowMethods({ export function useFollowMethods({
@@ -15,8 +15,8 @@ export function useFollowMethods({
logContext, logContext,
}: { }: {
profile: Shadow<bsky.profile.AnyProfileView> profile: Shadow<bsky.profile.AnyProfileView>
logContext: LogEvents['profile:follow']['logContext'] & logContext: Metrics['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext'] Metrics['profile:unfollow']['logContext']
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const requireAuth = useRequireAuth() const requireAuth = useRequireAuth()
+7 -4
View File
@@ -3,7 +3,6 @@ import {ScrollView, View} from 'react-native'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import { import {
useTrendingSettings, useTrendingSettings,
useTrendingSettingsApi, useTrendingSettingsApi,
@@ -19,6 +18,7 @@ import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Tre
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {TrendingTopicLink} from '#/components/TrendingTopics' import {TrendingTopicLink} from '#/components/TrendingTopics'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
export function TrendingInterstitial() { export function TrendingInterstitial() {
const {enabled} = useTrendingConfig() const {enabled} = useTrendingConfig()
@@ -29,6 +29,7 @@ export function TrendingInterstitial() {
export function Inner() { export function Inner() {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const gutters = useGutters([0, 'base', 0, 'base']) const gutters = useGutters([0, 'base', 0, 'base'])
const trendingPrompt = Prompt.usePromptControl() const trendingPrompt = Prompt.usePromptControl()
const {setTrendingDisabled} = useTrendingSettingsApi() const {setTrendingDisabled} = useTrendingSettingsApi()
@@ -36,9 +37,9 @@ export function Inner() {
const noTopics = !isLoading && !error && !trending?.topics?.length const noTopics = !isLoading && !error && !trending?.topics?.length
const onConfirmHide = React.useCallback(() => { const onConfirmHide = React.useCallback(() => {
logEvent('trendingTopics:hide', {context: 'interstitial'}) ax.metric('trendingTopics:hide', {context: 'interstitial'})
setTrendingDisabled(true) setTrendingDisabled(true)
}, [setTrendingDisabled]) }, [ax, setTrendingDisabled])
return error || noTopics ? null : ( return error || noTopics ? null : (
<View style={[t.atoms.border_contrast_low, a.border_t, a.border_b]}> <View style={[t.atoms.border_contrast_low, a.border_t, a.border_b]}>
@@ -94,7 +95,9 @@ export function Inner() {
key={topic.link} key={topic.link}
topic={topic} topic={topic}
onPress={() => { onPress={() => {
logEvent('trendingTopic:click', {context: 'interstitial'}) ax.metric('trendingTopic:click', {
context: 'interstitial',
})
}}> }}>
<View style={[a.py_lg]}> <View style={[a.py_lg]}>
<Text <Text
@@ -7,10 +7,8 @@ import {useQueryClient} from '@tanstack/react-query'
import {VIDEO_FEED_URI} from '#/lib/constants' import {VIDEO_FEED_URI} from '#/lib/constants'
import {makeCustomFeedLink} from '#/lib/routes/links' import {makeCustomFeedLink} from '#/lib/routes/links'
import {logEvent} from '#/lib/statsig/statsig'
import {useTrendingSettingsApi} from '#/state/preferences/trending' import {useTrendingSettingsApi} from '#/state/preferences/trending'
import {usePostFeedQuery} from '#/state/queries/post-feed' import {RQKEY, usePostFeedQuery} from '#/state/queries/post-feed'
import {RQKEY} from '#/state/queries/post-feed'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, useGutters, useTheme} from '#/alf' import {atoms as a, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button' import {Button, ButtonIcon} from '#/components/Button'
@@ -23,6 +21,7 @@ import {
CompactVideoPostCard, CompactVideoPostCard,
CompactVideoPostCardPlaceholder, CompactVideoPostCardPlaceholder,
} from '#/components/VideoPostCard' } from '#/components/VideoPostCard'
import {useAnalytics} from '#/analytics'
const CARD_WIDTH = 108 const CARD_WIDTH = 108
@@ -36,6 +35,7 @@ const FEED_PARAMS: {
export function TrendingVideos() { export function TrendingVideos() {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const gutters = useGutters([0, 'base']) const gutters = useGutters([0, 'base'])
const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS) const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS)
@@ -57,8 +57,8 @@ export function TrendingVideos() {
const onConfirmHide = useCallback(() => { const onConfirmHide = useCallback(() => {
setTrendingVideoDisabled(true) setTrendingVideoDisabled(true)
logEvent('trendingVideos:hide', {context: 'interstitial:discover'}) ax.metric('trendingVideos:hide', {context: 'interstitial:discover'})
}, [setTrendingVideoDisabled]) }, [ax, setTrendingVideoDisabled])
if (error) { if (error) {
return null return null
@@ -147,6 +147,7 @@ function VideoCards({
}: { }: {
data: Exclude<ReturnType<typeof usePostFeedQuery>['data'], undefined> data: Exclude<ReturnType<typeof usePostFeedQuery>['data'], undefined>
}) { }) {
const ax = useAnalytics()
const items = useMemo(() => { const items = useMemo(() => {
return data.pages return data.pages
.flatMap(page => page.slices) .flatMap(page => page.slices)
@@ -169,7 +170,7 @@ function VideoCards({
sourceInterstitial: 'discover', sourceInterstitial: 'discover',
}} }}
onInteract={() => { onInteract={() => {
logEvent('videoCard:click', { ax.metric('videoCard:click', {
context: 'interstitial:discover', context: 'interstitial:discover',
}) })
}} }}
+4 -11
View File
@@ -11,7 +11,6 @@ import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {type NavigationProp} from '#/lib/routes/types' import {type NavigationProp} from '#/lib/routes/types'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {toNiceDomain} from '#/lib/strings/url-helpers' import {toNiceDomain} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {unstableCacheProfileView} from '#/state/queries/profile' import {unstableCacheProfileView} from '#/state/queries/profile'
import {android, atoms as a, platform, tokens, useTheme, web} from '#/alf' import {android, atoms as a, platform, tokens, useTheme, web} from '#/alf'
@@ -22,6 +21,7 @@ import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
import {useGlobalReportDialogControl} from '#/components/moderation/ReportDialog' import {useGlobalReportDialogControl} from '#/components/moderation/ReportDialog'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import {Globe_Stroke2_Corner0_Rounded} from '../icons/Globe' import {Globe_Stroke2_Corner0_Rounded} from '../icons/Globe'
import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '../icons/SquareArrowTopRight' import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '../icons/SquareArrowTopRight'
@@ -103,6 +103,7 @@ export function LiveStatus({
padding?: 'lg' | 'xl' padding?: 'lg' | 'xl'
onPressOpenProfile: () => void onPressOpenProfile: () => void
}) { }) {
const ax = useAnalytics()
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -174,11 +175,7 @@ export function LiveStatus({
color="primary" color="primary"
variant="solid" variant="solid"
onPress={() => { onPress={() => {
logger.metric( ax.metric('live:card:watch', {subject: profile.did})
'live:card:watch',
{subject: profile.did},
{statsig: true},
)
openLink(embed.external.uri, false) openLink(embed.external.uri, false)
}}> }}>
<ButtonText> <ButtonText>
@@ -207,11 +204,7 @@ export function LiveStatus({
color="secondary" color="secondary"
variant="solid" variant="solid"
onPress={() => { onPress={() => {
logger.metric( ax.metric('live:card:openProfile', {subject: profile.did})
'live:card:openProfile',
{subject: profile.did},
{statsig: true},
)
unstableCacheProfileView(queryClient, profile) unstableCacheProfileView(queryClient, profile)
onPressOpenProfile() onPressOpenProfile()
}}> }}>
+9 -15
View File
@@ -12,13 +12,13 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {uploadBlob} from '#/lib/api' import {uploadBlob} from '#/lib/api'
import {imageToThumb} from '#/lib/api/resolve' import {imageToThumb} from '#/lib/api/resolve'
import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta' import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta'
import {logger} from '#/logger'
import {updateProfileShadow} from '#/state/cache/profile-shadow' import {updateProfileShadow} from '#/state/cache/profile-shadow'
import {useLiveNowConfig} from '#/state/service-config' import {useLiveNowConfig} from '#/state/service-config'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {useDialogContext} from '#/components/Dialog' import {useDialogContext} from '#/components/Dialog'
import {getLiveServiceNames} from '#/components/live/utils' import {getLiveServiceNames} from '#/components/live/utils'
import {useAnalytics} from '#/analytics'
export function useLiveLinkMetaQuery(url: string | null) { export function useLiveLinkMetaQuery(url: string | null) {
const liveNowConfig = useLiveNowConfig() const liveNowConfig = useLiveNowConfig()
@@ -50,6 +50,7 @@ export function useUpsertLiveStatusMutation(
linkMeta: LinkMeta | null | undefined, linkMeta: LinkMeta | null | undefined,
createdAt?: string, createdAt?: string,
) { ) {
const ax = useAnalytics()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const agent = useAgent() const agent = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -77,7 +78,7 @@ export function useUpsertLiveStatusMutation(
thumb = blob.data.blob thumb = blob.data.blob
} }
} catch (e: any) { } catch (e: any) {
logger.error(`Failed to upload thumbnail for live status`, { ax.logger.error(`Failed to upload thumbnail for live status`, {
url: linkMeta.url, url: linkMeta.url,
image: linkMeta.image, image: linkMeta.image,
safeMessage: e, safeMessage: e,
@@ -133,7 +134,7 @@ export function useUpsertLiveStatusMutation(
} }
}, },
onError: (e: any) => { onError: (e: any) => {
logger.error(`Failed to upsert live status`, { ax.logger.error(`Failed to upsert live status`, {
url: linkMeta?.url, url: linkMeta?.url,
image: linkMeta?.image, image: linkMeta?.image,
safeMessage: e, safeMessage: e,
@@ -141,17 +142,9 @@ export function useUpsertLiveStatusMutation(
}, },
onSuccess: ({record, image}) => { onSuccess: ({record, image}) => {
if (createdAt) { if (createdAt) {
logger.metric( ax.metric('live:edit', {duration: record.durationMinutes})
'live:edit',
{duration: record.durationMinutes},
{statsig: true},
)
} else { } else {
logger.metric( ax.metric('live:create', {duration: record.durationMinutes})
'live:create',
{duration: record.durationMinutes},
{statsig: true},
)
} }
Toast.show(_(msg`You are now live!`)) Toast.show(_(msg`You are now live!`))
@@ -187,6 +180,7 @@ export function useUpsertLiveStatusMutation(
} }
export function useRemoveLiveStatusMutation() { export function useRemoveLiveStatusMutation() {
const ax = useAnalytics()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const agent = useAgent() const agent = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -203,12 +197,12 @@ export function useRemoveLiveStatusMutation() {
}) })
}, },
onError: (e: any) => { onError: (e: any) => {
logger.error(`Failed to remove live status`, { ax.logger.error(`Failed to remove live status`, {
safeMessage: e, safeMessage: e,
}) })
}, },
onSuccess: () => { onSuccess: () => {
logger.metric('live:remove', {}, {statsig: true}) ax.metric('live:remove', {})
Toast.show(_(msg`You are no longer live`)) Toast.show(_(msg`You are no longer live`))
control.close(() => { control.close(() => {
if (!currentAccount) return if (!currentAccount) return
@@ -6,8 +6,8 @@ import {useLingui} from '@lingui/react'
import {wait} from '#/lib/async/wait' import {wait} from '#/lib/async/wait'
import {getLabelingServiceTitle} from '#/lib/moderation' import {getLabelingServiceTitle} from '#/lib/moderation'
import {useCallOnce} from '#/lib/once'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {Logger} from '#/logger'
import {useMyLabelersQuery} from '#/state/queries/preferences' import {useMyLabelersQuery} from '#/state/queries/preferences'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserAvatar} from '#/view/com/util/UserAvatar'
@@ -28,6 +28,7 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {createStaticClick, InlineLinkText, Link} from '#/components/Link' import {createStaticClick, InlineLinkText, Link} from '#/components/Link'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env' import {IS_NATIVE} from '#/env'
import {useSubmitReportMutation} from './action' import {useSubmitReportMutation} from './action'
import { import {
@@ -53,8 +54,6 @@ export function useGlobalReportDialogControl() {
return useGlobalDialogsControlContext().reportDialogControl return useGlobalDialogsControlContext().reportDialogControl
} }
const logger = Logger.create(Logger.Context.ReportDialog)
export function GlobalReportDialog() { export function GlobalReportDialog() {
const {value, control} = useGlobalReportDialogControl() const {value, control} = useGlobalReportDialogControl()
return <ReportDialog control={control} subject={value?.subject} /> return <ReportDialog control={control} subject={value?.subject} />
@@ -65,13 +64,14 @@ export function ReportDialog(
subject?: ReportSubject subject?: ReportSubject
}, },
) { ) {
const ax = useAnalytics()
const subject = React.useMemo( const subject = React.useMemo(
() => (props.subject ? parseReportSubject(props.subject) : undefined), () => (props.subject ? parseReportSubject(props.subject) : undefined),
[props.subject], [props.subject],
) )
const onClose = React.useCallback(() => { const onClose = React.useCallback(() => {
logger.metric('reportDialog:close', {}, {statsig: false}) ax.metric('reportDialog:close', {})
}, []) }, [ax])
return ( return (
<Dialog.Outer control={props.control} onClose={onClose}> <Dialog.Outer control={props.control} onClose={onClose}>
<Dialog.Handle /> <Dialog.Handle />
@@ -103,6 +103,8 @@ function Invalid() {
} }
function Inner(props: ReportDialogProps) { function Inner(props: ReportDialogProps) {
const ax = useAnalytics()
const logger = ax.logger.useChild(ax.logger.Context.ReportDialog)
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const ref = React.useRef<ScrollView>(null) const ref = React.useRef<ScrollView>(null)
@@ -208,15 +210,11 @@ function Inner(props: ReportDialogProps) {
}), }),
) )
setSuccess(true) setSuccess(true)
logger.metric( ax.metric('reportDialog:success', {
'reportDialog:success', reason: state.selectedOption?.reason ?? '',
{ labeler: state.selectedLabeler?.creator.handle ?? '',
reason: state.selectedOption?.reason ?? '', details: !!state.details,
labeler: state.selectedLabeler?.creator.handle ?? '', })
details: !!state.details,
},
{statsig: false},
)
// give time for user feedback // give time for user feedback
setTimeout(() => { setTimeout(() => {
props.control.close(() => { props.control.close(() => {
@@ -224,7 +222,7 @@ function Inner(props: ReportDialogProps) {
}) })
}, 1e3) }, 1e3)
} catch (e: any) { } catch (e: any) {
logger.metric('reportDialog:failure', {}, {statsig: false}) ax.metric('reportDialog:failure', {})
logger.error(e, { logger.error(e, {
source: 'ReportDialog', source: 'ReportDialog',
}) })
@@ -237,15 +235,11 @@ function Inner(props: ReportDialogProps) {
} }
}, [_, submitReport, state, dispatch, props, setPending, setSuccess]) }, [_, submitReport, state, dispatch, props, setPending, setSuccess])
React.useEffect(() => { useCallOnce(() => {
logger.metric( ax.metric('reportDialog:open', {
'reportDialog:open', subjectType: props.subject.type,
{ })
subjectType: props.subject.type, })()
},
{statsig: false},
)
}, [props.subject])
return ( return (
<Dialog.ScrollableInner <Dialog.ScrollableInner
@@ -2,7 +2,6 @@ import {View} from 'react-native'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/types' import {type Shadow} from '#/state/cache/types'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
@@ -12,6 +11,7 @@ import {type FullVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck' import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {VerificationsDialog} from '#/components/verification/VerificationsDialog' import {VerificationsDialog} from '#/components/verification/VerificationsDialog'
import {VerifierDialog} from '#/components/verification/VerifierDialog' import {VerifierDialog} from '#/components/verification/VerifierDialog'
import {useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
export function shouldShowVerificationCheckButton( export function shouldShowVerificationCheckButton(
@@ -77,6 +77,7 @@ export function Badge({
size: 'lg' | 'md' | 'sm' size: 'lg' | 'md' | 'sm'
}) { }) {
const t = useTheme() const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui() const {_} = useLingui()
const verificationsDialogControl = useDialogControl() const verificationsDialogControl = useDialogControl()
const verifierDialogControl = useDialogControl() const verifierDialogControl = useDialogControl()
@@ -101,7 +102,7 @@ export function Badge({
hitSlop={20} hitSlop={20}
onPress={evt => { onPress={evt => {
evt.preventDefault() evt.preventDefault()
logger.metric('verification:badge:click', {}, {statsig: true}) ax.metric('verification:badge:click', {})
if (state.profile.role === 'verifier') { if (state.profile.role === 'verifier') {
verifierDialogControl.open() verifierDialogControl.open()
} else { } else {
@@ -5,7 +5,6 @@ import {useLingui} from '@lingui/react'
import {urls} from '#/lib/constants' import {urls} from '#/lib/constants'
import {getUserDisplayName} from '#/lib/getUserDisplayName' import {getUserDisplayName} from '#/lib/getUserDisplayName'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
@@ -20,6 +19,7 @@ import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {type FullVerificationState} from '#/components/verification' import {type FullVerificationState} from '#/components/verification'
import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt' import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt'
import {useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
export {useDialogControl} from '#/components/Dialog' export {useDialogControl} from '#/components/Dialog'
@@ -55,6 +55,7 @@ function Inner({
verificationState: FullVerificationState verificationState: FullVerificationState
}) { }) {
const t = useTheme() const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui() const {_} = useLingui()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
@@ -158,13 +159,9 @@ function Inner({
color="secondary" color="secondary"
style={[a.justify_center]} style={[a.justify_center]}
onPress={() => { onPress={() => {
logger.metric( ax.metric('verification:learn-more', {
'verification:learn-more', location: 'verificationsDialog',
{ })
location: 'verificationsDialog',
},
{statsig: true},
)
}}> }}>
<ButtonText> <ButtonText>
<Trans context="english-only-resource">Learn more</Trans> <Trans context="english-only-resource">Learn more</Trans>
@@ -5,7 +5,6 @@ import {useLingui} from '@lingui/react'
import {urls} from '#/lib/constants' import {urls} from '#/lib/constants'
import {getUserDisplayName} from '#/lib/getUserDisplayName' import {getUserDisplayName} from '#/lib/getUserDisplayName'
import {logger} from '#/logger'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
@@ -14,6 +13,7 @@ import {VerifierCheck} from '#/components/icons/VerifierCheck'
import {Link} from '#/components/Link' import {Link} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {type FullVerificationState} from '#/components/verification' import {type FullVerificationState} from '#/components/verification'
import {useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
export {useDialogControl} from '#/components/Dialog' export {useDialogControl} from '#/components/Dialog'
@@ -49,6 +49,7 @@ function Inner({
verificationState: FullVerificationState verificationState: FullVerificationState
}) { }) {
const t = useTheme() const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui() const {_} = useLingui()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {currentAccount} = useSession() const {currentAccount} = useSession()
@@ -126,13 +127,9 @@ function Inner({
color="primary" color="primary"
style={[a.justify_center]} style={[a.justify_center]}
onPress={() => { onPress={() => {
logger.metric( ax.metric('verification:learn-more', {
'verification:learn-more', location: 'verifierDialog',
{ })
location: 'verifierDialog',
},
{statsig: true},
)
}}> }}>
<ButtonText> <ButtonText>
<Trans context="english-only-resource">Learn more</Trans> <Trans context="english-only-resource">Learn more</Trans>
+19 -1
View File
@@ -50,7 +50,7 @@ export const BUNDLE_IDENTIFIER: string =
/** /**
* This will always be in the format of YYMMDDHH, so that it always increases * This will always be in the format of YYMMDDHH, so that it always increases
* for each build. This should only be used for StatSig reporting and shouldn't * for each build. This should only be used for analytics reporting and shouldn't
* be used to identify a specific bundle. * be used to identify a specific bundle.
*/ */
export const BUNDLE_DATE: number = export const BUNDLE_DATE: number =
@@ -84,6 +84,24 @@ export const BLUESKY_PROXY_DID: Did =
export const CHAT_PROXY_DID: Did = export const CHAT_PROXY_DID: Did =
process.env.EXPO_PUBLIC_CHAT_PROXY_DID || 'did:web:api.bsky.chat' process.env.EXPO_PUBLIC_CHAT_PROXY_DID || 'did:web:api.bsky.chat'
/**
* Metrics API host
*/
export const METRICS_API_HOST: string =
process.env.EXPO_PUBLIC_METRICS_API_HOST || 'https://events.bsky.app'
/**
* Growthbook API host
*/
export const GROWTHBOOK_API_HOST: string =
process.env.EXPO_PUBLIC_GROWTHBOOK_API_HOST || `${METRICS_API_HOST}/gb`
/**
* Growthbook client key
*/
export const GROWTHBOOK_CLIENT_KEY: string =
process.env.EXPO_PUBLIC_GROWTHBOOK_CLIENT_KEY || 'sdk-7gkUkGy9wguUjyFe'
/** /**
* Sentry DSN for telemetry * Sentry DSN for telemetry
*/ */
@@ -6,11 +6,11 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {isBskyCustomFeedUrl} from '#/lib/strings/url-helpers' import {isBskyCustomFeedUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {atoms as a, utils} from '#/alf' import {atoms as a, utils} from '#/alf'
import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live' import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
import {Link} from '#/components/Link' import {Link} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import { import {
type LiveEventFeed, type LiveEventFeed,
type LiveEventFeedMetricContext, type LiveEventFeedMetricContext,
@@ -26,6 +26,7 @@ export function LiveEventFeedCardCompact({
metricContext: LiveEventFeedMetricContext metricContext: LiveEventFeedMetricContext
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const layout = feed.layouts.compact const layout = feed.layouts.compact
const overlayColor = layout.overlayColor const overlayColor = layout.overlayColor
@@ -39,7 +40,7 @@ export function LiveEventFeedCardCompact({
}, [feed.url]) }, [feed.url])
useEffect(() => { useEffect(() => {
logger.metric('liveEvents:feedBanner:seen', { ax.metric('liveEvents:feedBanner:seen', {
feed: feed.url, feed: feed.url,
context: metricContext, context: metricContext,
}) })
@@ -52,7 +53,7 @@ export function LiveEventFeedCardCompact({
label={_(msg`Live event happening now: ${feed.title}`)} label={_(msg`Live event happening now: ${feed.title}`)}
style={[a.w_full]} style={[a.w_full]}
onPress={() => { onPress={() => {
logger.metric('liveEvents:feedBanner:click', { ax.metric('liveEvents:feedBanner:click', {
feed: feed.url, feed: feed.url,
context: metricContext, context: metricContext,
}) })
@@ -1,15 +1,16 @@
import {useEffect, useMemo} from 'react' import {useMemo} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {LinearGradient} from 'expo-linear-gradient' import {LinearGradient} from 'expo-linear-gradient'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useCallOnce} from '#/lib/once'
import {isBskyCustomFeedUrl} from '#/lib/strings/url-helpers' import {isBskyCustomFeedUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {atoms as a, useBreakpoints, utils} from '#/alf' import {atoms as a, useBreakpoints, utils} from '#/alf'
import {Link} from '#/components/Link' import {Link} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import { import {
type LiveEventFeed, type LiveEventFeed,
type LiveEventFeedMetricContext, type LiveEventFeedMetricContext,
@@ -24,6 +25,7 @@ export function LiveEventFeedCardWide({
feed: LiveEventFeed feed: LiveEventFeed
metricContext: LiveEventFeedMetricContext metricContext: LiveEventFeedMetricContext
}) { }) {
const ax = useAnalytics()
const {_} = useLingui() const {_} = useLingui()
const {gtPhone} = useBreakpoints() const {gtPhone} = useBreakpoints()
@@ -38,13 +40,12 @@ export function LiveEventFeedCardWide({
return '/' return '/'
}, [feed.url]) }, [feed.url])
useEffect(() => { useCallOnce(() => {
logger.metric('liveEvents:feedBanner:seen', { ax.metric('liveEvents:feedBanner:seen', {
feed: feed.url, feed: feed.url,
context: metricContext, context: metricContext,
}) })
// eslint-disable-next-line react-hooks/exhaustive-deps })()
}, [])
return ( return (
<Link <Link
@@ -52,7 +53,7 @@ export function LiveEventFeedCardWide({
label={_(msg`Live event happening now: ${feed.title}`)} label={_(msg`Live event happening now: ${feed.title}`)}
style={[a.w_full]} style={[a.w_full]}
onPress={() => { onPress={() => {
logger.metric('liveEvents:feedBanner:click', { ax.metric('liveEvents:feedBanner:click', {
feed: feed.url, feed: feed.url,
context: metricContext, context: metricContext,
}) })
+6 -5
View File
@@ -2,12 +2,12 @@ import {useEffect} from 'react'
import {type Agent, AppBskyActorDefs, asPredicate} from '@atproto/api' import {type Agent, AppBskyActorDefs, asPredicate} from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query' import {useMutation, useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger'
import { import {
preferencesQueryKey, preferencesQueryKey,
usePreferencesQuery, usePreferencesQuery,
} from '#/state/queries/preferences' } from '#/state/queries/preferences'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env' import {IS_WEB} from '#/env'
import * as env from '#/env' import * as env from '#/env'
import { import {
@@ -63,6 +63,7 @@ export function useUpdateLiveEventPreferences(props: {
undoAction: LiveEventPreferencesAction | null undoAction: LiveEventPreferencesAction | null
}) => void }) => void
}) { }) {
const ax = useAnalytics()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const agent = useAgent() const agent = useAgent()
@@ -116,7 +117,7 @@ export function useUpdateLiveEventPreferences(props: {
case 'hideFeed': case 'hideFeed':
case 'unhideFeed': { case 'unhideFeed': {
if (!props.feed) { if (!props.feed) {
logger.error( ax.logger.error(
`useUpdateLiveEventPreferences: feed is missing, but required for hiding/unhiding`, `useUpdateLiveEventPreferences: feed is missing, but required for hiding/unhiding`,
{ {
action, action,
@@ -125,7 +126,7 @@ export function useUpdateLiveEventPreferences(props: {
break break
} }
logger.metric( ax.metric(
action.type === 'hideFeed' action.type === 'hideFeed'
? 'liveEvents:feedBanner:hide' ? 'liveEvents:feedBanner:hide'
: 'liveEvents:feedBanner:unhide', : 'liveEvents:feedBanner:unhide',
@@ -138,11 +139,11 @@ export function useUpdateLiveEventPreferences(props: {
} }
case 'toggleHideAllFeeds': { case 'toggleHideAllFeeds': {
if (prefs!.hideAllFeeds) { if (prefs!.hideAllFeeds) {
logger.metric('liveEvents:hideAllFeedBanners', { ax.metric('liveEvents:hideAllFeedBanners', {
context: props.metricContext, context: props.metricContext,
}) })
} else { } else {
logger.metric('liveEvents:unhideAllFeedBanners', { ax.metric('liveEvents:unhideAllFeedBanners', {
context: props.metricContext, context: props.metricContext,
}) })
} }
+26
View File
@@ -0,0 +1,26 @@
import {useEffect, useState} from 'react'
import {AppState, type AppStateStatus} from 'react-native'
export const getCurrentState = () => AppState.currentState
export function onAppStateChange(cb: (state: AppStateStatus) => void) {
let prev = AppState.currentState
return AppState.addEventListener('change', next => {
if (next === prev) return
prev = next
cb(next)
})
}
export function useAppState() {
const [state, setState] = useState(AppState.currentState)
useEffect(() => {
const sub = onAppStateChange(next => {
setState(next)
})
return () => sub.remove()
}, [])
return state
}
+6 -5
View File
@@ -6,11 +6,12 @@ import {logger} from '#/logger'
import {type SessionAccount, useSessionApi} from '#/state/session' import {type SessionAccount, useSessionApi} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {useAnalytics} from '#/analytics'
import {type Metrics} from '#/analytics/metrics'
import {IS_WEB} from '#/env' import {IS_WEB} from '#/env'
import {logEvent} from '../statsig/statsig'
import {type LogEvents} from '../statsig/statsig'
export function useAccountSwitcher() { export function useAccountSwitcher() {
const ax = useAnalytics()
const [pendingDid, setPendingDid] = useState<string | null>(null) const [pendingDid, setPendingDid] = useState<string | null>(null)
const {_} = useLingui() const {_} = useLingui()
const {resumeSession} = useSessionApi() const {resumeSession} = useSessionApi()
@@ -19,7 +20,7 @@ export function useAccountSwitcher() {
const onPressSwitchAccount = useCallback( const onPressSwitchAccount = useCallback(
async ( async (
account: SessionAccount, account: SessionAccount,
logContext: LogEvents['account:loggedIn']['logContext'], logContext: Metrics['account:loggedIn']['logContext'],
) => { ) => {
if (pendingDid) { if (pendingDid) {
// The session API isn't resilient to race conditions so let's just ignore this. // The session API isn't resilient to race conditions so let's just ignore this.
@@ -37,7 +38,7 @@ export function useAccountSwitcher() {
history.pushState(null, '', '/') history.pushState(null, '', '/')
} }
await resumeSession(account, true) await resumeSession(account, true)
logEvent('account:loggedIn', {logContext, withPassword: false}) ax.metric('account:loggedIn', {logContext, withPassword: false})
Toast.show(_(msg`Signed in as @${account.handle}`)) Toast.show(_(msg`Signed in as @${account.handle}`))
} else { } else {
requestSwitchToAccount({requestedAccount: account.did}) requestSwitchToAccount({requestedAccount: account.did})
@@ -59,7 +60,7 @@ export function useAccountSwitcher() {
setPendingDid(null) setPendingDid(null)
} }
}, },
[_, resumeSession, requestSwitchToAccount, pendingDid], [_, ax, resumeSession, requestSwitchToAccount, pendingDid],
) )
return {onPressSwitchAccount, pendingDid} return {onPressSwitchAccount, pendingDid}
-15
View File
@@ -1,15 +0,0 @@
import {useEffect, useState} from 'react'
import {AppState} from 'react-native'
export function useAppState() {
const [state, setState] = useState(AppState.currentState)
useEffect(() => {
const sub = AppState.addEventListener('change', nextAppState => {
setState(nextAppState)
})
return () => sub.remove()
}, [])
return state
}
-20
View File
@@ -1,20 +0,0 @@
import {useCallback} from 'react'
export enum OnceKey {
PreferencesThread = 'preferences:thread',
}
const called: Record<OnceKey, boolean> = {
[OnceKey.PreferencesThread]: false,
}
export function useCallOnce(key: OnceKey) {
return useCallback(
(cb: () => void) => {
if (called[key] === true) return
called[key] = true
cb()
},
[key],
)
}
+4 -2
View File
@@ -5,10 +5,10 @@ import * as WebBrowser from 'expo-web-browser'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {parseLinkingUrl} from '#/lib/parseLinkingUrl' import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
import {logger} from '#/logger'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useCloseAllActiveElements} from '#/state/util' import {useCloseAllActiveElements} from '#/state/util'
import {useIntentDialogs} from '#/components/intents/IntentDialogs' import {useIntentDialogs} from '#/components/intents/IntentDialogs'
import {useAnalytics} from '#/analytics'
import {IS_IOS, IS_NATIVE} from '#/env' import {IS_IOS, IS_NATIVE} from '#/env'
import {Referrer} from '../../../modules/expo-bluesky-swiss-army' import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
import {useApplyPullRequestOTAUpdate} from './useOTAUpdates' import {useApplyPullRequestOTAUpdate} from './useOTAUpdates'
@@ -22,6 +22,7 @@ let previousIntentUrl = ''
export function useIntentHandler() { export function useIntentHandler() {
const incomingUrl = Linking.useLinkingURL() const incomingUrl = Linking.useLinkingURL()
const ax = useAnalytics()
const composeIntent = useComposeIntent() const composeIntent = useComposeIntent()
const verifyEmailIntent = useVerifyEmailIntent() const verifyEmailIntent = useVerifyEmailIntent()
const {currentAccount} = useSession() const {currentAccount} = useSession()
@@ -36,7 +37,7 @@ export function useIntentHandler() {
const referrerInfo = Referrer.getReferrerInfo() const referrerInfo = Referrer.getReferrerInfo()
if (referrerInfo && referrerInfo.hostname !== 'bsky.app') { if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
logger.metric('deepLink:referrerReceived', { ax.metric('deepLink:referrerReceived', {
to: url, to: url,
referrer: referrerInfo?.referrer, referrer: referrerInfo?.referrer,
hostname: referrerInfo?.hostname, hostname: referrerInfo?.hostname,
@@ -95,6 +96,7 @@ export function useIntentHandler() {
} }
}, [ }, [
incomingUrl, incomingUrl,
ax,
composeIntent, composeIntent,
verifyEmailIntent, verifyEmailIntent,
currentAccount, currentAccount,
+3 -3
View File
@@ -1,8 +1,8 @@
import {useMemo} from 'react' import {useMemo} from 'react'
import {useGate} from '#/lib/statsig/statsig' import {useAnalytics} from '#/analytics'
export function useIsBskyTeam() { export function useIsBskyTeam() {
const gate = useGate() const ax = useAnalytics()
return useMemo(() => gate('is_bsky_team_member'), [gate]) return useMemo(() => ax.features.enabled(ax.features.IsBskyTeam), [ax])
} }
+16 -12
View File
@@ -16,6 +16,7 @@ import {truncateAndInvalidate} from '#/state/queries/util'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util' import {useCloseAllActiveElements} from '#/state/util'
import {useAnalytics} from '#/analytics'
import {IS_ANDROID, IS_IOS} from '#/env' import {IS_ANDROID, IS_IOS} from '#/env'
import {resetToTab} from '#/Navigation' import {resetToTab} from '#/Navigation'
import {router} from '#/routes' import {router} from '#/routes'
@@ -75,6 +76,8 @@ let storedAccountSwitchPayload: NotificationPayload
let lastHandledNotificationDateDedupe = 0 let lastHandledNotificationDateDedupe = 0
export function useNotificationsHandler() { export function useNotificationsHandler() {
const ax = useAnalytics()
const logger = ax.logger.useChild(ax.logger.Context.Notifications)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {currentAccount, accounts} = useSession() const {currentAccount, accounts} = useSession()
const {onPressSwitchAccount} = useAccountSwitcher() const {onPressSwitchAccount} = useAccountSwitcher()
@@ -190,7 +193,7 @@ export function useNotificationsHandler() {
if (!payload) return if (!payload) return
if (payload.reason === 'chat-message') { if (payload.reason === 'chat-message') {
notyLogger.debug(`useNotificationsHandler: handling chat message`, { logger.debug(`useNotificationsHandler: handling chat message`, {
payload, payload,
}) })
@@ -250,7 +253,7 @@ export function useNotificationsHandler() {
const [screen, params] = router.matchPath(url) const [screen, params] = router.matchPath(url)
// @ts-expect-error router is not typed :/ -sfn // @ts-expect-error router is not typed :/ -sfn
navigation.navigate('HomeTab', {screen, params}) navigation.navigate('HomeTab', {screen, params})
notyLogger.debug(`useNotificationsHandler: navigate`, { logger.debug(`useNotificationsHandler: navigate`, {
screen, screen,
params, params,
}) })
@@ -264,7 +267,7 @@ export function useNotificationsHandler() {
if (!payload) return DEFAULT_HANDLER_OPTIONS if (!payload) return DEFAULT_HANDLER_OPTIONS
notyLogger.debug('useNotificationsHandler: incoming', {e, payload}) logger.debug('useNotificationsHandler: incoming', {e, payload})
if ( if (
payload.reason === 'chat-message' && payload.reason === 'chat-message' &&
@@ -290,7 +293,7 @@ export function useNotificationsHandler() {
if (e.notification.date === lastHandledNotificationDateDedupe) return if (e.notification.date === lastHandledNotificationDateDedupe) return
lastHandledNotificationDateDedupe = e.notification.date lastHandledNotificationDateDedupe = e.notification.date
notyLogger.debug('useNotificationsHandler: response received', { logger.debug('useNotificationsHandler: response received', {
actionIdentifier: e.actionIdentifier, actionIdentifier: e.actionIdentifier,
}) })
@@ -301,15 +304,14 @@ export function useNotificationsHandler() {
const payload = getNotificationPayload(e.notification) const payload = getNotificationPayload(e.notification)
if (payload) { if (payload) {
notyLogger.debug( logger.debug(
'User pressed a notification, opening notifications tab', 'User pressed a notification, opening notifications tab',
{}, {},
) )
notyLogger.metric( ax.metric('notifications:openApp', {
'notifications:openApp', reason: payload.reason,
{reason: payload.reason, causedBoot: false}, causedBoot: false,
{statsig: false}, })
)
invalidateCachedUnreadPage() invalidateCachedUnreadPage()
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all')) truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all'))
@@ -322,7 +324,7 @@ export function useNotificationsHandler() {
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions')) truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions'))
} }
notyLogger.debug('Notifications: handleNotification', { logger.debug('Notifications: handleNotification', {
content: e.notification.request.content, content: e.notification.request.content,
payload: payload, payload: payload,
}) })
@@ -330,7 +332,7 @@ export function useNotificationsHandler() {
handleNotification(payload) handleNotification(payload)
Notifications.dismissAllNotificationsAsync() Notifications.dismissAllNotificationsAsync()
} else { } else {
notyLogger.error('useNotificationsHandler: received no payload', { logger.error('useNotificationsHandler: received no payload', {
identifier: e.notification.request.identifier, identifier: e.notification.request.identifier,
}) })
} }
@@ -350,6 +352,8 @@ export function useNotificationsHandler() {
responseReceivedListener.remove() responseReceivedListener.remove()
} }
}, [ }, [
ax,
logger,
queryClient, queryClient,
currentAccount, currentAccount,
currentConvoId, currentConvoId,
+2 -3
View File
@@ -12,8 +12,7 @@ import {
import {isNetworkError} from '#/lib/strings/errors' import {isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
import {IS_ANDROID, IS_IOS} from '#/env' import {IS_ANDROID, IS_IOS, IS_TESTFLIGHT} from '#/env'
import {IS_TESTFLIGHT} from '#/env'
const MINIMUM_MINIMIZE_TIME = 15 * 60e3 const MINIMUM_MINIMIZE_TIME = 15 * 60e3
@@ -170,7 +169,7 @@ export function useOTAUpdates() {
return return
} }
// We use this setTimeout to allow Statsig to initialize before we check for an update // We use this setTimeout to allow analytics to initialize before we check for an update
// For Testflight users, we can prompt the user to update immediately whenever there's an available update. This // For Testflight users, we can prompt the user to update immediately whenever there's an available update. This
// is suspect however with the Apple App Store guidelines, so we don't want to prompt production users to update // is suspect however with the Apple App Store guidelines, so we don't want to prompt production users to update
// immediately. // immediately.
+4 -3
View File
@@ -2,7 +2,6 @@ import {useCallback} from 'react'
import {Linking} from 'react-native' import {Linking} from 'react-native'
import * as WebBrowser from 'expo-web-browser' import * as WebBrowser from 'expo-web-browser'
import {logEvent} from '#/lib/statsig/statsig'
import { import {
createBskyAppAbsoluteUrl, createBskyAppAbsoluteUrl,
createProxiedUrl, createProxiedUrl,
@@ -16,9 +15,11 @@ import {useInAppBrowser} from '#/state/preferences/in-app-browser'
import {useTheme} from '#/alf' import {useTheme} from '#/alf'
import {useDialogContext} from '#/components/Dialog' import {useDialogContext} from '#/components/Dialog'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env' import {IS_NATIVE} from '#/env'
export function useOpenLink() { export function useOpenLink() {
const ax = useAnalytics()
const enabled = useInAppBrowser() const enabled = useInAppBrowser()
const t = useTheme() const t = useTheme()
const dialogContext = useDialogContext() const dialogContext = useDialogContext()
@@ -31,7 +32,7 @@ export function useOpenLink() {
} }
if (!isBskyAppUrl(url)) { if (!isBskyAppUrl(url)) {
logEvent('link:clicked', { ax.metric('link:clicked', {
domain: toNiceDomain(url), domain: toNiceDomain(url),
url, url,
}) })
@@ -72,7 +73,7 @@ export function useOpenLink() {
} }
Linking.openURL(url) Linking.openURL(url)
}, },
[enabled, inAppBrowserConsentControl, t, dialogContext], [ax, enabled, inAppBrowserConsentControl, t, dialogContext],
) )
return openLink return openLink
+9 -13
View File
@@ -1,8 +1,7 @@
import {useCallback, useRef} from 'react' import {useCallback, useRef} from 'react'
import {type AppBskyFeedDefs} from '@atproto/api' import {type AppBskyFeedDefs} from '@atproto/api'
import {logger} from '#/logger' import {type Metrics, useAnalytics} from '#/analytics'
import {type MetricEvents} from '#/logger/metrics'
/** /**
* Hook that returns a callback to track post:view events. * Hook that returns a callback to track post:view events.
@@ -12,8 +11,9 @@ import {type MetricEvents} from '#/logger/metrics'
* @returns A callback that accepts a post and logs the view event * @returns A callback that accepts a post and logs the view event
*/ */
export function usePostViewTracking( export function usePostViewTracking(
logContext: MetricEvents['post:view']['logContext'], logContext: Metrics['post:view']['logContext'],
) { ) {
const ax = useAnalytics()
const seenUrisRef = useRef(new Set<string>()) const seenUrisRef = useRef(new Set<string>())
const trackPostView = useCallback( const trackPostView = useCallback(
@@ -21,17 +21,13 @@ export function usePostViewTracking(
if (seenUrisRef.current.has(post.uri)) return if (seenUrisRef.current.has(post.uri)) return
seenUrisRef.current.add(post.uri) seenUrisRef.current.add(post.uri)
logger.metric( ax.metric('post:view', {
'post:view', uri: post.uri,
{ authorDid: post.author.did,
uri: post.uri, logContext,
authorDid: post.author.did, })
logContext,
},
{statsig: false},
)
}, },
[logContext], [ax, logContext],
) )
return trackPostView return trackPostView
+5 -5
View File
@@ -2,8 +2,7 @@ import {useCallback, useEffect} from 'react'
import {Platform} from 'react-native' import {Platform} from 'react-native'
import * as Notifications from 'expo-notifications' import * as Notifications from 'expo-notifications'
import {getBadgeCountAsync, setBadgeCountAsync} from 'expo-notifications' import {getBadgeCountAsync, setBadgeCountAsync} from 'expo-notifications'
import {type AtpAgent} from '@atproto/api' import {type AppBskyNotificationRegisterPush, type AtpAgent} from '@atproto/api'
import {type AppBskyNotificationRegisterPush} from '@atproto/api'
import debounce from 'lodash.debounce' import debounce from 'lodash.debounce'
import { import {
@@ -16,8 +15,8 @@ import {isNetworkError} from '#/lib/strings/errors'
import {type SessionAccount, useAgent, useSession} from '#/state/session' import {type SessionAccount, useAgent, useSession} from '#/state/session'
import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler' import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler'
import {useAgeAssurance} from '#/ageAssurance' import {useAgeAssurance} from '#/ageAssurance'
import {IS_NATIVE} from '#/env' import {useAnalytics} from '#/analytics'
import {IS_DEV} from '#/env' import {IS_DEV, IS_NATIVE} from '#/env'
/** /**
* @private * @private
@@ -227,6 +226,7 @@ export function useNotificationsRegistration() {
} }
export function useRequestNotificationsPermission() { export function useRequestNotificationsPermission() {
const ax = useAnalytics()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const getAndRegisterPushToken = useGetAndRegisterPushToken() const getAndRegisterPushToken = useGetAndRegisterPushToken()
@@ -251,7 +251,7 @@ export function useRequestNotificationsPermission() {
const res = await Notifications.requestPermissionsAsync() const res = await Notifications.requestPermissionsAsync()
notyLogger.metric(`notifications:request`, { ax.metric(`notifications:request`, {
context: context, context: context,
status: res.status, status: res.status,
}) })
+27
View File
@@ -0,0 +1,27 @@
import {useCallback, useRef} from 'react'
type Cb = () => void
export function callOnce() {
let ran = false
return function runCallbackOnce(cb: Cb) {
if (ran) return
ran = true
cb()
}
}
export function useCallOnce(cb: Cb): () => void
export function useCallOnce(cb?: undefined): (cb: Cb) => void
export function useCallOnce(cb?: Cb) {
const ran = useRef(false)
return useCallback(
(icb: Cb) => {
if (ran.current) return
ran.current = true
if (icb) icb()
else if (cb) cb()
},
[cb],
)
}
-7
View File
@@ -1,7 +0,0 @@
export type Gate =
// Keep this alphabetic please.
| 'debug_show_feedcontext'
| 'is_bsky_team_member' // special, do not remove
| 'disable_onboarding_find_contacts'
| 'disable_settings_find_contacts'
| 'disable_live_now_beta'

Some files were not shown because too many files have changed in this diff Show More