diff --git a/.github/workflows/check-template-and-add-labels.yml b/.github/workflows/check-template-and-add-labels.yml index bb8b3b5b767..b5c5451c24b 100644 --- a/.github/workflows/check-template-and-add-labels.yml +++ b/.github/workflows/check-template-and-add-labels.yml @@ -52,7 +52,6 @@ jobs: - name: Get access token id: get-token uses: MetaMask/github-tools/.github/actions/get-token@v1 - continue-on-error: true with: token-exchange-url: ${{ vars.TOKEN_EXCHANGE_URL }} permissions: | @@ -63,6 +62,6 @@ jobs: - name: Check template and add labels id: check-template-and-add-labels env: - LABEL_TOKEN: ${{ steps.get-token.outputs.token || secrets.LABEL_TOKEN }} + LABEL_TOKEN: ${{ steps.get-token.outputs.token }} run: npm run check-template-and-add-labels working-directory: '.github/scripts' diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index d5ebd5490eb..b7cb78ee703 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -24,6 +24,7 @@ jobs: source_branch: main environment: exp testflight_group: 'MetaMask BETA & Release Candidates' + distribute_external: true secrets: inherit ios-rc: @@ -34,6 +35,7 @@ jobs: source_branch: main environment: rc testflight_group: 'MetaMask BETA & Release Candidates' + distribute_external: true secrets: inherit android-exp-generate: diff --git a/.js.env.example b/.js.env.example index 2bf4eca766c..2a44fe9c778 100644 --- a/.js.env.example +++ b/.js.env.example @@ -135,6 +135,9 @@ export MM_MUSD_CONVERSION_MIN_ASSET_BALANCE_REQUIRED="0.01" # Money Hub export MM_MONEY_HUB_ENABLED="false" +export MM_MONEY_PAYMENT_TOKENS_BLOCKLIST="" +export MM_MONEY_DEPOSIT_NO_FEE_TOKENS="" +export MM_MONEY_DEPOSIT_MIN_ASSET_BALANCE="0.01" # Activates remote feature flag override mode. # Remote feature flag values won't be updated, diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index ba84b8814f5..e8cefca1458 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png index 0e51d75cd6a..666809fbd24 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png index 8d096db7221..e8cefca1458 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 0408808b975..8adca3aa79f 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png index 360769f1fa4..36242c63631 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png index 5686d8dcb33..8adca3aa79f 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 2df9e78483c..16228eeb2b5 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png index ab304825478..c7cdda475c7 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png index 2eb208e2b78..16228eeb2b5 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d2df798a48c..17da7a44fca 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png index 1005926ee26..f5e9f7d01aa 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index c638770cfa8..17da7a44fca 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 2368377e946..aec7c7c929c 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png index 4d78dd76718..d09dfaba554 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index 1ed03db649c..aec7c7c929c 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.tsx b/app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.tsx index d5cadd7c95e..4a75e7a4d4a 100644 --- a/app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.tsx +++ b/app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.tsx @@ -6,7 +6,7 @@ import { View } from 'react-native'; // External dependencies. import { useStyles } from '../../hooks'; -import Pressable from '../Pressable'; +import Pressable, { PressableVariant } from '../Pressable'; import ListItem from '../../../component-library/components/List/ListItem/ListItem'; // Internal dependencies. @@ -49,6 +49,7 @@ const ListItemMultiSelectButton: React.FC = ({ return ( ( ); -const PressableCard = ({ surfaceColor }: { surfaceColor: string }) => { +const PressableCard = ({ + surfaceColor, + variant, +}: { + surfaceColor: string; + variant: PressableVariant; +}) => { const { colors } = useTheme(); return ( undefined} style={[ layout.card, @@ -108,7 +116,7 @@ const PressableCard = ({ surfaceColor }: { surfaceColor: string }) => { ); }; -const SurfaceCatalog = () => { +const SurfaceCatalog = ({ variant }: { variant: PressableVariant }) => { const { colors } = useTheme(); const surfaces = [ { key: 'default', color: colors.background.default }, @@ -121,7 +129,7 @@ const SurfaceCatalog = () => { {surfaces.map((s) => ( background.{s.key} - + ))} @@ -131,7 +139,15 @@ const SurfaceCatalog = () => { export const Default = { render: () => ( - + + + ), +}; + +export const Highlight = { + render: () => ( + + ), }; diff --git a/app/component-library/components-temp/Pressable/Pressable.test.tsx b/app/component-library/components-temp/Pressable/Pressable.test.tsx index 6cbd0517f68..de5b09a832e 100644 --- a/app/component-library/components-temp/Pressable/Pressable.test.tsx +++ b/app/component-library/components-temp/Pressable/Pressable.test.tsx @@ -4,27 +4,11 @@ import { fireEvent, render } from '@testing-library/react-native'; import { mockTheme } from '../../../util/theme'; -import Pressable from './Pressable'; - -interface AnyStyle { - [key: string]: unknown; -} - -const flatten = (style: unknown): AnyStyle => { - if (Array.isArray(style)) { - return style.reduce( - (acc, item) => ({ ...acc, ...flatten(item) }), - {}, - ); - } - if (style && typeof style === 'object') { - return style as AnyStyle; - } - return {}; -}; +import Pressable, { PRESSED_OPACITY, pressedStyleFor } from './Pressable'; +import { PressableVariant } from './Pressable.types'; const RESTING = mockTheme.colors.background.section; -const PRESSED = mockTheme.colors.background.pressed; +const PRESSED_BG = mockTheme.colors.background.pressed; const styles = StyleSheet.create({ padded: { padding: 16, backgroundColor: RESTING }, @@ -91,9 +75,12 @@ describe('Pressable', () => { , ); - const resting = flatten(getByTestId('p').props.style); - expect(resting.backgroundColor).toBe(RESTING); - expect(resting.padding).toBe(16); + const resting = getByTestId('p').props.style; + const flat = Array.isArray(resting) + ? Object.assign({}, ...resting.filter(Boolean)) + : resting; + expect(flat.backgroundColor).toBe(RESTING); + expect(flat.padding).toBe(16); }); it('forwards a ref to the underlying view', () => { @@ -108,16 +95,29 @@ describe('Pressable', () => { expect(typeof ref.current?.measure).toBe('function'); }); - it('resolves a function-form caller style on render', () => { - const styleFn = jest.fn(() => ({ borderWidth: 1 })); - render( - - x - , - ); - - expect(styleFn).toHaveBeenCalledWith( - expect.objectContaining({ pressed: expect.any(Boolean) }), - ); + describe('pressedStyleFor', () => { + it('default variant returns subtree opacity dim', () => { + expect(pressedStyleFor(PressableVariant.Default, PRESSED_BG)).toEqual({ + opacity: PRESSED_OPACITY, + }); + }); + + it('highlight variant returns background.pressed overlay', () => { + expect(pressedStyleFor(PressableVariant.Highlight, PRESSED_BG)).toEqual({ + backgroundColor: PRESSED_BG, + }); + }); + + it('does not include both opacity and backgroundColor for either variant', () => { + const def = pressedStyleFor(PressableVariant.Default, PRESSED_BG); + const hi = pressedStyleFor(PressableVariant.Highlight, PRESSED_BG); + expect('backgroundColor' in def).toBe(false); + expect('opacity' in hi).toBe(false); + }); + + it('none variant returns an empty overlay (no opacity, no background)', () => { + const overlay = pressedStyleFor(PressableVariant.None, PRESSED_BG); + expect(overlay).toEqual({}); + }); }); }); diff --git a/app/component-library/components-temp/Pressable/Pressable.tsx b/app/component-library/components-temp/Pressable/Pressable.tsx index b48008565ec..2bfbad4f99d 100644 --- a/app/component-library/components-temp/Pressable/Pressable.tsx +++ b/app/component-library/components-temp/Pressable/Pressable.tsx @@ -9,26 +9,62 @@ import { import { useTheme } from '../../../util/theme'; -import type { PressableProps } from './Pressable.types'; +import { PressableVariant, type PressableProps } from './Pressable.types'; + +export const PRESSED_OPACITY = 0.7; + +/** + * Returns the pressed-state style overlay for the given variant. Exported + * for direct testing of the per-variant feedback model. + */ +export const pressedStyleFor = ( + variant: PressableVariant, + pressedBackgroundColor: string, +): ViewStyle => { + switch (variant) { + case PressableVariant.Highlight: + return { backgroundColor: pressedBackgroundColor }; + case PressableVariant.None: + return {}; + case PressableVariant.Default: + default: + return { opacity: PRESSED_OPACITY }; + } +}; /** * Design-system Pressable. * - * Replaces `TouchableOpacity` across the app. Instead of dimming the - * entire subtree on press, this layers the semi-transparent - * `background.pressed` token on top of whatever resting surface the - * parent owns. The component itself never sets a resting background. + * Replaces `TouchableOpacity` across the app. The component supports two + * visual feedback modes via the `variant` prop: + * + * `default` (the default) dims the caller's subtree by lowering opacity to + * `PRESSED_OPACITY`. Mirrors the familiar `TouchableOpacity` model while + * keeping content visible under pure-black mode. + * + * `highlight` composites `background.pressed` over the caller's resting + * surface. Use for list rows, settings rows, and similar surfaces where a + * backdrop highlight is the established design pattern. */ const Pressable = forwardRef( - ({ style, accessibilityRole = 'button', children, ...props }, ref) => { + ( + { + style, + accessibilityRole = 'button', + children, + variant = PressableVariant.Default, + ...props + }, + ref, + ) => { const { colors } = useTheme(); const composedStyle = useCallback( (state: PressableStateCallbackType): StyleProp => [ typeof style === 'function' ? style(state) : style, - state.pressed && { backgroundColor: colors.background.pressed }, + state.pressed && pressedStyleFor(variant, colors.background.pressed), ], - [style, colors.background.pressed], + [style, colors.background.pressed, variant], ); return ( diff --git a/app/component-library/components-temp/Pressable/Pressable.types.ts b/app/component-library/components-temp/Pressable/Pressable.types.ts index aa0835a948a..68ea1be4c3c 100644 --- a/app/component-library/components-temp/Pressable/Pressable.types.ts +++ b/app/component-library/components-temp/Pressable/Pressable.types.ts @@ -1,6 +1,34 @@ import type { PressableProps as RNPressableProps } from 'react-native'; import type { PressableProps as RNGHPressableProps } from 'react-native-gesture-handler'; -export type PressableProps = RNPressableProps; +/** + * Visual feedback applied on press. + * + * `Default` dims the caller's subtree (matches the legacy `TouchableOpacity` + * behaviour with a gentler opacity that keeps content visible under + * pure-black mode). Use for the broad majority of touchable surfaces. + * + * `Highlight` composites the semantic `background.pressed` token over the + * caller's resting surface. Use for list rows, settings rows, and other + * surfaces where a backdrop highlight is the established design pattern. + * + * `None` applies no visual feedback. Use when the caller component already + * renders its own press-state styling (e.g. a button that maintains a + * `pressed` state internally and toggles its own background or border). + */ +export const PressableVariant = { + Default: 'default', + Highlight: 'highlight', + None: 'none', +} as const; -export type PressableGHProps = RNGHPressableProps; +export type PressableVariant = + (typeof PressableVariant)[keyof typeof PressableVariant]; + +export type PressableProps = RNPressableProps & { + variant?: PressableVariant; +}; + +export type PressableGHProps = RNGHPressableProps & { + variant?: PressableVariant; +}; diff --git a/app/component-library/components-temp/Pressable/PressableGH.test.tsx b/app/component-library/components-temp/Pressable/PressableGH.test.tsx index b4e71315dd1..e1d6220b284 100644 --- a/app/component-library/components-temp/Pressable/PressableGH.test.tsx +++ b/app/component-library/components-temp/Pressable/PressableGH.test.tsx @@ -9,6 +9,7 @@ jest.mock('react-native-gesture-handler', () => { }); import PressableGH from './PressableGH'; +import { PressableVariant } from './Pressable.types'; describe('PressableGH', () => { it('renders children', () => { @@ -75,4 +76,22 @@ describe('PressableGH', () => { expect(ref.current).not.toBeNull(); expect(typeof ref.current?.measure).toBe('function'); }); + + it('accepts the variant prop without crashing', () => { + expect(() => + render( + + x + , + ), + ).not.toThrow(); + + expect(() => + render( + + x + , + ), + ).not.toThrow(); + }); }); diff --git a/app/component-library/components-temp/Pressable/PressableGH.tsx b/app/component-library/components-temp/Pressable/PressableGH.tsx index 37479bed72c..e4a5482edb0 100644 --- a/app/component-library/components-temp/Pressable/PressableGH.tsx +++ b/app/component-library/components-temp/Pressable/PressableGH.tsx @@ -7,7 +7,8 @@ import { import { useTheme } from '../../../util/theme'; -import type { PressableGHProps } from './Pressable.types'; +import { pressedStyleFor } from './Pressable'; +import { PressableVariant, type PressableGHProps } from './Pressable.types'; /** * Gesture-handler variant of `Pressable`. @@ -15,17 +16,29 @@ import type { PressableGHProps } from './Pressable.types'; * Use this when the pressable lives inside a `react-native-gesture-handler` * scroll/list tree. Mixing RN core `Pressable` with RNGH scroll views * causes swipe/scroll gesture conflicts on Android. + * + * Supports the same `variant` API as `Pressable` (`default` opacity dim, + * `highlight` background composite). See `Pressable` for details. */ const PressableGH = forwardRef( - ({ style, accessibilityRole = 'button', children, ...props }, ref) => { + ( + { + style, + accessibilityRole = 'button', + children, + variant = PressableVariant.Default, + ...props + }, + ref, + ) => { const { colors } = useTheme(); const composedStyle = useCallback( (state: PressableStateCallbackType): StyleProp => [ typeof style === 'function' ? style(state) : style, - state.pressed && { backgroundColor: colors.background.pressed }, + state.pressed && pressedStyleFor(variant, colors.background.pressed), ], - [style, colors.background.pressed], + [style, colors.background.pressed, variant], ); return ( diff --git a/app/component-library/components-temp/Pressable/README.md b/app/component-library/components-temp/Pressable/README.md index 6450d63decc..88ece094673 100644 --- a/app/component-library/components-temp/Pressable/README.md +++ b/app/component-library/components-temp/Pressable/README.md @@ -1,15 +1,9 @@ # Pressable A design-system `Pressable` that replaces `TouchableOpacity` across the -app. Instead of dimming the entire subtree on press, it layers the -semi-transparent `background.pressed` token on top of the caller's -resting style. The component never owns a resting background — that -stays the parent's responsibility — so the overlay composites correctly -over any surface (`default`, `section`, `error.default`, etc.). - -This matches the pressed-state model used elsewhere in the design -system (e.g. `Button` tertiary variant in -`@metamask/design-system-react-native`). +app. The component supports two visual feedback modes via the `variant` +prop, so the same primitive covers both the broad case (subtree dim) and +the list-row case (backdrop highlight) without per-call-site guesswork. ## Exports @@ -21,40 +15,71 @@ system (e.g. `Button` tertiary variant in ## Props -The default export's props are exactly RN `PressableProps`. `PressableGH` -takes RNGH's `PressableProps`. The only behavioural differences vs. the -underlying primitive are: +The default export's props are RN `PressableProps` plus a `variant` +field. `PressableGH` takes RNGH's `PressableProps` plus the same +`variant`. Behavioural differences vs. the underlying primitive: - `accessibilityRole` defaults to `'button'` (preserves the implicit role `TouchableOpacity` provided). -- On press, the component appends `{ backgroundColor: colors.background.pressed }` - to the caller's style. +- `variant` (defaults to `'default'`) controls press feedback. + +### `variant` + +Use the `PressableVariant` enum-like const (matches the MMDS pattern used by +`TextVariant`, `ButtonVariants`, etc.) — don't pass raw string literals. + +| Value | Behaviour | When to use | +| ---------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `PressableVariant.Default` | Lowers caller subtree opacity to `0.7` on press. | The general case: buttons, icon affordances, inline tappable elements, anywhere `TouchableOpacity` was previously used. | +| `PressableVariant.Highlight` | Composites `colors.background.pressed` over the caller's resting surface on press. | List rows, settings rows, sheet rows — surfaces where a backdrop highlight is the established design pattern. | +| `PressableVariant.None` | Applies no visual feedback. | Only when the caller renders its own press-state styling internally (e.g. `useState(pressed)` toggling its own bg). | + +The `Default` variant mirrors the familiar `TouchableOpacity` model and +is the safe choice when migrating any existing call site. The +`Highlight` variant is an opt-in for list-context surfaces, and is the +recommended choice anywhere the DS list-item treatment applies. ## Usage ```tsx -import Pressable from 'app/component-library/components-temp/Pressable'; +import Pressable, { + PressableVariant, +} from 'app/component-library/components-temp/Pressable'; +// Default: opacity dim — no need to pass `variant` explicitly Action ; + +// List-row highlight + + Item +; ``` Inside a `react-native-gesture-handler` scroll/list tree: ```tsx -import { PressableGH } from 'app/component-library/components-temp/Pressable'; +import { + PressableGH, + PressableVariant, +} from 'app/component-library/components-temp/Pressable'; - + ... ; ``` ## Migration notes -- The parent container should own the resting `backgroundColor`, not - the `Pressable`. The pressed overlay composites against whatever - surface is behind the Pressable. - Replacing `TouchableOpacity` with `activeOpacity={1}` → just use `Pressable`. A transparent overlay over a transparent surface is a visual no-op, so no explicit "no feedback" prop is needed. @@ -67,3 +92,6 @@ import { PressableGH } from 'app/component-library/components-temp/Pressable'; that open a detail screen → `"link"` or none; backdrops / dismiss overlays → none). Pass `accessibilityRole` explicitly so screen readers don't announce "button". +- When using `variant="highlight"`, the parent container should own the + resting `backgroundColor`, not the `Pressable`. The pressed overlay + composites against whatever surface is behind the Pressable. diff --git a/app/component-library/components-temp/Pressable/index.ts b/app/component-library/components-temp/Pressable/index.ts index 8c14f951a3a..3b0b8c72f7c 100644 --- a/app/component-library/components-temp/Pressable/index.ts +++ b/app/component-library/components-temp/Pressable/index.ts @@ -1,4 +1,5 @@ export { default } from './Pressable'; export { default as Pressable } from './Pressable'; export { default as PressableGH } from './PressableGH'; +export { PressableVariant } from './Pressable.types'; export type { PressableProps, PressableGHProps } from './Pressable.types'; diff --git a/app/component-library/components/Form/TextField/foundation/Input/Input.styles.ts b/app/component-library/components/Form/TextField/foundation/Input/Input.styles.ts index 7dd0e003216..a05e540e97d 100644 --- a/app/component-library/components/Form/TextField/foundation/Input/Input.styles.ts +++ b/app/component-library/components/Form/TextField/foundation/Input/Input.styles.ts @@ -4,6 +4,7 @@ import { Platform, StyleSheet, TextStyle } from 'react-native'; // External dependencies. import { Theme } from '../../../../../../util/theme/models'; import { colors } from '../../../../../../styles/common'; +import { getElevatedSurfaceColor } from '../../../../../../util/theme/themeUtils'; import { getFontFamily } from '../../../../Texts/Text/'; // Internal dependencies @@ -50,7 +51,7 @@ const styleSheet = (params: { theme: Theme; vars: InputStyleSheetVars }) => { color: theme.colors.text.default, borderWidth: 1, borderColor: colors.transparent, - backgroundColor: theme.colors.background.default, + backgroundColor: getElevatedSurfaceColor(theme), ...stateObj, fontFamily: getFontFamily(textVariant), fontWeight: theme.typography[textVariant].fontWeight, diff --git a/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.tsx b/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.tsx index 243509d46e0..d378321b36d 100644 --- a/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.tsx +++ b/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.tsx @@ -7,7 +7,9 @@ import { View } from 'react-native'; // External dependencies. import Checkbox from '../../Checkbox'; import { useStyles } from '../../../hooks'; -import Pressable from '../../../components-temp/Pressable'; +import Pressable, { + PressableVariant, +} from '../../../components-temp/Pressable'; import ListItem from '../../List/ListItem/ListItem'; // Internal dependencies. @@ -28,6 +30,7 @@ const ListItemMultiSelect: React.FC = ({ return ( = ({ return ( void; } -const TokenDetails: React.FC = ({ asset }) => { +const TokenDetails: React.FC = ({ + asset, + onCopyAddress, +}) => { // For non evm assets, the resultChainId is equal to the asset.chainId; while for evm assets; the resultChainId === "eip155:1" !== asset.chainId const resultChainId = formatChainIdToCaip(asset.chainId as Hex); const isNonEvmAsset = resultChainId === asset.chainId; @@ -220,7 +224,10 @@ const TokenDetails: React.FC = ({ asset }) => { return ( {(asset.isETH || isNonEvmAsset || hasAddressAndDecimals) && ( - + )} {marketData && marketDetails && ( diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.test.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.test.tsx index 464f60f575e..15a6ded6b65 100644 --- a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.test.tsx +++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.test.tsx @@ -1,8 +1,13 @@ import React from 'react'; -import { render } from '@testing-library/react-native'; +import { render, fireEvent, waitFor } from '@testing-library/react-native'; import TokenDetailsList from './'; import { ToastContext } from '../../../../../component-library/components/Toast'; +const mockSetString = jest.fn(); +jest.mock('../../../../../core/ClipboardManager', () => ({ + setString: (...args: unknown[]) => mockSetString(...args), +})); + const mockShowToast = jest.fn(); const mockCloseToast = jest.fn(); const mockToastRef = { @@ -15,10 +20,10 @@ const mockTokenDetails = { tokenList: 'Metamask, Coinmarketcap', }; -const renderComponent = () => +const renderComponent = (props?: { onCopyAddress?: () => void }) => render( - + , ); @@ -38,4 +43,17 @@ describe('TokenDetails', () => { expect(getByText('Token list')).toBeOnTheScreen(); expect(getByText('Metamask, Coinmarketcap')).toBeOnTheScreen(); }); + + it('calls onCopyAddress when contract address is tapped', async () => { + mockSetString.mockResolvedValue(undefined); + const mockOnCopyAddress = jest.fn(); + const { getByText } = renderComponent({ + onCopyAddress: mockOnCopyAddress, + }); + + fireEvent.press(getByText('0x935E7...05477')); + await waitFor(() => { + expect(mockOnCopyAddress).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.tsx index a8de91f2980..c6f31de54b4 100644 --- a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.tsx +++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.tsx @@ -25,10 +25,12 @@ import { useTheme } from '../../../../../util/theme'; interface TokenDetailsListProps { tokenDetails: TokenDetails; + onCopyAddress?: () => void; } const TokenDetailsList: React.FC = ({ tokenDetails, + onCopyAddress, }) => { const { styles } = useStyles(styleSheet, {}); const { toastRef } = useContext(ToastContext); @@ -37,6 +39,7 @@ const TokenDetailsList: React.FC = ({ const copyAccountToClipboard = async () => { await ClipboardManager.setString(tokenDetails.contractAddress); + onCopyAddress?.(); toastRef?.current?.showToast({ variant: ToastVariants.Icon, diff --git a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.test.tsx b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.test.tsx index 40dc051553e..c5d8c117840 100644 --- a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.test.tsx +++ b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.test.tsx @@ -44,7 +44,6 @@ interface MockBatchSellQuoteData { hasPendingQuoteRows: boolean; needsNewQuote: boolean; networkFee: { formatted: string; formattedFiat: string }; - networkFeeIsLoading: boolean; } const defaultQuoteData: MockBatchSellQuoteData = { @@ -78,7 +77,6 @@ const defaultQuoteData: MockBatchSellQuoteData = { formatted: '1.20 USDC', formattedFiat: '$1.20', }, - networkFeeIsLoading: false, }; let mockBatchSellQuoteData = defaultQuoteData; const defaultSelectedTokens: BridgeToken[] = [ @@ -258,9 +256,10 @@ describe('BatchSellReview', () => { isSummaryLoading: true, hasPendingQuoteRows: true, }; - const { getByTestId } = render(); + const { getByTestId, getByText } = render(); const reviewButton = getByTestId(BatchSellReviewSelectorsIDs.REVIEW_BUTTON); + expect(getByText('Searching for best quotes')).toBeOnTheScreen(); expect( getByTestId(BatchSellReviewSelectorsIDs.TOTAL_RECEIVED_SKELETON), ).toBeOnTheScreen(); @@ -277,6 +276,22 @@ describe('BatchSellReview', () => { expect(reviewButton.props.accessibilityState.disabled).toBe(true); }); + it('keeps the review CTA disabled while quotes are fetching even when rows have streamed in', () => { + mockBatchSellQuoteData = { + ...defaultQuoteData, + isLoading: true, + isSummaryLoading: false, + hasAnyQuote: true, + hasPendingQuoteRows: false, + }; + + const { getByTestId, getByText } = render(); + const reviewButton = getByTestId(BatchSellReviewSelectorsIDs.REVIEW_BUTTON); + + expect(getByText('Searching for best quotes')).toBeOnTheScreen(); + expect(reviewButton.props.accessibilityState.disabled).toBe(true); + }); + it('shows available row quotes and progressive total while other rows are still loading', () => { mockBatchSellQuoteData = { ...defaultQuoteData, @@ -300,11 +315,12 @@ describe('BatchSellReview', () => { }, }; - const { getAllByText, getByTestId, queryByTestId } = render( + const { getAllByText, getByTestId, getByText, queryByTestId } = render( , ); const reviewButton = getByTestId(BatchSellReviewSelectorsIDs.REVIEW_BUTTON); + expect(getByText('Searching for best quotes')).toBeOnTheScreen(); expect(getAllByText('$3,456.78').length).toBeGreaterThan(0); expect( queryByTestId(BatchSellReviewSelectorsIDs.TOTAL_RECEIVED_SKELETON), @@ -416,10 +432,13 @@ describe('BatchSellReview', () => { hasAnyQuote: false, hasPendingQuoteRows: false, }; - const { getAllByText, getByTestId } = render(); + const { getAllByText, getByTestId, getByText } = render( + , + ); const reviewButton = getByTestId(BatchSellReviewSelectorsIDs.REVIEW_BUTTON); expect(getAllByText('No quote available')).toHaveLength(2); + expect(getByText('Review')).toBeOnTheScreen(); expect(reviewButton.props.accessibilityState.disabled).toBe(true); }); @@ -536,7 +555,6 @@ describe('BatchSellReview', () => { mockBatchSellQuoteData = { ...defaultQuoteData, needsNewQuote: true, - networkFeeIsLoading: true, hasPendingQuoteRows: true, }; const { getByTestId, getByText } = render(); diff --git a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.tsx b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.tsx index 0fb0ad9f344..c12a8cf80a1 100644 --- a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.tsx +++ b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.tsx @@ -324,6 +324,20 @@ export function BatchSellReview() { [dispatch, isRemoveTokenDisabled, selectedTokens], ); + const shouldGetNewQuote = batchSellQuoteData.needsNewQuote; + const isFetchingQuotes = batchSellQuoteData.isLoading && !shouldGetNewQuote; + const hasReviewableQuote = + batchSellQuoteData.hasAnyQuote && !batchSellQuoteData.hasPendingQuoteRows; + const isReviewButtonDisabled = + !shouldGetNewQuote && (isFetchingQuotes || !hasReviewableQuote); + let reviewButtonLabel = strings('bridge.batch_sell_review'); + + if (shouldGetNewQuote) { + reviewButtonLabel = strings('quote_expired_modal.get_new_quote'); + } else if (isFetchingQuotes) { + reviewButtonLabel = strings('bridge.batch_sell_searching_best_quotes'); + } + return ( - {batchSellQuoteData.needsNewQuote - ? strings('quote_expired_modal.get_new_quote') - : strings('bridge.batch_sell_review')} + {reviewButtonLabel} diff --git a/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.test.tsx b/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.test.tsx index 9b0d0c575db..9638b3b8039 100644 --- a/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.test.tsx +++ b/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { StyleSheet } from 'react-native'; -import { fireEvent, render } from '@testing-library/react-native'; +import { fireEvent, render, waitFor } from '@testing-library/react-native'; import { lightTheme } from '@metamask/design-tokens'; import Routes from '../../../../../constants/navigation/Routes'; @@ -9,9 +9,12 @@ import { BatchSellFinalReviewModal } from './index'; import { BatchSellFinalReviewModalSelectorsIDs } from './BatchSellFinalReviewModal.testIds'; const mockGoBack = jest.fn(); +const mockNavigate = jest.fn(); const mockReplace = jest.fn(); +const mockDispatch = jest.fn(); const mockUpdateBatchSellQuoteParams = jest.fn(); const mockGetNewQuote = jest.fn(); +const mockSubmitBatchSellTx = jest.fn(); const mockUseBatchSellHasSufficientGas = jest.fn((_params: unknown) => true); const errorTextColor = lightTheme.colors.error.default; const ethAssetId = 'eip155:1/erc20:0x1111111111111111111111111111111111111111'; @@ -38,6 +41,11 @@ const linkToken = { decimals: 18, symbol: 'LINK', }; +const defaultRecommendedQuotes = [ + { quoteId: 'eth-quote-id' }, + { quoteId: 'uni-quote-id' }, +]; +let mockIsSubmittingTx = false; interface MockQuoteTokenData { key: string; @@ -57,10 +65,13 @@ interface MockBatchSellQuoteData { isLoading: boolean; isSummaryLoading: boolean; isGasless: boolean; + isBatchSellTradeAvailable: boolean; + isBatchSellTradesLoading: boolean; hasAnyQuote: boolean; hasPendingQuoteRows: boolean; needsNewQuote: boolean; quotePercentFee?: string; + recommendedQuotes: unknown[]; networkFee: { amount?: string; valueInCurrency?: string | null; @@ -75,7 +86,6 @@ interface MockBatchSellQuoteData { formatted: string; formattedFiat: string; }; - networkFeeIsLoading: boolean; } const defaultQuoteData: MockBatchSellQuoteData = { @@ -106,10 +116,13 @@ const defaultQuoteData: MockBatchSellQuoteData = { isLoading: false, isSummaryLoading: false, isGasless: false, + isBatchSellTradeAvailable: true, + isBatchSellTradesLoading: false, hasAnyQuote: true, hasPendingQuoteRows: false, needsNewQuote: false, quotePercentFee: '1.25', + recommendedQuotes: defaultRecommendedQuotes, networkFee: { amount: '1.2', valueInCurrency: '1.2', @@ -124,7 +137,6 @@ const defaultQuoteData: MockBatchSellQuoteData = { formatted: '1.20 USDC', formattedFiat: '$1.20', }, - networkFeeIsLoading: false, }; let mockSelectedTokens = defaultSelectedTokens; let mockBatchSellQuoteData = defaultQuoteData; @@ -132,16 +144,23 @@ let mockBatchSellQuoteData = defaultQuoteData; jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ goBack: mockGoBack, + navigate: mockNavigate, replace: mockReplace, }), })); jest.mock('react-redux', () => ({ useSelector: (selector: (state: unknown) => unknown) => selector({}), + useDispatch: () => mockDispatch, })); jest.mock('../../../../../core/redux/slices/bridge', () => ({ selectBatchSellSourceTokens: jest.fn(() => mockSelectedTokens), + selectIsSubmittingTx: jest.fn(() => mockIsSubmittingTx), + setIsSubmittingTx: jest.fn((isSubmittingTx: boolean) => ({ + type: 'bridge/setIsSubmittingTx', + payload: isSubmittingTx, + })), })); jest.mock('../../hooks/useBatchSellQuoteData', () => ({ @@ -160,6 +179,12 @@ jest.mock('../../hooks/useBatchSellHasSufficientGas', () => ({ mockUseBatchSellHasSufficientGas(params), })); +jest.mock('../../hooks/useSubmitBatchSellTx', () => ({ + useSubmitBatchSellTx: () => ({ + submitBatchSellTx: mockSubmitBatchSellTx, + }), +})); + function renderModal(overrides: Partial = {}) { mockBatchSellQuoteData = { ...defaultQuoteData, @@ -173,9 +198,11 @@ describe('BatchSellFinalReviewModal', () => { beforeEach(() => { jest.clearAllMocks(); mockSelectedTokens = defaultSelectedTokens; + mockIsSubmittingTx = false; mockBatchSellQuoteData = defaultQuoteData; mockUpdateBatchSellQuoteParams.mockClear(); mockGetNewQuote.mockClear(); + mockSubmitBatchSellTx.mockResolvedValue(undefined); mockUseBatchSellHasSufficientGas.mockReturnValue(true); }); @@ -217,6 +244,45 @@ describe('BatchSellFinalReviewModal', () => { ).toBeNull(); }); + it('submits Batch Sell with the recommended quotes', async () => { + const { getByTestId } = renderModal(); + + fireEvent.press( + getByTestId(BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON), + ); + + await waitFor(() => { + expect(mockSubmitBatchSellTx).toHaveBeenCalledWith({ + quoteResponses: defaultRecommendedQuotes, + }); + }); + expect(mockDispatch).toHaveBeenNthCalledWith(1, { + type: 'bridge/setIsSubmittingTx', + payload: true, + }); + expect(mockDispatch).toHaveBeenLastCalledWith({ + type: 'bridge/setIsSubmittingTx', + payload: false, + }); + expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW); + }); + + it('blocks Sell all while submitting', () => { + mockIsSubmittingTx = true; + + const { getByTestId, getByText } = renderModal(); + + expect(getByText('Submitting')).toBeOnTheScreen(); + expect( + getByTestId(BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON).props + .accessibilityState.disabled, + ).toBe(true); + expect( + getByTestId(BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON).props + .accessibilityState.busy, + ).toBe(true); + }); + it('closes with navigation when the close button is pressed', () => { const { getByTestId } = renderModal(); @@ -377,7 +443,8 @@ describe('BatchSellFinalReviewModal', () => { it('renders a network fee values skeleton while the network fee is loading', () => { const { getByTestId, getByText, queryByText } = renderModal({ - networkFeeIsLoading: true, + isBatchSellTradeAvailable: false, + isBatchSellTradesLoading: true, }); expect( @@ -398,6 +465,45 @@ describe('BatchSellFinalReviewModal', () => { ).toBe(true); }); + it('blocks Sell all while the Batch Sell trade is unavailable', () => { + const { getByTestId } = renderModal({ + isBatchSellTradeAvailable: false, + }); + + expect( + getByTestId(BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON).props + .accessibilityState.disabled, + ).toBe(true); + }); + + it('shows insufficient funds when gasless destination-token fee cannot be covered', () => { + const { getByTestId, getByText, queryByTestId } = renderModal({ + isGasless: true, + isBatchSellTradeAvailable: false, + isBatchSellTradesLoading: false, + }); + const getTextColor = (text: string) => + StyleSheet.flatten(getByText(text).props.style).color; + + expect( + queryByTestId( + BatchSellFinalReviewModalSelectorsIDs.NETWORK_FEE_VALUES_SKELETON, + ), + ).toBeNull(); + 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('blocks Sell all and highlights the network fee when gas is insufficient', () => { mockUseBatchSellHasSufficientGas.mockReturnValue(false); @@ -424,7 +530,7 @@ describe('BatchSellFinalReviewModal', () => { const { getByTestId, getByText } = renderModal({ needsNewQuote: true, - networkFeeIsLoading: true, + isBatchSellTradeAvailable: false, hasPendingQuoteRows: true, }); const button = getByTestId( diff --git a/app/components/UI/Bridge/components/BatchSellFinalReviewModal/index.tsx b/app/components/UI/Bridge/components/BatchSellFinalReviewModal/index.tsx index 192d05da263..e5a9282208f 100644 --- a/app/components/UI/Bridge/components/BatchSellFinalReviewModal/index.tsx +++ b/app/components/UI/Bridge/components/BatchSellFinalReviewModal/index.tsx @@ -2,7 +2,7 @@ import { useNavigation } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; import React, { useCallback, useMemo, useState } from 'react'; import { Pressable } from 'react-native'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { formatAddressToAssetId } from '@metamask/bridge-controller'; import { @@ -31,13 +31,18 @@ import { import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; -import { selectBatchSellSourceTokens } from '../../../../../core/redux/slices/bridge'; +import { + selectBatchSellSourceTokens, + selectIsSubmittingTx, + setIsSubmittingTx, +} from '../../../../../core/redux/slices/bridge'; import { type BatchSellQuoteTokenData, useBatchSellQuoteData, } from '../../hooks/useBatchSellQuoteData'; import { useBatchSellQuoteRequest } from '../../hooks/useBatchSellQuoteRequest'; import { useBatchSellHasSufficientGas } from '../../hooks/useBatchSellHasSufficientGas'; +import { useSubmitBatchSellTx } from '../../hooks/useSubmitBatchSellTx'; import type { BridgeToken } from '../../types'; import { BatchSellQuoteDetails } from '../BatchSellQuoteDetailsModal'; import { BatchSellFinalReviewModalSelectorsIDs } from './BatchSellFinalReviewModal.testIds'; @@ -279,13 +284,16 @@ function NetworkFeeRow({ } export function BatchSellFinalReviewModal() { + const dispatch = useDispatch(); const navigation = useNavigation>>(); const selectedTokens = useSelector(selectBatchSellSourceTokens); + const isSubmittingTx = useSelector(selectIsSubmittingTx); const batchSellQuoteData = useBatchSellQuoteData({ shouldUpdateBatchSellTrades: false, }); const { getNewQuote } = useBatchSellQuoteRequest(); + const { submitBatchSellTx } = useSubmitBatchSellTx(); const hasSufficientGas = useBatchSellHasSufficientGas({ isGasless: batchSellQuoteData.isGasless, networkFee: batchSellQuoteData.networkFee, @@ -305,12 +313,20 @@ export function BatchSellFinalReviewModal() { selectedTokens, ], ); - const hasInsufficientGas = hasSufficientGas === false; + const isBatchSellTradesLoading = batchSellQuoteData.isBatchSellTradesLoading; + const hasInsufficientGaslessDestinationToken = + batchSellQuoteData.isGasless && + !isBatchSellTradesLoading && + !batchSellQuoteData.isBatchSellTradeAvailable; + const hasInsufficientGas = + hasSufficientGas === false || hasInsufficientGaslessDestinationToken; const isSellAllDisabled = batchSellQuoteData.isLoading || - batchSellQuoteData.networkFeeIsLoading || + isBatchSellTradesLoading || + !batchSellQuoteData.isBatchSellTradeAvailable || !batchSellQuoteData.hasAnyQuote || batchSellQuoteData.hasPendingQuoteRows || + isSubmittingTx || hasInsufficientGas; const isButtonDisabled = batchSellQuoteData.needsNewQuote ? false @@ -319,7 +335,8 @@ export function BatchSellFinalReviewModal() { !batchSellQuoteData.needsNewQuote && isSellAllDisabled && (batchSellQuoteData.isLoading || - batchSellQuoteData.networkFeeIsLoading || + isBatchSellTradesLoading || + isSubmittingTx || batchSellQuoteData.hasPendingQuoteRows); const actionButtonLabel = (() => { if (batchSellQuoteData.needsNewQuote) { @@ -330,6 +347,10 @@ export function BatchSellFinalReviewModal() { return strings('bridge.insufficient_funds'); } + if (isSubmittingTx) { + return strings('bridge.submitting_transaction'); + } + return strings('bridge.batch_sell_sell_all'); })(); @@ -356,9 +377,25 @@ export function BatchSellFinalReviewModal() { }); }; - const handleSellAll = useCallback(() => { - // TODO: submit the executable Batch Sell trades. - }, []); + const handleSellAll = useCallback(async () => { + try { + dispatch(setIsSubmittingTx(true)); + + await submitBatchSellTx({ + quoteResponses: batchSellQuoteData.recommendedQuotes, + }); + } catch (error) { + console.error('Error submitting Batch Sell tx', error); + } finally { + dispatch(setIsSubmittingTx(false)); + navigation.navigate(Routes.TRANSACTIONS_VIEW); + } + }, [ + batchSellQuoteData.recommendedQuotes, + dispatch, + navigation, + submitBatchSellTx, + ]); return ( diff --git a/app/components/UI/Bridge/components/GaslessQuickPickOptions/abTestConfig.ts b/app/components/UI/Bridge/components/GaslessQuickPickOptions/abTestConfig.ts index 5f57d6a891c..48b6debae48 100644 --- a/app/components/UI/Bridge/components/GaslessQuickPickOptions/abTestConfig.ts +++ b/app/components/UI/Bridge/components/GaslessQuickPickOptions/abTestConfig.ts @@ -1,3 +1,4 @@ +import { ASSET_VIEWED_PROPERTY } from '../../../../../core/Analytics/trade-transaction-funnel/assetViewedAnalytics'; import { EVENT_NAME } from '../../../../../core/Analytics/MetaMetrics.events'; import type { ABTestAnalyticsMapping } from '../../../../../util/analytics/abTestAnalytics.types'; @@ -30,5 +31,10 @@ export const NUMPAD_QUICK_ACTIONS_AB_TEST_ANALYTICS_MAPPING: ABTestAnalyticsMapp { flagKey: NUMPAD_QUICK_ACTIONS_AB_KEY, validVariants: Object.values(NumpadQuickActionsVariant), - eventNames: [EVENT_NAME.SWAP_PAGE_VIEWED], + eventNames: [EVENT_NAME.SWAP_PAGE_VIEWED, EVENT_NAME.ASSET_VIEWED], + eventPropertyRequirements: { + [EVENT_NAME.ASSET_VIEWED]: { + [ASSET_VIEWED_PROPERTY.TRADE_TYPE]: 'Swaps', + }, + }, }; diff --git a/app/components/UI/Bridge/components/InputStepper/styles.tsx b/app/components/UI/Bridge/components/InputStepper/styles.tsx index 96fb97aa332..c4360170e30 100644 --- a/app/components/UI/Bridge/components/InputStepper/styles.tsx +++ b/app/components/UI/Bridge/components/InputStepper/styles.tsx @@ -1,5 +1,6 @@ import { Theme } from '@metamask/design-tokens'; import { Platform, StyleSheet } from 'react-native'; +import { colors as importedColors } from '../../../../../styles/common'; export const inputStepperStyles = ({ vars, @@ -22,6 +23,7 @@ export const inputStepperStyles = ({ justifyContent: 'center', }, input: { + backgroundColor: importedColors.transparent, borderWidth: 0, lineHeight: vars.fontSize * 1.25, height: vars.fontSize * 1.25, diff --git a/app/components/UI/Bridge/components/TokenInputArea/index.tsx b/app/components/UI/Bridge/components/TokenInputArea/index.tsx index dbe5e897e38..44a0c3e44a4 100644 --- a/app/components/UI/Bridge/components/TokenInputArea/index.tsx +++ b/app/components/UI/Bridge/components/TokenInputArea/index.tsx @@ -12,6 +12,7 @@ import { } from 'react-native'; import { useSelector } from 'react-redux'; import { useStyles } from '../../../../../component-library/hooks'; +import { colors as importedColors } from '../../../../../styles/common'; import { Box } from '../../../Box/Box'; import Text, { TextColor, @@ -86,6 +87,7 @@ const createStyles = ({ height: vars.fontSize * 1.25, fontSize: vars.fontSize, paddingVertical: Platform.OS === 'ios' ? 2 : 1, + backgroundColor: importedColors.transparent, flex: 1, flexShrink: 1, }, diff --git a/app/components/UI/Bridge/components/TokenSelectorItem.abTestConfig.ts b/app/components/UI/Bridge/components/TokenSelectorItem.abTestConfig.ts index 078a27496d9..1b51c8b86aa 100644 --- a/app/components/UI/Bridge/components/TokenSelectorItem.abTestConfig.ts +++ b/app/components/UI/Bridge/components/TokenSelectorItem.abTestConfig.ts @@ -1,3 +1,4 @@ +import { ASSET_VIEWED_PROPERTY } from '../../../../core/Analytics/trade-transaction-funnel/assetViewedAnalytics'; import { EVENT_NAME } from '../../../../core/Analytics/MetaMetrics.events'; import type { ABTestAnalyticsMapping } from '../../../../util/analytics/abTestAnalytics.types'; @@ -32,5 +33,10 @@ export const TOKEN_SELECTOR_BALANCE_LAYOUT_AB_TEST_ANALYTICS_MAPPING: ABTestAnal { flagKey: TOKEN_SELECTOR_BALANCE_LAYOUT_AB_KEY, validVariants: Object.values(TokenSelectorBalanceLayoutVariant), - eventNames: [EVENT_NAME.SWAP_PAGE_VIEWED], + eventNames: [EVENT_NAME.SWAP_PAGE_VIEWED, EVENT_NAME.ASSET_VIEWED], + eventPropertyRequirements: { + [EVENT_NAME.ASSET_VIEWED]: { + [ASSET_VIEWED_PROPERTY.TRADE_TYPE]: 'Swaps', + }, + }, }; diff --git a/app/components/UI/Bridge/constants/tokens.ts b/app/components/UI/Bridge/constants/tokens.ts index 5d38439e668..445f5309181 100644 --- a/app/components/UI/Bridge/constants/tokens.ts +++ b/app/components/UI/Bridge/constants/tokens.ts @@ -47,4 +47,22 @@ export const BridgeTokenMetadata: Record = { 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/137/erc20/0x3c499c542cef5e3811e1192ce70d8cc03d5c3359.png', chainId: '0x89', }, + 'eip155:56/erc20:0x55d398326f99059ff775485246999027b3197955': { + symbol: 'USDT', + name: 'Tether USD', + address: '0x55d398326f99059ff775485246999027b3197955', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0x55d398326f99059ff775485246999027b3197955.png', + chainId: '0x38', + }, + 'eip155:59144/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da': { + decimals: 6, + name: 'MetaMask USD', + symbol: 'MUSD', + address: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/59144/erc20/0xaca92e438df0b2401ff60da7e4337b687a2435da.png', + chainId: '0xe708', + }, }; diff --git a/app/components/UI/Bridge/hooks/useBatchSellQuoteData/index.ts b/app/components/UI/Bridge/hooks/useBatchSellQuoteData/index.ts index c1a9e8a71f3..f99e249920a 100644 --- a/app/components/UI/Bridge/hooks/useBatchSellQuoteData/index.ts +++ b/app/components/UI/Bridge/hooks/useBatchSellQuoteData/index.ts @@ -299,13 +299,14 @@ export function useBatchSellQuoteData({ ); const hasAnyQuote = availableRecommendedQuotes.length > 0; const totalNetworkFee = batchSellTrades.totalNetworkFee; + const isBatchSellTradesLoading = Boolean(batchSellTrades.isLoading); + // 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), ); @@ -349,7 +350,6 @@ export function useBatchSellQuoteData({ () => getBatchSellTradesRequestKey(availableRecommendedQuotes), [availableRecommendedQuotes], ); - const networkFeeIsLoading = !batchSellTrades.isBatchSellTradeAvailable; const totalReceivedAmount = canDisplayAggregatedQuoteData ? totalReceived.amount : undefined; @@ -496,11 +496,13 @@ export function useBatchSellQuoteData({ isLoading, isSummaryLoading, isGasless, + isBatchSellTradeAvailable: batchSellTrades.isBatchSellTradeAvailable, + isBatchSellTradesLoading, hasAnyQuote, hasPendingQuoteRows, needsNewQuote, - networkFeeIsLoading, networkFee: networkFeeData, quotePercentFee, + recommendedQuotes: availableRecommendedQuotes, }; } diff --git a/app/components/UI/Bridge/hooks/useBatchSellQuoteData/useBatchSellQuoteData.test.ts b/app/components/UI/Bridge/hooks/useBatchSellQuoteData/useBatchSellQuoteData.test.ts index 22d88793101..42f06aa4825 100644 --- a/app/components/UI/Bridge/hooks/useBatchSellQuoteData/useBatchSellQuoteData.test.ts +++ b/app/components/UI/Bridge/hooks/useBatchSellQuoteData/useBatchSellQuoteData.test.ts @@ -154,6 +154,7 @@ let mockBatchSellTrades: { } | undefined; isBatchSellTradeAvailable: boolean; + isLoading: boolean; } = { totalNetworkFee: { amount: '1.2', @@ -161,6 +162,7 @@ let mockBatchSellTrades: { asset: ethNetworkFeeAsset, }, isBatchSellTradeAvailable: true, + isLoading: false, }; let mockBridgeFeatureFlags: { chains: Record; @@ -226,6 +228,7 @@ describe('useBatchSellQuoteData', () => { asset: ethNetworkFeeAsset, }, isBatchSellTradeAvailable: true, + isLoading: false, }; mockBridgeFeatureFlags = { chains: {}, @@ -239,6 +242,8 @@ describe('useBatchSellQuoteData', () => { expect(result.current.hasAnyQuote).toBe(true); expect(result.current.isGasless).toBe(false); + expect(result.current.isBatchSellTradeAvailable).toBe(true); + expect(result.current.isBatchSellTradesLoading).toBe(false); expect(result.current.isLoading).toBe(false); expect(result.current.isSummaryLoading).toBe(false); expect(result.current.hasPendingQuoteRows).toBe(false); @@ -252,9 +257,11 @@ describe('useBatchSellQuoteData', () => { 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(result.current.recommendedQuotes).toEqual( + mockBatchSellQuotes.recommendedQuotes, + ); expect( Engine.context.BridgeController.updateBatchSellTrades, ).toHaveBeenCalledWith(mockBatchSellQuotes.recommendedQuotes); @@ -325,6 +332,18 @@ describe('useBatchSellQuoteData', () => { expect(result.current.isGasless).toBe(true); }); + it('returns the Batch Sell trades loading state', () => { + mockBatchSellTrades = { + ...mockBatchSellTrades, + isBatchSellTradeAvailable: false, + isLoading: true, + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.isBatchSellTradesLoading).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); @@ -500,12 +519,14 @@ describe('useBatchSellQuoteData', () => { mockBatchSellTrades = { totalNetworkFee: undefined, isBatchSellTradeAvailable: false, + isLoading: false, }; const { result } = renderHook(() => useBatchSellQuoteData()); expect(result.current.networkFee.formatted).toBe('--'); - expect(result.current.networkFeeIsLoading).toBe(true); + expect(result.current.isBatchSellTradeAvailable).toBe(false); + expect(result.current.isBatchSellTradesLoading).toBe(false); expect(result.current.networkFee.formattedFiat).toBe('-'); }); diff --git a/app/components/UI/Bridge/hooks/useSubmitBatchSellTx/index.ts b/app/components/UI/Bridge/hooks/useSubmitBatchSellTx/index.ts new file mode 100644 index 00000000000..c93eca484de --- /dev/null +++ b/app/components/UI/Bridge/hooks/useSubmitBatchSellTx/index.ts @@ -0,0 +1,51 @@ +import { useSelector } from 'react-redux'; +import type { + MetaMetricsSwapsEventSource, + QuoteMetadata, + QuoteResponse, +} from '@metamask/bridge-controller'; + +import Engine from '../../../../../core/Engine'; +import { selectBatchSellDestToken } from '../../../../../core/redux/slices/bridge'; +import { selectBatchSellSourceWalletAddress } from '../../../../../selectors/bridge'; +import { selectShouldUseSmartTransaction } from '../../../../../selectors/smartTransactionsController'; + +export function useSubmitBatchSellTx() { + const stxEnabled = useSelector(selectShouldUseSmartTransaction); + const walletAddress = useSelector(selectBatchSellSourceWalletAddress); + const destToken = useSelector(selectBatchSellDestToken); + + const submitBatchSellTx = async ({ + quoteResponses, + location, + }: { + quoteResponses: ((QuoteResponse & QuoteMetadata) | null)[]; + /** The entry point from which the user initiated the swap or bridge */ + location?: MetaMetricsSwapsEventSource; + }) => { + if (!walletAddress) { + throw new Error('Batch Sell wallet address is not set'); + } + + const tokenSecurityTypeDestination = destToken?.securityData?.type ?? null; + const normalizedQuoteResponses = quoteResponses.map((quoteResponse) => + quoteResponse + ? { + ...quoteResponse, + approval: quoteResponse.approval ?? undefined, + } + : quoteResponse, + ); + + return await Engine.context.BridgeStatusController.submitBatchSell({ + quoteResponses: normalizedQuoteResponses, + accountAddress: walletAddress, + location, + isStxEnabled: stxEnabled, + quotesReceivedContext: undefined, + tokenSecurityTypeDestination, + }); + }; + + return { submitBatchSellTx }; +} diff --git a/app/components/UI/Bridge/hooks/useSubmitBatchSellTx/useSubmitBatchSellTx.test.tsx b/app/components/UI/Bridge/hooks/useSubmitBatchSellTx/useSubmitBatchSellTx.test.tsx new file mode 100644 index 00000000000..6e65fc47400 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useSubmitBatchSellTx/useSubmitBatchSellTx.test.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { Provider } from 'react-redux'; +import configureMockStore from 'redux-mock-store'; +import type { QuoteMetadata, QuoteResponse } from '@metamask/bridge-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; + +import { + DummyQuoteMetadata, + DummyQuotesNoApproval, + DummyQuotesWithApproval, +} from '../../../../../../tests/api-mocking/mock-responses/bridge-api-quotes'; +import { selectBatchSellSourceWalletAddress } from '../../../../../selectors/bridge'; +import { useSubmitBatchSellTx } from '.'; + +type BridgeQuoteResponse = QuoteResponse & QuoteMetadata; + +let mockSubmitBatchSell: jest.Mock< + Promise, + [ + { + quoteResponses: (BridgeQuoteResponse | null)[]; + accountAddress: string; + }, + ] +>; + +jest.mock('../../../../../core/Engine', () => { + mockSubmitBatchSell = jest.fn< + Promise, + [ + { + quoteResponses: (BridgeQuoteResponse | null)[]; + accountAddress: string; + }, + ] + >(); + + return { + controllerMessenger: {}, + context: { + BridgeStatusController: { + submitBatchSell: mockSubmitBatchSell, + }, + }, + }; +}); + +jest.mock('../../../../../selectors/smartTransactionsController', () => ({ + ...jest.requireActual('../../../../../selectors/smartTransactionsController'), + selectShouldUseSmartTransaction: jest.fn(() => true), +})); + +jest.mock('../../../../../selectors/bridge', () => ({ + ...jest.requireActual('../../../../../selectors/bridge'), + selectBatchSellSourceWalletAddress: jest.fn( + () => '0x1234567890123456789012345678901234567890', + ), +})); + +const mockStore = configureMockStore(); + +describe('useSubmitBatchSellTx', () => { + const createWrapper = (mockState = {}) => { + const store = mockStore({ + bridge: { + batchSellDestToken: undefined, + }, + ...mockState, + }); + + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest + .mocked(selectBatchSellSourceWalletAddress) + .mockReturnValue('0x1234567890123456789012345678901234567890'); + }); + + it('submits Batch Sell with the recommended quote responses', async () => { + const { result } = renderHook(() => useSubmitBatchSellTx(), { + wrapper: createWrapper({ + bridge: { + batchSellDestToken: { + symbol: 'SCAM', + securityData: { + type: 'Malicious', + }, + }, + }, + }), + }); + + const firstQuoteResponse = { + ...DummyQuotesWithApproval.ETH_11_USDC_TO_ARB[0], + ...DummyQuoteMetadata, + } as BridgeQuoteResponse; + const secondQuoteResponse = { + ...DummyQuotesNoApproval.OP_0_005_ETH_TO_ARB[0], + ...DummyQuoteMetadata, + } as BridgeQuoteResponse; + const mockBatchSellResult = { + chainId: '0x1', + id: 'batch-sell-1', + networkClientId: '1', + status: 'submitted', + time: Date.now(), + txParams: { + from: '0x1234567890123456789012345678901234567890', + }, + } as TransactionMeta; + + mockSubmitBatchSell.mockResolvedValueOnce(mockBatchSellResult); + + const txResult = await result.current.submitBatchSellTx({ + quoteResponses: [firstQuoteResponse, secondQuoteResponse], + }); + + expect(mockSubmitBatchSell).toHaveBeenCalledWith({ + quoteResponses: [ + { + ...firstQuoteResponse, + approval: firstQuoteResponse.approval ?? undefined, + }, + { + ...secondQuoteResponse, + approval: secondQuoteResponse.approval ?? undefined, + }, + ], + accountAddress: '0x1234567890123456789012345678901234567890', + location: undefined, + isStxEnabled: true, + quotesReceivedContext: undefined, + tokenSecurityTypeDestination: 'Malicious', + }); + expect(txResult).toEqual(mockBatchSellResult); + }); + + it('throws when Batch Sell wallet address is not set', async () => { + jest.mocked(selectBatchSellSourceWalletAddress).mockReturnValue(undefined); + + const { result } = renderHook(() => useSubmitBatchSellTx(), { + wrapper: createWrapper(), + }); + + await expect( + result.current.submitBatchSellTx({ + quoteResponses: [], + }), + ).rejects.toThrow('Batch Sell wallet address is not set'); + }); +}); diff --git a/app/components/UI/Bridge/hooks/useTrackSwapPageViewed/index.test.ts b/app/components/UI/Bridge/hooks/useTrackSwapPageViewed/index.test.ts new file mode 100644 index 00000000000..3c6d137ed9a --- /dev/null +++ b/app/components/UI/Bridge/hooks/useTrackSwapPageViewed/index.test.ts @@ -0,0 +1,124 @@ +import { renderHook } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import { + selectDestToken, + selectSourceToken, +} from '../../../../../core/redux/slices/bridge'; +import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { useTrackSwapPageViewed } from './index'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +jest.mock('../../../../hooks/useAnalytics/useAnalytics'); + +const mockUseSelector = useSelector as jest.MockedFunction; +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(); + +const sourceToken = { + symbol: 'ETH', + chainId: '0x1', + address: '0x0000000000000000000000000000000000000000', +}; + +const destToken = { + symbol: 'USDC', + chainId: '0x89', + address: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174', +}; + +describe('useTrackSwapPageViewed', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCreateEventBuilder.mockImplementation(() => ({ + addProperties: jest.fn().mockReturnThis(), + build: jest.fn(() => ({ type: 'built' })), + })); + jest.mocked(useAnalytics).mockReturnValue({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + } as unknown as ReturnType); + }); + + it('does not track when the source token is missing', () => { + mockUseSelector.mockImplementation((selector: unknown) => { + if (selector === selectSourceToken) { + return null; + } + if (selector === selectDestToken) { + return destToken; + } + return undefined; + }); + + renderHook(() => useTrackSwapPageViewed()); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(mockCreateEventBuilder).not.toHaveBeenCalled(); + }); + + it('tracks SWAP_PAGE_VIEWED and Asset Viewed with swap page properties when the source token is set', () => { + mockUseSelector.mockImplementation((selector: unknown) => { + if (selector === selectSourceToken) { + return sourceToken; + } + if (selector === selectDestToken) { + return destToken; + } + return undefined; + }); + + renderHook(() => useTrackSwapPageViewed()); + + const expectedPageProperties = { + chain_id_source: '1', + chain_id_destination: '137', + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + token_address_source: sourceToken.address, + token_address_destination: destToken.address, + }; + + expect(mockTrackEvent).toHaveBeenCalledTimes(2); + expect(mockCreateEventBuilder.mock.calls[0][0]).toBe( + MetaMetricsEvents.SWAP_PAGE_VIEWED, + ); + expect(mockCreateEventBuilder.mock.calls[1][0]).toBe( + MetaMetricsEvents.ASSET_VIEWED, + ); + + const swapBuilder = mockCreateEventBuilder.mock.results[0].value; + const assetViewedBuilder = mockCreateEventBuilder.mock.results[1].value; + + expect(swapBuilder.addProperties).toHaveBeenCalledWith( + expectedPageProperties, + ); + expect(assetViewedBuilder.addProperties).toHaveBeenCalledWith({ + ...expectedPageProperties, + trade_type: 'Swaps', + implementation_type: 'native', + }); + }); + + it('tracks at most once per hook mount when the source token stays set', () => { + mockUseSelector.mockImplementation((selector: unknown) => { + if (selector === selectSourceToken) { + return sourceToken; + } + if (selector === selectDestToken) { + return destToken; + } + return undefined; + }); + + const { rerender } = renderHook(() => useTrackSwapPageViewed()); + + rerender(undefined); + + expect(mockTrackEvent).toHaveBeenCalledTimes(2); + }); +}); diff --git a/app/components/UI/Bridge/hooks/useTrackSwapPageViewed/index.ts b/app/components/UI/Bridge/hooks/useTrackSwapPageViewed/index.ts index 919e4b1345c..662310e5546 100644 --- a/app/components/UI/Bridge/hooks/useTrackSwapPageViewed/index.ts +++ b/app/components/UI/Bridge/hooks/useTrackSwapPageViewed/index.ts @@ -1,7 +1,10 @@ import { useEffect, useRef } from 'react'; import { useSelector } from 'react-redux'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; -import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { + MetaMetricsEvents, + mergeAssetViewedProperties, +} from '../../../../../core/Analytics'; import { getDecimalChainId } from '../../../../../util/networks'; import { selectDestToken, @@ -33,6 +36,13 @@ export const useTrackSwapPageViewed = () => { .addProperties(pageViewedProperties) .build(), ); + trackEvent( + createEventBuilder(MetaMetricsEvents.ASSET_VIEWED) + .addProperties( + mergeAssetViewedProperties('Swaps', pageViewedProperties), + ) + .build(), + ); } }, [sourceToken, destToken, trackEvent, createEventBuilder]); }; diff --git a/app/components/UI/Card/components/Onboarding/RegionSelectorModal.tsx b/app/components/UI/Card/components/Onboarding/RegionSelectorModal.tsx index 91662247223..0a803bd6744 100644 --- a/app/components/UI/Card/components/Onboarding/RegionSelectorModal.tsx +++ b/app/components/UI/Card/components/Onboarding/RegionSelectorModal.tsx @@ -11,7 +11,6 @@ import Fuse from 'fuse.js'; import BottomSheet, { BottomSheetRef, } from '../../../../../component-library/components/BottomSheets/BottomSheet'; -import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import ListItemSelect from '../../../../../component-library/components/List/ListItemSelect'; import ListItemColumn, { WidthType, @@ -27,6 +26,7 @@ import { Box, BoxAlignItems, BoxFlexDirection, + HeaderStandard, Text, TextVariant, } from '@metamask/design-system-react-native'; @@ -209,7 +209,7 @@ function RegionSelectorModal() { keyboardAvoidingViewEnabled={false} testID="region-selector-modal" > - ({ jest.mock('../../../util/Logger', () => ({ error: jest.fn(), + log: jest.fn(), })); describe('FoxLoader', () => { @@ -300,7 +301,7 @@ describe('FoxLoader', () => { expect(onAnimationComplete).not.toHaveBeenCalled(); act(() => { - jest.advanceTimersByTime(5_000); + jest.advanceTimersByTime(3_000); }); expect(onAnimationComplete).toHaveBeenCalledTimes(1); @@ -456,7 +457,7 @@ describe('FoxLoader', () => { ); await act(async () => { - jest.advanceTimersByTime(5_000); + jest.advanceTimersByTime(3_000); }); expect(Logger.error).toHaveBeenCalledWith( diff --git a/app/components/UI/FoxLoader/FoxLoader.tsx b/app/components/UI/FoxLoader/FoxLoader.tsx index 6fdca77df0b..1a7830cbeb2 100644 --- a/app/components/UI/FoxLoader/FoxLoader.tsx +++ b/app/components/UI/FoxLoader/FoxLoader.tsx @@ -24,7 +24,7 @@ const SPLASH_STATE_MACHINE = 'Splash_animation'; const SPLASH_IDLE_STATE = 'Blink and look around (Shorter)'; // Maximum time to wait for the animation to complete before forcing the app to show. // Guards against silent failures: corrupted .riv file, stuck state machine, unsupported renderer. -const ANIMATION_TIMEOUT_MS = 5_000; +const ANIMATION_TIMEOUT_MS = 3_000; // Persist across remounts so animation state is consistent for the app session let animationStarted = false; @@ -141,14 +141,11 @@ const FoxLoaderAnimation = ({ useEffect(() => { const timeout = setTimeout(() => { if (!isCompleteRef.current) { - // Only log an error if the animation genuinely got stuck globally. - // If animationComplete is true, the primary instance finished successfully — - // this is a secondary instance (LockScreen, AppFlow) that mounted mid-animation. + // Expected on devices where Rive can't play (e.g. unsupported renderer on + // low-end Android); the static fox fallback handles it. Log without + // raising a Sentry error. if (!animationComplete) { - Logger.error( - new Error('Splash animation timed out'), - 'FoxLoader: forcing app reveal after timeout', - ); + Logger.log('FoxLoader: forcing app reveal after timeout'); } // Ensure the native splash is hidden even if onLoad never fired on the static fox image. hideAsync().catch((error: unknown) => diff --git a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx index 5731aa88325..ff8da6c20c7 100644 --- a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx +++ b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx @@ -162,6 +162,10 @@ interface MarketInsightsRouteParams { hasPerpsPosition?: boolean; /** Surface from which Market Insights was accessed */ source?: 'token_details' | 'perps' | 'unknown'; + /** Whether the price trend is positive on the parent Token Details screen. */ + isPricePositive?: boolean; + /** Whether the ambient price color A/B test treatment is active. */ + useAmbientColor?: boolean; } /** @@ -190,6 +194,8 @@ const MarketInsightsView: React.FC = () => { isPerps = false, hasPerpsPosition = false, source: routeSource = 'unknown', + isPricePositive, + useAmbientColor, } = route.params; const isMarketInsightsEnabled = isPerps @@ -823,6 +829,8 @@ const MarketInsightsView: React.FC = () => { onSwapPress={handleStickySwapPress} onBuyPress={handleStickyBuyPress} sourcePage="MarketInsightsView" + isPricePositive={isPricePositive} + useAmbientColor={useAmbientColor} /> { }; }); -const mockConversionTokens = [ +const mockDepositTokens = [ { name: 'USD Coin', symbol: 'USDC', @@ -63,15 +62,15 @@ const mockConversionTokens = [ }, ]; -const mockUseMusdConversionTokens = jest.fn(() => ({ - tokens: mockConversionTokens as ReturnType, +const mockUseMoneyDepositTokens = jest.fn(() => ({ + tokens: mockDepositTokens as ReturnType, + isNoFeeToken: jest.fn(() => false), + isEligibleToken: jest.fn(() => false), + filterAllowedTokens: jest.fn((t) => t), })); -jest.mock('../../../Earn/hooks/useMusdConversionTokens', () => ({ - useMusdConversionTokens: () => mockUseMusdConversionTokens(), - STABLECOIN_SYMBOLS: new Set(['USDC', 'USDT', 'DAI']), - tokenFiatValue: (token: { fiat?: { balance?: number } }) => - token?.fiat?.balance ?? 0, +jest.mock('../../hooks/useMoneyDepositTokens', () => ({ + useMoneyDepositTokens: () => mockUseMoneyDepositTokens(), })); jest.mock('../../hooks/useMoneyAccountTransactions', () => ({ @@ -88,14 +87,6 @@ jest.mock('../../hooks/useMoneyAccountInfo', () => ({ default: jest.fn(), })); -jest.mock('../../../Earn/hooks/useMusdConversion', () => ({ - useMusdConversion: jest.fn(), -})); - -jest.mock('../../../Earn/hooks/useMusdBalance', () => ({ - useMusdBalance: jest.fn(), -})); - jest.mock('../../../../../core/NavigationService', () => ({ __esModule: true, default: { @@ -140,7 +131,7 @@ jest.mock('../../../Earn/hooks/useMusdBalance', () => ({ jest.mock('../../hooks/useMoneyAccount', () => ({ useMoneyAccountDeposit: jest.fn(() => ({ - initiateDeposit: jest.fn(() => Promise.resolve()), + initiateDeposit: mockInitiateDeposit, })), useMoneyAccountWithdrawal: jest.fn(() => ({ initiateWithdrawal: jest.fn(() => Promise.resolve()), @@ -171,13 +162,11 @@ const mockUseMoneyAccountTransactions = jest.mocked( useMoneyAccountTransactions, ); -const mockUseMusdConversion = jest.mocked(useMusdConversion); +const mockUseMusdBalance = jest.mocked(useMusdBalance); const mockUseMoneyAccountBalance = jest.mocked(useMoneyAccountBalance); const mockUseMoneyAccountInfo = jest.mocked(useMoneyAccountInfo); -const mockUseMusdBalance = jest.mocked(useMusdBalance); - jest.mock( '../../../../UI/Assets/components/AssetLogo/AssetLogo', () => 'AssetLogo', @@ -231,10 +220,7 @@ describe('MoneyHomeView', () => { jest.clearAllMocks(); global.alert = jest.fn(); - mockInitiateCustomConversion.mockResolvedValue(undefined); - mockUseMusdConversion.mockReturnValue({ - initiateCustomConversion: mockInitiateCustomConversion, - } as unknown as ReturnType); + mockInitiateDeposit.mockResolvedValue(undefined); mockSelectIsCardholder.mockReturnValue(false); mockGetDetectedGeolocation.mockReturnValue('US'); @@ -680,13 +666,16 @@ describe('MoneyHomeView', () => { }); it('navigates to potential earnings screen when View potential earnings is pressed', () => { - mockUseMusdConversionTokens.mockReturnValueOnce({ + mockUseMoneyDepositTokens.mockReturnValueOnce({ tokens: Array.from({ length: 6 }, (_, i) => ({ - ...mockConversionTokens[0], + ...mockDepositTokens[0], address: `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB${i.toString(16).padStart(2, '0')}` as `0x${string}`, fiat: { balance: 5000 }, })), + isNoFeeToken: jest.fn(() => false), + isEligibleToken: jest.fn(() => false), + filterAllowedTokens: jest.fn((t) => t), }); const { getByTestId } = renderWithProvider(); @@ -1074,13 +1063,16 @@ describe('MoneyHomeView', () => { describe('filled state navigation handlers', () => { it('navigates to Potential Earnings when View all is pressed on potential earnings section', () => { - mockUseMusdConversionTokens.mockReturnValueOnce({ + mockUseMoneyDepositTokens.mockReturnValueOnce({ tokens: Array.from({ length: 6 }, (_, i) => ({ - ...mockConversionTokens[0], + ...mockDepositTokens[0], address: `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB${i.toString(16).padStart(2, '0')}` as `0x${string}`, fiat: { balance: 5000 }, })), + isNoFeeToken: jest.fn(() => false), + isEligibleToken: jest.fn(() => false), + filterAllowedTokens: jest.fn((t) => t), }); const { getByTestId } = renderWithProvider(); @@ -1093,7 +1085,7 @@ describe('MoneyHomeView', () => { ); }); - it('initiates a custom conversion when a token Convert button is pressed', async () => { + it('initiates a deposit when a token Convert button is pressed', async () => { const { getByTestId } = renderWithProvider(); const potentialEarnings = getByTestId( @@ -1105,19 +1097,17 @@ describe('MoneyHomeView', () => { ), ); - expect(mockInitiateCustomConversion).toHaveBeenCalledWith( + expect(mockInitiateDeposit).toHaveBeenCalledWith( expect.objectContaining({ preferredPaymentToken: expect.objectContaining({ - address: mockConversionTokens[0].address, + address: mockDepositTokens[0].address, }), }), ); }); - it('logs an error when initiateCustomConversion rejects', async () => { - mockInitiateCustomConversion.mockRejectedValueOnce( - new Error('network failure'), - ); + it('logs an error when initiateDeposit rejects', async () => { + mockInitiateDeposit.mockRejectedValueOnce(new Error('network failure')); const Logger = jest.requireMock('../../../../../util/Logger'); const { getByTestId } = renderWithProvider(); diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx index b47ec8068f9..08c565c7690 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx @@ -15,7 +15,6 @@ import MoneyOnboardingCard from '../../components/MoneyOnboardingCard'; import MoneyCondensedInfoCards from '../../components/MoneyCondensedInfoCards'; import MoneyHowItWorks from '../../components/MoneyHowItWorks'; import MoneyPotentialEarnings from '../../components/MoneyPotentialEarnings'; -import { hasConvertibleTokensWithBalance } from '../../components/MoneyPotentialEarnings/MoneyPotentialEarnings'; import MoneyMetaMaskCard from '../../components/MoneyMetaMaskCard'; import MoneyWhatYouGet from '../../components/MoneyWhatYouGet'; import MoneyActivityList from '../../components/MoneyActivityList'; @@ -23,8 +22,7 @@ import MoneyFooter from '../../components/MoneyFooter'; import Routes from '../../../../../constants/navigation/Routes'; import { MoneyHomeViewTestIds } from './MoneyHomeView.testIds'; import styleSheet from './MoneyHomeView.styles'; -import { useMusdConversionTokens } from '../../../Earn/hooks/useMusdConversionTokens'; -import { useMusdConversion } from '../../../Earn/hooks/useMusdConversion'; +import { useMoneyDepositTokens } from '../../hooks/useMoneyDepositTokens'; import { useMusdBalance } from '../../../Earn/hooks/useMusdBalance'; import { useMoneyAccountTransactions } from '../../hooks/useMoneyAccountTransactions'; import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; @@ -47,6 +45,7 @@ import { MoneyBalanceDisplayState } from '../../types'; import { Hex } from '@metamask/utils'; import { AssetType } from '../../../../Views/confirmations/types/token'; import { MONEY_ONBOARDING_TOTAL_STEPS } from '../../components/MoneyOnboardingCard/MoneyOnboardingCard'; +import { useMoneyAccountDeposit } from '../../hooks/useMoneyAccount'; const Divider = () => ; type MoneyHomeState = 'empty' | 'milestone' | 'filled'; @@ -93,8 +92,8 @@ const MoneyHomeView = () => { const { fiatBalanceAggregatedFormatted: musdFiatFormatted } = useMusdBalance(); - const { tokens: conversionTokens } = useMusdConversionTokens(); - const { initiateCustomConversion } = useMusdConversion(); + const { tokens: depositTokens, isNoFeeToken } = useMoneyDepositTokens(); + const { initiateDeposit } = useMoneyAccountDeposit(); const { allTransactions, moneyAddress, mockDataEnabled } = useMoneyAccountTransactions(); @@ -218,10 +217,10 @@ const MoneyHomeView = () => { }); }, []); - const handleTokenConvertPress = useCallback( + const handleTokenDepositPress = useCallback( async (token: AssetType) => { try { - await initiateCustomConversion({ + await initiateDeposit({ preferredPaymentToken: { address: token.address as Hex, chainId: token.chainId as Hex, @@ -234,7 +233,7 @@ const MoneyHomeView = () => { }); } }, - [initiateCustomConversion], + [initiateDeposit], ); const handleEarnCryptoPress = useCallback(() => { @@ -355,12 +354,13 @@ const MoneyHomeView = () => { )} - {hasConvertibleTokensWithBalance(conversionTokens) && ( + {depositTokens.length > 0 && ( <> { @@ -23,7 +23,7 @@ jest.mock('@react-navigation/native', () => { }; }); -const mockConversionTokens = [ +const mockDepositTokens = [ { name: 'USD Coin', symbol: 'USDC', @@ -80,11 +80,13 @@ const mockConversionTokens = [ }, ]; -jest.mock('../../../Earn/hooks/useMusdConversionTokens', () => ({ - useMusdConversionTokens: () => ({ tokens: mockTokens }), - STABLECOIN_SYMBOLS: new Set(['USDC', 'USDT', 'DAI']), - tokenFiatValue: (token: { fiat?: { balance?: number } }) => - token?.fiat?.balance ?? 0, +jest.mock('../../hooks/useMoneyDepositTokens', () => ({ + useMoneyDepositTokens: () => ({ + tokens: mockTokens, + isNoFeeToken: jest.fn(() => false), + isEligibleToken: jest.fn(() => false), + filterAllowedTokens: jest.fn((t) => t), + }), })); jest.mock('../../hooks/useMoneyAccountBalance', () => ({ @@ -92,9 +94,9 @@ jest.mock('../../hooks/useMoneyAccountBalance', () => ({ default: jest.fn(), })); -jest.mock('../../../Earn/hooks/useMusdConversion', () => ({ - useMusdConversion: () => ({ - initiateCustomConversion: mockInitiateCustomConversion, +jest.mock('../../hooks/useMoneyAccount', () => ({ + useMoneyAccountDeposit: () => ({ + initiateDeposit: mockInitiateDeposit, }), })); @@ -129,8 +131,8 @@ const mockUseMoneyAccountBalance = jest.mocked(useMoneyAccountBalance); describe('MoneyPotentialEarningsView', () => { beforeEach(() => { jest.clearAllMocks(); - mockTokens = mockConversionTokens; - mockInitiateCustomConversion.mockResolvedValue(undefined); + mockTokens = mockDepositTokens; + mockInitiateDeposit.mockResolvedValue(undefined); mockUseMoneyAccountBalance.mockReturnValue({ apyPercent: 4, apyDecimal: 0.04, @@ -260,14 +262,12 @@ describe('MoneyPotentialEarningsView', () => { ); }); - it('triggers conversion when the bottom Convert CTA is pressed', async () => { + it('triggers deposit when the bottom Convert CTA is pressed', async () => { const { getByTestId } = renderWithProvider(); fireEvent.press(getByTestId(MoneyPotentialEarningsViewTestIds.CTA_BUTTON)); - await waitFor(() => - expect(mockInitiateCustomConversion).toHaveBeenCalled(), - ); + await waitFor(() => expect(mockInitiateDeposit).toHaveBeenCalled()); }); it('disables the Convert CTA when there are no eligible tokens', () => { @@ -294,46 +294,24 @@ describe('MoneyPotentialEarningsView', () => { fireEvent.press(getByTestId(MoneyPotentialEarningsViewTestIds.CTA_BUTTON)); - await waitFor(() => - expect(mockInitiateCustomConversion).not.toHaveBeenCalled(), - ); + await waitFor(() => expect(mockInitiateDeposit).not.toHaveBeenCalled()); }); - it('logs but swallows conversion errors from the Convert CTA', async () => { - const conversionError = new Error('conversion failed'); - mockInitiateCustomConversion.mockRejectedValueOnce(conversionError); + it('calls initiateDeposit from the Convert CTA', async () => { const { getByTestId } = renderWithProvider(); fireEvent.press(getByTestId(MoneyPotentialEarningsViewTestIds.CTA_BUTTON)); - await waitFor(() => - expect(mockInitiateCustomConversion).toHaveBeenCalled(), - ); - }); - - it('triggers conversion when a token row is pressed', async () => { - const { getByTestId } = renderWithProvider(); - - fireEvent.press( - getByTestId(MoneyPotentialEarningsViewTestIds.TOKEN_ROW(0)), - ); - - await waitFor(() => - expect(mockInitiateCustomConversion).toHaveBeenCalled(), - ); + await waitFor(() => expect(mockInitiateDeposit).toHaveBeenCalled()); }); - it('logs but swallows conversion errors when a token row press throws', async () => { - const conversionError = new Error('token conversion failed'); - mockInitiateCustomConversion.mockRejectedValueOnce(conversionError); + it('triggers deposit when a token row is pressed', async () => { const { getByTestId } = renderWithProvider(); fireEvent.press( getByTestId(MoneyPotentialEarningsViewTestIds.TOKEN_ROW(0)), ); - await waitFor(() => - expect(mockInitiateCustomConversion).toHaveBeenCalled(), - ); + await waitFor(() => expect(mockInitiateDeposit).toHaveBeenCalled()); }); }); diff --git a/app/components/UI/Money/Views/MoneyPotentialEarningsView/MoneyPotentialEarningsView.tsx b/app/components/UI/Money/Views/MoneyPotentialEarningsView/MoneyPotentialEarningsView.tsx index 1a9d13307ea..595b3f63ef7 100644 --- a/app/components/UI/Money/Views/MoneyPotentialEarningsView/MoneyPotentialEarningsView.tsx +++ b/app/components/UI/Money/Views/MoneyPotentialEarningsView/MoneyPotentialEarningsView.tsx @@ -22,11 +22,7 @@ import { } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; import { useStyles } from '../../../../../component-library/hooks'; -import { - useMusdConversionTokens, - STABLECOIN_SYMBOLS, -} from '../../../Earn/hooks/useMusdConversionTokens'; -import { useMusdConversion } from '../../../Earn/hooks/useMusdConversion'; +import { useMoneyDepositTokens } from '../../hooks/useMoneyDepositTokens'; import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; import { useProjectedEarnings } from '../../hooks/useProjectedEarnings'; import { selectCurrentCurrency } from '../../../../../selectors/currencyRateController'; @@ -39,6 +35,7 @@ import PotentialEarningsTokenRow from '../../components/MoneyPotentialEarnings/P import { isPositiveNumber } from '../../utils/number'; import styleSheet from './MoneyPotentialEarningsView.styles'; import { MoneyPotentialEarningsViewTestIds } from './MoneyPotentialEarningsView.testIds'; +import { useMoneyAccountDeposit } from '../../hooks/useMoneyAccount'; const MoneyPotentialEarningsView = () => { const navigation = useNavigation(); @@ -46,13 +43,13 @@ const MoneyPotentialEarningsView = () => { const { styles } = useStyles(styleSheet, {}); const currentCurrency = useSelector(selectCurrentCurrency); - const { tokens } = useMusdConversionTokens(); - const { initiateCustomConversion } = useMusdConversion(); + const { tokens: depositTokens, isNoFeeToken } = useMoneyDepositTokens(); + const { initiateDeposit } = useMoneyAccountDeposit(); const { apyPercent } = useMoneyAccountBalance(); const apyPercentForProjection = apyPercent ?? 0; const { eligibleTokens, totalAssetsFiat, projectedAmount } = - useProjectedEarnings(tokens, apyPercent); + useProjectedEarnings(depositTokens, apyPercent); const handleBackPress = useCallback(() => { navigation.goBack(); @@ -65,16 +62,13 @@ const MoneyPotentialEarningsView = () => { }, [navigation]); const handleConvertPress = useCallback(async () => { - // The conversion flow picks the actual source by inspecting balances; the - // first eligible token (sorted by useMusdConversionTokens) seeds the - // confirmation screen so it can resolve a default if the user does not - // change it. const defaultToken = eligibleTokens[0]; + if (!defaultToken) { return; } try { - await initiateCustomConversion({ + await initiateDeposit({ preferredPaymentToken: { address: defaultToken.address as Hex, chainId: defaultToken.chainId as Hex, @@ -83,15 +77,15 @@ const MoneyPotentialEarningsView = () => { } catch (error) { Logger.error(error as Error, { message: - '[MoneyPotentialEarningsView] Failed to initiate conversion from CTA', + '[MoneyPotentialEarningsView] Failed to initiate deposit from CTA', }); } - }, [eligibleTokens, initiateCustomConversion]); + }, [eligibleTokens, initiateDeposit]); const handleTokenPress = useCallback( (token: AssetType) => async () => { try { - await initiateCustomConversion({ + await initiateDeposit({ preferredPaymentToken: { address: token.address as Hex, chainId: token.chainId as Hex, @@ -99,11 +93,11 @@ const MoneyPotentialEarningsView = () => { }); } catch (error) { Logger.error(error as Error, { - message: '[MoneyPotentialEarningsView] Failed to initiate conversion', + message: '[MoneyPotentialEarningsView] Failed to initiate deposit', }); } }, - [initiateCustomConversion], + [initiateDeposit], ); return ( @@ -184,7 +178,7 @@ const MoneyPotentialEarningsView = () => { { const { startLinkFlow, isCardAuthenticated, isCardLinkedToMoneyAccount } = useMoneyAccountCardLinkage(); - const handleRedirectToCryptoDeposit = useCallback(() => { - initiateDeposit().catch(() => undefined); + const handleRedirectToCryptoDeposit = useCallback(async () => { + await initiateDeposit().catch(() => undefined); }, [initiateDeposit]); const handleCardCtaPress = useCallback(() => { diff --git a/app/components/UI/Money/components/MoneyPotentialEarnings/MoneyPotentialEarnings.test.tsx b/app/components/UI/Money/components/MoneyPotentialEarnings/MoneyPotentialEarnings.test.tsx index c649ddccde2..e208476738e 100644 --- a/app/components/UI/Money/components/MoneyPotentialEarnings/MoneyPotentialEarnings.test.tsx +++ b/app/components/UI/Money/components/MoneyPotentialEarnings/MoneyPotentialEarnings.test.tsx @@ -326,4 +326,59 @@ describe('MoneyPotentialEarnings', () => { // which fails isPositiveNumber and hides the "+$..." text in each token row. expect(queryByText(/^\+\$/)).not.toBeOnTheScreen(); }); + + describe('isNoFeeToken prop — "No fee" badge', () => { + it('renders the No fee badge on a token row when isNoFeeToken returns true', () => { + const { getByText } = render( + true} + />, + ); + + expect( + getByText(strings('money.potential_earnings.no_fee')), + ).toBeOnTheScreen(); + }); + + it('does not render the No fee badge when isNoFeeToken returns false', () => { + const { queryByText } = render( + false} + />, + ); + + expect( + queryByText(strings('money.potential_earnings.no_fee')), + ).not.toBeOnTheScreen(); + }); + + it('does not render any No fee badge when isNoFeeToken is omitted', () => { + const { queryByText } = render( + , + ); + + expect( + queryByText(strings('money.potential_earnings.no_fee')), + ).not.toBeOnTheScreen(); + }); + + it('renders No fee badge only on eligible token rows', () => { + const { getAllByText, queryByText } = render( + token.symbol === 'USDC'} + />, + ); + + expect( + getAllByText(strings('money.potential_earnings.no_fee')), + ).toHaveLength(1); + expect(queryByText('USDT')).toBeOnTheScreen(); + }); + }); }); diff --git a/app/components/UI/Money/components/MoneyPotentialEarnings/MoneyPotentialEarnings.tsx b/app/components/UI/Money/components/MoneyPotentialEarnings/MoneyPotentialEarnings.tsx index 86013007673..bba09978d6f 100644 --- a/app/components/UI/Money/components/MoneyPotentialEarnings/MoneyPotentialEarnings.tsx +++ b/app/components/UI/Money/components/MoneyPotentialEarnings/MoneyPotentialEarnings.tsx @@ -20,26 +20,12 @@ import MoneySectionHeader from '../MoneySectionHeader'; import { MoneyPotentialEarningsTestIds } from './MoneyPotentialEarnings.testIds'; import { selectCurrentCurrency } from '../../../../../selectors/currencyRateController'; import { moneyFormatFiat } from '../../utils/moneyFormatFiat'; -import { - STABLECOIN_SYMBOLS, - tokenFiatValue, -} from '../../../Earn/hooks/useMusdConversionTokens'; import { AssetType } from '../../../../Views/confirmations/types/token'; import { isPositiveNumber } from '../../utils/number'; import PotentialEarningsTokenRow from './PotentialEarningsTokenRow'; import { useProjectedEarnings } from '../../hooks/useProjectedEarnings'; -/** Number of years the projected earnings are simulated over. */ -const MAX_TOKENS = 5; - -/** - * True when the token list contains at least one token with a positive fiat - * balance — the same criterion MoneyPotentialEarnings uses before rendering. - * Exported so parents can gate surrounding chrome (e.g. Dividers) without - * drifting from the component's internal filter. - */ -export const hasConvertibleTokensWithBalance = (tokens: AssetType[]) => - tokens.some((token) => tokenFiatValue(token) > 0); +const VISIBLE_TOKENS_COUNT = 5; interface MoneyPotentialEarningsProps { tokens: AssetType[]; @@ -49,6 +35,13 @@ interface MoneyPotentialEarningsProps { * alongside each token and in the description. */ apy: number | undefined; + /** + * Returns true when the given token qualifies for a subsidised (no-fee) + * deposit. Used to render the "No fee" badge on each token row. + * Sourced from the `earnMoneyDepositNoFeeTokens` remote feature flag via + * useMoneyDepositTokens. + */ + isNoFeeToken?: (token: AssetType) => boolean; onTokenPress?: (token: AssetType) => void; onViewAllPress?: () => void; onHeaderPress?: () => void; @@ -62,6 +55,7 @@ interface MoneyPotentialEarningsProps { const MoneyPotentialEarnings = ({ tokens, apy, + isNoFeeToken = () => false, onTokenPress, onViewAllPress, onHeaderPress, @@ -70,11 +64,6 @@ const MoneyPotentialEarnings = ({ const currentCurrency = useSelector(selectCurrentCurrency); const apyPercent = apy ?? 0; - // Tokens arrive pre-sorted (stablecoins first, then fiat desc) from - // useMusdConversionTokens; the hook strips zero-balance entries - // defensively, since the feature flag threshold may be set to 0 in some - // environments. - // // Sum across every eligible token (not just the five we render). The "View // all" affordance tells users there are more rows than shown, so the // headline is intentionally the full projection — clipping the headline to @@ -82,7 +71,7 @@ const MoneyPotentialEarnings = ({ const { eligibleTokens, totalAssetsFiat, projectedAmount } = useProjectedEarnings(tokens, apyPercent); const visibleTokens = useMemo( - () => eligibleTokens.slice(0, MAX_TOKENS), + () => eligibleTokens.slice(0, VISIBLE_TOKENS_COUNT), [eligibleTokens], ); @@ -173,13 +162,13 @@ const MoneyPotentialEarnings = ({ ))} - {eligibleTokens.length > MAX_TOKENS && ( + {eligibleTokens.length > VISIBLE_TOKENS_COUNT && (