diff --git a/app/browser/reducers/ledgerReducer.js b/app/browser/reducers/ledgerReducer.js new file mode 100644 index 00000000000..adfa8830a00 --- /dev/null +++ b/app/browser/reducers/ledgerReducer.js @@ -0,0 +1,221 @@ +/* This SourceCode Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const Immutable = require('immutable') +const underscore = require('underscore') + +// Constants +const appConstants = require('../../../js/constants/appConstants') +const windowConstants = require('../../../js/constants/windowConstants') +const settings = require('../../../js/constants/settings') + +// State +const ledgerState = require('../../common/state/ledgerState') + +// Utils +const ledgerUtil = require('../../common/lib/ledgerUtil') +const {makeImmutable} = require('../../common/state/immutableUtil') +const getSetting = require('../../../js/settings').getSetting + +const ledgerReducer = (state, action, immutableAction) => { + action = immutableAction || makeImmutable(action) + switch (action.get('actionType')) { + case appConstants.APP_UPDATE_LEDGER_INFO: + { + state = state.setIn(['ledger', 'info'], action.get('ledgerInfo')) + break + } + // TODO refactor + case appConstants.APP_UPDATE_LOCATION_INFO: + { + state = state.setIn(['ledger', 'locations'], action.get('locationInfo')) + break + } + case appConstants.APP_LEDGER_RECOVERY_STATUS_CHANGED: + { + state = ledgerState.setRecoveryStatus(state, action.get('recoverySucceeded')) + break + } + case appConstants.APP_SET_STATE: + { + state = ledgerUtil.init(state) + break + } + case appConstants.APP_BACKUP_KEYS: + { + ledgerUtil.backupKeys(state, action.get('backupAction')) + break + } + case appConstants.APP_RECOVER_WALLET: + { + state = ledgerUtil.recoverKeys( + state, + action.get('useRecoveryKeyFile'), + action.get('firstRecoveryKey'), + action.get('secondRecoveryKey') + ) + break + } + case appConstants.APP_SHUTTING_DOWN: + { + state = ledgerUtil.quit(state) + break + } + case appConstants.APP_ON_CLEAR_BROWSING_DATA: + { + const defaults = state.get('clearBrowsingDataDefaults') + const temp = state.get('tempClearBrowsingData', Immutable.Map()) + const clearData = defaults ? defaults.merge(temp) : temp + if (clearData.get('browserHistory') && !getSetting(settings.PAYMENTS_ENABLED)) { + state = ledgerState.resetSynopsis(state) + } + break + } + // TODO not sure that we use APP_IDLE_STATE_CHANGED anymore + case appConstants.APP_IDLE_STATE_CHANGED: + { + state = ledgerUtil.pageDataChanged(state) + ledgerUtil.addVisit('NOOP', underscore.now(), null) + break + } + case appConstants.APP_CHANGE_SETTING: + { + switch (action.get('key')) { + case settings.PAYMENTS_ENABLED: + { + state = ledgerUtil.initialize(state, action.get('value')) + break + } + case settings.PAYMENTS_CONTRIBUTION_AMOUNT: + { + ledgerUtil.setPaymentInfo(action.get('value')) + break + } + case settings.PAYMENTS_MINIMUM_VISIT_TIME: + { + const value = action.get('value') + if (value <= 0) break + ledgerUtil.synopsis.options.minPublisherDuration = action.value + state = ledgerState.setSynopsisOption(state, 'minPublisherDuration', value) + break + } + case settings.PAYMENTS_MINIMUM_VISITS: + { + const value = action.get('value') + if (value <= 0) break + + ledgerUtil.synopsis.options.minPublisherVisits = value + state = ledgerState.setSynopsisOption(state, 'minPublisherVisits', value) + break + } + + case settings.PAYMENTS_ALLOW_NON_VERIFIED: + { + const value = action.get('value') + ledgerUtil.synopsis.options.showOnlyVerified = value + state = ledgerState.setSynopsisOption(state, 'showOnlyVerified', value) + break + } + } + break + } + case appConstants.APP_CHANGE_SITE_SETTING: + { + const pattern = action.get('hostPattern') + if (!pattern) { + console.warn('Changing site settings should always have a hostPattern') + break + } + const i = pattern.indexOf('://') + if (i === -1) break + + const publisherKey = pattern.substr(i + 3) + switch (action.get('key')) { + case 'ledgerPaymentsShown': + { + if (action.get('value') === false) { + delete ledgerUtil.synopsis.publishers[publisherKey] + state = ledgerState.deletePublishers(state, publisherKey) + state = ledgerUtil.updatePublisherInfo(state) + } + break + } + case 'ledgerPayments': + { + const publisher = ledgerState.getPublisher(state, publisherKey) + if (publisher.isEmpty()) { + break + } + state = ledgerUtil.updatePublisherInfo(state) + state = ledgerUtil.verifiedP(state, publisherKey) + break + } + case 'ledgerPinPercentage': + { + const publisher = ledgerState.getPublisher(state, publisherKey) + if (publisher.isEmpty()) { + break + } + + ledgerUtil.synopsis.publishers[publisherKey].pinPercentage = action.get('value') + state = ledgerUtil.updatePublisherInfo(state, publisherKey) + break + } + } + break + } + case appConstants.APP_REMOVE_SITE_SETTING: + { + const pattern = action.get('hostPattern') + if (!pattern) { + console.warn('Changing site settings should always have a hostPattern') + break + } + + const i = pattern.indexOf('://') + if (i === -1) break + + const publisherKey = pattern.substr(i + 3) + if (action.get('key') === 'ledgerPayments') { + const publisher = ledgerState.getPublisher(state, publisherKey) + if (publisher.isEmpty()) { + break + } + state = ledgerUtil.updatePublisherInfo(state) + } + break + } + case appConstants.APP_NETWORK_CONNECTED: + { + setTimeout((state) => { + ledgerUtil.networkConnected(state) + }, 1000, state) + break + } + case appConstants.APP_NAVIGATOR_HANDLER_REGISTERED: + { + const hasBitcoinHandler = (action.get('protocol') === 'bitcoin') + state = ledgerState.setInfoProp(state, 'hasBitcoinHandler', hasBitcoinHandler) + break + } + case appConstants.APP_NAVIGATOR_HANDLER_UNREGISTERED: + { + const hasBitcoinHandler = false + state = ledgerState.setInfoProp(state, 'hasBitcoinHandler', hasBitcoinHandler) + break + } + case 'event-set-page-info': + case appConstants.APP_WINDOW_BLURRED: + case appConstants.APP_CLOSE_WINDOW: + case windowConstants.WINDOW_SET_FOCUSED_FRAME: + case windowConstants.WINDOW_GOT_RESPONSE_DETAILS: + { + state = ledgerUtil.pageDataChanged(state) + break + } + } + return state +} + +module.exports = ledgerReducer diff --git a/app/browser/reducers/pageDataReducer.js b/app/browser/reducers/pageDataReducer.js new file mode 100644 index 00000000000..74eaf9df48e --- /dev/null +++ b/app/browser/reducers/pageDataReducer.js @@ -0,0 +1,90 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const electron = require('electron') +const BrowserWindow = electron.BrowserWindow + +// Constants +const appConstants = require('../../../js/constants/appConstants') +const windowConstants = require('../../../js/constants/windowConstants') + +// State +const pageDataState = require('../../common/state/pageDataState') + +// Utils +const {makeImmutable} = require('../../common/state/immutableUtil') +const {isSourceAboutUrl} = require('../../../js/lib/appUrlUtil') +const {responseHasContent} = require('../../common/lib/httpUtil') + +const pageDataReducer = (state, action, immutableAction) => { + action = immutableAction || makeImmutable(action) + switch (action.get('actionType')) { + case windowConstants.WINDOW_SET_FOCUSED_FRAME: + { + if (action.get('location')) { + state = pageDataState.addView(state, action.get('location'), action.get('tabId')) + } + break + } + case appConstants.APP_WINDOW_BLURRED: + { + let windowCount = BrowserWindow.getAllWindows().filter((win) => win.isFocused()).length + if (windowCount === 0) { + state = pageDataState.addView(state) + } + break + } + // TODO check if this is used anymore + case appConstants.APP_IDLE_STATE_CHANGED: + { + if (action.has('idleState') && action.get('idleState') !== 'active') { + state = pageDataState.addView(state) + } + break + } + case appConstants.APP_WINDOW_CLOSED: + { + state = pageDataState.addView(state) + break + } + case 'event-set-page-info': + { + // retains all past pages, not really sure that's needed... [MTR] + state = pageDataState.addInfo(state, action.get('pageInfo')) + break + } + case windowConstants.WINDOW_GOT_RESPONSE_DETAILS: + { + // Only capture response for the page (not subresources, like images, JavaScript, etc) + if (action.getIn(['details', 'resourceType']) === 'mainFrame') { + const pageUrl = action.getIn(['details', 'newURL']) + + // create a page view event if this is a page load on the active tabId + const lastActiveTabId = pageDataState.getLastActiveTabId(state) + const tabId = action.get('tabId') + if (!lastActiveTabId || tabId === lastActiveTabId) { + state = pageDataState.addView(state, pageUrl, tabId) + } + + const responseCode = action.getIn(['details', 'httpResponseCode']) + if (isSourceAboutUrl(pageUrl) || !responseHasContent(responseCode)) { + break + } + + const pageLoadEvent = makeImmutable({ + timestamp: new Date().getTime(), + url: pageUrl, + tabId: tabId, + details: action.get('details') + }) + state = pageDataState.addLoad(state, pageLoadEvent) + } + break + } + } + + return state +} + +module.exports = pageDataReducer diff --git a/app/browser/reducers/siteSettingsReducer.js b/app/browser/reducers/siteSettingsReducer.js new file mode 100644 index 00000000000..e0a7be29800 --- /dev/null +++ b/app/browser/reducers/siteSettingsReducer.js @@ -0,0 +1,86 @@ +/* This SourceCode Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict' +const Immutable = require('immutable') +const appConstants = require('../../../js/constants/appConstants') +const siteSettings = require('../../../js/state/siteSettings') +const urlUtil = require('../../../js/lib/urlutil') +const {makeImmutable} = require('../../common/state/immutableUtil') + +const siteSettingsReducer = (state, action, immutableAction) => { + action = immutableAction || makeImmutable(action) + switch (action.get('actionType')) { + case appConstants.APP_ALLOW_FLASH_ONCE: + { + const propertyName = action.get('isPrivate') ? 'temporarySiteSettings' : 'siteSettings' + state = state.set(propertyName, + siteSettings.mergeSiteSetting(state.get(propertyName), urlUtil.getOrigin(action.get('url')), 'flash', 1)) + break + } + case appConstants.APP_ALLOW_FLASH_ALWAYS: + { + const propertyName = action.get('isPrivate') ? 'temporarySiteSettings' : 'siteSettings' + const expirationTime = Date.now() + (7 * 24 * 3600 * 1000) + state = state.set(propertyName, + siteSettings.mergeSiteSetting(state.get(propertyName), urlUtil.getOrigin(action.get('url')), 'flash', expirationTime)) + break + } + case appConstants.APP_CHANGE_SITE_SETTING: + { + let propertyName = action.get('temporary') ? 'temporarySiteSettings' : 'siteSettings' + let newSiteSettings = siteSettings.mergeSiteSetting(state.get(propertyName), action.get('hostPattern'), action.get('key'), action.get('value')) + if (action.get('skipSync')) { + newSiteSettings = newSiteSettings.setIn([action.get('hostPattern'), 'skipSync'], true) + } + state = state.set(propertyName, newSiteSettings) + break + } + case appConstants.APP_REMOVE_SITE_SETTING: + { + let propertyName = action.get('temporary') ? 'temporarySiteSettings' : 'siteSettings' + let newSiteSettings = siteSettings.removeSiteSetting(state.get(propertyName), + action.get('hostPattern'), action.get('key')) + if (action.get('skipSync')) { + newSiteSettings = newSiteSettings.setIn([action.get('hostPattern'), 'skipSync'], true) + } + state = state.set(propertyName, newSiteSettings) + break + } + case appConstants.APP_CLEAR_SITE_SETTINGS: + { + let propertyName = action.get('temporary') ? 'temporarySiteSettings' : 'siteSettings' + let newSiteSettings = new Immutable.Map() + state.get(propertyName).map((entry, hostPattern) => { + let newEntry = entry.delete(action.get('key')) + if (action.get('skipSync')) { + newEntry = newEntry.set('skipSync', true) + } + newSiteSettings = newSiteSettings.set(hostPattern, newEntry) + }) + state = state.set(propertyName, newSiteSettings) + break + } + case appConstants.APP_ADD_NOSCRIPT_EXCEPTIONS: + { + const origin = action.get('origins') + const hostPattern = action.get('hostPattern') + const propertyName = action.get('temporary') ? 'temporarySiteSettings' : 'siteSettings' + // Note that this is always cleared on restart or reload, so should not + // be synced or persisted. + const key = 'noScriptExceptions' + if (!origin || !origin.size) { + // Clear the exceptions + state = state.setIn([propertyName, hostPattern, key], new Immutable.Map()) + } else { + const currentExceptions = state.getIn([propertyName, hostPattern, key]) || new Immutable.Map() + state = state.setIn([propertyName, hostPattern, key], currentExceptions.merge(origin)) + } + break + } + } + return state +} + +module.exports = siteSettingsReducer diff --git a/app/browser/tabs.js b/app/browser/tabs.js index 5affb1fd8ea..8e6101337cb 100644 --- a/app/browser/tabs.js +++ b/app/browser/tabs.js @@ -538,7 +538,7 @@ const api = { tab.on('did-get-response-details', (evt, status, newURL, originalURL, httpResponseCode, requestMethod, referrer, headers, resourceType) => { if (resourceType === 'mainFrame') { - windowActions.gotResponseDetails(tabId, {status, newURL, originalURL, httpResponseCode, requestMethod, referrer, headers, resourceType}) + windowActions.gotResponseDetails(tabId, {status, newURL, originalURL, httpResponseCode, requestMethod, referrer, resourceType}) } }) }) diff --git a/app/common/lib/ledgerUtil.js b/app/common/lib/ledgerUtil.js index e788259bef2..89625ae8b92 100644 --- a/app/common/lib/ledgerUtil.js +++ b/app/common/lib/ledgerUtil.js @@ -4,44 +4,143 @@ 'use strict' -const {responseHasContent} = require('./httpUtil') +const acorn = require('acorn') const moment = require('moment') +const Immutable = require('immutable') +const electron = require('electron') +const path = require('path') +const os = require('os') +const qr = require('qr-image') +const underscore = require('underscore') +const tldjs = require('tldjs') +const urlFormat = require('url').format +const queryString = require('queryString') +const levelUp = require('level') +const random = require('random-lib') + +// Actions +const appActions = require('../../../js/actions/appActions') + +// State +const ledgerState = require('../state/ledgerState') +const pageDataState = require('../state/pageDataState') + +// Constants +const settings = require('../../../js/constants/settings') + +// Utils +const {responseHasContent} = require('./httpUtil') +const {makeImmutable} = require('../../common/state/immutableUtil') +const tabs = require('../../browser/tabs') +const locale = require('../../locale') +const siteSettingsState = require('../state/siteSettingsState') +const appConfig = require('../../../js/constants/appConfig') +const getSetting = require('../../../js/settings').getSetting +const {fileUrl} = require('../../../js/lib/appUrlUtil') +const urlParse = require('../urlParse') +const ruleSolver = require('../../extensions/brave/content/scripts/pageInformation') +const request = require('../../../js/lib/request') + +let ledgerPublisher +let ledgerClient +let ledgerBalance +let client +let locationDefault = 'NOOP' +let currentUrl = locationDefault +let currentTimestamp = new Date().getTime() +let visitsByPublisher = {} +let synopsis +let notificationTimeout +let runTimeoutId + +// Database +let v2RulesetDB +const v2RulesetPath = 'ledger-rulesV2.leveldb' +let v2PublishersDB +const v2PublishersPath = 'ledger-publishersV2.leveldb' +const statePath = 'ledger-state.json' + +const miliseconds = { + year: 365 * 24 * 60 * 60 * 1000, + week: 7 * 24 * 60 * 60 * 1000, + day: 24 * 60 * 60 * 1000, + hour: 60 * 60 * 1000, + minute: 60 * 1000, + second: 1000 +} + +const clientOptions = { + debugP: process.env.LEDGER_DEBUG, + loggingP: process.env.LEDGER_LOGGING, + rulesTestP: process.env.LEDGER_RULES_TESTING, + verboseP: process.env.LEDGER_VERBOSE, + server: process.env.LEDGER_SERVER_URL, + createWorker: electron.app.createWorker +} + +const ledgerInfo = { + _internal: { + paymentInfo: {} + } +} + +// TODO only temporally so that standard is happy +const publisherInfo = { + _internal: { + verboseP: true, + debugP: true, + enabled: false, + ruleset: { + raw: [], + cooked: [] + } + } +} /** * Is page an actual page being viewed by the user? (not an error page, etc) * If the page is invalid, we don't want to collect usage info. - * @param {Object} view - an entry from page_view (from EventStore) - * @param {Object} responseList - full page_response array (from EventStore) + * @param {Map} view - an entry from ['pageData', 'view'] + * @param {List} responseList - full ['pageData', 'load'] List * @return {boolean} true if page should have usage collected, false if not */ -module.exports.shouldTrackView = (view, responseList) => { - if (!view || !view.url || !view.tabId) { +const shouldTrackView = (view, responseList) => { + view = makeImmutable(view) + + if (view == null) { return false } - if (!responseList || !Array.isArray(responseList) || !responseList.length) { + + const tabId = view.get('tabId') + const url = view.get('url') + + if (!url || !tabId) { return false } - const tabId = view.tabId - const url = view.url + responseList = makeImmutable(responseList) + if (!responseList || responseList.size === 0) { + return false + } - for (let i = responseList.length; i > -1; i--) { - const response = responseList[i] + for (let i = (responseList.size - 1); i > -1; i--) { + const response = responseList.get(i) - if (!response) continue + if (!response) { + continue + } - const responseUrl = response && response.details - ? response.details.newURL - : null + const responseUrl = response.getIn(['details', 'newURL'], null) - if (url === responseUrl && response.tabId === tabId) { - return responseHasContent(response.details.httpResponseCode) + if (url === responseUrl && response.get('tabId') === tabId) { + return responseHasContent(response.getIn(['details', 'httpResponseCode'])) } } + return false } -module.exports.btcToCurrencyString = (btc, ledgerData) => { +const btcToCurrencyString = (btc, ledgerData) => { const balance = Number(btc || 0) const currency = ledgerData.get('currency') || 'USD' @@ -69,17 +168,17 @@ module.exports.btcToCurrencyString = (btc, ledgerData) => { return `${balance} BTC` } -module.exports.formattedTimeFromNow = (timestamp) => { +const formattedTimeFromNow = (timestamp) => { moment.locale(navigator.language) return moment(new Date(timestamp)).fromNow() } -module.exports.formattedDateFromTimestamp = (timestamp, format) => { +const formattedDateFromTimestamp = (timestamp, format) => { moment.locale(navigator.language) return moment(new Date(timestamp)).format(format) } -module.exports.walletStatus = (ledgerData) => { +const walletStatus = (ledgerData) => { let status = {} if (ledgerData.get('error')) { @@ -93,7 +192,7 @@ module.exports.walletStatus = (ledgerData) => { status.id = 'insufficientFundsStatus' } else if (pendingFunds > 0) { status.id = 'pendingFundsStatus' - status.args = {funds: module.exports.btcToCurrencyString(pendingFunds, ledgerData)} + status.args = {funds: btcToCurrencyString(pendingFunds, ledgerData)} } else if (transactions && transactions.size > 0) { status.id = 'defaultWalletStatus' } else { @@ -106,3 +205,1715 @@ module.exports.walletStatus = (ledgerData) => { } return status } + +const promptForRecoveryKeyFile = () => { + const defaultRecoveryKeyFilePath = path.join(electron.app.getPath('userData'), '/brave_wallet_recovery.txt') + let files + if (process.env.SPECTRON) { + // skip the dialog for tests + console.log(`for test, trying to recover keys from path: ${defaultRecoveryKeyFilePath}`) + files = [defaultRecoveryKeyFilePath] + } else { + const dialog = electron.dialog + files = dialog.showOpenDialog({ + properties: ['openFile'], + defaultPath: defaultRecoveryKeyFilePath, + filters: [{ + name: 'TXT files', + extensions: ['txt'] + }] + }) + } + + return (files && files.length ? files[0] : null) +} + +const logError = (state, err, caller) => { + if (err) { + console.error('Error in %j: %j', caller, err) + state = ledgerState.setLedgerError(state, err, caller) + } else { + state = ledgerState.setLedgerError(state) + } + + return state +} + +const loadKeysFromBackupFile = (state, filePath) => { + let keys = null + const fs = require('fs') + let data = fs.readFileSync(filePath) + + if (!data || !data.length || !(data.toString())) { + state = logError(state, 'No data in backup file', 'recoveryWallet') + } else { + try { + const recoveryFileContents = data.toString() + + let messageLines = recoveryFileContents.split(os.EOL) + + let paymentIdLine = '' || messageLines[3] + let passphraseLine = '' || messageLines[4] + + const paymentIdPattern = new RegExp([locale.translation('ledgerBackupText3'), '([^ ]+)'].join(' ')) + const paymentId = (paymentIdLine.match(paymentIdPattern) || [])[1] + + const passphrasePattern = new RegExp([locale.translation('ledgerBackupText4'), '(.+)$'].join(' ')) + const passphrase = (passphraseLine.match(passphrasePattern) || [])[1] + + keys = { + paymentId, + passphrase + } + } catch (exc) { + state = logError(state, exc, 'recoveryWallet') + } + } + + return { + state, + keys + } +} + +const getPublisherData = (result, scorekeeper) => { + let duration = result.duration + + let data = { + verified: result.options.verified || false, + site: result.publisher, + views: result.visits, + duration: duration, + daysSpent: 0, + hoursSpent: 0, + minutesSpent: 0, + secondsSpent: 0, + faviconURL: result.faviconURL, + score: result.scores[scorekeeper], + pinPercentage: result.pinPercentage, + weight: result.pinPercentage + } + // HACK: Protocol is sometimes blank here, so default to http:// so we can + // still generate publisherURL. + data.publisherURL = (result.protocol || 'http:') + '//' + result.publisher + + if (duration >= miliseconds.day) { + data.daysSpent = Math.max(Math.round(duration / miliseconds.day), 1) + } else if (duration >= miliseconds.hour) { + data.hoursSpent = Math.max(Math.floor(duration / miliseconds.hour), 1) + data.minutesSpent = Math.round((duration % miliseconds.hour) / miliseconds.minute) + } else if (duration >= miliseconds.minute) { + data.minutesSpent = Math.max(Math.round(duration / miliseconds.minute), 1) + data.secondsSpent = Math.round((duration % miliseconds.minute) / miliseconds.second) + } else { + data.secondsSpent = Math.max(Math.round(duration / miliseconds.second), 1) + } + + return data +} + +const normalizePinned = (dataPinned, total, target, setOne) => { + return dataPinned.map((publisher) => { + let newPer + let floatNumber + + if (setOne) { + newPer = 1 + floatNumber = 1 + } else { + floatNumber = (publisher.pinPercentage / total) * target + newPer = Math.floor(floatNumber) + if (newPer < 1) { + newPer = 1 + } + } + + publisher.weight = floatNumber + publisher.pinPercentage = newPer + return publisher + }) +} + +// courtesy of https://stackoverflow.com/questions/13483430/how-to-make-rounded-percentages-add-up-to-100#13485888 +const roundToTarget = (l, target, property) => { + let off = target - underscore.reduce(l, (acc, x) => { return acc + Math.round(x[property]) }, 0) + + return underscore.sortBy(l, (x) => Math.round(x[property]) - x[property]) + .map((x, i) => { + x[property] = Math.round(x[property]) + (off > i) - (i >= (l.length + off)) + return x + }) +} + +// TODO rename function +const blockedP = (state, publisherKey) => { + const pattern = `https?://${publisherKey}` + const ledgerPaymentsShown = siteSettingsState.getSettingsProp(state, pattern, 'ledgerPaymentsShown') + + return ledgerPaymentsShown === false +} + +// TODO rename function +const stickyP = (state, publisherKey) => { + const pattern = `https?://${publisherKey}` + let result = siteSettingsState.getSettingsProp(state, pattern, 'ledgerPayments') + + // NB: legacy clean-up + if ((typeof result === 'undefined') && (typeof synopsis.publishers[publisherKey].options.stickyP !== 'undefined')) { + result = synopsis.publishers[publisherKey].options.stickyP + appActions.changeSiteSetting(pattern, 'ledgerPayments', result) + } + if (synopsis.publishers[publisherKey] && + synopsis.publishers[publisherKey].options && + synopsis.publishers[publisherKey].options.stickyP) { + delete synopsis.publishers[publisherKey].options.stickyP + } + + return (result === undefined || result) +} + +// TODO rename function +const eligibleP = (state, publisherKey) => { + if (!synopsis.options.minPublisherDuration && process.env.NODE_ENV !== 'test') { + // TODO make sure that appState has correct data in + synopsis.options.minPublisherDuration = getSetting(settings.PAYMENTS_MINIMUM_VISIT_TIME) + } + + const scorekeeper = ledgerState.getSynopsisOption(state, 'scorekeeper') + const minPublisherDuration = ledgerState.getSynopsisOption(state, 'minPublisherDuration') + const minPublisherVisits = ledgerState.getSynopsisOption(state, 'minPublisherVisits') + const publisher = ledgerState.getPublisher(state, publisherKey) + + return ( + publisher.getIn(['scores', scorekeeper]) > 0 && + publisher.get('duration') >= minPublisherDuration && + publisher.get('visits') >= minPublisherVisits + ) +} + +// TODO rename function +const visibleP = (state, publisherKey) => { + const publisher = ledgerState.getPublisher(state, publisherKey) + // TODO you stopped here + let showOnlyVerified = ledgerState.getSynopsisOption(state, 'showOnlyVerified') + if (showOnlyVerified == null) { + showOnlyVerified = getSetting(settings.PAYMENTS_ALLOW_NON_VERIFIED) + state = ledgerState.setSynopsisOption(state, 'showOnlyVerified', showOnlyVerified) + synopsis.options.showOnlyVerified = showOnlyVerified + } + + const publisherOptions = publisher.get('options', Immutable.Map()) + const onlyVerified = !showOnlyVerified + + // Publisher Options + const excludedByUser = blockedP(state, publisherKey) + const eligibleByPublisherToggle = stickyP(state, publisherKey) != null + const eligibleByNumberOfVisits = eligibleP(state, publisherKey) + const isInExclusionList = publisherOptions.get('exclude') + const verifiedPublisher = publisherOptions.get('verified') + + // websites not included in exclusion list are eligible by number of visits + // but can be enabled by user action in the publisher toggle + const isEligible = (eligibleByNumberOfVisits && !isInExclusionList) || eligibleByPublisherToggle + + // If user decide to remove the website, don't show it. + if (excludedByUser) { + return false + } + + // Unless user decided to enable publisher with publisherToggle, + // do not show exclusion list. + if (!eligibleByPublisherToggle && isInExclusionList) { + return false + } + + // If verified option is set, only show verified publishers + if (isEligible && onlyVerified) { + return verifiedPublisher + } + + return isEligible +} + +const synopsisNormalizer = (state, publishers, options, changedPublisher) => { + let results + let dataPinned = [] + let dataUnPinned = [] + let dataExcluded = [] + let pinnedTotal = 0 + let unPinnedTotal = 0 + const scorekeeper = options.scorekeeper + + results = [] // TODO convert to Immutable.List + publishers.forEach((publisher, index) => { + if (!visibleP(state, index)) { + return + } + + publisher.publisher = index + results.push(publisher) + }) + results = underscore.sortBy(results, (entry) => { return -entry.scores[scorekeeper] }) + + // move publisher to the correct array and get totals + results.forEach((result) => { + if (result.pinPercentage && result.pinPercentage > 0) { + // pinned + pinnedTotal += result.pinPercentage + dataPinned.push(getPublisherData(result, scorekeeper)) + } else if (stickyP(result.publisher)) { + // unpinned + unPinnedTotal += result.scores[scorekeeper] + dataUnPinned.push(result) + } else { + // excluded + let publisher = getPublisherData(result, scorekeeper) + publisher.percentage = 0 + publisher.weight = 0 + dataExcluded.push(publisher) + } + }) + + // round if over 100% of pinned publishers + if (pinnedTotal > 100) { + if (changedPublisher) { + const changedObject = dataPinned.filter(publisher => publisher.site === changedPublisher)[0] + const setOne = changedObject.pinPercentage > (100 - dataPinned.length - 1) + + if (setOne) { + changedObject.pinPercentage = 100 - dataPinned.length + 1 + changedObject.weight = changedObject.pinPercentage + } + + const pinnedRestTotal = pinnedTotal - changedObject.pinPercentage + dataPinned = dataPinned.filter(publisher => publisher.site !== changedPublisher) + dataPinned = normalizePinned(dataPinned, pinnedRestTotal, (100 - changedObject.pinPercentage), setOne) + dataPinned = roundToTarget(dataPinned, (100 - changedObject.pinPercentage), 'pinPercentage') + + dataPinned.push(changedObject) + } else { + dataPinned = normalizePinned(dataPinned, pinnedTotal, 100) + dataPinned = roundToTarget(dataPinned, 100, 'pinPercentage') + } + + dataUnPinned = dataUnPinned.map((result) => { + let publisher = getPublisherData(result, scorekeeper) + publisher.percentage = 0 + publisher.weight = 0 + return publisher + }) + + // sync app store + state = ledgerState.changePinnedValues(dataPinned) + } else if (dataUnPinned.length === 0 && pinnedTotal < 100) { + // when you don't have any unpinned sites and pinned total is less then 100 % + dataPinned = normalizePinned(dataPinned, pinnedTotal, 100, false) + dataPinned = roundToTarget(dataPinned, 100, 'pinPercentage') + + // sync app store + state = ledgerState.changePinnedValues(dataPinned) + } else { + // unpinned publishers + dataUnPinned = dataUnPinned.map((result) => { + let publisher = getPublisherData(result, scorekeeper) + const floatNumber = (publisher.score / unPinnedTotal) * (100 - pinnedTotal) + publisher.percentage = Math.round(floatNumber) + publisher.weight = floatNumber + return publisher + }) + + // normalize unpinned values + dataUnPinned = roundToTarget(dataUnPinned, (100 - pinnedTotal), 'percentage') + } + + const newData = dataPinned.concat(dataUnPinned, dataExcluded) + + // sync synopsis + newData.forEach((item) => { + synopsis.publishers[item.site].weight = item.weight + synopsis.publishers[item.site].pinPercentage = item.pinPercentage + }) + + return ledgerState.saveSynopsis(state, newData, options) +} + +// TODO make sure that every call assign state +const updatePublisherInfo = (state, changedPublisher) => { + if (!getSetting(settings.PAYMENTS_ENABLED)) { + return + } + + const options = synopsis.options + state = synopsisNormalizer(state, synopsis.publishers, options, changedPublisher) + + if (publisherInfo._internal.debugP) { + const data = [] + synopsis.publishers.forEach((entry) => { + data.push(underscore.extend(underscore.omit(entry, [ 'faviconURL' ]), { faviconURL: entry.faviconURL && '...' })) + }) + + console.log('\nupdatePublisherInfo: ' + JSON.stringify({ options: options, synopsis: data }, null, 2)) + } + + return state +} + +// TODO rename function name +// TODO make sure that every call assign state +const verifiedP = (state, publisherKey, callback) => { + inspectP(v2PublishersDB, v2PublishersPath, publisherKey, 'verified', null, callback) + + if (process.env.NODE_ENV === 'test') { + ['brianbondy.com', 'clifton.io'].forEach((key) => { + const publisher = ledgerState.getPublisher(state, key) + if (!publisher.isEmpty()) { + state = ledgerState.setSynopsisOption(state, 'verified', true) + } + }) + state = updatePublisherInfo(state) + } + + return state +} + +// TODO refactor +const inspectP = (db, path, publisher, property, key, callback) => { + var done = (err, result) => { + if ((!err) && (typeof result !== 'undefined') && (!!synopsis.publishers[publisher]) && + (synopsis.publishers[publisher].options[property] !== result[property])) { + synopsis.publishers[publisher].options[property] = result[property] + updatePublisherInfo() + } + + if (callback) callback(err, result) + } + + if (!key) key = publisher + db.get(key, (err, value) => { + var result + + if (err) { + if (!err.notFound) console.error(path + ' get ' + key + ' error: ' + JSON.stringify(err, null, 2)) + return done(err) + } + + try { + result = JSON.parse(value) + } catch (ex) { + console.error(v2RulesetPath + ' stream invalid JSON ' + key + ': ' + value) + result = {} + } + + done(null, result) + }) +} + +// TODO refactor +const excludeP = (publisher, callback) => { + var doneP + + var done = (err, result) => { + doneP = true + if ((!err) && (typeof result !== 'undefined') && (!!synopsis.publishers[publisher]) && + (synopsis.publishers[publisher].options.exclude !== result)) { + synopsis.publishers[publisher].options.exclude = result + updatePublisherInfo() + } + + if (callback) callback(err, result) + } + + if (!v2RulesetDB) return setTimeout(() => { excludeP(publisher, callback) }, 5 * miliseconds.second) + + inspectP(v2RulesetDB, v2RulesetPath, publisher, 'exclude', 'domain:' + publisher, (err, result) => { + var props + + if (!err) return done(err, result.exclude) + + props = ledgerPublisher.getPublisherProps('https://' + publisher) + if (!props) return done() + + v2RulesetDB.createReadStream({ lt: 'domain:' }).on('data', (data) => { + var regexp, result, sldP, tldP + + if (doneP) return + + sldP = data.key.indexOf('SLD:') === 0 + tldP = data.key.indexOf('TLD:') === 0 + if ((!tldP) && (!sldP)) return + + if (underscore.intersection(data.key.split(''), + [ '^', '$', '*', '+', '?', '[', '(', '{', '|' ]).length === 0) { + if ((data.key !== ('TLD:' + props.TLD)) && (props.SLD && data.key !== ('SLD:' + props.SLD.split('.')[0]))) return + } else { + try { + regexp = new RegExp(data.key.substr(4)) + if (!regexp.test(props[tldP ? 'TLD' : 'SLD'])) return + } catch (ex) { + console.error(v2RulesetPath + ' stream invalid regexp ' + data.key + ': ' + ex.toString()) + } + } + + try { + result = JSON.parse(data.value) + } catch (ex) { + console.error(v2RulesetPath + ' stream invalid JSON ' + data.entry + ': ' + data.value) + } + + done(null, result.exclude) + }).on('error', (err) => { + console.error(v2RulesetPath + ' stream error: ' + JSON.stringify(err, null, 2)) + }).on('close', () => { + }).on('end', () => { + if (!doneP) done(null, false) + }) + }) +} + +const setLocation = (state, timestamp, tabId) => { + if (!synopsis) { + return + } + + const locationData = ledgerState.getLocation(currentUrl) + if (publisherInfo._internal.verboseP) { + console.log( + `locations[${currentUrl}]=${JSON.stringify(locationData, null, 2)} ` + + `duration=${(timestamp - currentTimestamp)} msec tabId= ${tabId}` + ) + } + if (!locationData || !tabId) { + return state + } + + let publisherKey = locationData.get('publisher') + if (!publisherKey) { + return state + } + + if (!visitsByPublisher[publisherKey]) { + visitsByPublisher[publisherKey] = {} + } + + if (!visitsByPublisher[publisherKey][currentUrl]) { + visitsByPublisher[publisherKey][currentUrl] = { + tabIds: [] + } + } + + const revisitP = visitsByPublisher[publisherKey][currentUrl].tabIds.indexOf(tabId) !== -1 + if (!revisitP) { + visitsByPublisher[publisherKey][currentUrl].tabIds.push(tabId) + } + + let duration = timestamp - currentTimestamp + if (publisherInfo._internal.verboseP) { + console.log('\nadd publisher ' + publisherKey + ': ' + duration + ' msec' + ' revisitP=' + revisitP + ' state=' + + JSON.stringify(underscore.extend({ location: currentUrl }, visitsByPublisher[publisherKey][currentUrl]), + null, 2)) + } + + synopsis.addPublisher(publisherKey, { duration: duration, revisitP: revisitP }) + state = updatePublisherInfo(state) + state = verifiedP(state, publisherKey) + + return state +} + +const addVisit = (state, location, timestamp, tabId) => { + if (location === currentUrl) { + return state + } + + state = setLocation(state, timestamp, tabId) + + currentUrl = location.match(/^about/) ? locationDefault : location + currentTimestamp = timestamp + return state +} + +// TODO refactor +const pageDataChanged = (state) => { + // NB: in theory we have already seen every element in info except for (perhaps) the last one... + const info = pageDataState.getLastInfo(state) + + if (!synopsis || info.isEmpty()) { + return + } + + if (info.get('url', '').match(/^about/)) { + return + } + + let publisher = info.get('publisher') + const location = info.get('key') + if (publisher) { + // TODO refactor + if (synopsis.publishers[publisher] && + (typeof synopsis.publishers[publisher].faviconURL === 'undefined' || synopsis.publishers[publisher].faviconURL === null)) { + getFavIcon(synopsis.publishers[publisher], info, location) + } + + // TODO refactor + return updateLocation(location, publisher) + } else { + try { + publisher = ledgerPublisher.getPublisher(location, publisherInfo._internal.ruleset.raw) + // TODO refactor + if (publisher && !blockedP(state, publisher)) { + state = pageDataState.setPublisher(state, location, publisher) + } else { + publisher = null + } + } catch (ex) { + console.error('getPublisher error for ' + location + ': ' + ex.toString()) + } + } + + if (!publisher) { + return + } + + const pattern = `https?://${publisher}` + const initP = !synopsis.publishers[publisher] + // TODO refactor + synopsis.initPublisher(publisher) + + if (initP) { + // TODO refactor + state = excludeP(state, publisher, (unused, exclude) => { + if (!getSetting(settings.PAYMENTS_SITES_AUTO_SUGGEST)) { + exclude = false + } else { + exclude = !exclude + } + appActions.changeSiteSetting(pattern, 'ledgerPayments', exclude) + updatePublisherInfo() + }) + } + // TODO refactor + updateLocation(location, publisher) + // TODO refactor + getFavIcon(synopsis.publishers[publisher], info, location) + + const pageLoad = pageDataState.getLoad(state) + const view = pageDataState.getView(state) + + if (shouldTrackView(view, pageLoad)) { + // TODO refactor + addVisit(view.get('url', 'NOOP'), view.get('timestamp', underscore.now()), view.get('tabId')) + } + + return state +} + +const backupKeys = (state, backupAction) => { + const date = moment().format('L') + const paymentId = state.getIn(['ledgerInfo', 'paymentId']) + const passphrase = state.getIn(['ledgerInfo', 'passphrase']) + + const messageLines = [ + locale.translation('ledgerBackupText1'), + [locale.translation('ledgerBackupText2'), date].join(' '), + '', + [locale.translation('ledgerBackupText3'), paymentId].join(' '), + [locale.translation('ledgerBackupText4'), passphrase].join(' '), + '', + locale.translation('ledgerBackupText5') + ] + + const message = messageLines.join(os.EOL) + const filePath = path.join(electron.app.getPath('userData'), '/brave_wallet_recovery.txt') + + const fs = require('fs') + fs.writeFile(filePath, message, (err) => { + if (err) { + console.error(err) + } else { + tabs.create({url: fileUrl(filePath)}, (webContents) => { + if (backupAction === 'print') { + webContents.print({silent: false, printBackground: false}) + } else { + webContents.downloadURL(fileUrl(filePath), true) + } + }) + } + }) +} + +const recoverKeys = (state, useRecoveryKeyFile, firstKey, secondKey) => { + let firstRecoveryKey, secondRecoveryKey + + if (useRecoveryKeyFile) { + let recoveryKeyFile = promptForRecoveryKeyFile() + if (!recoveryKeyFile) { + // user canceled from dialog, we abort without error + return + } + + if (recoveryKeyFile) { + const result = loadKeysFromBackupFile(state, recoveryKeyFile) + const keys = result.keys || {} + state = result.state + + if (keys) { + firstRecoveryKey = keys.paymentId + secondRecoveryKey = keys.passphrase + } + } + } + + if (!firstRecoveryKey || !secondRecoveryKey) { + firstRecoveryKey = firstKey + secondRecoveryKey = secondKey + } + + const UUID_REGEX = /^[0-9a-z]{8}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{12}$/ + if ( + typeof firstRecoveryKey !== 'string' || + !firstRecoveryKey.match(UUID_REGEX) || + typeof secondRecoveryKey !== 'string' || + !secondRecoveryKey.match(UUID_REGEX) + ) { + // calling logError sets the error object + state = logError(state, true, 'recoverKeys') + state = ledgerState.setRecoveryStatus(state, false) + return state + } + + // TODO should we change this to async await? + // TODO enable when ledger will work again + /* + client.recoverWallet(firstRecoveryKey, secondRecoveryKey, (err, result) => { + let existingLedgerError = ledgerInfo.error + + if (err) { + // we reset ledgerInfo.error to what it was before (likely null) + // if ledgerInfo.error is not null, the wallet info will not display in UI + // logError sets ledgerInfo.error, so we must we clear it or UI will show an error + state = logError(err, 'recoveryWallet') + appActions.updateLedgerInfoProp('error', existingLedgerError) + // appActions.ledgerRecoveryFailed() TODO update based on top comment (async) + } else { + callback(err, result) + + if (balanceTimeoutId) { + clearTimeout(balanceTimeoutId) + } + getBalance() + // appActions.ledgerRecoverySucceeded() TODO update based on top comment (async) + } + }) + */ + + return state +} + +const quit = (state) => { + // quitP = true TODO remove if not needed + state = addVisit(state, locationDefault, new Date().getTime(), null) + + if ((!getSetting(settings.PAYMENTS_ENABLED)) && (getSetting(settings.SHUTDOWN_CLEAR_HISTORY))) { + state = ledgerState.resetSynopsis(state) + } + + return state +} + +const initSynopsis = (state) => { + // cf., the `Synopsis` constructor, https://github.com/brave/ledger-publisher/blob/master/index.js#L167 + let value = getSetting(settings.PAYMENTS_MINIMUM_VISIT_TIME) + if (!value) { + value = 8 * 1000 + appActions.changeSetting(settings.PAYMENTS_MINIMUM_VISIT_TIME, value) + } + + // for earlier versions of the code... + if ((value > 0) && (value < 1000)) { + value = value * 1000 + synopsis.options.minPublisherDuration = value + state = ledgerState.setSynopsisOption(state, 'minPublisherDuration', value) + } + + value = getSetting(settings.PAYMENTS_MINIMUM_VISITS) + if (!value) { + value = 1 + appActions.changeSetting(settings.PAYMENTS_MINIMUM_VISITS, value) + } + + if (value > 0) { + synopsis.options.minPublisherVisits = value + state = ledgerState.setSynopsisOption(state, 'minPublisherVisits', value) + } + + if (process.env.NODE_ENV === 'test') { + synopsis.options.minPublisherDuration = 0 + synopsis.options.minPublisherVisits = 0 + state = ledgerState.setSynopsisOption(state, 'minPublisherDuration', 0) + state = ledgerState.setSynopsisOption(state, 'minPublisherVisits', 0) + } else { + if (process.env.LEDGER_PUBLISHER_MIN_DURATION) { + value = ledgerClient.prototype.numbion(process.env.LEDGER_PUBLISHER_MIN_DURATION) + synopsis.options.minPublisherDuration = value + state = ledgerState.setSynopsisOption(state, 'minPublisherDuration', value) + } + if (process.env.LEDGER_PUBLISHER_MIN_VISITS) { + value = ledgerClient.prototype.numbion(process.env.LEDGER_PUBLISHER_MIN_VISITS) + synopsis.options.minPublisherVisits = value + state = ledgerState.setSynopsisOption(state, 'minPublisherVisits', value) + } + } + + underscore.keys(synopsis.publishers).forEach((publisher) => { + excludeP(publisher) + state = verifiedP(state, publisher) + }) + + state = updatePublisherInfo(state) + + return state +} + +const enable = (state, paymentsEnabled) => { + if (paymentsEnabled && !getSetting(settings.PAYMENTS_NOTIFICATION_TRY_PAYMENTS_DISMISSED)) { + appActions.changeSetting(settings.PAYMENTS_NOTIFICATION_TRY_PAYMENTS_DISMISSED, true) + } + + publisherInfo._internal.enabled = paymentsEnabled + if (synopsis) { + return updatePublisherInfo(state) + } + + if (!ledgerPublisher) { + ledgerPublisher = require('ledger-publisher') + } + synopsis = new (ledgerPublisher.Synopsis)() + const stateSynopsis = ledgerState.getSynopsis(state) + + if (publisherInfo._internal.verboseP) { + console.log('\nstarting up ledger publisher integration') + } + + if (stateSynopsis.isEmpty()) { + return initSynopsis(state) + } + + try { + synopsis = new (ledgerPublisher.Synopsis)(stateSynopsis) + } catch (ex) { + console.error('synopsisPath parse error: ' + ex.toString()) + } + + state = initSynopsis(state) + + // synopsis cleanup + underscore.keys(synopsis.publishers).forEach((publisher) => { + if (synopsis.publishers[publisher].faviconURL === null) { + delete synopsis.publishers[publisher].faviconURL + } + }) + + // change undefined include publishers to include publishers + state = ledgerState.enableUndefinedPublishers(state, stateSynopsis.get('publishers')) + + return state +} + +const pathName = (name) => { + const parts = path.parse(name) + return path.join(electron.app.getPath('userData'), parts.name + parts.ext) +} + +const sufficientBalanceToReconcile = (state) => { + const balance = Number(ledgerState.getInfoProp(state, 'balance') || 0) + const unconfirmed = Number(ledgerState.getInfoProp(state, 'unconfirmed') || 0) + const btc = ledgerState.getInfoProp(state, 'btc') + return btc && (balance + unconfirmed > 0.9 * Number(btc)) +} + +const shouldShowNotificationReviewPublishers = () => { + const nextTime = getSetting(settings.PAYMENTS_NOTIFICATION_RECONCILE_SOON_TIMESTAMP) + return !nextTime || (underscore.now() > nextTime) +} + +const shouldShowNotificationAddFunds = () => { + const nextTime = getSetting(settings.PAYMENTS_NOTIFICATION_ADD_FUNDS_TIMESTAMP) + return !nextTime || (underscore.now() > nextTime) +} + +const showNotificationReviewPublishers = (nextTime) => { + appActions.changeSetting(settings.PAYMENTS_NOTIFICATION_RECONCILE_SOON_TIMESTAMP, nextTime) + + appActions.showNotification({ + greeting: locale.translation('updateHello'), + message: locale.translation('reconciliationNotification'), + buttons: [ + {text: locale.translation('turnOffNotifications')}, + {text: locale.translation('dismiss')}, + {text: locale.translation('reviewSites'), className: 'primaryButton'} + ], + options: { + style: 'greetingStyle', + persist: false + } + }) +} + +const showNotificationAddFunds = () => { + const nextTime = underscore.now() + (3 * miliseconds.day) + appActions.changeSetting(settings.PAYMENTS_NOTIFICATION_ADD_FUNDS_TIMESTAMP, nextTime) + + appActions.showNotification({ + greeting: locale.translation('updateHello'), + message: locale.translation('addFundsNotification'), + buttons: [ + {text: locale.translation('turnOffNotifications')}, + {text: locale.translation('updateLater')}, + {text: locale.translation('addFunds'), className: 'primaryButton'} + ], + options: { + style: 'greetingStyle', + persist: false + } + }) +} + +/** + * Show message that it's time to add funds if reconciliation is less than + * a day in the future and balance is too low. + * 24 hours prior to reconciliation, show message asking user to review + * their votes. + */ +const showEnabledNotifications = (state) => { + const reconcileStamp = ledgerState.getInfoProp(state, 'reconcileStamp') + + if (!reconcileStamp) { + return + } + + if (reconcileStamp - new Date().getTime() < miliseconds.day) { + if (sufficientBalanceToReconcile(state)) { + if (shouldShowNotificationReviewPublishers()) { + const reconcileFrequency = ledgerState.getInfoProp(state, 'reconcileFrequency') + showNotificationReviewPublishers(reconcileStamp + ((reconcileFrequency - 2) * miliseconds.day)) + } + } else if (shouldShowNotificationAddFunds()) { + showNotificationAddFunds() + } + } else if (reconcileStamp - underscore.now() < 2 * miliseconds.day) { + if (sufficientBalanceToReconcile(state) && (shouldShowNotificationReviewPublishers())) { + showNotificationReviewPublishers(underscore.now() + miliseconds.day) + } + } +} + +const showDisabledNotifications = (state) => { + if (!getSetting(settings.PAYMENTS_NOTIFICATION_TRY_PAYMENTS_DISMISSED)) { + const firstRunTimestamp = state.get('firstRunTimestamp') + if (new Date().getTime() - firstRunTimestamp < appConfig.payments.delayNotificationTryPayments) { + return + } + + appActions.showNotification({ + greeting: locale.translation('updateHello'), + message: locale.translation('notificationTryPayments'), + buttons: [ + {text: locale.translation('noThanks')}, + {text: locale.translation('notificationTryPaymentsYes'), className: 'primaryButton'} + ], + options: { + style: 'greetingStyle', + persist: false + } + }) + } +} + +const showNotifications = (state) => { + if (getSetting(settings.PAYMENTS_ENABLED)) { + if (getSetting(settings.PAYMENTS_NOTIFICATIONS)) { + showEnabledNotifications(state) + } + } else { + showDisabledNotifications(state) + } +} + +const cacheRuleSet = (state, ruleset) => { + if ((!ruleset) || (underscore.isEqual(publisherInfo._internal.ruleset.raw, ruleset))) return + + try { + let stewed = [] + ruleset.forEach((rule) => { + let entry = { condition: acorn.parse(rule.condition) } + + if (rule.dom) { + if (rule.dom.publisher) { + entry.publisher = { selector: rule.dom.publisher.nodeSelector, + consequent: acorn.parse(rule.dom.publisher.consequent) + } + } + if (rule.dom.faviconURL) { + entry.faviconURL = { selector: rule.dom.faviconURL.nodeSelector, + consequent: acorn.parse(rule.dom.faviconURL.consequent) + } + } + } + if (!entry.publisher) entry.consequent = rule.consequent ? acorn.parse(rule.consequent) : rule.consequent + + stewed.push(entry) + }) + + publisherInfo._internal.ruleset.raw = ruleset + publisherInfo._internal.ruleset.cooked = stewed + if (!synopsis) { + return + } + + let syncP = false + ledgerState.getPublishers(state).forEach((publisher, index) => { + const location = (publisher.get('protocol') || 'http:') + '//' + index + let ctx = urlParse(location, true) + + ctx.TLD = tldjs.getPublicSuffix(ctx.host) + if (!ctx.TLD) return + + ctx = underscore.mapObject(ctx, function (value, key) { if (!underscore.isFunction(value)) return value }) + ctx.URL = location + ctx.SLD = tldjs.getDomain(ctx.host) + ctx.RLD = tldjs.getSubdomain(ctx.host) + ctx.QLD = ctx.RLD ? underscore.last(ctx.RLD.split('.')) : '' + + stewed.forEach((rule) => { + if ((rule.consequent !== null) || (rule.dom)) return + if (!ruleSolver.resolve(rule.condition, ctx)) return + + if (publisherInfo._internal.verboseP) console.log('\npurging ' + index) + delete synopsis.publishers[publisher] + state = ledgerState.deletePublishers(state, index) + syncP = true + }) + }) + + if (!syncP) { + return + } + + return updatePublisherInfo(state) + } catch (ex) { + console.error('ruleset error: ', ex) + return state + } +} + +const clientprep = () => { + if (!ledgerClient) ledgerClient = require('ledger-client') + ledgerInfo._internal.debugP = ledgerClient.prototype.boolion(process.env.LEDGER_CLIENT_DEBUG) + publisherInfo._internal.debugP = ledgerClient.prototype.boolion(process.env.LEDGER_PUBLISHER_DEBUG) + publisherInfo._internal.verboseP = ledgerClient.prototype.boolion(process.env.LEDGER_PUBLISHER_VERBOSE) +} + +const roundtrip = (params, options, callback) => { + let parts = typeof params.server === 'string' ? urlParse(params.server) + : typeof params.server !== 'undefined' ? params.server + : typeof options.server === 'string' ? urlParse(options.server) : options.server + const rawP = options.rawP + + if (!params.method) params.method = 'GET' + parts = underscore.extend(underscore.pick(parts, [ 'protocol', 'hostname', 'port' ]), + underscore.omit(params, [ 'headers', 'payload', 'timeout' ])) + +// TBD: let the user configure this via preferences [MTR] + if ((parts.hostname === 'ledger.brave.com') && (params.useProxy)) parts.hostname = 'ledger-proxy.privateinternetaccess.com' + + const i = parts.path.indexOf('?') + if (i !== -1) { + parts.pathname = parts.path.substring(0, i) + parts.search = parts.path.substring(i) + } else { + parts.pathname = parts.path + } + + options = { + url: urlFormat(parts), + method: params.method, + payload: params.payload, + responseType: 'text', + headers: underscore.defaults(params.headers || {}, { 'content-type': 'application/json; charset=utf-8' }), + verboseP: options.verboseP + } + request.request(options, (err, response, body) => { + let payload + + if ((response) && (options.verboseP)) { + console.log('[ response for ' + params.method + ' ' + parts.protocol + '//' + parts.hostname + params.path + ' ]') + console.log('>>> HTTP/' + response.httpVersionMajor + '.' + response.httpVersionMinor + ' ' + response.statusCode + + ' ' + (response.statusMessage || '')) + underscore.keys(response.headers).forEach((header) => { console.log('>>> ' + header + ': ' + response.headers[header]) }) + console.log('>>>') + console.log('>>> ' + (body || '').split('\n').join('\n>>> ')) + } + + if (err) return callback(err) + + if (Math.floor(response.statusCode / 100) !== 2) { + return callback(new Error('HTTP response ' + response.statusCode) + ' for ' + params.method + ' ' + params.path) + } + + try { + payload = rawP ? body : (response.statusCode !== 204) ? JSON.parse(body) : null + } catch (err) { + return callback(err) + } + + try { + callback(null, response, payload) + } catch (err0) { + if (options.verboseP) console.log('\ncallback: ' + err0.toString() + '\n' + err0.stack) + } + }) + + if (!options.verboseP) return + + console.log('<<< ' + params.method + ' ' + parts.protocol + '//' + parts.hostname + params.path) + underscore.keys(options.headers).forEach((header) => { console.log('<<< ' + header + ': ' + options.headers[header]) }) + console.log('<<<') + if (options.payload) console.log('<<< ' + JSON.stringify(params.payload, null, 2).split('\n').join('\n<<< ')) +} + +const updateLedgerInfo = (state) => { + const info = ledgerInfo._internal.paymentInfo + const now = underscore.now() + + // TODO check if we can have internal info in the state already + if (info) { + underscore.extend(ledgerInfo, + underscore.pick(info, [ 'address', 'passphrase', 'balance', 'unconfirmed', 'satoshis', 'btc', 'amount', + 'currency' ])) + if ((!info.buyURLExpires) || (info.buyURLExpires > now)) { + ledgerInfo.buyURL = info.buyURL + ledgerInfo.buyMaximumUSD = 6 + } + if (typeof process.env.ADDFUNDS_URL !== 'undefined') { + ledgerInfo.buyURLFrame = true + ledgerInfo.buyURL = process.env.ADDFUNDS_URL + '?' + + queryString.stringify({ currency: ledgerInfo.currency, + amount: getSetting(settings.PAYMENTS_CONTRIBUTION_AMOUNT), + address: ledgerInfo.address }) + ledgerInfo.buyMaximumUSD = false + } + + underscore.extend(ledgerInfo, ledgerInfo._internal.cache || {}) + } + + // TODO we don't need this for BAT + /* + if ((client) && (now > ledgerInfo._internal.geoipExpiry)) { + ledgerInfo._internal.geoipExpiry = now + (5 * miliseconds.minute) + + if (!ledgerGeoIP) ledgerGeoIP = require('ledger-geoip') + return ledgerGeoIP.getGeoIP(client.options, (err, provider, result) => { + if (err) console.warn('ledger geoip warning: ' + JSON.stringify(err, null, 2)) + if (result) ledgerInfo.countryCode = result + + ledgerInfo.exchangeInfo = ledgerInfo._internal.exchanges[ledgerInfo.countryCode] + + if (now <= ledgerInfo._internal.exchangeExpiry) return updateLedgerInfo() + + ledgerInfo._internal.exchangeExpiry = now + miliseconds.day + roundtrip({ path: '/v1/exchange/providers' }, client.options, (err, response, body) => { + if (err) console.error('ledger exchange error: ' + JSON.stringify(err, null, 2)) + + ledgerInfo._internal.exchanges = body || {} + ledgerInfo.exchangeInfo = ledgerInfo._internal.exchanges[ledgerInfo.countryCode] + updateLedgerInfo() + }) + }) + } + */ + + if (ledgerInfo._internal.debugP) { + console.log('\nupdateLedgerInfo: ' + JSON.stringify(underscore.omit(ledgerInfo, [ '_internal' ]), null, 2)) + } + + return ledgerState.mergeInfoProp(state, underscore.omit(ledgerInfo, [ '_internal' ])) +} + +// Called from observeTransactions() when we see a new payment (transaction). +const showNotificationPaymentDone = (transactionContributionFiat) => { + const notificationPaymentDoneMessage = locale.translation('notificationPaymentDone') + .replace(/{{\s*amount\s*}}/, transactionContributionFiat.amount) + .replace(/{{\s*currency\s*}}/, transactionContributionFiat.currency) + // Hide the 'waiting for deposit' message box if it exists + appActions.hideNotification(locale.translation('addFundsNotification')) + appActions.showNotification({ + greeting: locale.translation('updateHello'), + message: notificationPaymentDoneMessage, + buttons: [ + {text: locale.translation('turnOffNotifications')}, + {text: locale.translation('Ok'), className: 'primaryButton'} + ], + options: { + style: 'greetingStyle', + persist: false + } + }) +} + +const observeTransactions = (state, transactions) => { + const current = ledgerState.getInfoProp(state, 'transactions') + if (underscore.isEqual(current, transactions)) { + return + } + // Notify the user of new transactions. + if (getSetting(settings.PAYMENTS_NOTIFICATIONS) && current !== null) { + const newTransactions = underscore.difference(transactions, current) + if (newTransactions.length > 0) { + const newestTransaction = newTransactions[newTransactions.length - 1] + showNotificationPaymentDone(newestTransaction.contribution.fiat) + } + } +} + +const getStateInfo = (state, parsedData) => { + const info = parsedData.paymentInfo + const then = underscore.now() - miliseconds.year + + if (!parsedData.properties.wallet) { + return + } + + const newInfo = { + paymentId: parsedData.properties.wallet.paymentId, + passphrase: parsedData.properties.wallet.keychains.passphrase, + created: !!parsedData.properties.wallet, + creating: !parsedData.properties.wallet, + reconcileFrequency: parsedData.properties.days, + reconcileStamp: parsedData.reconcileStamp + } + + state = ledgerState.mergeInfoProp(state, newInfo) + + if (info) { + ledgerInfo._internal.paymentInfo = info // TODO check if we can just save this into the state + const paymentURL = 'bitcoin:' + info.address + '?amount=' + info.btc + '&label=' + encodeURI('Brave Software') + const oldUrl = ledgerState.getInfoProp(state, 'paymentURL') + if (oldUrl !== paymentURL) { + state = ledgerState.setInfoProp(state, 'paymentURL', paymentURL) + try { + let chunks = [] + qr.image(paymentURL, { type: 'png' }) + .on('data', (chunk) => { chunks.push(chunk) }) + .on('end', () => { + const paymentIMG = 'data:image/png;base64,' + Buffer.concat(chunks).toString('base64') + state = ledgerState.setInfoProp(state, 'paymentIMG', paymentIMG) + }) + } catch (ex) { + console.error('qr.imageSync error: ' + ex.toString()) + } + } + } + + let transactions = [] + if (!parsedData.transactions) { + return updateLedgerInfo(state) + } + + for (let i = parsedData.transactions.length - 1; i >= 0; i--) { + let transaction = parsedData.transactions[i] + if (transaction.stamp < then) break + + if ((!transaction.ballots) || (transaction.ballots.length < transaction.count)) continue + + let ballots = underscore.clone(transaction.ballots || {}) + parsedData.ballots.forEach((ballot) => { + if (ballot.viewingId !== transaction.viewingId) return + + if (!ballots[ballot.publisher]) ballots[ballot.publisher] = 0 + ballots[ballot.publisher]++ + }) + + transactions.push(underscore.extend(underscore.pick(transaction, + [ 'viewingId', 'contribution', 'submissionStamp', 'count' ]), + { ballots: ballots })) + } + + observeTransactions(state, transactions) + state = ledgerState.setInfoProp(state, 'transactions', transactions) + return updateLedgerInfo(state) +} + +// TODO refactor when action is added +/* +var getPaymentInfo = () => { + var amount, currency + + if (!client) return + + try { + ledgerInfo.bravery = client.getBraveryProperties() + if (ledgerInfo.bravery.fee) { + amount = ledgerInfo.bravery.fee.amount + currency = ledgerInfo.bravery.fee.currency + } + + client.getWalletProperties(amount, currency, function (err, body) { + var info = ledgerInfo._internal.paymentInfo || {} + + if (logError(err, 'getWalletProperties')) { + return + } + + info = underscore.extend(info, underscore.pick(body, [ 'buyURL', 'buyURLExpires', 'balance', 'unconfirmed', 'satoshis' ])) + info.address = client.getWalletAddress() + if ((amount) && (currency)) { + info = underscore.extend(info, { amount: amount, currency: currency }) + if ((body.rates) && (body.rates[currency])) { + info.btc = (amount / body.rates[currency]).toFixed(8) + } + } + ledgerInfo._internal.paymentInfo = info + updateLedgerInfo() + cacheReturnValue() + }) + } catch (ex) { + console.error('properties error: ' + ex.toString()) + } +} +*/ + +const setPaymentInfo = (amount) => { + var bravery + + if (!client) return + + try { + bravery = client.getBraveryProperties() + } catch (ex) { + // wallet being created... + return setTimeout(function () { setPaymentInfo(amount) }, 2 * miliseconds.second) + } + + amount = parseInt(amount, 10) + if (isNaN(amount) || (amount <= 0)) return + + underscore.extend(bravery.fee, { amount: amount }) + client.setBraveryProperties(bravery, (err, result) => { + if (ledgerInfo.created) { + // getPaymentInfo() TODO create action for this + } + + if (err) return console.error('ledger setBraveryProperties: ' + err.toString()) + + if (result) { + muonWriter(pathName(statePath), result) + // TODO save this new data to appState + } + }) +} + +let balanceTimeoutId = false +const getBalance = (state) => { + if (!client) return + + balanceTimeoutId = setTimeout(getBalance, 1 * miliseconds.minute) + if (!ledgerState.getInfoProp(state, 'address')) { + return + } + + if (!ledgerBalance) ledgerBalance = require('ledger-balance') + ledgerBalance.getBalance(ledgerInfo.address, underscore.extend({ balancesP: true }, client.options), + (err, provider, result) => { + // TODO create action to handle callback + if (err) { + return console.warn('ledger balance warning: ' + JSON.stringify(err, null, 2)) + } + /* + var unconfirmed + var info = ledgerInfo._internal.paymentInfo + + if (typeof result.unconfirmed === 'undefined') return + + if (result.unconfirmed > 0) { + unconfirmed = (result.unconfirmed / 1e8).toFixed(4) + if ((info || ledgerInfo).unconfirmed === unconfirmed) return + + ledgerInfo.unconfirmed = unconfirmed + if (info) info.unconfirmed = ledgerInfo.unconfirmed + if (clientOptions.verboseP) console.log('\ngetBalance refreshes ledger info: ' + ledgerInfo.unconfirmed) + return updateLedgerInfo() + } + + if (ledgerInfo.unconfirmed === '0.0000') return + + if (clientOptions.verboseP) console.log('\ngetBalance refreshes payment info') + getPaymentInfo() + */ + }) +} + +// TODO +const callback = (err, result, delayTime) => { + /* + var results + var entries = client && client.report() + + if (clientOptions.verboseP) { + console.log('\nledger client callback: clientP=' + (!!client) + ' errP=' + (!!err) + ' resultP=' + (!!result) + + ' delayTime=' + delayTime) + } + + if (err) { + console.log('ledger client error(1): ' + JSON.stringify(err, null, 2) + (err.stack ? ('\n' + err.stack) : '')) + if (!client) return + + if (typeof delayTime === 'undefined') delayTime = random.randomInt({ min: miliseconds.minute, max: 10 * miliseconds.minute }) + } + + if (!result) return run(delayTime) + + if ((client) && (result.properties.wallet)) { + if (!ledgerInfo.created) setPaymentInfo(getSetting(settings.PAYMENTS_CONTRIBUTION_AMOUNT)) + + getStateInfo(result) + getPaymentInfo() + } + cacheRuleSet(result.ruleset) + if (result.rulesetV2) { + results = result.rulesetV2 + delete result.rulesetV2 + + entries = [] + results.forEach((entry) => { + var key = entry.facet + ':' + entry.publisher + + if (entry.exclude !== false) { + entries.push({ type: 'put', key: key, value: JSON.stringify(underscore.omit(entry, [ 'facet', 'publisher' ])) }) + } else { + entries.push({ type: 'del', key: key }) + } + }) + + v2RulesetDB.batch(entries, (err) => { + if (err) return console.error(v2RulesetPath + ' error: ' + JSON.stringify(err, null, 2)) + + if (entries.length === 0) return + + underscore.keys(synopsis.publishers).forEach((publisher) => { +// be safe... + if (synopsis.publishers[publisher]) delete synopsis.publishers[publisher].options.exclude + + excludeP(publisher) + }) + }) + } + if (result.publishersV2) { + results = result.publishersV2 + delete result.publishersV2 + + entries = [] + results.forEach((entry) => { + entries.push({ type: 'put', + key: entry.publisher, + value: JSON.stringify(underscore.omit(entry, [ 'publisher' ])) + }) + if ((synopsis.publishers[entry.publisher]) && + (synopsis.publishers[entry.publisher].options.verified !== entry.verified)) { + synopsis.publishers[entry.publisher].options.verified = entry.verified + updatePublisherInfo() + } + }) + v2PublishersDB.batch(entries, (err) => { + if (err) return console.error(v2PublishersPath + ' error: ' + JSON.stringify(err, null, 2)) + }) + } + + muonWriter(pathName(statePath), result) + run(delayTime) + */ +} + +const initialize = (state, paymentsEnabled) => { + if (!v2RulesetDB) v2RulesetDB = levelUp(pathName(v2RulesetPath)) + if (!v2PublishersDB) v2PublishersDB = levelUp(pathName(v2PublishersPath)) + state = enable(state, paymentsEnabled) + + // Check if relevant browser notifications should be shown every 15 minutes + if (notificationTimeout) { + clearInterval(notificationTimeout) + } + notificationTimeout = setInterval((state) => { + showNotifications(state) + }, 15 * miliseconds.minute, state) + + if (!paymentsEnabled) { + client = null + return ledgerState.resetInfo(state) + } + + if (client) { + return + } + + if (!ledgerPublisher) ledgerPublisher = require('ledger-publisher') + let ruleset = [] + ledgerPublisher.ruleset.forEach(rule => { if (rule.consequent) ruleset.push(rule) }) + state = cacheRuleSet(state, ruleset) + + try { + const fs = require('fs') + fs.accessSync(pathName(statePath), fs.FF_OK) + const data = fs.readFileSync(pathName(statePath)) + let parsedData + + try { + parsedData = JSON.parse(data) + if (clientOptions.verboseP) { + console.log('\nstarting up ledger client integration') + } + } catch (ex) { + console.error('statePath parse error: ' + ex.toString()) + return state + } + + state = getStateInfo(state, parsedData) + + try { + let timeUntilReconcile + clientprep() + client = ledgerClient(parsedData.personaId, + underscore.extend(parsedData.options, { roundtrip: roundtrip }, clientOptions), + parsedData) + + // Scenario: User enables Payments, disables it, waits 30+ days, then + // enables it again -> reconcileStamp is in the past. + // In this case reset reconcileStamp to the future. + try { timeUntilReconcile = client.timeUntilReconcile() } catch (ex) {} + let ledgerWindow = (ledgerState.getSynopsisOption(state, 'numFrames') - 1) * ledgerState.getSynopsisOption(state, 'frameSize') + if (typeof timeUntilReconcile === 'number' && timeUntilReconcile < -ledgerWindow) { + client.setTimeUntilReconcile(null, (err, stateResult) => { + if (err) return console.error('ledger setTimeUntilReconcile error: ' + err.toString()) + + if (!stateResult) { + return + } + state = getStateInfo(stateResult) + + muonWriter(pathName(statePath), stateResult) + }) + } + } catch (ex) { + return console.error('ledger client creation error: ', ex) + } + + // speed-up browser start-up by delaying the first synchronization action + // TODO create new action that is triggered after 3s + /* + setTimeout(() => { + if (!client) return + + if (client.sync(callback) === true) run(random.randomInt({ min: miliseconds.minute, max: 10 * miliseconds.minute })) + state = cacheRuleSet(state, parsedData.ruleset) + }, 3 * miliseconds.second) + */ + + // Make sure bravery props are up-to-date with user settings + const address = ledgerState.getInfoProp(state, 'address') + if (!address) { + state = ledgerState.setInfoProp(state, 'address', client.getWalletAddress()) + } + + setPaymentInfo(getSetting(settings.PAYMENTS_CONTRIBUTION_AMOUNT)) + getBalance(state) + + return state + } catch (err) { + if (err.code !== 'ENOENT') { + console.error('statePath read error: ' + err.toString()) + } + state = ledgerState.resetInfo(state) + return state + } +} + +const init = (state) => { + try { + state = initialize(state, getSetting(settings.PAYMENTS_ENABLED)) + } catch (ex) { + console.error('ledger.js initialization failed: ', ex) + } + + return state +} + +// TODO rename +const contributeP = (state, publisherKey) => { + const publisher = ledgerState.getPublisher(state, publisherKey) + return ( + (stickyP(state, publisherKey) || publisher.getIn(['options', 'exclude']) !== true) && + eligibleP(state, publisherKey) && + !blockedP(state, publisherKey) + ) +} + +const run = (delayTime) => { + // TODO implement + /* + if (clientOptions.verboseP) { + var entries + + console.log('\nledger client run: clientP=' + (!!client) + ' delayTime=' + delayTime) + + var line = (fields) => { + var result = '' + + fields.forEach((field) => { + var spaces + var max = (result.length > 0) ? 9 : 19 + + if (typeof field !== 'string') field = field.toString() + if (field.length < max) { + spaces = ' '.repeat(max - field.length) + field = spaces + field + } else { + field = field.substr(0, max) + } + result += ' ' + field + }) + + console.log(result.substr(1)) + } + + line([ 'publisher', + 'blockedP', 'stickyP', 'verified', + 'excluded', 'eligibleP', 'visibleP', + 'contribP', + 'duration', 'visits' + ]) + entries = synopsis.topN() || [] + entries.forEach((entry) => { + var publisher = entry.publisher + + line([ publisher, + blockedP(publisher), stickyP(publisher), synopsis.publishers[publisher].options.verified === true, + synopsis.publishers[publisher].options.exclude === true, eligibleP(publisher), visibleP(publisher), + contributeP(publisher), + Math.round(synopsis.publishers[publisher].duration / 1000), synopsis.publishers[publisher].visits ]) + }) + } + + if ((typeof delayTime === 'undefined') || (!client)) return + + var active, state, weights, winners + var ballots = client.ballots() + var data = (synopsis) && (ballots > 0) && synopsisNormalizer() + + if (data) { + weights = [] + data.forEach((datum) => { weights.push({ publisher: datum.site, weight: datum.weight / 100.0 }) }) + winners = synopsis.winners(ballots, weights) + } + if (!winners) winners = [] + + try { + winners.forEach((winner) => { + var result + + if (!contributeP(winner)) return + + result = client.vote(winner) + if (result) state = result + }) + if (state) muonWriter(pathName(statePath), state) + } catch (ex) { + console.log('ledger client error(2): ' + ex.toString() + (ex.stack ? ('\n' + ex.stack) : '')) + } + + if (delayTime === 0) { + try { + delayTime = client.timeUntilReconcile() + } catch (ex) { + delayTime = false + } + if (delayTime === false) delayTime = random.randomInt({ min: miliseconds.minute, max: 10 * miliseconds.minute }) + } + if (delayTime > 0) { + if (runTimeoutId) return + + active = client + if (delayTime > (1 * miliseconds.hour)) delayTime = random.randomInt({ min: 3 * miliseconds.minute, max: miliseconds.hour }) + + runTimeoutId = setTimeout(() => { + runTimeoutId = false + if (active !== client) return + + if (!client) return console.log('\n\n*** MTR says this can\'t happen(1)... please tell him that he\'s wrong!\n\n') + + if (client.sync(callback) === true) return run(0) + }, delayTime) + return + } + + if (client.isReadyToReconcile()) return client.reconcile(uuid.v4().toLowerCase(), callback) + + console.log('what? wait, how can this happen?') + */ +} + +const networkConnected = (state) => { + // TODO pass state into debounced function + underscore.debounce((state) => { + if (!client) return + + if (runTimeoutId) { + clearTimeout(runTimeoutId) + runTimeoutId = false + } + if (client.sync(callback) === true) { + // TODO refactor + const delayTime = random.randomInt({ min: miliseconds.minute, max: 10 * miliseconds.minute }) + run(state, delayTime) + } + + if (balanceTimeoutId) clearTimeout(balanceTimeoutId) + balanceTimeoutId = setTimeout(getBalance, 5 * miliseconds.second) + }, 1 * miliseconds.minute, true) +} + +// TODO check if quitP is needed, now is defined in ledgerUtil.quit +const muonWriter = (path, payload) => { + muon.file.writeImportant(path, JSON.stringify(payload, null, 2), (success) => { + if (!success) return console.error('write error: ' + path) + + if ((quitP) && (!getSetting(settings.PAYMENTS_ENABLED)) && (getSetting(settings.SHUTDOWN_CLEAR_HISTORY))) { + if (ledgerInfo._internal.debugP) { + console.log('\ndeleting ' + path) + } + + const fs = require('fs') + return fs.unlink(path, (err) => { if (err) console.error('unlink error: ' + err.toString()) }) + } + + if (ledgerInfo._internal.debugP) console.log('\nwrote ' + path) + }) +} + +module.exports = { + synopsis, + shouldTrackView, + btcToCurrencyString, + formattedTimeFromNow, + formattedDateFromTimestamp, + walletStatus, + backupKeys, + recoverKeys, + quit, + addVisit, + pageDataChanged, + init, + initialize, + setPaymentInfo, + updatePublisherInfo, + networkConnected, + verifiedP +} diff --git a/app/common/lib/pageDataUtil.js b/app/common/lib/pageDataUtil.js new file mode 100644 index 00000000000..c9ed1131916 --- /dev/null +++ b/app/common/lib/pageDataUtil.js @@ -0,0 +1,17 @@ + +const urlFormat = require('url').format +const _ = require('underscore') + +const urlParse = require('../../common/urlParse') + +const getInfoKey = (url) => { + if (typeof url !== 'string') { + return null + } + + return urlFormat(_.pick(urlParse(url), [ 'protocol', 'host', 'hostname', 'port', 'pathname' ])) +} + +module.exports = { + getInfoKey +} diff --git a/app/common/state/ledgerState.js b/app/common/state/ledgerState.js new file mode 100644 index 00000000000..2b51a532c27 --- /dev/null +++ b/app/common/state/ledgerState.js @@ -0,0 +1,143 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const Immutable = require('immutable') + +// Utils +const siteSettings = require('../../../js/state/siteSettings') +const {makeImmutable} = require('../../common/state/immutableUtil') + +const ledgerState = { + setRecoveryStatus: (state, status) => { + const date = new Date().getTime() + state = state.setIn(['about', 'preferences', 'recoverySucceeded'], status) + return state.setIn(['about', 'preferences', 'updatedStamp'], date) + }, + + setLedgerError: (state, error, caller) => { + if (error == null && caller == null) { + return state.setIn(['ledger', 'info', 'error'], null) + } + + return state + .setIn(['ledger', 'info', 'error', 'caller'], caller) + .setIn(['ledger', 'info', 'error', 'error'], error) + }, + + getLocation: (state, url) => { + if (url == null) { + return null + } + + return state.getIn(['ledger', 'locations', url]) + }, + + changePinnedValues: (state, publishers) => { + if (publishers == null) { + return state + } + + publishers = makeImmutable(publishers) + publishers.forEach((item, index) => { + const pattern = `https?://${index}` + const percentage = item.get('pinPercentage') + let newSiteSettings = siteSettings.mergeSiteSetting(state.get('siteSettings'), pattern, 'ledgerPinPercentage', percentage) + state = state.set('siteSettings', newSiteSettings) + }) + + return state + }, + + getSynopsis: (state) => { + return state.getIn(['ledger', 'synopsis']) || Immutable.Map() + }, + + saveSynopsis: (state, publishers, options) => { + return state + .setIn(['ledger', 'synopsis', 'publishers'], publishers) + .setIn(['ledger', 'synopsis', 'options'], options) + }, + + getPublisher: (state, key) => { + if (key == null) { + return Immutable.Map() + } + + return state.getIn(['ledger', 'synopsis', 'publishers', key]) || Immutable.Map() + }, + + getPublishers: (state) => { + return state.getIn(['ledger', 'synopsis', 'publishers']) || Immutable.Map() + }, + + deletePublishers: (state, key) => { + return state.deleteIn(['ledger', 'synopsis', 'publishers', key]) + }, + + getSynopsisOption: (state, prop) => { + if (prop == null) { + return state.getIn(['ledger', 'synopsis', 'options']) + } + + return state.getIn(['ledger', 'synopsis', 'options', prop]) + }, + + setSynopsisOption: (state, prop, value) => { + if (prop == null) { + return state + } + + return state.setIn(['ledger', 'synopsis', 'options', prop], value) + }, + + enableUndefinedPublishers: (state, publishers) => { + const sitesObject = state.get('siteSettings') + Object.keys(publishers).map((item) => { + const pattern = `https?://${item}` + const result = sitesObject.getIn([pattern, 'ledgerPayments']) + + if (result === undefined) { + const newSiteSettings = siteSettings.mergeSiteSetting(state.get('siteSettings'), pattern, 'ledgerPayments', true) + state = state.set('siteSettings', newSiteSettings) + } + }) + + return state + }, + + getInfoProp: (state, prop) => { + if (prop == null) { + return state.getIn(['ledger', 'info']) + } + + return state.getIn(['ledger', 'info', prop]) + }, + + setInfoProp: (state, prop, value) => { + if (prop == null) { + return state + } + + return state.setIn(['ledger', 'info', prop], value) + }, + + mergeInfoProp: (state, data) => { + if (data == null) { + return state + } + + const oldData = ledgerState.getInfoProp() + return state.setIn(['ledger', 'info'], oldData.merge(data)) + }, + + resetInfo: (state) => { + return state.setIn(['ledger', 'info'], {}) + }, + + resetSynopsis: (state) => { + return state.deleteIn(['ledger', 'synopsis']) + } +} + +module.exports = ledgerState diff --git a/app/common/state/pageDataState.js b/app/common/state/pageDataState.js new file mode 100644 index 00000000000..2b399e7c4d3 --- /dev/null +++ b/app/common/state/pageDataState.js @@ -0,0 +1,98 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const Immutable = require('immutable') + +// Utils +const pageDataUtil = require('../lib/pageDataUtil') +const {getWebContents} = require('../../browser/webContentsCache') +const {isSourceAboutUrl} = require('../../../js/lib/appUrlUtil') +const {makeImmutable} = require('./immutableUtil') + +const pageDataState = { + addView: (state, url = null, tabId = null) => { + const tab = getWebContents(tabId) + const isPrivate = !tab || + tab.isDestroyed() || + !tab.session.partition.startsWith('persist:') + + state = pageDataState.setLastActiveTabId(state, tabId) + + if ((url && isSourceAboutUrl(url)) || isPrivate) { + url = null + } + + const lastView = pageDataState.getView(state) + if (lastView.get('url') === url) { + return state + } + + let pageViewEvent = makeImmutable({ + timestamp: new Date().getTime(), + url, + tabId + }) + return state.setIn(['pageData', 'view'], pageViewEvent) + }, + + addInfo: (state, data) => { + data = makeImmutable(data) + + if (data == null) { + return state + } + + const key = pageDataUtil.getInfoKey(data.get('url')) + + data = data.set('key', key) + state = state.setIn(['pageData', 'last', 'info'], key) + return state.setIn(['pageData', 'info', key], data) + }, + + addLoad: (state, data) => { + if (data == null) { + return state + } + + // select only last 100 loads + const newLoad = state.getIn(['pageData', 'load'], Immutable.List()).slice(-100).push(data) + return state.setIn(['pageData', 'load'], newLoad) + }, + + getView: (state) => { + return state.getIn(['pageData', 'view']) || Immutable.Map() + }, + + getLastInfo: (state) => { + const key = state.getIn(['pageData', 'last', 'info']) + + if (key == null) { + Immutable.Map() + } + + return state.getIn(['pageData', 'info', key], Immutable.Map()) + }, + + getLoad: (state) => { + return state.getIn(['pageData', 'load'], Immutable.List()) + }, + + getLastActiveTabId: (state) => { + return state.getIn(['pageData', 'last', 'tabId']) + }, + + setLastActiveTabId: (state, tabId) => { + return state.setIn(['pageData', 'last', 'tabId'], tabId) + }, + + setPublisher: (state, key, publisher) => { + if (key == null) { + return state + } + + return state.setIn(['pageData', 'info', key, 'publisher'], publisher) + } +} + +module.exports = pageDataState diff --git a/app/common/state/siteSettingsState.js b/app/common/state/siteSettingsState.js index ba00633663e..5fd8041487b 100644 --- a/app/common/state/siteSettingsState.js +++ b/app/common/state/siteSettingsState.js @@ -28,8 +28,24 @@ const api = { return siteSettings ? siteSettings.get(hostPattern) : Immutable.Map() }, - isNoScriptEnabled (state, settings) { + isNoScriptEnabled: (state, settings) => { return siteSettings.activeSettings(settings, state, appConfig).noScript === true + }, + + getSettingsProp: (state, pattern, prop) => { + if (prop == null) { + return null + } + + return state.getIn(['siteSettings', pattern, prop]) + }, + + setSettingsProp: (state, pattern, prop, value) => { + if (prop == null) { + return null + } + + return state.setIn(['siteSettings', pattern, prop], value) } } diff --git a/app/ledger.js b/app/ledger.js index a38856f46f6..d8e3a03882e 100644 --- a/app/ledger.js +++ b/app/ledger.js @@ -36,7 +36,6 @@ const os = require('os') const path = require('path') const urlParse = require('./common/urlParse') const urlFormat = require('url').format -const util = require('util') const Immutable = require('immutable') const electron = require('electron') @@ -56,18 +55,16 @@ const uuid = require('uuid') const appActions = require('../js/actions/appActions') const appConfig = require('../js/constants/appConfig') -const appConstants = require('../js/constants/appConstants') const messages = require('../js/constants/messages') const settings = require('../js/constants/settings') const request = require('../js/lib/request') const getSetting = require('../js/settings').getSetting const locale = require('./locale') const appStore = require('../js/stores/appStore') -const eventStore = require('../js/stores/eventStore') const rulesolver = require('./extensions/brave/content/scripts/pageInformation') const ledgerUtil = require('./common/lib/ledgerUtil') const tabs = require('./browser/tabs') -const {fileUrl} = require('../js/lib/appUrlUtil') +const pageDataState = require('./common/state/pageDataState') // "only-when-needed" loading... let ledgerBalance = null @@ -76,18 +73,15 @@ let ledgerGeoIP = null let ledgerPublisher = null // testing data -const testVerifiedPublishers = [ - 'brianbondy.com', - 'clifton.io' -] + // TBD: remove these post beta [MTR] +// TODO remove, it's not used anymore const logPath = 'ledger-log.json' const publisherPath = 'ledger-publisher.json' const scoresPath = 'ledger-scores.json' // TBD: move these to secureState post beta [MTR] -const statePath = 'ledger-state.json' const synopsisPath = 'ledger-synopsis.json' /* @@ -104,16 +98,8 @@ const clientOptions = { server: process.env.LEDGER_SERVER_URL, createWorker: app.createWorker } - -var doneTimer var quitP -var v2RulesetDB -const v2RulesetPath = 'ledger-rulesV2.leveldb' - -var v2PublishersDB -const v2PublishersPath = 'ledger-publishersV2.leveldb' - /* * publisher globals */ @@ -126,14 +112,6 @@ var publishers = {} * utility globals */ -const msecs = { year: 365 * 24 * 60 * 60 * 1000, - week: 7 * 24 * 60 * 60 * 1000, - day: 24 * 60 * 60 * 1000, - hour: 60 * 60 * 1000, - minute: 60 * 1000, - second: 1000 -} - /* * notification state globals */ @@ -144,170 +122,11 @@ let notificationPaymentDoneMessage let notificationTryPaymentsMessage let notificationTimeout = null -// TODO(bridiver) - create a better way to get setting changes -const doAction = (state, action) => { - var i, publisher - -/* TBD: handle - - { actionType: "window-set-blocked-by" - , frameProps: - { audioPlaybackActive: true - ... - } - , ... - } - */ - if (publisherInfo._internal.debugP) { - console.log('\napplication event: ' + JSON.stringify(underscore.pick(action, [ 'actionType', 'key' ]), null, 2)) - } - - switch (action.actionType) { - case appConstants.APP_SET_STATE: - init() - break - - case appConstants.APP_BACKUP_KEYS: - state = backupKeys(state, action) - break - - case appConstants.APP_RECOVER_WALLET: - state = recoverKeys(state, action) - break - - case appConstants.APP_SHUTTING_DOWN: - quit() - break - - case appConstants.APP_ON_CLEAR_BROWSING_DATA: - { - const defaults = state.get('clearBrowsingDataDefaults') - const temp = state.get('tempClearBrowsingData', Immutable.Map()) - const clearData = defaults ? defaults.merge(temp) : temp - if (clearData.get('browserHistory') && !getSetting(settings.PAYMENTS_ENABLED)) { - reset(true) - } - break - } - - case appConstants.APP_IDLE_STATE_CHANGED: - visit('NOOP', underscore.now(), null) - break - - case appConstants.APP_CHANGE_SETTING: - switch (action.key) { - case settings.PAYMENTS_ENABLED: - initialize(action.value) - break - - case settings.PAYMENTS_CONTRIBUTION_AMOUNT: - setPaymentInfo(action.value) - break - - case settings.PAYMENTS_MINIMUM_VISIT_TIME: - if (action.value <= 0) break - - synopsis.options.minPublisherDuration = action.value - updatePublisherInfo() - break - - case settings.PAYMENTS_MINIMUM_VISITS: - if (action.value <= 0) break - - synopsis.options.minPublisherVisits = action.value - updatePublisherInfo() - break - - case settings.PAYMENTS_ALLOW_NON_VERIFIED: - synopsis.options.showOnlyVerified = action.value - updatePublisherInfo() - break - - default: - break - } - break - - case appConstants.APP_CHANGE_SITE_SETTING: - if (!action.hostPattern) { - console.warn('Changing site settings should always have a hostPattern') - break - } - i = action.hostPattern.indexOf('://') - if (i === -1) break - - publisher = action.hostPattern.substr(i + 3) - if (action.key === 'ledgerPaymentsShown') { - if (action.value === false) { - if (publisherInfo._internal.verboseP) console.log('\npurging ' + publisher) - delete synopsis.publishers[publisher] - delete publishers[publisher] - updatePublisherInfo() - } - } else if (action.key === 'ledgerPayments') { - if (!synopsis.publishers[publisher]) break - - if (publisherInfo._internal.verboseP) console.log('\nupdating ' + publisher + ' stickyP=' + action.value) - updatePublisherInfo() - verifiedP(publisher) - } else if (action.key === 'ledgerPinPercentage') { - if (!synopsis.publishers[publisher]) break - synopsis.publishers[publisher].pinPercentage = action.value - updatePublisherInfo(publisher) - } - break - - case appConstants.APP_REMOVE_SITE_SETTING: - i = action.hostPattern.indexOf('://') - if (i === -1) break - - publisher = action.hostPattern.substr(i + 3) - if (action.key === 'ledgerPayments') { - if (!synopsis.publishers[publisher]) break - - if (publisherInfo._internal.verboseP) console.log('\nupdating ' + publisher + ' stickyP=' + true) - updatePublisherInfo() - } - break - - case appConstants.APP_NETWORK_CONNECTED: - setTimeout(networkConnected, 1 * msecs.second) - break - - case appConstants.APP_NAVIGATOR_HANDLER_REGISTERED: - ledgerInfo.hasBitcoinHandler = (action.protocol === 'bitcoin') - appActions.updateLedgerInfo(underscore.omit(ledgerInfo, [ '_internal' ])) - break - - case appConstants.APP_NAVIGATOR_HANDLER_UNREGISTERED: - ledgerInfo.hasBitcoinHandler = false - appActions.updateLedgerInfo(underscore.omit(ledgerInfo, [ '_internal' ])) - break - - default: - break - } - - return state -} /* * module entry points */ -var init = () => { - try { - initialize(getSetting(settings.PAYMENTS_ENABLED)) - } catch (ex) { console.error('ledger.js initialization failed: ', ex) } -} - -var quit = () => { - quitP = true - visit('NOOP', underscore.now(), null) - clearInterval(doneTimer) - - if ((!getSetting(settings.PAYMENTS_ENABLED)) && (getSetting(settings.SHUTDOWN_CLEAR_HISTORY))) reset(true) -} var boot = () => { if ((bootP) || (client)) return @@ -329,184 +148,22 @@ var boot = () => { bootP = false return console.error('ledger client boot error: ', ex) } - if (client.sync(callback) === true) run(random.randomInt({ min: msecs.minute, max: 10 * msecs.minute })) + if (client.sync(callback) === true) run(random.randomInt({ min: miliseconds.minute, max: 10 * miliseconds.minute })) getBalance() bootP = false }) } -var reset = (doneP) => { - var files = [ logPath, publisherPath, scoresPath, synopsisPath ] - - if (!doneP) files.push(statePath) - files.forEach((file) => { - fs.unlink(pathName(file), (err) => { - if ((err) && (err.code !== 'ENOENT')) { - console.error('error removing file ' + file + ': ', err) - } - }) - }) -} - /* * Print or Save Recovery Keys */ -var backupKeys = (appState, action) => { - const date = moment().format('L') - const paymentId = appState.getIn(['ledgerInfo', 'paymentId']) - const passphrase = appState.getIn(['ledgerInfo', 'passphrase']) - - const messageLines = [ - locale.translation('ledgerBackupText1'), - [locale.translation('ledgerBackupText2'), date].join(' '), - '', - [locale.translation('ledgerBackupText3'), paymentId].join(' '), - [locale.translation('ledgerBackupText4'), passphrase].join(' '), - '', - locale.translation('ledgerBackupText5') - ] - - const message = messageLines.join(os.EOL) - const filePath = path.join(app.getPath('userData'), '/brave_wallet_recovery.txt') - - fs.writeFile(filePath, message, (err) => { - if (err) { - console.error(err) - } else { - tabs.create({url: fileUrl(filePath)}, (webContents) => { - if (action.backupAction === 'print') { - webContents.print({silent: false, printBackground: false}) - } else { - webContents.downloadURL(fileUrl(filePath), true) - } - }) - } - }) - - return appState -} - -var loadKeysFromBackupFile = (filePath) => { - let keys = null - let data = fs.readFileSync(filePath) - - if (!data || !data.length || !(data.toString())) { - logError('No data in backup file', 'recoveryWallet') - } else { - try { - const recoveryFileContents = data.toString() - - let messageLines = recoveryFileContents.split(os.EOL) - - let paymentIdLine = '' || messageLines[3] - let passphraseLine = '' || messageLines[4] - - const paymentIdPattern = new RegExp([locale.translation('ledgerBackupText3'), '([^ ]+)'].join(' ')) - const paymentId = (paymentIdLine.match(paymentIdPattern) || [])[1] - - const passphrasePattern = new RegExp([locale.translation('ledgerBackupText4'), '(.+)$'].join(' ')) - const passphrase = (passphraseLine.match(passphrasePattern) || [])[1] - - keys = { - paymentId, - passphrase - } - } catch (exc) { - logError(exc, 'recoveryWallet') - } - } - - return keys -} /* * Recover Ledger Keys */ -var recoverKeys = (appState, action) => { - let firstRecoveryKey, secondRecoveryKey - - if (action.useRecoveryKeyFile) { - let recoveryKeyFile = promptForRecoveryKeyFile() - if (!recoveryKeyFile) { - // user canceled from dialog, we abort without error - return appState - } - - if (recoveryKeyFile) { - let keys = loadKeysFromBackupFile(recoveryKeyFile) || {} - - if (keys) { - firstRecoveryKey = keys.paymentId - secondRecoveryKey = keys.passphrase - } - } - } - - if (!firstRecoveryKey || !secondRecoveryKey) { - firstRecoveryKey = action.firstRecoveryKey - secondRecoveryKey = action.secondRecoveryKey - } - - const UUID_REGEX = /^[0-9a-z]{8}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{12}$/ - if (typeof firstRecoveryKey !== 'string' || !firstRecoveryKey.match(UUID_REGEX) || typeof secondRecoveryKey !== 'string' || !secondRecoveryKey.match(UUID_REGEX)) { - // calling logError sets the error object - logError(true, 'recoverKeys') - appActions.updateLedgerInfo(underscore.omit(ledgerInfo, [ '_internal' ])) - appActions.ledgerRecoveryFailed() - return appState - } - - client.recoverWallet(firstRecoveryKey, secondRecoveryKey, (err, result) => { - let existingLedgerError = ledgerInfo.error - - if (logError(err, 'recoveryWallet')) { - // we reset ledgerInfo.error to what it was before (likely null) - // if ledgerInfo.error is not null, the wallet info will not display in UI - // logError sets ledgerInfo.error, so we must we clear it or UI will show an error - ledgerInfo.error = existingLedgerError - appActions.updateLedgerInfo(underscore.omit(ledgerInfo, [ '_internal' ])) - appActions.ledgerRecoveryFailed() - } else { - callback(err, result) - - appActions.updateLedgerInfo(underscore.omit(ledgerInfo, [ '_internal' ])) - if (balanceTimeoutId) clearTimeout(balanceTimeoutId) - getBalance() - appActions.ledgerRecoverySucceeded() - } - }) - - return appState -} - -const dialog = electron.dialog - -var promptForRecoveryKeyFile = () => { - const defaultRecoveryKeyFilePath = path.join(app.getPath('userData'), '/brave_wallet_recovery.txt') - - let files - - if (process.env.SPECTRON) { - // skip the dialog for tests - console.log(`for test, trying to recover keys from path: ${defaultRecoveryKeyFilePath}`) - files = [defaultRecoveryKeyFilePath] - } else { - files = dialog.showOpenDialog({ - properties: ['openFile'], - defaultPath: defaultRecoveryKeyFilePath, - filters: [{ - name: 'TXT files', - extensions: ['txt'] - }] - }) - } - - return (files && files.length ? files[0] : null) -} - /* * IPC entry point */ @@ -562,7 +219,7 @@ if (ipc) { ipc.on(messages.NOTIFICATION_RESPONSE, (e, message, buttonIndex) => { const win = electron.BrowserWindow.getActiveWindow() - if (message === addFundsMessage) { + if (message === locale.translation('addFundsNotification')) { appActions.hideNotification(message) // See showNotificationAddFunds() for buttons. // buttonIndex === 1 is "Later"; the timestamp until which to delay is set @@ -576,7 +233,7 @@ if (ipc) { windowId: win.id }) } - } else if (message === reconciliationMessage) { + } else if (message === locale.translation('reconciliationNotification')) { appActions.hideNotification(message) // buttonIndex === 1 is Dismiss if (buttonIndex === 0) { @@ -592,7 +249,7 @@ if (ipc) { if (buttonIndex === 0) { appActions.changeSetting(settings.PAYMENTS_NOTIFICATIONS, false) } - } else if (message === notificationTryPaymentsMessage) { + } else if (message === locale.translation('notificationTryPayments')) { appActions.hideNotification(message) if (buttonIndex === 1 && win) { appActions.createTabRequested({ @@ -606,7 +263,7 @@ if (ipc) { ipc.on(messages.ADD_FUNDS_CLOSED, () => { if (balanceTimeoutId) clearTimeout(balanceTimeoutId) - balanceTimeoutId = setTimeout(getBalance, 5 * msecs.second) + balanceTimeoutId = setTimeout(getBalance, 5 * milisecons.second) }) } @@ -628,256 +285,10 @@ underscore.keys(fileTypes).forEach((fileType) => { }) signatureMax = Math.ceil(signatureMax * 1.5) -eventStore.addChangeListener(() => { - var initP - const eventState = eventStore.getState().toJS() - var view = eventState.page_view - var info = eventState.page_info - var pageLoad = eventState.page_load - - if ((!synopsis) || (!util.isArray(info))) return - -// NB: in theory we have already seen every element in info except for (perhaps) the last one... - underscore.rest(info, info.length - 1).forEach((page) => { - let pattern, publisher - let location = page.url - - if (location.match(/^about/)) return - - location = urlFormat(underscore.pick(urlParse(location), [ 'protocol', 'host', 'hostname', 'port', 'pathname' ])) - publisher = locations[location] && locations[location].publisher - if (publisher) { - if (synopsis.publishers[publisher] && - (typeof synopsis.publishers[publisher].faviconURL === 'undefined' || synopsis.publishers[publisher].faviconURL === null)) { - getFavIcon(synopsis.publishers[publisher], page, location) - } - return updateLocation(location, publisher) - } - - if (!page.publisher) { - try { - publisher = ledgerPublisher.getPublisher(location, publisherInfo._internal.ruleset.raw) - if ((publisher) && (blockedP(publisher))) publisher = null - if (publisher) page.publisher = publisher - } catch (ex) { - console.error('getPublisher error for ' + location + ': ' + ex.toString()) - } - } - locations[location] = underscore.omit(page, [ 'url', 'protocol', 'faviconURL' ]) - if (!page.publisher) return - - publisher = page.publisher - pattern = `https?://${publisher}` - initP = !synopsis.publishers[publisher] - synopsis.initPublisher(publisher) - if (initP) { - excludeP(publisher, (unused, exclude) => { - if (!getSetting(settings.PAYMENTS_SITES_AUTO_SUGGEST)) { - exclude = false - } else { - exclude = !exclude - } - appActions.changeSiteSetting(pattern, 'ledgerPayments', exclude) - updatePublisherInfo() - }) - } - updateLocation(location, publisher) - getFavIcon(synopsis.publishers[publisher], page, location) - }) - - view = underscore.last(view) || {} - if (ledgerUtil.shouldTrackView(view, pageLoad)) { - visit(view.url || 'NOOP', view.timestamp || underscore.now(), view.tabId) - } -}) - /* * module initialization */ -var initialize = (paymentsEnabled) => { - var ruleset - - if (!v2RulesetDB) v2RulesetDB = levelup(pathName(v2RulesetPath)) - if (!v2PublishersDB) v2PublishersDB = levelup(pathName(v2PublishersPath)) - enable(paymentsEnabled) - - // Check if relevant browser notifications should be shown every 15 minutes - if (notificationTimeout) clearInterval(notificationTimeout) - notificationTimeout = setInterval(showNotifications, 15 * msecs.minute) - - if (!paymentsEnabled) { - client = null - return appActions.updateLedgerInfo({}) - } - if (client) return - - if (!ledgerPublisher) ledgerPublisher = require('ledger-publisher') - ruleset = [] - ledgerPublisher.ruleset.forEach(rule => { if (rule.consequent) ruleset.push(rule) }) - cacheRuleSet(ruleset) - - fs.access(pathName(statePath), fs.FF_OK, (err) => { - if (!err) { - if (clientOptions.verboseP) console.log('\nfound ' + pathName(statePath)) - - fs.readFile(pathName(statePath), (err, data) => { - var state - - if (err) return console.error('read error: ' + err.toString()) - - try { - state = JSON.parse(data) - if (clientOptions.verboseP) console.log('\nstarting up ledger client integration') - } catch (ex) { - return console.error('statePath parse error: ' + ex.toString()) - } - - getStateInfo(state) - - try { - var timeUntilReconcile - clientprep() - client = ledgerClient(state.personaId, - underscore.extend(state.options, { roundtrip: roundtrip }, clientOptions), - state) - - // Scenario: User enables Payments, disables it, waits 30+ days, then - // enables it again -> reconcileStamp is in the past. - // In this case reset reconcileStamp to the future. - try { timeUntilReconcile = client.timeUntilReconcile() } catch (ex) {} - let ledgerWindow = (synopsis.options.numFrames - 1) * synopsis.options.frameSize - if (typeof timeUntilReconcile === 'number' && timeUntilReconcile < -ledgerWindow) { - client.setTimeUntilReconcile(null, (err, stateResult) => { - if (err) return console.error('ledger setTimeUntilReconcile error: ' + err.toString()) - - if (!stateResult) { - return - } - getStateInfo(stateResult) - - muonWriter(pathName(statePath), stateResult) - }) - } - } catch (ex) { - return console.error('ledger client creation error: ', ex) - } - - // speed-up browser start-up by delaying the first synchronization action - setTimeout(() => { - if (!client) return - - if (client.sync(callback) === true) run(random.randomInt({ min: msecs.minute, max: 10 * msecs.minute })) - cacheRuleSet(state.ruleset) - }, 3 * msecs.second) - - // Make sure bravery props are up-to-date with user settings - if (!ledgerInfo.address) ledgerInfo.address = client.getWalletAddress() - setPaymentInfo(getSetting(settings.PAYMENTS_CONTRIBUTION_AMOUNT)) - getBalance() - }) - return - } - - if (err.code !== 'ENOENT') console.error('statePath read error: ' + err.toString()) - appActions.updateLedgerInfo({}) - }) -} - -var clientprep = () => { - if (!ledgerClient) ledgerClient = require('ledger-client') - ledgerInfo._internal.debugP = ledgerClient.prototype.boolion(process.env.LEDGER_CLIENT_DEBUG) - publisherInfo._internal.debugP = ledgerClient.prototype.boolion(process.env.LEDGER_PUBLISHER_DEBUG) - publisherInfo._internal.verboseP = ledgerClient.prototype.boolion(process.env.LEDGER_PUBLISHER_VERBOSE) -} - -var enable = (paymentsEnabled) => { - if (paymentsEnabled && !getSetting(settings.PAYMENTS_NOTIFICATION_TRY_PAYMENTS_DISMISSED)) { - appActions.changeSetting(settings.PAYMENTS_NOTIFICATION_TRY_PAYMENTS_DISMISSED, true) - } - - publisherInfo._internal.enabled = paymentsEnabled - if (synopsis) return updatePublisherInfo() - - if (!ledgerPublisher) ledgerPublisher = require('ledger-publisher') - synopsis = new (ledgerPublisher.Synopsis)() - fs.readFile(pathName(synopsisPath), (err, data) => { - var initSynopsis = () => { - var value - - // cf., the `Synopsis` constructor, https://github.com/brave/ledger-publisher/blob/master/index.js#L167 - value = getSetting(settings.PAYMENTS_MINIMUM_VISIT_TIME) - if (!value) { - value = 8 * 1000 - appActions.changeSetting(settings.PAYMENTS_MINIMUM_VISIT_TIME, value) - } - - // for earlier versions of the code... - if ((value > 0) && (value < 1000)) synopsis.options.minPublisherDuration = value * 1000 - - value = getSetting(settings.PAYMENTS_MINIMUM_VISITS) - if (!value) { - value = 1 - appActions.changeSetting(settings.PAYMENTS_MINIMUM_VISITS, value) - } - if (value > 0) synopsis.options.minPublisherVisits = value - - if (process.env.NODE_ENV === 'test') { - synopsis.options.minPublisherDuration = 0 - synopsis.options.minPublisherVisits = 0 - } else { - if (process.env.LEDGER_PUBLISHER_MIN_DURATION) { - synopsis.options.minPublisherDuration = ledgerClient.prototype.numbion(process.env.LEDGER_PUBLISHER_MIN_DURATION) - } - if (process.env.LEDGER_PUBLISHER_MIN_VISITS) { - synopsis.options.minPublisherVisits = ledgerClient.prototype.numbion(process.env.LEDGER_PUBLISHER_MIN_VISITS) - } - } - - underscore.keys(synopsis.publishers).forEach((publisher) => { - excludeP(publisher) - verifiedP(publisher) - }) - - updatePublisherInfo() - } - - if (publisherInfo._internal.verboseP) console.log('\nstarting up ledger publisher integration') - - if (err) { - if (err.code !== 'ENOENT') console.error('synopsisPath read error: ' + err.toString()) - initSynopsis() - return updatePublisherInfo() - } - - if (publisherInfo._internal.verboseP) console.log('\nfound ' + pathName(synopsisPath)) - try { - synopsis = new (ledgerPublisher.Synopsis)(data) - } catch (ex) { - console.error('synopsisPath parse error: ' + ex.toString()) - } - initSynopsis() - underscore.keys(synopsis.publishers).forEach((publisher) => { - if (synopsis.publishers[publisher].faviconURL === null) delete synopsis.publishers[publisher].faviconURL - }) - updatePublisherInfo() - - // change undefined include publishers to include publishers - appActions.enableUndefinedPublishers(synopsis.publishers) - - fs.unlink(pathName(publisherPath), (err) => { - if ((err) && (err.code !== 'ENOENT')) { - console.error('error removing file ' + pathName(publisherPath) + ': ', err) - } - }) - fs.unlink(pathName(scoresPath), (err) => { - if ((err) && (err.code !== 'ENOENT')) { - console.error('error removing file ' + pathName(scoresPath) + ': ', err) - } - }) - }) -} - /* * update location information */ @@ -919,39 +330,23 @@ var updateLocation = (location, publisher) => { if (updateP) updateLocationInfo(location) } -/* - * update publisher information - */ - -var publisherInfo = { - options: undefined, - - synopsis: undefined, - - _internal: { - enabled: false, - - ruleset: { raw: [], cooked: [] } - } -} - -const getFavIcon = (entry, page, location) => { - if ((page.protocol) && (!entry.protocol)) { - entry.protocol = page.protocol +const getFavIcon = (publisher, page, location) => { + if ((page.protocol) && (!publisher.protocol)) { + publisher.protocol = page.protocol } - if ((typeof entry.faviconURL === 'undefined') && ((page.faviconURL) || (entry.protocol))) { - let faviconURL = page.faviconURL || entry.protocol + '//' + urlParse(location).host + '/favicon.ico' + if ((typeof publisher.faviconURL === 'undefined') && ((page.faviconURL) || (publisher.protocol))) { + let faviconURL = page.faviconURL || publisher.protocol + '//' + urlParse(location).host + '/favicon.ico' if (publisherInfo._internal.debugP) { console.log('\nrequest: ' + faviconURL) } - entry.faviconURL = null - fetchFavIcon(entry, faviconURL) + publisher.faviconURL = null + fetchFavIcon(publisher, faviconURL) } } -const fetchFavIcon = (entry, url, redirects) => { +const fetchFavIcon = (publisher, url, redirects) => { if (typeof redirects === 'undefined') redirects = 0 request.request({ url: url, responseType: 'blob' }, (err, response, blob) => { @@ -978,7 +373,7 @@ const fetchFavIcon = (entry, url, redirects) => { } if ((response.statusCode === 301) && (response.headers.location)) { - if (redirects < 3) fetchFavIcon(entry, response.headers.location, redirects++) + if (redirects < 3) fetchFavIcon(publisher, response.headers.location, redirects++) return null } @@ -1008,1262 +403,111 @@ const fetchFavIcon = (entry, url, redirects) => { } else if ((tail > 0) && (tail + 8 >= blob.length)) return if (publisherInfo._internal.debugP) { - console.log('\n' + entry.site + ' synopsis=' + - JSON.stringify(underscore.extend(underscore.omit(entry, [ 'faviconURL', 'window' ]), - { faviconURL: entry.faviconURL && '... ' }), null, 2)) + console.log('\n' + publisher.site + ' synopsis=' + + JSON.stringify(underscore.extend(underscore.omit(publisher, [ 'faviconURL', 'window' ]), + { faviconURL: publisher.faviconURL && '... ' }), null, 2)) } - entry.faviconURL = blob + publisher.faviconURL = blob updatePublisherInfo() }) } -var updatePublisherInfo = (changedPublisher) => { - var data - muonWriter(pathName(synopsisPath), synopsis) - if (!publisherInfo._internal.enabled) return +/* + * publisher utilities + */ - publisherInfo.synopsis = synopsisNormalizer(changedPublisher) - publisherInfo.synopsisOptions = synopsis.options +/* + * update ledger information + */ - if (publisherInfo._internal.debugP) { - data = [] - publisherInfo.synopsis.forEach((entry) => { - data.push(underscore.extend(underscore.omit(entry, [ 'faviconURL' ]), { faviconURL: entry.faviconURL && '...' })) - }) +var ledgerInfo = { + creating: false, + created: false, - console.log('\nupdatePublisherInfo: ' + JSON.stringify({ options: publisherInfo.synopsisOptions, synopsis: data }, null, 2)) - } + reconcileFrequency: undefined, + reconcileStamp: undefined, - appActions.updatePublisherInfo(underscore.omit(publisherInfo, [ '_internal' ])) -} + transactions: + [ +/* + { + viewingId: undefined, + surveyorId: undefined, + contribution: { + fiat: { + amount: undefined, + currency: undefined + }, + rates: { + [currency]: undefined // bitcoin value in + }, + satoshis: undefined, + fee: undefined + }, + submissionStamp: undefined, + submissionId: undefined, + count: undefined, + satoshis: undefined, + votes: undefined, + ballots: { + [publisher]: undefined + } + , ... + */ + ], + + // set from ledger client's state.paymentInfo OR client's getWalletProperties + // Bitcoin wallet address + address: undefined, -var blockedP = (publisher) => { - var siteSetting = appStore.getState().get('siteSettings').get(`https?://${publisher}`) + // Bitcoin wallet balance (truncated BTC and satoshis) + balance: undefined, + unconfirmed: undefined, + satoshis: undefined, - return ((!!siteSetting) && (siteSetting.get('ledgerPaymentsShown') === false)) -} + // the desired contribution (the btc value approximates the amount/currency designation) + btc: undefined, + amount: undefined, + currency: undefined, -var stickyP = (publisher) => { - var siteSettings = appStore.getState().get('siteSettings') - var pattern = `https?://${publisher}` - var siteSetting = siteSettings.get(pattern) - var result = (siteSetting) && (siteSetting.get('ledgerPayments')) + paymentURL: undefined, + buyURL: undefined, + bravery: undefined, - // NB: legacy clean-up - if ((typeof result === 'undefined') && (typeof synopsis.publishers[publisher].options.stickyP !== 'undefined')) { - result = synopsis.publishers[publisher].options.stickyP - appActions.changeSiteSetting(pattern, 'ledgerPayments', result) - } + // wallet credentials + paymentId: undefined, + passphrase: undefined, - if (synopsis.publishers[publisher] && - synopsis.publishers[publisher].options && - synopsis.publishers[publisher].options.stickyP) { - delete synopsis.publishers[publisher].options.stickyP - } + // advanced ledger settings + minPublisherDuration: undefined, + minPublisherVisits: undefined, + showOnlyVerified: undefined, - return (result === undefined || result) -} + hasBitcoinHandler: false, -var eligibleP = (publisher) => { - if (!synopsis.options.minPublisherDuration && process.env.NODE_ENV !== 'test') { - synopsis.options.minPublisherDuration = getSetting(settings.PAYMENTS_MINIMUM_VISIT_TIME) - } + // geoIP/exchange information + countryCode: undefined, + exchangeInfo: undefined, - return ((synopsis.publishers[publisher].scores[synopsis.options.scorekeeper] > 0) && - (synopsis.publishers[publisher].duration >= synopsis.options.minPublisherDuration) && - (synopsis.publishers[publisher].visits >= synopsis.options.minPublisherVisits)) + _internal: { + exchangeExpiry: 0, + exchanges: {}, + geoipExpiry: 0 + }, + error: null } -var visibleP = (publisher) => { - if (synopsis.options.showOnlyVerified === undefined) { - synopsis.options.showOnlyVerified = getSetting(settings.PAYMENTS_ALLOW_NON_VERIFIED) - } - - const publisherOptions = synopsis.publishers[publisher].options - const onlyVerified = !synopsis.options.showOnlyVerified - - // Publisher Options - const excludedByUser = blockedP(publisher) - const eligibleByPublisherToggle = stickyP(publisher) != null - const eligibleByNumberOfVisits = eligibleP(publisher) - const isInExclusionList = publisherOptions && publisherOptions.exclude - const verifiedPublisher = publisherOptions && publisherOptions.verified - - // websites not included in exclusion list are eligible by number of visits - // but can be enabled by user action in the publisher toggle - const isEligible = (eligibleByNumberOfVisits && !isInExclusionList) || eligibleByPublisherToggle - - // If user decide to remove the website, don't show it. - if (excludedByUser) { - return false - } - - // Unless user decided to enable publisher with publisherToggle, - // do not show exclusion list. - if (!eligibleByPublisherToggle && isInExclusionList) { - return false - } - - // If verified option is set, only show verified publishers - if (isEligible && onlyVerified) { - return verifiedPublisher - } - - return isEligible -} - -var contributeP = (publisher) => { - return (((stickyP(publisher)) || (synopsis.publishers[publisher].options.exclude !== true)) && - (eligibleP(publisher)) && - (!blockedP(publisher))) -} - -var synopsisNormalizer = (changedPublisher) => { - // courtesy of https://stackoverflow.com/questions/13483430/how-to-make-rounded-percentages-add-up-to-100#13485888 - const roundToTarget = (l, target, property) => { - let off = target - underscore.reduce(l, (acc, x) => { return acc + Math.round(x[property]) }, 0) - - return underscore.sortBy(l, (x) => Math.round(x[property]) - x[property]) - .map((x, i) => { - x[property] = Math.round(x[property]) + (off > i) - (i >= (l.length + off)) - return x - }) - } - - const normalizePinned = (dataPinned, total, target, setOne) => dataPinned.map((publisher) => { - let newPer - let floatNumber - - if (setOne) { - newPer = 1 - floatNumber = 1 - } else { - floatNumber = (publisher.pinPercentage / total) * target - newPer = Math.floor(floatNumber) - if (newPer < 1) { - newPer = 1 - } - } - - publisher.weight = floatNumber - publisher.pinPercentage = newPer - return publisher - }) - - const getPublisherData = (result) => { - let duration = result.duration - - let data = { - verified: result.options.verified || false, - site: result.publisher, - views: result.visits, - duration: duration, - daysSpent: 0, - hoursSpent: 0, - minutesSpent: 0, - secondsSpent: 0, - faviconURL: result.faviconURL, - score: result.scores[scorekeeper], - pinPercentage: result.pinPercentage, - weight: result.pinPercentage - } - // HACK: Protocol is sometimes blank here, so default to http:// so we can - // still generate publisherURL. - data.publisherURL = (result.protocol || 'http:') + '//' + result.publisher - - if (duration >= msecs.day) { - data.daysSpent = Math.max(Math.round(duration / msecs.day), 1) - } else if (duration >= msecs.hour) { - data.hoursSpent = Math.max(Math.floor(duration / msecs.hour), 1) - data.minutesSpent = Math.round((duration % msecs.hour) / msecs.minute) - } else if (duration >= msecs.minute) { - data.minutesSpent = Math.max(Math.round(duration / msecs.minute), 1) - data.secondsSpent = Math.round((duration % msecs.minute) / msecs.second) - } else { - data.secondsSpent = Math.max(Math.round(duration / msecs.second), 1) - } - - return data - } - - let results - let dataPinned = [] - let dataUnPinned = [] - let dataExcluded = [] - let pinnedTotal = 0 - let unPinnedTotal = 0 - const scorekeeper = synopsis.options.scorekeeper - - results = [] - underscore.keys(synopsis.publishers).forEach((publisher) => { - if (!visibleP(publisher)) return - - results.push(underscore.extend({publisher: publisher}, underscore.omit(synopsis.publishers[publisher], 'window'))) - }, synopsis) - results = underscore.sortBy(results, (entry) => { return -entry.scores[scorekeeper] }) - - // move publisher to the correct array and get totals - results.forEach((result) => { - if (result.pinPercentage && result.pinPercentage > 0) { - // pinned - pinnedTotal += result.pinPercentage - dataPinned.push(getPublisherData(result)) - } else if (stickyP(result.publisher)) { - // unpinned - unPinnedTotal += result.scores[scorekeeper] - dataUnPinned.push(result) - } else { - // excluded - let publisher = getPublisherData(result) - publisher.percentage = 0 - publisher.weight = 0 - dataExcluded.push(publisher) - } - }) - - // round if over 100% of pinned publishers - if (pinnedTotal > 100) { - if (changedPublisher) { - const changedObject = dataPinned.filter(publisher => publisher.site === changedPublisher)[0] - const setOne = changedObject.pinPercentage > (100 - dataPinned.length - 1) - - if (setOne) { - changedObject.pinPercentage = 100 - dataPinned.length + 1 - changedObject.weight = changedObject.pinPercentage - } - - const pinnedRestTotal = pinnedTotal - changedObject.pinPercentage - dataPinned = dataPinned.filter(publisher => publisher.site !== changedPublisher) - dataPinned = normalizePinned(dataPinned, pinnedRestTotal, (100 - changedObject.pinPercentage), setOne) - dataPinned = roundToTarget(dataPinned, (100 - changedObject.pinPercentage), 'pinPercentage') - - dataPinned.push(changedObject) - } else { - dataPinned = normalizePinned(dataPinned, pinnedTotal, 100) - dataPinned = roundToTarget(dataPinned, 100, 'pinPercentage') - } - - dataUnPinned = dataUnPinned.map((result) => { - let publisher = getPublisherData(result) - publisher.percentage = 0 - publisher.weight = 0 - return publisher - }) - - // sync app store - appActions.changeLedgerPinnedPercentages(dataPinned) - } else if (dataUnPinned.length === 0 && pinnedTotal < 100) { - // when you don't have any unpinned sites and pinned total is less then 100 % - dataPinned = normalizePinned(dataPinned, pinnedTotal, 100, false) - dataPinned = roundToTarget(dataPinned, 100, 'pinPercentage') - - // sync app store - appActions.changeLedgerPinnedPercentages(dataPinned) - } else { - // unpinned publishers - dataUnPinned = dataUnPinned.map((result) => { - let publisher = getPublisherData(result) - const floatNumber = (publisher.score / unPinnedTotal) * (100 - pinnedTotal) - publisher.percentage = Math.round(floatNumber) - publisher.weight = floatNumber - return publisher - }) - - // normalize unpinned values - dataUnPinned = roundToTarget(dataUnPinned, (100 - pinnedTotal), 'percentage') - } - - const newData = dataPinned.concat(dataUnPinned, dataExcluded) - - // sync synopsis - newData.forEach((item) => { - synopsis.publishers[item.site].weight = item.weight - synopsis.publishers[item.site].pinPercentage = item.pinPercentage - }) - - return newData -} - -/* - * publisher utilities - */ - -var currentLocation = 'NOOP' -var currentTimestamp = underscore.now() - -var visit = (location, timestamp, tabId) => { - var setLocation = () => { - var duration, publisher, revisitP - - if (!synopsis) return - - if (publisherInfo._internal.verboseP) { - console.log('locations[' + currentLocation + ']=' + JSON.stringify(locations[currentLocation], null, 2) + - ' duration=' + (timestamp - currentTimestamp) + ' msec' + ' tabId=' + tabId) - } - if ((location === currentLocation) || (!locations[currentLocation]) || (!tabId)) return - - publisher = locations[currentLocation].publisher - if (!publisher) return - - if (!publishers[publisher]) publishers[publisher] = {} - if (!publishers[publisher][currentLocation]) publishers[publisher][currentLocation] = { tabIds: [] } - publishers[publisher][currentLocation].timestamp = timestamp - revisitP = publishers[publisher][currentLocation].tabIds.indexOf(tabId) !== -1 - if (!revisitP) publishers[publisher][currentLocation].tabIds.push(tabId) - - duration = timestamp - currentTimestamp - if (publisherInfo._internal.verboseP) { - console.log('\nadd publisher ' + publisher + ': ' + duration + ' msec' + ' revisitP=' + revisitP + ' state=' + - JSON.stringify(underscore.extend({ location: currentLocation }, publishers[publisher][currentLocation]), - null, 2)) - } - synopsis.addPublisher(publisher, { duration: duration, revisitP: revisitP }) - updatePublisherInfo() - verifiedP(publisher) - } - - setLocation() - if (location === currentLocation) return - - currentLocation = location.match(/^about/) ? 'NOOP' : location - currentTimestamp = timestamp -} - -var cacheRuleSet = (ruleset) => { - var stewed, syncP - - if ((!ruleset) || (underscore.isEqual(publisherInfo._internal.ruleset.raw, ruleset))) return - - try { - stewed = [] - ruleset.forEach((rule) => { - var entry = { condition: acorn.parse(rule.condition) } - - if (rule.dom) { - if (rule.dom.publisher) { - entry.publisher = { selector: rule.dom.publisher.nodeSelector, - consequent: acorn.parse(rule.dom.publisher.consequent) - } - } - if (rule.dom.faviconURL) { - entry.faviconURL = { selector: rule.dom.faviconURL.nodeSelector, - consequent: acorn.parse(rule.dom.faviconURL.consequent) - } - } - } - if (!entry.publisher) entry.consequent = rule.consequent ? acorn.parse(rule.consequent) : rule.consequent - - stewed.push(entry) - }) - - publisherInfo._internal.ruleset.raw = ruleset - publisherInfo._internal.ruleset.cooked = stewed - if (!synopsis) return - - underscore.keys(synopsis.publishers).forEach((publisher) => { - var location = (synopsis.publishers[publisher].protocol || 'http:') + '//' + publisher - var ctx = urlParse(location, true) - - ctx.TLD = tldjs.getPublicSuffix(ctx.host) - if (!ctx.TLD) return - - ctx = underscore.mapObject(ctx, function (value, key) { if (!underscore.isFunction(value)) return value }) - ctx.URL = location - ctx.SLD = tldjs.getDomain(ctx.host) - ctx.RLD = tldjs.getSubdomain(ctx.host) - ctx.QLD = ctx.RLD ? underscore.last(ctx.RLD.split('.')) : '' - - stewed.forEach((rule) => { - if ((rule.consequent !== null) || (rule.dom)) return - if (!rulesolver.resolve(rule.condition, ctx)) return - - if (publisherInfo._internal.verboseP) console.log('\npurging ' + publisher) - delete synopsis.publishers[publisher] - delete publishers[publisher] - syncP = true - }) - }) - if (!syncP) return - - updatePublisherInfo() - } catch (ex) { - console.error('ruleset error: ', ex) - } -} - -var excludeP = (publisher, callback) => { - var doneP - - var done = (err, result) => { - doneP = true - if ((!err) && (typeof result !== 'undefined') && (!!synopsis.publishers[publisher]) && - (synopsis.publishers[publisher].options.exclude !== result)) { - synopsis.publishers[publisher].options.exclude = result - updatePublisherInfo() - } - - if (callback) callback(err, result) - } - - if (!v2RulesetDB) return setTimeout(() => { excludeP(publisher, callback) }, 5 * msecs.second) - - inspectP(v2RulesetDB, v2RulesetPath, publisher, 'exclude', 'domain:' + publisher, (err, result) => { - var props - - if (!err) return done(err, result.exclude) - - props = ledgerPublisher.getPublisherProps('https://' + publisher) - if (!props) return done() - - v2RulesetDB.createReadStream({ lt: 'domain:' }).on('data', (data) => { - var regexp, result, sldP, tldP - - if (doneP) return - - sldP = data.key.indexOf('SLD:') === 0 - tldP = data.key.indexOf('TLD:') === 0 - if ((!tldP) && (!sldP)) return - - if (underscore.intersection(data.key.split(''), - [ '^', '$', '*', '+', '?', '[', '(', '{', '|' ]).length === 0) { - if ((data.key !== ('TLD:' + props.TLD)) && (props.SLD && data.key !== ('SLD:' + props.SLD.split('.')[0]))) return - } else { - try { - regexp = new RegExp(data.key.substr(4)) - if (!regexp.test(props[tldP ? 'TLD' : 'SLD'])) return - } catch (ex) { - console.error(v2RulesetPath + ' stream invalid regexp ' + data.key + ': ' + ex.toString()) - } - } - - try { - result = JSON.parse(data.value) - } catch (ex) { - console.error(v2RulesetPath + ' stream invalid JSON ' + data.entry + ': ' + data.value) - } - - done(null, result.exclude) - }).on('error', (err) => { - console.error(v2RulesetPath + ' stream error: ' + JSON.stringify(err, null, 2)) - }).on('close', () => { - }).on('end', () => { - if (!doneP) done(null, false) - }) - }) -} - -var verifiedP = (publisher, callback) => { - inspectP(v2PublishersDB, v2PublishersPath, publisher, 'verified', null, callback) - - if (process.env.NODE_ENV === 'test') { - testVerifiedPublishers.forEach((publisher) => { - if (synopsis.publishers[publisher]) { - if (!synopsis.publishers[publisher].options) { - synopsis.publishers[publisher].options = {} - } - - synopsis.publishers[publisher].options.verified = true - } - }) - updatePublisherInfo() - } -} - -var inspectP = (db, path, publisher, property, key, callback) => { - var done = (err, result) => { - if ((!err) && (typeof result !== 'undefined') && (!!synopsis.publishers[publisher]) && - (synopsis.publishers[publisher].options[property] !== result[property])) { - synopsis.publishers[publisher].options[property] = result[property] - updatePublisherInfo() - } - - if (callback) callback(err, result) - } - - if (!key) key = publisher - db.get(key, (err, value) => { - var result - - if (err) { - if (!err.notFound) console.error(path + ' get ' + key + ' error: ' + JSON.stringify(err, null, 2)) - return done(err) - } - - try { - result = JSON.parse(value) - } catch (ex) { - console.error(v2RulesetPath + ' stream invalid JSON ' + key + ': ' + value) - result = {} - } - - done(null, result) - }) -} - -/* - * update ledger information - */ - -var ledgerInfo = { - creating: false, - created: false, - - reconcileFrequency: undefined, - reconcileStamp: undefined, - - transactions: - [ -/* - { - viewingId: undefined, - surveyorId: undefined, - contribution: { - fiat: { - amount: undefined, - currency: undefined - }, - rates: { - [currency]: undefined // bitcoin value in - }, - satoshis: undefined, - fee: undefined - }, - submissionStamp: undefined, - submissionId: undefined, - count: undefined, - satoshis: undefined, - votes: undefined, - ballots: { - [publisher]: undefined - } - , ... - */ - ], - - // set from ledger client's state.paymentInfo OR client's getWalletProperties - // Bitcoin wallet address - address: undefined, - - // Bitcoin wallet balance (truncated BTC and satoshis) - balance: undefined, - unconfirmed: undefined, - satoshis: undefined, - - // the desired contribution (the btc value approximates the amount/currency designation) - btc: undefined, - amount: undefined, - currency: undefined, - - paymentURL: undefined, - buyURL: undefined, - bravery: undefined, - - // wallet credentials - paymentId: undefined, - passphrase: undefined, - - // advanced ledger settings - minPublisherDuration: undefined, - minPublisherVisits: undefined, - showOnlyVerified: undefined, - - hasBitcoinHandler: false, - - // geoIP/exchange information - countryCode: undefined, - exchangeInfo: undefined, - - _internal: { - exchangeExpiry: 0, - exchanges: {}, - geoipExpiry: 0 - }, - error: null -} - -var updateLedgerInfo = () => { - var info = ledgerInfo._internal.paymentInfo - var now = underscore.now() - - if (info) { - underscore.extend(ledgerInfo, - underscore.pick(info, [ 'address', 'passphrase', 'balance', 'unconfirmed', 'satoshis', 'btc', 'amount', - 'currency' ])) - if ((!info.buyURLExpires) || (info.buyURLExpires > now)) { - ledgerInfo.buyURL = info.buyURL - ledgerInfo.buyMaximumUSD = 6 - } - if (typeof process.env.ADDFUNDS_URL !== 'undefined') { - ledgerInfo.buyURLFrame = true - ledgerInfo.buyURL = process.env.ADDFUNDS_URL + '?' + - querystring.stringify({ currency: ledgerInfo.currency, - amount: getSetting(settings.PAYMENTS_CONTRIBUTION_AMOUNT), - address: ledgerInfo.address }) - ledgerInfo.buyMaximumUSD = false - } - - underscore.extend(ledgerInfo, ledgerInfo._internal.cache || {}) - } - - if ((client) && (now > ledgerInfo._internal.geoipExpiry)) { - ledgerInfo._internal.geoipExpiry = now + (5 * msecs.minute) - - if (!ledgerGeoIP) ledgerGeoIP = require('ledger-geoip') - return ledgerGeoIP.getGeoIP(client.options, (err, provider, result) => { - if (err) console.warn('ledger geoip warning: ' + JSON.stringify(err, null, 2)) - if (result) ledgerInfo.countryCode = result - - ledgerInfo.exchangeInfo = ledgerInfo._internal.exchanges[ledgerInfo.countryCode] - - if (now <= ledgerInfo._internal.exchangeExpiry) return updateLedgerInfo() - - ledgerInfo._internal.exchangeExpiry = now + msecs.day - roundtrip({ path: '/v1/exchange/providers' }, client.options, (err, response, body) => { - if (err) console.error('ledger exchange error: ' + JSON.stringify(err, null, 2)) - - ledgerInfo._internal.exchanges = body || {} - ledgerInfo.exchangeInfo = ledgerInfo._internal.exchanges[ledgerInfo.countryCode] - updateLedgerInfo() - }) - }) - } - - if (ledgerInfo._internal.debugP) { - console.log('\nupdateLedgerInfo: ' + JSON.stringify(underscore.omit(ledgerInfo, [ '_internal' ]), null, 2)) - } - - appActions.updateLedgerInfo(underscore.omit(ledgerInfo, [ '_internal' ])) -} - -/* - * ledger client callbacks - */ - -var callback = (err, result, delayTime) => { - var results - var entries = client && client.report() - - if (clientOptions.verboseP) { - console.log('\nledger client callback: clientP=' + (!!client) + ' errP=' + (!!err) + ' resultP=' + (!!result) + - ' delayTime=' + delayTime) - } - - if (err) { - console.log('ledger client error(1): ' + JSON.stringify(err, null, 2) + (err.stack ? ('\n' + err.stack) : '')) - if (!client) return - - if (typeof delayTime === 'undefined') delayTime = random.randomInt({ min: msecs.minute, max: 10 * msecs.minute }) - } - - if (!result) return run(delayTime) - - if ((client) && (result.properties.wallet)) { - if (!ledgerInfo.created) setPaymentInfo(getSetting(settings.PAYMENTS_CONTRIBUTION_AMOUNT)) - - getStateInfo(result) - getPaymentInfo() - } - cacheRuleSet(result.ruleset) - if (result.rulesetV2) { - results = result.rulesetV2 - delete result.rulesetV2 - - entries = [] - results.forEach((entry) => { - var key = entry.facet + ':' + entry.publisher - - if (entry.exclude !== false) { - entries.push({ type: 'put', key: key, value: JSON.stringify(underscore.omit(entry, [ 'facet', 'publisher' ])) }) - } else { - entries.push({ type: 'del', key: key }) - } - }) - - v2RulesetDB.batch(entries, (err) => { - if (err) return console.error(v2RulesetPath + ' error: ' + JSON.stringify(err, null, 2)) - - if (entries.length === 0) return - - underscore.keys(synopsis.publishers).forEach((publisher) => { -// be safe... - if (synopsis.publishers[publisher]) delete synopsis.publishers[publisher].options.exclude - - excludeP(publisher) - }) - }) - } - if (result.publishersV2) { - results = result.publishersV2 - delete result.publishersV2 - - entries = [] - results.forEach((entry) => { - entries.push({ type: 'put', - key: entry.publisher, - value: JSON.stringify(underscore.omit(entry, [ 'publisher' ])) - }) - if ((synopsis.publishers[entry.publisher]) && - (synopsis.publishers[entry.publisher].options.verified !== entry.verified)) { - synopsis.publishers[entry.publisher].options.verified = entry.verified - updatePublisherInfo() - } - }) - v2PublishersDB.batch(entries, (err) => { - if (err) return console.error(v2PublishersPath + ' error: ' + JSON.stringify(err, null, 2)) - }) - } - - muonWriter(pathName(statePath), result) - run(delayTime) -} - -var roundtrip = (params, options, callback) => { - var i - var parts = typeof params.server === 'string' ? urlParse(params.server) - : typeof params.server !== 'undefined' ? params.server - : typeof options.server === 'string' ? urlParse(options.server) : options.server - var rawP = options.rawP - - if (!params.method) params.method = 'GET' - parts = underscore.extend(underscore.pick(parts, [ 'protocol', 'hostname', 'port' ]), - underscore.omit(params, [ 'headers', 'payload', 'timeout' ])) - -// TBD: let the user configure this via preferences [MTR] - if ((parts.hostname === 'ledger.brave.com') && (params.useProxy)) parts.hostname = 'ledger-proxy.privateinternetaccess.com' - - i = parts.path.indexOf('?') - if (i !== -1) { - parts.pathname = parts.path.substring(0, i) - parts.search = parts.path.substring(i) - } else { - parts.pathname = parts.path - } - - options = { - url: urlFormat(parts), - method: params.method, - payload: params.payload, - responseType: 'text', - headers: underscore.defaults(params.headers || {}, { 'content-type': 'application/json; charset=utf-8' }), - verboseP: options.verboseP - } - request.request(options, (err, response, body) => { - var payload - - if ((response) && (options.verboseP)) { - console.log('[ response for ' + params.method + ' ' + parts.protocol + '//' + parts.hostname + params.path + ' ]') - console.log('>>> HTTP/' + response.httpVersionMajor + '.' + response.httpVersionMinor + ' ' + response.statusCode + - ' ' + (response.statusMessage || '')) - underscore.keys(response.headers).forEach((header) => { console.log('>>> ' + header + ': ' + response.headers[header]) }) - console.log('>>>') - console.log('>>> ' + (body || '').split('\n').join('\n>>> ')) - } - - if (err) return callback(err) - - if (Math.floor(response.statusCode / 100) !== 2) { - return callback(new Error('HTTP response ' + response.statusCode) + ' for ' + params.method + ' ' + params.path) - } - - try { - payload = rawP ? body : (response.statusCode !== 204) ? JSON.parse(body) : null - } catch (err) { - return callback(err) - } - - try { - callback(null, response, payload) - } catch (err0) { - if (options.verboseP) console.log('\ncallback: ' + err0.toString() + '\n' + err0.stack) - } - }) - - if (!options.verboseP) return - - console.log('<<< ' + params.method + ' ' + parts.protocol + '//' + parts.hostname + params.path) - underscore.keys(options.headers).forEach((header) => { console.log('<<< ' + header + ': ' + options.headers[header]) }) - console.log('<<<') - if (options.payload) console.log('<<< ' + JSON.stringify(params.payload, null, 2).split('\n').join('\n<<< ')) -} - -var runTimeoutId = false - -var run = (delayTime) => { - if (clientOptions.verboseP) { - var entries - - console.log('\nledger client run: clientP=' + (!!client) + ' delayTime=' + delayTime) - - var line = (fields) => { - var result = '' - - fields.forEach((field) => { - var spaces - var max = (result.length > 0) ? 9 : 19 - - if (typeof field !== 'string') field = field.toString() - if (field.length < max) { - spaces = ' '.repeat(max - field.length) - field = spaces + field - } else { - field = field.substr(0, max) - } - result += ' ' + field - }) - - console.log(result.substr(1)) - } - - line([ 'publisher', - 'blockedP', 'stickyP', 'verified', - 'excluded', 'eligibleP', 'visibleP', - 'contribP', - 'duration', 'visits' - ]) - entries = synopsis.topN() || [] - entries.forEach((entry) => { - var publisher = entry.publisher - - line([ publisher, - blockedP(publisher), stickyP(publisher), synopsis.publishers[publisher].options.verified === true, - synopsis.publishers[publisher].options.exclude === true, eligibleP(publisher), visibleP(publisher), - contributeP(publisher), - Math.round(synopsis.publishers[publisher].duration / 1000), synopsis.publishers[publisher].visits ]) - }) - } - - if ((typeof delayTime === 'undefined') || (!client)) return - - var active, state, weights, winners - var ballots = client.ballots() - var data = (synopsis) && (ballots > 0) && synopsisNormalizer() - - if (data) { - weights = [] - data.forEach((datum) => { weights.push({ publisher: datum.site, weight: datum.weight / 100.0 }) }) - winners = synopsis.winners(ballots, weights) - } - if (!winners) winners = [] - - try { - winners.forEach((winner) => { - var result - - if (!contributeP(winner)) return - - result = client.vote(winner) - if (result) state = result - }) - if (state) muonWriter(pathName(statePath), state) - } catch (ex) { - console.log('ledger client error(2): ' + ex.toString() + (ex.stack ? ('\n' + ex.stack) : '')) - } - - if (delayTime === 0) { - try { - delayTime = client.timeUntilReconcile() - } catch (ex) { - delayTime = false - } - if (delayTime === false) delayTime = random.randomInt({ min: msecs.minute, max: 10 * msecs.minute }) - } - if (delayTime > 0) { - if (runTimeoutId) return - - active = client - if (delayTime > (1 * msecs.hour)) delayTime = random.randomInt({ min: 3 * msecs.minute, max: msecs.hour }) - - runTimeoutId = setTimeout(() => { - runTimeoutId = false - if (active !== client) return - - if (!client) return console.log('\n\n*** MTR says this can\'t happen(1)... please tell him that he\'s wrong!\n\n') - - if (client.sync(callback) === true) return run(0) - }, delayTime) - return - } - - if (client.isReadyToReconcile()) return client.reconcile(uuid.v4().toLowerCase(), callback) - - console.log('what? wait, how can this happen?') -} - -var getStateInfo = (state) => { - var ballots, i, transaction - var info = state.paymentInfo - var then = underscore.now() - msecs.year - - if (!state.properties.wallet) return - - ledgerInfo.paymentId = state.properties.wallet.paymentId - ledgerInfo.passphrase = state.properties.wallet.keychains.passphrase - - ledgerInfo.created = !!state.properties.wallet - ledgerInfo.creating = !ledgerInfo.created - - ledgerInfo.reconcileFrequency = state.properties.days - ledgerInfo.reconcileStamp = state.reconcileStamp - - if (info) { - ledgerInfo._internal.paymentInfo = info - cacheReturnValue() - } - - ledgerInfo.transactions = [] - if (!state.transactions) return updateLedgerInfo() - - for (i = state.transactions.length - 1; i >= 0; i--) { - transaction = state.transactions[i] - if (transaction.stamp < then) break - - if ((!transaction.ballots) || (transaction.ballots.length < transaction.count)) continue - - ballots = underscore.clone(transaction.ballots || {}) - state.ballots.forEach((ballot) => { - if (ballot.viewingId !== transaction.viewingId) return - - if (!ballots[ballot.publisher]) ballots[ballot.publisher] = 0 - ballots[ballot.publisher]++ - }) - - ledgerInfo.transactions.push(underscore.extend(underscore.pick(transaction, - [ 'viewingId', 'contribution', 'submissionStamp', 'count' ]), - { ballots: ballots })) - } - - observeTransactions(state.transactions) - updateLedgerInfo() -} - -// Observe ledger client state.transactions for changes. -// Called by getStateInfo(). Updated state provided by ledger-client. -var cachedTransactions = null -var observeTransactions = (transactions) => { - if (underscore.isEqual(cachedTransactions, transactions)) { - return - } - // Notify the user of new transactions. - if (getSetting(settings.PAYMENTS_NOTIFICATIONS) && cachedTransactions !== null) { - const newTransactions = underscore.difference(transactions, cachedTransactions) - if (newTransactions.length > 0) { - const newestTransaction = newTransactions[newTransactions.length - 1] - showNotificationPaymentDone(newestTransaction.contribution.fiat) - } - } - cachedTransactions = underscore.clone(transactions) -} - -var balanceTimeoutId = false - -var getBalance = () => { - if (!client) return - - balanceTimeoutId = setTimeout(getBalance, 1 * msecs.minute) - if (!ledgerInfo.address) return - - if (!ledgerBalance) ledgerBalance = require('ledger-balance') - ledgerBalance.getBalance(ledgerInfo.address, underscore.extend({ balancesP: true }, client.options), - (err, provider, result) => { - var unconfirmed - var info = ledgerInfo._internal.paymentInfo - - if (err) return console.warn('ledger balance warning: ' + JSON.stringify(err, null, 2)) - - if (typeof result.unconfirmed === 'undefined') return - - if (result.unconfirmed > 0) { - unconfirmed = (result.unconfirmed / 1e8).toFixed(4) - if ((info || ledgerInfo).unconfirmed === unconfirmed) return - - ledgerInfo.unconfirmed = unconfirmed - if (info) info.unconfirmed = ledgerInfo.unconfirmed - if (clientOptions.verboseP) console.log('\ngetBalance refreshes ledger info: ' + ledgerInfo.unconfirmed) - return updateLedgerInfo() - } - - if (ledgerInfo.unconfirmed === '0.0000') return - - if (clientOptions.verboseP) console.log('\ngetBalance refreshes payment info') - getPaymentInfo() - }) -} - -var logError = (err, caller) => { - if (err) { - ledgerInfo.error = { - caller: caller, - error: err - } - console.error('Error in %j: %j', caller, err) - return true - } else { - ledgerInfo.error = null - return false - } -} - -var getPaymentInfo = () => { - var amount, currency - - if (!client) return - - try { - ledgerInfo.bravery = client.getBraveryProperties() - if (ledgerInfo.bravery.fee) { - amount = ledgerInfo.bravery.fee.amount - currency = ledgerInfo.bravery.fee.currency - } - - client.getWalletProperties(amount, currency, function (err, body) { - var info = ledgerInfo._internal.paymentInfo || {} - - if (logError(err, 'getWalletProperties')) { - return - } - - info = underscore.extend(info, underscore.pick(body, [ 'buyURL', 'buyURLExpires', 'balance', 'unconfirmed', 'satoshis' ])) - info.address = client.getWalletAddress() - if ((amount) && (currency)) { - info = underscore.extend(info, { amount: amount, currency: currency }) - if ((body.rates) && (body.rates[currency])) { - info.btc = (amount / body.rates[currency]).toFixed(8) - } - } - ledgerInfo._internal.paymentInfo = info - updateLedgerInfo() - cacheReturnValue() - }) - } catch (ex) { - console.error('properties error: ' + ex.toString()) - } -} - -var setPaymentInfo = (amount) => { - var bravery - - if (!client) return - - try { bravery = client.getBraveryProperties() } catch (ex) { -// wallet being created... - - return setTimeout(function () { setPaymentInfo(amount) }, 2 * msecs.second) - } - - amount = parseInt(amount, 10) - if (isNaN(amount) || (amount <= 0)) return - - underscore.extend(bravery.fee, { amount: amount }) - client.setBraveryProperties(bravery, (err, result) => { - if (err) return console.error('ledger setBraveryProperties: ' + err.toString()) - - if (result) muonWriter(pathName(statePath), result) - }) - if (ledgerInfo.created) getPaymentInfo() -} - -var cacheReturnValue = () => { - var chunks, cache, paymentURL - var info = ledgerInfo._internal.paymentInfo - - if (!info) return - - if (!ledgerInfo._internal.cache) ledgerInfo._internal.cache = {} - cache = ledgerInfo._internal.cache - - paymentURL = 'bitcoin:' + info.address + '?amount=' + info.btc + '&label=' + encodeURI('Brave Software') - if (cache.paymentURL === paymentURL) return - - cache.paymentURL = paymentURL - updateLedgerInfo() - try { - chunks = [] - - qr.image(paymentURL, { type: 'png' }).on('data', (chunk) => { chunks.push(chunk) }).on('end', () => { - cache.paymentIMG = 'data:image/png;base64,' + Buffer.concat(chunks).toString('base64') - updateLedgerInfo() - }) - } catch (ex) { - console.error('qr.imageSync error: ' + ex.toString()) - } -} - -var networkConnected = underscore.debounce(() => { - if (!client) return - - if (runTimeoutId) { - clearTimeout(runTimeoutId) - runTimeoutId = false - } - if (client.sync(callback) === true) run(random.randomInt({ min: msecs.minute, max: 10 * msecs.minute })) - - if (balanceTimeoutId) clearTimeout(balanceTimeoutId) - balanceTimeoutId = setTimeout(getBalance, 5 * msecs.second) -}, 1 * msecs.minute, true) +/* + * ledger client callbacks + */ /* * low-level utilities */ -var muonWriter = (path, payload) => { - muon.file.writeImportant(path, JSON.stringify(payload, null, 2), (success) => { - if (!success) return console.error('write error: ' + path) - - if ((quitP) && (!getSetting(settings.PAYMENTS_ENABLED)) && (getSetting(settings.SHUTDOWN_CLEAR_HISTORY))) { - if (ledgerInfo._internal.debugP) console.log('\ndeleting ' + path) - return fs.unlink(path, (err) => { if (err) console.error('unlink error: ' + err.toString()) }) - } - - if (ledgerInfo._internal.debugP) console.log('\nwrote ' + path) - }) -} - -var pathName = (name) => { - var parts = path.parse(name) - - return path.join(app.getPath('userData'), parts.name + parts.ext) -} - -/* - * UI controller functionality - */ - -const showNotifications = () => { - if (getSetting(settings.PAYMENTS_ENABLED)) { - if (getSetting(settings.PAYMENTS_NOTIFICATIONS)) showEnabledNotifications() - } else { - showDisabledNotifications() - } -} - -// When Payments is disabled -const showDisabledNotifications = () => { - if (!getSetting(settings.PAYMENTS_NOTIFICATION_TRY_PAYMENTS_DISMISSED)) { - const firstRunTimestamp = appStore.getState().get('firstRunTimestamp') - if (new Date().getTime() - firstRunTimestamp < appConfig.payments.delayNotificationTryPayments) { - return - } - notificationTryPaymentsMessage = locale.translation('notificationTryPayments') - appActions.showNotification({ - greeting: locale.translation('updateHello'), - message: notificationTryPaymentsMessage, - buttons: [ - {text: locale.translation('noThanks')}, - {text: locale.translation('notificationTryPaymentsYes'), className: 'primaryButton'} - ], - options: { - style: 'greetingStyle', - persist: false - } - }) - } -} - -/** -* Show message that it's time to add funds if reconciliation is less than -* a day in the future and balance is too low. -* 24 hours prior to reconciliation, show message asking user to review -* their votes. -*/ -const showEnabledNotifications = () => { - const reconcileStamp = ledgerInfo.reconcileStamp - - if (!reconcileStamp) return - - if (reconcileStamp - underscore.now() < msecs.day) { - if (sufficientBalanceToReconcile()) { - if (shouldShowNotificationReviewPublishers()) { - showNotificationReviewPublishers(reconcileStamp + ((ledgerInfo.reconcileFrequency - 2) * msecs.day)) - } - } else if (shouldShowNotificationAddFunds()) { - showNotificationAddFunds() - } - } else if (reconcileStamp - underscore.now() < 2 * msecs.day) { - if (sufficientBalanceToReconcile() && (shouldShowNotificationReviewPublishers())) { - showNotificationReviewPublishers(underscore.now() + msecs.day) - } - } -} - -const sufficientBalanceToReconcile = () => { - const balance = Number(ledgerInfo.balance || 0) - const unconfirmed = Number(ledgerInfo.unconfirmed || 0) - return ledgerInfo.btc && - (balance + unconfirmed > 0.9 * Number(ledgerInfo.btc)) -} - -const shouldShowNotificationAddFunds = () => { - const nextTime = getSetting(settings.PAYMENTS_NOTIFICATION_ADD_FUNDS_TIMESTAMP) - return !nextTime || (underscore.now() > nextTime) -} -const showNotificationAddFunds = () => { - const nextTime = underscore.now() + (3 * msecs.day) - appActions.changeSetting(settings.PAYMENTS_NOTIFICATION_ADD_FUNDS_TIMESTAMP, nextTime) - - addFundsMessage = addFundsMessage || locale.translation('addFundsNotification') - appActions.showNotification({ - greeting: locale.translation('updateHello'), - message: addFundsMessage, - buttons: [ - {text: locale.translation('turnOffNotifications')}, - {text: locale.translation('updateLater')}, - {text: locale.translation('addFunds'), className: 'primaryButton'} - ], - options: { - style: 'greetingStyle', - persist: false - } - }) -} - -const shouldShowNotificationReviewPublishers = () => { - const nextTime = getSetting(settings.PAYMENTS_NOTIFICATION_RECONCILE_SOON_TIMESTAMP) - return !nextTime || (underscore.now() > nextTime) -} - -const showNotificationReviewPublishers = (nextTime) => { - appActions.changeSetting(settings.PAYMENTS_NOTIFICATION_RECONCILE_SOON_TIMESTAMP, nextTime) - - reconciliationMessage = reconciliationMessage || locale.translation('reconciliationNotification') - appActions.showNotification({ - greeting: locale.translation('updateHello'), - message: reconciliationMessage, - buttons: [ - {text: locale.translation('turnOffNotifications')}, - {text: locale.translation('dismiss')}, - {text: locale.translation('reviewSites'), className: 'primaryButton'} - ], - options: { - style: 'greetingStyle', - persist: false - } - }) -} - -// Called from observeTransactions() when we see a new payment (transaction). -const showNotificationPaymentDone = (transactionContributionFiat) => { - notificationPaymentDoneMessage = locale.translation('notificationPaymentDone') - .replace(/{{\s*amount\s*}}/, transactionContributionFiat.amount) - .replace(/{{\s*currency\s*}}/, transactionContributionFiat.currency) - // Hide the 'waiting for deposit' message box if it exists - appActions.hideNotification(addFundsMessage) - appActions.showNotification({ - greeting: locale.translation('updateHello'), - message: notificationPaymentDoneMessage, - buttons: [ - {text: locale.translation('turnOffNotifications')}, - {text: locale.translation('Ok'), className: 'primaryButton'} - ], - options: { - style: 'greetingStyle', - persist: false - } - }) -} module.exports = { init: init, diff --git a/app/sessionStore.js b/app/sessionStore.js index 2699ff8e626..d5112455d68 100644 --- a/app/sessionStore.js +++ b/app/sessionStore.js @@ -393,6 +393,7 @@ module.exports.cleanAppData = (immutableData, isShutdown) => { } immutableData = immutableData.delete('menu') + immutableData = immutableData.delete('pageData') try { immutableData = tabState.getPersistentState(immutableData) @@ -970,7 +971,17 @@ module.exports.defaultAppState = () => { count: 0 }, defaultWindowParams: {}, - searchDetail: null + searchDetail: null, + pageData: { + info: {}, + last: { + info: '', + url: '', + tabId: -1 + }, + load: [], + view: {} + } } } diff --git a/docs/state.md b/docs/state.md index 5e51d830c95..761abfaaa5f 100644 --- a/docs/state.md +++ b/docs/state.md @@ -174,6 +174,115 @@ AppStore themeColor: string } }, + ledger: { + info: { + address: string, // the BTC wallet address (in base58) + amount: number, // fiat amount to contribute per reconciliation period + balance: string, // confirmed balance in BTC.toFixed(4) + bravery: { + fee: { + amount: number, // set from `amount` above + currency: string // set from `currency` above + } + }, // values round-tripped through the ledger-client + btc: string, // BTC to contribute per reconciliation period + buyURL: string, // URL to buy bitcoin using debit/credit card + countryCode: string, // ISO3166 2-letter code for country of browser's location + created: boolean, // wallet is created + creating: boolean, // wallet is being created + currency: string, // fiat currency denominating the amount + error: { + caller: string, // function in which error was handled + error: object // error object returned + }, // non-null if the last updateLedgerInfo happened concurrently with an error + exchangeInfo: { + exchangeName: string, // the name of the BTC exchange + exchangeURL: string // the URL of the BTC exchange + }, // information about the corresponding "friendliest" BTC exchange (suggestions welcome!) + hasBitcoinHandler: boolean, // brave browser has a `bitcoin:` URI handler + passphrase: string, // the BTC wallet passphrase + paymentIMG: string, // the QR code equivalent of `paymentURL` expressed as "data:image/...;base64,..." + paymentURL: string, // bitcoin:...?amount={btc}&label=Brave%20Software + reconcileFrequency: number, // duration between each reconciliation in days + reconcileStamp: number, // timestamp for the next reconcilation + recoverySucceeded: boolean, // the status of an attempted recovery + satoshis: number, // confirmed balance as an integer number of satoshis + transactions: [{ + ballots: { + [publisher]: number // e.g., "wikipedia.org": 3 + }, // number of ballots cast for each publisher + contribution: { + fee: number, // bitcoin transaction fee + fiat: { + amount: number, // e.g., 5 + currency: string // e.g., "USD" + }, // roughly-equivalent fiat amount + rates: { + [currency]: number //e.g., { "USD": 575.45 } + }, // exchange rate + satoshis: number, // actual number of satoshis transferred + }, + count: number, // total number of ballots allowed to be cast + submissionStamp: number, // timestamp for this contribution + viewingId: string, // UUIDv4 for this contribution + }], // contributions reconciling/reconciled + unconfirmed: string // unconfirmed balance in BTC.toFixed(4) + }, + isBooting: boolean, // flag which telll us if wallet is still creating or not + isQuiting: boolan, // flag which tell us if we are closing ledger (because of browser close) + locations: { + [url]: { + publisher: string, // url of the publisher in question + verified: boolean, // wheter or not site is a verified publisher + exclude: boolean, // wheter or not site is in the excluded list + stickyP: boolean, // wheter or not site was added using addFunds urlbar toggle + timestamp: number // timestamp in milliseconds + } + } + synopsis: { + options: { + emptyScores: { + concave: number, + visits: number + }, + frameSize: number, + minPublisherDuration: number, + minPublisherVisits: number, + numFrames: number, + scorekeeper: string, // concave or visits + scorekeepers: Array, // concave and visits + showOnlyVerified: boolean + }, + publishers: { + [publisherId]: { + duration: number, + faviconUrl: string, + options: { + exclude: boolean, + verified: boolean, + stickyP: boolean + }, + pinPercentage: number, + protocol: string, + scores: { + concave: number, + visits: number + }, + visits: number, + weight: number, + window: [{ + timestamp: number, + visits: number, + duration: number, + scores: { + concave: number, + visits: number + } + }] + } + } + } + }, menu: { template: object // used on Windows and by our tests: template object with Menubar control }, @@ -194,6 +303,39 @@ AppStore noScript: { enabled: boolean // enable noscript }, + pageData: { + info: [{ + faviconURL: string, + protocol: string, + publisher: string, + timestamp: number, + url: string, + }], + last: { + info: string, // last added info + tabId: number, // last active tabId + url: string // last active URL + }, + load: [{ + timestamp: number, + url: string, + tabId: number, + details: { + status: boolean, + newURL: string, + originalURL: string, + httpResponseCode: number, + requestMethod: string, + referrer: string, + resourceType: string + } + }], + view: { + timestamp: number, + url: string, + tabId: number + } // we save only the last view + }, pinnedSites: { [siteKey]: { location: string, @@ -285,9 +427,6 @@ AppStore 'advanced.minimum-visits': number, 'advanced.auto-suggest-sites': boolean // show auto suggestion }, - locationSiteKeyCache: { - [location]: Array. // location -> site keys - }, siteSettings: { [hostPattern]: { adControl: string, // (showBraveAds | blockAds | allowAdsAndTracking) @@ -575,59 +714,6 @@ WindowStore type: number }, lastAppVersion: string, // version of the last file that was saved - ledgerInfo: { - address: string, // the BTC wallet address (in base58) - amount: number, // fiat amount to contribute per reconciliation period - balance: string, // confirmed balance in BTC.toFixed(4) - bravery: { - fee: { - amount: number, // set from `amount` above - currency: string // set from `currency` above - } - }, // values round-tripped through the ledger-client - btc: string, // BTC to contribute per reconciliation period - buyURL: string, // URL to buy bitcoin using debit/credit card - countryCode: string, // ISO3166 2-letter code for country of browser's location - created: boolean, // wallet is created - creating: boolean, // wallet is being created - currency: string, // fiat currency denominating the amount - error: { - caller: string, // function in which error was handled - error: object // error object returned - }, // non-null if the last updateLedgerInfo happened concurrently with an error - exchangeInfo: { - exchangeName: string, // the name of the BTC exchange - exchangeURL: string // the URL of the BTC exchange - }, // information about the corresponding "friendliest" BTC exchange (suggestions welcome!) - hasBitcoinHandler: boolean, // brave browser has a `bitcoin:` URI handler - passphrase: string, // the BTC wallet passphrase - paymentIMG: string, // the QR code equivalent of `paymentURL` expressed as "data:image/...;base64,..." - paymentURL: string, // bitcoin:...?amount={btc}&label=Brave%20Software - reconcileFrequency: number, // duration between each reconciliation in days - reconcileStamp: number, // timestamp for the next reconcilation - recoverySucceeded: boolean, // the status of an attempted recovery - satoshis: number, // confirmed balance as an integer number of satoshis - transactions: [{ - ballots: { - [publisher]: number // e.g., "wikipedia.org": 3 - }, // number of ballots cast for each publisher - contribution: { - fee: number, // bitcoin transaction fee - fiat: { - amount: number, // e.g., 5 - currency: string // e.g., "USD" - }, // roughly-equivalent fiat amount - rates: { - [currency]: number //e.g., { "USD": 575.45 } - }, // exchange rate - satoshis: number, // actual number of satoshis transferred - }, - count: number, // total number of ballots allowed to be cast - submissionStamp: number, // timestamp for this contribution - viewingId: string, // UUIDv4 for this contribution - }], // contributions reconciling/reconciled - unconfirmed: string // unconfirmed balance in BTC.toFixed(4) - }, modalDialogDetail: { [className]: { object // props @@ -643,38 +729,6 @@ WindowStore top: number // the top position of the popup window }, previewFrameKey: number, - locationInfo: { - [url]: { - publisher: string, // url of the publisher in question - verified: boolean, // wheter or not site is a verified publisher - exclude: boolean, // wheter or not site is in the excluded list - stickyP: boolean, // wheter or not site was added using addFunds urlbar toggle - timestamp: number // timestamp in milliseconds - } - }, - publisherInfo: { - synopsis: [{ - daysSpent: number, // e.g., 1 - duration: number, // total millisecond-views, e.g., 93784000 = 1 day, 2 hours, 3 minutes, 4 seconds - faviconURL: string, // i.e., "data:image/...;base64,..." - hoursSpent: number, // e.g., 2 - minutesSpent: number, // e.g., 3 - percentage: number, // i.e., 0, 1, ... 100 - pinPercentage: number, // i.e., 0, 1, ... 100 - publisherURL: string, // publisher site, e.g., "https://wikipedia.org/" - rank: number, // i.e., 1, 2, 3, ... - score: number, // float indicating the current score - secondsSpent: number, // e.g., 4 - site: string, // publisher name, e.g., "wikipedia.org" - verified: boolean, // there is a verified wallet for this publisher - views: number, // total page-views, - weight: number // float indication of the ration - }], // one entry for each publisher having a non-zero `score` - synopsisOptions: { - minPublisherDuration: number, // e.g., 8000 for 8 seconds - minPublisherVisits: number // e.g., 0 - } - }, searchResults: array, // autocomplete server results if enabled ui: { bookmarksToolbar: { diff --git a/js/actions/appActions.js b/js/actions/appActions.js index dc5d2ae4b09..eb9ae07f17e 100644 --- a/js/actions/appActions.js +++ b/js/actions/appActions.js @@ -536,17 +536,6 @@ const appActions = { }) }, - /** - * Updates publisher information for the payments pane - * @param {object} publisherInfo - the current publisher synopsis - */ - updatePublisherInfo: function (publisherInfo) { - dispatch({ - actionType: appConstants.APP_UPDATE_PUBLISHER_INFO, - publisherInfo - }) - }, - /** * Shows a message in the notification bar * @param {{message: string, buttons: Array., frameOrigin: string, options: Object}} detail @@ -1116,30 +1105,6 @@ const appActions = { }) }, - /** - - * Change all undefined publishers in site settings to defined sites - * also change all undefined ledgerPayments to value true - * @param publishers {Object} publishers from the synopsis - */ - enableUndefinedPublishers: function (publishers) { - dispatch({ - actionType: appConstants.APP_ENABLE_UNDEFINED_PUBLISHERS, - publishers - }) - }, - - /** - * Update ledger publishers pinned percentages according to the new synopsis - * @param publishers {Object} updated publishers - */ - changeLedgerPinnedPercentages: function (publishers) { - dispatch({ - actionType: appConstants.APP_CHANGE_LEDGER_PINNED_PERCENTAGES, - publishers - }) - }, - /** * Dispatches a message to change a the pinned status of a tab * @param {number} tabId - The tabId of the tab to pin diff --git a/js/constants/appConstants.js b/js/constants/appConstants.js index 36e646766df..5a1e00366f7 100644 --- a/js/constants/appConstants.js +++ b/js/constants/appConstants.js @@ -33,9 +33,7 @@ const appConstants = { APP_ON_CLEAR_BROWSING_DATA: _, APP_IMPORT_BROWSER_DATA: _, APP_UPDATE_LEDGER_INFO: _, - APP_LEDGER_RECOVERY_STATUS_CHANGED: _, APP_UPDATE_LOCATION_INFO: _, - APP_UPDATE_PUBLISHER_INFO: _, APP_SHOW_NOTIFICATION: _, /** @param {Object} detail */ APP_HIDE_NOTIFICATION: _, /** @param {string} message */ APP_BACKUP_KEYS: _, @@ -111,8 +109,6 @@ const appConstants = { APP_URL_BAR_SUGGESTIONS_CHANGED: _, APP_SEARCH_SUGGESTION_RESULTS_AVAILABLE: _, APP_DEFAULT_SEARCH_ENGINE_LOADED: _, - APP_CHANGE_LEDGER_PINNED_PERCENTAGES: _, - APP_ENABLE_UNDEFINED_PUBLISHERS: _, APP_TAB_PINNED: _, APP_DRAG_STARTED: _, APP_DRAG_ENDED: _, diff --git a/js/stores/appStore.js b/js/stores/appStore.js index 35f7fa77b42..7039fc699cf 100644 --- a/js/stores/appStore.js +++ b/js/stores/appStore.js @@ -10,7 +10,6 @@ const appDispatcher = require('../dispatcher/appDispatcher') const settings = require('../constants/settings') const {STATE_SITES} = require('../constants/stateConstants') const syncUtil = require('../state/syncUtil') -const siteSettings = require('../state/siteSettings') const electron = require('electron') const app = electron.app const messages = require('../constants/messages') @@ -202,7 +201,9 @@ const handleAppAction = (action) => { require('../../app/browser/reducers/topSitesReducer'), require('../../app/browser/reducers/braverySettingsReducer'), require('../../app/browser/reducers/bookmarkToolbarReducer'), - require('../../app/ledger').doAction, + require('../../app/browser/reducers/siteSettingsReducer'), + require('../../app/browser/reducers/pageDataReducer'), + require('../../app/browser/reducers/ledgerReducer'), require('../../app/browser/menu') ] initialized = true @@ -288,56 +289,6 @@ const handleAppAction = (action) => { appState = appState.setIn(['settings', action.key], action.value) appState = handleChangeSettingAction(appState, action.key, action.value) break - case appConstants.APP_ALLOW_FLASH_ONCE: - { - const propertyName = action.isPrivate ? 'temporarySiteSettings' : 'siteSettings' - appState = appState.set(propertyName, - siteSettings.mergeSiteSetting(appState.get(propertyName), urlUtil.getOrigin(action.url), 'flash', 1)) - break - } - case appConstants.APP_ALLOW_FLASH_ALWAYS: - { - const propertyName = action.isPrivate ? 'temporarySiteSettings' : 'siteSettings' - const expirationTime = Date.now() + (7 * 24 * 3600 * 1000) - appState = appState.set(propertyName, - siteSettings.mergeSiteSetting(appState.get(propertyName), urlUtil.getOrigin(action.url), 'flash', expirationTime)) - break - } - case appConstants.APP_CHANGE_SITE_SETTING: - { - let propertyName = action.temporary ? 'temporarySiteSettings' : 'siteSettings' - let newSiteSettings = siteSettings.mergeSiteSetting(appState.get(propertyName), action.hostPattern, action.key, action.value) - if (action.skipSync) { - newSiteSettings = newSiteSettings.setIn([action.hostPattern, 'skipSync'], true) - } - appState = appState.set(propertyName, newSiteSettings) - break - } - case appConstants.APP_REMOVE_SITE_SETTING: - { - let propertyName = action.temporary ? 'temporarySiteSettings' : 'siteSettings' - let newSiteSettings = siteSettings.removeSiteSetting(appState.get(propertyName), - action.hostPattern, action.key) - if (action.skipSync) { - newSiteSettings = newSiteSettings.setIn([action.hostPattern, 'skipSync'], true) - } - appState = appState.set(propertyName, newSiteSettings) - break - } - case appConstants.APP_CLEAR_SITE_SETTINGS: - { - let propertyName = action.temporary ? 'temporarySiteSettings' : 'siteSettings' - let newSiteSettings = new Immutable.Map() - appState.get(propertyName).map((entry, hostPattern) => { - let newEntry = entry.delete(action.key) - if (action.skipSync) { - newEntry = newEntry.set('skipSync', true) - } - newSiteSettings = newSiteSettings.set(hostPattern, newEntry) - }) - appState = appState.set(propertyName, newSiteSettings) - break - } case appConstants.APP_SET_SKIP_SYNC: { if (appState.getIn(action.path)) { @@ -345,30 +296,6 @@ const handleAppAction = (action) => { } break } - case appConstants.APP_ADD_NOSCRIPT_EXCEPTIONS: - { - const propertyName = action.temporary ? 'temporarySiteSettings' : 'siteSettings' - // Note that this is always cleared on restart or reload, so should not - // be synced or persisted. - const key = 'noScriptExceptions' - if (!action.origins || !action.origins.size) { - // Clear the exceptions - appState = appState.setIn([propertyName, action.hostPattern, key], new Immutable.Map()) - } else { - const currentExceptions = appState.getIn([propertyName, action.hostPattern, key]) || new Immutable.Map() - appState = appState.setIn([propertyName, action.hostPattern, key], currentExceptions.merge(action.origins)) - } - } - break - case appConstants.APP_UPDATE_LEDGER_INFO: - appState = appState.set('ledgerInfo', Immutable.fromJS(action.ledgerInfo)) - break - case appConstants.APP_UPDATE_LOCATION_INFO: - appState = appState.set('locationInfo', Immutable.fromJS(action.locationInfo)) - break - case appConstants.APP_UPDATE_PUBLISHER_INFO: - appState = appState.set('publisherInfo', Immutable.fromJS(action.publisherInfo)) - break case appConstants.APP_SHOW_NOTIFICATION: let notifications = appState.get('notifications') notifications = notifications.filterNot((notification) => { @@ -422,13 +349,6 @@ const handleAppAction = (action) => { } } break - case appConstants.APP_LEDGER_RECOVERY_STATUS_CHANGED: - { - const date = new Date().getTime() - appState = appState.setIn(['about', 'preferences', 'recoverySucceeded'], action.recoverySucceeded) - appState = appState.setIn(['about', 'preferences', 'updatedStamp'], date) - } - break case appConstants.APP_ON_CLEAR_BROWSING_DATA: const defaults = appState.get('clearBrowsingDataDefaults') const temp = appState.get('tempClearBrowsingData', Immutable.Map()) @@ -665,26 +585,6 @@ const handleAppAction = (action) => { case appConstants.APP_HIDE_DOWNLOAD_DELETE_CONFIRMATION: appState = appState.set('deleteConfirmationVisible', false) break - case appConstants.APP_ENABLE_UNDEFINED_PUBLISHERS: - const sitesObject = appState.get('siteSettings') - Object.keys(action.publishers).map((item) => { - const pattern = `https?://${item}` - const siteSetting = sitesObject.get(pattern) - const result = (siteSetting) && (siteSetting.get('ledgerPayments')) - - if (result === undefined) { - let newSiteSettings = siteSettings.mergeSiteSetting(appState.get('siteSettings'), pattern, 'ledgerPayments', true) - appState = appState.set('siteSettings', newSiteSettings) - } - }) - break - case appConstants.APP_CHANGE_LEDGER_PINNED_PERCENTAGES: - Object.keys(action.publishers).map((item) => { - const pattern = `https?://${item}` - let newSiteSettings = siteSettings.mergeSiteSetting(appState.get('siteSettings'), pattern, 'ledgerPinPercentage', action.publishers[item].pinPercentage) - appState = appState.set('siteSettings', newSiteSettings) - }) - break case appConstants.APP_DEFAULT_SEARCH_ENGINE_LOADED: appState = appState.set('searchDetail', action.searchDetail) break diff --git a/js/stores/eventStore.js b/js/stores/eventStore.js deleted file mode 100644 index c33068cff38..00000000000 --- a/js/stores/eventStore.js +++ /dev/null @@ -1,156 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this file, - * You can obtain one at http://mozilla.org/MPL/2.0/. */ - -const appConstants = require('../constants/appConstants') -const appDispatcher = require('../dispatcher/appDispatcher') -const AppStore = require('./appStore') -const EventEmitter = require('events').EventEmitter -const Immutable = require('immutable') -const windowConstants = require('../constants/windowConstants') -const debounce = require('../lib/debounce') -const {getWebContents} = require('../../app/browser/webContentsCache') -const {isSourceAboutUrl} = require('../lib/appUrlUtil') -const {responseHasContent} = require('../../app/common/lib/httpUtil') - -const electron = require('electron') -const BrowserWindow = electron.BrowserWindow - -let eventState = Immutable.fromJS({ - page_load: [], - page_view: [], - page_info: [] -}) - -const CHANGE_EVENT = 'change' - -class EventStore extends EventEmitter { - getState () { - return eventState - } - - emitChanges () { - this.emit(CHANGE_EVENT) - } - - addChangeListener (callback) { - this.on(CHANGE_EVENT, callback) - } - - removeChangeListener (callback) { - this.removeListener(CHANGE_EVENT, callback) - } -} - -const eventStore = new EventStore() -const emitChanges = debounce(eventStore.emitChanges.bind(eventStore), 5) - -let lastActivePageUrl = null -let lastActiveTabId = null - -const addPageView = (url, tabId) => { - const tab = getWebContents(tabId) - const isPrivate = !tab || - tab.isDestroyed() || - !tab.session.partition.startsWith('persist:') - - if ((url && isSourceAboutUrl(url)) || isPrivate) { - url = null - } - - if (lastActivePageUrl === url) { - return - } - - let pageViewEvent = Immutable.fromJS({ - timestamp: new Date().getTime(), - url, - tabId - }) - eventState = eventState.set('page_view', eventState.get('page_view').slice(-100).push(pageViewEvent)) - lastActivePageUrl = url -} - -const windowBlurred = (windowId) => { - let windowCount = BrowserWindow.getAllWindows().filter((win) => win.isFocused()).length - if (windowCount === 0) { - addPageView(null, null) - } -} - -const windowClosed = (windowId) => { - let windowCount = BrowserWindow.getAllWindows().length - let win = BrowserWindow.getFocusedWindow() - // window may not be closed yet - if (windowCount > 0 && win && win.id === windowId) { - win.once('closed', () => { - windowClosed(windowId) - }) - } - - if (!win || windowCount === 0) { - addPageView(null, null) - } -} - -// Register callback to handle all updates -const doAction = (action) => { - switch (action.actionType) { - case windowConstants.WINDOW_SET_FOCUSED_FRAME: - if (action.location) { - addPageView(action.location, action.tabId) - } - break - case appConstants.APP_WINDOW_BLURRED: - windowBlurred(action.windowId) - break - case appConstants.APP_IDLE_STATE_CHANGED: - if (action.idleState !== 'active') { - addPageView(null, null) - } else { - addPageView(lastActivePageUrl, lastActiveTabId) - } - break - case appConstants.APP_CLOSE_WINDOW: - appDispatcher.waitFor([AppStore.dispatchToken], () => { - windowClosed(action.windowId) - }) - break - case 'event-set-page-info': - // retains all past pages, not really sure that's needed... [MTR] - eventState = eventState.set('page_info', eventState.get('page_info').slice(-100).push(action.pageInfo)) - break - case windowConstants.WINDOW_GOT_RESPONSE_DETAILS: - // Only capture response for the page (not subresources, like images, JavaScript, etc) - if (action.details && action.details.resourceType === 'mainFrame') { - const pageUrl = action.details.newURL - - // create a page view event if this is a page load on the active tabId - if (!lastActiveTabId || action.tabId === lastActiveTabId) { - addPageView(pageUrl, action.tabId) - } - - const responseCode = action.details.httpResponseCode - if (isSourceAboutUrl(pageUrl) || !responseHasContent(responseCode)) { - break - } - - const pageLoadEvent = Immutable.fromJS({ - timestamp: new Date().getTime(), - url: pageUrl, - tabId: action.tabId, - details: action.details - }) - eventState = eventState.set('page_load', eventState.get('page_load').slice(-100).push(pageLoadEvent)) - } - break - default: - return - } - - emitChanges() -} - -appDispatcher.register(doAction) - -module.exports = eventStore diff --git a/test/unit/app/browser/reducers/pageDataReducerTest.js b/test/unit/app/browser/reducers/pageDataReducerTest.js new file mode 100644 index 00000000000..48bfd8e7c61 --- /dev/null +++ b/test/unit/app/browser/reducers/pageDataReducerTest.js @@ -0,0 +1,436 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +/* global describe, it, before, beforeEach, after, afterEach */ +const mockery = require('mockery') +const Immutable = require('immutable') +const assert = require('assert') +const sinon = require('sinon') + +const appConstants = require('../../../../../js/constants/appConstants') +const windowConstants = require('../../../../../js/constants/windowConstants') + +describe('pageDataReducer unit tests', function () { + let pageDataReducer, pageDataState, isFocused + + const state = Immutable.fromJS({ + pageData: { + view: {}, + load: [], + info: {}, + last: { + info: '', + tabId: null + } + } + }) + + before(function () { + this.clock = sinon.useFakeTimers() + this.clock.tick(0) + mockery.enable({ + warnOnReplace: false, + warnOnUnregistered: false, + useCleanCache: true + }) + mockery.registerMock('electron', { + BrowserWindow: { + getAllWindows: function () { + return [{ + id: 1, + isFocused: () => isFocused + }] + } + } + }) + mockery.registerMock('../../browser/webContentsCache', { + getWebContents: (tabId) => { + if (tabId == null) return null + + return { + isDestroyed: () => false, + session: { + partition: 'persist:0' + } + } + } + }) + pageDataState = require('../../../../../app/common/state/pageDataState') + pageDataReducer = require('../../../../../app/browser/reducers/pageDataReducer') + }) + + beforeEach(function () { + isFocused = false + }) + + after(function () { + mockery.disable() + this.clock.restore() + }) + + describe('WINDOW_SET_FOCUSED_FRAME', function () { + let spy + + afterEach(function () { + spy.restore() + }) + + it('null case', function () { + spy = sinon.spy(pageDataState, 'addView') + const result = pageDataReducer(state, { + actionType: windowConstants.WINDOW_SET_FOCUSED_FRAME + }) + + assert.equal(spy.notCalled, true) + assert.deepEqual(result.toJS(), state.toJS()) + }) + + it('data is ok', function () { + spy = sinon.spy(pageDataState, 'addView') + const result = pageDataReducer(state, { + actionType: windowConstants.WINDOW_SET_FOCUSED_FRAME, + location: 'https://brave.com', + tabId: 1 + }) + + const expectedState = state + .setIn(['pageData', 'last', 'tabId'], 1) + .setIn(['pageData', 'view'], Immutable.fromJS({ + timestamp: 0, + url: 'https://brave.com', + tabId: 1 + })) + + assert.equal(spy.calledOnce, true) + assert.deepEqual(result.toJS(), expectedState.toJS()) + }) + }) + + describe('APP_WINDOW_BLURRED', function () { + let spy + + afterEach(function () { + spy.restore() + }) + + it('there is one focused window', function () { + isFocused = true + spy = sinon.spy(pageDataState, 'addView') + const result = pageDataReducer(state, { + actionType: appConstants.APP_WINDOW_BLURRED + }) + + assert.equal(spy.notCalled, true) + assert.deepEqual(result.toJS(), state.toJS()) + }) + + it('there is no focused windows', function () { + spy = sinon.spy(pageDataState, 'addView') + const result = pageDataReducer(state, { + actionType: appConstants.APP_WINDOW_BLURRED + }) + + const expectedState = state + .setIn(['pageData', 'last', 'tabId'], null) + .setIn(['pageData', 'view'], Immutable.fromJS({ + timestamp: 0, + url: null, + tabId: null + })) + + assert.equal(spy.calledOnce, true) + assert.deepEqual(result.toJS(), expectedState.toJS()) + }) + }) + + describe('APP_IDLE_STATE_CHANGED', function () { + let spy + + afterEach(function () { + spy.restore() + }) + + it('null case', function () { + spy = sinon.spy(pageDataState, 'addView') + const result = pageDataReducer(state, { + actionType: appConstants.APP_IDLE_STATE_CHANGED + }) + + assert.equal(spy.notCalled, true) + assert.deepEqual(result.toJS(), state.toJS()) + }) + + it('idleState is active', function () { + spy = sinon.spy(pageDataState, 'addView') + const result = pageDataReducer(state, { + actionType: appConstants.APP_IDLE_STATE_CHANGED, + idleState: 'active' + }) + + assert.equal(spy.notCalled, true) + assert.deepEqual(result.toJS(), state.toJS()) + }) + + it('idleState is not active', function () { + spy = sinon.spy(pageDataState, 'addView') + const result = pageDataReducer(state, { + actionType: appConstants.APP_IDLE_STATE_CHANGED, + idleState: 'nonactive' + }) + + const expectedState = state + .setIn(['pageData', 'last', 'tabId'], null) + .setIn(['pageData', 'view'], Immutable.fromJS({ + timestamp: 0, + url: null, + tabId: null + })) + + assert.equal(spy.calledOnce, true) + assert.deepEqual(result.toJS(), expectedState.toJS()) + }) + }) + + describe('APP_WINDOW_CLOSED', function () { + let spy + + afterEach(function () { + spy.restore() + }) + + it('data is ok', function () { + spy = sinon.spy(pageDataState, 'addView') + const result = pageDataReducer(state, { + actionType: appConstants.APP_WINDOW_CLOSED + }) + + const expectedState = state + .setIn(['pageData', 'last', 'tabId'], null) + .setIn(['pageData', 'view'], Immutable.fromJS({ + timestamp: 0, + url: null, + tabId: null + })) + + assert.equal(spy.calledOnce, true) + assert.deepEqual(result.toJS(), expectedState.toJS()) + }) + }) + + describe('event-set-page-info', function () { + let spy + + afterEach(function () { + spy.restore() + }) + + it('data is ok', function () { + spy = sinon.spy(pageDataState, 'addInfo') + const result = pageDataReducer(state, { + actionType: 'event-set-page-info', + pageInfo: { + timestamp: 0, + url: 'https://brave.com' + } + }) + + const expectedState = state + .setIn(['pageData', 'last', 'info'], 'https://brave.com/') + .setIn(['pageData', 'info', 'https://brave.com/'], Immutable.fromJS({ + key: 'https://brave.com/', + timestamp: 0, + url: 'https://brave.com' + })) + + assert.equal(spy.calledOnce, true) + assert.deepEqual(result.toJS(), expectedState.toJS()) + }) + }) + + describe('WINDOW_GOT_RESPONSE_DETAILS', function () { + let spyView, spyActiveTab, spyLoad + + afterEach(function () { + spyView.restore() + spyActiveTab.restore() + spyLoad.restore() + }) + + it('null case', function () { + spyView = sinon.spy(pageDataState, 'addView') + spyActiveTab = sinon.spy(pageDataState, 'getLastActiveTabId') + spyLoad = sinon.spy(pageDataState, 'addLoad') + const result = pageDataReducer(state, { + actionType: windowConstants.WINDOW_GOT_RESPONSE_DETAILS + }) + + assert.equal(spyView.notCalled, true) + assert.equal(spyActiveTab.notCalled, true) + assert.equal(spyLoad.notCalled, true) + assert.deepEqual(result.toJS(), state.toJS()) + }) + + it('add view if we dont have last active tab', function () { + spyView = sinon.spy(pageDataState, 'addView') + spyActiveTab = sinon.spy(pageDataState, 'getLastActiveTabId') + spyLoad = sinon.spy(pageDataState, 'addLoad') + const result = pageDataReducer(state, { + actionType: windowConstants.WINDOW_GOT_RESPONSE_DETAILS, + details: { + resourceType: 'mainFrame', + newURL: 'https://brave.com' + }, + tabId: 1 + }) + + const expectedState = state + .setIn(['pageData', 'last', 'tabId'], 1) + .setIn(['pageData', 'view'], Immutable.fromJS({ + timestamp: 0, + url: 'https://brave.com', + tabId: 1 + })) + + assert.equal(spyView.calledOnce, true) + assert.equal(spyActiveTab.calledOnce, true) + assert.equal(spyLoad.notCalled, true) + assert.deepEqual(result.toJS(), expectedState.toJS()) + }) + + it('add view if tabId is the same as last active tab', function () { + spyView = sinon.spy(pageDataState, 'addView') + spyActiveTab = sinon.spy(pageDataState, 'getLastActiveTabId') + spyLoad = sinon.spy(pageDataState, 'addLoad') + + const newState = state + .setIn(['pageData', 'last', 'tabId'], 1) + + const result = pageDataReducer(newState, { + actionType: windowConstants.WINDOW_GOT_RESPONSE_DETAILS, + details: { + resourceType: 'mainFrame', + newURL: 'https://brave.com' + }, + tabId: 1 + }) + + const expectedState = newState + .setIn(['pageData', 'last', 'tabId'], 1) + .setIn(['pageData', 'view'], Immutable.fromJS({ + timestamp: 0, + url: 'https://brave.com', + tabId: 1 + })) + + assert.equal(spyView.calledOnce, true) + assert.equal(spyActiveTab.calledOnce, true) + assert.equal(spyLoad.notCalled, true) + assert.deepEqual(result.toJS(), expectedState.toJS()) + }) + + it('dont add view if tabId is different as last active tab', function () { + spyView = sinon.spy(pageDataState, 'addView') + spyActiveTab = sinon.spy(pageDataState, 'getLastActiveTabId') + spyLoad = sinon.spy(pageDataState, 'addLoad') + + const newState = state + .setIn(['pageData', 'last', 'tabId'], 2) + + const result = pageDataReducer(newState, { + actionType: windowConstants.WINDOW_GOT_RESPONSE_DETAILS, + details: { + resourceType: 'mainFrame', + newURL: 'https://brave.com' + }, + tabId: 1 + }) + + assert.equal(spyView.notCalled, true) + assert.equal(spyActiveTab.calledOnce, true) + assert.equal(spyLoad.notCalled, true) + assert.deepEqual(result.toJS(), newState.toJS()) + }) + + it('dont add load if response is not successful', function () { + spyView = sinon.spy(pageDataState, 'addView') + spyActiveTab = sinon.spy(pageDataState, 'getLastActiveTabId') + spyLoad = sinon.spy(pageDataState, 'addLoad') + + const newState = state + .setIn(['pageData', 'last', 'tabId'], 2) + + const result = pageDataReducer(newState, { + actionType: windowConstants.WINDOW_GOT_RESPONSE_DETAILS, + details: { + resourceType: 'mainFrame', + newURL: 'https://brave.com', + httpResponseCode: 500 + }, + tabId: 1 + }) + + assert.equal(spyView.notCalled, true) + assert.equal(spyActiveTab.calledOnce, true) + assert.equal(spyLoad.notCalled, true) + assert.deepEqual(result.toJS(), newState.toJS()) + }) + + it('dont add load if URL is about page', function () { + spyView = sinon.spy(pageDataState, 'addView') + spyActiveTab = sinon.spy(pageDataState, 'getLastActiveTabId') + spyLoad = sinon.spy(pageDataState, 'addLoad') + + const newState = state + .setIn(['pageData', 'last', 'tabId'], 2) + + const result = pageDataReducer(newState, { + actionType: windowConstants.WINDOW_GOT_RESPONSE_DETAILS, + details: { + resourceType: 'mainFrame', + newURL: 'about:history', + httpResponseCode: 200 + }, + tabId: 1 + }) + + assert.equal(spyView.notCalled, true) + assert.equal(spyActiveTab.calledOnce, true) + assert.equal(spyLoad.notCalled, true) + assert.deepEqual(result.toJS(), newState.toJS()) + }) + + it('add load', function () { + spyView = sinon.spy(pageDataState, 'addView') + spyActiveTab = sinon.spy(pageDataState, 'getLastActiveTabId') + spyLoad = sinon.spy(pageDataState, 'addLoad') + + const details = { + resourceType: 'mainFrame', + newURL: 'https://brave.com', + httpResponseCode: 200 + } + const newState = state + .setIn(['pageData', 'last', 'tabId'], 2) + + const result = pageDataReducer(newState, { + actionType: windowConstants.WINDOW_GOT_RESPONSE_DETAILS, + details: details, + tabId: 1 + }) + + const expectedState = newState + .setIn(['pageData', 'load'], Immutable.fromJS([{ + timestamp: 0, + url: 'https://brave.com', + tabId: 1, + details: details + }])) + + assert.equal(spyView.notCalled, true) + assert.equal(spyActiveTab.calledOnce, true) + assert.equal(spyLoad.calledOnce, true) + assert.deepEqual(result.toJS(), expectedState.toJS()) + }) + }) +}) diff --git a/test/unit/app/common/lib/ledgerUtilTest.js b/test/unit/app/common/lib/ledgerUtilTest.js index df643fb74ba..9210b698665 100644 --- a/test/unit/app/common/lib/ledgerUtilTest.js +++ b/test/unit/app/common/lib/ledgerUtilTest.js @@ -1,15 +1,42 @@ /* global describe, it */ const ledgerUtil = require('../../../../../app/common/lib/ledgerUtil') const assert = require('assert') +const Immutable = require('immutable') require('../../../braveUnit') describe('ledgerUtil test', function () { describe('shouldTrackView', function () { - const validView = { tabId: 1, url: 'https://brave.com/' } - const validResponseList = [{ tabId: validView.tabId, details: { newURL: validView.url, httpResponseCode: 200 } }] - const noMatchResponseList = [{ tabId: 3, details: { newURL: 'https://not-brave.com' } }] - const matchButErrored = [{ tabId: validView.tabId, details: { newURL: validView.url, httpResponseCode: 404 } }] + const validView = Immutable.fromJS({ + tabId: 1, + url: 'https://brave.com/' + }) + const validResponseList = Immutable.fromJS([ + { + tabId: validView.get('tabId'), + details: { + newURL: validView.get('url'), + httpResponseCode: 200 + } + } + ]) + const noMatchResponseList = Immutable.fromJS([ + { + tabId: 3, + details: { + newURL: 'https://not-brave.com' + } + } + ]) + const matchButErrored = Immutable.fromJS([ + { + tabId: validView.get('tabId'), + details: { + newURL: validView.get('url'), + httpResponseCode: 404 + } + } + ]) describe('input validation', function () { it('returns false if view is falsey', function () { diff --git a/test/unit/app/common/lib/pageDataUtilTest.js b/test/unit/app/common/lib/pageDataUtilTest.js new file mode 100644 index 00000000000..70744ec882d --- /dev/null +++ b/test/unit/app/common/lib/pageDataUtilTest.js @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* global describe, it */ +const pageDataUtil = require('../../../../../app/common/lib/pageDataUtil') +const assert = require('assert') + +require('../../../braveUnit') + +describe('pageDataUtil unit tests', () => { + describe('getInfoKey', () => { + it('null case', () => { + const result = pageDataUtil.getInfoKey() + assert.equal(result, null) + }) + + it('url is converted to location', () => { + const result = pageDataUtil.getInfoKey('https://brave.com') + assert.equal(result, 'https://brave.com/') + }) + }) +}) diff --git a/test/unit/app/common/state/pageDataStateTest.js b/test/unit/app/common/state/pageDataStateTest.js new file mode 100644 index 00000000000..3ef0a42d0e5 --- /dev/null +++ b/test/unit/app/common/state/pageDataStateTest.js @@ -0,0 +1,304 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* global describe, it, before, beforeEach, after */ +const Immutable = require('immutable') +const assert = require('assert') +const sinon = require('sinon') +const mockery = require('mockery') + +describe('pageDataState unit tests', function () { + let pageDataState, isPrivate, clock, now + + const state = Immutable.fromJS({ + pageData: { + view: {}, + load: [], + info: {}, + last: { + info: '', + tabId: null + } + } + }) + + const stateWithData = Immutable.fromJS({ + pageData: { + view: { + timestamp: 0, + url: 'https://brave.com', + tabId: 1 + }, + load: [ + { + timestamp: 0, + url: 'https://brave.com', + tabId: 1 + } + ], + info: { + 'https://brave.com/': { + timestamp: 0, + url: 'https://brave.com', + tabId: 1 + } + }, + last: { + info: '', + tabId: 1 + } + } + }) + + before(function () { + clock = sinon.useFakeTimers() + now = new Date(0) + mockery.enable({ + warnOnReplace: false, + warnOnUnregistered: false, + useCleanCache: true + }) + mockery.registerMock('../../browser/webContentsCache', { + getWebContents: (tabId) => { + if (tabId == null) return null + + return { + isDestroyed: () => false, + session: { + partition: isPrivate ? '' : 'persist:0' + } + } + } + }) + pageDataState = require('../../../../../app/common/state/pageDataState') + }) + + beforeEach(function () { + isPrivate = false + }) + + after(function () { + mockery.disable() + clock.restore() + }) + + describe('addView', function () { + it('null case', function () { + const result = pageDataState.addView(state) + const expectedResult = state + .setIn(['pageData', 'last', 'tabId'], null) + .setIn(['pageData', 'view'], Immutable.fromJS({ + timestamp: now.getTime(), + url: null, + tabId: null + })) + assert.deepEqual(result.toJS(), expectedResult.toJS()) + }) + + it('url is the same as last one', function () { + const newState = state + .setIn(['pageData', 'view'], Immutable.fromJS({ + timestamp: now.getTime(), + url: 'https://brave.com', + tabId: 1 + })) + const result = pageDataState.addView(state, 'https://brave.com', 1) + const expectedResult = newState + .setIn(['pageData', 'last', 'tabId'], 1) + + assert.deepEqual(result, expectedResult) + }) + + it('url is private', function () { + isPrivate = true + + const result = pageDataState.addView(state, 'https://brave.com', 1) + const expectedResult = state + .setIn(['pageData', 'last', 'tabId'], 1) + .setIn(['pageData', 'view'], Immutable.fromJS({ + timestamp: now.getTime(), + url: null, + tabId: 1 + })) + + assert.deepEqual(result.toJS(), expectedResult.toJS()) + }) + + it('url is about page', function () { + const result = pageDataState.addView(state, 'about:history', 1) + const expectedResult = state + .setIn(['pageData', 'last', 'tabId'], 1) + .setIn(['pageData', 'view'], Immutable.fromJS({ + timestamp: now.getTime(), + url: null, + tabId: 1 + })) + + assert.deepEqual(result.toJS(), expectedResult.toJS()) + }) + + it('url is ok', function () { + const result = pageDataState.addView(state, 'https://brave.com', 1) + const expectedResult = state + .setIn(['pageData', 'last', 'tabId'], 1) + .setIn(['pageData', 'view'], Immutable.fromJS({ + timestamp: now.getTime(), + url: 'https://brave.com', + tabId: 1 + })) + + assert.deepEqual(result.toJS(), expectedResult.toJS()) + }) + }) + + describe('addInfo', function () { + it('null case', function () { + const result = pageDataState.addInfo(state) + assert.deepEqual(result.toJS(), state.toJS()) + }) + + it('data is ok', function () { + const data = Immutable.fromJS({ + timestamp: now.getTime(), + url: 'https://brave.com' + }) + + const result = pageDataState.addInfo(state, data) + const expectedResult = state + .setIn(['pageData', 'info', 'https://brave.com/'], data.set('key', 'https://brave.com/')) + .setIn(['pageData', 'last', 'info'], 'https://brave.com/') + assert.deepEqual(result.toJS(), expectedResult.toJS()) + }) + }) + + describe('addLoad', function () { + it('null case', function () { + const result = pageDataState.addLoad(state) + assert.deepEqual(result.toJS(), state.toJS()) + }) + + it('data is ok', function () { + const result = pageDataState.addLoad(state) + assert.deepEqual(result.toJS(), state.toJS()) + }) + + it('we only take last 100 views', function () { + let newState = state + + for (let i = 0; i < 100; i++) { + const data = Immutable.fromJS([{ + timestamp: now.getTime(), + url: `https://page${i}.com`, + tabId: 1 + }]) + newState = newState.setIn(['pageData', 'load'], newState.getIn(['pageData', 'load']).push(data)) + } + + const newLoad = Immutable.fromJS({ + timestamp: now.getTime(), + url: 'https://brave.com', + tabId: 1 + }) + + const result = pageDataState.addLoad(newState, newLoad) + const expectedResult = newState + .setIn(['pageData', 'load'], newState.getIn(['pageData', 'load']).shift()) + .setIn(['pageData', 'load'], newState.getIn(['pageData', 'load']).push(newLoad)) + + assert.deepEqual(result.toJS(), expectedResult.toJS()) + }) + }) + + describe('getView', function () { + it('null case', function () { + const result = pageDataState.getView(state) + assert.deepEqual(result, Immutable.Map()) + }) + + it('data is ok', function () { + const result = pageDataState.getView(stateWithData) + assert.deepEqual(result.toJS(), stateWithData.getIn(['pageData', 'view']).toJS()) + }) + }) + + describe('getLastInfo', function () { + it('null case', function () { + const result = pageDataState.getLastInfo(state) + assert.deepEqual(result, Immutable.Map()) + }) + + it('key is provided, but data is not there', function () { + const newState = state + .setIn(['pageData', 'last', 'info'], 'https://brave.com/') + .setIn(['pageData', 'info', 'https://test.com/'], Immutable.fromJS({ + timestamp: now.getTime(), + url: 'https://test.com', + tabId: 1 + })) + + const result = pageDataState.getLastInfo(newState) + assert.deepEqual(result, Immutable.Map()) + }) + + it('key is provided and data is there', function () { + const info = Immutable.fromJS({ + timestamp: now.getTime(), + url: 'https://brave.com', + tabId: 1 + }) + + const newState = state + .setIn(['pageData', 'last', 'info'], 'https://brave.com/') + .setIn(['pageData', 'info', 'https://brave.com/'], info) + + const result = pageDataState.getLastInfo(newState) + assert.deepEqual(result.toJS(), info.toJS()) + }) + }) + + describe('getLoad', function () { + it('null case', function () { + const result = pageDataState.getLoad(state) + assert.deepEqual(result, Immutable.List()) + }) + + it('data is there', function () { + const result = pageDataState.getLoad(stateWithData) + assert.deepEqual(result.toJS(), stateWithData.getIn(['pageData', 'load']).toJS()) + }) + }) + + describe('getLastActiveTabId', function () { + it('null case', function () { + const result = pageDataState.getLastActiveTabId(state) + assert.deepEqual(result, null) + }) + + it('data is there', function () { + const result = pageDataState.getLastActiveTabId(stateWithData) + assert.deepEqual(result, 1) + }) + }) + + describe('setLastActiveTabId', function () { + it('id is saved', function () { + const result = pageDataState.setLastActiveTabId(state, 10) + const expectedState = state.setIn(['pageData', 'last', 'tabId'], 10) + assert.deepEqual(result.toJS(), expectedState.toJS()) + }) + }) + + describe('setPublisher', function () { + it('null case', function () { + const result = pageDataState.setPublisher(state) + assert.deepEqual(result.toJS(), state.toJS()) + }) + + it('data is ok', function () { + const result = pageDataState.setPublisher(stateWithData, 'https://brave.com/', 'https://brave.com') + const expectedState = stateWithData.setIn(['pageData', 'info', 'https://brave.com/', 'publisher'], 'https://brave.com') + assert.deepEqual(result.toJS(), expectedState.toJS()) + }) + }) +})