diff --git a/.depcheckrc.yml b/.depcheckrc.yml index f51f487fd9b..36e2bc04bb1 100644 --- a/.depcheckrc.yml +++ b/.depcheckrc.yml @@ -2,6 +2,7 @@ ignores: - '@metamask/oss-attribution-generator' - 'webpack-cli' + - '@react-native-community/datetimepicker' - '@react-native-community/slider' - 'patch-package' - '@lavamoat/allow-scripts' @@ -48,22 +49,17 @@ ignores: - 'rn-nodeify' ## Unused devDependencies to investigate - - '@ethersproject/abi' - '@metamask/swappable-obj-proxy' - - '@react-native-picker/picker' - - '@rnhooks/keyboard' - '@segment/sovran-react-native' - '@tradle/react-native-http' - 'asyncstorage-down' - 'buffer' - 'd3-shape' - - 'dnode' - 'eciesjs' - 'eth-block-tracker' - 'eth-json-rpc-infura' - 'events' - 'https-browserify' - - 'obs-store' - 'path' - 'pbkdf2' - 'pify' @@ -72,11 +68,9 @@ ignores: - 'react-native-aes-crypto' - 'react-native-aes-crypto-forked' - 'react-native-crypto' - - 'react-native-flash-message' - 'react-native-level-fs' - 'react-native-os' - 'react-native-randombytes' - - 'react-native-redash' - 'react-native-swipe-gestures' - 'react-native-tcp' - 'socket.io-client' diff --git a/.detoxrc.js b/.detoxrc.js index ad953511261..b1a80212b46 100644 --- a/.detoxrc.js +++ b/.detoxrc.js @@ -26,7 +26,7 @@ module.exports = { configurations: { 'ios.sim.apiSpecs': { device: 'ios.simulator', - app: 'ios.qa', + app: process.env.CI ? 'ios.qa' :'ios.debug', testRunner: { args: { "$0": "node e2e/api-specs/run-api-spec-tests.js", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 584e983f57c..75b4cf336b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: cache: yarn - uses: ruby/setup-ruby@a6e6f86333f0a2523ece813039b8b4be04560854 #v1 with: - ruby-version: '3.1.5' + ruby-version: '3.1.6' bundler-cache: true env: BUNDLE_GEMFILE: ios/Gemfile diff --git a/.js.env.example b/.js.env.example index 68e8316f034..81d9fea44a1 100644 --- a/.js.env.example +++ b/.js.env.example @@ -90,7 +90,10 @@ export MM_ENABLE_SETTINGS_PAGE_DEV_OPTIONS="true" # The endpoint used to submit errors and tracing data to Sentry for dev environment. # export MM_SENTRY_DSN_DEV= -# Multichain Feature flag -export MULTICHAIN_V1="" +# Per dapp selected network (Amon Hen) feature flag +export MM_PER_DAPP_SELECTED_NETWORK="" + +export MM_CHAIN_PERMISSIONS="" + #Multichain feature flag specific to UI changes export MM_MULTICHAIN_V1_ENABLED="" diff --git a/.ruby-version b/.ruby-version index 3ad0595adcc..9cec7165ab0 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.1.5 +3.1.6 diff --git a/app/component-library/components/Form/TextField/foundation/Input/Input.tsx b/app/component-library/components/Form/TextField/foundation/Input/Input.tsx index facc8e6e6fd..0a9054e2a11 100644 --- a/app/component-library/components/Form/TextField/foundation/Input/Input.tsx +++ b/app/component-library/components/Form/TextField/foundation/Input/Input.tsx @@ -21,7 +21,7 @@ const Input: React.FC = ({ isReadonly = false, onBlur, onFocus, - autoFocus = false, + autoFocus = true, ...props }) => { const [isFocused, setIsFocused] = useState(autoFocus); @@ -64,7 +64,7 @@ const Input: React.FC = ({ {...props} style={styles.base} editable={!isDisabled && !isReadonly} - autoFocus + autoFocus={autoFocus} onBlur={onBlurHandler} onFocus={onFocusHandler} /> diff --git a/app/component-library/components/Form/TextField/foundation/Input/__snapshots__/Input.test.tsx.snap b/app/component-library/components/Form/TextField/foundation/Input/__snapshots__/Input.test.tsx.snap index 99a88ffc241..fd6fedfeaa4 100644 --- a/app/component-library/components/Form/TextField/foundation/Input/__snapshots__/Input.test.tsx.snap +++ b/app/component-library/components/Form/TextField/foundation/Input/__snapshots__/Input.test.tsx.snap @@ -9,7 +9,7 @@ exports[`Input should render correctly 1`] = ` style={ { "backgroundColor": "#ffffff", - "borderColor": "transparent", + "borderColor": "#0376c9", "borderWidth": 1, "color": "#141618", "fontFamily": "Euclid Circular B", diff --git a/app/components/Nav/App/index.js b/app/components/Nav/App/index.js index 8f424320041..a587ef6c3ae 100644 --- a/app/components/Nav/App/index.js +++ b/app/components/Nav/App/index.js @@ -131,7 +131,6 @@ import OptionsSheet from '../../UI/SelectOptionSheet/OptionsSheet'; import FoxLoader from '../../../components/UI/FoxLoader'; import { AppStateEventProcessor } from '../../../core/AppStateEventListener'; import MultiRpcModal from '../../../components/Views/MultiRpcModal/MultiRpcModal'; -import { trace, TraceName, TraceOperation } from '../../../util/trace'; const clearStackNavigatorOptions = { headerShown: false, @@ -355,15 +354,7 @@ const App = (props) => { setOnboarded(!!existingUser); try { if (existingUser) { - await trace( - { - name: TraceName.BiometricAuthentication, - op: TraceOperation.BiometricAuthentication, - }, - async () => { - await Authentication.appTriggeredAuth(); - }, - ); + await Authentication.appTriggeredAuth(); // we need to reset the navigator here so that the user cannot go back to the login screen navigator.reset({ routes: [{ name: Routes.ONBOARDING.HOME_NAV }] }); } else { diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx index 8864c78c35c..476ab653953 100644 --- a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx +++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx @@ -5,7 +5,7 @@ import { useSelector } from 'react-redux'; import i18n from '../../../../../locales/i18n'; import { useStyles } from '../../../../component-library/hooks'; import styleSheet from './TokenDetails.styles'; -import { formatAddress, safeToChecksumAddress } from '../../../../util/address'; +import { safeToChecksumAddress } from '../../../../util/address'; import { selectTokenList } from '../../../../selectors/tokenListController'; import { selectContractExchangeRates } from '../../../../selectors/tokenRatesController'; import { @@ -75,13 +75,12 @@ const TokenDetails: React.FC = ({ asset }) => { const tokenDetails: TokenDetails = asset.isETH ? { - contractAddress: formatAddress(zeroAddress(), 'short'), + contractAddress: zeroAddress(), tokenDecimal: 18, tokenList: '', } : { - contractAddress: - formatAddress(tokenContractAddress as string, 'short') || null, + contractAddress: tokenContractAddress || null, tokenDecimal: tokenMetadata?.decimals || null, tokenList: tokenMetadata?.aggregators.join(', ') || null, }; diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.tsx index b218361c5ff..7697440bfbd 100644 --- a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.tsx +++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.tsx @@ -17,6 +17,7 @@ import Icon, { import ClipboardManager from '../../../../../core/ClipboardManager'; import { TokenDetails } from '../TokenDetails'; import TokenDetailsListItem from '../TokenDetailsListItem'; +import { formatAddress } from '../../../../../util/address'; interface TokenDetailsListProps { tokenDetails: TokenDetails; @@ -62,7 +63,7 @@ const TokenDetailsList: React.FC = ({ onPress={copyAccountToClipboard} > - {tokenDetails.contractAddress} + {formatAddress(tokenDetails.contractAddress, 'short')} { AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE, NftController: { allNfts: { - [MOCK_ADDRESS]: { + [MOCK_ADDRESS.toLowerCase()]: { '0x1': [ { address: '0x72b1FDb6443338A158DeC2FbF411B71aeB157A42', @@ -131,7 +131,7 @@ describe('CollectibleContracts', () => { }, }, allNftContracts: { - [MOCK_ADDRESS]: { + [MOCK_ADDRESS.toLowerCase()]: { '0x1': [ { address: '0x72b1FDb6443338A158DeC2FbF411B71aeB157A42', diff --git a/app/components/UI/LedgerModals/LedgerConfirmationModal.test.tsx b/app/components/UI/LedgerModals/LedgerConfirmationModal.test.tsx index 3cefe4a7158..165c4032f0f 100644 --- a/app/components/UI/LedgerModals/LedgerConfirmationModal.test.tsx +++ b/app/components/UI/LedgerModals/LedgerConfirmationModal.test.tsx @@ -407,24 +407,10 @@ describe('LedgerConfirmationModal', () => { }); it('calls onRejection when user refuses confirmation', async () => { - const onRejection = jest.fn(); - (useLedgerBluetooth as jest.Mock).mockReturnValue({ - isSendingLedgerCommands: true, - isAppLaunchConfirmationNeeded: false, - ledgerLogicToRun: jest.fn(), - error: LedgerCommunicationErrors.UserRefusedConfirmation, - }); - - renderWithProvider( - , + checkLedgerCommunicationErrorFlow( + LedgerCommunicationErrors.UserRefusedConfirmation, + strings('ledger.user_reject_transaction'), + strings('ledger.user_reject_transaction_message'), ); - // eslint-disable-next-line no-empty-function - await act(async () => {}); - - expect(onRejection).toHaveBeenCalled(); }); }); diff --git a/app/components/UI/LedgerModals/LedgerConfirmationModal.tsx b/app/components/UI/LedgerModals/LedgerConfirmationModal.tsx index a1526426502..b35967f8c40 100644 --- a/app/components/UI/LedgerModals/LedgerConfirmationModal.tsx +++ b/app/components/UI/LedgerModals/LedgerConfirmationModal.tsx @@ -160,7 +160,10 @@ const LedgerConfirmationModal = ({ }); break; case LedgerCommunicationErrors.UserRefusedConfirmation: - onReject(); + setErrorDetails({ + title: strings('ledger.user_reject_transaction'), + subtitle: strings('ledger.user_reject_transaction_message'), + }); break; case LedgerCommunicationErrors.LedgerHasPendingConfirmation: setErrorDetails({ @@ -275,7 +278,9 @@ const LedgerConfirmationModal = ({ isRetryHide={ ledgerError === LedgerCommunicationErrors.UnknownError || ledgerError === LedgerCommunicationErrors.NonceTooLow || - ledgerError === LedgerCommunicationErrors.NotSupported + ledgerError === LedgerCommunicationErrors.NotSupported || + ledgerError === LedgerCommunicationErrors.BlindSignError || + ledgerError === LedgerCommunicationErrors.UserRefusedConfirmation } /> diff --git a/app/components/UI/PermissionsSummary/PermissionsSummary.test.tsx b/app/components/UI/PermissionsSummary/PermissionsSummary.test.tsx index ebc79900213..5ce8f2a4f7b 100644 --- a/app/components/UI/PermissionsSummary/PermissionsSummary.test.tsx +++ b/app/components/UI/PermissionsSummary/PermissionsSummary.test.tsx @@ -29,6 +29,24 @@ const mockInitialState = { }; describe('PermissionsSummary', () => { + it('should render correctly for network switch', () => { + const { toJSON } = renderWithProvider( + , + { state: mockInitialState }, + ); + expect(toJSON()).toMatchSnapshot(); + }); it('should render correctly', () => { const { toJSON } = renderWithProvider( { onUserAction?.(USER_INTENT.Confirm); + onConfirm?.(); }; const cancel = () => { onUserAction?.(USER_INTENT.Cancel); + onCancel?.(); }; const handleEditAccountsButtonPress = () => { @@ -208,21 +227,33 @@ const PermissionsSummary = ({ {strings('permissions.use_enabled_networks')} - - - - {strings('permissions.requesting_for')} - - - {networkName} - - - - - - + {isNetworkSwitch && ( + <> + + + + {strings('permissions.requesting_for')} + + + {chainName} + + + + + + )} + {!isNetworkSwitch && ( + + + + )} {!isNetworkSwitch && renderEndAccessory()} @@ -247,6 +278,7 @@ const PermissionsSummary = ({ })} + {/*TODO These should be conditional upon which permissions are being requested*/} {!isNetworkSwitch && renderAccountPermissionsRequestInfoCard()} {renderNetworkPermissionsRequestInfoCard()} diff --git a/app/components/UI/PermissionsSummary/PermissionsSummary.types.ts b/app/components/UI/PermissionsSummary/PermissionsSummary.types.ts index c80d27e198a..1be07727a7d 100644 --- a/app/components/UI/PermissionsSummary/PermissionsSummary.types.ts +++ b/app/components/UI/PermissionsSummary/PermissionsSummary.types.ts @@ -9,10 +9,16 @@ export interface PermissionsSummaryProps { onEdit?: () => void; onEditNetworks?: () => void; onBack?: () => void; + onCancel?: () => void; + onConfirm?: () => void; onUserAction?: React.Dispatch>; showActionButtons?: boolean; isAlreadyConnected?: boolean; isRenderedAsBottomSheet?: boolean; isDisconnectAllShown?: boolean; isNetworkSwitch?: boolean; + customNetworkInformation?: { + chainName: string; + chainId: string; + }; } diff --git a/app/components/UI/PermissionsSummary/__snapshots__/PermissionsSummary.test.tsx.snap b/app/components/UI/PermissionsSummary/__snapshots__/PermissionsSummary.test.tsx.snap index 07ce968d48c..b6d0fea2047 100644 --- a/app/components/UI/PermissionsSummary/__snapshots__/PermissionsSummary.test.tsx.snap +++ b/app/components/UI/PermissionsSummary/__snapshots__/PermissionsSummary.test.tsx.snap @@ -500,60 +500,6 @@ exports[`PermissionsSummary should render correctly 1`] = ` } } > - - - - Requesting for - - - Ethereum Main Network - - - `; + +exports[`PermissionsSummary should render correctly for network switch 1`] = ` + + + + + + + + + + a + + + + + + + + + app.uniswap.org wants to: + + + + + + + + + + Use your enabled networks + + + + + + Requesting for + + + Sepolia + + + + + + + + + + + + + + + + + Disconnect all + + + + + + + Cancel + + + + + Confirm + + + + + + +`; diff --git a/app/components/UI/Ramp/Views/Settings/ActivationKeyForm.tsx b/app/components/UI/Ramp/Views/Settings/ActivationKeyForm.tsx index 4ae7ad7fe15..e5969b0f072 100644 --- a/app/components/UI/Ramp/Views/Settings/ActivationKeyForm.tsx +++ b/app/components/UI/Ramp/Views/Settings/ActivationKeyForm.tsx @@ -116,6 +116,7 @@ function ActivationKeyForm() { returnKeyType={'done'} onSubmitEditing={handleSubmit} isReadonly={Boolean(key)} + autoFocus /> diff --git a/app/components/UI/Ramp/Views/Settings/__snapshots__/ActivationKeyForm.test.tsx.snap b/app/components/UI/Ramp/Views/Settings/__snapshots__/ActivationKeyForm.test.tsx.snap index bd9145f787a..0d6c3a48b2c 100644 --- a/app/components/UI/Ramp/Views/Settings/__snapshots__/ActivationKeyForm.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Settings/__snapshots__/ActivationKeyForm.test.tsx.snap @@ -536,7 +536,7 @@ exports[`AddActivationKey renders correctly 1`] = ` { "alignItems": "center", "backgroundColor": "#ffffff", - "borderColor": "#bbc0c5", + "borderColor": "#0376c9", "borderRadius": 8, "borderWidth": 1, "flexDirection": "row", diff --git a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx index 10ac002beb1..9648fba601e 100644 --- a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx +++ b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx @@ -5,6 +5,7 @@ import { renderScreen } from '../../../../../util/test/renderWithProvider'; import Routes from '../../../../../constants/navigation/Routes'; import { backgroundState } from '../../../../../util/test/initial-root-state'; import { BN } from 'ethereumjs-util'; +import { Stake } from '../../sdk/stakeSdkProvider'; function render(Component: React.ComponentType) { return renderScreen( @@ -51,6 +52,17 @@ jest.mock('../../../../../selectors/currencyRateController.ts', () => ({ })); const mockBalanceBN = new BN('1500000000000000000'); + +jest.mock('../../hooks/useStakeContext.ts', () => ({ + useStakeContext: jest.fn(() => { + const stakeContext: Stake = { + setSdkType: jest.fn(), + sdkService: undefined + } + return stakeContext + }) +})) + jest.mock('../../hooks/useBalance', () => ({ __esModule: true, default: () => ({ diff --git a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx index 0431e67a77f..782e4d5ab3c 100644 --- a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx +++ b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx @@ -19,6 +19,7 @@ import styleSheet from './StakeInputView.styles'; import useStakingInputHandlers from '../../hooks/useStakingInput'; import useBalance from '../../hooks/useBalance'; import InputDisplay from '../../components/InputDisplay'; +import { useStakeContext } from '../../hooks/useStakeContext'; const StakeInputView = () => { const title = strings('stake.stake_eth'); @@ -43,6 +44,9 @@ const StakeInputView = () => { estimatedAnnualRewards, } = useStakingInputHandlers(balanceWei); + + const { sdkService } = useStakeContext(); + const navigateToLearnMoreModal = () => { navigation.navigate('StakeModals', { screen: Routes.STAKING.MODALS.LEARN_MORE, @@ -57,7 +61,8 @@ const StakeInputView = () => { amountFiat: fiatAmount, }, }); - }, [amountWei, fiatAmount, navigation]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [amountWei, fiatAmount, navigation, sdkService]); const balanceText = strings('stake.balance'); diff --git a/app/components/UI/Stake/hooks/useBalance.ts b/app/components/UI/Stake/hooks/useBalance.ts index cbe6db124d3..2ce1dfc6af4 100644 --- a/app/components/UI/Stake/hooks/useBalance.ts +++ b/app/components/UI/Stake/hooks/useBalance.ts @@ -48,7 +48,7 @@ const useBalance = () => { [balanceWei, conversionRate], ); - return { balance, balanceFiat, balanceWei, balanceFiatNumber }; + return { balance, balanceFiat, balanceWei, balanceFiatNumber, conversionRate, currentCurrency }; }; export default useBalance; diff --git a/app/components/UI/Stake/hooks/useStakeContext.ts b/app/components/UI/Stake/hooks/useStakeContext.ts new file mode 100644 index 00000000000..0fc280593da --- /dev/null +++ b/app/components/UI/Stake/hooks/useStakeContext.ts @@ -0,0 +1,7 @@ +import { useContext } from 'react'; +import { Stake, StakeContext } from '../sdk/stakeSdkProvider'; + +export const useStakeContext = () => { + const context = useContext(StakeContext); + return context as Stake; +}; diff --git a/app/components/UI/Stake/routes/index.tsx b/app/components/UI/Stake/routes/index.tsx index 14993705ca5..73960d4a7af 100644 --- a/app/components/UI/Stake/routes/index.tsx +++ b/app/components/UI/Stake/routes/index.tsx @@ -5,6 +5,7 @@ import LearnMoreModal from '../components/LearnMoreModal'; import Routes from '../../../../constants/navigation/Routes'; import StakeConfirmationView from '../Views/StakeConfirmationView/StakeConfirmationView'; import UnstakeInputView from '../Views/UnstakeInputView/UnstakeInputView'; +import { StakeSDKProvider } from '../sdk/stakeSdkProvider'; const Stack = createStackNavigator(); const ModalStack = createStackNavigator(); @@ -18,28 +19,35 @@ const clearStackNavigatorOptions = { // Regular Stack for Screens const StakeScreenStack = () => ( - - - - - + + + + + + + ); // Modal Stack for Modals const StakeModalStack = () => ( - - - + + + + + ); export { StakeScreenStack, StakeModalStack }; diff --git a/app/components/UI/Stake/sdk/__snapshots__/stakeSdkProvider.test.tsx.snap b/app/components/UI/Stake/sdk/__snapshots__/stakeSdkProvider.test.tsx.snap new file mode 100644 index 00000000000..12944c1a6ce --- /dev/null +++ b/app/components/UI/Stake/sdk/__snapshots__/stakeSdkProvider.test.tsx.snap @@ -0,0 +1,2224 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Stake Modals With Stake Sdk Provider should render correctly stake modal with stake sdk provider and resolve the stake context 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + Stake ETH and earn + + + + + Stake any amount of ETH + + + No minimum required. + + + + + Earn ETH rewards + + + Start earning as soon as you stake. Rewards compound automatically. + + + + + Flexible unstaking + + + Unstake anytime. Typically takes up to 11 days to process. + + + + + Staking does not guarantee rewards, and involves risks including a loss of funds. + + + + + + + + Learn more + + + + + + + Got it + + + + + + + + + + + + + + + + + + +`; + +exports[`Stake Modals With Stake Sdk Provider should render correctly stake screen with stake sdk provider and resolve the stake context 1`] = ` + + + + + + + + + + + + + Stake ETH + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + + Balance + : + 0 ETH + + + + + 0 + + + ETH + + + + + + 0 USD + + + + + + + + + + + MetaMask Pool + + + + + + + + 2.6% + + + Estimated annual rewards + + + + + + + + + 25% + + + + + 50% + + + + + 75% + + + + + + Max + + + + + + + + 1 + + + + + 2 + + + + + 3 + + + + + + + 4 + + + + + 5 + + + + + 6 + + + + + + + 7 + + + + + 8 + + + + + 9 + + + + + + + . + + + + + 0 + + + + +  + + + + + + + + Enter amount + + + + + + + + + + + + + + + +`; diff --git a/app/components/UI/Stake/sdk/stakeSdkProvider.test.tsx b/app/components/UI/Stake/sdk/stakeSdkProvider.test.tsx new file mode 100644 index 00000000000..6c47a20406c --- /dev/null +++ b/app/components/UI/Stake/sdk/stakeSdkProvider.test.tsx @@ -0,0 +1,71 @@ +import { + ChainId, + PooledStakingContract, + StakingType, +} from '@metamask/stake-sdk'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import { backgroundState } from '../../../../util/test/initial-root-state'; +import { Stake } from '../sdk/stakeSdkProvider'; +// eslint-disable-next-line import/no-namespace +import * as useStakeContextHook from '../hooks/useStakeContext'; +import { Contract } from '@ethersproject/contracts'; +import { StakeModalStack, StakeScreenStack } from '../routes'; + +const mockPooledStakingContractService: PooledStakingContract = { + chainId: ChainId.ETHEREUM, + connectSignerOrProvider: jest.fn(), + contract: new Contract('0x0000000000000000000000000000000000000000', []), + convertToShares: jest.fn(), + encodeClaimExitedAssetsTransactionData: jest.fn(), + encodeDepositTransactionData: jest.fn(), + encodeEnterExitQueueTransactionData: jest.fn(), + encodeMulticallTransactionData: jest.fn(), + estimateClaimExitedAssetsGas: jest.fn(), + estimateDepositGas: jest.fn(), + estimateEnterExitQueueGas: jest.fn(), + estimateMulticallGas: jest.fn(), +}; + +const mockSDK: Stake = { + sdkService: mockPooledStakingContractService, + sdkType: StakingType.POOLED, + setSdkType: jest.fn(), +}; + +jest.mock('../../Stake/constants', () => ({ + isPooledStakingFeatureEnabled: jest.fn().mockReturnValue(true), +})); + +describe('Stake Modals With Stake Sdk Provider', () => { + const initialState = { + engine: { + backgroundState, + }, + }; + it('should render correctly stake screen with stake sdk provider and resolve the stake context', () => { + const useStakeContextSpy = jest + .spyOn(useStakeContextHook, 'useStakeContext') + .mockReturnValue(mockSDK); + + const { toJSON } = renderWithProvider(StakeScreenStack(), { + state: initialState, + }); + + expect(toJSON()).toMatchSnapshot(); + expect(useStakeContextSpy).toHaveBeenCalled(); + }); + + it('should render correctly stake modal with stake sdk provider and resolve the stake context', () => { + const useStakeContextSpy = jest + .spyOn(useStakeContextHook, 'useStakeContext') + .mockReturnValue(mockSDK); + + const { toJSON } = renderWithProvider(StakeModalStack(), { + state: initialState, + }); + + expect(toJSON()).toMatchSnapshot(); + expect(useStakeContextSpy).toHaveBeenCalledTimes(0); + + }); +}); diff --git a/app/components/UI/Stake/sdk/stakeSdkProvider.tsx b/app/components/UI/Stake/sdk/stakeSdkProvider.tsx new file mode 100644 index 00000000000..19a6769949b --- /dev/null +++ b/app/components/UI/Stake/sdk/stakeSdkProvider.tsx @@ -0,0 +1,71 @@ +import { StakingType, StakeSdk, PooledStakingContract } from '@metamask/stake-sdk'; +import Logger from '../../../../util/Logger'; +import React, { + useState, + useEffect, + createContext, + useMemo, + PropsWithChildren, +} from 'react'; + +export const SDK = StakeSdk.create({ stakingType: StakingType.POOLED }); + +export interface Stake { + sdkError?: Error; + sdkService?: PooledStakingContract; // to do : facade it for other services implementation + + sdkType?: StakingType; + setSdkType: (stakeType: StakingType) => void; +} + +export const StakeContext = createContext(undefined); + +export interface StakeProviderProps { + stakingType?: StakingType; +} +export const StakeSDKProvider: React.FC> = ({ + children, +}) => { + const [sdkService, setSdkService] = useState(); + const [sdkError, setSdkError] = useState(); + const [sdkType, setSdkType] = useState(StakingType.POOLED); + + useEffect(() => { + (async () => { + try { + if (sdkType === StakingType?.POOLED) { + setSdkService(SDK.pooledStakingContractService); + } else { + const notImplementedError = new Error( + `StakeSDKProvider SDK.StakingType ${sdkType} not implemented yet`, + ); + Logger.error(notImplementedError); + setSdkError(notImplementedError); + } + } catch (error) { + Logger.error(error as Error, `StakeSDKProvider SDK.service failed`); + setSdkError(error as Error); + } + })(); + }, [sdkType]); + + const stakeContextValue = useMemo( + (): Stake => ({ + sdkError, + sdkService, + sdkType, + setSdkType, + }), + [ + sdkError, + sdkService, + sdkType, + setSdkType, + ], + ); + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/app/components/UI/Swaps/QuotesView.js b/app/components/UI/Swaps/QuotesView.js index ca0b8a17e90..87ed528aa03 100644 --- a/app/components/UI/Swaps/QuotesView.js +++ b/app/components/UI/Swaps/QuotesView.js @@ -17,6 +17,7 @@ import { swapsUtils } from '@metamask/swaps-controller'; import { WalletDevice, TransactionStatus, + CHAIN_IDS, } from '@metamask/transaction-controller'; import { query, toHex } from '@metamask/controller-utils'; import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; @@ -1004,6 +1005,21 @@ function SwapsQuotesView({ transactionMeta.id, ); + // TODO: remove this when linea swaps issue is resolved with better transaction awaiting + if ( + [ + CHAIN_IDS.LINEA_MAINNET, + CHAIN_IDS.LINEA_GOERLI, + CHAIN_IDS.LINEA_SEPOLIA, + ].includes(chainId) + ) { + Logger.log('Delaying submitting trade tx to make Linea confirmation more likely',); + const waitPromise = new Promise((resolve) => + setTimeout(resolve, 5000), + ); + await waitPromise; + } + setRecipient(selectedAddress); const approvalTransactionMetaId = transactionMeta.id; @@ -1051,6 +1067,7 @@ function SwapsQuotesView({ setRecipient, resetTransaction, shouldUseSmartTransaction, + chainId, ], ); diff --git a/app/components/UI/Tokens/TokenList/PortfolioBalance/index.test.tsx b/app/components/UI/Tokens/TokenList/PortfolioBalance/index.test.tsx new file mode 100644 index 00000000000..e1e1f694376 --- /dev/null +++ b/app/components/UI/Tokens/TokenList/PortfolioBalance/index.test.tsx @@ -0,0 +1,141 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import { BN } from 'ethereumjs-util'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import { backgroundState } from '../../../../../util/test/initial-root-state'; +import AppConstants from '../../../../../../app/core/AppConstants'; +import Routes from '../../../../../../app/constants/navigation/Routes'; +import { WalletViewSelectorsIDs } from '../../../../../../e2e/selectors/wallet/WalletView.selectors'; +import { PortfolioBalance } from '.'; + +jest.mock('../../../../../core/Engine', () => ({ + getTotalFiatAccountBalance: jest.fn(), + context: { + TokensController: { + ignoreTokens: jest.fn(() => Promise.resolve()), + }, + }, +})); + +const initialState = { + engine: { + backgroundState: { + ...backgroundState, + TokensController: { + tokens: [ + { + name: 'Ethereum', + symbol: 'ETH', + address: '0x0', + decimals: 18, + isETH: true, + + balanceFiat: '< $0.01', + iconUrl: '', + }, + { + name: 'Bat', + symbol: 'BAT', + address: '0x01', + decimals: 18, + balanceFiat: '$0', + iconUrl: '', + }, + { + name: 'Link', + symbol: 'LINK', + address: '0x02', + decimals: 18, + balanceFiat: '$0', + iconUrl: '', + }, + ], + }, + TokenRatesController: { + marketData: { + '0x1': { + '0x0': { price: 0.005 }, + '0x01': { price: 0.005 }, + '0x02': { price: 0.005 }, + }, + }, + }, + CurrencyRateController: { + currentCurrency: 'USD', + currencyRates: { + ETH: { + conversionRate: 1, + }, + }, + }, + TokenBalancesController: { + contractBalances: { + '0x00': new BN(2), + '0x01': new BN(2), + '0x02': new BN(0), + }, + }, + }, + }, + settings: { + primaryCurrency: 'usd', + hideZeroBalanceTokens: true, + }, + security: { + dataCollectionForMarketing: true, + }, +}; + +const mockNavigate = jest.fn(); +const mockPush = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actualReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualReactNavigation, + useNavigation: () => ({ + navigate: mockNavigate, + push: mockPush, + }), + }; +}); + +// TODO: Replace "any" with type +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const renderPortfolioBalance = (state: any = {}) => + renderWithProvider(, { state }); + +describe('PortfolioBalance', () => { + afterEach(() => { + mockNavigate.mockClear(); + mockPush.mockClear(); + }); + + it('fiat balance must be defined', () => { + const { getByTestId } = renderPortfolioBalance(initialState); + expect( + getByTestId(WalletViewSelectorsIDs.TOTAL_BALANCE_TEXT), + ).toBeDefined(); + }); + + it('portfolio button should render correctly', () => { + const { getByTestId } = renderPortfolioBalance(initialState); + + expect(getByTestId(WalletViewSelectorsIDs.PORTFOLIO_BUTTON)).toBeDefined(); + }); + + it('navigates to Portfolio url when portfolio button is pressed', () => { + const { getByTestId } = renderPortfolioBalance(initialState); + + const expectedUrl = `${AppConstants.PORTFOLIO.URL}/?metamaskEntry=mobile&metricsEnabled=false&marketingEnabled=${initialState.security.dataCollectionForMarketing}`; + + fireEvent.press(getByTestId(WalletViewSelectorsIDs.PORTFOLIO_BUTTON)); + expect(mockNavigate).toHaveBeenCalledWith(Routes.BROWSER.HOME, { + params: { + newTabUrl: expectedUrl, + timestamp: 123, + }, + screen: Routes.BROWSER.VIEW, + }); + }); +}); diff --git a/app/components/UI/Tokens/TokenList/PortfolioBalance/index.tsx b/app/components/UI/Tokens/TokenList/PortfolioBalance/index.tsx index f18648f6054..06729279ac3 100644 --- a/app/components/UI/Tokens/TokenList/PortfolioBalance/index.tsx +++ b/app/components/UI/Tokens/TokenList/PortfolioBalance/index.tsx @@ -25,7 +25,9 @@ import Button, { ButtonSize, ButtonWidthTypes, } from '../../../../../component-library/components/Buttons/Button'; -import Text from '../../../../../component-library/components/Texts/Text'; +import Text, { + TextVariant, +} from '../../../../../component-library/components/Texts/Text'; import AggregatedPercentage from '../../../../../component-library/components-temp/Price/AggregatedPercentage'; import { IconName } from '../../../../../component-library/components/Icons/Icon'; import { BrowserTab } from '../../types'; @@ -110,8 +112,8 @@ export const PortfolioBalance = () => { {fiatBalance} diff --git a/app/components/UI/Tokens/TokenList/index.tsx b/app/components/UI/Tokens/TokenList/index.tsx index 47b88c8e8b9..f5460d51281 100644 --- a/app/components/UI/Tokens/TokenList/index.tsx +++ b/app/components/UI/Tokens/TokenList/index.tsx @@ -16,7 +16,6 @@ import createStyles from '../styles'; import Text from '../../../../component-library/components/Texts/Text'; import { TokenI } from '../types'; import { strings } from '../../../../../locales/i18n'; -import { PortfolioBalance } from './PortfolioBalance'; import { TokenListFooter } from './TokenListFooter'; import { TokenListItem } from './TokenListItem'; @@ -77,7 +76,6 @@ export const TokenList = ({ return tokens?.length ? ( } data={tokens} renderItem={({ item }) => ( } - ListHeaderComponent={} data={ [ { @@ -392,131 +391,6 @@ exports[`Tokens should hide zero balance tokens when setting is on 1`] = ` > - - - - - 0 USD - - - - - (+0.00%) - - - - - - Portfolio - - - - - } - ListHeaderComponent={} data={ [ { @@ -1641,131 +1514,6 @@ exports[`Tokens should render correctly 1`] = ` > - - - - - 0 USD - - - - - (+0.00%) - - - - - - Portfolio - - - - - } - ListHeaderComponent={} data={ [ { @@ -2906,131 +2653,6 @@ exports[`Tokens should show all balance tokens when hideZeroBalanceTokens settin > - - - - - 0 USD - - - - - (+0.00%) - - - - - - Portfolio - - - - - { expect(getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER)).toBeDefined(); }); - it('fiat balance must be defined', () => { - const { getByTestId } = renderComponent(initialState); - - expect( - getByTestId(WalletViewSelectorsIDs.TOTAL_BALANCE_TEXT), - ).toBeDefined(); - }); - it('portfolio button should render correctly', () => { - const { getByTestId } = renderComponent(initialState); - - expect(getByTestId(WalletViewSelectorsIDs.PORTFOLIO_BUTTON)).toBeDefined(); - }); - it('navigates to Portfolio url when portfolio button is pressed', () => { - const { getByTestId } = renderComponent(initialState); - - const expectedUrl = `${AppConstants.PORTFOLIO.URL}/?metamaskEntry=mobile&metricsEnabled=false&marketingEnabled=${initialState.security.dataCollectionForMarketing}`; - - fireEvent.press(getByTestId(WalletViewSelectorsIDs.PORTFOLIO_BUTTON)); - expect(mockNavigate).toHaveBeenCalledWith(Routes.BROWSER.HOME, { - params: { - newTabUrl: expectedUrl, - timestamp: 123, - }, - screen: Routes.BROWSER.VIEW, - }); - }); it('should display unable to find conversion rate', async () => { const state = { engine: { diff --git a/app/components/UI/Tokens/styles.ts b/app/components/UI/Tokens/styles.ts index 9292733d50e..2e7b7ccf575 100644 --- a/app/components/UI/Tokens/styles.ts +++ b/app/components/UI/Tokens/styles.ts @@ -99,13 +99,7 @@ const createStyles = (colors: Colors) => alignItems: 'center', marginHorizontal: 16, justifyContent: 'space-between', - marginVertical: 24, - }, - fiatBalance: { - ...fontStyles.normal, - fontSize: 32, - lineHeight: 40, - fontWeight: '500', + paddingTop: 24, }, portfolioLink: { marginLeft: 8 }, bottomModal: { diff --git a/app/components/Views/AccountConnect/AccountConnect.tsx b/app/components/Views/AccountConnect/AccountConnect.tsx index 6bb65b3e2d0..9883878435c 100644 --- a/app/components/Views/AccountConnect/AccountConnect.tsx +++ b/app/components/Views/AccountConnect/AccountConnect.tsx @@ -121,7 +121,6 @@ const AccountConnect = (props: AccountConnectProps) => { const accountsLength = useSelector(selectAccountsLength); const { wc2Metadata } = useSelector((state: RootState) => state.sdk); - const isOriginWalletConnect = wc2Metadata?.id && wc2Metadata?.id.length > 0; const { origin: channelIdOrHostname } = hostInfo.metadata as { id: string; origin: string; @@ -141,6 +140,8 @@ const AccountConnect = (props: AccountConnectProps) => { const isOriginMMSDKRemoteConn = sdkConnection !== undefined; + const isOriginWalletConnect = !isOriginMMSDKRemoteConn && wc2Metadata?.id && wc2Metadata?.id.length > 0; + const dappIconUrl = sdkConnection?.originatorInfo?.icon; const dappUrl = sdkConnection?.originatorInfo?.url ?? ''; @@ -344,7 +345,6 @@ const AccountConnect = (props: AccountConnectProps) => { }, approvedAccounts: selectedAddresses, }; - const connectedAccountLength = selectedAddresses.length; const activeAddress = selectedAddresses[0]; const activeAccountName = getAccountNameWithENS({ @@ -606,6 +606,11 @@ const AccountConnect = (props: AccountConnectProps) => { onBack={() => setScreen(AccountConnectScreens.SingleConnect)} connection={sdkConnection} hostname={hostname} + onPrimaryActionButtonPress={ + isMultichainVersion1Enabled + ? () => setScreen(AccountConnectScreens.SingleConnect) + : undefined + } /> ), [ diff --git a/app/components/Views/AccountConnect/AccountConnectMultiSelector/AccountConnectMultiSelector.tsx b/app/components/Views/AccountConnect/AccountConnectMultiSelector/AccountConnectMultiSelector.tsx index 91d505d7cec..457090f802f 100644 --- a/app/components/Views/AccountConnect/AccountConnectMultiSelector/AccountConnectMultiSelector.tsx +++ b/app/components/Views/AccountConnect/AccountConnectMultiSelector/AccountConnectMultiSelector.tsx @@ -53,6 +53,8 @@ const AccountConnectMultiSelector = ({ onBack, screenTitle, isRenderedAsBottomSheet = true, + showDisconnectAllButton = true, + onPrimaryActionButtonPress, }: AccountConnectMultiSelectorProps) => { const { styles } = useStyles(styleSheet, { isRenderedAsBottomSheet }); const { navigate } = useNavigation(); @@ -205,7 +207,7 @@ const AccountConnectMultiSelector = ({ variant={ButtonVariants.Primary} label={strings( isMultichainVersion1Enabled - ? 'app_settings.fiat_on_ramp.update' + ? 'networks.update' : 'accounts.connect_with_count', { countLabel: selectedAddresses.length @@ -213,7 +215,15 @@ const AccountConnectMultiSelector = ({ : '', }, )} - onPress={() => onUserAction(USER_INTENT.Confirm)} + onPress={() => { + if (!isMultichainVersion1Enabled) { + onUserAction(USER_INTENT.Confirm); + } else { + onPrimaryActionButtonPress + ? onPrimaryActionButtonPress() + : onUserAction(USER_INTENT.Confirm); + } + }} size={ButtonSize.Lg} style={{ ...styles.button, @@ -227,29 +237,31 @@ const AccountConnectMultiSelector = ({ /> )} - {isMultichainVersion1Enabled && areNoAccountsSelected && ( - - - - {strings('common.disconnect_you_from', { - dappUrl: hostname, - })} - - - - + label={strings('login.reset_wallet')} + /> diff --git a/app/components/Views/Login/index.test.tsx b/app/components/Views/Login/index.test.tsx index 07938c54984..7d6b922eb7c 100644 --- a/app/components/Views/Login/index.test.tsx +++ b/app/components/Views/Login/index.test.tsx @@ -1,28 +1,12 @@ import React from 'react'; -import { shallow } from 'enzyme'; import Login from './'; -import configureMockStore from 'redux-mock-store'; -import { Provider } from 'react-redux'; -import { backgroundState } from '../../../util/test/initial-root-state'; - -const mockStore = configureMockStore(); -const initialState = { - engine: { - backgroundState, - }, - user: { - passwordSet: true, - }, -}; -const store = mockStore(initialState); +import renderWithProvider from '../../../util/test/renderWithProvider'; describe('Login', () => { it('should render correctly', () => { - const wrapper = shallow( - + const { toJSON } = renderWithProvider( - , ); - expect(wrapper).toMatchSnapshot(); + expect(toJSON()).toMatchSnapshot(); }); }); diff --git a/app/components/Views/Notifications/Details/Fields/NetworkFeeField.tsx b/app/components/Views/Notifications/Details/Fields/NetworkFeeField.tsx index 34a08efff32..6be567b0b7e 100644 --- a/app/components/Views/Notifications/Details/Fields/NetworkFeeField.tsx +++ b/app/components/Views/Notifications/Details/Fields/NetworkFeeField.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { TouchableOpacity, View } from 'react-native'; + import { strings } from '../../../../../../locales/i18n'; import BottomSheet, { BottomSheetRef, @@ -27,6 +28,7 @@ import { } from '../../../../../util/notifications'; import { useMetrics } from '../../../../../components/hooks/useMetrics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import NetworkFeeFieldSkeleton from './Skeletons/NetworkFeeField'; type NetworkFeeFieldProps = ModalFieldNetworkFee & { notification: Notification; @@ -38,13 +40,22 @@ type NetworkFee = Awaited>; function useNetworkFee({ getNetworkFees }: NetworkFeeFieldProps) { const [data, setData] = useState(undefined); + const [isLoading, setIsLoading] = useState(false); useEffect(() => { + setIsLoading(true); getNetworkFees() - .then((result) => setData(result)) - .catch(() => setData(undefined)); + .then((result) => { + setData(result); + setIsLoading(false); + }) + .catch(() => { + setData(undefined); + }).finally(() => { + setIsLoading(false); + }); }, [getNetworkFees]); - return data; + return { data, isLoading }; } function NetworkFeeLabelAndValue(props: { @@ -73,15 +84,55 @@ function NetworkFeeField(props: NetworkFeeFieldProps) { const { setIsCollapsed, isCollapsed, notification } = props; const { styles, theme } = useStyles(); const sheetRef = useRef(null); - const networkFee = useNetworkFee(props); + const {data: networkFee, isLoading} = useNetworkFee(props); const { trackEvent } = useMetrics(); + if (isLoading && !networkFee) { + return ( + + + + ); + } + + const renderNetworkFeeDetails = () => { if (!networkFee) { - return null; + return ( + + + {strings('notifications.network_fee_not_available')} + + + ); } - const collapsedIcon = isCollapsed ? IconName.ArrowDown : IconName.ArrowUp; const ticker = CURRENCY_SYMBOL_BY_CHAIN_ID[networkFee.chainId]; + const collapsedIcon = isCollapsed ? IconName.ArrowDown : IconName.ArrowUp; + return ( + <> + + + {strings('asset_details.network_fee')} + + + + {networkFee.transactionFeeInEth} {ticker} ($ + {networkFee.transactionFeeInUsd}) + + + + + {strings('transaction.details')} + + + + + ); + }; const onPress = () => { setIsCollapsed(!isCollapsed); @@ -109,31 +160,11 @@ function NetworkFeeField(props: NetworkFeeFieldProps) { backgroundColor={theme.colors.info.muted} iconColor={IconColor.Info} /> - - - - {strings('asset_details.network_fee')} - - - - {networkFee.transactionFeeInEth} {ticker} ($ - {networkFee.transactionFeeInUsd}) - - - - - {strings('transaction.details')} - - - + {renderNetworkFeeDetails()} - {!isCollapsed && ( + {!isCollapsed && networkFee && ( + + + + + + ); +} diff --git a/app/components/Views/Notifications/Details/Fields/__snapshots__/NetworkFeeField.test.tsx.snap b/app/components/Views/Notifications/Details/Fields/__snapshots__/NetworkFeeField.test.tsx.snap index d783926f116..86545187371 100644 --- a/app/components/Views/Notifications/Details/Fields/__snapshots__/NetworkFeeField.test.tsx.snap +++ b/app/components/Views/Notifications/Details/Fields/__snapshots__/NetworkFeeField.test.tsx.snap @@ -1,3 +1,77 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`NetworkFeeField should renders correctly when type has "ModalField-NetworkFee" 1`] = `null`; +exports[`NetworkFeeField should renders correctly when type has "ModalField-NetworkFee" 1`] = ` + + + + + + + + + + + + +`; diff --git a/app/components/Views/Notifications/Details/__snapshots__/index.test.tsx.snap b/app/components/Views/Notifications/Details/__snapshots__/index.test.tsx.snap index 878a3791d02..1fc3b04b61f 100644 --- a/app/components/Views/Notifications/Details/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/Notifications/Details/__snapshots__/index.test.tsx.snap @@ -979,6 +979,79 @@ exports[`NotificationsDetails should renders correctly 1`] = ` + + + + + + + + + + + + StyleSheet.create({ @@ -276,33 +275,24 @@ class Onboarding extends PureComponent { }; onPressCreate = () => { - const action = () => { - trace( - { - name: TraceName.CreateNewWalletToChoosePassword, - op: TraceOperation.CreateNewWalletToChoosePassword, - }, - () => { - const { metrics } = this.props; - if (metrics.isEnabled()) { - this.props.navigation.navigate('ChoosePassword', { + const action = async () => { + const { metrics } = this.props; + if (metrics.isEnabled()) { + this.props.navigation.navigate('ChoosePassword', { + [PREVIOUS_SCREEN]: ONBOARDING, + }); + this.track(MetaMetricsEvents.WALLET_SETUP_STARTED); + } else { + this.props.navigation.navigate('OptinMetrics', { + onContinue: () => { + this.props.navigation.replace('ChoosePassword', { [PREVIOUS_SCREEN]: ONBOARDING, }); this.track(MetaMetricsEvents.WALLET_SETUP_STARTED); - } else { - this.props.navigation.navigate('OptinMetrics', { - onContinue: () => { - this.props.navigation.replace('ChoosePassword', { - [PREVIOUS_SCREEN]: ONBOARDING, - }); - this.track(MetaMetricsEvents.WALLET_SETUP_STARTED); - }, - }); - } - }, - ); + }, + }); + } }; - this.handleExistingUser(action); }; diff --git a/app/components/Views/Settings/NotificationsSettings/AccountsList.test.tsx b/app/components/Views/Settings/NotificationsSettings/AccountsList.test.tsx new file mode 100644 index 00000000000..aa3d30a0ee7 --- /dev/null +++ b/app/components/Views/Settings/NotificationsSettings/AccountsList.test.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import renderWithProvider, { DeepPartial } from '../../../../util/test/renderWithProvider'; +import { AccountsList } from './AccountsList'; +import { AvatarAccountType } from '../../../../component-library/components/Avatars/Avatar'; +import { Account } from '../../../../components/hooks/useAccounts/useAccounts.types'; +import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../../util/test/accountsControllerTestUtils'; +import { Hex } from '@metamask/utils'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import { toChecksumAddress } from 'ethereumjs-util'; +import { RootState } from '../../../../reducers'; +import { backgroundState } from '../../../../util/test/initial-root-state'; + +const MOCK_ACCOUNT_ADDRESSES = Object.values( + MOCK_ACCOUNTS_CONTROLLER_STATE.internalAccounts.accounts, +).map((account) => account.address); + +const MOCK_ACCOUNT_1: Account = { + name: 'Account 1', + address: toChecksumAddress(MOCK_ACCOUNT_ADDRESSES[0]) as Hex, + type: KeyringTypes.hd, + yOffset: 0, + isSelected: false, + assets: { + fiatBalance: '\n0 ETH', + }, + balanceError: undefined, +}; +const MOCK_ACCOUNT_2: Account = { + name: 'Account 2', + address: toChecksumAddress(MOCK_ACCOUNT_ADDRESSES[1]) as Hex, + type: KeyringTypes.hd, + yOffset: 78, + isSelected: true, + assets: { + fiatBalance: '\n< 0.00001 ETH', + }, + balanceError: undefined, +}; + +const MOCK_ACCOUNTS = [MOCK_ACCOUNT_1, MOCK_ACCOUNT_2]; + +const mockInitialState: DeepPartial = { + engine: { + backgroundState: { + ...backgroundState, + NotificationServicesController: { + metamaskNotificationsList: [], + }, + }, + }, +}; + +describe('AccountsList', () => { + it('matches snapshot', () => { + + const { toJSON } = renderWithProvider( + , + { + state: mockInitialState, + }, + ); + expect(toJSON()).toMatchSnapshot(); + }); + + it('triggers updateAndfetchAccountSettings on mount', () => { + const updateAndfetchAccountSettings = jest.fn(); + renderWithProvider( + , + { + state: mockInitialState, + }, + ); + + expect(updateAndfetchAccountSettings).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/Views/Settings/NotificationsSettings/AccountsList.tsx b/app/components/Views/Settings/NotificationsSettings/AccountsList.tsx new file mode 100644 index 00000000000..8dded276cf9 --- /dev/null +++ b/app/components/Views/Settings/NotificationsSettings/AccountsList.tsx @@ -0,0 +1,53 @@ +import React, { useEffect } from 'react'; +import { FlatList, View } from 'react-native'; +import NotificationOptionToggle from './NotificationOptionToggle'; +import { Account } from '../../../../components/hooks/useAccounts/useAccounts.types'; +import { NotificationsToggleTypes } from './NotificationsSettings.constants'; +import { NotificationsAccountsState } from '../../../../core/redux/slices/notifications'; +import { AvatarAccountType } from '../../../../component-library/components/Avatars/Avatar'; + +export const AccountsList = ({ + accounts, + accountAvatarType, + accountSettingsData, + updateAndfetchAccountSettings, + isUpdatingMetamaskNotificationsAccount, +}: { + accounts: Account[]; + accountAvatarType: AvatarAccountType; + accountSettingsData: NotificationsAccountsState; + updateAndfetchAccountSettings: () => Promise | undefined>; + isUpdatingMetamaskNotificationsAccount: string[]; +}) => { + + useEffect(() => { + const fetchInitialData = async () => { + await updateAndfetchAccountSettings(); + }; + fetchInitialData(); + }, [updateAndfetchAccountSettings]); + + return ( + + `address-${item.address}`} + renderItem={({ item }) => ( + 0} + isLoading={isUpdatingMetamaskNotificationsAccount.includes( + item.address.toLowerCase(), + )} + isEnabled={accountSettingsData?.[item.address.toLowerCase()]} + updateAndfetchAccountSettings={updateAndfetchAccountSettings} + /> + )} + /> + + ); +}; diff --git a/app/components/Views/Settings/NotificationsSettings/NotificationOptionToggle/index.tsx b/app/components/Views/Settings/NotificationsSettings/NotificationOptionToggle/index.tsx index d04cad3c248..ad6ae7921a3 100644 --- a/app/components/Views/Settings/NotificationsSettings/NotificationOptionToggle/index.tsx +++ b/app/components/Views/Settings/NotificationsSettings/NotificationOptionToggle/index.tsx @@ -34,7 +34,7 @@ interface NotificationOptionsToggleProps { disabledSwitch?: boolean; isLoading?: boolean; isEnabled: boolean; - refetchAccountSettings: () => Promise; + updateAndfetchAccountSettings: () => Promise | undefined>; } /** @@ -50,7 +50,7 @@ const NotificationOptionToggle = ({ isEnabled, disabledSwitch, isLoading, - refetchAccountSettings, + updateAndfetchAccountSettings, }: NotificationOptionsToggleProps) => { const theme = useTheme(); const { colors } = theme; @@ -59,7 +59,7 @@ const NotificationOptionToggle = ({ const { toggleAccount, loading: isUpdatingAccount } = useUpdateAccountSetting( address, - refetchAccountSettings, + updateAndfetchAccountSettings, ); const loading = isLoading || isUpdatingAccount; @@ -104,7 +104,7 @@ const NotificationOptionToggle = ({ )} - {isLoading || loading ? ( + {loading ? ( ) : ( }, button: { alignSelf: 'stretch', - marginBottom: 16, + marginBottom: 48, + }, + + }); + + export const styles = StyleSheet.create({ + headerLeft: { + marginHorizontal: 16, }, }); diff --git a/app/components/Views/Settings/NotificationsSettings/__snapshots__/AccountsList.test.tsx.snap b/app/components/Views/Settings/NotificationsSettings/__snapshots__/AccountsList.test.tsx.snap new file mode 100644 index 00000000000..621e0158390 --- /dev/null +++ b/app/components/Views/Settings/NotificationsSettings/__snapshots__/AccountsList.test.tsx.snap @@ -0,0 +1,520 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AccountsList matches snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + Account 1 + + + 0xc495...d272 + + + + + + + + + + + + + + + + + + + + + + + Account 2 + + + 0xc496...a756 + + + + + + + + + + +`; diff --git a/app/components/Views/Settings/NotificationsSettings/index.tsx b/app/components/Views/Settings/NotificationsSettings/index.tsx index e046b203c2e..20456dc6389 100644 --- a/app/components/Views/Settings/NotificationsSettings/index.tsx +++ b/app/components/Views/Settings/NotificationsSettings/index.tsx @@ -1,6 +1,5 @@ -/* eslint-disable react-native/no-inline-styles */ /* eslint-disable react/display-name */ -import React, { useEffect, useMemo, useCallback } from 'react'; +import React, { useEffect, useCallback } from 'react'; import { ScrollView, Switch, View, Linking } from 'react-native'; import { useSelector } from 'react-redux'; import { NavigationProp, ParamListBase } from '@react-navigation/native'; @@ -13,6 +12,7 @@ import Text, { TextVariant, TextColor, } from '../../../../component-library/components/Texts/Text'; +import { AccountsList} from './AccountsList'; import { useAccounts } from '../../../../components/hooks/useAccounts'; import { useMetrics } from '../../../../components/hooks/useMetrics'; import { AvatarAccountType } from '../../../../component-library/components/Avatars/Avatar'; @@ -21,9 +21,7 @@ import SwitchLoadingModal from '../../../UI/Notification/SwitchLoadingModal'; import { Props } from './NotificationsSettings.types'; import { useStyles } from '../../../../component-library/hooks'; -import NotificationOptionToggle from './NotificationOptionToggle'; import CustomNotificationsRow from './CustomNotificationsRow'; -import { NotificationsToggleTypes } from './NotificationsSettings.constants'; import { selectIsFeatureAnnouncementsEnabled, selectIsMetamaskNotificationsEnabled, @@ -51,7 +49,7 @@ import { useAccountSettingsProps, useSwitchNotifications, } from '../../../../util/notifications/hooks/useSwitchNotifications'; -import styleSheet from './NotificationsSettings.styles'; +import styleSheet, { styles as navigationOptionsStyles } from './NotificationsSettings.styles'; import AppConstants from '../../../../core/AppConstants'; import notificationsRows from './notificationsRows'; import { IconName } from '../../../../component-library/components/Icons/Icon'; @@ -121,19 +119,6 @@ const NotificationsSettings = ({ navigation, route }: Props) => { selectIsUpdatingMetamaskNotificationsAccount, ); - const accountAddresses = useMemo( - () => accounts.map((a) => a.address), - [accounts], - ); - - const { switchFeatureAnnouncements } = useSwitchNotifications(); - - // Account Settings - const accountSettingsProps = useAccountSettingsProps(accountAddresses); - const refetchAccountSettings = useCallback(async () => { - await accountSettingsProps.update(accountAddresses); - }, [accountAddresses, accountSettingsProps]); - const { enableNotifications, loading: enableLoading, @@ -146,6 +131,9 @@ const NotificationsSettings = ({ navigation, route }: Props) => { error: disablingError, } = useDisableNotifications(); + const { switchFeatureAnnouncements } = useSwitchNotifications(); + const { updateAndfetchAccountSettings } = useAccountSettingsProps(accounts); + const accountAvatarType = useSelector((state: RootState) => state.settings.useBlockieIcon ? AvatarAccountType.Blockies @@ -157,7 +145,7 @@ const NotificationsSettings = ({ navigation, route }: Props) => { const [uiNotificationStatus, setUiNotificationStatus] = React.useState(false); const [platformAnnouncementsState, setPlatformAnnouncementsState] = React.useState(isFeatureAnnouncementsEnabled); - + const accountSettingsData = useSelector((state: RootState) => state.notifications); const loading = enableLoading || disableLoading; const errorText = enablingError || disablingError; const loadingText = !uiNotificationStatus @@ -170,7 +158,7 @@ const NotificationsSettings = ({ navigation, route }: Props) => { const isFullScreenModal = route?.params?.isFullScreenModal; // Style const { colors } = theme; - const { styles } = useStyles(styleSheet, {}); + const { styles } = useStyles(styleSheet, { theme }); /** * Initializes the notifications feature. @@ -220,37 +208,6 @@ const NotificationsSettings = ({ navigation, route }: Props) => { ); }, [colors, isFullScreenModal, navigation]); - const renderAccounts = useCallback( - () => - accounts.map((account) => { - const isEnabled = - accountSettingsProps.data?.[account.address.toLowerCase()]; - return ( - 0} - isLoading={accountSettingsProps.accountsBeingUpdated.includes( - account.address.toLowerCase(), - )} - isEnabled={isEnabled ?? false} - refetchAccountSettings={refetchAccountSettings} - /> - ); - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - accountSettingsProps.data, - accountSettingsProps.accountsBeingUpdated, - accountAvatarType, - isUpdatingMetamaskNotificationsAccount.length, - refetchAccountSettings, - ], - ); - const renderResetNotificationsBtn = useCallback(() => (