diff --git a/app/actions/notification/helpers/index.test.tsx b/app/actions/notification/helpers/index.test.tsx index 16ed2137ed87..03f27f21daad 100644 --- a/app/actions/notification/helpers/index.test.tsx +++ b/app/actions/notification/helpers/index.test.tsx @@ -11,6 +11,7 @@ import { enablePushNotifications, disablePushNotifications, hasNotificationPreferences, + setMarketingNotificationPreferencesEnabled, type setContentPreviewToken as setContentPreviewTokenFn, type getContentPreviewToken as getContentPreviewTokenFn, type subscribeToContentPreviewToken as subscribeToContentPreviewTokenFn, @@ -64,6 +65,13 @@ describe('helpers - enableNotificationServices()', () => { Engine.context.NotificationServicesController.enableMetamaskNotifications, ).toHaveBeenCalledWith(options); }); + + it('forwards enable notification options', async () => { + await enableNotifications({ registerPushNotifications: false }); + expect( + Engine.context.NotificationServicesController.enableMetamaskNotifications, + ).toHaveBeenCalledWith({ registerPushNotifications: false }); + }); }); describe('helpers - hasNotificationPreferences()', () => { @@ -89,6 +97,61 @@ describe('helpers - hasNotificationPreferences()', () => { }); }); +describe('helpers - setMarketingNotificationPreferencesEnabled()', () => { + it('updates marketing notification preferences when AUS preferences exist', async () => { + const preferences = { + walletActivity: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, + accounts: [], + }, + marketing: { + inAppNotificationsEnabled: false, + pushNotificationsEnabled: false, + }, + perps: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, + }, + socialAI: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, + txAmountLimit: 500, + mutedTraderProfileIds: [], + }, + }; + jest.mocked(Engine.controllerMessenger.call).mockResolvedValue(preferences); + + await setMarketingNotificationPreferencesEnabled(true); + + expect(Engine.controllerMessenger.call).toHaveBeenCalledWith( + 'AuthenticatedUserStorageService:getNotificationPreferences', + ); + expect(Engine.controllerMessenger.call).toHaveBeenCalledWith( + 'AuthenticatedUserStorageService:putNotificationPreferences', + { + ...preferences, + marketing: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, + }, + }, + 'mobile', + ); + }); + + it('does not persist when AUS preferences are missing', async () => { + jest.mocked(Engine.controllerMessenger.call).mockResolvedValue(null); + + await setMarketingNotificationPreferencesEnabled(true); + + expect(Engine.controllerMessenger.call).toHaveBeenCalledTimes(1); + expect(Engine.controllerMessenger.call).toHaveBeenCalledWith( + 'AuthenticatedUserStorageService:getNotificationPreferences', + ); + }); +}); + describe('helpers - disableNotificationServices()', () => { it('invoke notification services method', async () => { await disableNotifications(); diff --git a/app/actions/notification/helpers/index.ts b/app/actions/notification/helpers/index.ts index e9bf86787419..ff970f48348b 100644 --- a/app/actions/notification/helpers/index.ts +++ b/app/actions/notification/helpers/index.ts @@ -6,6 +6,12 @@ import type { import Engine from '../../../core/Engine'; import { isNotificationsFeatureEnabled } from '../../../util/notifications'; +const CLIENT_TYPE = 'mobile' as const; +const GET_NOTIFICATION_PREFERENCES_ACTION = + 'AuthenticatedUserStorageService:getNotificationPreferences' as const; +const PUT_NOTIFICATION_PREFERENCES_ACTION = + 'AuthenticatedUserStorageService:putNotificationPreferences' as const; + const previewTokenEventEmitter = new EventEmitter2(); const PREVIEW_TOKEN_UPDATE_EVENT = 'previewTokenUpdate'; let previewToken: string | undefined; @@ -57,11 +63,37 @@ export const enableNotifications = async ( export const hasNotificationPreferences = async () => { assertIsFeatureEnabled(); const preferences = await Engine.controllerMessenger.call( - 'AuthenticatedUserStorageService:getNotificationPreferences', + GET_NOTIFICATION_PREFERENCES_ACTION, ); return preferences != null; }; +export const setMarketingNotificationPreferencesEnabled = async ( + isEnabled: boolean, +) => { + assertIsFeatureEnabled(); + const preferences = await Engine.controllerMessenger.call( + GET_NOTIFICATION_PREFERENCES_ACTION, + ); + + if (!preferences) { + return; + } + + await Engine.controllerMessenger.call( + PUT_NOTIFICATION_PREFERENCES_ACTION, + { + ...preferences, + marketing: { + ...preferences.marketing, + inAppNotificationsEnabled: isEnabled, + pushNotificationsEnabled: isEnabled, + }, + }, + CLIENT_TYPE, + ); +}; + /** * Disable Notifications Switch * - Disables wallet notifications, feature announcements, and push notifications diff --git a/app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.styles.ts b/app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.styles.ts index 860adbd5f247..dbe320b0e44f 100644 --- a/app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.styles.ts +++ b/app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.styles.ts @@ -24,12 +24,7 @@ const styleSheet = (params: { const { colors } = params.theme; return StyleSheet.create({ - base: Object.assign( - { - padding: 16, - } as ViewStyle, - style, - ) as ViewStyle, + base: Object.assign({} as ViewStyle, style) as ViewStyle, cellBase: Object.assign( { flexDirection: 'row', diff --git a/app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.tsx b/app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.tsx index 88f229ffa3a9..99ba10148ace 100644 --- a/app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.tsx +++ b/app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.tsx @@ -2,10 +2,11 @@ // Third library dependencies. import React from 'react'; -import { TouchableOpacity, TouchableWithoutFeedback, View } from 'react-native'; +import { View } from 'react-native'; // External dependencies. import { useStyles } from '../../hooks'; +import Pressable from '../Pressable'; import Tag from '../../../component-library/components/Tags/Tag'; // Internal dependencies. @@ -74,27 +75,25 @@ const CellSelectWithMenu = ({ )} {!!secondaryText && (props.onTextClick ? ( - - + - - {secondaryText} - - {showSecondaryTextIcon && ( - - )} - - + {secondaryText} + + {showSecondaryTextIcon && ( + + )} + ) : ( { it('renders with default props', () => { - const { getByRole } = render( + const { getByTestId } = render( + + + , + ); + + expect(getByTestId(ROW_TEST_ID)).toBeOnTheScreen(); + }); + + it('exposes accessibilityRole="button" on the row', () => { + const { getByTestId } = render( , ); - expect(getByRole('button')).toBeOnTheScreen(); + expect(getByTestId(ROW_TEST_ID).props.accessibilityRole).toBe('button'); }); it('calls onPress when the button is pressed', () => { const mockOnPress = jest.fn(); - const { getByRole } = render( + const { getByTestId } = render( { , ); - fireEvent.press(getByRole('button')); + fireEvent.press(getByTestId(ROW_TEST_ID)); expect(mockOnPress).toHaveBeenCalled(); }); diff --git a/app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.tsx b/app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.tsx index 48ea2f5e34d4..d5cadd7c95eb 100644 --- a/app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.tsx +++ b/app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.tsx @@ -2,10 +2,11 @@ // Third party dependencies. import React from 'react'; -import { TouchableOpacity, View } from 'react-native'; +import { View } from 'react-native'; // External dependencies. import { useStyles } from '../../hooks'; +import Pressable from '../Pressable'; import ListItem from '../../../component-library/components/List/ListItem/ListItem'; // Internal dependencies. @@ -14,6 +15,7 @@ import { ListItemMultiSelectButtonProps } from './ListItemMultiSelectButton.type import { BUTTON_TEST_ID, DEFAULT_LISTITEMMULTISELECT_GAP, + ROW_TEST_ID, } from './ListItemMultiSelectButton.constants'; import ButtonIcon from '../../../component-library/components/Buttons/ButtonIcon'; import { @@ -46,18 +48,19 @@ const ListItemMultiSelectButton: React.FC = ({ }); return ( - - + + {children} - + {showButtonIcon ? ( = ({ /> ) : null} - + ); }; diff --git a/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.constants.ts b/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.constants.ts index 2464df237281..b45e59f09da0 100644 --- a/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.constants.ts +++ b/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.constants.ts @@ -10,6 +10,7 @@ export const DEFAULT_LIST_ITEM_MULTISELECT_WITH_MENU_BUTTON_GAP = 16; export const BUTTON_TEST_ID = 'button-menu-select-with-menu-button-test-id'; export const BUTTON_TEXT_TEST_ID = 'button-text-select-with-menu-button-test-id'; +export const ROW_TEST_ID = 'list-item-multi-select-with-menu-button-row'; // Sample consts export const SAMPLE_LIST_ITEM_MULTISELECT_WITH_MENU_BUTTON_PROPS = { diff --git a/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.styles.ts b/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.styles.ts index ba5109ac6078..9a852939ea20 100644 --- a/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.styles.ts +++ b/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.styles.ts @@ -23,16 +23,10 @@ const styleSheet = (params: { const { colors } = theme; const { style, isDisabled } = vars; return StyleSheet.create({ - base: Object.assign( - { - flex: 1, - position: 'relative', - opacity: isDisabled ? 0.5 : 1, - padding: 16, - zIndex: 1, - } as ViewStyle, - style, - ) as ViewStyle, + base: { + flex: 1, + padding: 16, + } as ViewStyle, containerColumn: { flexDirection: 'column', alignItems: 'flex-start', @@ -40,13 +34,16 @@ const styleSheet = (params: { paddingTop: 0, paddingBottom: 0, paddingLeft: 0, - zIndex: 2, - }, - container: { - backgroundColor: colors.background.default, - flexDirection: 'row', - alignItems: 'center', }, + container: Object.assign( + { + backgroundColor: colors.background.default, + flexDirection: 'row', + alignItems: 'center', + opacity: isDisabled ? 0.5 : 1, + } as ViewStyle, + style, + ) as ViewStyle, buttonIcon: { paddingRight: 20, }, diff --git a/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.test.tsx b/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.test.tsx index 0f62af57d09d..f975eb6dbd52 100644 --- a/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.test.tsx +++ b/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.test.tsx @@ -6,16 +6,28 @@ import { View } from 'react-native'; // Internal dependencies. import ListItemMultiSelectWithMenuButton from './ListItemMultiSelectWithMenuButton'; import { IconName } from '../../../component-library/components/Icons/Icon'; -import { BUTTON_TEST_ID } from './ListItemMultiSelectWithMenuButton.constants'; +import { + BUTTON_TEST_ID, + ROW_TEST_ID, +} from './ListItemMultiSelectWithMenuButton.constants'; describe('ListItemMultiSelectWithMenuButton', () => { it('should render correctly with default props', () => { - const { getByRole } = render( + const { getByTestId } = render( , ); - expect(getByRole('button')).toBeOnTheScreen(); + expect(getByTestId(ROW_TEST_ID)).toBeOnTheScreen(); + }); + + it('exposes accessibilityRole="button" on the row', () => { + const { getByTestId } = render( + + + , + ); + expect(getByTestId(ROW_TEST_ID).props.accessibilityRole).toBe('button'); }); it('should not render checkbox icon when isSelected is false', () => { @@ -30,7 +42,7 @@ describe('ListItemMultiSelectWithMenuButton', () => { it('should call onPress when the button is pressed', () => { const mockOnPress = jest.fn(); - const { getByRole } = render( + const { getByTestId } = render( { , ); - fireEvent.press(getByRole('button')); + fireEvent.press(getByTestId(ROW_TEST_ID)); expect(mockOnPress).toHaveBeenCalled(); }); @@ -81,14 +93,14 @@ describe('ListItemMultiSelectWithMenuButton', () => { it('should be disabled when isDisabled is true', () => { const mockOnPress = jest.fn(); - const { getByRole } = render( + const { getByTestId } = render( , ); // The component should render without error when disabled - expect(getByRole('button')).toBeTruthy(); + expect(getByTestId(ROW_TEST_ID)).toBeTruthy(); }); it('should not render button icon when showButtonIcon is false', () => { @@ -102,23 +114,23 @@ describe('ListItemMultiSelectWithMenuButton', () => { it('should call onPress on long press', () => { const mockOnPress = jest.fn(); - const { getByRole } = render( + const { getByTestId } = render( , ); // Test that the component renders with onLongPress prop set to onPress - expect(getByRole('button')).toBeTruthy(); + expect(getByTestId(ROW_TEST_ID)).toBeTruthy(); }); it('should render with custom gap', () => { - const { getByRole } = render( + const { getByTestId } = render( , ); - expect(getByRole('button')).toBeTruthy(); + expect(getByTestId(ROW_TEST_ID)).toBeTruthy(); }); it('should use custom button test ID when provided', () => { @@ -145,7 +157,7 @@ describe('ListItemMultiSelectWithMenuButton', () => { }); it('should handle button props with text button', () => { - const { getByRole } = render( + const { getByTestId } = render( { , ); - expect(getByRole('button')).toBeTruthy(); + expect(getByTestId(ROW_TEST_ID)).toBeTruthy(); }); it('should handle button props with showButtonIcon false', () => { diff --git a/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.tsx b/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.tsx index e72dea4700e2..d607aa327508 100644 --- a/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.tsx +++ b/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.tsx @@ -2,10 +2,11 @@ // Third party dependencies. import React from 'react'; -import { TouchableOpacity, View } from 'react-native'; +import { View } from 'react-native'; // External dependencies. import { useStyles } from '../../hooks'; +import Pressable from '../Pressable'; import ListItem from '../../../component-library/components/List/ListItem/ListItem'; import Checkbox from '../../components/Checkbox'; @@ -15,6 +16,7 @@ import { ListItemMultiSelectWithMenuButtonProps } from './ListItemMultiSelectWit import { BUTTON_TEST_ID, DEFAULT_LIST_ITEM_MULTISELECT_WITH_MENU_BUTTON_GAP, + ROW_TEST_ID, } from './ListItemMultiSelectWithMenuButton.constants'; import ButtonIcon from '../../../component-library/components/Buttons/ButtonIcon'; import { @@ -43,19 +45,20 @@ const ListItemMultiSelectWithMenuButton: React.FC< }); return ( - - + + {children} - + {showButtonIcon ? ( ) : null} - + ); }; diff --git a/app/component-library/components-temp/Pressable/Pressable.test.tsx b/app/component-library/components-temp/Pressable/Pressable.test.tsx index 1fdd51cc996f..6cbd0517f680 100644 --- a/app/component-library/components-temp/Pressable/Pressable.test.tsx +++ b/app/component-library/components-temp/Pressable/Pressable.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { StyleSheet, Text } from 'react-native'; +import { StyleSheet, Text, type View } from 'react-native'; import { fireEvent, render } from '@testing-library/react-native'; import { mockTheme } from '../../../util/theme'; @@ -96,6 +96,18 @@ describe('Pressable', () => { expect(resting.padding).toBe(16); }); + it('forwards a ref to the underlying view', () => { + const ref = React.createRef(); + render( + + x + , + ); + + expect(ref.current).not.toBeNull(); + expect(typeof ref.current?.measure).toBe('function'); + }); + it('resolves a function-form caller style on render', () => { const styleFn = jest.fn(() => ({ borderWidth: 1 })); render( diff --git a/app/component-library/components-temp/Pressable/Pressable.tsx b/app/component-library/components-temp/Pressable/Pressable.tsx index 0d16d46ad37d..b48008565ec2 100644 --- a/app/component-library/components-temp/Pressable/Pressable.tsx +++ b/app/component-library/components-temp/Pressable/Pressable.tsx @@ -1,8 +1,9 @@ -import React, { useCallback } from 'react'; +import React, { forwardRef, useCallback } from 'react'; import { Pressable as RNPressable, type PressableStateCallbackType, type StyleProp, + type View, type ViewStyle, } from 'react-native'; @@ -18,31 +19,31 @@ import type { PressableProps } from './Pressable.types'; * `background.pressed` token on top of whatever resting surface the * parent owns. The component itself never sets a resting background. */ -const Pressable = ({ - style, - accessibilityRole = 'button', - children, - ...props -}: PressableProps) => { - const { colors } = useTheme(); +const Pressable = forwardRef( + ({ style, accessibilityRole = 'button', children, ...props }, ref) => { + const { colors } = useTheme(); - const composedStyle = useCallback( - (state: PressableStateCallbackType): StyleProp => [ - typeof style === 'function' ? style(state) : style, - state.pressed && { backgroundColor: colors.background.pressed }, - ], - [style, colors.background.pressed], - ); + const composedStyle = useCallback( + (state: PressableStateCallbackType): StyleProp => [ + typeof style === 'function' ? style(state) : style, + state.pressed && { backgroundColor: colors.background.pressed }, + ], + [style, colors.background.pressed], + ); - return ( - - {children} - - ); -}; + return ( + + {children} + + ); + }, +); + +Pressable.displayName = 'Pressable'; export default Pressable; diff --git a/app/component-library/components-temp/Pressable/PressableGH.test.tsx b/app/component-library/components-temp/Pressable/PressableGH.test.tsx index 5968454a2d75..b4e71315dd12 100644 --- a/app/component-library/components-temp/Pressable/PressableGH.test.tsx +++ b/app/component-library/components-temp/Pressable/PressableGH.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Text } from 'react-native'; +import { Text, type View } from 'react-native'; import { fireEvent, render } from '@testing-library/react-native'; jest.mock('react-native-gesture-handler', () => { @@ -63,4 +63,16 @@ describe('PressableGH', () => { expect(getByLabelText('Action')).toBeOnTheScreen(); }); + + it('forwards a ref to the underlying view', () => { + const ref = React.createRef(); + render( + + x + , + ); + + expect(ref.current).not.toBeNull(); + expect(typeof ref.current?.measure).toBe('function'); + }); }); diff --git a/app/component-library/components-temp/Pressable/PressableGH.tsx b/app/component-library/components-temp/Pressable/PressableGH.tsx index 71f30a15803c..37479bed72c5 100644 --- a/app/component-library/components-temp/Pressable/PressableGH.tsx +++ b/app/component-library/components-temp/Pressable/PressableGH.tsx @@ -1,5 +1,5 @@ -import React, { useCallback } from 'react'; -import type { StyleProp, ViewStyle } from 'react-native'; +import React, { forwardRef, useCallback } from 'react'; +import type { StyleProp, View, ViewStyle } from 'react-native'; import { Pressable as RNGHPressable, type PressableStateCallbackType, @@ -16,31 +16,31 @@ import type { PressableGHProps } from './Pressable.types'; * scroll/list tree. Mixing RN core `Pressable` with RNGH scroll views * causes swipe/scroll gesture conflicts on Android. */ -const PressableGH = ({ - style, - accessibilityRole = 'button', - children, - ...props -}: PressableGHProps) => { - const { colors } = useTheme(); +const PressableGH = forwardRef( + ({ style, accessibilityRole = 'button', children, ...props }, ref) => { + const { colors } = useTheme(); - const composedStyle = useCallback( - (state: PressableStateCallbackType): StyleProp => [ - typeof style === 'function' ? style(state) : style, - state.pressed && { backgroundColor: colors.background.pressed }, - ], - [style, colors.background.pressed], - ); + const composedStyle = useCallback( + (state: PressableStateCallbackType): StyleProp => [ + typeof style === 'function' ? style(state) : style, + state.pressed && { backgroundColor: colors.background.pressed }, + ], + [style, colors.background.pressed], + ); - return ( - - {children} - - ); -}; + return ( + + {children} + + ); + }, +); + +PressableGH.displayName = 'PressableGH'; export default PressableGH; diff --git a/app/component-library/components/Icons/Icon/Icon.assets.ts b/app/component-library/components/Icons/Icon/Icon.assets.ts index 97625ce00701..5f55cdb789ff 100644 --- a/app/component-library/components/Icons/Icon/Icon.assets.ts +++ b/app/component-library/components/Icons/Icon/Icon.assets.ts @@ -251,6 +251,7 @@ import tabcloseSVG from './assets/tab-close.svg'; import tablerowSVG from './assets/table-row.svg'; import tabletSVG from './assets/tablet.svg'; import tagSVG from './assets/tag.svg'; +import telegramSVG from './assets/telegram.svg'; import thumbdownfilledSVG from './assets/thumb-down-filled.svg'; import thumbdownSVG from './assets/thumb-down.svg'; import thumbupfilledSVG from './assets/thumb-up-filled.svg'; @@ -540,6 +541,7 @@ export const assetByIconName: AssetByIconName = { [IconName.TableRow]: tablerowSVG, [IconName.Tablet]: tabletSVG, [IconName.Tag]: tagSVG, + [IconName.Telegram]: telegramSVG, [IconName.ThumbDownFilled]: thumbdownfilledSVG, [IconName.ThumbDown]: thumbdownSVG, [IconName.ThumbUpFilled]: thumbupfilledSVG, diff --git a/app/component-library/components/Icons/Icon/Icon.types.ts b/app/component-library/components/Icons/Icon/Icon.types.ts index 649011ddab5d..96afde4dbe08 100644 --- a/app/component-library/components/Icons/Icon/Icon.types.ts +++ b/app/component-library/components/Icons/Icon/Icon.types.ts @@ -321,6 +321,7 @@ export enum IconName { TableRow = 'TableRow', Tablet = 'Tablet', Tag = 'Tag', + Telegram = 'Telegram', ThumbDownFilled = 'ThumbDownFilled', ThumbDown = 'ThumbDown', ThumbUpFilled = 'ThumbUpFilled', diff --git a/app/component-library/components/Icons/Icon/assets/telegram.svg b/app/component-library/components/Icons/Icon/assets/telegram.svg new file mode 100644 index 000000000000..4c19924785e7 --- /dev/null +++ b/app/component-library/components/Icons/Icon/assets/telegram.svg @@ -0,0 +1 @@ + diff --git a/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.styles.ts b/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.styles.ts index 9c584ad4163d..adf60f67b1ac 100644 --- a/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.styles.ts +++ b/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.styles.ts @@ -27,7 +27,6 @@ const styleSheet = (params: { { padding: 16, borderRadius: 4, - backgroundColor: colors.background.default, opacity: isDisabled ? 0.5 : 1, } as ViewStyle, style, diff --git a/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.test.tsx b/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.test.tsx index ad83ea5bea4d..b907dcbe6d53 100644 --- a/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.test.tsx +++ b/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.test.tsx @@ -22,6 +22,18 @@ describe('ListItemMultiSelect', () => { expect(getByTestId('test-content')).toBeOnTheScreen(); }); + it('exposes accessibilityRole="button" on the row', () => { + const { getByTestId } = render( + null} testID="list-item-multi-select"> + + , + ); + + expect(getByTestId('list-item-multi-select').props.accessibilityRole).toBe( + 'button', + ); + }); + it('renders when disabled', () => { const { getByTestId } = render( = ({ const { styles } = useStyles(styleSheet, { style, gap, isDisabled }); return ( - = ({ {isSelected && ( )} - + ); }; diff --git a/app/component-library/components/List/ListItemSelect/ListItemSelect.styles.ts b/app/component-library/components/List/ListItemSelect/ListItemSelect.styles.ts index cf5bef216bc0..fd08dcd55ec4 100644 --- a/app/component-library/components/List/ListItemSelect/ListItemSelect.styles.ts +++ b/app/component-library/components/List/ListItemSelect/ListItemSelect.styles.ts @@ -28,7 +28,6 @@ const styleSheet = (params: { position: 'relative', opacity: isDisabled ? 0.5 : 1, borderRadius: 4, - backgroundColor: colors.background.default, } as ViewStyle, style, ) as ViewStyle, diff --git a/app/component-library/components/List/ListItemSelect/ListItemSelect.test.tsx b/app/component-library/components/List/ListItemSelect/ListItemSelect.test.tsx index 698586de61eb..020c4693d25a 100644 --- a/app/component-library/components/List/ListItemSelect/ListItemSelect.test.tsx +++ b/app/component-library/components/List/ListItemSelect/ListItemSelect.test.tsx @@ -23,6 +23,18 @@ describe('ListItemSelect', () => { expect(getByTestId('test-content')).toBeOnTheScreen(); }); + it('exposes accessibilityRole="button" on the row', () => { + const { getByTestId } = render( + null} testID="list-item-select"> + + , + ); + + expect(getByTestId('list-item-select').props.accessibilityRole).toBe( + 'button', + ); + }); + it('renders when disabled', () => { const { getByTestId } = render( null} isDisabled testID="list-item-select"> diff --git a/app/component-library/components/List/ListItemSelect/ListItemSelect.tsx b/app/component-library/components/List/ListItemSelect/ListItemSelect.tsx index 88a156734424..3d0699eb1b4a 100644 --- a/app/component-library/components/List/ListItemSelect/ListItemSelect.tsx +++ b/app/component-library/components/List/ListItemSelect/ListItemSelect.tsx @@ -2,10 +2,11 @@ // Third party dependencies. import React from 'react'; -import { TouchableOpacity, View } from 'react-native'; +import { View } from 'react-native'; // External dependencies. import { useStyles } from '../../../hooks'; +import Pressable from '../../../components-temp/Pressable'; import ListItem from '../../List/ListItem/ListItem'; // Internal dependencies. @@ -28,7 +29,7 @@ const ListItemSelect: React.FC = ({ const { styles } = useStyles(styleSheet, { style, isDisabled }); return ( - = ({ {isSelected && ( )} - + ); }; diff --git a/app/component-library/components/Pickers/PickerAccount/PickerAccount.stories.tsx b/app/component-library/components/Pickers/PickerAccount/PickerAccount.stories.tsx index 992a2ae1d90b..51f6f26885f5 100644 --- a/app/component-library/components/Pickers/PickerAccount/PickerAccount.stories.tsx +++ b/app/component-library/components/Pickers/PickerAccount/PickerAccount.stories.tsx @@ -5,7 +5,7 @@ import React from 'react'; // Internal dependencies. import { default as PickerAccountComponent } from './PickerAccount'; import { SAMPLE_PICKERACCOUNT_PROPS } from './PickerAccount.constants'; -import { TouchableOpacityProps, View } from 'react-native'; +import { View } from 'react-native'; import { PickerAccountProps } from './PickerAccount.types'; const PickerAccountMeta = { @@ -26,10 +26,6 @@ export const PickerAccount = { render: ( args: React.JSX.IntrinsicAttributes & PickerAccountProps & - React.RefAttributes< - React.ForwardRefExoticComponent< - TouchableOpacityProps & React.RefAttributes - > - >, + React.RefAttributes, ) => , }; diff --git a/app/component-library/components/Pickers/PickerAccount/PickerAccount.styles.ts b/app/component-library/components/Pickers/PickerAccount/PickerAccount.styles.ts index c2ac07ecd554..e7eb07f4f52b 100644 --- a/app/component-library/components/Pickers/PickerAccount/PickerAccount.styles.ts +++ b/app/component-library/components/Pickers/PickerAccount/PickerAccount.styles.ts @@ -28,13 +28,6 @@ const styleSheet = (params: { flexDirection: 'row', borderWidth: 0, }, - basePressed: { - ...(style as ViewStyle), - flexDirection: 'row', - borderWidth: 0, - borderRadius: 2, - backgroundColor: colors.background.pressed, - }, accountAddressLabel: { color: colors.text.alternative, textAlign: 'center', diff --git a/app/component-library/components/Pickers/PickerAccount/PickerAccount.test.tsx b/app/component-library/components/Pickers/PickerAccount/PickerAccount.test.tsx index acb520b5f74b..b1e091728314 100644 --- a/app/component-library/components/Pickers/PickerAccount/PickerAccount.test.tsx +++ b/app/component-library/components/Pickers/PickerAccount/PickerAccount.test.tsx @@ -1,5 +1,6 @@ // Third party dependencies. import React from 'react'; +import type { View } from 'react-native'; import { render, fireEvent } from '@testing-library/react-native'; // Internal dependencies. @@ -165,16 +166,12 @@ describe('PickerAccount', () => { }); describe('Ref Forwarding', () => { - it('forwards ref correctly', () => { - const TestRefComponent = () => { - const ref = React.useRef(null); - return ; - }; + it('exposes the underlying view via the forwarded ref', () => { + const ref = React.createRef(); + render(); - // Verify component renders without throwing when ref is provided - expect(() => { - render(); - }).not.toThrow(); + expect(ref.current).not.toBeNull(); + expect(typeof ref.current?.measure).toBe('function'); }); }); diff --git a/app/component-library/components/Pickers/PickerAccount/PickerAccount.tsx b/app/component-library/components/Pickers/PickerAccount/PickerAccount.tsx index ac3928f2f046..02aa734b2566 100644 --- a/app/component-library/components/Pickers/PickerAccount/PickerAccount.tsx +++ b/app/component-library/components/Pickers/PickerAccount/PickerAccount.tsx @@ -1,8 +1,8 @@ /* eslint-disable react/prop-types */ // Third party dependencies. -import React, { forwardRef, useState, useCallback } from 'react'; -import { TouchableOpacity, GestureResponderEvent } from 'react-native'; +import React, { forwardRef } from 'react'; +import type { View } from 'react-native'; // External dependencies. import DSText, { TextVariant } from '../../Texts/Text'; @@ -14,55 +14,30 @@ import { PickerAccountProps } from './PickerAccount.types'; import styleSheet from './PickerAccount.styles'; import { WalletViewSelectorsIDs } from '../../../../components/Views/Wallet/WalletView.testIds'; -const PickerAccount: React.ForwardRefRenderFunction< - typeof TouchableOpacity, - PickerAccountProps -> = ( - { style, accountName, hitSlop, onPress, onPressIn, onPressOut, ...props }, - _ref: React.Ref, -) => { - const [pressed, setPressed] = useState(false); - - const { styles } = useStyles(styleSheet, { - style, - pressed, - }); - - const triggerOnPressedIn = useCallback( - (e: GestureResponderEvent) => { - setPressed(true); - onPressIn?.(e); - }, - [setPressed, onPressIn], - ); - - const triggerOnPressedOut = useCallback( - (e: GestureResponderEvent) => { - setPressed(false); - onPressOut?.(e); - }, - [setPressed, onPressOut], - ); - - return ( - - ( + ({ style, accountName, hitSlop, onPress, ...props }, ref) => { + const { styles } = useStyles(styleSheet, { style }); + + return ( + - {accountName} - - - ); -}; - -export default forwardRef(PickerAccount); + + {accountName} + + + ); + }, +); + +PickerAccount.displayName = 'PickerAccount'; + +export default PickerAccount; diff --git a/app/component-library/components/Pickers/PickerAccount/PickerAccount.types.ts b/app/component-library/components/Pickers/PickerAccount/PickerAccount.types.ts index 58663e4e8ebb..cf3951c7e535 100644 --- a/app/component-library/components/Pickers/PickerAccount/PickerAccount.types.ts +++ b/app/component-library/components/Pickers/PickerAccount/PickerAccount.types.ts @@ -14,6 +14,4 @@ export interface PickerAccountProps extends Omit { /** * Style sheet input parameters. */ -export type PickerAccountStyleSheetVars = Pick & { - pressed: boolean; -}; +export type PickerAccountStyleSheetVars = Pick; diff --git a/app/component-library/components/Pickers/PickerBase/PickerBase.tsx b/app/component-library/components/Pickers/PickerBase/PickerBase.tsx index 83b6d219779a..04dbbceaa021 100644 --- a/app/component-library/components/Pickers/PickerBase/PickerBase.tsx +++ b/app/component-library/components/Pickers/PickerBase/PickerBase.tsx @@ -2,39 +2,42 @@ // Third party dependencies. import React, { forwardRef } from 'react'; -import { TouchableOpacity, View } from 'react-native'; +import type { View } from 'react-native'; // External dependencies. import { useStyles } from '../../../hooks'; +import Pressable from '../../../components-temp/Pressable'; import Icon, { IconName, IconSize } from '../../Icons/Icon'; // Internal dependencies. import { PickerBaseProps } from './PickerBase.types'; import styleSheet from './PickerBase.styles'; -const PickerBase: React.ForwardRefRenderFunction = ( - { iconSize = IconSize.Md, style, dropdownIconStyle, children, ...props }, - ref, -) => { - const { styles, theme } = useStyles(styleSheet, { style, dropdownIconStyle }); - const { colors } = theme; +const PickerBase = forwardRef( + ( + { iconSize = IconSize.Md, style, dropdownIconStyle, children, ...props }, + ref, + ) => { + const { styles, theme } = useStyles(styleSheet, { + style, + dropdownIconStyle, + }); + const { colors } = theme; - return ( - - {children} - - - ); -}; + return ( + + {children} + + + ); + }, +); -export default forwardRef(PickerBase); +PickerBase.displayName = 'PickerBase'; + +export default PickerBase; diff --git a/app/component-library/components/Pickers/PickerBase/PickerBase.types.ts b/app/component-library/components/Pickers/PickerBase/PickerBase.types.ts index 65abdf960473..4eb5f1e5bb7e 100644 --- a/app/component-library/components/Pickers/PickerBase/PickerBase.types.ts +++ b/app/component-library/components/Pickers/PickerBase/PickerBase.types.ts @@ -1,11 +1,12 @@ // Third party dependencies. -import { TouchableOpacityProps, ViewStyle } from 'react-native'; +import { ViewStyle } from 'react-native'; import { IconSize } from '../../Icons/Icon'; +import { PressableProps } from '../../../components-temp/Pressable'; /** * PickerBase component props. */ -export interface PickerBaseProps extends TouchableOpacityProps { +export interface PickerBaseProps extends PressableProps { /** * Callback to trigger when pressed. */ diff --git a/app/components/Nav/Main/index.js b/app/components/Nav/Main/index.js index 848c9532954e..0453b21e4bfe 100644 --- a/app/components/Nav/Main/index.js +++ b/app/components/Nav/Main/index.js @@ -34,6 +34,7 @@ import { } from '../../../actions/notification'; import ProtectYourWalletModal from '../../UI/ProtectYourWalletModal'; +import PushNotificationOnboardingRoot from '../../Views/Notifications/PushNotificationOnboarding/PushNotificationOnboardingRoot'; import MainNavigator from './MainNavigator'; import { query } from '@metamask/controller-utils'; import EarnTransactionMonitor from '../../UI/Earn/components/EarnTransactionMonitor'; @@ -435,6 +436,7 @@ const Main = (props) => { + ); diff --git a/app/components/Nav/Main/index.test.tsx b/app/components/Nav/Main/index.test.tsx index 9b8d208b42f6..7e858613f956 100644 --- a/app/components/Nav/Main/index.test.tsx +++ b/app/components/Nav/Main/index.test.tsx @@ -89,6 +89,15 @@ jest.mock( }), ); +jest.mock( + '../../Views/Notifications/PushNotificationOnboarding/PushNotificationOnboardingRoot', + () => ({ + __esModule: true, + default: () => + mockReact.createElement('PushNotificationOnboardingRootMock'), + }), +); + jest.mock('../../UI/ReviewModal', () => ({ __esModule: true, default: () => mockReact.createElement('ReviewModalMock'), diff --git a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellPercentageSlider.test.tsx b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellPercentageSlider.test.tsx index cdeb67cc9146..dc42ef1504c5 100644 --- a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellPercentageSlider.test.tsx +++ b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellPercentageSlider.test.tsx @@ -3,8 +3,8 @@ import { fireEvent, render } from '@testing-library/react-native'; import { BatchSellPercentageSlider, - SNAP_POINTS, - snapToPercentageStep, + clampToPercentage, + MARKER_POINTS, } from './BatchSellPercentageSlider'; const SLIDER_TEST_ID = 'batch-sell-percentage-slider'; @@ -37,22 +37,22 @@ describe('BatchSellPercentageSlider', () => { it.each([ [-10, 0], [0, 0], - [12, 0], - [13, 25], - [37, 25], - [38, 50], - [62, 50], - [63, 75], - [87, 75], - [88, 100], + [0.4, 0], + [0.5, 1], + [12.4, 12], + [12.5, 13], + [24.4, 24], + [24.5, 25], + [99.4, 99], + [99.5, 100], [120, 100], - ])('snaps %s to %s', (value, expectedValue) => { - const result = snapToPercentageStep(value); + ])('clamps %s to %s', (value, expectedValue) => { + const result = clampToPercentage(value); expect(result).toBe(expectedValue); }); - it('increments accessibility value by one snap point', () => { + it('increments accessibility value by one percentage point', () => { const onValueChange = jest.fn(); const { getByTestId } = render( { nativeEvent: { actionName: 'increment' }, }); - expect(onValueChange).toHaveBeenCalledWith(75); + expect(onValueChange).toHaveBeenCalledWith(51); }); - it('decrements accessibility value by one snap point', () => { + it('decrements accessibility value by one percentage point', () => { const onValueChange = jest.fn(); const { getByTestId } = render( { nativeEvent: { actionName: 'decrement' }, }); - expect(onValueChange).toHaveBeenCalledWith(25); + expect(onValueChange).toHaveBeenCalledWith(49); }); - it('renders muted marker dots for each snap point', () => { + it('does not decrement below 0%', () => { + const onValueChange = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent(getByTestId(SLIDER_TEST_ID), 'accessibilityAction', { + nativeEvent: { actionName: 'decrement' }, + }); + + expect(onValueChange).toHaveBeenCalledWith(0); + }); + + it('renders muted marker dots for each marker point', () => { const { getByTestId } = render( { />, ); - SNAP_POINTS.forEach((snapPoint) => { + MARKER_POINTS.forEach((markerPoint) => { expect( - getByTestId(`${SLIDER_TEST_ID}-snap-point-${snapPoint}`), + getByTestId(`${SLIDER_TEST_ID}-marker-point-${markerPoint}`), ).toBeOnTheScreen(); }); }); diff --git a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellPercentageSlider.tsx b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellPercentageSlider.tsx index 6551c17fae2f..c221f27f791d 100644 --- a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellPercentageSlider.tsx +++ b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellPercentageSlider.tsx @@ -14,12 +14,13 @@ import { useTailwind } from '@metamask/design-system-twrnc-preset'; const HANDLE_SIZE = 24; const MARKER_SIZE = 4; -const PERCENTAGE_STEP = 25; -export const SNAP_POINTS = [0, 25, 50, 75, 100]; +const ACCESSIBILITY_STEP = 1; +export const MARKER_POINTS = [25, 50, 75, 100]; +const MIN_PERCENTAGE = 0; +const MAX_PERCENTAGE = 100; -export function snapToPercentageStep(value: number): number { - const snappedValue = Math.round(value / PERCENTAGE_STEP) * PERCENTAGE_STEP; - return Math.max(0, Math.min(100, snappedValue)); +export function clampToPercentage(value: number): number { + return Math.max(MIN_PERCENTAGE, Math.min(MAX_PERCENTAGE, Math.round(value))); } interface BatchSellPercentageSliderProps { @@ -34,26 +35,29 @@ export function BatchSellPercentageSlider({ testID, }: BatchSellPercentageSliderProps) { const tw = useTailwind(); + const clampedValue = clampToPercentage(value); const sliderWidth = useSharedValue(0); const translateX = useSharedValue(0); const widthRef = useRef(0); const updatePosition = useCallback( (nextValue: number, width = widthRef.current) => { - const snappedValue = snapToPercentageStep(nextValue); - translateX.value = (snappedValue / 100) * width; + const nextClampedValue = clampToPercentage(nextValue); + translateX.value = (nextClampedValue / MAX_PERCENTAGE) * width; }, [translateX], ); - const updateValueFromPosition = useCallback( + const commitValueFromPosition = useCallback( (position: number, width: number) => { if (width === 0) { return; } const clampedPosition = Math.max(0, Math.min(position, width)); - const nextValue = snapToPercentageStep((clampedPosition / width) * 100); + const nextValue = clampToPercentage( + (clampedPosition / width) * MAX_PERCENTAGE, + ); updatePosition(nextValue, width); onValueChange(nextValue); @@ -66,14 +70,14 @@ export function BatchSellPercentageSlider({ const { width } = event.nativeEvent.layout; widthRef.current = width; sliderWidth.value = width; - updatePosition(value, width); + updatePosition(clampedValue, width); }, - [sliderWidth, updatePosition, value], + [sliderWidth, clampedValue, updatePosition], ); useEffect(() => { - updatePosition(value); - }, [updatePosition, value]); + updatePosition(clampedValue); + }, [clampedValue, updatePosition]); const progressStyle = useAnimatedStyle(() => ({ width: translateX.value, @@ -95,30 +99,45 @@ export function BatchSellPercentageSlider({ const gesture = Gesture.Simultaneous( Gesture.Tap().onEnd((event) => { - runOnJS(updateValueFromPosition)(event.x, sliderWidth.value); - }), - Gesture.Pan().onUpdate((event) => { - runOnJS(updateValueFromPosition)(event.x, sliderWidth.value); + runOnJS(commitValueFromPosition)(event.x, sliderWidth.value); }), + Gesture.Pan() + .onUpdate((event) => { + const width = sliderWidth.value; + + if (width === 0) { + return; + } + + translateX.value = Math.max(0, Math.min(event.x, width)); + }) + .onEnd((event) => { + runOnJS(commitValueFromPosition)(event.x, sliderWidth.value); + }), ); const handleAccessibilityAction = useCallback( (event: AccessibilityActionEvent) => { const nextValue = event.nativeEvent.actionName === 'increment' - ? snapToPercentageStep(value + PERCENTAGE_STEP) - : snapToPercentageStep(value - PERCENTAGE_STEP); + ? clampToPercentage(clampedValue + ACCESSIBILITY_STEP) + : clampToPercentage(clampedValue - ACCESSIBILITY_STEP); onValueChange(nextValue); }, - [onValueChange, value], + [onValueChange, clampedValue], ); return ( - {SNAP_POINTS.map((snapPoint) => ( + {MARKER_POINTS.map((markerPoint) => ( ; + totalReceived: { formatted: string; formattedFiat: string }; + minimumReceived: { formatted: string }; + isLoading: boolean; + isSummaryLoading: boolean; + hasAnyQuote: boolean; + hasPendingQuoteRows: boolean; + needsNewQuote: boolean; + networkFee: { formatted: string; formattedFiat: string }; + networkFeeIsLoading: boolean; +} + +const defaultQuoteData: MockBatchSellQuoteData = { + tokenData: { + [ethAssetId]: { + key: ethAssetId, + tokenSymbol: 'ETH', + slippage: '2%', + receivedAmount: '3,456.78 USDC', + receivedAmountFiat: '$3,456.78', + }, + [uniAssetId]: { + key: uniAssetId, + tokenSymbol: 'UNI', + slippage: '2%', + receivedAmount: '500 USDC', + receivedAmountFiat: '$500.00', + }, + }, + totalReceived: { + formatted: '3,956.78 USDC', + formattedFiat: '$3,956.78', + }, + minimumReceived: { formatted: '3,900 USDC' }, + isLoading: false, + isSummaryLoading: false, + hasAnyQuote: true, + hasPendingQuoteRows: false, + needsNewQuote: false, + networkFee: { + formatted: '1.20 USDC', + formattedFiat: '$1.20', + }, + networkFeeIsLoading: false, +}; +let mockBatchSellQuoteData = defaultQuoteData; const defaultSelectedTokens: BridgeToken[] = [ { address: '0x1111111111111111111111111111111111111111', @@ -51,6 +123,9 @@ let mockSelectedDestinationToken: BridgeToken | undefined = usdcToken; let mockDestinationTokens: BridgeToken[] = [usdcToken]; let mockBatchSellSlippages: Partial> = {}; +let mockBatchSellSourceTokenAmounts: Partial< + Record +> = {}; jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ @@ -60,6 +135,17 @@ jest.mock('@react-navigation/native', () => ({ }), })); +jest.mock('../../../../../core/Engine', () => ({ + __esModule: true, + default: { + context: { + BridgeController: { + resetState: jest.fn(), + }, + }, + }, +})); + jest.mock('../../../../../core/redux/slices/bridge', () => ({ resetBridgeState: jest.fn(() => ({ type: 'bridge/resetBridgeState', @@ -68,10 +154,31 @@ jest.mock('../../../../../core/redux/slices/bridge', () => ({ selectBatchSellDestStablecoins: jest.fn(() => mockDestinationTokens), selectBatchSellDestToken: jest.fn(() => mockSelectedDestinationToken), selectBatchSellSlippages: jest.fn(() => mockBatchSellSlippages), + selectBatchSellSourceTokenAmounts: jest.fn( + () => mockBatchSellSourceTokenAmounts, + ), setBatchSellDestToken: jest.fn((token: BridgeToken) => ({ type: 'bridge/setBatchSellDestToken', payload: token, })), + setBatchSellSourceTokenAmount: jest.fn( + ({ + assetId, + amount, + }: { + assetId: CaipAssetType; + amount: string | undefined; + }) => ({ + type: 'bridge/setBatchSellSourceTokenAmount', + payload: { assetId, amount }, + }), + ), + setBatchSellSourceTokenAmounts: jest.fn( + (amounts: Partial>) => ({ + type: 'bridge/setBatchSellSourceTokenAmounts', + payload: amounts, + }), + ), setBatchSellSourceTokens: jest.fn((tokens: BridgeToken[]) => ({ type: 'bridge/setBatchSellSourceTokens', payload: tokens, @@ -89,46 +196,27 @@ jest.mock('react-redux', () => ({ useSelector: (selector: (state: unknown) => unknown) => selector({}), })); -jest.mock('./BatchSellReviewTokenRow', () => { - const ReactActual = jest.requireActual('react'); - const { Pressable, Text, View } = jest.requireActual('react-native'); - - return { - BatchSellReviewTokenRow: ({ - isRemoveTokenDisabled, - onRemovePress, - onSlippagePress, - percent, - token, - tokenKey, - }: { - isRemoveTokenDisabled?: boolean; - onRemovePress: (token: BridgeToken) => void; - onSlippagePress: (token: BridgeToken) => void; - percent: number; - token: BridgeToken; - tokenKey: string; - }) => - ReactActual.createElement( - View, - { testID: `batch-sell-review-token-row-${tokenKey}` }, - ReactActual.createElement(Text, null, token.symbol), - ReactActual.createElement(Text, null, `${percent}%`), - ReactActual.createElement(Pressable, { - onPress: () => onSlippagePress(token), - testID: `batch-sell-review-customize-button-${tokenKey}`, - }), - ReactActual.createElement(Pressable, { - accessibilityState: { disabled: Boolean(isRemoveTokenDisabled) }, - disabled: isRemoveTokenDisabled, - onPress: isRemoveTokenDisabled - ? undefined - : () => onRemovePress(token), - testID: `batch-sell-review-remove-button-${tokenKey}`, - }), - ), - }; -}); +jest.mock('../../hooks/useBatchSellQuoteRequest', () => ({ + getBatchSellAtomicSourceAmount: jest.fn( + (token: { balance?: string }, amount?: string) => + token.balance && amount && Number(amount) > 0 ? '1' : undefined, + ), + getBatchSellSourceTokenAmount: jest.fn( + (token: { balance?: string }, percent: number) => { + if (percent <= 0) return '0'; + + return token.balance; + }, + ), + useBatchSellQuoteRequest: jest.fn(() => ({ + updateBatchSellQuoteParams: mockUpdateBatchSellQuoteParams, + getNewQuote: mockGetNewQuote, + })), +})); + +jest.mock('../../hooks/useBatchSellQuoteData', () => ({ + useBatchSellQuoteData: () => mockBatchSellQuoteData, +})); describe('BatchSellReview', () => { beforeEach(() => { @@ -137,33 +225,234 @@ describe('BatchSellReview', () => { mockSelectedDestinationToken = usdcToken; mockDestinationTokens = [usdcToken]; mockBatchSellSlippages = {}; + mockBatchSellSourceTokenAmounts = { + [ethAssetId]: '1.498', + [uniAssetId]: '154.297', + }; + mockBatchSellQuoteData = defaultQuoteData; + mockGetNewQuote.mockClear(); }); - it('renders the quote loading screen', () => { + it('renders the quote review screen', () => { const { getByTestId, getByText } = render(); expect( getByTestId(BatchSellReviewSelectorsIDs.CONTAINER), ).toBeOnTheScreen(); expect(getByText('Total received')).toBeOnTheScreen(); + expect(getByText('$3,956.78')).toBeOnTheScreen(); + expect( + getByTestId(BatchSellReviewSelectorsIDs.DESTINATION_TOKEN_PILL), + ).toBeOnTheScreen(); + expect(getByText('USDC')).toBeOnTheScreen(); + expect(getByText('1.498 ETH • 100%')).toBeOnTheScreen(); + expect(getByText('154.297 UNI • 100%')).toBeOnTheScreen(); + expect(getByText('$3,456.78')).toBeOnTheScreen(); + expect(getByText('$500.00')).toBeOnTheScreen(); + }); + + it('renders the quote loading screen', () => { + mockBatchSellQuoteData = { + ...defaultQuoteData, + isLoading: true, + isSummaryLoading: true, + hasPendingQuoteRows: true, + }; + const { getByTestId } = render(); + const reviewButton = getByTestId(BatchSellReviewSelectorsIDs.REVIEW_BUTTON); + expect( getByTestId(BatchSellReviewSelectorsIDs.TOTAL_RECEIVED_SKELETON), ).toBeOnTheScreen(); expect( - getByTestId(BatchSellReviewSelectorsIDs.DESTINATION_TOKEN_PILL), + getByTestId( + `${BatchSellReviewSelectorsIDs.TOKEN_AMOUNT_SKELETON}-0x1:0x1111111111111111111111111111111111111111`, + ), ).toBeOnTheScreen(); - expect(getByText('USDC')).toBeOnTheScreen(); - expect(getByText('ETH')).toBeOnTheScreen(); - expect(getByText('UNI')).toBeOnTheScreen(); + expect( + getByTestId( + `${BatchSellReviewSelectorsIDs.TOKEN_AMOUNT_SKELETON}-0x1:0x2222222222222222222222222222222222222222`, + ), + ).toBeOnTheScreen(); + expect(reviewButton.props.accessibilityState.disabled).toBe(true); + }); + + it('shows available row quotes and progressive total while other rows are still loading', () => { + mockBatchSellQuoteData = { + ...defaultQuoteData, + totalReceived: { + ...defaultQuoteData.totalReceived, + formattedFiat: '$3,456.78', + }, + isLoading: true, + isSummaryLoading: false, + hasPendingQuoteRows: true, + tokenData: { + ...defaultQuoteData.tokenData, + [ethAssetId]: { + ...defaultQuoteData.tokenData[ethAssetId], + isLoading: false, + }, + [uniAssetId]: { + ...defaultQuoteData.tokenData[uniAssetId], + isLoading: true, + }, + }, + }; + + const { getAllByText, getByTestId, queryByTestId } = render( + , + ); + const reviewButton = getByTestId(BatchSellReviewSelectorsIDs.REVIEW_BUTTON); + + expect(getAllByText('$3,456.78').length).toBeGreaterThan(0); + expect( + queryByTestId(BatchSellReviewSelectorsIDs.TOTAL_RECEIVED_SKELETON), + ).toBeNull(); + expect( + queryByTestId( + `${BatchSellReviewSelectorsIDs.TOKEN_AMOUNT_SKELETON}-0x1:0x1111111111111111111111111111111111111111`, + ), + ).toBeNull(); + expect( + getByTestId( + `${BatchSellReviewSelectorsIDs.TOKEN_AMOUNT_SKELETON}-0x1:0x2222222222222222222222222222222222222222`, + ), + ).toBeOnTheScreen(); + expect(reviewButton.props.accessibilityState.disabled).toBe(true); + }); + + it('renders no quote available for unavailable rows and allows review with multiple available quotes', () => { + mockSelectedTokens = [...defaultSelectedTokens, thirdSelectedToken]; + mockBatchSellSourceTokenAmounts = { + ...mockBatchSellSourceTokenAmounts, + [linkAssetId]: '42.123', + }; + mockBatchSellQuoteData = { + ...defaultQuoteData, + tokenData: { + ...defaultQuoteData.tokenData, + [linkAssetId]: { + key: linkAssetId, + tokenSymbol: 'LINK', + slippage: '2%', + receivedAmount: '-- USDC', + receivedAmountFiat: '-', + isQuoteUnavailable: true, + }, + }, + totalReceived: { + formatted: '3,956.78 USDC', + formattedFiat: '$3,956.78', + }, + isLoading: false, + isSummaryLoading: false, + hasAnyQuote: true, + hasPendingQuoteRows: false, + }; + + const { getByTestId, getByText } = render(); + const reviewButton = getByTestId(BatchSellReviewSelectorsIDs.REVIEW_BUTTON); + + expect(getByText('No quote available')).toBeOnTheScreen(); + expect(getByText('42.123 LINK • 100%')).toBeOnTheScreen(); + expect(reviewButton.props.accessibilityState.disabled).not.toBe(true); + }); + + it('opens final review without a quote snapshot when unavailable rows are present', () => { + mockSelectedTokens = [...defaultSelectedTokens, thirdSelectedToken]; + mockBatchSellSourceTokenAmounts = { + ...mockBatchSellSourceTokenAmounts, + [linkAssetId]: '42.123', + }; + mockBatchSellQuoteData = { + ...defaultQuoteData, + tokenData: { + ...defaultQuoteData.tokenData, + [linkAssetId]: { + key: linkAssetId, + tokenSymbol: 'LINK', + slippage: '2%', + receivedAmount: '-- USDC', + receivedAmountFiat: '-', + isLoading: false, + isQuoteUnavailable: true, + }, + }, + hasAnyQuote: true, + hasPendingQuoteRows: false, + }; + + const { getByTestId } = render(); + + fireEvent.press(getByTestId(BatchSellReviewSelectorsIDs.REVIEW_BUTTON)); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { + screen: Routes.BRIDGE.MODALS.BATCH_SELL_FINAL_REVIEW_MODAL, + }); + }); + + it('disables review when no rows have quotes', () => { + mockBatchSellQuoteData = { + ...defaultQuoteData, + tokenData: Object.entries(defaultQuoteData.tokenData).reduce< + MockBatchSellQuoteData['tokenData'] + >((tokenDataByAssetId, [assetId, tokenData]) => { + tokenDataByAssetId[assetId] = { + ...tokenData, + receivedAmount: '-- USDC', + receivedAmountFiat: '-', + isQuoteUnavailable: true, + }; + return tokenDataByAssetId; + }, {}), + totalReceived: { + formatted: '-- USDC', + formattedFiat: '-', + }, + minimumReceived: { formatted: '-- USDC' }, + isLoading: false, + isSummaryLoading: false, + hasAnyQuote: false, + hasPendingQuoteRows: false, + }; + const { getAllByText, getByTestId } = render(); + const reviewButton = getByTestId(BatchSellReviewSelectorsIDs.REVIEW_BUTTON); + + expect(getAllByText('No quote available')).toHaveLength(2); + expect(reviewButton.props.accessibilityState.disabled).toBe(true); }); it('sets selected token percents to 100% on entry', () => { - const { getAllByText } = render(); + const { getByText } = render(); + + expect(getByText('1.498 ETH • 100%')).toBeOnTheScreen(); + expect(getByText('154.297 UNI • 100%')).toBeOnTheScreen(); + }); + + it('does not dispatch source token amount updates when undefined values are unchanged', () => { + mockSelectedTokens = [ + { + ...defaultSelectedTokens[0], + balance: undefined, + }, + defaultSelectedTokens[1], + ]; + mockBatchSellSourceTokenAmounts = { + [ethAssetId]: undefined, + [uniAssetId]: '154.297', + }; + + render(); + + const sourceAmountUpdateCalls = mockDispatch.mock.calls.filter( + ([action]) => action?.type === 'bridge/setBatchSellSourceTokenAmounts', + ); - expect(getAllByText('100%')).toHaveLength(mockSelectedTokens.length); + expect(sourceAmountUpdateCalls).toHaveLength(0); }); - it('enables the review button while quote placeholders are available', () => { + it('enables the review button when quotes are available', () => { const { getByTestId, getByText } = render(); const reviewButton = getByTestId(BatchSellReviewSelectorsIDs.REVIEW_BUTTON); @@ -175,11 +464,10 @@ describe('BatchSellReview', () => { mockSelectedDestinationToken = undefined; mockDestinationTokens = []; - const { getByTestId, getByText, queryByText } = render(); + const { getByTestId, getByText } = render(); const reviewButton = getByTestId(BatchSellReviewSelectorsIDs.REVIEW_BUTTON); expect(getByText('UNKNOWN')).toBeOnTheScreen(); - expect(queryByText('USDC')).toBeNull(); expect(reviewButton.props.accessibilityState.disabled).not.toBe(true); }); @@ -204,25 +492,33 @@ describe('BatchSellReview', () => { expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { screen: Routes.BRIDGE.MODALS.BATCH_SELL_QUOTE_DETAILS_MODAL, - params: { - tokenData: [ - { - key: '0x1:0x1111111111111111111111111111111111111111', - tokenSymbol: 'ETH', - slippage: '2%', - receivedAmount: '-- USDC', - }, - { - key: '0x1:0x2222222222222222222222222222222222222222', - tokenSymbol: 'UNI', - slippage: '2%', - receivedAmount: '-- USDC', - }, - ], - totalReceived: '-- USDC', - minimumReceived: '-- USDC', - isLoading: false, + }); + }); + + it('opens the high price impact info modal from a token row tag', () => { + mockBatchSellQuoteData = { + ...defaultQuoteData, + tokenData: { + ...defaultQuoteData.tokenData, + [ethAssetId]: { + ...defaultQuoteData.tokenData[ethAssetId], + priceImpact: '0.06', + isHighPriceImpact: true, + }, }, + }; + const { getByTestId, getByText } = render(); + + expect(getByText('High price impact')).toBeOnTheScreen(); + fireEvent.press( + getByTestId( + `${BatchSellReviewSelectorsIDs.HIGH_PRICE_IMPACT_TAG}-0x1:0x1111111111111111111111111111111111111111`, + ), + ); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { + screen: Routes.BRIDGE.MODALS.BATCH_SELL_PRICE_IMPACT_INFO_MODAL, + params: { priceImpact: '0.06' }, }); }); @@ -233,38 +529,26 @@ describe('BatchSellReview', () => { expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { screen: Routes.BRIDGE.MODALS.BATCH_SELL_FINAL_REVIEW_MODAL, - params: { - tokenData: [ - { - key: '0x1:0x1111111111111111111111111111111111111111', - tokenSymbol: 'ETH', - slippage: '2%', - receivedAmount: '-- USDC', - }, - { - key: '0x1:0x2222222222222222222222222222222222222222', - tokenSymbol: 'UNI', - slippage: '2%', - receivedAmount: '-- USDC', - }, - ], - totalReceived: '-- USDC', - minimumReceived: '-- USDC', - isLoading: false, - sourceTokens: [ - { - key: '0x1:0x1111111111111111111111111111111111111111', - tokenSymbol: 'ETH', - }, - { - key: '0x1:0x2222222222222222222222222222222222222222', - tokenSymbol: 'UNI', - }, - ], - networkFee: '1.20 USDC', - networkFeeFiat: '$1.20', - metamaskFeePercent: '0.875', - }, + }); + }); + + it('shows Get new quote when max refresh expires and fetches fresh quotes', () => { + mockBatchSellQuoteData = { + ...defaultQuoteData, + needsNewQuote: true, + networkFeeIsLoading: true, + hasPendingQuoteRows: true, + }; + const { getByTestId, getByText } = render(); + const reviewButton = getByTestId(BatchSellReviewSelectorsIDs.REVIEW_BUTTON); + + fireEvent.press(reviewButton); + + expect(getByText('Get new quote')).toBeOnTheScreen(); + expect(reviewButton.props.accessibilityState.disabled).not.toBe(true); + expect(mockGetNewQuote).toHaveBeenCalledTimes(1); + expect(mockNavigate).not.toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { + screen: Routes.BRIDGE.MODALS.BATCH_SELL_FINAL_REVIEW_MODAL, }); }); @@ -282,8 +566,7 @@ describe('BatchSellReview', () => { params: { sourceChainId: '0x1', destChainId: '0x1', - batchSellAssetId: - 'eip155:1/erc20:0x1111111111111111111111111111111111111111', + batchSellAssetId: ethAssetId, }, }); }); @@ -310,13 +593,28 @@ describe('BatchSellReview', () => { }); }); - it('resets bridge state on unmount', () => { + it('updates Batch Sell quote params when Redux inputs are present', () => { + render(); + + expect(mockUpdateBatchSellQuoteParams).toHaveBeenCalled(); + }); + + it('cancels the Batch Sell quote params update on unmount', () => { + const { unmount } = render(); + + unmount(); + + expect(mockCancelBatchSellQuoteParams).toHaveBeenCalled(); + }); + + it('resets controller quote state but leaves Redux bridge state intact on unmount', () => { const { unmount } = render(); mockDispatch.mockClear(); unmount(); - expect(mockDispatch).toHaveBeenCalledWith({ + expect(Engine.context.BridgeController.resetState).toHaveBeenCalledTimes(1); + expect(mockDispatch).not.toHaveBeenCalledWith({ type: 'bridge/resetBridgeState', }); }); diff --git a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.testIds.ts b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.testIds.ts index 76623b19dfc0..08a4e4cdbfc1 100644 --- a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.testIds.ts +++ b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.testIds.ts @@ -5,6 +5,7 @@ export const BatchSellReviewSelectorsIDs = { DESTINATION_TOKEN_PILL: 'batch-sell-review-destination-token-pill', TOKEN_ROW: 'batch-sell-review-token-row', TOKEN_AMOUNT_SKELETON: 'batch-sell-review-token-amount-skeleton', + HIGH_PRICE_IMPACT_TAG: 'batch-sell-review-high-price-impact-tag', TOKEN_SLIDER: 'batch-sell-review-token-slider', CUSTOMIZE_BUTTON: 'batch-sell-review-customize-button', REMOVE_BUTTON: 'batch-sell-review-remove-button', diff --git a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.tsx b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.tsx index 0547acd716e1..0fb0ad9f3442 100644 --- a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.tsx +++ b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.tsx @@ -1,8 +1,9 @@ import { useNavigation } from '@react-navigation/native'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ScrollView } from 'react-native-gesture-handler'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useDispatch, useSelector } from 'react-redux'; +import { formatAddressToAssetId } from '@metamask/bridge-controller'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { AvatarToken, @@ -29,67 +30,82 @@ import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { - resetBridgeState, selectBatchSellSlippages, selectBatchSellDestToken, selectBatchSellDestStablecoins, + selectBatchSellSourceTokenAmounts, selectBatchSellSourceTokens, setBatchSellDestToken, + setBatchSellSourceTokenAmount, + setBatchSellSourceTokenAmounts, setBatchSellSourceTokens, setBatchSellTokenSlippages, } from '../../../../../core/redux/slices/bridge'; import { RootState } from '../../../../../reducers'; +import Engine from '../../../../../core/Engine'; import { BridgeToken } from '../../types'; -import { getBridgeTokenAssetId } from '../../utils/tokenUtils'; -import { - DEFAULT_BATCH_SELL_SLIPPAGE, - getBatchSellSlippage, - getSlippageDisplayValue, -} from '../../components/SlippageModal/utils'; -import { BatchSellFinalReviewSourceTokenData } from '../../components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.types'; +import { getBatchSellSlippage } from '../../components/SlippageModal/utils'; import { BatchSellReviewSelectorsIDs } from './BatchSellReview.testIds'; import { BatchSellReviewTokenRow } from './BatchSellReviewTokenRow'; +import { + getBatchSellAtomicSourceAmount, + getBatchSellSourceTokenAmount, + useBatchSellQuoteRequest, +} from '../../hooks/useBatchSellQuoteRequest'; +import { useBatchSellQuoteData } from '../../hooks/useBatchSellQuoteData'; const DEFAULT_PERCENT = 100; const UNKNOWN_DESTINATION_TOKEN_SYMBOL = 'UNKNOWN'; -// TODO(SWAPS-4439): When Batch Sell quote fetching is wired, pass -// batchSellSlippages[assetId] into each token's BridgeController quote request. -const HAS_QUOTES = true; -const QUOTE_DETAILS_PLACEHOLDER_AMOUNT = '--'; -const NETWORK_FEE_PLACEHOLDER = '1.20 USDC'; -const NETWORK_FEE_FIAT_PLACEHOLDER = '$1.20'; -const METAMASK_FEE_PERCENT = '0.875'; +const TOTAL_RECEIVED_SKELETON_WIDTH = 195; +const TOTAL_RECEIVED_SKELETON_HEIGHT = 50; const getTokenKey = (token: BridgeToken) => `${token.chainId}:${token.address}`; -function getSourceTokenData( - token: BridgeToken, -): BatchSellFinalReviewSourceTokenData { - const sourceTokenData: BatchSellFinalReviewSourceTokenData = { - key: getTokenKey(token), - tokenSymbol: token.symbol, - }; +function TotalReceivedValue({ + totalReceived, + isLoading, +}: { + totalReceived: { formattedFiat: string }; + isLoading: boolean; +}) { + const tw = useTailwind(); - if (token.image) sourceTokenData.image = token.image; + if (isLoading) { + return ( + + ); + } - return sourceTokenData; + return ( + + {totalReceived.formattedFiat} + + ); } -function areBatchSellSlippageMapsEqual( +function areBatchSellValueMapsEqual( first: Record, second: Record, ) { const firstKeys = Object.keys(first); const secondKeys = Object.keys(second); - return ( - firstKeys.length === secondKeys.length && - firstKeys.every( - (assetId) => - Object.prototype.hasOwnProperty.call(second, assetId) && - first[assetId] === second[assetId], - ) - ); + if (firstKeys.length !== secondKeys.length) return false; + + return firstKeys.every((assetId) => { + if (!Object.prototype.hasOwnProperty.call(second, assetId)) return false; + + return Object.is(first[assetId], second[assetId]); + }); } export function BatchSellReview() { @@ -103,10 +119,31 @@ export function BatchSellReview() { ); const selectedDestinationToken = useSelector(selectBatchSellDestToken); const batchSellSlippages = useSelector(selectBatchSellSlippages); + const batchSellSourceTokenAmounts = useSelector( + selectBatchSellSourceTokenAmounts, + ); const isRemoveTokenDisabled = selectedTokens.length <= 2; const [percentsByTokenKey, setPercentsByTokenKey] = useState< Record >({}); + const { updateBatchSellQuoteParams, getNewQuote: handleGetNewQuote } = + useBatchSellQuoteRequest(); + const batchSellQuoteData = useBatchSellQuoteData(); + const hasValidBatchSellInputs = useMemo( + () => + Boolean(selectedDestinationToken) && + selectedTokens.some((token) => { + const assetId = formatAddressToAssetId(token.address, token.chainId); + return ( + assetId && + getBatchSellAtomicSourceAmount( + token, + batchSellSourceTokenAmounts[assetId], + ) + ); + }), + [batchSellSourceTokenAmounts, selectedDestinationToken, selectedTokens], + ); // Seed the selected destination token on entry so the pill always reads from Redux. useEffect(() => { @@ -126,20 +163,62 @@ export function BatchSellReview() { ); }, [selectedTokens]); - // Reset bridge state when component unmounts. + useEffect(() => { + if (hasValidBatchSellInputs) { + updateBatchSellQuoteParams(); + } + + return () => { + updateBatchSellQuoteParams.cancel(); + }; + }, [hasValidBatchSellInputs, updateBatchSellQuoteParams]); + useEffect( () => () => { - dispatch(resetBridgeState()); + // Clear controller quote state so returning to review does not show stale quotes. + Engine.context.BridgeController?.resetState?.(); }, - [dispatch], + [], ); + useEffect(() => { + const nextSourceTokenAmounts = selectedTokens.reduce< + Record + >((sourceAmountsByAssetId, token) => { + const assetId = formatAddressToAssetId(token.address, token.chainId); + + if (!assetId) return sourceAmountsByAssetId; + + sourceAmountsByAssetId[assetId] = + batchSellSourceTokenAmounts[assetId] ?? + getBatchSellSourceTokenAmount( + token, + percentsByTokenKey[getTokenKey(token)] ?? DEFAULT_PERCENT, + ); + return sourceAmountsByAssetId; + }, {}); + + if ( + !areBatchSellValueMapsEqual( + batchSellSourceTokenAmounts, + nextSourceTokenAmounts, + ) + ) { + dispatch(setBatchSellSourceTokenAmounts(nextSourceTokenAmounts)); + } + }, [ + batchSellSourceTokenAmounts, + dispatch, + percentsByTokenKey, + selectedTokens, + ]); + useEffect(() => { // Keep Redux slippages aligned with selected tokens when the user removes tokens. const nextSlippage = selectedTokens.reduce< Record >((slippageByAssetId, token) => { - const assetId = getBridgeTokenAssetId(token); + const assetId = formatAddressToAssetId(token.address, token.chainId); if (!assetId) return slippageByAssetId; @@ -150,7 +229,7 @@ export function BatchSellReview() { return slippageByAssetId; }, {}); - if (!areBatchSellSlippageMapsEqual(batchSellSlippages, nextSlippage)) { + if (!areBatchSellValueMapsEqual(batchSellSlippages, nextSlippage)) { dispatch(setBatchSellTokenSlippages(nextSlippage)); } }, [batchSellSlippages, dispatch, selectedTokens]); @@ -161,8 +240,24 @@ export function BatchSellReview() { ...currentPercents, [tokenKey]: percent, })); + + const token = selectedTokens.find( + (selectedToken) => getTokenKey(selectedToken) === tokenKey, + ); + const assetId = token + ? formatAddressToAssetId(token.address, token.chainId) + : undefined; + + if (!token || !assetId) return; + + dispatch( + setBatchSellSourceTokenAmount({ + assetId, + amount: getBatchSellSourceTokenAmount(token, percent), + }), + ); }, - [], + [dispatch, selectedTokens], ); const handleBackPress = useCallback(() => { @@ -175,54 +270,31 @@ export function BatchSellReview() { }); }, [navigation]); - const getQuoteDetailsParams = useCallback(() => { - const destinationTokenSymbol = - selectedDestinationToken?.symbol ?? UNKNOWN_DESTINATION_TOKEN_SYMBOL; - const placeholderAmount = `${QUOTE_DETAILS_PLACEHOLDER_AMOUNT} ${destinationTokenSymbol}`; - - return { - tokenData: selectedTokens.map((token) => { - const assetId = getBridgeTokenAssetId(token); - const slippage = assetId - ? getBatchSellSlippage(batchSellSlippages, assetId) - : DEFAULT_BATCH_SELL_SLIPPAGE; - - return { - key: getTokenKey(token), - tokenSymbol: token.symbol, - slippage: getSlippageDisplayValue(slippage), - receivedAmount: placeholderAmount, - }; - }), - totalReceived: placeholderAmount, - minimumReceived: placeholderAmount, - isLoading: !HAS_QUOTES, - }; - }, [batchSellSlippages, selectedDestinationToken?.symbol, selectedTokens]); - const handleOpenQuoteDetails = useCallback(() => { navigation.navigate(Routes.BRIDGE.MODALS.ROOT, { screen: Routes.BRIDGE.MODALS.BATCH_SELL_QUOTE_DETAILS_MODAL, - params: getQuoteDetailsParams(), }); - }, [getQuoteDetailsParams, navigation]); + }, [navigation]); + + const handleOpenHighPriceImpactInfo = useCallback( + (priceImpact: string) => { + navigation.navigate(Routes.BRIDGE.MODALS.ROOT, { + screen: Routes.BRIDGE.MODALS.BATCH_SELL_PRICE_IMPACT_INFO_MODAL, + params: { priceImpact }, + }); + }, + [navigation], + ); const handleOpenFinalReview = useCallback(() => { navigation.navigate(Routes.BRIDGE.MODALS.ROOT, { screen: Routes.BRIDGE.MODALS.BATCH_SELL_FINAL_REVIEW_MODAL, - params: { - ...getQuoteDetailsParams(), - sourceTokens: selectedTokens.map(getSourceTokenData), - networkFee: NETWORK_FEE_PLACEHOLDER, - networkFeeFiat: NETWORK_FEE_FIAT_PLACEHOLDER, - metamaskFeePercent: METAMASK_FEE_PERCENT, - }, }); - }, [getQuoteDetailsParams, navigation, selectedTokens]); + }, [navigation]); const handleSlippagePress = useCallback( (token: BridgeToken) => { - const assetId = getBridgeTokenAssetId(token); + const assetId = formatAddressToAssetId(token.address, token.chainId); if (!assetId) return; @@ -262,7 +334,7 @@ export function BatchSellReview() { twClassName="flex-1 bg-default" > - + - diff --git a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReviewTokenRow.test.tsx b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReviewTokenRow.test.tsx index b1adef656e50..8dc60fae609e 100644 --- a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReviewTokenRow.test.tsx +++ b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReviewTokenRow.test.tsx @@ -22,55 +22,6 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({ }), })); -jest.mock('@metamask/design-system-react-native', () => { - const ReactActual = jest.requireActual('react'); - const { - Pressable: RNPressable, - View: RNView, - Text: RNText, - } = jest.requireActual('react-native'); - - return { - AvatarToken: ({ testID }: { testID?: string }) => - ReactActual.createElement(RNView, { testID }), - AvatarTokenSize: { Lg: 'lg' }, - Box: ({ children, ...props }: { children?: React.ReactNode }) => - ReactActual.createElement(RNView, props, children), - BoxAlignItems: { Center: 'center' }, - BoxFlexDirection: { Row: 'row' }, - ButtonIcon: ({ - accessibilityLabel, - isDisabled, - onPress, - testID, - }: { - accessibilityLabel?: string; - isDisabled?: boolean; - onPress?: () => void; - testID?: string; - }) => - ReactActual.createElement( - RNPressable, - { - accessibilityLabel, - accessibilityState: { disabled: Boolean(isDisabled) }, - disabled: isDisabled, - onPress: isDisabled ? undefined : onPress, - testID, - }, - null, - ), - ButtonIconSize: { Md: 'md' }, - FontWeight: { Medium: 'medium' }, - IconColor: { IconAlternative: 'icon-alternative' }, - IconName: { Customize: 'customize', RemoveMinus: 'remove-minus' }, - Text: ({ children, ...props }: { children?: React.ReactNode }) => - ReactActual.createElement(RNText, props, children), - TextColor: { TextAlternative: 'text-alternative' }, - TextVariant: { BodySm: 'body-sm' }, - }; -}); - jest.mock('../../../../../component-library/components-temp/Skeleton', () => { const ReactActual = jest.requireActual('react'); const { View: RNView } = jest.requireActual('react-native'); @@ -119,6 +70,8 @@ describe('BatchSellReviewTokenRow', () => { token={mockToken} tokenKey={mockTokenKey} percent={100} + receivedAmount="123.45 USDC" + isLoading onPercentChange={mockOnPercentChange} onSlippagePress={mockOnSlippagePress} onRemovePress={mockOnRemovePress} @@ -136,12 +89,110 @@ describe('BatchSellReviewTokenRow', () => { expect(getByText('1.49812 ETH • 100%')).toBeOnTheScreen(); }); + it('renders the received amount when loaded', () => { + const { getByText, queryByTestId } = render( + , + ); + + expect(getByText('123.45 USDC')).toBeOnTheScreen(); + expect( + queryByTestId( + `${BatchSellReviewSelectorsIDs.TOKEN_AMOUNT_SKELETON}-${mockTokenKey}`, + ), + ).toBeNull(); + }); + + it('renders and forwards high price impact tag presses', () => { + const mockOnHighPriceImpactPress = jest.fn(); + const { getByTestId, getByText } = render( + , + ); + const tag = getByTestId( + `${BatchSellReviewSelectorsIDs.HIGH_PRICE_IMPACT_TAG}-${mockTokenKey}`, + ); + + expect(getByText('High price impact')).toBeOnTheScreen(); + fireEvent.press(tag); + + expect(mockOnHighPriceImpactPress).toHaveBeenCalledTimes(1); + }); + + it('does not render high price impact tag while loading or unavailable', () => { + const { queryByTestId, rerender } = render( + , + ); + const tagTestId = `${BatchSellReviewSelectorsIDs.HIGH_PRICE_IMPACT_TAG}-${mockTokenKey}`; + + expect(queryByTestId(tagTestId)).toBeNull(); + + rerender( + , + ); + + expect(queryByTestId(tagTestId)).toBeNull(); + }); + + it('renders a no quote available row state', () => { + const { getByText, queryByTestId } = render( + , + ); + + const noQuoteText = getByText('No quote available'); + + expect(noQuoteText).toBeOnTheScreen(); + expect( + queryByTestId( + `${BatchSellReviewSelectorsIDs.TOKEN_AMOUNT_SKELETON}-${mockTokenKey}`, + ), + ).toBeNull(); + }); + it('matches token picker balance formatting for tiny balances', () => { const { getByText } = render( , ); @@ -149,12 +200,27 @@ describe('BatchSellReviewTokenRow', () => { expect(getByText('< 0.00001 ETH • 100%')).toBeOnTheScreen(); }); + it('renders the source amount used in the quote request', () => { + const { getByText } = render( + , + ); + + expect(getByText('0.74906 ETH • 50%')).toBeOnTheScreen(); + }); + it('forwards slider percent changes', () => { const { getByTestId } = render( , ); @@ -174,6 +240,7 @@ describe('BatchSellReviewTokenRow', () => { token={mockToken} tokenKey={mockTokenKey} percent={100} + receivedAmount="123.45 USDC" onPercentChange={mockOnPercentChange} onSlippagePress={mockOnSlippagePress} onRemovePress={mockOnRemovePress} @@ -201,6 +268,7 @@ describe('BatchSellReviewTokenRow', () => { token={mockToken} tokenKey={mockTokenKey} percent={100} + receivedAmount="123.45 USDC" onPercentChange={mockOnPercentChange} onRemovePress={mockOnRemovePress} isRemoveTokenDisabled diff --git a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReviewTokenRow.tsx b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReviewTokenRow.tsx index c5a7be0ae719..b3d614c170ec 100644 --- a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReviewTokenRow.tsx +++ b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReviewTokenRow.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useMemo } from 'react'; +import { Pressable } from 'react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { AvatarToken, @@ -9,8 +10,10 @@ import { ButtonIcon, ButtonIconSize, FontWeight, + Icon, IconColor, IconName, + IconSize, Text, TextColor, TextVariant, @@ -22,11 +25,17 @@ import { formatTokenBalance } from '../../utils'; import { BridgeToken } from '../../types'; import { BatchSellReviewSelectorsIDs } from './BatchSellReview.testIds'; import { BatchSellPercentageSlider } from './BatchSellPercentageSlider'; +import { getBatchSellSourceTokenAmount } from '../../hooks/useBatchSellQuoteRequest'; interface BatchSellReviewTokenRowProps { token: BridgeToken; tokenKey: string; percent: number; + receivedAmount: string; + isLoading?: boolean; + isQuoteUnavailable?: boolean; + isHighPriceImpact?: boolean; + onHighPriceImpactPress?: () => void; onPercentChange: (tokenKey: string, percent: number) => void; onSlippagePress?: (token: BridgeToken) => void; onRemovePress?: (token: BridgeToken) => void; @@ -34,8 +43,9 @@ interface BatchSellReviewTokenRowProps { } function getTokenBalanceText(token: BridgeToken, percent: number) { - const balanceText = token.balance - ? `${formatTokenBalance(token.balance)} ${token.symbol}` + const sourceAmount = getBatchSellSourceTokenAmount(token, percent); + const balanceText = sourceAmount + ? `${formatTokenBalance(sourceAmount)} ${token.symbol}` : token.symbol; return `${balanceText} • ${percent}%`; @@ -45,6 +55,11 @@ export function BatchSellReviewTokenRow({ token, tokenKey, percent, + receivedAmount, + isLoading = false, + isQuoteUnavailable = false, + isHighPriceImpact = false, + onHighPriceImpactPress, onPercentChange, onSlippagePress, onRemovePress, @@ -55,6 +70,8 @@ export function BatchSellReviewTokenRow({ () => getTokenBalanceText(token, percent), [percent, token], ); + const shouldShowHighPriceImpactTag = + !isLoading && !isQuoteUnavailable && isHighPriceImpact; const handlePercentChange = useCallback( (nextPercent: number) => { @@ -85,12 +102,76 @@ export function BatchSellReviewTokenRow({ size={AvatarTokenSize.Lg} /> - + {isLoading ? ( + + ) : isQuoteUnavailable ? ( + + {strings('bridge.batch_sell_no_quote_available')} + + ) : ( + + + {receivedAmount} + + {shouldShowHighPriceImpactTag && ( + + tw.style( + 'rounded-md bg-warning-muted px-1.5 py-0.5', + pressed && 'opacity-70', + ) + } + > + + + + {strings('bridge.batch_sell_high_price_impact')} + + + + )} + + )} ({ type: 'bridge/setBatchSellSourceTokens', payload: tokens, })), + setBatchSellSourceTokenAmounts: jest.fn( + (amounts: Partial>) => ({ + type: 'bridge/setBatchSellSourceTokenAmounts', + payload: amounts, + }), + ), + setBatchSellDestToken: jest.fn((token: BridgeToken | undefined) => ({ + type: 'bridge/setBatchSellDestToken', + payload: token, + })), + setBatchSellTokenSlippages: jest.fn( + (slippages: Partial>) => ({ + type: 'bridge/setBatchSellTokenSlippages', + payload: slippages, + }), + ), })); jest.mock('../../components/TokenSelectorItem', () => { @@ -402,16 +419,17 @@ describe('BatchSellTokenSelect', () => { expect(queryByText('USDC')).not.toBeOnTheScreen(); }); - it('resets bridge state on unmount', () => { + it('resets bridge state on mount', () => { const { unmount } = render(); - expect(mockDispatch).not.toHaveBeenCalledWith({ + expect(mockDispatch).toHaveBeenCalledWith({ type: 'bridge/resetBridgeState', }); + mockDispatch.mockClear(); unmount(); - expect(mockDispatch).toHaveBeenCalledWith({ + expect(mockDispatch).not.toHaveBeenCalledWith({ type: 'bridge/resetBridgeState', }); }); @@ -841,12 +859,15 @@ describe('BatchSellTokenSelect', () => { }); }); - it('dispatches selected source tokens for multi-token handoff', () => { + it('dispatches Batch Sell Redux handoff data for multi-token Continue', () => { + const stablecoinAssetId = + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as CaipAssetType; const firstToken = createToken({ symbol: 'ONE' }); const secondToken = createToken({ symbol: 'TWO', address: '0x2222222222222222222222222222222222222222', }); + mockDestinationStablecoins = [BridgeTokenMetadata[stablecoinAssetId]]; mockWalletTokens = [firstToken, secondToken]; const { getByTestId, getByText } = render(); @@ -855,12 +876,33 @@ describe('BatchSellTokenSelect', () => { fireEvent.press(getByText('TWO')); expect(getByText('Continue with (2) tokens')).toBeOnTheScreen(); + mockDispatch.mockClear(); fireEvent.press(getByTestId(BatchSellTokenSelectSelectorsIDs.NEXT_BUTTON)); - expect(mockDispatch).toHaveBeenCalledWith({ + expect(mockDispatch).toHaveBeenNthCalledWith(1, { type: 'bridge/setBatchSellSourceTokens', payload: [firstToken, secondToken], }); + expect(mockDispatch).toHaveBeenNthCalledWith(2, { + type: 'bridge/setBatchSellSourceTokenAmounts', + payload: { + 'eip155:1/erc20:0x1111111111111111111111111111111111111111': '1', + 'eip155:1/erc20:0x2222222222222222222222222222222222222222': '1', + }, + }); + expect(mockDispatch).toHaveBeenNthCalledWith(3, { + type: 'bridge/setBatchSellDestToken', + payload: BridgeTokenMetadata[stablecoinAssetId], + }); + expect(mockDispatch).toHaveBeenNthCalledWith(4, { + type: 'bridge/setBatchSellTokenSlippages', + payload: { + 'eip155:1/erc20:0x1111111111111111111111111111111111111111': + DEFAULT_BATCH_SELL_SLIPPAGE, + 'eip155:1/erc20:0x2222222222222222222222222222222222222222': + DEFAULT_BATCH_SELL_SLIPPAGE, + }, + }); expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.BATCH_SELL_REVIEW); }); }); diff --git a/app/components/UI/Bridge/Views/BatchSellTokenSelect/BatchSellTokenSelect.tsx b/app/components/UI/Bridge/Views/BatchSellTokenSelect/BatchSellTokenSelect.tsx index 92a9dbab4959..aaedc61360e2 100644 --- a/app/components/UI/Bridge/Views/BatchSellTokenSelect/BatchSellTokenSelect.tsx +++ b/app/components/UI/Bridge/Views/BatchSellTokenSelect/BatchSellTokenSelect.tsx @@ -4,6 +4,7 @@ import { ListRenderItemInfo, Pressable } from 'react-native'; import { FlatList, ScrollView } from 'react-native-gesture-handler'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useDispatch, useSelector } from 'react-redux'; +import BigNumber from 'bignumber.js'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { AvatarBaseShape, @@ -25,8 +26,11 @@ import { TextColor, TextVariant, } from '@metamask/design-system-react-native'; -import { formatChainIdToCaip } from '@metamask/bridge-controller'; -import { CaipChainId } from '@metamask/utils'; +import { + formatAddressToAssetId, + formatChainIdToCaip, +} from '@metamask/bridge-controller'; +import { CaipAssetType, CaipChainId } from '@metamask/utils'; import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; @@ -34,7 +38,10 @@ import { resetBridgeState, selectBatchSellDestStablecoins, selectBatchSellDestStablecoinsByChain, + setBatchSellDestToken, + setBatchSellSourceTokenAmounts, setBatchSellSourceTokens, + setBatchSellTokenSlippages, } from '../../../../../core/redux/slices/bridge'; import { RootState } from '../../../../../reducers'; import { BridgeToken } from '../../types'; @@ -54,10 +61,51 @@ import { import { BatchSellTokenSelectSelectorsIDs } from './BatchSellTokenSelect.testIds'; import { BatchSellTokenRow } from './BatchSellTokenRow'; import { BatchSellEmptyState } from './BatchSellEmptyState'; +import { DEFAULT_BATCH_SELL_SLIPPAGE } from '../../components/SlippageModal/utils'; const getTokenKey = (token: BridgeToken) => `${formatChainIdToCaip(token.chainId)}:${token.address}`; +function getBatchSellSourceTokenAmount(token: BridgeToken, percent: number) { + if (percent <= 0) return '0'; + if (!token.balance) return undefined; + + const sourceAmount = new BigNumber(token.balance).times(percent).div(100); + + return sourceAmount.isFinite() ? sourceAmount.toFixed() : undefined; +} + +function getDefaultBatchSellSlippages(selectedTokens: BridgeToken[]) { + return selectedTokens.reduce>>( + (slippagesByAssetId, token) => { + const assetId = formatAddressToAssetId(token.address, token.chainId); + + if (assetId) { + slippagesByAssetId[assetId] = DEFAULT_BATCH_SELL_SLIPPAGE; + } + + return slippagesByAssetId; + }, + {}, + ); +} + +function getDefaultBatchSellSourceTokenAmounts(selectedTokens: BridgeToken[]) { + return selectedTokens.reduce>>( + (sourceAmountsByAssetId, token) => { + const assetId = formatAddressToAssetId(token.address, token.chainId); + const amount = getBatchSellSourceTokenAmount(token, 100); + + if (assetId) { + sourceAmountsByAssetId[assetId] = amount; + } + + return sourceAmountsByAssetId; + }, + {}, + ); +} + export function BatchSellTokenSelect() { const navigation = useNavigation(); const dispatch = useDispatch(); @@ -85,13 +133,9 @@ export function BatchSellTokenSelect() { >(() => sortedEligibleChains[0]?.chainId); const [selectedTokens, setSelectedTokens] = useState([]); - // Reset bridge state when component unmounts. - useEffect( - () => () => { - dispatch(resetBridgeState()); - }, - [dispatch], - ); + useEffect(() => { + dispatch(resetBridgeState()); + }, [dispatch]); useEffect(() => { // Default to the highest-value chain once balances load, but preserve a @@ -222,6 +266,22 @@ export function BatchSellTokenSelect() { } dispatch(setBatchSellSourceTokens(selectedTokens)); + dispatch( + setBatchSellSourceTokenAmounts( + getDefaultBatchSellSourceTokenAmounts(selectedTokens), + ), + ); + dispatch( + setBatchSellDestToken( + getBatchSellDestinationToken( + selectedTokens[0].chainId, + destinationStablecoins, + ), + ), + ); + dispatch( + setBatchSellTokenSlippages(getDefaultBatchSellSlippages(selectedTokens)), + ); navigation.navigate(Routes.BRIDGE.BATCH_SELL_REVIEW); }, [destinationStablecoins, dispatch, navigation, selectedTokens]); diff --git a/app/components/UI/Bridge/Views/BatchSellTokenSelect/BatchSellTokenSelect.utils.ts b/app/components/UI/Bridge/Views/BatchSellTokenSelect/BatchSellTokenSelect.utils.ts index 1f886891245e..6de56257880e 100644 --- a/app/components/UI/Bridge/Views/BatchSellTokenSelect/BatchSellTokenSelect.utils.ts +++ b/app/components/UI/Bridge/Views/BatchSellTokenSelect/BatchSellTokenSelect.utils.ts @@ -1,11 +1,11 @@ import { + formatAddressToAssetId, formatChainIdToCaip, formatChainIdToHex, } from '@metamask/bridge-controller'; import { CaipAssetType, CaipChainId } from '@metamask/utils'; import { NETWORK_TO_SHORT_NETWORK_NAME_MAP } from '../../../../../constants/bridge'; import { BridgeToken } from '../../types'; -import { getBridgeTokenAssetId } from '../../utils/tokenUtils'; export const MAX_BATCH_SELL_SOURCE_TOKENS = 5; // TODO: The fetching of 7702 chains needs to be dynamic so there's no need for @@ -55,7 +55,9 @@ export function removeStablecoinsFromSourceTokens({ chainId as CaipChainId, new Set( (stablecoins ?? []) - .map((stablecoin) => getBridgeTokenAssetId(stablecoin)) + .map((stablecoin) => + formatAddressToAssetId(stablecoin.address, stablecoin.chainId), + ) .filter((assetId): assetId is CaipAssetType => Boolean(assetId)), ), ]), @@ -69,7 +71,7 @@ export function removeStablecoinsFromSourceTokens({ return true; } - const assetId = getBridgeTokenAssetId(token); + const assetId = formatAddressToAssetId(token.address, token.chainId); if (!assetId) { return true; diff --git a/app/components/UI/Bridge/_mocks_/bridgeReducerState.ts b/app/components/UI/Bridge/_mocks_/bridgeReducerState.ts index 20099b12c4cc..7824ba23aee5 100644 --- a/app/components/UI/Bridge/_mocks_/bridgeReducerState.ts +++ b/app/components/UI/Bridge/_mocks_/bridgeReducerState.ts @@ -39,6 +39,7 @@ export const mockBridgeReducerState: BridgeState = { visiblePillChainIds: undefined, selectedQuoteRequestId: undefined, batchSellSourceTokens: [], + batchSellSourceTokenAmounts: {}, batchSellDestToken: undefined, batchSellSlippages: {}, }; diff --git a/app/components/UI/Bridge/_mocks_/initialState.ts b/app/components/UI/Bridge/_mocks_/initialState.ts index 3cc24e1fd760..d93addab3e51 100644 --- a/app/components/UI/Bridge/_mocks_/initialState.ts +++ b/app/components/UI/Bridge/_mocks_/initialState.ts @@ -770,6 +770,7 @@ export const initialState = { slippage: '0.5', batchSellSlippages: {}, batchSellSourceTokens: [], + batchSellSourceTokenAmounts: {}, batchSellDestToken: undefined, isSubmittingTx: false, bridgeViewMode: undefined, diff --git a/app/components/UI/Bridge/components/BatchSellDestinationTokenSelectorModal/BatchSellDestinationTokenSelectorModal.test.tsx b/app/components/UI/Bridge/components/BatchSellDestinationTokenSelectorModal/BatchSellDestinationTokenSelectorModal.test.tsx index 31029c57b373..17309381d101 100644 --- a/app/components/UI/Bridge/components/BatchSellDestinationTokenSelectorModal/BatchSellDestinationTokenSelectorModal.test.tsx +++ b/app/components/UI/Bridge/components/BatchSellDestinationTokenSelectorModal/BatchSellDestinationTokenSelectorModal.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { fireEvent, render } from '@testing-library/react-native'; import { CaipAssetType, Hex } from '@metamask/utils'; +import { formatAddressToAssetId } from '@metamask/bridge-controller'; import { BridgeTokenMetadata } from '../../constants/tokens'; import { BridgeToken } from '../../types'; @@ -14,6 +15,10 @@ const usdcAssetId = 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as CaipAssetType; const usdtAssetId = 'eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7' as CaipAssetType; +const usdcBalanceAssetId = formatAddressToAssetId( + BridgeTokenMetadata[usdcAssetId].address, + BridgeTokenMetadata[usdcAssetId].chainId, +) as CaipAssetType; const mockSourceToken: BridgeToken = { address: '0x1111111111111111111111111111111111111111', chainId: '0x1' as Hex, @@ -24,7 +29,7 @@ let mockSelectedDestinationToken: BridgeToken | undefined; let mockDestinationStablecoins: BridgeToken[]; let mockBalancesByAssetId: Record< string, - { balance: string; balanceFiat?: string } + { balance: string; balanceFiat?: string; tokenFiatAmount?: number } >; jest.mock('@react-navigation/native', () => ({ @@ -168,9 +173,10 @@ describe('BatchSellDestinationTokenSelectorModal', () => { it('renders the stablecoin fiat value from wallet balances', () => { mockBalancesByAssetId = { - [usdcAssetId]: { + [usdcBalanceAssetId]: { balance: '123', balanceFiat: '$123.00', + tokenFiatAmount: 123, }, }; @@ -183,9 +189,36 @@ describe('BatchSellDestinationTokenSelectorModal', () => { expect(queryByText('123 USDC')).not.toBeOnTheScreen(); }); - it('does not render a balance fallback when fiat value is missing', () => { - const { queryByText } = render(); + it('falls back to the stablecoin balance when fiat value is missing', () => { + mockBalancesByAssetId = { + [usdcBalanceAssetId]: { + balance: '123', + }, + }; + + const { getByText, queryByText } = render( + , + ); + + expect(getByText('123 USDC')).toBeOnTheScreen(); + expect(queryByText('0')).not.toBeOnTheScreen(); + }); + + it('falls back to the stablecoin balance when fiat value is zero for a nonzero balance', () => { + mockBalancesByAssetId = { + [usdcBalanceAssetId]: { + balance: '123', + balanceFiat: '$0.00', + tokenFiatAmount: 0, + }, + }; + + const { getByText, queryByText } = render( + , + ); + expect(getByText('123 USDC')).toBeOnTheScreen(); + expect(queryByText('$0.00')).not.toBeOnTheScreen(); expect(queryByText('0')).not.toBeOnTheScreen(); }); diff --git a/app/components/UI/Bridge/components/BatchSellDestinationTokenSelectorModal/index.tsx b/app/components/UI/Bridge/components/BatchSellDestinationTokenSelectorModal/index.tsx index 9d35612d24da..1c06e9390e99 100644 --- a/app/components/UI/Bridge/components/BatchSellDestinationTokenSelectorModal/index.tsx +++ b/app/components/UI/Bridge/components/BatchSellDestinationTokenSelectorModal/index.tsx @@ -2,6 +2,8 @@ import { useNavigation } from '@react-navigation/native'; import React, { useCallback, useMemo, useRef } from 'react'; import { Pressable } from 'react-native'; import { useDispatch, useSelector } from 'react-redux'; +import BigNumber from 'bignumber.js'; +import { formatAddressToAssetId } from '@metamask/bridge-controller'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { AvatarToken, @@ -20,8 +22,10 @@ import { } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; -import { useBalancesByAssetId } from '../../hooks/useBalancesByAssetId'; -import { getBridgeTokenAssetId } from '../../utils/tokenUtils'; +import { + type BalanceData, + useBalancesByAssetId, +} from '../../hooks/useBalancesByAssetId'; import { selectBatchSellDestStablecoins, selectBatchSellDestToken, @@ -30,6 +34,7 @@ import { } from '../../../../../core/redux/slices/bridge'; import { RootState } from '../../../../../reducers'; import { BridgeToken } from '../../types'; +import { formatTokenBalance } from '../../utils'; import { BatchSellDestinationTokenSelectorModalSelectorsIDs } from './BatchSellDestinationTokenSelectorModal.testIds'; const getTokenKey = (token: BridgeToken) => `${token.chainId}:${token.address}`; @@ -42,6 +47,26 @@ const isSameToken = (tokenA?: BridgeToken, tokenB?: BridgeToken) => tokenA.address.toLowerCase() === tokenB.address.toLowerCase(), ); +function getStablecoinBalanceDisplayValue( + balanceData: BalanceData | undefined, + symbol: string, +) { + const balance = balanceData?.balance; + + if (!balance) return undefined; + + const hasNonZeroTokenBalance = new BigNumber(balance).gt(0); + const hasMissingFiatValue = + !balanceData.balanceFiat || + (balanceData.tokenFiatAmount === 0 && hasNonZeroTokenBalance); + + if (hasMissingFiatValue) { + return `${formatTokenBalance(balance)} ${symbol}`; + } + + return balanceData.balanceFiat; +} + export function BatchSellDestinationTokenSelectorModal() { const navigation = useNavigation(); const dispatch = useDispatch(); @@ -94,10 +119,11 @@ export function BatchSellDestinationTokenSelectorModal() { {destinationTokens.map((token) => { const tokenKey = getTokenKey(token); const isSelected = isSameToken(token, selectedDestinationToken); - const assetId = getBridgeTokenAssetId(token); - const tokenFiatValue = assetId - ? balancesByAssetId[assetId]?.balanceFiat - : undefined; + const assetId = formatAddressToAssetId(token.address, token.chainId); + const tokenBalanceValue = getStablecoinBalanceDisplayValue( + assetId ? balancesByAssetId[assetId] : undefined, + token.symbol, + ); return ( - {tokenFiatValue ? ( + {tokenBalanceValue ? ( - {tokenFiatValue} + {tokenBalanceValue} ) : null} diff --git a/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.test.tsx b/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.test.tsx index 4d6f80946056..9b0d0c575dbe 100644 --- a/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.test.tsx +++ b/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.test.tsx @@ -1,64 +1,168 @@ import React from 'react'; +import { StyleSheet } from 'react-native'; import { fireEvent, render } from '@testing-library/react-native'; +import { lightTheme } from '@metamask/design-tokens'; import Routes from '../../../../../constants/navigation/Routes'; import { BatchSellQuoteDetailsModalSelectorsIDs } from '../BatchSellQuoteDetailsModal/BatchSellQuoteDetailsModal.testIds'; import { BatchSellFinalReviewModal } from './index'; import { BatchSellFinalReviewModalSelectorsIDs } from './BatchSellFinalReviewModal.testIds'; -import { BatchSellFinalReviewModalParams } from './BatchSellFinalReviewModal.types'; const mockGoBack = jest.fn(); const mockReplace = jest.fn(); -let mockRouteParams: BatchSellFinalReviewModalParams; +const mockUpdateBatchSellQuoteParams = jest.fn(); +const mockGetNewQuote = jest.fn(); +const mockUseBatchSellHasSufficientGas = jest.fn((_params: unknown) => true); +const errorTextColor = lightTheme.colors.error.default; +const ethAssetId = 'eip155:1/erc20:0x1111111111111111111111111111111111111111'; +const uniAssetId = 'eip155:1/erc20:0x2222222222222222222222222222222222222222'; +const linkAssetId = 'eip155:1/erc20:0x3333333333333333333333333333333333333333'; +const defaultSelectedTokens = [ + { + address: '0x1111111111111111111111111111111111111111', + chainId: '0x1', + decimals: 18, + symbol: 'ETH', + image: 'eth-image-url', + }, + { + address: '0x2222222222222222222222222222222222222222', + chainId: '0x1', + decimals: 18, + symbol: 'UNI', + }, +]; +const linkToken = { + address: '0x3333333333333333333333333333333333333333', + chainId: '0x1', + decimals: 18, + symbol: 'LINK', +}; -jest.mock('@react-navigation/native', () => ({ - useNavigation: () => ({ - goBack: mockGoBack, - replace: mockReplace, - }), -})); +interface MockQuoteTokenData { + key: string; + tokenSymbol: string; + slippage: string; + receivedAmount: string; + receivedAmountFiat: string; + isLoading: boolean; + isHighPriceImpact: boolean; + isQuoteUnavailable: boolean; +} -jest.mock('../../../../../util/navigation/navUtils', () => ({ - useParams: () => mockRouteParams, -})); +interface MockBatchSellQuoteData { + tokenData: Record; + totalReceived: { formatted: string }; + minimumReceived: { formatted: string }; + isLoading: boolean; + isSummaryLoading: boolean; + isGasless: boolean; + hasAnyQuote: boolean; + hasPendingQuoteRows: boolean; + needsNewQuote: boolean; + quotePercentFee?: string; + networkFee: { + amount?: string; + valueInCurrency?: string | null; + asset?: { + address: string; + assetId: string; + chainId: number; + decimals: number; + name: string; + symbol: string; + }; + formatted: string; + formattedFiat: string; + }; + networkFeeIsLoading: boolean; +} -const defaultParams: BatchSellFinalReviewModalParams = { - sourceTokens: [ - { - key: 'eth', - tokenSymbol: 'ETH', - image: 'eth-image-url', - }, - { - key: 'uni', - tokenSymbol: 'UNI', - }, - ], - tokenData: [ - { - key: 'eth', +const defaultQuoteData: MockBatchSellQuoteData = { + tokenData: { + [ethAssetId]: { + key: ethAssetId, tokenSymbol: 'ETH', slippage: '0.5%', receivedAmount: '3,456.78 USDC', + receivedAmountFiat: '$3,456.78', + isLoading: false, + isHighPriceImpact: false, + isQuoteUnavailable: false, }, - { - key: 'uni', + [uniAssetId]: { + key: uniAssetId, tokenSymbol: 'UNI', slippage: '0.5%', receivedAmount: '500 USDC', + receivedAmountFiat: '$500.00', + isLoading: false, + isHighPriceImpact: false, + isQuoteUnavailable: false, }, - ], - totalReceived: '+7,638.23 USDC', - minimumReceived: '+7,485.47 USDC', + }, + totalReceived: { formatted: '7,638.23 USDC' }, + minimumReceived: { formatted: '7,485.47 USDC' }, isLoading: false, - networkFee: '1.20 USDC', - networkFeeFiat: '$1.20', - metamaskFeePercent: '0.875', + isSummaryLoading: false, + isGasless: false, + hasAnyQuote: true, + hasPendingQuoteRows: false, + needsNewQuote: false, + quotePercentFee: '1.25', + networkFee: { + amount: '1.2', + valueInCurrency: '1.2', + asset: { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:1/slip44:60', + chainId: 1, + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + formatted: '1.20 USDC', + formattedFiat: '$1.20', + }, + networkFeeIsLoading: false, }; +let mockSelectedTokens = defaultSelectedTokens; +let mockBatchSellQuoteData = defaultQuoteData; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + goBack: mockGoBack, + replace: mockReplace, + }), +})); + +jest.mock('react-redux', () => ({ + useSelector: (selector: (state: unknown) => unknown) => selector({}), +})); -function renderModal(overrides: Partial = {}) { - mockRouteParams = { - ...defaultParams, +jest.mock('../../../../../core/redux/slices/bridge', () => ({ + selectBatchSellSourceTokens: jest.fn(() => mockSelectedTokens), +})); + +jest.mock('../../hooks/useBatchSellQuoteData', () => ({ + useBatchSellQuoteData: jest.fn(() => mockBatchSellQuoteData), +})); + +jest.mock('../../hooks/useBatchSellQuoteRequest', () => ({ + useBatchSellQuoteRequest: jest.fn(() => ({ + updateBatchSellQuoteParams: mockUpdateBatchSellQuoteParams, + getNewQuote: mockGetNewQuote, + })), +})); + +jest.mock('../../hooks/useBatchSellHasSufficientGas', () => ({ + useBatchSellHasSufficientGas: (params: unknown) => + mockUseBatchSellHasSufficientGas(params), +})); + +function renderModal(overrides: Partial = {}) { + mockBatchSellQuoteData = { + ...defaultQuoteData, ...overrides, }; @@ -68,11 +172,18 @@ function renderModal(overrides: Partial = {}) { describe('BatchSellFinalReviewModal', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouteParams = defaultParams; + mockSelectedTokens = defaultSelectedTokens; + mockBatchSellQuoteData = defaultQuoteData; + mockUpdateBatchSellQuoteParams.mockClear(); + mockGetNewQuote.mockClear(); + mockUseBatchSellHasSufficientGas.mockReturnValue(true); }); - it('renders the final review sheet content from route params', () => { + it('renders the final review sheet content from live quote data', () => { const { getByTestId, getByText } = renderModal(); + const sellAllButton = getByTestId( + BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON, + ); expect( getByTestId(BatchSellFinalReviewModalSelectorsIDs.SHEET), @@ -83,14 +194,27 @@ describe('BatchSellFinalReviewModal', () => { expect(getByText('ETH • 0.5% slippage')).toBeOnTheScreen(); expect(getByText('3,456.78 USDC')).toBeOnTheScreen(); expect(getByText('Total received')).toBeOnTheScreen(); - expect(getByText('+7,638.23 USDC')).toBeOnTheScreen(); + expect(getByText('7,638.23 USDC')).toBeOnTheScreen(); expect(getByText('Minimum received')).toBeOnTheScreen(); - expect(getByText('+7,485.47 USDC')).toBeOnTheScreen(); + expect(getByText('7,485.47 USDC')).toBeOnTheScreen(); expect(getByText('Network fee')).toBeOnTheScreen(); expect(getByText('1.20 USDC')).toBeOnTheScreen(); expect(getByText('$1.20')).toBeOnTheScreen(); expect(getByText('Sell all')).toBeOnTheScreen(); - expect(getByText('Includes 0.875% MetaMask fee')).toBeOnTheScreen(); + expect(sellAllButton.props.accessibilityState.disabled).not.toBe(true); + expect(getByText('Includes 1.25% MetaMask fee')).toBeOnTheScreen(); + }); + + it('hides the MetaMask fee disclosure when quoteBpsFee has no fee', () => { + const { queryByTestId } = renderModal({ + quotePercentFee: undefined, + }); + + expect( + queryByTestId( + BatchSellFinalReviewModalSelectorsIDs.METAMASK_FEE_DISCLOSURE, + ), + ).toBeNull(); }); it('closes with navigation when the close button is pressed', () => { @@ -113,9 +237,9 @@ describe('BatchSellFinalReviewModal', () => { expect(queryByText('ETH • 0.5% slippage')).toBeNull(); expect(queryByText('UNI • 0.5% slippage')).toBeNull(); expect(getByText('Total received')).toBeOnTheScreen(); - expect(getByText('+7,638.23 USDC')).toBeOnTheScreen(); + expect(getByText('7,638.23 USDC')).toBeOnTheScreen(); expect(getByText('Minimum received')).toBeOnTheScreen(); - expect(getByText('+7,485.47 USDC')).toBeOnTheScreen(); + expect(getByText('7,485.47 USDC')).toBeOnTheScreen(); }); it('expands token rows after they are collapsed', () => { @@ -131,6 +255,30 @@ describe('BatchSellFinalReviewModal', () => { expect(getByText('UNI • 0.5% slippage')).toBeOnTheScreen(); }); + it('shows only quoted rows and source tokens', () => { + mockSelectedTokens = [...defaultSelectedTokens, linkToken]; + const { getByText, queryByText } = renderModal({ + tokenData: { + ...defaultQuoteData.tokenData, + [linkAssetId]: { + key: linkAssetId, + tokenSymbol: 'LINK', + slippage: '0.5%', + receivedAmount: '-- USDC', + receivedAmountFiat: '-', + isLoading: false, + isHighPriceImpact: false, + isQuoteUnavailable: true, + }, + }, + }); + + expect(getByText('2 tokens')).toBeOnTheScreen(); + expect(getByText('ETH • 0.5% slippage')).toBeOnTheScreen(); + expect(getByText('UNI • 0.5% slippage')).toBeOnTheScreen(); + expect(queryByText('LINK • 0.5% slippage')).toBeNull(); + }); + it('switches to the minimum received info modal when the info button is pressed', () => { const { getByTestId } = renderModal(); @@ -145,7 +293,6 @@ describe('BatchSellFinalReviewModal', () => { { sourceModal: { screen: Routes.BRIDGE.MODALS.BATCH_SELL_FINAL_REVIEW_MODAL, - params: defaultParams, }, }, ); @@ -165,21 +312,40 @@ describe('BatchSellFinalReviewModal', () => { { sourceModal: { screen: Routes.BRIDGE.MODALS.BATCH_SELL_FINAL_REVIEW_MODAL, - params: defaultParams, }, }, ); }); - it('renders quote amount skeletons while loading', () => { - const { getByTestId, getByText, queryByText } = renderModal({ + it('keeps token rows visible as skeletons while loading and disables Sell all', () => { + const { getByTestId, getByText, queryByTestId, queryByText } = renderModal({ + tokenData: { + [ethAssetId]: { + ...defaultQuoteData.tokenData[ethAssetId], + isLoading: true, + }, + [uniAssetId]: { + ...defaultQuoteData.tokenData[uniAssetId], + isLoading: true, + }, + }, isLoading: true, + isSummaryLoading: true, + hasAnyQuote: false, + hasPendingQuoteRows: true, }); + expect(getByText('2 tokens')).toBeOnTheScreen(); expect(getByText('ETH • 0.5% slippage')).toBeOnTheScreen(); + expect(getByText('UNI • 0.5% slippage')).toBeOnTheScreen(); expect( getByTestId( - `${BatchSellQuoteDetailsModalSelectorsIDs.QUOTE_ROW_RECEIVED_AMOUNT_SKELETON}-eth`, + `${BatchSellQuoteDetailsModalSelectorsIDs.QUOTE_ROW_RECEIVED_AMOUNT_SKELETON}-${ethAssetId}`, + ), + ).toBeOnTheScreen(); + expect( + getByTestId( + `${BatchSellQuoteDetailsModalSelectorsIDs.QUOTE_ROW_RECEIVED_AMOUNT_SKELETON}-${uniAssetId}`, ), ).toBeOnTheScreen(); expect( @@ -193,6 +359,108 @@ describe('BatchSellFinalReviewModal', () => { ), ).toBeOnTheScreen(); expect(queryByText('3,456.78 USDC')).toBeNull(); - expect(queryByText('+7,638.23 USDC')).toBeNull(); + expect(queryByText('7,638.23 USDC')).toBeNull(); + expect( + queryByTestId( + `${BatchSellFinalReviewModalSelectorsIDs.SOURCE_TOKEN_AVATAR}-${linkAssetId}`, + ), + ).toBeNull(); + expect( + getByTestId(BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON).props + .accessibilityState.disabled, + ).toBe(true); + expect( + getByTestId(BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON).props + .accessibilityState.busy, + ).toBe(true); + }); + + it('renders a network fee values skeleton while the network fee is loading', () => { + const { getByTestId, getByText, queryByText } = renderModal({ + networkFeeIsLoading: true, + }); + + expect( + getByTestId( + BatchSellFinalReviewModalSelectorsIDs.NETWORK_FEE_VALUES_SKELETON, + ), + ).toBeOnTheScreen(); + expect(getByText('Network fee')).toBeOnTheScreen(); + expect(queryByText('1.20 USDC')).toBeNull(); + expect(queryByText('$1.20')).toBeNull(); + expect( + getByTestId(BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON).props + .accessibilityState.disabled, + ).toBe(true); + expect( + getByTestId(BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON).props + .accessibilityState.busy, + ).toBe(true); + }); + + it('blocks Sell all and highlights the network fee when gas is insufficient', () => { + mockUseBatchSellHasSufficientGas.mockReturnValue(false); + + const { getByTestId, getByText } = renderModal(); + const getTextColor = (text: string) => + StyleSheet.flatten(getByText(text).props.style).color; + + expect(getByText('Insufficient funds')).toBeOnTheScreen(); + expect(getTextColor('Network fee')).toBe(errorTextColor); + expect(getTextColor('1.20 USDC')).toBe(errorTextColor); + expect(getTextColor('$1.20')).toBe(errorTextColor); + expect( + getByTestId(BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON).props + .accessibilityState.disabled, + ).toBe(true); + expect( + getByTestId(BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON).props + .accessibilityState.busy, + ).not.toBe(true); + }); + + it('shows Get new quote when max refresh expires and fetches fresh quotes', () => { + mockUseBatchSellHasSufficientGas.mockReturnValue(false); + + const { getByTestId, getByText } = renderModal({ + needsNewQuote: true, + networkFeeIsLoading: true, + hasPendingQuoteRows: true, + }); + const button = getByTestId( + BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON, + ); + + fireEvent.press(button); + + expect(getByText('Get new quote')).toBeOnTheScreen(); + expect(button.props.accessibilityState.disabled).not.toBe(true); + expect(button.props.accessibilityState.busy).not.toBe(true); + expect(mockGetNewQuote).toHaveBeenCalledTimes(1); + }); + + it('updates quote values from live data while mounted', () => { + const { getByText, rerender } = renderModal(); + + expect(getByText('7,638.23 USDC')).toBeOnTheScreen(); + + mockBatchSellQuoteData = { + ...defaultQuoteData, + tokenData: { + ...defaultQuoteData.tokenData, + [ethAssetId]: { + ...defaultQuoteData.tokenData[ethAssetId], + receivedAmount: '3,500 USDC', + }, + }, + totalReceived: { formatted: '7,700 USDC' }, + minimumReceived: { formatted: '7,650 USDC' }, + }; + + rerender(); + + expect(getByText('3,500 USDC')).toBeOnTheScreen(); + expect(getByText('7,700 USDC')).toBeOnTheScreen(); + expect(getByText('7,650 USDC')).toBeOnTheScreen(); }); }); diff --git a/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.testIds.ts b/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.testIds.ts index 90ca1d4a8b71..d88fc1be03ae 100644 --- a/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.testIds.ts +++ b/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.testIds.ts @@ -6,6 +6,8 @@ export const BatchSellFinalReviewModalSelectorsIDs = { SOURCE_TOKEN_AVATAR: 'batch-sell-final-review-source-token-avatar', NETWORK_FEE_ROW: 'batch-sell-final-review-network-fee-row', NETWORK_FEE_INFO_BUTTON: 'batch-sell-final-review-network-fee-info-button', + NETWORK_FEE_VALUES_SKELETON: + 'batch-sell-final-review-network-fee-values-skeleton', SELL_ALL_BUTTON: 'batch-sell-final-review-sell-all-button', METAMASK_FEE_DISCLOSURE: 'batch-sell-final-review-metamask-fee-disclosure', }; diff --git a/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.types.ts b/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.types.ts deleted file mode 100644 index 27c3781ff21a..000000000000 --- a/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { BatchSellQuoteDetailsModalParams } from '../BatchSellQuoteDetailsModal/BatchSellQuoteDetailsModal.types'; - -export interface BatchSellFinalReviewSourceTokenData { - key: string; - tokenSymbol: string; - image?: string; -} - -export interface BatchSellFinalReviewModalParams - extends BatchSellQuoteDetailsModalParams { - sourceTokens: BatchSellFinalReviewSourceTokenData[]; - networkFee: string; - networkFeeFiat: string; - metamaskFeePercent: string; -} diff --git a/app/components/UI/Bridge/components/BatchSellFinalReviewModal/index.tsx b/app/components/UI/Bridge/components/BatchSellFinalReviewModal/index.tsx index 7c9a58c7a2c7..23b96a144672 100644 --- a/app/components/UI/Bridge/components/BatchSellFinalReviewModal/index.tsx +++ b/app/components/UI/Bridge/components/BatchSellFinalReviewModal/index.tsx @@ -1,8 +1,10 @@ import { useNavigation } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; -import React, { useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { Pressable } from 'react-native'; +import { useSelector } from 'react-redux'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { formatAddressToAssetId } from '@metamask/bridge-controller'; import { AvatarToken, AvatarTokenSize, @@ -28,21 +30,67 @@ import { import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; -import { useParams } from '../../../../../util/navigation/navUtils'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; +import { selectBatchSellSourceTokens } from '../../../../../core/redux/slices/bridge'; +import { + type BatchSellQuoteTokenData, + useBatchSellQuoteData, +} from '../../hooks/useBatchSellQuoteData'; +import { useBatchSellQuoteRequest } from '../../hooks/useBatchSellQuoteRequest'; +import { useBatchSellHasSufficientGas } from '../../hooks/useBatchSellHasSufficientGas'; +import type { BridgeToken } from '../../types'; import { BatchSellQuoteDetails } from '../BatchSellQuoteDetailsModal'; import { BatchSellFinalReviewModalSelectorsIDs } from './BatchSellFinalReviewModal.testIds'; -import { - BatchSellFinalReviewModalParams, - BatchSellFinalReviewSourceTokenData, -} from './BatchSellFinalReviewModal.types'; const MAX_VISIBLE_SOURCE_TOKEN_AVATARS = 5; const SOURCE_TOKEN_AVATAR_OVERLAP = 12; +const NETWORK_FEE_VALUES_SKELETON_WIDTH = 150; +const NETWORK_FEE_SKELETON_HEIGHT = 24; + +const getTokenKey = (token: BridgeToken) => `${token.chainId}:${token.address}`; + +interface FinalReviewQuoteData { + sourceTokens: BridgeToken[]; + tokenData: BatchSellQuoteTokenData[]; +} + +function getFinalReviewQuoteData({ + isLoading, + sourceTokens, + tokenDataByAssetId, +}: { + isLoading: boolean; + sourceTokens: BridgeToken[]; + tokenDataByAssetId: Record; +}) { + return sourceTokens.reduce( + (quoteData, sourceToken) => { + const assetId = formatAddressToAssetId( + sourceToken.address, + sourceToken.chainId, + ); + const tokenData = assetId ? tokenDataByAssetId[assetId] : undefined; + + if ( + !tokenData || + (!isLoading && (tokenData.isLoading || tokenData.isQuoteUnavailable)) + ) { + return quoteData; + } + + quoteData.sourceTokens.push(sourceToken); + quoteData.tokenData.push(tokenData); + + return quoteData; + }, + { sourceTokens: [], tokenData: [] }, + ); +} function SourceTokenAvatarStack({ sourceTokens, }: { - sourceTokens: BatchSellFinalReviewSourceTokenData[]; + sourceTokens: BridgeToken[]; }) { const tw = useTailwind(); @@ -50,23 +98,27 @@ function SourceTokenAvatarStack({ {sourceTokens .slice(0, MAX_VISIBLE_SOURCE_TOKEN_AVATARS) - .map((sourceToken, index) => ( - - - - ))} + .map((sourceToken, index) => { + const sourceTokenKey = getTokenKey(sourceToken); + + return ( + + + + ); + })} ); } @@ -76,7 +128,7 @@ function YouSellRow({ isTokenDetailsExpanded, onToggleTokenDetails, }: { - sourceTokens: BatchSellFinalReviewSourceTokenData[]; + sourceTokens: BridgeToken[]; isTokenDetailsExpanded: boolean; onToggleTokenDetails: () => void; }) { @@ -132,13 +184,25 @@ function YouSellRow({ function NetworkFeeRow({ networkFee, - networkFeeFiat, + hasInsufficientGas, + isLoading, onInfoPress, }: { - networkFee: string; - networkFeeFiat: string; + networkFee: { + formatted: string; + formattedFiat: string; + }; + hasInsufficientGas: boolean; + isLoading: boolean; onInfoPress: () => void; }) { + const textColor = hasInsufficientGas + ? TextColor.ErrorDefault + : TextColor.TextAlternative; + const fiatTextColor = hasInsufficientGas + ? TextColor.ErrorDefault + : TextColor.TextDefault; + return ( {strings('bridge.network_fee')} @@ -179,22 +243,35 @@ function NetworkFeeRow({ gap={2} twClassName="min-w-0 flex-1" > - - {networkFee} - - - {networkFeeFiat} - + {isLoading ? ( + + ) : ( + <> + + {networkFee.formatted} + + + {networkFee.formattedFiat} + + + )} ); @@ -203,8 +280,56 @@ function NetworkFeeRow({ export function BatchSellFinalReviewModal() { const navigation = useNavigation>>(); - const params = useParams(); + const selectedTokens = useSelector(selectBatchSellSourceTokens); + const batchSellQuoteData = useBatchSellQuoteData({ + shouldUpdateBatchSellTrades: false, + }); + const { getNewQuote } = useBatchSellQuoteRequest(); + const hasSufficientGas = useBatchSellHasSufficientGas({ + isGasless: batchSellQuoteData.isGasless, + networkFee: batchSellQuoteData.networkFee, + }); const [isTokenDetailsExpanded, setIsTokenDetailsExpanded] = useState(true); + const finalReviewQuoteData = useMemo( + () => + getFinalReviewQuoteData({ + isLoading: batchSellQuoteData.isLoading, + sourceTokens: selectedTokens, + tokenDataByAssetId: batchSellQuoteData.tokenData, + }), + [ + batchSellQuoteData.isLoading, + batchSellQuoteData.tokenData, + selectedTokens, + ], + ); + const hasInsufficientGas = hasSufficientGas === false; + const isSellAllDisabled = + batchSellQuoteData.isLoading || + batchSellQuoteData.networkFeeIsLoading || + !batchSellQuoteData.hasAnyQuote || + batchSellQuoteData.hasPendingQuoteRows || + hasInsufficientGas; + const isButtonDisabled = batchSellQuoteData.needsNewQuote + ? false + : isSellAllDisabled; + const isSellAllLoading = + !batchSellQuoteData.needsNewQuote && + isSellAllDisabled && + (batchSellQuoteData.isLoading || + batchSellQuoteData.networkFeeIsLoading || + batchSellQuoteData.hasPendingQuoteRows); + const actionButtonLabel = (() => { + if (batchSellQuoteData.needsNewQuote) { + return strings('quote_expired_modal.get_new_quote'); + } + + if (hasInsufficientGas) { + return strings('bridge.insufficient_funds'); + } + + return strings('bridge.batch_sell_sell_all'); + })(); const handleToggleTokenDetails = () => { setIsTokenDetailsExpanded((isExpanded) => !isExpanded); @@ -216,7 +341,6 @@ export function BatchSellFinalReviewModal() { { sourceModal: { screen: Routes.BRIDGE.MODALS.BATCH_SELL_FINAL_REVIEW_MODAL, - params, }, }, ); @@ -226,11 +350,14 @@ export function BatchSellFinalReviewModal() { navigation.replace(Routes.BRIDGE.MODALS.BATCH_SELL_NETWORK_FEE_INFO_MODAL, { sourceModal: { screen: Routes.BRIDGE.MODALS.BATCH_SELL_FINAL_REVIEW_MODAL, - params, }, }); }; + const handleSellAll = useCallback(() => { + // TODO: submit the executable Batch Sell trades. + }, []); + return ( @@ -268,21 +396,30 @@ export function BatchSellFinalReviewModal() { variant={ButtonVariant.Primary} size={ButtonSize.Lg} isFullWidth + isDisabled={isButtonDisabled} + isLoading={isSellAllLoading} + onPress={ + batchSellQuoteData.needsNewQuote ? getNewQuote : handleSellAll + } testID={BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON} > - {strings('bridge.batch_sell_sell_all')} + {actionButtonLabel} - - {strings('bridge.batch_sell_includes_metamask_fee', { - fee: params.metamaskFeePercent, - })} - + {batchSellQuoteData.quotePercentFee ? ( + + {strings('bridge.batch_sell_includes_metamask_fee', { + fee: batchSellQuoteData.quotePercentFee, + })} + + ) : null} ); diff --git a/app/components/UI/Bridge/components/BatchSellNetworkFeeInfoModal/BatchSellNetworkFeeInfoModal.test.tsx b/app/components/UI/Bridge/components/BatchSellNetworkFeeInfoModal/BatchSellNetworkFeeInfoModal.test.tsx index 35d344337785..c33dea993dbe 100644 --- a/app/components/UI/Bridge/components/BatchSellNetworkFeeInfoModal/BatchSellNetworkFeeInfoModal.test.tsx +++ b/app/components/UI/Bridge/components/BatchSellNetworkFeeInfoModal/BatchSellNetworkFeeInfoModal.test.tsx @@ -46,7 +46,7 @@ describe('BatchSellNetworkFeeInfoModal', () => { getByTestId(BatchSellNetworkFeeInfoModalSelectorsIDs.DESCRIPTION), ).toBeOnTheScreen(); expect( - getByText(strings('bridge.network_fee_info_content')), + getByText(strings('bridge.batch_sell_network_fee_info_content')), ).toBeOnTheScreen(); }); diff --git a/app/components/UI/Bridge/components/BatchSellNetworkFeeInfoModal/index.tsx b/app/components/UI/Bridge/components/BatchSellNetworkFeeInfoModal/index.tsx index b17c0d954d15..931b0421225e 100644 --- a/app/components/UI/Bridge/components/BatchSellNetworkFeeInfoModal/index.tsx +++ b/app/components/UI/Bridge/components/BatchSellNetworkFeeInfoModal/index.tsx @@ -48,7 +48,7 @@ export function BatchSellNetworkFeeInfoModal() { color={TextColor.TextAlternative} testID={BatchSellNetworkFeeInfoModalSelectorsIDs.DESCRIPTION} > - {strings('bridge.network_fee_info_content')} + {strings('bridge.batch_sell_network_fee_info_content')} diff --git a/app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/BatchSellPriceImpactInfoModal.test.tsx b/app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/BatchSellPriceImpactInfoModal.test.tsx new file mode 100644 index 000000000000..50bda1dd8dfa --- /dev/null +++ b/app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/BatchSellPriceImpactInfoModal.test.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; + +import { strings } from '../../../../../../locales/i18n'; +import { BatchSellPriceImpactInfoModal } from './index'; +import { BatchSellPriceImpactInfoModalSelectorsIDs } from './BatchSellPriceImpactInfoModal.testIds'; +import { BatchSellPriceImpactInfoModalParams } from './BatchSellPriceImpactInfoModal.types'; + +const mockGoBack = jest.fn(); +let mockRouteParams: BatchSellPriceImpactInfoModalParams; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + goBack: mockGoBack, + }), +})); + +jest.mock('../../../../../util/navigation/navUtils', () => ({ + useParams: () => mockRouteParams, +})); + +function renderModal( + params: BatchSellPriceImpactInfoModalParams = { priceImpact: '0.06' }, +) { + mockRouteParams = params; + + return render(); +} + +describe('BatchSellPriceImpactInfoModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockRouteParams = { priceImpact: '0.06' }; + }); + + it('renders the high price impact information', () => { + const { getByTestId, getByText } = renderModal(); + + expect( + getByTestId(BatchSellPriceImpactInfoModalSelectorsIDs.SHEET), + ).toBeOnTheScreen(); + expect( + getByText(strings('bridge.batch_sell_high_price_impact')), + ).toBeOnTheScreen(); + expect( + getByTestId(BatchSellPriceImpactInfoModalSelectorsIDs.DESCRIPTION), + ).toBeOnTheScreen(); + expect( + getByText( + strings('bridge.batch_sell_high_price_impact_description', { + priceImpact: '6.00%', + }), + ), + ).toBeOnTheScreen(); + }); + + it('closes with navigation when the close button is pressed', () => { + const { getByTestId } = renderModal(); + + fireEvent.press( + getByTestId(BatchSellPriceImpactInfoModalSelectorsIDs.CLOSE_BUTTON), + ); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/BatchSellPriceImpactInfoModal.testIds.ts b/app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/BatchSellPriceImpactInfoModal.testIds.ts new file mode 100644 index 000000000000..192a22fb0b29 --- /dev/null +++ b/app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/BatchSellPriceImpactInfoModal.testIds.ts @@ -0,0 +1,5 @@ +export const BatchSellPriceImpactInfoModalSelectorsIDs = { + SHEET: 'batch-sell-price-impact-info-modal-sheet', + CLOSE_BUTTON: 'batch-sell-price-impact-info-modal-close-button', + DESCRIPTION: 'batch-sell-price-impact-info-modal-description', +}; diff --git a/app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/BatchSellPriceImpactInfoModal.types.ts b/app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/BatchSellPriceImpactInfoModal.types.ts new file mode 100644 index 000000000000..850dbc08dffb --- /dev/null +++ b/app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/BatchSellPriceImpactInfoModal.types.ts @@ -0,0 +1,3 @@ +export interface BatchSellPriceImpactInfoModalParams { + priceImpact: string; +} diff --git a/app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/index.tsx b/app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/index.tsx new file mode 100644 index 000000000000..d5a7d016a20e --- /dev/null +++ b/app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/index.tsx @@ -0,0 +1,58 @@ +import { useNavigation } from '@react-navigation/native'; +import React from 'react'; +import { + BottomSheet, + BottomSheetHeader, + Box, + ButtonIconSize, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; + +import { strings } from '../../../../../../locales/i18n'; +import { useParams } from '../../../../../util/navigation/navUtils'; +import { BatchSellPriceImpactInfoModalSelectorsIDs } from './BatchSellPriceImpactInfoModal.testIds'; +import { BatchSellPriceImpactInfoModalParams } from './BatchSellPriceImpactInfoModal.types'; + +function formatPriceImpact(priceImpact: string) { + const parsedPriceImpact = Number(priceImpact); + + if (!Number.isFinite(parsedPriceImpact)) return '0%'; + + return `${(parsedPriceImpact * 100).toFixed(2)}%`; +} + +export function BatchSellPriceImpactInfoModal() { + const navigation = useNavigation(); + const { priceImpact } = useParams(); + const formattedPriceImpact = formatPriceImpact(priceImpact); + + return ( + + + {strings('bridge.batch_sell_high_price_impact')} + + + + {strings('bridge.batch_sell_high_price_impact_description', { + priceImpact: formattedPriceImpact, + })} + + + + ); +} diff --git a/app/components/UI/Bridge/components/BatchSellQuoteDetailsModal/BatchSellQuoteDetails.tsx b/app/components/UI/Bridge/components/BatchSellQuoteDetailsModal/BatchSellQuoteDetails.tsx index d43a681b3c7c..2f02a4c72d96 100644 --- a/app/components/UI/Bridge/components/BatchSellQuoteDetailsModal/BatchSellQuoteDetails.tsx +++ b/app/components/UI/Bridge/components/BatchSellQuoteDetailsModal/BatchSellQuoteDetails.tsx @@ -28,12 +28,12 @@ const VALUE_SKELETON_HEIGHT = 24; function QuoteDetailsRow({ tokenData, - isLoading, }: { tokenData: BatchSellQuoteDetailsTokenData; - isLoading?: boolean; }) { const rowKey = tokenData.key ?? tokenData.tokenSymbol; + const isRowLoading = tokenData.isLoading; + const isRowQuoteUnavailable = tokenData.isQuoteUnavailable && !isRowLoading; return ( - {isLoading ? ( + {isRowLoading ? ( + ) : isRowQuoteUnavailable ? ( + + {strings('bridge.batch_sell_no_quote_available')} + ) : ( ))} @@ -190,7 +198,7 @@ export function BatchSellQuoteDetails({ ; + totalReceived: { formatted: string }; + minimumReceived: { formatted: string }; + isSummaryLoading: boolean; +} + +const defaultQuoteData: MockBatchSellQuoteData = { + tokenData: { + [ethAssetId]: { + key: 'eth', + tokenSymbol: 'ETH', + slippage: '0.5%', + receivedAmount: '3,456.78 USDC', + receivedAmountFiat: '$3,456.78', + isLoading: false, + isHighPriceImpact: false, + isQuoteUnavailable: false, + }, + [uniAssetId]: { + key: 'uni', + tokenSymbol: 'UNI', + slippage: '0.5%', + receivedAmount: '500 USDC', + receivedAmountFiat: '$500.00', + isLoading: false, + isHighPriceImpact: false, + isQuoteUnavailable: false, + }, + }, + totalReceived: { formatted: '7,638.23 USDC' }, + minimumReceived: { formatted: '7,485.47 USDC' }, + isSummaryLoading: false, +}; +let mockSelectedTokens = defaultSourceTokens; +let mockBatchSellQuoteData = defaultQuoteData; jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ @@ -20,11 +87,28 @@ jest.mock('@react-navigation/native', () => ({ }), })); -jest.mock('../../../../../util/navigation/navUtils', () => ({ - useParams: () => mockRouteParams, +jest.mock('react-redux', () => ({ + useSelector: (selector: (state: unknown) => unknown) => selector({}), +})); + +jest.mock('../../../../../core/redux/slices/bridge', () => ({ + selectBatchSellSourceTokens: jest.fn(() => mockSelectedTokens), })); -const defaultParams: BatchSellQuoteDetailsModalParams = { +jest.mock('../../hooks/useBatchSellQuoteData', () => ({ + getBatchSellOrderedQuoteTokenData: jest.fn( + ( + sourceTokens: typeof defaultSourceTokens, + tokenData: Record, + ) => + sourceTokens + .map((token) => tokenData[`eip155:1/erc20:${token.address}`]) + .filter(Boolean), + ), + useBatchSellQuoteData: jest.fn(() => mockBatchSellQuoteData), +})); + +const defaultDetailsProps: BatchSellQuoteDetailsProps = { tokenData: [ { key: 'eth', @@ -39,15 +123,13 @@ const defaultParams: BatchSellQuoteDetailsModalParams = { receivedAmount: '500 USDC', }, ], - totalReceived: '7,638.23 USDC', - minimumReceived: '7,485.47 USDC', + totalReceived: { formatted: '7,638.23 USDC' }, + minimumReceived: { formatted: '7,485.47 USDC' }, }; -function renderModal( - overrides: Partial = {}, -) { - mockRouteParams = { - ...defaultParams, +function renderModal(overrides: Partial = {}) { + mockBatchSellQuoteData = { + ...defaultQuoteData, ...overrides, }; @@ -57,10 +139,11 @@ function renderModal( describe('BatchSellQuoteDetailsModal', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouteParams = defaultParams; + mockSelectedTokens = defaultSourceTokens; + mockBatchSellQuoteData = defaultQuoteData; }); - it('renders the sheet header and quote rows from route params', () => { + it('renders the sheet header and quote rows from live quote data', () => { const { getAllByText, getByTestId, getByText } = renderModal(); expect( @@ -103,23 +186,23 @@ describe('BatchSellQuoteDetailsModal', () => { expect(getByText('7,485.47 USDC')).toBeOnTheScreen(); }); - it('renders skeletons for quote amounts while loading', () => { - const { getByTestId, getByText, queryByText } = renderModal({ - isLoading: true, + it('renders summary skeletons while loading', () => { + const { getByTestId, getByText, queryByTestId, queryByText } = renderModal({ + isSummaryLoading: true, }); expect(getByText('ETH • 0.5% slippage')).toBeOnTheScreen(); expect(getByText('UNI • 0.5% slippage')).toBeOnTheScreen(); expect( - getByTestId( + queryByTestId( `${BatchSellQuoteDetailsModalSelectorsIDs.QUOTE_ROW_RECEIVED_AMOUNT_SKELETON}-eth`, ), - ).toBeOnTheScreen(); + ).toBeNull(); expect( - getByTestId( + queryByTestId( `${BatchSellQuoteDetailsModalSelectorsIDs.QUOTE_ROW_RECEIVED_AMOUNT_SKELETON}-uni`, ), - ).toBeOnTheScreen(); + ).toBeNull(); expect( getByTestId( BatchSellQuoteDetailsModalSelectorsIDs.TOTAL_RECEIVED_SKELETON, @@ -135,13 +218,90 @@ describe('BatchSellQuoteDetailsModal', () => { BatchSellQuoteDetailsModalSelectorsIDs.MINIMUM_RECEIVED_INFO_BUTTON, ), ).toBeOnTheScreen(); - expect(queryByText('3,456.78 USDC')).toBeNull(); + expect(queryByText('3,456.78 USDC')).toBeOnTheScreen(); expect(queryByText('7,638.23 USDC')).toBeNull(); }); + it('renders row-level loading and unavailable states', () => { + mockSelectedTokens = [...defaultSourceTokens, linkSourceToken]; + const { getAllByText, getByTestId, getByText, queryByTestId } = renderModal( + { + tokenData: { + ...defaultQuoteData.tokenData, + [uniAssetId]: { + ...defaultQuoteData.tokenData[uniAssetId], + isLoading: true, + }, + [linkAssetId]: { + key: 'link', + tokenSymbol: 'LINK', + slippage: '0.5%', + receivedAmount: '-- USDC', + receivedAmountFiat: '-', + isLoading: false, + isHighPriceImpact: false, + isQuoteUnavailable: true, + }, + }, + totalReceived: { formatted: '3,456.78 USDC' }, + minimumReceived: { formatted: '3,456.78 USDC' }, + isSummaryLoading: false, + }, + ); + + expect(getAllByText('3,456.78 USDC').length).toBeGreaterThan(0); + expect( + queryByTestId( + `${BatchSellQuoteDetailsModalSelectorsIDs.QUOTE_ROW_RECEIVED_AMOUNT_SKELETON}-eth`, + ), + ).toBeNull(); + expect( + getByTestId( + `${BatchSellQuoteDetailsModalSelectorsIDs.QUOTE_ROW_RECEIVED_AMOUNT_SKELETON}-uni`, + ), + ).toBeOnTheScreen(); + expect(getByText('UNI • 0.5% slippage')).toBeOnTheScreen(); + expect(getByText('No quote available')).toBeOnTheScreen(); + }); + + it('updates quote rows from live quote data while mounted', () => { + const { getByTestId, getByText, queryByTestId, rerender } = renderModal({ + tokenData: { + [ethAssetId]: { + ...defaultQuoteData.tokenData[ethAssetId], + isLoading: true, + }, + [uniAssetId]: { + ...defaultQuoteData.tokenData[uniAssetId], + isLoading: true, + }, + }, + totalReceived: { formatted: '-- USDC' }, + minimumReceived: { formatted: '-- USDC' }, + isSummaryLoading: true, + }); + + expect( + getByTestId( + `${BatchSellQuoteDetailsModalSelectorsIDs.QUOTE_ROW_RECEIVED_AMOUNT_SKELETON}-eth`, + ), + ).toBeOnTheScreen(); + + mockBatchSellQuoteData = defaultQuoteData; + + rerender(); + + expect(getByText('3,456.78 USDC')).toBeOnTheScreen(); + expect( + queryByTestId( + `${BatchSellQuoteDetailsModalSelectorsIDs.QUOTE_ROW_RECEIVED_AMOUNT_SKELETON}-eth`, + ), + ).toBeNull(); + }); + it('hides quote rows when token details are collapsed', () => { const props: BatchSellQuoteDetailsProps = { - ...defaultParams, + ...defaultDetailsProps, isTokenDetailsExpanded: false, }; const { getByText, queryByText } = render( @@ -159,7 +319,7 @@ describe('BatchSellQuoteDetailsModal', () => { it('calls onMinimumReceivedInfoPress when the info button is pressed', () => { const onMinimumReceivedInfoPress = jest.fn(); const props: BatchSellQuoteDetailsProps = { - ...defaultParams, + ...defaultDetailsProps, onMinimumReceivedInfoPress, }; const { getByTestId } = render(); @@ -187,7 +347,6 @@ describe('BatchSellQuoteDetailsModal', () => { { sourceModal: { screen: Routes.BRIDGE.MODALS.BATCH_SELL_QUOTE_DETAILS_MODAL, - params: defaultParams, }, }, ); diff --git a/app/components/UI/Bridge/components/BatchSellQuoteDetailsModal/BatchSellQuoteDetailsModal.types.ts b/app/components/UI/Bridge/components/BatchSellQuoteDetailsModal/BatchSellQuoteDetailsModal.types.ts index 168bebb37d58..a3a286542315 100644 --- a/app/components/UI/Bridge/components/BatchSellQuoteDetailsModal/BatchSellQuoteDetailsModal.types.ts +++ b/app/components/UI/Bridge/components/BatchSellQuoteDetailsModal/BatchSellQuoteDetailsModal.types.ts @@ -2,19 +2,20 @@ export interface BatchSellQuoteDetailsTokenData { tokenSymbol: string; slippage: string; receivedAmount: string; + isLoading?: boolean; + isQuoteUnavailable?: boolean; key?: string; } +export interface BatchSellQuoteDetailsAmountData { + formatted: string; +} + export interface BatchSellQuoteDetailsProps { tokenData: BatchSellQuoteDetailsTokenData[]; - totalReceived: string; - minimumReceived: string; + totalReceived: BatchSellQuoteDetailsAmountData; + minimumReceived: BatchSellQuoteDetailsAmountData; isLoading?: boolean; isTokenDetailsExpanded?: boolean; onMinimumReceivedInfoPress?: () => void; } - -export type BatchSellQuoteDetailsModalParams = Omit< - BatchSellQuoteDetailsProps, - 'onMinimumReceivedInfoPress' ->; diff --git a/app/components/UI/Bridge/components/BatchSellQuoteDetailsModal/index.tsx b/app/components/UI/Bridge/components/BatchSellQuoteDetailsModal/index.tsx index 33d70236d1f6..d95e222f0e43 100644 --- a/app/components/UI/Bridge/components/BatchSellQuoteDetailsModal/index.tsx +++ b/app/components/UI/Bridge/components/BatchSellQuoteDetailsModal/index.tsx @@ -1,6 +1,7 @@ import { useNavigation } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; -import React from 'react'; +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; import { BottomSheet, BottomSheetHeader, @@ -8,25 +9,36 @@ import { } from '@metamask/design-system-react-native'; import Routes from '../../../../../constants/navigation/Routes'; -import { useParams } from '../../../../../util/navigation/navUtils'; +import { selectBatchSellSourceTokens } from '../../../../../core/redux/slices/bridge'; +import { + getBatchSellOrderedQuoteTokenData, + useBatchSellQuoteData, +} from '../../hooks/useBatchSellQuoteData'; import { BatchSellQuoteDetails } from './BatchSellQuoteDetails'; import { BatchSellQuoteDetailsModalSelectorsIDs } from './BatchSellQuoteDetailsModal.testIds'; -import { BatchSellQuoteDetailsModalParams } from './BatchSellQuoteDetailsModal.types'; import { strings } from '../../../../../../locales/i18n'; export function BatchSellQuoteDetailsModal() { const navigation = useNavigation>>(); - const quoteDetailsParams = useParams(); - const { tokenData, totalReceived, minimumReceived, isLoading } = - quoteDetailsParams; + const sourceTokens = useSelector(selectBatchSellSourceTokens); + const batchSellQuoteData = useBatchSellQuoteData({ + shouldUpdateBatchSellTrades: false, + }); + const tokenData = useMemo( + () => + getBatchSellOrderedQuoteTokenData( + sourceTokens, + batchSellQuoteData.tokenData, + ), + [batchSellQuoteData.tokenData, sourceTokens], + ); const handleOpenMinimumReceivedInfo = () => { navigation.replace( Routes.BRIDGE.MODALS.BATCH_SELL_MINIMUM_RECEIVED_INFO_MODAL, { sourceModal: { screen: Routes.BRIDGE.MODALS.BATCH_SELL_QUOTE_DETAILS_MODAL, - params: quoteDetailsParams, }, }, ); @@ -48,9 +60,9 @@ export function BatchSellQuoteDetailsModal() { diff --git a/app/components/UI/Bridge/hooks/useBalancesByAssetId/index.test.ts b/app/components/UI/Bridge/hooks/useBalancesByAssetId/index.test.ts index b7f71027fbdd..723b9378ac6d 100644 --- a/app/components/UI/Bridge/hooks/useBalancesByAssetId/index.test.ts +++ b/app/components/UI/Bridge/hooks/useBalancesByAssetId/index.test.ts @@ -12,13 +12,6 @@ jest.mock('../useTokensWithBalance', () => ({ useTokensWithBalance: jest.fn(), })); -jest.mock('@metamask/bridge-controller', () => ({ - formatAddressToAssetId: jest.fn( - (address: string, chainId: string) => `${chainId}/erc20:${address}`, - ), - isNonEvmChainId: jest.fn((chainId: string) => !chainId.startsWith('0x')), -})); - const mockUseTokensWithBalance = useTokensWithBalance as jest.Mock; describe('useBalancesByAssetId', () => { @@ -43,13 +36,13 @@ describe('useBalancesByAssetId', () => { it('maps token balances to assetId keys', () => { const mockTokens = [ createMockTokenWithBalance({ - address: '0xtoken1', + address: '0x1111111111111111111111111111111111111111', balance: '50.0', balanceFiat: '$50', tokenFiatAmount: 50, }), createMockTokenWithBalance({ - address: '0xtoken2', + address: '0x2222222222222222222222222222222222222222', balance: '100.0', balanceFiat: '$100', tokenFiatAmount: 100, @@ -64,13 +57,13 @@ describe('useBalancesByAssetId', () => { ); expect(result.current.balancesByAssetId).toEqual({ - '0x1/erc20:0xtoken1': { + 'eip155:1/erc20:0x1111111111111111111111111111111111111111': { balance: '50.0', balanceFiat: '$50', tokenFiatAmount: 50, currencyExchangeRate: 1, }, - '0x1/erc20:0xtoken2': { + 'eip155:1/erc20:0x2222222222222222222222222222222222222222': { balance: '100.0', balanceFiat: '$100', tokenFiatAmount: 100, @@ -79,6 +72,74 @@ describe('useBalancesByAssetId', () => { }); }); + it('maps EVM token balances to canonical and lowercase assetId keys', () => { + const mockTokens = [ + createMockTokenWithBalance({ + address: '0xA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48', + balance: '50.0', + balanceFiat: '$50', + }), + ]; + mockUseTokensWithBalance.mockReturnValue(mockTokens); + + const { result } = renderHook(() => + useBalancesByAssetId({ + chainIds: [MOCK_CHAIN_IDS_HEX.ethereum as Hex], + }), + ); + + expect( + result.current.balancesByAssetId[ + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as CaipAssetType + ], + ).toEqual( + expect.objectContaining({ + balance: '50.0', + balanceFiat: '$50', + }), + ); + expect( + result.current.balancesByAssetId[ + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as CaipAssetType + ], + ).toEqual( + expect.objectContaining({ + balance: '50.0', + balanceFiat: '$50', + }), + ); + }); + + it('maps non-EVM token balances to a single assetId key', () => { + const mockTokens = [ + createMockTokenWithBalance({ + address: 'SoLTokenABC', + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as CaipChainId, + balance: '50.0', + balanceFiat: '$50', + }), + ]; + mockUseTokensWithBalance.mockReturnValue(mockTokens); + + const { result } = renderHook(() => + useBalancesByAssetId({ + chainIds: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as CaipChainId], + }), + ); + + expect(Object.keys(result.current.balancesByAssetId)).toHaveLength(1); + expect( + result.current.balancesByAssetId[ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:SoLTokenABC' as CaipAssetType + ], + ).toEqual( + expect.objectContaining({ + balance: '50.0', + balanceFiat: '$50', + }), + ); + }); + it('returns tokensWithBalance array from useTokensWithBalance', () => { const mockTokens = [ createMockTokenWithBalance({ address: '0xtoken1' }), @@ -100,11 +161,11 @@ describe('useBalancesByAssetId', () => { it('excludes tokens without balance', () => { const mockTokens = [ createMockTokenWithBalance({ - address: '0xwithbalance', + address: '0x3333333333333333333333333333333333333333', balance: '50.0', }), createMockTokenWithBalance({ - address: '0xnobalance', + address: '0x4444444444444444444444444444444444444444', balance: undefined, }), ]; @@ -119,12 +180,12 @@ describe('useBalancesByAssetId', () => { expect(Object.keys(result.current.balancesByAssetId)).toHaveLength(1); expect( result.current.balancesByAssetId[ - '0x1/erc20:0xwithbalance' as CaipAssetType + 'eip155:1/erc20:0x3333333333333333333333333333333333333333' as CaipAssetType ], ).toBeDefined(); expect( result.current.balancesByAssetId[ - '0x1/erc20:0xnobalance' as CaipAssetType + 'eip155:1/erc20:0x4444444444444444444444444444444444444444' as CaipAssetType ], ).toBeUndefined(); }); @@ -134,12 +195,12 @@ describe('useBalancesByAssetId', () => { it('handles multiple chain IDs', () => { const mockTokens = [ createMockTokenWithBalance({ - address: '0xtoken1', + address: '0x1111111111111111111111111111111111111111', chainId: MOCK_CHAIN_IDS_HEX.ethereum as Hex, balance: '10.0', }), createMockTokenWithBalance({ - address: '0xtoken2', + address: '0x2222222222222222222222222222222222222222', chainId: '0xa' as Hex, balance: '20.0', }), @@ -153,17 +214,21 @@ describe('useBalancesByAssetId', () => { ); expect( - result.current.balancesByAssetId['0x1/erc20:0xtoken1' as CaipAssetType], + result.current.balancesByAssetId[ + 'eip155:1/erc20:0x1111111111111111111111111111111111111111' as CaipAssetType + ], ).toBeDefined(); expect( - result.current.balancesByAssetId['0xa/erc20:0xtoken2' as CaipAssetType], + result.current.balancesByAssetId[ + 'eip155:10/erc20:0x2222222222222222222222222222222222222222' as CaipAssetType + ], ).toBeDefined(); }); it('handles CAIP chain IDs', () => { const mockTokens = [ createMockTokenWithBalance({ - address: '0xtoken1', + address: '0x1111111111111111111111111111111111111111', chainId: 'eip155:1' as CaipChainId, balance: '100.0', }), @@ -176,7 +241,7 @@ describe('useBalancesByAssetId', () => { expect( result.current.balancesByAssetId[ - 'eip155:1/erc20:0xtoken1' as CaipAssetType + 'eip155:1/erc20:0x1111111111111111111111111111111111111111' as CaipAssetType ], ).toBeDefined(); }); @@ -208,7 +273,7 @@ describe('useBalancesByAssetId', () => { it('preserves optional balance properties', () => { const mockTokens = [ createMockTokenWithBalance({ - address: '0xtoken1', + address: '0x5555555555555555555555555555555555555555', balance: '50.0', balanceFiat: undefined, tokenFiatAmount: undefined, @@ -224,7 +289,9 @@ describe('useBalancesByAssetId', () => { ); expect( - result.current.balancesByAssetId['0x1/erc20:0xtoken1' as CaipAssetType], + result.current.balancesByAssetId[ + 'eip155:1/erc20:0x5555555555555555555555555555555555555555' as CaipAssetType + ], ).toEqual({ balance: '50.0', balanceFiat: undefined, @@ -237,7 +304,7 @@ describe('useBalancesByAssetId', () => { it('includes accountType when token has accountType', () => { const mockTokens = [ createMockTokenWithBalance({ - address: '0xbtctoken', + address: '0x6666666666666666666666666666666666666666', balance: '1.5', balanceFiat: '$45000', tokenFiatAmount: 45000, @@ -254,7 +321,7 @@ describe('useBalancesByAssetId', () => { expect( result.current.balancesByAssetId[ - '0x1/erc20:0xbtctoken' as CaipAssetType + 'eip155:1/erc20:0x6666666666666666666666666666666666666666' as CaipAssetType ], ).toEqual({ balance: '1.5', diff --git a/app/components/UI/Bridge/hooks/useBalancesByAssetId/index.ts b/app/components/UI/Bridge/hooks/useBalancesByAssetId/index.ts index e1d617340eef..e0d779443f6a 100644 --- a/app/components/UI/Bridge/hooks/useBalancesByAssetId/index.ts +++ b/app/components/UI/Bridge/hooks/useBalancesByAssetId/index.ts @@ -45,17 +45,22 @@ export const useBalancesByAssetId = ({ tokensWithBalance.forEach((token) => { const assetId = formatAddressToAssetId(token.address, token.chainId); if (assetId && token.balance) { - // Normalize assetId because API returns assetId in lowercase for EVM chains - const normalizedAssetId = isNonEvmChainId(token.chainId) - ? assetId - : (assetId.toLowerCase() as CaipAssetType); - balancesMap[normalizedAssetId] = { + const balanceData = { balance: token.balance, balanceFiat: token.balanceFiat, tokenFiatAmount: token.tokenFiatAmount, currencyExchangeRate: token.currencyExchangeRate, accountType: token.accountType, }; + + // Store the canonical bridge-controller key for checksummed lookups for EVM. + balancesMap[assetId] = balanceData; + + // Also store the lowercase EVM key + const normalizedAssetId = isNonEvmChainId(token.chainId) + ? assetId + : (assetId.toLowerCase() as CaipAssetType); + balancesMap[normalizedAssetId] = balanceData; } }); diff --git a/app/components/UI/Bridge/hooks/useBatchSellHasSufficientGas/index.ts b/app/components/UI/Bridge/hooks/useBatchSellHasSufficientGas/index.ts new file mode 100644 index 000000000000..b792d4df2a36 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useBatchSellHasSufficientGas/index.ts @@ -0,0 +1,63 @@ +import { + formatChainIdToCaip, + formatChainIdToHex, + isNonEvmChainId, +} from '@metamask/bridge-controller'; +import type { CaipChainId, Hex } from '@metamask/utils'; +import BigNumber from 'bignumber.js'; +import { ethers } from 'ethers'; + +import { isNumberValue } from '../../../../../util/number/bigint'; +import { useLatestBalance } from '../useLatestBalance'; +import type { useBatchSellQuoteData } from '../useBatchSellQuoteData'; + +type BatchSellNetworkFee = ReturnType< + typeof useBatchSellQuoteData +>['networkFee']; + +interface Props { + isGasless: boolean; + networkFee: BatchSellNetworkFee; +} + +/** + * @returns null if the fee token balance is not available, true if the balance is sufficient, false if the balance is insufficient + */ +export const useBatchSellHasSufficientGas = ({ + isGasless, + networkFee, +}: Props): boolean | null => { + const networkFeeAsset = networkFee.asset; + const networkFeeChainId = networkFeeAsset?.chainId; + + let hexOrCaipChainId: CaipChainId | Hex | undefined; + if (networkFeeChainId) { + hexOrCaipChainId = isNonEvmChainId(networkFeeChainId) + ? formatChainIdToCaip(networkFeeChainId) + : formatChainIdToHex(networkFeeChainId); + } + + const feeTokenBalance = useLatestBalance({ + address: networkFeeAsset?.address, + chainId: hexOrCaipChainId, + decimals: networkFeeAsset?.decimals, + }); + + // TODO figure out what happen when the transactions array is empty in obtainBatchSellQuotes endpoint + if (isGasless) { + return true; + } + + const networkFeeAmount = + isNumberValue(networkFee.amount) && networkFee.amount != null + ? new BigNumber(networkFee.amount).toFixed() + : null; + const atomicNetworkFee = + networkFeeAmount && networkFeeAsset?.decimals !== undefined + ? ethers.utils.parseUnits(networkFeeAmount, networkFeeAsset.decimals) + : null; + + return feeTokenBalance?.atomicBalance && atomicNetworkFee + ? feeTokenBalance.atomicBalance.gte(atomicNetworkFee) + : null; +}; diff --git a/app/components/UI/Bridge/hooks/useBatchSellHasSufficientGas/useBatchSellHasSufficientGas.test.ts b/app/components/UI/Bridge/hooks/useBatchSellHasSufficientGas/useBatchSellHasSufficientGas.test.ts new file mode 100644 index 000000000000..d2dd0f86a220 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useBatchSellHasSufficientGas/useBatchSellHasSufficientGas.test.ts @@ -0,0 +1,148 @@ +import { BigNumber } from 'ethers'; + +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import { useLatestBalance } from '../useLatestBalance'; +import { useBatchSellHasSufficientGas } from './index'; + +jest.mock('../useLatestBalance'); + +type BatchSellNetworkFee = Parameters< + typeof useBatchSellHasSufficientGas +>[0]['networkFee']; + +const feeAsset: NonNullable = { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:1/slip44:60', + chainId: 1, + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', +}; + +const createNetworkFee = ( + overrides: Partial = {}, +): BatchSellNetworkFee => ({ + amount: '0.001', + valueInCurrency: '3.25', + asset: feeAsset, + formatted: '0.001 ETH', + formattedFiat: '$3.25', + ...overrides, +}); + +describe('useBatchSellHasSufficientGas', () => { + const mockUseLatestBalance = useLatestBalance as jest.MockedFunction< + typeof useLatestBalance + >; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns true when the fee token balance covers the batch sell network fee', () => { + mockUseLatestBalance.mockReturnValue({ + displayBalance: '0.01', + atomicBalance: BigNumber.from('10000000000000000'), + }); + + const { result } = renderHookWithProvider( + () => + useBatchSellHasSufficientGas({ + isGasless: false, + networkFee: createNetworkFee({ amount: '0.001' }), + }), + { state: {} }, + ); + + expect(result.current).toBe(true); + expect(mockUseLatestBalance).toHaveBeenCalledWith({ + address: feeAsset.address, + chainId: '0x1', + decimals: feeAsset.decimals, + }); + }); + + it('returns false when the fee token balance is below the batch sell network fee', () => { + mockUseLatestBalance.mockReturnValue({ + displayBalance: '0.001', + atomicBalance: BigNumber.from('1000000000000000'), + }); + + const { result } = renderHookWithProvider( + () => + useBatchSellHasSufficientGas({ + isGasless: false, + networkFee: createNetworkFee({ amount: '0.01' }), + }), + { state: {} }, + ); + + expect(result.current).toBe(false); + }); + + it('handles scientific notation in the batch sell network fee', () => { + mockUseLatestBalance.mockReturnValue({ + displayBalance: '0.001', + atomicBalance: BigNumber.from('1000000000000000'), + }); + + const { result } = renderHookWithProvider( + () => + useBatchSellHasSufficientGas({ + isGasless: false, + networkFee: createNetworkFee({ amount: '9.200359292e-8' }), + }), + { state: {} }, + ); + + expect(result.current).toBe(true); + }); + + it('returns null when the batch sell network fee is missing', () => { + mockUseLatestBalance.mockReturnValue({ + displayBalance: '0.01', + atomicBalance: BigNumber.from('10000000000000000'), + }); + + const { result } = renderHookWithProvider( + () => + useBatchSellHasSufficientGas({ + isGasless: false, + networkFee: createNetworkFee({ amount: undefined }), + }), + { state: {} }, + ); + + expect(result.current).toBe(null); + }); + + it('returns null when the fee token balance is missing', () => { + mockUseLatestBalance.mockReturnValue(undefined); + + const { result } = renderHookWithProvider( + () => + useBatchSellHasSufficientGas({ + isGasless: false, + networkFee: createNetworkFee(), + }), + { state: {} }, + ); + + expect(result.current).toBe(null); + }); + + it('returns true when the batch sell quotes are gasless', () => { + mockUseLatestBalance.mockReturnValue(undefined); + + const { result } = renderHookWithProvider( + () => + useBatchSellHasSufficientGas({ + isGasless: true, + networkFee: createNetworkFee({ amount: undefined, asset: undefined }), + }), + { state: {} }, + ); + + expect(result.current).toBe(true); + }); +}); diff --git a/app/components/UI/Bridge/hooks/useBatchSellQuoteData/index.ts b/app/components/UI/Bridge/hooks/useBatchSellQuoteData/index.ts new file mode 100644 index 000000000000..c1a9e8a71f32 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useBatchSellQuoteData/index.ts @@ -0,0 +1,506 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { useSelector } from 'react-redux'; +import BigNumber from 'bignumber.js'; +import { CaipAssetType } from '@metamask/utils'; +import { + formatAddressToAssetId, + isNativeAddress, +} from '@metamask/bridge-controller'; + +import { + selectBatchSellDestToken, + selectBatchSellQuotes, + selectBatchSellSlippages, + selectBatchSellSourceTokens, + selectBatchSellTrades, + selectBridgeFeatureFlags, +} from '../../../../../core/redux/slices/bridge'; +import AppConstants from '../../../../../core/AppConstants'; +import Engine from '../../../../../core/Engine'; +import { selectCurrentCurrency } from '../../../../../selectors/currencyRateController'; +import formatFiat from '../../../../../util/formatFiat'; +import Logger from '../../../../../util/Logger'; +import { formatTokenBalance } from '../../utils'; +import { + getBatchSellSlippage, + getSlippageDisplayValue, +} from '../../components/SlippageModal/utils'; +import type { BridgeToken } from '../../types'; +import { getQuoteRefreshRate, isQuoteExpired } from '../../utils/quoteUtils'; + +const UNKNOWN_DESTINATION_TOKEN_SYMBOL = 'UNKNOWN'; +const QUOTE_DETAILS_PLACEHOLDER_AMOUNT = '--'; +const BATCH_SELL_TRADES_REQUEST_KEY_SEPARATOR = '|'; + +export interface BatchSellQuoteTokenData { + key: string; + tokenSymbol: string; + slippage: string; + receivedAmount: string; + receivedAmountFiat: string; + priceImpact?: string; + isLoading: boolean; + isHighPriceImpact: boolean; + isQuoteUnavailable: boolean; +} + +export type BatchSellQuoteTokenDataByAssetId = Record< + CaipAssetType, + BatchSellQuoteTokenData +>; +type BatchSellRecommendedQuote = NonNullable< + ReturnType['recommendedQuotes'][number] +>; +type BatchSellRecommendedQuotes = ReturnType< + typeof selectBatchSellQuotes +>['recommendedQuotes']; +type BatchSellQuoteAmountKey = 'toTokenAmount' | 'minToTokenAmount'; + +interface BatchSellQuoteRow { + assetId: CaipAssetType; + recommendedQuote: BatchSellRecommendedQuote | undefined; + tokenSymbol: string; +} + +interface UseBatchSellQuoteDataOptions { + shouldUpdateBatchSellTrades?: boolean; +} + +export function getBatchSellOrderedQuoteTokenData( + sourceTokens: BridgeToken[], + tokenData: BatchSellQuoteTokenDataByAssetId, +) { + return sourceTokens.reduce( + (quoteTokenData, token) => { + const assetId = formatAddressToAssetId(token.address, token.chainId); + const tokenQuoteData = assetId ? tokenData[assetId] : undefined; + + if (tokenQuoteData) quoteTokenData.push(tokenQuoteData); + + return quoteTokenData; + }, + [], + ); +} + +function formatTokenAmountWithSymbol( + amount: string | undefined, + symbol: string | undefined, +) { + const tokenSymbol = symbol ? ` ${symbol}` : ''; + + if (amount === undefined) + return `${QUOTE_DETAILS_PLACEHOLDER_AMOUNT}${tokenSymbol}`; + + return `${formatTokenBalance(amount)}${tokenSymbol}`; +} + +function formatQuoteDisplayValue({ + amount, + valueInCurrency, + symbol, + currency, +}: { + amount: string | undefined; + valueInCurrency: string | null | undefined; + symbol: string | undefined; + currency: string; +}) { + const hasTokenAmount = amount !== undefined; + const hasNonZeroTokenAmount = hasTokenAmount && new BigNumber(amount).gt(0); + const hasMissingDisplayValue = + !valueInCurrency || + (new BigNumber(valueInCurrency).isZero() && hasNonZeroTokenAmount); + + if (hasMissingDisplayValue && hasTokenAmount) { + return formatTokenAmountWithSymbol(amount, symbol); + } + + if (!valueInCurrency) return '-'; + + return formatFiat(new BigNumber(valueInCurrency), currency); +} + +function formatCurrencyDisplayValue( + valueInCurrency: string | null | undefined, + currency: string, +) { + if (!valueInCurrency) return '-'; + + return formatFiat(new BigNumber(valueInCurrency), currency); +} + +function isQuoteForDestinationAssetId( + quote: BatchSellRecommendedQuote, + destinationAssetId: CaipAssetType | undefined, +) { + return ( + destinationAssetId !== undefined && + formatAddressToAssetId( + quote.quote.destAsset.address, + quote.quote.destChainId, + ) === destinationAssetId + ); +} + +function getRecommendedQuoteBySourceAndDestinationAssetId( + recommendedQuotes: BatchSellRecommendedQuotes, + sourceAssetId: CaipAssetType, + destinationAssetId: CaipAssetType | undefined, +) { + return recommendedQuotes.find((quote): quote is BatchSellRecommendedQuote => + Boolean( + quote && + formatAddressToAssetId( + quote.quote.srcAsset.address, + quote.quote.srcChainId, + ) === sourceAssetId && + isQuoteForDestinationAssetId(quote, destinationAssetId), + ), + ); +} + +function getBatchSellTradesRequestKey( + recommendedQuotes: BatchSellRecommendedQuotes, +) { + return recommendedQuotes + .map((quote) => quote?.quoteId ?? quote?.quote.requestId ?? '') + .join(BATCH_SELL_TRADES_REQUEST_KEY_SEPARATOR); +} + +function sumRecommendedQuoteAmounts( + recommendedQuotes: BatchSellRecommendedQuote[], + amountKey: BatchSellQuoteAmountKey, +) { + return recommendedQuotes.reduce( + (total, quote) => ({ + amount: new BigNumber(total.amount) + .plus(quote[amountKey]?.amount ?? 0) + .toString(), + valueInCurrency: + total.valueInCurrency || quote[amountKey]?.valueInCurrency + ? new BigNumber(total.valueInCurrency ?? 0) + .plus(quote[amountKey]?.valueInCurrency ?? 0) + .toString() + : null, + }), + { amount: '0', valueInCurrency: null as string | null }, + ); +} + +function getBatchSellMetamaskFeePercent( + recommendedQuotes: BatchSellRecommendedQuote[], +) { + const quoteBpsFee = recommendedQuotes + .map((recommendedQuote) => { + // TODO: remove this once controller types are updated + // @ts-expect-error: controller types are not up to date yet + const fee = recommendedQuote.quote.feeData?.metabridge?.quoteBpsFee; + + return fee as number | string | null | undefined; + }) + .find((fee): fee is number | string => fee !== undefined && fee !== null); + const parsedQuoteBpsFee = + quoteBpsFee === undefined ? undefined : new BigNumber(quoteBpsFee); + + if (!parsedQuoteBpsFee?.isFinite() || parsedQuoteBpsFee.lte(0)) + return undefined; + + return parsedQuoteBpsFee.div(100).toString(); +} + +export function useBatchSellQuoteData({ + shouldUpdateBatchSellTrades = true, +}: UseBatchSellQuoteDataOptions = {}) { + const sourceTokens = useSelector(selectBatchSellSourceTokens); + const selectedDestinationToken = useSelector(selectBatchSellDestToken); + const batchSellSlippages = useSelector(selectBatchSellSlippages); + const batchSellQuotes = useSelector(selectBatchSellQuotes); + const batchSellTrades = useSelector(selectBatchSellTrades); + const bridgeFeatureFlags = useSelector(selectBridgeFeatureFlags); + const currentCurrency = useSelector(selectCurrentCurrency); + const priceImpactWarningThreshold = + bridgeFeatureFlags?.priceImpactThreshold?.warning ?? + AppConstants.BRIDGE.PRICE_IMPACT_WARNING_THRESHOLD; + const refreshRate = getQuoteRefreshRate(bridgeFeatureFlags, sourceTokens[0]); + + const destinationTokenSymbol = + selectedDestinationToken?.symbol ?? UNKNOWN_DESTINATION_TOKEN_SYMBOL; + const destinationAssetId = selectedDestinationToken + ? formatAddressToAssetId( + selectedDestinationToken.address, + selectedDestinationToken.chainId, + ) + : undefined; + const recommendedQuotes = useMemo( + () => batchSellQuotes.recommendedQuotes ?? [], + [batchSellQuotes.recommendedQuotes], + ); + const recommendedQuotesRequestKey = useMemo( + () => getBatchSellTradesRequestKey(recommendedQuotes), + [recommendedQuotes], + ); + const lastFetchedRecommendedQuotesRequestKey = useRef( + undefined, + ); + const lastBatchSellTradesRequestKey = useRef(undefined); + useEffect(() => { + if (!batchSellQuotes.isLoading) { + lastFetchedRecommendedQuotesRequestKey.current = + recommendedQuotesRequestKey; + } + }, [batchSellQuotes.isLoading, recommendedQuotesRequestKey]); + + const shouldHideStaleRefreshQuotes = Boolean( + batchSellQuotes.isLoading && + lastFetchedRecommendedQuotesRequestKey.current && + lastFetchedRecommendedQuotesRequestKey.current === + recommendedQuotesRequestKey, + ); + const visibleRecommendedQuotes = useMemo( + () => (shouldHideStaleRefreshQuotes ? [] : recommendedQuotes), + [recommendedQuotes, shouldHideStaleRefreshQuotes], + ); + const hasStaleDestinationQuotes = recommendedQuotes.some( + (quote) => + quote && !isQuoteForDestinationAssetId(quote, destinationAssetId), + ); + const hasQuoteResultsForSelectedTokens = + sourceTokens.length > 0 && + (Boolean(batchSellQuotes.quotesLastFetchedMs) || + visibleRecommendedQuotes.length === sourceTokens.length); + const quoteRows = useMemo( + () => + sourceTokens.reduce((rows, token) => { + const assetId = formatAddressToAssetId(token.address, token.chainId); + + if (!assetId) return rows; + + rows.push({ + assetId, + recommendedQuote: getRecommendedQuoteBySourceAndDestinationAssetId( + visibleRecommendedQuotes, + assetId, + destinationAssetId, + ), + tokenSymbol: token.symbol, + }); + + return rows; + }, []), + [destinationAssetId, sourceTokens, visibleRecommendedQuotes], + ); + const availableRecommendedQuotes = useMemo( + () => + quoteRows + .map(({ recommendedQuote }) => recommendedQuote) + .filter((quote): quote is BatchSellRecommendedQuote => Boolean(quote)), + [quoteRows], + ); + const hasAnyQuote = availableRecommendedQuotes.length > 0; + const totalNetworkFee = batchSellTrades.totalNetworkFee; + // Quote-level gasless params are not reliable for Batch Sell because gasless + // behavior is only simulated when the controller calls obtainGaslessBatch. + // Clients do not consume that API response directly; selectBatchSellTrades + // exposes the controller-interpreted result, so derive gasless state from it. + const isGasless = + hasAnyQuote && + batchSellTrades.isBatchSellTradeAvailable && + Boolean( + totalNetworkFee?.asset && !isNativeAddress(totalNetworkFee.asset.address), + ); + const isWaitingForQuoteRows = + !hasQuoteResultsForSelectedTokens || + batchSellQuotes.isLoading || + hasStaleDestinationQuotes; + const hasPendingQuoteRows = quoteRows.some( + ({ recommendedQuote }) => !recommendedQuote && isWaitingForQuoteRows, + ); + const canDisplayAggregatedQuoteData = + hasAnyQuote && !hasStaleDestinationQuotes; + const needsNewQuote = + canDisplayAggregatedQuoteData && + !batchSellQuotes.isLoading && + isQuoteExpired( + batchSellQuotes.isQuoteGoingToRefresh, + refreshRate, + batchSellQuotes.quotesLastFetchedMs ?? null, + ); + const isLoading = + batchSellQuotes.isLoading || + !hasQuoteResultsForSelectedTokens || + hasStaleDestinationQuotes; + const isSummaryLoading = + (!hasAnyQuote || hasStaleDestinationQuotes) && isLoading; + const totalReceived = useMemo( + () => + sumRecommendedQuoteAmounts(availableRecommendedQuotes, 'toTokenAmount'), + [availableRecommendedQuotes], + ); + const minimumReceived = useMemo( + () => + sumRecommendedQuoteAmounts( + availableRecommendedQuotes, + 'minToTokenAmount', + ), + [availableRecommendedQuotes], + ); + const batchSellTradesRequestKey = useMemo( + () => getBatchSellTradesRequestKey(availableRecommendedQuotes), + [availableRecommendedQuotes], + ); + const networkFeeIsLoading = !batchSellTrades.isBatchSellTradeAvailable; + const totalReceivedAmount = canDisplayAggregatedQuoteData + ? totalReceived.amount + : undefined; + const totalReceivedValueInCurrency = canDisplayAggregatedQuoteData + ? totalReceived.valueInCurrency + : undefined; + const minimumReceivedAmount = canDisplayAggregatedQuoteData + ? minimumReceived.amount + : undefined; + const totalNetworkFeeAmount = canDisplayAggregatedQuoteData + ? totalNetworkFee?.amount + : undefined; + const totalNetworkFeeValueInCurrency = canDisplayAggregatedQuoteData + ? totalNetworkFee?.valueInCurrency + : undefined; + const totalReceivedData = { + amount: totalReceivedAmount, + valueInCurrency: totalReceivedValueInCurrency, + formatted: formatTokenAmountWithSymbol( + totalReceivedAmount, + destinationTokenSymbol, + ), + formattedFiat: canDisplayAggregatedQuoteData + ? formatQuoteDisplayValue({ + amount: totalReceivedAmount, + valueInCurrency: totalReceivedValueInCurrency, + symbol: destinationTokenSymbol, + currency: currentCurrency, + }) + : '-', + }; + const minimumReceivedData = { + amount: minimumReceivedAmount, + valueInCurrency: canDisplayAggregatedQuoteData + ? minimumReceived.valueInCurrency + : undefined, + formatted: formatTokenAmountWithSymbol( + minimumReceivedAmount, + destinationTokenSymbol, + ), + }; + const networkFeeData = { + amount: totalNetworkFeeAmount, + valueInCurrency: totalNetworkFeeValueInCurrency, + asset: totalNetworkFee?.asset, + formatted: formatTokenAmountWithSymbol( + totalNetworkFeeAmount, + totalNetworkFee?.asset.symbol, + ), + formattedFiat: canDisplayAggregatedQuoteData + ? formatCurrencyDisplayValue( + totalNetworkFeeValueInCurrency, + currentCurrency, + ) + : '-', + }; + const quotePercentFee = useMemo( + () => getBatchSellMetamaskFeePercent(availableRecommendedQuotes), + [availableRecommendedQuotes], + ); + + useEffect(() => { + if ( + !shouldUpdateBatchSellTrades || + !hasAnyQuote || + hasPendingQuoteRows || + hasStaleDestinationQuotes + ) { + return; + } + + if (lastBatchSellTradesRequestKey.current === batchSellTradesRequestKey) { + return; + } + + lastBatchSellTradesRequestKey.current = batchSellTradesRequestKey; + + Engine.context.BridgeController.updateBatchSellTrades( + availableRecommendedQuotes, + ).catch((error) => { + Logger.error(error, 'Failed to update Batch Sell trades'); + }); + }, [ + availableRecommendedQuotes, + batchSellTradesRequestKey, + hasAnyQuote, + hasPendingQuoteRows, + hasStaleDestinationQuotes, + shouldUpdateBatchSellTrades, + ]); + + const tokenData = useMemo( + () => + quoteRows.reduce( + (tokenDataByAssetId, { assetId, recommendedQuote, tokenSymbol }) => { + const slippage = getBatchSellSlippage(batchSellSlippages, assetId); + const quoteDestinationTokenSymbol = + recommendedQuote?.quote.destAsset.symbol ?? destinationTokenSymbol; + const priceImpact = recommendedQuote?.quote.priceData?.priceImpact; + const parsedPriceImpact = Number(priceImpact); + const isMissingQuote = !recommendedQuote; + + tokenDataByAssetId[assetId] = { + key: assetId, + tokenSymbol, + slippage: getSlippageDisplayValue(slippage), + receivedAmount: formatTokenAmountWithSymbol( + recommendedQuote?.toTokenAmount.amount, + quoteDestinationTokenSymbol, + ), + receivedAmountFiat: formatQuoteDisplayValue({ + amount: recommendedQuote?.toTokenAmount.amount, + valueInCurrency: recommendedQuote?.toTokenAmount.valueInCurrency, + symbol: quoteDestinationTokenSymbol, + currency: currentCurrency, + }), + priceImpact, + isHighPriceImpact: + priceImpact !== undefined && + Number.isFinite(parsedPriceImpact) && + parsedPriceImpact >= priceImpactWarningThreshold, + isLoading: isMissingQuote && isWaitingForQuoteRows, + isQuoteUnavailable: isMissingQuote && !isWaitingForQuoteRows, + }; + + return tokenDataByAssetId; + }, + {}, + ), + [ + batchSellSlippages, + destinationTokenSymbol, + currentCurrency, + isWaitingForQuoteRows, + priceImpactWarningThreshold, + quoteRows, + ], + ); + + return { + tokenData, + totalReceived: totalReceivedData, + minimumReceived: minimumReceivedData, + isLoading, + isSummaryLoading, + isGasless, + hasAnyQuote, + hasPendingQuoteRows, + needsNewQuote, + networkFeeIsLoading, + networkFee: networkFeeData, + quotePercentFee, + }; +} diff --git a/app/components/UI/Bridge/hooks/useBatchSellQuoteData/useBatchSellQuoteData.test.ts b/app/components/UI/Bridge/hooks/useBatchSellQuoteData/useBatchSellQuoteData.test.ts new file mode 100644 index 000000000000..22d887931010 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useBatchSellQuoteData/useBatchSellQuoteData.test.ts @@ -0,0 +1,847 @@ +import { renderHook } from '@testing-library/react-native'; +import { CaipAssetType, Hex } from '@metamask/utils'; + +import Engine from '../../../../../core/Engine'; +import { BridgeToken } from '../../types'; +import { useBatchSellQuoteData } from '.'; + +jest.mock('../useBatchSellQuoteRequest', () => ({ + getBatchSellAtomicSourceAmount: jest.fn( + (_token: BridgeToken, sourceAmount?: string) => + sourceAmount && Number(sourceAmount) > 0 ? '1' : undefined, + ), +})); + +jest.mock('../../../../../core/Engine', () => ({ + __esModule: true, + default: { + context: { + BridgeController: { + state: { + batchSellTrades: undefined, + batchSellTradesLoadingStatus: undefined, + quotesLoadingStatus: undefined, + }, + updateBatchSellTrades: jest.fn().mockResolvedValue(undefined), + }, + }, + }, +})); + +const ethAssetId = + 'eip155:1/erc20:0x1111111111111111111111111111111111111111' as CaipAssetType; +const uniAssetId = + 'eip155:1/erc20:0x2222222222222222222222222222222222222222' as CaipAssetType; + +const ethToken: BridgeToken = { + address: '0x1111111111111111111111111111111111111111', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + balance: '1', +}; + +const uniToken: BridgeToken = { + address: '0x2222222222222222222222222222222222222222', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'UNI', + balance: '2', +}; + +const usdcToken: BridgeToken = { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', +}; + +const usdtToken: BridgeToken = { + address: '0xdac17f958d2ee523a2206206994597c13d831ec7', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDT', +}; + +function buildMockRecommendedQuote( + sourceToken: BridgeToken, + amount: string, + valueInCurrency: string | null, + destinationToken: BridgeToken = usdcToken, + priceData?: { priceImpact?: string }, + quoteId = `${sourceToken.symbol}-${destinationToken.symbol}-${amount}`, + quoteOverrides: Partial<{ + gasIncluded: boolean; + gasIncluded7702: boolean; + gasSponsored: boolean; + quoteBpsFee: number | string | null; + }> = {}, +) { + const { quoteBpsFee = 87.5, ...remainingQuoteOverrides } = quoteOverrides; + + return { + quoteId, + quote: { + requestId: quoteId, + srcAsset: { address: sourceToken.address }, + srcChainId: Number(sourceToken.chainId), + destAsset: { + address: destinationToken.address, + symbol: destinationToken.symbol, + }, + destChainId: Number(destinationToken.chainId), + feeData: { metabridge: { quoteBpsFee } }, + ...(priceData ? { priceData } : {}), + ...remainingQuoteOverrides, + }, + toTokenAmount: { amount, valueInCurrency }, + minToTokenAmount: { amount, valueInCurrency }, + }; +} + +type MockRecommendedQuote = ReturnType; + +const ethNetworkFeeAsset = { + symbol: 'ETH', + chainId: 1, + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:1/slip44:60' as CaipAssetType, + name: 'Ether', + decimals: 18, +}; + +const usdcNetworkFeeAsset = { + symbol: 'USDC', + chainId: 1, + address: usdcToken.address, + assetId: + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as CaipAssetType, + name: 'USD Coin', + decimals: 6, +}; + +let mockSelectedTokens: BridgeToken[] = [ethToken, uniToken]; +let mockSelectedDestinationToken: BridgeToken | undefined = usdcToken; +let mockBatchSellSourceTokenAmounts: Partial< + Record +> = { + [ethAssetId]: '1', + [uniAssetId]: '2', +}; +let mockBatchSellQuotes: { + recommendedQuotes: (MockRecommendedQuote | null)[]; + totalReceived: { amount: string; valueInCurrency: string | null }; + minimumReceived: { amount: string; valueInCurrency: string | null }; + isLoading: boolean; + quotesLastFetchedMs?: number; + isQuoteGoingToRefresh: boolean; +} = { + recommendedQuotes: [ + buildMockRecommendedQuote(ethToken, '123', '123.45'), + buildMockRecommendedQuote(uniToken, '77', '77.89'), + ], + totalReceived: { amount: '200', valueInCurrency: '201.34' }, + minimumReceived: { amount: '190', valueInCurrency: '191.23' }, + isLoading: false, + isQuoteGoingToRefresh: true, +}; +let mockBatchSellTrades: { + totalNetworkFee: + | { + amount: string; + valueInCurrency: string | null; + asset: typeof ethNetworkFeeAsset; + } + | undefined; + isBatchSellTradeAvailable: boolean; +} = { + totalNetworkFee: { + amount: '1.2', + valueInCurrency: '1.25', + asset: ethNetworkFeeAsset, + }, + isBatchSellTradeAvailable: true, +}; +let mockBridgeFeatureFlags: { + chains: Record; + refreshRate: number; + priceImpactThreshold?: { warning?: number }; +} = { + chains: {}, + refreshRate: 30000, + priceImpactThreshold: { warning: 0.05 }, +}; + +jest.mock('react-redux', () => ({ + useSelector: (selector: (state: unknown) => unknown) => selector({}), +})); + +jest.mock('../../../../../core/redux/slices/bridge', () => ({ + selectBatchSellDestToken: jest.fn(() => mockSelectedDestinationToken), + selectBatchSellQuotes: jest.fn(() => mockBatchSellQuotes), + selectBatchSellSlippages: jest.fn(() => ({})), + selectBatchSellSourceTokenAmounts: jest.fn( + () => mockBatchSellSourceTokenAmounts, + ), + selectBatchSellSourceTokens: jest.fn(() => mockSelectedTokens), + selectBatchSellTrades: jest.fn(() => mockBatchSellTrades), + selectBridgeFeatureFlags: jest.fn(() => mockBridgeFeatureFlags), +})); + +jest.mock('../../../../../selectors/currencyRateController', () => ({ + selectCurrentCurrency: jest.fn(() => 'USD'), +})); + +jest.mock('../../../../../util/Logger', () => ({ + __esModule: true, + default: { + error: jest.fn(), + log: jest.fn(), + }, +})); + +describe('useBatchSellQuoteData', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSelectedTokens = [ethToken, uniToken]; + mockSelectedDestinationToken = usdcToken; + mockBatchSellSourceTokenAmounts = { + [ethAssetId]: '1', + [uniAssetId]: '2', + }; + mockBatchSellQuotes = { + recommendedQuotes: [ + buildMockRecommendedQuote(ethToken, '123', '123.45'), + buildMockRecommendedQuote(uniToken, '77', '77.89'), + ], + totalReceived: { amount: '200', valueInCurrency: '201.34' }, + minimumReceived: { amount: '190', valueInCurrency: '191.23' }, + isLoading: false, + isQuoteGoingToRefresh: true, + }; + mockBatchSellTrades = { + totalNetworkFee: { + amount: '1.2', + valueInCurrency: '1.25', + asset: ethNetworkFeeAsset, + }, + isBatchSellTradeAvailable: true, + }; + mockBridgeFeatureFlags = { + chains: {}, + refreshRate: 30000, + priceImpactThreshold: { warning: 0.05 }, + }; + }); + + it('formats complete Batch Sell quote data', () => { + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.hasAnyQuote).toBe(true); + expect(result.current.isGasless).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.isSummaryLoading).toBe(false); + expect(result.current.hasPendingQuoteRows).toBe(false); + expect(result.current.needsNewQuote).toBe(false); + expect(result.current.totalReceived.amount).toBe('200'); + expect(result.current.totalReceived.valueInCurrency).toBe('201.34'); + expect(result.current.minimumReceived.amount).toBe('200'); + expect(result.current.networkFee.amount).toBe('1.2'); + expect(result.current.networkFee.valueInCurrency).toBe('1.25'); + expect(result.current.quotePercentFee).toBe('0.875'); + expect(result.current.totalReceived.formatted).toBe('200 USDC'); + expect(result.current.totalReceived.formattedFiat).toBe('$201.34'); + expect(result.current.minimumReceived.formatted).toBe('200 USDC'); + expect(result.current.networkFeeIsLoading).toBe(false); + expect(result.current.networkFee.formatted).toBe('1.2 ETH'); + expect(result.current.networkFee.formattedFiat).toBe('$1.25'); + expect( + Engine.context.BridgeController.updateBatchSellTrades, + ).toHaveBeenCalledWith(mockBatchSellQuotes.recommendedQuotes); + expect(result.current.tokenData).toEqual({ + [ethAssetId]: expect.objectContaining({ + key: ethAssetId, + tokenSymbol: 'ETH', + receivedAmount: '123 USDC', + receivedAmountFiat: '$123.45', + isLoading: false, + isHighPriceImpact: false, + isQuoteUnavailable: false, + }), + [uniAssetId]: expect.objectContaining({ + key: uniAssetId, + tokenSymbol: 'UNI', + receivedAmount: '77 USDC', + receivedAmountFiat: '$77.89', + isLoading: false, + isHighPriceImpact: false, + isQuoteUnavailable: false, + }), + }); + }); + + it('does not mark Batch Sell quote data as gasless when the network fee is the native gas token', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [ + buildMockRecommendedQuote( + ethToken, + '123', + '123.45', + usdcToken, + undefined, + 'gasless-eth', + { gasIncluded: true, gasIncluded7702: false }, + ), + buildMockRecommendedQuote( + uniToken, + '77', + '77.89', + usdcToken, + undefined, + 'gasless-uni', + { gasIncluded: false, gasIncluded7702: true }, + ), + ], + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.isGasless).toBe(false); + }); + + it('marks Batch Sell quote data as gasless when the network fee is not the native gas token', () => { + mockBatchSellTrades = { + ...mockBatchSellTrades, + totalNetworkFee: { + amount: '1.2', + valueInCurrency: '1.25', + asset: usdcNetworkFeeAsset, + }, + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.isGasless).toBe(true); + }); + + it('does not need a new quote when the quote is expired but going to refresh', () => { + const now = 60000; + const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(now); + mockBridgeFeatureFlags = { + ...mockBridgeFeatureFlags, + refreshRate: 30000, + }; + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + quotesLastFetchedMs: 1, + isQuoteGoingToRefresh: true, + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.needsNewQuote).toBe(false); + + dateNowSpy.mockRestore(); + }); + + it('needs a new quote when the quote is expired and no longer refreshing', () => { + const now = 60000; + const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(now); + mockBridgeFeatureFlags = { + ...mockBridgeFeatureFlags, + refreshRate: 30000, + }; + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + quotesLastFetchedMs: 1, + isQuoteGoingToRefresh: false, + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.needsNewQuote).toBe(true); + expect(result.current.totalReceived.formatted).toBe('200 USDC'); + expect(result.current.totalReceived.formattedFiat).toBe('$201.34'); + + dateNowSpy.mockRestore(); + }); + + it('derives the MetaMask fee from the quoteBpsFee on quote data', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [ + buildMockRecommendedQuote( + ethToken, + '123', + '123.45', + usdcToken, + undefined, + 'dynamic-fee-eth', + { quoteBpsFee: 125 }, + ), + buildMockRecommendedQuote( + uniToken, + '77', + '77.89', + usdcToken, + undefined, + 'dynamic-fee-uni', + { quoteBpsFee: 125 }, + ), + ], + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.quotePercentFee).toBe('1.25'); + }); + + it('does not expose a MetaMask fee when quoteBpsFee is zero', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [ + buildMockRecommendedQuote( + ethToken, + '123', + '123.45', + usdcToken, + undefined, + 'zero-fee-eth', + { quoteBpsFee: 0 }, + ), + ], + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.quotePercentFee).toBeUndefined(); + }); + + it('does not fetch Batch Sell trades again for the same quote ids', () => { + const { rerender } = renderHook(() => useBatchSellQuoteData()); + + expect( + Engine.context.BridgeController.updateBatchSellTrades, + ).toHaveBeenCalledTimes(1); + + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [...mockBatchSellQuotes.recommendedQuotes], + }; + + rerender({}); + + expect( + Engine.context.BridgeController.updateBatchSellTrades, + ).toHaveBeenCalledTimes(1); + }); + + it('fetches Batch Sell trades again when the recommended quote id changes', () => { + const { rerender } = renderHook(() => useBatchSellQuoteData()); + + const [firstQuote, secondQuote] = mockBatchSellQuotes.recommendedQuotes; + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [ + firstQuote + ? { + ...firstQuote, + quoteId: 'updated-quote-id', + } + : firstQuote, + secondQuote, + ], + }; + + rerender({}); + + expect( + Engine.context.BridgeController.updateBatchSellTrades, + ).toHaveBeenCalledTimes(2); + }); + + it('falls back to destination token amounts when display currency values are unavailable', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [ + buildMockRecommendedQuote(ethToken, '123', null), + buildMockRecommendedQuote(uniToken, '77', null), + ], + totalReceived: { amount: '200', valueInCurrency: '0' }, + }; + mockBatchSellTrades = { + ...mockBatchSellTrades, + totalNetworkFee: { + amount: '1.2', + valueInCurrency: '', + asset: ethNetworkFeeAsset, + }, + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.hasAnyQuote).toBe(true); + expect(result.current.isSummaryLoading).toBe(false); + expect(result.current.totalReceived.formattedFiat).toBe('200 USDC'); + expect(result.current.networkFee.formatted).toBe('1.2 ETH'); + expect(result.current.networkFee.formattedFiat).toBe('-'); + expect(result.current.tokenData).toEqual({ + [ethAssetId]: expect.objectContaining({ + receivedAmountFiat: '123 USDC', + }), + [uniAssetId]: expect.objectContaining({ + receivedAmountFiat: '77 USDC', + }), + }); + }); + + it('does not fall back to the destination token symbol when trade fee is unavailable', () => { + mockBatchSellTrades = { + totalNetworkFee: undefined, + isBatchSellTradeAvailable: false, + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.networkFee.formatted).toBe('--'); + expect(result.current.networkFeeIsLoading).toBe(true); + expect(result.current.networkFee.formattedFiat).toBe('-'); + }); + + it('marks quote rows below the warning threshold as safe', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [ + buildMockRecommendedQuote(ethToken, '123', '123.45', usdcToken, { + priceImpact: '0.049', + }), + buildMockRecommendedQuote(uniToken, '77', '77.89'), + ], + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.tokenData[ethAssetId]).toEqual( + expect.objectContaining({ + priceImpact: '0.049', + isHighPriceImpact: false, + }), + ); + }); + + it('marks quote rows at the warning threshold as high price impact', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [ + buildMockRecommendedQuote(ethToken, '123', '123.45', usdcToken, { + priceImpact: '0.05', + }), + buildMockRecommendedQuote(uniToken, '77', '77.89'), + ], + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.tokenData[ethAssetId]).toEqual( + expect.objectContaining({ + priceImpact: '0.05', + isHighPriceImpact: true, + }), + ); + }); + + it('falls back to the default warning threshold when the flag is absent', () => { + mockBridgeFeatureFlags = { + ...mockBridgeFeatureFlags, + priceImpactThreshold: {}, + }; + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [ + buildMockRecommendedQuote(ethToken, '123', '123.45', usdcToken, { + priceImpact: '0.05', + }), + buildMockRecommendedQuote(uniToken, '77', '77.89'), + ], + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.tokenData[ethAssetId].isHighPriceImpact).toBe(true); + }); + + it('matches recommended quotes by source asset id instead of array index', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [ + buildMockRecommendedQuote(uniToken, '77', '77.89'), + buildMockRecommendedQuote(ethToken, '123', '123.45'), + ], + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.tokenData[ethAssetId]).toEqual( + expect.objectContaining({ + tokenSymbol: 'ETH', + receivedAmount: '123 USDC', + receivedAmountFiat: '$123.45', + }), + ); + expect(result.current.tokenData[uniAssetId]).toEqual( + expect.objectContaining({ + tokenSymbol: 'UNI', + receivedAmount: '77 USDC', + receivedAmountFiat: '$77.89', + }), + ); + }); + + it('hides stale quotes when their destination does not match the selected stablecoin', () => { + mockSelectedDestinationToken = usdcToken; + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [ + buildMockRecommendedQuote(ethToken, '123', null, usdtToken), + buildMockRecommendedQuote(uniToken, '77', '77.89', usdtToken), + ], + totalReceived: { amount: '200', valueInCurrency: '201.34' }, + minimumReceived: { amount: '190', valueInCurrency: '191.23' }, + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.hasAnyQuote).toBe(false); + expect(result.current.isLoading).toBe(true); + expect(result.current.isSummaryLoading).toBe(true); + expect(result.current.hasPendingQuoteRows).toBe(true); + expect(result.current.totalReceived.formatted).toBe('-- USDC'); + expect(result.current.totalReceived.formattedFiat).toBe('-'); + expect(result.current.minimumReceived.formatted).toBe('-- USDC'); + expect(result.current.networkFee.formatted).toBe('-- ETH'); + expect(result.current.networkFee.formattedFiat).toBe('-'); + expect(result.current.tokenData).toEqual({ + [ethAssetId]: expect.objectContaining({ + receivedAmount: '-- USDC', + receivedAmountFiat: '-', + isQuoteUnavailable: false, + }), + [uniAssetId]: expect.objectContaining({ + receivedAmount: '-- USDC', + receivedAmountFiat: '-', + isQuoteUnavailable: false, + }), + }); + }); + + it('marks rows without recommended quotes as unavailable after loading', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [ + buildMockRecommendedQuote(ethToken, '123', '123.45'), + null, + ], + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.hasAnyQuote).toBe(true); + expect(result.current.isLoading).toBe(false); + expect(result.current.isSummaryLoading).toBe(false); + expect(result.current.hasPendingQuoteRows).toBe(false); + expect( + Engine.context.BridgeController.updateBatchSellTrades, + ).toHaveBeenCalledWith([mockBatchSellQuotes.recommendedQuotes[0]]); + expect(result.current.tokenData[uniAssetId]).toEqual( + expect.objectContaining({ + tokenSymbol: 'UNI', + receivedAmount: '-- USDC', + receivedAmountFiat: '-', + isLoading: false, + isQuoteUnavailable: true, + }), + ); + }); + + it('shows streamed row data and progressive totals while other rows are loading', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + isLoading: true, + recommendedQuotes: [ + buildMockRecommendedQuote(ethToken, '123', '123.45'), + null, + ], + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.hasAnyQuote).toBe(true); + expect(result.current.isLoading).toBe(true); + expect(result.current.isSummaryLoading).toBe(false); + expect(result.current.hasPendingQuoteRows).toBe(true); + expect(result.current.totalReceived.formatted).toBe('123 USDC'); + expect(result.current.totalReceived.formattedFiat).toBe('$123.45'); + expect(result.current.minimumReceived.formatted).toBe('123 USDC'); + expect( + Engine.context.BridgeController.updateBatchSellTrades, + ).not.toHaveBeenCalled(); + expect(result.current.tokenData[ethAssetId]).toEqual( + expect.objectContaining({ + tokenSymbol: 'ETH', + receivedAmount: '123 USDC', + isLoading: false, + isQuoteUnavailable: false, + }), + ); + expect(result.current.tokenData[uniAssetId]).toEqual( + expect.objectContaining({ + tokenSymbol: 'UNI', + isLoading: true, + isQuoteUnavailable: false, + }), + ); + }); + + it('clears pending rows when every selected token has a quote while still loading', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + isLoading: true, + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.hasAnyQuote).toBe(true); + expect(result.current.isLoading).toBe(true); + expect(result.current.isSummaryLoading).toBe(false); + expect(result.current.hasPendingQuoteRows).toBe(false); + expect( + Engine.context.BridgeController.updateBatchSellTrades, + ).toHaveBeenCalledWith(mockBatchSellQuotes.recommendedQuotes); + }); + + it('hides stale quotes when a refresh starts and reveals new streamed quotes progressively', () => { + const { result, rerender } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.hasAnyQuote).toBe(true); + expect(result.current.totalReceived.formatted).toBe('200 USDC'); + + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + isLoading: true, + }; + + rerender({}); + + expect(result.current.hasAnyQuote).toBe(false); + expect(result.current.isSummaryLoading).toBe(true); + expect(result.current.hasPendingQuoteRows).toBe(true); + expect(result.current.totalReceived.formatted).toBe('-- USDC'); + expect(result.current.tokenData[ethAssetId]).toEqual( + expect.objectContaining({ + isLoading: true, + }), + ); + + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [ + buildMockRecommendedQuote(ethToken, '125', '125.45'), + null, + ], + }; + + rerender({}); + + expect(result.current.hasAnyQuote).toBe(true); + expect(result.current.isSummaryLoading).toBe(false); + expect(result.current.hasPendingQuoteRows).toBe(true); + expect(result.current.totalReceived.formatted).toBe('125 USDC'); + expect(result.current.tokenData[ethAssetId]).toEqual( + expect.objectContaining({ + receivedAmount: '125 USDC', + isLoading: false, + }), + ); + expect(result.current.tokenData[uniAssetId]).toEqual( + expect.objectContaining({ + isLoading: true, + }), + ); + }); + + it('keeps the batch loading before initial quote results arrive', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [], + totalReceived: { amount: '0', valueInCurrency: null }, + minimumReceived: { amount: '0', valueInCurrency: null }, + isLoading: false, + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.hasAnyQuote).toBe(false); + expect(result.current.isLoading).toBe(true); + expect(result.current.isSummaryLoading).toBe(true); + expect(result.current.hasPendingQuoteRows).toBe(true); + expect(result.current.totalReceived.formattedFiat).toBe('-'); + expect(result.current.tokenData[ethAssetId]).toEqual( + expect.objectContaining({ + tokenSymbol: 'ETH', + isLoading: true, + isQuoteUnavailable: false, + }), + ); + }); + + it('keeps the batch loading when quote results do not match selected tokens', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [buildMockRecommendedQuote(ethToken, '123', '123.45')], + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.hasAnyQuote).toBe(true); + expect(result.current.isLoading).toBe(true); + expect(result.current.isSummaryLoading).toBe(false); + expect(result.current.hasPendingQuoteRows).toBe(true); + expect(result.current.tokenData[uniAssetId]).toEqual( + expect.objectContaining({ + tokenSymbol: 'UNI', + isLoading: true, + isQuoteUnavailable: false, + }), + ); + }); + + it('marks the quote set unavailable when no rows have quotes', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [null, null], + totalReceived: { amount: '0', valueInCurrency: null }, + minimumReceived: { amount: '0', valueInCurrency: null }, + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.hasAnyQuote).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.isSummaryLoading).toBe(false); + expect(result.current.hasPendingQuoteRows).toBe(false); + expect(result.current.totalReceived.formattedFiat).toBe('-'); + expect(result.current.tokenData).toEqual({ + [ethAssetId]: expect.objectContaining({ + tokenSymbol: 'ETH', + isLoading: false, + isQuoteUnavailable: true, + }), + [uniAssetId]: expect.objectContaining({ + tokenSymbol: 'UNI', + isLoading: false, + isQuoteUnavailable: true, + }), + }); + }); +}); diff --git a/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/index.ts b/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/index.ts new file mode 100644 index 000000000000..d847635fd85f --- /dev/null +++ b/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/index.ts @@ -0,0 +1,223 @@ +import { useCallback, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { debounce } from 'lodash'; +import BigNumber from 'bignumber.js'; +import { + formatAddressToAssetId, + formatAddressToCaipReference, +} from '@metamask/bridge-controller'; + +import Engine from '../../../../../core/Engine'; +import { + selectBatchSellDestToken, + selectBatchSellSlippages, + selectBatchSellSourceTokenAmounts, + selectBatchSellSourceTokens, +} from '../../../../../core/redux/slices/bridge'; +import { selectBatchSellSourceWalletAddress } from '../../../../../selectors/bridge'; +import { selectShouldUseSmartTransaction } from '../../../../../selectors/smartTransactionsController'; +import { getDecimalChainId } from '../../../../../util/networks'; +import type { BridgeToken } from '../../types'; +import { getBatchSellSlippage } from '../../components/SlippageModal/utils'; +import { getSecurityWarnings } from '../../utils/tokenSecurityUtils'; + +export const BATCH_SELL_QUOTE_DEBOUNCE_MS = 300; + +interface BuildBatchSellQuoteRequestDataParams { + batchSellSlippages: ReturnType; + batchSellSourceTokenAmounts: ReturnType< + typeof selectBatchSellSourceTokenAmounts + >; + destToken: BridgeToken | undefined; + smartTransactionsEnabled: boolean; + sourceTokens: BridgeToken[]; + walletAddress: string | undefined; +} + +type BatchSellQuoteContext = Parameters< + typeof Engine.context.BridgeController.updateBridgeQuoteRequestParams +>[1]; +type BatchSellQuoteRequest = Parameters< + typeof Engine.context.BridgeController.updateBridgeQuoteRequestParams +>[0]; + +interface BatchSellQuoteRequestData { + quoteRequest: BatchSellQuoteRequest; + context: BatchSellQuoteContext; +} + +export function getBatchSellSourceTokenAmount( + token: BridgeToken, + percent: number, +) { + if (percent <= 0) return '0'; + if (!token.balance) return undefined; + + const sourceAmount = new BigNumber(token.balance).times(percent).div(100); + + if (!sourceAmount.isFinite()) return undefined; + + return sourceAmount.toFixed(); +} + +export function getBatchSellAtomicSourceAmount( + token: BridgeToken, + sourceAmount: string | undefined, +) { + if (!sourceAmount) return undefined; + + const atomicAmount = new BigNumber(sourceAmount) + .times(new BigNumber(10).pow(token.decimals)) + .integerValue(BigNumber.ROUND_DOWN); + + if (!atomicAmount.isFinite() || atomicAmount.lte(0)) return undefined; + + return atomicAmount.toFixed(0); +} + +function getBatchSellUsdAmountSource(token: BridgeToken, sourceAmount: string) { + const balance = token.balance ? Number(token.balance) : 0; + const numericSourceAmount = Number(sourceAmount); + + if (!Number.isFinite(numericSourceAmount) || balance <= 0) return 0; + + return ((token.tokenFiatAmount ?? 0) * numericSourceAmount) / balance; +} + +export function buildBatchSellQuoteRequestData({ + batchSellSlippages, + batchSellSourceTokenAmounts, + destToken, + smartTransactionsEnabled, + sourceTokens, + walletAddress, +}: BuildBatchSellQuoteRequestDataParams): BatchSellQuoteRequestData[] { + if (!destToken || !walletAddress) return []; + + const securityWarnings = getSecurityWarnings(destToken); + + return sourceTokens.reduce( + (quoteRequestData, sourceToken) => { + const assetId = formatAddressToAssetId( + sourceToken.address, + sourceToken.chainId, + ); + const sourceAmount = assetId + ? batchSellSourceTokenAmounts[assetId] + : undefined; + const srcTokenAmount = getBatchSellAtomicSourceAmount( + sourceToken, + sourceAmount, + ); + + if (!assetId || !sourceAmount || !srcTokenAmount) return quoteRequestData; + + const slippage = getBatchSellSlippage(batchSellSlippages, assetId); + const slippageNumber = + slippage === undefined ? undefined : Number(slippage); + + quoteRequestData.push({ + // The backend decides what kind of quote to return, so gasIncluded + // and gasIncluded7702 values are ignored. No need to include them. + quoteRequest: { + srcChainId: getDecimalChainId(sourceToken.chainId), + srcTokenAddress: formatAddressToCaipReference(sourceToken.address), + destChainId: getDecimalChainId(destToken.chainId), + destTokenAddress: formatAddressToCaipReference(destToken.address), + srcTokenAmount, + slippage: + slippageNumber === undefined || Number.isNaN(slippageNumber) + ? undefined + : slippageNumber, + walletAddress, + destWalletAddress: walletAddress, + }, + context: { + stx_enabled: smartTransactionsEnabled, + token_symbol_source: sourceToken.symbol, + token_symbol_destination: destToken.symbol, + token_security_type_destination: destToken.securityData?.type ?? null, + security_warnings: securityWarnings, + usd_amount_source: getBatchSellUsdAmountSource( + sourceToken, + sourceAmount, + ), + }, + }); + + return quoteRequestData; + }, + [], + ); +} + +async function updateBatchSellQuoteRequests( + quoteRequestData: BatchSellQuoteRequestData[], +) { + if (quoteRequestData.length === 0) return; + + for (let index = 0; index < quoteRequestData.length; index += 1) { + const { quoteRequest, context } = quoteRequestData[index]; + + await Engine.context.BridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + context, + index, + quoteRequestData.length, + ); + } +} + +export function useBatchSellQuoteRequest() { + const sourceTokens = useSelector(selectBatchSellSourceTokens); + const batchSellSourceTokenAmounts = useSelector( + selectBatchSellSourceTokenAmounts, + ); + const destToken = useSelector(selectBatchSellDestToken); + const batchSellSlippages = useSelector(selectBatchSellSlippages); + const walletAddress = useSelector(selectBatchSellSourceWalletAddress); + const smartTransactionsEnabled = useSelector(selectShouldUseSmartTransaction); + + const quoteRequestData = useMemo( + () => + buildBatchSellQuoteRequestData({ + batchSellSlippages, + batchSellSourceTokenAmounts, + destToken, + smartTransactionsEnabled, + sourceTokens, + walletAddress, + }), + [ + batchSellSlippages, + batchSellSourceTokenAmounts, + destToken, + sourceTokens, + walletAddress, + smartTransactionsEnabled, + ], + ); + + const updateQuoteParams = useCallback( + () => updateBatchSellQuoteRequests(quoteRequestData), + [quoteRequestData], + ); + + const updateBatchSellQuoteParams = useMemo( + () => debounce(updateQuoteParams, BATCH_SELL_QUOTE_DEBOUNCE_MS), + [updateQuoteParams], + ); + + const getNewQuote = useCallback(() => { + Engine.context.BridgeController?.resetState?.(); + updateBatchSellQuoteParams(); + }, [updateBatchSellQuoteParams]); + + return useMemo( + () => ({ + updateBatchSellQuoteParams, + getNewQuote, + }), + [getNewQuote, updateBatchSellQuoteParams], + ); +} diff --git a/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/useBatchSellQuoteRequest.test.ts b/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/useBatchSellQuoteRequest.test.ts new file mode 100644 index 000000000000..4b4e5ebab5fb --- /dev/null +++ b/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/useBatchSellQuoteRequest.test.ts @@ -0,0 +1,382 @@ +import { act } from '@testing-library/react-native'; +import { CaipAssetType, Hex } from '@metamask/utils'; + +import Engine from '../../../../../core/Engine'; +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import { createBridgeTestState } from '../../testUtils'; +import type { BridgeToken } from '../../types'; +import { + BATCH_SELL_QUOTE_DEBOUNCE_MS, + buildBatchSellQuoteRequestData, + getBatchSellAtomicSourceAmount, + getBatchSellSourceTokenAmount, + useBatchSellQuoteRequest, +} from '.'; + +let mockWalletAddress: string | undefined = + '0x1234567890123456789012345678901234567890'; + +jest.mock('../../../../../core/Engine', () => ({ + __esModule: true, + default: { + context: { + BridgeController: { + resetState: jest.fn(), + updateBridgeQuoteRequestParams: jest.fn().mockResolvedValue(undefined), + }, + }, + }, +})); + +jest.mock('../../../../../selectors/bridge', () => ({ + selectBatchSellSourceWalletAddress: jest.fn(() => mockWalletAddress), +})); + +jest.mock('../../../../../selectors/smartTransactionsController', () => ({ + selectShouldUseSmartTransaction: jest.fn(() => false), +})); + +const ethToken: BridgeToken = { + address: '0x1111111111111111111111111111111111111111', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + balance: '1.498', + tokenFiatAmount: 3000, +}; + +const uniToken: BridgeToken = { + address: '0x2222222222222222222222222222222222222222', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'UNI', + balance: '154.297', + tokenFiatAmount: 1000, +}; + +const usdcToken: BridgeToken = { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', +}; + +const ethAssetId = + 'eip155:1/erc20:0x1111111111111111111111111111111111111111' as CaipAssetType; + +function getBridgeControllerMock() { + return Engine.context.BridgeController as jest.Mocked< + typeof Engine.context.BridgeController + >; +} + +async function flushQuoteRequestDebounce() { + await act(async () => { + jest.advanceTimersByTime(BATCH_SELL_QUOTE_DEBOUNCE_MS); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe('useBatchSellQuoteRequest', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + mockWalletAddress = '0x1234567890123456789012345678901234567890'; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns Batch Sell quote request functions', () => { + const testState = createBridgeTestState(); + + const { result } = renderHookWithProvider( + () => useBatchSellQuoteRequest(), + { + state: testState, + }, + ); + + expect(typeof result.current.updateBatchSellQuoteParams).toBe('function'); + expect(typeof result.current.updateBatchSellQuoteParams.cancel).toBe( + 'function', + ); + expect(typeof result.current.getNewQuote).toBe('function'); + }); + + it('calculates source amounts from token balance percentages', () => { + const amount = getBatchSellSourceTokenAmount(ethToken, 50); + + expect(amount).toBe('0.749'); + }); + + it('calculates atomic source amounts from source amount values', () => { + const amount = getBatchSellAtomicSourceAmount(ethToken, '0.749'); + + expect(amount).toBe('749000000000000000'); + }); + + it('builds quote request data for non-zero Batch Sell source token amounts', () => { + const quoteRequestData = buildBatchSellQuoteRequestData({ + batchSellSlippages: { + [ethAssetId]: '2.5', + }, + batchSellSourceTokenAmounts: { + [ethAssetId]: '0.749', + }, + destToken: usdcToken, + smartTransactionsEnabled: false, + sourceTokens: [ethToken, uniToken], + walletAddress: mockWalletAddress, + }); + + expect(quoteRequestData).toEqual([ + expect.objectContaining({ + quoteRequest: expect.objectContaining({ + srcChainId: '1', + srcTokenAddress: ethToken.address, + destChainId: '1', + destTokenAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + srcTokenAmount: '749000000000000000', + slippage: 2.5, + walletAddress: mockWalletAddress, + destWalletAddress: mockWalletAddress, + }), + context: expect.objectContaining({ + stx_enabled: false, + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + token_security_type_destination: null, + usd_amount_source: 1500, + }), + }), + ]); + }); + + it('updates BridgeController quote request params in index order', async () => { + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + batchSellSourceTokens: [ethToken, uniToken], + batchSellSourceTokenAmounts: { + [ethAssetId]: ethToken.balance, + 'eip155:1/erc20:0x2222222222222222222222222222222222222222': + uniToken.balance, + }, + batchSellDestToken: usdcToken, + batchSellSlippages: {}, + }, + }); + + const { result } = renderHookWithProvider( + () => useBatchSellQuoteRequest(), + { + state: testState, + }, + ); + + result.current.updateBatchSellQuoteParams(); + await flushQuoteRequestDebounce(); + + const bridgeController = getBridgeControllerMock(); + expect( + bridgeController.updateBridgeQuoteRequestParams, + ).toHaveBeenCalledTimes(2); + expect( + bridgeController.updateBridgeQuoteRequestParams.mock.calls[0][2], + ).toBe(0); + expect( + bridgeController.updateBridgeQuoteRequestParams.mock.calls[0][3], + ).toBe(2); + expect( + bridgeController.updateBridgeQuoteRequestParams.mock.calls[1][2], + ).toBe(1); + expect( + bridgeController.updateBridgeQuoteRequestParams.mock.calls[1][3], + ).toBe(2); + }); + + it('passes Batch Sell context to BridgeController quote request params', async () => { + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + batchSellSourceTokens: [ethToken, uniToken], + batchSellSourceTokenAmounts: { + [ethAssetId]: '0.749', + 'eip155:1/erc20:0x2222222222222222222222222222222222222222': + '38.57425', + }, + batchSellDestToken: usdcToken, + batchSellSlippages: {}, + }, + }); + + const { result } = renderHookWithProvider( + () => useBatchSellQuoteRequest(), + { + state: testState, + }, + ); + + result.current.updateBatchSellQuoteParams(); + await flushQuoteRequestDebounce(); + + const bridgeController = getBridgeControllerMock(); + expect( + bridgeController.updateBridgeQuoteRequestParams.mock.calls[0][1], + ).toEqual( + expect.objectContaining({ + stx_enabled: false, + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + token_security_type_destination: null, + usd_amount_source: 1500, + }), + ); + expect( + bridgeController.updateBridgeQuoteRequestParams.mock.calls[1][1], + ).toEqual( + expect.objectContaining({ + stx_enabled: false, + token_symbol_source: 'UNI', + token_symbol_destination: 'USDC', + token_security_type_destination: null, + usd_amount_source: 250, + }), + ); + }); + + it('skips update when destination token is missing', async () => { + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + batchSellSourceTokens: [ethToken], + batchSellDestToken: undefined, + }, + }); + + const { result } = renderHookWithProvider( + () => useBatchSellQuoteRequest(), + { + state: testState, + }, + ); + + result.current.updateBatchSellQuoteParams(); + await flushQuoteRequestDebounce(); + + expect( + getBridgeControllerMock().updateBridgeQuoteRequestParams, + ).not.toHaveBeenCalled(); + }); + + it('skips update when wallet address is missing', async () => { + mockWalletAddress = undefined; + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + batchSellSourceTokens: [ethToken], + batchSellSourceTokenAmounts: { + [ethAssetId]: ethToken.balance, + }, + batchSellDestToken: usdcToken, + }, + }); + + const { result } = renderHookWithProvider( + () => useBatchSellQuoteRequest(), + { + state: testState, + }, + ); + + result.current.updateBatchSellQuoteParams(); + await flushQuoteRequestDebounce(); + + expect( + getBridgeControllerMock().updateBridgeQuoteRequestParams, + ).not.toHaveBeenCalled(); + }); + + it('skips update when token percentages produce zero source amounts', async () => { + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + batchSellSourceTokens: [ethToken, uniToken], + batchSellSourceTokenAmounts: { + [ethAssetId]: '0', + 'eip155:1/erc20:0x2222222222222222222222222222222222222222': '0', + }, + batchSellDestToken: usdcToken, + }, + }); + + const { result } = renderHookWithProvider( + () => useBatchSellQuoteRequest(), + { + state: testState, + }, + ); + + result.current.updateBatchSellQuoteParams(); + await flushQuoteRequestDebounce(); + + expect( + getBridgeControllerMock().updateBridgeQuoteRequestParams, + ).not.toHaveBeenCalled(); + }); + + it('does not reset BridgeController state during quote request updates', async () => { + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + batchSellSourceTokens: [ethToken], + batchSellSourceTokenAmounts: { + [ethAssetId]: ethToken.balance, + }, + batchSellDestToken: usdcToken, + }, + }); + + const { result } = renderHookWithProvider( + () => useBatchSellQuoteRequest(), + { + state: testState, + }, + ); + + result.current.updateBatchSellQuoteParams(); + await flushQuoteRequestDebounce(); + + expect(getBridgeControllerMock().resetState).not.toHaveBeenCalled(); + }); + + it('resets BridgeController state before requesting a new quote', async () => { + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + batchSellSourceTokens: [ethToken], + batchSellSourceTokenAmounts: { + [ethAssetId]: ethToken.balance, + }, + batchSellDestToken: usdcToken, + }, + }); + + const { result } = renderHookWithProvider( + () => useBatchSellQuoteRequest(), + { + state: testState, + }, + ); + + result.current.getNewQuote(); + + expect(getBridgeControllerMock().resetState).toHaveBeenCalledTimes(1); + await flushQuoteRequestDebounce(); + expect( + getBridgeControllerMock().updateBridgeQuoteRequestParams, + ).toHaveBeenCalledTimes(1); + expect( + getBridgeControllerMock().resetState.mock.invocationCallOrder[0], + ).toBeLessThan( + getBridgeControllerMock().updateBridgeQuoteRequestParams.mock + .invocationCallOrder[0], + ); + }); +}); diff --git a/app/components/UI/Bridge/hooks/useRewards/useRewards.test.ts b/app/components/UI/Bridge/hooks/useRewards/useRewards.test.ts index da8bc2d0e20c..c4d761c5ae7d 100644 --- a/app/components/UI/Bridge/hooks/useRewards/useRewards.test.ts +++ b/app/components/UI/Bridge/hooks/useRewards/useRewards.test.ts @@ -113,7 +113,7 @@ const mockActiveQuote = { value: '0xde0b6b3a7640000', data: '0x5f575529000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000136f6e65496e6368563646656544796e616d69630000000000000000000000000000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000001033050560000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000f326e4de8f66a0bdc0970b79e0924e33c79f191500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000048a76dfc3b0000000000000000000000000000000000000000000000000000000103305056200000000000000000000000e0554a476a092703abdb3ef35c80e0d76d32939f7dcbea7c0000000000000000000000000000000000000000000000001f', gasLimit: 266281, - }, + } as const, estimatedProcessingTimeInSeconds: 0, sentAmount: { amount: '1', diff --git a/app/components/UI/Bridge/routes.tsx b/app/components/UI/Bridge/routes.tsx index 5d9f4aa6bdd0..a6ad992ade21 100644 --- a/app/components/UI/Bridge/routes.tsx +++ b/app/components/UI/Bridge/routes.tsx @@ -28,6 +28,7 @@ import { BatchSellQuoteDetailsModal } from './components/BatchSellQuoteDetailsMo import { BatchSellFinalReviewModal } from './components/BatchSellFinalReviewModal'; import { BatchSellNetworkFeeInfoModal } from './components/BatchSellNetworkFeeInfoModal'; import { BatchSellMinimumReceivedInfoModal } from './components/BatchSellMinimumReceivedInfoModal'; +import { BatchSellPriceImpactInfoModal } from './components/BatchSellPriceImpactInfoModal'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type ScreenComponent = React.ComponentType; @@ -137,5 +138,9 @@ export const BridgeModalStack = () => ( name={Routes.BRIDGE.MODALS.BATCH_SELL_MINIMUM_RECEIVED_INFO_MODAL} component={BatchSellMinimumReceivedInfoModal} /> + ); diff --git a/app/components/UI/Bridge/utils/tokenUtils.ts b/app/components/UI/Bridge/utils/tokenUtils.ts index f927e887e1eb..8972a759bde4 100644 --- a/app/components/UI/Bridge/utils/tokenUtils.ts +++ b/app/components/UI/Bridge/utils/tokenUtils.ts @@ -60,17 +60,6 @@ export function normalizeEvmAssetId(assetId: CaipAssetType): CaipAssetType { } } -export function getBridgeTokenAssetId( - token: BridgeToken, -): CaipAssetType | undefined { - try { - const assetId = formatAddressToAssetId(token.address, token.chainId); - return assetId ? normalizeEvmAssetId(assetId) : undefined; - } catch { - return undefined; - } -} - /** * Creates a formatted native token object for the given chain ID */ diff --git a/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx b/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx index b09c44a744b7..c3ef6fb80dd9 100644 --- a/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx +++ b/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx @@ -580,6 +580,7 @@ const CardAuthentication = () => { description={description} formFields={formFields} actions={actions} + headerMode="back" /> ); }; diff --git a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx index e6211ea931aa..5bce110a4e8a 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx @@ -68,7 +68,6 @@ import { Alert, Linking } from 'react-native'; import { useSelector } from 'react-redux'; import React from 'react'; import CardHome from './CardHome'; -import { cardDefaultNavigationOptions } from '../../routes'; import { renderScreen } from '../../../../../util/test/renderWithProvider'; import { withCardSDK } from '../../sdk'; import { backgroundState } from '../../../../../util/test/initial-root-state'; @@ -1548,26 +1547,6 @@ describe('CardHome Component', () => { ).toBeTruthy(); }); - it('sets navigation options correctly', () => { - // Given: navigation object - const mockNavigation = { - navigate: mockNavigate, - goBack: mockGoBack, - setOptions: mockSetNavigationOptions, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any; - - // When: getting navigation options - const navigationOptions = cardDefaultNavigationOptions({ - navigation: mockNavigation, - }); - - // Then: should include all required header components - expect(navigationOptions).toHaveProperty('headerLeft'); - expect(navigationOptions).toHaveProperty('headerTitle'); - expect(navigationOptions).toHaveProperty('headerRight'); - }); - it('dispatches bridge tokens when opening swaps with non-supported token', async () => { // Given: ETH token (not supported for deposit) jest.mocked(useFocusEffect).mockImplementation(jest.fn()); diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx index 441c862c998a..f73366902d8d 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx @@ -14,7 +14,9 @@ import { Button, ButtonVariant, ButtonSize, + HeaderStandard, } from '@metamask/design-system-react-native'; +import { useCardHeaderHandlers } from '../../hooks/useCardHeaderHandlers'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import Icon, { IconName, @@ -235,36 +237,45 @@ const CardHome = () => { const hasPriorityTokenBalance = (primaryToken?.rawTokenBalance ?? 0) > 0; + const headerHandlers = useCardHeaderHandlers('back'); + // --- Error state --- if (isError) { return ( - - + - - {strings('card.card_home.error_title')} - - - {strings('card.card_home.error_description')} - - - + {strings('card.card_home.error_description')} + + + + ); @@ -272,183 +283,192 @@ const CardHome = () => { // --- Main render --- return ( - - } - > - - {strings('card.card_home.title')} - - - - - !( - a.type === 'close_to_spending_limit' && - isSpendingLimitWarningDismissed - ), - )} - onNavigateToSpendingLimit={actions.manageSpendingLimitAction} - onDismissSpendingLimitWarning={() => - setIsSpendingLimitWarningDismissed(true) - } - /> - + + + + } + > + + {strings('card.card_home.title')} + - - - + + !( + a.type === 'close_to_spending_limit' && + isSpendingLimitWarningDismissed + ), + )} + onNavigateToSpendingLimit={actions.manageSpendingLimitAction} + onDismissSpendingLimitWarning={() => + setIsSpendingLimitWarningDismissed(true) } /> - {!hasSetupActions && !hasAlertOnlyState && ( - - )} + + + + - {showSpendingLimitProgress && data?.primaryFundingAsset && ( - - )} + {!hasSetupActions && !hasAlertOnlyState && ( + + )} - {((data?.actions ?? []).length > 0 || isLoading) && ( - - - - )} - + )} - {!isLoading && canAddToWallet && ( - - {isProvisioning ? ( - - + {((data?.actions ?? []).length > 0 || isLoading) && ( + + - ) : ( - )} - )} - {canLinkMoneyAccount && ( - <> - - - + {!isLoading && canAddToWallet && ( + + {isProvisioning ? ( + + + + ) : ( + + )} - - - )} - - + )} - + {canLinkMoneyAccount && ( + <> + + + + + + + )} + + + + - - + + + ); }; diff --git a/app/components/UI/Card/Views/Cashback/Cashback.tsx b/app/components/UI/Card/Views/Cashback/Cashback.tsx index 3a96f8f154c1..f965893d11c3 100644 --- a/app/components/UI/Card/Views/Cashback/Cashback.tsx +++ b/app/components/UI/Card/Views/Cashback/Cashback.tsx @@ -9,7 +9,9 @@ import { Button, ButtonVariant, ButtonSize, + HeaderStandard, } from '@metamask/design-system-react-native'; +import { useCardHeaderHandlers } from '../../hooks/useCardHeaderHandlers'; import { IconName } from '../../../../../component-library/components/Icons/Icon'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { useTheme } from '../../../../../util/theme'; @@ -55,6 +57,7 @@ const formatAmount = (value: string | number): string => { const Cashback: React.FC = () => { const navigation = useNavigation(); const tw = useTailwind(); + const headerHandlers = useCardHeaderHandlers('back'); const theme = useTheme(); const { toastRef } = useContext(ToastContext); const { trackEvent, createEventBuilder } = useAnalytics(); @@ -222,6 +225,11 @@ const Cashback: React.FC = () => { edges={['bottom']} testID={CashbackSelectors.CONTAINER} > + {requiresLineaFunding ? ( diff --git a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.test.tsx b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.test.tsx index 944a78228e4c..f9a5e240e901 100644 --- a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.test.tsx +++ b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.test.tsx @@ -120,6 +120,7 @@ jest.mock('@metamask/design-system-react-native', () => { const { TouchableOpacity } = jest.requireActual('react-native'); return { + HeaderStandard: () => null, Box: ({ children, ...props diff --git a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.tsx b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.tsx index c61bcc451cae..6fe9e5437a89 100644 --- a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.tsx +++ b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.tsx @@ -27,7 +27,12 @@ import { Button, ButtonVariant, ButtonSize, + HeaderStandard, } from '@metamask/design-system-react-native'; +import { + useCardHeaderHandlers, + type CardHeaderMode, +} from '../../hooks/useCardHeaderHandlers'; import { strings } from '../../../../../../locales/i18n'; import Icon, { IconName, @@ -75,6 +80,10 @@ const ChooseYourCard = () => { const { flow = 'onboarding', shippingAddress } = useParams(); const isUpgradeFlow = flow === 'upgrade'; + // 'onboarding' is the linear sign-up flow; no header chrome there. + // 'upgrade' / 'home' are user-initiated entries, so show a back button. + const headerMode: CardHeaderMode = flow === 'onboarding' ? 'none' : 'back'; + const headerHandlers = useCardHeaderHandlers(headerMode); // Arrow bounce animation for swipe indicator useEffect(() => { @@ -366,9 +375,16 @@ const ChooseYourCard = () => { return ( + {headerMode !== 'none' && ( + + )} { const { TouchableOpacity } = jest.requireActual('react-native'); return { + HeaderStandard: () => null, Box: ({ children, ...props diff --git a/app/components/UI/Card/Views/OrderCompleted/OrderCompleted.tsx b/app/components/UI/Card/Views/OrderCompleted/OrderCompleted.tsx index 850c4457003c..f2300ec9c7b7 100644 --- a/app/components/UI/Card/Views/OrderCompleted/OrderCompleted.tsx +++ b/app/components/UI/Card/Views/OrderCompleted/OrderCompleted.tsx @@ -11,7 +11,9 @@ import { Button, ButtonVariant, ButtonSize, + HeaderStandard, } from '@metamask/design-system-react-native'; +import { useCardHeaderHandlers } from '../../hooks/useCardHeaderHandlers'; import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; @@ -31,6 +33,7 @@ const OrderCompleted: React.FC = () => { const { trackEvent, createEventBuilder } = useAnalytics(); const navigation = useNavigation(); const tw = useTailwind(); + const headerHandlers = useCardHeaderHandlers('back'); const { fromUpgrade } = useParams(); useEffect(() => { @@ -75,6 +78,11 @@ const OrderCompleted: React.FC = () => { edges={['bottom']} testID={OrderCompletedSelectors.CONTAINER} > + { const { TouchableOpacity } = jest.requireActual('react-native'); return { + HeaderStandard: () => null, Box: ({ children, ...props diff --git a/app/components/UI/Card/Views/ReviewOrder/ReviewOrder.tsx b/app/components/UI/Card/Views/ReviewOrder/ReviewOrder.tsx index 54d6cbc7cbc3..eace24e2648f 100644 --- a/app/components/UI/Card/Views/ReviewOrder/ReviewOrder.tsx +++ b/app/components/UI/Card/Views/ReviewOrder/ReviewOrder.tsx @@ -11,7 +11,9 @@ import { Button, ButtonVariant, ButtonSize, + HeaderStandard, } from '@metamask/design-system-react-native'; +import { useCardHeaderHandlers } from '../../hooks/useCardHeaderHandlers'; import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; @@ -43,6 +45,7 @@ const ReviewOrder = () => { const { navigate } = useNavigation(); const { trackEvent, createEventBuilder } = useAnalytics(); const tw = useTailwind(); + const headerHandlers = useCardHeaderHandlers('back'); const { shippingAddress: routeShippingAddress, fromUpgrade } = useParams(); @@ -218,6 +221,11 @@ const ReviewOrder = () => { edges={['bottom']} testID={ReviewOrderSelectors.CONTAINER} > + = ({ route }) => { const flow = route?.params?.flow || 'manage'; const isOnboardingFlow = flow === 'onboarding'; + // Onboarding flow: linear sign-up, exit resets the stack to Card Home. + // Other flows: standard back navigation. + const headerMode: CardHeaderMode = isOnboardingFlow + ? 'close-reset-home' + : 'back'; + const headerHandlers = useCardHeaderHandlers(headerMode); const selectedTokenFromRoute = route?.params?.selectedToken; const { primaryToken, @@ -177,6 +188,11 @@ const SpendingLimit: React.FC = ({ route }) => { style={tw.style('flex-1 bg-background-default')} edges={['bottom']} > + = ({ route }) => { style={tw.style('flex-1 bg-background-default')} edges={['bottom']} > + = ({ route }) => { style={tw.style('flex-1 bg-background-default')} edges={['bottom']} > + { }; }); -jest.mock('../../hooks/useCardOnboardingNavigationHandlers', () => ({ - useCardOnboardingNavigationHandlers: jest.fn(() => ({})), +jest.mock('../../hooks/useCardHeaderHandlers', () => ({ + useCardHeaderHandlers: jest.fn(() => ({})), })); describe('OnboardingStep Component', () => { diff --git a/app/components/UI/Card/components/Onboarding/OnboardingStep.tsx b/app/components/UI/Card/components/Onboarding/OnboardingStep.tsx index a5fd78b9dc9b..9285eb1df4f0 100644 --- a/app/components/UI/Card/components/Onboarding/OnboardingStep.tsx +++ b/app/components/UI/Card/components/Onboarding/OnboardingStep.tsx @@ -10,9 +10,9 @@ import { import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; import { SafeAreaView } from 'react-native-safe-area-context'; import { - useCardOnboardingNavigationHandlers, - type CardOnboardingHeaderMode, -} from '../../hooks/useCardOnboardingNavigationHandlers'; + useCardHeaderHandlers, + type CardHeaderMode, +} from '../../hooks/useCardHeaderHandlers'; interface OnboardingStepProps { title: string; @@ -29,7 +29,7 @@ interface OnboardingStepProps { * Controls the in-screen header rendered via HeaderStandard. * Navigator headers are hidden; onboarding screens own their header chrome. */ - headerMode?: CardOnboardingHeaderMode; + headerMode?: CardHeaderMode; } const OnboardingStep = ({ @@ -41,7 +41,7 @@ const OnboardingStep = ({ headerMode = 'none', }: OnboardingStepProps) => { const tw = useTailwind(); - const headerHandlers = useCardOnboardingNavigationHandlers(headerMode); + const headerHandlers = useCardHeaderHandlers(headerMode); const renderHeader = () => { if (headerMode === 'none') { diff --git a/app/components/UI/Card/hooks/useCardOnboardingNavigationHandlers.test.tsx b/app/components/UI/Card/hooks/useCardHeaderHandlers.test.tsx similarity index 78% rename from app/components/UI/Card/hooks/useCardOnboardingNavigationHandlers.test.tsx rename to app/components/UI/Card/hooks/useCardHeaderHandlers.test.tsx index 3dc0e3082754..097318d0effb 100644 --- a/app/components/UI/Card/hooks/useCardOnboardingNavigationHandlers.test.tsx +++ b/app/components/UI/Card/hooks/useCardHeaderHandlers.test.tsx @@ -1,16 +1,18 @@ import React from 'react'; import { Alert, Pressable } from 'react-native'; import { render, fireEvent } from '@testing-library/react-native'; -import { useCardOnboardingNavigationHandlers } from './useCardOnboardingNavigationHandlers'; +import { useCardHeaderHandlers } from './useCardHeaderHandlers'; import Routes from '../../../../constants/navigation/Routes'; const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); +const mockReset = jest.fn(); jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ navigate: mockNavigate, goBack: mockGoBack, + reset: mockReset, }), })); @@ -22,10 +24,10 @@ const HookProbe = ({ headerMode, handlerKey, }: { - headerMode: Parameters[0]; + headerMode: Parameters[0]; handlerKey: 'onBack' | 'onClose'; }) => { - const handlers = useCardOnboardingNavigationHandlers(headerMode); + const handlers = useCardHeaderHandlers(headerMode); const handler = handlers[handlerKey]; return ( @@ -35,7 +37,7 @@ const HookProbe = ({ ); }; -describe('useCardOnboardingNavigationHandlers', () => { +describe('useCardHeaderHandlers', () => { beforeEach(() => { jest.clearAllMocks(); jest.spyOn(Alert, 'alert'); @@ -95,9 +97,24 @@ describe('useCardOnboardingNavigationHandlers', () => { expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET.HOME); }); + it('resets navigator to Card Home for close-reset-home header mode', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('handler-button')); + + expect(Alert.alert).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockReset).toHaveBeenCalledWith({ + index: 0, + routes: [{ name: Routes.CARD.HOME }], + }); + }); + it('returns no handlers for none header mode', () => { const NoneProbe = () => { - const handlers = useCardOnboardingNavigationHandlers('none'); + const handlers = useCardHeaderHandlers('none'); return ( Trigger @@ -112,5 +129,6 @@ describe('useCardOnboardingNavigationHandlers', () => { expect(Alert.alert).not.toHaveBeenCalled(); expect(mockNavigate).not.toHaveBeenCalled(); expect(mockGoBack).not.toHaveBeenCalled(); + expect(mockReset).not.toHaveBeenCalled(); }); }); diff --git a/app/components/UI/Card/hooks/useCardHeaderHandlers.ts b/app/components/UI/Card/hooks/useCardHeaderHandlers.ts new file mode 100644 index 000000000000..cf9c47116bb8 --- /dev/null +++ b/app/components/UI/Card/hooks/useCardHeaderHandlers.ts @@ -0,0 +1,93 @@ +import { useCallback } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { Alert } from 'react-native'; +import Routes from '../../../../constants/navigation/Routes'; +import { strings } from '../../../../../locales/i18n'; + +/** + * Header modes shared across Card onboarding and main routes. + * + * `back` — back arrow that calls `navigation.goBack()`. Used for any screen + * the user can step back from. + * + * `close-with-confirmation` — close (X) icon that shows a "leave onboarding?" + * alert before navigating to `WALLET.HOME`. Used in the onboarding flow after + * email entry. + * + * `close-direct` — close (X) icon that navigates straight to `WALLET.HOME`. + * Used on KYC status screens. + * + * `close-reset-home` — close (X) icon that resets the navigator to + * `CARD.HOME`. Used by SpendingLimit's onboarding flow where the user must + * exit the linear flow without the ability to swipe back. + * + * `none` — no header chrome (caller renders something else, or the screen + * intentionally has no header). + * + * Returns props that spread directly into ``. + */ +export type CardHeaderMode = + | 'back' + | 'close-with-confirmation' + | 'close-direct' + | 'close-reset-home' + | 'none'; + +export const useCardHeaderHandlers = (mode: CardHeaderMode = 'none') => { + const navigation = useNavigation(); + + const handleBack = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + const handleCloseDirect = useCallback(() => { + navigation.navigate(Routes.WALLET.HOME); + }, [navigation]); + + const handleCloseResetHome = useCallback(() => { + navigation.reset({ + index: 0, + routes: [{ name: Routes.CARD.HOME }], + }); + }, [navigation]); + + const handleCloseWithConfirmation = useCallback(() => { + Alert.alert( + strings('card.card_onboarding.exit_confirmation.title'), + strings('card.card_onboarding.exit_confirmation.message'), + [ + { + text: strings('card.card_onboarding.exit_confirmation.cancel_button'), + style: 'cancel', + }, + { + text: strings('card.card_onboarding.exit_confirmation.exit_button'), + onPress: () => navigation.navigate(Routes.WALLET.HOME), + style: 'destructive', + }, + ], + ); + }, [navigation]); + + switch (mode) { + case 'back': + return { onBack: handleBack }; + case 'close-with-confirmation': + return { + onClose: handleCloseWithConfirmation, + closeButtonProps: { testID: 'card-header-close-button' }, + }; + case 'close-direct': + return { + onClose: handleCloseDirect, + closeButtonProps: { testID: 'card-header-close-button' }, + }; + case 'close-reset-home': + return { + onClose: handleCloseResetHome, + closeButtonProps: { testID: 'card-header-close-button' }, + }; + default: + return {}; + } +}; diff --git a/app/components/UI/Card/hooks/useCardOnboardingNavigationHandlers.ts b/app/components/UI/Card/hooks/useCardOnboardingNavigationHandlers.ts deleted file mode 100644 index be4630a7f548..000000000000 --- a/app/components/UI/Card/hooks/useCardOnboardingNavigationHandlers.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useCallback } from 'react'; -import { useNavigation } from '@react-navigation/native'; -import { Alert } from 'react-native'; -import Routes from '../../../../constants/navigation/Routes'; -import { strings } from '../../../../../locales/i18n'; - -export type CardOnboardingHeaderMode = - | 'back' - | 'close-with-confirmation' - | 'close-direct' - | 'none'; - -export const useCardOnboardingNavigationHandlers = ( - headerMode: CardOnboardingHeaderMode = 'none', -) => { - const navigation = useNavigation(); - - const handleBack = useCallback(() => { - navigation.goBack(); - }, [navigation]); - - const handleCloseDirect = useCallback(() => { - navigation.navigate(Routes.WALLET.HOME); - }, [navigation]); - - const handleCloseWithConfirmation = useCallback(() => { - Alert.alert( - strings('card.card_onboarding.exit_confirmation.title'), - strings('card.card_onboarding.exit_confirmation.message'), - [ - { - text: strings('card.card_onboarding.exit_confirmation.cancel_button'), - style: 'cancel', - }, - { - text: strings('card.card_onboarding.exit_confirmation.exit_button'), - onPress: () => navigation.navigate(Routes.WALLET.HOME), - style: 'destructive', - }, - ], - ); - }, [navigation]); - - switch (headerMode) { - case 'back': - return { onBack: handleBack }; - case 'close-with-confirmation': - return { - onClose: handleCloseWithConfirmation, - closeButtonProps: { testID: 'exit-onboarding-button' }, - }; - case 'close-direct': - return { - onClose: handleCloseDirect, - closeButtonProps: { testID: 'exit-onboarding-button' }, - }; - default: - return {}; - } -}; diff --git a/app/components/UI/Card/routes/index.tsx b/app/components/UI/Card/routes/index.tsx index 355f765de240..4455b7fa3d5e 100644 --- a/app/components/UI/Card/routes/index.tsx +++ b/app/components/UI/Card/routes/index.tsx @@ -6,8 +6,6 @@ import { import Routes from '../../../../constants/navigation/Routes'; import CardHome from '../Views/CardHome/CardHome'; import CardWelcome from '../Views/CardWelcome/CardWelcome'; -import { NavigationProp, ParamListBase } from '@react-navigation/native'; -import { StyleSheet, View } from 'react-native'; import CardAuthentication from '../Views/CardAuthentication/CardAuthentication'; import SpendingLimit from '../Views/SpendingLimit/SpendingLimit'; import ChooseYourCard from '../Views/ChooseYourCard/ChooseYourCard'; @@ -22,7 +20,6 @@ import { withCardSDK } from '../sdk'; import AddFundsBottomSheet from '../components/AddFundsBottomSheet/AddFundsBottomSheet'; import AssetSelectionBottomSheet from '../components/AssetSelectionBottomSheet/AssetSelectionBottomSheet'; import PasswordBottomSheet from '../components/PasswordBottomSheet'; -import { colors } from '../../../../styles/common'; import RegionSelectorModal from '../components/Onboarding/RegionSelectorModal'; import ConfirmModal from '../components/Onboarding/ConfirmModal'; import RecurringFeeModal from '../components/RecurringFeeModal/RecurringFeeModal'; @@ -32,108 +29,25 @@ import SpendingLimitOptionsSheet from '../Views/SpendingLimit/components/Spendin import WaitlistFormModal from '../components/WaitlistFormModal/WaitlistFormModal'; import OrderCompleted from '../Views/OrderCompleted/OrderCompleted'; import Cashback from '../Views/Cashback/Cashback'; -import { - ButtonIcon, - ButtonIconSize, - IconName, -} from '@metamask/design-system-react-native'; import { clearStackNavigatorOptions } from '../../../../constants/navigation/clearStackNavigatorOptions'; const Stack = createStackNavigator(); const ModalsStack = createStackNavigator(); -export const headerStyle = StyleSheet.create({ - icon: { marginHorizontal: 16 }, - title: { alignSelf: 'center' }, -}); - -// Default navigation has only back button on the left -export const cardDefaultNavigationOptions = ({ - navigation, -}: { - navigation: NavigationProp; -}): StackNavigationOptions => ({ - headerLeft: () => ( - navigation.goBack()} - /> - ), - headerTitle: () => , - headerRight: () => , -}); +// All Card main screens render their own header via HeaderStandard, so hide +// the navigator chrome by default. +const mainScreenOptions: StackNavigationOptions = { headerShown: false }; -export const cardSpendingLimitNavigationOptions = ({ - navigation, +// SpendingLimit's onboarding flow renders a close (X) header and must not be +// swipe-dismissable; all other flows keep the default gesture behavior. +const spendingLimitScreenOptions = ({ route, }: { - navigation: NavigationProp; route: { params?: { flow?: 'manage' | 'enable' | 'onboarding' } }; -}): StackNavigationOptions => { - const flow = route.params?.flow || 'manage'; - const isOnboardingFlow = flow === 'onboarding'; - - return { - headerLeft: () => - isOnboardingFlow ? ( - - ) : ( - navigation.goBack()} - /> - ), - headerTitle: () => , - headerRight: () => - isOnboardingFlow ? ( - - navigation.reset({ - index: 0, - routes: [{ name: Routes.CARD.HOME }], - }) - } - /> - ) : ( - - ), - gestureEnabled: !isOnboardingFlow, - }; -}; - -export const cardChooseYourCardNavigationOptions = ({ - navigation, - route, -}: { - navigation: NavigationProp; - route: { params?: { flow?: 'onboarding' | 'upgrade' | 'home' } }; -}): StackNavigationOptions => { - const flow = route.params?.flow || 'onboarding'; - const showBackButton = flow === 'upgrade' || flow === 'home'; - - return { - headerLeft: () => - showBackButton ? ( - navigation.goBack()} - /> - ) : ( - - ), - headerTitle: () => , - headerRight: () => , - }; -}; +}): StackNavigationOptions => ({ + headerShown: false, + gestureEnabled: route.params?.flow !== 'onboarding', +}); const MainRoutes = () => { const isAuthenticated = useSelector(selectIsCardAuthenticated); @@ -146,51 +60,34 @@ const MainRoutes = () => { ); return ( - - - + + + - + - + ); diff --git a/app/components/Views/ChoosePassword/index.test.tsx b/app/components/Views/ChoosePassword/index.test.tsx index af2fc849e76c..64f97a883672 100644 --- a/app/components/Views/ChoosePassword/index.test.tsx +++ b/app/components/Views/ChoosePassword/index.test.tsx @@ -54,7 +54,10 @@ jest.mock('@metamask/key-tree', () => ({ import ChoosePassword from './index.tsx'; import trackOnboarding from '../../../util/metrics/TrackOnboarding/trackOnboarding'; -import { AccountType } from '../../../constants/onboarding'; +import { + AccountType, + ONBOARDING_SUCCESS_FLOW, +} from '../../../constants/onboarding'; import { TraceName, TraceOperation, @@ -700,7 +703,9 @@ describe('ChoosePassword', () => { routes: [ { name: 'OnboardingSuccess', - params: { showPasswordHint: true }, + params: { + successFlow: ONBOARDING_SUCCESS_FLOW.SEEDLESS_ONBOARDING, + }, }, ], }); diff --git a/app/components/Views/ChoosePassword/index.tsx b/app/components/Views/ChoosePassword/index.tsx index dfb7b7616b62..6014541d70f9 100644 --- a/app/components/Views/ChoosePassword/index.tsx +++ b/app/components/Views/ChoosePassword/index.tsx @@ -60,6 +60,7 @@ import { MetaMetricsEvents } from '../../../core/Analytics'; import { AccountType, getSocialAccountType, + ONBOARDING_SUCCESS_FLOW, } from '../../../constants/onboarding'; import type { IMetaMetricsEvent, @@ -362,7 +363,9 @@ const ChoosePassword = () => { routes: [ { name: Routes.ONBOARDING.SUCCESS, - params: { showPasswordHint: true }, + params: { + successFlow: ONBOARDING_SUCCESS_FLOW.SEEDLESS_ONBOARDING, + }, }, ], }); diff --git a/app/components/Views/Homepage/Homepage.test.tsx b/app/components/Views/Homepage/Homepage.test.tsx index cd4e3362adbd..4e9ff5502b7d 100644 --- a/app/components/Views/Homepage/Homepage.test.tsx +++ b/app/components/Views/Homepage/Homepage.test.tsx @@ -107,11 +107,20 @@ jest.mock('../../UI/Perps', () => ({ })); jest.mock('../../UI/Perps/providers/PerpsConnectionProvider', () => { - const actual = jest.requireActual( - '../../UI/Perps/providers/PerpsConnectionProvider', - ); + const ReactLib = jest.requireActual('react'); + const PerpsConnectionContext = ReactLib.createContext({ + isConnected: true, + isConnecting: false, + isInitialized: true, + error: null, + connect: jest.fn(), + disconnect: jest.fn(), + resetError: jest.fn(), + reconnectWithNewContext: jest.fn().mockResolvedValue(undefined), + }); + return { - ...actual, + PerpsConnectionContext, PerpsConnectionProvider: ({ children }: { children: React.ReactNode }) => children, }; @@ -190,6 +199,46 @@ jest.mock('../../UI/NftGrid/NftGridItemBottomSheet', () => () => null); jest.mock('../../UI/Predict/selectors/featureFlags', () => ({ selectPredictEnabledFlag: jest.fn(() => true), + selectPredictWorldCupConfig: jest.fn(() => ({ + enabled: false, + minimumVersion: '', + showMainFeedBanner: false, + showMainFeedTab: false, + showWorldCupScreen: false, + seriesId: '10218', + tagSlug: 'fifa-world-cup', + gamesTagId: '100639', + stages: [], + })), + selectPredictWorldCupScreenEnabledFlag: jest.fn(() => false), + selectPredictHomepageDiscoveryNbaChampionEnabledFlag: jest.fn(() => false), +})); + +jest.mock('../../UI/Predict/hooks/usePredictWorldCup', () => ({ + usePredictWorldCupMarkets: () => ({ + marketData: [], + isFetching: false, + isFetchingMore: false, + error: null, + hasMore: false, + refetch: jest.fn().mockResolvedValue(undefined), + fetchMore: jest.fn().mockResolvedValue(undefined), + }), + usePredictWorldCupAvailability: () => ({ + availability: { live: false, props: false, stages: {} }, + isFetching: false, + isLoading: false, + errors: [], + refetch: jest.fn(), + }), + usePredictWorldCupAvailableTabs: () => ({ + availability: { live: false, props: false, stages: {} }, + tabs: [], + isFetching: false, + isLoading: false, + errors: [], + refetch: jest.fn(), + }), })); jest.mock('@tanstack/react-query', () => { diff --git a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx index 15a29a0faf24..e12ba28a8544 100644 --- a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx @@ -184,12 +184,8 @@ jest.mock('@tanstack/react-query', () => { jest.mock('./hooks', () => { const actual = jest.requireActual('./hooks') as Record; const tagQueries = actual.HOMEPAGE_PREDICT_TAG_QUERIES as { - worldCup: string; nbaChampion: string; }; - // Two distinct jest mocks under the hood so tests can target each feed - // independently (`.mockReturnValue(...)` on either still works); the - // consolidated `useHomepagePredictTaggedMarkets` dispatches by tag query. const worldCupMock = jest.fn(() => worldCupMarketsWithDiscoveryChampionship(), ); @@ -210,11 +206,12 @@ jest.mock('./hooks', () => { error: null, refetch: jest.fn(), })), + useHomepagePredictWorldCupMarkets: worldCupMock, useHomepagePredictTaggedMarkets: jest.fn( ({ customQueryParams }: { customQueryParams: string }) => customQueryParams === tagQueries.nbaChampion ? nbaMock() - : worldCupMock(), + : worldCupHomepageMarketsMock([]), ), __mockUsePredictWorldCupHomepageMarkets: worldCupMock, __mockUsePredictNbaChampionHomepageMarkets: nbaMock, diff --git a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx index 56a574e9661e..e5abacae7669 100644 --- a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx @@ -25,6 +25,7 @@ import { usePredictMarketsForHomepage, usePredictPositionsForHomepage, useHomepagePredictTaggedMarkets, + useHomepagePredictWorldCupMarkets, HOMEPAGE_PREDICT_TAG_QUERIES, usePredictHomepageDiscoveryExperiment, } from './hooks'; @@ -52,10 +53,7 @@ import type { TransactionActiveAbTestEntry } from '../../../../../util/transacti /** Loads both feeds the World Cup discovery rail needs (World Cup tag + NBA Champion event). */ const useWorldCupDiscoveryFeeds = (enabled: boolean) => ({ - worldCup: useHomepagePredictTaggedMarkets({ - enabled, - customQueryParams: HOMEPAGE_PREDICT_TAG_QUERIES.worldCup, - }), + worldCup: useHomepagePredictWorldCupMarkets({ enabled }), nbaChampion: useHomepagePredictTaggedMarkets({ enabled, customQueryParams: HOMEPAGE_PREDICT_TAG_QUERIES.nbaChampion, diff --git a/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictTrendingMarkets.tsx b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictTrendingMarkets.tsx index c55cbed27dc2..e30838f1da25 100644 --- a/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictTrendingMarkets.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictTrendingMarkets.tsx @@ -1,6 +1,7 @@ import React from 'react'; import type { PredictMarket } from '../../../../../UI/Predict/types'; import type { TransactionActiveAbTestEntry } from '../../../../../../util/transactions/transaction-active-ab-test-attribution-registry'; +import type { UseHomepagePredictWorldCupMarketsResult } from '../hooks/useHomepagePredictWorldCupMarkets'; import type { UseHomepagePredictTaggedMarketsResult } from '../hooks/useHomepagePredictTaggedMarkets'; import type { PredictionsTrendingHeaderTestId } from '../predictionsSectionTypes'; import type { PredictEmptyStateCtaName } from '../../../abTestConfig'; @@ -18,7 +19,7 @@ export interface HomepagePredictTrendingMarketsProps { markets: PredictMarket[]; transactionActiveAbTests?: TransactionActiveAbTestEntry[]; /** Required when `discoveryLayout` is `list` (World Cup discovery rail). */ - worldCupHomepage?: UseHomepagePredictTaggedMarketsResult; + worldCupHomepage?: UseHomepagePredictWorldCupMarketsResult; /** Required when `discoveryLayout` is `list` (NBA champion event, separate from World Cup tag). */ nbaChampionHomepage?: UseHomepagePredictTaggedMarketsResult; emptyStateTransactionActiveAbTests?: TransactionActiveAbTestEntry[]; diff --git a/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/index.tsx b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/index.tsx index b3416d12ce0b..78a162ae1a6e 100644 --- a/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/index.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/index.tsx @@ -23,6 +23,7 @@ import { pickWorldCupWinnerMarket, resolveNbaChampionHomepageMarket, } from '../../utils/marketResolvers'; +import type { UseHomepagePredictWorldCupMarketsResult } from '../../hooks/useHomepagePredictWorldCupMarkets'; import type { UseHomepagePredictTaggedMarketsResult } from '../../hooks/useHomepagePredictTaggedMarkets'; import type { PredictionsTrendingHeaderTestId } from '../../predictionsSectionTypes'; import type { TransactionActiveAbTestEntry } from '../../../../../../../util/transactions/transaction-active-ab-test-attribution-registry'; @@ -39,7 +40,7 @@ export interface HomepagePredictWorldCupDiscoveryProps { transactionActiveAbTests?: TransactionActiveAbTestEntry[], ) => void; headerTestIdKey: PredictionsTrendingHeaderTestId; - worldCup: UseHomepagePredictTaggedMarketsResult; + worldCup: UseHomepagePredictWorldCupMarketsResult; nbaChampion: UseHomepagePredictTaggedMarketsResult; transactionActiveAbTests?: TransactionActiveAbTestEntry[]; onTreatmentCtaClick?: ( diff --git a/app/components/Views/Homepage/Sections/Predictions/hooks/index.ts b/app/components/Views/Homepage/Sections/Predictions/hooks/index.ts index ec29f97cd3fa..4129bf934864 100644 --- a/app/components/Views/Homepage/Sections/Predictions/hooks/index.ts +++ b/app/components/Views/Homepage/Sections/Predictions/hooks/index.ts @@ -2,6 +2,7 @@ export * from './usePredictMarketsForHomepage'; export * from './usePredictPositionsForHomepage'; export * from './usePredictHomepageDiscoveryExperiment'; export * from './useHomepagePredictTaggedMarkets'; +export * from './useHomepagePredictWorldCupMarkets'; export * from './usePredictionsSectionNavigation'; export * from './usePredictionsDefaultSectionModel'; export * from './useTreatmentDiscoveryFeedsLoading'; diff --git a/app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictTaggedMarkets.ts b/app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictTaggedMarkets.ts index 4887f832038d..aecc3964559a 100644 --- a/app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictTaggedMarkets.ts +++ b/app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictTaggedMarkets.ts @@ -4,12 +4,8 @@ import { } from '../../../../../UI/Predict/hooks/usePredictMarketData'; import { PREDICT_HOME_NBA_CHAMPION_EVENT_QUERY } from '../constants/homepageNbaChampionDiscovery'; -/** Polymarket tag for 2026 FIFA World Cup (homepage discovery feed). */ -export const PREDICT_HOME_WORLD_CUP_TAG_QUERY = 'tag_id=102350'; - /** Predefined query parameter slugs the homepage rail loads. */ export const HOMEPAGE_PREDICT_TAG_QUERIES = { - worldCup: PREDICT_HOME_WORLD_CUP_TAG_QUERY, nbaChampion: PREDICT_HOME_NBA_CHAMPION_EVENT_QUERY, } as const; diff --git a/app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictWorldCupMarkets.ts b/app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictWorldCupMarkets.ts new file mode 100644 index 000000000000..f8d37dde11bd --- /dev/null +++ b/app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictWorldCupMarkets.ts @@ -0,0 +1,28 @@ +import { useSelector } from 'react-redux'; +import { usePredictWorldCupMarkets } from '../../../../../UI/Predict/hooks/usePredictWorldCup'; +import type { UsePredictMarketDataResult } from '../../../../../UI/Predict/hooks/usePredictMarketData'; +import { PREDICT_WORLD_CUP_TAB_KEYS } from '../../../../../UI/Predict/constants/worldCupTabs'; +import { selectPredictWorldCupConfig } from '../../../../../UI/Predict/selectors/featureFlags'; + +interface UseHomepagePredictWorldCupMarketsArgs { + enabled: boolean; +} + +/** + * Homepage discovery: loads World Cup markets using the same ALL-tab query path + * as the dedicated World Cup screen (`buildPredictWorldCupAllQuery` → keyset API). + */ +export function useHomepagePredictWorldCupMarkets({ + enabled, +}: UseHomepagePredictWorldCupMarketsArgs): UsePredictMarketDataResult { + const config = useSelector(selectPredictWorldCupConfig); + + return usePredictWorldCupMarkets({ + tabKey: PREDICT_WORLD_CUP_TAB_KEYS.ALL, + config, + enabled, + }); +} + +export type UseHomepagePredictWorldCupMarketsResult = + UsePredictMarketDataResult; diff --git a/app/components/Views/Notifications/PushNotificationOnboarding/NewUserSheet/NewUserSheet.test.tsx b/app/components/Views/Notifications/PushNotificationOnboarding/NewUserSheet/NewUserSheet.test.tsx index e758c730b2a3..91e0b8fc0b53 100644 --- a/app/components/Views/Notifications/PushNotificationOnboarding/NewUserSheet/NewUserSheet.test.tsx +++ b/app/components/Views/Notifications/PushNotificationOnboarding/NewUserSheet/NewUserSheet.test.tsx @@ -5,6 +5,8 @@ import renderWithProvider from '../../../../../util/test/renderWithProvider'; import { strings } from '../../../../../../locales/i18n'; import { NewUserSheetSelectorsIDs } from './NewUserSheet.testIds'; +const mockOnCloseBottomSheet = jest.fn((callback?: () => void) => callback?.()); + jest.mock( '../../../../../component-library/components/BottomSheets/BottomSheet', () => { @@ -13,7 +15,7 @@ jest.mock( // eslint-disable-next-line @typescript-eslint/no-explicit-any ({ children }: any, ref: any) => { MockReact.useImperativeHandle(ref, () => ({ - onCloseBottomSheet: (callback?: () => void) => callback?.(), + onCloseBottomSheet: mockOnCloseBottomSheet, })); return children; }, @@ -82,13 +84,17 @@ describe('NewUserSheet', () => { ).toBeOnTheScreen(); }); - it('calls onYes when Yes is pressed', () => { + it('closes the sheet before calling onYes when Yes is pressed', () => { const mockOnYes = jest.fn(); const { getByTestId } = renderWithProvider( , ); fireEvent.press(getByTestId(NewUserSheetSelectorsIDs.BUTTON_YES)); expect(mockOnYes).toHaveBeenCalledTimes(1); + expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); + expect(mockOnCloseBottomSheet.mock.invocationCallOrder[0]).toBeLessThan( + mockOnYes.mock.invocationCallOrder[0], + ); }); it('calls onNotNow when Not now is pressed', () => { @@ -98,5 +104,9 @@ describe('NewUserSheet', () => { ); fireEvent.press(getByTestId(NewUserSheetSelectorsIDs.BUTTON_NOT_NOW)); expect(mockOnNotNow).toHaveBeenCalledTimes(1); + expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); + expect(mockOnCloseBottomSheet.mock.invocationCallOrder[0]).toBeLessThan( + mockOnNotNow.mock.invocationCallOrder[0], + ); }); }); diff --git a/app/components/Views/Notifications/PushNotificationOnboarding/PushNotificationOnboardingRoot.test.tsx b/app/components/Views/Notifications/PushNotificationOnboarding/PushNotificationOnboardingRoot.test.tsx new file mode 100644 index 000000000000..26394997d8d1 --- /dev/null +++ b/app/components/Views/Notifications/PushNotificationOnboarding/PushNotificationOnboardingRoot.test.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { act, render } from '@testing-library/react-native'; +import PushNotificationOnboardingRoot from './PushNotificationOnboardingRoot'; +import PushNotificationOnboarding, { + type PushPrePromptCompletionReason, +} from '.'; +import { usePushPrePromptVariant } from '../../../../util/notifications/hooks/usePushPrePromptVariant'; + +jest.mock( + '../../../../util/notifications/hooks/usePushPrePromptVariant', + () => ({ + usePushPrePromptVariant: jest.fn(), + }), +); + +jest.mock('.', () => ({ + __esModule: true, + default: jest.fn(() => null), +})); + +const mockUsePushPrePromptVariant = jest.mocked(usePushPrePromptVariant); +const mockPushNotificationOnboarding = jest.mocked(PushNotificationOnboarding); + +const mockDismissPrePrompt = jest.fn(); +const mockMarkPrePromptShown = jest.fn(); +let mockIsE2EValue = false; + +jest.mock('../../../../util/test/utils', () => ({ + get isE2E() { + return mockIsE2EValue; + }, +})); + +const mockPrePromptState = ({ + nativeOsPermissionEnabled = null, + variant = null, +}: Partial> = {}) => { + mockUsePushPrePromptVariant.mockReturnValue({ + dismiss: mockDismissPrePrompt, + isResolving: false, + markShown: mockMarkPrePromptShown, + nativeOsPermissionEnabled, + variant, + }); +}; + +const getLatestProps = () => + mockPushNotificationOnboarding.mock.calls[ + mockPushNotificationOnboarding.mock.calls.length - 1 + ][0]; + +describe('PushNotificationOnboardingRoot', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsE2EValue = false; + mockPrePromptState(); + }); + + afterEach(() => { + mockIsE2EValue = false; + }); + + it('does not render or resolve the pre-prompt during e2e runs', () => { + mockIsE2EValue = true; + mockPrePromptState({ + nativeOsPermissionEnabled: false, + variant: 'push_permission', + }); + + render(); + + expect(mockUsePushPrePromptVariant).not.toHaveBeenCalled(); + expect(mockPushNotificationOnboarding).not.toHaveBeenCalled(); + }); + + it('does not render the sheet when no variant is available', () => { + mockPrePromptState({ variant: null }); + + render(); + + expect(mockPushNotificationOnboarding).not.toHaveBeenCalled(); + }); + + it('renders the resolved pre-prompt variant', () => { + mockPrePromptState({ + nativeOsPermissionEnabled: false, + variant: 'push_permission', + }); + + render(); + + expect(getLatestProps()).toEqual( + expect.objectContaining({ + dismissPrePrompt: mockDismissPrePrompt, + isVisible: true, + markPrePromptShown: mockMarkPrePromptShown, + nativeOsPermissionEnabled: false, + prePromptVariant: 'push_permission', + }), + ); + }); + + it('keeps a visible variant latched until the pre-prompt completes', () => { + mockPrePromptState({ + nativeOsPermissionEnabled: true, + variant: 'push_permission', + }); + const { rerender } = render(); + + mockPrePromptState({ variant: null }); + rerender(); + + expect(getLatestProps()).toEqual( + expect.objectContaining({ + prePromptVariant: 'push_permission', + nativeOsPermissionEnabled: true, + }), + ); + + act(() => { + getLatestProps().onComplete('dismiss' as PushPrePromptCompletionReason); + }); + + mockPrePromptState({ variant: null }); + mockPushNotificationOnboarding.mockClear(); + rerender(); + + expect(mockPushNotificationOnboarding).not.toHaveBeenCalled(); + }); + + it('latches the native OS permission status for the visible pre-prompt', () => { + mockPrePromptState({ + nativeOsPermissionEnabled: true, + variant: 'push_permission', + }); + const { rerender } = render(); + + mockPrePromptState({ + nativeOsPermissionEnabled: false, + variant: 'marketing_consent', + }); + rerender(); + + expect(getLatestProps()).toEqual( + expect.objectContaining({ + nativeOsPermissionEnabled: true, + prePromptVariant: 'push_permission', + }), + ); + }); +}); diff --git a/app/components/Views/Notifications/PushNotificationOnboarding/PushNotificationOnboardingRoot.tsx b/app/components/Views/Notifications/PushNotificationOnboarding/PushNotificationOnboardingRoot.tsx new file mode 100644 index 000000000000..d6a987673994 --- /dev/null +++ b/app/components/Views/Notifications/PushNotificationOnboarding/PushNotificationOnboardingRoot.tsx @@ -0,0 +1,69 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import PushNotificationOnboarding, { + type PushPrePromptCompletionReason, +} from '.'; +import { + usePushPrePromptVariant, + type PushPrePromptVariant, +} from '../../../../util/notifications/hooks/usePushPrePromptVariant'; +import { isE2E } from '../../../../util/test/utils'; + +type VisibleVariant = Exclude; +interface VisiblePrePrompt { + nativeOsPermissionEnabled: boolean | null; + variant: VisibleVariant; +} + +const PushNotificationOnboardingRootContent = () => { + const { + dismiss: dismissPrePrompt, + markShown: markPrePromptShown, + nativeOsPermissionEnabled, + variant, + } = usePushPrePromptVariant(); + + const [visiblePrePrompt, setVisiblePrePrompt] = + useState(null); + + useEffect(() => { + if (variant && !visiblePrePrompt) { + setVisiblePrePrompt({ nativeOsPermissionEnabled, variant }); + } + }, [nativeOsPermissionEnabled, variant, visiblePrePrompt]); + + const currentPrePrompt = + visiblePrePrompt ?? + (variant ? { nativeOsPermissionEnabled, variant } : null); + + const handleComplete = useCallback( + (_reason: PushPrePromptCompletionReason) => { + setVisiblePrePrompt(null); + }, + [], + ); + + if (!currentPrePrompt) { + return null; + } + + return ( + + ); +}; + +const PushNotificationOnboardingRoot = () => { + if (isE2E) { + return null; + } + + return ; +}; + +export default PushNotificationOnboardingRoot; diff --git a/app/components/Views/Notifications/PushNotificationOnboarding/index.test.tsx b/app/components/Views/Notifications/PushNotificationOnboarding/index.test.tsx new file mode 100644 index 000000000000..a6b992c9b0fc --- /dev/null +++ b/app/components/Views/Notifications/PushNotificationOnboarding/index.test.tsx @@ -0,0 +1,451 @@ +import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react-native'; +import { Platform } from 'react-native'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import { + ToastContext, + ToastVariants, +} from '../../../../component-library/components/Toast'; +import PushNotificationOnboarding from '.'; +import type { PushPrePromptVariant } from '../../../../util/notifications/hooks/usePushPrePromptVariant'; + +const mockMarkPrePromptShown = jest.fn().mockResolvedValue(undefined); +const mockDismissPrePrompt = jest.fn(); +const mockRequestPushPermission = jest.fn(); +const mockEnableNotificationsInBackground = jest.fn(); +const mockEnableMarketingConsent = jest.fn(); +const mockShowToast = jest.fn(); +const mockTrackPrePromptViewed = jest.fn(); +const mockTrackPrePromptDismissed = jest.fn(); +const mockTrackPrePromptButtonClicked = jest.fn(); +const mockTrackOsPromptShown = jest.fn(); +const mockTrackOsPromptResponse = jest.fn(); +const mockIdentifyMarketingConsent = jest.fn(); +const mockIdentifyPushNotificationsEnabled = jest.fn(); +const mockOnComplete = jest.fn(); + +jest.mock( + '../../../../util/notifications/hooks/usePushPermissionNotificationSetup', + () => ({ + usePushPermissionNotificationSetup: () => ({ + enableNotificationsInBackground: mockEnableNotificationsInBackground, + requestPushPermission: mockRequestPushPermission, + }), + }), +); + +jest.mock( + '../../../../util/notifications/hooks/useEnableMarketingConsent', + () => ({ + useEnableMarketingConsent: () => ({ + enableMarketingConsent: mockEnableMarketingConsent, + }), + }), +); + +jest.mock( + '../../../../util/notifications/hooks/usePushPrePromptAnalytics', + () => ({ + usePushPrePromptAnalytics: () => ({ + trackPrePromptViewed: mockTrackPrePromptViewed, + trackPrePromptDismissed: mockTrackPrePromptDismissed, + trackPrePromptButtonClicked: mockTrackPrePromptButtonClicked, + trackOsPromptShown: mockTrackOsPromptShown, + trackOsPromptResponse: mockTrackOsPromptResponse, + identifyMarketingConsent: mockIdentifyMarketingConsent, + identifyPushNotificationsEnabled: mockIdentifyPushNotificationsEnabled, + }), + }), +); + +jest.mock( + '../../../../util/notifications/services/NotificationService', + () => ({ + __esModule: true, + isPushPermissionPromptable: jest.fn(), + }), +); +const mockNotificationService = jest.requireMock( + '../../../../util/notifications/services/NotificationService', +); +const mockIsPushPermissionPromptable = + mockNotificationService.isPushPermissionPromptable as jest.Mock; + +jest.mock('./NewUserSheet', () => ({ + __esModule: true, + default: (props: { + isVisible: boolean; + onClose: (hasPendingAction?: boolean) => void; + onYes: () => void; + onNotNow: () => void; + }) => { + const MockReact = jest.requireActual('react'); + const { Pressable: MockPressable, View: MockView } = + jest.requireActual('react-native'); + + return props.isVisible + ? MockReact.createElement( + MockView, + { testID: 'mock-push-permission-sheet' }, + MockReact.createElement(MockPressable, { + testID: 'mock-push-permission-dismiss', + onPress: props.onClose, + }), + MockReact.createElement(MockPressable, { + testID: 'mock-push-permission-action-close', + onPress: () => props.onClose(true), + }), + MockReact.createElement(MockPressable, { + testID: 'mock-push-permission-yes', + onPress: props.onYes, + }), + MockReact.createElement(MockPressable, { + testID: 'mock-push-permission-not-now', + onPress: props.onNotNow, + }), + ) + : null; + }, +})); + +jest.mock('./ExistingUserSheet', () => ({ + __esModule: true, + default: (props: { + isVisible: boolean; + onConfirm: () => void; + onNotNow: () => void; + }) => { + const MockReact = jest.requireActual('react'); + const { Pressable: MockPressable, View: MockView } = + jest.requireActual('react-native'); + + return props.isVisible + ? MockReact.createElement( + MockView, + { testID: 'mock-marketing-consent-sheet' }, + MockReact.createElement(MockPressable, { + testID: 'mock-marketing-consent-confirm', + onPress: props.onConfirm, + }), + MockReact.createElement(MockPressable, { + testID: 'mock-marketing-consent-not-now', + onPress: props.onNotNow, + }), + ) + : null; + }, +})); + +const renderPushNotificationOnboarding = ({ + isVisible = true, + nativeOsPermissionEnabled = false, + prePromptVariant = 'push_permission', +}: { + isVisible?: boolean; + nativeOsPermissionEnabled?: boolean | null; + prePromptVariant?: PushPrePromptVariant; +} = {}) => + renderWithProvider( + + + , + { + state: { + security: { + dataCollectionForMarketing: false, + }, + }, + }, + ); + +const expectNotificationsOnToast = () => { + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: ToastVariants.Plain, + labelOptions: [{ label: 'Notifications are on', isBold: true }], + descriptionOptions: { + description: "We'll send you transactions, price alerts, and updates.", + }, + startAccessory: expect.any(Object), + customBottomOffset: expect.any(Number), + hasNoTimeout: false, + }), + ); +}; + +const expectNotificationsOffToast = () => { + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: ToastVariants.Plain, + labelOptions: [{ label: 'Notifications are off', isBold: true }], + descriptionOptions: { + description: 'Turn them on anytime in Settings → Notifications.', + }, + startAccessory: expect.any(Object), + customBottomOffset: expect.any(Number), + hasNoTimeout: false, + }), + ); +}; + +const expectPersonalizedAlertsOnToast = () => { + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: ToastVariants.Plain, + labelOptions: [{ label: 'Personalized alerts is on', isBold: true }], + descriptionOptions: { + description: 'Manage this anytime in Settings.', + }, + startAccessory: expect.any(Object), + customBottomOffset: expect.any(Number), + hasNoTimeout: false, + }), + ); +}; + +const expectPersonalizedAlertsOffToast = () => { + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: ToastVariants.Plain, + labelOptions: [{ label: 'Personalized alerts is off', isBold: true }], + descriptionOptions: { + description: 'Turn it on anytime in Settings.', + }, + startAccessory: expect.any(Object), + customBottomOffset: expect.any(Number), + hasNoTimeout: false, + }), + ); +}; + +describe('PushNotificationOnboarding', () => { + beforeEach(() => { + jest.clearAllMocks(); + Platform.OS = 'ios'; + mockEnableMarketingConsent.mockResolvedValue(undefined); + mockRequestPushPermission.mockResolvedValue(false); + mockIsPushPermissionPromptable.mockResolvedValue(true); + mockIdentifyMarketingConsent.mockResolvedValue(undefined); + mockIdentifyPushNotificationsEnabled.mockResolvedValue(undefined); + }); + + it('marks the prompt as shown when the push permission sheet renders', async () => { + renderPushNotificationOnboarding(); + + await waitFor(() => { + expect(mockMarkPrePromptShown).toHaveBeenCalledTimes(1); + }); + expect(mockTrackPrePromptViewed).toHaveBeenCalledWith('push_permission'); + }); + + it('does not render or mark shown when not visible', () => { + const { queryByTestId } = renderPushNotificationOnboarding({ + isVisible: false, + }); + + expect(queryByTestId('mock-push-permission-sheet')).toBeNull(); + expect(mockMarkPrePromptShown).not.toHaveBeenCalled(); + }); + + it('requests OS permission, grants marketing consent, and starts background setup with push when Yes is pressed', async () => { + mockRequestPushPermission.mockResolvedValue(true); + const { getByTestId } = renderPushNotificationOnboarding(); + + fireEvent.press(getByTestId('mock-push-permission-yes')); + + await waitFor(() => { + expect(mockOnComplete).toHaveBeenCalledWith('engage'); + }); + expect(mockRequestPushPermission).toHaveBeenCalledTimes(1); + expect(mockEnableMarketingConsent).toHaveBeenCalledTimes(1); + expect(mockEnableNotificationsInBackground).toHaveBeenCalledWith(true); + expect(mockDismissPrePrompt).toHaveBeenCalledTimes(1); + expect(mockTrackPrePromptButtonClicked).toHaveBeenCalledWith( + 'push_permission', + 'yes', + ); + expect(mockTrackOsPromptShown).toHaveBeenCalledWith('push_permission'); + expect(mockTrackOsPromptResponse).toHaveBeenCalledWith( + 'push_permission', + 'allowed', + ); + expect(mockIdentifyPushNotificationsEnabled).toHaveBeenCalledWith(true); + expectNotificationsOnToast(); + expect(mockOnComplete.mock.invocationCallOrder[0]).toBeLessThan( + mockEnableNotificationsInBackground.mock.invocationCallOrder[0], + ); + }); + + it('starts background setup without push when OS permission is denied', async () => { + const { getByTestId } = renderPushNotificationOnboarding(); + + fireEvent.press(getByTestId('mock-push-permission-yes')); + + await waitFor(() => { + expect(mockOnComplete).toHaveBeenCalledWith('engage'); + }); + expect(mockRequestPushPermission).toHaveBeenCalledTimes(1); + expect(mockEnableMarketingConsent).toHaveBeenCalledTimes(1); + expect(mockEnableNotificationsInBackground).toHaveBeenCalledWith(false); + expect(mockTrackOsPromptResponse).toHaveBeenCalledWith( + 'push_permission', + 'denied', + ); + expect(mockIdentifyPushNotificationsEnabled).toHaveBeenCalledWith(false); + expectNotificationsOffToast(); + }); + + it.each(['ios', 'android'] as const)( + 'skips the OS prompt and treats permission as denied on %s when native permission was previously denied', + async (platform) => { + Platform.OS = platform; + mockIsPushPermissionPromptable.mockResolvedValue(false); + const { getByTestId } = renderPushNotificationOnboarding(); + + fireEvent.press(getByTestId('mock-push-permission-yes')); + + await waitFor(() => { + expect(mockOnComplete).toHaveBeenCalledWith('engage'); + }); + expect(mockEnableMarketingConsent).toHaveBeenCalledTimes(1); + expect(mockRequestPushPermission).not.toHaveBeenCalled(); + expect(mockTrackOsPromptShown).not.toHaveBeenCalled(); + expect(mockTrackOsPromptResponse).not.toHaveBeenCalled(); + expect(mockIdentifyPushNotificationsEnabled).toHaveBeenCalledWith(false); + expect(mockEnableNotificationsInBackground).toHaveBeenCalledWith(false); + expectNotificationsOffToast(); + }, + ); + + it('keeps the pre-prompt pending until the OS prompt result resolves', async () => { + let resolveRequestPushPermission: (isEnabled: boolean) => void = jest.fn(); + mockRequestPushPermission.mockReturnValue( + new Promise((resolve) => { + resolveRequestPushPermission = resolve; + }), + ); + const { getByTestId } = renderPushNotificationOnboarding(); + + fireEvent.press(getByTestId('mock-push-permission-yes')); + + await waitFor(() => { + expect(mockRequestPushPermission).toHaveBeenCalledTimes(1); + }); + expect(mockOnComplete).not.toHaveBeenCalled(); + expect(mockDismissPrePrompt).not.toHaveBeenCalled(); + expect(mockEnableNotificationsInBackground).not.toHaveBeenCalled(); + + resolveRequestPushPermission(true); + + await waitFor(() => { + expect(mockOnComplete).toHaveBeenCalledWith('engage'); + }); + expect(mockDismissPrePrompt).toHaveBeenCalledTimes(1); + expect(mockEnableNotificationsInBackground).toHaveBeenCalledWith(true); + }); + + it('skips the OS permission request when native push is already enabled', async () => { + const { getByTestId } = renderPushNotificationOnboarding({ + nativeOsPermissionEnabled: true, + }); + + fireEvent.press(getByTestId('mock-push-permission-yes')); + + await waitFor(() => { + expect(mockOnComplete).toHaveBeenCalledWith('engage'); + }); + expect(mockRequestPushPermission).not.toHaveBeenCalled(); + expect(mockTrackOsPromptShown).not.toHaveBeenCalled(); + expect(mockTrackOsPromptResponse).not.toHaveBeenCalled(); + expect(mockIdentifyPushNotificationsEnabled).toHaveBeenCalledWith(true); + expect(mockEnableMarketingConsent).toHaveBeenCalledTimes(1); + expect(mockEnableNotificationsInBackground).toHaveBeenCalledWith(true); + expectNotificationsOnToast(); + }); + + it('does not request notifications when Not now is pressed', () => { + const { getByTestId } = renderPushNotificationOnboarding(); + + fireEvent.press(getByTestId('mock-push-permission-not-now')); + + expect(mockRequestPushPermission).not.toHaveBeenCalled(); + expect(mockEnableNotificationsInBackground).not.toHaveBeenCalled(); + expect(mockDismissPrePrompt).toHaveBeenCalledTimes(1); + expect(mockOnComplete).toHaveBeenCalledWith('dismiss'); + expect(mockTrackPrePromptButtonClicked).toHaveBeenCalledWith( + 'push_permission', + 'not_now', + ); + expectNotificationsOffToast(); + }); + + it('sets marketing consent when the marketing prompt is confirmed', () => { + const { getByTestId } = renderPushNotificationOnboarding({ + prePromptVariant: 'marketing_consent', + }); + + fireEvent.press(getByTestId('mock-marketing-consent-confirm')); + + expect(mockOnComplete).toHaveBeenCalledWith('engage'); + expect(mockRequestPushPermission).not.toHaveBeenCalled(); + expect(mockEnableNotificationsInBackground).not.toHaveBeenCalled(); + expect(mockEnableMarketingConsent).toHaveBeenCalledTimes(1); + expect(mockTrackPrePromptButtonClicked).toHaveBeenCalledWith( + 'marketing_consent', + 'confirm', + ); + expectPersonalizedAlertsOnToast(); + }); + + it('does not enable marketing notifications when the marketing prompt is skipped', () => { + const { getByTestId } = renderPushNotificationOnboarding({ + prePromptVariant: 'marketing_consent', + }); + + fireEvent.press(getByTestId('mock-marketing-consent-not-now')); + + expect(mockOnComplete).toHaveBeenCalledWith('dismiss'); + expect(mockEnableMarketingConsent).not.toHaveBeenCalled(); + expect(mockTrackPrePromptButtonClicked).toHaveBeenCalledWith( + 'marketing_consent', + 'not_now', + ); + expect(mockIdentifyMarketingConsent).toHaveBeenCalledWith(false); + expectPersonalizedAlertsOffToast(); + }); + + it('does not dismiss when the sheet closes for a pending button action', () => { + const { getByTestId } = renderPushNotificationOnboarding(); + + fireEvent.press(getByTestId('mock-push-permission-action-close')); + + expect(mockDismissPrePrompt).not.toHaveBeenCalled(); + expect(mockTrackPrePromptDismissed).not.toHaveBeenCalled(); + }); + + it('dismisses when the sheet is closed directly', () => { + const { getByTestId } = renderPushNotificationOnboarding(); + + fireEvent.press(getByTestId('mock-push-permission-dismiss')); + + expect(mockDismissPrePrompt).toHaveBeenCalledTimes(1); + expect(mockOnComplete).toHaveBeenCalledWith('dismiss'); + expect(mockTrackPrePromptDismissed).toHaveBeenCalledWith('push_permission'); + }); +}); diff --git a/app/components/Views/Notifications/PushNotificationOnboarding/index.tsx b/app/components/Views/Notifications/PushNotificationOnboarding/index.tsx new file mode 100644 index 000000000000..bcd19de133b6 --- /dev/null +++ b/app/components/Views/Notifications/PushNotificationOnboarding/index.tsx @@ -0,0 +1,284 @@ +import React, { useCallback, useContext, useEffect, useRef } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { strings } from '../../../../../locales/i18n'; +import { + ToastContext, + ToastVariants, +} from '../../../../component-library/components/Toast'; +import Icon, { + IconColor, + IconName, + IconSize, +} from '../../../../component-library/components/Icons/Icon'; +import { useEnableMarketingConsent } from '../../../../util/notifications/hooks/useEnableMarketingConsent'; +import { usePushPermissionNotificationSetup } from '../../../../util/notifications/hooks/usePushPermissionNotificationSetup'; +import { PushPrePromptVariant } from '../../../../util/notifications/hooks/usePushPrePromptVariant'; +import { usePushPrePromptAnalytics } from '../../../../util/notifications/hooks/usePushPrePromptAnalytics'; +import { isPushPermissionPromptable } from '../../../../util/notifications/services/NotificationService'; +import { TAB_BAR_HEIGHT } from '../../../../component-library/components/Navigation/TabBar/TabBar.constants'; +import ExistingUserSheet from './ExistingUserSheet'; +import NewUserSheet from './NewUserSheet'; + +export type PushPrePromptCompletionReason = 'complete' | 'dismiss' | 'engage'; + +interface PushNotificationOnboardingProps { + dismissPrePrompt: () => void; + isVisible: boolean; + markPrePromptShown: () => Promise; + nativeOsPermissionEnabled: boolean | null; + onComplete: (reason: PushPrePromptCompletionReason) => void; + prePromptVariant: PushPrePromptVariant; +} + +const styles = StyleSheet.create({ + toastAccessory: { + alignSelf: 'flex-start', + marginRight: 12, + paddingTop: 4, + }, +}); + +const METRICS_OPT_IN_LOCATION = 'push_pre_prompt'; + +const PushNotificationOnboarding = ({ + dismissPrePrompt, + isVisible, + markPrePromptShown, + nativeOsPermissionEnabled, + onComplete, + prePromptVariant, +}: PushNotificationOnboardingProps) => { + // Helpers to request OS push permission and finish wiring up notifications once granted. + const { enableNotificationsInBackground, requestPushPermission } = + usePushPermissionNotificationSetup(); + + const { toastRef } = useContext(ToastContext); + const viewedPrePromptVariant = useRef(null); + + // Analytics emitters for every stage of the pre-prompt → OS prompt funnel. + const { + trackPrePromptViewed, + trackPrePromptDismissed, + trackPrePromptButtonClicked, + trackOsPromptShown, + trackOsPromptResponse, + identifyMarketingConsent, + identifyPushNotificationsEnabled, + } = usePushPrePromptAnalytics(); + + // Opt the user into marketing consent (and MetaMetrics if needed) when they accept the prompt. + const { enableMarketingConsent } = useEnableMarketingConsent({ + metricsOptInLocation: METRICS_OPT_IN_LOCATION, + }); + + // Mark each variant as shown once, when its sheet first becomes visible. + useEffect(() => { + if ( + !isVisible || + !prePromptVariant || + viewedPrePromptVariant.current === prePromptVariant + ) { + return; + } + + viewedPrePromptVariant.current = prePromptVariant; + markPrePromptShown().catch(() => undefined); + trackPrePromptViewed(prePromptVariant); + }, [isVisible, markPrePromptShown, prePromptVariant, trackPrePromptViewed]); + + const showNotificationStatusToast = useCallback( + ({ + isEnabled, + title, + description, + }: { + isEnabled: boolean; + title: string; + description: string; + }) => { + const iconColor = isEnabled ? IconColor.Success : IconColor.Alternative; + + toastRef?.current?.showToast({ + variant: ToastVariants.Plain, + labelOptions: [ + { + label: title, + isBold: true, + }, + ], + descriptionOptions: { + description, + }, + startAccessory: ( + + + + ), + customBottomOffset: TAB_BAR_HEIGHT, + hasNoTimeout: false, + }); + }, + [toastRef], + ); + + const showPushPermissionToast = useCallback( + (areNotificationsEnabled: boolean) => { + showNotificationStatusToast({ + isEnabled: areNotificationsEnabled, + title: strings( + areNotificationsEnabled + ? 'notifications.push_onboarding.new_user.toast.notifications_on.title' + : 'notifications.push_onboarding.new_user.toast.notifications_off.title', + ), + description: strings( + areNotificationsEnabled + ? 'notifications.push_onboarding.new_user.toast.notifications_on.description' + : 'notifications.push_onboarding.new_user.toast.notifications_off.description', + ), + }); + }, + [showNotificationStatusToast], + ); + + const showMarketingConsentToast = useCallback( + (arePersonalizedAlertsEnabled: boolean) => { + showNotificationStatusToast({ + isEnabled: arePersonalizedAlertsEnabled, + title: strings( + arePersonalizedAlertsEnabled + ? 'notifications.push_onboarding.existing_user.toast.personalized_alerts_on.title' + : 'notifications.push_onboarding.existing_user.toast.personalized_alerts_off.title', + ), + description: strings( + arePersonalizedAlertsEnabled + ? 'notifications.push_onboarding.existing_user.toast.personalized_alerts_on.description' + : 'notifications.push_onboarding.existing_user.toast.personalized_alerts_off.description', + ), + }); + }, + [showNotificationStatusToast], + ); + + const handlePrePromptDismissed = useCallback( + (hasPendingAction?: boolean) => { + // BottomSheet onClose can fire while a CTA action is still running. + if (hasPendingAction) { + return; + } + if (prePromptVariant) { + trackPrePromptDismissed(prePromptVariant); + } + dismissPrePrompt(); + onComplete('dismiss'); + }, + [dismissPrePrompt, onComplete, prePromptVariant, trackPrePromptDismissed], + ); + + const handlePushPermissionYes = useCallback(async () => { + let nativePermissionEnabled = nativeOsPermissionEnabled === true; + trackPrePromptButtonClicked('push_permission', 'yes'); + try { + // Accepting push notifications also opts the user into marketing consent. + await enableMarketingConsent(); + + if (!nativePermissionEnabled) { + // A "denied" OS state means the dialog will not be shown again + // (iOS after any denial; Android 13+ after permanent denial; + // Android <13 when the user disabled notifications in Settings). + // Skip the request and treat it as denied in all those cases. + const isPromptable = await isPushPermissionPromptable(); + if (isPromptable) { + trackOsPromptShown('push_permission'); + nativePermissionEnabled = await requestPushPermission(); + trackOsPromptResponse( + 'push_permission', + nativePermissionEnabled ? 'allowed' : 'denied', + ); + } + } + identifyPushNotificationsEnabled(nativePermissionEnabled).catch( + () => undefined, + ); + showPushPermissionToast(nativePermissionEnabled); + } finally { + dismissPrePrompt(); + onComplete('engage'); + enableNotificationsInBackground(nativePermissionEnabled); + } + }, [ + dismissPrePrompt, + enableMarketingConsent, + enableNotificationsInBackground, + identifyPushNotificationsEnabled, + nativeOsPermissionEnabled, + onComplete, + requestPushPermission, + showPushPermissionToast, + trackOsPromptResponse, + trackOsPromptShown, + trackPrePromptButtonClicked, + ]); + + const handlePushPermissionNotNow = useCallback(() => { + dismissPrePrompt(); + onComplete('dismiss'); + trackPrePromptButtonClicked('push_permission', 'not_now'); + showPushPermissionToast(false); + }, [ + dismissPrePrompt, + onComplete, + showPushPermissionToast, + trackPrePromptButtonClicked, + ]); + + const handleMarketingConsentConfirm = useCallback(() => { + dismissPrePrompt(); + onComplete('engage'); + trackPrePromptButtonClicked('marketing_consent', 'confirm'); + enableMarketingConsent().catch(() => undefined); + showMarketingConsentToast(true); + }, [ + dismissPrePrompt, + enableMarketingConsent, + onComplete, + showMarketingConsentToast, + trackPrePromptButtonClicked, + ]); + + const handleMarketingConsentNotNow = useCallback(() => { + dismissPrePrompt(); + onComplete('dismiss'); + trackPrePromptButtonClicked('marketing_consent', 'not_now'); + identifyMarketingConsent(false).catch(() => undefined); + showMarketingConsentToast(false); + }, [ + dismissPrePrompt, + identifyMarketingConsent, + onComplete, + showMarketingConsentToast, + trackPrePromptButtonClicked, + ]); + + return ( + <> + + + + ); +}; + +export default PushNotificationOnboarding; diff --git a/app/components/Views/OnboardingSuccess/index.test.tsx b/app/components/Views/OnboardingSuccess/index.test.tsx index 2105a9f6f05f..431d19e30911 100644 --- a/app/components/Views/OnboardingSuccess/index.test.tsx +++ b/app/components/Views/OnboardingSuccess/index.test.tsx @@ -15,6 +15,7 @@ import Engine from '../../../core/Engine/Engine'; import { strings } from '../../../../locales/i18n'; import { useSelector } from 'react-redux'; import Logger from '../../../util/Logger'; +import { Platform } from 'react-native'; import { SET_WALLET_HOME_ONBOARDING_STEPS_ELIGIBLE, setWalletHomeOnboardingStepsEligible, @@ -254,6 +255,28 @@ describe('OnboardingSuccessComponent', () => { expect(getByTestId('onboarding-success-end-animation')).toBeOnTheScreen(); }); + it('hides OnboardingSuccessEndAnimation on Android for seedless onboarding flow', () => { + const originalPlatform = Platform.OS; + Object.defineProperty(Platform, 'OS', { + writable: true, + value: 'android', + }); + + const { queryByTestId } = renderWithProvider( + , + ); + + expect(queryByTestId('onboarding-success-end-animation')).toBeNull(); + + Object.defineProperty(Platform, 'OS', { + writable: true, + value: originalPlatform, + }); + }); + it('hides manage default settings button for SETTINGS_BACKUP flow', () => { const { queryByTestId } = renderWithProvider( = ({ } return strings('onboarding_success.wallet_ready'); }; + const shouldSkipSuccessAnimation = + Platform.OS === 'android' && + successFlow === ONBOARDING_SUCCESS_FLOW.SEEDLESS_ONBOARDING; const renderContent = () => ( <> - { - // No-op: Animation completion not needed in success mode - }} - /> + {!shouldSkipSuccessAnimation && ( + { + // No-op: Animation completion not needed in success mode + }} + /> + )} ({ @@ -193,6 +194,11 @@ jest.mock('../../../constants/bridge', () => ({ BATCH_SELL_ENABLED: true, })); +jest.mock('../../../util/address', () => ({ + ...jest.requireActual('../../../util/address'), + isHardwareAccount: jest.fn(), +})); + const mockInitialState: DeepPartial = { swaps: { '0x1': { isLive: true }, hasOnboarded: false, isLive: true }, fiatOrders: { @@ -291,6 +297,7 @@ jest.mock('../../../util/navigation/navUtils', () => ({ describe('TradeWalletActions', () => { beforeEach(() => { jest.clearAllMocks(); + jest.mocked(isHardwareAccount).mockReturnValue(false); mockUseStakingEligibility.mockReturnValue({ isEligible: true, @@ -375,6 +382,27 @@ describe('TradeWalletActions', () => { ).toBeDefined(); }); + it('does not render Batch Sell for hardware wallets', () => { + jest.mocked(isHardwareAccount).mockReturnValue(true); + + const { getByTestId, queryByTestId } = renderScreen( + TradeWalletActions, + { + name: 'TradeWalletActions', + }, + { + state: mockInitialState, + }, + ); + + expect( + queryByTestId(WalletActionsBottomSheetSelectorsIDs.BATCH_SELL_BUTTON), + ).toBeNull(); + expect( + getByTestId(WalletActionsBottomSheetSelectorsIDs.SWAP_BUTTON), + ).toBeDefined(); + }); + it('does not render earn button when user is not eligible', () => { ( selectStablecoinLendingEnabledFlag as jest.MockedFunction< diff --git a/app/components/Views/TradeWalletActions/TradeWalletActions.tsx b/app/components/Views/TradeWalletActions/TradeWalletActions.tsx index d12af4fdcbe2..70aa1145ab32 100644 --- a/app/components/Views/TradeWalletActions/TradeWalletActions.tsx +++ b/app/components/Views/TradeWalletActions/TradeWalletActions.tsx @@ -43,9 +43,13 @@ import Routes from '../../../constants/navigation/Routes'; import AppConstants from '../../../core/AppConstants'; import { selectIsSwapsEnabled } from '../../../core/redux/slices/bridge'; import { RootState } from '../../../reducers'; -import { selectCanSignTransactions } from '../../../selectors/accountsController'; +import { + selectCanSignTransactions, + selectSelectedInternalAccountAddress, +} from '../../../selectors/accountsController'; import { earnSelectors } from '../../../selectors/earnController'; import { selectChainId } from '../../../selectors/networkController'; +import { isHardwareAccount } from '../../../util/address'; import { getDecimalChainId } from '../../../util/networks'; import { SwapBridgeNavigationLocation, @@ -111,6 +115,12 @@ function TradeWalletActions() { const { isEligible: isEarnEligible } = useStakingEligibility(); const canSignTransactions = useSelector(selectCanSignTransactions); + const selectedAddress = useSelector(selectSelectedInternalAccountAddress); + const isHardwareWallet = selectedAddress + ? Boolean(isHardwareAccount(selectedAddress)) + : false; + const shouldRenderBatchSell = + BATCH_SELL_ENABLED && AppConstants.SWAPS.ACTIVE && !isHardwareWallet; const isPerpsEnabled = useSelector(selectPerpsEnabledFlag); const isPredictEnabled = useSelector(selectPredictEnabledFlag); @@ -299,7 +309,7 @@ function TradeWalletActions() { `px-0`, )} > - {BATCH_SELL_ENABLED && AppConstants.SWAPS.ACTIVE && ( + {shouldRenderBatchSell && ( diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 26b9ba0aa1df..a05f3d080b35 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -331,6 +331,7 @@ const Routes = { BATCH_SELL_NETWORK_FEE_INFO_MODAL: 'BatchSellNetworkFeeInfoModal', BATCH_SELL_MINIMUM_RECEIVED_INFO_MODAL: 'BatchSellMinimumReceivedInfoModal', + BATCH_SELL_PRICE_IMPACT_INFO_MODAL: 'BatchSellPriceImpactInfoModal', }, BRIDGE_TRANSACTION_DETAILS: 'BridgeTransactionDetails', }, diff --git a/app/constants/onboarding.ts b/app/constants/onboarding.ts index 7ea249f5c712..a5b65c3c6dab 100644 --- a/app/constants/onboarding.ts +++ b/app/constants/onboarding.ts @@ -69,6 +69,7 @@ export enum ONBOARDING_SUCCESS_FLOW { BACKED_UP_SRP = 'backedUpSRP', NO_BACKED_UP_SRP = 'noBackedUpSRP', IMPORT_FROM_SEED_PHRASE = 'importFromSeedPhrase', + SEEDLESS_ONBOARDING = 'seedlessOnboarding', SETTINGS_BACKUP = 'settingsBackup', REMINDER_BACKUP = 'reminderBackup', } diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 1246b431d7a5..2fbfc482ef96 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -553,6 +553,9 @@ enum EVENT_NAME { NOTIFICATION_DETAIL_CLICKED = 'Notification Detail Clicked', // Push Notifications + PUSH_NOTIFICATION_PRE_PROMPT_VIEWED = 'Push Notification Pre-prompt Viewed', + PUSH_NOTIFICATION_PRE_PROMPT_BUTTON_CLICKED = 'Push Notification Pre-prompt Button Clicked', + OS_PUSH_NOTIFICATION_BUTTON_CLICKED = 'OS Push Notification Button Clicked', PUSH_NOTIFICATION_RECEIVED = 'Push Notification Received', PUSH_NOTIFICATION_CLICKED = 'Push Notification Clicked', @@ -1465,6 +1468,15 @@ const events = { ), // Push Notifications Flow + PUSH_NOTIFICATION_PRE_PROMPT_VIEWED: generateOpt( + EVENT_NAME.PUSH_NOTIFICATION_PRE_PROMPT_VIEWED, + ), + PUSH_NOTIFICATION_PRE_PROMPT_BUTTON_CLICKED: generateOpt( + EVENT_NAME.PUSH_NOTIFICATION_PRE_PROMPT_BUTTON_CLICKED, + ), + OS_PUSH_NOTIFICATION_BUTTON_CLICKED: generateOpt( + EVENT_NAME.OS_PUSH_NOTIFICATION_BUTTON_CLICKED, + ), PUSH_NOTIFICATION_RECEIVED: generateOpt( EVENT_NAME.PUSH_NOTIFICATION_RECEIVED, ), diff --git a/app/core/Engine/controllers/bridge-controller/bridge-controller-init.test.ts b/app/core/Engine/controllers/bridge-controller/bridge-controller-init.test.ts index 2547029c4752..b4d94fd5afa8 100644 --- a/app/core/Engine/controllers/bridge-controller/bridge-controller-init.test.ts +++ b/app/core/Engine/controllers/bridge-controller/bridge-controller-init.test.ts @@ -410,5 +410,29 @@ describe('BridgeController Init', () => { handleBridgeFetch(url, options); expect(handleFetch).toHaveBeenCalledWith(url.toString(), options); }); + + it('should use fetch if the url includes obtainGaslessBatch', async () => { + const url = new URL('http://localhost:3000/obtainGaslessBatch'); + const options = { + body: JSON.stringify({ quotes: [] }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }; + const response = { + ok: true, + status: 200, + statusText: 'OK', + } as unknown as Response; + const fetchMock = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(response); + + await expect(handleBridgeFetch(url, options)).resolves.toBe(response); + + expect(fetchMock).toHaveBeenCalledWith(url.toString(), options); + expect(handleFetch).not.toHaveBeenCalled(); + + fetchMock.mockRestore(); + }); }); }); diff --git a/app/core/Engine/controllers/bridge-controller/bridge-controller-init.ts b/app/core/Engine/controllers/bridge-controller/bridge-controller-init.ts index 1f9ad2754fee..89a957cf5896 100644 --- a/app/core/Engine/controllers/bridge-controller/bridge-controller-init.ts +++ b/app/core/Engine/controllers/bridge-controller/bridge-controller-init.ts @@ -34,10 +34,17 @@ export const handleBridgeFetch = async ( url: RequestInfo | URL, options: RequestInit = {}, ) => { - if (url.toString().includes('Stream')) { + const urlString = url.toString(); + + if (urlString.includes('Stream')) { // @ts-expect-error - expoFetch has a different RequestInit type - return expoFetch(url.toString(), options); + return expoFetch(urlString, options); + } + + if (urlString.includes('/obtainGaslessBatch')) { + return fetch(urlString, options); } + return handleFetch(url, options); }; diff --git a/app/core/NavigationService/types.ts b/app/core/NavigationService/types.ts index a746199b8990..93259262e577 100644 --- a/app/core/NavigationService/types.ts +++ b/app/core/NavigationService/types.ts @@ -19,8 +19,6 @@ import type { BrowserParams } from '../../components/Views/Browser/Browser.types // Bridge params import type { BridgeRouteParams } from '../../components/UI/Bridge/hooks/useSwapBridgeNavigation'; import type { BridgeTokenSelectorRouteParams } from '../../components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector'; -import type { BatchSellQuoteDetailsModalParams } from '../../components/UI/Bridge/components/BatchSellQuoteDetailsModal/BatchSellQuoteDetailsModal.types'; -import type { BatchSellFinalReviewModalParams } from '../../components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.types'; import type { BatchSellNetworkFeeInfoModalParams } from '../../components/UI/Bridge/components/BatchSellNetworkFeeInfoModal/BatchSellNetworkFeeInfoModal.types'; import type { BatchSellMinimumReceivedInfoModalParams } from '../../components/UI/Bridge/components/BatchSellMinimumReceivedInfoModal/BatchSellMinimumReceivedInfoModal.types'; import type { @@ -550,8 +548,8 @@ export interface RootStackParamList extends ParamListBase { BlockaidModal: BlockaidModalParams; RecipientSelectorModal: undefined; BatchSellDestinationTokenSelectorModal: undefined; - BatchSellQuoteDetailsModal: BatchSellQuoteDetailsModalParams; - BatchSellFinalReviewModal: BatchSellFinalReviewModalParams; + BatchSellQuoteDetailsModal: undefined; + BatchSellFinalReviewModal: undefined; BatchSellNetworkFeeInfoModal: BatchSellNetworkFeeInfoModalParams | undefined; BatchSellMinimumReceivedInfoModal: | BatchSellMinimumReceivedInfoModalParams diff --git a/app/core/redux/slices/bridge/index.test.ts b/app/core/redux/slices/bridge/index.test.ts index ed980ae97f58..53d020377cfb 100644 --- a/app/core/redux/slices/bridge/index.test.ts +++ b/app/core/redux/slices/bridge/index.test.ts @@ -24,10 +24,14 @@ import reducer, { selectIsRwaSwap, setBatchSellSourceTokens, selectBatchSellSourceTokens, + setBatchSellSourceTokenAmount, + setBatchSellSourceTokenAmounts, + selectBatchSellSourceTokenAmounts, setBatchSellDestToken, selectBatchSellDestToken, selectBatchSellDestStablecoins, selectBatchSellDestStablecoinsByChain, + selectBatchSellQuotes, selectBatchSellSlippages, setBatchSellTokenSlippage, setBatchSellTokenSlippages, @@ -37,12 +41,32 @@ import { BridgeToken, BridgeViewMode, } from '../../../../components/UI/Bridge/types'; -import { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; +import { + CaipAssetType, + CaipChainId, + Hex, + parseCaipAssetType, +} from '@metamask/utils'; import { RootState } from '../../../../reducers'; import { cloneDeep } from 'lodash'; import { BridgeTokenMetadata } from '../../../../components/UI/Bridge/constants/tokens'; +import { formatAddressToAssetId } from '@metamask/bridge-controller'; describe('bridge slice', () => { + function getChecksummedBridgeTokenMetadata(assetId: CaipAssetType) { + const metadata = BridgeTokenMetadata[assetId]; + const formattedAssetId = formatAddressToAssetId( + metadata.address, + metadata.chainId, + ) as CaipAssetType; + const { assetReference } = parseCaipAssetType(formattedAssetId); + + return { + ...metadata, + address: assetReference, + }; + } + const mockToken: BridgeToken = { address: '0x123', symbol: 'ETH', @@ -98,6 +122,7 @@ describe('bridge slice', () => { selectedQuoteRequestId: undefined, abTestContext: undefined, batchSellSourceTokens: [], + batchSellSourceTokenAmounts: {}, batchSellDestToken: undefined, batchSellSlippages: {}, }); @@ -281,6 +306,50 @@ describe('bridge slice', () => { expect(selectBatchSellSourceTokens(mockState)).toEqual([mockToken]); }); + it('sets Batch Sell source token amount by asset ID', () => { + const assetId = + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as CaipAssetType; + + const state = reducer( + initialState, + setBatchSellSourceTokenAmount({ assetId, amount: '1.5' }), + ); + + expect(state.batchSellSourceTokenAmounts[assetId]).toBe('1.5'); + }); + + it('replaces Batch Sell source token amount map', () => { + const assetId = + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as CaipAssetType; + + const state = reducer( + { + ...initialState, + batchSellSourceTokenAmounts: { + 'eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7': '0.5', + }, + }, + setBatchSellSourceTokenAmounts({ [assetId]: '3' }), + ); + + expect(state.batchSellSourceTokenAmounts).toEqual({ [assetId]: '3' }); + }); + + it('selects Batch Sell source token amount map', () => { + const assetId = + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as CaipAssetType; + const mockState = { + bridge: { + ...initialState, + batchSellSourceTokenAmounts: { [assetId]: '2' }, + }, + } as RootState; + + expect(selectBatchSellSourceTokenAmounts(mockState)).toEqual({ + [assetId]: '2', + }); + }); + it('sets Batch Sell destination token metadata', () => { const state = reducer(initialState, setBatchSellDestToken(mockToken)); @@ -760,7 +829,7 @@ describe('bridge slice', () => { { symbol: 'USDC', name: 'USD Coin', - address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6, image: 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.png', @@ -796,14 +865,12 @@ describe('bridge slice', () => { batchSellDestStablecoins: [baseUsdc], } as unknown as any; - const expectedEthUsdc = - BridgeTokenMetadata[ - 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as CaipAssetType - ]; - const expectedBaseUsdc = - BridgeTokenMetadata[ - 'eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913' as CaipAssetType - ]; + const expectedEthUsdc = getChecksummedBridgeTokenMetadata( + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as CaipAssetType, + ); + const expectedBaseUsdc = getChecksummedBridgeTokenMetadata( + 'eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913' as CaipAssetType, + ); const result = selectBatchSellDestStablecoinsByChain( mockState as unknown as RootState, @@ -859,6 +926,20 @@ describe('bridge slice', () => { }); }); + describe('selectBatchSellQuotes', () => { + it('uses the BridgeController quote request count', () => { + const mockState = cloneDeep(mockRootState); + mockState.engine.backgroundState.BridgeController.quoteRequest = [ + { srcTokenAddress: '0x1111111111111111111111111111111111111111' }, + { srcTokenAddress: '0x2222222222222222222222222222222222222222' }, + ] as unknown as typeof mockState.engine.backgroundState.BridgeController.quoteRequest; + + const result = selectBatchSellQuotes(mockState as unknown as RootState); + + expect(result.recommendedQuotes).toHaveLength(2); + }); + }); + describe('selectTokenSelectorNetworkFilter', () => { it('should return undefined when no filter is set', () => { const mockState = cloneDeep(mockRootState); diff --git a/app/core/redux/slices/bridge/index.ts b/app/core/redux/slices/bridge/index.ts index 2fe2ce26b861..c080f70dbeb5 100644 --- a/app/core/redux/slices/bridge/index.ts +++ b/app/core/redux/slices/bridge/index.ts @@ -4,6 +4,7 @@ import { Hex, CaipChainId, parseCaipChainId, + parseCaipAssetType, CaipAssetType, } from '@metamask/utils'; import { createSelector } from 'reselect'; @@ -18,10 +19,13 @@ import { formatChainIdToCaip, isSolanaChainId, selectBridgeQuotes as selectBridgeQuotesBase, + selectBatchSellQuotes as selectBatchSellQuotesBase, + selectBatchSellTrades as selectBatchSellTradesBase, SortOrder, selectBridgeFeatureFlags as selectBridgeFeatureFlagsBase, DEFAULT_FEATURE_FLAG_CONFIG, isNonEvmChainId, + formatAddressToAssetId, formatChainIdToHex, type QuoteStreamCompleteData, } from '@metamask/bridge-controller'; @@ -42,10 +46,7 @@ import { selectCanSignTransactions } from '../../../../selectors/accountsControl import { selectBasicFunctionalityEnabled } from '../../../../selectors/settings'; import { hasMinimumRequiredVersion } from './utils/hasMinimumRequiredVersion'; import { Bip44TokensForDefaultPairs } from '../../../../components/UI/Bridge/constants/default-swap-dest-tokens'; -import { - normalizeEvmAssetId, - normalizeTokenAddress, -} from '../../../../components/UI/Bridge/utils/tokenUtils'; +import { normalizeTokenAddress } from '../../../../components/UI/Bridge/utils/tokenUtils'; import { isStockRwaBridgeToken } from '../../../../components/UI/Bridge/utils/isStockRwaBridgeToken'; import { selectRWAEnabledFlag } from '../../../../selectors/featureFlagController/rwa'; import { BridgeTokenMetadata } from '../../../../components/UI/Bridge/constants/tokens'; @@ -95,6 +96,9 @@ export interface BridgeState { */ selectedQuoteRequestId: string | undefined; batchSellSourceTokens: BridgeToken[]; + batchSellSourceTokenAmounts: Partial< + Record + >; batchSellDestToken: BridgeToken | undefined; batchSellSlippages: Partial>; } @@ -123,6 +127,7 @@ export const initialState: BridgeState = { // Batch Sell batchSellSourceTokens: [], + batchSellSourceTokenAmounts: {}, batchSellDestToken: undefined, batchSellSlippages: {}, }; @@ -260,6 +265,22 @@ const slice = createSlice({ setBatchSellSourceTokens: (state, action: PayloadAction) => { state.batchSellSourceTokens = action.payload.map(normalizeBridgeToken); }, + setBatchSellSourceTokenAmount: ( + state, + action: PayloadAction<{ + assetId: CaipAssetType; + amount: string | undefined; + }>, + ) => { + state.batchSellSourceTokenAmounts[action.payload.assetId] = + action.payload.amount; + }, + setBatchSellSourceTokenAmounts: ( + state, + action: PayloadAction, + ) => { + state.batchSellSourceTokenAmounts = action.payload; + }, setBatchSellDestToken: ( state, action: PayloadAction, @@ -368,23 +389,57 @@ export const selectBridgeFeatureFlags = createSelector( }, ); +function formatBatchSellStablecoinAssetId( + assetId: CaipAssetType, +): CaipAssetType | undefined { + try { + const { assetNamespace, assetReference, chainId } = + parseCaipAssetType(assetId); + + if (chainId.startsWith('eip155:') && assetNamespace === 'erc20') { + return formatAddressToAssetId(assetReference, chainId); + } + + return formatAddressToAssetId(assetId) ?? assetId; + } catch { + return undefined; + } +} + function getBridgeTokenMetadata( assetId: CaipAssetType, ): BridgeToken | undefined { - const exactMatch = BridgeTokenMetadata[assetId]; + const formattedAssetId = formatBatchSellStablecoinAssetId(assetId); - if (exactMatch) { - return exactMatch; + if (!formattedAssetId) { + return undefined; } - const normalizedAssetId = normalizeEvmAssetId(assetId); const metadataAssetIds = Object.keys(BridgeTokenMetadata) as CaipAssetType[]; const metadataAssetId = metadataAssetIds.find( (bridgeTokenMetadataAssetId) => - normalizeEvmAssetId(bridgeTokenMetadataAssetId) === normalizedAssetId, + formatBatchSellStablecoinAssetId(bridgeTokenMetadataAssetId) === + formattedAssetId, ); + const tokenMetadata = metadataAssetId + ? BridgeTokenMetadata[metadataAssetId] + : undefined; - return metadataAssetId ? BridgeTokenMetadata[metadataAssetId] : undefined; + if (!tokenMetadata) { + return undefined; + } + + const { assetNamespace, assetReference, chainId } = + parseCaipAssetType(formattedAssetId); + + if (chainId.startsWith('eip155:') && assetNamespace === 'erc20') { + return { + ...tokenMetadata, + address: assetReference, + }; + } + + return tokenMetadata; } function getBatchSellDestStablecoinMetadata( @@ -574,6 +629,11 @@ export const selectBatchSellSourceTokens = createSelector( (bridgeState) => bridgeState.batchSellSourceTokens, ); +export const selectBatchSellSourceTokenAmounts = createSelector( + selectBridgeState, + (bridgeState) => bridgeState.batchSellSourceTokenAmounts ?? {}, +); + export const selectBatchSellDestToken = createSelector( selectBridgeState, (bridgeState) => bridgeState.batchSellDestToken, @@ -650,6 +710,21 @@ export const selectBridgeQuotes = createSelector( }, ); +export const selectBatchSellQuotes = createSelector( + selectControllerFields, + (requiredControllerFields) => + selectBatchSellQuotesBase(requiredControllerFields, { + sortOrder: SortOrder.COST_ASC, + requestCount: requiredControllerFields.quoteRequest.length, + }), +); + +export const selectBatchSellTrades = createSelector( + selectControllerFields, + (requiredControllerFields) => + selectBatchSellTradesBase(requiredControllerFields), +); + export const selectIsSolanaSourced = createSelector( selectSourceToken, (sourceToken) => sourceToken?.chainId && isSolanaChainId(sourceToken.chainId), @@ -873,6 +948,8 @@ export const { setVisiblePillChainIds, setSelectedQuoteRequestId, setBatchSellSourceTokens, + setBatchSellSourceTokenAmount, + setBatchSellSourceTokenAmounts, setBatchSellDestToken, setBatchSellTokenSlippage, setBatchSellTokenSlippages, diff --git a/app/selectors/bridge.ts b/app/selectors/bridge.ts index 493cf8617a11..a68a56521a00 100644 --- a/app/selectors/bridge.ts +++ b/app/selectors/bridge.ts @@ -10,6 +10,7 @@ import { RootState } from '../reducers'; import { selectSourceToken, selectDestToken, + selectBatchSellSourceTokens, selectIsSwap, selectIsGasIncludedSTXSendBundleSupported, selectIsGasIncluded7702Supported, @@ -38,6 +39,24 @@ export const selectSourceWalletAddress = createSelector( }, ); +/** + * Gets the wallet address for the first Batch Sell source token by finding the + * selected account that matches the token's chain scope. + */ +export const selectBatchSellSourceWalletAddress = createSelector( + [(state: RootState) => state, selectBatchSellSourceTokens], + (state, sourceTokens) => { + const [sourceToken] = sourceTokens; + if (!sourceToken) return undefined; + + const chainId = formatChainIdToCaip(sourceToken.chainId); + const internalAccount = + selectSelectedInternalAccountByScope(state)(chainId); + + return internalAccount ? internalAccount.address : undefined; + }, +); + /** * Returns a Set of InternalAccount IDs that are valid as destination accounts * for the currently selected destination token. For EVM destinations, includes diff --git a/app/selectors/engagement.test.ts b/app/selectors/engagement.test.ts new file mode 100644 index 000000000000..0caca0ce7b78 --- /dev/null +++ b/app/selectors/engagement.test.ts @@ -0,0 +1,21 @@ +import { selectDataCollectionForMarketingEnabled } from './engagement'; +import type { RootState } from '../reducers'; + +describe('engagement selectors', () => { + it.each([ + [true, true], + [false, false], + [false, null], + ])( + 'returns %s when dataCollectionForMarketing is %s', + (expected, dataCollectionForMarketing) => { + const state = { + security: { + dataCollectionForMarketing, + }, + } as RootState; + + expect(selectDataCollectionForMarketingEnabled(state)).toBe(expected); + }, + ); +}); diff --git a/app/selectors/engagement.ts b/app/selectors/engagement.ts new file mode 100644 index 000000000000..a55ec0ea9425 --- /dev/null +++ b/app/selectors/engagement.ts @@ -0,0 +1,4 @@ +import type { RootState } from '../reducers'; + +export const selectDataCollectionForMarketingEnabled = (state: RootState) => + state.security?.dataCollectionForMarketing === true; diff --git a/app/store/sagas/backfillSocialLoginMarketingConsent.test.ts b/app/store/sagas/backfillSocialLoginMarketingConsent.test.ts index 43c3ccca5036..f88111d8f700 100644 --- a/app/store/sagas/backfillSocialLoginMarketingConsent.test.ts +++ b/app/store/sagas/backfillSocialLoginMarketingConsent.test.ts @@ -86,8 +86,8 @@ describe('backfillSocialLoginMarketingConsent', () => { await expectSaga(backfillSocialLoginMarketingConsentSaga) .withState(state) .dispatch(loginAction) - .put(setPendingSocialLoginMarketingConsentBackfill(null)) .put(setDataCollectionForMarketing(true)) + .put(setPendingSocialLoginMarketingConsentBackfill(null)) .run(); expect(mockedGetMarketingOptInStatus).not.toHaveBeenCalled(); @@ -125,8 +125,8 @@ describe('backfillSocialLoginMarketingConsent', () => { await expectSaga(backfillSocialLoginMarketingConsentSaga) .withState(state) .dispatch(loginAction) - .put(setPendingSocialLoginMarketingConsentBackfill(null)) .put(setDataCollectionForMarketing(false)) + .put(setPendingSocialLoginMarketingConsentBackfill(null)) .run(); expect(mockedGetMarketingOptInStatus).toHaveBeenCalled(); @@ -166,8 +166,8 @@ describe('backfillSocialLoginMarketingConsent', () => { await expectSaga(backfillSocialLoginMarketingConsentSaga) .withState(state) .dispatch(loginAction) - .put(setPendingSocialLoginMarketingConsentBackfill(null)) .put(setDataCollectionForMarketing(true)) + .put(setPendingSocialLoginMarketingConsentBackfill(null)) .run(); expect(mockedGetMarketingOptInStatus).toHaveBeenCalled(); @@ -183,7 +183,7 @@ describe('backfillSocialLoginMarketingConsent', () => { ); }); - it('does not clear the marker when getMarketingOptInStatus rejects', async () => { + it('clears the marker when getMarketingOptInStatus rejects', async () => { mockedGetMarketingOptInStatus.mockRejectedValueOnce( new Error('no access token'), ); @@ -203,7 +203,7 @@ describe('backfillSocialLoginMarketingConsent', () => { await expectSaga(backfillSocialLoginMarketingConsentSaga) .withState(state) .dispatch(loginAction) - .not.put(setPendingSocialLoginMarketingConsentBackfill(null)) + .put(setPendingSocialLoginMarketingConsentBackfill(null)) .run(); expect(mockedLoggerError).toHaveBeenCalledWith( @@ -215,7 +215,7 @@ describe('backfillSocialLoginMarketingConsent', () => { expect(updateDataRecordingFlag).not.toHaveBeenCalled(); }); - it('does not clear the marker when trackEvent throws', async () => { + it('clears the marker when trackEvent throws', async () => { const state = { ...initialRootState, security: { @@ -235,7 +235,7 @@ describe('backfillSocialLoginMarketingConsent', () => { await expectSaga(backfillSocialLoginMarketingConsentSaga) .withState(state) .dispatch(loginAction) - .not.put(setPendingSocialLoginMarketingConsentBackfill(null)) + .put(setPendingSocialLoginMarketingConsentBackfill(null)) .run(); expect(mockedIdentify).toHaveBeenCalledWith({ @@ -243,4 +243,30 @@ describe('backfillSocialLoginMarketingConsent', () => { }); expect(updateDataRecordingFlag).not.toHaveBeenCalled(); }); + + it('persists fetched OAuth marketing consent before clearing the marker when analytics fails', async () => { + const state = { + ...initialRootState, + security: { + ...initialRootState.security, + dataCollectionForMarketing: false, + }, + onboarding: { + ...initialRootState.onboarding, + pendingSocialLoginMarketingConsentBackfill: 'google', + }, + }; + + mockedGetMarketingOptInStatus.mockResolvedValueOnce({ is_opt_in: true }); + mockedTrackEvent.mockImplementation(() => { + throw new Error('track failed'); + }); + + await expectSaga(backfillSocialLoginMarketingConsentSaga) + .withState(state) + .dispatch(loginAction) + .put(setDataCollectionForMarketing(true)) + .put(setPendingSocialLoginMarketingConsentBackfill(null)) + .run(); + }); }); diff --git a/app/store/sagas/backfillSocialLoginMarketingConsent.ts b/app/store/sagas/backfillSocialLoginMarketingConsent.ts index cc8f65aa1bf1..3805e8ddb45f 100644 --- a/app/store/sagas/backfillSocialLoginMarketingConsent.ts +++ b/app/store/sagas/backfillSocialLoginMarketingConsent.ts @@ -27,6 +27,7 @@ export function* backfillSocialLoginMarketingConsentSaga() { yield select( (state: RootState) => state.security?.dataCollectionForMarketing, ); + let fetchedMarketingConsent = false; try { if (marketingConsent !== true) { @@ -34,17 +35,20 @@ export function* backfillSocialLoginMarketingConsentSaga() { ReturnType > = yield call([OAuthService, OAuthService.getMarketingOptInStatus]); marketingConsent = marketingOptIn.is_opt_in; + fetchedMarketingConsent = true; } + const resolvedMarketingConsent = Boolean(marketingConsent); + yield call([analytics, analytics.identify], { - [UserProfileProperty.HAS_MARKETING_CONSENT]: Boolean(marketingConsent), + [UserProfileProperty.HAS_MARKETING_CONSENT]: resolvedMarketingConsent, }); const event = AnalyticsEventBuilder.createEventBuilder( MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED, ) .setSaveDataRecording(true) .addProperties({ - [UserProfileProperty.HAS_MARKETING_CONSENT]: Boolean(marketingConsent), + [UserProfileProperty.HAS_MARKETING_CONSENT]: resolvedMarketingConsent, is_metrics_opted_in: true, location: 'saga_backfill_marketing_consent', updated_after_onboarding: true, @@ -55,12 +59,16 @@ export function* backfillSocialLoginMarketingConsentSaga() { yield call([analytics, analytics.trackEvent], event); yield call(updateDataRecordingFlag, true); + yield put(setDataCollectionForMarketing(resolvedMarketingConsent)); yield put(setPendingSocialLoginMarketingConsentBackfill(null)); - yield put(setDataCollectionForMarketing(marketingConsent)); } catch (error) { Logger.error( error as Error, 'Failed to backfill social login marketing consent analytics', ); + if (fetchedMarketingConsent) { + yield put(setDataCollectionForMarketing(Boolean(marketingConsent))); + } + yield put(setPendingSocialLoginMarketingConsentBackfill(null)); } } diff --git a/app/util/metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types.ts b/app/util/metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types.ts index fdb080eec34c..ac18db954e02 100644 --- a/app/util/metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types.ts +++ b/app/util/metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types.ts @@ -14,6 +14,7 @@ export enum UserProfileProperty { PRIMARY_CURRENCY = 'primary_currency', CURRENT_CURRENCY = 'current_currency', HAS_MARKETING_CONSENT = 'has_marketing_consent', + PUSH_NOTIFICATIONS_ENABLED = 'push_notifications_enabled', NUMBER_OF_HD_ENTROPIES = 'number_of_hd_entropies', NUMBER_OF_ACCOUNT_GROUPS = 'number_of_account_groups', NUMBER_OF_IMPORTED_ACCOUNTS = 'number_of_imported_accounts', @@ -40,6 +41,7 @@ export interface UserProfileMetaData { [UserProfileProperty.PRIMARY_CURRENCY]?: string; [UserProfileProperty.CURRENT_CURRENCY]?: string; [UserProfileProperty.HAS_MARKETING_CONSENT]: boolean; + [UserProfileProperty.PUSH_NOTIFICATIONS_ENABLED]?: boolean; [UserProfileProperty.NUMBER_OF_HD_ENTROPIES]: number; [UserProfileProperty.NUMBER_OF_ACCOUNT_GROUPS]: number; [UserProfileProperty.NUMBER_OF_IMPORTED_ACCOUNTS]: number; diff --git a/app/util/notifications/constants/notification-storage-keys.ts b/app/util/notifications/constants/notification-storage-keys.ts index edad4b1926c9..f132022432b3 100644 --- a/app/util/notifications/constants/notification-storage-keys.ts +++ b/app/util/notifications/constants/notification-storage-keys.ts @@ -6,8 +6,6 @@ import { } from '../../../constants/storage'; import storageWrapper from '../../../store/storage-wrapper'; -export { PUSH_PRE_PROMPT_SHOWN }; - /** * Used to track when/how often we should re-subscribe users to notifications. * It ensures that users notification subscriptions are kept up to date (in case our backend adds new support for certian notifications) diff --git a/app/util/notifications/hooks/types.ts b/app/util/notifications/hooks/types.ts deleted file mode 100644 index 63ea82ed1861..000000000000 --- a/app/util/notifications/hooks/types.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { KeyringTypes } from '@metamask/keyring-controller'; - -export interface EnableMetametricsReturn { - enableMetametrics: () => Promise; - loading: boolean; - error?: string; -} -export interface DisableMetametricsReturn { - disableMetametrics: () => Promise; - loading: boolean; - error?: string; -} - -export type AccountType = InternalAccount & { - balance: string; - keyring: KeyringTypes; - label: string; -}; - -export interface SwitchSnapNotificationsChangeReturn { - onChange: (state: boolean) => void; - error?: string; -} -export interface SwitchFeatureAnnouncementsChangeReturn { - onChange: (state: boolean) => void; - error?: string; -} - -export interface SwitchPushNotificationsReturn { - onChange: (UUIDS: string[], state: boolean) => void; - error?: string; -} - -export interface UseSwitchAccountNotificationsData { - [address: string]: boolean; -} - -export interface SwitchAccountNotificationsReturn { - switchAccountNotifications: () => Promise; - isLoading: boolean; - error?: string; -} - -export interface SwitchAccountNotificationsChangeReturn { - onChange: (addresses: string[], state: boolean) => void; - error?: string; -} diff --git a/app/util/notifications/hooks/useEnableMarketingConsent.test.ts b/app/util/notifications/hooks/useEnableMarketingConsent.test.ts new file mode 100644 index 000000000000..c14ad0caffb6 --- /dev/null +++ b/app/util/notifications/hooks/useEnableMarketingConsent.test.ts @@ -0,0 +1,226 @@ +import { act, waitFor } from '@testing-library/react-native'; + +import OAuthService from '../../../core/OAuthService/OAuthService'; +import { MetaMetricsEvents } from '../../../core/Analytics'; +import { AccountType } from '../../../constants/onboarding'; +import { UserProfileProperty } from '../../metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types'; +import { renderHookWithProvider } from '../../test/renderWithProvider'; +import { analytics } from '../../analytics/analytics'; +import generateDeviceAnalyticsMetaData, { + UserSettingsAnalyticsMetaData as generateUserSettingsAnalyticsMetaData, +} from '../../metrics'; +import { updateCachedConsent } from '../../trace'; +import Logger from '../../Logger'; +import { useEnableMarketingConsent } from './useEnableMarketingConsent'; + +jest.mock('../../analytics/analytics', () => ({ + analytics: { + identify: jest.fn(), + isEnabled: jest.fn(), + optIn: jest.fn(), + trackEvent: jest.fn(), + }, +})); + +jest.mock('../../metrics', () => ({ + __esModule: true, + default: jest.fn(), + UserSettingsAnalyticsMetaData: jest.fn(), +})); + +jest.mock('../../trace', () => ({ + updateCachedConsent: jest.fn(), +})); + +jest.mock('../../../core/OAuthService/OAuthService', () => ({ + __esModule: true, + default: { + updateMarketingOptInStatus: jest.fn(), + }, +})); + +jest.mock('../../Logger', () => ({ + error: jest.fn(), +})); + +const deviceTraits = { device_trait: 'device' }; +const userSettingsTraits = { user_settings_trait: 'settings' }; + +const renderUseEnableMarketingConsent = ({ + accountType = AccountType.MetamaskGoogle, + hasMarketingConsent = false, + isSeedlessOnboardingLoginFlow = false, +}: { + accountType?: AccountType; + hasMarketingConsent?: boolean; + isSeedlessOnboardingLoginFlow?: boolean; +} = {}) => + renderHookWithProvider( + () => + useEnableMarketingConsent({ + metricsOptInLocation: 'push_pre_prompt', + }), + { + state: { + engine: { + backgroundState: { + SeedlessOnboardingController: { + vault: isSeedlessOnboardingLoginFlow ? 'vault' : undefined, + }, + }, + }, + onboarding: { + accountType, + }, + security: { + dataCollectionForMarketing: hasMarketingConsent, + }, + }, + }, + ); + +describe('useEnableMarketingConsent', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(analytics.isEnabled).mockReturnValue(false); + jest.mocked(analytics.optIn).mockResolvedValue(undefined); + jest.mocked(generateDeviceAnalyticsMetaData).mockReturnValue(deviceTraits); + jest + .mocked(generateUserSettingsAnalyticsMetaData) + .mockReturnValue(userSettingsTraits); + jest + .mocked(OAuthService.updateMarketingOptInStatus) + .mockResolvedValue(undefined); + }); + + it('opts into metrics, dispatches marketing consent, and identifies consent when analytics is disabled', async () => { + const { result, store } = renderUseEnableMarketingConsent(); + + await act(async () => { + await result.current.enableMarketingConsent(); + }); + + expect(analytics.optIn).toHaveBeenCalledTimes(1); + expect(updateCachedConsent).toHaveBeenCalledWith(true); + expect(analytics.identify).toHaveBeenCalledWith({ + ...deviceTraits, + ...userSettingsTraits, + [UserProfileProperty.HAS_MARKETING_CONSENT]: true, + }); + expect(analytics.identify).toHaveBeenCalledTimes(1); + expect( + jest.mocked(analytics.identify).mock.invocationCallOrder[0], + ).toBeLessThan( + jest.mocked(analytics.trackEvent).mock.invocationCallOrder[0], + ); + expect(analytics.trackEvent).toHaveBeenCalledTimes(2); + expect(analytics.trackEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_IN.category, + properties: expect.objectContaining({ + account_type: AccountType.MetamaskGoogle, + location: 'push_pre_prompt', + updated_after_onboarding: true, + }), + }), + ); + expect(analytics.trackEvent).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + name: MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED.category, + properties: expect.objectContaining({ + [UserProfileProperty.HAS_MARKETING_CONSENT]: true, + is_metrics_opted_in: true, + account_type: AccountType.MetamaskGoogle, + location: 'push_pre_prompt', + updated_after_onboarding: true, + }), + }), + ); + expect(store.getState().security.dataCollectionForMarketing).toBe(true); + }); + + it('dispatches and identifies marketing consent without metrics opt-in when analytics is already enabled', async () => { + jest.mocked(analytics.isEnabled).mockReturnValue(true); + const { result, store } = renderUseEnableMarketingConsent(); + + await act(async () => { + await result.current.enableMarketingConsent(); + }); + + expect(analytics.optIn).not.toHaveBeenCalled(); + expect(updateCachedConsent).not.toHaveBeenCalled(); + expect(analytics.identify).toHaveBeenCalledWith({ + [UserProfileProperty.HAS_MARKETING_CONSENT]: true, + }); + expect( + jest.mocked(analytics.identify).mock.invocationCallOrder[0], + ).toBeLessThan( + jest.mocked(analytics.trackEvent).mock.invocationCallOrder[0], + ); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + expect(analytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED.category, + properties: expect.objectContaining({ + [UserProfileProperty.HAS_MARKETING_CONSENT]: true, + is_metrics_opted_in: true, + account_type: AccountType.MetamaskGoogle, + location: 'push_pre_prompt', + updated_after_onboarding: true, + }), + }), + ); + expect(store.getState().security.dataCollectionForMarketing).toBe(true); + }); + + it('syncs marketing consent to OAuth for seedless users', async () => { + jest.mocked(analytics.isEnabled).mockReturnValue(true); + const { result, store } = renderUseEnableMarketingConsent({ + isSeedlessOnboardingLoginFlow: true, + }); + + await act(async () => { + await result.current.enableMarketingConsent(); + }); + + expect(store.getState().security.dataCollectionForMarketing).toBe(true); + expect(OAuthService.updateMarketingOptInStatus).toHaveBeenCalledWith(true); + }); + + it('reverts Redux marketing consent when the seedless OAuth sync fails', async () => { + jest.mocked(analytics.isEnabled).mockReturnValue(true); + jest + .mocked(OAuthService.updateMarketingOptInStatus) + .mockRejectedValue(new Error('oauth failed')); + const { result, store } = renderUseEnableMarketingConsent({ + isSeedlessOnboardingLoginFlow: true, + }); + + await act(async () => { + await result.current.enableMarketingConsent(); + }); + + await waitFor(() => { + expect(store.getState().security.dataCollectionForMarketing).toBe(false); + }); + expect(Logger.error).toHaveBeenCalled(); + }); + + it('does nothing when marketing consent is already enabled', async () => { + const { result } = renderUseEnableMarketingConsent({ + hasMarketingConsent: true, + }); + + await act(async () => { + await result.current.enableMarketingConsent(); + }); + + expect(analytics.optIn).not.toHaveBeenCalled(); + expect(updateCachedConsent).not.toHaveBeenCalled(); + expect(analytics.identify).not.toHaveBeenCalled(); + expect(analytics.trackEvent).not.toHaveBeenCalled(); + expect(OAuthService.updateMarketingOptInStatus).not.toHaveBeenCalled(); + }); +}); diff --git a/app/util/notifications/hooks/useEnableMarketingConsent.ts b/app/util/notifications/hooks/useEnableMarketingConsent.ts new file mode 100644 index 000000000000..2b09b576b28f --- /dev/null +++ b/app/util/notifications/hooks/useEnableMarketingConsent.ts @@ -0,0 +1,103 @@ +import { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { setDataCollectionForMarketing } from '../../../actions/security'; +import { selectOnboardingAccountType } from '../../../selectors/onboarding'; +import { selectSeedlessOnboardingLoginFlow } from '../../../selectors/seedlessOnboardingController'; +import { selectDataCollectionForMarketingEnabled } from '../../../selectors/engagement'; +import OAuthService from '../../../core/OAuthService/OAuthService'; +import { MetaMetricsEvents } from '../../../core/Analytics'; +import { AnalyticsEventBuilder } from '../../analytics/AnalyticsEventBuilder'; +import { analytics } from '../../analytics/analytics'; +import { UserProfileProperty } from '../../metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types'; +import generateDeviceAnalyticsMetaData, { + UserSettingsAnalyticsMetaData as generateUserSettingsAnalyticsMetaData, +} from '../../metrics'; +import { updateCachedConsent } from '../../trace'; +import Logger from '../../Logger'; + +interface UseEnableMarketingConsentOptions { + metricsOptInLocation: string; +} + +export function useEnableMarketingConsent({ + metricsOptInLocation, +}: UseEnableMarketingConsentOptions) { + const dispatch = useDispatch(); + const hasMarketingConsent = useSelector( + selectDataCollectionForMarketingEnabled, + ); + const isSeedlessOnboardingLoginFlow = useSelector( + selectSeedlessOnboardingLoginFlow, + ); + const accountType = useSelector(selectOnboardingAccountType); + + const enableMarketingConsent = useCallback(async () => { + const marketingConsentTraits = { + [UserProfileProperty.HAS_MARKETING_CONSENT]: true, + }; + + if (hasMarketingConsent) { + return; + } + + const shouldOptInToMetrics = !analytics.isEnabled(); + + if (shouldOptInToMetrics) { + await analytics.optIn(); + updateCachedConsent(true); + } + + dispatch(setDataCollectionForMarketing(true)); + if (shouldOptInToMetrics) { + analytics.identify({ + ...generateDeviceAnalyticsMetaData(), + ...generateUserSettingsAnalyticsMetaData(), + ...marketingConsentTraits, + }); + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder( + MetaMetricsEvents.METRICS_OPT_IN, + ) + .addProperties({ + updated_after_onboarding: true, + location: metricsOptInLocation, + ...(accountType && { account_type: accountType }), + }) + .build(), + ); + } else { + analytics.identify(marketingConsentTraits); + } + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder( + MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED, + ) + .addProperties({ + ...marketingConsentTraits, + is_metrics_opted_in: true, + updated_after_onboarding: true, + location: metricsOptInLocation, + ...(accountType && { account_type: accountType }), + }) + .build(), + ); + + if (isSeedlessOnboardingLoginFlow) { + // Social-login wallets also store marketing opt-in server-side so the + // setting survives OAuth rehydration. Match settings behavior and revert + // the optimistic Redux update if that sync fails. + OAuthService.updateMarketingOptInStatus(true).catch((error) => { + Logger.error(error as Error); + dispatch(setDataCollectionForMarketing(false)); + }); + } + }, [ + accountType, + dispatch, + hasMarketingConsent, + isSeedlessOnboardingLoginFlow, + metricsOptInLocation, + ]); + + return { enableMarketingConsent }; +} diff --git a/app/util/notifications/hooks/useNotifications.test.tsx b/app/util/notifications/hooks/useNotifications.test.tsx index eb61cf8f8cc8..790d73abc6fc 100644 --- a/app/util/notifications/hooks/useNotifications.test.tsx +++ b/app/util/notifications/hooks/useNotifications.test.tsx @@ -1,5 +1,4 @@ -import { act, renderHook } from '@testing-library/react-hooks'; -import { waitFor } from '@testing-library/react-native'; +import { act, renderHook, waitFor } from '@testing-library/react-native'; // eslint-disable-next-line import-x/no-namespace import * as Actions from '../../../actions/notification/helpers'; @@ -116,7 +115,9 @@ describe('useNotifications - useEnableNotifications()', () => { // Act const hook = renderHookWithProvider(() => useEnableNotifications()); - await act(() => hook.result.current.enableNotifications()); + await act(async () => { + await hook.result.current.enableNotifications(); + }); await waitFor(() => expect(mocks.mockEnableNotifications).toHaveBeenCalled(), ); @@ -133,6 +134,7 @@ describe('useNotifications - useEnableNotifications()', () => { expect(mocks.mockEnableNotifications).toHaveBeenCalledWith({ hasMarketingConsent: false, productAnnouncementEnabled: true, + registerPushNotifications: true, }); }); @@ -150,7 +152,45 @@ describe('useNotifications - useEnableNotifications()', () => { expect(mocks.mockEnableNotifications).toHaveBeenCalledWith({ hasMarketingConsent: true, productAnnouncementEnabled: true, + registerPushNotifications: true, + }); + }); + + it('keeps shared notification setup before push toggling', async () => { + const { mocks } = await arrangeAct(); + + expect( + mocks.mockEnableNotifications.mock.invocationCallOrder[0], + ).toBeLessThan( + mocks.mockTogglePushNotification.mock.invocationCallOrder[0], + ); + }); + + it('does not register push while enabling notifications without a push nudge', async () => { + const mocks = arrangeMocks(); + + const hook = renderHookWithProvider(() => + useEnableNotifications({ nudgeEnablePush: false }), + ); + await act(async () => { + await hook.result.current.enableNotifications(); + }); + + expect(mocks.mockEnableNotifications).toHaveBeenCalledWith({ + hasMarketingConsent: false, + productAnnouncementEnabled: true, + registerPushNotifications: false, }); + expect(mocks.mockTogglePushNotification).toHaveBeenCalled(); + }); + + it('continues when push enablement fails', async () => { + const { mocks } = await arrangeAct((m) => { + m.mockTogglePushNotification.mockResolvedValue(false); + }); + + expect(mocks.mockEnableNotifications).toHaveBeenCalled(); + expect(mocks.mockTogglePushNotification).toHaveBeenCalled(); }); it('creates an error when fails', async () => { @@ -262,6 +302,10 @@ describe('useNotifications - useContiguousLoading()', () => { jest.useFakeTimers(); }); + afterEach(() => { + jest.useRealTimers(); + }); + const arrangeHook = (loading1: boolean, loading2: boolean) => renderHook( ({ loadingParam1, loadingParam2 }) => diff --git a/app/util/notifications/hooks/useNotifications.ts b/app/util/notifications/hooks/useNotifications.ts index ebc64bf193bc..fbfb5b4fbe65 100644 --- a/app/util/notifications/hooks/useNotifications.ts +++ b/app/util/notifications/hooks/useNotifications.ts @@ -124,12 +124,18 @@ export function useEnableNotifications(props = { nudgeEnablePush: true }) { await enableNotificationsHelper({ hasMarketingConsent, productAnnouncementEnabled, + registerPushNotifications: Boolean(props.nudgeEnablePush), }).catch((e) => setError(e)); await togglePushNotification(true).catch(() => { /* Do Nothing */ }); await updateNotificationSubscriptionExpiration(); - }, [hasMarketingConsent, productAnnouncementEnabled, togglePushNotification]); + }, [ + props.nudgeEnablePush, + hasMarketingConsent, + productAnnouncementEnabled, + togglePushNotification, + ]); const contiguousLoading = useContiguousLoading(loading, pushLoading); diff --git a/app/util/notifications/hooks/usePushPermissionNotificationSetup.test.ts b/app/util/notifications/hooks/usePushPermissionNotificationSetup.test.ts new file mode 100644 index 000000000000..87796b773717 --- /dev/null +++ b/app/util/notifications/hooks/usePushPermissionNotificationSetup.test.ts @@ -0,0 +1,195 @@ +import { act, renderHook, waitFor } from '@testing-library/react-native'; + +import { + assertIsFeatureEnabled, + enableNotifications, + hasNotificationPreferences, + setMarketingNotificationPreferencesEnabled, +} from '../../../actions/notification/helpers'; +import { updateNotificationSubscriptionExpiration } from '../constants/notification-storage-keys'; +import { requestPushPermissions } from '../services/NotificationService'; +import Logger from '../../Logger'; +import { usePushPermissionNotificationSetup } from './usePushPermissionNotificationSetup'; + +jest.mock('../../../actions/notification/helpers', () => ({ + assertIsFeatureEnabled: jest.fn(), + enableNotifications: jest.fn(), + hasNotificationPreferences: jest.fn(), + setMarketingNotificationPreferencesEnabled: jest.fn(), +})); + +jest.mock('../constants/notification-storage-keys', () => ({ + updateNotificationSubscriptionExpiration: jest.fn(), +})); + +jest.mock('../services/NotificationService', () => ({ + requestPushPermissions: jest.fn(), +})); + +jest.mock('../../Logger', () => ({ + error: jest.fn(), +})); + +describe('usePushPermissionNotificationSetup', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(assertIsFeatureEnabled).mockImplementation(() => undefined); + jest.mocked(requestPushPermissions).mockResolvedValue(true); + jest.mocked(enableNotifications).mockResolvedValue(undefined); + jest.mocked(hasNotificationPreferences).mockResolvedValue(false); + jest + .mocked(setMarketingNotificationPreferencesEnabled) + .mockResolvedValue(undefined); + jest + .mocked(updateNotificationSubscriptionExpiration) + .mockResolvedValue(undefined); + }); + + it('requests native push permission before MetaMask notification setup', async () => { + const { result } = renderHook(() => usePushPermissionNotificationSetup()); + + let nativePermissionEnabled = false; + await act(async () => { + nativePermissionEnabled = await result.current.requestPushPermission(); + }); + + expect(nativePermissionEnabled).toBe(true); + expect(requestPushPermissions).toHaveBeenCalledTimes(1); + expect(hasNotificationPreferences).not.toHaveBeenCalled(); + expect(enableNotifications).not.toHaveBeenCalled(); + + act(() => { + result.current.enableNotificationsInBackground(nativePermissionEnabled); + }); + + await waitFor(() => { + expect(enableNotifications).toHaveBeenCalledWith({ + hasMarketingConsent: true, + productAnnouncementEnabled: true, + registerPushNotifications: true, + }); + }); + expect( + jest.mocked(requestPushPermissions).mock.invocationCallOrder[0], + ).toBeLessThan( + jest.mocked(hasNotificationPreferences).mock.invocationCallOrder[0], + ); + expect( + jest.mocked(hasNotificationPreferences).mock.invocationCallOrder[0], + ).toBeLessThan( + jest.mocked(enableNotifications).mock.invocationCallOrder[0], + ); + expect(updateNotificationSubscriptionExpiration).toHaveBeenCalledTimes(1); + }); + + it('passes marketing options when initializing notification preferences from the prompt', async () => { + const { result } = renderHook(() => usePushPermissionNotificationSetup()); + + act(() => { + result.current.enableNotificationsInBackground(true); + }); + + await waitFor(() => { + expect(enableNotifications).toHaveBeenCalledWith({ + hasMarketingConsent: true, + productAnnouncementEnabled: true, + registerPushNotifications: true, + }); + }); + expect(setMarketingNotificationPreferencesEnabled).not.toHaveBeenCalled(); + expect(updateNotificationSubscriptionExpiration).toHaveBeenCalledTimes(1); + }); + + it('updates existing marketing preferences when enabling notifications from the prompt', async () => { + jest.mocked(hasNotificationPreferences).mockResolvedValue(true); + const { result } = renderHook(() => usePushPermissionNotificationSetup()); + + act(() => { + result.current.enableNotificationsInBackground(true); + }); + + await waitFor(() => { + expect(enableNotifications).toHaveBeenCalledWith({ + registerPushNotifications: true, + }); + }); + await waitFor(() => + expect(setMarketingNotificationPreferencesEnabled).toHaveBeenCalledWith( + true, + ), + ); + expect(updateNotificationSubscriptionExpiration).toHaveBeenCalledTimes(1); + }); + + it('enables in-app notifications without push registration when native permission is denied', async () => { + jest.mocked(requestPushPermissions).mockResolvedValue(false); + const { result } = renderHook(() => usePushPermissionNotificationSetup()); + + let nativePermissionEnabled = true; + await act(async () => { + nativePermissionEnabled = await result.current.requestPushPermission(); + }); + + expect(nativePermissionEnabled).toBe(false); + + act(() => { + result.current.enableNotificationsInBackground(nativePermissionEnabled); + }); + + await waitFor(() => { + expect(enableNotifications).toHaveBeenCalledWith({ + hasMarketingConsent: true, + productAnnouncementEnabled: true, + registerPushNotifications: false, + }); + }); + expect(updateNotificationSubscriptionExpiration).toHaveBeenCalledTimes(1); + }); + + it('treats native permission request errors as denied', async () => { + jest + .mocked(requestPushPermissions) + .mockRejectedValue(new Error('permission failed')); + const { result } = renderHook(() => usePushPermissionNotificationSetup()); + + let nativePermissionEnabled = true; + await act(async () => { + nativePermissionEnabled = await result.current.requestPushPermission(); + }); + + expect(nativePermissionEnabled).toBe(false); + expect(Logger.error).toHaveBeenCalled(); + }); + + it('treats feature gate assertion errors as denied', async () => { + jest.mocked(assertIsFeatureEnabled).mockImplementation(() => { + throw new Error('feature disabled'); + }); + const { result } = renderHook(() => usePushPermissionNotificationSetup()); + + let nativePermissionEnabled = true; + await act(async () => { + nativePermissionEnabled = await result.current.requestPushPermission(); + }); + + expect(nativePermissionEnabled).toBe(false); + expect(requestPushPermissions).not.toHaveBeenCalled(); + expect(Logger.error).toHaveBeenCalled(); + }); + + it('logs background setup failures without throwing', async () => { + jest + .mocked(enableNotifications) + .mockRejectedValue(new Error('setup failed')); + const { result } = renderHook(() => usePushPermissionNotificationSetup()); + + act(() => { + result.current.enableNotificationsInBackground(true); + }); + + await waitFor(() => { + expect(Logger.error).toHaveBeenCalled(); + }); + expect(updateNotificationSubscriptionExpiration).not.toHaveBeenCalled(); + }); +}); diff --git a/app/util/notifications/hooks/usePushPermissionNotificationSetup.ts b/app/util/notifications/hooks/usePushPermissionNotificationSetup.ts new file mode 100644 index 000000000000..54771a30f44c --- /dev/null +++ b/app/util/notifications/hooks/usePushPermissionNotificationSetup.ts @@ -0,0 +1,72 @@ +import { useCallback } from 'react'; + +import { + assertIsFeatureEnabled, + enableNotifications as enableNotificationsHelper, + hasNotificationPreferences as hasNotificationPreferencesHelper, + setMarketingNotificationPreferencesEnabled, +} from '../../../actions/notification/helpers'; +import { updateNotificationSubscriptionExpiration } from '../constants/notification-storage-keys'; +import { requestPushPermissions } from '../services/NotificationService'; +import Logger from '../../Logger'; + +export function usePushPermissionNotificationSetup() { + // Ask the OS for push permission while the pre-prompt is still in focus. + const requestPushPermission = useCallback(async () => { + try { + assertIsFeatureEnabled(); + return await requestPushPermissions(); + } catch (requestError) { + Logger.error( + requestError as Error, + 'Failed to request push permission from pre-prompt', + ); + return false; + } + }, []); + + // Finish MetaMask notification setup after the pre-prompt resolves. The OS + // permission result determines whether push registration should be attempted. + const enableNotificationsInBackground = useCallback( + (nativePermissionEnabled: boolean) => { + const registerPushNotifications = nativePermissionEnabled; + + const enableNotifications = async () => { + try { + const hasExistingNotificationPreferences = + await hasNotificationPreferencesHelper(); + + if (hasExistingNotificationPreferences) { + // Still run the enable flow for auth, trigger refresh, controller + // state, and push registration; existing AUS prefs are updated separately. + await enableNotificationsHelper({ + registerPushNotifications, + }); + await setMarketingNotificationPreferencesEnabled(true); + } else { + await enableNotificationsHelper({ + hasMarketingConsent: true, + productAnnouncementEnabled: true, + registerPushNotifications, + }); + } + + await updateNotificationSubscriptionExpiration(); + } catch (backgroundSetupError) { + Logger.error( + backgroundSetupError as Error, + 'Failed to enable notifications from push pre-prompt', + ); + } + }; + + void enableNotifications(); + }, + [], + ); + + return { + enableNotificationsInBackground, + requestPushPermission, + }; +} diff --git a/app/util/notifications/hooks/usePushPrePromptAnalytics.test.ts b/app/util/notifications/hooks/usePushPrePromptAnalytics.test.ts new file mode 100644 index 000000000000..223048ffd99c --- /dev/null +++ b/app/util/notifications/hooks/usePushPrePromptAnalytics.test.ts @@ -0,0 +1,151 @@ +import { renderHook } from '@testing-library/react-native'; + +import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../core/Analytics'; +import { AnalyticsEventBuilder } from '../../analytics/AnalyticsEventBuilder'; +import { createMockUseAnalyticsHook } from '../../test/analyticsMock'; +import { UserProfileProperty } from '../../metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types'; +import { usePushPrePromptAnalytics } from './usePushPrePromptAnalytics'; + +jest.mock('../../../components/hooks/useAnalytics/useAnalytics'); + +describe('usePushPrePromptAnalytics', () => { + const mockIdentify = jest.fn(); + const mockTrackEvent = jest.fn(); + const mockCreateEventBuilder = jest.fn( + AnalyticsEventBuilder.createEventBuilder, + ); + + const getLastTrackedEvent = () => + mockTrackEvent.mock.calls[mockTrackEvent.mock.calls.length - 1][0]; + + beforeEach(() => { + jest.clearAllMocks(); + mockIdentify.mockResolvedValue(undefined); + jest.mocked(useAnalytics).mockReturnValue( + createMockUseAnalyticsHook({ + createEventBuilder: mockCreateEventBuilder, + identify: mockIdentify, + trackEvent: mockTrackEvent, + }), + ); + }); + + it('tracks the pre-prompt viewed event', () => { + const { result } = renderHook(() => usePushPrePromptAnalytics()); + + result.current.trackPrePromptViewed('push_permission'); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.PUSH_NOTIFICATION_PRE_PROMPT_VIEWED, + ); + expect(getLastTrackedEvent()).toEqual( + expect.objectContaining({ + name: MetaMetricsEvents.PUSH_NOTIFICATION_PRE_PROMPT_VIEWED.category, + properties: {}, + }), + ); + }); + + it.each([ + ['dismissed' as const, () => ['trackPrePromptDismissed'], 'dismiss'], + [ + 'allowed with the push permission CTA' as const, + () => ['trackPrePromptButtonClicked', 'push_permission', 'yes'], + 'allow', + ], + [ + 'allowed with the marketing consent CTA' as const, + () => ['trackPrePromptButtonClicked', 'marketing_consent', 'confirm'], + 'allow', + ], + [ + 'denied with the not now CTA' as const, + () => ['trackPrePromptButtonClicked', 'marketing_consent', 'not_now'], + 'deny', + ], + [ + 'denied with the push permission not now CTA' as const, + () => ['trackPrePromptButtonClicked', 'push_permission', 'not_now'], + 'deny', + ], + ])( + 'tracks the pre-prompt button when %s', + (_label, getAction, buttonType) => { + const { result } = renderHook(() => usePushPrePromptAnalytics()); + const [method, variant, button] = getAction(); + + if (method === 'trackPrePromptDismissed') { + result.current.trackPrePromptDismissed('marketing_consent'); + } else { + result.current.trackPrePromptButtonClicked( + variant as 'push_permission' | 'marketing_consent', + button as 'yes' | 'not_now' | 'confirm', + ); + } + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.PUSH_NOTIFICATION_PRE_PROMPT_BUTTON_CLICKED, + ); + expect(getLastTrackedEvent()).toEqual( + expect.objectContaining({ + name: MetaMetricsEvents.PUSH_NOTIFICATION_PRE_PROMPT_BUTTON_CLICKED + .category, + properties: { button_type: buttonType }, + }), + ); + }, + ); + + it.each([ + ['allowed' as const, 'allow'], + ['denied' as const, 'deny'], + ])( + 'tracks the OS prompt response when permission is %s', + (response, buttonType) => { + const { result } = renderHook(() => usePushPrePromptAnalytics()); + + result.current.trackOsPromptResponse('push_permission', response); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.OS_PUSH_NOTIFICATION_BUTTON_CLICKED, + ); + expect(getLastTrackedEvent()).toEqual( + expect.objectContaining({ + name: MetaMetricsEvents.OS_PUSH_NOTIFICATION_BUTTON_CLICKED.category, + properties: { button_type: buttonType }, + }), + ); + }, + ); + + it('keeps OS prompt shown as a noop because the schema has no shown event', () => { + const { result } = renderHook(() => usePushPrePromptAnalytics()); + + result.current.trackOsPromptShown('push_permission'); + + expect(mockCreateEventBuilder).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + it('identifies push notifications enabled', async () => { + const { result } = renderHook(() => usePushPrePromptAnalytics()); + + await result.current.identifyPushNotificationsEnabled(true); + + expect(mockIdentify).toHaveBeenCalledWith({ + [UserProfileProperty.PUSH_NOTIFICATIONS_ENABLED]: true, + }); + }); + + it('identifies marketing consent', async () => { + const { result } = renderHook(() => usePushPrePromptAnalytics()); + + await result.current.identifyMarketingConsent(true); + + expect(mockIdentify).toHaveBeenCalledWith({ + [UserProfileProperty.HAS_MARKETING_CONSENT]: true, + }); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/app/util/notifications/hooks/usePushPrePromptAnalytics.ts b/app/util/notifications/hooks/usePushPrePromptAnalytics.ts new file mode 100644 index 000000000000..118665e8508f --- /dev/null +++ b/app/util/notifications/hooks/usePushPrePromptAnalytics.ts @@ -0,0 +1,145 @@ +import { useCallback, useMemo } from 'react'; + +import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../core/Analytics'; +import { UserProfileProperty } from '../../metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types'; +import { PushPrePromptVariant } from './usePushPrePromptVariant'; + +type PushPrePromptAnalyticsVariant = Exclude; +type PushPrePromptButton = 'yes' | 'not_now' | 'confirm'; +type PushOsPromptResponse = 'allowed' | 'denied'; +type PushPrePromptButtonType = 'allow' | 'deny' | 'dismiss'; + +interface PushPrePromptAnalytics { + trackPrePromptViewed: (variant: PushPrePromptAnalyticsVariant) => void; + trackPrePromptDismissed: (variant: PushPrePromptAnalyticsVariant) => void; + trackPrePromptButtonClicked: ( + variant: PushPrePromptAnalyticsVariant, + button: PushPrePromptButton, + ) => void; + trackOsPromptShown: (variant: PushPrePromptAnalyticsVariant) => void; + trackOsPromptResponse: ( + variant: PushPrePromptAnalyticsVariant, + response: PushOsPromptResponse, + ) => void; + identifyMarketingConsent: (enabled: boolean) => Promise; + identifyPushNotificationsEnabled: (enabled: boolean) => Promise; +} + +const noop = () => undefined; +const trackOsPromptShown: PushPrePromptAnalytics['trackOsPromptShown'] = noop; + +const pushPrePromptButtonTypeByButton: Record< + PushPrePromptButton, + PushPrePromptButtonType +> = { + yes: 'allow', + confirm: 'allow', + not_now: 'deny', +}; + +const osPromptButtonTypeByResponse: Record< + PushOsPromptResponse, + Exclude +> = { + allowed: 'allow', + denied: 'deny', +}; + +export function usePushPrePromptAnalytics() { + const { createEventBuilder, identify, trackEvent } = useAnalytics(); + + const trackPrePromptViewed = useCallback( + (_variant: PushPrePromptAnalyticsVariant) => { + trackEvent( + createEventBuilder( + MetaMetricsEvents.PUSH_NOTIFICATION_PRE_PROMPT_VIEWED, + ).build(), + ); + }, + [createEventBuilder, trackEvent], + ); + + const trackPrePromptButtonType = useCallback( + (buttonType: PushPrePromptButtonType) => { + trackEvent( + createEventBuilder( + MetaMetricsEvents.PUSH_NOTIFICATION_PRE_PROMPT_BUTTON_CLICKED, + ) + .addProperties({ button_type: buttonType }) + .build(), + ); + }, + [createEventBuilder, trackEvent], + ); + + const trackPrePromptDismissed = useCallback( + (_variant: PushPrePromptAnalyticsVariant) => { + trackPrePromptButtonType('dismiss'); + }, + [trackPrePromptButtonType], + ); + + const trackPrePromptButtonClicked = useCallback( + (_variant: PushPrePromptAnalyticsVariant, button: PushPrePromptButton) => { + trackPrePromptButtonType(pushPrePromptButtonTypeByButton[button]); + }, + [trackPrePromptButtonType], + ); + + const trackOsPromptResponse = useCallback( + ( + _variant: PushPrePromptAnalyticsVariant, + response: PushOsPromptResponse, + ) => { + trackEvent( + createEventBuilder( + MetaMetricsEvents.OS_PUSH_NOTIFICATION_BUTTON_CLICKED, + ) + .addProperties({ + button_type: osPromptButtonTypeByResponse[response], + }) + .build(), + ); + }, + [createEventBuilder, trackEvent], + ); + + const identifyMarketingConsent = useCallback( + async (enabled: boolean) => { + await identify({ + [UserProfileProperty.HAS_MARKETING_CONSENT]: enabled, + }); + }, + [identify], + ); + + const identifyPushNotificationsEnabled = useCallback( + async (enabled: boolean) => { + await identify({ + [UserProfileProperty.PUSH_NOTIFICATIONS_ENABLED]: enabled, + }); + }, + [identify], + ); + + return useMemo( + () => ({ + trackOsPromptShown, + trackPrePromptViewed, + trackPrePromptDismissed, + trackPrePromptButtonClicked, + trackOsPromptResponse, + identifyMarketingConsent, + identifyPushNotificationsEnabled, + }), + [ + identifyMarketingConsent, + identifyPushNotificationsEnabled, + trackOsPromptResponse, + trackPrePromptButtonClicked, + trackPrePromptDismissed, + trackPrePromptViewed, + ], + ); +} diff --git a/app/util/notifications/hooks/usePushPrePromptVariant.test.ts b/app/util/notifications/hooks/usePushPrePromptVariant.test.ts index 21f222566abf..10217c430cd8 100644 --- a/app/util/notifications/hooks/usePushPrePromptVariant.test.ts +++ b/app/util/notifications/hooks/usePushPrePromptVariant.test.ts @@ -2,103 +2,70 @@ import { act, waitFor } from '@testing-library/react-native'; // eslint-disable-next-line import-x/no-namespace import * as NotificationSelectors from '../../../selectors/notifications'; // eslint-disable-next-line import-x/no-namespace -import * as KeyringSelectors from '../../../selectors/keyringController'; +import * as OnboardingSelectors from '../../../selectors/onboarding'; // eslint-disable-next-line import-x/no-namespace import * as SettingsSelectors from '../../../selectors/settings'; -// eslint-disable-next-line import-x/no-namespace -import * as OnboardingSelectors from '../../../selectors/onboarding'; -import { setDataCollectionForMarketing } from '../../../actions/security'; -import { TRUE } from '../../../constants/storage'; +import { setCompletedOnboarding } from '../../../actions/onboarding'; +import { PUSH_PRE_PROMPT_SHOWN, TRUE } from '../../../constants/storage'; import storageWrapper from '../../../store/storage-wrapper'; import { renderHookWithProvider } from '../../test/renderWithProvider'; -// eslint-disable-next-line import-x/no-namespace -import * as Constants from '../constants/config'; -import { PUSH_PRE_PROMPT_SHOWN } from '../constants/notification-storage-keys'; -import { resolvePushNotificationStatus } from '../utils/push-notification-status'; +import { isNotificationsFeatureEnabled } from '../constants'; +import { resolveNativePushPermissionStatus } from '../utils/push-notification-status'; import { usePushPrePromptVariant } from './usePushPrePromptVariant'; -jest.mock('../../../core/Engine', () => ({ - __esModule: true, - default: { - context: { - RemoteFeatureFlagController: { - state: { - remoteFeatureFlags: { - assetsNotificationsEnabled: true, - }, - }, - }, - }, - }, +jest.mock('../utils/push-notification-status', () => ({ + resolveNativePushPermissionStatus: jest.fn(), })); -jest.mock('../utils/push-notification-status', () => ({ - resolvePushNotificationStatus: jest.fn(), +jest.mock('../constants', () => ({ + isNotificationsFeatureEnabled: jest.fn(), })); -const mockResolvePushNotificationStatus = jest.mocked( - resolvePushNotificationStatus, +const mockResolveNativePushPermissionStatus = jest.mocked( + resolveNativePushPermissionStatus, +); +const mockIsNotificationsFeatureEnabled = jest.mocked( + isNotificationsFeatureEnabled, ); -type PushNotificationStatusResult = Awaited< - ReturnType ->; - -const createDeferred = () => { - let resolve!: (value: Value) => void; - const promise = new Promise((promiseResolve) => { - resolve = promiseResolve; +const mockNativePushPermissionStatus = ({ + nativeOsPermissionEnabled = true, + nativeOsPermissionPromptable = false, +}: { + nativeOsPermissionEnabled?: boolean; + nativeOsPermissionPromptable?: boolean; +} = {}) => { + mockResolveNativePushPermissionStatus.mockResolvedValue({ + nativeOsPermissionEnabled, + nativeOsPermissionPromptable, }); - return { promise, resolve }; }; const arrangeStorage = ( values: Partial> = {}, ) => { - const storageWrapperWithSync = storageWrapper as typeof storageWrapper & { - getItemSync: (key: string) => string | null; - }; - - if (!storageWrapperWithSync.getItemSync) { - storageWrapperWithSync.getItemSync = jest.fn(); - } - - jest - .spyOn(storageWrapperWithSync, 'getItemSync') - .mockImplementation((key) => { - if (key in values) { - return values[key] ?? null; - } - return null; - }); + jest.spyOn(storageWrapper, 'getItemSync').mockImplementation((key) => { + if (key in values) { + return values[key] ?? null; + } + return null; + }); jest.spyOn(storageWrapper, 'setItem').mockResolvedValue(undefined); + jest.spyOn(storageWrapper, 'removeItem').mockResolvedValue(undefined); }; const arrangeSelectors = ({ completedOnboarding = true, - isBasicFunctionalityEnabled = true, - isNotificationsFeatureEnabled = true, - isPushEnabled = false, isFeatureFlagOn = true, - isUnlocked = true, + isBasicFunctionalityEnabled = true, }: { completedOnboarding?: boolean; - isBasicFunctionalityEnabled?: boolean; - isNotificationsFeatureEnabled?: boolean; - isPushEnabled?: boolean; isFeatureFlagOn?: boolean; - isUnlocked?: boolean; + isBasicFunctionalityEnabled?: boolean; } = {}) => { - jest.spyOn(KeyringSelectors, 'selectIsUnlocked').mockReturnValue(isUnlocked); - jest - .spyOn(SettingsSelectors, 'selectBasicFunctionalityEnabled') - .mockReturnValue(isBasicFunctionalityEnabled); jest .spyOn(OnboardingSelectors, 'selectCompletedOnboarding') .mockReturnValue(completedOnboarding); - jest - .spyOn(NotificationSelectors, 'selectIsMetaMaskPushNotificationsEnabled') - .mockReturnValue(isPushEnabled); jest .spyOn( NotificationSelectors, @@ -106,19 +73,27 @@ const arrangeSelectors = ({ ) .mockReturnValue(isFeatureFlagOn); jest - .spyOn(Constants, 'isNotificationsFeatureEnabled') - .mockReturnValue(isNotificationsFeatureEnabled); + .spyOn(SettingsSelectors, 'selectBasicFunctionalityEnabled') + .mockReturnValue(isBasicFunctionalityEnabled); }; const renderUsePushPrePromptVariant = ({ - dataCollectionForMarketing = false, + completedOnboarding = true, + hasMarketingConsent = false, + pendingSocialLoginMarketingConsentBackfill = null, }: { - dataCollectionForMarketing?: boolean | null; + completedOnboarding?: boolean; + hasMarketingConsent?: boolean; + pendingSocialLoginMarketingConsentBackfill?: string | null; } = {}) => renderHookWithProvider(() => usePushPrePromptVariant(), { state: { + onboarding: { + completedOnboarding, + pendingSocialLoginMarketingConsentBackfill, + }, security: { - dataCollectionForMarketing, + dataCollectionForMarketing: hasMarketingConsent, }, }, }); @@ -128,60 +103,143 @@ describe('usePushPrePromptVariant', () => { jest.clearAllMocks(); arrangeSelectors(); arrangeStorage(); - mockResolvePushNotificationStatus.mockResolvedValue({ - controllerIsPushEnabled: true, - effectivePushEnabled: true, - nativeOsPermissionEnabled: true, - }); + mockIsNotificationsFeatureEnabled.mockReturnValue(true); + mockNativePushPermissionStatus(); }); afterEach(() => { jest.restoreAllMocks(); }); - it('returns the push permission prompt when onboarding is complete and push is disabled', async () => { + it('returns the push permission prompt when onboarding is complete and native push is disabled', async () => { + mockNativePushPermissionStatus({ + nativeOsPermissionEnabled: false, + nativeOsPermissionPromptable: true, + }); + const { result } = renderUsePushPrePromptVariant(); await waitFor(() => { expect(result.current.variant).toBe('push_permission'); }); + expect(result.current.nativeOsPermissionEnabled).toBe(false); + expect(mockResolveNativePushPermissionStatus).toHaveBeenCalledTimes(1); + }); + + it('does not return a prompt when native push permission was previously denied', async () => { + mockNativePushPermissionStatus({ + nativeOsPermissionEnabled: false, + nativeOsPermissionPromptable: false, + }); + + const { result } = renderUsePushPrePromptVariant(); + + await waitFor(() => { + expect(result.current.isResolving).toBe(false); + }); + expect(result.current.variant).toBeNull(); + expect(result.current.nativeOsPermissionEnabled).toBe(false); + expect(mockResolveNativePushPermissionStatus).toHaveBeenCalledTimes(1); }); it('does not return a prompt before onboarding completes', async () => { arrangeSelectors({ completedOnboarding: false }); - const { result } = renderUsePushPrePromptVariant(); + const { result } = renderUsePushPrePromptVariant({ + completedOnboarding: false, + }); await waitFor(() => { - expect(result.current.variant).toBeNull(); + expect(result.current.isResolving).toBe(false); }); + expect(result.current.variant).toBeNull(); + expect(mockResolveNativePushPermissionStatus).not.toHaveBeenCalled(); }); - it.each<[string, Parameters[0]]>([ - ['wallet is locked', { isUnlocked: false }], - ['basic functionality is disabled', { isBasicFunctionalityEnabled: false }], - [ - 'notifications enabled by default feature flag is off', - { isFeatureFlagOn: false }, - ], - [ - 'notifications feature flag is off', - { isNotificationsFeatureEnabled: false }, - ], - ])( - 'does not return a prompt when %s', - async (_caseName, selectorOverrides) => { - arrangeSelectors({ isPushEnabled: true, ...selectorOverrides }); - - const { result } = renderUsePushPrePromptVariant(); - - await waitFor(() => { - expect(result.current.isResolving).toBe(false); - expect(result.current.variant).toBeNull(); + it('stays resolving when onboarding completion changes the eligibility inputs', async () => { + arrangeSelectors({ completedOnboarding: false }); + let resolveNativePushPermission: + | (( + value: Awaited>, + ) => void) + | undefined; + mockResolveNativePushPermissionStatus.mockReturnValue( + new Promise((resolve) => { + resolveNativePushPermission = resolve; + }), + ); + jest + .spyOn(OnboardingSelectors, 'selectCompletedOnboarding') + .mockImplementation((state) => state.onboarding.completedOnboarding); + + const { result, store } = renderUsePushPrePromptVariant({ + completedOnboarding: false, + }); + + await waitFor(() => { + expect(result.current.isResolving).toBe(false); + }); + expect(result.current.variant).toBeNull(); + expect(mockResolveNativePushPermissionStatus).not.toHaveBeenCalled(); + + act(() => { + store.dispatch(setCompletedOnboarding(true)); + }); + + expect(result.current.variant).toBeNull(); + expect(result.current.isResolving).toBe(true); + + await act(async () => { + resolveNativePushPermission?.({ + nativeOsPermissionEnabled: false, + nativeOsPermissionPromptable: true, }); - expect(mockResolvePushNotificationStatus).not.toHaveBeenCalled(); - }, - ); + }); + + await waitFor(() => { + expect(result.current.variant).toBe('push_permission'); + }); + }); + + it('does not return a prompt when basic functionality is disabled', async () => { + // When basicFunctionality is off, the prompt gate fails, so native push is + // never queried and no prompt is shown. + arrangeSelectors({ + isBasicFunctionalityEnabled: false, + }); + + const { result } = renderUsePushPrePromptVariant(); + + await waitFor(() => { + expect(result.current.isResolving).toBe(false); + }); + expect(result.current.variant).toBeNull(); + expect(mockResolveNativePushPermissionStatus).not.toHaveBeenCalled(); + }); + + it('does not return a prompt when the default-on feature flag is disabled', async () => { + arrangeSelectors({ isFeatureFlagOn: false }); + + const { result } = renderUsePushPrePromptVariant(); + + await waitFor(() => { + expect(result.current.isResolving).toBe(false); + }); + expect(result.current.variant).toBeNull(); + expect(mockResolveNativePushPermissionStatus).not.toHaveBeenCalled(); + }); + + it('does not return a prompt when the notifications feature is disabled', async () => { + mockIsNotificationsFeatureEnabled.mockReturnValue(false); + + const { result } = renderUsePushPrePromptVariant(); + + await waitFor(() => { + expect(result.current.isResolving).toBe(false); + }); + expect(result.current.variant).toBeNull(); + expect(mockResolveNativePushPermissionStatus).not.toHaveBeenCalled(); + }); it('does not return a prompt when local storage says it was shown', async () => { arrangeStorage({ [PUSH_PRE_PROMPT_SHOWN]: TRUE }); @@ -190,92 +248,138 @@ describe('usePushPrePromptVariant', () => { await waitFor(() => { expect(result.current.isResolving).toBe(false); - expect(result.current.variant).toBeNull(); }); + expect(result.current.variant).toBeNull(); - expect(storageWrapper.getItemSync).toHaveBeenCalledWith( - PUSH_PRE_PROMPT_SHOWN, - ); + expect(mockResolveNativePushPermissionStatus).not.toHaveBeenCalled(); expect(storageWrapper.setItem).not.toHaveBeenCalled(); }); - it('returns the marketing consent prompt when push is enabled and marketing consent is missing', async () => { - arrangeSelectors({ isPushEnabled: true }); + it('does not reopen in the same session when shown storage is reset', async () => { + let storedPrePromptShown: string | null = TRUE; + jest.spyOn(storageWrapper, 'getItemSync').mockImplementation((key) => { + if (key === PUSH_PRE_PROMPT_SHOWN) { + return storedPrePromptShown; + } + return null; + }); - const { result } = renderUsePushPrePromptVariant(); + const { result, rerender } = renderUsePushPrePromptVariant(); await waitFor(() => { - expect(result.current.variant).toBe('marketing_consent'); + expect(result.current.isResolving).toBe(false); }); - }); + expect(result.current.variant).toBeNull(); + expect(mockResolveNativePushPermissionStatus).not.toHaveBeenCalled(); - it('returns the push permission prompt when native push permission is disabled', async () => { - arrangeSelectors({ isPushEnabled: true }); - mockResolvePushNotificationStatus.mockResolvedValue({ - controllerIsPushEnabled: true, - effectivePushEnabled: false, - nativeOsPermissionEnabled: false, + storedPrePromptShown = null; + rerender(undefined); + + await waitFor(() => { + expect(result.current.isResolving).toBe(false); }); + expect(result.current.variant).toBeNull(); + expect(mockResolveNativePushPermissionStatus).not.toHaveBeenCalled(); + }); + it('returns the marketing consent prompt when OS push is enabled and Redux marketing consent is missing', async () => { const { result } = renderUsePushPrePromptVariant(); await waitFor(() => { - expect(result.current.variant).toBe('push_permission'); + expect(result.current.variant).toBe('marketing_consent'); }); + expect(result.current.nativeOsPermissionEnabled).toBe(true); + expect(mockResolveNativePushPermissionStatus).toHaveBeenCalledTimes(1); }); - it('ignores stale async native permission results after eligibility changes', async () => { - arrangeSelectors({ isPushEnabled: true }); - const nativePermissionCheck = - createDeferred(); - mockResolvePushNotificationStatus - .mockReturnValueOnce(nativePermissionCheck.promise) - .mockResolvedValueOnce({ - controllerIsPushEnabled: true, - effectivePushEnabled: true, - nativeOsPermissionEnabled: true, - }); - - const { result, store } = renderUsePushPrePromptVariant(); + it('does not return a prompt when OS push and Redux marketing consent are enabled', async () => { + const { result } = renderUsePushPrePromptVariant({ + hasMarketingConsent: true, + }); await waitFor(() => { - expect(mockResolvePushNotificationStatus).toHaveBeenCalledTimes(1); + expect(result.current.isResolving).toBe(false); }); + expect(result.current.variant).toBeNull(); + expect(result.current.nativeOsPermissionEnabled).toBe(true); + }); - act(() => { - store.dispatch(setDataCollectionForMarketing(true)); + it('defers the marketing consent prompt while social login marketing consent backfill is pending', async () => { + const { result } = renderUsePushPrePromptVariant({ + pendingSocialLoginMarketingConsentBackfill: 'google', }); await waitFor(() => { expect(result.current.isResolving).toBe(false); - expect(result.current.variant).toBeNull(); }); + expect(result.current.variant).toBeNull(); + expect(result.current.nativeOsPermissionEnabled).toBe(true); + expect(mockResolveNativePushPermissionStatus).toHaveBeenCalledTimes(1); + }); + + it('does not defer the push permission prompt for social login marketing consent backfill', async () => { + mockNativePushPermissionStatus({ + nativeOsPermissionEnabled: false, + nativeOsPermissionPromptable: true, + }); + const { result } = renderUsePushPrePromptVariant({ + pendingSocialLoginMarketingConsentBackfill: 'google', + }); + + await waitFor(() => { + expect(result.current.variant).toBe('push_permission'); + }); + expect(result.current.nativeOsPermissionEnabled).toBe(false); + }); + + it('waits for native push permission before showing marketing consent', async () => { + let resolveNativePushPermission: + | (( + value: Awaited>, + ) => void) + | undefined; + mockResolveNativePushPermissionStatus.mockReturnValue( + new Promise((resolve) => { + resolveNativePushPermission = resolve; + }), + ); + + const { result } = renderUsePushPrePromptVariant(); + + expect(result.current.variant).toBeNull(); + expect(result.current.isResolving).toBe(true); await act(async () => { - nativePermissionCheck.resolve({ - controllerIsPushEnabled: true, - effectivePushEnabled: true, + resolveNativePushPermission?.({ nativeOsPermissionEnabled: true, + nativeOsPermissionPromptable: false, }); }); - expect(result.current.variant).toBeNull(); - expect(mockResolvePushNotificationStatus).toHaveBeenCalledTimes(2); + await waitFor(() => { + expect(result.current.variant).toBe('marketing_consent'); + }); }); - it('does not return a prompt when push and marketing consent are enabled', async () => { - arrangeSelectors({ isPushEnabled: true }); + it('returns null when the native push permission check fails', async () => { + mockResolveNativePushPermissionStatus.mockRejectedValue( + new Error('native permission failed'), + ); - const { result } = renderUsePushPrePromptVariant({ - dataCollectionForMarketing: true, - }); + const { result } = renderUsePushPrePromptVariant(); await waitFor(() => { - expect(result.current.variant).toBeNull(); + expect(result.current.isResolving).toBe(false); }); + expect(result.current.variant).toBeNull(); + expect(result.current.nativeOsPermissionEnabled).toBeNull(); }); it('marks the prompt as shown without hiding it until dismissed', async () => { + mockNativePushPermissionStatus({ + nativeOsPermissionEnabled: false, + nativeOsPermissionPromptable: true, + }); const { result } = renderUsePushPrePromptVariant(); await waitFor(() => { diff --git a/app/util/notifications/hooks/usePushPrePromptVariant.ts b/app/util/notifications/hooks/usePushPrePromptVariant.ts index cdcd4334feef..cd6c8dc85cbf 100644 --- a/app/util/notifications/hooks/usePushPrePromptVariant.ts +++ b/app/util/notifications/hooks/usePushPrePromptVariant.ts @@ -1,23 +1,19 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; -import { selectIsUnlocked } from '../../../selectors/keyringController'; -import { - getIsNotificationEnabledByDefaultFeatureFlag, - selectIsMetaMaskPushNotificationsEnabled, -} from '../../../selectors/notifications'; -import { selectBasicFunctionalityEnabled } from '../../../selectors/settings'; +import { getIsNotificationEnabledByDefaultFeatureFlag } from '../../../selectors/notifications'; import { selectCompletedOnboarding, selectPendingSocialLoginMarketingConsentBackfill, } from '../../../selectors/onboarding'; -import { RootState } from '../../../reducers'; +import { selectDataCollectionForMarketingEnabled } from '../../../selectors/engagement'; +import { selectBasicFunctionalityEnabled } from '../../../selectors/settings'; import Logger from '../../Logger'; -import { isNotificationsFeatureEnabled } from '../constants'; import { hasPushPrePromptBeenShown, setPushPrePromptShown, } from '../constants/notification-storage-keys'; -import { resolvePushNotificationStatus } from '../utils/push-notification-status'; +import { isNotificationsFeatureEnabled } from '../constants'; +import { resolveNativePushPermissionStatus } from '../utils/push-notification-status'; export type PushPrePromptVariant = | 'push_permission' @@ -27,51 +23,27 @@ export type PushPrePromptVariant = interface PushPrePromptResolutionState { isResolving: boolean; key: string; + nativeOsPermissionEnabled: boolean | null; variant: PushPrePromptVariant; } interface PushPrePromptEligibility { - completedOnboarding: boolean; + canShowPrePrompt: boolean; + hasPrePromptBeenShown: boolean; hasMarketingConsent: boolean; - isBasicFunctionalityEnabled: boolean; - isNotificationFeatureFlagOn: boolean; - isPushEnabled: boolean; - isUnlocked: boolean; - notificationsFlagEnabled: boolean; pendingSocialLoginMarketingConsentBackfill: string | null; } -const isEligibleForPrePrompt = ({ - completedOnboarding, - isBasicFunctionalityEnabled, - isNotificationFeatureFlagOn, - isUnlocked, - notificationsFlagEnabled, -}: PushPrePromptEligibility): boolean => - isUnlocked && - isBasicFunctionalityEnabled && - completedOnboarding && - isNotificationFeatureFlagOn && - notificationsFlagEnabled; - const getResolutionKey = ({ - completedOnboarding, + canShowPrePrompt, + hasPrePromptBeenShown, hasMarketingConsent, - isBasicFunctionalityEnabled, - isNotificationFeatureFlagOn, - isPushEnabled, - isUnlocked, - notificationsFlagEnabled, pendingSocialLoginMarketingConsentBackfill, }: PushPrePromptEligibility) => [ - `completedOnboarding:${completedOnboarding}`, + `canShowPrePrompt:${canShowPrePrompt}`, + `hasPrePromptBeenShown:${hasPrePromptBeenShown}`, `hasMarketingConsent:${hasMarketingConsent}`, - `isBasicFunctionalityEnabled:${isBasicFunctionalityEnabled}`, - `isNotificationFeatureFlagOn:${isNotificationFeatureFlagOn}`, - `isPushEnabled:${isPushEnabled}`, - `isUnlocked:${isUnlocked}`, - `notificationsFlagEnabled:${notificationsFlagEnabled}`, `pendingSocialLoginMarketingConsentBackfill:${ pendingSocialLoginMarketingConsentBackfill ?? 'null' }`, @@ -80,94 +52,128 @@ const getResolutionKey = ({ const getResolvingState = (key: string): PushPrePromptResolutionState => ({ isResolving: true, key, + nativeOsPermissionEnabled: null, variant: null, }); +interface PushPrePromptResolutionResult { + nativeOsPermissionEnabled: boolean | null; + variant: PushPrePromptVariant; +} + const resolvePrePromptVariant = async ( eligibility: PushPrePromptEligibility, -): Promise => { - if (!isEligibleForPrePrompt(eligibility)) { - return null; - } - - if (hasPushPrePromptBeenShown()) { - return null; +): Promise => { + if (eligibility.hasPrePromptBeenShown) { + return { + nativeOsPermissionEnabled: null, + variant: null, + }; } - if (!eligibility.isPushEnabled) { - return 'push_permission'; + // The prompt is ineligible, so there is nothing to show. + if (!eligibility.canShowPrePrompt) { + return { + nativeOsPermissionEnabled: null, + variant: null, + }; } - const pushStatus = await resolvePushNotificationStatus({ - controllerIsPushEnabled: eligibility.isPushEnabled, - }); + const { nativeOsPermissionEnabled, nativeOsPermissionPromptable } = + await resolveNativePushPermissionStatus(); - if (!pushStatus.effectivePushEnabled) { - return 'push_permission'; + if (!nativeOsPermissionEnabled) { + return { + nativeOsPermissionEnabled, + variant: nativeOsPermissionPromptable ? 'push_permission' : null, + }; } if (eligibility.hasMarketingConsent) { - return null; + return { + nativeOsPermissionEnabled, + variant: null, + }; } if (eligibility.pendingSocialLoginMarketingConsentBackfill) { - return null; + return { + nativeOsPermissionEnabled, + variant: null, + }; } - return 'marketing_consent'; + return { + nativeOsPermissionEnabled, + variant: 'marketing_consent', + }; }; /** * Resolves whether the startup notification pre-prompt should be shown. * - * The startup surface coordinator uses this hook to decide between the push - * permission prompt, the marketing consent prompt, or no prompt. The hook keeps - * the UI in a resolving state while it checks local "already shown" storage and - * native push permission, then exposes helpers for marking the prompt as shown - * and hiding it after the user dismisses or completes the flow. + * The pre-prompt presenter uses this hook to decide between the push permission + * prompt, the marketing consent prompt, or no prompt. The hook keeps the UI in + * a resolving state while it checks local "already shown" storage and native + * push permission, then exposes helpers for marking the prompt as shown and + * hiding it after the user dismisses or completes the flow. + * + * Eligibility is shared by both prompts. Once eligible, native OS push + * permission decides whether to show the push-permission prompt; otherwise, + * Redux marketing consent decides whether to show the marketing-consent prompt. */ export function usePushPrePromptVariant(): { isResolving: boolean; + nativeOsPermissionEnabled: boolean | null; variant: PushPrePromptVariant; markShown: () => Promise; dismiss: () => void; } { - const isUnlocked = Boolean(useSelector(selectIsUnlocked)); - const isBasicFunctionalityEnabled = Boolean( - useSelector(selectBasicFunctionalityEnabled), + // Two independent gates: + // - `isNotificationsFeatureAvailable` gates the notifications feature itself + // (build flag + `assetsNotificationsEnabled` remote flag). + // - `isNotificationsByDefaultFlagOn` gates this post-onboarding nudge + // (`assetsEnableNotificationsByDefault` remote flag). + const isNotificationsFeatureAvailable = isNotificationsFeatureEnabled(); + const isNotificationsByDefaultFlagOn = useSelector( + getIsNotificationEnabledByDefaultFeatureFlag, ); const completedOnboarding = useSelector(selectCompletedOnboarding); - const isPushEnabled = useSelector(selectIsMetaMaskPushNotificationsEnabled); - const isNotificationFeatureFlagOn = useSelector( - getIsNotificationEnabledByDefaultFeatureFlag, + const isBasicFunctionalityEnabled = Boolean( + useSelector(selectBasicFunctionalityEnabled), ); - const notificationsFlagEnabled = isNotificationsFeatureEnabled(); const hasMarketingConsent = useSelector( - (state: RootState) => state.security?.dataCollectionForMarketing === true, + selectDataCollectionForMarketingEnabled, ); const pendingSocialLoginMarketingConsentBackfill = useSelector( selectPendingSocialLoginMarketingConsentBackfill, ); + const canShowPrePrompt = + Boolean(completedOnboarding) && + isNotificationsFeatureAvailable && + isNotificationsByDefaultFlagOn && + isBasicFunctionalityEnabled; + + // Storage resets should affect the next app session/remount, not reopen the + // pre-prompt while this root is already mounted. + const hasPrePromptBeenShownRef = useRef(null); + if (hasPrePromptBeenShownRef.current === null) { + hasPrePromptBeenShownRef.current = hasPushPrePromptBeenShown(); + } + const hasPrePromptBeenShown = hasPrePromptBeenShownRef.current; + const eligibility = useMemo( () => ({ - completedOnboarding: Boolean(completedOnboarding), + canShowPrePrompt, + hasPrePromptBeenShown, hasMarketingConsent, - isBasicFunctionalityEnabled, - isNotificationFeatureFlagOn, - isPushEnabled, - isUnlocked, - notificationsFlagEnabled, pendingSocialLoginMarketingConsentBackfill, }), [ - completedOnboarding, + canShowPrePrompt, + hasPrePromptBeenShown, hasMarketingConsent, - isBasicFunctionalityEnabled, - isNotificationFeatureFlagOn, - isPushEnabled, - isUnlocked, - notificationsFlagEnabled, pendingSocialLoginMarketingConsentBackfill, ], ); @@ -183,14 +189,15 @@ export function usePushPrePromptVariant(): { useState({ isResolving: true, key: resolutionKey, + nativeOsPermissionEnabled: null, variant: null, }); useEffect(() => { let cancelled = false; - // When eligibility inputs change, hold the startup surface in a resolving - // state until storage/native permission checks finish. + // When eligibility inputs change, hold the pre-prompt in a resolving state + // until storage/native permission checks finish. setResolutionState((currentState) => currentState.key === resolutionKey && currentState.isResolving && @@ -199,12 +206,13 @@ export function usePushPrePromptVariant(): { : getResolvingState(resolutionKey), ); - const applyResolvedVariant = (nextVariant: PushPrePromptVariant) => { + const applyResolvedVariant = (result: PushPrePromptResolutionResult) => { if (!cancelled) { setResolutionState({ isResolving: false, key: resolutionKey, - variant: nextVariant, + nativeOsPermissionEnabled: result.nativeOsPermissionEnabled, + variant: result.variant, }); } }; @@ -216,7 +224,10 @@ export function usePushPrePromptVariant(): { error instanceof Error ? error : new Error(String(error)), 'Failed to resolve push pre-prompt variant', ); - applyResolvedVariant(null); + applyResolvedVariant({ + nativeOsPermissionEnabled: null, + variant: null, + }); }); return () => { @@ -225,6 +236,7 @@ export function usePushPrePromptVariant(): { }, [eligibility, resolutionKey]); const markShown = useCallback(async () => { + hasPrePromptBeenShownRef.current = true; await setPushPrePromptShown(); }, []); @@ -234,6 +246,7 @@ export function usePushPrePromptVariant(): { ? { ...currentState, isResolving: false, + nativeOsPermissionEnabled: null, variant: null, } : currentState, @@ -246,6 +259,9 @@ export function usePushPrePromptVariant(): { dismiss, isResolving: isCurrentResolution ? resolutionState.isResolving : true, markShown, + nativeOsPermissionEnabled: isCurrentResolution + ? resolutionState.nativeOsPermissionEnabled + : null, variant: isCurrentResolution ? resolutionState.variant : null, }; } diff --git a/app/util/notifications/hooks/useStartupNotificationsEffect.test.ts b/app/util/notifications/hooks/useStartupNotificationsEffect.test.ts index 0e291f121284..725b2a5d69bd 100644 --- a/app/util/notifications/hooks/useStartupNotificationsEffect.test.ts +++ b/app/util/notifications/hooks/useStartupNotificationsEffect.test.ts @@ -145,6 +145,23 @@ describe('useRegisterAndFetchNotifications', () => { }); }); + it('refreshes notification registrations without prompting for push permission', async () => { + const mocks = arrange(); + mocks.selectors.mockIsNotifsEnabled.mockReturnValue(true); + mocks.selectors.mockSelectBasicFunctionalityEnabled.mockReturnValue(true); + mocks.selectors.mockSelectIsUnlocked.mockReturnValue(true); + mocks.selectors.mockSelectIsSignedIn.mockReturnValue(true); + + renderHookWithProvider(() => useRegisterAndFetchNotifications(), {}); + + await waitFor(() => { + expect(mocks.hooks.mockUseEnableNotifications).toHaveBeenCalledWith({ + nudgeEnablePush: false, + }); + expect(mocks.hooks.enableNotifications).toHaveBeenCalled(); + }); + }); + it('does not enable notifications if resubscription has not expired', async () => { const mocks = arrange(); mocks.selectors.mockIsNotifsEnabled.mockReturnValue(true); diff --git a/app/util/notifications/hooks/useStartupNotificationsEffect.ts b/app/util/notifications/hooks/useStartupNotificationsEffect.ts index 1ba01343ff36..4c7740a21ab1 100644 --- a/app/util/notifications/hooks/useStartupNotificationsEffect.ts +++ b/app/util/notifications/hooks/useStartupNotificationsEffect.ts @@ -24,10 +24,10 @@ import { } from '../constants/notification-storage-keys'; import { hasNotificationPreferences } from '../../../actions/notification/helpers'; -const showPushNush = { nudgeEnablePush: true }; +const silentPushCheck = { nudgeEnablePush: false }; const useEnableAndRefresh = () => { - const { enableNotifications } = useEnableNotifications(showPushNush); + const { enableNotifications } = useEnableNotifications(silentPushCheck); const { listNotifications } = useListNotifications(); return useCallback( async (shouldEnable = true) => { diff --git a/app/util/notifications/services/NotificationService.test.ts b/app/util/notifications/services/NotificationService.test.ts index 3902cd77a2d2..5ae964983e13 100644 --- a/app/util/notifications/services/NotificationService.test.ts +++ b/app/util/notifications/services/NotificationService.test.ts @@ -10,7 +10,12 @@ import { ChannelId, notificationChannels, } from '../../../util/notifications/androidChannels'; -import NotificationService, { getPushPermission } from './NotificationService'; +import NotificationService, { + getPushPermission, + getPushPermissionStatus, + isPushPermissionGranted, + isPushPermissionPromptable, +} from './NotificationService'; import { store } from '../../../store'; jest.mock('@notifee/react-native', () => ({ @@ -146,6 +151,118 @@ describe('getPushPermission', () => { }); }); +describe('isPushPermissionGranted', () => { + const arrangeMocks = (status: string) => + jest.mocked(notifee.getNotificationSettings).mockResolvedValue({ + authorizationStatus: status, + } as unknown as NotificationSettings); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + { status: AuthorizationStatus.AUTHORIZED, expected: true }, + { status: AuthorizationStatus.PROVISIONAL, expected: true }, + { status: AuthorizationStatus.DENIED, expected: false }, + { status: AuthorizationStatus.NOT_DETERMINED, expected: false }, + ])( + 'returns $expected when status is $status', + async ({ status, expected }) => { + arrangeMocks(status as unknown as string); + expect(await isPushPermissionGranted()).toBe(expected); + }, + ); + + it('returns false when getNotificationSettings throws', async () => { + jest + .mocked(notifee.getNotificationSettings) + .mockRejectedValue(new Error('TEST ERROR')); + expect(await isPushPermissionGranted()).toBe(false); + }); +}); + +describe('getPushPermissionStatus', () => { + const arrangeMocks = (status: string) => + jest.mocked(notifee.getNotificationSettings).mockResolvedValue({ + authorizationStatus: status, + } as unknown as NotificationSettings); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + { status: AuthorizationStatus.AUTHORIZED, expected: 'granted' }, + { status: AuthorizationStatus.PROVISIONAL, expected: 'granted' }, + { status: AuthorizationStatus.NOT_DETERMINED, expected: 'promptable' }, + { status: AuthorizationStatus.DENIED, expected: 'denied' }, + ])( + 'returns $expected when status is $status', + async ({ status, expected }) => { + arrangeMocks(status as unknown as string); + + const result = await getPushPermissionStatus(); + + expect(result).toBe(expected); + }, + ); + + it('returns denied when getNotificationSettings throws', async () => { + jest + .mocked(notifee.getNotificationSettings) + .mockRejectedValue(new Error('TEST ERROR')); + + const result = await getPushPermissionStatus(); + + expect(result).toBe('denied'); + }); +}); + +describe('isPushPermissionPromptable', () => { + const arrangeMocks = (status: string) => + jest.mocked(notifee.getNotificationSettings).mockResolvedValue({ + authorizationStatus: status, + } as unknown as NotificationSettings); + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(Platform).OS = 'ios'; + }); + + it.each([ + { status: AuthorizationStatus.AUTHORIZED, expected: false }, + { status: AuthorizationStatus.PROVISIONAL, expected: false }, + { status: AuthorizationStatus.NOT_DETERMINED, expected: true }, + { status: AuthorizationStatus.DENIED, expected: false }, + ])( + 'returns $expected when status is $status', + async ({ status, expected }) => { + arrangeMocks(status as unknown as string); + expect(await isPushPermissionPromptable()).toBe(expected); + }, + ); + + it.each([ + { status: AuthorizationStatus.AUTHORIZED, expected: false }, + { status: AuthorizationStatus.DENIED, expected: true }, + ])( + 'returns $expected on android when status is $status', + async ({ status, expected }) => { + jest.mocked(Platform).OS = 'android'; + arrangeMocks(status as unknown as string); + expect(await isPushPermissionPromptable()).toBe(expected); + }, + ); + + it('returns false when getNotificationSettings throws', async () => { + jest + .mocked(notifee.getNotificationSettings) + .mockRejectedValue(new Error('TEST ERROR')); + expect(await isPushPermissionPromptable()).toBe(false); + }); +}); + describe('NotificationService - getAllPermissions', () => { const arrangeMocks = () => { const mockCreateChannel = jest diff --git a/app/util/notifications/services/NotificationService.ts b/app/util/notifications/services/NotificationService.ts index 1a90ba791412..b2e6b0acaf9e 100644 --- a/app/util/notifications/services/NotificationService.ts +++ b/app/util/notifications/services/NotificationService.ts @@ -313,6 +313,25 @@ const NotificationService = new NotificationsService(); export default NotificationService; +export type PushPermissionStatus = 'granted' | 'promptable' | 'denied'; + +const getPushPermissionStatusFromAuthorizationStatus = ( + authorizationStatus: AuthorizationStatus, +): PushPermissionStatus => { + if ( + authorizationStatus === AuthorizationStatus.AUTHORIZED || + authorizationStatus === AuthorizationStatus.PROVISIONAL + ) { + return 'granted'; + } + + if (authorizationStatus === AuthorizationStatus.NOT_DETERMINED) { + return 'promptable'; + } + + return 'denied'; +}; + export async function requestPushPermissions() { const result = await NotificationService.getAllPermissions(true); return result.permission === 'authorized'; @@ -327,3 +346,37 @@ export async function getPushPermission() { const result = await NotificationService.getAllPermissions(false); return result.permission; } + +export async function getPushPermissionStatus(): Promise { + try { + const settings = await notifee.getNotificationSettings(); + return getPushPermissionStatusFromAuthorizationStatus( + settings.authorizationStatus, + ); + } catch { + return 'denied'; + } +} + +/** + * Returns true when the OS has granted push permission (AUTHORIZED or PROVISIONAL). + * NOT_DETERMINED and DENIED both return false. + * Use this to gate registration, settings UI, and pre-prompt eligibility. + */ +export async function isPushPermissionGranted(): Promise { + return (await getPushPermissionStatus()) === 'granted'; +} + +/** + * Returns true when requesting push permission may show the OS dialog. + * iOS exposes a NOT_DETERMINED state, but Notifee only exposes AUTHORIZED/DENIED + * on Android. Treat Android's not-granted state as promptable and let + * requestPermission determine whether the OS can show a dialog. + */ +export async function isPushPermissionPromptable(): Promise { + if (Platform.OS === 'android') { + return !(await isPushPermissionGranted()); + } + + return (await getPushPermissionStatus()) === 'promptable'; +} diff --git a/app/util/notifications/utils/push-notification-status.test.ts b/app/util/notifications/utils/push-notification-status.test.ts index 12234e832bbb..61ed6751cddf 100644 --- a/app/util/notifications/utils/push-notification-status.test.ts +++ b/app/util/notifications/utils/push-notification-status.test.ts @@ -1,37 +1,44 @@ -import FCMService from '../services/FCMService'; -import { resolvePushNotificationStatus } from './push-notification-status'; - -jest.mock('../services/FCMService', () => ({ - __esModule: true, - default: { - isPushNotificationsEnabled: jest.fn(), - }, +import { + isPushPermissionGranted, + isPushPermissionPromptable, +} from '../services/NotificationService'; +import { + resolveNativePushPermissionEnabled, + resolveNativePushPermissionStatus, + resolvePushNotificationStatus, +} from './push-notification-status'; + +jest.mock('../services/NotificationService', () => ({ + isPushPermissionGranted: jest.fn(), + isPushPermissionPromptable: jest.fn(), })); -const mockIsPushNotificationsEnabled = jest.mocked( - FCMService.isPushNotificationsEnabled, -); +const mockIsPushPermissionGranted = jest.mocked(isPushPermissionGranted); +const mockIsPushPermissionPromptable = jest.mocked(isPushPermissionPromptable); describe('push-notification-status', () => { beforeEach(() => { - mockIsPushNotificationsEnabled.mockReset(); + mockIsPushPermissionGranted.mockReset(); + mockIsPushPermissionPromptable.mockReset(); }); - it('does not check native permission when controller push is disabled', async () => { + it('checks native permission when controller push is disabled', async () => { + mockIsPushPermissionGranted.mockResolvedValue(true); + const status = await resolvePushNotificationStatus({ controllerIsPushEnabled: false, }); - expect(mockIsPushNotificationsEnabled).not.toHaveBeenCalled(); + expect(mockIsPushPermissionGranted).toHaveBeenCalledTimes(1); expect(status).toEqual({ controllerIsPushEnabled: false, effectivePushEnabled: false, - nativeOsPermissionEnabled: null, + nativeOsPermissionEnabled: true, }); }); it('checks native permission each time push is enabled', async () => { - mockIsPushNotificationsEnabled + mockIsPushPermissionGranted .mockResolvedValueOnce(true) .mockResolvedValueOnce(false); @@ -42,7 +49,7 @@ describe('push-notification-status', () => { controllerIsPushEnabled: true, }); - expect(mockIsPushNotificationsEnabled).toHaveBeenCalledTimes(2); + expect(mockIsPushPermissionGranted).toHaveBeenCalledTimes(2); expect(firstStatus).toEqual({ controllerIsPushEnabled: true, effectivePushEnabled: true, @@ -56,7 +63,7 @@ describe('push-notification-status', () => { }); it('treats native permission errors as disabled push', async () => { - mockIsPushNotificationsEnabled.mockRejectedValue(new Error('nope')); + mockIsPushPermissionGranted.mockRejectedValue(new Error('nope')); const status = await resolvePushNotificationStatus({ controllerIsPushEnabled: true, @@ -68,4 +75,28 @@ describe('push-notification-status', () => { nativeOsPermissionEnabled: false, }); }); + + it('resolves native push permission without controller state', async () => { + mockIsPushPermissionGranted.mockResolvedValue(true); + + const nativePushPermissionEnabled = + await resolveNativePushPermissionEnabled(); + + expect(nativePushPermissionEnabled).toBe(true); + expect(mockIsPushPermissionGranted).toHaveBeenCalledTimes(1); + expect(mockIsPushPermissionPromptable).not.toHaveBeenCalled(); + }); + + it('resolves promptable native push permission status', async () => { + mockIsPushPermissionGranted.mockResolvedValue(false); + mockIsPushPermissionPromptable.mockResolvedValue(true); + + const nativePushPermissionStatus = + await resolveNativePushPermissionStatus(); + + expect(nativePushPermissionStatus).toEqual({ + nativeOsPermissionEnabled: false, + nativeOsPermissionPromptable: true, + }); + }); }); diff --git a/app/util/notifications/utils/push-notification-status.ts b/app/util/notifications/utils/push-notification-status.ts index 45597c8f3798..66fcaf801b01 100644 --- a/app/util/notifications/utils/push-notification-status.ts +++ b/app/util/notifications/utils/push-notification-status.ts @@ -1,4 +1,7 @@ -import FCMService from '../services/FCMService'; +import { + isPushPermissionGranted, + isPushPermissionPromptable, +} from '../services/NotificationService'; export interface PushNotificationStatus { controllerIsPushEnabled: boolean; @@ -6,29 +9,48 @@ export interface PushNotificationStatus { nativeOsPermissionEnabled: boolean | null; } +export interface NativePushPermissionStatus { + nativeOsPermissionEnabled: boolean; + nativeOsPermissionPromptable: boolean; +} + interface ResolvePushNotificationStatusOptions { controllerIsPushEnabled: boolean; } -export const resolvePushNotificationStatus = async ({ - controllerIsPushEnabled, -}: ResolvePushNotificationStatusOptions): Promise => { - if (!controllerIsPushEnabled) { +export const resolveNativePushPermissionStatus = + async (): Promise => { + const nativeOsPermissionEnabled = await isPushPermissionGranted().catch( + () => false, + ); + + if (nativeOsPermissionEnabled) { + return { + nativeOsPermissionEnabled, + nativeOsPermissionPromptable: false, + }; + } + + const nativeOsPermissionPromptable = + await isPushPermissionPromptable().catch(() => false); + return { - controllerIsPushEnabled, - effectivePushEnabled: false, - nativeOsPermissionEnabled: null, + nativeOsPermissionEnabled, + nativeOsPermissionPromptable, }; - } + }; + +export const resolveNativePushPermissionEnabled = async (): Promise => + await isPushPermissionGranted().catch(() => false); - const nativeOsPermissionEnabled = - await FCMService.isPushNotificationsEnabled() - .then(Boolean) - .catch(() => false); +export const resolvePushNotificationStatus = async ({ + controllerIsPushEnabled, +}: ResolvePushNotificationStatusOptions): Promise => { + const nativeOsPermissionEnabled = await resolveNativePushPermissionEnabled(); return { controllerIsPushEnabled, - effectivePushEnabled: nativeOsPermissionEnabled, + effectivePushEnabled: controllerIsPushEnabled && nativeOsPermissionEnabled, nativeOsPermissionEnabled, }; }; diff --git a/app/util/onboarding/walletHomeOnboardingStepsEligibility.test.ts b/app/util/onboarding/walletHomeOnboardingStepsEligibility.test.ts index 5c4567b2eed5..c87de44d1068 100644 --- a/app/util/onboarding/walletHomeOnboardingStepsEligibility.test.ts +++ b/app/util/onboarding/walletHomeOnboardingStepsEligibility.test.ts @@ -6,6 +6,7 @@ describe('shouldMarkWalletHomeOnboardingStepsEligible', () => { ONBOARDING_SUCCESS_FLOW.BACKED_UP_SRP, ONBOARDING_SUCCESS_FLOW.NO_BACKED_UP_SRP, ONBOARDING_SUCCESS_FLOW.IMPORT_FROM_SEED_PHRASE, + ONBOARDING_SUCCESS_FLOW.SEEDLESS_ONBOARDING, ])('returns true for first-time onboarding flow %s', (flow) => { expect(shouldMarkWalletHomeOnboardingStepsEligible(flow)).toBe(true); }); diff --git a/app/util/onboarding/walletHomeOnboardingStepsEligibility.ts b/app/util/onboarding/walletHomeOnboardingStepsEligibility.ts index 8434ea377ca5..69998031343a 100644 --- a/app/util/onboarding/walletHomeOnboardingStepsEligibility.ts +++ b/app/util/onboarding/walletHomeOnboardingStepsEligibility.ts @@ -23,6 +23,7 @@ export function shouldMarkWalletHomeOnboardingStepsEligible( return ( successFlow === ONBOARDING_SUCCESS_FLOW.BACKED_UP_SRP || successFlow === ONBOARDING_SUCCESS_FLOW.NO_BACKED_UP_SRP || - successFlow === ONBOARDING_SUCCESS_FLOW.IMPORT_FROM_SEED_PHRASE + successFlow === ONBOARDING_SUCCESS_FLOW.IMPORT_FROM_SEED_PHRASE || + successFlow === ONBOARDING_SUCCESS_FLOW.SEEDLESS_ONBOARDING ); } diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index f4437a3ec045..de8446ce4fc9 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -698,6 +698,8 @@ }, "BridgeController": { "assetExchangeRates": {}, + "batchSellTrades": null, + "batchSellTradesLoadingStatus": null, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, "quoteRequest": [ diff --git a/app/util/test/testSetup.js b/app/util/test/testSetup.js index 787acc381032..110c4507f8a5 100644 --- a/app/util/test/testSetup.js +++ b/app/util/test/testSetup.js @@ -757,6 +757,8 @@ jest.mock('redux-persist', () => ({ jest.mock('../../store/storage-wrapper', () => ({ getItem: jest.fn(), + getItemSync: jest.fn(), + removeItem: jest.fn(), setItem: jest.fn(), })); diff --git a/locales/languages/en.json b/locales/languages/en.json index 94f7858ff8aa..9117e3ee84f5 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -5357,6 +5357,16 @@ "time": "1h ago", "title": "Received 0.25 ETH", "message": "From 0x9a21…4f8c · $640.29" + }, + "toast": { + "notifications_on": { + "title": "Notifications are on", + "description": "We'll send you transactions, price alerts, and updates." + }, + "notifications_off": { + "title": "Notifications are off", + "description": "Turn them on anytime in Settings → Notifications." + } } }, "existing_user": { @@ -5365,8 +5375,20 @@ "card_title": "What you'll get", "card_description": "Personalized alerts and updates tailored to your trading activity.", "button_confirm": "Confirm", - "button_not_now": "Not now" - } + "button_not_now": "Not now", + "toast": { + "personalized_alerts_on": { + "title": "Personalized alerts is on", + "description": "Manage this anytime in Settings." + }, + "personalized_alerts_off": { + "title": "Personalized alerts is off", + "description": "Turn it on anytime in Settings." + } + } + }, + "toast_enabled": "Notifications enabled", + "toast_settings_hint": "You can enable notifications any time in Settings > Notifications" } }, "protect_your_wallet_modal": { @@ -7494,6 +7516,9 @@ "batch_sell_total_received": "Total received", "batch_sell_minimum_received": "Minimum received", "batch_sell_quote_details_row": "{{tokenSymbol}} • {{slippage}} slippage", + "batch_sell_no_quote_available": "No quote available", + "batch_sell_high_price_impact": "High price impact", + "batch_sell_high_price_impact_description": "This trade has an estimated {{priceImpact}} price impact, which reflects how much your trade changes the market price. The quote already reflects this.", "batch_sell_review": "Review", "batch_sell_you_sell": "You sell", "batch_sell_token_count": "{{tokenCount}} tokens", @@ -7533,6 +7558,7 @@ "quote_info_title": "Rate", "network_fee_info_title": "Network fee", "network_fee_info_content": "Network fees depend on how busy the network is and how complex your transaction is.", + "batch_sell_network_fee_info_content": "Network fees depend on how busy the network is and how complex your transaction is. If you don't have enough to cover the fee, we'll take it from the token you're converting to.", "network_fee_info_content_sponsored": "This network fee is paid by MetaMask, so you can transact without {{nativeToken}} in your account.", "points": "Est. points", "points_tooltip": "Points", diff --git a/package.json b/package.json index ecfc41900c90..e2ead296c418 100644 --- a/package.json +++ b/package.json @@ -252,7 +252,7 @@ "@metamask/authenticated-user-storage": "^2.0.0", "@metamask/base-controller": "^9.0.1", "@metamask/bitcoin-wallet-snap": "^1.11.0", - "@metamask/bridge-controller": "^72.0.0", + "@metamask/bridge-controller": "^73.0.1", "@metamask/bridge-status-controller": "^71.1.1", "@metamask/chain-agnostic-permission": "^1.5.0", "@metamask/chomp-api-service": "^3.1.0", diff --git a/tests/smoke/notifications/enable-notifications-after-onboarding.spec.ts b/tests/smoke/notifications/enable-notifications-after-onboarding.spec.ts index 3768833f73b1..adf39dd59b0a 100644 --- a/tests/smoke/notifications/enable-notifications-after-onboarding.spec.ts +++ b/tests/smoke/notifications/enable-notifications-after-onboarding.spec.ts @@ -35,19 +35,24 @@ describe(SmokeNetworkAbstractions('Notification Onboarding'), () => { await TabBarComponent.tapAccountsMenu(); await AccountMenu.tapNotifications(); + const featureAnnouncementItemId = getMockFeatureAnnouncementItemId(); + await Assertions.expectElementToBeVisible(NotificationMenuView.title); + await NotificationMenuView.scrollToNotificationItem( + featureAnnouncementItemId, + ); await Assertions.expectElementToBeVisible( NotificationMenuView.selectNotificationItem( - getMockFeatureAnnouncementItemId(), + featureAnnouncementItemId, ), { description: 'Feature Announcement Item', }, ); - // Feature Annonucement Details + // Feature Announcement Details await NotificationMenuView.tapOnNotificationItem( - getMockFeatureAnnouncementItemId(), + featureAnnouncementItemId, ); await Assertions.expectElementToBeVisible( NotificationDetailsView.title, diff --git a/yarn.lock b/yarn.lock index 647ad9039394..23e5bdf69494 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8131,7 +8131,7 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-controller@npm:^72.0.0, @metamask/bridge-controller@npm:^72.0.4": +"@metamask/bridge-controller@npm:^72.0.4": version: 72.0.4 resolution: "@metamask/bridge-controller@npm:72.0.4" dependencies: @@ -35537,7 +35537,7 @@ __metadata: "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/base-controller": "npm:^9.0.1" "@metamask/bitcoin-wallet-snap": "npm:^1.11.0" - "@metamask/bridge-controller": "npm:^72.0.0" + "@metamask/bridge-controller": "npm:^73.0.1" "@metamask/bridge-status-controller": "npm:^71.1.1" "@metamask/browser-passworder": "npm:^5.0.0" "@metamask/browser-playground": "npm:0.3.0"