Skip to content

Commit 05853aa

Browse files
ghgoodreaumicaelae
andauthored
feat: MMS-2553 unified UI for swaps and bridges (#33487)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** Adds support for unified UI (swapping and bridging on a singular page). When isUnifiedUIEnabled is true, we provide swapping and bridging all within the prepare-bridge-page. When it is false, care was taken to ensure that functionality remains backwards-compatible. <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/33487?quickstart=1) ## **Related issues** Closes: https://consensyssoftware.atlassian.net/browse/MMS-2553 ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --------- Co-authored-by: Micaela Estabillo <100321200+micaelae@users.noreply.github.com>
1 parent 929f04a commit 05853aa

File tree

3 files changed

+127
-59
lines changed

3 files changed

+127
-59
lines changed

ui/hooks/bridge/useTokensWithFiltering.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,10 @@ export const useTokensWithFiltering = (
197197
(tokenToExclude && tokenChainId
198198
? !(
199199
tokenToExclude.symbol === symbol &&
200-
tokenToExclude.address === address &&
200+
(isSolanaChainId(tokenChainId)
201+
? tokenToExclude.address === address
202+
: tokenToExclude.address?.toLowerCase() ===
203+
address?.toLowerCase()) &&
201204
tokenToExclude.chainId === formatChainIdToCaip(tokenChainId)
202205
)
203206
: true);
@@ -274,7 +277,9 @@ export const useTokensWithFiltering = (
274277

275278
// Yield topTokens from selected chain
276279
for (const token_ of topTokens) {
277-
const matchedToken = tokenList?.[token_.address];
280+
const matchedToken =
281+
tokenList?.[token_.address] ??
282+
tokenList?.[token_.address.toLowerCase()];
278283
const token = buildTokenData(chainId, matchedToken);
279284
if (
280285
token &&

ui/pages/bridge/hooks/useDestinationAccount.ts

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,45 +2,51 @@ import { useSelector } from 'react-redux';
22
import { useEffect, useState } from 'react';
33
import { isSolanaChainId } from '@metamask/bridge-controller';
44
import {
5-
getSelectedInternalAccount,
65
getSelectedEvmInternalAccount,
6+
getSelectedInternalAccount,
77
} from '../../../selectors';
88
import { getToChain } from '../../../ducks/bridge/selectors';
9-
import {
10-
getLastSelectedSolanaAccount,
11-
getMultichainIsEvm,
12-
} from '../../../selectors/multichain';
13-
import { useMultichainSelector } from '../../../hooks/useMultichainSelector';
9+
import { getLastSelectedSolanaAccount } from '../../../selectors/multichain';
1410
import type { DestinationAccount } from '../prepare/types';
1511

16-
export const useDestinationAccount = (isSwap = false) => {
12+
export const useDestinationAccount = (isSwap: boolean) => {
1713
const [selectedDestinationAccount, setSelectedDestinationAccount] =
1814
useState<DestinationAccount | null>(null);
1915

20-
const isEvm = useMultichainSelector(getMultichainIsEvm);
2116
const selectedEvmAccount = useSelector(getSelectedEvmInternalAccount);
2217
const selectedSolanaAccount = useSelector(getLastSelectedSolanaAccount);
23-
const selectedMultichainAccount = useMultichainSelector(
24-
getSelectedInternalAccount,
25-
);
26-
const selectedAccount = isEvm
27-
? selectedEvmAccount
28-
: selectedMultichainAccount;
18+
const currentlySelectedAccount = useSelector(getSelectedInternalAccount);
2919

3020
const toChain = useSelector(getToChain);
3121
const isDestinationSolana = toChain && isSolanaChainId(toChain.chainId);
3222

3323
// Auto-select most recently used account when toChain or account changes
3424
useEffect(() => {
35-
if (isSwap) {
36-
setSelectedDestinationAccount(selectedAccount);
25+
if (!toChain) {
26+
// If no destination chain selected, clear the destination account
27+
setSelectedDestinationAccount(null);
3728
return;
3829
}
3930

40-
setSelectedDestinationAccount(
41-
isDestinationSolana ? selectedSolanaAccount : selectedEvmAccount,
42-
);
43-
}, [isDestinationSolana, selectedSolanaAccount, selectedEvmAccount]);
31+
// Use isSwap parameter to determine behavior
32+
// This preserves legacy behavior when unified UI is disabled
33+
if (isSwap) {
34+
// For swaps, always use the currently selected account
35+
setSelectedDestinationAccount(currentlySelectedAccount);
36+
} else {
37+
// For bridges, use the appropriate account type for the destination chain
38+
setSelectedDestinationAccount(
39+
isDestinationSolana ? selectedSolanaAccount : selectedEvmAccount,
40+
);
41+
}
42+
}, [
43+
isDestinationSolana,
44+
selectedSolanaAccount,
45+
selectedEvmAccount,
46+
toChain,
47+
currentlySelectedAccount,
48+
isSwap,
49+
]);
4450

4551
return { selectedDestinationAccount, setSelectedDestinationAccount };
4652
};

ui/pages/bridge/prepare/prepare-bridge-page.tsx

Lines changed: 94 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,15 @@ import {
4646
getWasTxDeclined,
4747
getFromAmountInCurrency,
4848
getValidationErrors,
49-
isBridgeSolanaEnabled,
5049
getIsToOrFromSolana,
5150
getIsSolanaSwap,
5251
getQuoteRefreshRate,
5352
getHardwareWalletName,
5453
getIsQuoteExpired,
54+
getIsUnifiedUIEnabled,
55+
getIsSwap,
5556
BridgeAppState,
57+
isBridgeSolanaEnabled,
5658
} from '../../../ducks/bridge/selectors';
5759
import {
5860
AvatarFavicon,
@@ -79,7 +81,6 @@ import {
7981
import { useI18nContext } from '../../../hooks/useI18nContext';
8082
import { useTokensWithFiltering } from '../../../hooks/bridge/useTokensWithFiltering';
8183
import {
82-
setActiveNetwork,
8384
setActiveNetworkWithError,
8485
setSelectedAccount,
8586
} from '../../../store/actions';
@@ -143,7 +144,17 @@ const PrepareBridgePage = () => {
143144

144145
const t = useI18nContext();
145146

146-
const isSwap = useIsMultichainSwap();
147+
const fromChain = useSelector(getFromChain);
148+
const isUnifiedUIEnabled = useSelector((state: BridgeAppState) =>
149+
getIsUnifiedUIEnabled(state, fromChain?.chainId),
150+
);
151+
152+
// Check the two types of swaps
153+
const isSwapFromQuote = useSelector(getIsSwap);
154+
const isSwapFromUrl = useIsMultichainSwap();
155+
156+
// Use the appropriate value based on unified UI setting
157+
const isSwap = isUnifiedUIEnabled ? isSwapFromQuote : isSwapFromUrl;
147158

148159
const fromToken = useSelector(getFromToken);
149160
const fromTokens = useSelector(getTokenList) as TokenListMap;
@@ -152,7 +163,6 @@ const PrepareBridgePage = () => {
152163

153164
const fromChains = useSelector(getFromChains);
154165
const toChains = useSelector(getToChains);
155-
const fromChain = useSelector(getFromChain);
156166
const toChain = useSelector(getToChain);
157167

158168
const isFromTokensLoading = useMemo(() => {
@@ -241,7 +251,27 @@ const PrepareBridgePage = () => {
241251
isLoading: isToTokensLoading,
242252
} = useTokensWithFiltering(
243253
toChain?.chainId ?? fromChain?.chainId,
244-
fromToken,
254+
fromChain?.chainId === toChain?.chainId && fromToken && fromChain
255+
? (() => {
256+
// Determine the address format based on chain type
257+
// We need to make evm tokens lowercase for comparison as sometimes they are checksummed
258+
let address = '';
259+
if (isNativeAddress(fromToken.address)) {
260+
address = '';
261+
} else if (isSolanaChainId(fromChain.chainId)) {
262+
address = fromToken.address || '';
263+
} else {
264+
address = fromToken.address?.toLowerCase() || '';
265+
}
266+
267+
return {
268+
...fromToken,
269+
address,
270+
// Ensure chainId is in CAIP format for proper comparison
271+
chainId: formatChainIdToCaip(fromChain.chainId),
272+
};
273+
})()
274+
: null,
245275
selectedDestinationAccount !== null && 'id' in selectedDestinationAccount
246276
? selectedDestinationAccount.id
247277
: undefined,
@@ -533,19 +563,22 @@ const PrepareBridgePage = () => {
533563
}
534564
}, [isSwap, isSolanaSwap, fromChain, toChain, dispatch]);
535565

536-
// Set the default destination token for swaps
566+
// Trace swap/bridge view loaded
537567
useEffect(() => {
538568
endTrace({
539569
name: isSwap ? TraceName.SwapViewLoaded : TraceName.BridgeViewLoaded,
540570
timestamp: Date.now(),
541571
});
572+
}, []);
542573

543-
// Set default destination token for swaps
544-
if (isSwap && fromChain && !toToken) {
574+
// Set the default destination token for swaps (only when unified UI is disabled)
575+
useEffect(() => {
576+
// Only set default token when unified UI is disabled (preserve existing behavior)
577+
if (!isUnifiedUIEnabled && isSwap && fromChain && !toToken) {
545578
dispatch(setToChainId(fromChain.chainId));
546579
dispatch(setToToken(SOLANA_USDC_ASSET));
547580
}
548-
}, [isSwap, dispatch, fromChain, toToken]);
581+
}, [isSwap, dispatch, fromChain, toToken, isUnifiedUIEnabled]);
549582

550583
// Edge-case fix: if user lands with USDC selected for both sides on Solana,
551584
// switch destination to SOL (native asset).
@@ -586,11 +619,25 @@ const PrepareBridgePage = () => {
586619
useState<BridgeToken | null>(null);
587620
const [toastTriggerCounter, setToastTriggerCounter] = useState(0);
588621

622+
const getFromInputHeader = () => {
623+
if (isUnifiedUIEnabled) {
624+
return t('yourNetworks');
625+
}
626+
return isSwap ? t('swapSwapFrom') : t('bridgeFrom');
627+
};
628+
629+
const getToInputHeader = () => {
630+
if (isUnifiedUIEnabled) {
631+
return t('swapSelectToken');
632+
}
633+
return isSwap ? t('swapSwapTo') : t('bridgeTo');
634+
};
635+
589636
return (
590637
<>
591638
<Column className="prepare-bridge-page" gap={8}>
592639
<BridgeInputGroup
593-
header={isSwap ? t('swapSwapFrom') : t('bridgeFrom')}
640+
header={getFromInputHeader()}
594641
token={fromToken}
595642
onAmountChange={(e) => {
596643
dispatch(setFromTokenInputValue(e));
@@ -613,7 +660,7 @@ const PrepareBridgePage = () => {
613660
}}
614661
networkProps={{
615662
network: fromChain,
616-
networks: isSwap ? undefined : fromChains,
663+
networks: isSwap && !isUnifiedUIEnabled ? undefined : fromChains,
617664
onNetworkChange: (networkConfig) => {
618665
networkConfig?.chainId &&
619666
networkConfig.chainId !== fromChain?.chainId &&
@@ -648,7 +695,7 @@ const PrepareBridgePage = () => {
648695
},
649696
header: t('yourNetworks'),
650697
}}
651-
isMultiselectEnabled={!isSwap}
698+
isMultiselectEnabled={isUnifiedUIEnabled || !isSwap}
652699
onMaxButtonClick={(value: string) => {
653700
dispatch(setFromTokenInputValue(value));
654701
}}
@@ -718,12 +765,10 @@ const PrepareBridgePage = () => {
718765
disabled={
719766
isSwitchingTemporarilyDisabled ||
720767
!isValidQuoteRequest(quoteRequest, false) ||
721-
(!isSwap && !isNetworkAdded(toChain))
768+
(toChain && !isNetworkAdded(toChain))
722769
}
723770
onClick={() => {
724-
if (!isSwap && !isNetworkAdded(toChain)) {
725-
return;
726-
}
771+
// Track the flip event
727772
toChain?.chainId &&
728773
fromToken &&
729774
toToken &&
@@ -752,36 +797,46 @@ const PrepareBridgePage = () => {
752797
},
753798
),
754799
);
800+
755801
setRotateSwitchTokens(!rotateSwitchTokens);
802+
756803
flippedRequestProperties &&
757804
trackCrossChainSwapsEvent({
758805
event: MetaMetricsEventName.InputSourceDestinationFlipped,
759806
properties: flippedRequestProperties,
760807
});
761-
if (!isSwap) {
762-
// Only flip networks if bridging
763-
const toChainClientId =
764-
toChain?.defaultRpcEndpointIndex !== undefined &&
765-
toChain?.rpcEndpoints &&
766-
isNetworkAdded(toChain)
767-
? toChain.rpcEndpoints[toChain.defaultRpcEndpointIndex]
768-
.networkClientId
769-
: undefined;
808+
809+
const shouldFlipNetworks = isUnifiedUIEnabled || !isSwap;
810+
if (shouldFlipNetworks) {
811+
// Handle account switching for Solana
770812
if (
771813
toChain?.chainId &&
772814
formatChainIdToCaip(toChain.chainId) ===
773815
MultichainNetworks.SOLANA &&
774816
selectedSolanaAccount
775817
) {
776-
// Switch accounts to switch to solana
777818
dispatch(setSelectedAccount(selectedSolanaAccount.address));
778819
} else {
779820
dispatch(setSelectedAccount(selectedEvmAccount.address));
780821
}
781-
toChainClientId &&
782-
dispatch(setActiveNetwork(toChainClientId));
783-
fromChain?.chainId &&
822+
823+
// Get the network client ID for switching
824+
const toChainClientId =
825+
toChain?.defaultRpcEndpointIndex !== undefined &&
826+
toChain?.rpcEndpoints
827+
? toChain.rpcEndpoints[toChain.defaultRpcEndpointIndex]
828+
: undefined;
829+
const networkClientId =
830+
toChainClientId && 'networkClientId' in toChainClientId
831+
? toChainClientId.networkClientId
832+
: toChain?.chainId;
833+
834+
if (networkClientId) {
835+
dispatch(setActiveNetworkWithError(networkClientId));
836+
}
837+
if (fromChain?.chainId) {
784838
dispatch(setToChainId(fromChain.chainId));
839+
}
785840
}
786841
dispatch(setFromToken(toToken));
787842
dispatch(setToToken(fromToken));
@@ -790,7 +845,7 @@ const PrepareBridgePage = () => {
790845
</Box>
791846

792847
<BridgeInputGroup
793-
header={t('swapSelectToken')}
848+
header={getToInputHeader()}
794849
token={toToken}
795850
onAssetChange={(token) => {
796851
const bridgeToken = {
@@ -805,7 +860,7 @@ const PrepareBridgePage = () => {
805860
dispatch(setToToken(bridgeToken));
806861
}}
807862
networkProps={
808-
isSwap
863+
isSwap && !isUnifiedUIEnabled
809864
? undefined
810865
: {
811866
network: toChain,
@@ -822,15 +877,17 @@ const PrepareBridgePage = () => {
822877
);
823878
dispatch(setToToken(destNativeAsset));
824879
},
825-
header: isSwap ? t('swapSwapTo') : t('bridgeTo'),
826-
shouldDisableNetwork: ({ chainId }) =>
827-
chainId === fromChain?.chainId,
880+
header: getToInputHeader(),
881+
shouldDisableNetwork: isUnifiedUIEnabled
882+
? undefined
883+
: ({ chainId }) => chainId === fromChain?.chainId,
828884
}
829885
}
830886
customTokenListGenerator={
831-
// TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31880
832-
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
833-
toChain || isSwap ? toTokenListGenerator : undefined
887+
toChain &&
888+
(isSwapFromUrl || toChain.chainId !== fromChain?.chainId)
889+
? toTokenListGenerator
890+
: undefined
834891
}
835892
amountInFiat={
836893
// TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31880

0 commit comments

Comments
 (0)