Add ability to show notices to user & get confirmation.

Implement generation of markdown for notice files.
Create npm command. Enhance notice generation.
Add test files to test multiple notices.
Add basic markdown support to notices.
Interval checks for updates.
Add extensionizer and linker
Add terms and conditions state file
Add link support to disclaimer.
Changelog addition.
This commit is contained in:
Kevin Serrano 2016-12-07 14:34:15 -08:00 committed by Dan Finlay
parent d5569781ba
commit 8819475a2e
17 changed files with 554 additions and 4 deletions

View File

@ -9,6 +9,8 @@
## 2.13.11 2016-11-23
- Add support for synchronous RPC method "eth_uninstallFilter".
- Add support for notices.
- Add support for working links.
## 2.13.10 2016-11-22

View File

@ -2,6 +2,7 @@ const Migrator = require('pojo-migrator')
const MetamaskConfig = require('../config.js')
const migrations = require('./migrations')
const rp = require('request-promise')
const notices = require('../../../development/notices.json')
const TESTNET_RPC = MetamaskConfig.network.testnet
const MAINNET_RPC = MetamaskConfig.network.mainnet
@ -161,6 +162,69 @@ ConfigManager.prototype.setData = function (data) {
this.migrator.saveData(data)
}
//
// Notices
//
ConfigManager.prototype.getNoticesList = function () {
var data = this.getData()
if ('noticesList' in data) {
return data.noticesList
} else {
return []
}
}
ConfigManager.prototype.setNoticesList = function (list) {
var data = this.getData()
data.noticesList = list
this.setData(data)
return Promise.resolve(true)
}
ConfigManager.prototype.markNoticeRead = function (notice) {
var notices = this.getNoticesList()
var id = notice.id
notices[id].read = true
this.setNoticesList(notices)
}
ConfigManager.prototype.updateNoticesList = function () {
return this._retrieveNoticeData().then((newNotices) => {
var oldNotices = this.getNoticesList()
var combinedNotices = this._mergeNotices(oldNotices, newNotices)
return Promise.resolve(this.setNoticesList(combinedNotices))
})
}
ConfigManager.prototype.getLatestUnreadNotice = function () {
var notices = this.getNoticesList()
var filteredNotices = notices.filter((notice) => {
return notice.read === false
})
return filteredNotices[filteredNotices.length - 1]
}
ConfigManager.prototype._mergeNotices = function (oldNotices, newNotices) {
var noticeMap = this._mapNoticeIds(oldNotices)
newNotices.forEach((notice) => {
if (noticeMap.indexOf(notice.id) === -1) {
oldNotices.push(notice)
}
})
return oldNotices
}
ConfigManager.prototype._mapNoticeIds = function (notices) {
return notices.map((notice) => notice.id)
}
ConfigManager.prototype._retrieveNoticeData = function () {
// Placeholder for the API.
return Promise.resolve(notices)
}
//
// Tx
//

View File

@ -2,6 +2,7 @@ const extend = require('xtend')
const EthStore = require('eth-store')
const MetaMaskProvider = require('web3-provider-engine/zero.js')
const IdentityStore = require('./lib/idStore')
const NoticeController = require('./notice-controller')
const messageManager = require('./lib/message-manager')
const HostStore = require('./lib/remote-store.js').HostStore
const Web3 = require('web3')
@ -17,6 +18,9 @@ module.exports = class MetamaskController {
this.idStore = new IdentityStore({
configManager: this.configManager,
})
this.noticeController = new NoticeController({
configManager: this.configManager,
})
this.provider = this.initializeProvider(opts)
this.ethStore = new EthStore(this.provider)
this.idStore.setStore(this.ethStore)
@ -27,17 +31,19 @@ module.exports = class MetamaskController {
this.configManager.setCurrentFiat(currentFiat)
this.configManager.updateConversionRate()
this.checkNotices()
this.checkTOSChange()
this.scheduleConversionInterval()
this.scheduleNoticeCheck()
}
getState () {
return extend(
this.ethStore.getState(),
this.idStore.getState(),
this.configManager.getConfig()
this.configManager.getConfig(),
this.noticeController.getState()
)
}
@ -55,6 +61,7 @@ module.exports = class MetamaskController {
agreeToEthWarning: this.agreeToEthWarning.bind(this),
setTOSHash: this.setTOSHash.bind(this),
checkTOSChange: this.checkTOSChange.bind(this),
checkNotices: this.checkNotices.bind(this),
setGasMultiplier: this.setGasMultiplier.bind(this),
// forward directly to idStore
@ -77,6 +84,8 @@ module.exports = class MetamaskController {
buyEth: this.buyEth.bind(this),
// shapeshift
createShapeShiftTx: this.createShapeShiftTx.bind(this),
// notices
markNoticeRead: this.markNoticeRead.bind(this),
}
}
@ -289,6 +298,25 @@ module.exports = class MetamaskController {
}
checkNotices () {
try {
this.configManager.updateNoticesList()
} catch (e) {
console.error('Error in checking notices.')
}
}
// notice
markNoticeRead (notice, cb) {
try {
this.configManager.markNoticeRead(notice)
cb(null, this.configManager.getLatestUnreadNotice())
} catch (e) {
cb(e)
}
}
agreeToDisclaimer (cb) {
try {
this.configManager.setConfirmed(true)
@ -331,6 +359,7 @@ module.exports = class MetamaskController {
}, 300000)
}
<<<<<<< HEAD
agreeToEthWarning (cb) {
try {
this.configManager.setShouldntShowWarning()
@ -338,6 +367,15 @@ module.exports = class MetamaskController {
} catch (e) {
cb(e)
}
=======
scheduleNoticeCheck () {
if (this.noticeCheck) {
clearInterval(this.noticeCheck)
}
this.noticeCheck = setInterval(() => {
this.configManager.updateNoticesList()
}, 300000)
>>>>>>> 25acad7... Add ability to show notices to user & get confirmation.
}
// called from popup

View File

@ -0,0 +1,18 @@
const EventEmitter = require('events').EventEmitter
module.exports = class NoticeController extends EventEmitter {
constructor (opts) {
super()
this.configManager = opts.configManager
}
getState() {
var lastUnreadNotice = this.configManager.getLatestUnreadNotice()
return {
lastUnreadNotice: lastUnreadNotice,
noActiveNotices: !lastUnreadNotice,
}
}
}

View File

@ -0,0 +1,36 @@
var fsp = require('fs-promise')
var path = require('path')
var prompt = require('prompt')
var open = require('open')
var extend = require('extend')
var notices = require('./notices.json')
var id = 0
var date = new Date().toDateString()
var notice = {
read: false,
date: date,
}
fsp.readdir('notices')
.then((files) => {
files.forEach(file => { id ++ })
Promise.resolve()
}).then(() => {
fsp.writeFile(`notices/notice_${id}.md`,'Message goes here. Please write out your notice and save before proceeding at the command line.')
.then(() => {
open(`notices/notice_${id}.md`)
prompt.start()
prompt.get(['title'], (err, result) => {
notice.title = result.title
fsp.readFile(`notices/notice_${id}.md`)
.then((body) => {
notice.body = body.toString()
notice.id = id
notices.push(notice)
return fsp.writeFile(`development/notices.json`, JSON.stringify(notices))
})
})
})
})

1
development/notices.json Normal file
View File

@ -0,0 +1 @@
[{"read":false,"date":"Tue Dec 13 2016","title":"MultiVault Support","body":"# Multi\n# Line\n## Support\n\n### MultiVault declaration\n\nThe winds of change are coming.\n","id":0},{"read":false,"date":"Tue Dec 13 2016","title":"Rocket League!","body":"MetaMask development is now purely going eSports, as we convert our team into a professional Rocket League team.\n\nWe are now hiring for a fifth player! Please submit your CV. Must be proficient in both eSports and JavaScript debugging.\n","id":1},{"read":false,"date":"Thu Dec 15 2016","title":"Link Test","body":"This is a cool notice with a [link](https://metamask.io)!","id":2}]

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,63 @@
{
"metamask": {
"isInitialized": true,
"isUnlocked": true,
"rpcTarget": "https://rawtestrpc.metamask.io/",
"identities": {
"0x24a1d059462456aa332d6da9117aa7f91a46f2ac": {
"address": "0x24a1d059462456aa332d6da9117aa7f91a46f2ac",
"name": "Account 1"
}
},
"unconfTxs": {},
"currentFiat": "USD",
"conversionRate": 8.3533002,
"conversionDate": 1481671082,
"noActiveNotices": false,
"lastUnreadNotice": {
"read": false,
"date": "Tue Dec 13 2016",
"title": "MultiVault Support",
"body": "# Multi\n# Line\n## Support\nLorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi tincidunt dapibus justo a auctor. Sed luctus metus non mi laoreet, sit amet placerat nibh ultricies. Cras fringilla, urna sit amet sodales porttitor, lacus risus lacinia lorem, non euismod magna felis id ex. Nam iaculis, ante nec imperdiet suscipit, nisi quam fringilla nisl, sed fringilla turpis lectus et nibh. Pellentesque sed neque pretium nulla elementum lacinia eu eget felis. Nulla facilisi. Pellentesque id mi tempor, tempus sapien id, ultricies nibh. Integer faucibus elit non orci dapibus porttitor. Pellentesque rutrum hendrerit sapien ut lacinia. Nunc elementum eget arcu eu volutpat. Integer ullamcorper aliquam metus, eu malesuada tellus vestibulum a.\n",
"id": 0
},
"network": "3",
"accounts": {
"0x24a1d059462456aa332d6da9117aa7f91a46f2ac": {
"code": "0x",
"nonce": "0x0",
"balance": "0x0",
"address": "0x24a1d059462456aa332d6da9117aa7f91a46f2ac"
}
},
"transactions": [],
"provider": {
"type": "testnet"
},
"selectedAccount": "0x24a1d059462456aa332d6da9117aa7f91a46f2ac",
"seedWords": null,
"isDisclaimerConfirmed": true,
"unconfMsgs": {},
"messages": [],
"shapeShiftTxList": [],
"keyringTypes": [
"Simple Key Pair",
"HD Key Tree"
]
},
"appState": {
"menuOpen": false,
"currentView": {
"name": "accountDetail",
"detailView": null,
"context": "0x24a1d059462456aa332d6da9117aa7f91a46f2ac"
},
"accountDetail": {
"subview": "transactions"
},
"transForward": true,
"isLoading": false,
"warning": null
},
"identities": {}
}

View File

@ -0,0 +1,40 @@
{
"metamask": {
"isInitialized": false,
"isUnlocked": false,
"rpcTarget": "https://rawtestrpc.metamask.io/",
"identities": {},
"unconfTxs": {},
"currentFiat": "USD",
"conversionRate": 8.18703468,
"conversionDate": 1481755832,
"network": "3",
"accounts": {},
"transactions": [],
"provider": {
"type": "testnet"
},
"isDisclaimerConfirmed": false,
"unconfMsgs": {},
"messages": [],
"shapeShiftTxList": [],
"keyringTypes": [
"Simple Key Pair",
"HD Key Tree"
]
},
"appState": {
"menuOpen": false,
"currentView": {
"name": "accounts",
"detailView": null
},
"accountDetail": {
"subview": "transactions"
},
"transForward": true,
"isLoading": false,
"warning": null
},
"identities": {}
}

View File

@ -16,7 +16,8 @@
"buildMock": "browserify ./mock-dev.js -o ./development/bundle.js",
"testem": "npm run buildMock && testem",
"ci": "npm run buildMock && testem ci -P 2",
"announce": "node development/announcer.js"
"announce": "node development/announcer.js",
"generateNotice": "node development/notice-generator.js"
},
"browserify": {
"transform": [
@ -47,6 +48,8 @@
"ethereumjs-tx": "^1.0.0",
"ethereumjs-util": "^4.4.0",
"express": "^4.14.0",
"extension-link-enabler": "^1.0.0",
"extensionizer": "^1.0.0",
"gulp-eslint": "^2.0.0",
"hat": "0.0.3",
"identicon.js": "^1.2.1",
@ -97,6 +100,7 @@
"chai": "^3.5.0",
"deep-freeze-strict": "^1.1.1",
"del": "^2.2.0",
"fs-promise": "^1.0.0",
"gulp": "github:gulpjs/gulp#4.0",
"gulp-brfs": "^0.1.0",
"gulp-if": "^2.0.1",
@ -116,6 +120,8 @@
"mocha-jsdom": "^1.1.0",
"mocha-sinon": "^1.1.5",
"nock": "^8.0.0",
"open": "0.0.5",
"prompt": "^1.0.0",
"qs": "^6.2.0",
"qunit": "^0.9.1",
"sinon": "^1.17.3",

View File

@ -3,6 +3,7 @@ const extend = require('xtend')
const STORAGE_KEY = 'metamask-persistance-key'
var configManagerGen = require('../lib/mock-config-manager')
var configManager
var testList
const rp = require('request-promise')
const nock = require('nock')
@ -13,6 +14,100 @@ describe('config-manager', function() {
configManager = configManagerGen()
})
describe('notices', function() {
describe('#getNoticesList', function() {
it('should return an empty array when new', function() {
var testList = [{
id:0,
read:false,
title:"Futuristic Notice"
}]
var result = configManager.getNoticesList()
assert.equal(result.length, 0)
})
})
describe('#setNoticesList', function() {
it('should set data appropriately', function () {
var testList = [{
id:0,
read:false,
title:"Futuristic Notice"
}]
configManager.setNoticesList(testList)
var testListId = configManager.getNoticesList()[0].id
assert.equal(testListId, 0)
})
})
describe('#updateNoticeslist', function() {
it('should integrate the latest changes from the source', function() {
var testList = [{
id:55,
read:false,
title:"Futuristic Notice"
}]
configManager.setNoticesList(testList)
configManager.updateNoticesList().then(() => {
var newList = configManager.getNoticesList()
assert.ok(newList[0].id === 55)
assert.ok(newList[1])
})
})
it('should not overwrite any existing fields', function () {
var testList = [{
id:0,
read:false,
title:"Futuristic Notice"
}]
configManager.setNoticesList(testList)
configManager.updateNoticesList().then(() => {
var newList = configManager.getNoticesList()
assert.equal(newList[0].id, 0)
assert.equal(newList[0].title, "Futuristic Notice")
assert.equal(newList.length, 1)
})
})
})
describe('#markNoticeRead', function () {
it('should mark a notice as read', function () {
var testList = [{
id:0,
read:false,
title:"Futuristic Notice"
}]
configManager.setNoticesList(testList)
configManager.markNoticeRead(testList[0])
var newList = configManager.getNoticesList()
assert.ok(newList[0].read)
})
})
describe('#getLatestUnreadNotice', function () {
it('should retrieve the latest unread notice', function () {
var testList = [
{id:0,read:true,title:"Past Notice"},
{id:1,read:false,title:"Current Notice"},
{id:2,read:false,title:"Future Notice"},
]
configManager.setNoticesList(testList)
var latestUnread = configManager.getLatestUnreadNotice()
assert.equal(latestUnread.id, 2)
})
it('should return undefined if no unread notices exist.', function () {
var testList = [
{id:0,read:true,title:"Past Notice"},
{id:1,read:true,title:"Current Notice"},
{id:2,read:true,title:"Future Notice"},
]
configManager.setNoticesList(testList)
var latestUnread = configManager.getLatestUnreadNotice()
assert.ok(!latestUnread)
})
})
})
describe('currency conversions', function() {
describe('#getCurrentFiat', function() {

View File

@ -7,6 +7,13 @@ var actions = {
// remote state
UPDATE_METAMASK_STATE: 'UPDATE_METAMASK_STATE',
updateMetamaskState: updateMetamaskState,
// notices
MARK_NOTICE_READ: 'MARK_NOTICE_READ',
markNoticeRead: markNoticeRead,
SHOW_NOTICE: 'SHOW_NOTICE',
showNotice: showNotice,
CLEAR_NOTICES: 'CLEAR_NOTICES',
clearNotices: clearNotices,
// intialize screen
AGREE_TO_DISCLAIMER: 'AGREE_TO_DISCLAIMER',
agreeToDisclaimer: agreeToDisclaimer,
@ -519,6 +526,43 @@ function goBackToInitView () {
}
}
//
// notice
//
function markNoticeRead (notice) {
return (dispatch) => {
dispatch(this.showLoadingIndication())
background.markNoticeRead(notice, (err, notice) => {
dispatch(this.hideLoadingIndication())
if (err) {
return dispatch(actions.showWarning(err))
}
if (notice) {
return dispatch(actions.showNotice(notice))
} else {
dispatch(this.clearNotices())
return {
type: actions.SHOW_ACCOUNTS_PAGE,
}
}
})
}
}
function showNotice (notice) {
return {
type: actions.SHOW_NOTICE,
value: notice,
}
}
function clearNotices () {
return {
type: actions.CLEAR_NOTICES,
}
}
//
// config
//

View File

@ -17,6 +17,8 @@ const AccountsScreen = require('./accounts')
const AccountDetailScreen = require('./account-detail')
const SendTransactionScreen = require('./send')
const ConfirmTxScreen = require('./conf-tx')
// notice
const NoticeScreen = require('./notice')
// other views
const ConfigScreen = require('./config')
const RevealSeedConfirmation = require('./recover-seed/confirmation')
@ -41,6 +43,7 @@ function mapStateToProps (state) {
isLoading: state.appState.isLoading,
isConfirmed: state.metamask.isConfirmed,
isEthConfirmed: state.metamask.isEthConfirmed,
noActiveNotices: state.metamask.noActiveNotices,
isInitialized: state.metamask.isInitialized,
isUnlocked: state.metamask.isUnlocked,
currentView: state.appState.currentView,
@ -425,6 +428,10 @@ App.prototype.renderPrimary = function () {
return h(UnlockScreen, {key: 'locked'})
}
if (!props.noActiveNotices) {
return h(NoticeScreen, {key: 'NoticeScreen'})
}
// show current view
switch (props.currentView.name) {
case 'EthStoreWarning':

View File

@ -6,6 +6,8 @@ const actions = require('../actions')
const ReactMarkdown = require('react-markdown')
const fs = require('fs')
const path = require('path')
const linker = require('extension-link-enabler')
const findDOMNode = require('react-dom').findDOMNode
const disclaimer = fs.readFileSync(path.join(__dirname, '..', '..', '..', 'USER_AGREEMENT.md')).toString()
module.exports = connect(mapStateToProps)(DisclaimerScreen)
@ -98,3 +100,13 @@ DisclaimerScreen.prototype.render = function () {
])
)
}
DisclaimerScreen.prototype.componentDidMount = function () {
var node = findDOMNode(this)
linker.setupListener(node)
}
DisclaimerScreen.prototype.componentWillUnmount = function () {
var node = findDOMNode(this)
linker.teardownListener(node)
}

105
ui/app/notice.js Normal file
View File

@ -0,0 +1,105 @@
const inherits = require('util').inherits
const Component = require('react').Component
const h = require('react-hyperscript')
const ReactMarkdown = require('react-markdown')
const connect = require('react-redux').connect
const actions = require('./actions')
const linker = require('extension-link-enabler')
const findDOMNode = require('react-dom').findDOMNode
module.exports = connect(mapStateToProps)(Notice)
function mapStateToProps (state) {
return {
lastUnreadNotice: state.metamask.lastUnreadNotice,
}
}
inherits(Notice, Component)
function Notice () {
Component.call(this)
}
Notice.prototype.render = function () {
const props = this.props
const title = props.lastUnreadNotice.title
return (
h('.flex-column.flex-center.flex-grow', [
h('h3.flex-center.text-transform-uppercacse.terms-header', {
style: {
background: '#EBEBEB',
color: '#AEAEAE',
marginBottom: 24,
width: '100%',
fontSize: '20px',
textAlign: 'center',
padding: 6,
},
}, [
title,
]),
h('style', `
.markdown {
overflow-x: hidden;
}
.markdown h1, .markdown h2, .markdown h3 {
margin: 10px 0;
font-weight: bold;
}
.markdown strong {
font-weight: bold;
}
.markdown em {
font-style: italic;
}
.markdown p {
margin: 10px 0;
}
.markdown a {
color: blue;
}
`),
h('div.markdown', {
style: {
background: 'rgb(235, 235, 235)',
height: '310px',
padding: '6px',
width: '90%',
overflowY: 'scroll',
scroll: 'auto',
},
}, [
`${props.lastUnreadNotice.title}`,
h(ReactMarkdown, {
source: props.lastUnreadNotice.body,
skipHtml: true,
}),
]),
h('button', {
onClick: () => props.dispatch(actions.markNoticeRead(props.lastUnreadNotice)),
style: {
marginTop: '18px',
},
}, 'Continue'),
])
)
}
Notice.prototype.componentDidMount = function () {
var node = findDOMNode(this)
linker.setupListener(node)
}
Notice.prototype.componentWillUnmount = function () {
var node = findDOMNode(this)
linker.teardownListener(node)
}

View File

@ -229,6 +229,12 @@ function reduceApp (state, action) {
scrollToBottom: false,
})
case actions.SHOW_NOTICE:
return extend(appState, {
transForward: true,
isLoading: false,
})
case actions.REVEAL_ACCOUNT:
return extend(appState, {
scrollToBottom: true,

View File

@ -17,6 +17,8 @@ function reduceMetamask (state, action) {
currentFiat: 'USD',
conversionRate: 0,
conversionDate: 'N/A',
noActiveNotices: true,
lastUnreadNotice: undefined,
}, state.metamask)
switch (action.type) {
@ -26,6 +28,17 @@ function reduceMetamask (state, action) {
delete newState.seedWords
return newState
case actions.SHOW_NOTICE:
return extend(metamaskState, {
noActiveNotices: false,
lastUnreadNotice: action.value,
})
case actions.CLEAR_NOTICES:
return extend(metamaskState, {
noActiveNotices: true,
})
case actions.UPDATE_METAMASK_STATE:
return extend(metamaskState, action.value)