}
+ */
+async function saveFileUsingFilePicker(filename, data, contentType) {
+ const blob = new window.Blob([data], { contentType });
+ const fileExtension = ExtensionForContentType[contentType];
+
+ const handle = await window.showSaveFilePicker({
+ suggestedName: filename,
+ types: [
+ {
+ description: filename,
+ accept: {
+ [contentType]: [fileExtension],
+ },
+ },
+ ],
+ });
+
+ const writable = await handle.createWritable();
+ await writable.write(blob);
+ await writable.close();
+}
+
+/**
+ * Saves a file using a data URI.
+ * This is a fallback for browsers that do not support the File System Access API.
+ * This method is less preferred because it requires the entire file to be encoded in a data URI.
+ *
+ * @param {string} filename - The name of the file to export.
+ * @param {string} data - The data to export.
+ * @param {ExportableContentType} contentType - The content type of the file to export.
+ */
+function saveFileUsingDataUri(filename, data, contentType) {
const b64 = Buffer.from(data, 'utf8').toString('base64');
- // eslint-disable-next-line no-param-reassign
- filename = filename || getRandomFileName();
- const elem = window.document.createElement('a');
- elem.href = `data:${type};Base64,${b64}`;
+ const elem = document.createElement('a');
+ elem.href = `data:${contentType};Base64,${b64}`;
elem.download = filename;
document.body.appendChild(elem);
elem.click();
diff --git a/ui/helpers/utils/export-utils.test.js b/ui/helpers/utils/export-utils.test.js
new file mode 100644
index 000000000..a5ac3c2aa
--- /dev/null
+++ b/ui/helpers/utils/export-utils.test.js
@@ -0,0 +1,68 @@
+import { exportAsFile, ExportableContentType } from './export-utils';
+
+describe('exportAsFile', () => {
+ let windowSpy;
+
+ beforeEach(() => {
+ windowSpy = jest.spyOn(window, 'window', 'get');
+ });
+
+ afterEach(() => {
+ windowSpy.mockRestore();
+ });
+
+ describe('when showSaveFilePicker is supported', () => {
+ it('uses .json file extension when content type is JSON', async () => {
+ const showSaveFilePicker = mockShowSaveFilePicker();
+ const filename = 'test.json';
+ const data = '{file: "content"}';
+ windowSpy.mockImplementation(() => ({
+ showSaveFilePicker,
+ Blob: global.Blob,
+ }));
+
+ await exportAsFile(filename, data, ExportableContentType.JSON);
+
+ expect(showSaveFilePicker).toHaveBeenCalledWith({
+ suggestedName: filename,
+ types: [
+ {
+ description: filename,
+ accept: { 'application/json': ['.json'] },
+ },
+ ],
+ });
+ });
+
+ it('uses .txt file extension when content type is TXT', async () => {
+ const showSaveFilePicker = mockShowSaveFilePicker();
+ const filename = 'test.txt';
+ const data = 'file content';
+
+ windowSpy.mockImplementation(() => ({
+ showSaveFilePicker,
+ Blob: global.Blob,
+ }));
+
+ await exportAsFile(filename, data, ExportableContentType.TXT);
+
+ expect(showSaveFilePicker).toHaveBeenCalledWith({
+ suggestedName: filename,
+ types: [
+ {
+ description: filename,
+ accept: { 'text/plain': ['.txt'] },
+ },
+ ],
+ });
+ });
+ });
+});
+
+function mockShowSaveFilePicker() {
+ return jest.fn().mockResolvedValueOnce({
+ createWritable: jest
+ .fn()
+ .mockResolvedValueOnce({ write: jest.fn(), close: jest.fn() }),
+ });
+}
diff --git a/ui/pages/settings/advanced-tab/advanced-tab.component.js b/ui/pages/settings/advanced-tab/advanced-tab.component.js
index cb3a701ee..a92451cf7 100644
--- a/ui/pages/settings/advanced-tab/advanced-tab.component.js
+++ b/ui/pages/settings/advanced-tab/advanced-tab.component.js
@@ -23,7 +23,10 @@ import {
MetaMetricsEventName,
} from '../../../../shared/constants/metametrics';
import { DEFAULT_AUTO_LOCK_TIME_LIMIT } from '../../../../shared/constants/preferences';
-import { exportAsFile } from '../../../helpers/utils/export-utils';
+import {
+ exportAsFile,
+ ExportableContentType,
+} from '../../../helpers/utils/export-utils';
import ActionableMessage from '../../../components/ui/actionable-message';
import ZENDESK_URLS from '../../../helpers/constants/zendesk-url';
import { BannerAlert } from '../../../components/component-library';
@@ -150,7 +153,7 @@ export default class AdvancedTab extends PureComponent {
backupUserData = async () => {
const { fileName, data } = await this.props.backupUserData();
- exportAsFile(fileName, data);
+ exportAsFile(fileName, data, ExportableContentType.JSON);
this.context.trackEvent({
event: 'User Data Exported',
@@ -185,7 +188,11 @@ export default class AdvancedTab extends PureComponent {
if (err) {
displayWarning(t('stateLogError'));
} else {
- exportAsFile(`${t('stateLogFileName')}.json`, result);
+ exportAsFile(
+ `${t('stateLogFileName')}.json`,
+ result,
+ ExportableContentType.JSON,
+ );
}
});
}}
diff --git a/ui/pages/swaps/build-quote/build-quote.js b/ui/pages/swaps/build-quote/build-quote.js
index 7c2836703..9ed74343c 100644
--- a/ui/pages/swaps/build-quote/build-quote.js
+++ b/ui/pages/swaps/build-quote/build-quote.js
@@ -48,6 +48,7 @@ import {
getIsFeatureFlagLoaded,
getCurrentSmartTransactionsError,
getSmartTransactionFees,
+ getLatestAddedTokenTo,
} from '../../../ducks/swaps/swaps';
import {
getSwapsDefaultToken,
@@ -84,6 +85,7 @@ import {
import {
resetSwapsPostFetchState,
+ ignoreTokens,
setBackgroundSwapRouteState,
clearSwapsQuotes,
stopPollingForQuotes,
@@ -144,6 +146,7 @@ export default function BuildQuote({
const tokenList = useSelector(getTokenList, isEqual);
const quotes = useSelector(getQuotes, isEqual);
const areQuotesPresent = Object.keys(quotes).length > 0;
+ const latestAddedTokenTo = useSelector(getLatestAddedTokenTo, isEqual);
const tokenConversionRates = useSelector(getTokenExchangeRates, isEqual);
const conversionRate = useSelector(getConversionRate);
@@ -347,12 +350,21 @@ export default function BuildQuote({
? getURLHostName(blockExplorerTokenLink)
: t('etherscan');
+ const { address: toAddress } = toToken || {};
const onToSelect = useCallback(
(token) => {
+ if (latestAddedTokenTo && token.address !== toAddress) {
+ dispatch(
+ ignoreTokens({
+ tokensToIgnore: toAddress,
+ dontShowLoadingIndicator: true,
+ }),
+ );
+ }
dispatch(setSwapToToken(token));
setVerificationClicked(false);
},
- [dispatch],
+ [dispatch, latestAddedTokenTo, toAddress],
);
const hideDropdownItemIf = useCallback(
diff --git a/ui/pages/swaps/build-quote/build-quote.test.js b/ui/pages/swaps/build-quote/build-quote.test.js
index e09ec1149..ea8c2cc70 100644
--- a/ui/pages/swaps/build-quote/build-quote.test.js
+++ b/ui/pages/swaps/build-quote/build-quote.test.js
@@ -29,6 +29,7 @@ const createProps = (customProps = {}) => {
setBackgroundConnection({
resetPostFetchState: jest.fn(),
+ ignoreTokens: jest.fn(),
setBackgroundSwapRouteState: jest.fn(),
clearSwapsQuotes: jest.fn(),
stopPollingForQuotes: jest.fn(),
diff --git a/ui/pages/swaps/index.js b/ui/pages/swaps/index.js
index 351561f0b..1a37271b4 100644
--- a/ui/pages/swaps/index.js
+++ b/ui/pages/swaps/index.js
@@ -50,6 +50,7 @@ import {
navigateBackToBuildQuote,
getSwapRedesignEnabled,
setTransactionSettingsOpened,
+ getLatestAddedTokenTo,
} from '../../ducks/swaps/swaps';
import {
checkNetworkAndAccountSupports1559,
@@ -79,6 +80,7 @@ import {
import {
resetBackgroundSwapsState,
setSwapsTokens,
+ ignoreTokens,
setBackgroundSwapRouteState,
setSwapsErrorKey,
} from '../../store/actions';
@@ -134,6 +136,7 @@ export default function Swap() {
const routeState = useSelector(getBackgroundSwapRouteState);
const selectedAccount = useSelector(getSelectedAccount, shallowEqual);
const quotes = useSelector(getQuotes, isEqual);
+ const latestAddedTokenTo = useSelector(getLatestAddedTokenTo, isEqual);
const txList = useSelector(currentNetworkTxListSelector, shallowEqual);
const tradeTxId = useSelector(getTradeTxId);
const approveTxId = useSelector(getApproveTxId);
@@ -209,6 +212,32 @@ export default function Swap() {
swapsErrorKey = SWAP_FAILED_ERROR;
}
+ const clearTemporaryTokenRef = useRef();
+ useEffect(() => {
+ clearTemporaryTokenRef.current = () => {
+ if (latestAddedTokenTo && (!isAwaitingSwapRoute || conversionError)) {
+ dispatch(
+ ignoreTokens({
+ tokensToIgnore: latestAddedTokenTo,
+ dontShowLoadingIndicator: true,
+ }),
+ );
+ }
+ };
+ }, [
+ conversionError,
+ dispatch,
+ latestAddedTokenTo,
+ destinationTokenInfo,
+ fetchParams,
+ isAwaitingSwapRoute,
+ ]);
+ useEffect(() => {
+ return () => {
+ clearTemporaryTokenRef.current();
+ };
+ }, []);
+
// eslint-disable-next-line
useEffect(() => {
if (!isSwapsChain) {
@@ -283,6 +312,7 @@ export default function Swap() {
const beforeUnloadEventAddedRef = useRef();
useEffect(() => {
const fn = () => {
+ clearTemporaryTokenRef.current();
if (isLoadingQuotesRoute) {
dispatch(prepareToLeaveSwaps());
}
@@ -349,6 +379,7 @@ export default function Swap() {
}
const redirectToDefaultRoute = async () => {
+ clearTemporaryTokenRef.current();
dispatch(clearSwapsState());
await dispatch(resetBackgroundSwapsState());
history.push(DEFAULT_ROUTE);
@@ -400,6 +431,7 @@ export default function Swap() {
{
+ clearTemporaryTokenRef.current();
dispatch(clearSwapsState());
await dispatch(resetBackgroundSwapsState());
history.push(DEFAULT_ROUTE);
diff --git a/ui/pages/swaps/prepare-swap-page/prepare-swap-page.js b/ui/pages/swaps/prepare-swap-page/prepare-swap-page.js
index 654d2b766..f7c7f9fc6 100644
--- a/ui/pages/swaps/prepare-swap-page/prepare-swap-page.js
+++ b/ui/pages/swaps/prepare-swap-page/prepare-swap-page.js
@@ -54,6 +54,7 @@ import {
getAggregatorMetadata,
getTransactionSettingsOpened,
setTransactionSettingsOpened,
+ getLatestAddedTokenTo,
} from '../../../ducks/swaps/swaps';
import {
getSwapsDefaultToken,
@@ -92,6 +93,7 @@ import {
} from '../../../../shared/constants/swaps';
import {
resetSwapsPostFetchState,
+ ignoreTokens,
clearSwapsQuotes,
stopPollingForQuotes,
setSmartTransactionsOptInStatus,
@@ -182,6 +184,7 @@ export default function PrepareSwapPage({
const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider, shallowEqual);
const tokenList = useSelector(getTokenList, isEqual);
const quotes = useSelector(getQuotes, isEqual);
+ const latestAddedTokenTo = useSelector(getLatestAddedTokenTo, isEqual);
const numberOfQuotes = Object.keys(quotes).length;
const areQuotesPresent = numberOfQuotes > 0;
const swapsErrorKey = useSelector(getSwapsErrorKey);
@@ -449,12 +452,21 @@ export default function PrepareSwapPage({
? getURLHostName(blockExplorerTokenLink)
: t('etherscan');
+ const { address: toAddress } = toToken || {};
const onToSelect = useCallback(
(token) => {
+ if (latestAddedTokenTo && token.address !== toAddress) {
+ dispatch(
+ ignoreTokens({
+ tokensToIgnore: toAddress,
+ dontShowLoadingIndicator: true,
+ }),
+ );
+ }
dispatch(setSwapToToken(token));
setVerificationClicked(false);
},
- [dispatch],
+ [dispatch, latestAddedTokenTo, toAddress],
);
const tokensWithBalancesFromToken = tokensWithBalances.find((token) =>
diff --git a/ui/pages/swaps/prepare-swap-page/prepare-swap-page.test.js b/ui/pages/swaps/prepare-swap-page/prepare-swap-page.test.js
index 61255621f..9f0a499f7 100644
--- a/ui/pages/swaps/prepare-swap-page/prepare-swap-page.test.js
+++ b/ui/pages/swaps/prepare-swap-page/prepare-swap-page.test.js
@@ -27,6 +27,7 @@ const createProps = (customProps = {}) => {
setBackgroundConnection({
resetPostFetchState: jest.fn(),
+ ignoreTokens: jest.fn(),
setBackgroundSwapRouteState: jest.fn(),
clearSwapsQuotes: jest.fn(),
stopPollingForQuotes: jest.fn(),
diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js
index 4b8deb662..079c1f493 100644
--- a/ui/selectors/selectors.js
+++ b/ui/selectors/selectors.js
@@ -1008,6 +1008,7 @@ function getAllowedAnnouncementIds(state) {
19: false,
20: currentKeyringIsLedger && isFirefox,
21: isSwapsChain,
+ 22: true,
};
}
diff --git a/yarn.lock b/yarn.lock
index b4ca89b19..6ab9f254b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3960,13 +3960,13 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/base-controller@npm:^3.0.0, @metamask/base-controller@npm:^3.1.0":
- version: 3.1.0
- resolution: "@metamask/base-controller@npm:3.1.0"
+"@metamask/base-controller@npm:^3.0.0, @metamask/base-controller@npm:^3.1.0, @metamask/base-controller@npm:^3.2.0":
+ version: 3.2.0
+ resolution: "@metamask/base-controller@npm:3.2.0"
dependencies:
- "@metamask/utils": ^5.0.2
+ "@metamask/utils": ^6.2.0
immer: ^9.0.6
- checksum: fc1597a099e6d28bd089df936ca349d6c38c2e1b0f0737385cba30c34a5239241519eb172d77c70f8db2604f4dc5724f6893affe42bdd104cef98f9cfd6f1db8
+ checksum: 3be6f2594309c013e07f83c4bb8271e1e99f02b6ff829c18b5e7218fbab4e6a9e03bcb49056704ce47f84ae2f38b1bc1c10284ec538aad56ed7b554ef2d3e189
languageName: node
linkType: hard
@@ -4027,20 +4027,19 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/controller-utils@npm:^4.0.0, @metamask/controller-utils@npm:^4.0.1, @metamask/controller-utils@npm:^4.1.0, @metamask/controller-utils@npm:^4.2.0":
- version: 4.2.0
- resolution: "@metamask/controller-utils@npm:4.2.0"
+"@metamask/controller-utils@npm:^4.0.0, @metamask/controller-utils@npm:^4.0.1, @metamask/controller-utils@npm:^4.1.0, @metamask/controller-utils@npm:^4.2.0, @metamask/controller-utils@npm:^4.3.0":
+ version: 4.3.1
+ resolution: "@metamask/controller-utils@npm:4.3.1"
dependencies:
- "@metamask/utils": ^5.0.2
+ "@metamask/eth-query": ^3.0.1
+ "@metamask/utils": ^6.2.0
"@spruceid/siwe-parser": 1.1.3
- babel-runtime: ^6.26.0
eth-ens-namehash: ^2.0.8
- eth-query: ^2.1.2
eth-rpc-errors: ^4.0.2
ethereumjs-util: ^7.0.10
ethjs-unit: ^0.1.6
fast-deep-equal: ^3.1.3
- checksum: e71779577c37038e6e605a43ef6b9c1af82e0b3887a72c01f48ae1e4e2005116fc9d09c8b690139478c04dd2929e227642c5fd80cfbc81814d667c415c714228
+ checksum: 5bb471df560a12fba1b7fa147fe0332e06b527637c04facff1774b1279dd388b4cf1d74340469adb13551c08cc156f204d90e36599ad69b54716b11e5842b348
languageName: node
linkType: hard
@@ -4206,6 +4205,16 @@ __metadata:
languageName: node
linkType: hard
+"@metamask/eth-query@npm:^3.0.1":
+ version: 3.0.1
+ resolution: "@metamask/eth-query@npm:3.0.1"
+ dependencies:
+ json-rpc-random-id: ^1.0.0
+ xtend: ^4.0.1
+ checksum: b9a323dff67328eace7d54fc8b0bc4dd763bf15760870656cbd5aad5380d1ee4489fb5c59506290d5f77cf55e74e530ee97b52702a329f1090ec03a6158434b7
+ languageName: node
+ linkType: hard
+
"@metamask/eth-sig-util@npm:5.0.2":
version: 5.0.2
resolution: "@metamask/eth-sig-util@npm:5.0.2"
@@ -4531,16 +4540,16 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/phishing-controller@npm:^3.0.0":
- version: 3.0.0
- resolution: "@metamask/phishing-controller@npm:3.0.0"
+"@metamask/phishing-controller@npm:^6.0.0":
+ version: 6.0.0
+ resolution: "@metamask/phishing-controller@npm:6.0.0"
dependencies:
- "@metamask/base-controller": ^2.0.0
- "@metamask/controller-utils": ^3.0.0
+ "@metamask/base-controller": ^3.2.0
+ "@metamask/controller-utils": ^4.3.0
"@types/punycode": ^2.1.0
eth-phishing-detect: ^1.2.0
punycode: ^2.1.1
- checksum: b0b9a86cba1928f0fd22a2aed196d75dc19a5e56547efe1b533d7ae06eaaf9432a6ee5004a8fd477f52310b50c2f3635a1e70ac83e3670f4cc6a1f488a674d73
+ checksum: 13a85865cef1515f6d0ee1cd02da37e5e6b98c493676e3a80195294725b717aa17651a0c24d2e841f790bbd22ae16911cc16bab7846da8266f4ee03007a17f4e
languageName: node
linkType: hard
@@ -5144,16 +5153,17 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/utils@npm:^6.0.0, @metamask/utils@npm:^6.0.1, @metamask/utils@npm:^6.1.0":
- version: 6.1.0
- resolution: "@metamask/utils@npm:6.1.0"
+"@metamask/utils@npm:^6.0.0, @metamask/utils@npm:^6.0.1, @metamask/utils@npm:^6.1.0, @metamask/utils@npm:^6.2.0":
+ version: 6.2.0
+ resolution: "@metamask/utils@npm:6.2.0"
dependencies:
"@ethereumjs/tx": ^4.1.2
+ "@noble/hashes": ^1.3.1
"@types/debug": ^4.1.7
debug: ^4.3.4
semver: ^7.3.8
superstruct: ^1.0.3
- checksum: d4eac3ce3c08674b8e9ef838d1661a5025690c6f266c26ebdb8e8d0da11fce786e54c326b5d9c6d33b262f37e7057e31d6545a3715613bd0a5bfa10e7755643a
+ checksum: 0bc675358ecc09b3bc04da613d73666295d7afa51ff6b8554801585966900b24b8545bd93b8b2e9a17db867ebe421fe884baf3558ec4ca3199fa65504f677c1b
languageName: node
linkType: hard
@@ -5735,18 +5745,18 @@ __metadata:
languageName: node
linkType: hard
-"@sentry/cli@npm:^1.58.0":
- version: 1.58.0
- resolution: "@sentry/cli@npm:1.58.0"
+"@sentry/cli@npm:^2.19.4":
+ version: 2.19.4
+ resolution: "@sentry/cli@npm:2.19.4"
dependencies:
https-proxy-agent: ^5.0.0
- mkdirp: ^0.5.5
- node-fetch: ^2.6.0
+ node-fetch: ^2.6.7
progress: ^2.0.3
proxy-from-env: ^1.1.0
+ which: ^2.0.2
bin:
sentry-cli: bin/sentry-cli
- checksum: fc781bbffcf5cd970bb023168421ad89bca4184c2ddfbfddde92f4f5333c8b9075e9e16a8a4b192ecc3b197ac97062715e7b350c306ccc538fc01b955b06c3bb
+ checksum: 1f2442857a5eec2bc6f872a633d88fc2f11ed7f434db36627a034d904390f4cbbb4dccc33c571a8815e423cd36b863c72621298d49a1541b28370c7f7308f0dc
languageName: node
linkType: hard
@@ -24649,7 +24659,7 @@ __metadata:
"@metamask/approval-controller": ^3.4.0
"@metamask/assets-controllers": ^9.2.0
"@metamask/auto-changelog": ^2.1.0
- "@metamask/base-controller": ^3.1.0
+ "@metamask/base-controller": ^3.2.0
"@metamask/browser-passworder": ^4.1.0
"@metamask/contract-metadata": ^2.3.1
"@metamask/controller-utils": ^4.2.0
@@ -24678,7 +24688,7 @@ __metadata:
"@metamask/notification-controller": ^3.0.0
"@metamask/obs-store": ^8.1.0
"@metamask/permission-controller": ^4.0.0
- "@metamask/phishing-controller": ^3.0.0
+ "@metamask/phishing-controller": ^6.0.0
"@metamask/phishing-warning": ^2.1.0
"@metamask/post-message-stream": ^6.0.0
"@metamask/ppom-validator": ^0.0.1
@@ -24705,7 +24715,7 @@ __metadata:
"@reduxjs/toolkit": ^1.6.2
"@segment/loosely-validate-event": ^2.0.0
"@sentry/browser": ^7.53.0
- "@sentry/cli": ^1.58.0
+ "@sentry/cli": ^2.19.4
"@sentry/integrations": ^7.53.0
"@sentry/types": ^7.53.0
"@sentry/utils": ^7.53.0
@@ -26294,8 +26304,8 @@ __metadata:
linkType: hard
"node-fetch@npm:^2, node-fetch@npm:^2.6.0, node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.11, node-fetch@npm:^2.6.7, node-fetch@npm:~2.6.1":
- version: 2.6.11
- resolution: "node-fetch@npm:2.6.11"
+ version: 2.6.12
+ resolution: "node-fetch@npm:2.6.12"
dependencies:
whatwg-url: ^5.0.0
peerDependencies:
@@ -26303,7 +26313,7 @@ __metadata:
peerDependenciesMeta:
encoding:
optional: true
- checksum: 249d0666a9497553384d46b5ab296ba223521ac88fed4d8a17d6ee6c2efb0fc890f3e8091cafe7f9fba8151a5b8d925db2671543b3409a56c3cd522b468b47b3
+ checksum: 3bc1655203d47ee8e313c0d96664b9673a3d4dd8002740318e9d27d14ef306693a4b2ef8d6525775056fd912a19e23f3ac0d7111ad8925877b7567b29a625592
languageName: node
linkType: hard