diff --git a/.yarnrc.yml b/.yarnrc.yml index dad119471ed4..8a6d20d53c67 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -7,11 +7,6 @@ enableScripts: false nodeLinker: node-modules npmAuditIgnoreAdvisories: - ### Advisories: - # Issue: Regular Expression Denial of Service (ReDoS) in cross-spawn - # URL - https://github.com/advisories/GHSA-3xgq-45jj-v275 - # The affected versions <6.0.6, is only present in wdio which is a dev dependency - - 1104663 yarnPath: .yarn/releases/yarn-4.10.3.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index c2138c62d19a..985f34f0f8c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.57.1] + +### Fixed + +- fix: show edit account bottomsheet on android when its behind the keyboard ([#21477](https://github.com/MetaMask/metamask-mobile/pull/21477)) +- fix: Patch touchable issue in React Native ([#21568](https://github.com/MetaMask/metamask-mobile/pull/21568)) + ## [7.57.0] ### Added @@ -403,35 +410,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [7.56.5] ### Fixed -- fix: use SharedDeeplinkManager to parse instead of Linking API ([#20960](https://github.com/MetaMask/metamask-mobile/pull/20960)) +* fix: use SharedDeeplinkManager to parse instead of Linking API ([#20960](https://github.com/MetaMask/metamask-mobile/pull/20960)) ## [7.56.4] ### Fixed -- fix: address feature flag config issue +* fix: address feature flag config issue ## [7.56.3] ### Fixed -- fix: remove unintended metrics from transaction finalised event ([#20733](https://github.com/MetaMask/metamask-mobile/pull/20733)) -- fix: force rendering on token list when order changes ([#20771](https://github.com/MetaMask/metamask-mobile/pull/20771)) -- fix: add contentful max version number segmentation ([#20769](https://github.com/MetaMask/metamask-mobile/pull/20769)) +* fix: remove unintended metrics from transaction finalised event ([#20733](https://github.com/MetaMask/metamask-mobile/pull/20733)) +* fix: force rendering on token list when order changes ([#20771](https://github.com/MetaMask/metamask-mobile/pull/20771)) +* fix: add contentful max version number segmentation ([#20769](https://github.com/MetaMask/metamask-mobile/pull/20769)) ## [7.56.2] ### Fixed -- fix: address feature flag config issue +* fix: address feature flag config issue ## [7.56.1] ### Fixed -- fix: in recipient validations for internal accounts ([#20694](https://github.com/MetaMask/metamask-mobile/pull/20694)) -- feat: iOS Rehydration Flow Update to release/7.56.1 ([#20681](https://github.com/MetaMask/metamask-mobile/pull/20681)) -- feat: social login success screen added for social login users and ios platform. ([#20679](https://github.com/MetaMask/metamask-mobile/pull/20679)) -- fix: Returned Scrollview to Perps and Defi tab cp-7.56.1 ([#20650](https://github.com/MetaMask/metamask-mobile/pull/20650)) -- fix: missing transactions in activity after perps deposit (\#20507) ([09ef7e5](https://github.com/MetaMask/metamask-mobile/commit/09ef7e5f5111d0d3592b5e6d60499f31dc22f013)) -- fix: cp-7.56.1 Temp Revert page-level scroll for Wallet (#20579) ([#20616](https://github.com/MetaMask/metamask-mobile/pull/20616)) -- fix: Temp Revert page-level scroll for Wallet (\#20579) ([9022244](https://github.com/MetaMask/metamask-mobile/commit/902224410fbdf37250b990c75986eb7a948fb5ec)) +* fix: in recipient validations for internal accounts ([#20694](https://github.com/MetaMask/metamask-mobile/pull/20694)) +* feat: iOS Rehydration Flow Update to release/7.56.1 ([#20681](https://github.com/MetaMask/metamask-mobile/pull/20681)) +* feat: social login success screen added for social login users and ios platform. ([#20679](https://github.com/MetaMask/metamask-mobile/pull/20679)) +* fix: Returned Scrollview to Perps and Defi tab cp-7.56.1 ([#20650](https://github.com/MetaMask/metamask-mobile/pull/20650)) +* fix: missing transactions in activity after perps deposit (\#20507) ([09ef7e5](https://github.com/MetaMask/metamask-mobile/commit/09ef7e5f5111d0d3592b5e6d60499f31dc22f013)) +* fix: cp-7.56.1 Temp Revert page-level scroll for Wallet (#20579) ([#20616](https://github.com/MetaMask/metamask-mobile/pull/20616)) +* fix: Temp Revert page-level scroll for Wallet (\#20579) ([9022244](https://github.com/MetaMask/metamask-mobile/commit/902224410fbdf37250b990c75986eb7a948fb5ec)) ## [7.56.0] @@ -2498,7 +2505,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add InlineAlert component ([#13709](https://github.com/MetaMask/metamask-mobile/pull/13709)) - feat: add MultipleAlertModal component ([#13683](https://github.com/MetaMask/metamask-mobile/pull/13683)) - feat: Add Snaps UI `Selector` component ([#13747](https://github.com/MetaMask/metamask-mobile/pull/13747)) -- feat: added mocks to sonar.coverage.exclusions ([#13787](https://github.com/MetaMask/metamask-mobile/pull/13787)) +- feat: added **/**mocks**/** to sonar.coverage.exclusions ([#13787](https://github.com/MetaMask/metamask-mobile/pull/13787)) - feat: add `GeneralAlertBanner` component ([#13627](https://github.com/MetaMask/metamask-mobile/pull/13627)) ### Fixed @@ -7632,7 +7639,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#957](https://github.com/MetaMask/metamask-mobile/pull/957): fix timeouts (#957) - [#954](https://github.com/MetaMask/metamask-mobile/pull/954): Bugfix: onboarding navigation (#954) -[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.57.0...HEAD +[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.57.1...HEAD +[7.57.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.57.0...v7.57.1 [7.57.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.56.5...v7.57.0 [7.56.5]: https://github.com/MetaMask/metamask-mobile/compare/v7.56.4...v7.56.5 [7.56.4]: https://github.com/MetaMask/metamask-mobile/compare/v7.56.3...v7.56.4 diff --git a/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.test.tsx b/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.test.tsx new file mode 100644 index 000000000000..20affdd5ca88 --- /dev/null +++ b/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.test.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { Text, View } from 'react-native'; +import ConditionalScrollView from './ConditionalScrollView'; + +describe('ConditionalScrollView', () => { + const testContent = ( + + Test Content + + ); + + describe('when isScrollEnabled is true', () => { + it('wraps children in ScrollView and renders content', () => { + const { getByTestId, getByText } = render( + + {testContent} + , + ); + + expect(getByTestId('scroll-container')).toBeDefined(); + expect(getByText('Test Content')).toBeDefined(); + }); + + it('passes scrollViewProps to ScrollView', () => { + const testID = 'test-scroll-view'; + const { getByTestId } = render( + + {testContent} + , + ); + + const scrollView = getByTestId(testID); + expect(scrollView.props.showsVerticalScrollIndicator).toBe(false); + expect(scrollView.props.bounces).toBe(false); + }); + }); + + describe('when isScrollEnabled is false', () => { + it('renders children without ScrollView wrapper', () => { + const { getByText, queryByTestId } = render( + + {testContent} + , + ); + + expect(queryByTestId('should-not-exist')).toBeNull(); + expect(getByText('Test Content')).toBeDefined(); + }); + }); + + describe('dynamic behavior', () => { + it('switches between ScrollView and direct rendering when isScrollEnabled changes', () => { + const result = render( + + {testContent} + , + ); + + expect(result.getByTestId('scroll-view')).toBeDefined(); + + result.rerender( + + {testContent} + , + ); + + expect(result.queryByTestId('scroll-view')).toBeNull(); + + result.rerender( + + {testContent} + , + ); + + expect(result.getByTestId('scroll-view')).toBeDefined(); + }); + }); +}); diff --git a/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.tsx b/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.tsx new file mode 100644 index 000000000000..c6ab4878ca17 --- /dev/null +++ b/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { ScrollView } from 'react-native'; +import { ConditionalScrollViewProps } from './ConditionalScrollView.types'; + +/** + * ConditionalScrollView renders either a ScrollView or content directly based on isScrollEnabled prop. + * This is useful for homepage redesign where we want to remove nested scroll views in favor of a global scroll container. + */ +const ConditionalScrollView: React.FC = ({ + children, + isScrollEnabled, + scrollViewProps, +}) => + isScrollEnabled ? ( + {children} + ) : ( + <>{children} + ); + +export default ConditionalScrollView; diff --git a/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.types.ts b/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.types.ts new file mode 100644 index 000000000000..07f452c2c6c8 --- /dev/null +++ b/app/component-library/components-temp/ConditionalScrollView/ConditionalScrollView.types.ts @@ -0,0 +1,16 @@ +import { ScrollViewProps } from 'react-native'; + +export interface ConditionalScrollViewProps { + /** + * Content to render inside the conditional scroll view + */ + children: React.ReactNode; + /** + * If true, wraps children in ScrollView. If false, renders children directly. + */ + isScrollEnabled: boolean; + /** + * Optional props to pass to ScrollView when isScrollEnabled is true + */ + scrollViewProps?: ScrollViewProps; +} diff --git a/app/component-library/components-temp/ConditionalScrollView/index.ts b/app/component-library/components-temp/ConditionalScrollView/index.ts new file mode 100644 index 000000000000..81b7bba21a00 --- /dev/null +++ b/app/component-library/components-temp/ConditionalScrollView/index.ts @@ -0,0 +1 @@ +export { default } from './ConditionalScrollView'; diff --git a/app/component-library/components-temp/Tabs/TabsList/TabsList.test.tsx b/app/component-library/components-temp/Tabs/TabsList/TabsList.test.tsx index 3c261f7d4382..35efd2dba349 100644 --- a/app/component-library/components-temp/Tabs/TabsList/TabsList.test.tsx +++ b/app/component-library/components-temp/Tabs/TabsList/TabsList.test.tsx @@ -1,7 +1,7 @@ // Third party dependencies. import React from 'react'; import { render, fireEvent, act, waitFor } from '@testing-library/react-native'; -import { View } from 'react-native'; +import { View, InteractionManager } from 'react-native'; // External dependencies. import { Text } from '@metamask/design-system-react-native'; @@ -10,9 +10,25 @@ import { Text } from '@metamask/design-system-react-native'; import TabsList from './TabsList'; import { TabViewProps, TabsListRef } from './TabsList.types'; +// Mock InteractionManager +jest.mock('react-native/Libraries/Interaction/InteractionManager', () => ({ + runAfterInteractions: jest.fn((callback) => { + // Execute callback immediately for tests + callback(); + return { cancel: jest.fn() }; + }), +})); + describe('TabsList', () => { beforeEach(() => { jest.clearAllMocks(); + // Reset to default behavior that executes callbacks immediately + (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation( + (callback) => { + callback(); + return { cancel: jest.fn() }; + }, + ); }); it('renders correctly with multiple tabs', () => { @@ -55,16 +71,18 @@ describe('TabsList', () => { , ); - // Assert - Active tab loads immediately + // Assert - Active tab loads via InteractionManager expect(getByText('Tokens Content')).toBeOnTheScreen(); // Other tabs should not be loaded yet (on-demand loading) expect(queryByText('NFTs Content')).toBeNull(); // When user clicks the NFTs tab, it should load - fireEvent.press(getAllByText('NFTs')[0]); + await act(async () => { + fireEvent.press(getAllByText('NFTs')[0]); + }); - // Wait for the delayed loading to complete + // Wait for the deferred loading to complete await waitFor(() => { expect(getByText('NFTs Content')).toBeOnTheScreen(); }); @@ -78,7 +96,7 @@ describe('TabsList', () => { ]; // Act - const { getByText, queryByText, getAllByText } = render( + const { getByText, getAllByText } = render( {tabs.map((tab, index) => ( { // Switch to second tab fireEvent.press(getAllByText('NFTs')[0]); - // Assert - NFTs content should be on screen, Tokens content exists but not visible + // Assert - NFTs content should be on screen expect(getByText('NFTs Content')).toBeOnTheScreen(); - expect(queryByText('Tokens Content')).toBeTruthy(); // Content exists in DOM but not visible }); it('calls onChangeTab callback when tab changes', async () => { @@ -301,14 +318,15 @@ describe('TabsList', () => { it('handles all tabs disabled by setting activeIndex to -1', () => { // Arrange + const ref = React.createRef(); const tabs = [ { label: 'Tab 1', content: 'Tab 1 Content' }, { label: 'Tab 2', content: 'Tab 2 Content' }, ]; // Act - const { queryByText } = render( - + render( + { , ); - // Assert - No content should be displayed when all tabs are disabled - expect(queryByText('Tab 1 Content')).toBeNull(); - expect(queryByText('Tab 2 Content')).toBeNull(); + // Assert - activeIndex set to -1 when all tabs are disabled + expect(ref.current?.getCurrentIndex()).toBe(-1); }); it('switches to first enabled tab when initialActiveIndex points to disabled tab', () => { // Arrange + const ref = React.createRef(); const tabs = [ { label: 'Disabled Tab', content: 'Disabled Content' }, { label: 'Active Tab', content: 'Active Content' }, @@ -338,8 +356,8 @@ describe('TabsList', () => { ]; // Act - const { getByText, queryByText } = render( - + const { getByText } = render( + { , ); - // Assert - Should display the first enabled tab (index 1) instead of the disabled tab (index 0) + // Assert - Should switch to first enabled tab (index 1) when initial tab is disabled + expect(ref.current?.getCurrentIndex()).toBe(1); expect(getByText('Active Content')).toBeOnTheScreen(); - expect(queryByText('Disabled Content')).toBeNull(); - expect(queryByText('Another Content')).toBeNull(); }); it('preserves active tab selection when tabs array changes dynamically', () => { @@ -387,7 +404,6 @@ describe('TabsList', () => { // Assert - Perps content should be visible after clicking expect(getByText('Perps Content')).toBeOnTheScreen(); - expect(queryByText('Tokens Content')).toBeTruthy(); // Content exists in DOM but not visible // Create tabs without Perps (simulating when isPerpsEnabled becomes false) const tabsWithoutPerps = [ @@ -438,195 +454,152 @@ describe('TabsList', () => { // even when the tab was temporarily removed and re-added }); - it('preserves tab selection by key when tab order changes', () => { - // Arrange - Create tabs in original order - const originalOrder = [ - { key: 'tokens-tab', label: 'Tokens', content: 'Tokens Content' }, - { key: 'perps-tab', label: 'Perps', content: 'Perps Content' }, - { key: 'nfts-tab', label: 'NFTs', content: 'NFTs Content' }, - ]; - - // Create tabs in different order (simulating dynamic reordering) - const reorderedTabs = [ - { key: 'tokens-tab', label: 'Tokens', content: 'Tokens Content' }, - { key: 'nfts-tab', label: 'NFTs', content: 'NFTs Content' }, - { key: 'perps-tab', label: 'Perps', content: 'Perps Content' }, - ]; + describe('Deferred Content Loading', () => { + it('loads active tab content via InteractionManager', () => { + // Arrange + const mockRunAfterInteractions = jest.fn((callback) => { + callback(); + return { cancel: jest.fn() }; + }); + (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation( + mockRunAfterInteractions, + ); - const { rerender, getByText, getAllByText, queryByText } = render( - - {originalOrder.map((tab) => ( - - {tab.content} + // Act + const { getByText } = render( + + + Content 1 - ))} - , - ); - - // Act - Switch to Perps tab (originally at index 1) - fireEvent.press(getAllByText('Perps')[0]); - - // Assert - Perps content should be visible - expect(getByText('Perps Content')).toBeOnTheScreen(); - - // Act - Reorder tabs (Perps now at index 2) - rerender( - - {reorderedTabs.map((tab) => ( - - {tab.content} + + Content 2 - ))} - , - ); + , + ); - // Assert - The reordering shows NFTs Content, which means the activeIndex (1) - // now points to NFTs instead of Perps. This is expected behavior when tabs are reordered - // Note: Previously loaded tabs may not persist through reordering - this is acceptable - expect(getByText('NFTs Content')).toBeOnTheScreen(); - expect(queryByText('Tokens Content')).toBeTruthy(); // Content exists in DOM but not visible - // Perps content may not be loaded after reordering since it's no longer active - // expect(queryByText('Perps Content')).toBeTruthy(); // Content exists in DOM but not visible - }); + // Assert - InteractionManager used for initial tab load + expect(mockRunAfterInteractions).toHaveBeenCalled(); + expect(getByText('Content 1')).toBeOnTheScreen(); + }); - describe('Swipe Gesture Navigation', () => { - it('renders with GestureDetector wrapper', () => { + it('defers loading of inactive tabs until switched to', () => { // Arrange & Act - const { getByTestId } = render( - - - Tab 1 Content + const { queryByText } = render( + + + Content 1 - - Tab 2 Content + + Content 2 , ); - // Assert - Component should render with gesture support - const tabsList = getByTestId('tabs-list'); - expect(tabsList).toBeOnTheScreen(); + // Assert - Inactive tab content not loaded + expect(queryByText('Content 2')).toBeNull(); }); - it('navigates to next tab programmatically via ref', () => { + it('cancels pending content load when switching tabs quickly', async () => { // Arrange - const mockOnChangeTab = jest.fn(); - const tabsRef = React.createRef(); - const { getByText } = render( - - - Tab 1 Content + const mockCancel = jest.fn(); + let capturedCallback: (() => void) | null = null; + (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation( + (callback: () => void) => { + capturedCallback = callback; + return { cancel: mockCancel }; + }, + ); + + const { getAllByText } = render( + + + Content 1 - - Tab 2 Content + + Content 2 - - Tab 3 Content + + Content 3 , ); - // Assert initial state - expect(getByText('Tab 1 Content')).toBeOnTheScreen(); - - // Act - Navigate to next tab programmatically - act(() => { - tabsRef.current?.goToTabIndex(1); + // Act - Switch tabs quickly before interaction completes + await act(async () => { + fireEvent.press(getAllByText('Tab 2')[0]); + fireEvent.press(getAllByText('Tab 3')[0]); + if (capturedCallback) { + capturedCallback(); + } }); - // Assert - Should navigate to Tab 2 - expect(mockOnChangeTab).toHaveBeenCalledWith({ - i: 1, - ref: expect.anything(), - }); + // Assert - Previous interaction was cancelled + expect(mockCancel).toHaveBeenCalled(); }); - it('skips disabled tabs when navigating programmatically', () => { + it('loads already-loaded tabs immediately without InteractionManager delay', async () => { // Arrange - const mockOnChangeTab = jest.fn(); - const tabsRef = React.createRef(); - const { getByText } = render( - - - Tab 1 Content - - - Tab 2 Content + const mockRunAfterInteractions = jest.fn((callback) => { + callback(); + return { cancel: jest.fn() }; + }); + (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation( + mockRunAfterInteractions, + ); + + const { getAllByText, getByText } = render( + + + Content 1 - - Tab 3 Content + + Content 2 , ); - // Assert initial state - expect(getByText('Tab 1 Content')).toBeOnTheScreen(); - - // Act - Try to navigate to disabled tab (should be ignored) - act(() => { - tabsRef.current?.goToTabIndex(1); + // Load Tab 2 for the first time + await act(async () => { + fireEvent.press(getAllByText('Tab 2')[0]); }); - // Assert - Should not navigate to disabled tab - expect(mockOnChangeTab).not.toHaveBeenCalled(); + const callCountAfterFirstSwitch = + mockRunAfterInteractions.mock.calls.length; - // Act - Navigate to enabled tab - act(() => { - tabsRef.current?.goToTabIndex(2); + // Act - Switch back to Tab 1 (already loaded) + await act(async () => { + fireEvent.press(getAllByText('Tab 1')[0]); }); - // Assert - Should navigate to Tab 3 - expect(mockOnChangeTab).toHaveBeenCalledWith({ - i: 2, - ref: expect.anything(), - }); + // Assert - Already loaded tab displays immediately without new InteractionManager call + expect(getByText('Content 1')).toBeOnTheScreen(); + expect(mockRunAfterInteractions).toHaveBeenCalledTimes( + callCountAfterFirstSwitch, + ); }); + }); - it('handles swipe gesture integration with disabled tabs', () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const { getByTestId, getByText } = render( - + describe('Gesture Detection', () => { + it('renders with GestureDetector wrapper', () => { + // Arrange & Act + const { getByTestId } = render( + Tab 1 Content - + Tab 2 Content - - Tab 3 Content - , ); - // Assert - Component should render with gesture support and handle disabled tabs + // Assert - Component should render with gesture support const tabsList = getByTestId('tabs-list'); expect(tabsList).toBeOnTheScreen(); - - // Initial content should be visible - expect(getByText('Tab 1 Content')).toBeOnTheScreen(); }); it('maintains performance by only rendering active tab content', () => { - // Arrange + // Arrange & Act const { getByText, queryByText } = render( @@ -646,20 +619,12 @@ describe('TabsList', () => { expect(getByText('Tab 2 Content')).toBeOnTheScreen(); expect(queryByText('Tab 3 Content')).toBeNull(); }); - }); - describe('Enhanced Edge Cases', () => { - it('handles rapid tab switching during initialization', () => { + it('allows navigation through multiple tabs using ref', async () => { // Arrange - const mockOnChangeTab = jest.fn(); - const tabsRef = React.createRef(); - - render( - + const ref = React.createRef(); + const { getByText } = render( + Tab 1 Content @@ -672,1127 +637,74 @@ describe('TabsList', () => { , ); - // Act - Rapid tab switching - act(() => { - tabsRef.current?.goToTabIndex(1); // 0 -> 1: should trigger onChangeTab - }); - - act(() => { - tabsRef.current?.goToTabIndex(2); // 1 -> 2: should trigger onChangeTab - }); + expect(getByText('Tab 2 Content')).toBeOnTheScreen(); - act(() => { - tabsRef.current?.goToTabIndex(0); // 2 -> 0: should trigger onChangeTab + // Act - Navigate backward to Tab 1 + await act(async () => { + ref.current?.goToTabIndex(0); }); - // Assert - Should handle rapid switching gracefully - expect(mockOnChangeTab).toHaveBeenCalledTimes(3); - expect(tabsRef.current?.getCurrentIndex()).toBe(0); - }); - - it('handles tab array changes during active session', () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const { rerender, getByText, queryByText } = render( - - - Tab 1 Content - - - Tab 2 Content - - , - ); - - // Assert initial state - expect(getByText('Tab 1 Content')).toBeOnTheScreen(); - - // Act - Add more tabs - rerender( - - - Tab 1 Content - - - Tab 2 Content - - - Tab 3 Content - - , - ); - - // Assert - Should maintain active tab + // Assert expect(getByText('Tab 1 Content')).toBeOnTheScreen(); - expect(queryByText('Tab 2 Content')).toBeNull(); - expect(queryByText('Tab 3 Content')).toBeNull(); - }); - - it('handles mixed enabled/disabled tab scenarios', () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const tabsRef = React.createRef(); - - render( - - - Tab 1 Content - - - Tab 2 Content - - - Tab 3 Content - - - Tab 4 Content - - , - ); + expect(ref.current?.getCurrentIndex()).toBe(0); - // Act - Try to navigate to disabled tabs - act(() => { - tabsRef.current?.goToTabIndex(1); // Disabled - tabsRef.current?.goToTabIndex(2); // Disabled - }); - - // Assert - Should not navigate to disabled tabs - expect(mockOnChangeTab).not.toHaveBeenCalled(); - - // Act - Navigate to enabled tab - act(() => { - tabsRef.current?.goToTabIndex(3); // Enabled + // Act - Navigate forward to Tab 3 + await act(async () => { + ref.current?.goToTabIndex(2); }); - // Assert - Should navigate to enabled tab - expect(mockOnChangeTab).toHaveBeenCalledWith({ - i: 3, - ref: expect.anything(), - }); + // Assert + expect(getByText('Tab 3 Content')).toBeOnTheScreen(); + expect(ref.current?.getCurrentIndex()).toBe(2); }); }); - describe('Single Tab Support', () => { - it('handles single tab correctly', () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const { getByText } = render( - - - Only Tab Content - - , - ); - - // Assert - Single tab should be rendered and active - expect(getByText('Only Tab Content')).toBeOnTheScreen(); - }); - - it('handles single tab with swipe gestures disabled', () => { + describe('Edge Cases', () => { + it('handles non-React element children with default values', () => { // Arrange - const mockOnChangeTab = jest.fn(); - const tabsRef = React.createRef(); + const nonReactElementChild = 'Plain text'; - const { getByText } = render( - - - Only Tab Content + // Act + const { toJSON } = render( + + + Tab 1 Content + {nonReactElementChild as unknown as React.ReactElement} , ); - // Assert - Single tab should be rendered - expect(getByText('Only Tab Content')).toBeOnTheScreen(); - - // Act - Try programmatic navigation (should not do anything) - act(() => { - tabsRef.current?.goToTabIndex(1); // Invalid index - }); - - // Assert - Should remain on the single tab, no callback triggered - expect(mockOnChangeTab).not.toHaveBeenCalled(); - expect(tabsRef.current?.getCurrentIndex()).toBe(0); + // Assert - Component handles non-React elements gracefully + expect(toJSON()).toMatchSnapshot(); }); - it('handles single disabled tab', () => { + it('uses initialActiveIndex when it points to an enabled tab', () => { // Arrange - const mockOnChangeTab = jest.fn(); + const ref = React.createRef(); + const tabs = [ + { label: 'Tab 1', content: 'Tab 1 Content' }, + { label: 'Tab 2', content: 'Tab 2 Content' }, + { label: 'Tab 3', content: 'Tab 3 Content' }, + ]; + + // Act - initialActiveIndex points to Tab 3 (index 2) which is enabled const { getByText } = render( - - - Disabled Tab Content + + + {tabs[0].content} - , - ); - - // Assert - Single disabled tab should be rendered but not active - // Content should not be rendered when activeIndex is -1 - expect(() => getByText('Disabled Tab Content')).toThrow(); - }); - - it('handles single tab with ref methods', () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const tabsRef = React.createRef(); - - render( - - - Only Tab Content + + {tabs[1].content} - , - ); - - // Assert - Ref methods should work correctly - expect(tabsRef.current?.getCurrentIndex()).toBe(0); - - // Act - Try to navigate to same tab (should not trigger callback) - act(() => { - tabsRef.current?.goToTabIndex(0); - }); - - // Assert - Should not trigger callback for same index - expect(mockOnChangeTab).not.toHaveBeenCalled(); - }); - - it('handles single tab layout and animation', () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const { getByTestId, getByText } = render( - - - Only Tab Content + + {tabs[2].content} , ); - // Assert - Component should render correctly - const tabsList = getByTestId('single-tab-list'); - expect(tabsList).toBeOnTheScreen(); - expect(getByText('Only Tab Content')).toBeOnTheScreen(); - - // Single tab should not enable scrolling - // The underline animation should still work for the single tab - }); - - it('supports both single child and array of children (TypeScript compatibility)', () => { - // Arrange & Act - This test verifies TypeScript accepts both patterns - const SingleChildComponent = () => ( - - - Single Content - - - ); - - const MultipleChildrenComponent = () => ( - - - Tab 1 Content - - - Tab 2 Content - - - ); - - // Assert - Both should render without TypeScript errors - const { getByText: getSingleText, unmount: unmountSingle } = render( - , - ); - expect(getSingleText('Single Content')).toBeOnTheScreen(); - - unmountSingle(); - - const { getByText: getMultipleText } = render( - , - ); - expect(getMultipleText('Tab 1 Content')).toBeOnTheScreen(); - }); - }); - - describe('Swipe Navigation Coverage', () => { - it('covers early return when tabs.length <= 1', () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const tabsRef = React.createRef(); - - render( - - - Only Tab Content - - , - ); - - // Act - Try to trigger swipe navigation with single tab - // This should hit the early return: if (tabs.length <= 1) return; - act(() => { - // Simulate internal call to navigateToTab - this would normally be called by gesture - // but we can't easily trigger the gesture in tests, so we test the logic directly - tabsRef.current?.goToTabIndex(0); // Same index, should not trigger callback - }); - - // Assert - No navigation should occur with single tab - expect(mockOnChangeTab).not.toHaveBeenCalled(); - }); - - it('covers navigation direction logic for next tab', () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const tabsRef = React.createRef(); - - render( - - - Tab 1 Content - - - Tab 2 Content - - - Tab 3 Content - - , - ); - - // Act - Navigate to next enabled tab (should skip disabled tab 3) - act(() => { - tabsRef.current?.goToTabIndex(1); // Go to tab 2 first - }); - - // Assert - Should navigate to tab 2 - expect(mockOnChangeTab).toHaveBeenCalledWith({ - i: 1, - ref: expect.anything(), - }); - }); - - it('covers navigation direction logic for previous tab', () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const tabsRef = React.createRef(); - - render( - - - Tab 1 Content - - - Tab 2 Content - - - Tab 3 Content - - , - ); - - // Act - Navigate to previous enabled tab (should skip disabled tab 1) - act(() => { - tabsRef.current?.goToTabIndex(1); // Should go to tab 2 (skipping disabled tab 1) - }); - - // Assert - Should navigate to tab 2 - expect(mockOnChangeTab).toHaveBeenCalledWith({ - i: 1, - ref: expect.anything(), - }); - }); - - it('covers targetIndex validation and bounds checking', () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const tabsRef = React.createRef(); - - render( - - - Tab 1 Content - - - Tab 2 Content - - , - ); - - // Act - Try to navigate to out-of-bounds indices - act(() => { - tabsRef.current?.goToTabIndex(-1); // Negative index - tabsRef.current?.goToTabIndex(99); // Index beyond array length - }); - - // Assert - No navigation should occur for invalid indices - expect(mockOnChangeTab).not.toHaveBeenCalled(); - }); - }); - - describe('Lazy Loading and Swipe Functionality', () => { - it('loads active tab immediately and others on-demand when accessed', async () => { - // Arrange - const tabs = [ - { label: 'Active', content: 'Active Content' }, - { label: 'Background', content: 'Background Content' }, - { label: 'Disabled', content: 'Disabled Content' }, - ]; - - // Act - const { getByText, queryByText, getAllByText } = render( - - - {tabs[0].content} - - - {tabs[1].content} - - - {tabs[2].content} - - , - ); - - // Assert - Active tab loads immediately - expect(getByText('Active Content')).toBeOnTheScreen(); - - // Other tabs should not be loaded yet (on-demand loading) - expect(queryByText('Background Content')).toBeNull(); - - // When user clicks the background tab, it should load - fireEvent.press(getAllByText('Background')[0]); - - // Wait for the delayed loading to complete - await waitFor(() => { - expect(getByText('Background Content')).toBeOnTheScreen(); - }); - // Disabled tab should not be loaded - expect(queryByText('Disabled Content')).toBeNull(); - }); - - it('handles horizontal scroll view for swipeable content', () => { - // Arrange - const tabs = [ - { label: 'Tab 1', content: 'Content 1' }, - { label: 'Tab 2', content: 'Content 2' }, - ]; - - // Act - const { getByText, getByTestId } = render( - - {tabs.map((tab, index) => ( - - {tab.content} - - ))} - , - ); - - // Assert - Component renders with ScrollView structure - const tabsList = getByTestId('swipeable-tabs'); - expect(tabsList).toBeOnTheScreen(); - expect(getByText('Content 1')).toBeOnTheScreen(); - }); - - it('handles scroll events to change active tab', async () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const tabs = [ - { label: 'Tab 1', content: 'Content 1' }, - { label: 'Tab 2', content: 'Content 2' }, - ]; - - // Act - const { getByTestId } = render( - - {tabs.map((tab, index) => ( - - {tab.content} - - ))} - , - ); - - const scrollView = getByTestId('scroll-tabs'); - - // Simulate scroll to second tab - await act(async () => { - fireEvent.scroll(scrollView, { - nativeEvent: { - contentOffset: { x: 400, y: 0 }, // Assuming 400px width per tab - }, - }); - }); - - // Assert - Should trigger tab change - // Note: Scroll event simulation in tests doesn't work the same as real scrolling - // This test would pass in actual app usage but fails in test environment - // expect(mockOnChangeTab).toHaveBeenCalledWith({ - // i: 1, - // ref: expect.anything(), - // }); - }); - - it('maintains individual tab heights without constraint', () => { - // Arrange - const tabs = [ - { label: 'Short', content: 'Short' }, - { - label: 'Tall', - content: - 'Very tall content that should not be constrained by other tabs', - }, - ]; - - // Act - const { getAllByText } = render( - - {tabs.map((tab, index) => ( - - {tab.content} - - ))} - , - ); - - // Assert - Each tab content should render with its natural height - expect(getAllByText('Short')[0]).toBeOnTheScreen(); // Use getAllByText to handle multiple matches - // The component should not enforce a fixed height constraint - }); - - it('skips disabled tabs during swipe navigation', async () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const tabs = [ - { label: 'Tab 1', content: 'Content 1' }, - { label: 'Tab 2', content: 'Content 2', disabled: true }, - { label: 'Tab 3', content: 'Content 3' }, - ]; - - // Act - const { getByTestId } = render( - - - {tabs[0].content} - - - {tabs[1].content} - - - {tabs[2].content} - - , - ); - - const scrollView = getByTestId('skip-disabled-tabs'); - - // Simulate scroll to third tab (skipping disabled second tab) - await act(async () => { - fireEvent.scroll(scrollView, { - nativeEvent: { - contentOffset: { x: 800, y: 0 }, // Scroll to third tab position - }, - }); - }); - - // Assert - Should navigate to third tab, skipping disabled second tab - // Note: Scroll event simulation in tests doesn't work the same as real scrolling - // expect(mockOnChangeTab).toHaveBeenCalledWith({ - // i: 2, - // ref: expect.anything(), - // }); - }); - - it('handles container width changes for responsive behavior', () => { - // Arrange - const tabs = [ - { label: 'Tab 1', content: 'Content 1' }, - { label: 'Tab 2', content: 'Content 2' }, - ]; - - // Act - const { getByTestId } = render( - - {tabs.map((tab, index) => ( - - {tab.content} - - ))} - , - ); - - const tabsList = getByTestId('responsive-tabs'); - - // Simulate layout change - act(() => { - fireEvent(tabsList, 'layout', { - nativeEvent: { - layout: { width: 500, height: 300 }, - }, - }); - }); - - // Assert - Component should handle layout changes gracefully - expect(tabsList).toBeOnTheScreen(); - }); - - it('loads tabs on demand when accessed via swipe', async () => { - // Arrange - const mockOnChangeTab = jest.fn(); - const tabs = [ - { label: 'Tab 1', content: 'Content 1' }, - { label: 'Tab 2', content: 'Content 2' }, - ]; - - // Act - const { getByText, getByTestId } = render( - - {tabs.map((tab, index) => ( - - {tab.content} - - ))} - , - ); - - // Assert initial state - expect(getByText('Content 1')).toBeOnTheScreen(); - - const scrollView = getByTestId('on-demand-tabs'); - - // Simulate swipe to second tab - await act(async () => { - fireEvent.scroll(scrollView, { - nativeEvent: { - contentOffset: { x: 400, y: 0 }, - }, - }); - }); - - // Assert - Second tab should be loaded and callback triggered - // Note: Scroll event simulation in tests doesn't work the same as real scrolling - // expect(mockOnChangeTab).toHaveBeenCalledWith({ - // i: 1, - // ref: expect.anything(), - // }); - }); - }); - - describe('Children Processing Coverage', () => { - it('covers children array processing and validation', () => { - // Arrange - Multiple React elements for array processing - const mockOnChangeTab = jest.fn(); - - const { getByText } = render( - - - Tab 1 Content - - - Tab 2 Content - - , - ); - - // Assert - Should handle children array processing gracefully - expect(getByText('Tab 1 Content')).toBeOnTheScreen(); - }); - - it('covers horizontal vs vertical gesture detection', () => { - // Arrange - Test that vertical gestures don't interfere with scrolling - const mockOnChangeTab = jest.fn(); - - render( - - - Tab 1 Content - - - Tab 2 Content - - , - ); - - // Assert - This test verifies the gesture configuration exists - // The actual gesture behavior is tested through the pan gesture setup - // which now includes activeOffsetX and failOffsetY for proper gesture handling - expect(mockOnChangeTab).not.toHaveBeenCalled(); - }); - - it('covers missing tabLabel prop handling', () => { - // Arrange - Children without tabLabel prop - const { getByText } = render( - - - Content 1 - - - Content 2 - - , - ); - - // Assert - Should generate default labels and render first tab - expect(getByText('Content 1')).toBeOnTheScreen(); - }); - - it('covers missing key prop handling', () => { - // Arrange - Children without key prop - const { getByText } = render( - - - No Key Content - - , - ); - - // Assert - Should generate default key and render - expect(getByText('No Key Content')).toBeOnTheScreen(); - }); - }); - - describe('Tab State Management Edge Cases', () => { - it('handles tab key preservation when tabs change', () => { - const mockOnChangeTab = jest.fn(); - const { rerender } = render( - - - Content 1 - - - Content 2 - - - Content 3 - - , - ); - - // Change tabs but keep the same key for active tab - rerender( - - - New Content 1 - - - Content 2 - - - Content 4 - - , - ); - - // Should handle tab structure changes gracefully - no onChangeTab call expected during prop changes - expect(mockOnChangeTab).not.toHaveBeenCalled(); - }); - - it('handles fallback when current tab becomes disabled', () => { - const mockOnChangeTab = jest.fn(); - const { rerender } = render( - - - Content 1 - - - Content 2 - - - Content 3 - - , - ); - - // Disable the currently active tab - rerender( - - - Content 1 - - - Content 2 - - - Content 3 - - , - ); - - // Should handle disabled tab gracefully - no onChangeTab call expected during prop changes - expect(mockOnChangeTab).not.toHaveBeenCalled(); - }); - - it('handles fallback to initialActiveIndex when current becomes invalid', () => { - const mockOnChangeTab = jest.fn(); - const { rerender } = render( - - - Content 1 - - - Content 2 - - - Content 3 - - , - ); - - // Remove tabs to make current activeIndex invalid - rerender( - - - Content 2 - - , - ); - - // Should handle tab removal gracefully - no onChangeTab call expected during prop changes - expect(mockOnChangeTab).not.toHaveBeenCalled(); - }); - - it('finds first enabled tab when initialActiveIndex is disabled', () => { - const mockOnChangeTab = jest.fn(); - render( - - - Content 1 - - - Content 2 - - - Content 3 - - , - ); - - // Component should handle disabled initial tab - onChangeTab not called during initialization - expect(mockOnChangeTab).not.toHaveBeenCalled(); - }); - - it('handles case when no enabled tabs exist', () => { - const mockOnChangeTab = jest.fn(); - render( - - - Content 1 - - - Content 2 - - - Content 3 - - , - ); - - // Should handle all disabled tabs gracefully - no onChangeTab call during initialization - expect(mockOnChangeTab).not.toHaveBeenCalled(); - }); - }); - - describe('Scroll Event Handling', () => { - it('handles scroll events with zero container width', () => { - const mockOnChangeTab = jest.fn(); - const { getByTestId } = render( - - Content 1 - Content 2 - , - ); - - const scrollView = getByTestId('tabs-list-content'); - - // Simulate scroll event with zero container width - const scrollEvent = { - nativeEvent: { - contentOffset: { x: 100, y: 0 }, - contentSize: { width: 400, height: 300 }, - layoutMeasurement: { width: 0, height: 300 }, - }, - }; - - fireEvent.scroll(scrollView, scrollEvent); - - // Should handle zero container width gracefully - expect(mockOnChangeTab).not.toHaveBeenCalled(); - }); - - it('handles programmatic scroll flag correctly', () => { - const mockOnChangeTab = jest.fn(); - const ref = React.createRef(); - const { getByTestId } = render( - - Content 1 - Content 2 - , - ); - - const scrollView = getByTestId('tabs-list-content'); - - // Trigger programmatic scroll via ref - act(() => { - ref.current?.goToTabIndex(1); - }); - - // Simulate scroll event during programmatic scroll - const scrollEvent = { - nativeEvent: { - contentOffset: { x: 200, y: 0 }, - contentSize: { width: 400, height: 300 }, - layoutMeasurement: { width: 400, height: 300 }, - }, - }; - - fireEvent.scroll(scrollView, scrollEvent); - - // Should ignore scroll events during programmatic scroll - // The onChangeTab call should only be from the programmatic scroll, not the scroll event - expect(mockOnChangeTab).toHaveBeenCalledTimes(1); - }); - - it('handles scroll begin events correctly', () => { - const mockOnChangeTab = jest.fn(); - const { getByTestId } = render( - - Content 1 - Content 2 - , - ); - - const scrollView = getByTestId('tabs-list-content'); - - // Simulate scroll begin - fireEvent(scrollView, 'onScrollBeginDrag'); - - // Should handle scroll begin without errors - expect(scrollView).toBeOnTheScreen(); - }); - - it('handles scroll end events correctly', () => { - const mockOnChangeTab = jest.fn(); - const { getByTestId } = render( - - Content 1 - Content 2 - , - ); - - const scrollView = getByTestId('tabs-list-content'); - - // Simulate scroll end - fireEvent(scrollView, 'onScrollEndDrag'); - fireEvent(scrollView, 'onMomentumScrollEnd'); - - // Should handle scroll end without errors - expect(scrollView).toBeOnTheScreen(); - }); - - it('clears scroll timeout on new scroll begin', () => { - const mockOnChangeTab = jest.fn(); - const { getByTestId } = render( - - Content 1 - Content 2 - , - ); - - const scrollView = getByTestId('tabs-list-content'); - - // Start scroll, then immediately start another - fireEvent(scrollView, 'onScrollBeginDrag'); - fireEvent(scrollView, 'onScrollEndDrag'); - fireEvent(scrollView, 'onScrollBeginDrag'); // Should clear previous timeout - - // Should handle timeout clearing gracefully - expect(scrollView).toBeOnTheScreen(); - }); - }); - - describe('Layout Handling', () => { - it('handles layout changes correctly', () => { - const mockOnChangeTab = jest.fn(); - const { getByTestId } = render( - - Content 1 - Content 2 - , - ); - - const scrollView = getByTestId('tabs-list-content'); - - // Simulate layout change - const layoutEvent = { - nativeEvent: { - layout: { x: 0, y: 0, width: 400, height: 300 }, - }, - }; - - fireEvent(scrollView, 'onLayout', layoutEvent); - - // Should update container width - expect(scrollView).toBeOnTheScreen(); - }); - - it('handles multiple layout changes', () => { - const mockOnChangeTab = jest.fn(); - const { getByTestId } = render( - - Content 1 - Content 2 - , - ); - - const scrollView = getByTestId('tabs-list-content'); - - // Simulate multiple layout changes - const layoutEvent1 = { - nativeEvent: { - layout: { x: 0, y: 0, width: 300, height: 300 }, - }, - }; - - const layoutEvent2 = { - nativeEvent: { - layout: { x: 0, y: 0, width: 500, height: 300 }, - }, - }; - - fireEvent(scrollView, 'onLayout', layoutEvent1); - fireEvent(scrollView, 'onLayout', layoutEvent2); - - // Should handle multiple layout changes - expect(scrollView).toBeOnTheScreen(); - }); - }); - - describe('Ref Method Edge Cases', () => { - it('handles goToTabIndex with invalid indices', () => { - const ref = React.createRef(); - const mockOnChangeTab = jest.fn(); - render( - - Content 1 - Content 2 - , - ); - - // Try invalid indices - act(() => { - ref.current?.goToTabIndex(-1); - ref.current?.goToTabIndex(10); - }); - - // Should handle invalid indices gracefully - expect(mockOnChangeTab).not.toHaveBeenCalled(); - }); - - it('handles goToTabIndex with disabled tab', () => { - const ref = React.createRef(); - const mockOnChangeTab = jest.fn(); - render( - - Content 1 - - Content 2 - - , - ); - - // Try to go to disabled tab - act(() => { - ref.current?.goToTabIndex(1); - }); - - // Should handle disabled tab gracefully - expect(mockOnChangeTab).not.toHaveBeenCalled(); + // Assert - Should show the tab at initialActiveIndex + expect(getByText('Tab 3 Content')).toBeOnTheScreen(); + expect(ref.current?.getCurrentIndex()).toBe(2); }); }); }); diff --git a/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx b/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx index e85cae59aaca..9449c08e4e98 100644 --- a/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx +++ b/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx @@ -1,4 +1,3 @@ -// Third party dependencies. import React, { useState, useEffect, @@ -8,18 +7,12 @@ import React, { useMemo, useRef, } from 'react'; -import { - ScrollView, - Dimensions, - NativeScrollEvent, - NativeSyntheticEvent, -} from 'react-native'; -// External dependencies. -import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box } from '@metamask/design-system-react-native'; +import { GestureDetector, Gesture } from 'react-native-gesture-handler'; +import { runOnJS } from 'react-native-reanimated'; +import { InteractionManager } from 'react-native'; -// Internal dependencies. import TabsBar from '../TabsBar'; import { TabsListProps, TabsListRef, TabItem } from './TabsList.types'; @@ -36,21 +29,10 @@ const TabsList = forwardRef( }, ref, ) => { - const tw = useTailwind(); const [activeIndex, setActiveIndex] = useState(initialActiveIndex); - const [containerWidth, setContainerWidth] = useState( - Dimensions.get('window').width, - ); const [loadedTabs, setLoadedTabs] = useState>(new Set()); - const scrollViewRef = useRef(null); - const isScrolling = useRef(false); - const isProgrammaticScroll = useRef(false); - const scrollTimeout = useRef(null); - const loadTabTimeout = useRef(null); - const programmaticScrollTimeout = useRef(null); - const goToTabTimeout = useRef(null); + const interactionHandleRef = useRef<{ cancel: () => void } | null>(null); - // Extract tab items from children const tabs: TabItem[] = useMemo( () => React.Children.map(children, (child, index) => { @@ -80,100 +62,42 @@ const TabsList = forwardRef( [children], ); - // Create a separate array of only enabled tabs for ScrollView content - const enabledTabs = useMemo( - () => - tabs - .map((tab, index) => ({ ...tab, originalIndex: index })) - .filter((tab) => !tab.isDisabled), - [tabs], - ); - - // Create mapping functions between tab index and content index - const getContentIndexFromTabIndex = useCallback( - (tabIndex: number): number => { - if ( - tabIndex < 0 || - tabIndex >= tabs.length || - tabs[tabIndex]?.isDisabled - ) { - return -1; - } - return enabledTabs.findIndex( - (enabledTab) => enabledTab.originalIndex === tabIndex, - ); - }, - [tabs, enabledTabs], - ); - - const getTabIndexFromContentIndex = useCallback( - (contentIndex: number): number => { - if (contentIndex < 0 || contentIndex >= enabledTabs.length) { - return -1; + // Cache only the actively viewed tab (no preloading of adjacent tabs) + // Use InteractionManager to defer content loading until after animations complete + useEffect(() => { + if (activeIndex >= 0 && activeIndex < tabs.length) { + if (interactionHandleRef.current) { + interactionHandleRef.current.cancel(); } - return enabledTabs[contentIndex]?.originalIndex ?? -1; - }, - [enabledTabs], - ); - // Check if there are any enabled tabs and if current active tab is enabled - const hasAnyEnabledTabs = useMemo( - () => tabs.some((tab) => !tab.isDisabled), - [tabs], - ); + const isAlreadyLoaded = loadedTabs.has(activeIndex); - const shouldShowContent = useMemo(() => { - // Don't show any content if all tabs are disabled - if (!hasAnyEnabledTabs) return false; - // Don't show content if active tab is disabled - if (activeIndex < 0 || activeIndex >= tabs.length) return false; - return !tabs[activeIndex]?.isDisabled; - }, [hasAnyEnabledTabs, activeIndex, tabs]); + if (isAlreadyLoaded) { + return; + } - // Load tab content on-demand when tab becomes active for the first time - useEffect(() => { - if (activeIndex >= 0 && activeIndex < tabs.length) { - setLoadedTabs((prev) => { - // Only update if the tab isn't already loaded - if (!prev.has(activeIndex)) { - return new Set(prev).add(activeIndex); - } - return prev; + const handle = InteractionManager.runAfterInteractions(() => { + setLoadedTabs((prev) => { + const newLoadedTabs = new Set(prev); + newLoadedTabs.add(activeIndex); + return newLoadedTabs.size !== prev.size ? newLoadedTabs : prev; + }); }); + + interactionHandleRef.current = handle; } - }, [activeIndex, tabs.length]); - // Cleanup effect to clear all timers on unmount - useEffect( - () => () => { - if (scrollTimeout.current) { - clearTimeout(scrollTimeout.current); - scrollTimeout.current = null; + return () => { + if (interactionHandleRef.current) { + interactionHandleRef.current.cancel(); } - if (loadTabTimeout.current) { - clearTimeout(loadTabTimeout.current); - loadTabTimeout.current = null; - } - if (programmaticScrollTimeout.current) { - clearTimeout(programmaticScrollTimeout.current); - programmaticScrollTimeout.current = null; - } - if (goToTabTimeout.current) { - clearTimeout(goToTabTimeout.current); - goToTabTimeout.current = null; - } - }, - [], - ); + }; + }, [activeIndex, tabs.length, loadedTabs]); - // Update active index when initialActiveIndex or tabs change useEffect(() => { - // Store the current active tab key for preservation const currentActiveTabKey = tabs[activeIndex]?.key; - // First, try to preserve the current active tab by key when tabs array changes if (currentActiveTabKey && tabs.length > 0) { - // Try to find the current active tab by key in the new tabs array const newIndexForCurrentTab = tabs.findIndex( (tab) => tab.key === currentActiveTabKey, ); @@ -182,46 +106,28 @@ const TabsList = forwardRef( !tabs[newIndexForCurrentTab].isDisabled && newIndexForCurrentTab !== activeIndex ) { - // Preserve the current selection if the tab still exists and is enabled setActiveIndex(newIndexForCurrentTab); return; } } - // Fallback: When current tab is no longer available, try to keep current index if valid if ( activeIndex >= 0 && activeIndex < tabs.length && !tabs[activeIndex]?.isDisabled ) { - // Current activeIndex is still valid, keep it return; } - // If current activeIndex is invalid, fall back to initialActiveIndex or first enabled tab const targetTab = tabs[initialActiveIndex]; if (targetTab && !targetTab.isDisabled) { setActiveIndex(initialActiveIndex); } else { - // Find first enabled tab const firstEnabledIndex = tabs.findIndex((tab) => !tab.isDisabled); setActiveIndex(firstEnabledIndex >= 0 ? firstEnabledIndex : -1); } }, [initialActiveIndex, tabs, activeIndex]); - // Scroll to active tab when activeIndex changes - useEffect(() => { - if (scrollViewRef.current && containerWidth > 0) { - const contentIndex = getContentIndexFromTabIndex(activeIndex); - if (contentIndex >= 0) { - scrollViewRef.current.scrollTo({ - x: contentIndex * containerWidth, - animated: !isScrolling.current, // Don't animate if user is currently scrolling - }); - } - } - }, [activeIndex, containerWidth, getContentIndexFromTabIndex]); - const handleTabPress = useCallback( (tabIndex: number) => { if ( @@ -232,197 +138,78 @@ const TabsList = forwardRef( return; } - // Get the content index for this tab - const contentIndex = getContentIndexFromTabIndex(tabIndex); - if (contentIndex < 0) return; - - // Only update state and call callback if the tab actually changed const tabChanged = tabIndex !== activeIndex; - // Update activeIndex immediately for TabsBar animation setActiveIndex(tabIndex); - // Ensure the tab is loaded - if (!loadedTabs.has(tabIndex)) { - // Synchronous updates for tests - if (process.env.JEST_WORKER_ID) { - setLoadedTabs((prev) => new Set(prev).add(tabIndex)); - } else { - if (loadTabTimeout.current) { - clearTimeout(loadTabTimeout.current); - } - loadTabTimeout.current = setTimeout(() => { - setLoadedTabs((prev) => new Set(prev).add(tabIndex)); - loadTabTimeout.current = null; - }, 10); // Brief delay for smooth loading - } - } - - // Mark as programmatic scroll - isProgrammaticScroll.current = true; - - // Scroll to the content index, not the tab index - if (scrollViewRef.current && containerWidth > 0) { - scrollViewRef.current.scrollTo({ - x: contentIndex * containerWidth, - animated: true, - }); + if ( + (process.env.JEST_WORKER_ID || process.env.E2E) && + !loadedTabs.has(tabIndex) + ) { + setLoadedTabs((prev) => new Set(prev).add(tabIndex)); } - // Only call onChangeTab if the tab actually changed if (onChangeTab && tabChanged) { onChangeTab({ i: tabIndex, ref: tabs[tabIndex]?.content || null, }); } - - // Reset programmatic scroll flag - if (programmaticScrollTimeout.current) { - clearTimeout(programmaticScrollTimeout.current); - } - programmaticScrollTimeout.current = setTimeout(() => { - isProgrammaticScroll.current = false; - programmaticScrollTimeout.current = null; - }, 400); }, - [ - activeIndex, - tabs, - onChangeTab, - containerWidth, - getContentIndexFromTabIndex, - loadedTabs, - ], + [activeIndex, tabs, onChangeTab, loadedTabs], ); - const handleScroll = useCallback( - (scrollEvent: NativeSyntheticEvent) => { - if (isProgrammaticScroll.current) return; - - const { contentOffset } = scrollEvent.nativeEvent; - if (containerWidth <= 0) return; - - // Calculate which content index we're at - const contentIndex = Math.round(contentOffset.x / containerWidth); - - // Convert content index back to tab index - const newTabIndex = getTabIndexFromContentIndex(contentIndex); - - if (newTabIndex >= 0 && newTabIndex !== activeIndex) { - // Update activeIndex immediately to trigger TabsBar animation alongside content scroll - // This matches the behavior of tab clicks - setActiveIndex(newTabIndex); - setLoadedTabs((prev) => new Set(prev).add(newTabIndex)); - - if (onChangeTab) { - onChangeTab({ - i: newTabIndex, - ref: tabs[newTabIndex]?.content || null, - }); - } + const goToPreviousTab = useCallback(() => { + // Iterate backwards to find the next enabled tab + for (let i = activeIndex - 1; i >= 0; i--) { + if (!tabs[i]?.isDisabled) { + handleTabPress(i); + return; } - }, - [ - activeIndex, - containerWidth, - onChangeTab, - tabs, - getTabIndexFromContentIndex, - ], - ); - - const handleScrollBegin = useCallback(() => { - // Clear any existing timeout - if (scrollTimeout.current) { - clearTimeout(scrollTimeout.current); } + }, [activeIndex, tabs, handleTabPress]); - // Only mark as user scroll if it's not programmatic - if (!isProgrammaticScroll.current) { - isScrolling.current = true; + const goToNextTab = useCallback(() => { + // Iterate forwards to find the next enabled tab + for (let i = activeIndex + 1; i < tabs.length; i++) { + if (!tabs[i]?.isDisabled) { + handleTabPress(i); + return; + } } - }, []); - - const handleScrollEnd = useCallback(() => { - // Reset scrolling flag - scrollTimeout.current = setTimeout(() => { - isScrolling.current = false; - }, 150); - }, []); + }, [activeIndex, tabs, handleTabPress]); - const handleLayout = useCallback( - (layoutEvent: { nativeEvent: { layout: { width: number } } }) => { - const { width } = layoutEvent.nativeEvent.layout; - setContainerWidth(width); - }, - [], + const swipeGesture = useMemo( + () => + Gesture.Pan() + .activeOffsetX([-50, 50]) + .failOffsetY([-15, 15]) + .maxPointers(1) + .onEnd((gestureEvent) => { + 'worklet'; + const { translationX, velocityX } = gestureEvent; + + // Match ScrollView paging behavior with lower thresholds for natural feel + if (Math.abs(translationX) > 50 || Math.abs(velocityX) > 500) { + if (translationX > 0) { + runOnJS(goToPreviousTab)(); + } else if (translationX < 0) { + runOnJS(goToNextTab)(); + } + } + }), + [goToPreviousTab, goToNextTab], ); - // Expose methods via ref useImperativeHandle( ref, () => ({ goToTabIndex: (tabIndex: number) => { - if ( - tabIndex < 0 || - tabIndex >= tabs.length || - tabs[tabIndex]?.isDisabled - ) { - return; - } - - const contentIndex = getContentIndexFromTabIndex(tabIndex); - if (contentIndex < 0) return; - - // Only update state and call callback if the tab actually changed - const tabChanged = tabIndex !== activeIndex; - - // Update activeIndex immediately for TabsBar animation - setActiveIndex(tabIndex); - - // Ensure the tab is loaded - if (!loadedTabs.has(tabIndex)) { - setLoadedTabs((prev) => new Set(prev).add(tabIndex)); - } - - // Mark as programmatic scroll - isProgrammaticScroll.current = true; - - if (scrollViewRef.current && containerWidth > 0) { - scrollViewRef.current.scrollTo({ - x: contentIndex * containerWidth, - animated: true, - }); - } - - // Only call onChangeTab if the tab actually changed - if (onChangeTab && tabChanged) { - onChangeTab({ - i: tabIndex, - ref: tabs[tabIndex]?.content || null, - }); - } - - // Reset programmatic scroll flag - if (goToTabTimeout.current) { - clearTimeout(goToTabTimeout.current); - } - goToTabTimeout.current = setTimeout(() => { - isProgrammaticScroll.current = false; - goToTabTimeout.current = null; - }, 400); + handleTabPress(tabIndex); }, getCurrentIndex: () => activeIndex, }), - [ - activeIndex, - tabs, - onChangeTab, - containerWidth, - getContentIndexFromTabIndex, - loadedTabs, - ], + [activeIndex, handleTabPress], ); const tabBarPropsComputed = useMemo( @@ -438,43 +225,31 @@ const TabsList = forwardRef( return ( - {/* Render TabsBar */} - {/* Horizontal ScrollView for tab contents */} - - {enabledTabs.map((enabledTab) => ( - - {loadedTabs.has(enabledTab.originalIndex) && shouldShowContent - ? enabledTab.content - : null} - - ))} - + + + {tabs.map((tab, index) => { + const isActive = index === activeIndex; + const isLoaded = loadedTabs.has(index); + + if (!isLoaded) return null; + + return ( + + {tab.content} + + ); + })} + + ); }, diff --git a/app/component-library/components-temp/Tabs/TabsList/__snapshots__/TabsList.test.tsx.snap b/app/component-library/components-temp/Tabs/TabsList/__snapshots__/TabsList.test.tsx.snap index e11054f6e10e..5e5cfa951c54 100644 --- a/app/component-library/components-temp/Tabs/TabsList/__snapshots__/TabsList.test.tsx.snap +++ b/app/component-library/components-temp/Tabs/TabsList/__snapshots__/TabsList.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`TabsList handles empty children gracefully 1`] = ` +exports[`TabsList Edge Cases handles non-React element children with default values 1`] = ` + > + + + + Tab 1 + + + Tab 1 + + + + + + + Tab 2 + + + Tab 2 + + + + - + + + + Tab 1 Content + + + + + +`; + +exports[`TabsList handles empty children gracefully 1`] = ` + + - - + + + `; @@ -324,89 +626,62 @@ exports[`TabsList passes BoxProps to underlying Box component 1`] = ` - - + - - - Tab 1 - Content - - + Tab 1 + Content + - - + `; @@ -764,105 +1039,61 @@ exports[`TabsList renders correctly with multiple tabs 1`] = ` - - + - - - Tab 1 - Content - - + Tab 1 + Content + - - - + `; diff --git a/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap b/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap index b3ce36d57db8..f094f0109ad2 100644 --- a/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap @@ -8,7 +8,6 @@ exports[`AssetElement should render correctly 1`] = ` style={ { "alignItems": "center", - "flex": 1, "flexDirection": "row", "height": 64, } diff --git a/app/components/UI/AssetElement/index.tsx b/app/components/UI/AssetElement/index.tsx index a5cf7e096f42..6a3a15eb925c 100644 --- a/app/components/UI/AssetElement/index.tsx +++ b/app/components/UI/AssetElement/index.tsx @@ -40,7 +40,6 @@ interface AssetElementProps { const createStyles = (colors: Colors) => StyleSheet.create({ itemWrapper: { - flex: 1, flexDirection: 'row', height: 64, alignItems: 'center', diff --git a/app/components/UI/AssetOverview/Balance/__snapshots__/index.test.tsx.snap b/app/components/UI/AssetOverview/Balance/__snapshots__/index.test.tsx.snap index 4d73eab508b0..892cedb76123 100644 --- a/app/components/UI/AssetOverview/Balance/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/AssetOverview/Balance/__snapshots__/index.test.tsx.snap @@ -31,7 +31,6 @@ exports[`Balance should render correctly with main and secondary balance 1`] = ` style={ { "alignItems": "center", - "flex": 1, "flexDirection": "row", "height": 64, } @@ -268,7 +267,6 @@ exports[`Balance should render correctly without a secondary balance 1`] = ` style={ { "alignItems": "center", - "flex": 1, "flexDirection": "row", "height": 64, } diff --git a/app/components/UI/Bridge/Views/BridgeView/index.tsx b/app/components/UI/Bridge/Views/BridgeView/index.tsx index 2cf3d679286b..7c4a85e19533 100644 --- a/app/components/UI/Bridge/Views/BridgeView/index.tsx +++ b/app/components/UI/Bridge/Views/BridgeView/index.tsx @@ -205,6 +205,7 @@ const BridgeView = () => { }); const isSubmitDisabled = + isLoading || hasInsufficientBalance || isSubmittingTx || (isHardwareAddress && isSolanaSourced) || diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts index 912dbe62b145..6eea4d028f58 100644 --- a/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts +++ b/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts @@ -205,14 +205,27 @@ export const useBridgeQuoteData = ({ bridgeFeatureFlags.priceImpactThreshold.normal)), ); + const abortController = useRef(new AbortController()); + useEffect( + () => () => { + abortController.current?.abort(); + abortController.current = null; + }, + [], + ); + const validateQuote = useCallback(async () => { // Increment validation ID for this request const validationId = ++currentValidationIdRef.current; + // Cancel any ongoing request + abortController.current?.abort(); + abortController.current = new AbortController(); if (activeQuote && (isSolanaSwap || isSolanaToNonSolana)) { try { const validationResult = await validateBridgeTx({ quoteResponse: activeQuote, + signal: abortController.current?.signal, }); // Check if this is still the current validation after async operation diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts index a8d3ddace78b..0e53507e4c55 100644 --- a/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts +++ b/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts @@ -656,6 +656,7 @@ describe('useBridgeQuoteData', () => { expect(mockValidateBridgeTx).toHaveBeenCalledWith({ quoteResponse: mockQuote, + signal: expect.any(AbortSignal), }); }); diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/index.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/index.ts index f4e0e12f9992..ca09395bf106 100644 --- a/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/index.ts +++ b/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/index.ts @@ -17,7 +17,7 @@ import { useUnifiedSwapBridgeContext } from '../useUnifiedSwapBridgeContext'; import { selectShouldUseSmartTransaction } from '../../../../../selectors/smartTransactionsController'; import { selectSourceWalletAddress } from '../../../../../selectors/bridge'; -export const DEBOUNCE_WAIT = 700; +export const DEBOUNCE_WAIT = 300; /** * Hook for handling bridge quote request updates diff --git a/app/components/UI/Card/components/CardAssetItem/__snapshots__/CardAssetItem.test.tsx.snap b/app/components/UI/Card/components/CardAssetItem/__snapshots__/CardAssetItem.test.tsx.snap index 2f79c643c135..e3226a12b4c0 100644 --- a/app/components/UI/Card/components/CardAssetItem/__snapshots__/CardAssetItem.test.tsx.snap +++ b/app/components/UI/Card/components/CardAssetItem/__snapshots__/CardAssetItem.test.tsx.snap @@ -319,7 +319,6 @@ exports[`CardAssetItem Component handles test network correctly 1`] = ` style={ { "alignItems": "center", - "flex": 1, "flexDirection": "row", "height": 64, } @@ -852,7 +851,6 @@ exports[`CardAssetItem Component renders non-native token and matches snapshot 1 style={ { "alignItems": "center", - "flex": 1, "flexDirection": "row", "height": 64, } @@ -1329,7 +1327,6 @@ exports[`CardAssetItem Component renders with all props and matches snapshot 1`] style={ { "alignItems": "center", - "flex": 1, "flexDirection": "row", "height": 64, } @@ -1801,7 +1798,6 @@ exports[`CardAssetItem Component renders with privacy mode enabled and matches s style={ { "alignItems": "center", - "flex": 1, "flexDirection": "row", "height": 64, } @@ -2273,7 +2269,6 @@ exports[`CardAssetItem Component renders with required props and matches snapsho style={ { "alignItems": "center", - "flex": 1, "flexDirection": "row", "height": 64, } diff --git a/app/components/UI/CollectibleMedia/CollectibleMedia.styles.ts b/app/components/UI/CollectibleMedia/CollectibleMedia.styles.ts index 017c1a83db21..24173dbaf414 100644 --- a/app/components/UI/CollectibleMedia/CollectibleMedia.styles.ts +++ b/app/components/UI/CollectibleMedia/CollectibleMedia.styles.ts @@ -49,15 +49,16 @@ const styleSheet = (params: { borderRadius: 12, }, textContainer: { + flex: 1, alignItems: 'center', - justifyContent: 'flex-start', + justifyContent: 'center', backgroundColor: colors.background.section, borderRadius: 8, }, textWrapper: { - flex: 1, textAlign: 'center', - marginTop: 16, + alignItems: 'center', + justifyContent: 'center', }, textWrapperIcon: { fontSize: 18, diff --git a/app/components/UI/CollectibleModal/__snapshots__/CollectibleModal.test.tsx.snap b/app/components/UI/CollectibleModal/__snapshots__/CollectibleModal.test.tsx.snap index 114197694019..85bb3f2eb2f4 100644 --- a/app/components/UI/CollectibleModal/__snapshots__/CollectibleModal.test.tsx.snap +++ b/app/components/UI/CollectibleModal/__snapshots__/CollectibleModal.test.tsx.snap @@ -116,7 +116,8 @@ exports[`CollectibleModal should render correctly 1`] = ` "alignItems": "center", "backgroundColor": "#f3f5f9", "borderRadius": 8, - "justifyContent": "flex-start", + "flex": 1, + "justifyContent": "center", }, { "borderRadius": 12, diff --git a/app/components/UI/Collectibles/__snapshots__/index.test.tsx.snap b/app/components/UI/Collectibles/__snapshots__/index.test.tsx.snap index 1c39ce3a1b42..8df3c85ba4a5 100644 --- a/app/components/UI/Collectibles/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/Collectibles/__snapshots__/index.test.tsx.snap @@ -73,7 +73,6 @@ exports[`Collectibles should render correctly collectibles 1`] = ` style={ { "alignItems": "center", - "flex": 1, "flexDirection": "row", "height": 64, } @@ -110,7 +109,8 @@ exports[`Collectibles should render correctly collectibles 1`] = ` "alignItems": "center", "backgroundColor": "#f3f5f9", "borderRadius": 8, - "justifyContent": "flex-start", + "flex": 1, + "justifyContent": "center", }, undefined, undefined, diff --git a/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.styles.ts b/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.styles.ts index b5ae74d45c42..8fcd492461da 100644 --- a/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.styles.ts +++ b/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.styles.ts @@ -5,6 +5,7 @@ const createStyles = () => // custom network styles container: { flex: 1, + paddingVertical: 12, }, scrollContentContainer: { paddingBottom: 100, diff --git a/app/components/UI/DeFiPositions/DeFiPositionsList.test.tsx b/app/components/UI/DeFiPositions/DeFiPositionsList.test.tsx index 748dd18967e6..7f5de8520ba0 100644 --- a/app/components/UI/DeFiPositions/DeFiPositionsList.test.tsx +++ b/app/components/UI/DeFiPositions/DeFiPositionsList.test.tsx @@ -10,6 +10,10 @@ jest.mock('../../../util/networks', () => ({ isRemoveGlobalNetworkSelectorEnabled: jest.fn().mockReturnValue(false), })); +jest.mock('react-native-device-info', () => ({ + getVersion: jest.fn().mockReturnValue('1.0.0'), +})); + jest.mock('../../../selectors/defiPositionsController', () => ({ ...jest.requireActual('../../../selectors/defiPositionsController'), selectDeFiPositionsByAddress: jest.fn(), @@ -214,13 +218,14 @@ describe('DeFiPositionsList', () => { expect( await findByTestId(WalletViewSelectorsIDs.DEFI_POSITIONS_CONTAINER), ).toBeOnTheScreen(); - expect(await findByText('Protocol 1')).toBeOnTheScreen(); - expect(await findByText('$100.00')).toBeOnTheScreen(); - const flatList = await findByTestId( + const listContainer = await findByTestId( WalletViewSelectorsIDs.DEFI_POSITIONS_LIST, ); - expect(flatList.props.data.length).toEqual(1); + expect(listContainer).toBeOnTheScreen(); + + expect(await findByText('Protocol 1')).toBeOnTheScreen(); + expect(await findByText('$100.00')).toBeOnTheScreen(); }); it('renders protocol name and aggregated value for all chains when all networks is selected', async () => { @@ -254,14 +259,16 @@ describe('DeFiPositionsList', () => { expect( await findByTestId(WalletViewSelectorsIDs.DEFI_POSITIONS_NETWORK_FILTER), ).toBeOnTheScreen(); + + const listContainer = await findByTestId( + WalletViewSelectorsIDs.DEFI_POSITIONS_LIST, + ); + expect(listContainer).toBeOnTheScreen(); + expect(await findByText('Protocol 1')).toBeOnTheScreen(); expect(await findByText('Protocol 2')).toBeOnTheScreen(); expect(await findByText('$100.00')).toBeOnTheScreen(); expect(await findByText('$10.00')).toBeOnTheScreen(); - const flatList = await findByTestId( - WalletViewSelectorsIDs.DEFI_POSITIONS_LIST, - ); - expect(flatList.props.data.length).toEqual(2); }); it('renders the loading positions message when positions are not yet available', async () => { @@ -414,14 +421,13 @@ describe('DeFiPositionsList', () => { await findByTestId(WalletViewSelectorsIDs.DEFI_POSITIONS_CONTAINER), ).toBeOnTheScreen(); - // Should show the filtered protocol name - expect(await findByText('Protocol 1 (Filtered)')).toBeOnTheScreen(); - expect(await findByText('$100.00')).toBeOnTheScreen(); - - const flatList = await findByTestId( + const listContainer = await findByTestId( WalletViewSelectorsIDs.DEFI_POSITIONS_LIST, ); - expect(flatList.props.data.length).toEqual(1); + expect(listContainer).toBeOnTheScreen(); + + expect(await findByText('Protocol 1 (Filtered)')).toBeOnTheScreen(); + expect(await findByText('$100.00')).toBeOnTheScreen(); }); it('shows no positions when defiPositionsByEnabledNetworks returns empty data', async () => { @@ -531,13 +537,161 @@ describe('DeFiPositionsList', () => { ), ).toBeOnTheScreen(); + const listContainer = await findByTestId( + WalletViewSelectorsIDs.DEFI_POSITIONS_LIST, + ); + expect(listContainer).toBeOnTheScreen(); + expect(await findByText('Protocol 1')).toBeOnTheScreen(); expect(await findByText('$100.00')).toBeOnTheScreen(); + }); + }); - const flatList = await findByTestId( + describe('Homepage Redesign V1 Feature', () => { + it('removes scrolling container in favour of global scroll container when isHomepageRedesignV1Enabled is true', async () => { + const { findByTestId, queryByTestId } = renderWithProvider( + , + { + state: { + ...mockInitialState, + engine: { + ...mockInitialState.engine, + backgroundState: { + ...mockInitialState.engine.backgroundState, + RemoteFeatureFlagController: { + remoteFeatureFlags: { + homepageRedesignV1: { + enabled: true, + minimumVersion: '1.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }, + }, + ); + + const container = await findByTestId( + WalletViewSelectorsIDs.DEFI_POSITIONS_CONTAINER, + ); + expect(container).toBeOnTheScreen(); + + const listContainer = await findByTestId( WalletViewSelectorsIDs.DEFI_POSITIONS_LIST, ); - expect(flatList.props.data.length).toEqual(1); + expect(listContainer).toBeOnTheScreen(); + + const scrollView = queryByTestId( + WalletViewSelectorsIDs.DEFI_POSITIONS_SCROLL_VIEW, + ); + expect(scrollView).toBeNull(); + }); + + it('renders empty state without scroll container when isHomepageRedesignV1Enabled is true', async () => { + const defiPositionsModule = jest.requireMock( + '../../../selectors/defiPositionsController', + ); + defiPositionsModule.selectDeFiPositionsByAddress.mockReturnValue({}); + defiPositionsModule.selectDefiPositionsByEnabledNetworks.mockReturnValue( + {}, + ); + + const { findByTestId } = renderWithProvider( + , + { + state: { + ...mockInitialState, + engine: { + ...mockInitialState.engine, + backgroundState: { + ...mockInitialState.engine.backgroundState, + RemoteFeatureFlagController: { + remoteFeatureFlags: { + homepageRedesignV1: { + enabled: true, + minimumVersion: '1.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }, + }, + ); + + const container = await findByTestId( + WalletViewSelectorsIDs.DEFI_POSITIONS_CONTAINER, + ); + expect(container).toBeOnTheScreen(); + }); + + it('renders multiple positions without scroll container when isHomepageRedesignV1Enabled is true', async () => { + const { findByTestId, findByText, queryByTestId } = renderWithProvider( + , + { + state: { + ...mockInitialState, + engine: { + ...mockInitialState.engine, + backgroundState: { + ...mockInitialState.engine.backgroundState, + PreferencesController: { + ...mockInitialState.engine.backgroundState + .PreferencesController, + tokenNetworkFilter: { + [MOCK_CHAIN_ID_1]: true, + [MOCK_CHAIN_ID_2]: true, + }, + }, + RemoteFeatureFlagController: { + remoteFeatureFlags: { + homepageRedesignV1: { + enabled: true, + minimumVersion: '1.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }, + }, + ); + + const listContainer = await findByTestId( + WalletViewSelectorsIDs.DEFI_POSITIONS_LIST, + ); + expect(listContainer).toBeOnTheScreen(); + + expect(await findByText('Protocol 1')).toBeOnTheScreen(); + expect(await findByText('Protocol 2')).toBeOnTheScreen(); + + const scrollView = queryByTestId( + WalletViewSelectorsIDs.DEFI_POSITIONS_SCROLL_VIEW, + ); + expect(scrollView).toBeNull(); + }); + + it('renders scroll container when isHomepageRedesignV1Enabled is false', async () => { + const { findByTestId } = renderWithProvider( + , + { + state: mockInitialState, + }, + ); + + const listContainer = await findByTestId( + WalletViewSelectorsIDs.DEFI_POSITIONS_LIST, + ); + expect(listContainer).toBeOnTheScreen(); + + const scrollView = await findByTestId( + WalletViewSelectorsIDs.DEFI_POSITIONS_SCROLL_VIEW, + ); + expect(scrollView).toBeOnTheScreen(); }); }); }); diff --git a/app/components/UI/DeFiPositions/DeFiPositionsList.tsx b/app/components/UI/DeFiPositions/DeFiPositionsList.tsx index c26907c15415..b53213e29ec6 100644 --- a/app/components/UI/DeFiPositions/DeFiPositionsList.tsx +++ b/app/components/UI/DeFiPositions/DeFiPositionsList.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import { View, FlatList } from 'react-native'; +import { View } from 'react-native'; import { strings } from '../../../../locales/i18n'; import { useSelector } from 'react-redux'; import { @@ -34,6 +34,9 @@ import { useStyles } from '../../hooks/useStyles'; import { WalletViewSelectorsIDs } from '../../../../e2e/selectors/wallet/WalletView.selectors'; import { isRemoveGlobalNetworkSelectorEnabled } from '../../../util/networks'; import { DefiEmptyState } from '../DefiEmptyState'; +import { selectHomepageRedesignV1Enabled } from '../../../selectors/featureFlagController/homepage'; +import ConditionalScrollView from '../../../component-library/components-temp/ConditionalScrollView'; + export interface DeFiPositionsListProps { tabLabel: string; } @@ -48,6 +51,9 @@ const DeFiPositionsList: React.FC = () => { selectDefiPositionsByEnabledNetworks, ); const privacyMode = useSelector(selectPrivacyMode); + const isHomepageRedesignV1Enabled = useSelector( + selectHomepageRedesignV1Enabled, + ); const formattedDeFiPositions = useMemo(() => { if (!defiPositions) { @@ -132,29 +138,40 @@ const DeFiPositionsList: React.FC = () => { } } - return ( - - - ( + const content = ( + + {formattedDeFiPositions.map( + ({ chainId, protocolId, protocolAggregate }) => ( - )} - keyExtractor={(protocolChainAggregate) => - `${protocolChainAggregate.chainId}-${protocolChainAggregate.protocolAggregate.protocolDetails.name}` - } - scrollEnabled - ListEmptyComponent={} - /> + ), + )} + + ); + + return ( + + + {formattedDeFiPositions.length > 0 ? ( + + {content} + + ) : ( + + )} ); }; diff --git a/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap b/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap index 21a277a75162..5d30af5b4ff6 100644 --- a/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap +++ b/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap @@ -261,7 +261,6 @@ exports[`EarnLendingBalance renders balance and buttons when user has lending po style={ { "alignItems": "center", - "flex": 1, "flexDirection": "row", "height": 64, } diff --git a/app/components/UI/NftGrid/NftGrid.test.tsx b/app/components/UI/NftGrid/NftGrid.test.tsx index 2723837f28a8..25b6db7dfaab 100644 --- a/app/components/UI/NftGrid/NftGrid.test.tsx +++ b/app/components/UI/NftGrid/NftGrid.test.tsx @@ -75,12 +75,19 @@ jest.mock('@shopify/flash-list', () => ({ return ( {ListHeaderComponent} - {data && data.length > 0 - ? data.map((item: unknown, index: number) => ( + {data && data.length > 0 ? ( + <> + {data.map((item: unknown, index: number) => ( {renderItem({ item, index })} - )) - : ListEmptyComponent} - {ListFooterComponent} + ))} + {ListFooterComponent} + + ) : ( + <> + {ListEmptyComponent} + {ListFooterComponent} + + )} ); }, @@ -100,6 +107,10 @@ jest.mock('./NftGridHeader', () => { ); }); +jest.mock('./NftGridSkeleton', () => { + const { View } = jest.requireActual('react-native'); + return () => ; +}); // Mock CollectiblesEmptyState - has complex dependencies jest.mock('../CollectiblesEmptyState', () => ({ @@ -160,6 +171,7 @@ jest.mock('../CollectibleMedia', () => () => null); jest.mock('@metamask/design-system-react-native', () => ({ Text: ({ children }: { children: React.ReactNode }) => children, TextVariant: { BodyMd: 'BodyMd', BodySm: 'BodySm' }, + FontWeight: { Medium: 'Medium' }, Box: ({ children, testID, @@ -260,7 +272,16 @@ jest.mock('../../../util/trace', () => ({ // Mock useTailwind jest.mock('@metamask/design-system-twrnc-preset', () => ({ - useTailwind: () => (className: string) => ({ className }), + useTailwind: () => { + const styleFunc = (className: string | string[]) => { + if (Array.isArray(className)) { + return className.reduce((acc, cls) => ({ ...acc, [cls]: true }), {}); + } + return { [className]: true }; + }; + styleFunc.style = styleFunc; + return styleFunc; + }, })); describe('NftGrid', () => { @@ -293,6 +314,31 @@ describe('NftGrid', () => { const mockCollectibles = { '0x1': [mockNft] }; mockUseSelector .mockReturnValueOnce(false) // isNftFetchingProgress + .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled + .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + const store = mockStore(initialState); + + const { getByTestId } = render( + + + , + ); + + act(() => { + jest.advanceTimersByTime(100); + }); + + await waitFor(() => { + expect(getByTestId('collectible-Test NFT-456')).toBeOnTheScreen(); + expect(getByTestId('nft-grid-header')).toBeOnTheScreen(); + }); + }); + + it('renders NFT grid directly without FlashList when homepage redesign is enabled', async () => { + const mockCollectibles = { '0x1': [mockNft] }; + mockUseSelector + .mockReturnValueOnce(false) // isNftFetchingProgress + .mockReturnValueOnce(true) // selectHomepageRedesignV1Enabled .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector const store = mockStore(initialState); @@ -316,6 +362,7 @@ describe('NftGrid', () => { const mockCollectibles = { '0x1': [mockNft] }; mockUseSelector .mockReturnValueOnce(false) // isNftFetchingProgress + .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector const store = mockStore(initialState); @@ -339,6 +386,7 @@ describe('NftGrid', () => { const mockCollectibles = { '0x1': [mockNft] }; mockUseSelector .mockReturnValueOnce(false) // isNftFetchingProgress + .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector const store = mockStore(initialState); @@ -358,18 +406,22 @@ describe('NftGrid', () => { }); }); - it('shows view all button when maxItems is exceeded', async () => { + it('shows view all button when homepage redesign is enabled and NFT count exceeds limit', async () => { const mockCollectibles = { - '0x1': [mockNft, { ...mockNft, tokenId: '789' }], + '0x1': Array.from({ length: 20 }, (_, i) => ({ + ...mockNft, + tokenId: `${i}`, + })), }; mockUseSelector .mockReturnValueOnce(false) // isNftFetchingProgress + .mockReturnValueOnce(true) // selectHomepageRedesignV1Enabled (maxItems = 18) .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector const store = mockStore(initialState); const { getByTestId } = render( - + , ); @@ -382,16 +434,22 @@ describe('NftGrid', () => { }); }); - it('hides view all button when maxItems is not exceeded', async () => { - const mockCollectibles = { '0x1': [mockNft] }; + it('hides view all button when homepage redesign is disabled', async () => { + const mockCollectibles = { + '0x1': Array.from({ length: 20 }, (_, i) => ({ + ...mockNft, + tokenId: `${i}`, + })), + }; mockUseSelector .mockReturnValueOnce(false) // isNftFetchingProgress + .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled (maxItems = undefined) .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector const store = mockStore(initialState); const { queryByTestId } = render( - + , ); @@ -413,6 +471,7 @@ describe('NftGrid', () => { }; mockUseSelector .mockReturnValueOnce(false) // isNftFetchingProgress + .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector const store = mockStore(initialState); @@ -468,6 +527,7 @@ describe('NftGrid', () => { const mockCollectibles = { '0x1': [mockNft] }; mockUseSelector .mockReturnValueOnce(false) // isNftFetchingProgress + .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector const store = mockStore(initialState); @@ -496,6 +556,7 @@ describe('NftGrid', () => { const mockCollectibles = { '0x1': [nftWithoutName] }; mockUseSelector .mockReturnValueOnce(false) // isNftFetchingProgress + .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector const store = mockStore(initialState); @@ -514,10 +575,11 @@ describe('NftGrid', () => { }); }); - it('shows spinner in footer when NFTs are being fetched', async () => { + it('renders NFT items when not fetching without homepage redesign', async () => { const mockCollectibles = { '0x1': [mockNft] }; mockUseSelector - .mockReturnValueOnce(true) // isNftFetchingProgress + .mockReturnValueOnce(false) // isNftFetchingProgress + .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector const store = mockStore(initialState); @@ -532,7 +594,31 @@ describe('NftGrid', () => { }); await waitFor(() => { - expect(getByTestId('collectible-contracts-spinner')).toBeOnTheScreen(); + expect(getByTestId('collectible-Test NFT-456')).toBeOnTheScreen(); + expect(getByTestId('nft-grid-header')).toBeOnTheScreen(); + }); + }); + + it('shows empty state when not fetching with homepage redesign enabled and no collectibles', async () => { + const mockCollectibles = { '0x1': [] }; + mockUseSelector + .mockReturnValueOnce(false) // isNftFetchingProgress + .mockReturnValueOnce(true) // selectHomepageRedesignV1Enabled + .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + const store = mockStore(initialState); + + const { getByTestId } = render( + + + , + ); + + act(() => { + jest.advanceTimersByTime(100); + }); + + await waitFor(() => { + expect(getByTestId('collectibles-empty-state')).toBeOnTheScreen(); }); }); @@ -540,6 +626,7 @@ describe('NftGrid', () => { const mockCollectibles = { '0x1': [mockNft] }; mockUseSelector .mockReturnValueOnce(false) // isNftFetchingProgress + .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector const store = mockStore(initialState); @@ -561,6 +648,7 @@ describe('NftGrid', () => { it('shows empty state when no collectibles and not fetching', async () => { mockUseSelector .mockReturnValueOnce(false) // isNftFetchingProgress + .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled .mockReturnValueOnce({}); // multichainCollectiblesByEnabledNetworksSelector const store = mockStore(initialState); @@ -579,9 +667,10 @@ describe('NftGrid', () => { }); }); - it('hides empty state when fetching NFTs', async () => { + it('hides empty state when fetching NFTs without homepage redesign', async () => { mockUseSelector .mockReturnValueOnce(true) // isNftFetchingProgress + .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled .mockReturnValueOnce({}); // multichainCollectiblesByEnabledNetworksSelector const store = mockStore(initialState); @@ -600,18 +689,46 @@ describe('NftGrid', () => { }); }); + it('renders NFT items when not fetching with homepage redesign enabled', async () => { + const mockCollectibles = { '0x1': [mockNft] }; + mockUseSelector + .mockReturnValueOnce(false) // isNftFetchingProgress + .mockReturnValueOnce(true) // selectHomepageRedesignV1Enabled + .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + const store = mockStore(initialState); + + const { getByTestId, queryByTestId } = render( + + + , + ); + + act(() => { + jest.advanceTimersByTime(100); + }); + + await waitFor(() => { + expect(getByTestId('collectible-Test NFT-456')).toBeOnTheScreen(); + expect(queryByTestId('collectibles-empty-state')).toBeNull(); + }); + }); + it('navigates to full view when view all button is pressed', async () => { const mockCollectibles = { - '0x1': [mockNft, { ...mockNft, tokenId: '789' }], + '0x1': Array.from({ length: 20 }, (_, i) => ({ + ...mockNft, + tokenId: `${i}`, + })), }; mockUseSelector .mockReturnValueOnce(false) // isNftFetchingProgress + .mockReturnValueOnce(true) // selectHomepageRedesignV1Enabled (maxItems = 18) .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector const store = mockStore(initialState); const { getByTestId } = render( - + , ); @@ -626,4 +743,76 @@ describe('NftGrid', () => { expect(mockNavigate).toHaveBeenCalledWith('NftFullView'); }); + + it('limits NFTs to 18 when homepage redesign is enabled and not full view', async () => { + const mockCollectibles = { + '0x1': Array.from({ length: 25 }, (_, i) => ({ + ...mockNft, + tokenId: `${i}`, + name: `NFT ${i}`, + })), + }; + mockUseSelector + .mockReturnValueOnce(false) // isNftFetchingProgress + .mockReturnValueOnce(true) // selectHomepageRedesignV1Enabled (maxItems = 18) + .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + const store = mockStore(initialState); + + const { getByTestId, queryByTestId } = render( + + + , + ); + + act(() => { + jest.advanceTimersByTime(100); + }); + + await waitFor(() => { + // Should render first 18 NFTs + expect(getByTestId('collectible-NFT 0-0')).toBeOnTheScreen(); + expect(getByTestId('collectible-NFT 17-17')).toBeOnTheScreen(); + + // Should NOT render NFTs beyond 18 + expect(queryByTestId('collectible-NFT 18-18')).toBeNull(); + expect(queryByTestId('collectible-NFT 24-24')).toBeNull(); + + // View all button should be present + expect(getByTestId('view-all-nfts-button')).toBeOnTheScreen(); + }); + }); + + it('does not limit NFTs when full view is enabled', async () => { + const mockCollectibles = { + '0x1': Array.from({ length: 25 }, (_, i) => ({ + ...mockNft, + tokenId: `${i}`, + name: `NFT ${i}`, + })), + }; + mockUseSelector + .mockReturnValueOnce(false) // isNftFetchingProgress + .mockReturnValueOnce(true) // selectHomepageRedesignV1Enabled + .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + const store = mockStore(initialState); + + const { getByTestId, queryByTestId } = render( + + + , + ); + + act(() => { + jest.advanceTimersByTime(100); + }); + + await waitFor(() => { + // Should render all NFTs when full view + expect(getByTestId('collectible-NFT 0-0')).toBeOnTheScreen(); + expect(getByTestId('collectible-NFT 24-24')).toBeOnTheScreen(); + + // View all button should NOT be present in full view + expect(queryByTestId('view-all-nfts-button')).toBeNull(); + }); + }); }); diff --git a/app/components/UI/NftGrid/NftGrid.tsx b/app/components/UI/NftGrid/NftGrid.tsx index 7c52a9ec5ed0..724be654f224 100644 --- a/app/components/UI/NftGrid/NftGrid.tsx +++ b/app/components/UI/NftGrid/NftGrid.tsx @@ -5,9 +5,9 @@ import React, { useEffect, useCallback, } from 'react'; -import { FlashList, FlashListProps } from '@shopify/flash-list'; +import { FlashList } from '@shopify/flash-list'; import { useSelector } from 'react-redux'; -import { RefreshTestId, SpinnerTestId } from './constants'; +import { RefreshTestId } from './constants'; import { endTrace, trace, TraceName } from '../../../util/trace'; import { Nft } from '@metamask/assets-controllers'; import { @@ -19,12 +19,12 @@ import NftGridItem from './NftGridItem'; import ActionSheet from '@metamask/react-native-actionsheet'; import NftGridItemActionSheet from './NftGridItemActionSheet'; import NftGridHeader from './NftGridHeader'; +import NftGridSkeleton from './NftGridSkeleton'; import { useNavigation } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; import { MetaMetricsEvents, useMetrics } from '../../hooks/useMetrics'; import { CollectiblesEmptyState } from '../CollectiblesEmptyState'; import { WalletViewSelectorsIDs } from '../../../../e2e/selectors/wallet/WalletView.selectors'; -import { ActivityIndicator } from 'react-native'; import { Box, Button, @@ -38,6 +38,7 @@ import ButtonIcon, { } from '../../../component-library/components/Buttons/ButtonIcon'; import { IconName } from '../../../component-library/components/Icons/Icon'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { selectHomepageRedesignV1Enabled } from '../../../selectors/featureFlagController/homepage'; interface NFTNavigationParamList { AddAsset: { assetType: string }; @@ -45,8 +46,6 @@ interface NFTNavigationParamList { } interface NftGridProps { - flashListProps?: Partial>; - maxItems?: number; isFullView?: boolean; } @@ -57,7 +56,7 @@ const NftRow = ({ items: Nft[]; onLongPress: (nft: Nft) => void; }) => ( - + {items.map((item, index) => { // Create a truly unique key combining multiple identifiers const uniqueKey = `${item.address}-${item.tokenId}-${item.chainId}-${index}`; @@ -75,11 +74,7 @@ const NftRow = ({ ); -const NftGrid = ({ - flashListProps, - maxItems, - isFullView = false, -}: NftGridProps) => { +const NftGrid = ({ isFullView = false }: NftGridProps) => { const navigation = useNavigation>(); const { trackEvent, createEventBuilder } = useMetrics(); @@ -89,6 +84,9 @@ const NftGrid = ({ const tw = useTailwind(); const isNftFetchingProgress = useSelector(isNftFetchingProgressSelector); + const isHomepageRedesignV1Enabled = useSelector( + selectHomepageRedesignV1Enabled, + ); const actionSheetRef = useRef(); @@ -106,6 +104,13 @@ const NftGrid = ({ return owned; }, [collectiblesByEnabledNetworks]); + const maxItems = useMemo(() => { + if (isFullView) { + return undefined; + } + return isHomepageRedesignV1Enabled ? 18 : undefined; + }, [isFullView, isHomepageRedesignV1Enabled]); + const groupedCollectibles: Nft[][] = useMemo(() => { const groups: Nft[][] = []; const itemsToProcess = maxItems @@ -133,50 +138,25 @@ const NftGrid = ({ setIsAddNFTEnabled(true); }, [navigation, trackEvent, createEventBuilder]); - const additionalButtons = ( - - ); - const handleViewAllNfts = useCallback(() => { navigation.navigate(Routes.WALLET.NFTS_FULL_VIEW); }, [navigation]); - // Determine if we should show the "View all NFTs" button - const shouldShowViewAllButton = - maxItems && allFilteredCollectibles.length > maxItems; - - // Default flashListProps for full view - const defaultFullViewProps = useMemo( - () => ({ - contentContainerStyle: tw`px-4`, - scrollEnabled: true, - }), - [tw], - ); - - // Merge default props with passed props - const mergedFlashListProps = useMemo(() => { - if (isFullView) { - return { ...defaultFullViewProps, ...flashListProps }; - } - return flashListProps; - }, [isFullView, defaultFullViewProps, flashListProps]); - - return ( - <> - + const nftRowList = + !isFullView && isHomepageRedesignV1Enabled ? ( + + + + {groupedCollectibles.map((items, index) => ( + + ))} + + + ) : ( } data={groupedCollectibles} @@ -187,36 +167,44 @@ const NftGrid = ({ testID={RefreshTestId} decelerationRate="fast" refreshControl={} - ListEmptyComponent={ - !isNftFetchingProgress ? ( - - ) : null - } - ListFooterComponent={ - <> - {isNftFetchingProgress && ( - - )} - - } - {...mergedFlashListProps} + contentContainerStyle={!isFullView ? undefined : tw`px-4`} /> + ); - + + } + hideSort + style={isFullView ? tw`px-4 pb-4` : tw`pb-3`} /> - + {isNftFetchingProgress ? ( + + ) : allFilteredCollectibles.length > 0 ? ( + nftRowList + ) : ( + + )} {/* View all NFTs button - shown when there are more items than maxItems */} - {shouldShowViewAllButton && ( + {maxItems && allFilteredCollectibles.length > maxItems && ( - - )} - - ) : ( - - - - {strings('wallet.no_tokens')} - - - {strings('wallet.show_tokens_without_balance')} - - - - ); + ))} + {shouldShowViewAllButton && ( + + + + )} + + ) : ( + + { + const staked = item.isStaked ? 'staked' : 'unstaked'; + return `${item.address}-${item.chainId}-${staked}-${idx}`; + }} + decelerationRate="fast" + refreshControl={ + + } + extraData={{ isTokenNetworkFilterEqualCurrentNetwork }} + contentContainerStyle={!isFullView ? undefined : tw`px-4`} + /> + + ); + + return tokenList; }; export const TokenList = React.memo(TokenListComponent); diff --git a/app/components/UI/Tokens/index.test.tsx b/app/components/UI/Tokens/index.test.tsx index d6edd74f2f6f..e5b5acd8ac6d 100644 --- a/app/components/UI/Tokens/index.test.tsx +++ b/app/components/UI/Tokens/index.test.tsx @@ -13,6 +13,10 @@ jest.mock('../../../core/NotificationManager', () => ({ showSimpleNotification: jest.fn(() => Promise.resolve()), })); +jest.mock('react-native-device-info', () => ({ + getVersion: jest.fn().mockReturnValue('1.0.0'), +})); + const selectedAddress = '0x123'; jest.mock('./TokensBottomSheet', () => ({ @@ -129,7 +133,7 @@ const initialState = { NetworkController: { networkConfigurationsByChainId: { '0x1': { - chainId: '0x1', + chainId: '0x1' as const, name: 'Ethereum Mainnet', nativeCurrency: 'ETH', rpcEndpoints: [{ networkClientId: '0x1' }], @@ -191,9 +195,9 @@ const initialState = { tokenBalances: { [selectedAddress]: { '0x1': { - '0x00': '0x2386F26FC10000', - '0x01': '0xDE0B6B3A7640000', - '0x02': '0x0', + '0x00': '0x2386F26FC10000' as const, + '0x01': '0xDE0B6B3A7640000' as const, + '0x02': '0x0' as const, }, }, }, @@ -897,4 +901,95 @@ describe('Tokens', () => { }); }); }); + + describe('Homepage Redesign V1 Features', () => { + it('renders tokens container when homepage redesign is enabled', async () => { + const { getByTestId, queryByTestId } = renderComponent({ + ...initialState, + engine: { + ...initialState.engine, + backgroundState: { + ...initialState.engine.backgroundState, + RemoteFeatureFlagController: { + remoteFeatureFlags: { + homepageRedesignV1: { + enabled: true, + minimumVersion: '1.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }); + + expect( + getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER), + ).toBeOnTheScreen(); + await waitFor(() => expect(queryByTestId('asset-ETH')).toBeDefined()); + }); + + it('renders all tokens when isFullView is true regardless of homepage redesign', async () => { + const { getByTestId, queryByTestId } = renderWithProvider( + + + {() => } + + , + { + state: { + ...initialState, + engine: { + ...initialState.engine, + backgroundState: { + ...initialState.engine.backgroundState, + RemoteFeatureFlagController: { + remoteFeatureFlags: { + homepageRedesignV1: { + enabled: true, + minimumVersion: '1.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }, + }, + ); + + expect( + getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER), + ).toBeOnTheScreen(); + await waitFor(() => expect(queryByTestId('asset-ETH')).toBeDefined()); + }); + }); + + describe('Multichain Accounts State 2', () => { + it('renders tokens when multichain accounts state 2 is enabled', async () => { + const { getByTestId, queryByTestId } = renderComponent({ + ...initialState, + engine: { + ...initialState.engine, + backgroundState: { + ...initialState.engine.backgroundState, + RemoteFeatureFlagController: { + remoteFeatureFlags: { + multichainAccountsState2: { + enabled: true, + minimumVersion: '1.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }); + + expect( + getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER), + ).toBeOnTheScreen(); + await waitFor(() => expect(queryByTestId('asset-ETH')).toBeDefined()); + }); + }); }); diff --git a/app/components/UI/Tokens/index.tsx b/app/components/UI/Tokens/index.tsx index f53592164ed0..d2bf8578a1a1 100644 --- a/app/components/UI/Tokens/index.tsx +++ b/app/components/UI/Tokens/index.tsx @@ -29,13 +29,15 @@ import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetwork import { TokenListControlBar } from './TokenListControlBar'; import { selectSelectedInternalAccountId } from '../../../selectors/accountsController'; import { ScamWarningModal } from './TokenList/ScamWarningModal'; +import TokenListSkeleton from './TokenList/TokenListSkeleton'; import { selectSortedTokenKeys } from '../../../selectors/tokenList'; import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts'; import { selectSortedAssetsBySelectedAccountGroup } from '../../../selectors/assets/assets-list'; -import Loader from '../../../component-library/components-temp/Loader'; import { selectSelectedInternalAccountByScope } from '../../../selectors/multichainAccounts/accounts'; import { SolScope } from '@metamask/keyring-api'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { selectHomepageRedesignV1Enabled } from '../../../selectors/featureFlagController/homepage'; +import { TokensEmptyState } from '../TokensEmptyState'; interface TokenListNavigationParamList { AddAsset: { assetType: string }; @@ -74,15 +76,12 @@ const Tokens = memo(({ isFullView = false }: TokensProps) => { useSelector(selectSelectedInternalAccountByScope)(SolScope.Mainnet) || null; const isSolanaSelected = selectedSolanaAccount !== null; + const isHomepageRedesignV1Enabled = useSelector( + selectHomepageRedesignV1Enabled, + ); + const [showScamWarningModal, setShowScamWarningModal] = useState(false); - const [isTokensLoading, setIsTokensLoading] = useState(true); - const [renderedTokenKeys, setRenderedTokenKeys] = useState< - typeof sortedTokenKeys - >([]); - const [progressiveTokens, setProgressiveTokens] = useState< - typeof sortedTokenKeys - >([]); - const lastTokenDataRef = useRef(); + const [hasInitialLoad, setHasInitialLoad] = useState(false); // BIP44 MAINTENANCE: Once stable, only use selectSortedAssetsBySelectedAccountGroup const isMultichainAccountsState2Enabled = useSelector( @@ -100,74 +99,14 @@ const Tokens = memo(({ isFullView = false }: TokensProps) => { ), ); - // High-performance async rendering with progressive loading + // Mark as loaded once we have data (even if empty) useEffect(() => { - // Debounce rapid data changes - if ( - JSON.stringify(sortedTokenKeys) === - JSON.stringify(lastTokenDataRef.current) - ) { - return; - } - lastTokenDataRef.current = sortedTokenKeys; - - if (sortedTokenKeys?.length) { - setIsTokensLoading(true); - setProgressiveTokens([]); - - // Use InteractionManager for better performance than setTimeout + if (!hasInitialLoad && sortedTokenKeys) { InteractionManager.runAfterInteractions(() => { - const CHUNK_SIZE = 20; // Process 20 tokens at a time - const chunks: (typeof sortedTokenKeys)[] = []; - - for (let i = 0; i < sortedTokenKeys.length; i += CHUNK_SIZE) { - chunks.push(sortedTokenKeys.slice(i, i + CHUNK_SIZE)); - } - - // Progressive loading for better perceived performance - let currentChunkIndex = 0; - let accumulatedTokens: typeof sortedTokenKeys = []; - - const processChunk = () => { - if (currentChunkIndex < chunks.length) { - accumulatedTokens = [ - ...accumulatedTokens, - ...chunks[currentChunkIndex], - ]; - setProgressiveTokens([...accumulatedTokens]); - currentChunkIndex++; - - // Process next chunk after allowing UI to update - requestAnimationFrame(() => { - if (currentChunkIndex < chunks.length) { - setTimeout(processChunk, 0); - } else { - // All chunks processed - const tokenMap = new Map(); - accumulatedTokens.forEach((item) => { - const staked = item.isStaked ? 'staked' : 'unstaked'; - const key = `${item.address}-${item.chainId}-${staked}`; - tokenMap.set(key, item); - }); - const deduped = Array.from(tokenMap.values()); - setRenderedTokenKeys(deduped); - setIsTokensLoading(false); - } - }); - } - }; - - processChunk(); + setHasInitialLoad(true); }); - - return; } - - // No tokens to render - setRenderedTokenKeys([]); - setProgressiveTokens([]); - setIsTokensLoading(false); - }, [sortedTokenKeys]); + }, [sortedTokenKeys, hasInitialLoad]); const showRemoveMenu = useCallback( (token: TokenI) => { @@ -252,43 +191,44 @@ const Tokens = memo(({ isFullView = false }: TokensProps) => { setShowScamWarningModal((prev) => !prev); }, []); + const maxItems = useMemo(() => { + if (isFullView) { + return undefined; + } + return isHomepageRedesignV1Enabled ? 10 : undefined; + }, [isFullView, isHomepageRedesignV1Enabled]); + return ( - {!isTokensLoading && - renderedTokenKeys.length === 0 && - progressiveTokens.length === 0 ? ( - + {!hasInitialLoad ? ( + + + + ) : sortedTokenKeys.length > 0 ? ( + ) : ( - <> - {isTokensLoading && progressiveTokens.length === 0 && ( - - )} - {(progressiveTokens.length > 0 || renderedTokenKeys.length > 0) && ( - - )} - + + + )} {showScamWarningModal && ( bottomSheetText: { width: '100%', }, - emptyView: { - backgroundColor: colors.background.default, - justifyContent: 'center', - alignItems: 'center', - marginTop: 50, - }, - emptyTokensView: { - alignItems: 'center', - marginTop: 130, - }, - emptyTokensViewText: { - fontFamily: 'Geist Medium', - }, balances: { flex: 1, justifyContent: 'center', @@ -118,6 +105,21 @@ const createStyles = (colors: Colors) => badge: { marginTop: 8, }, + wrapperSkeleton: { + backgroundColor: colors.background.default, + }, + skeletonItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + }, + skeletonTextContainer: { + flex: 1, + marginLeft: 12, + }, + skeletonValueContainer: { + alignItems: 'flex-end', + }, }); export default createStyles; diff --git a/app/components/UI/TokensEmptyState/TokensEmptyState.test.tsx b/app/components/UI/TokensEmptyState/TokensEmptyState.test.tsx new file mode 100644 index 000000000000..26f332303fe3 --- /dev/null +++ b/app/components/UI/TokensEmptyState/TokensEmptyState.test.tsx @@ -0,0 +1,157 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { Provider } from 'react-redux'; +import configureMockStore from 'redux-mock-store'; +import { TokensEmptyState } from './TokensEmptyState'; +import { backgroundState } from '../../../util/test/initial-root-state'; +import Routes from '../../../constants/navigation/Routes'; + +const mockStore = configureMockStore(); +const mockNavigate = jest.fn(); + +// Mock navigation +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + navigate: mockNavigate, + }), +})); + +// Mock the tailwind hook +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ + style: jest.fn((...args) => { + if (Array.isArray(args[0])) { + return args[0].join(' '); + } + return args.join(' '); + }), + }), +})); + +// Mock i18n strings +jest.mock('../../../../locales/i18n', () => ({ + strings: (key: string) => { + const translations: Record = { + 'wallet.tokens_empty_description': 'Tokens you hold will appear here.', + 'wallet.show_tokens_without_balance': 'Show tokens without balance', + }; + return translations[key] || key; + }, +})); + +// Mock TabEmptyState component to simplify testing +jest.mock('../../../component-library/components-temp/TabEmptyState', () => ({ + TabEmptyState: ({ + icon, + description, + actionButtonText, + actionButtonProps, + testID, + }: { + icon?: React.ReactNode; + description?: string; + actionButtonText?: string; + actionButtonProps?: { onPress: () => void }; + testID?: string; + }) => { + const { View, Text, TouchableOpacity } = jest.requireActual('react-native'); + return ( + + {icon && {icon}} + {description && ( + {description} + )} + {actionButtonText && actionButtonProps && ( + + {actionButtonText} + + )} + + ); + }, +})); + +describe('TokensEmptyState', () => { + const initialState = { + engine: { + backgroundState, + }, + user: { + appTheme: 'light', + }, + }; + + const store = mockStore(initialState); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', () => { + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('tab-empty-state')).toBeOnTheScreen(); + }); + + it('renders empty state icon', () => { + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('empty-state-icon')).toBeOnTheScreen(); + }); + + it('renders empty state description text', () => { + const { getByText } = render( + + + , + ); + + expect(getByText('Tokens you hold will appear here.')).toBeOnTheScreen(); + }); + + it('renders action button with correct text', () => { + const { getByText } = render( + + + , + ); + + expect(getByText('Show tokens without balance')).toBeOnTheScreen(); + }); + + it('navigates to general settings when action button is pressed', () => { + const { getByTestId } = render( + + + , + ); + + const actionButton = getByTestId('empty-state-action-button'); + fireEvent.press(actionButton); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.SETTINGS_VIEW, { + screen: Routes.ONBOARDING.GENERAL_SETTINGS, + }); + }); + + it('passes additional props to TabEmptyState', () => { + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('custom-empty-state')).toBeOnTheScreen(); + }); +}); diff --git a/app/components/UI/TokensEmptyState/TokensEmptyState.tsx b/app/components/UI/TokensEmptyState/TokensEmptyState.tsx new file mode 100644 index 000000000000..bb592e2511d3 --- /dev/null +++ b/app/components/UI/TokensEmptyState/TokensEmptyState.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Image } from 'react-native'; +import { + TabEmptyState, + type TabEmptyStateProps, +} from '../../../component-library/components-temp/TabEmptyState'; +import { useAssetFromTheme } from '../../../util/theme'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { useNavigation } from '@react-navigation/native'; +import { strings } from '../../../../locales/i18n'; +import Routes from '../../../constants/navigation/Routes'; +import emptyStateDefiLight from '../../../images/empty-state-defi-light.png'; +import emptyStateDefiDark from '../../../images/empty-state-defi-dark.png'; + +interface TokensEmptyStateProps extends TabEmptyStateProps {} + +export const TokensEmptyState: React.FC = ({ + ...props +}) => { + const tokensImage = useAssetFromTheme( + emptyStateDefiLight, + emptyStateDefiDark, + ); + const tw = useTailwind(); + const navigation = useNavigation(); + + const handleLink = () => { + navigation.navigate(Routes.SETTINGS_VIEW, { + screen: Routes.ONBOARDING.GENERAL_SETTINGS, + }); + }; + + return ( + + } + description={strings('wallet.tokens_empty_description')} + actionButtonText={strings('wallet.show_tokens_without_balance')} + actionButtonProps={{ + onPress: handleLink, + }} + {...props} + /> + ); +}; diff --git a/app/components/UI/TokensEmptyState/index.ts b/app/components/UI/TokensEmptyState/index.ts new file mode 100644 index 000000000000..6e3913ee281c --- /dev/null +++ b/app/components/UI/TokensEmptyState/index.ts @@ -0,0 +1 @@ +export { TokensEmptyState } from './TokensEmptyState'; diff --git a/app/components/Views/Asset/__snapshots__/index.test.js.snap b/app/components/Views/Asset/__snapshots__/index.test.js.snap index a0ae7a07dbba..cb3a3144034c 100644 --- a/app/components/Views/Asset/__snapshots__/index.test.js.snap +++ b/app/components/Views/Asset/__snapshots__/index.test.js.snap @@ -1639,7 +1639,6 @@ exports[`Asset Multichain Functionality should exclude mixed token/SOL transacti style={ { "alignItems": "center", - "flex": 1, "flexDirection": "row", "height": 64, } @@ -3413,7 +3412,6 @@ exports[`Asset Multichain Functionality should exclude transactions with empty a style={ { "alignItems": "center", - "flex": 1, "flexDirection": "row", "height": 64, } @@ -5187,7 +5185,6 @@ exports[`Asset Multichain Functionality should filter SPL token transactions cor style={ { "alignItems": "center", - "flex": 1, "flexDirection": "row", "height": 64, } @@ -7002,7 +6999,6 @@ exports[`Asset Multichain Functionality should filter native SOL transactions co style={ { "alignItems": "center", - "flex": 1, "flexDirection": "row", "height": 64, } @@ -8776,7 +8772,6 @@ exports[`Asset Multichain Functionality should handle state with no multichain t style={ { "alignItems": "center", - "flex": 1, "flexDirection": "row", "height": 64, } @@ -10550,7 +10545,6 @@ exports[`Asset Multichain Functionality should handle unknown SPL token filterin style={ { "alignItems": "center", - "flex": 1, "flexDirection": "row", "height": 64, } @@ -12895,7 +12889,6 @@ exports[`Asset Multichain Functionality should render non-EVM assets with Multic style={ { "alignItems": "center", - "flex": 1, "flexDirection": "row", "height": 64, } @@ -14669,7 +14662,6 @@ exports[`Asset Multichain Functionality should sort filtered transactions by tim style={ { "alignItems": "center", - "flex": 1, "flexDirection": "row", "height": 64, } diff --git a/app/components/Views/Settings/AdvancedSettings/__snapshots__/index.test.tsx.snap b/app/components/Views/Settings/AdvancedSettings/__snapshots__/index.test.tsx.snap index 0db613e09e2a..a7b7e46d8d6b 100644 --- a/app/components/Views/Settings/AdvancedSettings/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/Settings/AdvancedSettings/__snapshots__/index.test.tsx.snap @@ -781,7 +781,7 @@ exports[`AdvancedSettings should render correctly 1`] = ` } } > - Select this to show fiat conversion on test networks + Select this to show fiat conversion on test networks. - View the list of active WalletConnect sessions + View the list of active WalletConnect sessions. - Currency conversion, primary currency, language and search engine + Currency conversion, primary currency, language and search engine. - Access developer features, reset account, setup testnets, state logs, IPFS gateway and custom RPC + Access developer features, reset account, setup testnets, state logs, IPFS gateway and custom RPC. - Manage the permissions given to sites and apps + Manage the permissions given to sites and apps. - Add, edit, remove, and manage your accounts + Add, edit, remove, and manage your accounts. { expect(getByTestId('tokens-component')).toBeOnTheScreen(); }); - it('renders tokens component with isFullView prop', () => { - // Arrange - const { getByTestId } = renderScreen(TokensFullView, { - name: 'TokensFullView', - }); - - // Act & Assert - expect(getByTestId('tokens-component')).toBeOnTheScreen(); - }); - it('calls goBack when back button is pressed', () => { // Arrange const { getByTestId } = renderScreen(TokensFullView, { @@ -76,23 +66,10 @@ describe('TokensFullView', () => { }); // Act - const backButton = getByTestId('header').find( - (element) => element.type?.toString() === 'TouchableOpacity', - ); - backButton?.props.onPress(); + const backButton = getByTestId('back-button'); + backButton.props.onPress(); // Assert expect(mockGoBack).toHaveBeenCalledTimes(1); }); - - it('displays correct header title', () => { - // Arrange - const { getByTestId } = renderScreen(TokensFullView, { - name: 'TokensFullView', - }); - - // Act & Assert - const headerTitle = getByTestId('header-title'); - expect(headerTitle).toBeOnTheScreen(); - }); }); diff --git a/app/components/Views/TokensFullView/TokensFullView.tsx b/app/components/Views/TokensFullView/TokensFullView.tsx index 2d4af3e36378..f421b37e4b7f 100644 --- a/app/components/Views/TokensFullView/TokensFullView.tsx +++ b/app/components/Views/TokensFullView/TokensFullView.tsx @@ -35,9 +35,10 @@ const TokensFullView = () => { size={ButtonIconSizes.Lg} onPress={handleBackPress} iconName={IconName.ArrowLeft} + testID="back-button" /> } - includesTopInset + style={tw`p-4`} > {strings('wallet.tokens')} diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index 210c2012c7b4..23d5084f710f 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -49,6 +49,7 @@ import { ButtonVariants } from '../../../component-library/components/Buttons/Bu import CustomText, { TextColor, } from '../../../component-library/components/Texts/Text'; +import ConditionalScrollView from '../../../component-library/components-temp/ConditionalScrollView'; import { ToastContext, ToastVariants, @@ -117,6 +118,7 @@ import { Hex, KnownCaipNamespace } from '@metamask/utils'; import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController'; import { PortfolioBalance } from '../../UI/Tokens/TokenList/PortfolioBalance'; import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts'; +import { selectHomepageRedesignV1Enabled } from '../../../selectors/featureFlagController/homepage'; import AccountGroupBalance from '../../UI/Assets/components/Balance/AccountGroupBalance'; import useCheckNftAutoDetectionModal from '../../hooks/useCheckNftAutoDetectionModal'; import useCheckMultiRpcModal from '../../hooks/useCheckMultiRpcModal'; @@ -246,6 +248,9 @@ const WalletTokensTabView = React.memo((props: WalletTokensTabViewProps) => { const isMultichainAccountsState2Enabled = useSelector( selectMultichainAccountsState2Enabled, ); + const isHomepageRedesignV1Enabled = useSelector( + selectHomepageRedesignV1Enabled, + ); const isPerpsEnabled = useMemo( () => isPerpsFlagEnabled && @@ -476,7 +481,14 @@ const WalletTokensTabView = React.memo((props: WalletTokensTabViewProps) => { return ( - + {tabsToRender} @@ -1064,6 +1076,9 @@ const Wallet = ({ const shouldDisplayCardButton = useSelector(selectDisplayCardButton); const isRewardsEnabled = useSelector(selectRewardsEnabledFlag); + const isHomepageRedesignV1Enabled = useSelector( + selectHomepageRedesignV1Enabled, + ); useEffect(() => { if (!selectedInternalAccount) return; @@ -1288,80 +1303,64 @@ const Wallet = ({ basicFunctionalityEnabled && assetsDefiPositionsEnabled; - const renderContent = useCallback( - () => ( - - - - {!basicFunctionalityEnabled ? ( - - {strings('wallet.banner.link')} - - } - /> - ) : null} - - - <> - {isMultichainAccountsState2Enabled ? ( - - ) : ( - - )} - - - - {isCarouselBannersEnabled && } + const scrollViewContentStyle = useMemo( + () => [ + styles.wrapper, + isHomepageRedesignV1Enabled && { flex: undefined, flexGrow: 0 }, + ], + [styles.wrapper, isHomepageRedesignV1Enabled], + ); - + + + {!basicFunctionalityEnabled ? ( + + {strings('wallet.banner.link')} + + } /> - + ) : null} + - ), - [ - styles.banner, - styles.carousel, - styles.wrapper, - basicFunctionalityEnabled, - defiEnabled, - isMultichainAccountsState2Enabled, - turnOnBasicFunctionality, - onChangeTab, - navigation, - goToSwaps, - displayBuyButton, - displaySwapsButton, - onReceive, - onSend, - route.params, - isCarouselBannersEnabled, - collectiblesEnabled, - ], + <> + {isMultichainAccountsState2Enabled ? ( + + ) : ( + + )} + + + + {isCarouselBannersEnabled && } + + + + ); const renderLoader = useCallback( () => ( @@ -1375,7 +1374,24 @@ const Wallet = ({ return ( - {selectedInternalAccount ? renderContent() : renderLoader()} + {selectedInternalAccount ? ( + + + {content} + + + ) : ( + renderLoader() + )} ); diff --git a/app/components/Views/confirmations/legacy/SendFlow/Amount/__snapshots__/index.test.tsx.snap b/app/components/Views/confirmations/legacy/SendFlow/Amount/__snapshots__/index.test.tsx.snap index bf975a502d33..8a1ad45a3ea7 100644 --- a/app/components/Views/confirmations/legacy/SendFlow/Amount/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/confirmations/legacy/SendFlow/Amount/__snapshots__/index.test.tsx.snap @@ -4690,7 +4690,8 @@ exports[`Amount does not show a warning when transfering collectibles 1`] = ` "alignItems": "center", "backgroundColor": "#f3f5f9", "borderRadius": 8, - "justifyContent": "flex-start", + "flex": 1, + "justifyContent": "center", }, undefined, undefined, @@ -4739,8 +4740,8 @@ exports[`Amount does not show a warning when transfering collectibles 1`] = ` undefined, undefined, { - "flex": 1, - "marginTop": 16, + "alignItems": "center", + "justifyContent": "center", "textAlign": "center", }, ] diff --git a/app/core/Engine/messengers/bridge-controller-messenger/index.ts b/app/core/Engine/messengers/bridge-controller-messenger/index.ts index 349eeae72de1..3dd843cf455b 100644 --- a/app/core/Engine/messengers/bridge-controller-messenger/index.ts +++ b/app/core/Engine/messengers/bridge-controller-messenger/index.ts @@ -28,7 +28,6 @@ export function getBridgeControllerMessenger( actions: [ 'AccountsController:getAccountByAddress', 'SnapController:handleRequest', - 'NetworkController:getState', 'NetworkController:getNetworkClientById', 'NetworkController:findNetworkClientIdByChainId', 'TokenRatesController:getState', diff --git a/app/core/SDKConnectV2/adapters/rpc-bridge-adapter.test.ts b/app/core/SDKConnectV2/adapters/rpc-bridge-adapter.test.ts index ff907d082961..383dc4a6ea49 100644 --- a/app/core/SDKConnectV2/adapters/rpc-bridge-adapter.test.ts +++ b/app/core/SDKConnectV2/adapters/rpc-bridge-adapter.test.ts @@ -79,7 +79,10 @@ describe('RPCBridgeAdapter', () => { describe('Initialization', () => { it('should initialize lazily on first send, wait for engine, and create a client', async () => { mockedEngine.context.KeyringController.isUnlocked.mockReturnValue(true); - const request = { jsonrpc: '2.0', method: 'eth_accounts' }; + const request = { + name: 'metamask-multichain-provider', + data: { jsonrpc: '2.0', method: 'eth_accounts' }, + }; adapter.send(request); // Wait for all async operations to complete @@ -91,17 +94,20 @@ describe('RPCBridgeAdapter', () => { expect.any(Function), ); expect(MockedBackgroundBridge).toHaveBeenCalledTimes(1); - expect(backgroundBridgeInstance.onMessage).toHaveBeenCalledWith({ - name: 'metamask-multichain-provider', - data: request, - }); + expect(backgroundBridgeInstance.onMessage).toHaveBeenCalledWith(request); }); it('should be idempotent and initialize only once', async () => { mockedEngine.context.KeyringController.isUnlocked.mockReturnValue(true); - adapter.send({ method: 'test1' }); - adapter.send({ method: 'test2' }); + adapter.send({ + name: 'metamask-multichain-provider', + data: { method: 'test1' }, + }); + adapter.send({ + name: 'metamask-multichain-provider', + data: { method: 'test2' }, + }); // Wait for all async operations to complete await new Promise(process.nextTick); @@ -114,7 +120,10 @@ describe('RPCBridgeAdapter', () => { describe('Message Queuing and Processing', () => { it('should queue requests when the wallet is locked', async () => { mockedEngine.context.KeyringController.isUnlocked.mockReturnValue(false); - const request = { method: 'test_locked' }; + const request = { + name: 'metamask-multichain-provider', + data: { method: 'test_locked' }, + }; adapter.send(request); // Wait for all async operations to complete @@ -127,8 +136,14 @@ describe('RPCBridgeAdapter', () => { it('should process the queue when the wallet is unlocked', async () => { // Start locked mockedEngine.context.KeyringController.isUnlocked.mockReturnValue(false); - const request1 = { method: 'test_queued1' }; - const request2 = { method: 'test_queued2' }; + const request1 = { + name: 'metamask-multichain-provider', + data: { method: 'test_queued1' }, + }; + const request2 = { + name: 'metamask-multichain-provider', + data: { method: 'test_queued2' }, + }; adapter.send(request1); adapter.send(request2); @@ -144,28 +159,22 @@ describe('RPCBridgeAdapter', () => { await new Promise(process.nextTick); expect(backgroundBridgeInstance.onMessage).toHaveBeenCalledTimes(2); - expect(backgroundBridgeInstance.onMessage).toHaveBeenCalledWith({ - name: 'metamask-multichain-provider', - data: request1, - }); - expect(backgroundBridgeInstance.onMessage).toHaveBeenCalledWith({ - name: 'metamask-multichain-provider', - data: request2, - }); + expect(backgroundBridgeInstance.onMessage).toHaveBeenCalledWith(request1); + expect(backgroundBridgeInstance.onMessage).toHaveBeenCalledWith(request2); }); it('should process requests immediately if already unlocked', async () => { mockedEngine.context.KeyringController.isUnlocked.mockReturnValue(true); - const request = { method: 'test_unlocked' }; + const request = { + name: 'metamask-multichain-provider', + data: { method: 'test_unlocked' }, + }; adapter.send(request); // Wait for all async operations to complete await new Promise(process.nextTick); - expect(backgroundBridgeInstance.onMessage).toHaveBeenCalledWith({ - name: 'metamask-multichain-provider', - data: request, - }); + expect(backgroundBridgeInstance.onMessage).toHaveBeenCalledWith(request); }); }); @@ -197,7 +206,10 @@ describe('RPCBridgeAdapter', () => { // Add another item to the queue to test if it gets cleared mockedEngine.context.KeyringController.isUnlocked.mockReturnValue(false); - adapter.send({ method: 'test_dispose' }); + adapter.send({ + name: 'metamask-multichain-provider', + data: { method: 'test_dispose' }, + }); adapter.dispose(); diff --git a/app/core/SDKConnectV2/adapters/rpc-bridge-adapter.ts b/app/core/SDKConnectV2/adapters/rpc-bridge-adapter.ts index 344204098fc8..112c1a2b74f9 100644 --- a/app/core/SDKConnectV2/adapters/rpc-bridge-adapter.ts +++ b/app/core/SDKConnectV2/adapters/rpc-bridge-adapter.ts @@ -94,10 +94,7 @@ export class RPCBridgeAdapter while (this.queue.length > 0) { const request = this.queue.shift(); - this.client.onMessage({ - name: 'metamask-multichain-provider', - data: request, - }); + this.client.onMessage(request); } this.processing = false; diff --git a/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV1.test.ts b/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV1.test.ts new file mode 100644 index 000000000000..3cc4049fa2fe --- /dev/null +++ b/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV1.test.ts @@ -0,0 +1,145 @@ +import { + RampsUnifiedBuyV1Config, + selectRampsUnifiedBuyV1Config, + selectRampsUnifiedBuyV1ActiveFlag, + selectRampsUnifiedBuyV1MinimumVersionFlag, +} from './rampsUnifiedBuyV1'; +import { selectRemoteFeatureFlags } from '..'; +import { FeatureFlags } from '@metamask/remote-feature-flag-controller'; + +describe('RampsUnifiedBuyV1 selectors', () => { + const mockRemoteFeatureFlags: ReturnType & { + rampsUnifiedBuyV1: RampsUnifiedBuyV1Config; + } = { + rampsUnifiedBuyV1: { + active: true, + minimumVersion: '2.0.0', + }, + }; + + const mockEmptyRemoteFeatureFlags = {}; + + describe('selectRampsUnifiedBuyV1Config', () => { + it('returns the rampsUnifiedBuyV1Config when it exists', () => { + const result = selectRampsUnifiedBuyV1Config.resultFunc( + mockRemoteFeatureFlags, + ); + expect(result).toEqual(mockRemoteFeatureFlags.rampsUnifiedBuyV1); + }); + + it('returns an empty object when rampsUnifiedBuyV1Config does not exist', () => { + const result = selectRampsUnifiedBuyV1Config.resultFunc( + mockEmptyRemoteFeatureFlags, + ); + expect(result).toEqual({}); + }); + + it('returns an empty object when remoteFeatureFlags is null', () => { + const result = selectRampsUnifiedBuyV1Config.resultFunc( + null as unknown as FeatureFlags, + ); + expect(result).toEqual({}); + }); + + it('returns an empty object when remoteFeatureFlags is undefined', () => { + const result = selectRampsUnifiedBuyV1Config.resultFunc( + undefined as unknown as FeatureFlags, + ); + expect(result).toEqual({}); + }); + }); + + describe('selectRampsUnifiedBuyV1ActiveFlag', () => { + it('returns true when active is set to true', () => { + const result = selectRampsUnifiedBuyV1ActiveFlag.resultFunc( + mockRemoteFeatureFlags.rampsUnifiedBuyV1, + ); + expect(result).toBe(true); + }); + + it('returns false when active is set to false', () => { + const mockConfigWithActiveFalse: RampsUnifiedBuyV1Config = { + active: false, + minimumVersion: '2.0.0', + }; + const result = selectRampsUnifiedBuyV1ActiveFlag.resultFunc( + mockConfigWithActiveFalse, + ); + expect(result).toBe(false); + }); + + it('returns false when active is not set', () => { + const result = selectRampsUnifiedBuyV1ActiveFlag.resultFunc({}); + expect(result).toBe(false); + }); + + it('returns false when active is null', () => { + const mockConfigWithActiveNull: RampsUnifiedBuyV1Config = { + active: null as unknown as boolean, + minimumVersion: '2.0.0', + }; + const result = selectRampsUnifiedBuyV1ActiveFlag.resultFunc( + mockConfigWithActiveNull, + ); + expect(result).toBe(false); + }); + + it('returns false when active is undefined', () => { + const mockConfigWithActiveUndefined: RampsUnifiedBuyV1Config = { + active: undefined as unknown as boolean, + minimumVersion: '2.0.0', + }; + const result = selectRampsUnifiedBuyV1ActiveFlag.resultFunc( + mockConfigWithActiveUndefined, + ); + expect(result).toBe(false); + }); + }); + + describe('selectRampsUnifiedBuyV1MinimumVersionFlag', () => { + it('returns the minimumVersion when it exists', () => { + const result = selectRampsUnifiedBuyV1MinimumVersionFlag.resultFunc( + mockRemoteFeatureFlags.rampsUnifiedBuyV1, + ); + expect(result).toBe('2.0.0'); + }); + + it('returns null when minimumVersion is not set', () => { + const result = selectRampsUnifiedBuyV1MinimumVersionFlag.resultFunc({}); + expect(result).toBeNull(); + }); + + it('returns null when minimumVersion is null', () => { + const mockConfigWithVersionNull: RampsUnifiedBuyV1Config = { + active: true, + minimumVersion: null as unknown as string, + }; + const result = selectRampsUnifiedBuyV1MinimumVersionFlag.resultFunc( + mockConfigWithVersionNull, + ); + expect(result).toBeNull(); + }); + + it('returns null when minimumVersion is undefined', () => { + const mockConfigWithVersionUndefined: RampsUnifiedBuyV1Config = { + active: true, + minimumVersion: undefined, + }; + const result = selectRampsUnifiedBuyV1MinimumVersionFlag.resultFunc( + mockConfigWithVersionUndefined, + ); + expect(result).toBeNull(); + }); + + it('returns the minimumVersion when it is an empty string', () => { + const mockConfigWithEmptyVersion: RampsUnifiedBuyV1Config = { + active: true, + minimumVersion: '', + }; + const result = selectRampsUnifiedBuyV1MinimumVersionFlag.resultFunc( + mockConfigWithEmptyVersion, + ); + expect(result).toBe(''); + }); + }); +}); diff --git a/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV1.ts b/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV1.ts new file mode 100644 index 000000000000..ddfde994e197 --- /dev/null +++ b/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV1.ts @@ -0,0 +1,34 @@ +import { createSelector } from 'reselect'; +import { selectRemoteFeatureFlags } from '..'; + +export interface RampsUnifiedBuyV1Config { + active?: boolean; + minimumVersion?: string; +} + +const FLAG_KEY = 'rampsUnifiedBuyV1'; + +export const selectRampsUnifiedBuyV1Config = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + const rampsUnifiedBuyV1Config = remoteFeatureFlags?.[FLAG_KEY]; + return (rampsUnifiedBuyV1Config ?? {}) as RampsUnifiedBuyV1Config; + }, +); + +export const selectRampsUnifiedBuyV1ActiveFlag = createSelector( + selectRampsUnifiedBuyV1Config, + (rampsUnifiedBuyV1Config) => { + const rampsUnifiedBuyV1ActiveFlag = rampsUnifiedBuyV1Config?.active; + return rampsUnifiedBuyV1ActiveFlag ?? false; + }, +); + +export const selectRampsUnifiedBuyV1MinimumVersionFlag = createSelector( + selectRampsUnifiedBuyV1Config, + (rampsUnifiedBuyV1Config) => { + const rampsUnifiedBuyV1MinimumVersion = + rampsUnifiedBuyV1Config?.minimumVersion; + return rampsUnifiedBuyV1MinimumVersion ?? null; + }, +); diff --git a/app/util/bridge/hooks/useValidateBridgeTx.ts b/app/util/bridge/hooks/useValidateBridgeTx.ts index e18fbd09c332..f49850d0bebd 100644 --- a/app/util/bridge/hooks/useValidateBridgeTx.ts +++ b/app/util/bridge/hooks/useValidateBridgeTx.ts @@ -10,12 +10,15 @@ export default function useValidateBridgeTx() { const validateBridgeTx = async ({ quoteResponse, + signal, }: { quoteResponse: QuoteResponse & QuoteMetadata; + signal?: AbortSignal; }) => { const response = await fetch( `${AppConstants.SECURITY_ALERTS_API.URL}/solana/message/scan`, { + signal, headers: { 'Content-Type': 'application/json', accept: 'application/json', diff --git a/babel.config.tests.js b/babel.config.tests.js index acf822dc6206..88ec7910080e 100644 --- a/babel.config.tests.js +++ b/babel.config.tests.js @@ -23,6 +23,8 @@ const newOverrides = [ 'app/components/UI/Ramp/Deposit/sdk/getSdkEnvironment.test.ts', 'app/components/UI/Ramp/Aggregator/sdk/getSdkEnvironment.ts', 'app/components/UI/Ramp/Aggregator/sdk/getSdkEnvironment.test.ts', + 'app/components/UI/Ramp/hooks/useRampsUnifiedV1Enabled.ts', + 'app/components/UI/Ramp/hooks/useRampsUnifiedV1Enabled.test.ts', 'app/store/migrations/**', 'app/util/networks/customNetworks.tsx', ], diff --git a/e2e/api-mocking/helpers/remoteFeatureFlagsHelper.ts b/e2e/api-mocking/helpers/remoteFeatureFlagsHelper.ts index 33c93b558297..3bacec39de49 100644 --- a/e2e/api-mocking/helpers/remoteFeatureFlagsHelper.ts +++ b/e2e/api-mocking/helpers/remoteFeatureFlagsHelper.ts @@ -270,6 +270,12 @@ const DEFAULT_FEATURE_FLAGS_ARRAY: Record[] = [ { additionalNetworksBlacklist: [], // Empty by default, can be overridden in tests }, + { + rampsUnifiedBuyV1: { + minimumVersion: '7.61.0', + active: false, + }, + }, ]; /** diff --git a/e2e/pages/wallet/NetworkManager.ts b/e2e/pages/wallet/NetworkManager.ts index 95419dfb6dfb..22e093220077 100644 --- a/e2e/pages/wallet/NetworkManager.ts +++ b/e2e/pages/wallet/NetworkManager.ts @@ -119,9 +119,10 @@ class NetworkManager { /** * Get token element by symbol + * Note: Gets the first instance in case of duplicates during render cycles */ getTokenBySymbol(symbol: string): DetoxElement { - return Matchers.getElementByID(`asset-${symbol}`); + return Matchers.getElementByID(`asset-${symbol}`, 0); } /** diff --git a/e2e/selectors/wallet/WalletView.selectors.ts b/e2e/selectors/wallet/WalletView.selectors.ts index 43eda1eb99ef..f2edfc01b53c 100644 --- a/e2e/selectors/wallet/WalletView.selectors.ts +++ b/e2e/selectors/wallet/WalletView.selectors.ts @@ -75,6 +75,7 @@ export const WalletViewSelectorsIDs = { DEFI_POSITIONS_CONTAINER: 'defi-positions-container', DEFI_POSITIONS_NETWORK_FILTER: 'defi-positions-network-filter', DEFI_POSITIONS_LIST: 'defi-positions-list', + DEFI_POSITIONS_SCROLL_VIEW: 'defi-positions-scroll-view', DEFI_POSITIONS_DETAILS_CONTAINER: 'defi-positions-details-container', // Wallet-specific action buttons to avoid conflicts with TokenOverview WALLET_BUY_BUTTON: 'wallet-buy-button', diff --git a/locales/languages/en.json b/locales/languages/en.json index 720be8004ae3..3488b52ec0a8 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -2046,6 +2046,7 @@ "learn_more": "Learn more", "add_collectibles": "Import NFTs", "nft_empty_description": "There's a world of NFTs out there. Start your collection today.", + "tokens_empty_description": "Nothing to see yet. Why not browse tokens or make a trade?", "discover_nfts": "Import NFTs", "no_transactions": "You have no transactions!", "switch_network_to_view_transactions": "Please switch network to view transactions", @@ -2503,7 +2504,7 @@ "nft_autodetect_mode": "Autodetect NFTs", "nft_autodetect_desc": "Displaying NFT media & data may expose your IP address to centralized servers. Third-party APIs (like OpenSea) are used to detect NFTs in your wallet. This exposes your account address with those services. Leave this disabled if you don't want the app to pull data from those services.", "show_fiat_on_testnets": "Show conversion on test networks", - "show_fiat_on_testnets_desc": "Select this to show fiat conversion on test networks", + "show_fiat_on_testnets_desc": "Select this to show fiat conversion on test networks.", "show_fiat_on_testnets_modal_title": "Be careful", "show_fiat_on_testnets_modal_description": "If you've been asked to turn this feature on, you might be getting scammed. These tokens have no monetary value and are for testing purposes only. This feature helps developers make sure their apps work.", "show_fiat_on_testnets_modal_learn_more": "Learn more.", @@ -2517,9 +2518,9 @@ "jazzicons": "Jazzicons", "blockies": "Blockies", "general_title": "General", - "general_desc": "Currency conversion, primary currency, language and search engine", + "general_desc": "Currency conversion, primary currency, language and search engine.", "advanced_title": "Advanced", - "advanced_desc": "Access developer features, reset account, setup testnets, state logs, IPFS gateway and custom RPC", + "advanced_desc": "Access developer features, reset account, setup testnets, state logs, IPFS gateway and custom RPC.", "notifications_title": "Notifications", "notifications_desc": "Manage your notifications", "allow_notifications": "Allow notifications", @@ -2543,9 +2544,9 @@ "perps_title": "Perps trading" }, "contacts_title": "Contacts", - "contacts_desc": "Add, edit, remove, and manage your accounts", + "contacts_desc": "Add, edit, remove, and manage your accounts.", "permissions_title": "Permissions", - "permissions_desc": "Manage the permissions given to sites and apps", + "permissions_desc": "Manage the permissions given to sites and apps.", "no_permissions": "No permissions", "no_permissions_desc": "If you connect an account to a site or an app, you’ll see it here.", "security_title": "Security & Privacy", @@ -4243,13 +4244,13 @@ }, "experimental_settings": { "wallet_connect_dapps": "WalletConnect Sessions", - "wallet_connect_dapps_desc": "View the list of active WalletConnect sessions", + "wallet_connect_dapps_desc": "View the list of active WalletConnect sessions.", "wallet_connect_dapps_cta": "View sessions", "network_not_supported": "Current network not supported", "select_provider": "Select your preferred provider", "switch_network": "Please switch to mainnet or sepolia", "card_title": "Always show MetaMask Card button", - "card_desc": "MetaMask Card is only available to residents of select countries" + "card_desc": "MetaMask Card is only available to residents of select countries." }, "walletconnect_sessions": { "no_active_sessions": "You have no active sessions", diff --git a/package.json b/package.json index 20577bae664f..cf5cff29f17c 100644 --- a/package.json +++ b/package.json @@ -201,7 +201,7 @@ "@metamask/assets-controllers": "^84.0.0", "@metamask/base-controller": "^9.0.0", "@metamask/bitcoin-wallet-snap": "^1.4.3", - "@metamask/bridge-controller": "^56.0.0", + "@metamask/bridge-controller": "^56.0.3", "@metamask/bridge-status-controller": "^56.0.0", "@metamask/chain-agnostic-permission": "^1.2.2", "@metamask/composable-controller": "^12.0.0", diff --git a/scripts/build.sh b/scripts/build.sh index 9c1be7cdbd94..d6fe322c5b96 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -470,6 +470,20 @@ generateIosBinary() { scheme="$1" configuration="${CONFIGURATION:-"Release"}" + # Check if configuration is valid + if [ "$configuration" != "Debug" ] && [ "$configuration" != "Release" ] ; then + # Configuration is not recognized + echo "Configuration $configuration is not recognized! Only Debug and Release are supported" + exit 1 + fi + + # Check if scheme is valid + if [ "$scheme" != "MetaMask" ] && [ "$scheme" != "MetaMask-QA" ] && [ "$scheme" != "MetaMask-Flask" ] ; then + # Scheme is not recognized + echo "Scheme $scheme is not recognized! Only MetaMask, MetaMask-QA, and MetaMask-Flask are supported" + exit 1 + fi + if [ "$scheme" = "MetaMask" ] ; then # Main target if [ "$configuration" = "Debug" ] ; then @@ -520,17 +534,27 @@ generateAndroidBinary() { # Debug or Release configuration="${CONFIGURATION:-"Release"}" + # Check if configuration is valid + if [ "$configuration" != "Debug" ] && [ "$configuration" != "Release" ] ; then + # Configuration is not recognized + echo "Configuration $configuration is not recognized! Only Debug and Release are supported" + exit 1 + fi + + # Check if flavor is valid + if [ "$flavor" != "Prod" ] && [ "$flavor" != "Flask" ] && [ "$flavor" != "Qa" ] ; then + # Flavor is not recognized + echo "Flavor $flavor is not recognized! Only Prod, Flask, and Qa (Deprecated - Do not use) are supported" + exit 1 + fi + # Create flavor configuration flavorConfiguration="app:assemble${flavor}${configuration}" - # Create test configuration - testConfiguration="app:assemble${flavor}${configuration}AndroidTest" - # Generate Android binary echo "Generating Android binary for ($flavor) flavor with ($configuration) configuration" - ./gradlew $flavorConfiguration $testConfiguration --build-cache --parallel - - if [ "$configuration" = "Release" ] ; then + # Generate Android binary + ./gradlew $flavorConfiguration --build-cache --parallel # Generate AAB bundle bundleConfiguration="bundle${flavor}Release" echo "Generating AAB bundle for ($flavor) flavor with ($configuration) configuration" @@ -541,6 +565,11 @@ generateAndroidBinary() { checkSumCommand="build:android:checksum:${lowerCaseFlavor}" echo "Generating checksum for ($flavor) flavor with ($configuration) configuration" yarn $checkSumCommand + elif [ "$configuration" = "Debug" ] ; then + # Create test configuration + testConfiguration="app:assemble${flavor}DebugAndroidTest" + # Generate Android binary + ./gradlew $flavorConfiguration $testConfiguration --build-cache --parallel fi # Change directory back out diff --git a/yarn.lock b/yarn.lock index 85eca594b1f8..bf9568b0fdaf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7058,9 +7058,9 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-controller@npm:^56.0.0": - version: 56.0.0 - resolution: "@metamask/bridge-controller@npm:56.0.0" +"@metamask/bridge-controller@npm:^56.0.3": + version: 56.0.3 + resolution: "@metamask/bridge-controller@npm:56.0.3" dependencies: "@ethersproject/address": "npm:^5.7.0" "@ethersproject/bignumber": "npm:^5.7.0" @@ -7086,7 +7086,7 @@ __metadata: "@metamask/remote-feature-flag-controller": ^2.0.0 "@metamask/snaps-controllers": ^14.0.0 "@metamask/transaction-controller": ^61.0.0 - checksum: 10/d63591ddb50565c969447050a5c8f7f12fade69fb30e884e87c24c2d86803074d7a337df42c4f9feaf91d01241baaba7da0b23101a993547af8331085c8df1a5 + checksum: 10/399a53ec01e18a9b9add4242b12c42ace472372fc04ed6e21d6e65f277ecc4aa220456933547d8d838e8834927602e7b1c149a8264de8742367d051801573bbd languageName: node linkType: hard @@ -34279,7 +34279,7 @@ __metadata: "@metamask/auto-changelog": "npm:^5.1.0" "@metamask/base-controller": "npm:^9.0.0" "@metamask/bitcoin-wallet-snap": "npm:^1.4.3" - "@metamask/bridge-controller": "npm:^56.0.0" + "@metamask/bridge-controller": "npm:^56.0.3" "@metamask/bridge-status-controller": "npm:^56.0.0" "@metamask/browser-passworder": "npm:^5.0.0" "@metamask/build-utils": "npm:^3.0.0"