diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 90dbc4f983..050391e39a 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -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': {} } diff --git a/src/components/dialogs/nuxs/InviteFriendsAnnouncement.tsx b/src/components/dialogs/nuxs/InviteFriendsAnnouncement.tsx index 1ef8c5dbbc..5bc04c86ba 100644 --- a/src/components/dialogs/nuxs/InviteFriendsAnnouncement.tsx +++ b/src/components/dialogs/nuxs/InviteFriendsAnnouncement.tsx @@ -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 ( <> diff --git a/src/components/dialogs/nuxs/index.tsx b/src/components/dialogs/nuxs/index.tsx index 3413f19dfb..444f050406 100644 --- a/src/components/dialogs/nuxs/index.tsx +++ b/src/components/dialogs/nuxs/index.tsx @@ -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 ( diff --git a/src/features/inviteFriends/InviteFriendsDialogInner.tsx b/src/features/inviteFriends/InviteFriendsDialogInner.tsx index 1e2d43066c..244dd24204 100644 --- a/src/features/inviteFriends/InviteFriendsDialogInner.tsx +++ b/src/features/inviteFriends/InviteFriendsDialogInner.tsx @@ -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() 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 ( }> - + {profileQuery.isLoading ? ( diff --git a/src/features/inviteFriends/InviteScannerScreen.tsx b/src/features/inviteFriends/InviteScannerScreen.tsx index cd2794677f..4101f3b8b2 100644 --- a/src/features/inviteFriends/InviteScannerScreen.tsx +++ b/src/features/inviteFriends/InviteScannerScreen.tsx @@ -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() 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(() => { diff --git a/src/features/inviteFriends/components/FollowersPromoBanner.tsx b/src/features/inviteFriends/components/FollowersPromoBanner.tsx index 35020b884b..8701181599 100644 --- a/src/features/inviteFriends/components/FollowersPromoBanner.tsx +++ b/src/features/inviteFriends/components/FollowersPromoBanner.tsx @@ -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 ( {/* @@ -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}) => [ { diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index 2164c7c499..e023753515 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -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`)}> diff --git a/src/screens/Settings/FindContactsSettings.tsx b/src/screens/Settings/FindContactsSettings.tsx index cd34a3047b..57f1edde90 100644 --- a/src/screens/Settings/FindContactsSettings.tsx +++ b/src/screens/Settings/FindContactsSettings.tsx @@ -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]}> Share my profile diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index 918195cf0c..2806155a79 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -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 } /> ) : (