mirror of
https://github.com/kremalicious/metamask-extension.git
synced 2024-12-22 09:23:21 +01:00
Add extension desktop UI (#17748)
* feat: add desktop enable button component This component will be added to the experimental page. Users will then be able to initialize a desktop connection * feat: add desktop pairing page * feat: add desktop deep-linking shared lib * test: add initial entries to render helper Allow specifying initialEntries for MemoryRouter. This change will allow testing pages that use the useParam hook. * feat: add desktop error page Error page for any desktop pairing related issue * feat: add desktop routes to route component * feat: add enable desktop button to experimental tab * feat: add desktop icon when paired in dev mode * feat: disable ledger live control when desktop enabled * feat: register desktop error actions on ui init * fix: add missing code fencing * chore: remove enable desktop rpc middleware Now that we are adding the UI there's no need for this rpc middleware (as it was used to test desktop background code) * fix: display experimental tab for desktop
This commit is contained in:
parent
505f1f5445
commit
bde74756d3
99
app/_locales/en/messages.json
generated
99
app/_locales/en/messages.json
generated
@ -1002,6 +1002,105 @@
|
||||
"description": {
|
||||
"message": "Description"
|
||||
},
|
||||
"desktopConnectionCriticalErrorDescription": {
|
||||
"message": "This error could be intermittent, so try restarting the extension or disable MetaMask Desktop."
|
||||
},
|
||||
"desktopConnectionCriticalErrorTitle": {
|
||||
"message": "MetaMask had trouble starting"
|
||||
},
|
||||
"desktopConnectionLostErrorDescription": {
|
||||
"message": "Please make sure you have the desktop app up and running or disable MetaMask Desktop."
|
||||
},
|
||||
"desktopConnectionLostErrorTitle": {
|
||||
"message": "MetaMask Desktop connection was lost"
|
||||
},
|
||||
"desktopDisableButton": {
|
||||
"message": "Disable Desktop App"
|
||||
},
|
||||
"desktopDisableErrorCTA": {
|
||||
"message": "Disable MetaMask Desktop"
|
||||
},
|
||||
"desktopEnableButton": {
|
||||
"message": "Enable Desktop App"
|
||||
},
|
||||
"desktopEnableButtonDescription": {
|
||||
"message": "Click to run all background processes in the desktop app."
|
||||
},
|
||||
"desktopErrorNavigateSettingsCTA": {
|
||||
"message": "Return to Settings Page"
|
||||
},
|
||||
"desktopErrorRestartMMCTA": {
|
||||
"message": "Restart MetaMask"
|
||||
},
|
||||
"desktopNotFoundErrorCTA": {
|
||||
"message": "Download MetaMask Desktop"
|
||||
},
|
||||
"desktopNotFoundErrorDescription1": {
|
||||
"message": "Please make sure you have the desktop app up and running."
|
||||
},
|
||||
"desktopNotFoundErrorDescription2": {
|
||||
"message": "If you have no desktop app installed, please download it on the MetaMask website."
|
||||
},
|
||||
"desktopNotFoundErrorTitle": {
|
||||
"message": "MetaMask Desktop was not found"
|
||||
},
|
||||
"desktopOpenOrDownloadCTA": {
|
||||
"message": "Open MetaMask Desktop"
|
||||
},
|
||||
"desktopOutdatedErrorCTA": {
|
||||
"message": "Update MetaMask Desktop"
|
||||
},
|
||||
"desktopOutdatedErrorDescription": {
|
||||
"message": "Your MetaMask desktop app needs to be upgraded."
|
||||
},
|
||||
"desktopOutdatedErrorTitle": {
|
||||
"message": "MetaMask Desktop is outdated"
|
||||
},
|
||||
"desktopOutdatedExtensionErrorCTA": {
|
||||
"message": "Update MetaMask Extension"
|
||||
},
|
||||
"desktopOutdatedExtensionErrorDescription": {
|
||||
"message": "Your MetaMask extension needs to be upgraded."
|
||||
},
|
||||
"desktopOutdatedExtensionErrorTitle": {
|
||||
"message": "MetaMask Extension is outdated"
|
||||
},
|
||||
"desktopPageDescription": {
|
||||
"message": "If the pairing is successful, extension will restart and you'll have to re-enter your password."
|
||||
},
|
||||
"desktopPageSubTitle": {
|
||||
"message": "Open your MetaMask Desktop and type this code"
|
||||
},
|
||||
"desktopPageTitle": {
|
||||
"message": "Pair with Desktop"
|
||||
},
|
||||
"desktopPairedWarningDeepLink": {
|
||||
"message": "Go to Settings in MetaMask Desktop"
|
||||
},
|
||||
"desktopPairedWarningDescription": {
|
||||
"message": "If you want to start a new pairing, please remove the current connection."
|
||||
},
|
||||
"desktopPairedWarningTitle": {
|
||||
"message": "MM Desktop is already paired"
|
||||
},
|
||||
"desktopPairingExpireMessage": {
|
||||
"message": "Code expires in $1 seconds"
|
||||
},
|
||||
"desktopRouteNotFoundErrorDescription": {
|
||||
"message": "desktopRouteNotFoundErrorDescription"
|
||||
},
|
||||
"desktopRouteNotFoundErrorTitle": {
|
||||
"message": "desktopRouteNotFoundErrorTitle"
|
||||
},
|
||||
"desktopUnexpectedErrorCTA": {
|
||||
"message": "Return MetaMask Home"
|
||||
},
|
||||
"desktopUnexpectedErrorDescription": {
|
||||
"message": "Check your MetaMask Desktop to restore connection"
|
||||
},
|
||||
"desktopUnexpectedErrorTitle": {
|
||||
"message": "Something went wrong..."
|
||||
},
|
||||
"details": {
|
||||
"message": "Details"
|
||||
},
|
||||
|
1
app/images/logo/desktop.svg
Normal file
1
app/images/logo/desktop.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M64 0C28.7 0 0 28.7 0 64V352c0 35.3 28.7 64 64 64H240l-10.7 32H160c-17.7 0-32 14.3-32 32s14.3 32 32 32H416c17.7 0 32-14.3 32-32s-14.3-32-32-32H346.7L336 416H512c35.3 0 64-28.7 64-64V64c0-35.3-28.7-64-64-64H64zM512 64V352H64V64H512z"/></svg>
|
After Width: | Height: | Size: 312 B |
@ -1,43 +0,0 @@
|
||||
import { MESSAGE_TYPE } from '../../../../../../shared/constants/app';
|
||||
|
||||
/**
|
||||
* A wrapper for `eth_accounts` that returns an empty array when permission is denied.
|
||||
*/
|
||||
|
||||
const requestEthereumAccounts = {
|
||||
methodNames: [MESSAGE_TYPE.ENABLE_DESKTOP],
|
||||
implementation: enableDesktop,
|
||||
hookNames: {
|
||||
testDesktopConnection: true,
|
||||
generateOtp: true,
|
||||
},
|
||||
};
|
||||
export default requestEthereumAccounts;
|
||||
|
||||
/**
|
||||
* @typedef {Record<string, Function>} EthAccountsOptions
|
||||
* @property {Function} getAccounts - Gets the accounts for the requesting
|
||||
* origin.
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('json-rpc-engine').JsonRpcRequest<unknown>} _req - The JSON-RPC request object.
|
||||
* @param {import('json-rpc-engine').JsonRpcResponse<true>} res - The JSON-RPC response object.
|
||||
* @param {Function} _next - The json-rpc-engine 'next' callback.
|
||||
* @param {Function} end - The json-rpc-engine 'end' callback.
|
||||
* @param {EthAccountsOptions} options - The RPC method hooks.
|
||||
*/
|
||||
async function enableDesktop(
|
||||
_req,
|
||||
res,
|
||||
_next,
|
||||
end,
|
||||
{ testDesktopConnection, generateOtp },
|
||||
) {
|
||||
const testResult = await testDesktopConnection();
|
||||
const otp = testResult.isConnected ? await generateOtp() : undefined;
|
||||
|
||||
res.result = { ...testResult, otp };
|
||||
return end();
|
||||
}
|
@ -7,10 +7,6 @@ import sendMetadata from './send-metadata';
|
||||
import switchEthereumChain from './switch-ethereum-chain';
|
||||
import watchAsset from './watch-asset';
|
||||
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
import enableDesktop from './desktop/enable-desktop';
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
|
||||
const handlers = [
|
||||
addEthereumChain,
|
||||
ethAccounts,
|
||||
@ -20,8 +16,5 @@ const handlers = [
|
||||
sendMetadata,
|
||||
switchEthereumChain,
|
||||
watchAsset,
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
enableDesktop,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
];
|
||||
export default handlers;
|
||||
|
@ -20,7 +20,12 @@ import {
|
||||
import { isManifestV3 } from '../../shared/modules/mv3.utils';
|
||||
import { checkForLastErrorAndLog } from '../../shared/modules/browser-runtime.utils';
|
||||
import { SUPPORT_LINK } from '../../shared/lib/ui-utils';
|
||||
import { getErrorHtml } from '../../shared/lib/error-utils';
|
||||
import {
|
||||
getErrorHtml,
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
registerDesktopErrorActions,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
} from '../../shared/lib/error-utils';
|
||||
import ExtensionPlatform from './platforms/extension';
|
||||
import { setupMultiplex } from './lib/stream-utils';
|
||||
import { getEnvironmentType, getPlatform } from './lib/util';
|
||||
@ -253,28 +258,55 @@ async function start() {
|
||||
}
|
||||
|
||||
function initializeUiWithTab(tab) {
|
||||
initializeUi(tab, connectionStream, (err, store) => {
|
||||
if (err) {
|
||||
// if there's an error, store will be = metamaskState
|
||||
displayCriticalError('troubleStarting', err, store);
|
||||
return;
|
||||
}
|
||||
isUIInitialised = true;
|
||||
initializeUi(
|
||||
tab,
|
||||
connectionStream,
|
||||
(
|
||||
err,
|
||||
store,
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
backgroundConnection,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
) => {
|
||||
if (err) {
|
||||
// if there's an error, store will be = metamaskState
|
||||
displayCriticalError(
|
||||
'troubleStarting',
|
||||
err,
|
||||
store,
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
backgroundConnection,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
);
|
||||
return;
|
||||
}
|
||||
isUIInitialised = true;
|
||||
|
||||
const state = store.getState();
|
||||
const { metamask: { completedOnboarding } = {} } = state;
|
||||
const state = store.getState();
|
||||
const { metamask: { completedOnboarding } = {} } = state;
|
||||
|
||||
if (!completedOnboarding && windowType !== ENVIRONMENT_TYPE_FULLSCREEN) {
|
||||
global.platform.openExtensionInBrowser();
|
||||
}
|
||||
});
|
||||
if (
|
||||
!completedOnboarding &&
|
||||
windowType !== ENVIRONMENT_TYPE_FULLSCREEN
|
||||
) {
|
||||
global.platform.openExtensionInBrowser();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Function to update new backgroundConnection in the UI
|
||||
function updateUiStreams() {
|
||||
connectToAccountManager(connectionStream, (err, backgroundConnection) => {
|
||||
if (err) {
|
||||
displayCriticalError('troubleStarting', err);
|
||||
displayCriticalError(
|
||||
'troubleStarting',
|
||||
err,
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
undefined,
|
||||
backgroundConnection,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -310,7 +342,13 @@ async function queryCurrentActiveTab(windowType) {
|
||||
function initializeUi(activeTab, connectionStream, cb) {
|
||||
connectToAccountManager(connectionStream, (err, backgroundConnection) => {
|
||||
if (err) {
|
||||
cb(err, null);
|
||||
cb(
|
||||
err,
|
||||
null,
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
backgroundConnection,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -325,14 +363,32 @@ function initializeUi(activeTab, connectionStream, cb) {
|
||||
});
|
||||
}
|
||||
|
||||
async function displayCriticalError(errorKey, err, metamaskState) {
|
||||
const html = await getErrorHtml(errorKey, SUPPORT_LINK, metamaskState);
|
||||
async function displayCriticalError(
|
||||
errorKey,
|
||||
err,
|
||||
metamaskState,
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
backgroundConnection,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
) {
|
||||
const html = await getErrorHtml(
|
||||
errorKey,
|
||||
SUPPORT_LINK,
|
||||
metamaskState,
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
err,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
);
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
registerDesktopErrorActions(backgroundConnection, browser);
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
|
||||
const button = document.getElementById('critical-error-button');
|
||||
|
||||
button.addEventListener('click', (_) => {
|
||||
button?.addEventListener('click', (_) => {
|
||||
browser.runtime.reload();
|
||||
});
|
||||
|
||||
|
9
shared/constants/desktop.ts
Normal file
9
shared/constants/desktop.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export const EXTENSION_ERROR_PAGE_TYPES = {
|
||||
NOT_FOUND: 'not-found',
|
||||
CONNECTION_LOST: 'connection-lost',
|
||||
DESKTOP_OUTDATED: 'desktop-outdated',
|
||||
EXTENSION_OUTDATED: 'extension-outdated',
|
||||
CRITICAL_ERROR: 'critical-error',
|
||||
ROUTE_NOT_FOUND: 'route-not-found',
|
||||
PAIRING_KEY_NOT_MATCH: 'pairing-key-not-match',
|
||||
};
|
20
shared/lib/deep-linking.js
Normal file
20
shared/lib/deep-linking.js
Normal file
@ -0,0 +1,20 @@
|
||||
export function openCustomProtocol(protocolLink) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// msLaunchUri is windows specific. It will open and app or service
|
||||
// that handles a given protocol
|
||||
if (window?.navigator?.msLaunchUri) {
|
||||
window.navigator.msLaunchUri(protocolLink, resolve, () => {
|
||||
reject(new Error('Failed to open custom protocol link'));
|
||||
});
|
||||
} else {
|
||||
const timeoutId = window.setTimeout(function () {
|
||||
reject(new Error('Timeout opening custom protocol link'));
|
||||
}, 500);
|
||||
window.addEventListener('blur', function () {
|
||||
window.clearTimeout(timeoutId);
|
||||
resolve();
|
||||
});
|
||||
window.location = protocolLink;
|
||||
}
|
||||
});
|
||||
}
|
88
shared/lib/deep-linking.test.js
Normal file
88
shared/lib/deep-linking.test.js
Normal file
@ -0,0 +1,88 @@
|
||||
import { openCustomProtocol } from './deep-linking';
|
||||
|
||||
describe('#openCustomProtocol', () => {
|
||||
describe('with msLaunchUri available', () => {
|
||||
const mockMsLaunchUri = jest.fn();
|
||||
let windowSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
windowSpy = jest
|
||||
.spyOn(global, 'window', 'get')
|
||||
.mockImplementation(() => ({
|
||||
navigator: {
|
||||
msLaunchUri: mockMsLaunchUri,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
windowSpy.mockRestore();
|
||||
mockMsLaunchUri.mockRestore();
|
||||
});
|
||||
|
||||
it('successfully open when protocol found', async () => {
|
||||
mockMsLaunchUri.mockImplementation((_protocol, cb) => cb());
|
||||
|
||||
await openCustomProtocol('TEST PROTOCOL');
|
||||
|
||||
expect(mockMsLaunchUri).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws when protocol not found', async () => {
|
||||
mockMsLaunchUri.mockImplementation((_protocol, _cb, errorCb) =>
|
||||
errorCb(),
|
||||
);
|
||||
|
||||
await expect(openCustomProtocol('TEST PROTOCOL')).rejects.toThrow(
|
||||
'Failed to open custom protocol link',
|
||||
);
|
||||
expect(mockMsLaunchUri).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('without msLaunchUri available', () => {
|
||||
it('successfully open when protocol found', async () => {
|
||||
// eslint-disable-next-line consistent-return
|
||||
const mockAddEventListener = jest.fn().mockImplementation((event, cb) => {
|
||||
if (event === 'blur') {
|
||||
return cb();
|
||||
}
|
||||
});
|
||||
const clearTimeoutMock = jest.fn();
|
||||
|
||||
const windowSpy = jest
|
||||
.spyOn(global, 'window', 'get')
|
||||
.mockImplementation(() => ({
|
||||
addEventListener: mockAddEventListener,
|
||||
setTimeout: jest.fn(),
|
||||
clearTimeout: clearTimeoutMock,
|
||||
}));
|
||||
|
||||
await openCustomProtocol('TEST PROTOCOL');
|
||||
|
||||
expect(mockAddEventListener).toHaveBeenCalledTimes(1);
|
||||
expect(clearTimeoutMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
windowSpy.mockRestore();
|
||||
mockAddEventListener.mockRestore();
|
||||
clearTimeoutMock.mockRestore();
|
||||
});
|
||||
|
||||
it('throws when protocol not found', async () => {
|
||||
jest.useFakeTimers();
|
||||
const setTimeoutSpy = jest.spyOn(window, 'setTimeout');
|
||||
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
|
||||
|
||||
const openCustomProtocolPromise = openCustomProtocol('TEST PROTOCOL');
|
||||
|
||||
jest.advanceTimersByTime(500);
|
||||
|
||||
await expect(openCustomProtocolPromise).rejects.toThrow(
|
||||
'Timeout opening custom protocol link',
|
||||
);
|
||||
|
||||
expect(setTimeoutSpy).toHaveBeenCalledTimes(1);
|
||||
expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,9 +1,17 @@
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
import browser from 'webextension-polyfill';
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
import { memoize } from 'lodash';
|
||||
import getFirstPreferredLangCode from '../../app/scripts/lib/get-first-preferred-lang-code';
|
||||
import {
|
||||
fetchLocale,
|
||||
loadRelativeTimeFormatLocaleData,
|
||||
} from '../../ui/helpers/utils/i18n-helper';
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
import { renderDesktopError } from '../../ui/pages/desktop-error/render-desktop-error';
|
||||
import { EXTENSION_ERROR_PAGE_TYPES } from '../constants/desktop';
|
||||
import { openCustomProtocol } from './deep-linking';
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
import switchDirection from './switch-direction';
|
||||
|
||||
const _setupLocale = async (currentLocale) => {
|
||||
@ -32,7 +40,14 @@ const getLocaleContext = (currentLocaleMessages, enLocaleMessages) => {
|
||||
};
|
||||
};
|
||||
|
||||
export async function getErrorHtml(errorKey, supportLink, metamaskState) {
|
||||
export async function getErrorHtml(
|
||||
errorKey,
|
||||
supportLink,
|
||||
metamaskState,
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
err,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
) {
|
||||
let response, preferredLocale;
|
||||
if (metamaskState?.currentLocale) {
|
||||
preferredLocale = metamaskState.currentLocale;
|
||||
@ -50,6 +65,23 @@ export async function getErrorHtml(errorKey, supportLink, metamaskState) {
|
||||
const { currentLocaleMessages, enLocaleMessages } = response;
|
||||
const t = getLocaleContext(currentLocaleMessages, enLocaleMessages);
|
||||
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
const isDesktopEnabled = metamaskState?.desktopEnabled === true;
|
||||
|
||||
if (isDesktopEnabled) {
|
||||
let errorType = EXTENSION_ERROR_PAGE_TYPES.CRITICAL_ERROR;
|
||||
|
||||
if (err?.message.includes('No response from RPC')) {
|
||||
errorType = EXTENSION_ERROR_PAGE_TYPES.CONNECTION_LOST;
|
||||
}
|
||||
|
||||
return renderDesktopError({
|
||||
type: errorType,
|
||||
t,
|
||||
isHtmlError: true,
|
||||
});
|
||||
}
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
/**
|
||||
* The pattern ${errorKey === 'troubleStarting' ? t('troubleStarting') : ''}
|
||||
* is neccessary because we we need linter to see the string
|
||||
@ -87,3 +119,59 @@ export async function getErrorHtml(errorKey, supportLink, metamaskState) {
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
function disableDesktop(backgroundConnection) {
|
||||
backgroundConnection.disableDesktopError();
|
||||
}
|
||||
|
||||
export function downloadDesktopApp() {
|
||||
global.platform.openTab({ url: 'https://metamask.io/' });
|
||||
}
|
||||
|
||||
export function downloadExtension() {
|
||||
global.platform.openTab({ url: 'https://metamask.io/' });
|
||||
}
|
||||
|
||||
export function restartExtension() {
|
||||
browser.runtime.reload();
|
||||
}
|
||||
|
||||
export function openOrDownloadMMD() {
|
||||
openCustomProtocol('metamask-desktop://pair').catch(() => {
|
||||
window.open('https://metamask.io/download.html', '_blank').focus();
|
||||
});
|
||||
}
|
||||
|
||||
export function registerDesktopErrorActions(backgroundConnection) {
|
||||
const disableDesktopButton = document.getElementById(
|
||||
'desktop-error-button-disable-mmd',
|
||||
);
|
||||
const restartMMButton = document.getElementById(
|
||||
'desktop-error-button-restart-mm',
|
||||
);
|
||||
const downloadMMDButton = document.getElementById(
|
||||
'desktop-error-button-download-mmd',
|
||||
);
|
||||
|
||||
const openOrDownloadMMDButton = document.getElementById(
|
||||
'desktop-error-button-open-or-download-mmd',
|
||||
);
|
||||
|
||||
disableDesktopButton?.addEventListener('click', (_) => {
|
||||
disableDesktop(backgroundConnection);
|
||||
});
|
||||
|
||||
restartMMButton?.addEventListener('click', (_) => {
|
||||
restartExtension();
|
||||
});
|
||||
|
||||
downloadMMDButton?.addEventListener('click', (_) => {
|
||||
downloadDesktopApp();
|
||||
});
|
||||
|
||||
openOrDownloadMMDButton?.addEventListener('click', (_) => {
|
||||
openOrDownloadMMD();
|
||||
});
|
||||
}
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
|
@ -1439,7 +1439,8 @@
|
||||
],
|
||||
"origin": "tmashuang.github.io"
|
||||
}
|
||||
]
|
||||
],
|
||||
"desktopEnabled": false
|
||||
},
|
||||
"send": {
|
||||
"amountMode": "INPUT",
|
||||
|
@ -33,10 +33,10 @@ I18nProvider.defaultProps = {
|
||||
children: undefined,
|
||||
};
|
||||
|
||||
export function renderWithProvider(component, store) {
|
||||
export function renderWithProvider(component, store, initialEntries) {
|
||||
const Wrapper = ({ children }) => {
|
||||
const WithoutStore = () => (
|
||||
<MemoryRouter initialEntries={['/']} initialIndex={0}>
|
||||
<MemoryRouter initialEntries={initialEntries || ['/']} initialIndex={0}>
|
||||
<I18nProvider currentLocale="en" current={en} en={en}>
|
||||
<LegacyI18nProvider>{children}</LegacyI18nProvider>
|
||||
</I18nProvider>
|
||||
|
@ -31,6 +31,9 @@ export default class AppHeader extends PureComponent {
|
||||
showBetaHeader: PropTypes.bool,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
onClick: PropTypes.func,
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
desktopEnabled: PropTypes.bool,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
@ -122,6 +125,9 @@ export default class AppHeader extends PureComponent {
|
||||
///: BEGIN:ONLY_INCLUDE_IN(beta)
|
||||
showBetaHeader,
|
||||
///: END:ONLY_INCLUDE_IN(beta)
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
desktopEnabled,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
@ -143,6 +149,18 @@ export default class AppHeader extends PureComponent {
|
||||
history.push(DEFAULT_ROUTE);
|
||||
}}
|
||||
/>
|
||||
{
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
desktopEnabled && process.env.METAMASK_DEBUG && (
|
||||
<div data-testid="app-header-desktop-dev-logo">
|
||||
<MetaFoxLogo
|
||||
unsetIconHeight
|
||||
src="./images/logo/desktop.svg"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
}
|
||||
<div className="app-header__account-menu-container">
|
||||
{!hideNetworkIndicator && (
|
||||
<div className="app-header__network-component-wrapper">
|
||||
|
@ -16,7 +16,14 @@ import AppHeader from './app-header.component';
|
||||
const mapStateToProps = (state) => {
|
||||
const { appState, metamask } = state;
|
||||
const { networkDropdownOpen } = appState;
|
||||
const { selectedAddress, isUnlocked, isAccountMenuOpen } = metamask;
|
||||
const {
|
||||
selectedAddress,
|
||||
isUnlocked,
|
||||
isAccountMenuOpen,
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
desktopEnabled,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
} = metamask;
|
||||
|
||||
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||
const unreadNotificationsCount = getUnreadNotificationsCount(state);
|
||||
@ -37,6 +44,9 @@ const mapStateToProps = (state) => {
|
||||
///: BEGIN:ONLY_INCLUDE_IN(beta)
|
||||
showBetaHeader,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
desktopEnabled,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -145,4 +145,38 @@ describe('App Header', () => {
|
||||
expect(mockToggleAccountMenu).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('App Header Desktop dev mode Logo', () => {
|
||||
const tempDebug = process.env.METAMASK_DEBUG;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.METAMASK_DEBUG = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.METAMASK_DEBUG = tempDebug;
|
||||
});
|
||||
|
||||
it('displays desktop icon when in dev mode', () => {
|
||||
const desktopEnabledState = {
|
||||
...mockState,
|
||||
metamask: {
|
||||
...mockState.metamask,
|
||||
desktopEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
const desktopEnabledStore = configureMockStore([thunk])(
|
||||
desktopEnabledState,
|
||||
);
|
||||
const { queryByTestId } = renderWithProvider(
|
||||
<AppHeader />,
|
||||
desktopEnabledStore,
|
||||
);
|
||||
|
||||
const desktopDevLogo = queryByTestId('app-header-desktop-dev-logo');
|
||||
|
||||
expect(desktopDevLogo).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -0,0 +1,13 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Desktop Enable Button should match snapshot 1`] = `
|
||||
<div>
|
||||
<button
|
||||
class="button btn--rounded btn-primary btn--large"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Enable Desktop App
|
||||
</button>
|
||||
</div>
|
||||
`;
|
@ -0,0 +1,96 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import browser from 'webextension-polyfill';
|
||||
import { PairingKeyStatus } from '@metamask/desktop/dist/types';
|
||||
import { I18nContext } from '../../../contexts/i18n';
|
||||
import Button from '../../ui/button';
|
||||
import {
|
||||
DESKTOP_ERROR_ROUTE,
|
||||
DESKTOP_PAIRING_ROUTE,
|
||||
} from '../../../helpers/constants/routes';
|
||||
import { EXTENSION_ERROR_PAGE_TYPES } from '../../../../shared/constants/desktop';
|
||||
import { getIsDesktopEnabled } from '../../../selectors';
|
||||
import {
|
||||
hideLoadingIndication,
|
||||
showLoadingIndication,
|
||||
setDesktopEnabled as setDesktopEnabledAction,
|
||||
testDesktopConnection,
|
||||
disableDesktop,
|
||||
} from '../../../store/actions';
|
||||
import { SECOND } from '../../../../shared/constants/time';
|
||||
|
||||
const DESKTOP_ERROR_DESKTOP_OUTDATED_ROUTE = `${DESKTOP_ERROR_ROUTE}/${EXTENSION_ERROR_PAGE_TYPES.DESKTOP_OUTDATED}`;
|
||||
const DESKTOP_ERROR_EXTENSION_OUTDATED_ROUTE = `${DESKTOP_ERROR_ROUTE}/${EXTENSION_ERROR_PAGE_TYPES.EXTENSION_OUTDATED}`;
|
||||
const DESKTOP_ERROR_NOT_FOUND_ROUTE = `${DESKTOP_ERROR_ROUTE}/${EXTENSION_ERROR_PAGE_TYPES.NOT_FOUND}`;
|
||||
const DESKTOP_ERROR_PAIRING_KEY_NOT_MATCH_ROUTE = `${DESKTOP_ERROR_ROUTE}/${EXTENSION_ERROR_PAGE_TYPES.PAIRING_KEY_NOT_MATCH}`;
|
||||
const SKIP_PAIRING_RESTART_DELAY = 2 * SECOND;
|
||||
|
||||
export default function DesktopEnableButton() {
|
||||
const t = useContext(I18nContext);
|
||||
const dispatch = useDispatch();
|
||||
const history = useHistory();
|
||||
const showLoader = () => dispatch(showLoadingIndication());
|
||||
const hideLoader = () => dispatch(hideLoadingIndication());
|
||||
const desktopEnabled = useSelector(getIsDesktopEnabled);
|
||||
const setDesktopEnabled = (val) => dispatch(setDesktopEnabledAction(val));
|
||||
const restart = () => dispatch(browser.runtime.reload());
|
||||
|
||||
const onClick = async () => {
|
||||
if (desktopEnabled) {
|
||||
await disableDesktop();
|
||||
setDesktopEnabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
showLoader();
|
||||
const testResult = await testDesktopConnection();
|
||||
hideLoader();
|
||||
|
||||
if ([PairingKeyStatus.NO_MATCH].includes(testResult.pairingKeyCheck)) {
|
||||
history.push(DESKTOP_ERROR_PAIRING_KEY_NOT_MATCH_ROUTE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!testResult.isConnected) {
|
||||
history.push(DESKTOP_ERROR_NOT_FOUND_ROUTE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!testResult.versionCheck?.isExtensionVersionValid) {
|
||||
history.push(DESKTOP_ERROR_EXTENSION_OUTDATED_ROUTE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!testResult.versionCheck?.isDesktopVersionValid) {
|
||||
history.push(DESKTOP_ERROR_DESKTOP_OUTDATED_ROUTE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.SKIP_OTP_PAIRING_FLOW) {
|
||||
showLoader();
|
||||
setDesktopEnabled(true);
|
||||
|
||||
// Wait for new state to persist before restarting
|
||||
setTimeout(() => {
|
||||
restart();
|
||||
}, SKIP_PAIRING_RESTART_DELAY);
|
||||
return;
|
||||
}
|
||||
|
||||
history.push(DESKTOP_PAIRING_ROUTE);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="primary"
|
||||
large
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
{desktopEnabled ? t('desktopDisableButton') : t('desktopEnableButton')}
|
||||
</Button>
|
||||
);
|
||||
}
|
@ -0,0 +1,232 @@
|
||||
import React from 'react';
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import reactRouterDom from 'react-router-dom';
|
||||
import thunk from 'redux-thunk';
|
||||
import { PairingKeyStatus } from '@metamask/desktop/dist/types';
|
||||
import mockState from '../../../../test/data/mock-state.json';
|
||||
import { renderWithProvider } from '../../../../test/lib/render-helpers';
|
||||
import {
|
||||
DESKTOP_ERROR_ROUTE,
|
||||
DESKTOP_PAIRING_ROUTE,
|
||||
} from '../../../helpers/constants/routes';
|
||||
import actions from '../../../store/actions';
|
||||
import { EXTENSION_ERROR_PAGE_TYPES } from '../../../../shared/constants/desktop';
|
||||
import DesktopEnableButton from './desktop-enable-button.component';
|
||||
|
||||
const mockHideLoadingIndication = jest.fn();
|
||||
const mockShowLoadingIndication = jest.fn();
|
||||
const mockSetDesktopEnabled = jest.fn();
|
||||
|
||||
jest.mock('../../../store/actions', () => {
|
||||
return {
|
||||
hideLoadingIndication: () => mockHideLoadingIndication,
|
||||
showLoadingIndication: () => mockShowLoadingIndication,
|
||||
setDesktopEnabled: () => mockSetDesktopEnabled,
|
||||
testDesktopConnection: jest.fn(),
|
||||
disableDesktop: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedActions = actions;
|
||||
|
||||
describe('Desktop Enable Button', () => {
|
||||
const mockHistoryPush = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(reactRouterDom, 'useHistory')
|
||||
.mockImplementation()
|
||||
.mockReturnValue({ push: mockHistoryPush });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const store = configureMockStore([thunk])(mockState);
|
||||
|
||||
it('should match snapshot', () => {
|
||||
const { container } = renderWithProvider(<DesktopEnableButton />, store);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('Click enable button', () => {
|
||||
it('succefully routes to otp pairing page', async () => {
|
||||
mockedActions.testDesktopConnection.mockResolvedValue({
|
||||
isConnected: true,
|
||||
versionCheck: {
|
||||
extensionVersion: 'dummyVersion',
|
||||
desktopVersion: 'dummyVersion',
|
||||
isExtensionVersionValid: true,
|
||||
isDesktopVersionValid: true,
|
||||
},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
renderWithProvider(<DesktopEnableButton />, store);
|
||||
});
|
||||
|
||||
const [enableDesktopButton] = screen.queryAllByText('Enable Desktop App');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(enableDesktopButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedActions.testDesktopConnection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(mockShowLoadingIndication).toHaveBeenCalledTimes(1);
|
||||
expect(mockHideLoadingIndication).toHaveBeenCalledTimes(1);
|
||||
expect(mockHistoryPush).toHaveBeenCalledTimes(1);
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(`${DESKTOP_PAIRING_ROUTE}`);
|
||||
});
|
||||
|
||||
it('routes to pairing key error page when pairing does not match', async () => {
|
||||
mockedActions.testDesktopConnection.mockResolvedValue({
|
||||
isConnected: false,
|
||||
pairingKeyCheck: PairingKeyStatus.NO_MATCH,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
renderWithProvider(<DesktopEnableButton />, store);
|
||||
});
|
||||
|
||||
const [enableDesktopButton] = screen.queryAllByText('Enable Desktop App');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(enableDesktopButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedActions.testDesktopConnection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(mockShowLoadingIndication).toHaveBeenCalledTimes(1);
|
||||
expect(mockHideLoadingIndication).toHaveBeenCalledTimes(1);
|
||||
expect(mockHistoryPush).toHaveBeenCalledTimes(1);
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(
|
||||
`${DESKTOP_ERROR_ROUTE}/${EXTENSION_ERROR_PAGE_TYPES.PAIRING_KEY_NOT_MATCH}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('routes to pairing key error page when fails to connect', async () => {
|
||||
mockedActions.testDesktopConnection.mockResolvedValue({
|
||||
isConnected: false,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
renderWithProvider(<DesktopEnableButton />, store);
|
||||
});
|
||||
|
||||
const [enableDesktopButton] = screen.queryAllByText('Enable Desktop App');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(enableDesktopButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedActions.testDesktopConnection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(mockShowLoadingIndication).toHaveBeenCalledTimes(1);
|
||||
expect(mockHideLoadingIndication).toHaveBeenCalledTimes(1);
|
||||
expect(mockHistoryPush).toHaveBeenCalledTimes(1);
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(
|
||||
`${DESKTOP_ERROR_ROUTE}/${EXTENSION_ERROR_PAGE_TYPES.NOT_FOUND}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('routes to pairing key error page when extension version is not supported', async () => {
|
||||
mockedActions.testDesktopConnection.mockResolvedValue({
|
||||
isConnected: true,
|
||||
versionCheck: {
|
||||
isExtensionVersionValid: false,
|
||||
},
|
||||
});
|
||||
act(() => {
|
||||
renderWithProvider(<DesktopEnableButton />, store);
|
||||
});
|
||||
|
||||
const [enableDesktopButton] = screen.queryAllByText('Enable Desktop App');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(enableDesktopButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedActions.testDesktopConnection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(mockShowLoadingIndication).toHaveBeenCalledTimes(1);
|
||||
expect(mockHideLoadingIndication).toHaveBeenCalledTimes(1);
|
||||
expect(mockHistoryPush).toHaveBeenCalledTimes(1);
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(
|
||||
`${DESKTOP_ERROR_ROUTE}/${EXTENSION_ERROR_PAGE_TYPES.EXTENSION_OUTDATED}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('routes to pairing key error page when desktop version is not supported', async () => {
|
||||
mockedActions.testDesktopConnection.mockResolvedValue({
|
||||
isConnected: true,
|
||||
versionCheck: {
|
||||
isExtensionVersionValid: true,
|
||||
isDesktopVersionValid: false,
|
||||
},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
renderWithProvider(<DesktopEnableButton />, store);
|
||||
});
|
||||
|
||||
const [enableDesktopButton] = screen.queryAllByText('Enable Desktop App');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(enableDesktopButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedActions.testDesktopConnection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(mockShowLoadingIndication).toHaveBeenCalledTimes(1);
|
||||
expect(mockHideLoadingIndication).toHaveBeenCalledTimes(1);
|
||||
expect(mockHistoryPush).toHaveBeenCalledTimes(1);
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(
|
||||
`${DESKTOP_ERROR_ROUTE}/${EXTENSION_ERROR_PAGE_TYPES.DESKTOP_OUTDATED}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Click disable button', () => {
|
||||
it('succefully dispatches disable desktop action when desktop is enabled', async () => {
|
||||
const desktopEnabledStore = configureMockStore([thunk])({
|
||||
...mockState,
|
||||
metamask: { ...mockState, desktopEnabled: true },
|
||||
});
|
||||
|
||||
act(() => {
|
||||
renderWithProvider(<DesktopEnableButton />, desktopEnabledStore);
|
||||
});
|
||||
|
||||
const [disableDesktopButton] = screen.queryAllByText(
|
||||
'Disable Desktop App',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(disableDesktopButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedActions.disableDesktop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(mockSetDesktopEnabled).toHaveBeenCalledTimes(1);
|
||||
expect(mockShowLoadingIndication).not.toHaveBeenCalled();
|
||||
expect(mockedActions.testDesktopConnection).not.toHaveBeenCalled();
|
||||
expect(mockHideLoadingIndication).not.toHaveBeenCalled();
|
||||
expect(mockHistoryPush).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
1
ui/components/app/desktop-enable-button/index.js
Normal file
1
ui/components/app/desktop-enable-button/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './desktop-enable-button.component';
|
83
ui/components/ui/icon/icon-desktop-pairing.js
Normal file
83
ui/components/ui/icon/icon-desktop-pairing.js
Normal file
@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const IconDesktopPairing = ({
|
||||
size = 64,
|
||||
color = 'currentColor',
|
||||
ariaLabel,
|
||||
className,
|
||||
onClick,
|
||||
}) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
fill={color}
|
||||
className={className}
|
||||
aria-label={ariaLabel}
|
||||
onClick={onClick}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 64 39"
|
||||
>
|
||||
<path
|
||||
d="M2.05588 38.7539L2.05764 38.7541H40.952H40.9539C40.9557 38.7541 40.9573 38.7536 40.9591 38.7536C42.0608 38.7497 43.0116 37.8498 43.0116 36.6965C43.0112 35.5025 43.0127 41.0218 43.0084 25.0192H41.7318V32.2147H1.27784V8.06471H41.7318V13.7349H43.0084C43.0088 12.6803 43.0073 17.5546 43.0116 3.42458C43.0116 2.33886 42.1096 1.3715 40.9592 1.36749C40.9574 1.36749 40.9557 1.36694 40.9539 1.36694H40.952H2.05826L2.05592 1.3671C0.938953 1.36848 0 2.2928 0 3.42458C0.00118187 40.1604 0.00115779 35.5443 0.00151939 36.7115C0.00442983 37.7954 0.916013 38.7525 2.05588 38.7539ZM40.952 2.64354C40.9545 2.64354 40.9568 2.64459 40.9593 2.64463C41.3592 2.6475 41.7318 2.97087 41.7318 3.42396V6.78811H1.27784V3.42396C1.27784 3.4215 1.27776 3.42143 1.27773 3.41905C1.28066 3.03143 1.60496 2.64481 2.05596 2.64389L2.05826 2.64354H40.952ZM41.7318 33.4913V36.6971C41.7318 37.1484 41.3421 37.4739 40.959 37.4765C40.9566 37.4765 40.9545 37.4775 40.952 37.4775H2.05826L2.05588 37.4772C1.67874 37.4762 1.2822 37.1511 1.278 36.7035C1.278 36.7013 1.27784 36.6993 1.27784 36.6971V33.4913H41.7318Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path d="M8.2473 4.07751H3.19141V5.35411H8.2473V4.07751Z" fill="black" />
|
||||
<path d="M23.5665 34.8462H18.5106V36.1228H23.5665V34.8462Z" fill="black" />
|
||||
<path
|
||||
d="M36.5719 5.16847C36.8185 4.92251 36.8186 4.50823 36.5719 4.26214C36.3294 4.0259 35.9018 4.03244 35.6656 4.26214C35.4197 4.52205 35.4195 4.90836 35.6656 5.16847C35.9317 5.42024 36.3318 5.40917 36.5719 5.16847Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M58.7234 1.98595V5.13942H44.305V6.41602H58.7234V29.2623H44.305V30.5389H58.7234V33.544C58.7234 33.9351 58.408 34.2533 58.0203 34.2533H44.305V35.5299H58.0203C59.1117 35.5299 60 34.6392 60 33.544C60 26.776 60 9.50087 60 1.98595V1.97972C60 0.888256 59.1117 0 58.0203 0H42.8152V1.2766H58.0203C58.408 1.2766 58.7234 1.59481 58.7234 1.98595Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path d="M51.0488 2.56812H46.2522V3.84471H51.0488V2.56812Z" fill="black" />
|
||||
<path d="M51.0488 31.7573H46.2522V33.0339H51.0488V31.7573Z" fill="black" />
|
||||
<path
|
||||
d="M53.8826 3.65558C54.1329 3.4194 54.1259 2.98539 53.8826 2.7558C53.6463 2.51955 53.2125 2.51955 52.9762 2.7558C52.7244 3.02196 52.7345 3.41386 52.9762 3.65558C53.236 3.91538 53.6465 3.90578 53.8826 3.65558Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M37.2794 13.3285L34.9387 15.6694L37.2794 18.0101L38.1819 17.1075L37.3822 16.3077H48.899V15.0311H37.382L38.1819 14.2311L37.2794 13.3285Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M46.5582 24.5231L47.4608 25.4257L49.8015 23.0848L47.4608 20.7441L46.5582 21.6467L47.358 22.4465H35.8412V23.7231H47.3581L46.5582 24.5231Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path d="M33.8298 12.7659H8.29785V14.0425H33.8298V12.7659Z" fill="black" />
|
||||
<path d="M33.8298 15.9574H8.29785V17.234H33.8298V15.9574Z" fill="black" />
|
||||
<path d="M33.8298 19.7874H8.29785V21.0639H33.8298V19.7874Z" fill="black" />
|
||||
<path d="M33.8298 22.9788H8.29785V24.2554H33.8298V22.9788Z" fill="black" />
|
||||
<path d="M57.0254 12.6194H51.0127V13.896H57.0254V12.6194Z" fill="black" />
|
||||
<path d="M57.0254 16.0691H51.0127V17.3457H57.0254V16.0691Z" fill="black" />
|
||||
<path d="M57.0254 19.5188H51.0127V20.7954H57.0254V19.5188Z" fill="black" />
|
||||
<path d="M57.0254 22.9688H51.0127V24.2453H57.0254V22.9688Z" fill="black" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
IconDesktopPairing.propTypes = {
|
||||
/**
|
||||
* The size of the Icon follows an 8px grid 2 = 16px, 3 = 24px etc
|
||||
*/
|
||||
size: PropTypes.number,
|
||||
/**
|
||||
* The color of the icon accepts design token css variables
|
||||
*/
|
||||
color: PropTypes.string,
|
||||
/**
|
||||
* An additional className to assign the Icon
|
||||
*/
|
||||
className: PropTypes.string,
|
||||
/**
|
||||
* The onClick handler
|
||||
*/
|
||||
onClick: PropTypes.func,
|
||||
/**
|
||||
* The aria-label of the icon for accessibility purposes
|
||||
*/
|
||||
ariaLabel: PropTypes.string,
|
||||
};
|
||||
|
||||
export default IconDesktopPairing;
|
49
ui/components/ui/icon/icon-times.js
Normal file
49
ui/components/ui/icon/icon-times.js
Normal file
@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const IconTimes = ({
|
||||
size = 24,
|
||||
color = 'currentColor',
|
||||
ariaLabel,
|
||||
className,
|
||||
onClick,
|
||||
}) => (
|
||||
// This SVG copied from `@fortawesome/fontawesome-free@5.13.0/regular/times.svg`.
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
fill={color}
|
||||
className={className}
|
||||
aria-label={ariaLabel}
|
||||
onClick={onClick}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 352 512"
|
||||
>
|
||||
<path d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
IconTimes.propTypes = {
|
||||
/**
|
||||
* The size of the Icon follows an 8px grid 2 = 16px, 3 = 24px etc
|
||||
*/
|
||||
size: PropTypes.number,
|
||||
/**
|
||||
* The color of the icon accepts design token css variables
|
||||
*/
|
||||
color: PropTypes.string,
|
||||
/**
|
||||
* An additional className to assign the Icon
|
||||
*/
|
||||
className: PropTypes.string,
|
||||
/**
|
||||
* The onClick handler
|
||||
*/
|
||||
onClick: PropTypes.func,
|
||||
/**
|
||||
* The aria-label of the icon for accessibility purposes
|
||||
*/
|
||||
ariaLabel: PropTypes.string,
|
||||
};
|
||||
|
||||
export default IconTimes;
|
@ -8,6 +8,9 @@ export default class MetaFoxLogo extends PureComponent {
|
||||
onClick: PropTypes.func,
|
||||
unsetIconHeight: PropTypes.bool,
|
||||
isOnboarding: PropTypes.bool,
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
src: PropTypes.string,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@ -15,9 +18,45 @@ export default class MetaFoxLogo extends PureComponent {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { onClick, unsetIconHeight, isOnboarding } = this.props;
|
||||
const {
|
||||
onClick,
|
||||
unsetIconHeight,
|
||||
isOnboarding,
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
src,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
} = this.props;
|
||||
const iconProps = unsetIconHeight ? {} : { height: 42, width: 42 };
|
||||
|
||||
let renderHorizontalLogo = () => (
|
||||
<MetaFoxHorizontalLogo
|
||||
className={classnames({
|
||||
'app-header__metafox-logo--horizontal': !isOnboarding,
|
||||
'onboarding-app-header__metafox-logo--horizontal': isOnboarding,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
let imageSrc = './images/logo/metamask-fox.svg';
|
||||
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
if (src) {
|
||||
renderHorizontalLogo = () => (
|
||||
<img
|
||||
{...iconProps}
|
||||
src={src}
|
||||
className={classnames({
|
||||
'app-header__metafox-logo--horizontal': !isOnboarding,
|
||||
'onboarding-app-header__metafox-logo--horizontal': isOnboarding,
|
||||
})}
|
||||
alt=""
|
||||
/>
|
||||
);
|
||||
|
||||
imageSrc = src;
|
||||
}
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
@ -28,15 +67,11 @@ export default class MetaFoxLogo extends PureComponent {
|
||||
})}
|
||||
data-testid="app-header-logo"
|
||||
>
|
||||
<MetaFoxHorizontalLogo
|
||||
className={classnames({
|
||||
'app-header__metafox-logo--horizontal': !isOnboarding,
|
||||
'onboarding-app-header__metafox-logo--horizontal': isOnboarding,
|
||||
})}
|
||||
/>
|
||||
{renderHorizontalLogo()}
|
||||
|
||||
<img
|
||||
{...iconProps}
|
||||
src="./images/logo/metamask-fox.svg"
|
||||
src={imageSrc}
|
||||
className={classnames({
|
||||
'app-header__metafox-logo--icon': !isOnboarding,
|
||||
'onboarding-app-header__metafox-logo--icon': isOnboarding,
|
||||
|
@ -166,6 +166,15 @@ export default function reduceMetamask(state = initialState, action) {
|
||||
};
|
||||
}
|
||||
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
case actionConstants.FORCE_DISABLE_DESKTOP: {
|
||||
return {
|
||||
...metamaskState,
|
||||
desktopEnabled: false,
|
||||
};
|
||||
}
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
|
||||
default:
|
||||
return metamaskState;
|
||||
}
|
||||
|
@ -222,6 +222,17 @@ describe('MetaMask Reducers', () => {
|
||||
expect(state.pendingTokens).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('disables desktop', () => {
|
||||
const enabledMetaMaskState = {
|
||||
desktopEnabled: true,
|
||||
};
|
||||
const enabledDesktopMetaMask = reduceMetamask(enabledMetaMaskState, {
|
||||
type: actionConstants.FORCE_DISABLE_DESKTOP,
|
||||
});
|
||||
|
||||
expect(enabledDesktopMetaMask.desktopEnabled).toStrictEqual(false);
|
||||
});
|
||||
|
||||
describe('metamask state selectors', () => {
|
||||
describe('getBlockGasLimit', () => {
|
||||
it('should return the current block gas limit', () => {
|
||||
|
@ -85,6 +85,10 @@ const SIGNATURE_REQUEST_PATH = '/signature-request';
|
||||
const DECRYPT_MESSAGE_REQUEST_PATH = '/decrypt-message-request';
|
||||
const ENCRYPTION_PUBLIC_KEY_REQUEST_PATH = '/encryption-public-key-request';
|
||||
const CONFIRMATION_V_NEXT_ROUTE = '/confirmation';
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
const DESKTOP_ERROR_ROUTE = '/desktop/error';
|
||||
const DESKTOP_PAIRING_ROUTE = '/desktop-pairing';
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
|
||||
// Used to pull a convenient name for analytics tracking events. The key must
|
||||
// be react-router ready path, and can include params such as :id for popup windows
|
||||
@ -242,4 +246,8 @@ export {
|
||||
INITIALIZE_EXPERIMENTAL_AREA,
|
||||
ONBOARDING_EXPERIMENTAL_AREA,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
DESKTOP_ERROR_ROUTE,
|
||||
DESKTOP_PAIRING_ROUTE,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
};
|
||||
|
14
ui/hooks/desktopHooks.js
Normal file
14
ui/hooks/desktopHooks.js
Normal file
@ -0,0 +1,14 @@
|
||||
import { DESKTOP_HOOK_TYPES } from '@metamask/desktop/dist/constants';
|
||||
import { DESKTOP_ERROR_ROUTE } from '../helpers/constants/routes';
|
||||
import { EXTENSION_ERROR_PAGE_TYPES } from '../../shared/constants/desktop';
|
||||
|
||||
const registerOnDesktopDisconnect = (history) => {
|
||||
return (request) => {
|
||||
if (request.type === DESKTOP_HOOK_TYPES.DISCONNECT) {
|
||||
const connectionLostRoute = `${DESKTOP_ERROR_ROUTE}/${EXTENSION_ERROR_PAGE_TYPES.CONNECTION_LOST}`;
|
||||
history.push(connectionLostRoute);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export { registerOnDesktopDisconnect };
|
27
ui/hooks/desktopHooks.test.js
Normal file
27
ui/hooks/desktopHooks.test.js
Normal file
@ -0,0 +1,27 @@
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { DESKTOP_HOOK_TYPES } from '@metamask/desktop/dist/constants';
|
||||
import { EXTENSION_ERROR_PAGE_TYPES } from '../../shared/constants/desktop';
|
||||
import { DESKTOP_ERROR_ROUTE } from '../helpers/constants/routes';
|
||||
import { registerOnDesktopDisconnect } from './desktopHooks';
|
||||
|
||||
describe('desktopHooks', () => {
|
||||
describe('registerOnDesktopDisconnect', () => {
|
||||
it('push desktop connection lost route when request type is disconnect', () => {
|
||||
const mockHistoryPush = jest.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
registerOnDesktopDisconnect({ push: mockHistoryPush }),
|
||||
);
|
||||
|
||||
expect(result).toBeInstanceOf(Object);
|
||||
expect(mockHistoryPush).not.toHaveBeenCalled();
|
||||
|
||||
result.current({ type: DESKTOP_HOOK_TYPES.DISCONNECT });
|
||||
|
||||
expect(mockHistoryPush).toHaveBeenCalledTimes(1);
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(
|
||||
`${DESKTOP_ERROR_ROUTE}/${EXTENSION_ERROR_PAGE_TYPES.CONNECTION_LOST}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
31
ui/index.js
31
ui/index.js
@ -53,15 +53,42 @@ export const updateBackgroundConnection = (backgroundConnection) => {
|
||||
|
||||
export default function launchMetamaskUi(opts, cb) {
|
||||
const { backgroundConnection } = opts;
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
let desktopEnabled = false;
|
||||
|
||||
backgroundConnection.getDesktopEnabled(function (err, result) {
|
||||
if (err) {
|
||||
return;
|
||||
}
|
||||
|
||||
desktopEnabled = result;
|
||||
});
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
|
||||
// check if we are unlocked first
|
||||
backgroundConnection.getState(function (err, metamaskState) {
|
||||
if (err) {
|
||||
cb(err, metamaskState);
|
||||
cb(
|
||||
err,
|
||||
{
|
||||
...metamaskState,
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
desktopEnabled,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
},
|
||||
backgroundConnection,
|
||||
);
|
||||
return;
|
||||
}
|
||||
startApp(metamaskState, backgroundConnection, opts).then((store) => {
|
||||
setupDebuggingHelpers(store);
|
||||
cb(null, store);
|
||||
cb(
|
||||
null,
|
||||
store,
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
backgroundConnection,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
373
ui/pages/desktop-error/__snapshots__/desktop-error.test.js.snap
Normal file
373
ui/pages/desktop-error/__snapshots__/desktop-error.test.js.snap
Normal file
@ -0,0 +1,373 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Desktop Error page should render connection lost page 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="box box--margin-top-6 box--margin-right-6 box--margin-left-6 box--display-flex box--flex-direction-column box--align-items-center box--text-align-center"
|
||||
>
|
||||
<svg
|
||||
fill="var(--color-error-default"
|
||||
height="64"
|
||||
viewBox="0 0 352 512"
|
||||
width="64"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"
|
||||
/>
|
||||
</svg>
|
||||
<h4
|
||||
class="box box--margin-top-6 box--margin-bottom-6 box--flex-direction-row typography typography--h4 typography--weight-bold typography--style-normal typography--color-text-default"
|
||||
>
|
||||
MetaMask Desktop connection was lost
|
||||
</h4>
|
||||
<p
|
||||
class="box box--margin-top-1 box--margin-bottom-1 box--flex-direction-row typography typography--p typography--weight-normal typography--style-normal typography--color-text-default"
|
||||
>
|
||||
Please make sure you have the desktop app up and running or disable MetaMask Desktop.
|
||||
</p>
|
||||
<div
|
||||
class="box box--margin-top-6 box--flex-direction-row"
|
||||
>
|
||||
<button
|
||||
class="button btn--rounded btn-primary"
|
||||
id="desktop-error-button-open-or-download-mmd"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Open MetaMask Desktop
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="box box--margin-top-6 box--flex-direction-row"
|
||||
>
|
||||
<button
|
||||
class="button btn--rounded btn-primary"
|
||||
id="desktop-error-button-disable-mmd"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Disable MetaMask Desktop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Desktop Error page should render critical error page 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="box box--margin-top-6 box--margin-right-6 box--margin-left-6 box--display-flex box--flex-direction-column box--align-items-center box--text-align-center"
|
||||
>
|
||||
<svg
|
||||
fill="var(--color-error-default"
|
||||
height="64"
|
||||
viewBox="0 0 352 512"
|
||||
width="64"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"
|
||||
/>
|
||||
</svg>
|
||||
<h4
|
||||
class="box box--margin-top-6 box--margin-bottom-6 box--flex-direction-row typography typography--h4 typography--weight-bold typography--style-normal typography--color-text-default"
|
||||
>
|
||||
MetaMask had trouble starting
|
||||
</h4>
|
||||
<p
|
||||
class="box box--margin-top-1 box--margin-bottom-1 box--flex-direction-row typography typography--p typography--weight-normal typography--style-normal typography--color-text-default"
|
||||
>
|
||||
This error could be intermittent, so try restarting the extension or disable MetaMask Desktop.
|
||||
</p>
|
||||
<div
|
||||
class="box box--margin-top-6 box--flex-direction-row"
|
||||
>
|
||||
<button
|
||||
class="button btn--rounded btn-primary"
|
||||
id="desktop-error-button-restart-mm"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Restart MetaMask
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="box box--margin-top-6 box--flex-direction-row"
|
||||
>
|
||||
<button
|
||||
class="button btn--rounded btn-primary"
|
||||
id="desktop-error-button-disable-mmd"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Disable MetaMask Desktop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Desktop Error page should render default error page 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="box box--margin-top-6 box--margin-right-6 box--margin-left-6 box--display-flex box--flex-direction-column box--align-items-center box--text-align-center"
|
||||
>
|
||||
<svg
|
||||
fill="var(--color-error-default"
|
||||
height="64"
|
||||
viewBox="0 0 352 512"
|
||||
width="64"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"
|
||||
/>
|
||||
</svg>
|
||||
<h4
|
||||
class="box box--margin-top-6 box--margin-bottom-6 box--flex-direction-row typography typography--h4 typography--weight-bold typography--style-normal typography--color-text-default"
|
||||
>
|
||||
Something went wrong...
|
||||
</h4>
|
||||
<p
|
||||
class="box box--margin-top-1 box--margin-bottom-1 box--flex-direction-row typography typography--p typography--weight-normal typography--style-normal typography--color-text-default"
|
||||
>
|
||||
Check your MetaMask Desktop to restore connection
|
||||
</p>
|
||||
<div
|
||||
class="box box--margin-top-6 box--flex-direction-row"
|
||||
>
|
||||
<button
|
||||
class="button btn--rounded btn-primary"
|
||||
id="desktop-error-button-return-mm-home"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Return MetaMask Home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Desktop Error page should render desktop app outdated page 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="box box--margin-top-6 box--margin-right-6 box--margin-left-6 box--display-flex box--flex-direction-column box--align-items-center box--text-align-center"
|
||||
>
|
||||
<svg
|
||||
fill="var(--color-error-default"
|
||||
height="64"
|
||||
viewBox="0 0 352 512"
|
||||
width="64"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"
|
||||
/>
|
||||
</svg>
|
||||
<h4
|
||||
class="box box--margin-top-6 box--margin-bottom-6 box--flex-direction-row typography typography--h4 typography--weight-bold typography--style-normal typography--color-text-default"
|
||||
>
|
||||
MetaMask Desktop is outdated
|
||||
</h4>
|
||||
<p
|
||||
class="box box--margin-top-1 box--margin-bottom-1 box--flex-direction-row typography typography--p typography--weight-normal typography--style-normal typography--color-text-default"
|
||||
>
|
||||
Your MetaMask desktop app needs to be upgraded.
|
||||
</p>
|
||||
<div
|
||||
class="box box--margin-top-6 box--flex-direction-row"
|
||||
>
|
||||
<button
|
||||
class="button btn--rounded btn-primary"
|
||||
id="desktop-error-button-update-mmd"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Update MetaMask Desktop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Desktop Error page should render extension outdated page 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="box box--margin-top-6 box--margin-right-6 box--margin-left-6 box--display-flex box--flex-direction-column box--align-items-center box--text-align-center"
|
||||
>
|
||||
<svg
|
||||
fill="var(--color-error-default"
|
||||
height="64"
|
||||
viewBox="0 0 352 512"
|
||||
width="64"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"
|
||||
/>
|
||||
</svg>
|
||||
<h4
|
||||
class="box box--margin-top-6 box--margin-bottom-6 box--flex-direction-row typography typography--h4 typography--weight-bold typography--style-normal typography--color-text-default"
|
||||
>
|
||||
MetaMask Extension is outdated
|
||||
</h4>
|
||||
<p
|
||||
class="box box--margin-top-1 box--margin-bottom-1 box--flex-direction-row typography typography--p typography--weight-normal typography--style-normal typography--color-text-default"
|
||||
>
|
||||
Your MetaMask extension needs to be upgraded.
|
||||
</p>
|
||||
<div
|
||||
class="box box--margin-top-6 box--flex-direction-row"
|
||||
>
|
||||
<button
|
||||
class="button btn--rounded btn-primary"
|
||||
id="desktop-error-button-update-extension"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Update MetaMask Extension
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Desktop Error page should render not found page 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="box box--margin-top-6 box--margin-right-6 box--margin-left-6 box--display-flex box--flex-direction-column box--align-items-center box--text-align-center"
|
||||
>
|
||||
<svg
|
||||
fill="var(--color-error-default"
|
||||
height="64"
|
||||
viewBox="0 0 352 512"
|
||||
width="64"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"
|
||||
/>
|
||||
</svg>
|
||||
<h4
|
||||
class="box box--margin-top-6 box--margin-bottom-6 box--flex-direction-row typography typography--h4 typography--weight-bold typography--style-normal typography--color-text-default"
|
||||
>
|
||||
MetaMask Desktop was not found
|
||||
</h4>
|
||||
<p
|
||||
class="box box--margin-top-1 box--margin-bottom-1 box--flex-direction-row typography typography--p typography--weight-normal typography--style-normal typography--color-text-default"
|
||||
>
|
||||
Please make sure you have the desktop app up and running.
|
||||
</p>
|
||||
<p
|
||||
class="box box--margin-top-1 box--margin-bottom-1 box--flex-direction-row typography typography--p typography--weight-normal typography--style-normal typography--color-text-default"
|
||||
>
|
||||
If you have no desktop app installed, please download it on the MetaMask website.
|
||||
</p>
|
||||
<div
|
||||
class="box box--margin-top-6 box--flex-direction-row"
|
||||
>
|
||||
<button
|
||||
class="button btn--rounded btn-primary"
|
||||
id="desktop-error-button-download-mmd"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Download MetaMask Desktop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Desktop Error page should render pairing key not match page 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="box box--margin-top-6 box--margin-right-6 box--margin-left-6 box--display-flex box--flex-direction-column box--align-items-center box--text-align-center"
|
||||
>
|
||||
<svg
|
||||
fill="var(--color-error-default"
|
||||
height="64"
|
||||
viewBox="0 0 352 512"
|
||||
width="64"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"
|
||||
/>
|
||||
</svg>
|
||||
<h4
|
||||
class="box box--margin-top-6 box--margin-bottom-6 box--flex-direction-row typography typography--h4 typography--weight-bold typography--style-normal typography--color-text-default"
|
||||
>
|
||||
MM Desktop is already paired
|
||||
</h4>
|
||||
<p
|
||||
class="box box--margin-top-1 box--margin-bottom-1 box--flex-direction-row typography typography--p typography--weight-normal typography--style-normal typography--color-text-default"
|
||||
>
|
||||
If you want to start a new pairing, please remove the current connection.
|
||||
</p>
|
||||
<a
|
||||
class="button btn-link desktop-pairing-warning__link"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Go to Settings in MetaMask Desktop
|
||||
</a>
|
||||
<div
|
||||
class="box box--margin-top-6 box--flex-direction-row"
|
||||
>
|
||||
<button
|
||||
class="button btn--rounded btn-primary"
|
||||
id="desktop-error-button-navigate-settings"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Return to Settings Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Desktop Error page should render route not found page 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="box box--margin-top-6 box--margin-right-6 box--margin-left-6 box--display-flex box--flex-direction-column box--align-items-center box--text-align-center"
|
||||
>
|
||||
<svg
|
||||
fill="var(--color-error-default"
|
||||
height="64"
|
||||
viewBox="0 0 352 512"
|
||||
width="64"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"
|
||||
/>
|
||||
</svg>
|
||||
<h4
|
||||
class="box box--margin-top-6 box--margin-bottom-6 box--flex-direction-row typography typography--h4 typography--weight-bold typography--style-normal typography--color-text-default"
|
||||
>
|
||||
desktopRouteNotFoundErrorTitle
|
||||
</h4>
|
||||
<p
|
||||
class="box box--margin-top-1 box--margin-bottom-1 box--flex-direction-row typography typography--p typography--weight-normal typography--style-normal typography--color-text-default"
|
||||
>
|
||||
desktopRouteNotFoundErrorDescription
|
||||
</p>
|
||||
<div
|
||||
class="box box--margin-top-6 box--flex-direction-row"
|
||||
>
|
||||
<button
|
||||
class="button btn--rounded btn-primary"
|
||||
id="desktop-error-button-navigate-settings"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Return to Settings Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
29
ui/pages/desktop-error/desktop-error.component.js
Normal file
29
ui/pages/desktop-error/desktop-error.component.js
Normal file
@ -0,0 +1,29 @@
|
||||
import { useParams, useHistory } from 'react-router-dom';
|
||||
import { useI18nContext } from '../../hooks/useI18nContext';
|
||||
import {
|
||||
downloadDesktopApp,
|
||||
downloadExtension,
|
||||
restartExtension,
|
||||
} from '../../../shared/lib/error-utils';
|
||||
import { DEFAULT_ROUTE } from '../../helpers/constants/routes';
|
||||
import { renderDesktopError } from './render-desktop-error';
|
||||
|
||||
export default function DesktopError({ forceDisableDesktop }) {
|
||||
const t = useI18nContext();
|
||||
const { errorType } = useParams();
|
||||
const history = useHistory();
|
||||
|
||||
return renderDesktopError({
|
||||
type: errorType,
|
||||
t,
|
||||
isHtmlError: false,
|
||||
history,
|
||||
disableDesktop: () => {
|
||||
forceDisableDesktop();
|
||||
history.push(DEFAULT_ROUTE);
|
||||
},
|
||||
downloadDesktopApp,
|
||||
downloadExtension,
|
||||
restartExtension,
|
||||
});
|
||||
}
|
20
ui/pages/desktop-error/desktop-error.container.js
Normal file
20
ui/pages/desktop-error/desktop-error.container.js
Normal file
@ -0,0 +1,20 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { compose } from 'redux';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { FORCE_DISABLE_DESKTOP } from '../../store/actionConstants';
|
||||
import DesktopError from './desktop-error.component';
|
||||
|
||||
const mapStateToProps = () => ({});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
forceDisableDesktop: async () => {
|
||||
dispatch({
|
||||
type: FORCE_DISABLE_DESKTOP,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default compose(
|
||||
withRouter,
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
)(DesktopError);
|
127
ui/pages/desktop-error/desktop-error.test.js
Normal file
127
ui/pages/desktop-error/desktop-error.test.js
Normal file
@ -0,0 +1,127 @@
|
||||
import React from 'react';
|
||||
import reactRouterDom, { Route } from 'react-router-dom';
|
||||
import configureStore from '../../store/store';
|
||||
import { renderWithProvider } from '../../../test/jest';
|
||||
import mockState from '../../../test/data/mock-state.json';
|
||||
import { EXTENSION_ERROR_PAGE_TYPES } from '../../../shared/constants/desktop';
|
||||
import DesktopErrorPage from '.';
|
||||
|
||||
describe('Desktop Error page', () => {
|
||||
const mockHistoryPush = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(reactRouterDom, 'useHistory')
|
||||
.mockImplementation()
|
||||
.mockReturnValue({ push: mockHistoryPush });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render not found page', async () => {
|
||||
const store = configureStore(mockState);
|
||||
const { container } = renderWithProvider(
|
||||
<Route path="/:errorType">
|
||||
<DesktopErrorPage />
|
||||
</Route>,
|
||||
store,
|
||||
[`/${EXTENSION_ERROR_PAGE_TYPES.NOT_FOUND}`],
|
||||
);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render connection lost page', async () => {
|
||||
const store = configureStore(mockState);
|
||||
const { container } = renderWithProvider(
|
||||
<Route path="/:errorType">
|
||||
<DesktopErrorPage />
|
||||
</Route>,
|
||||
store,
|
||||
[`/${EXTENSION_ERROR_PAGE_TYPES.CONNECTION_LOST}`],
|
||||
);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render desktop app outdated page', async () => {
|
||||
const store = configureStore(mockState);
|
||||
const { container } = renderWithProvider(
|
||||
<Route path="/:errorType">
|
||||
<DesktopErrorPage />
|
||||
</Route>,
|
||||
store,
|
||||
[`/${EXTENSION_ERROR_PAGE_TYPES.DESKTOP_OUTDATED}`],
|
||||
);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render extension outdated page', async () => {
|
||||
const store = configureStore(mockState);
|
||||
const { container } = renderWithProvider(
|
||||
<Route path="/:errorType">
|
||||
<DesktopErrorPage />
|
||||
</Route>,
|
||||
store,
|
||||
[`/${EXTENSION_ERROR_PAGE_TYPES.EXTENSION_OUTDATED}`],
|
||||
);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render critical error page', async () => {
|
||||
const store = configureStore(mockState);
|
||||
const { container } = renderWithProvider(
|
||||
<Route path="/:errorType">
|
||||
<DesktopErrorPage />
|
||||
</Route>,
|
||||
store,
|
||||
[`/${EXTENSION_ERROR_PAGE_TYPES.CRITICAL_ERROR}`],
|
||||
);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render route not found page', async () => {
|
||||
const store = configureStore(mockState);
|
||||
const { container } = renderWithProvider(
|
||||
<Route path="/:errorType">
|
||||
<DesktopErrorPage />
|
||||
</Route>,
|
||||
store,
|
||||
[`/${EXTENSION_ERROR_PAGE_TYPES.ROUTE_NOT_FOUND}`],
|
||||
);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render pairing key not match page', async () => {
|
||||
const store = configureStore(mockState);
|
||||
const { container } = renderWithProvider(
|
||||
<Route path="/:errorType">
|
||||
<DesktopErrorPage />
|
||||
</Route>,
|
||||
store,
|
||||
[`/${EXTENSION_ERROR_PAGE_TYPES.PAIRING_KEY_NOT_MATCH}`],
|
||||
);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render default error page', async () => {
|
||||
const store = configureStore(mockState);
|
||||
const { container } = renderWithProvider(
|
||||
<Route path="/:errorType">
|
||||
<DesktopErrorPage />
|
||||
</Route>,
|
||||
store,
|
||||
[`/unknown-error-type`],
|
||||
);
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
1
ui/pages/desktop-error/index.js
Normal file
1
ui/pages/desktop-error/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './desktop-error.container';
|
237
ui/pages/desktop-error/render-desktop-error.js
Normal file
237
ui/pages/desktop-error/render-desktop-error.js
Normal file
@ -0,0 +1,237 @@
|
||||
import React from 'react';
|
||||
import ReactDOMServer from 'react-dom/server';
|
||||
|
||||
import IconTimes from '../../components/ui/icon/icon-times';
|
||||
import { EXTENSION_ERROR_PAGE_TYPES } from '../../../shared/constants/desktop';
|
||||
import {
|
||||
TypographyVariant,
|
||||
DISPLAY,
|
||||
FLEX_DIRECTION,
|
||||
AlignItems,
|
||||
TEXT_ALIGN,
|
||||
FONT_WEIGHT,
|
||||
} from '../../helpers/constants/design-system';
|
||||
import { DEFAULT_ROUTE, SETTINGS_ROUTE } from '../../helpers/constants/routes';
|
||||
import Typography from '../../components/ui/typography';
|
||||
import Button from '../../components/ui/button';
|
||||
import Box from '../../components/ui/box';
|
||||
import { openCustomProtocol } from '../../../shared/lib/deep-linking';
|
||||
|
||||
export function renderDesktopError({
|
||||
type,
|
||||
t,
|
||||
isHtmlError,
|
||||
history,
|
||||
disableDesktop,
|
||||
downloadExtension,
|
||||
downloadDesktopApp,
|
||||
restartExtension,
|
||||
openOrDownloadDesktopApp,
|
||||
}) {
|
||||
let content;
|
||||
|
||||
const noop = () => {
|
||||
// do nothing
|
||||
};
|
||||
|
||||
const returnExtensionHome = () => {
|
||||
history?.push(DEFAULT_ROUTE);
|
||||
};
|
||||
|
||||
const navigateSettings = () => {
|
||||
history?.push(SETTINGS_ROUTE);
|
||||
};
|
||||
|
||||
const renderHeader = (text) => {
|
||||
return (
|
||||
<Typography
|
||||
variant={TypographyVariant.H4}
|
||||
fontWeight={FONT_WEIGHT.BOLD}
|
||||
marginTop={6}
|
||||
marginBottom={6}
|
||||
>
|
||||
{text}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
const renderDescription = (text) => {
|
||||
return (
|
||||
<Typography variant={TypographyVariant.Paragraph}>{text}</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCTA = (id, text, onClick) => {
|
||||
return (
|
||||
<Box marginTop={6}>
|
||||
<Button type="primary" onClick={onClick ?? noop} id={id}>
|
||||
{text}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const openSettingsOrDownloadMMD = () => {
|
||||
openCustomProtocol('metamask-desktop://pair').catch(() => {
|
||||
window.open('https://metamask.io/download.html', '_blank').focus();
|
||||
});
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case EXTENSION_ERROR_PAGE_TYPES.NOT_FOUND:
|
||||
content = (
|
||||
<>
|
||||
{renderHeader(t('desktopNotFoundErrorTitle'))}
|
||||
{renderDescription(t('desktopNotFoundErrorDescription1'))}
|
||||
{renderDescription(t('desktopNotFoundErrorDescription2'))}
|
||||
{renderCTA(
|
||||
'desktop-error-button-download-mmd',
|
||||
t('desktopNotFoundErrorCTA'),
|
||||
downloadDesktopApp,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
break;
|
||||
|
||||
case EXTENSION_ERROR_PAGE_TYPES.CONNECTION_LOST:
|
||||
content = (
|
||||
<>
|
||||
{renderHeader(t('desktopConnectionLostErrorTitle'))}
|
||||
{renderDescription(t('desktopConnectionLostErrorDescription'))}
|
||||
{renderCTA(
|
||||
'desktop-error-button-open-or-download-mmd',
|
||||
t('desktopOpenOrDownloadCTA'),
|
||||
openOrDownloadDesktopApp,
|
||||
)}
|
||||
{renderCTA(
|
||||
'desktop-error-button-disable-mmd',
|
||||
t('desktopDisableErrorCTA'),
|
||||
disableDesktop,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
break;
|
||||
|
||||
case EXTENSION_ERROR_PAGE_TYPES.DESKTOP_OUTDATED:
|
||||
content = (
|
||||
<>
|
||||
{renderHeader(t('desktopOutdatedErrorTitle'))}
|
||||
{renderDescription(t('desktopOutdatedErrorDescription'))}
|
||||
{renderCTA(
|
||||
'desktop-error-button-update-mmd',
|
||||
t('desktopOutdatedErrorCTA'),
|
||||
downloadDesktopApp,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
break;
|
||||
|
||||
case EXTENSION_ERROR_PAGE_TYPES.EXTENSION_OUTDATED:
|
||||
content = (
|
||||
<>
|
||||
{renderHeader(t('desktopOutdatedExtensionErrorTitle'))}
|
||||
{renderDescription(t('desktopOutdatedExtensionErrorDescription'))}
|
||||
{renderCTA(
|
||||
'desktop-error-button-update-extension',
|
||||
t('desktopOutdatedExtensionErrorCTA'),
|
||||
downloadExtension,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
break;
|
||||
|
||||
case EXTENSION_ERROR_PAGE_TYPES.CRITICAL_ERROR:
|
||||
content = (
|
||||
<>
|
||||
{renderHeader(t('desktopConnectionCriticalErrorTitle'))}
|
||||
{renderDescription(t('desktopConnectionCriticalErrorDescription'))}
|
||||
{renderCTA(
|
||||
'desktop-error-button-restart-mm',
|
||||
t('desktopErrorRestartMMCTA'),
|
||||
restartExtension,
|
||||
)}
|
||||
{renderCTA(
|
||||
'desktop-error-button-disable-mmd',
|
||||
t('desktopDisableErrorCTA'),
|
||||
disableDesktop,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
break;
|
||||
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
// This route only exists on the Desktop App
|
||||
case EXTENSION_ERROR_PAGE_TYPES.ROUTE_NOT_FOUND:
|
||||
content = (
|
||||
<>
|
||||
{renderHeader(t('desktopRouteNotFoundErrorTitle'))}
|
||||
{renderDescription(t('desktopRouteNotFoundErrorDescription'))}
|
||||
{renderCTA(
|
||||
'desktop-error-button-navigate-settings',
|
||||
t('desktopErrorNavigateSettingsCTA'),
|
||||
navigateSettings,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
break;
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
|
||||
case EXTENSION_ERROR_PAGE_TYPES.PAIRING_KEY_NOT_MATCH:
|
||||
content = (
|
||||
<>
|
||||
{renderHeader(t('desktopPairedWarningTitle'))}
|
||||
{renderDescription(t('desktopPairedWarningDescription'))}
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => {
|
||||
openSettingsOrDownloadMMD();
|
||||
}}
|
||||
className="desktop-pairing-warning__link"
|
||||
>
|
||||
{t('desktopPairedWarningDeepLink')}
|
||||
</Button>
|
||||
{renderCTA(
|
||||
'desktop-error-button-navigate-settings',
|
||||
t('desktopErrorNavigateSettingsCTA'),
|
||||
navigateSettings,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
content = (
|
||||
<>
|
||||
{renderHeader(t('desktopUnexpectedErrorTitle'))}
|
||||
{renderDescription(t('desktopUnexpectedErrorDescription'))}
|
||||
{renderCTA(
|
||||
'desktop-error-button-return-mm-home',
|
||||
t('desktopUnexpectedErrorCTA'),
|
||||
returnExtensionHome,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const errorContent = (
|
||||
<Box
|
||||
display={DISPLAY.FLEX}
|
||||
alignItems={AlignItems.center}
|
||||
textAlign={TEXT_ALIGN.CENTER}
|
||||
flexDirection={FLEX_DIRECTION.COLUMN}
|
||||
marginLeft={6}
|
||||
marginRight={6}
|
||||
marginTop={isHtmlError ? 8 : 6}
|
||||
>
|
||||
<IconTimes size={64} color="var(--color-error-default" />
|
||||
{content}
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (isHtmlError) {
|
||||
return ReactDOMServer.renderToStaticMarkup(errorContent);
|
||||
}
|
||||
|
||||
return errorContent;
|
||||
}
|
@ -0,0 +1,172 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Desktop Pairing page should render otp component 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="page-container__content"
|
||||
>
|
||||
<div
|
||||
class="desktop-pairing"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="box box--margin-top-12 box--margin-right-6 box--margin-left-6 box--display-flex box--flex-direction-column box--align-items-center box--text-align-center"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="64"
|
||||
viewBox="0 0 64 39"
|
||||
width="64"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M2.05588 38.7539L2.05764 38.7541H40.952H40.9539C40.9557 38.7541 40.9573 38.7536 40.9591 38.7536C42.0608 38.7497 43.0116 37.8498 43.0116 36.6965C43.0112 35.5025 43.0127 41.0218 43.0084 25.0192H41.7318V32.2147H1.27784V8.06471H41.7318V13.7349H43.0084C43.0088 12.6803 43.0073 17.5546 43.0116 3.42458C43.0116 2.33886 42.1096 1.3715 40.9592 1.36749C40.9574 1.36749 40.9557 1.36694 40.9539 1.36694H40.952H2.05826L2.05592 1.3671C0.938953 1.36848 0 2.2928 0 3.42458C0.00118187 40.1604 0.00115779 35.5443 0.00151939 36.7115C0.00442983 37.7954 0.916013 38.7525 2.05588 38.7539ZM40.952 2.64354C40.9545 2.64354 40.9568 2.64459 40.9593 2.64463C41.3592 2.6475 41.7318 2.97087 41.7318 3.42396V6.78811H1.27784V3.42396C1.27784 3.4215 1.27776 3.42143 1.27773 3.41905C1.28066 3.03143 1.60496 2.64481 2.05596 2.64389L2.05826 2.64354H40.952ZM41.7318 33.4913V36.6971C41.7318 37.1484 41.3421 37.4739 40.959 37.4765C40.9566 37.4765 40.9545 37.4775 40.952 37.4775H2.05826L2.05588 37.4772C1.67874 37.4762 1.2822 37.1511 1.278 36.7035C1.278 36.7013 1.27784 36.6993 1.27784 36.6971V33.4913H41.7318Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M8.2473 4.07751H3.19141V5.35411H8.2473V4.07751Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M23.5665 34.8462H18.5106V36.1228H23.5665V34.8462Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M36.5719 5.16847C36.8185 4.92251 36.8186 4.50823 36.5719 4.26214C36.3294 4.0259 35.9018 4.03244 35.6656 4.26214C35.4197 4.52205 35.4195 4.90836 35.6656 5.16847C35.9317 5.42024 36.3318 5.40917 36.5719 5.16847Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M58.7234 1.98595V5.13942H44.305V6.41602H58.7234V29.2623H44.305V30.5389H58.7234V33.544C58.7234 33.9351 58.408 34.2533 58.0203 34.2533H44.305V35.5299H58.0203C59.1117 35.5299 60 34.6392 60 33.544C60 26.776 60 9.50087 60 1.98595V1.97972C60 0.888256 59.1117 0 58.0203 0H42.8152V1.2766H58.0203C58.408 1.2766 58.7234 1.59481 58.7234 1.98595Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M51.0488 2.56812H46.2522V3.84471H51.0488V2.56812Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M51.0488 31.7573H46.2522V33.0339H51.0488V31.7573Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M53.8826 3.65558C54.1329 3.4194 54.1259 2.98539 53.8826 2.7558C53.6463 2.51955 53.2125 2.51955 52.9762 2.7558C52.7244 3.02196 52.7345 3.41386 52.9762 3.65558C53.236 3.91538 53.6465 3.90578 53.8826 3.65558Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M37.2794 13.3285L34.9387 15.6694L37.2794 18.0101L38.1819 17.1075L37.3822 16.3077H48.899V15.0311H37.382L38.1819 14.2311L37.2794 13.3285Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M46.5582 24.5231L47.4608 25.4257L49.8015 23.0848L47.4608 20.7441L46.5582 21.6467L47.358 22.4465H35.8412V23.7231H47.3581L46.5582 24.5231Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M33.8298 12.7659H8.29785V14.0425H33.8298V12.7659Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M33.8298 15.9574H8.29785V17.234H33.8298V15.9574Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M33.8298 19.7874H8.29785V21.0639H33.8298V19.7874Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M33.8298 22.9788H8.29785V24.2554H33.8298V22.9788Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M57.0254 12.6194H51.0127V13.896H57.0254V12.6194Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M57.0254 16.0691H51.0127V17.3457H57.0254V16.0691Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M57.0254 19.5188H51.0127V20.7954H57.0254V19.5188Z"
|
||||
fill="black"
|
||||
/>
|
||||
<path
|
||||
d="M57.0254 22.9688H51.0127V24.2453H57.0254V22.9688Z"
|
||||
fill="black"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="desktop-pairing__title"
|
||||
>
|
||||
Pair with Desktop
|
||||
</div>
|
||||
<div
|
||||
class="desktop-pairing__subtitle"
|
||||
>
|
||||
Open your MetaMask Desktop and type this code
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="desktop-pairing"
|
||||
>
|
||||
<div
|
||||
class="desktop-pairing__clickable"
|
||||
data-testid="desktop-pairing-otp-content"
|
||||
>
|
||||
<div
|
||||
class="box box--margin-right-6 box--margin-left-6 box--display-flex box--flex-direction-column box--align-items-center box--text-align-center"
|
||||
>
|
||||
<div
|
||||
class="desktop-pairing__tooltip-wrapper"
|
||||
>
|
||||
<div
|
||||
aria-describedby="tippy-tooltip-1"
|
||||
class=""
|
||||
data-original-title="Copy to clipboard"
|
||||
data-tooltipped=""
|
||||
style="display: inline;"
|
||||
tabindex="0"
|
||||
>
|
||||
<p
|
||||
class="box box--margin-top-1 box--margin-bottom-1 box--flex-direction-row typography desktop-pairing__otp typography--p typography--weight-normal typography--style-normal typography--align-center typography--color-text-default"
|
||||
>
|
||||
123456
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
class="box box--margin-top-1 box--margin-bottom-1 box--flex-direction-row typography desktop-pairing__countdown-timer typography--p typography--weight-normal typography--style-normal typography--align-center typography--color-text-default"
|
||||
>
|
||||
<span>
|
||||
|
||||
Code expires in
|
||||
<span
|
||||
class="desktop-pairing__countdown-timer-seconds"
|
||||
>
|
||||
30
|
||||
</span>
|
||||
seconds
|
||||
|
||||
</span>
|
||||
</p>
|
||||
<div
|
||||
class="desktop-pairing__description"
|
||||
>
|
||||
If the pairing is successful, extension will restart and you'll have to re-enter your password.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="desktop-pairing__buttons"
|
||||
>
|
||||
<button
|
||||
class="button btn--rounded btn-primary"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
190
ui/pages/desktop-pairing/desktop-pairing.component.js
Normal file
190
ui/pages/desktop-pairing/desktop-pairing.component.js
Normal file
@ -0,0 +1,190 @@
|
||||
import React, { useState, useEffect, useRef, useContext } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import Button from '../../components/ui/button';
|
||||
import { SECOND } from '../../../shared/constants/time';
|
||||
import Typography from '../../components/ui/typography';
|
||||
import { I18nContext } from '../../contexts/i18n';
|
||||
import IconDesktopPairing from '../../components/ui/icon/icon-desktop-pairing';
|
||||
import {
|
||||
TEXT_ALIGN,
|
||||
TypographyVariant,
|
||||
DISPLAY,
|
||||
AlignItems,
|
||||
FLEX_DIRECTION,
|
||||
} from '../../helpers/constants/design-system';
|
||||
import Box from '../../components/ui/box/box';
|
||||
import { useCopyToClipboard } from '../../hooks/useCopyToClipboard';
|
||||
import Tooltip from '../../components/ui/tooltip';
|
||||
|
||||
export default function DesktopPairingPage({
|
||||
generateOtp,
|
||||
mostRecentOverviewPage,
|
||||
showLoadingIndication,
|
||||
hideLoadingIndication,
|
||||
}) {
|
||||
const t = useContext(I18nContext);
|
||||
const history = useHistory();
|
||||
const OTP_DURATION = SECOND * 30;
|
||||
const REFRESH_INTERVAL = SECOND;
|
||||
const time = new Date().getTime();
|
||||
|
||||
const [otp, setOtp] = useState();
|
||||
const [lastOtpTime, setLastOtpTime] = useState(time);
|
||||
const [currentTime, setCurrentTime] = useState(time);
|
||||
const [copied, handleCopy] = useCopyToClipboard();
|
||||
const generateIntervalRef = useRef();
|
||||
const refreshIntervalRef = useRef();
|
||||
|
||||
const updateCurrentTime = () => {
|
||||
setCurrentTime(new Date().getTime());
|
||||
};
|
||||
|
||||
const getExpireDuration = () => {
|
||||
const timeSinceOtp = currentTime - lastOtpTime;
|
||||
const expireDurationMilliseconds = OTP_DURATION - timeSinceOtp;
|
||||
|
||||
const expireDurationSeconds = Math.round(
|
||||
expireDurationMilliseconds / SECOND,
|
||||
);
|
||||
|
||||
return expireDurationSeconds;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const generate = async () => {
|
||||
setLastOtpTime(new Date().getTime());
|
||||
const OTP = await generateOtp();
|
||||
setOtp(OTP);
|
||||
};
|
||||
|
||||
generate();
|
||||
updateCurrentTime();
|
||||
|
||||
generateIntervalRef.current = setInterval(() => generate(), OTP_DURATION);
|
||||
refreshIntervalRef.current = setInterval(
|
||||
() => updateCurrentTime(),
|
||||
REFRESH_INTERVAL,
|
||||
);
|
||||
|
||||
return function cleanup() {
|
||||
clearInterval(generateIntervalRef.current);
|
||||
clearInterval(refreshIntervalRef.current);
|
||||
};
|
||||
}, [OTP_DURATION, REFRESH_INTERVAL, generateOtp]);
|
||||
|
||||
const renderIcon = () => {
|
||||
return (
|
||||
<div>
|
||||
<Box
|
||||
display={DISPLAY.FLEX}
|
||||
alignItems={AlignItems.center}
|
||||
textAlign={TEXT_ALIGN.CENTER}
|
||||
flexDirection={FLEX_DIRECTION.COLUMN}
|
||||
marginLeft={6}
|
||||
marginRight={6}
|
||||
marginTop={12}
|
||||
>
|
||||
<IconDesktopPairing size={64} />
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
history?.push(mostRecentOverviewPage);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (!otp) {
|
||||
showLoadingIndication();
|
||||
return null;
|
||||
}
|
||||
|
||||
hideLoadingIndication();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="desktop-pairing__clickable"
|
||||
onClick={() => {
|
||||
handleCopy(otp);
|
||||
}}
|
||||
data-testid="desktop-pairing-otp-content"
|
||||
>
|
||||
<Box
|
||||
display={DISPLAY.FLEX}
|
||||
alignItems={AlignItems.center}
|
||||
textAlign={TEXT_ALIGN.CENTER}
|
||||
flexDirection={FLEX_DIRECTION.COLUMN}
|
||||
marginLeft={6}
|
||||
marginRight={6}
|
||||
>
|
||||
<Tooltip
|
||||
wrapperClassName="desktop-pairing__tooltip-wrapper"
|
||||
position="top"
|
||||
title={copied ? t('copiedExclamation') : t('copyToClipboard')}
|
||||
>
|
||||
<Typography
|
||||
align={TEXT_ALIGN.CENTER}
|
||||
className="desktop-pairing__otp"
|
||||
>
|
||||
{otp}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant={TypographyVariant.paragraph}
|
||||
align={TEXT_ALIGN.CENTER}
|
||||
className="desktop-pairing__countdown-timer"
|
||||
>
|
||||
{t('desktopPairingExpireMessage', [
|
||||
<span className="desktop-pairing__countdown-timer-seconds" key={1}>
|
||||
{getExpireDuration()}
|
||||
</span>,
|
||||
])}
|
||||
</Typography>
|
||||
<div className="desktop-pairing__description">
|
||||
{t('desktopPageDescription')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFooter = () => {
|
||||
return (
|
||||
<div className="desktop-pairing__buttons">
|
||||
<Button
|
||||
type="primary"
|
||||
rounded
|
||||
onClick={() => {
|
||||
goBack();
|
||||
}}
|
||||
>
|
||||
{t('done')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container__content">
|
||||
<div className="desktop-pairing">
|
||||
{renderIcon()}
|
||||
<div className="desktop-pairing__title">{t('desktopPageTitle')}</div>
|
||||
<div className="desktop-pairing__subtitle">
|
||||
{t('desktopPageSubTitle')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="desktop-pairing">{renderContent()}</div>
|
||||
{renderFooter()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DesktopPairingPage.propTypes = {
|
||||
mostRecentOverviewPage: PropTypes.string,
|
||||
showLoadingIndication: PropTypes.func,
|
||||
hideLoadingIndication: PropTypes.func,
|
||||
generateOtp: PropTypes.func,
|
||||
};
|
24
ui/pages/desktop-pairing/desktop-pairing.container.js
Normal file
24
ui/pages/desktop-pairing/desktop-pairing.container.js
Normal file
@ -0,0 +1,24 @@
|
||||
import { connect } from 'react-redux';
|
||||
import {
|
||||
generateOtp,
|
||||
hideLoadingIndication,
|
||||
showLoadingIndication,
|
||||
} from '../../store/actions';
|
||||
import { getMostRecentOverviewPage } from '../../ducks/history/history';
|
||||
import DesktopPairingPage from './desktop-pairing.component';
|
||||
|
||||
const mapDispatchToProps = (dispatch) => {
|
||||
return {
|
||||
generateOtp: () => generateOtp(),
|
||||
showLoadingIndication: () => dispatch(showLoadingIndication()),
|
||||
hideLoadingIndication: () => dispatch(hideLoadingIndication()),
|
||||
};
|
||||
};
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
return {
|
||||
mostRecentOverviewPage: getMostRecentOverviewPage(state),
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(DesktopPairingPage);
|
110
ui/pages/desktop-pairing/desktop-pairing.test.js
Normal file
110
ui/pages/desktop-pairing/desktop-pairing.test.js
Normal file
@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import reactRouterDom from 'react-router-dom';
|
||||
import { waitFor, act, screen } from '@testing-library/react';
|
||||
import actions from '../../store/actions';
|
||||
import configureStore from '../../store/store';
|
||||
import { renderWithProvider } from '../../../test/jest';
|
||||
import mockState from '../../../test/data/mock-state.json';
|
||||
import { SECOND } from '../../../shared/constants/time';
|
||||
import DesktopPairingPage from '.';
|
||||
|
||||
const mockHideLoadingIndication = jest.fn();
|
||||
const mockShowLoadingIndication = jest.fn();
|
||||
|
||||
jest.mock('../../store/actions', () => {
|
||||
return {
|
||||
hideLoadingIndication: () => mockHideLoadingIndication,
|
||||
showLoadingIndication: () => mockShowLoadingIndication,
|
||||
generateOtp: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedActions = actions;
|
||||
|
||||
describe('Desktop Pairing page', () => {
|
||||
const mockHistoryPush = jest.fn();
|
||||
|
||||
function flushPromises() {
|
||||
// Wait for promises running in the non-async timer callback to complete.
|
||||
// From https://github.com/facebook/jest/issues/2157#issuecomment-897935688
|
||||
return new Promise(jest.requireActual('timers').setImmediate);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(reactRouterDom, 'useHistory')
|
||||
.mockImplementation()
|
||||
.mockReturnValue({ push: mockHistoryPush });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render otp component', async () => {
|
||||
const otp = '123456';
|
||||
mockedActions.generateOtp.mockResolvedValue(otp);
|
||||
|
||||
const store = configureStore(mockState);
|
||||
let container = null;
|
||||
|
||||
act(() => {
|
||||
container = renderWithProvider(<DesktopPairingPage />, store).container;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('desktop-pairing-otp-content')).toBeDefined();
|
||||
});
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should re-render otp component after 30s', async () => {
|
||||
jest.useFakeTimers();
|
||||
const otp = '123456';
|
||||
const newOtp = '654321';
|
||||
const neverGeneratedOTP = '111222';
|
||||
mockedActions.generateOtp
|
||||
.mockResolvedValueOnce(otp)
|
||||
.mockResolvedValueOnce(newOtp)
|
||||
.mockResolvedValueOnce(neverGeneratedOTP);
|
||||
|
||||
const store = configureStore(mockState);
|
||||
|
||||
act(() => {
|
||||
renderWithProvider(<DesktopPairingPage />, store);
|
||||
});
|
||||
|
||||
// First render
|
||||
await waitFor(async () => {
|
||||
await flushPromises();
|
||||
expect(screen.getByTestId('desktop-pairing-otp-content')).toBeDefined();
|
||||
expect(screen.getByText(otp)).toBeDefined();
|
||||
expect(mockedActions.generateOtp).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Advance timers 30s to trigger next OTP
|
||||
act(() => jest.advanceTimersByTime(30 * SECOND));
|
||||
|
||||
await waitFor(async () => {
|
||||
await flushPromises();
|
||||
expect(screen.getByTestId('desktop-pairing-otp-content')).toBeDefined();
|
||||
expect(screen.getByText(newOtp)).toBeDefined();
|
||||
expect(mockedActions.generateOtp).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Advance timers 10s to test that OTP is still the same
|
||||
act(() => jest.advanceTimersByTime(10 * SECOND));
|
||||
|
||||
await waitFor(async () => {
|
||||
await flushPromises();
|
||||
expect(screen.getByTestId('desktop-pairing-otp-content')).toBeDefined();
|
||||
expect(screen.getByText(newOtp)).toBeDefined();
|
||||
expect(mockedActions.generateOtp).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
jest.clearAllTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
1
ui/pages/desktop-pairing/index.js
Normal file
1
ui/pages/desktop-pairing/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './desktop-pairing.container';
|
128
ui/pages/desktop-pairing/index.scss
Normal file
128
ui/pages/desktop-pairing/index.scss
Normal file
@ -0,0 +1,128 @@
|
||||
.desktop-pairing {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
align-items: center;
|
||||
padding: 0 30px 0;
|
||||
|
||||
&__countdown-timer {
|
||||
background: #f2f3f4;
|
||||
border-radius: 15.5px;
|
||||
padding: 7px 0 7px 0;
|
||||
margin: 0 32px 0 32px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&__countdown-timer-seconds {
|
||||
color: var(--color-primary-default);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-size: 24px;
|
||||
line-height: 140.62%;
|
||||
text-align: center;
|
||||
color: #24292e;
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
&__subtitle,
|
||||
&__description {
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 140.62%;
|
||||
text-align: center;
|
||||
color: #000;
|
||||
padding-top: 8px;
|
||||
margin: 0 56px;
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
&__otp {
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-size: 48px;
|
||||
letter-spacing: 10px;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
&__buttons {
|
||||
display: flex;
|
||||
width: 70%;
|
||||
justify-content: space-between;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
&__tooltip-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__clickable {
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-pairing-warning {
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
|
||||
&__close-button {
|
||||
z-index: 1050;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
|
||||
&__close::after {
|
||||
content: '\00D7';
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
float: right;
|
||||
margin-top: -8px;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
&__link {
|
||||
color: var(--color-primary-default);
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
line-height: 100%;
|
||||
font-size: 0.875rem;
|
||||
padding: 0 0;
|
||||
}
|
||||
|
||||
&__warning-content {
|
||||
border-left: 5px solid var(--color-warning-default);
|
||||
border-right: 0;
|
||||
border-bottom: 0;
|
||||
border-top: 0;
|
||||
margin: 4px;
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 10px;
|
||||
}
|
||||
}
|
||||
}
|
@ -25,3 +25,4 @@
|
||||
@import 'unlock-page/index';
|
||||
@import 'onboarding-flow/index';
|
||||
@import 'notifications/index';
|
||||
@import 'desktop-pairing/index';
|
||||
|
@ -4,6 +4,9 @@ import React, { Component } from 'react';
|
||||
import { matchPath, Route, Switch } from 'react-router-dom';
|
||||
import IdleTimer from 'react-idle-timer';
|
||||
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
import browserAPI from 'webextension-polyfill';
|
||||
///: END:ONLY_INCLUDE_IN(desktop)
|
||||
import SendTransactionScreen from '../send';
|
||||
import Swaps from '../swaps';
|
||||
import ConfirmTransaction from '../confirm-transaction';
|
||||
@ -36,6 +39,11 @@ import TokenDetailsPage from '../token-details';
|
||||
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||
import Notifications from '../notifications';
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
import { registerOnDesktopDisconnect } from '../../hooks/desktopHooks';
|
||||
import DesktopErrorPage from '../desktop-error';
|
||||
import DesktopPairingPage from '../desktop-pairing';
|
||||
///: END:ONLY_INCLUDE_IN(desktop)
|
||||
|
||||
import {
|
||||
IMPORT_TOKEN_ROUTE,
|
||||
@ -63,8 +71,16 @@ import {
|
||||
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||
NOTIFICATIONS_ROUTE,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
DESKTOP_PAIRING_ROUTE,
|
||||
DESKTOP_ERROR_ROUTE,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
} from '../../helpers/constants/routes';
|
||||
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
import { EXTENSION_ERROR_PAGE_TYPES } from '../../../shared/constants/desktop';
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
|
||||
import {
|
||||
ENVIRONMENT_TYPE_NOTIFICATION,
|
||||
ENVIRONMENT_TYPE_POPUP,
|
||||
@ -128,6 +144,22 @@ export default class Routes extends Component {
|
||||
document.documentElement.setAttribute('data-theme', osTheme);
|
||||
}
|
||||
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
componentDidMount() {
|
||||
const { history } = this.props;
|
||||
browserAPI.runtime.onMessage.addListener(
|
||||
registerOnDesktopDisconnect(history),
|
||||
);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const { history } = this.props;
|
||||
browserAPI.runtime.onMessage.removeListener(
|
||||
registerOnDesktopDisconnect(history),
|
||||
);
|
||||
}
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { theme } = this.props;
|
||||
|
||||
@ -173,6 +205,15 @@ export default class Routes extends Component {
|
||||
<Switch>
|
||||
<Route path={ONBOARDING_ROUTE} component={OnboardingFlow} />
|
||||
<Route path={LOCK_ROUTE} component={Lock} exact />
|
||||
{
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
<Route
|
||||
path={`${DESKTOP_ERROR_ROUTE}/:errorType`}
|
||||
component={DesktopErrorPage}
|
||||
exact
|
||||
/>
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
}
|
||||
<Initialized path={UNLOCK_ROUTE} component={UnlockPage} exact />
|
||||
<RestoreVaultComponent
|
||||
path={RESTORE_VAULT_ROUTE}
|
||||
@ -239,6 +280,15 @@ export default class Routes extends Component {
|
||||
/>
|
||||
<Authenticated path={`${ASSET_ROUTE}/:asset/:id`} component={Asset} />
|
||||
<Authenticated path={`${ASSET_ROUTE}/:asset/`} component={Asset} />
|
||||
{
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
<Authenticated
|
||||
path={DESKTOP_PAIRING_ROUTE}
|
||||
component={DesktopPairingPage}
|
||||
exact
|
||||
/>
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
}
|
||||
<Authenticated path={DEFAULT_ROUTE} component={Home} />
|
||||
</Switch>
|
||||
);
|
||||
@ -295,6 +345,19 @@ export default class Routes extends Component {
|
||||
hideAppHeader() {
|
||||
const { location } = this.props;
|
||||
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
const isDesktopConnectionLostScreen = Boolean(
|
||||
matchPath(location.pathname, {
|
||||
path: `${DESKTOP_ERROR_ROUTE}/${EXTENSION_ERROR_PAGE_TYPES.CONNECTION_LOST}`,
|
||||
exact: true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (isDesktopConnectionLostScreen) {
|
||||
return true;
|
||||
}
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
|
||||
const isInitializing = Boolean(
|
||||
matchPath(location.pathname, {
|
||||
path: ONBOARDING_ROUTE,
|
||||
|
@ -60,6 +60,9 @@ export default class AdvancedTab extends PureComponent {
|
||||
disabledRpcMethodPreferences: PropTypes.shape({
|
||||
eth_sign: PropTypes.bool.isRequired,
|
||||
}),
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
desktopEnabled: PropTypes.bool,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
};
|
||||
|
||||
state = {
|
||||
@ -466,8 +469,17 @@ export default class AdvancedTab extends PureComponent {
|
||||
ledgerTransportType,
|
||||
setLedgerTransportPreference,
|
||||
userHasALedgerAccount,
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
desktopEnabled,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
} = this.props;
|
||||
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
if (desktopEnabled) {
|
||||
return null;
|
||||
}
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
|
||||
const LEDGER_TRANSPORT_NAMES = {
|
||||
LIVE: t('ledgerLive'),
|
||||
WEBHID: t('webhid'),
|
||||
@ -497,7 +509,11 @@ export default class AdvancedTab extends PureComponent {
|
||||
: LEDGER_TRANSPORT_NAMES.U2F;
|
||||
|
||||
return (
|
||||
<div ref={this.settingsRefs[9]} className="settings-page__content-row">
|
||||
<div
|
||||
ref={this.settingsRefs[9]}
|
||||
className="settings-page__content-row"
|
||||
data-testId="ledger-live-control"
|
||||
>
|
||||
<div className="settings-page__content-item">
|
||||
<span>{t('preferredLedgerConnectionType')}</span>
|
||||
<div className="settings-page__content-description">
|
||||
|
@ -54,4 +54,21 @@ describe('AdvancedTab Component', () => {
|
||||
|
||||
expect(mockSetShowTestNetworks).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not render ledger live control with desktop pairing enabled', () => {
|
||||
const mockStoreWithDesktopEnabled = configureMockStore([thunk])({
|
||||
...mockState,
|
||||
metamask: {
|
||||
...mockState.metamask,
|
||||
desktopEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { queryByTestId } = renderWithProvider(
|
||||
<AdvancedTab />,
|
||||
mockStoreWithDesktopEnabled,
|
||||
);
|
||||
|
||||
expect(queryByTestId('ledger-live-control')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
@ -30,6 +30,9 @@ export const mapStateToProps = (state) => {
|
||||
useNonceField,
|
||||
ledgerTransportType,
|
||||
dismissSeedBackUpReminder,
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
desktopEnabled,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
} = metamask;
|
||||
const {
|
||||
showFiatInTestnets,
|
||||
@ -51,6 +54,9 @@ export const mapStateToProps = (state) => {
|
||||
dismissSeedBackUpReminder,
|
||||
userHasALedgerAccount,
|
||||
disabledRpcMethodPreferences,
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
desktopEnabled,
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -0,0 +1,37 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ExperimentalTab with desktop enabled renders ExperimentalTab component without error 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="settings-page__body"
|
||||
>
|
||||
<div
|
||||
class="settings-page__content-row"
|
||||
data-testid="advanced-setting-desktop-pairing"
|
||||
>
|
||||
<div
|
||||
class="settings-page__content-item"
|
||||
>
|
||||
<span>
|
||||
Click to run all background processes in the desktop app.
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="settings-page__content-item"
|
||||
>
|
||||
<div
|
||||
class="settings-page__content-item-col"
|
||||
>
|
||||
<button
|
||||
class="button btn--rounded btn-primary btn--large"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Disable Desktop App
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
@ -13,6 +13,9 @@ import {
|
||||
TextColor,
|
||||
TypographyVariant,
|
||||
} from '../../../helpers/constants/design-system';
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
import DesktopEnableButton from '../../../components/app/desktop-enable-button';
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
|
||||
export default class ExperimentalTab extends PureComponent {
|
||||
static contextTypes = {
|
||||
@ -223,12 +226,40 @@ export default class ExperimentalTab extends PureComponent {
|
||||
);
|
||||
}
|
||||
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
renderDesktopEnableButton() {
|
||||
const { t } = this.context;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={this.settingsRefs[6]}
|
||||
className="settings-page__content-row"
|
||||
data-testid="advanced-setting-desktop-pairing"
|
||||
>
|
||||
<div className="settings-page__content-item">
|
||||
<span>{t('desktopEnableButtonDescription')}</span>
|
||||
</div>
|
||||
<div className="settings-page__content-item">
|
||||
<div className="settings-page__content-item-col">
|
||||
<DesktopEnableButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="settings-page__body">
|
||||
{process.env.TRANSACTION_SECURITY_PROVIDER &&
|
||||
this.renderTransactionSecurityCheckToggle()}
|
||||
{process.env.NFTS_V1 && this.renderOpenSeaEnabledToggle()}
|
||||
{
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
this.renderDesktopEnableButton()
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -4,10 +4,11 @@ import configureStore from '../../../store/store';
|
||||
import mockState from '../../../../test/data/mock-state.json';
|
||||
import ExperimentalTab from './experimental-tab.component';
|
||||
|
||||
const render = () => {
|
||||
const render = (overrideMetaMaskState) => {
|
||||
const store = configureStore({
|
||||
metamask: {
|
||||
...mockState.metamask,
|
||||
...overrideMetaMaskState,
|
||||
},
|
||||
});
|
||||
return renderWithProvider(<ExperimentalTab />, store);
|
||||
@ -19,4 +20,11 @@ describe('ExperimentalTab', () => {
|
||||
render();
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
describe('with desktop enabled', () => {
|
||||
it('renders ExperimentalTab component without error', () => {
|
||||
const { container } = render({ desktopEnabled: true });
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -78,8 +78,16 @@ class SettingsPage extends PureComponent {
|
||||
searchText: '',
|
||||
};
|
||||
|
||||
shouldRenderExperimentalTab =
|
||||
process.env.TRANSACTION_SECURITY_PROVIDER || process.env.NFTS_V1;
|
||||
shouldRenderExperimentalTab() {
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
const desktopAvailable = true;
|
||||
if (desktopAvailable) {
|
||||
return true;
|
||||
}
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
|
||||
return process.env.TRANSACTION_SECURITY_PROVIDER || process.env.NFTS_V1;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.handleConversionDate();
|
||||
@ -305,7 +313,7 @@ class SettingsPage extends PureComponent {
|
||||
},
|
||||
];
|
||||
|
||||
if (this.shouldRenderExperimentalTab) {
|
||||
if (this.shouldRenderExperimentalTab()) {
|
||||
tabs.push({
|
||||
content: t('experimental'),
|
||||
icon: <i className="fa fa-flask" />,
|
||||
@ -365,7 +373,7 @@ class SettingsPage extends PureComponent {
|
||||
render={() => <AddNetwork />}
|
||||
/>
|
||||
<Route exact path={SECURITY_ROUTE} component={SecurityTab} />
|
||||
{this.shouldRenderExperimentalTab ? (
|
||||
{this.shouldRenderExperimentalTab() ? (
|
||||
<Route exact path={EXPERIMENTAL_ROUTE} component={ExperimentalTab} />
|
||||
) : null}
|
||||
<Route exact path={CONTACT_LIST_ROUTE} component={ContactListTab} />
|
||||
|
@ -1410,3 +1410,15 @@ export function getCustomTokenAmount(state) {
|
||||
export function getUseCurrencyRateCheck(state) {
|
||||
return Boolean(state.metamask.useCurrencyRateCheck);
|
||||
}
|
||||
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
/**
|
||||
* To get the `desktopEnabled` value which determines whether we use the desktop app
|
||||
*
|
||||
* @param {*} state
|
||||
* @returns Boolean
|
||||
*/
|
||||
export function getIsDesktopEnabled(state) {
|
||||
return state.metamask.desktopEnabled;
|
||||
}
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
|
@ -294,4 +294,9 @@ describe('Selectors', () => {
|
||||
selectors.getTotalUnapprovedSignatureRequestCount(mockState);
|
||||
expect(totalUnapprovedSignatureRequestCount).toStrictEqual(0);
|
||||
});
|
||||
|
||||
it('#getIsDesktopEnabled', () => {
|
||||
const isDesktopEnabled = selectors.getIsDesktopEnabled(mockState);
|
||||
expect(isDesktopEnabled).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
@ -104,3 +104,7 @@ export const SET_NEW_TOKENS_IMPORTED = 'SET_NEW_TOKENS_IMPORTED';
|
||||
|
||||
// Token allowance
|
||||
export const SET_CUSTOM_TOKEN_AMOUNT = 'SET_CUSTOM_TOKEN_AMOUNT';
|
||||
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
export const FORCE_DISABLE_DESKTOP = 'FORCE_DISABLE_DESKTOP';
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
|
@ -1832,4 +1832,28 @@ describe('Actions', () => {
|
||||
expect(expectedActions[1].value.id).toStrictEqual(msgsList[1].id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Desktop', () => {
|
||||
describe('#setDesktopEnabled', () => {
|
||||
it('calls background setDesktopEnabled method', async () => {
|
||||
const store = mockStore();
|
||||
const setDesktopEnabled = sinon.stub().callsFake((_, cb) => cb());
|
||||
|
||||
background.getApi.returns({
|
||||
setDesktopEnabled,
|
||||
getState: sinon.stub().callsFake((cb) =>
|
||||
cb(null, {
|
||||
desktopEnabled: true,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
_setBackgroundConnection(background.getApi());
|
||||
|
||||
await store.dispatch(actions.setDesktopEnabled(true));
|
||||
|
||||
expect(setDesktopEnabled.calledOnceWith(true)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -4584,3 +4584,27 @@ export function requestAddNetworkApproval(
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
export function setDesktopEnabled(desktopEnabled: boolean) {
|
||||
return async () => {
|
||||
try {
|
||||
await submitRequestToBackground('setDesktopEnabled', [desktopEnabled]);
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateOtp() {
|
||||
return await submitRequestToBackground('generateOtp');
|
||||
}
|
||||
|
||||
export async function testDesktopConnection() {
|
||||
return await submitRequestToBackground('testDesktopConnection');
|
||||
}
|
||||
|
||||
export async function disableDesktop() {
|
||||
return await submitRequestToBackground('disableDesktop');
|
||||
}
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
|
Loading…
Reference in New Issue
Block a user