Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: account picker and quote card integrations #30522

Draft
wants to merge 5 commits into
base: sol-staging
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions app/_locales/en/messages.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 19 additions & 3 deletions ui/pages/bridge/prepare/bridge-cta-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@ import { isQuoteExpired as isQuoteExpiredUtil } from '../utils/quote';

export const BridgeCTAButton = ({
onFetchNewQuotes,
needsDestinationAddress = false,
}: {
onFetchNewQuotes: () => void;
needsDestinationAddress?: boolean;
}) => {
const t = useI18nContext();

Expand Down Expand Up @@ -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) {
Expand All @@ -144,6 +154,7 @@ export const BridgeCTAButton = ({
isInsufficientGasForQuote,
wasTxDeclined,
isQuoteExpired,
needsDestinationAddress,
]);

// Label for the secondary button that re-starts quote fetching
Expand Down Expand Up @@ -186,7 +197,12 @@ export const BridgeCTAButton = ({
}
}}
loading={isSubmitting}
disabled={!isTxSubmittable || isQuoteExpired || isSubmitting}
disabled={
!isTxSubmittable ||
isQuoteExpired ||
isSubmitting ||
needsDestinationAddress
}
>
{label}
</ButtonPrimary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,7 @@ export const DestinationAccountPicker = ({
style={{
height: '70px',
borderRadius: '8px',
marginLeft: 'auto',
marginRight: 'auto',
boxShadow: 'var(--shadow-bridge-picker)',
boxShadow: 'var(--shadow-size-sm) var(--color-shadow-default)',
}}
>
<Box
Expand Down Expand Up @@ -116,9 +114,6 @@ export const DestinationAccountPicker = ({
style={{
borderRadius: '8px',
position: 'relative',
marginLeft: 'auto',
marginRight: 'auto',
boxShadow: 'var(--shadow-bridge-picker)',
}}
>
<Box
Expand All @@ -134,6 +129,7 @@ export const DestinationAccountPicker = ({
borderBottomStyle: 'solid',
borderBottomColor: '#B7BBC866',
borderRadius: '8px 8px 0 0',
boxShadow: 'var(--shadow-size-sm) var(--color-shadow-default)',
}}
>
<TextField
Expand All @@ -151,20 +147,21 @@ export const DestinationAccountPicker = ({
}}
/>
</Box>
{/* Invisible buffer to match selected account height */}
<Box style={{ height: '20px' }} />
<Box
className="destination-account-picker__list"
backgroundColor={BackgroundColor.backgroundDefault}
style={{
position: 'absolute',
top: '45px',
top: '50px',
left: 0,
right: 0,
minHeight: '79px',
maxHeight: '240px',
overflowY: 'auto',
borderRadius: '0 0 8px 8px',
zIndex: 1000,
boxShadow: 'var(--shadow-bridge-picker)',
boxShadow: 'var(--shadow-size-sm) var(--color-shadow-default)',
}}
>
{filteredAccounts.map((account) => (
Expand Down
124 changes: 113 additions & 11 deletions ui/pages/bridge/prepare/prepare-bridge-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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';
Expand All @@ -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(
Expand Down Expand Up @@ -287,6 +294,35 @@ const PrepareBridgePage = () => {
isLowReturnBannerOpen,
]);

const [selectedDestinationAccount, setSelectedDestinationAccount] =
useState<InternalAccount | null>(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,
Expand All @@ -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,
Expand All @@ -328,7 +362,8 @@ const PrepareBridgePage = () => {
slippage,
selectedAccount?.address,
selectedEvmAccount?.address,
lastSelectedNonEvmAccount?.address,
selectedDestinationAccount?.address,
isToOrFromSolana,
],
);

Expand All @@ -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(
(
Expand Down Expand Up @@ -408,6 +476,8 @@ const PrepareBridgePage = () => {
}
}, [fromChain, fromToken, fromTokens, search, isFromTokensLoading]);

const isSolanaEnabled = useSelector(getIsSolanaSupportEnabled);

return (
<Column className="prepare-bridge-page" gap={8}>
<BridgeInputGroup
Expand Down Expand Up @@ -601,6 +671,17 @@ const PrepareBridgePage = () => {
}}
isTokenListLoading={isToTokensLoading}
/>

{isSolanaEnabled && isToOrFromSolana && (
<Box padding={6} paddingBottom={3} paddingTop={3}>
<DestinationAccountPicker
onAccountSelect={setSelectedDestinationAccount}
selectedSwapToAccount={selectedDestinationAccount}
isDestinationSolana={isDestinationSolana}
/>
</Box>
)}

<Column height={BlockSize.Full} justifyContent={JustifyContent.center}>
{isLoading && !activeQuote ? (
<>
Expand All @@ -615,7 +696,7 @@ const PrepareBridgePage = () => {
) : null}
</Column>

<Row padding={6}>
<Row padding={6} paddingTop={activeQuote ? 0 : 6}>
<Column
gap={3}
className={activeQuote ? 'highlight' : ''}
Expand All @@ -625,6 +706,14 @@ const PrepareBridgePage = () => {
paddingInline: 16,
position: 'relative',
overflow: 'hidden',
...(activeQuote && !wasTxDeclined && isSolanaEnabled
? {
boxShadow:
'var(--shadow-size-sm) var(--color-shadow-default)',
backgroundColor: 'var(--color-background-default)',
borderRadius: 8,
}
: {}),
}}
>
{activeQuote && isQuoteGoingToRefresh && (
Expand All @@ -641,12 +730,25 @@ const PrepareBridgePage = () => {
backgroundColor={BackgroundColor.primaryMuted}
/>
)}
{!wasTxDeclined && activeQuote && <BridgeQuoteCard />}
{!wasTxDeclined &&
activeQuote &&
(isSolanaEnabled ? (
<MultichainBridgeQuoteCard
destinationAddress={selectedDestinationAccount?.address}
/>
) : (
<BridgeQuoteCard />
))}
<Footer padding={0} flexDirection={FlexDirection.Column} gap={2}>
<BridgeCTAButton
onFetchNewQuotes={() => {
debouncedUpdateQuoteRequestInController(quoteParams);
}}
needsDestinationAddress={
isSolanaEnabled &&
isToOrFromSolana &&
!selectedDestinationAccount
}
/>
{activeQuote?.approval && fromAmount && fromToken ? (
<Row justifyContent={JustifyContent.center} gap={1}>
Expand Down
Loading
Loading