Feature Flag + Mobile Sync (#5955)
parent
fdc7eb2113
commit
f507f2a927
@ -0,0 +1,10 @@ |
||||
# Secret Preferences |
||||
|
||||
Sometimes we want to test a feature in the wild that may not be ready for public consumption. |
||||
|
||||
One example is our "sync with mobile" feature, which didn't make sense to roll out before the mobile version was live. |
||||
|
||||
To enable features like this, first open the background console, and then you can use the global method `global.setPreference(key, value)`. |
||||
|
||||
For example, if the feature flag was a booelan was called `mobileSync`, you might type `setPreference('mobileSync', true)`. |
||||
|
@ -0,0 +1,387 @@ |
||||
const { Component } = require('react') |
||||
const { connect } = require('react-redux') |
||||
const PropTypes = require('prop-types') |
||||
const h = require('react-hyperscript') |
||||
const classnames = require('classnames') |
||||
const PubNub = require('pubnub') |
||||
|
||||
const { requestRevealSeedWords, fetchInfoToSync } = require('../../../actions') |
||||
const { DEFAULT_ROUTE } = require('../../../routes') |
||||
const actions = require('../../../actions') |
||||
|
||||
const qrCode = require('qrcode-generator') |
||||
|
||||
import Button from '../../button' |
||||
import LoadingScreen from '../../loading-screen' |
||||
|
||||
const PASSWORD_PROMPT_SCREEN = 'PASSWORD_PROMPT_SCREEN' |
||||
const REVEAL_SEED_SCREEN = 'REVEAL_SEED_SCREEN' |
||||
|
||||
class MobileSyncPage extends Component { |
||||
static propTypes = { |
||||
history: PropTypes.object, |
||||
selectedAddress: PropTypes.string, |
||||
displayWarning: PropTypes.func, |
||||
fetchInfoToSync: PropTypes.func, |
||||
requestRevealSeedWords: PropTypes.func, |
||||
} |
||||
|
||||
constructor (props) { |
||||
super(props) |
||||
|
||||
this.state = { |
||||
screen: PASSWORD_PROMPT_SCREEN, |
||||
password: '', |
||||
seedWords: null, |
||||
error: null, |
||||
syncing: false, |
||||
completed: false, |
||||
} |
||||
|
||||
this.syncing = false |
||||
} |
||||
|
||||
componentDidMount () { |
||||
const passwordBox = document.getElementById('password-box') |
||||
if (passwordBox) { |
||||
passwordBox.focus() |
||||
} |
||||
} |
||||
|
||||
handleSubmit (event) { |
||||
event.preventDefault() |
||||
this.setState({ seedWords: null, error: null }) |
||||
this.props.requestRevealSeedWords(this.state.password) |
||||
.then(seedWords => { |
||||
this.generateCipherKeyAndChannelName() |
||||
this.setState({ seedWords, screen: REVEAL_SEED_SCREEN }) |
||||
this.initWebsockets() |
||||
}) |
||||
.catch(error => this.setState({ error: error.message })) |
||||
} |
||||
|
||||
generateCipherKeyAndChannelName () { |
||||
this.cipherKey = `${this.props.selectedAddress.substr(-4)}-${PubNub.generateUUID()}` |
||||
this.channelName = `mm-${PubNub.generateUUID()}` |
||||
} |
||||
|
||||
initWebsockets () { |
||||
this.pubnub = new PubNub({ |
||||
subscribeKey: process.env.PUBNUB_SUB_KEY, |
||||
publishKey: process.env.PUBNUB_PUB_KEY, |
||||
cipherKey: this.cipherKey, |
||||
ssl: true, |
||||
}) |
||||
|
||||
this.pubnubListener = this.pubnub.addListener({ |
||||
message: (data) => { |
||||
const {channel, message} = data |
||||
// handle message
|
||||
if (channel !== this.channelName || !message) { |
||||
return false |
||||
} |
||||
|
||||
if (message.event === 'start-sync') { |
||||
this.startSyncing() |
||||
} else if (message.event === 'end-sync') { |
||||
this.disconnectWebsockets() |
||||
this.setState({syncing: false, completed: true}) |
||||
} |
||||
}, |
||||
}) |
||||
|
||||
this.pubnub.subscribe({ |
||||
channels: [this.channelName], |
||||
withPresence: false, |
||||
}) |
||||
|
||||
} |
||||
|
||||
disconnectWebsockets () { |
||||
if (this.pubnub && this.pubnubListener) { |
||||
this.pubnub.disconnect(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) |
||||
for (let i = 0, o = 0; i < numChunks; ++i, o += size) { |
||||
chunks[i] = str.substr(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) { |
||||
resolve() |
||||
} else { |
||||
reject(response) |
||||
} |
||||
}) |
||||
}) |
||||
} |
||||
|
||||
async startSyncing () { |
||||
if (this.syncing) return false |
||||
this.syncing = true |
||||
this.setState({syncing: true}) |
||||
|
||||
const { accounts, network, preferences, transactions } = await this.props.fetchInfoToSync() |
||||
|
||||
const allDataStr = JSON.stringify({ |
||||
accounts, |
||||
network, |
||||
preferences, |
||||
transactions, |
||||
udata: { |
||||
pwd: this.state.password, |
||||
seed: this.state.seedWords, |
||||
}, |
||||
}) |
||||
|
||||
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('Sync failed :(') |
||||
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) { |
||||
resolve() |
||||
} else { |
||||
reject(response) |
||||
} |
||||
} |
||||
) |
||||
}) |
||||
} |
||||
|
||||
|
||||
componentWillUnmount () { |
||||
this.disconnectWebsockets() |
||||
} |
||||
|
||||
renderWarning (text) { |
||||
return ( |
||||
h('.page-container__warning-container', [ |
||||
h('.page-container__warning-message', [ |
||||
h('div', [text]), |
||||
]), |
||||
]) |
||||
) |
||||
} |
||||
|
||||
renderContent () { |
||||
const { t } = this.context |
||||
|
||||
if (this.state.syncing) { |
||||
return h(LoadingScreen, {loadingMessage: 'Sync in progress'}) |
||||
} |
||||
|
||||
if (this.state.completed) { |
||||
return h('div.reveal-seed__content', {}, |
||||
h('label.reveal-seed__label', { |
||||
style: { |
||||
width: '100%', |
||||
textAlign: 'center', |
||||
}, |
||||
}, t('syncWithMobileComplete')), |
||||
) |
||||
} |
||||
|
||||
return this.state.screen === PASSWORD_PROMPT_SCREEN |
||||
? h('div', {}, [ |
||||
this.renderWarning(this.context.t('mobileSyncText')), |
||||
h('.reveal-seed__content', [ |
||||
this.renderPasswordPromptContent(), |
||||
]), |
||||
]) |
||||
: h('div', {}, [ |
||||
this.renderWarning(this.context.t('syncWithMobileBeCareful')), |
||||
h('.reveal-seed__content', [ this.renderRevealSeedContent() ]), |
||||
]) |
||||
} |
||||
|
||||
renderPasswordPromptContent () { |
||||
const { t } = this.context |
||||
|
||||
return ( |
||||
h('form', { |
||||
onSubmit: event => this.handleSubmit(event), |
||||
}, [ |
||||
h('label.input-label', { |
||||
htmlFor: 'password-box', |
||||
}, t('enterPasswordContinue')), |
||||
h('.input-group', [ |
||||
h('input.form-control', { |
||||
type: 'password', |
||||
placeholder: t('password'), |
||||
id: 'password-box', |
||||
value: this.state.password, |
||||
onChange: event => this.setState({ password: event.target.value }), |
||||
className: classnames({ 'form-control--error': this.state.error }), |
||||
}), |
||||
]), |
||||
this.state.error && h('.reveal-seed__error', this.state.error), |
||||
]) |
||||
) |
||||
} |
||||
|
||||
renderRevealSeedContent () { |
||||
|
||||
const qrImage = qrCode(0, 'M') |
||||
qrImage.addData(`metamask-sync:${this.channelName}|@|${this.cipherKey}`) |
||||
qrImage.make() |
||||
|
||||
const { t } = this.context |
||||
return ( |
||||
h('div', [ |
||||
h('label.reveal-seed__label', { |
||||
style: { |
||||
width: '100%', |
||||
textAlign: 'center', |
||||
}, |
||||
}, t('syncWithMobileScanThisCode')), |
||||
h('.div.qr-wrapper', { |
||||
style: { |
||||
display: 'flex', |
||||
justifyContent: 'center', |
||||
}, |
||||
dangerouslySetInnerHTML: { |
||||
__html: qrImage.createTableTag(4), |
||||
}, |
||||
}), |
||||
]) |
||||
) |
||||
} |
||||
|
||||
renderFooter () { |
||||
return this.state.screen === PASSWORD_PROMPT_SCREEN |
||||
? this.renderPasswordPromptFooter() |
||||
: this.renderRevealSeedFooter() |
||||
} |
||||
|
||||
renderPasswordPromptFooter () { |
||||
return ( |
||||
h('div.new-account-import-form__buttons', {style: {padding: 30}}, [ |
||||
|
||||
h(Button, { |
||||
type: 'default', |
||||
large: true, |
||||
className: 'new-account-create-form__button', |
||||
onClick: () => this.props.history.push(DEFAULT_ROUTE), |
||||
}, this.context.t('cancel')), |
||||
|
||||
h(Button, { |
||||
type: 'primary', |
||||
large: true, |
||||
className: 'new-account-create-form__button', |
||||
onClick: event => this.handleSubmit(event), |
||||
disabled: this.state.password === '', |
||||
}, this.context.t('next')), |
||||
]) |
||||
) |
||||
} |
||||
|
||||
renderRevealSeedFooter () { |
||||
return ( |
||||
h('.page-container__footer', {style: {padding: 30}}, [ |
||||
h(Button, { |
||||
type: 'default', |
||||
large: true, |
||||
className: 'page-container__footer-button', |
||||
onClick: () => this.props.history.push(DEFAULT_ROUTE), |
||||
}, this.context.t('close')), |
||||
]) |
||||
) |
||||
} |
||||
|
||||
render () { |
||||
return ( |
||||
h('.page-container', [ |
||||
h('.page-container__header', [ |
||||
h('.page-container__title', this.context.t('syncWithMobileTitle')), |
||||
this.state.screen === PASSWORD_PROMPT_SCREEN ? h('.page-container__subtitle', this.context.t('syncWithMobileDesc')) : null, |
||||
this.state.screen === PASSWORD_PROMPT_SCREEN ? h('.page-container__subtitle', this.context.t('syncWithMobileDescNewUsers')) : null, |
||||
]), |
||||
h('.page-container__content', [ |
||||
this.renderContent(), |
||||
]), |
||||
this.renderFooter(), |
||||
]) |
||||
) |
||||
} |
||||
} |
||||
|
||||
MobileSyncPage.propTypes = { |
||||
requestRevealSeedWords: PropTypes.func, |
||||
fetchInfoToSync: PropTypes.func, |
||||
history: PropTypes.object, |
||||
} |
||||
|
||||
MobileSyncPage.contextTypes = { |
||||
t: PropTypes.func, |
||||
} |
||||
|
||||
const mapDispatchToProps = dispatch => { |
||||
return { |
||||
requestRevealSeedWords: password => dispatch(requestRevealSeedWords(password)), |
||||
fetchInfoToSync: () => dispatch(fetchInfoToSync()), |
||||
displayWarning: (message) => dispatch(actions.displayWarning(message || null)), |
||||
} |
||||
|
||||
} |
||||
|
||||
const mapStateToProps = state => { |
||||
const { |
||||
metamask: { selectedAddress }, |
||||
} = state |
||||
|
||||
return { |
||||
selectedAddress, |
||||
} |
||||
} |
||||
|
||||
module.exports = connect(mapStateToProps, mapDispatchToProps)(MobileSyncPage) |
Loading…
Reference in new issue