diff --git a/.github/workflows/bump-version-name.yml b/.github/workflows/bump-version-name.yml index 9e1de2231b3..96b899d71b4 100644 --- a/.github/workflows/bump-version-name.yml +++ b/.github/workflows/bump-version-name.yml @@ -7,7 +7,7 @@ on: types: [opened] jobs: bump-version-name: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest if: "contains(github.head_ref, 'release/')" permissions: contents: write diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 93758bae919..0f7977000c6 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -8,7 +8,7 @@ on: jobs: CLABot: if: github.event_name == 'pull_request_target' || contains(github.event.comment.html_url, '/pull/') - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest permissions: pull-requests: write contents: write diff --git a/.github/workflows/create-release-pr.yml b/.github/workflows/create-release-pr.yml index bb7c5767b5b..28b9070eccc 100644 --- a/.github/workflows/create-release-pr.yml +++ b/.github/workflows/create-release-pr.yml @@ -15,7 +15,7 @@ on: jobs: create-release-pr: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest permissions: contents: write pull-requests: write diff --git a/.github/workflows/crowdin_action.yml b/.github/workflows/crowdin_action.yml index 24cf9152bd6..119b13af130 100644 --- a/.github/workflows/crowdin_action.yml +++ b/.github/workflows/crowdin_action.yml @@ -13,7 +13,7 @@ on: jobs: synchronize-with-crowdin: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 639d6fb58a2..3ab51e57c83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ ## Current Main Branch +## 6.5.0 - May 4, 2023 +- [#5743](https://github.com/MetaMask/metamask-mobile/pull/5743): [FEATURE] On-ramp: Add buy-crypto deeplink +- [#6201](https://github.com/MetaMask/metamask-mobile/pull/6201): [FIX] [SDK] Missing redirect breaking backward compatibility +- [#6232](https://github.com/MetaMask/metamask-mobile/pull/6232): [FIX] bottom margin for detecting end of the page +- [#6166](https://github.com/MetaMask/metamask-mobile/pull/6166): [FEATURE] trigger walletconnect modal using approval controller +- [#6223](https://github.com/MetaMask/metamask-mobile/pull/6223): [IMPROVEMENT] Update to Node.js v16 +- [#6051](https://github.com/MetaMask/metamask-mobile/pull/6051): [FEATURE] Total balance and portfolio button changed +- [#6156](https://github.com/MetaMask/metamask-mobile/pull/6156): [IMPROVEMENT] On-ramp: Use dynamic list of networks +- [#6145](https://github.com/MetaMask/metamask-mobile/pull/6145): [IMPROVEMENT] Synced and optimized icons +- [#6138](https://github.com/MetaMask/metamask-mobile/pull/6138): [FEATURE] On-ramp: Add orderProcessor exponential backoff for orders +- [#6139](https://github.com/MetaMask/metamask-mobile/pull/6139): [FEATURE] On-ramp: Add same amount rendering as the order details to the order list +- [#6189](https://github.com/MetaMask/metamask-mobile/pull/6189): [FEATURE] On-ramp: Remove hiding the provider modal when quotes refresh +- [#6216](https://github.com/MetaMask/metamask-mobile/pull/6216): [IMPROVEMENT] account icon matches user's preferred identicon +- [#5956](https://github.com/MetaMask/metamask-mobile/pull/5956): [IMPROVEMENT] Show token symbol in verify contract details +- [#5458](https://github.com/MetaMask/metamask-mobile/pull/5458): [IMPROVEMENT] Support sepolia network +- [#6185](https://github.com/MetaMask/metamask-mobile/pull/6185): [FIX] remove pubnub package and associated sync with extension code +- [#6181](https://github.com/MetaMask/metamask-mobile/pull/6181): [IMPROVEMENT] Componentize Header Component +- [#6153](https://github.com/MetaMask/metamask-mobile/pull/6153): [IMPROVEMENT] On-ramp: Refactor order selector by id +- [#6044](https://github.com/MetaMask/metamask-mobile/pull/6044): [IMPROVEMENT] Componentize Badge and Badge Wrapper +- [#6180](https://github.com/MetaMask/metamask-mobile/pull/6180): [IMPROVEMENT] Componentized Overlay Component +- [#6173](https://github.com/MetaMask/metamask-mobile/pull/6173): [REFACTOR] Auto Lock section +- [#6174](https://github.com/MetaMask/metamask-mobile/pull/6174): [IMPROVEMENT] Update Tab bar styles +- [#6056](https://github.com/MetaMask/metamask-mobile/pull/6056): [IMPROVEMENT] Show Identicon for unknown token and if token icon is unknown +- [#6076](https://github.com/MetaMask/metamask-mobile/pull/6076): [BUGFIX] Fixes WalletConnect deep links (wc:// schema) not working properly +- [#6157](https://github.com/MetaMask/metamask-mobile/pull/6157): [REFACTOR] Change Password setting +- [#5718](https://github.com/MetaMask/metamask-mobile/pull/5718): [FIX] Nonce Too Low for Approve Transaction + ## 6.4.0 - Apr 20, 2023 - [#6144](https://github.com/MetaMask/metamask-mobile/pull/6144): [FEATURE] New Crowdin translations by Github Action - [#6143](https://github.com/MetaMask/metamask-mobile/pull/6143): [UPDATE] Crowdin token to use METAMASKBOT_CROWDIN_TOKEN diff --git a/README.md b/README.md index 389292fe0e8..b09b8937a92 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,7 @@ For local testing, the wallet is created using the secret recovery phrase from t ##### iOS All tests live within the e2e/specs folder. -Prequisites for running tests: +Prerequisites for running tests: - Make sure to install `detox-cli` by referring to the instructions mentioned [here](https://wix.github.io/Detox/docs/introduction/getting-started/#detox-prerequisites). - Additionally, install `applesimutils` by following the guidelines provided [here](https://github.com/wix/AppleSimulatorUtils). - Before running any tests, it's recommended to refer to the `iOS section` above and check the latest simulator device specified under `Install the correct simulator`. @@ -246,7 +246,12 @@ If you have already built the application for Detox and want to run a specific t ```bash yarn test:e2e:ios:debug:single e2e/specs/TEST_NAME.spec.js ``` +To run tests associated with a certain tag, you can do so using the `--testNamePattern` flag. For example: +```bash +yarn test:e2e:ios:debug --testNamePattern="Smoke" +``` +This runs all tests that are tagged "Smoke" ##### Android All android tests live within the wdio/feature folder. @@ -286,7 +291,7 @@ To get a better understanding of the internal architecture of this app take a lo ### Storybook -We have begun documenting our components using storybook please read the [Documentation Guidelines](./storybook/DOCUMENTATION_GUIDELINES.md) to get up and running. +We have begun documenting our components using Storybook. Please read the [Documentation Guidelines](./storybook/DOCUMENTATION_GUIDELINES.md) to get up and running. ### Other Docs diff --git a/android/app/build.gradle b/android/app/build.gradle index 0fb4fbb2e70..7fcedf5fe91 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,7 +98,7 @@ def enableSeparateBuildPerCPUArchitecture = false /** * Set this to true to Run Proguard on Release builds to minify the Java bytecode. */ -def enableProguardInReleaseBuilds = false +def enableProguardInReleaseBuilds = true /** * The preferred build flavor of JavaScriptCore (JSC) @@ -123,6 +123,14 @@ def reactNativeArchitectures() { return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"] } +/** +* Adding function that will retuen the Bitrise ndkPath if it is a QA or Production Build +*/ +def ndkPath() { + return System.getenv('METAMASK_ENVIRONMENT') == 'qa' || System.getenv('METAMASK_ENVIRONMENT') == 'production' ? rootProject.ext.bitriseNdkPath : "" +} + + android { ndkVersion rootProject.ext.ndkVersion @@ -148,24 +156,24 @@ android { } signingConfigs { - release { - storeFile file('../keystores/release.keystore') - storePassword System.getenv("BITRISEIO_ANDROID_KEYSTORE_PASSWORD") - keyAlias System.getenv("BITRISEIO_ANDROID_KEYSTORE_ALIAS") - keyPassword System.getenv("BITRISEIO_ANDROID_KEYSTORE_PRIVATE_KEY_PASSWORD") - } - qa { - storeFile file('../keystores/internalRelease.keystore') - storePassword System.getenv("BITRISEIO_ANDROID_QA_KEYSTORE_PASSWORD") - keyAlias System.getenv("BITRISEIO_ANDROID_QA_KEYSTORE_ALIAS") - keyPassword System.getenv("BITRISEIO_ANDROID_QA_KEYSTORE_PRIVATE_KEY_PASSWORD") - } - debug { - storeFile file('debug.keystore') - storePassword 'android' - keyAlias 'androiddebugkey' - keyPassword 'android' - } + release { + storeFile file('../keystores/release.keystore') + storePassword System.getenv("BITRISEIO_ANDROID_KEYSTORE_PASSWORD") + keyAlias System.getenv("BITRISEIO_ANDROID_KEYSTORE_ALIAS") + keyPassword System.getenv("BITRISEIO_ANDROID_KEYSTORE_PRIVATE_KEY_PASSWORD") + } + qa { + storeFile file('../keystores/internalRelease.keystore') + storePassword System.getenv("BITRISEIO_ANDROID_QA_KEYSTORE_PASSWORD") + keyAlias System.getenv("BITRISEIO_ANDROID_QA_KEYSTORE_ALIAS") + keyPassword System.getenv("BITRISEIO_ANDROID_QA_KEYSTORE_PRIVATE_KEY_PASSWORD") + } + debug { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } } splits { @@ -184,9 +192,6 @@ android { } release { manifestPlaceholders.isDebug = false - // Caution! In production, you need to generate your own keystore file. - // see https://reactnative.dev/docs/signed-apk-android. - signingConfig signingConfigs.debug minifyEnabled enableProguardInReleaseBuilds proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" } diff --git a/android/build.gradle b/android/build.gradle index 4c94dcd1772..e9d938304cd 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -8,7 +8,8 @@ buildscript { compileSdkVersion = 33 targetSdkVersion = 33 // We use NDK 23 which has both M1 support and is the side-by-side NDK version from AGP. - ndkVersion = "23.1.7779620" + ndkVersion = "24.0.8215888" + bitriseNdkPath = "/usr/local/share/android-sdk/ndk-bundle" kotlin_version = "1.7.0" kotlinVersion = "$kotlin_version" supportLibVersion = "28.0.0" diff --git a/app/component-library/components/Modals/ModalMandatory/ModalMandatory.tsx b/app/component-library/components/Modals/ModalMandatory/ModalMandatory.tsx index e3db0bb24b6..9751ea6f02b 100644 --- a/app/component-library/components/Modals/ModalMandatory/ModalMandatory.tsx +++ b/app/component-library/components/Modals/ModalMandatory/ModalMandatory.tsx @@ -234,7 +234,7 @@ const ModalMandatory = ({ route }: MandatoryModalProps) => { activeOpacity={1} {...generateTestId(Platform, TERMS_OF_USE_CHECKBOX_ICON_ID)} > - + {checkboxText} { ); + const EditAccountNameFlow = () => ( + + + + ); + return ( // do not render unless a route is defined (route && ( @@ -612,6 +619,10 @@ const App = ({ userLoggedIn }) => { component={ConnectQRHardwareFlow} options={{ animationEnabled: true }} /> + {renderSplash()} diff --git a/app/components/UI/ApproveTransactionReview/index.js b/app/components/UI/ApproveTransactionReview/index.js index 29d97b96884..4be08fc2d76 100644 --- a/app/components/UI/ApproveTransactionReview/index.js +++ b/app/components/UI/ApproveTransactionReview/index.js @@ -1,10 +1,5 @@ import React, { PureComponent } from 'react'; -import { - View, - TouchableOpacity, - InteractionManager, - Linking, -} from 'react-native'; +import { View, TouchableOpacity, InteractionManager } from 'react-native'; import Eth from 'ethjs-query'; import ActionView from '../../UI/ActionView'; import PropTypes from 'prop-types'; @@ -45,13 +40,11 @@ import { WALLET_CONNECT_ORIGIN } from '../../../util/walletconnect'; import { withNavigation } from '@react-navigation/compat'; import { isTestNet, - isMainnetByChainId, isMultiLayerFeeNetwork, fetchEstimatedMultiLayerL1Fee, } from '../../../util/networks'; import EditPermission from './EditPermission'; import Logger from '../../../util/Logger'; -import InfoModal from '../Swaps/components/InfoModal'; import ButtonLink from '../../../component-library/components/Buttons/Button/variants/ButtonLink'; import { getTokenList } from '../../../reducers/tokens'; import TransactionReview from '../../UI/TransactionReview/TransactionReviewEIP1559Update'; @@ -258,7 +251,6 @@ class ApproveTransactionReview extends PureComponent { spenderAddress: '0x...', transaction: this.props.transaction, token: {}, - showGasTooltip: false, gasTransactionObject: {}, multiLayerL1FeeTotal: '0x0', fetchingUpdateDone: false, @@ -560,46 +552,6 @@ class ApproveTransactionReview extends PureComponent { ); }; - openLinkAboutGas = () => - Linking.openURL( - 'https://community.metamask.io/t/what-is-gas-why-do-transactions-take-so-long/3172', - ); - - toggleGasTooltip = () => - this.setState((state) => ({ showGasTooltip: !state.showGasTooltip })); - - renderGasTooltip = () => { - const isMainnet = isMainnetByChainId(this.props.chainId); - return ( - - - {strings('transaction.gas_education_1')} - {strings( - `transaction.gas_education_2${isMainnet ? '_ethereum' : ''}`, - )}{' '} - {strings('transaction.gas_education_3')} - - - {strings('transaction.gas_education_4')} - - - - {strings('transaction.gas_education_learn_more')} - - - - } - /> - ); - }; - renderEditPermission = () => { const { host, @@ -863,7 +815,6 @@ class ApproveTransactionReview extends PureComponent { - {this.renderGasTooltip()} ); }; diff --git a/app/components/UI/CollectibleOverview/index.js b/app/components/UI/CollectibleOverview/index.js index 2162e5cbe2a..5028fc1e562 100644 --- a/app/components/UI/CollectibleOverview/index.js +++ b/app/components/UI/CollectibleOverview/index.js @@ -13,6 +13,7 @@ import { SafeAreaView, TouchableWithoutFeedback, } from 'react-native'; + import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { baseStyles } from '../../../styles/common'; @@ -28,6 +29,7 @@ import { toLocaleDate } from '../../../util/date'; import { renderFromWei } from '../../../util/number'; import { renderShortAddress } from '../../../util/address'; import { isMainNet } from '../../../util/networks'; +import { isLinkSafe } from '../../../util/linkCheck'; import etherscanLink from '@metamask/etherscan-link'; import { addFavoriteCollectible, @@ -131,6 +133,10 @@ const createStyles = (colors) => }, }); +const FieldType = { + Link: 'Link', + Text: 'Text', +}; /** * View that displays the information of a specific ERC-721 Token */ @@ -166,11 +172,10 @@ const CollectibleOverview = ({ }, [collectible.description]); const renderCollectibleInfoRow = useCallback( - (key, value, onPress) => { + ({ key, value, onPress, type }) => { if (!value) return null; - - if (value.toLowerCase().includes('javascript')) { - return null; + if (type === FieldType.Link) { + if (!isLinkSafe(value)) return null; } return ( @@ -203,42 +208,50 @@ const CollectibleOverview = ({ ); const renderCollectibleInfo = () => [ - renderCollectibleInfoRow( - strings('collectible.collectible_token_standard'), - collectible?.standard, - ), - renderCollectibleInfoRow( - strings('collectible.collectible_last_sold'), - collectible?.lastSale?.event_timestamp && + renderCollectibleInfoRow({ + key: strings('collectible.collectible_token_standard'), + value: collectible?.standard, + type: FieldType.Text, + }), + renderCollectibleInfoRow({ + key: strings('collectible.collectible_last_sold'), + value: + collectible?.lastSale?.event_timestamp && toLocaleDate( new Date(collectible?.lastSale?.event_timestamp), ).toString(), - ), - renderCollectibleInfoRow( - strings('collectible.collectible_last_price_sold'), - collectible?.lastSale?.total_price && + type: FieldType.Text, + }), + renderCollectibleInfoRow({ + key: strings('collectible.collectible_last_price_sold'), + value: + collectible?.lastSale?.total_price && `${renderFromWei(collectible?.lastSale?.total_price)} ETH`, - ), - renderCollectibleInfoRow( - strings('collectible.collectible_source'), - collectible?.imageOriginal, - () => openLink(collectible?.imageOriginal), - ), - renderCollectibleInfoRow( - strings('collectible.collectible_link'), - collectible?.externalLink, - () => openLink(collectible?.externalLink), - ), - renderCollectibleInfoRow( - strings('collectible.collectible_asset_contract'), - renderShortAddress(collectible?.address), - () => { + type: FieldType.Text, + }), + renderCollectibleInfoRow({ + key: strings('collectible.collectible_source'), + value: collectible?.imageOriginal, + onPress: () => openLink(collectible?.imageOriginal), + type: FieldType.Link, + }), + renderCollectibleInfoRow({ + key: strings('collectible.collectible_link'), + value: collectible?.externalLink, + onPress: () => openLink(collectible?.externalLink), + type: FieldType.Link, + }), + renderCollectibleInfoRow({ + key: strings('collectible.collectible_asset_contract'), + value: renderShortAddress(collectible?.address), + onPress: () => { if (isMainNet(chainId)) openLink( etherscanLink.createTokenTrackerLink(collectible?.address, chainId), ); }, - ), + type: FieldType.Text, + }), ]; const collectibleToFavorites = useCallback(() => { diff --git a/app/components/UI/FiatOnRampAggregator/Views/AmountToBuy.tsx b/app/components/UI/FiatOnRampAggregator/Views/AmountToBuy.tsx index 4bd1abbf398..65152f27ae5 100644 --- a/app/components/UI/FiatOnRampAggregator/Views/AmountToBuy.tsx +++ b/app/components/UI/FiatOnRampAggregator/Views/AmountToBuy.tsx @@ -663,7 +663,9 @@ const AmountToBuy = () => { description={strings( 'fiat_on_ramp_aggregator.no_tokens_available', { - network: NETWORKS_NAMES[selectedChainId], + network: + NETWORKS_NAMES[selectedChainId] || + strings('fiat_on_ramp_aggregator.this_network'), region: selectedRegion?.name, }, )} diff --git a/app/components/UI/FiatOnRampAggregator/Views/Checkout.tsx b/app/components/UI/FiatOnRampAggregator/Views/Checkout.tsx index 47aeee70091..25a6f9cf073 100644 --- a/app/components/UI/FiatOnRampAggregator/Views/Checkout.tsx +++ b/app/components/UI/FiatOnRampAggregator/Views/Checkout.tsx @@ -1,40 +1,32 @@ import React, { useCallback, useEffect, useState } from 'react'; import { View } from 'react-native'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { parseUrl } from 'query-string'; import { WebView, WebViewNavigation } from 'react-native-webview'; import { useNavigation } from '@react-navigation/native'; -import { CryptoCurrency, Order, Provider } from '@consensys/on-ramp-sdk'; +import { Provider } from '@consensys/on-ramp-sdk'; import { baseStyles } from '../../../../styles/common'; import { useTheme } from '../../../../util/theme'; import { getFiatOnRampAggNavbar } from '../../Navbar'; -import { NATIVE_ADDRESS } from '../../../../constants/on-ramp'; import { useFiatOnRampSDK, SDK } from '../sdk'; import { addFiatCustomIdData, - addFiatOrder, - FiatOrder, removeFiatCustomIdData, } from '../../../../reducers/fiatOrders'; import { CustomIdData } from '../../../../reducers/fiatOrders/types'; -import Engine from '../../../../core/Engine'; -import { toLowerCaseEquals } from '../../../../util/general'; import { createNavigationDetails, useParams, } from '../../../../util/navigation/navUtils'; -import { hexToBN } from '../../../../util/number'; -import { protectWalletModalVisible } from '../../../../actions/user'; import { aggregatorOrderToFiatOrder } from '../orderProcessor/aggregator'; import { createCustomOrderIdData } from '../orderProcessor/customOrderId'; -import { getNotificationDetails } from '..'; -import NotificationManager from '../../../../core/NotificationManager'; import ScreenLayout from '../components/ScreenLayout'; import ErrorView from '../components/ErrorView'; import ErrorViewWithReporting from '../components/ErrorViewWithReporting'; import useAnalytics from '../hooks/useAnalytics'; import { strings } from '../../../../../locales/i18n'; import Routes from '../../../../constants/navigation/Routes'; +import useHandleSuccessfulOrder from '../hooks/useHandleSuccessfulOrder'; interface CheckoutParams { url: string; @@ -58,10 +50,7 @@ const CheckoutWebView = () => { const navigation = useNavigation(); const params = useParams(); const { colors } = useTheme(); - const accounts = useSelector( - (state: any) => - state.engine.backgroundState.AccountTrackerController.accounts, - ); + const handleSuccessfulOrder = useHandleSuccessfulOrder(); const { url: uri, customOrderId, provider } = params; @@ -97,88 +86,6 @@ const CheckoutWebView = () => { dispatch(addFiatCustomIdData(customOrderIdData)); }, [customOrderId, dispatch, selectedAddress, selectedChainId]); - const addTokenToTokensController = useCallback( - async (token: CryptoCurrency) => { - if (!token) return; - - const { address, symbol, decimals, network, name } = token; - const chainId = network?.chainId; - - if ( - Number(chainId) !== Number(selectedChainId) || - address === NATIVE_ADDRESS - ) { - return; - } - - // @ts-expect-error Engine context typing - const { TokensController } = Engine.context; - - if ( - !TokensController.state.tokens.includes((t: any) => - toLowerCaseEquals(t.address, address), - ) - ) { - await TokensController.addToken(address, symbol, decimals, null, name); - } - }, - [selectedChainId], - ); - - const handleAddFiatOrder = useCallback( - (order) => { - dispatch(addFiatOrder(order)); - }, - [dispatch], - ); - - const handleDispatchUserWalletProtection = useCallback(() => { - dispatch(protectWalletModalVisible()); - }, [dispatch]); - - const handleSuccessfulOrder = useCallback( - async (order) => { - // add the order to the redux global store - handleAddFiatOrder(order); - // register the token automatically - await addTokenToTokensController((order as any)?.data?.cryptoCurrency); - - // prompt user to protect his/her wallet - handleDispatchUserWalletProtection(); - // close the checkout webview - // @ts-expect-error navigation prop mismatch - navigation.dangerouslyGetParent()?.pop(); - NotificationManager.showSimpleNotification( - getNotificationDetails(order as any), - ); - trackEvent('ONRAMP_PURCHASE_SUBMITTED', { - provider_onramp: ((order as FiatOrder)?.data as Order)?.provider?.name, - payment_method_id: ((order as FiatOrder)?.data as Order)?.paymentMethod - ?.id, - currency_source: ((order as FiatOrder)?.data as Order)?.fiatCurrency - .symbol, - currency_destination: ((order as FiatOrder)?.data as Order) - ?.cryptoCurrency.symbol, - chain_id_destination: selectedChainId, - order_type: (order as FiatOrder)?.orderType, - is_apple_pay: false, - has_zero_native_balance: accounts[selectedAddress]?.balance - ? (hexToBN(accounts[selectedAddress].balance) as any)?.isZero?.() - : undefined, - }); - }, - [ - accounts, - addTokenToTokensController, - handleAddFiatOrder, - handleDispatchUserWalletProtection, - navigation, - selectedAddress, - selectedChainId, - trackEvent, - ], - ); - const handleNavigationStateChange = async (navState: WebViewNavigation) => { if ( !isRedirectionHandled && diff --git a/app/components/UI/FiatOnRampAggregator/containers/ApplePayButton.tsx b/app/components/UI/FiatOnRampAggregator/containers/ApplePayButton.tsx index 1108f1580ee..20e40e87f60 100644 --- a/app/components/UI/FiatOnRampAggregator/containers/ApplePayButton.tsx +++ b/app/components/UI/FiatOnRampAggregator/containers/ApplePayButton.tsx @@ -1,24 +1,19 @@ import React, { useCallback } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { useNavigation } from '@react-navigation/native'; -import { Order, QuoteResponse } from '@consensys/on-ramp-sdk'; -import { protectWalletModalNotVisible } from '../../../../actions/user'; +import { QuoteResponse } from '@consensys/on-ramp-sdk'; import { addAuthenticationUrl, - addFiatOrder, FiatOrder, } from '../../../../reducers/fiatOrders'; import ApplePayButtonComponent from '../components/ApplePayButton'; import useApplePay, { ABORTED } from '../hooks/useApplePay'; -import useAnalytics from '../hooks/useAnalytics'; import Logger from '../../../../util/Logger'; import { strings } from '../../../../../locales/i18n'; import { setLockTime } from '../../../../actions/settings'; import { aggregatorOrderToFiatOrder } from '../orderProcessor/aggregator'; -import { getNotificationDetails } from '..'; import NotificationManager from '../../../../core/NotificationManager'; -import { hexToBN } from '../../../../util/number'; import { useFiatOnRampSDK } from '../sdk'; +import useHandleSuccessfulOrder from '../hooks/useHandleSuccessfulOrder'; function buildAuthenticationUrl(url: string, redirectUrl: string) { const urlObject = new URL(url); @@ -36,66 +31,13 @@ const ApplePayButton = ({ quote: QuoteResponse; label: string; }) => { - const navigation = useNavigation(); - const dispatch = useDispatch(); - const trackEvent = useAnalytics(); const { selectedAddress, selectedChainId, callbackBaseUrl } = useFiatOnRampSDK(); - const accounts = useSelector( - (state: any) => - state.engine.backgroundState.AccountTrackerController.accounts, - ); - + const dispatch = useDispatch(); const [pay] = useApplePay(quote); + const handleSuccessfulOrder = useHandleSuccessfulOrder(); const lockTime = useSelector((state: any) => state.settings.lockTime); - const addOrder = useCallback( - (order) => dispatch(addFiatOrder(order)), - [dispatch], - ); - const protectWalletModalVisible = useCallback( - () => dispatch(protectWalletModalNotVisible()), - [dispatch], - ); - - const handleSuccessfulOrder = useCallback( - (order) => { - const fiatOrder: FiatOrder = { - ...aggregatorOrderToFiatOrder(order), - network: selectedChainId, - account: selectedAddress, - }; - addOrder(fiatOrder); - // @ts-expect-error pop is not defined - navigation.dangerouslyGetParent()?.pop(); - protectWalletModalVisible(); - NotificationManager.showSimpleNotification( - getNotificationDetails(fiatOrder), - ); - trackEvent('ONRAMP_PURCHASE_SUBMITTED', { - provider_onramp: (fiatOrder?.data as Order)?.provider?.name, - payment_method_id: (fiatOrder?.data as Order)?.paymentMethod?.id, - currency_source: (fiatOrder?.data as Order)?.fiatCurrency.symbol, - currency_destination: (fiatOrder?.data as Order)?.cryptoCurrency.symbol, - chain_id_destination: selectedChainId, - is_apple_pay: true, - order_type: fiatOrder.orderType, - has_zero_native_balance: accounts[selectedAddress]?.balance - ? (hexToBN(accounts[selectedAddress].balance) as any)?.isZero?.() - : undefined, - }); - }, - [ - accounts, - addOrder, - selectedChainId, - navigation, - protectWalletModalVisible, - selectedAddress, - trackEvent, - ], - ); - const handlePress = useCallback(async () => { const prevLockTime = lockTime; dispatch(setLockTime(-1)); @@ -109,8 +51,14 @@ const ApplePayButton = ({ ); dispatch(addAuthenticationUrl(authenticationUrl)); } - - handleSuccessfulOrder(paymentResult.order); + if (paymentResult.order) { + const fiatOrder: FiatOrder = { + ...aggregatorOrderToFiatOrder(paymentResult.order), + network: selectedChainId, + account: selectedAddress, + }; + handleSuccessfulOrder(fiatOrder, { isApplePay: true }); + } } } catch (error: any) { NotificationManager.showSimpleNotification({ @@ -130,6 +78,8 @@ const ApplePayButton = ({ dispatch, pay, callbackBaseUrl, + selectedChainId, + selectedAddress, handleSuccessfulOrder, quote.crypto?.symbol, ]); diff --git a/app/components/UI/FiatOnRampAggregator/hooks/useHandleSuccessfulOrder.ts b/app/components/UI/FiatOnRampAggregator/hooks/useHandleSuccessfulOrder.ts new file mode 100644 index 00000000000..f4c680f303a --- /dev/null +++ b/app/components/UI/FiatOnRampAggregator/hooks/useHandleSuccessfulOrder.ts @@ -0,0 +1,118 @@ +import { CryptoCurrency, Order } from '@consensys/on-ramp-sdk'; +import { hexToBN } from '@metamask/controller-utils'; +import { useNavigation } from '@react-navigation/native'; +import { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { getNotificationDetails } from '..'; +import { protectWalletModalVisible } from '../../../../actions/user'; +import { NATIVE_ADDRESS } from '../../../../constants/on-ramp'; +import Engine from '../../../../core/Engine'; +import NotificationManager from '../../../../core/NotificationManager'; +import { addFiatOrder, FiatOrder } from '../../../../reducers/fiatOrders'; +import { toLowerCaseEquals } from '../../../../util/general'; +import useThunkDispatch from '../../../hooks/useThunkDispatch'; +import { useFiatOnRampSDK } from '../sdk'; +import { stateHasOrder } from '../utils'; +import useAnalytics from './useAnalytics'; + +function useHandleSuccessfulOrder() { + const { selectedChainId, selectedAddress } = useFiatOnRampSDK(); + const navigation = useNavigation(); + const dispatch = useDispatch(); + const dispatchThunk = useThunkDispatch(); + const trackEvent = useAnalytics(); + const accounts = useSelector( + (state: any) => + state.engine.backgroundState.AccountTrackerController.accounts, + ); + + const addTokenToTokensController = useCallback( + async (token: CryptoCurrency) => { + if (!token) return; + + const { address, symbol, decimals, network, name } = token; + const chainId = network?.chainId; + + if ( + Number(chainId) !== Number(selectedChainId) || + address === NATIVE_ADDRESS + ) { + return; + } + + const { TokensController } = Engine.context; + + if ( + !TokensController.state.tokens.includes((t: any) => + toLowerCaseEquals(t.address, address), + ) + ) { + await TokensController.addToken(address, symbol, decimals, null, name); + } + }, + [selectedChainId], + ); + + const handleDispatchUserWalletProtection = useCallback(() => { + dispatch(protectWalletModalVisible()); + }, [dispatch]); + + const handleAddFiatOrder = useCallback( + (order) => { + dispatch(addFiatOrder(order)); + }, + [dispatch], + ); + + const handleSuccessfulOrder = useCallback( + async ( + order: FiatOrder, + params?: { + isApplePay?: boolean; + }, + ) => { + await addTokenToTokensController((order as any)?.data?.cryptoCurrency); + handleDispatchUserWalletProtection(); + // @ts-expect-error navigation prop mismatch + navigation.dangerouslyGetParent()?.pop(); + + dispatchThunk((_, getState) => { + const state = getState(); + if (stateHasOrder(state, order)) { + return; + } + handleAddFiatOrder(order); + NotificationManager.showSimpleNotification( + getNotificationDetails(order as any), + ); + trackEvent('ONRAMP_PURCHASE_SUBMITTED', { + provider_onramp: (order?.data as Order)?.provider?.name, + payment_method_id: (order?.data as Order)?.paymentMethod?.id, + currency_source: (order?.data as Order)?.fiatCurrency.symbol, + currency_destination: (order?.data as Order)?.cryptoCurrency.symbol, + chain_id_destination: selectedChainId, + order_type: order?.orderType, + is_apple_pay: Boolean(params?.isApplePay), + has_zero_native_balance: accounts[selectedAddress]?.balance + ? (hexToBN(accounts[selectedAddress].balance) as any)?.isZero?.() + : undefined, + }); + }); + }, + [ + accounts, + addTokenToTokensController, + dispatchThunk, + handleAddFiatOrder, + handleDispatchUserWalletProtection, + navigation, + selectedAddress, + selectedChainId, + trackEvent, + ], + ); + + return handleSuccessfulOrder; +} + +export default useHandleSuccessfulOrder; diff --git a/app/components/UI/FiatOnRampAggregator/hooks/useInAppBrowser.ts b/app/components/UI/FiatOnRampAggregator/hooks/useInAppBrowser.ts index 2b0e7b04829..cc2c58a92a3 100644 --- a/app/components/UI/FiatOnRampAggregator/hooks/useInAppBrowser.ts +++ b/app/components/UI/FiatOnRampAggregator/hooks/useInAppBrowser.ts @@ -2,8 +2,7 @@ import { useCallback } from 'react'; import { Linking } from 'react-native'; import { useDispatch, useSelector } from 'react-redux'; import InAppBrowser from 'react-native-inappbrowser-reborn'; -import { useNavigation } from '@react-navigation/native'; -import { Order, OrderStatusEnum, Provider } from '@consensys/on-ramp-sdk'; +import { OrderStatusEnum, Provider } from '@consensys/on-ramp-sdk'; import BuyAction from '@consensys/on-ramp-sdk/dist/regions/BuyAction'; import useAnalytics from './useAnalytics'; import { callbackBaseDeeplink, SDK, useFiatOnRampSDK } from '../sdk'; @@ -11,16 +10,12 @@ import { createCustomOrderIdData } from '../orderProcessor/customOrderId'; import { aggregatorOrderToFiatOrder } from '../orderProcessor/aggregator'; import { addFiatCustomIdData, - addFiatOrder, FiatOrder, removeFiatCustomIdData, } from '../../../../reducers/fiatOrders'; import { setLockTime } from '../../../../actions/settings'; -import { getNotificationDetails } from '..'; -import { protectWalletModalVisible } from '../../../../actions/user'; -import NotificationManager from '../../../../core/NotificationManager'; -import { hexToBN } from '../../../../util/number'; import Logger from '../../../../util/Logger'; +import useHandleSuccessfulOrder from './useHandleSuccessfulOrder'; export default function useInAppBrowser() { const { @@ -29,60 +24,11 @@ export default function useInAppBrowser() { selectedAsset, selectedChainId, } = useFiatOnRampSDK(); - const navigation = useNavigation(); + const dispatch = useDispatch(); const trackEvent = useAnalytics(); const lockTime = useSelector((state: any) => state.settings.lockTime); - const accounts = useSelector( - (state: any) => - state.engine.backgroundState.AccountTrackerController.accounts, - ); - - const handleSuccessfulOrder = useCallback( - (order: Order) => { - const transformedOrder: FiatOrder = { - ...aggregatorOrderToFiatOrder(order), - account: selectedAddress, - network: selectedChainId, - }; - - // add the order to the redux global store - dispatch(addFiatOrder(transformedOrder)); - - // prompt user to protect his/her wallet - dispatch(protectWalletModalVisible()); - // close the checkout webview - // @ts-expect-error navigation prop mismatch - navigation.dangerouslyGetParent()?.pop(); - NotificationManager.showSimpleNotification( - getNotificationDetails(transformedOrder), - ); - trackEvent('ONRAMP_PURCHASE_SUBMITTED', { - provider_onramp: ((transformedOrder as FiatOrder)?.data as Order) - ?.provider?.name, - payment_method_id: ((transformedOrder as FiatOrder)?.data as Order) - ?.paymentMethod?.id, - currency_source: ((transformedOrder as FiatOrder)?.data as Order) - ?.fiatCurrency.symbol, - currency_destination: ((transformedOrder as FiatOrder)?.data as Order) - ?.cryptoCurrency.symbol, - chain_id_destination: selectedChainId, - is_apple_pay: false, - order_type: (transformedOrder as FiatOrder)?.orderType, - has_zero_native_balance: accounts[selectedAddress]?.balance - ? (hexToBN(accounts[selectedAddress].balance) as any)?.isZero?.() - : undefined, - }); - }, - [ - accounts, - dispatch, - navigation, - selectedAddress, - selectedChainId, - trackEvent, - ], - ); + const handleSuccessfulOrder = useHandleSuccessfulOrder(); const renderInAppBrowser = useCallback( async ( @@ -154,7 +100,13 @@ export default function useInAppBrowser() { return; } - handleSuccessfulOrder(order); + const transformedOrder: FiatOrder = { + ...aggregatorOrderToFiatOrder(order), + account: selectedAddress, + network: selectedChainId, + }; + + handleSuccessfulOrder(transformedOrder); } catch (error) { Logger.error(error as Error, { message: diff --git a/app/components/UI/FiatOnRampAggregator/index.tsx b/app/components/UI/FiatOnRampAggregator/index.tsx index c71c2943da3..55a9198ca07 100644 --- a/app/components/UI/FiatOnRampAggregator/index.tsx +++ b/app/components/UI/FiatOnRampAggregator/index.tsx @@ -2,6 +2,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { InteractionManager, StyleSheet, View } from 'react-native'; import React, { useCallback } from 'react'; import WebView from 'react-native-webview'; +import { Order } from '@consensys/on-ramp-sdk'; import AppConstants from '../../../core/AppConstants'; import { MetaMetricsEvents } from '../../../core/Analytics'; @@ -21,15 +22,16 @@ import { removeAuthenticationUrl, } from '../../../reducers/fiatOrders'; import useInterval from '../../hooks/useInterval'; +import useThunkDispatch, { ThunkAction } from '../../hooks/useThunkDispatch'; import processOrder from './orderProcessor'; import processCustomOrderIdData from './orderProcessor/customOrderId'; import { aggregatorOrderToFiatOrder } from './orderProcessor/aggregator'; import { trackEvent } from './hooks/useAnalytics'; -import { Order } from '@consensys/on-ramp-sdk'; import { AnalyticsEvents } from './types'; import { CustomIdData } from '../../../reducers/fiatOrders/types'; import { callbackBaseUrl } from '../FiatOnRampAggregator/sdk'; import useFetchOnRampNetworks from './hooks/useFetchOnRampNetworks'; +import { stateHasOrder } from './utils'; const POLLING_FREQUENCY = AppConstants.FIAT_ORDERS.POLLING_FREQUENCY; const NOTIFICATION_DURATION = 5000; @@ -215,10 +217,12 @@ async function processCustomOrderId( dispatchUpdateFiatCustomIdData, dispatchRemoveFiatCustomIdData, dispatchAddFiatOrder, + dispatchThunk, }: { dispatchUpdateFiatCustomIdData: (updatedCustomIdData: CustomIdData) => void; dispatchRemoveFiatCustomIdData: (customOrderIdData: CustomIdData) => void; dispatchAddFiatOrder: (fiatOrder: FiatOrder) => void; + dispatchThunk: (thunk: ThunkAction) => void; }, ) { const [customOrderId, fiatOrderResponse] = await processCustomOrderIdData( @@ -227,11 +231,17 @@ async function processCustomOrderId( if (fiatOrderResponse) { const fiatOrder = aggregatorOrderToFiatOrder(fiatOrderResponse); - dispatchAddFiatOrder(fiatOrder); - InteractionManager.runAfterInteractions(() => { - NotificationManager.showSimpleNotification( - getNotificationDetails(fiatOrder), - ); + dispatchThunk((_, getState) => { + const state = getState(); + if (stateHasOrder(state, fiatOrder)) { + return; + } + dispatchAddFiatOrder(fiatOrder); + InteractionManager.runAfterInteractions(() => { + NotificationManager.showSimpleNotification( + getNotificationDetails(fiatOrder), + ); + }); }); dispatchRemoveFiatCustomIdData(customOrderIdData); } else if (customOrderId.expired) { @@ -251,6 +261,7 @@ const styles = StyleSheet.create({ function FiatOrders() { useFetchOnRampNetworks(); const dispatch = useDispatch(); + const dispatchThunk = useThunkDispatch(); const pendingOrders = useSelector(getPendingOrders); const customOrderIds = useSelector(getCustomOrderIds); const authenticationUrls = useSelector(getAuthenticationUrls); @@ -293,6 +304,7 @@ function FiatOrders() { dispatchUpdateFiatCustomIdData, dispatchRemoveFiatCustomIdData, dispatchAddFiatOrder, + dispatchThunk, }), ), ); diff --git a/app/components/UI/FiatOnRampAggregator/sdk/index.tsx b/app/components/UI/FiatOnRampAggregator/sdk/index.tsx index 95cd27bfa87..57eac67260f 100644 --- a/app/components/UI/FiatOnRampAggregator/sdk/index.tsx +++ b/app/components/UI/FiatOnRampAggregator/sdk/index.tsx @@ -33,6 +33,33 @@ import I18n, { I18nEvents } from '../../../../../locales/i18n'; import Device from '../../../../util/device'; import useActivationKeys from '../hooks/useActivationKeys'; +const isDevelopment = process.env.NODE_ENV !== 'production'; +const isInternalBuild = process.env.ONRAMP_INTERNAL_BUILD === 'true'; +const isDevelopmentOrInternalBuild = isDevelopment || isInternalBuild; + +let environment = Environment.Production; +if (isInternalBuild) { + environment = Environment.Staging; +} else if (isDevelopment) { + environment = Environment.Development; +} + +let context = Context.Mobile; +if (Device.isAndroid()) { + context = Context.MobileAndroid; +} else if (Device.isIos()) { + context = Context.MobileIOS; +} + +export const SDK = OnRampSdk.create(environment, context, { + verbose: isDevelopment, + locale: I18n.locale, +}); + +I18nEvents.addListener('localeChanged', (locale) => { + SDK.setLocale(locale); +}); + interface OnRampSDKConfig { POLLING_INTERVAL: number; POLLING_INTERVAL_HIGHLIGHT: number; @@ -74,30 +101,6 @@ interface IProviderProps { children?: React.ReactNode | undefined; } -const isDevelopment = process.env.NODE_ENV !== 'production'; -const isInternalBuild = process.env.ONRAMP_INTERNAL_BUILD === 'true'; -const isDevelopmentOrInternalBuild = isDevelopment || isInternalBuild; - -const CONTEXT = Device.isAndroid() - ? Context.MobileAndroid - : Device.isIos() - ? Context.MobileIOS - : Context.Mobile; -const VERBOSE_SDK = isDevelopment; - -export const SDK = OnRampSdk.create( - isDevelopmentOrInternalBuild ? Environment.Staging : Environment.Production, - CONTEXT, - { - verbose: VERBOSE_SDK, - locale: I18n.locale, - }, -); - -I18nEvents.addListener('localeChanged', (locale) => { - SDK.setLocale(locale); -}); - export const callbackBaseUrl = isDevelopment ? 'https://on-ramp.metaswap-dev.codefi.network/regions/fake-callback' : 'https://on-ramp-content.metaswap.codefi.network/regions/fake-callback'; diff --git a/app/components/UI/FiatOnRampAggregator/utils/index.ts b/app/components/UI/FiatOnRampAggregator/utils/index.ts index 84cf8808d7d..917d504bc83 100644 --- a/app/components/UI/FiatOnRampAggregator/utils/index.ts +++ b/app/components/UI/FiatOnRampAggregator/utils/index.ts @@ -1,11 +1,12 @@ import { AggregatorNetwork } from '@consensys/on-ramp-sdk/dist/API'; import { Order } from '@consensys/on-ramp-sdk'; -import { FiatOrder } from '../../../../reducers/fiatOrders'; import { renderFromTokenMinimalUnit, renderNumber, toTokenMinimalUnit, } from '../../../../util/number'; +import { getOrders, FiatOrder } from '../../../../reducers/fiatOrders'; +import { RootState } from '../../../../reducers/fiatOrders/types'; const isOverAnHour = (minutes: number) => minutes > 59; @@ -148,3 +149,8 @@ export function getOrderAmount(order: FiatOrder) { } return amount; } + +export function stateHasOrder(state: RootState, order: FiatOrder) { + const orders = getOrders(state); + return orders.some((o) => o.id === order.id); +} diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index 0bae2beda5c..8c688d83dbe 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -5,15 +5,15 @@ import ModalNavbarTitle from '../ModalNavbarTitle'; import AccountRightButton from '../AccountRightButton'; import { Alert, - Text, - TouchableOpacity, - View, - StyleSheet, Image, InteractionManager, Platform, + StyleSheet, + Text, + TouchableOpacity, + View, } from 'react-native'; -import { fontStyles, colors as importedColors } from '../../../styles/common'; +import { colors as importedColors, fontStyles } from '../../../styles/common'; import IonicIcon from 'react-native-vector-icons/Ionicons'; import EvilIcons from 'react-native-vector-icons/EvilIcons'; import MaterialCommunityIcon from 'react-native-vector-icons/MaterialCommunityIcons'; @@ -29,9 +29,9 @@ import PickerNetwork from '../../../component-library/components/Pickers/PickerN import BrowserUrlBar from '../BrowserUrlBar'; import generateTestId from '../../../../wdio/utils/generateTestId'; import { - WALLET_VIEW_BURGER_ICON_ID, HAMBURGER_MENU_BUTTON, NAVBAR_NETWORK_BUTTON, + WALLET_VIEW_BURGER_ICON_ID, } from '../../../../wdio/screen-objects/testIDs/Screens/WalletView.testIds'; import { NAV_ANDROID_BACK_BUTTON, @@ -39,7 +39,6 @@ import { NETWORK_SCREEN_CLOSE_ICON, } from '../../../../wdio/screen-objects/testIDs/Screens/NetworksScreen.testids'; import { SEND_CANCEL_BUTTON } from '../../../../wdio/screen-objects/testIDs/Screens/SendScreen.testIds'; -import { CONTACT_EDIT_BUTTON } from '../../../../wdio/screen-objects/testIDs/Screens/Contacts.testids'; import { ASSET_BACK_BUTTON } from '../../../../wdio/screen-objects/testIDs/Screens/TokenOverviewScreen.testIds'; import { PAYMENT_REQUEST_CLOSE_BUTTON, @@ -47,12 +46,14 @@ import { } from '../../../../wdio/screen-objects/testIDs/Screens/RequestToken.testIds'; import { BACK_BUTTON_SIMPLE_WEBVIEW } from '../../../../wdio/screen-objects/testIDs/Components/SimpleWebView.testIds'; import ButtonIcon, { + ButtonIconSizes, ButtonIconVariants, } from '../../../component-library/components/Buttons/ButtonIcon'; import { IconName, IconSize, } from '../../../component-library/components/Icons/Icon'; +import { EDIT_BUTTON } from '../../../../wdio/screen-objects/testIDs/Common.testIds'; const trackEvent = (event) => { InteractionManager.runAfterInteractions(() => { @@ -212,10 +213,12 @@ export function getNavigationOptionsTitle( elevation: 0, }, }); + function navigationPop() { if (navigationPopEvent) trackEvent(navigationPopEvent); navigation.pop(); } + return { title, headerTitleStyle: innerStyles.headerTitleStyle, @@ -278,9 +281,11 @@ export function getEditableOptions(title, navigation, route, themeColors) { elevation: 0, }, }); + function navigationPop() { navigation.pop(); } + const rightAction = route.params?.dispatch; const editMode = route.params?.editMode === 'edit'; const addMode = route.params?.mode === 'add'; @@ -305,7 +310,7 @@ export function getEditableOptions(title, navigation, route, themeColors) { {editMode @@ -807,6 +812,7 @@ export function getOptinMetricsNavbarOptions(themeColors) { headerTintColor: themeColors.primary.default, }; } + /** * Function that returns the navigation options * for our closable screens, @@ -839,9 +845,11 @@ export function getClosableNavigationOptions( color: themeColors.text.default, }, }); + function navigationPop() { navigation.pop(); } + return { title, headerTitleStyle: innerStyles.headerTitleStyle, @@ -1365,6 +1373,7 @@ export function getSwapsAmountNavbar(navigation, route, themeColors) { headerStyle: innerStyles.headerStyle, }; } + export function getSwapsQuotesNavbar(navigation, route, themeColors) { const innerStyles = StyleSheet.create({ headerButtonText: { @@ -1529,3 +1538,32 @@ export function getFiatOnRampAggNavbar( headerTitleStyle: innerStyles.headerTitleStyle, }; } + +export const getEditAccountNameNavBarOptions = (goBack, themeColors) => { + const innerStyles = StyleSheet.create({ + headerStyle: { + backgroundColor: themeColors.background.default, + shadowColor: importedColors.transparent, + elevation: 0, + }, + headerTitleStyle: { + fontSize: 18, + ...fontStyles.normal, + color: themeColors.text.default, + }, + }); + + return { + headerTitle: {strings('account_actions.edit_name')}, + headerLeft: null, + headerRight: () => ( + + ), + ...innerStyles, + }; +}; diff --git a/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap b/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap index 669a0db79a9..d7be9f83a92 100644 --- a/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap @@ -560,7 +560,57 @@ exports[`Tokens should hide zero balance tokens when setting is on 1`] = ` ], } } - /> + > + + + + + + + > + + + + + + + > + + + + + + + > + + + + + + + > + + + + + + + > + + + + + + + > + + + + + + = ({ tokens }) => { const { colors, themeAppearance } = useTheme(); @@ -276,11 +277,13 @@ const Tokens: React.FC = ({ tokens }) => { balance={secondaryBalance} > + } > {asset.isETH ? ( diff --git a/app/components/UI/TransactionReview/TransactionReviewFeeCard/__snapshots__/index.test.tsx.snap b/app/components/UI/TransactionReview/TransactionReviewFeeCard/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 0bd021f7f0f..00000000000 --- a/app/components/UI/TransactionReview/TransactionReviewFeeCard/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,10 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`TransactionReviewFeeCard should render correctly 1`] = ` - -`; diff --git a/app/components/UI/TransactionReview/TransactionReviewFeeCard/index.js b/app/components/UI/TransactionReview/TransactionReviewFeeCard/index.js deleted file mode 100644 index 47e286fd91b..00000000000 --- a/app/components/UI/TransactionReview/TransactionReviewFeeCard/index.js +++ /dev/null @@ -1,398 +0,0 @@ -import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; -import { - StyleSheet, - View, - TouchableOpacity, - ActivityIndicator, - Linking, -} from 'react-native'; -import { strings } from '../../../../../locales/i18n'; -import Summary from '../../../Base/Summary'; -import Text from '../../../Base/Text'; -import InfoModal from '../../../UI/Swaps/components/InfoModal'; -import { isMainnetByChainId } from '../../../../util/networks'; -import { connect } from 'react-redux'; -import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; -import FadeAnimationView from '../../FadeAnimationView'; -import { ThemeContext, mockTheme } from '../../../../util/theme'; -import { selectChainId } from '../../../../selectors/networkController'; - -const createStyles = (colors) => - StyleSheet.create({ - overview: { - marginHorizontal: 24, - }, - loader: { - backgroundColor: colors.background.default, - height: 10, - flex: 1, - alignItems: 'flex-end', - }, - over: { - color: colors.error.default, - }, - valuesContainer: { - flex: 1, - flexDirection: 'row', - }, - gasInfoContainer: { - paddingHorizontal: 2, - }, - gasInfoIcon: { - color: colors.primary.default, - }, - amountContainer: { - flex: 1, - }, - gasFeeTitleContainer: { - flexDirection: 'row', - alignItems: 'center', - }, - primaryContainer: (flex) => { - if (flex) return { flex: 1 }; - return { width: 86, marginLeft: 2 }; - }, - hitSlop: { - top: 10, - left: 10, - bottom: 10, - right: 10, - }, - }); - -/** - * PureComponent that displays a transaction's fee and total details inside a card - */ -class TransactionReviewFeeCard extends PureComponent { - static propTypes = { - /** - * True if gas estimation for a transaction is complete - */ - gasEstimationReady: PropTypes.bool, - /** - * Total gas fee in fiat - */ - totalGasFiat: PropTypes.string, - /** - * Total gas fee in ETH - */ - totalGasEth: PropTypes.string, - /** - * Total transaction value in fiat - */ - totalFiat: PropTypes.oneOfType([ - PropTypes.arrayOf(PropTypes.node), - PropTypes.node, - PropTypes.string, - ]), - /** - * Transaction value in fiat before gas fee - */ - fiat: PropTypes.string, - /** - * Total transaction value in ETH - */ - totalValue: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), - /** - * Transaction value in ETH before gas fee - */ - transactionValue: PropTypes.string, - /** - * ETH or fiat, dependent on user setting - */ - primaryCurrency: PropTypes.string, - /** - * Changes mode to edit - */ - edit: PropTypes.func, - /** - * True if transaction is over the available funds - */ - over: PropTypes.bool, - /** - * True if transaction is gas price is higher than the "FAST" value - */ - warningGasPriceHigh: PropTypes.string, - /** - * A string representing the network chainId - */ - chainId: PropTypes.string, - /** - * Function to call when update animation starts - */ - onUpdatingValuesStart: PropTypes.func, - /** - * Function to call when update animation ends - */ - onUpdatingValuesEnd: PropTypes.func, - /** - * If the values should animate upon update or not - */ - animateOnChange: PropTypes.bool, - /** - * Boolean to determine if the animation is happening - */ - isAnimating: PropTypes.bool, - }; - - state = { - showGasTooltip: false, - }; - - getStyles = () => { - const colors = this.context.colors || mockTheme.colors; - return createStyles(colors); - }; - - renderIfGasEstimationReady = (children) => { - const { gasEstimationReady } = this.props; - const styles = this.getStyles(); - - return !gasEstimationReady ? ( - - - - ) : ( - children - ); - }; - - openLinkAboutGas = () => - Linking.openURL( - 'https://community.metamask.io/t/what-is-gas-why-do-transactions-take-so-long/3172', - ); - - toggleGasTooltip = () => - this.setState((state) => ({ showGasTooltip: !state.showGasTooltip })); - - renderGasTooltip = () => { - const isMainnet = isMainnetByChainId(this.props.chainId); - return ( - - - {strings('transaction.gas_education_1')} - {strings( - `transaction.gas_education_2${isMainnet ? '_ethereum' : ''}`, - )}{' '} - {strings('transaction.gas_education_3')} - - - {strings('transaction.gas_education_4')} - - - - {strings('transaction.gas_education_learn_more')} - - - - } - /> - ); - }; - - render() { - const { - totalGasFiat, - totalGasEth, - totalFiat, - fiat, - totalValue, - transactionValue, - primaryCurrency, - edit, - over, - warningGasPriceHigh, - chainId, - onUpdatingValuesStart, - onUpdatingValuesEnd, - animateOnChange, - isAnimating, - } = this.props; - const styles = this.getStyles(); - - const isMainnet = isMainnetByChainId(chainId); - - let amount; - let networkFee; - let totalAmount; - let primaryAmount; - let primaryNetworkFee; - let primaryTotalAmount; - const showNativeCurrency = primaryCurrency === 'ETH' || !isMainnet; - if (showNativeCurrency) { - amount = fiat; - networkFee = totalGasFiat; - totalAmount = totalFiat; - - primaryAmount = transactionValue; - primaryNetworkFee = totalGasEth; - primaryTotalAmount = totalValue; - } else { - amount = transactionValue; - networkFee = totalGasEth; - totalAmount = totalValue; - - primaryAmount = fiat; - primaryNetworkFee = totalGasFiat; - primaryTotalAmount = totalFiat; - } - - const valueToWatchAnimation = totalGasEth !== '0 ETH' ? totalGasEth : null; - - return ( - - - - - {strings('transaction.amount')} - - - {isMainnet && ( - - {amount} - - )} - - {primaryAmount} - - - - - - - - {strings('transaction.gas_fee')} - - - - - - - {this.renderIfGasEstimationReady( - - {isMainnet && ( - - - - {networkFee} - - - - )} - - - - {primaryNetworkFee} - - - - , - )} - - - - - {strings('transaction.total')} - - - {!!totalFiat && - this.renderIfGasEstimationReady( - - {isMainnet && ( - - {totalAmount} - - )} - - - {primaryTotalAmount} - - , - )} - - - {this.renderGasTooltip()} - - ); - } -} - -const mapStateToProps = (state) => ({ - conversionRate: - state.engine.backgroundState.CurrencyRateController.conversionRate, - currentCurrency: - state.engine.backgroundState.CurrencyRateController.currentCurrency, - chainId: selectChainId(state), -}); - -TransactionReviewFeeCard.contextType = ThemeContext; - -export default connect(mapStateToProps)(TransactionReviewFeeCard); diff --git a/app/components/UI/TransactionReview/TransactionReviewFeeCard/index.test.tsx b/app/components/UI/TransactionReview/TransactionReviewFeeCard/index.test.tsx deleted file mode 100644 index e6bd54328dc..00000000000 --- a/app/components/UI/TransactionReview/TransactionReviewFeeCard/index.test.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import TransactionReviewFeeCard from './'; -import { shallow } from 'enzyme'; -import configureMockStore from 'redux-mock-store'; -import { Provider } from 'react-redux'; - -const mockStore = configureMockStore(); -const initialState = { - engine: { - backgroundState: { - CurrencyRateController: { - currentCurrency: 'usd', - conversionRate: 0.1, - }, - NetworkController: { - providerConfig: { - ticker: 'ETH', - chainId: '1', - }, - }, - }, - }, -}; -const store = mockStore(initialState); - -describe('TransactionReviewFeeCard', () => { - it('should render correctly', () => { - const wrapper = shallow( - - - , - ); - expect(wrapper.dive()).toMatchSnapshot(); - }); -}); diff --git a/app/components/UI/WebsiteIcon/index.js b/app/components/UI/WebsiteIcon/index.js index cfde080c528..23b594926f3 100644 --- a/app/components/UI/WebsiteIcon/index.js +++ b/app/components/UI/WebsiteIcon/index.js @@ -86,10 +86,14 @@ export default class WebsiteIcon extends PureComponent { const colors = this.context.colors || mockTheme.colors; const styles = createStyles(colors); const apiLogoUrl = { uri: icon || this.getIconUrl(url) }; - const title = - typeof this.props.title === 'string' - ? this.props.title.substr(0, 1) - : getHost(url).substr(0, 1); + let title = this.props.title; + + if (title !== undefined) { + title = + typeof this.props.title === 'string' + ? this.props.title.substr(0, 1) + : getHost(url).substr(0, 1); + } if (renderIconUrlError && title) { return ( diff --git a/app/components/Views/AccountActions/AccountActions.tsx b/app/components/Views/AccountActions/AccountActions.tsx index 70c7268933a..04a70b1bf5c 100644 --- a/app/components/Views/AccountActions/AccountActions.tsx +++ b/app/components/Views/AccountActions/AccountActions.tsx @@ -121,6 +121,10 @@ const AccountActions = () => { }); }; + const goToEditAccountName = () => { + navigate('EditAccountName'); + }; + return ( @@ -128,7 +132,7 @@ const AccountActions = () => { actionTitle={strings('account_actions.edit_name')} iconName={IconName.Edit} // This action will be address on other PR - onPress={() => null} + onPress={goToEditAccountName} {...generateTestId(Platform, EDIT_ACCOUNT)} /> { + const { theme } = params; + const { colors } = theme; + return StyleSheet.create({ + screen: { + flex: 1, + paddingHorizontal: 16, + backgroundColor: colors.background.default, + }, + inputsContainer: { flex: 1, marginHorizontal: 16 }, + inputContainer: { marginTop: 24 }, + buttonsContainer: { + flexDirection: 'row', + marginHorizontal: 16, + }, + cancelButton: { flex: 1, marginRight: 8 }, + saveButton: { flex: 1, marginLeft: 8 }, + saveButtonDisabled: { opacity: 0.5 }, + }); +}; +export default styleSheet; diff --git a/app/components/Views/EditAccountName/EditAccountName.test.tsx b/app/components/Views/EditAccountName/EditAccountName.test.tsx new file mode 100644 index 00000000000..edbb79778cb --- /dev/null +++ b/app/components/Views/EditAccountName/EditAccountName.test.tsx @@ -0,0 +1,109 @@ +// Third party dependencies +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; + +// Internal dependencies +import EditAccountName from './EditAccountName'; +import renderWithProvider from '../../../util/test/renderWithProvider'; + +jest.unmock('react-redux'); +const mockSetAccountLabel = jest.fn(); + +jest.mock('../../../core/Engine', () => ({ + context: { + PreferencesController: { + setAccountLabel: () => mockSetAccountLabel, + }, + }, +})); + +const initialState = { + swaps: { '1': { isLive: true }, hasOnboarded: false, isLive: true }, + wizard: { + step: 0, + }, + settings: { + primaryCurrency: 'usd', + }, + engine: { + backgroundState: { + PreferencesController: { + selectedAddress: '0x', + identities: { + '0x': { name: 'Account 1', address: '0x' }, + }, + }, + }, + }, +}; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest + .fn() + .mockImplementation((callback) => callback(initialState)), +})); + +const mockNavigate = jest.fn(); +const mockSetOptions = jest.fn(); +const mockGoBack = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actualReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualReactNavigation, + useNavigation: () => ({ + navigate: mockNavigate, + setOptions: mockSetOptions, + goBack: mockGoBack, + }), + }; +}); + +const renderComponent = (state: any) => + renderWithProvider(, { state }); + +describe('EditAccountName', () => { + afterEach(() => { + mockNavigate.mockClear(); + mockGoBack.mockClear(); + mockSetOptions.mockClear(); + }); + it('should render correctly', () => { + const { getByText, toJSON } = renderComponent(initialState); + expect(getByText('Cancel')).toBeDefined(); + expect(getByText('Save')).toBeDefined(); + expect(getByText('Name')).toBeDefined(); + expect(getByText('Address')).toBeDefined(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('should enable the save button when text input changes', () => { + const { getByTestId } = renderComponent(initialState); + const input = getByTestId('account-name-input'); + const saveButton = getByTestId('save-button'); + + fireEvent.changeText(input, ''); + + expect(saveButton.props.disabled).toBe(true); + fireEvent.changeText(input, 'Account'); + + expect(saveButton.props.disabled).toBe(false); + }); + + it('should call goBack when cancel button is pressed', () => { + const { getByText } = renderComponent(initialState); + const cancelButton = getByText('Cancel'); + fireEvent.press(cancelButton); + expect(mockGoBack).toHaveBeenCalled(); + }); + + it('should call navigate when save button is pressed', () => { + const { getByTestId } = renderComponent(initialState); + const input = getByTestId('account-name-input'); + const saveButton = getByTestId('save-button'); + fireEvent.changeText(input, 'New Name'); + fireEvent.press(saveButton); + expect(mockNavigate).toHaveBeenCalled(); + }); +}); diff --git a/app/components/Views/EditAccountName/EditAccountName.tsx b/app/components/Views/EditAccountName/EditAccountName.tsx new file mode 100644 index 00000000000..9ba58e21962 --- /dev/null +++ b/app/components/Views/EditAccountName/EditAccountName.tsx @@ -0,0 +1,154 @@ +// Third party dependencies +import React, { useCallback, useEffect, useState } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { InteractionManager, Platform, SafeAreaView } from 'react-native'; + +// External dependencies +import Text from '../../../component-library/components/Texts/Text/Text'; +import { View } from 'react-native-animatable'; +import { TextVariant } from '../../../component-library/components/Texts/Text'; +import { strings } from '../../../../locales/i18n'; +import TextField from '../../../component-library/components/Form/TextField/TextField'; +import { formatAddress, getAddressAccountType } from '../../../util/address'; + +import Button from '../../../component-library/components/Buttons/Button/Button'; +import { + ButtonSize, + ButtonVariants, + ButtonWidthTypes, +} from '../../../component-library/components/Buttons/Button'; +import { useStyles } from '../../../component-library/hooks'; +import { getEditAccountNameNavBarOptions } from '../../../components/UI/Navbar'; +import Engine from '../../../core/Engine'; +import generateTestId from '../../../../wdio/utils/generateTestId'; +import Analytics from '../../../core/Analytics/Analytics'; +import { MetaMetricsEvents } from '../../../core/Analytics'; +import { selectChainId } from '../../../selectors/networkController'; +import { + doENSReverseLookup, + isDefaultAccountName, +} from '../../../util/ENSUtils'; +import { useTheme } from '../../../util/theme'; + +// Internal dependencies +import styleSheet from './EditAccountName.styles'; + +const EditAccountName = () => { + const { colors } = useTheme(); + const { styles } = useStyles(styleSheet, {}); + const { setOptions, goBack, navigate } = useNavigation(); + const [accountName, setAccountName] = useState(); + const [ens, setEns] = useState(); + + const selectedAddress = useSelector( + (state: any) => + state.engine.backgroundState.PreferencesController.selectedAddress, + ); + const identities = useSelector( + (state: any) => + state.engine.backgroundState.PreferencesController.identities, + ); + + const chainId = useSelector(selectChainId); + + const lookupEns = useCallback(async () => { + try { + const accountEns = await doENSReverseLookup(selectedAddress, chainId); + + setEns(accountEns); + // eslint-disable-next-line no-empty + } catch {} + }, [selectedAddress, chainId]); + + useEffect(() => { + lookupEns(); + }, [lookupEns]); + + const updateNavBar = useCallback(() => { + setOptions(getEditAccountNameNavBarOptions(goBack, colors)); + }, [setOptions, goBack, colors]); + + useEffect(() => { + updateNavBar(); + }, [updateNavBar]); + + useEffect(() => { + const name = identities[selectedAddress].name; + setAccountName(isDefaultAccountName(name) && ens ? ens : name); + }, [selectedAddress, identities, ens]); + + const onChangeName = (name: string) => { + setAccountName(name); + }; + + const saveAccountName = () => { + const { PreferencesController } = Engine.context; + PreferencesController.setAccountLabel(selectedAddress, accountName); + navigate('WalletView'); + + InteractionManager.runAfterInteractions(() => { + const analyticsProperties = async () => { + const accountType = getAddressAccountType(selectedAddress); + const account_type = accountType === 'QR' ? 'hardware' : accountType; + return { account_type, chain_id: chainId }; + }; + Analytics.trackEventWithParameters( + MetaMetricsEvents.ACCOUNT_RENAMED, + analyticsProperties(), + ); + }); + }; + + return ( + + + + + {strings('address_book.name')} + + + + + + {strings('address_book.address')} + + + + + +