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 /ui/app/components | |
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
Diffstat (limited to 'ui/app/components')
-rw-r--r-- | ui/app/components/ens-input.js | 2 | ||||
-rw-r--r-- | ui/app/components/modals/index.scss | 2 | ||||
-rw-r--r-- | ui/app/components/modals/modal.js | 13 | ||||
-rw-r--r-- | ui/app/components/modals/qr-scanner/index.js | 2 | ||||
-rw-r--r-- | ui/app/components/modals/qr-scanner/index.scss | 83 | ||||
-rw-r--r-- | ui/app/components/modals/qr-scanner/qr-scanner.component.js | 216 | ||||
-rw-r--r-- | ui/app/components/modals/qr-scanner/qr-scanner.container.js | 24 | ||||
-rw-r--r-- | ui/app/components/send/send-content/send-content.component.js | 6 | ||||
-rw-r--r-- | ui/app/components/send/send-content/send-to-row/send-to-row.component.js | 2 | ||||
-rw-r--r-- | ui/app/components/send/send.component.js | 33 | ||||
-rw-r--r-- | ui/app/components/send/send.container.js | 12 | ||||
-rw-r--r-- | ui/app/components/send/send.selectors.js | 5 | ||||
-rw-r--r-- | ui/app/components/send/tests/send-container.test.js | 2 | ||||
-rw-r--r-- | ui/app/components/send/to-autocomplete/to-autocomplete.js | 12 |
14 files changed, 410 insertions, 4 deletions
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(), |