From 1ad8f7a96d1294638a04361fda98d6f7d7ea19f1 Mon Sep 17 00:00:00 2001 From: ghgoodreau Date: Sun, 23 Feb 2025 18:24:46 -0600 Subject: [PATCH 1/5] feat: integration of account picker and v2 quote card --- app/_locales/en/messages.json | 9 ++ ui/pages/bridge/prepare/bridge-cta-button.tsx | 22 +++- .../bridge/prepare/prepare-bridge-page.tsx | 114 ++++++++++++++++-- .../quotes/multichain-bridge-quote-card.tsx | 59 ++++----- 4 files changed, 163 insertions(+), 41 deletions(-) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 0eb802250fbb..31010d6995a6 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -747,6 +747,9 @@ "bridgeEnterAmount": { "message": "Select amount" }, + "bridgeEnterAmountAndSelectAccount": { + "message": "Enter amount and select destination account" + }, "bridgeExplorerLinkViewOn": { "message": "View on $1" }, @@ -769,9 +772,15 @@ "bridgeQuoteExpired": { "message": "Your quote timed out." }, + "bridgeSelectDestinationAccount": { + "message": "Select destination account" + }, "bridgeSelectNetwork": { "message": "Select network" }, + "bridgeSelectTokenAmountAndAccount": { + "message": "Select token, amount and destination account" + }, "bridgeSelectTokenAndAmount": { "message": "Select token and amount" }, diff --git a/ui/pages/bridge/prepare/bridge-cta-button.tsx b/ui/pages/bridge/prepare/bridge-cta-button.tsx index 303c1f544ea1..890828138fd0 100644 --- a/ui/pages/bridge/prepare/bridge-cta-button.tsx +++ b/ui/pages/bridge/prepare/bridge-cta-button.tsx @@ -40,8 +40,10 @@ import { isQuoteExpired as isQuoteExpiredUtil } from '../utils/quote'; export const BridgeCTAButton = ({ onFetchNewQuotes, + needsDestinationAddress = false, }: { onFetchNewQuotes: () => void; + needsDestinationAddress?: boolean; }) => { const t = useI18nContext(); @@ -121,9 +123,17 @@ export const BridgeCTAButton = ({ if (!fromAmount) { if (!toToken) { - return t('bridgeSelectTokenAndAmount'); + return needsDestinationAddress + ? t('bridgeSelectTokenAmountAndAccount') + : t('bridgeSelectTokenAndAmount'); } - return t('bridgeEnterAmount'); + return needsDestinationAddress + ? t('bridgeEnterAmountAndSelectAccount') + : t('bridgeEnterAmount'); + } + + if (needsDestinationAddress) { + return t('bridgeSelectDestinationAccount'); } if (isTxSubmittable) { @@ -144,6 +154,7 @@ export const BridgeCTAButton = ({ isInsufficientGasForQuote, wasTxDeclined, isQuoteExpired, + needsDestinationAddress, ]); // Label for the secondary button that re-starts quote fetching @@ -186,7 +197,12 @@ export const BridgeCTAButton = ({ } }} loading={isSubmitting} - disabled={!isTxSubmittable || isQuoteExpired || isSubmitting} + disabled={ + !isTxSubmittable || + isQuoteExpired || + isSubmitting || + needsDestinationAddress + } > {label} diff --git a/ui/pages/bridge/prepare/prepare-bridge-page.tsx b/ui/pages/bridge/prepare/prepare-bridge-page.tsx index 7dc41ae585f2..cdb2e5ccb9a4 100644 --- a/ui/pages/bridge/prepare/prepare-bridge-page.tsx +++ b/ui/pages/bridge/prepare/prepare-bridge-page.tsx @@ -11,8 +11,10 @@ import { debounce } from 'lodash'; import { useHistory, useLocation } from 'react-router-dom'; import { BigNumber } from 'bignumber.js'; import { type TokenListMap } from '@metamask/assets-controllers'; +import { InternalAccount } from '@metamask/keyring-internal-api'; import { toChecksumAddress, zeroAddress } from 'ethereumjs-util'; import type { Hex, CaipChainId } from '@metamask/utils'; +import { SolAccountType } from '@metamask/keyring-api'; import { setFromToken, setFromTokenInputValue, @@ -64,7 +66,6 @@ import { useTokensWithFiltering } from '../../../hooks/bridge/useTokensWithFilte import { setActiveNetwork } from '../../../store/actions'; import type { QuoteRequest } from '../../../../shared/types/bridge'; import { calcTokenValue } from '../../../../shared/lib/swaps-utils'; -import { BridgeQuoteCard } from '../quotes/bridge-quote-card'; import { formatTokenAmount, isQuoteExpired as isQuoteExpiredUtil, @@ -88,7 +89,9 @@ import { useCountdownTimer } from '../../../hooks/bridge/useCountdownTimer'; import { getCurrentKeyring, getSelectedEvmInternalAccount, + getInternalAccounts, getTokenList, + getIsSolanaSupportEnabled, } from '../../../selectors'; import { isHardwareKeyring } from '../../../helpers/utils/hardware'; import { SECOND } from '../../../../shared/constants/time'; @@ -105,15 +108,19 @@ import { selectBridgeHistoryForAccount, selectBridgeStatusState, } from '../../../ducks/bridge-status/selectors'; -import { MultichainNetworks } from '../../../../shared/constants/multichain/networks'; +import { MultichainBridgeQuoteCard } from '../quotes/multichain-bridge-quote-card'; +import { BridgeQuoteCard } from '../quotes/bridge-quote-card'; import { BridgeInputGroup } from './bridge-input-group'; import { BridgeCTAButton } from './bridge-cta-button'; +import { DestinationAccountPicker } from './components/destination-account-picker'; const PrepareBridgePage = () => { const dispatch = useDispatch(); const t = useI18nContext(); + const accounts = useSelector(getInternalAccounts); + const fromToken = useSelector(getFromToken); const fromTokens = useSelector(getTokenList) as TokenListMap; const isFromTokensLoading = useMemo( @@ -287,6 +294,35 @@ const PrepareBridgePage = () => { isLowReturnBannerOpen, ]); + const [selectedDestinationAccount, setSelectedDestinationAccount] = + useState(null); + const hasAutoSelectedRef = useRef(false); + + const isToOrFromSolana = useMemo(() => { + if (!fromChain?.chainId || !toChain?.chainId) { + return false; + } + + const fromChainStartsWithSolana = fromChain.chainId + .toString() + .startsWith('solana:'); + const toChainStartsWithSolana = toChain.chainId + .toString() + .startsWith('solana:'); + + return ( + (toChainStartsWithSolana && !fromChainStartsWithSolana) || + (!toChainStartsWithSolana && fromChainStartsWithSolana) + ); + }, [fromChain?.chainId, toChain?.chainId]); + + const isDestinationSolana = useMemo(() => { + if (!toChain?.chainId) { + return false; + } + return toChain.chainId.toString().startsWith('solana:'); + }, [toChain?.chainId]); + const quoteParams = useMemo( () => ({ srcTokenAddress: fromToken?.address, @@ -310,11 +346,9 @@ const PrepareBridgePage = () => { insufficientBal: Boolean(providerConfig?.rpcUrl?.includes('tenderly')), slippage, walletAddress: selectedAccount?.address ?? '', - // TODO override with account selector's value - destWalletAddress: - toChain?.chainId === MultichainNetworks.SOLANA || isSwap - ? lastSelectedNonEvmAccount?.address - : selectedEvmAccount?.address, + destWalletAddress: isToOrFromSolana + ? selectedDestinationAccount?.address + : selectedEvmAccount?.address, }), [ fromToken?.address, @@ -328,7 +362,8 @@ const PrepareBridgePage = () => { slippage, selectedAccount?.address, selectedEvmAccount?.address, - lastSelectedNonEvmAccount?.address, + selectedDestinationAccount?.address, + isToOrFromSolana, ], ); @@ -342,7 +377,40 @@ const PrepareBridgePage = () => { useEffect(() => { debouncedUpdateQuoteRequestInController(quoteParams); - }, [quoteParams]); + }, [quoteParams, debouncedUpdateQuoteRequestInController]); + + // Auto-select most recently used account only once on initial load + useEffect(() => { + if ( + !selectedDestinationAccount && + !hasAutoSelectedRef.current && + isToOrFromSolana + ) { + const filteredAccounts = accounts + .filter((account: InternalAccount) => { + const isSolAccount = Boolean( + account && account.type === SolAccountType.DataAccount, + ); + return isDestinationSolana ? isSolAccount : !isSolAccount; + }) + .sort((a: InternalAccount, b: InternalAccount) => { + const aLastSelected = a.metadata.lastSelected || 0; + const bLastSelected = b.metadata.lastSelected || 0; + return bLastSelected - aLastSelected; + }); + + if (filteredAccounts.length > 0) { + const mostRecentAccount = filteredAccounts[0]; + setSelectedDestinationAccount(mostRecentAccount); + hasAutoSelectedRef.current = true; + } + } + }, [ + isToOrFromSolana, + selectedDestinationAccount, + isDestinationSolana, + accounts, + ]); const trackInputEvent = useCallback( ( @@ -408,6 +476,8 @@ const PrepareBridgePage = () => { } }, [fromChain, fromToken, fromTokens, search, isFromTokensLoading]); + const isSolanaEnabled = useSelector(getIsSolanaSupportEnabled); + return ( { }} isTokenListLoading={isToTokensLoading} /> + + {isSolanaEnabled && isToOrFromSolana && ( + + + + )} + {isLoading && !activeQuote ? ( <> @@ -641,12 +722,25 @@ const PrepareBridgePage = () => { backgroundColor={BackgroundColor.primaryMuted} /> )} - {!wasTxDeclined && activeQuote && } + {!wasTxDeclined && + activeQuote && + (isSolanaEnabled ? ( + + ) : ( + + ))}