import React, { Component } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import PubNub from 'pubnub'; import qrCode from 'qrcode-generator'; import Button from '../../components/ui/button'; import LoadingScreen from '../../components/ui/loading-screen'; import { MINUTE, SECOND } from '../../../shared/constants/time'; const PASSWORD_PROMPT_SCREEN = 'PASSWORD_PROMPT_SCREEN'; const REVEAL_SEED_SCREEN = 'REVEAL_SEED_SCREEN'; const KEYS_GENERATION_TIME = SECOND * 30; const IDLE_TIME = MINUTE * 2; export default class MobileSyncPage extends Component { static contextTypes = { t: PropTypes.func, }; static propTypes = { history: PropTypes.object.isRequired, selectedAddress: PropTypes.string.isRequired, displayWarning: PropTypes.func.isRequired, fetchInfoToSync: PropTypes.func.isRequired, mostRecentOverviewPage: PropTypes.string.isRequired, requestRevealSeedWords: PropTypes.func.isRequired, exportAccounts: PropTypes.func.isRequired, keyrings: PropTypes.array, hideWarning: PropTypes.func.isRequired, }; state = { screen: PASSWORD_PROMPT_SCREEN, password: '', seedWords: null, importedAccounts: [], error: null, syncing: false, completed: false, channelName: undefined, cipherKey: undefined, }; syncing = false; componentDidMount() { const passwordBox = document.getElementById('password-box'); if (passwordBox) { passwordBox.focus(); } } startIdleTimeout() { this.idleTimeout = setTimeout(() => { this.clearTimeouts(); this.goBack(); }, IDLE_TIME); } handleSubmit(event) { event.preventDefault(); this.setState({ seedWords: null, error: null }); this.props .requestRevealSeedWords(this.state.password) .then((seedWords) => { this.startKeysGeneration(); this.startIdleTimeout(); this.exportAccounts().then((importedAccounts) => { this.setState({ seedWords, importedAccounts, screen: REVEAL_SEED_SCREEN, }); }); }) .catch((error) => this.setState({ error: error.message })); } async exportAccounts() { const addresses = []; this.props.keyrings.forEach((keyring) => { if (keyring.type === 'Simple Key Pair') { addresses.push(keyring.accounts[0]); } }); const importedAccounts = await this.props.exportAccounts( this.state.password, addresses, ); return importedAccounts; } startKeysGeneration() { this.keysGenerationTimeout && clearTimeout(this.keysGenerationTimeout); this.disconnectWebsockets(); this.generateCipherKeyAndChannelName(); this.initWebsockets(); this.keysGenerationTimeout = setTimeout(() => { this.startKeysGeneration(); }, KEYS_GENERATION_TIME); } goBack() { const { history, mostRecentOverviewPage } = this.props; history.push(mostRecentOverviewPage); } clearTimeouts() { this.keysGenerationTimeout && clearTimeout(this.keysGenerationTimeout); this.idleTimeout && clearTimeout(this.idleTimeout); } generateCipherKeyAndChannelName() { this.cipherKey = `${this.props.selectedAddress.substr( -4, )}-${PubNub.generateUUID()}`; this.channelName = `mm-${PubNub.generateUUID()}`; this.setState({ cipherKey: this.cipherKey, channelName: this.channelName }); } initWithCipherKeyAndChannelName(cipherKey, channelName) { this.cipherKey = cipherKey; this.channelName = channelName; } initWebsockets() { // Make sure there are no existing listeners this.disconnectWebsockets(); this.pubnub = new PubNub({ subscribeKey: process.env.PUBNUB_SUB_KEY, publishKey: process.env.PUBNUB_PUB_KEY, cipherKey: this.cipherKey, ssl: true, }); this.pubnubListener = { message: (data) => { const { channel, message } = data; // handle message if (channel !== this.channelName || !message) { return; } if (message.event === 'start-sync') { this.startSyncing(); } else if (message.event === 'connection-info') { this.keysGenerationTimeout && clearTimeout(this.keysGenerationTimeout); this.disconnectWebsockets(); this.initWithCipherKeyAndChannelName(message.cipher, message.channel); this.initWebsockets(); } else if (message.event === 'end-sync') { this.disconnectWebsockets(); this.setState({ syncing: false, completed: true }); } }, }; this.pubnub.addListener(this.pubnubListener); this.pubnub.subscribe({ channels: [this.channelName], withPresence: false, }); } disconnectWebsockets() { if (this.pubnub && this.pubnubListener) { this.pubnub.removeListener(this.pubnubListener); } } // Calculating a PubNub Message Payload Size. calculatePayloadSize(channel, message) { return encodeURIComponent(channel + JSON.stringify(message)).length + 100; } chunkString(str, size) { const numChunks = Math.ceil(str.length / size); const chunks = new Array(numChunks); let o = 0; for (let i = 0; i < numChunks; i += 1) { chunks[i] = str.substr(o, size); o += size; } return chunks; } notifyError(errorMsg) { return new Promise((resolve, reject) => { this.pubnub.publish( { message: { event: 'error-sync', data: errorMsg, }, channel: this.channelName, sendByPost: false, // true to send via post storeInHistory: false, }, (status, _response) => { if (status.error) { reject(status.errorData); } else { resolve(); } }, ); }); } async startSyncing() { if (this.syncing) { return; } this.syncing = true; this.setState({ syncing: true }); const { accounts, network, preferences, transactions, tokens, } = await this.props.fetchInfoToSync(); const { t } = this.context; const allDataStr = JSON.stringify({ accounts, network, preferences, transactions, tokens, udata: { pwd: this.state.password, seed: this.state.seedWords, importedAccounts: this.state.importedAccounts, }, }); const chunks = this.chunkString(allDataStr, 17000); const totalChunks = chunks.length; try { for (let i = 0; i < totalChunks; i++) { await this.sendMessage(chunks[i], i + 1, totalChunks); } } catch (e) { this.props.displayWarning(`${t('syncFailed')} :(`); this.setState({ syncing: false }); this.syncing = false; this.notifyError(e.toString()); } } sendMessage(data, pkg, count) { return new Promise((resolve, reject) => { this.pubnub.publish( { message: { event: 'syncing-data', data, totalPkg: count, currentPkg: pkg, }, channel: this.channelName, sendByPost: false, // true to send via post storeInHistory: false, }, (status, _response) => { if (status.error) { reject(status.errorData); } else { resolve(); } }, ); }); } componentWillUnmount() { if (this.state.error) { this.props.hideWarning(); } this.clearTimeouts(); this.disconnectWebsockets(); } renderWarning(text) { return ( <div className="page-container__warning-container"> <div className="page-container__warning-message"> <div>{text}</div> </div> </div> ); } renderContent() { const { syncing, completed, screen } = this.state; const { t } = this.context; if (syncing) { return <LoadingScreen loadingMessage={t('syncInProgress')} />; } if (completed) { return ( <div className="reveal-seed__content"> <label className="reveal-seed__label" style={{ width: '100%', textAlign: 'center', }} > {t('syncWithMobileComplete')} </label> </div> ); } return screen === PASSWORD_PROMPT_SCREEN ? ( <div>{this.renderWarning(this.context.t('mobileSyncWarning'))}</div> ) : ( <div> {this.renderWarning(this.context.t('syncWithMobileBeCareful'))} <div className="reveal-seed__content"> {this.renderRevealSeedContent()} </div> </div> ); } renderPasswordPromptContent() { const { t } = this.context; return ( <form onSubmit={(event) => this.handleSubmit(event)}> <label className="input-label" htmlFor="password-box"> {t('enterPasswordContinue')} </label> <div className="input-group"> <input type="password" placeholder={t('password')} id="password-box" value={this.state.password} onChange={(event) => this.setState({ password: event.target.value }) } className={classnames('form-control', { 'form-control--error': this.state.error, })} /> </div> {this.state.error && ( <div className="reveal-seed__error">{this.state.error}</div> )} </form> ); } renderRevealSeedContent() { const qrImage = qrCode(0, 'M'); qrImage.addData( `metamask-sync:${this.state.channelName}|@|${this.state.cipherKey}`, ); qrImage.make(); const { t } = this.context; return ( <div> <label className="reveal-seed__label" style={{ width: '100%', textAlign: 'center', }} > {t('syncWithMobileScanThisCode')} </label> <div style={{ display: 'flex', justifyContent: 'center', }} dangerouslySetInnerHTML={{ __html: qrImage.createTableTag(4), }} /> </div> ); } renderFooter() { return this.state.screen === PASSWORD_PROMPT_SCREEN ? this.renderPasswordPromptFooter() : this.renderRevealSeedFooter(); } renderPasswordPromptFooter() { const { t } = this.context; const { password } = this.state; return ( <div className="new-account-import-form__buttons" style={{ padding: '30px 15px 30px 15px', marginTop: 0 }} > <Button type="secondary" large className="new-account-create-form__button" onClick={() => this.goBack()} > {t('cancel')} </Button> <Button type="primary" large className="new-account-create-form__button" onClick={(event) => this.handleSubmit(event)} disabled={password === ''} > {t('next')} </Button> </div> ); } renderRevealSeedFooter() { const { t } = this.context; return ( <div className="page-container__footer" style={{ padding: 30 }}> <Button type="secondary" large className="page-container__footer-button" onClick={() => this.goBack()} > {t('close')} </Button> </div> ); } render() { const { t } = this.context; const { screen } = this.state; return ( <div className="page-container"> <div className="page-container__header"> <div className="page-container__title"> {t('syncWithMobileTitle')} </div> {screen === PASSWORD_PROMPT_SCREEN ? ( <div className="page-container__subtitle"> {t('syncWithMobileDesc')} </div> ) : null} {screen === PASSWORD_PROMPT_SCREEN ? ( <div className="page-container__subtitle"> {t('syncWithMobileDescNewUsers')} </div> ) : null} </div> <div className="page-container__content">{this.renderContent()}</div> {this.renderFooter()} </div> ); } }