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
+39
View File
@@ -1189,4 +1189,43 @@ export type Events = {
totalImages: number totalImages: number
postUri: string postUri: string
} }
/*
* Invite friends (profile QR share sheet)
*/
// NUX announcement dialog was shown to the user
'invite:nux:presented': {}
// user pressed "Try it" on the NUX announcement
'invite:nux:tryItPressed': {}
// invite friends dialog opened, with the surface that triggered it
'invite:dialog:open': {
logContext:
| 'ProfileHeader'
| 'Drawer'
| 'FindContactsSettings'
| 'NuxAnnouncement'
}
// user copied the invite link to clipboard
'invite:action:copy': {}
// user invoked the native share sheet with the invite link
'invite:action:share': {}
// user saved the QR code image to their camera roll (success only)
'invite:action:download': {}
// user pressed the scan button to open the QR scanner
'invite:action:scan': {}
// user changed the QR card color theme
'invite:theme:change': {
themeKey: 'dawn' | 'day' | 'dusk' | 'night'
}
// QR scanner decoded a code; result indicates whether it resolved to a profile
'invite:scanner:scanned': {
result: 'profileFound' | 'invalidQr'
}
// empty-followers banner promoting invite/find friends was shown
'invite:followersPromo:seen': {}
// user pressed the empty-followers promo banner
'invite:followersPromo:press': {}
// user dismissed the empty-followers promo banner
'invite:followersPromo:dismiss': {}
} }
@@ -1,4 +1,4 @@
import {useCallback} from 'react' import {useCallback, useEffect} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {Trans, useLingui} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
@@ -9,6 +9,7 @@ import * as Dialog from '#/components/Dialog'
import {useNuxDialogContext} from '#/components/dialogs/nuxs' import {useNuxDialogContext} from '#/components/dialogs/nuxs'
import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle' import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_E2E, IS_NATIVE, IS_WEB} from '#/env' import {IS_E2E, IS_NATIVE, IS_WEB} from '#/env'
import {createIsEnabledCheck} from './utils' import {createIsEnabledCheck} from './utils'
@@ -21,16 +22,24 @@ export const enabled = createIsEnabledCheck(() => {
export function InviteFriendsAnnouncement() { export function InviteFriendsAnnouncement() {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const ax = useAnalytics()
const nuxDialogs = useNuxDialogContext() const nuxDialogs = useNuxDialogContext()
const control = Dialog.useDialogControl() const control = Dialog.useDialogControl()
Dialog.useAutoOpen(control) Dialog.useAutoOpen(control)
useEffect(() => {
ax.metric('invite:nux:presented', {})
// Fire once on mount - the NUX has a single lifecycle per session.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const onClose = useCallback(() => { const onClose = useCallback(() => {
nuxDialogs.dismissActiveNux() nuxDialogs.dismissActiveNux()
}, [nuxDialogs]) }, [nuxDialogs])
const onPressTryIt = useCallback(() => { const onPressTryIt = useCallback(() => {
ax.metric('invite:nux:tryItPressed', {})
// Close this announcement (which dismisses + unmounts the NUX), then open // Close this announcement (which dismisses + unmounts the NUX), then open
// the invite dialog. The invite dialog is mounted persistently by NuxDialogs // the invite dialog. The invite dialog is mounted persistently by NuxDialogs
// (not here) so it survives the dismissal - the native bottom sheet cannot // (not here) so it survives the dismissal - the native bottom sheet cannot
@@ -39,7 +48,7 @@ export function InviteFriendsAnnouncement() {
control.close(() => { control.close(() => {
requestAnimationFrame(() => nuxDialogs.openInviteFriends()) requestAnimationFrame(() => nuxDialogs.openInviteFriends())
}) })
}, [control, nuxDialogs]) }, [ax, control, nuxDialogs])
return ( return (
<> <>
+5 -2
View File
@@ -199,9 +199,12 @@ function Inner({
return { return {
activeNux, activeNux,
dismissActiveNux, dismissActiveNux,
openInviteFriends: () => inviteFriendsControl.open(), openInviteFriends: () => {
ax.metric('invite:dialog:open', {logContext: 'NuxAnnouncement'})
inviteFriendsControl.open()
},
} }
}, [activeNux, dismissActiveNux, inviteFriendsControl]) }, [ax, activeNux, dismissActiveNux, inviteFriendsControl])
return ( return (
<Context.Provider value={ctx}> <Context.Provider value={ctx}>
@@ -20,6 +20,7 @@ import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/i
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 {IS_NATIVE} from '#/env' import {IS_NATIVE} from '#/env'
import {ActionButtons} from './components/ActionButtons' import {ActionButtons} from './components/ActionButtons'
import {ThemedQrCard} from './components/ThemedQrCard' import {ThemedQrCard} from './components/ThemedQrCard'
@@ -34,6 +35,7 @@ export function InviteFriendsDialogInner({
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
const t = useTheme() const t = useTheme()
const ax = useAnalytics()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const profileQuery = useProfileQuery({did: currentAccount?.did}) const profileQuery = useProfileQuery({did: currentAccount?.did})
@@ -58,6 +60,7 @@ export function InviteFriendsDialogInner({
Toast.show(l`Could not share - please try again`, {type: 'error'}) Toast.show(l`Could not share - please try again`, {type: 'error'})
return return
} }
ax.metric('invite:action:share', {})
try { try {
await nativeShareUrl(canonicalShareUrl) await nativeShareUrl(canonicalShareUrl)
} catch (err) { } catch (err) {
@@ -90,6 +93,7 @@ export function InviteFriendsDialogInner({
try { try {
await createAssetAsync(`file://${uri}`) await createAssetAsync(`file://${uri}`)
ax.metric('invite:action:download', {})
Toast.show(l`QR code saved to your camera roll`) Toast.show(l`QR code saved to your camera roll`)
} catch (err) { } catch (err) {
logger.error('InviteFriendsDialog: download failed', {safeMessage: err}) logger.error('InviteFriendsDialog: download failed', {safeMessage: err})
@@ -98,6 +102,7 @@ export function InviteFriendsDialogInner({
} }
const onScan = () => { const onScan = () => {
ax.metric('invite:action:scan', {})
// Close dialog first, then navigate (control.close callback per CLAUDE.md // Close dialog first, then navigate (control.close callback per CLAUDE.md
// Dialog footgun rule — prevents race with the navigation push). // Dialog footgun rule — prevents race with the navigation push).
control.close(() => { control.close(() => {
@@ -112,6 +117,7 @@ export function InviteFriendsDialogInner({
} }
try { try {
await setStringAsync(canonicalShareUrl) await setStringAsync(canonicalShareUrl)
ax.metric('invite:action:copy', {})
Toast.show(l`Invite link copied`) Toast.show(l`Invite link copied`)
} catch (err) { } catch (err) {
logger.error('InviteFriendsDialog: copy failed', {safeMessage: 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 ( return (
<Dialog.ScrollableInner <Dialog.ScrollableInner
label={l`Invite friends`} label={l`Invite friends`}
@@ -140,7 +151,7 @@ export function InviteFriendsDialogInner({
</Dialog.Header> </Dialog.Header>
}> }>
<View style={[a.align_center, a.pt_xl, a.px_xl]}> <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]}> <View style={[a.mt_5xl]}>
{profileQuery.isLoading ? ( {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 {CircleInfo_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/CircleInfo'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
const SCAN_FRAME_SIZE = 333 const SCAN_FRAME_SIZE = 333
export function InviteScannerScreen() { export function InviteScannerScreen() {
const {t: l} = useLingui() const {t: l} = useLingui()
const t = useTheme() const t = useTheme()
const ax = useAnalytics()
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const [permission, requestPermission] = useCameraPermissions() const [permission, requestPermission] = useCameraPermissions()
@@ -43,16 +45,18 @@ export function InviteScannerScreen() {
/^(?:https?:\/\/)?bsky\.app\/profile\/([^/?#]+)/i, /^(?:https?:\/\/)?bsky\.app\/profile\/([^/?#]+)/i,
) )
if (!profileMatch) { if (!profileMatch) {
ax.metric('invite:scanner:scanned', {result: 'invalidQr'})
setScannerEnabled(false) setScannerEnabled(false)
setShowError(true) setShowError(true)
return return
} }
ax.metric('invite:scanner:scanned', {result: 'profileFound'})
setScannerEnabled(false) setScannerEnabled(false)
const handle = profileMatch[1] const handle = profileMatch[1]
navigation.replace('Profile', {name: handle}) navigation.replace('Profile', {name: handle})
}, },
[scannerEnabled, navigation], [ax, scannerEnabled, navigation],
) )
const onRetry = useCallback(() => { const onRetry = useCallback(() => {
@@ -1,3 +1,4 @@
import {useEffect} from 'react'
import {Pressable, View} from 'react-native' import {Pressable, View} from 'react-native'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
@@ -5,6 +6,7 @@ import {useLingui} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {TimesLarge_Stroke2_Corner0_Rounded as TimesIcon} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as TimesIcon} from '#/components/icons/Times'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
export function FollowersPromoBanner({ export function FollowersPromoBanner({
onPress, onPress,
@@ -15,6 +17,24 @@ export function FollowersPromoBanner({
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
const t = useTheme() 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 ( return (
<View style={[a.px_lg, a.pt_md]}> <View style={[a.px_lg, a.pt_md]}>
{/* {/*
@@ -28,7 +48,7 @@ export function FollowersPromoBanner({
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={l`Find and invite friends`} accessibilityLabel={l`Find and invite friends`}
accessibilityHint={l`Opens the find and invite friends settings`} accessibilityHint={l`Opens the find and invite friends settings`}
onPress={onPress} onPress={handlePress}
style={({pressed}) => [ style={({pressed}) => [
a.flex_row, a.flex_row,
a.align_center, a.align_center,
@@ -62,7 +82,7 @@ export function FollowersPromoBanner({
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={l`Dismiss`} accessibilityLabel={l`Dismiss`}
accessibilityHint={l`Hides the invite friends promo banner`} accessibilityHint={l`Hides the invite friends promo banner`}
onPress={onDismiss} onPress={handleDismiss}
hitSlop={12} hitSlop={12}
style={({pressed}) => [ style={({pressed}) => [
{ {
@@ -39,6 +39,7 @@ import * as Prompt from '#/components/Prompt'
import {RichText} from '#/components/RichText' import {RichText} from '#/components/RichText'
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_IOS, IS_NATIVE} from '#/env' import {IS_IOS, IS_NATIVE} from '#/env'
import {InviteFriendsDialog} from '#/features/inviteFriends' import {InviteFriendsDialog} from '#/features/inviteFriends'
import {useActorStatus} from '#/features/liveNow' import {useActorStatus} from '#/features/liveNow'
@@ -240,6 +241,7 @@ export function HeaderStandardButtons({
minimal?: boolean minimal?: boolean
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const {hasSession, currentAccount} = useSession() const {hasSession, currentAccount} = useSession()
const playHaptic = useHaptics() const playHaptic = useHaptics()
const requireAuth = useRequireAuth() const requireAuth = useRequireAuth()
@@ -360,6 +362,7 @@ export function HeaderStandardButtons({
shape="round" shape="round"
onPress={() => { onPress={() => {
playHaptic('Light') playHaptic('Light')
ax.metric('invite:dialog:open', {logContext: 'ProfileHeader'})
inviteFriendsControl.open() inviteFriendsControl.open()
}} }}
label={_(msg`Invite friends`)}> label={_(msg`Invite friends`)}>
@@ -115,6 +115,7 @@ function Intro() {
const gutter = useGutters(['base']) const gutter = useGutters(['base'])
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const inviteFriendsControl = useDialogControl() const inviteFriendsControl = useDialogControl()
const {data: isAvailable, isSuccess} = useQuery({ const {data: isAvailable, isSuccess} = useQuery({
@@ -171,7 +172,10 @@ function Intro() {
label={_(msg`Share my profile`)} label={_(msg`Share my profile`)}
size="large" size="large"
color="secondary" color="secondary"
onPress={() => inviteFriendsControl.open()} onPress={() => {
ax.metric('invite:dialog:open', {logContext: 'FindContactsSettings'})
inviteFriendsControl.open()
}}
style={[a.flex_1, a.justify_center]}> style={[a.flex_1, a.justify_center]}>
<ButtonText> <ButtonText>
<Trans>Share my profile</Trans> <Trans>Share my profile</Trans>
+6 -1
View File
@@ -328,7 +328,12 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
account={currentAccount} account={currentAccount}
onPressProfile={onPressDrawerHeaderProfile} onPressProfile={onPressDrawerHeaderProfile}
onPressShare={ onPressShare={
IS_NATIVE ? () => inviteFriendsControl.open() : undefined IS_NATIVE
? () => {
ax.metric('invite:dialog:open', {logContext: 'Drawer'})
inviteFriendsControl.open()
}
: undefined
} }
/> />
) : ( ) : (