diff --git a/suite-native/analytics/src/constants.ts b/suite-native/analytics/src/constants.ts index 7f570e5b1a7..952442a1f1f 100644 --- a/suite-native/analytics/src/constants.ts +++ b/suite-native/analytics/src/constants.ts @@ -42,4 +42,10 @@ export enum EventType { PassphraseAddHiddenWallet = 'passphrase/add_hidden_wallet', PassphraseExit = 'passphrase/exit', CoinEnablingInitState = 'coin-enabling/init_state', + SendAddressFilled = 'send/address_filled', + SendAmountInputSwitched = 'send/amount_input_switched', + SendRecipientCountChanged = 'send/recipient_count_changed', + SendFeeLevelChanged = 'send/fee_level_changed', + SendTransactionDispatched = 'send/transaction_dispatched', + SendFlowExited = 'send/flow_exited', } diff --git a/suite-native/analytics/src/events.ts b/suite-native/analytics/src/events.ts index 04a4c2be5be..12aa8c0cf1f 100644 --- a/suite-native/analytics/src/events.ts +++ b/suite-native/analytics/src/events.ts @@ -1,10 +1,11 @@ import { FiatCurrencyCode } from '@suite-common/suite-config'; import { UNIT_ABBREVIATION } from '@suite-common/suite-constants'; import { AccountType, NetworkSymbol } from '@suite-common/wallet-config'; -import { TokenAddress, TokenSymbol } from '@suite-common/wallet-types'; +import { FeeLevelLabel, TokenAddress, TokenSymbol } from '@suite-common/wallet-types'; import { DeviceModelInternal, VersionArray } from '@trezor/connect'; import { EventType } from './constants'; +import { AnalyticsSendFlowStep } from './types'; export type SuiteNativeAnalyticsEvent = | { @@ -258,4 +259,48 @@ export type SuiteNativeAnalyticsEvent = payload: { enabledNetworks: NetworkSymbol[]; }; + } + | { + type: EventType.SendTransactionDispatched; + payload: { + symbol: NetworkSymbol; + outputsCount: number; + selectedFee: FeeLevelLabel; + tokenSymbols?: TokenSymbol[]; + tokenAddresses?: TokenAddress[]; + hasEthereumData?: boolean; + hasEthereumNonce?: boolean; + hasRippleDestinationTag?: boolean; + hasBitcoinLocktime?: boolean; + }; + } + | { + type: EventType.SendAddressFilled; + payload: { + method: 'manual' | 'qr'; + }; + } + | { + type: EventType.SendFeeLevelChanged; + payload: { + value: FeeLevelLabel; + }; + } + | { + type: EventType.SendRecipientCountChanged; + payload: { + count: number; + }; + } + | { + type: EventType.SendAmountInputSwitched; + payload: { + changedTo: 'crypto' | 'fiat'; + }; + } + | { + type: EventType.SendFlowExited; + payload: { + step: AnalyticsSendFlowStep; + }; }; diff --git a/suite-native/analytics/src/index.ts b/suite-native/analytics/src/index.ts index d3e4bdcf283..acf2642be54 100644 --- a/suite-native/analytics/src/index.ts +++ b/suite-native/analytics/src/index.ts @@ -1,3 +1,4 @@ export * from './analytics'; export * from './analyticsThunks'; export * from './constants'; +export * from './types'; diff --git a/suite-native/analytics/src/types.ts b/suite-native/analytics/src/types.ts new file mode 100644 index 00000000000..cc80546a595 --- /dev/null +++ b/suite-native/analytics/src/types.ts @@ -0,0 +1,5 @@ +export type AnalyticsSendFlowStep = + | 'address_and_amount' + | 'fee_settings' + | 'address_review' + | 'outputs_review'; diff --git a/suite-native/forms/src/fields/TextInputField.tsx b/suite-native/forms/src/fields/TextInputField.tsx index 1400ed41e44..b628ead73b2 100644 --- a/suite-native/forms/src/fields/TextInputField.tsx +++ b/suite-native/forms/src/fields/TextInputField.tsx @@ -28,7 +28,10 @@ export type FieldProps = AllowedTextInputFieldProps & >; export const TextInputField = forwardRef( - ({ name, hint, onBlur, defaultValue = '', valueTransformer, ...otherProps }, ref) => { + ( + { name, hint, onBlur, defaultValue = '', valueTransformer, onChangeText, ...otherProps }, + ref, + ) => { const field = useField({ name, defaultValue, @@ -45,12 +48,19 @@ export const TextInputField = forwardRef( } }; + const handleOnChange = (text: string) => { + onChange(text); + if (onChangeText) { + onChangeText(text); + } + }; + return ( { +export const AddressInput = ({ index, accountKey }: AddressInputProps) => { const addressFieldName = getOutputFieldName(index, 'address'); const { setValue } = useFormContext(); + const networkSymbol = useSelector((state: AccountsRootState) => + selectAccountNetworkSymbol(state, accountKey), + ); + const handleScanAddressQRCode = (qrCodeData: string) => { - setValue(addressFieldName, qrCodeData); + setValue(addressFieldName, qrCodeData, { shouldValidate: true }); + if (networkSymbol && isAddressValid(qrCodeData, networkSymbol)) { + analytics.report({ type: EventType.SendAddressFilled, payload: { method: 'qr' } }); + } + }; + + const handleChangeValue = (newValue: string) => { + if (networkSymbol && isAddressValid(newValue, networkSymbol)) { + analytics.report({ type: EventType.SendAddressFilled, payload: { method: 'manual' } }); + } }; return ( @@ -28,6 +47,7 @@ export const AddressInput = ({ index }: AddressInputProps) => { multiline name={addressFieldName} testID={addressFieldName} + onChangeText={handleChangeValue} maxLength={formInputsMaxLength.address} accessibilityLabel="address input" rightIcon={} diff --git a/suite-native/module-send/src/components/AmountInputs.tsx b/suite-native/module-send/src/components/AmountInputs.tsx index 3f0fd30e656..477853c50f7 100644 --- a/suite-native/module-send/src/components/AmountInputs.tsx +++ b/suite-native/module-send/src/components/AmountInputs.tsx @@ -12,6 +12,7 @@ import { } from '@suite-common/wallet-core'; import { Translation } from '@suite-native/intl'; import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; +import { analytics, EventType } from '@suite-native/analytics'; import { CryptoAmountInput } from './CryptoAmountInput'; import { FiatAmountInput } from './FiatAmountInput'; @@ -80,6 +81,11 @@ export const AmountInputs = ({ index, accountKey }: AmountInputProps) => { setTimeout(() => cryptoRef.current?.focus(), ANIMATION_DURATION); } + analytics.report({ + type: EventType.SendAmountInputSwitched, + payload: { changedTo: isCryptoSelected ? 'fiat' : 'crypto' }, + }); + setIsCryptoSelected(!isCryptoSelected); }; diff --git a/suite-native/module-send/src/components/FeeOption.tsx b/suite-native/module-send/src/components/FeeOption.tsx index 67dbf0e8d7e..f9689465330 100644 --- a/suite-native/module-send/src/components/FeeOption.tsx +++ b/suite-native/module-send/src/components/FeeOption.tsx @@ -1,6 +1,6 @@ import { useContext } from 'react'; import { Pressable } from 'react-native'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import Animated, { interpolateColor, useAnimatedStyle, @@ -9,7 +9,7 @@ import Animated, { } from 'react-native-reanimated'; import { getNetworkType, NetworkSymbol } from '@suite-common/wallet-config'; -import { GeneralPrecomposedTransactionFinal } from '@suite-common/wallet-types'; +import { AccountKey, GeneralPrecomposedTransactionFinal } from '@suite-common/wallet-types'; import { Text, HStack, VStack, Radio, Box } from '@suite-native/atoms'; import { CryptoToFiatAmountFormatter, CryptoAmountFormatter } from '@suite-native/formatters'; import { FormContext } from '@suite-native/forms'; @@ -22,10 +22,12 @@ import { import { Color } from '@trezor/theme'; import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; import { getFeeUnits } from '@suite-common/wallet-utils'; +import { analytics, EventType } from '@suite-native/analytics'; import { SendFeesFormValues } from '../sendFeesFormSchema'; import { NativeSupportedFeeLevel } from '../types'; import { FeeOptionErrorMessage } from './FeeOptionErrorMessage'; +import { updateDraftFeeLevelThunk } from '../sendFormThunks'; const feeLabelsMap = { economy: 'moduleSend.fees.levels.low', @@ -45,20 +47,25 @@ const valuesWrapperStyle = prepareNativeStyle(utils => ({ padding: utils.spacings.medium, })); +type FeeOptionProps = { + feeKey: SendFeesFormValues['feeLevel']; + feeLevel: GeneralPrecomposedTransactionFinal; + networkSymbol: NetworkSymbol; + transactionBytes: number; + accountKey: AccountKey; +}; + export const FeeOption = ({ feeKey, feeLevel, networkSymbol, transactionBytes, -}: { - feeKey: SendFeesFormValues['feeLevel']; - feeLevel: GeneralPrecomposedTransactionFinal; - networkSymbol: NetworkSymbol; - transactionBytes: number; -}) => { + accountKey, +}: FeeOptionProps) => { const { utils } = useNativeStyles(); const { applyStyle } = useNativeStyles(); const { watch, setValue } = useContext(FormContext); + const dispatch = useDispatch(); const feeTimeEstimate = useSelector((state: FeesRootState) => selectNetworkFeeLevelTimeEstimate(state, feeKey, networkSymbol), @@ -74,6 +81,10 @@ export const FeeOption = ({ setValue('feeLevel', feeKey, { shouldValidate: true, }); + + analytics.report({ type: EventType.SendFeeLevelChanged, payload: { value: feeKey } }); + + dispatch(updateDraftFeeLevelThunk({ accountKey, feeLevel: feeKey })); }; const selectedLevel = watch('feeLevel'); diff --git a/suite-native/module-send/src/components/FeeOptionsList.tsx b/suite-native/module-send/src/components/FeeOptionsList.tsx index ba82803ecc7..29adb918464 100644 --- a/suite-native/module-send/src/components/FeeOptionsList.tsx +++ b/suite-native/module-send/src/components/FeeOptionsList.tsx @@ -1,19 +1,23 @@ import { D, pipe } from '@mobily/ts-belt'; import { NetworkSymbol } from '@suite-common/wallet-config'; -import { GeneralPrecomposedLevels, PrecomposedTransactionFinal } from '@suite-common/wallet-types'; +import { + AccountKey, + GeneralPrecomposedLevels, + PrecomposedTransactionFinal, +} from '@suite-common/wallet-types'; import { VStack } from '@suite-native/atoms'; import { FeeOption } from './FeeOption'; import { NativeSupportedFeeLevel } from '../types'; -export const FeeOptionsList = ({ - feeLevels, - networkSymbol, -}: { +type FeeOptionsListProps = { feeLevels: GeneralPrecomposedLevels; networkSymbol: NetworkSymbol; -}) => { + accountKey: AccountKey; +}; + +export const FeeOptionsList = ({ feeLevels, networkSymbol, accountKey }: FeeOptionsListProps) => { // Remove custom fee level from the list. It is not supported in the first version of the send flow. const predefinedFeeLevels = pipe( feeLevels, @@ -31,6 +35,7 @@ export const FeeOptionsList = ({ key={feeKey} feeKey={feeKey as NativeSupportedFeeLevel} feeLevel={feeLevel} + accountKey={accountKey} networkSymbol={networkSymbol} transactionBytes={transactionBytes} /> diff --git a/suite-native/module-send/src/components/OutputsReviewFooter.tsx b/suite-native/module-send/src/components/OutputsReviewFooter.tsx index d134e7f5c52..760e40ba5be 100644 --- a/suite-native/module-send/src/components/OutputsReviewFooter.tsx +++ b/suite-native/module-send/src/components/OutputsReviewFooter.tsx @@ -8,13 +8,16 @@ import { isFulfilled } from '@reduxjs/toolkit'; import { AccountsRootState, selectAccountByKey, + selectSendFormDraftByAccountKey, selectSendSignedTx, + SendRootState, } from '@suite-common/wallet-core'; import { AccountKey } from '@suite-common/wallet-types'; import { Button } from '@suite-native/atoms'; import { RootStackRoutes, AppTabsRoutes, RootStackParamList } from '@suite-native/navigation'; import { Translation } from '@suite-native/intl'; import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; +import { analytics, EventType } from '@suite-native/analytics'; import { SendConfirmOnDeviceImage } from '../components/SendConfirmOnDeviceImage'; import { sendTransactionAndCleanupSendFormThunk } from '../sendFormThunks'; @@ -67,6 +70,10 @@ export const OutputsReviewFooter = ({ accountKey }: { accountKey: AccountKey }) ); const signedTransaction = useSelector(selectSendSignedTx); + const formValues = useSelector((state: SendRootState) => + selectSendFormDraftByAccountKey(state, accountKey), + ); + { /* TODO: improve the illustration: https://github.com/trezor/trezor-suite/issues/13965 */ } @@ -74,10 +81,23 @@ export const OutputsReviewFooter = ({ accountKey }: { accountKey: AccountKey }) const handleSendTransaction = async () => { setIsSendInProgress(true); + const sendResponse = await dispatch(sendTransactionAndCleanupSendFormThunk({ account })); if (isFulfilled(sendResponse)) { const { txid } = sendResponse.payload; + + if (formValues) { + analytics.report({ + type: EventType.SendTransactionDispatched, + payload: { + symbol: account.symbol, + outputsCount: formValues.outputs.length, + selectedFee: formValues.selectedFee ?? 'normal', + }, + }); + } + navigation.dispatch( navigateToAccountDetail({ accountKey, txid, closeActionType: 'close' }), ); diff --git a/suite-native/module-send/src/components/RecipientInputs.tsx b/suite-native/module-send/src/components/RecipientInputs.tsx index ec2cb95f390..a6b5ba3fedd 100644 --- a/suite-native/module-send/src/components/RecipientInputs.tsx +++ b/suite-native/module-send/src/components/RecipientInputs.tsx @@ -13,7 +13,7 @@ type RecipientInputsProps = { export const RecipientInputs = ({ index, accountKey }: RecipientInputsProps) => { return ( - + diff --git a/suite-native/module-send/src/components/SendFeesForm.tsx b/suite-native/module-send/src/components/SendFeesForm.tsx index 49a0e54565c..67e3dc1b03b 100644 --- a/suite-native/module-send/src/components/SendFeesForm.tsx +++ b/suite-native/module-send/src/components/SendFeesForm.tsx @@ -88,7 +88,11 @@ export const SendFeesForm = ({ accountKey, feeLevels }: SendFormProps) => { - + { + const draft = selectSendFormDraftByAccountKey(getState(), accountKey); + + if (!draft) throw Error('Draft not found.'); + const draftCopy = { ...draft }; + + draftCopy.selectedFee = feeLevel; + + dispatch(sendFormActions.storeDraft({ accountKey, formState: draftCopy })); + }, +); diff --git a/suite-native/module-send/tsconfig.json b/suite-native/module-send/tsconfig.json index cbb5f04991e..453c171bb66 100644 --- a/suite-native/module-send/tsconfig.json +++ b/suite-native/module-send/tsconfig.json @@ -31,6 +31,7 @@ }, { "path": "../accounts" }, { "path": "../alerts" }, + { "path": "../analytics" }, { "path": "../atoms" }, { "path": "../device" }, { "path": "../device-mutex" }, diff --git a/suite-native/navigation/package.json b/suite-native/navigation/package.json index bee54834519..91c6b18c24a 100644 --- a/suite-native/navigation/package.json +++ b/suite-native/navigation/package.json @@ -12,6 +12,7 @@ "type-check": "yarn g:tsc --build" }, "dependencies": { + "@mobily/ts-belt": "^3.13.1", "@react-navigation/bottom-tabs": "6.6.1", "@react-navigation/native": "6.1.18", "@react-navigation/native-stack": "6.11.0", diff --git a/suite-native/navigation/src/components/NavigationContainerWithAnalytics.tsx b/suite-native/navigation/src/components/NavigationContainerWithAnalytics.tsx index 14c9fd0fdf2..5cb8aa5980f 100644 --- a/suite-native/navigation/src/components/NavigationContainerWithAnalytics.tsx +++ b/suite-native/navigation/src/components/NavigationContainerWithAnalytics.tsx @@ -11,12 +11,15 @@ import { import { analytics, EventType } from '@suite-native/analytics'; import { useNativeStyles } from '@trezor/styles'; +import { useReportSendFlowExitToAnalytics } from '../useReportSendFlowExitToAnalytics'; + export const NavigationContainerWithAnalytics = ({ children }: { children: ReactNode }) => { const navigationContainerRef = useNavigationContainerRef(); const routeNameRef = useRef(); const { utils: { colors, isDarkColor }, } = useNativeStyles(); + const reportSendFlowExitToAnalytics = useReportSendFlowExitToAnalytics(); const themeColors = useMemo(() => { // setting theme colors to match the background color of the screen to prevent white flash on screen change in dark mode @@ -48,6 +51,9 @@ export const NavigationContainerWithAnalytics = ({ children }: { children: React const previousRouteName = routeNameRef.current; const currentRouteName = navigationContainerRef.getCurrentRoute()?.name; + // If the user abandons the send flow, this function reports from which step. + reportSendFlowExitToAnalytics(currentRouteName); + if (previousRouteName !== currentRouteName) { // Save the current route name for later comparison routeNameRef.current = currentRouteName; diff --git a/suite-native/navigation/src/useReportSendFlowExitToAnalytics.ts b/suite-native/navigation/src/useReportSendFlowExitToAnalytics.ts new file mode 100644 index 00000000000..5a98dded75c --- /dev/null +++ b/suite-native/navigation/src/useReportSendFlowExitToAnalytics.ts @@ -0,0 +1,76 @@ +import { useCallback, useState } from 'react'; + +import { G } from '@mobily/ts-belt'; + +import { analytics, AnalyticsSendFlowStep, EventType } from '@suite-native/analytics'; + +import { RootStackRoutes, SendStackRoutes } from './routes'; + +type AnalyticsRelevantSendRoute = Exclude<`${SendStackRoutes}`, `${SendStackRoutes.SendAccounts}`>; + +// Analytics don't care about the accounts list step, so we filter it out. +const orderedRelevantScreensForAnalytics = Object.values(SendStackRoutes).filter( + screen => screen != SendStackRoutes.SendAccounts, +); + +const screenNameToAnalyticsLabelMap = { + [SendStackRoutes.SendOutputs]: 'address_and_amount', + [SendStackRoutes.SendFees]: 'fee_settings', + [SendStackRoutes.SendAddressReview]: 'address_review', + [SendStackRoutes.SendOutputsReview]: 'outputs_review', +} as const satisfies Record; + +const isAnalyticsRelevantSendRoute = ( + routeName?: string, +): routeName is AnalyticsRelevantSendRoute => { + return G.isNotNullable(routeName) && orderedRelevantScreensForAnalytics.includes(routeName); +}; + +export const useReportSendFlowExitToAnalytics = () => { + const [furthestSendStep, setFurthestSendStep] = useState( + null, + ); + + const reportSendFlowExitToAnalytics = useCallback( + (nextScreenRoute?: string) => { + // The user is still inside of the send flow. + if (isAnalyticsRelevantSendRoute(nextScreenRoute)) { + if (!furthestSendStep) { + setFurthestSendStep(nextScreenRoute); + + return; + } + + const currentFurthestIndex = + orderedRelevantScreensForAnalytics.indexOf(nextScreenRoute); + const previousFurthestIndex = + orderedRelevantScreensForAnalytics.indexOf(furthestSendStep); + + if (currentFurthestIndex > previousFurthestIndex) { + setFurthestSendStep(nextScreenRoute); + } + + return; + } + + // This means successful dispatch of the transaction, we don't want to report that. + if (nextScreenRoute === RootStackRoutes.TransactionDetail) { + setFurthestSendStep(null); + + return; + } + + // We are navigation outside of the send flow without successful transaction dispatch. Report the furthest step to analytics. + if (furthestSendStep) { + analytics.report({ + type: EventType.SendFlowExited, + payload: { step: screenNameToAnalyticsLabelMap[furthestSendStep] }, + }); + setFurthestSendStep(null); + } + }, + [setFurthestSendStep, furthestSendStep], + ); + + return reportSendFlowExitToAnalytics; +}; diff --git a/yarn.lock b/yarn.lock index 327dfc9ca7e..c47a67c8c34 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10342,6 +10342,7 @@ __metadata: "@suite-common/wallet-utils": "workspace:*" "@suite-native/accounts": "workspace:*" "@suite-native/alerts": "workspace:*" + "@suite-native/analytics": "workspace:*" "@suite-native/atoms": "workspace:*" "@suite-native/device": "workspace:*" "@suite-native/device-mutex": "workspace:*" @@ -10417,6 +10418,7 @@ __metadata: version: 0.0.0-use.local resolution: "@suite-native/navigation@workspace:suite-native/navigation" dependencies: + "@mobily/ts-belt": "npm:^3.13.1" "@react-navigation/bottom-tabs": "npm:6.6.1" "@react-navigation/native": "npm:6.1.18" "@react-navigation/native-stack": "npm:6.11.0"