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:
@@ -1189,4 +1189,43 @@ export type Events = {
|
||||
totalImages: number
|
||||
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 {Image} from 'expo-image'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
@@ -9,6 +9,7 @@ import * as Dialog from '#/components/Dialog'
|
||||
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
|
||||
import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_E2E, IS_NATIVE, IS_WEB} from '#/env'
|
||||
import {createIsEnabledCheck} from './utils'
|
||||
|
||||
@@ -21,16 +22,24 @@ export const enabled = createIsEnabledCheck(() => {
|
||||
export function InviteFriendsAnnouncement() {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const nuxDialogs = useNuxDialogContext()
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
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(() => {
|
||||
nuxDialogs.dismissActiveNux()
|
||||
}, [nuxDialogs])
|
||||
|
||||
const onPressTryIt = useCallback(() => {
|
||||
ax.metric('invite:nux:tryItPressed', {})
|
||||
// Close this announcement (which dismisses + unmounts the NUX), then open
|
||||
// the invite dialog. The invite dialog is mounted persistently by NuxDialogs
|
||||
// (not here) so it survives the dismissal - the native bottom sheet cannot
|
||||
@@ -39,7 +48,7 @@ export function InviteFriendsAnnouncement() {
|
||||
control.close(() => {
|
||||
requestAnimationFrame(() => nuxDialogs.openInviteFriends())
|
||||
})
|
||||
}, [control, nuxDialogs])
|
||||
}, [ax, control, nuxDialogs])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -199,9 +199,12 @@ function Inner({
|
||||
return {
|
||||
activeNux,
|
||||
dismissActiveNux,
|
||||
openInviteFriends: () => inviteFriendsControl.open(),
|
||||
openInviteFriends: () => {
|
||||
ax.metric('invite:dialog:open', {logContext: 'NuxAnnouncement'})
|
||||
inviteFriendsControl.open()
|
||||
},
|
||||
}
|
||||
}, [activeNux, dismissActiveNux, inviteFriendsControl])
|
||||
}, [ax, activeNux, dismissActiveNux, inviteFriendsControl])
|
||||
|
||||
return (
|
||||
<Context.Provider value={ctx}>
|
||||
|
||||
@@ -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}) => [
|
||||
{
|
||||
|
||||
@@ -39,6 +39,7 @@ import * as Prompt from '#/components/Prompt'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_IOS, IS_NATIVE} from '#/env'
|
||||
import {InviteFriendsDialog} from '#/features/inviteFriends'
|
||||
import {useActorStatus} from '#/features/liveNow'
|
||||
@@ -240,6 +241,7 @@ export function HeaderStandardButtons({
|
||||
minimal?: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const playHaptic = useHaptics()
|
||||
const requireAuth = useRequireAuth()
|
||||
@@ -360,6 +362,7 @@ export function HeaderStandardButtons({
|
||||
shape="round"
|
||||
onPress={() => {
|
||||
playHaptic('Light')
|
||||
ax.metric('invite:dialog:open', {logContext: 'ProfileHeader'})
|
||||
inviteFriendsControl.open()
|
||||
}}
|
||||
label={_(msg`Invite friends`)}>
|
||||
|
||||
@@ -115,6 +115,7 @@ function Intro() {
|
||||
const gutter = useGutters(['base'])
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const inviteFriendsControl = useDialogControl()
|
||||
|
||||
const {data: isAvailable, isSuccess} = useQuery({
|
||||
@@ -171,7 +172,10 @@ function Intro() {
|
||||
label={_(msg`Share my profile`)}
|
||||
size="large"
|
||||
color="secondary"
|
||||
onPress={() => inviteFriendsControl.open()}
|
||||
onPress={() => {
|
||||
ax.metric('invite:dialog:open', {logContext: 'FindContactsSettings'})
|
||||
inviteFriendsControl.open()
|
||||
}}
|
||||
style={[a.flex_1, a.justify_center]}>
|
||||
<ButtonText>
|
||||
<Trans>Share my profile</Trans>
|
||||
|
||||
@@ -328,7 +328,12 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
|
||||
account={currentAccount}
|
||||
onPressProfile={onPressDrawerHeaderProfile}
|
||||
onPressShare={
|
||||
IS_NATIVE ? () => inviteFriendsControl.open() : undefined
|
||||
IS_NATIVE
|
||||
? () => {
|
||||
ax.metric('invite:dialog:open', {logContext: 'Drawer'})
|
||||
inviteFriendsControl.open()
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user