Add analytics events for invite friends flow

Tracks the NUX funnel, dialog opens by surface, share/copy/download/scan
actions, theme picker selection, scanner results, and the empty-followers
promo banner impressions.
This commit is contained in:
vineyardbovines
2026-06-05 13:03:13 -04:00
parent 114bf0de6a
commit b9c5ef1013
9 changed files with 108 additions and 10 deletions
@@ -20,6 +20,7 @@ import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/i
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
import {ActionButtons} from './components/ActionButtons'
import {ThemedQrCard} from './components/ThemedQrCard'
@@ -34,6 +35,7 @@ export function InviteFriendsDialogInner({
}) {
const {t: l} = useLingui()
const t = useTheme()
const ax = useAnalytics()
const navigation = useNavigation<NavigationProp>()
const {currentAccount} = useSession()
const profileQuery = useProfileQuery({did: currentAccount?.did})
@@ -58,6 +60,7 @@ export function InviteFriendsDialogInner({
Toast.show(l`Could not share - please try again`, {type: 'error'})
return
}
ax.metric('invite:action:share', {})
try {
await nativeShareUrl(canonicalShareUrl)
} catch (err) {
@@ -90,6 +93,7 @@ export function InviteFriendsDialogInner({
try {
await createAssetAsync(`file://${uri}`)
ax.metric('invite:action:download', {})
Toast.show(l`QR code saved to your camera roll`)
} catch (err) {
logger.error('InviteFriendsDialog: download failed', {safeMessage: err})
@@ -98,6 +102,7 @@ export function InviteFriendsDialogInner({
}
const onScan = () => {
ax.metric('invite:action:scan', {})
// Close dialog first, then navigate (control.close callback per CLAUDE.md
// Dialog footgun rule — prevents race with the navigation push).
control.close(() => {
@@ -112,6 +117,7 @@ export function InviteFriendsDialogInner({
}
try {
await setStringAsync(canonicalShareUrl)
ax.metric('invite:action:copy', {})
Toast.show(l`Invite link copied`)
} catch (err) {
logger.error('InviteFriendsDialog: copy failed', {safeMessage: err})
@@ -119,6 +125,11 @@ export function InviteFriendsDialogInner({
}
}
const onThemeChange = (next: InviteThemeKey) => {
ax.metric('invite:theme:change', {themeKey: next})
setThemeKey(next)
}
return (
<Dialog.ScrollableInner
label={l`Invite friends`}
@@ -140,7 +151,7 @@ export function InviteFriendsDialogInner({
</Dialog.Header>
}>
<View style={[a.align_center, a.pt_xl, a.px_xl]}>
<ThemePicker value={themeKey} onChange={setThemeKey} />
<ThemePicker value={themeKey} onChange={onThemeChange} />
<View style={[a.mt_5xl]}>
{profileQuery.isLoading ? (
@@ -16,12 +16,14 @@ import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/i
import {CircleInfo_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/CircleInfo'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
const SCAN_FRAME_SIZE = 333
export function InviteScannerScreen() {
const {t: l} = useLingui()
const t = useTheme()
const ax = useAnalytics()
const insets = useSafeAreaInsets()
const navigation = useNavigation<NavigationProp>()
const [permission, requestPermission] = useCameraPermissions()
@@ -43,16 +45,18 @@ export function InviteScannerScreen() {
/^(?:https?:\/\/)?bsky\.app\/profile\/([^/?#]+)/i,
)
if (!profileMatch) {
ax.metric('invite:scanner:scanned', {result: 'invalidQr'})
setScannerEnabled(false)
setShowError(true)
return
}
ax.metric('invite:scanner:scanned', {result: 'profileFound'})
setScannerEnabled(false)
const handle = profileMatch[1]
navigation.replace('Profile', {name: handle})
},
[scannerEnabled, navigation],
[ax, scannerEnabled, navigation],
)
const onRetry = useCallback(() => {
@@ -1,3 +1,4 @@
import {useEffect} from 'react'
import {Pressable, View} from 'react-native'
import {Image} from 'expo-image'
import {useLingui} from '@lingui/react/macro'
@@ -5,6 +6,7 @@ import {useLingui} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf'
import {TimesLarge_Stroke2_Corner0_Rounded as TimesIcon} from '#/components/icons/Times'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
export function FollowersPromoBanner({
onPress,
@@ -15,6 +17,24 @@ export function FollowersPromoBanner({
}) {
const {t: l} = useLingui()
const t = useTheme()
const ax = useAnalytics()
useEffect(() => {
ax.metric('invite:followersPromo:seen', {})
// Fire once per mount - parent unmounts the banner when followers > 0 or
// when dismissed, so each mount is a distinct impression.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handlePress = () => {
ax.metric('invite:followersPromo:press', {})
onPress()
}
const handleDismiss = () => {
ax.metric('invite:followersPromo:dismiss', {})
onDismiss()
}
return (
<View style={[a.px_lg, a.pt_md]}>
{/*
@@ -28,7 +48,7 @@ export function FollowersPromoBanner({
accessibilityRole="button"
accessibilityLabel={l`Find and invite friends`}
accessibilityHint={l`Opens the find and invite friends settings`}
onPress={onPress}
onPress={handlePress}
style={({pressed}) => [
a.flex_row,
a.align_center,
@@ -62,7 +82,7 @@ export function FollowersPromoBanner({
accessibilityRole="button"
accessibilityLabel={l`Dismiss`}
accessibilityHint={l`Hides the invite friends promo banner`}
onPress={onDismiss}
onPress={handleDismiss}
hitSlop={12}
style={({pressed}) => [
{