diff options
author | Bruno Barbieri <bruno.barbieri@consensys.net> | 2018-08-11 01:40:06 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-08-11 01:40:06 +0800 |
commit | e2be22a4b722df608cb764042cc8ade6664414d8 (patch) | |
tree | 6a0c48ea06401ea8e06c14630edc7c0157f6e25c | |
parent | be1d5a7dd959f061b52f475bf8500b943ade786c (diff) | |
parent | d0ccc59e459ecb41742b6e55a1875dfa2a2f9f87 (diff) | |
download | tangerine-wallet-browser-e2be22a4b722df608cb764042cc8ade6664414d8.tar tangerine-wallet-browser-e2be22a4b722df608cb764042cc8ade6664414d8.tar.gz tangerine-wallet-browser-e2be22a4b722df608cb764042cc8ade6664414d8.tar.bz2 tangerine-wallet-browser-e2be22a4b722df608cb764042cc8ade6664414d8.tar.lz tangerine-wallet-browser-e2be22a4b722df608cb764042cc8ade6664414d8.tar.xz tangerine-wallet-browser-e2be22a4b722df608cb764042cc8ade6664414d8.tar.zst tangerine-wallet-browser-e2be22a4b722df608cb764042cc8ade6664414d8.zip |
Merge pull request #4897 from MetaMask/qr-code-scan
QR Code Scanner
27 files changed, 662 insertions, 22 deletions
diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 1b0183c92..62ec4ce37 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -2,6 +2,9 @@ "accept": { "message": "Accept" }, + "accessingYourCamera": { + "message": "Accesing your camera..." + }, "account": { "message": "Account" }, @@ -656,6 +659,12 @@ "notStarted": { "message": "Not Started" }, + "noWebcamFoundTitle": { + "message": "Webcam not found" + }, + "noWebcamFound": { + "message": "Your computer's webcam was not found. Please try again." + }, "oldUI": { "message": "Old UI" }, @@ -940,6 +949,12 @@ "info": { "message": "Info" }, + "scanInstructions": { + "message": "Place the QR code in front of your camera" + }, + "scanQrCode": { + "message": "Scan QR Code" + }, "shapeshiftBuy": { "message": "Buy with Shapeshift" }, @@ -1059,6 +1074,9 @@ "message": "We had trouble loading your token balances. You can view them ", "description": "Followed by a link (here) to view token balances" }, + "tryAgain": { + "message": "Try again" + }, "twelveWords": { "message": "These 12 words are the only way to restore your MetaMask accounts.\nSave them somewhere safe and secret." }, @@ -1089,6 +1107,15 @@ "unknownNetworkId": { "message": "Unknown network ID" }, + "unknownQrCode": { + "message": "Error: We couldn't identify that QR code" + }, + "unknownCameraErrorTitle": { + "message": "Ooops! Something went wrong...." + }, + "unknownCameraError": { + "message": "There was an error while trying to access you camera. Please try again..." + }, "unlock": { "message": "Unlock" }, @@ -1135,6 +1162,9 @@ "whatsThis": { "message": "What's this?" }, + "youNeedToAllowCameraAccess": { + "message": "You need to allow camera access to use this feature." + }, "yourSigRequested": { "message": "Your signature is being requested" }, diff --git a/app/images/webcam.svg b/app/images/webcam.svg new file mode 100644 index 000000000..4b9b58148 --- /dev/null +++ b/app/images/webcam.svg @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="53px" height="53px" viewBox="0 0 53 53" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch --> + <title>webcam</title> + <desc>Created with Sketch.</desc> + <defs></defs> + <g id="QR-Code-Scan" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> + <g id="Group-4-Copy" transform="translate(-482.000000, -218.000000)"> + <g id="webcam" transform="translate(482.000000, 218.000000)"> + <circle id="Oval" fill="#D5ECFA" cx="26.5" cy="26.5" r="26.5"></circle> + <g id="Group" transform="translate(14.000000, 19.000000)" fill="#259DE5"> + <rect id="Rectangle" x="0" y="0" width="18" height="16"></rect> + <polygon id="Triangle" points="19 6.57142857 26 3 26 13 19 9.42857143"></polygon> + </g> + </g> + </g> + </g> +</svg>
\ No newline at end of file diff --git a/app/scripts/contentscript.js b/app/scripts/contentscript.js index b7496f318..e0a2b0061 100644 --- a/app/scripts/contentscript.js +++ b/app/scripts/contentscript.js @@ -198,6 +198,6 @@ function blacklistedDomainCheck () { */ function redirectToPhishingWarning () { console.log('MetaMask - routing to Phishing Warning component') - let extensionURL = extension.runtime.getURL('phishing.html') + const extensionURL = extension.runtime.getURL('phishing.html') window.location.href = extensionURL } diff --git a/app/scripts/lib/enums.js b/app/scripts/lib/enums.js index 0a3afca47..c6d57a1bc 100644 --- a/app/scripts/lib/enums.js +++ b/app/scripts/lib/enums.js @@ -2,8 +2,19 @@ const ENVIRONMENT_TYPE_POPUP = 'popup' const ENVIRONMENT_TYPE_NOTIFICATION = 'notification' const ENVIRONMENT_TYPE_FULLSCREEN = 'fullscreen' +const PLATFORM_BRAVE = 'Brave' +const PLATFORM_CHROME = 'Chrome' +const PLATFORM_EDGE = 'Edge' +const PLATFORM_FIREFOX = 'Firefox' +const PLATFORM_OPERA = 'Opera' + module.exports = { ENVIRONMENT_TYPE_POPUP, ENVIRONMENT_TYPE_NOTIFICATION, ENVIRONMENT_TYPE_FULLSCREEN, + PLATFORM_BRAVE, + PLATFORM_CHROME, + PLATFORM_EDGE, + PLATFORM_FIREFOX, + PLATFORM_OPERA, } diff --git a/app/scripts/lib/util.js b/app/scripts/lib/util.js index 51e9036cc..d7423f2ad 100644 --- a/app/scripts/lib/util.js +++ b/app/scripts/lib/util.js @@ -5,6 +5,11 @@ const { ENVIRONMENT_TYPE_POPUP, ENVIRONMENT_TYPE_NOTIFICATION, ENVIRONMENT_TYPE_FULLSCREEN, + PLATFORM_FIREFOX, + PLATFORM_OPERA, + PLATFORM_CHROME, + PLATFORM_EDGE, + PLATFORM_BRAVE, } = require('./enums') /** @@ -38,6 +43,29 @@ const getEnvironmentType = (url = window.location.href) => { } /** + * Returns the platform (browser) where the extension is running. + * + * @returns {string} the platform ENUM + * + */ +const getPlatform = _ => { + const ua = navigator.userAgent + if (ua.search('Firefox') !== -1) { + return PLATFORM_FIREFOX + } else { + if (window && window.chrome && window.chrome.ipcRenderer) { + return PLATFORM_BRAVE + } else if (ua.search('Edge') !== -1) { + return PLATFORM_EDGE + } else if (ua.search('OPR') !== -1) { + return PLATFORM_OPERA + } else { + return PLATFORM_CHROME + } + } +} + +/** * Checks whether a given balance of ETH, represented as a hex string, is sufficient to pay a value plus a gas fee * * @param {object} txParams Contains data about a transaction @@ -100,6 +128,7 @@ function BnMultiplyByFraction (targetBN, numerator, denominator) { } module.exports = { + getPlatform, getStack, getEnvironmentType, sufficientBalance, diff --git a/app/scripts/platforms/extension.js b/app/scripts/platforms/extension.js index 0803164e8..71b162dd0 100644 --- a/app/scripts/platforms/extension.js +++ b/app/scripts/platforms/extension.js @@ -24,8 +24,13 @@ class ExtensionPlatform { return extension.runtime.getManifest().version } - openExtensionInBrowser (route = null) { + openExtensionInBrowser (route = null, queryString = null) { let extensionURL = extension.runtime.getURL('home.html') + + if (queryString) { + extensionURL += `?${queryString}` + } + if (route) { extensionURL += `#${route}` } diff --git a/old-ui/app/components/transaction-list-item.js b/old-ui/app/components/transaction-list-item.js index 015ef6ba6..6ecf7d193 100644 --- a/old-ui/app/components/transaction-list-item.js +++ b/old-ui/app/components/transaction-list-item.js @@ -40,7 +40,7 @@ TransactionListItem.prototype.showRetryButton = function () { const currentNonce = txParams.nonce const currentNonceTxs = transactions.filter(tx => tx.txParams.nonce === currentNonce) const currentNonceSubmittedTxs = currentNonceTxs.filter(tx => tx.status === 'submitted') - const currentSubmittedTxs = transactions.filter(tx => tx.status === 'submitted') + const currentSubmittedTxs = transactions.filter(tx => tx.status === 'submitted') const lastSubmittedTxWithCurrentNonce = currentNonceSubmittedTxs[0] const currentTxIsLatestWithNonce = lastSubmittedTxWithCurrentNonce && lastSubmittedTxWithCurrentNonce.id === transaction.id diff --git a/package-lock.json b/package-lock.json index 99bb67f65..6bee47cd6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1623,6 +1623,15 @@ "@types/react": "*" } }, + "@zxing/library": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.7.0.tgz", + "integrity": "sha512-VJ1cJaCWVF8MspnuyaZKGKlrSQLqQ5usgSap8uuCAvWGQ6W6OwN1NeGvnjhT+9hmnwkHK8XjaflvzaDBC7nKnw==", + "requires": { + "text-encoding": "^0.6.4", + "ts-custom-error": "^2.2.1" + } + }, "JSONStream": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.2.tgz", @@ -4624,9 +4633,9 @@ }, "dependencies": { "electron-to-chromium": { - "version": "1.3.52", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.52.tgz", - "integrity": "sha1-0tnxJwuko7lnuDHEDvcftNmrXOA=" + "version": "1.3.55", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.55.tgz", + "integrity": "sha1-8VDhCyC3fZ1Br8yjEu/gw7Gn/c4=" } } }, @@ -4954,9 +4963,9 @@ "integrity": "sha1-MN/YMAnVcE8C3/s3clBo7RKjZrs=" }, "caniuse-lite": { - "version": "1.0.30000865", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000865.tgz", - "integrity": "sha512-vs79o1mOSKRGv/1pSkp4EXgl4ZviWeYReXw60XfacPU64uQWZwJT6vZNmxRF9O+6zu71sJwMxLK5JXxbzuVrLw==" + "version": "1.0.30000874", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000874.tgz", + "integrity": "sha512-29nr1EPiHwrJTAHHsEmTt2h+55L8j2GNFdAcYPlRy2NX6iFz7ZZiepVI7kP/QqlnHLq3KvfWpbmGa0d063U09w==" }, "capture-stack-trace": { "version": "1.0.0", @@ -7045,6 +7054,11 @@ } } }, + "detectrtc": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/detectrtc/-/detectrtc-1.3.6.tgz", + "integrity": "sha1-2rwDU5gaPadzLelpBxwItt3dW1k=" + }, "di": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", @@ -8210,7 +8224,7 @@ "dependencies": { "async-eventemitter": { "version": "github:ahultgren/async-eventemitter#fa06e39e56786ba541c180061dbf2c0a5bbf951c", - "from": "github:ahultgren/async-eventemitter#fa06e39e56786ba541c180061dbf2c0a5bbf951c", + "from": "async-eventemitter@github:ahultgren/async-eventemitter#fa06e39e56786ba541c180061dbf2c0a5bbf951c", "requires": { "async": "^2.4.0" } @@ -8258,7 +8272,7 @@ } }, "eth-contract-metadata": { - "version": "github:MetaMask/eth-contract-metadata#f1a59032297fc00b9804de4674667f2955158757", + "version": "github:MetaMask/eth-contract-metadata#2da362052a312dc6c72a7eec116abf6284664f50", "from": "github:MetaMask/eth-contract-metadata#master" }, "eth-ens-namehash": { @@ -13227,9 +13241,9 @@ "integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==" }, "node-sass": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/node-sass/-/node-sass-4.9.2.tgz", - "integrity": "sha512-LdxoJLZutx0aQXHtWIYwJKMj+9pTjneTcLWJgzf2XbGu0q5pRNqW5QvFCEdm3mc5rJOdru/mzln5d0EZLacf6g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/node-sass/-/node-sass-4.9.1.tgz", + "integrity": "sha512-m6H1I6cHXsHsJ7BIWdnJsz9S9gVMyh+/H2cOTXgl2/2WqyyWlBcl4PHJcqrXo5RZVCfCUFqOtjPN0+0XbVHR5Q==", "requires": { "async-foreach": "^0.1.3", "chalk": "^1.1.1", @@ -26891,6 +26905,14 @@ "nearley": "^2.7.10" } }, + "rtcpeerconnection-shim": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rtcpeerconnection-shim/-/rtcpeerconnection-shim-1.2.13.tgz", + "integrity": "sha512-Xz4zQLZNs9lFBvqbaHGIjLWtyZ1V82ec5r+WNEo7NlIx3zF5M3ytn9mkkfYeZmpE032cNg3Vvf0rP8kNXUNd9w==", + "requires": { + "sdp": "^2.6.0" + } + }, "run-async": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.3.0.tgz", @@ -27209,6 +27231,11 @@ } } }, + "sdp": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-2.8.0.tgz", + "integrity": "sha512-wRSES07rAwKWAR7aev9UuClT7kdf9ZTdeUK5gTgHue9vlhs19Fbm3ccNEGJO4y2IitH4/JzS4sdzyPl6H2KQLw==" + }, "secp256k1": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-3.4.0.tgz", @@ -29897,8 +29924,7 @@ "text-encoding": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.6.4.tgz", - "integrity": "sha1-45mpgiV6J22uQou5KEXLcb3CbRk=", - "dev": true + "integrity": "sha1-45mpgiV6J22uQou5KEXLcb3CbRk=" }, "text-table": { "version": "0.2.0", @@ -30345,6 +30371,11 @@ } } }, + "ts-custom-error": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-2.2.1.tgz", + "integrity": "sha512-lHKZtU+PXkVuap6nlFZybIAFLUO8B3jbCs1VynBL8AUSAHfeG6HpztcBTDRp5I+fN5820N9kGg+eTIvr+le2yg==" + }, "tslib": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.9.2.tgz", @@ -32926,6 +32957,15 @@ } } }, + "webrtc-adapter": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-6.3.2.tgz", + "integrity": "sha512-7pFMXpZCka7ScIQyk8Wo+fOr3OlKLtGd6YHqkHVT74zerpY2Siyds8sxsmkE0bNqsi/J1b0vDzN7WpB34dQzAA==", + "requires": { + "rtcpeerconnection-shim": "^1.2.10", + "sdp": "^2.7.0" + } + }, "websocket": { "version": "1.0.26", "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.26.tgz", diff --git a/package.json b/package.json index 16aa143ba..29295a65b 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ }, "dependencies": { "@material-ui/core": "1.0.0", + "@zxing/library": "^0.7.0", "abi-decoder": "^1.0.9", "asmcrypto.js": "0.22.0", "async": "^2.5.0", @@ -97,6 +98,7 @@ "debounce-stream": "^2.0.0", "deep-extend": "^0.5.1", "detect-node": "^2.0.3", + "detectrtc": "^1.3.6", "disc": "^1.3.2", "dnode": "^1.2.2", "end-of-stream": "^1.1.0", @@ -206,6 +208,7 @@ "web3": "^0.20.1", "web3-provider-engine": "^14.0.5", "web3-stream-provider": "^3.0.1", + "webrtc-adapter": "^6.3.0", "xtend": "^4.0.1" }, "devDependencies": { diff --git a/ui/app/actions.js b/ui/app/actions.js index 9edb3511a..bd5d25327 100644 --- a/ui/app/actions.js +++ b/ui/app/actions.js @@ -12,6 +12,7 @@ const { fetchLocale } = require('../i18n-helper') const log = require('loglevel') const { ENVIRONMENT_TYPE_NOTIFICATION } = require('../../app/scripts/lib/enums') const { hasUnconfirmedTransactions } = require('./helpers/confirm-transaction/util') +const WebcamUtils = require('../lib/webcam-utils') var actions = { _setBackgroundConnection: _setBackgroundConnection, @@ -33,6 +34,8 @@ var actions = { ALERT_CLOSE: 'UI_ALERT_CLOSE', showAlert: showAlert, hideAlert: hideAlert, + QR_CODE_DETECTED: 'UI_QR_CODE_DETECTED', + qrCodeDetected, // network dropdown open NETWORK_DROPDOWN_OPEN: 'UI_NETWORK_DROPDOWN_OPEN', NETWORK_DROPDOWN_CLOSE: 'UI_NETWORK_DROPDOWN_CLOSE', @@ -125,7 +128,8 @@ var actions = { SHOW_CONF_TX_PAGE: 'SHOW_CONF_TX_PAGE', SHOW_CONF_MSG_PAGE: 'SHOW_CONF_MSG_PAGE', SET_CURRENT_FIAT: 'SET_CURRENT_FIAT', - setCurrentCurrency: setCurrentCurrency, + showQrScanner, + setCurrentCurrency, setCurrentAccountTab, // account detail screen SHOW_SEND_PAGE: 'SHOW_SEND_PAGE', @@ -723,6 +727,28 @@ function showInfoPage () { } } +function showQrScanner (ROUTE) { + return (dispatch, getState) => { + return WebcamUtils.checkStatus() + .then(status => { + if (!status.environmentReady) { + // We need to switch to fullscreen mode to ask for permission + global.platform.openExtensionInBrowser(`${ROUTE}`, `scan=true`) + } else { + dispatch(actions.showModal({ + name: 'QR_SCANNER', + })) + } + }).catch(e => { + dispatch(actions.showModal({ + name: 'QR_SCANNER', + error: true, + errorType: e.type, + })) + }) + } +} + function setCurrentCurrency (currencyCode) { return (dispatch) => { dispatch(actions.showLoadingIndication()) @@ -1809,6 +1835,17 @@ function hideAlert () { } } +/** + * This action will receive two types of values via qrCodeData + * an object with the following structure {type, values} + * or null (used to clear the previous value) + */ +function qrCodeDetected (qrCodeData) { + return { + type: actions.QR_CODE_DETECTED, + value: qrCodeData, + } +} function showLoadingIndication (message) { return { diff --git a/ui/app/components/ens-input.js b/ui/app/components/ens-input.js index b9f99b3d1..f538fd555 100644 --- a/ui/app/components/ens-input.js +++ b/ui/app/components/ens-input.js @@ -27,6 +27,7 @@ function EnsInput () { } EnsInput.prototype.onChange = function (recipient) { + const network = this.props.network const networkHasEnsSupport = getNetworkEnsSupport(network) @@ -54,6 +55,7 @@ EnsInput.prototype.render = function () { const opts = extend(props, { list: 'addresses', onChange: this.onChange.bind(this), + qrScanner: true, }) return h('div', { style: { width: '100%', position: 'relative' }, diff --git a/ui/app/components/modals/index.scss b/ui/app/components/modals/index.scss index e198cca44..0acccf172 100644 --- a/ui/app/components/modals/index.scss +++ b/ui/app/components/modals/index.scss @@ -1,5 +1,7 @@ @import './customize-gas/index'; +@import './qr-scanner/index'; + .modal-container { width: 100%; height: 100%; diff --git a/ui/app/components/modals/modal.js b/ui/app/components/modals/modal.js index f59825ed1..5dda50e52 100644 --- a/ui/app/components/modals/modal.js +++ b/ui/app/components/modals/modal.js @@ -21,6 +21,7 @@ const CustomizeGasModal = require('../customize-gas-modal') const NotifcationModal = require('./notification-modal') const ConfirmResetAccount = require('./confirm-reset-account') const ConfirmRemoveAccount = require('./confirm-remove-account') +const QRScanner = require('./qr-scanner') const TransactionConfirmed = require('./transaction-confirmed') const WelcomeBeta = require('./welcome-beta') const Notification = require('./notification') @@ -346,6 +347,18 @@ const MODALS = { borderRadius: '8px', }, }, + QR_SCANNER: { + contents: h(QRScanner), + mobileModalStyle: { + ...modalContainerMobileStyle, + }, + laptopModalStyle: { + ...modalContainerLaptopStyle, + }, + contentStyle: { + borderRadius: '8px', + }, + }, DEFAULT: { contents: [], diff --git a/ui/app/components/modals/qr-scanner/index.js b/ui/app/components/modals/qr-scanner/index.js new file mode 100644 index 000000000..470dee1f4 --- /dev/null +++ b/ui/app/components/modals/qr-scanner/index.js @@ -0,0 +1,2 @@ +import QrScanner from './qr-scanner.container' +module.exports = QrScanner diff --git a/ui/app/components/modals/qr-scanner/index.scss b/ui/app/components/modals/qr-scanner/index.scss new file mode 100644 index 000000000..6fa81d51f --- /dev/null +++ b/ui/app/components/modals/qr-scanner/index.scss @@ -0,0 +1,83 @@ +.qr-scanner { + width: 100%; + height: 100%; + background-color: #fff; + display: flex; + flex-flow: column; + border-radius: 8px; + + &__title { + font-size: 1.5rem; + font-weight: 500; + padding: 16px 0; + text-align: center; + } + + &__content { + padding-left: 20px; + padding-right: 20px; + + &__video-wrapper { + overflow: hidden; + width: 100%; + height: 275px; + display: flex; + align-items: center; + justify-content: center; + + video { + transform: scaleX(-1); + width: auto; + height: 275px; + } + } + } + + &__status { + text-align: center; + font-size: 14px; + padding: 15px; + } + + &__image { + font-size: 1.5rem; + font-weight: 500; + padding: 16px 0 0; + text-align: center; + } + + &__error { + text-align: center; + font-size: 16px; + padding: 15px; + } + + &__footer { + padding: 20px; + flex-direction: row; + display: flex; + + button { + margin-right: 15px; + } + + button:last-of-type { + margin-right: 0; + background-color: #009eec; + border: none; + color: #fff; + } + } + + &__close::after { + content: '\00D7'; + font-size: 35px; + color: #9b9b9b; + position: absolute; + top: 4px; + right: 20px; + cursor: pointer; + font-weight: 300; + } +} + diff --git a/ui/app/components/modals/qr-scanner/qr-scanner.component.js b/ui/app/components/modals/qr-scanner/qr-scanner.component.js new file mode 100644 index 000000000..cb8d07d83 --- /dev/null +++ b/ui/app/components/modals/qr-scanner/qr-scanner.component.js @@ -0,0 +1,216 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { BrowserQRCodeReader } from '@zxing/library' +import adapter from 'webrtc-adapter' // eslint-disable-line import/no-nodejs-modules, no-unused-vars +import Spinner from '../../spinner' +import WebcamUtils from '../../../../lib/webcam-utils' +import PageContainerFooter from '../../page-container/page-container-footer/page-container-footer.component' + +export default class QrScanner extends Component { + static propTypes = { + hideModal: PropTypes.func.isRequired, + qrCodeDetected: PropTypes.func, + scanQrCode: PropTypes.func, + error: PropTypes.bool, + errorType: PropTypes.string, + } + + static contextTypes = { + t: PropTypes.func, + } + + constructor (props, context) { + super(props) + + this.state = { + ready: false, + msg: context.t('accessingYourCamera'), + } + this.codeReader = null + this.permissionChecker = null + this.needsToReinit = false + + // Clear pre-existing qr code data before scanning + this.props.qrCodeDetected(null) + } + + componentDidMount () { + this.initCamera() + } + + async checkPermisisions () { + const { permissions } = await WebcamUtils.checkStatus() + if (permissions) { + clearTimeout(this.permissionChecker) + // Let the video stream load first... + setTimeout(_ => { + this.setState({ + ready: true, + msg: this.context.t('scanInstructions'), + }) + if (this.needsToReinit) { + this.initCamera() + this.needsToReinit = false + } + }, 2000) + } else { + // Keep checking for permissions + this.permissionChecker = setTimeout(_ => { + this.checkPermisisions() + }, 1000) + } + } + + componentWillUnmount () { + clearTimeout(this.permissionChecker) + if (this.codeReader) { + this.codeReader.reset() + } + } + + initCamera () { + this.codeReader = new BrowserQRCodeReader() + this.codeReader.getVideoInputDevices() + .then(videoInputDevices => { + clearTimeout(this.permissionChecker) + this.checkPermisisions() + this.codeReader.decodeFromInputVideoDevice(undefined, 'video') + .then(content => { + const result = this.parseContent(content.text) + if (result.type !== 'unknown') { + this.props.qrCodeDetected(result) + this.stopAndClose() + } else { + this.setState({msg: this.context.t('unknownQrCode')}) + } + }) + .catch(err => { + if (err && err.name === 'NotAllowedError') { + this.setState({msg: this.context.t('youNeedToAllowCameraAccess')}) + clearTimeout(this.permissionChecker) + this.needsToReinit = true + this.checkPermisisions() + } + }) + }).catch(err => { + console.error('[QR-SCANNER]: getVideoInputDevices threw an exception: ', err) + }) + } + + parseContent (content) { + let type = 'unknown' + let values = {} + + // Here we could add more cases + // To parse other type of links + // For ex. EIP-681 (https://eips.ethereum.org/EIPS/eip-681) + + + // Ethereum address links - fox ex. ethereum:0x.....1111 + if (content.split('ethereum:').length > 1) { + + type = 'address' + values = {'address': content.split('ethereum:')[1] } + + // Regular ethereum addresses - fox ex. 0x.....1111 + } else if (content.substring(0, 2).toLowerCase() === '0x') { + + type = 'address' + values = {'address': content } + + } + return {type, values} + } + + + stopAndClose = () => { + if (this.codeReader) { + this.codeReader.reset() + } + this.setState({ ready: false }) + this.props.hideModal() + } + + tryAgain = () => { + // close the modal + this.stopAndClose() + // wait for the animation and try again + setTimeout(_ => { + this.props.scanQrCode() + }, 1000) + } + + renderVideo () { + return ( + <div className={'qr-scanner__content__video-wrapper'}> + <video + id="video" + style={{ + display: this.state.ready ? 'block' : 'none', + }} + /> + { !this.state.ready ? <Spinner color={'#F7C06C'} /> : null} + </div> + ) + } + + renderErrorModal () { + let title, msg + + if (this.props.error) { + if (this.props.errorType === 'NO_WEBCAM_FOUND') { + title = this.context.t('noWebcamFoundTitle') + msg = this.context.t('noWebcamFound') + } else { + title = this.context.t('unknownCameraErrorTitle') + msg = this.context.t('unknownCameraError') + } + } + + return ( + <div className="qr-scanner"> + <div className="qr-scanner__close" onClick={this.stopAndClose}></div> + + <div className="qr-scanner__image"> + <img src={'images/webcam.svg'} width={70} height={70} /> + </div> + <div className="qr-scanner__title"> + { title } + </div> + <div className={'qr-scanner__error'}> + {msg} + </div> + <PageContainerFooter + onCancel={this.stopAndClose} + onSubmit={this.tryAgain} + cancelText={this.context.t('cancel')} + submitText={this.context.t('tryAgain')} + submitButtonType="confirm" + /> + </div> + ) + } + + render () { + const { t } = this.context + + if (this.props.error) { + return this.renderErrorModal() + } + + return ( + <div className="qr-scanner"> + <div className="qr-scanner__close" onClick={this.stopAndClose}></div> + <div className="qr-scanner__title"> + { `${t('scanQrCode')}` } + </div> + <div className="qr-scanner__content"> + { this.renderVideo() } + </div> + <div className={'qr-scanner__status'}> + {this.state.msg} + </div> + </div> + ) + } +} diff --git a/ui/app/components/modals/qr-scanner/qr-scanner.container.js b/ui/app/components/modals/qr-scanner/qr-scanner.container.js new file mode 100644 index 000000000..d0a35e03b --- /dev/null +++ b/ui/app/components/modals/qr-scanner/qr-scanner.container.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux' +import QrScanner from './qr-scanner.component' + +const { hideModal, qrCodeDetected, showQrScanner } = require('../../../actions') +import { + SEND_ROUTE, +} from '../../../routes' + +const mapStateToProps = state => { + return { + error: state.appState.modal.modalState.props.error, + errorType: state.appState.modal.modalState.props.errorType, + } +} + +const mapDispatchToProps = dispatch => { + return { + hideModal: () => dispatch(hideModal()), + qrCodeDetected: (data) => dispatch(qrCodeDetected(data)), + scanQrCode: () => dispatch(showQrScanner(SEND_ROUTE)), + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(QrScanner) diff --git a/ui/app/components/send/send-content/send-content.component.js b/ui/app/components/send/send-content/send-content.component.js index 7a0b1a18e..df7bcb7cc 100644 --- a/ui/app/components/send/send-content/send-content.component.js +++ b/ui/app/components/send/send-content/send-content.component.js @@ -11,6 +11,7 @@ export default class SendContent extends Component { static propTypes = { updateGas: PropTypes.func, + scanQrCode: PropTypes.func, }; render () { @@ -18,7 +19,10 @@ export default class SendContent extends Component { <PageContainerContent> <div className="send-v2__form"> <SendFromRow /> - <SendToRow updateGas={(updateData) => this.props.updateGas(updateData)} /> + <SendToRow + updateGas={(updateData) => this.props.updateGas(updateData)} + scanQrCode={ _ => this.props.scanQrCode()} + /> <SendAmountRow updateGas={(updateData) => this.props.updateGas(updateData)} /> <SendGasRow /> <SendHexDataRow /> diff --git a/ui/app/components/send/send-content/send-to-row/send-to-row.component.js b/ui/app/components/send/send-content/send-to-row/send-to-row.component.js index 892ad5d67..1163dcffc 100644 --- a/ui/app/components/send/send-content/send-to-row/send-to-row.component.js +++ b/ui/app/components/send/send-content/send-to-row/send-to-row.component.js @@ -17,6 +17,7 @@ export default class SendToRow extends Component { updateGas: PropTypes.func, updateSendTo: PropTypes.func, updateSendToError: PropTypes.func, + scanQrCode: PropTypes.func, }; static contextTypes = { @@ -51,6 +52,7 @@ export default class SendToRow extends Component { showError={inError} > <EnsInput + scanQrCode={_ => this.props.scanQrCode()} accounts={toAccounts} closeDropdown={() => closeToDropdown()} dropdownOpen={toDropdownOpen} diff --git a/ui/app/components/send/send.component.js b/ui/app/components/send/send.component.js index 6f1b20c55..0d8ffd179 100644 --- a/ui/app/components/send/send.component.js +++ b/ui/app/components/send/send.component.js @@ -38,12 +38,30 @@ export default class SendTransactionScreen extends PersistentForm { updateAndSetGasTotal: PropTypes.func, updateSendErrors: PropTypes.func, updateSendTokenBalance: PropTypes.func, + scanQrCode: PropTypes.func, + qrCodeDetected: PropTypes.func, + qrCodeData: PropTypes.object, }; static contextTypes = { t: PropTypes.func, }; + componentWillReceiveProps (nextProps) { + if (nextProps.qrCodeData) { + if (nextProps.qrCodeData.type === 'address') { + const scannedAddress = nextProps.qrCodeData.values.address.toLowerCase() + const currentAddress = this.props.to && this.props.to.toLowerCase() + if (currentAddress !== scannedAddress) { + this.props.updateSendTo(scannedAddress) + this.updateGas({ to: scannedAddress }) + // Clean up QR code data after handling + this.props.qrCodeDetected(null) + } + } + } + } + updateGas ({ to: updatedToAddress, amount: value } = {}) { const { amount, @@ -158,6 +176,16 @@ export default class SendTransactionScreen extends PersistentForm { address, }) this.updateGas() + + // Show QR Scanner modal if ?scan=true + if (window.location.search === '?scan=true') { + this.props.scanQrCode() + + // Clear the queryString param after showing the modal + const cleanUrl = location.href.split('?')[0] + history.pushState({}, null, `${cleanUrl}`) + window.location.hash = '#send' + } } componentWillUnmount () { @@ -170,7 +198,10 @@ export default class SendTransactionScreen extends PersistentForm { return ( <div className="page-container"> <SendHeader history={history}/> - <SendContent updateGas={(updateData) => this.updateGas(updateData)}/> + <SendContent + updateGas={(updateData) => this.updateGas(updateData)} + scanQrCode={_ => this.props.scanQrCode()} + /> <SendFooter history={history}/> </div> ) diff --git a/ui/app/components/send/send.container.js b/ui/app/components/send/send.container.js index 44ebd2792..41735de64 100644 --- a/ui/app/components/send/send.container.js +++ b/ui/app/components/send/send.container.js @@ -21,11 +21,15 @@ import { getSendFromObject, getSendTo, getTokenBalance, + getQrCodeData, } from './send.selectors' import { + updateSendTo, updateSendTokenBalance, updateGasData, setGasTotal, + showQrScanner, + qrCodeDetected, } from '../../actions' import { resetSendState, @@ -35,6 +39,10 @@ import { calcGasTotal, } from './send.utils.js' +import { + SEND_ROUTE, +} from '../../routes' + module.exports = compose( withRouter, connect(mapStateToProps, mapDispatchToProps) @@ -60,6 +68,7 @@ function mapStateToProps (state) { tokenBalance: getTokenBalance(state), tokenContract: getSelectedTokenContract(state), tokenToFiatRate: getSelectedTokenToFiatRate(state), + qrCodeData: getQrCodeData(state), } } @@ -89,5 +98,8 @@ function mapDispatchToProps (dispatch) { }, updateSendErrors: newError => dispatch(updateSendErrors(newError)), resetSendState: () => dispatch(resetSendState()), + scanQrCode: () => dispatch(showQrScanner(SEND_ROUTE)), + qrCodeDetected: (data) => dispatch(qrCodeDetected(data)), + updateSendTo: (to, nickname) => dispatch(updateSendTo(to, nickname)), } } diff --git a/ui/app/components/send/send.selectors.js b/ui/app/components/send/send.selectors.js index cf07eafe1..ab3f6d34b 100644 --- a/ui/app/components/send/send.selectors.js +++ b/ui/app/components/send/send.selectors.js @@ -46,6 +46,7 @@ const selectors = { getTokenExchangeRate, getUnapprovedTxs, transactionsSelector, + getQrCodeData, } module.exports = selectors @@ -282,3 +283,7 @@ function transactionsSelector (state) { : txsToRender .sort((a, b) => b.time - a.time) } + +function getQrCodeData (state) { + return state.appState.qrCodeData +} diff --git a/ui/app/components/send/tests/send-container.test.js b/ui/app/components/send/tests/send-container.test.js index 7a9120d24..57e332780 100644 --- a/ui/app/components/send/tests/send-container.test.js +++ b/ui/app/components/send/tests/send-container.test.js @@ -44,6 +44,7 @@ proxyquire('../send.container.js', { getSendEditingTransactionId: (s) => `mockEditingTransactionId:${s}`, getSendFromObject: (s) => `mockFrom:${s}`, getTokenBalance: (s) => `mockTokenBalance:${s}`, + getQrCodeData: (s) => `mockQrCodeData:${s}`, }, '../../actions': actionSpies, '../../ducks/send.duck': duckActionSpies, @@ -76,6 +77,7 @@ describe('send container', () => { tokenBalance: 'mockTokenBalance:mockState', tokenContract: 'mockTokenContract:mockState', tokenToFiatRate: 'mockTokenToFiatRate:mockState', + qrCodeData: 'mockQrCodeData:mockState', }) }) diff --git a/ui/app/components/send/to-autocomplete/to-autocomplete.js b/ui/app/components/send/to-autocomplete/to-autocomplete.js index 80cfa7a85..49ebf49d9 100644 --- a/ui/app/components/send/to-autocomplete/to-autocomplete.js +++ b/ui/app/components/send/to-autocomplete/to-autocomplete.js @@ -4,6 +4,7 @@ const h = require('react-hyperscript') const inherits = require('util').inherits const AccountListItem = require('../account-list-item/account-list-item.component').default const connect = require('react-redux').connect +const Tooltip = require('../../tooltip') ToAutoComplete.contextTypes = { t: PropTypes.func, @@ -94,11 +95,12 @@ ToAutoComplete.prototype.render = function () { dropdownOpen, onChange, inError, + qrScanner, } = this.props return h('div.send-v2__to-autocomplete', {}, [ - h('input.send-v2__to-autocomplete__input', { + h(`input.send-v2__to-autocomplete__input${qrScanner ? '.with-qr' : ''}`, { placeholder: this.context.t('recipientAddress'), className: inError ? `send-v2__error-border` : '', value: to, @@ -108,7 +110,13 @@ ToAutoComplete.prototype.render = function () { borderColor: inError ? 'red' : null, }, }), - + qrScanner && h(Tooltip, { + title: this.context.t('scanQrCode'), + position: 'bottom', + }, h(`i.fa.fa-qrcode.fa-lg.send-v2__to-autocomplete__qr-code`, { + style: { color: '#33333' }, + onClick: () => this.props.scanQrCode(), + })), !to && h(`i.fa.fa-caret-down.fa-lg.send-v2__to-autocomplete__down-caret`, { style: { color: '#dedede' }, onClick: () => this.handleInputEvent(), diff --git a/ui/app/css/itcss/components/send.scss b/ui/app/css/itcss/components/send.scss index e9c872ea7..806ac8536 100644 --- a/ui/app/css/itcss/components/send.scss +++ b/ui/app/css/itcss/components/send.scss @@ -626,6 +626,23 @@ top: 18px; right: 12px; } + + &__qr-code { + position: absolute; + top: 13px; + right: 33px; + cursor: pointer; + padding: 8px 5px 5px; + border-radius: 4px; + } + + &__qr-code:hover { + background: #f1f1f1; + } + + &__input.with-qr { + padding-right: 65px; + } } &__to-autocomplete, &__memo-text-area, &__hex-data { diff --git a/ui/app/reducers/app.js b/ui/app/reducers/app.js index e4e4c8581..98d467163 100644 --- a/ui/app/reducers/app.js +++ b/ui/app/reducers/app.js @@ -51,6 +51,7 @@ function reduceApp (state, action) { sidebarOpen: false, alertOpen: false, alertMessage: null, + qrCodeData: null, networkDropdownOpen: false, currentView: seedWords ? seedConfView : defaultView, accountDetail: { @@ -91,7 +92,7 @@ function reduceApp (state, action) { sidebarOpen: false, }) - // sidebar methods + // alert methods case actions.ALERT_OPEN: return extend(appState, { alertOpen: true, @@ -104,6 +105,13 @@ function reduceApp (state, action) { alertMessage: null, }) + // qr scanner methods + case actions.QR_CODE_DETECTED: + return extend(appState, { + qrCodeData: action.value, + }) + + // modal methods: case actions.MODAL_OPEN: const { name, ...modalProps } = action.payload diff --git a/ui/lib/webcam-utils.js b/ui/lib/webcam-utils.js new file mode 100644 index 000000000..eb717b23a --- /dev/null +++ b/ui/lib/webcam-utils.js @@ -0,0 +1,36 @@ +'use strict' + +import DetectRTC from 'detectrtc' +const { ENVIRONMENT_TYPE_POPUP } = require('../../app/scripts/lib/enums') +const { getEnvironmentType, getPlatform } = require('../../app/scripts/lib/util') +const { PLATFORM_BRAVE, PLATFORM_FIREFOX } = require('../../app/scripts/lib/enums') + +class WebcamUtils { + + static checkStatus () { + return new Promise((resolve, reject) => { + const isPopup = getEnvironmentType(window.location.href) === ENVIRONMENT_TYPE_POPUP + const isFirefoxOrBrave = getPlatform() === (PLATFORM_FIREFOX || PLATFORM_BRAVE) + try { + DetectRTC.load(_ => { + if (DetectRTC.hasWebcam) { + let environmentReady = true + if ((isFirefoxOrBrave && isPopup) || (isPopup && !DetectRTC.isWebsiteHasWebcamPermissions)) { + environmentReady = false + } + resolve({ + permissions: DetectRTC.isWebsiteHasWebcamPermissions, + environmentReady, + }) + } else { + reject({type: 'NO_WEBCAM_FOUND'}) + } + }) + } catch (e) { + reject({type: 'UNKNOWN_ERROR'}) + } + }) + } +} + +module.exports = WebcamUtils |