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