diff --git a/app/actions/networkConnectionBanner/index.test.ts b/app/actions/networkConnectionBanner/index.test.ts index 3c65a7bbaaf5..046fa48a9313 100644 --- a/app/actions/networkConnectionBanner/index.test.ts +++ b/app/actions/networkConnectionBanner/index.test.ts @@ -24,22 +24,25 @@ describe('networkConnectionBanner actions', () => { status: 'degraded' as const, networkName: 'Ethereum Mainnet', rpcUrl: 'https://mainnet.infura.io/v3/123', + isInfuraEndpoint: true, }, { chainId: '0x89', status: 'unavailable' as const, networkName: 'Polygon Mainnet', rpcUrl: 'https://polygon-rpc.com', + isInfuraEndpoint: false, }, ] as const)( 'should create an action to show the network connection banner with valid chainId, status, networkName and rpcUrl for $status status', - ({ chainId, status, networkName, rpcUrl }) => { + ({ chainId, status, networkName, rpcUrl, isInfuraEndpoint }) => { expect( showNetworkConnectionBanner({ chainId, status, networkName, rpcUrl, + isInfuraEndpoint, }), ).toStrictEqual({ type: NetworkConnectionBannerActionType.SHOW_NETWORK_CONNECTION_BANNER, @@ -47,21 +50,24 @@ describe('networkConnectionBanner actions', () => { status, networkName, rpcUrl, + isInfuraEndpoint, }); }, ); - it('should require chainId, status, networkName and rpcUrl parameters', () => { + it('should require chainId, status, networkName, rpcUrl, and isInfuraEndpoint parameters', () => { const chainId = '0x1'; const status: NetworkConnectionBannerStatus = 'degraded'; const networkName = 'Ethereum Mainnet'; const rpcUrl = 'https://mainnet.infura.io/v3/123'; + const isInfuraEndpoint = true; const action = showNetworkConnectionBanner({ chainId, status, networkName, rpcUrl, + isInfuraEndpoint, }); expect(action.chainId).toBe(chainId); @@ -74,6 +80,7 @@ describe('networkConnectionBanner actions', () => { 'status', 'networkName', 'rpcUrl', + 'isInfuraEndpoint', ]); }); }); diff --git a/app/actions/networkConnectionBanner/index.ts b/app/actions/networkConnectionBanner/index.ts index 19e0bf9b59bc..4c1325ae29ff 100644 --- a/app/actions/networkConnectionBanner/index.ts +++ b/app/actions/networkConnectionBanner/index.ts @@ -20,6 +20,7 @@ export interface ShowNetworkConnectionBannerAction extends Action { status: NetworkConnectionBannerStatus; networkName: string; rpcUrl: string; + isInfuraEndpoint: boolean; } /** @@ -45,11 +46,13 @@ export function showNetworkConnectionBanner({ status, networkName, rpcUrl, + isInfuraEndpoint, }: { chainId: Hex; status: NetworkConnectionBannerStatus; networkName: string; rpcUrl: string; + isInfuraEndpoint: boolean; }): ShowNetworkConnectionBannerAction { return { type: NetworkConnectionBannerActionType.SHOW_NETWORK_CONNECTION_BANNER, @@ -57,6 +60,7 @@ export function showNetworkConnectionBanner({ status, networkName, rpcUrl, + isInfuraEndpoint, }; } diff --git a/app/components/UI/NetworkConnectionBanner/NetworkConnectionBanner.test.tsx b/app/components/UI/NetworkConnectionBanner/NetworkConnectionBanner.test.tsx index e77eb091bc1e..05df0c634990 100644 --- a/app/components/UI/NetworkConnectionBanner/NetworkConnectionBanner.test.tsx +++ b/app/components/UI/NetworkConnectionBanner/NetworkConnectionBanner.test.tsx @@ -1,51 +1,53 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; +import { fireEvent } from '@testing-library/react-native'; -import NetworkConnectionBanner from './NetworkConnectionBanner'; import { useNetworkConnectionBanner } from '../../hooks/useNetworkConnectionBanner'; +import NetworkConnectionBanner from './NetworkConnectionBanner'; +import renderWithProvider from '../../../util/test/renderWithProvider'; jest.mock('../../hooks/useNetworkConnectionBanner'); -jest.mock('../AnimatedSpinner', () => ({ - __esModule: true, - default: ({ size }: { size: Record }) => { - const { View, Text } = jest.requireActual('react-native'); - return ( - - {size} - - ); - }, - SpinnerSize: { - SM: 'SM', - }, +jest.mock('../../../util/theme', () => ({ + useAppTheme: jest.fn(() => ({ + colors: { + background: { + section: '#FFFFFF', + }, + icon: { + default: '#000000', + }, + error: { + muted: '#FFE5E5', + default: '#FF0000', + }, + }, + themeAppearance: 'light', + typography: {}, + shadows: {}, + brandColors: {}, + })), })); -jest.mock('../../../component-library/components/Icons/Icon', () => ({ - __esModule: true, - default: ({ size }: { size: Record }) => { +// Necessary because we mock SVGs by default +jest.mock('@metamask/design-system-react-native', () => { + const Icon = ({ size }: { size: string }) => { + // We can't import this at the top because the mock gets hoisted before any imports. const { View, Text } = jest.requireActual('react-native'); return ( {size} ); - }, - IconName: { - Danger: 'Danger', - }, - IconSize: { - Md: 'Md', - }, - IconColor: { - Default: 'Default', - }, -})); + }; + + return { + __esModule: true, + ...jest.requireActual('@metamask/design-system-react-native'), + Icon, + }; +}); -const mockuseNetworkConnectionBanner = - useNetworkConnectionBanner as jest.MockedFunction< - typeof useNetworkConnectionBanner - >; +const useNetworkConnectionBannerMock = jest.mocked(useNetworkConnectionBanner); describe('NetworkConnectionBanner', () => { const mockUpdateRpc = jest.fn(); @@ -54,381 +56,119 @@ describe('NetworkConnectionBanner', () => { jest.clearAllMocks(); }); - describe('snapshots', () => { - it('should match snapshot when banner is not visible', () => { - mockuseNetworkConnectionBanner.mockReturnValue({ + describe('when banner is not visible', () => { + it('should not render when visible is false', () => { + useNetworkConnectionBannerMock.mockReturnValue({ networkConnectionBannerState: { visible: false }, updateRpc: mockUpdateRpc, }); - const { toJSON } = render(); + const { root } = renderWithProvider(); - expect(toJSON()).toMatchSnapshot(); + expect(root).toBeUndefined(); }); + }); - const statusSnapshotTestCases = [ + describe('when banner is visible', () => { + const customNetworkStatusTestCases = [ { status: 'degraded' as const, - name: 'for degraded status banner', + expectedMessage: 'Still connecting to Polygon Mainnet...', + updateRpcButtonText: 'Update RPC', }, { status: 'unavailable' as const, - name: 'for unavailable status banner', + expectedMessage: 'Unable to connect to Polygon Mainnet.', + updateRpcButtonText: 'update RPC', }, ]; - it.each(statusSnapshotTestCases)( - 'should match snapshot $name', - ({ status }) => { - mockuseNetworkConnectionBanner.mockReturnValue({ - networkConnectionBannerState: { - visible: true, - chainId: '0x1', - status, - networkName: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/test', - }, - updateRpc: mockUpdateRpc, - }); - - const { toJSON } = render(); - - expect(toJSON()).toMatchSnapshot(); - }, - ); - - it('should match snapshot for different network', () => { - mockuseNetworkConnectionBanner.mockReturnValue({ - networkConnectionBannerState: { - visible: true, - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - }, - updateRpc: mockUpdateRpc, - }); - - const { toJSON } = render(); - - expect(toJSON()).toMatchSnapshot(); - }); - }); - - describe('when banner is not visible', () => { - it('should not render when visible is false', () => { - mockuseNetworkConnectionBanner.mockReturnValue({ - networkConnectionBannerState: { visible: false }, - updateRpc: mockUpdateRpc, - }); - - const { queryByTestId } = render(); - - expect(queryByTestId('animated-spinner')).toBeNull(); - }); - }); - - describe('when banner is visible', () => { - const statusTestCases = [ + const infuraNetworkStatusTestCases = [ { status: 'degraded' as const, expectedMessage: 'Still connecting to Ethereum Mainnet...', - expectedIconTestId: 'animated-spinner', }, { status: 'unavailable' as const, expectedMessage: 'Unable to connect to Ethereum Mainnet.', - expectedIconTestId: 'icon', }, ]; - it.each(statusTestCases)( - 'should render the banner with correct structure for $status status', - ({ status, expectedMessage, expectedIconTestId }) => { - mockuseNetworkConnectionBanner.mockReturnValue({ + it.each(customNetworkStatusTestCases)( + 'should render the banner with correct structure for $status status with custom network', + ({ status, expectedMessage, updateRpcButtonText }) => { + useNetworkConnectionBannerMock.mockReturnValue({ networkConnectionBannerState: { visible: true, - chainId: '0x1', + chainId: '0x89', status, - networkName: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/test', + networkName: 'Polygon Mainnet', + rpcUrl: 'https://polygon-rpc.com', + isInfuraEndpoint: false, }, updateRpc: mockUpdateRpc, }); - const { getByTestId, getByText } = render(); + const { getByTestId, getByText } = renderWithProvider( + , + ); - expect(getByTestId(expectedIconTestId)).toBeTruthy(); + expect(getByTestId('icon')).toBeTruthy(); expect(getByText(expectedMessage)).toBeTruthy(); - expect(getByText('Update RPC')).toBeTruthy(); + expect(getByText(updateRpcButtonText)).toBeTruthy(); }, ); - it.each(statusTestCases)( - 'should display network name in the message for $status status', + it.each(infuraNetworkStatusTestCases)( + 'should render the banner with correct structure for $status status with Infura network', ({ status, expectedMessage }) => { - mockuseNetworkConnectionBanner.mockReturnValue({ + useNetworkConnectionBannerMock.mockReturnValue({ networkConnectionBannerState: { visible: true, chainId: '0x1', status, networkName: 'Ethereum Mainnet', rpcUrl: 'https://mainnet.infura.io/v3/test', + isInfuraEndpoint: true, }, updateRpc: mockUpdateRpc, }); - const { getByText } = render(); + const { getByTestId, getByText, queryByText } = renderWithProvider( + , + ); + expect(getByTestId('icon')).toBeTruthy(); expect(getByText(expectedMessage)).toBeTruthy(); + expect(queryByText('Update RPC')).toBeNull(); + expect(queryByText('update RPC')).toBeNull(); }, ); - it.each(statusTestCases)( + it.each(customNetworkStatusTestCases)( 'should call updateRpc when Update RPC button is pressed for $status status', - ({ status }) => { - mockuseNetworkConnectionBanner.mockReturnValue({ + ({ status, updateRpcButtonText }) => { + useNetworkConnectionBannerMock.mockReturnValue({ networkConnectionBannerState: { visible: true, - chainId: '0x1', + chainId: '0x89', status, - networkName: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/test', + networkName: 'Polygon Mainnet', + rpcUrl: 'https://polygon-rpc.com', + isInfuraEndpoint: false, }, updateRpc: mockUpdateRpc, }); - const { getByText } = render(); + const { getByText } = renderWithProvider(); - const updateButton = getByText('Update RPC'); + const updateButton = getByText(updateRpcButtonText); fireEvent.press(updateButton); expect(mockUpdateRpc).toHaveBeenCalledWith( - 'https://mainnet.infura.io/v3/test', - status, - '0x1', - ); - }, - ); - - it.each(statusTestCases)( - 'should render button with correct variant and properties for $status status', - ({ status }) => { - mockuseNetworkConnectionBanner.mockReturnValue({ - networkConnectionBannerState: { - visible: true, - chainId: '0x1', - status, - networkName: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/test', - }, - updateRpc: mockUpdateRpc, - }); - - const { getByText } = render(); - - const updateButton = getByText('Update RPC'); - expect(updateButton).toBeTruthy(); - }, - ); - - describe('status transitions', () => { - it('should update message when status changes from degraded to unavailable', () => { - // Start with degraded status - mockuseNetworkConnectionBanner.mockReturnValue({ - networkConnectionBannerState: { - visible: true, - chainId: '0x1', - status: 'degraded', - networkName: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/test', - }, - updateRpc: mockUpdateRpc, - }); - - const { rerender, getByText } = render(); - - expect( - getByText('Still connecting to Ethereum Mainnet...'), - ).toBeTruthy(); - - // Change to unavailable status - mockuseNetworkConnectionBanner.mockReturnValue({ - networkConnectionBannerState: { - visible: true, - chainId: '0x1', - status: 'unavailable', - networkName: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/test', - }, - updateRpc: mockUpdateRpc, - }); - - rerender(); - - expect( - getByText('Unable to connect to Ethereum Mainnet.'), - ).toBeTruthy(); - }); - - it('should update message when status changes from unavailable to degraded', () => { - // Start with unavailable status - mockuseNetworkConnectionBanner.mockReturnValue({ - networkConnectionBannerState: { - visible: true, - chainId: '0x1', - status: 'unavailable', - networkName: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/test', - }, - updateRpc: mockUpdateRpc, - }); - - const { rerender, getByText } = render(); - - expect( - getByText('Unable to connect to Ethereum Mainnet.'), - ).toBeTruthy(); - - // Change to degraded status - mockuseNetworkConnectionBanner.mockReturnValue({ - networkConnectionBannerState: { - visible: true, - chainId: '0x1', - status: 'degraded', - networkName: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/test', - }, - updateRpc: mockUpdateRpc, - }); - - rerender(); - - expect( - getByText('Still connecting to Ethereum Mainnet...'), - ).toBeTruthy(); - }); - }); - }); - - describe('with different network configurations', () => { - it('should display different network names correctly', () => { - mockuseNetworkConnectionBanner.mockReturnValue({ - networkConnectionBannerState: { - visible: true, - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - }, - updateRpc: mockUpdateRpc, - }); - - const { getByText } = render(); - - expect(getByText('Still connecting to Polygon Mainnet...')).toBeTruthy(); - }); - - it('should handle network with special characters in name', () => { - mockuseNetworkConnectionBanner.mockReturnValue({ - networkConnectionBannerState: { - visible: true, - chainId: '0x1', - status: 'degraded', - networkName: 'Test-Network (Beta)', - rpcUrl: 'https://mainnet.infura.io/v3/test', - }, - updateRpc: mockUpdateRpc, - }); - - const { getByText } = render(); - - expect( - getByText('Still connecting to Test-Network (Beta)...'), - ).toBeTruthy(); - }); - - it('should handle network with very long name', () => { - mockuseNetworkConnectionBanner.mockReturnValue({ - networkConnectionBannerState: { - visible: true, - chainId: '0x1', - status: 'degraded', - networkName: 'Very Long Network Name That Might Cause Layout Issues', - rpcUrl: 'https://mainnet.infura.io/v3/test', - }, - updateRpc: mockUpdateRpc, - }); - - const { getByText } = render(); - - expect( - getByText( - 'Still connecting to Very Long Network Name That Might Cause Layout Issues...', - ), - ).toBeTruthy(); - }); - }); - - describe('accessibility', () => { - const accessibilityTestCases = [ - { - status: 'degraded' as const, - expectedMessage: 'Still connecting to Ethereum Mainnet...', - }, - { - status: 'unavailable' as const, - expectedMessage: 'Unable to connect to Ethereum Mainnet.', - }, - ]; - - it.each(accessibilityTestCases)( - 'should render with proper accessibility structure for $status status', - ({ status, expectedMessage }) => { - mockuseNetworkConnectionBanner.mockReturnValue({ - networkConnectionBannerState: { - visible: true, - chainId: '0x1', - status, - networkName: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/test', - }, - updateRpc: mockUpdateRpc, - }); - - const { getByText } = render(); - - // The banner should be accessible with proper text content - expect(getByText(expectedMessage)).toBeTruthy(); - expect(getByText('Update RPC')).toBeTruthy(); - }, - ); - - it.each(accessibilityTestCases)( - 'should have accessible button for updating RPC for $status status', - ({ status }) => { - mockuseNetworkConnectionBanner.mockReturnValue({ - networkConnectionBannerState: { - visible: true, - chainId: '0x1', - status, - networkName: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/test', - }, - updateRpc: mockUpdateRpc, - }); - - const { getByText } = render(); - - const updateButton = getByText('Update RPC'); - expect(updateButton).toBeTruthy(); - - // Test that button is pressable - fireEvent.press(updateButton); - expect(mockUpdateRpc).toHaveBeenCalledWith( - 'https://mainnet.infura.io/v3/test', + 'https://polygon-rpc.com', status, - '0x1', + '0x89', ); }, ); @@ -449,36 +189,38 @@ describe('NetworkConnectionBanner', () => { it.each(emptyNameTestCases)( 'should handle network with empty name for $status status', ({ status, expectedMessage }) => { - mockuseNetworkConnectionBanner.mockReturnValue({ + useNetworkConnectionBannerMock.mockReturnValue({ networkConnectionBannerState: { visible: true, chainId: '0x1', status, networkName: '', rpcUrl: 'https://mainnet.infura.io/v3/test', + isInfuraEndpoint: true, }, updateRpc: mockUpdateRpc, }); - const { getByText } = render(); + const { getByText } = renderWithProvider(); expect(getByText(expectedMessage)).toBeTruthy(); }, ); it('should handle multiple rapid button presses', () => { - mockuseNetworkConnectionBanner.mockReturnValue({ + useNetworkConnectionBannerMock.mockReturnValue({ networkConnectionBannerState: { visible: true, - chainId: '0x1', + chainId: '0x89', status: 'degraded', - networkName: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/test', + networkName: 'Polygon Mainnet', + rpcUrl: 'https://polygon-rpc.com', + isInfuraEndpoint: false, }, updateRpc: mockUpdateRpc, }); - const { getByText } = render(); + const { getByText } = renderWithProvider(); const updateButton = getByText('Update RPC'); @@ -489,76 +231,10 @@ describe('NetworkConnectionBanner', () => { expect(mockUpdateRpc).toHaveBeenCalledTimes(3); expect(mockUpdateRpc).toHaveBeenCalledWith( - 'https://mainnet.infura.io/v3/test', + 'https://polygon-rpc.com', 'degraded', - '0x1', + '0x89', ); }); }); - - describe('component lifecycle', () => { - it('should update when hook values change', () => { - // Initially not visible - mockuseNetworkConnectionBanner.mockReturnValue({ - networkConnectionBannerState: { visible: false }, - updateRpc: mockUpdateRpc, - }); - - const { rerender, queryByTestId, getByTestId } = render( - , - ); - - expect(queryByTestId('animated-spinner')).toBeNull(); - - // Make it visible - mockuseNetworkConnectionBanner.mockReturnValue({ - networkConnectionBannerState: { - visible: true, - chainId: '0x1', - status: 'degraded', - networkName: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/test', - }, - updateRpc: mockUpdateRpc, - }); - - rerender(); - - expect(getByTestId('animated-spinner')).toBeTruthy(); - }); - - it('should handle hook returning different network', () => { - // Initially Ethereum - mockuseNetworkConnectionBanner.mockReturnValue({ - networkConnectionBannerState: { - visible: true, - chainId: '0x1', - status: 'degraded', - networkName: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/test', - }, - updateRpc: mockUpdateRpc, - }); - - const { rerender, getByText } = render(); - - expect(getByText('Still connecting to Ethereum Mainnet...')).toBeTruthy(); - - // Switch to Polygon - mockuseNetworkConnectionBanner.mockReturnValue({ - networkConnectionBannerState: { - visible: true, - chainId: '0x89', - status: 'degraded', - networkName: 'Polygon Mainnet', - rpcUrl: 'https://polygon-rpc.com', - }, - updateRpc: mockUpdateRpc, - }); - - rerender(); - - expect(getByText('Still connecting to Polygon Mainnet...')).toBeTruthy(); - }); - }); }); diff --git a/app/components/UI/NetworkConnectionBanner/NetworkConnectionBanner.tsx b/app/components/UI/NetworkConnectionBanner/NetworkConnectionBanner.tsx index fae62b707fe7..f84befc55aa7 100644 --- a/app/components/UI/NetworkConnectionBanner/NetworkConnectionBanner.tsx +++ b/app/components/UI/NetworkConnectionBanner/NetworkConnectionBanner.tsx @@ -1,59 +1,275 @@ -import React from 'react'; -import Banner, { - BannerAlertSeverity, - BannerVariant, -} from '../../../component-library/components/Banners/Banner'; -import Icon, { - IconName, - IconSize, -} from '../../../component-library/components/Icons/Icon'; -import { ButtonVariants } from '../../../component-library/components/Buttons/Button'; -import Spinner, { SpinnerSize } from '../AnimatedSpinner'; +import React, { useCallback, useEffect } from 'react'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { useAppTheme } from '../../../util/theme'; import { useNetworkConnectionBanner } from '../../hooks/useNetworkConnectionBanner'; import { strings } from '../../../../locales/i18n'; +import { NetworkConnectionBannerState } from '../../../reducers/networkConnectionBanner'; +import BannerBase from '../../../component-library/components/Banners/Banner/foundation/BannerBase'; +import { Theme } from '../../../util/theme/models'; +import Animated, { + cancelAnimation, + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated'; +import { Platform, Pressable } from 'react-native'; +import { + FontWeight, + Icon, + IconColor, + IconName, + IconProps, + IconSize, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; + +interface BannerIcon { + color: IconColor; + name: IconName; + component: typeof Icon | typeof SpinningIcon; +} + +// This is alternative to AnimatedSpinner that allows us to use the "loading" +// icon from the design system +const SpinningIcon = ({ twClassName, ...iconProps }: IconProps) => { + const tw = useTailwind(); + const rotation = useSharedValue(0); + const animatedRotation = useAnimatedStyle(() => ({ + transform: [{ rotate: `${rotation.value}deg` }], + })); + + useEffect(() => { + rotation.value = withRepeat( + withTiming(360, { + duration: 1000, + easing: Easing.linear, + }), + // The -1 means to run the animation infinitely + -1, + ); + + return () => { + cancelAnimation(rotation); + }; + }, [rotation]); + + return ( + + + + ); +}; + +const PrimaryMessage = ({ + primaryMessageKey, + networkConnectionBannerState, +}: { + primaryMessageKey: string; + networkConnectionBannerState: Exclude< + NetworkConnectionBannerState, + { visible: false } + >; +}) => ( + + {strings(primaryMessageKey, { + networkName: networkConnectionBannerState.networkName, + })} + +); + +const SecondaryMessage = ({ content }: { content: React.ReactNode }) => ( + + {content} + +); + +const UpdateRpcButton = ({ + isLowerCase, + isOnlyChild, + updateRpc, +}: { + isLowerCase: boolean; + isOnlyChild: boolean; + updateRpc: () => void; +}) => { + const updateRpcText = strings('network_connection_banner.update_rpc'); + + const tw = useTailwind(); + + // Not using TextButton directly because the extra Text around it seems to + // create extra vertical spacing in between lines on Android + return ( + + tw.style( + 'flex-row items-center', + pressed ? 'bg-pressed' : 'bg-transparent', + // Not sure why there are differences between platforms here, and + // why the spacing changes when other text around the button is + // present. + !isOnlyChild && + (Platform.OS === 'ios' + ? 'translate-y-[12px]' + : 'translate-y-[6px]'), + ) + } + onPress={updateRpc} + > + {({ pressed }) => ( + + {isLowerCase + ? updateRpcText[0].toLowerCase() + updateRpcText.slice(1) + : updateRpcText} + + )} + + ); +}; + +const getBannerContent = ( + theme: Theme, + networkConnectionBannerState: Exclude< + NetworkConnectionBannerState, + { visible: false } + >, + updateRpc: () => void, +): { + primaryMessage: React.ReactNode; + secondaryMessage: React.ReactNode; + backgroundColor: string; + icon: BannerIcon; +} => { + if (networkConnectionBannerState.status === 'degraded') { + const primaryMessage = ( + + ); + const secondaryMessage = + networkConnectionBannerState.isInfuraEndpoint ? null : ( + + } + /> + ); + + return { + primaryMessage, + secondaryMessage, + backgroundColor: theme.colors.background.section, + icon: { + color: IconColor.IconDefault, + name: IconName.Loading, + component: SpinningIcon, + }, + }; + } + + const primaryMessage = ( + + ); + const secondaryMessageContent = + networkConnectionBannerState.isInfuraEndpoint ? ( + strings('network_connection_banner.check_network_connectivity') + ) : ( + <> + {strings('network_connection_banner.check_network_connectivity_or')}{' '} + + {'.'} + + ); + const secondaryMessage = ( + + ); + + return { + primaryMessage, + secondaryMessage, + // Can't use Tailwind class here because they don't allow for using this + // color as a background color + backgroundColor: theme.colors.error.muted, + icon: { + color: IconColor.ErrorDefault, + name: IconName.Danger, + component: Icon, + }, + }; +}; -/** - * Network Connection Banner - * - * Shows when any network takes more than 5 seconds to initialize or is not available. - */ -const NetworkConnectionBanner: React.FC = () => { +export const NetworkConnectionBanner = () => { + const theme = useAppTheme(); + const tw = useTailwind(); const { networkConnectionBannerState, updateRpc } = useNetworkConnectionBanner(); + const handleUpdateRpc = useCallback(() => { + if (networkConnectionBannerState.visible) { + updateRpc( + networkConnectionBannerState.rpcUrl, + networkConnectionBannerState.status, + networkConnectionBannerState.chainId, + ); + } + }, [networkConnectionBannerState, updateRpc]); + if (!networkConnectionBannerState.visible) { return null; } + const { primaryMessage, secondaryMessage, backgroundColor, icon } = + getBannerContent(theme, networkConnectionBannerState, handleUpdateRpc); + return ( - - ) : ( - - ) + } - title={strings( - networkConnectionBannerState.status === 'degraded' - ? 'network_connection_banner.still_connecting_network' - : 'network_connection_banner.unable_to_connect_network', - { - networkName: networkConnectionBannerState.networkName, - }, - )} - actionButtonProps={{ - variant: ButtonVariants.Link, - label: strings('network_connection_banner.update_rpc'), - onPress: () => - updateRpc( - networkConnectionBannerState.rpcUrl, - networkConnectionBannerState.status, - networkConnectionBannerState.chainId, - ), - }} + title={primaryMessage} + description={secondaryMessage} /> ); }; diff --git a/app/components/UI/NetworkConnectionBanner/__snapshots__/NetworkConnectionBanner.test.tsx.snap b/app/components/UI/NetworkConnectionBanner/__snapshots__/NetworkConnectionBanner.test.tsx.snap deleted file mode 100644 index 8c6e8dffb3b5..000000000000 --- a/app/components/UI/NetworkConnectionBanner/__snapshots__/NetworkConnectionBanner.test.tsx.snap +++ /dev/null @@ -1,276 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`NetworkConnectionBanner snapshots should match snapshot for degraded status banner 1`] = ` - - - - - SM - - - - - - Still connecting to Ethereum Mainnet... - - - - Update RPC - - - - -`; - -exports[`NetworkConnectionBanner snapshots should match snapshot for different network 1`] = ` - - - - - SM - - - - - - Still connecting to Polygon Mainnet... - - - - Update RPC - - - - -`; - -exports[`NetworkConnectionBanner snapshots should match snapshot for unavailable status banner 1`] = ` - - - - - Md - - - - - - Unable to connect to Ethereum Mainnet. - - - - Update RPC - - - - -`; - -exports[`NetworkConnectionBanner snapshots should match snapshot when banner is not visible 1`] = `null`; diff --git a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx index 581dc7b5ac5f..94ef0fa137ee 100644 --- a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx +++ b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.test.tsx @@ -24,7 +24,12 @@ jest.mock('../../../core/Engine'); jest.mock('../../../selectors/networkEnablementController'); jest.mock('../useMetrics'); jest.mock('../../../selectors/networkConnectionBanner'); -jest.mock('../../../core/Engine/controllers/network-controller/utils'); +jest.mock('../../../core/Engine/controllers/network-controller/utils', () => ({ + ...jest.requireActual( + '../../../core/Engine/controllers/network-controller/utils', + ), + isPublicEndpointUrl: jest.fn(), +})); jest.mock('../../../constants/network', () => ({ ...jest.requireActual('../../../constants/network'), INFURA_PROJECT_ID: 'test-infura-project-id', @@ -40,7 +45,7 @@ const mockNetworkConfiguration: NetworkConfiguration = { name: 'Ethereum Mainnet', rpcEndpoints: [ { - url: 'https://mainnet.infura.io/v3/test', + url: 'https://mainnet.infura.io/v3/test-infura-project-id', networkClientId: '0x1', type: RpcEndpointType.Custom, }, @@ -201,7 +206,7 @@ describe('useNetworkConnectionBanner', () => { describe('updateRpc function', () => { it('should navigate to edit network screen with provided rpcUrl', () => { const status = 'degraded'; - const rpcUrl = 'https://mainnet.infura.io/v3/test'; + const rpcUrl = 'https://mainnet.infura.io/v3/test-infura-project-id'; const chainId = '0x1'; (selectNetworkConnectionBannerState as jest.Mock).mockReturnValue({ visible: true, @@ -227,7 +232,7 @@ describe('useNetworkConnectionBanner', () => { it('should track degraded RPC update event', () => { const status = 'degraded'; - const rpcUrl = 'https://mainnet.infura.io/v3/test'; + const rpcUrl = 'https://mainnet.infura.io/v3/test-infura-project-id'; const chainId = '0x1'; (selectNetworkConnectionBannerState as jest.Mock).mockReturnValue({ visible: true, @@ -258,7 +263,7 @@ describe('useNetworkConnectionBanner', () => { it('should track unavailable RPC update event', () => { const status = 'unavailable'; - const rpcUrl = 'https://mainnet.infura.io/v3/test'; + const rpcUrl = 'https://mainnet.infura.io/v3/test-infura-project-id'; const chainId = '0x1'; (selectNetworkConnectionBannerState as jest.Mock).mockReturnValue({ visible: true, @@ -323,7 +328,7 @@ describe('useNetworkConnectionBanner', () => { it('should use mocked Infura project ID from constants', () => { const status = 'degraded'; - const rpcUrl = 'https://mainnet.infura.io/v3/test'; + const rpcUrl = 'https://mainnet.infura.io/v3/test-infura-project-id'; const chainId = '0x1'; (selectNetworkConnectionBannerState as jest.Mock).mockReturnValue({ @@ -347,29 +352,6 @@ describe('useNetworkConnectionBanner', () => { 'test-infura-project-id', ); }); - - it('should dispatch hideNetworkConnectionBanner', () => { - const status = 'degraded'; - const rpcUrl = 'https://mainnet.infura.io/v3/test'; - const chainId = '0x1'; - (selectNetworkConnectionBannerState as jest.Mock).mockReturnValue({ - visible: true, - chainId, - status, - }); - - const { result } = renderHookWithProvider(); - - act(() => { - result.current.updateRpc(rpcUrl, status, chainId); - }); - - const actions = store.getActions(); - expect(actions).toHaveLength(1); - expect(actions[0]).toStrictEqual({ - type: 'HIDE_NETWORK_CONNECTION_BANNER', - }); - }); }); describe('useEffect', () => { @@ -392,6 +374,7 @@ describe('useNetworkConnectionBanner', () => { status: 'degraded', networkName: 'Polygon Mainnet', rpcUrl: 'https://polygon-rpc.com', + isInfuraEndpoint: false, }); }); @@ -402,6 +385,7 @@ describe('useNetworkConnectionBanner', () => { status: 'degraded', networkName: 'Polygon Mainnet', rpcUrl: 'https://polygon-rpc.com', + isInfuraEndpoint: false, }); renderHookWithProvider(); @@ -420,7 +404,8 @@ describe('useNetworkConnectionBanner', () => { chainId: '0x1', // Different from the unavailable network (0x89) status: 'degraded', networkName: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/test', + rpcUrl: 'https://mainnet.infura.io/v3/test-infura-project-id', + isInfuraEndpoint: true, }); renderHookWithProvider(); @@ -437,6 +422,7 @@ describe('useNetworkConnectionBanner', () => { status: 'degraded', networkName: 'Polygon Mainnet', rpcUrl: 'https://polygon-rpc.com', + isInfuraEndpoint: false, }); }); @@ -448,6 +434,7 @@ describe('useNetworkConnectionBanner', () => { status: 'degraded', networkName: 'Polygon Mainnet', rpcUrl: 'https://polygon-rpc.com', + isInfuraEndpoint: false, }); // Mock that 0x89 is now available but 0x1 is unavailable @@ -479,7 +466,8 @@ describe('useNetworkConnectionBanner', () => { chainId: '0x1', status: 'degraded', networkName: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/test', + rpcUrl: 'https://mainnet.infura.io/v3/test-infura-project-id', + isInfuraEndpoint: true, }); }); @@ -491,6 +479,7 @@ describe('useNetworkConnectionBanner', () => { status: 'degraded', networkName: 'Polygon Mainnet', rpcUrl: 'https://polygon-rpc.com', + isInfuraEndpoint: false, }); const allAvailableNetworkMetadata = { @@ -546,6 +535,7 @@ describe('useNetworkConnectionBanner', () => { status: 'degraded', networkName: 'Polygon Mainnet', rpcUrl: 'https://polygon-rpc.com', + isInfuraEndpoint: false, }); }); @@ -569,6 +559,7 @@ describe('useNetworkConnectionBanner', () => { status: 'degraded', networkName: 'Polygon Mainnet', rpcUrl: 'https://polygon-rpc.com', + isInfuraEndpoint: false, }); expect(actions[1]).toStrictEqual({ @@ -577,6 +568,7 @@ describe('useNetworkConnectionBanner', () => { status: 'unavailable', networkName: 'Polygon Mainnet', rpcUrl: 'https://polygon-rpc.com', + isInfuraEndpoint: false, }); }); @@ -588,6 +580,7 @@ describe('useNetworkConnectionBanner', () => { status: 'degraded', networkName: 'Polygon Mainnet', rpcUrl, + isInfuraEndpoint: false, }); renderHookWithProvider(); @@ -615,6 +608,7 @@ describe('useNetworkConnectionBanner', () => { status: 'unavailable', networkName: 'Polygon Mainnet', rpcUrl, + isInfuraEndpoint: false, }); renderHookWithProvider(); @@ -653,6 +647,7 @@ describe('useNetworkConnectionBanner', () => { status: 'degraded', networkName: 'Polygon Mainnet', rpcUrl: 'https://polygon-rpc.com', + isInfuraEndpoint: false, }); renderHookWithProvider(); @@ -670,6 +665,7 @@ describe('useNetworkConnectionBanner', () => { status: 'unavailable', networkName: 'Polygon Mainnet', rpcUrl: 'https://polygon-rpc.com', + isInfuraEndpoint: false, }); }); @@ -681,6 +677,7 @@ describe('useNetworkConnectionBanner', () => { status: 'unavailable', networkName: 'Polygon Mainnet', rpcUrl: 'https://polygon-rpc.com', + isInfuraEndpoint: false, }); renderHookWithProvider(); @@ -698,6 +695,7 @@ describe('useNetworkConnectionBanner', () => { status: 'degraded', networkName: 'Polygon Mainnet', rpcUrl: 'https://polygon-rpc.com', + isInfuraEndpoint: false, }); }); @@ -741,6 +739,7 @@ describe('useNetworkConnectionBanner', () => { status: 'degraded', networkName: 'Polygon Mainnet', rpcUrl: 'https://polygon-rpc.com', + isInfuraEndpoint: false, }); expect(actions[1]).toStrictEqual({ type: 'SHOW_NETWORK_CONNECTION_BANNER', @@ -748,6 +747,7 @@ describe('useNetworkConnectionBanner', () => { status: 'unavailable', networkName: 'Polygon Mainnet', rpcUrl: 'https://polygon-rpc.com', + isInfuraEndpoint: false, }); expect(actions[1].status).toBe('unavailable'); expect(actions[1].networkName).toBe('Polygon Mainnet'); @@ -762,6 +762,7 @@ describe('useNetworkConnectionBanner', () => { status: 'degraded', networkName: 'Polygon Mainnet', rpcUrl, + isInfuraEndpoint: false, }); renderHookWithProvider(); diff --git a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts index 9b994b34e5f5..4cae292f9294 100644 --- a/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts +++ b/app/components/hooks/useNetworkConnectionBanner/useNetworkConnectionBanner.ts @@ -14,7 +14,10 @@ import { import { NetworkConnectionBannerStatus } from '../../UI/NetworkConnectionBanner/types'; import { selectEVMEnabledNetworks } from '../../../selectors/networkEnablementController'; import { NetworkConnectionBannerState } from '../../../reducers/networkConnectionBanner'; -import { isPublicEndpointUrl } from '../../../core/Engine/controllers/network-controller/utils'; +import { + isPublicEndpointUrl, + getIsMetaMaskInfuraEndpointUrl, +} from '../../../core/Engine/controllers/network-controller/utils'; import onlyKeepHost from '../../../util/onlyKeepHost'; import { INFURA_PROJECT_ID } from '../../../constants/network'; @@ -64,7 +67,6 @@ const useNetworkConnectionBanner = (): { shouldShowPopularNetworks: false, }); - // Tracking the event trackEvent( createEventBuilder( MetaMetricsEvents.NETWORK_CONNECTION_BANNER_UPDATE_RPC_CLICKED, @@ -76,8 +78,6 @@ const useNetworkConnectionBanner = (): { }) .build(), ); - - dispatch(hideNetworkConnectionBanner()); } useEffect(() => { @@ -91,6 +91,7 @@ const useNetworkConnectionBanner = (): { status: NetworkConnectionBannerStatus; networkName: string; rpcUrl: string; + isInfuraEndpoint: boolean; } | null = null; for (const evmEnabledNetworkChainId of evmEnabledNetworksChainIds) { @@ -120,11 +121,17 @@ const useNetworkConnectionBanner = (): { networkConfig.defaultRpcEndpointIndex || 0 ]?.url || networkConfig.rpcEndpoints[0]?.url; + const isInfuraEndpoint = getIsMetaMaskInfuraEndpointUrl( + rpcUrl, + infuraProjectId, + ); + firstUnavailableNetwork = { chainId: evmEnabledNetworkChainId, status: timeoutType, networkName: networkConfig.name, rpcUrl, + isInfuraEndpoint, }; break; // Only show one banner at a time @@ -157,6 +164,7 @@ const useNetworkConnectionBanner = (): { status: firstUnavailableNetwork.status, networkName: firstUnavailableNetwork.networkName, rpcUrl: firstUnavailableNetwork.rpcUrl, + isInfuraEndpoint: firstUnavailableNetwork.isInfuraEndpoint, }), ); } diff --git a/app/reducers/networkConnectionBanner/index.test.ts b/app/reducers/networkConnectionBanner/index.test.ts index 42c839a5be12..be4f02222a58 100644 --- a/app/reducers/networkConnectionBanner/index.test.ts +++ b/app/reducers/networkConnectionBanner/index.test.ts @@ -23,6 +23,7 @@ describe('networkConnectionBanner reducer', () => { status: 'degraded', networkName: 'Ethereum Mainnet', rpcUrl: 'https://mainnet.infura.io/v3/123', + isInfuraEndpoint: true, } as const; const unknownAction = { type: 'UNKNOWN_ACTION_TYPE', @@ -56,6 +57,7 @@ describe('networkConnectionBanner reducer', () => { status: 'degraded', networkName, rpcUrl, + isInfuraEndpoint: true, }); const result = reducer(initialState, action); @@ -66,6 +68,7 @@ describe('networkConnectionBanner reducer', () => { status: 'degraded', networkName, rpcUrl, + isInfuraEndpoint: true, }); }); @@ -76,6 +79,7 @@ describe('networkConnectionBanner reducer', () => { status: 'degraded', networkName: 'Ethereum Mainnet', rpcUrl: 'https://mainnet.infura.io/v3/123', + isInfuraEndpoint: true, } as const; const newChainId = '0x89'; @@ -86,6 +90,7 @@ describe('networkConnectionBanner reducer', () => { status: 'degraded', networkName: newNetworkName, rpcUrl: newNetworkRpcUrl, + isInfuraEndpoint: false, }); const result = reducer(existingState, action); @@ -96,6 +101,7 @@ describe('networkConnectionBanner reducer', () => { status: 'degraded', networkName: newNetworkName, rpcUrl: newNetworkRpcUrl, + isInfuraEndpoint: false, }); }); }); @@ -108,6 +114,7 @@ describe('networkConnectionBanner reducer', () => { status: 'degraded', networkName: 'Ethereum Mainnet', rpcUrl: 'https://mainnet.infura.io/v3/123', + isInfuraEndpoint: true, } as const; const action = hideNetworkConnectionBanner(); @@ -137,6 +144,7 @@ describe('networkConnectionBanner reducer', () => { status: 'degraded', networkName, rpcUrl, + isInfuraEndpoint: false, }); const hideAction = hideNetworkConnectionBanner(); @@ -148,6 +156,7 @@ describe('networkConnectionBanner reducer', () => { status: 'degraded', networkName, rpcUrl, + isInfuraEndpoint: false, }); const afterHide = reducer(afterShow, hideAction); @@ -166,6 +175,7 @@ describe('networkConnectionBanner reducer', () => { status: 'degraded', networkName: 'Ethereum Mainnet', rpcUrl: 'https://mainnet.infura.io/v3/123', + isInfuraEndpoint: true, } as const; const action = hideNetworkConnectionBanner(); @@ -178,6 +188,7 @@ describe('networkConnectionBanner reducer', () => { status: 'degraded', networkName: 'Ethereum Mainnet', rpcUrl: 'https://mainnet.infura.io/v3/123', + isInfuraEndpoint: true, }); }); @@ -187,6 +198,7 @@ describe('networkConnectionBanner reducer', () => { status: 'degraded', networkName: 'Polygon Mainnet', rpcUrl: 'https://polygon-rpc.com', + isInfuraEndpoint: false, }); const result = reducer(initialState, action); @@ -198,6 +210,7 @@ describe('networkConnectionBanner reducer', () => { status: 'degraded', networkName: 'Polygon Mainnet', rpcUrl: 'https://polygon-rpc.com', + isInfuraEndpoint: false, }); }); }); diff --git a/app/reducers/networkConnectionBanner/index.ts b/app/reducers/networkConnectionBanner/index.ts index 1433de11db01..424c72b968a7 100644 --- a/app/reducers/networkConnectionBanner/index.ts +++ b/app/reducers/networkConnectionBanner/index.ts @@ -18,6 +18,7 @@ export type NetworkConnectionBannerState = status: NetworkConnectionBannerStatus; networkName: string; rpcUrl: string; + isInfuraEndpoint: boolean; }; /** @@ -47,6 +48,7 @@ const networkConnectionBannerReducer = ( status: action.status, networkName: action.networkName, rpcUrl: action.rpcUrl, + isInfuraEndpoint: action.isInfuraEndpoint, }; case NetworkConnectionBannerActionType.HIDE_NETWORK_CONNECTION_BANNER: return { diff --git a/locales/languages/en.json b/locales/languages/en.json index 1cb3182ef23a..25c5ab67be3b 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6562,6 +6562,8 @@ "network_connection_banner": { "still_connecting_network": "Still connecting to {{networkName}}...", "unable_to_connect_network": "Unable to connect to {{networkName}}.", - "update_rpc": "Update RPC" + "update_rpc": "Update RPC", + "check_network_connectivity": "Check your network connectivity.", + "check_network_connectivity_or": "Check your network connectivity or" } }