From fa633127bce568a12cf64eb2e02a8424699ab09c Mon Sep 17 00:00:00 2001 From: Sylva Elendu Date: Mon, 28 Mar 2022 12:42:35 +0100 Subject: [PATCH] Network Specific Asset Education - Updated (#3910) * show network specific asset education modal when toggled to a network Co-authored-by: Curtis --- app/actions/onboardNetwork/index.ts | 34 +++ app/components/Nav/Main/RootRPCMethodsUI.js | 7 + app/components/UI/DrawerView/index.js | 106 ++++++++- .../UI/NetworkInfo/InfoDescription/index.tsx | 3 + .../InfoDescription/infoDescription.tsx | 76 +++++++ .../__snapshots__/index.test.tsx.snap | 16 ++ app/components/UI/NetworkInfo/index.test.tsx | 41 ++++ app/components/UI/NetworkInfo/index.tsx | 203 ++++++++++++++++++ .../__snapshots__/index.test.tsx.snap | 8 + app/components/UI/NetworkList/index.js | 35 ++- app/components/UI/NetworkList/index.test.tsx | 3 + .../UI/SwitchCustomNetwork/index.js | 2 +- .../__snapshots__/index.test.tsx.snap | 10 +- .../NetworksSettings/NetworkSettings/index.js | 32 ++- .../NetworkSettings/index.test.tsx | 3 + app/constants/network.js | 1 + app/constants/test-ids.js | 4 + app/reducers/index.js | 2 + app/reducers/networkSelector/index.ts | 76 +++++++ .../{checkAddress.tsx => checkAddress.ts} | 0 app/util/sanitizeUrl.test.ts | 9 + app/util/sanitizeUrl.ts | 3 + e2e/pages/ImportTokensView.js | 5 + e2e/pages/modals/NetworkEducationModal.js | 30 +++ e2e/specs/add-custom-rpc.spec.js | 23 +- e2e/specs/contract-nickname.spec.js | 8 + e2e/specs/deeplinks.spec.js | 20 +- e2e/specs/wallet-tests.spec.js | 19 +- ios/MetaMask.xcodeproj/project.pbxproj | 1 - locales/languages/en.json | 18 ++ 30 files changed, 774 insertions(+), 24 deletions(-) create mode 100644 app/actions/onboardNetwork/index.ts create mode 100644 app/components/UI/NetworkInfo/InfoDescription/index.tsx create mode 100644 app/components/UI/NetworkInfo/InfoDescription/infoDescription.tsx create mode 100644 app/components/UI/NetworkInfo/__snapshots__/index.test.tsx.snap create mode 100644 app/components/UI/NetworkInfo/index.test.tsx create mode 100644 app/components/UI/NetworkInfo/index.tsx create mode 100644 app/reducers/networkSelector/index.ts rename app/util/{checkAddress.tsx => checkAddress.ts} (100%) create mode 100644 app/util/sanitizeUrl.test.ts create mode 100644 app/util/sanitizeUrl.ts create mode 100644 e2e/pages/modals/NetworkEducationModal.js diff --git a/app/actions/onboardNetwork/index.ts b/app/actions/onboardNetwork/index.ts new file mode 100644 index 00000000000..7fa0a1d26cb --- /dev/null +++ b/app/actions/onboardNetwork/index.ts @@ -0,0 +1,34 @@ +/** + * Handle the onboarding network action + * + * @param {object} data object containing the event data + * @returns + */ +export const onboardNetworkAction = (data: string) => ({ + type: 'NETWORK_ONBOARDED', + payload: data, +}); + +export const networkSwitched = ({ networkUrl, networkStatus }: { networkUrl: string; networkStatus: boolean }) => ({ + type: 'NETWORK_SWITCHED', + networkUrl, + networkStatus, +}); + +export const showNetworkOnboardingAction = ({ + networkUrl, + networkType, + nativeToken, + showNetworkOnboarding, +}: { + networkUrl: string; + networkType: string; + nativeToken: string; + showNetworkOnboarding: boolean; +}) => ({ + type: 'SHOW_NETWORK_ONBOARDING', + networkUrl, + networkType, + nativeToken, + showNetworkOnboarding, +}); diff --git a/app/components/Nav/Main/RootRPCMethodsUI.js b/app/components/Nav/Main/RootRPCMethodsUI.js index 922f3e9b3e2..dca19dd7f6e 100644 --- a/app/components/Nav/Main/RootRPCMethodsUI.js +++ b/app/components/Nav/Main/RootRPCMethodsUI.js @@ -46,6 +46,7 @@ import { getTokenList } from '../../../reducers/tokens'; import { toLowerCaseEquals } from '../../../util/general'; import { ApprovalTypes } from '../../../core/RPCMethods/RPCMethodMiddleware'; import { mockTheme, useAppThemeFromContext } from '../../../util/theme'; +import { networkSwitched } from '../../../actions/onboardNetwork'; const hstInterface = new ethers.utils.Interface(abi); @@ -487,6 +488,7 @@ const RootRPCMethodsUI = (props) => { const onSwitchCustomNetworkConfirm = () => { setShowPendingApproval(false); acceptPendingApproval(customNetworkToSwitch.id, customNetworkToSwitch.data); + props.networkSwitched({ networkUrl: customNetworkToSwitch.data.rpcUrl, networkStatus: true }); }; /** @@ -718,6 +720,10 @@ RootRPCMethodsUI.propTypes = { * Chain id */ chainId: PropTypes.string, + /** + * updates redux when network is switched + */ + networkSwitched: PropTypes.func, }; const mapStateToProps = (state) => ({ @@ -735,6 +741,7 @@ const mapDispatchToProps = (dispatch) => ({ setTransactionObject: (transaction) => dispatch(setTransactionObject(transaction)), toggleDappTransactionModal: (show = null) => dispatch(toggleDappTransactionModal(show)), toggleApproveModal: (show) => dispatch(toggleApproveModal(show)), + networkSwitched: ({ networkUrl, networkStatus }) => dispatch(networkSwitched({ networkUrl, networkStatus })), }); export default connect(mapStateToProps, mapDispatchToProps)(RootRPCMethodsUI); diff --git a/app/components/UI/DrawerView/index.js b/app/components/UI/DrawerView/index.js index 27e36ed002e..6de1c087f74 100644 --- a/app/components/UI/DrawerView/index.js +++ b/app/components/UI/DrawerView/index.js @@ -46,6 +46,9 @@ import { getCurrentRoute } from '../../../reducers/navigation'; import { ScrollView } from 'react-native-gesture-handler'; import { isZero } from '../../../util/lodash'; import { ThemeContext, mockTheme } from '../../../util/theme'; +import NetworkInfo from '../NetworkInfo'; +import sanitizeUrl from '../../../util/sanitizeUrl'; +import { onboardNetworkAction, networkSwitched } from '../../../actions/onboardNetwork'; const createStyles = (colors) => StyleSheet.create({ @@ -396,6 +399,26 @@ class DrawerView extends PureComponent { * Latest navigation route */ currentRoute: PropTypes.string, + /** + * handles action for onboarding to a network + */ + onboardNetworkAction: PropTypes.func, + /** + * returns network onboarding state + */ + networkOnboarding: PropTypes.object, + /** + * returns switched network state + */ + switchedNetwork: PropTypes.object, + /** + * updates when network is switched + */ + networkSwitched: PropTypes.func, + /** + * + */ + networkOnboardedState: PropTypes.array, }; state = { @@ -406,6 +429,11 @@ class DrawerView extends PureComponent { address: undefined, currentNetwork: undefined, }, + networkSelected: false, + networkType: undefined, + networkCurrency: undefined, + showModal: false, + networkUrl: undefined, }; browserSectionRef = React.createRef(); @@ -527,6 +555,22 @@ class DrawerView extends PureComponent { } }; + onInfoNetworksModalClose = async (manualClose) => { + const { + networkOnboarding: { showNetworkOnboarding, networkUrl }, + onboardNetworkAction, + switchedNetwork: { networkUrl: switchedNetworkUrl }, + networkSwitched, + } = this.props; + this.setState({ networkSelected: !this.state.networkSelected, showModal: false }); + !showNetworkOnboarding && this.toggleNetworksModal(); + onboardNetworkAction(sanitizeUrl(networkUrl) || sanitizeUrl(switchedNetworkUrl) || this.state.networkUrl); + networkSwitched({ networkUrl: '', networkStatus: false }); + if (!manualClose) { + await this.hideDrawer(); + } + }; + toggleNetworksModal = () => { if (!this.animatingNetworksModal) { this.animatingNetworksModal = true; @@ -537,6 +581,19 @@ class DrawerView extends PureComponent { } }; + onNetworkSelected = (type, currency, url) => { + this.setState({ + networkType: type, + networkUrl: url || type, + networkCurrency: currency, + networkSelected: true, + }); + }; + + switchModalContent = () => { + this.setState({ showModal: true }); + }; + showReceiveModal = () => { this.toggleReceiveModal(); }; @@ -947,6 +1004,11 @@ class DrawerView extends PureComponent { ticker, seedphraseBackedUp, currentRoute, + networkOnboarding, + networkOnboardedState, + switchedNetwork, + networkModalVisible, + navigation, } = this.props; const colors = this.context.colors || mockTheme.colors; const styles = createStyles(colors); @@ -955,6 +1017,8 @@ class DrawerView extends PureComponent { invalidCustomNetwork, showProtectWalletModal, account: { name: nameFromState, ens: ensFromState }, + showModal, + networkType, } = this.state; const account = { @@ -973,6 +1037,9 @@ class DrawerView extends PureComponent { this.currentBalance = fiatBalance; const fiatBalanceStr = renderFiat(this.currentBalance, currentCurrency); const accountName = isDefaultAccountName(name) && ens ? ens : name; + const checkIfCustomNetworkExists = networkOnboardedState.filter( + (item) => item.network === sanitizeUrl(switchedNetwork.networkUrl) + ); return ( @@ -1120,20 +1187,35 @@ class DrawerView extends PureComponent { - + {showModal || + networkOnboarding.showNetworkOnboarding || + (currentRoute === 'WalletView' && + switchedNetwork.networkStatus && + checkIfCustomNetworkExists.length === 0) ? ( + + ) : ( + + )} ({ collectibles: collectiblesSelector(state), seedphraseBackedUp: state.user.seedphraseBackedUp, currentRoute: getCurrentRoute(state), + networkOnboarding: state.networkOnboarded.networkState, + networkOnboardedState: state.networkOnboarded.networkOnboardedState, + networkProvider: state.engine.backgroundState.NetworkController.provider, + switchedNetwork: state.networkOnboarded.switchedNetwork, }); const mapDispatchToProps = (dispatch) => ({ @@ -1218,6 +1304,8 @@ const mapDispatchToProps = (dispatch) => ({ newAssetTransaction: (selectedAsset) => dispatch(newAssetTransaction(selectedAsset)), protectWalletModalVisible: () => dispatch(protectWalletModalVisible()), logOut: () => dispatch(logOut()), + onboardNetworkAction: (network) => dispatch(onboardNetworkAction(network)), + networkSwitched: ({ networkUrl, networkStatus }) => dispatch(networkSwitched({ networkUrl, networkStatus })), }); DrawerView.contextType = ThemeContext; diff --git a/app/components/UI/NetworkInfo/InfoDescription/index.tsx b/app/components/UI/NetworkInfo/InfoDescription/index.tsx new file mode 100644 index 00000000000..dd144fff1f6 --- /dev/null +++ b/app/components/UI/NetworkInfo/InfoDescription/index.tsx @@ -0,0 +1,3 @@ +import infoDescription from './infoDescription'; + +export default infoDescription; diff --git a/app/components/UI/NetworkInfo/InfoDescription/infoDescription.tsx b/app/components/UI/NetworkInfo/InfoDescription/infoDescription.tsx new file mode 100644 index 00000000000..80af4f82c4c --- /dev/null +++ b/app/components/UI/NetworkInfo/InfoDescription/infoDescription.tsx @@ -0,0 +1,76 @@ +import React, { memo } from 'react'; +import { View, Text, Linking, StyleSheet } from 'react-native'; +import { strings } from '../../../../../locales/i18n'; +import { useAppThemeFromContext, mockTheme } from '../../../../util/theme'; + +const createStyles = (colors: { + background: { default: string }; + text: { default: string }; + border: { muted: string }; + info: { default: string }; +}) => + StyleSheet.create({ + descriptionContainer: { + marginBottom: 10, + borderBottomWidth: StyleSheet.hairlineWidth, + borderColor: colors.border.muted, + }, + contentContainer: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 4, + }, + numberStyle: { + marginRight: 10, + color: colors.text.default, + }, + link: { + color: colors.info.default, + }, + description: { + width: '94%', + color: colors.text.default, + }, + }); + +interface DescriptionProps { + description: string; + clickableText: string | undefined; + number: number; + navigation: any; + onClose: () => void; +} + +const Description = (props: DescriptionProps) => { + const { description, clickableText, number, navigation, onClose } = props; + const { colors } = useAppThemeFromContext() || mockTheme; + const styles = createStyles(colors); + + const handlePress = () => { + if (number === 2) { + Linking.openURL(strings('network_information.learn_more_url')); + } else { + onClose(); + navigation.push('AddAsset', { assetType: 'token' }); + } + }; + + return ( + + + {number}. + + {description} + {clickableText && ( + + {' '} + {clickableText} + + )} + + + + ); +}; + +export default memo(Description); diff --git a/app/components/UI/NetworkInfo/__snapshots__/index.test.tsx.snap b/app/components/UI/NetworkInfo/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000..2aff9c5ae14 --- /dev/null +++ b/app/components/UI/NetworkInfo/__snapshots__/index.test.tsx.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NetworkInfo should render correctly 1`] = ` + +`; diff --git a/app/components/UI/NetworkInfo/index.test.tsx b/app/components/UI/NetworkInfo/index.test.tsx new file mode 100644 index 00000000000..9969036affd --- /dev/null +++ b/app/components/UI/NetworkInfo/index.test.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import NetworkInfo from './'; +import configureMockStore from 'redux-mock-store'; +import { Provider } from 'react-redux'; + +const mockStore = configureMockStore(); +const initialState = { + privacy: { + approvedHosts: {}, + }, + engine: { + backgroundState: { + NetworkController: { + provider: { type: 'mainnet', rpcTarget: 'http://10.0.2.2:8545' }, + }, + PreferencesController: { frequentRpcList: ['http://10.0.2.2:8545'] }, + }, + }, + networkOnboarded: { + networkOnboardedState: [{ network: 'mainnet', onboarded: true }], + }, +}; +const store = mockStore(initialState); + +describe('NetworkInfo', () => { + it('should render correctly', () => { + const wrapper = shallow( + + + + ); + expect(wrapper.dive()).toMatchSnapshot(); + }); +}); diff --git a/app/components/UI/NetworkInfo/index.tsx b/app/components/UI/NetworkInfo/index.tsx new file mode 100644 index 00000000000..aa929998f20 --- /dev/null +++ b/app/components/UI/NetworkInfo/index.tsx @@ -0,0 +1,203 @@ +/* eslint-disable no-mixed-spaces-and-tabs */ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import StyledButton from '../StyledButton'; +import { strings } from '../../../../locales/i18n'; +import NetworkMainAssetLogo from '../NetworkMainAssetLogo'; +import { MAINNET, RPC } from '../../../constants/network'; +import { connect } from 'react-redux'; +import Description from './InfoDescription'; +import { useAppThemeFromContext, mockTheme } from '../../../util/theme'; +import { + NETWORK_EDUCATION_MODAL_CONTAINER_ID, + NETWORK_EDUCATION_MODAL_CLOSE_BUTTON_ID, + NETWORK_EDUCATION_MODAL_NETWORK_NAME_ID, +} from '../../../constants/test-ids'; + +const createStyles = (colors: { + background: { default: string }; + text: { default: string }; + border: { muted: string }; +}) => + StyleSheet.create({ + wrapper: { + backgroundColor: colors.background.default, + borderRadius: 10, + }, + modalContentView: { + padding: 20, + }, + title: { + fontSize: 16, + fontWeight: 'bold', + marginVertical: 10, + textAlign: 'center', + color: colors.text.default, + }, + tokenView: { + marginBottom: 30, + alignItems: 'center', + }, + tokenType: { + padding: 10, + borderRadius: 40, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + backgroundColor: colors.border.muted, + }, + ethLogo: { + width: 30, + height: 30, + overflow: 'hidden', + marginHorizontal: 5, + }, + tokenText: { + fontSize: 15, + color: colors.text.default, + textAlign: 'center', + paddingRight: 10, + }, + capitalizeText: { + textTransform: 'capitalize', + }, + messageTitle: { + fontSize: 14, + fontWeight: 'bold', + marginBottom: 15, + textAlign: 'center', + color: colors.text.default, + }, + descriptionViews: { + marginBottom: 15, + }, + closeButton: { + marginVertical: 20, + borderColor: colors.border.muted, + }, + rpcUrl: { + fontSize: 10, + color: colors.border.muted, + textAlign: 'center', + paddingVertical: 5, + }, + unknownWrapper: { + backgroundColor: colors.background.default, + marginRight: 6, + height: 20, + width: 20, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + }, + unknownText: { + color: colors.text.default, + fontSize: 13, + }, + }); + +interface NetworkInfoProps { + onClose: () => void; + type: string; + ticker: string; + networkProvider: { + nickname: string; + type: string; + ticker: { + networkTicker: string; + }; + rpcTarget: string; + }; + navigation: any; +} + +const NetworkInfo = (props: NetworkInfoProps) => { + const { + onClose, + ticker, + networkProvider: { nickname, type, ticker: networkTicker, rpcTarget }, + navigation, + } = props; + const { colors } = useAppThemeFromContext() || mockTheme; + const styles = createStyles(colors); + + return ( + + + {strings('network_information.switched_network')} + + + {ticker === undefined ? ( + <> + + ? + + + {`${nickname}` || strings('network_information.unknown_network')} + + + ) : ( + <> + + + {type === RPC + ? `${nickname}` + : type === MAINNET + ? `${type}` + : `${strings('network_information.testnet_network', { type })}`} + + + )} + + {ticker === undefined && {rpcTarget}} + + {strings('network_information.things_to_keep_in_mind')}: + + + + + + + + {strings('network_information.got_it')} + + + + ); +}; + +const mapStateToProps = (state: any) => ({ + networkProvider: state.engine.backgroundState.NetworkController.provider, +}); + +export default connect(mapStateToProps)(NetworkInfo); diff --git a/app/components/UI/NetworkList/__snapshots__/index.test.tsx.snap b/app/components/UI/NetworkList/__snapshots__/index.test.tsx.snap index c67f4db4cd6..764d8cac2f7 100644 --- a/app/components/UI/NetworkList/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/NetworkList/__snapshots__/index.test.tsx.snap @@ -8,6 +8,14 @@ exports[`NetworkList should render correctly 1`] = ` "http://10.0.2.2:8545", ] } + networkOnboardedState={ + Array [ + Object { + "network": "mainnet", + "onboarded": true, + }, + ] + } provider={ Object { "rpcTarget": "http://10.0.2.2:8545", diff --git a/app/components/UI/NetworkList/index.js b/app/components/UI/NetworkList/index.js index cff79e6e5c2..f10baf9d734 100644 --- a/app/components/UI/NetworkList/index.js +++ b/app/components/UI/NetworkList/index.js @@ -8,9 +8,11 @@ import { strings } from '../../../../locales/i18n'; import Networks, { getAllNetworks, isSafeChainId } from '../../../util/networks'; import { connect } from 'react-redux'; import AnalyticsV2 from '../../../util/analyticsV2'; -import { MAINNET, RPC } from '../../../constants/network'; import { ThemeContext, mockTheme } from '../../../util/theme'; import { NETWORK_LIST_MODAL_CONTAINER_ID, OTHER_NETWORK_LIST_ID, NETWORK_SCROLL_ID } from '../../../constants/test-ids'; +import { MAINNET, RPC, PRIVATENETWORK } from '../../../constants/network'; +import { ETH } from '../../../util/custom-gas'; +import sanitizeUrl from '../../../util/sanitizeUrl'; const createStyles = (colors) => StyleSheet.create({ @@ -139,12 +141,35 @@ export class NetworkList extends PureComponent { * Show invalid custom network alert for networks without a chain ID */ showInvalidCustomNetworkAlert: PropTypes.func, + /** + * A function that handles the network selection + */ + onNetworkSelected: PropTypes.func, + /** + * A function that handles switching to info modal + */ + switchModalContent: PropTypes.func, + /** + * returns the network onboarding state + */ + networkOnboardedState: PropTypes.array, }; getOtherNetworks = () => getAllNetworks().slice(1); + handleNetworkSelected = (type, ticker, url) => { + const { networkOnboardedState, switchModalContent, onClose, onNetworkSelected } = this.props; + const networkOnboarded = networkOnboardedState.filter((item) => item.network === sanitizeUrl(url)); + if (networkOnboarded.length === 0) { + switchModalContent(); + } else { + onClose(false); + } + return onNetworkSelected(type, ticker, url, networkOnboardedState); + }; + onNetworkChange = (type) => { - this.props.onClose(false); + this.handleNetworkSelected(type, ETH, type); const { NetworkController, CurrencyRateController } = Engine.context; CurrencyRateController.setNativeCurrency('ETH'); NetworkController.setProviderType(type); @@ -175,6 +200,9 @@ export class NetworkList extends PureComponent { nickname, rpcPrefs: { blockExplorerUrl }, } = rpc; + const useRpcName = nickname || sanitizeUrl(rpcUrl); + const useTicker = ticker || PRIVATENETWORK; + this.handleNetworkSelected(useRpcName, useTicker, sanitizeUrl(rpcUrl)); // If the network does not have chainId then show invalid custom network alert const chainIdNumber = parseInt(chainId, 10); @@ -195,8 +223,6 @@ export class NetworkList extends PureComponent { block_explorer_url: blockExplorerUrl, network_name: 'rpc', }); - - this.props.onClose(false); }; getStyles = () => { @@ -312,6 +338,7 @@ const mapStateToProps = (state) => ({ provider: state.engine.backgroundState.NetworkController.provider, frequentRpcList: state.engine.backgroundState.PreferencesController.frequentRpcList, thirdPartyApiMode: state.privacy.thirdPartyApiMode, + networkOnboardedState: state.networkOnboarded.networkOnboardedState, }); NetworkList.contextType = ThemeContext; diff --git a/app/components/UI/NetworkList/index.test.tsx b/app/components/UI/NetworkList/index.test.tsx index 0a29f9e03e7..a0452045b25 100644 --- a/app/components/UI/NetworkList/index.test.tsx +++ b/app/components/UI/NetworkList/index.test.tsx @@ -17,6 +17,9 @@ const initialState = { PreferencesController: { frequentRpcList: ['http://10.0.2.2:8545'] }, }, }, + networkOnboarded: { + networkOnboardedState: [{ network: 'mainnet', onboarded: true }], + }, }; const store = mockStore(initialState); diff --git a/app/components/UI/SwitchCustomNetwork/index.js b/app/components/UI/SwitchCustomNetwork/index.js index d1fd938f9d4..76cbbcd3ae6 100644 --- a/app/components/UI/SwitchCustomNetwork/index.js +++ b/app/components/UI/SwitchCustomNetwork/index.js @@ -72,7 +72,7 @@ const createStyles = (colors) => }, networkText: { fontSize: 12, - colors: colors.text.default, + color: colors.text.default, }, }); diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/__snapshots__/index.test.tsx.snap b/app/components/Views/Settings/NetworksSettings/NetworkSettings/__snapshots__/index.test.tsx.snap index cb553fb788e..8251eae2293 100644 --- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/__snapshots__/index.test.tsx.snap @@ -2,7 +2,15 @@ exports[`NetworkSettings should render correctly 1`] = ` `; diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js index 547e2171ddb..f21df45de34 100644 --- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js +++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js @@ -19,6 +19,9 @@ import { isPrefixedFormattedHexString } from '../../../../../util/number'; import AppConstants from '../../../../../core/AppConstants'; import AnalyticsV2 from '../../../../../util/analyticsV2'; import { ThemeContext, mockTheme } from '../../../../../util/theme'; +import { PRIVATENETWORK } from '../../../../../constants/network'; +import { showNetworkOnboardingAction } from '../../../../../actions/onboardNetwork'; +import sanitizeUrl from '../../../../../util/sanitizeUrl'; const createStyles = (colors) => StyleSheet.create({ @@ -103,6 +106,14 @@ class NetworkSettings extends PureComponent { * Object that represents the current route info like params passed to it */ route: PropTypes.object, + /** + * handles action for onboarding to a network + */ + showNetworkOnboardingAction: PropTypes.func, + /** + * returns an array of onboarded networks + */ + networkOnboardedState: PropTypes.array, }; state = { @@ -278,7 +289,7 @@ class NetworkSettings extends PureComponent { const { PreferencesController, NetworkController, CurrencyRateController } = Engine.context; const { rpcUrl, chainId: stateChainId, nickname, blockExplorerUrl, editable, enableAction } = this.state; const ticker = this.state.ticker && this.state.ticker.toUpperCase(); - const { navigation } = this.props; + const { navigation, networkOnboardedState } = this.props; // Check if CTA is disabled const isCtaDisabled = !enableAction || this.disabledByRpcUrl() || this.disabledByChainId(); if (isCtaDisabled) { @@ -286,6 +297,16 @@ class NetworkSettings extends PureComponent { } // Conditionally check existence of network (Only check in Add Mode) const isNetworkExists = editable ? [] : await this.checkIfNetworkExists(rpcUrl); + let isOnboarded = false; + const isNetworkOnboarded = networkOnboardedState.filter((item) => item.network === sanitizeUrl(rpcUrl)); + if (isNetworkOnboarded.length === 0) { + isOnboarded = true; + } + + const nativeToken = ticker || PRIVATENETWORK; + const networkType = nickname || rpcUrl; + const networkUrl = sanitizeUrl(rpcUrl); + const showNetworkOnboarding = isOnboarded; const formChainId = stateChainId.trim().toLowerCase(); @@ -320,7 +341,7 @@ class NetworkSettings extends PureComponent { network_name: 'rpc', }; AnalyticsV2.trackEvent(AnalyticsV2.ANALYTICS_EVENTS.NETWORK_ADDED, analyticsParamsAdd); - + this.props.showNetworkOnboardingAction({ networkUrl, networkType, nativeToken, showNetworkOnboarding }); navigation.navigate('WalletView'); } }; @@ -623,9 +644,14 @@ class NetworkSettings extends PureComponent { } NetworkSettings.contextType = ThemeContext; +const mapDispatchToProps = (dispatch) => ({ + showNetworkOnboardingAction: ({ networkUrl, networkType, nativeToken, showNetworkOnboarding }) => + dispatch(showNetworkOnboardingAction({ networkUrl, networkType, nativeToken, showNetworkOnboarding })), +}); const mapStateToProps = (state) => ({ frequentRpcList: state.engine.backgroundState.PreferencesController.frequentRpcList, + networkOnboardedState: state.networkOnboarded.networkOnboardedState, }); -export default connect(mapStateToProps)(NetworkSettings); +export default connect(mapStateToProps, mapDispatchToProps)(NetworkSettings); diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx index 2e874de527d..60b88654893 100644 --- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx +++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.test.tsx @@ -13,6 +13,9 @@ const initialState = { }, }, }, + networkOnboarded: { + networkOnboardedState: [{ network: 'mainnet', onboarded: true }], + }, }; const store = mockStore(initialState); diff --git a/app/constants/network.js b/app/constants/network.js index dc3c0f7ee16..fd0571ce772 100644 --- a/app/constants/network.js +++ b/app/constants/network.js @@ -5,3 +5,4 @@ export const RINKEBY = 'rinkeby'; export const GOERLI = 'goerli'; export const RPC = 'rpc'; export const NO_RPC_BLOCK_EXPLORER = 'NO_BLOCK_EXPLORER'; +export const PRIVATENETWORK = 'PRIVATENETWORK'; diff --git a/app/constants/test-ids.js b/app/constants/test-ids.js index 2fddf14ab7f..541ad4896b2 100644 --- a/app/constants/test-ids.js +++ b/app/constants/test-ids.js @@ -16,3 +16,7 @@ export const ENTER_ALIAS_INPUT_BOX_ID = 'address-alias-input'; export const NETWORK_LIST_MODAL_CONTAINER_ID = 'networks-list'; export const OTHER_NETWORK_LIST_ID = 'other-network-name'; export const NETWORK_SCROLL_ID = 'other-networks-scroll'; + +export const NETWORK_EDUCATION_MODAL_CONTAINER_ID = 'network-education-modal'; +export const NETWORK_EDUCATION_MODAL_CLOSE_BUTTON_ID = 'network-education-modal-close-button'; +export const NETWORK_EDUCATION_MODAL_NETWORK_NAME_ID = 'network-education-modal-network-name'; diff --git a/app/reducers/index.js b/app/reducers/index.js index 3399888ad77..0f0a0c248eb 100644 --- a/app/reducers/index.js +++ b/app/reducers/index.js @@ -16,6 +16,7 @@ import infuraAvailabilityReducer from './infuraAvailability'; import collectiblesReducer from './collectibles'; import recentsReducer from './recents'; import navigationReducer from './navigation'; +import networkOnboardReducer from './networkSelector'; import { combineReducers } from 'redux'; const rootReducer = combineReducers({ @@ -37,6 +38,7 @@ const rootReducer = combineReducers({ fiatOrders, infuraAvailability: infuraAvailabilityReducer, navigation: navigationReducer, + networkOnboarded: networkOnboardReducer, }); export default rootReducer; diff --git a/app/reducers/networkSelector/index.ts b/app/reducers/networkSelector/index.ts new file mode 100644 index 00000000000..fe17bf27852 --- /dev/null +++ b/app/reducers/networkSelector/index.ts @@ -0,0 +1,76 @@ +export const initialState = { + networkOnboardedState: [], + networkState: { + showNetworkOnboarding: false, + nativeToken: '', + networkType: '', + networkUrl: '', + }, + switchedNetwork: { + networkUrl: '', + networkStatus: false, + }, +}; + +/** + * + * Network onboarding reducer + * @returns + */ + +function networkOnboardReducer( + state = initialState, + action: { + nativeToken: string; + networkType: string; + networkUrl: string; + networkStatus: boolean; + showNetworkOnboarding: boolean; + type: string; + payload: any; + } = { + nativeToken: '', + networkType: '', + networkUrl: '', + networkStatus: false, + showNetworkOnboarding: false, + type: '', + payload: undefined, + } +) { + switch (action.type) { + case 'SHOW_NETWORK_ONBOARDING': + return { + ...state, + networkState: { + showNetworkOnboarding: action.showNetworkOnboarding, + nativeToken: action.nativeToken, + networkType: action.networkType, + networkUrl: action.networkUrl, + }, + }; + case 'NETWORK_SWITCHED': + return { + ...state, + switchedNetwork: { + networkUrl: action.networkUrl, + networkStatus: action.networkStatus, + }, + }; + case 'NETWORK_ONBOARDED': + return { + ...state, + networkState: { + showNetworkOnboarding: false, + nativeToken: '', + networkType: '', + networkUrl: '', + }, + networkOnboardedState: [{ network: action.payload, onboarded: true }, ...state.networkOnboardedState], + }; + default: + return state; + } +} + +export default networkOnboardReducer; diff --git a/app/util/checkAddress.tsx b/app/util/checkAddress.ts similarity index 100% rename from app/util/checkAddress.tsx rename to app/util/checkAddress.ts diff --git a/app/util/sanitizeUrl.test.ts b/app/util/sanitizeUrl.test.ts new file mode 100644 index 00000000000..fe8addfade8 --- /dev/null +++ b/app/util/sanitizeUrl.test.ts @@ -0,0 +1,9 @@ +import SanitizeUrl from './sanitizeUrl'; + +describe('SanitizeUrl', () => { + it('should sanitize url', () => { + const urlString = 'https://www.example.com/'; + const sanitizedUrl = SanitizeUrl(urlString); + expect(sanitizedUrl).toEqual('https://www.example.com'); + }); +}); diff --git a/app/util/sanitizeUrl.ts b/app/util/sanitizeUrl.ts new file mode 100644 index 00000000000..f0e828a350a --- /dev/null +++ b/app/util/sanitizeUrl.ts @@ -0,0 +1,3 @@ +const sanitizeUrl = (url: string) => url.replace(/\/$/, ''); + +export default sanitizeUrl; diff --git a/e2e/pages/ImportTokensView.js b/e2e/pages/ImportTokensView.js index ea230111a89..cf298f7a1f9 100644 --- a/e2e/pages/ImportTokensView.js +++ b/e2e/pages/ImportTokensView.js @@ -22,6 +22,11 @@ export default class ImportTokensView { static async tapOnImportButton() { await TestHelpers.tapByText('IMPORT'); } + + static async tapOnCancelButton() { + await TestHelpers.tapByText('CANCEL'); + } + static async isVisible() { await TestHelpers.checkIfVisible(CUSTOM_TOKEN_CONTAINER_ID); } diff --git a/e2e/pages/modals/NetworkEducationModal.js b/e2e/pages/modals/NetworkEducationModal.js new file mode 100644 index 00000000000..05b065661a5 --- /dev/null +++ b/e2e/pages/modals/NetworkEducationModal.js @@ -0,0 +1,30 @@ +import TestHelpers from '../../helpers'; +import { + NETWORK_EDUCATION_MODAL_CONTAINER_ID, + NETWORK_EDUCATION_MODAL_CLOSE_BUTTON_ID, + NETWORK_EDUCATION_MODAL_NETWORK_NAME_ID, +} from '../../../app/constants/test-ids'; +import { strings } from '../../../locales/i18n'; + +const manuallyAddTokenText = strings('network_information.add_token'); +export default class NetworkEducationModal { + static async tapGotItButton() { + await TestHelpers.tap(NETWORK_EDUCATION_MODAL_CLOSE_BUTTON_ID); + } + + static async tapManuallyAddTokenLink() { + await TestHelpers.tapByText(manuallyAddTokenText); + } + + static async isNetworkNameCorrect(network) { + await TestHelpers.checkIfElementHasString(NETWORK_EDUCATION_MODAL_NETWORK_NAME_ID, network); + } + + static async isVisible() { + await TestHelpers.checkIfVisible(NETWORK_EDUCATION_MODAL_CONTAINER_ID); + } + + static async isNotVisible() { + await TestHelpers.checkIfNotVisible(NETWORK_EDUCATION_MODAL_CONTAINER_ID); + } +} diff --git a/e2e/specs/add-custom-rpc.spec.js b/e2e/specs/add-custom-rpc.spec.js index a2c00a2df97..2ea5ea3d00a 100644 --- a/e2e/specs/add-custom-rpc.spec.js +++ b/e2e/specs/add-custom-rpc.spec.js @@ -14,6 +14,8 @@ import DrawerView from '../pages/Drawer/DrawerView'; import SettingsView from '../pages/Drawer/Settings/SettingsView'; import NetworkListModal from '../pages/modals/NetworkListModal'; +import NetworkEducationModal from '../pages/modals/NetworkEducationModal'; + import SkipAccountSecurityModal from '../pages/modals/SkipAccountSecurityModal'; import OnboardingWizardModal from '../pages/modals/OnboardingWizardModal'; import ProtectYourWalletModal from '../pages/modals/ProtectYourWalletModal'; @@ -111,15 +113,33 @@ describe('Custom RPC Tests', () => { await WalletView.isVisible(); await WalletView.isNetworkNameVisible('xDai'); }); + it('should dismiss network education modal', async () => { + await NetworkEducationModal.isVisible(); + await NetworkEducationModal.isNetworkNameCorrect('Xdai'); + await NetworkEducationModal.tapGotItButton(); + await NetworkEducationModal.isNotVisible(); + }); - it('should validate that xDai is added to network list then switch networks', async () => { + it('should validate that xDai is added to network list', async () => { // Tap to prompt network list await WalletView.tapNetworksButtonOnNavBar(); await NetworkListModal.isVisible(); await NetworkListModal.isNetworkNameVisibleInListOfNetworks('xDai'); + }); + it('should switch to Rinkeby then dismiss the network education modal', async () => { await NetworkListModal.changeNetwork(RINKEBY); + await NetworkEducationModal.isVisible(); + await NetworkEducationModal.isNetworkNameCorrect('Rinkeby Testnet'); + + await NetworkEducationModal.tapGotItButton(); + await NetworkEducationModal.isNotVisible(); + + await WalletView.isVisible(); + }); + + it('should switch back to xDAI', async () => { await WalletView.isNetworkNameVisible(RINKEBY); await WalletView.tapNetworksButtonOnNavBar(); @@ -131,6 +151,7 @@ describe('Custom RPC Tests', () => { await WalletView.isVisible(); await WalletView.isNetworkNameVisible('xDai'); + await NetworkEducationModal.isNotVisible(); }); it('should go to settings networks and remove xDai network', async () => { diff --git a/e2e/specs/contract-nickname.spec.js b/e2e/specs/contract-nickname.spec.js index 50a969da160..9df04f5f301 100644 --- a/e2e/specs/contract-nickname.spec.js +++ b/e2e/specs/contract-nickname.spec.js @@ -17,6 +17,7 @@ import SettingsView from '../pages/Drawer/Settings/SettingsView'; import ApprovalModal from '../pages/modals/ApprovalModal'; import NetworkListModal from '../pages/modals/NetworkListModal'; import OnboardingWizardModal from '../pages/modals/OnboardingWizardModal'; +import NetworkEducationModal from '../pages/modals/NetworkEducationModal'; import TestHelpers from '../helpers'; @@ -70,6 +71,13 @@ describe('Adding Contract Nickname', () => { await NetworkListModal.changeNetwork(RINKEBY); await WalletView.isNetworkNameVisible(RINKEBY); + await TestHelpers.delay(1500); + }); + + it('should dismiss network education modal', async () => { + await NetworkEducationModal.isVisible(); + await NetworkEducationModal.tapGotItButton(); + await NetworkEducationModal.isNotVisible(); }); it('should deep link to the approval modal', async () => { diff --git a/e2e/specs/deeplinks.spec.js b/e2e/specs/deeplinks.spec.js index 2e97bb48d7b..e308a4b7e1f 100644 --- a/e2e/specs/deeplinks.spec.js +++ b/e2e/specs/deeplinks.spec.js @@ -8,6 +8,7 @@ import ImportWalletView from '../pages/Onboarding/ImportWalletView'; import OnboardingWizardModal from '../pages/modals/OnboardingWizardModal'; import ConnectModal from '../pages/modals/ConnectModal'; +import NetworkEducationModal from '../pages/modals/NetworkEducationModal'; import { Browser } from '../pages/Drawer/Browser'; import DrawerView from '../pages/Drawer/DrawerView'; @@ -111,11 +112,19 @@ describe('Deep linking Tests', () => { await NetworkView.tapRpcNetworkAddButton(); await WalletView.isVisible(); - await WalletView.isNetworkNameVisible('Binance Smart Chain Mainnet'); - await WalletView.tapDrawerButton(); // tapping burger menu + }); + + it('should dismiss network education modal', async () => { + await NetworkEducationModal.isVisible(); + await NetworkEducationModal.isNetworkNameCorrect('Binance Smart Chain Mainnet'); + await NetworkEducationModal.tapGotItButton(); + await NetworkEducationModal.isNotVisible(); }); it('should return to settings then networks', async () => { + await WalletView.isNetworkNameVisible('Binance Smart Chain Mainnet'); + await WalletView.tapDrawerButton(); // tapping burger menu + // Open Drawer await DrawerView.isVisible(); await DrawerView.tapSettings(); @@ -143,6 +152,13 @@ describe('Deep linking Tests', () => { await WalletView.isNetworkNameVisible('Polygon Mainnet'); }); + it('should dismiss the network education modal', async () => { + await NetworkEducationModal.isVisible(); + await NetworkEducationModal.isNetworkNameCorrect('Polygon Mainnet'); + await NetworkEducationModal.tapGotItButton(); + await NetworkEducationModal.isNotVisible(); + }); + it('should deep link to the send flow on matic', async () => { await TestHelpers.openDeepLink(POLYGON_DEEPLINK_URL); diff --git a/e2e/specs/wallet-tests.spec.js b/e2e/specs/wallet-tests.spec.js index 2984e538f5c..e5a2f83d16b 100644 --- a/e2e/specs/wallet-tests.spec.js +++ b/e2e/specs/wallet-tests.spec.js @@ -20,6 +20,7 @@ import ImportTokensView from '../pages/ImportTokensView'; import OnboardingWizardModal from '../pages/modals/OnboardingWizardModal'; import NetworkListModal from '../pages/modals/NetworkListModal'; import RequestPaymentModal from '../pages/modals/RequestPaymentModal'; +import NetworkEducationModal from '../pages/modals/NetworkEducationModal'; const SECRET_RECOVERY_PHRASE = 'fold media south add since false relax immense pause cloth just raven'; const PASSWORD = `12345678`; @@ -138,6 +139,12 @@ describe('Wallet Tests', () => { await WalletView.isNetworkNameVisible(RINKEBY); }); + it('should dismiss network education modal', async () => { + await NetworkEducationModal.isVisible(); + await NetworkEducationModal.tapGotItButton(); + await NetworkEducationModal.isNotVisible(); + }); + it('should add a collectible', async () => { await WalletView.isVisible(); // Tap on COLLECTIBLES tab @@ -173,8 +180,7 @@ describe('Wallet Tests', () => { await WalletView.isNFTNameVisible('1 CryptoKitties'); }); - it('should add a token', async () => { - // Check that we are on the wallet screen + it('should switch back to Mainnet network', async () => { await WalletView.isVisible(); // Tap on TOKENS tab await WalletView.tapTokensTab(); @@ -184,7 +190,16 @@ describe('Wallet Tests', () => { await NetworkListModal.isVisible(); await NetworkListModal.changeNetwork(ETHEREUM); await WalletView.isNetworkNameVisible(ETHEREUM); + }); + it('should dismiss mainnet network education modal', async () => { + await NetworkEducationModal.isVisible(); + await NetworkEducationModal.tapGotItButton(); + await NetworkEducationModal.isNotVisible(); + }); + + it('should add a token', async () => { + // Check that we are on the wallet screen // Tap on Add Tokens await WalletView.tapImportTokensButton(); // Search for SAI diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 82aa0ea1eb3..7985bc750f7 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1008,7 +1008,6 @@ COPY_PHASE_STRIP = YES; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; diff --git a/locales/languages/en.json b/locales/languages/en.json index 82eec78f45c..663c372bfe7 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1834,5 +1834,23 @@ "name_placeholder": "Add a nickname to this address", "contract": "Contract", "nickname": "nickname" + }, + "network_information": { + "things_to_keep_in_mind": "Things to keep in mind", + "testnet_network": "{{type}} Testnet", + "first_description": "The native token on this network is {{ticker}}. It is the token used for gas fees.", + "second_description": "If you attempt to send assets directly from one network to another, this may result in permanent asset loss. Make sure to use a bridge.", + "third_description": "Your wallet may not automatically show up in your wallet.", + "private_network": "This network is unknown and may use a special token for gas fees.", + "unknown_network": "Unknown network", + "switched_network": "You have switched to", + "learn_more": "Learn more", + "add_token": "Click here to manually add the tokens", + "add_token_manually": "Add token manually", + "got_it": "Got it", + "error_title": "Oops! Something went wrong.", + "error_message": "There was an error loading the network information. Please try again later.", + "private_network_third_description": "Please enter a name for this network to make it easy to identify.", + "learn_more_url": "https://metamask.zendesk.com/hc/en-us/articles/4404424659995" } }