diff --git a/app/browser/api/topSites.js b/app/browser/api/topSites.js new file mode 100644 index 00000000000..48f3c9ccfce --- /dev/null +++ b/app/browser/api/topSites.js @@ -0,0 +1,151 @@ +/* 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/. */ + +'use strict' + +const Immutable = require('immutable') +const appActions = require('../../../js/actions/appActions') +const debounce = require('../../../js/lib/debounce') +const siteUtil = require('../../../js/state/siteUtil') +const {isSourceAboutUrl} = require('../../../js/lib/appUrlUtil') +const aboutNewTabMaxEntries = 100 +let appStore + +let minCountOfTopSites +let minAccessOfTopSites + +const compareSites = (site1, site2) => { + if (!site1 || !site2) return false + return site1.get('location') === site2.get('location') && + site1.get('partitionNumber') === site2.get('partitionNumber') +} + +const pinnedTopSites = (state) => { + return (state.getIn(['about', 'newtab', 'pinnedTopSites']) || Immutable.List()).setSize(18) +} + +const ignoredTopSites = (state) => { + return state.getIn(['about', 'newtab', 'ignoredTopSites']) || Immutable.List() +} + +const isPinned = (state, siteProps) => { + return pinnedTopSites(state).filter((site) => compareSites(site, siteProps)).size > 0 +} + +const isIgnored = (state, siteProps) => { + return ignoredTopSites(state).filter((site) => compareSites(site, siteProps)).size > 0 +} + +const sortCountDescending = (left, right) => { + const leftCount = left.get('count') || 0 + const rightCount = right.get('count') || 0 + if (leftCount < rightCount) { + return 1 + } + if (leftCount > rightCount) { + return -1 + } + if (left.get('lastAccessedTime') < right.get('lastAccessedTime')) { + return 1 + } + if (left.get('lastAccessedTime') > right.get('lastAccessedTime')) { + return -1 + } + return 0 +} + +const removeDuplicateDomains = (list) => { + const siteDomains = new Set() + return list.filter((site) => { + if (!site.get('location')) { + return false + } + try { + const hostname = require('../../common/urlParse')(site.get('location')).hostname + if (!siteDomains.has(hostname)) { + siteDomains.add(hostname) + return true + } + } catch (e) { + console.log('Error parsing hostname: ', e) + } + return false + }) +} + +const calculateTopSites = (clearCache) => { + if (clearCache) { + clearTopSiteCacheData() + } + startCalculatingTopSiteData() +} + +/** + * TopSites are defined by users for the new tab page. Pinned sites are attached to their positions + * in the grid, and the non pinned indexes are populated with newly accessed sites + */ +const startCalculatingTopSiteData = debounce(() => { + if (!appStore) { + appStore = require('../../../js/stores/appStore') + } + const state = appStore.getState() + // remove folders; sort by visit count; enforce a max limit + const sites = (state.get('sites') ? state.get('sites').toList() : new Immutable.List()) + .filter((site) => !siteUtil.isFolder(site) && + !siteUtil.isImportedBookmark(site) && + !isSourceAboutUrl(site.get('location')) && + (minCountOfTopSites === undefined || (site.get('count') || 0) >= minCountOfTopSites) && + (minAccessOfTopSites === undefined || (site.get('lastAccessedTime') || 0) >= minAccessOfTopSites)) + .sort(sortCountDescending) + .slice(0, aboutNewTabMaxEntries) + + for (let i = 0; i < sites.size; i++) { + const count = sites.getIn([i, 'count']) || 0 + const access = sites.getIn([i, 'lastAccessedTime']) || 0 + if (minCountOfTopSites === undefined || count < minCountOfTopSites) { + minCountOfTopSites = count + } + if (minAccessOfTopSites === undefined || access < minAccessOfTopSites) { + minAccessOfTopSites = access + } + } + + // Filter out pinned and ignored sites + let unpinnedSites = sites.filter((site) => !(isPinned(state, site) || isIgnored(state, site))) + unpinnedSites = removeDuplicateDomains(unpinnedSites) + + // Merge the pinned and unpinned lists together + // Pinned items have priority because the position is important + let gridSites = pinnedTopSites(state).map((pinnedSite) => { + // Fetch latest siteDetail objects from appState.sites using location/partition + if (pinnedSite) { + const matches = sites.filter((site) => compareSites(site, pinnedSite)) + if (matches.size > 0) return matches.first() + } + // Default to unpinned items + const firstSite = unpinnedSites.first() + unpinnedSites = unpinnedSites.shift() + return firstSite + }) + + // Include up to [aboutNewTabMaxEntries] entries so that folks + // can ignore sites and have new items fill those empty spaces + if (unpinnedSites.size > 0) { + gridSites = gridSites.concat(unpinnedSites) + } + + const finalData = gridSites.filter((site) => site != null) + appActions.topSiteDataAvailable(finalData) +}, 5 * 1000) + +const clearTopSiteCacheData = () => { + minCountOfTopSites = undefined + minAccessOfTopSites = undefined +} + +module.exports = { + calculateTopSites, + clearTopSiteCacheData, + aboutNewTabMaxEntries +} diff --git a/app/browser/reducers/topSitesReducer.js b/app/browser/reducers/topSitesReducer.js new file mode 100644 index 00000000000..0f89abc79f5 --- /dev/null +++ b/app/browser/reducers/topSitesReducer.js @@ -0,0 +1,19 @@ +/* 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/. */ + +'use strict' + +const aboutNewTabState = require('../../common/state/aboutNewTabState') +const appConstants = require('../../../js/constants/appConstants') + +const topSitesReducer = (state, action) => { + switch (action.actionType) { + case appConstants.APP_TOP_SITE_DATA_AVAILABLE: + state = aboutNewTabState.setSites(state, action.topSites) + break + } + return state +} + +module.exports = topSitesReducer diff --git a/app/common/state/aboutNewTabState.js b/app/common/state/aboutNewTabState.js index 0328d243782..ea855edaf04 100644 --- a/app/common/state/aboutNewTabState.js +++ b/app/common/state/aboutNewTabState.js @@ -2,107 +2,14 @@ * 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 {makeImmutable} = require('./immutableUtil') -const siteUtil = require('../../../js/state/siteUtil') -const {isSourceAboutUrl} = require('../../../js/lib/appUrlUtil') -const aboutNewTabMaxEntries = 100 -const compareSites = (site1, site2) => { - if (!site1 || !site2) return false - return site1.get('location') === site2.get('location') && - site1.get('partitionNumber') === site2.get('partitionNumber') -} -const pinnedTopSites = (state) => { - return (state.getIn(['about', 'newtab', 'pinnedTopSites']) || Immutable.List()).setSize(18) -} -const ignoredTopSites = (state) => { - return state.getIn(['about', 'newtab', 'ignoredTopSites']) || Immutable.List() -} -const isPinned = (state, siteProps) => { - return pinnedTopSites(state).filter((site) => compareSites(site, siteProps)).size > 0 -} -const isIgnored = (state, siteProps) => { - return ignoredTopSites(state).filter((site) => compareSites(site, siteProps)).size > 0 -} -const sortCountDescending = (left, right) => { - const leftCount = left.get('count') || 0 - const rightCount = right.get('count') || 0 - if (leftCount < rightCount) { - return 1 - } - if (leftCount > rightCount) { - return -1 - } - if (left.get('lastAccessedTime') < right.get('lastAccessedTime')) { - return 1 - } - if (left.get('lastAccessedTime') > right.get('lastAccessedTime')) { - return -1 - } - return 0 -} -const removeDuplicateDomains = (list) => { - const siteDomains = new Set() - return list.filter((site) => { - if (!site.get('location')) { - return false - } - try { - const hostname = require('../urlParse')(site.get('location')).hostname - if (!siteDomains.has(hostname)) { - siteDomains.add(hostname) - return true - } - } catch (e) { - console.log('Error parsing hostname: ', e) - } - return false - }) -} /** * topSites are defined by users. Pinned sites are attached to their positions * in the grid, and the non pinned indexes are populated with newly accessed sites */ -const getTopSites = (state) => { - // remove folders; sort by visit count; enforce a max limit - const sites = (state.get('sites') ? state.get('sites').toList() : new Immutable.List()) - .filter((site) => !siteUtil.isFolder(site)) - .filter((site) => !siteUtil.isImportedBookmark(site)) - .filter((site) => !isSourceAboutUrl(site.get('location'))) - .sort(sortCountDescending) - .slice(0, aboutNewTabMaxEntries) - - // Filter out pinned and ignored sites - let unpinnedSites = sites.filter((site) => !(isPinned(state, site) || isIgnored(state, site))) - unpinnedSites = removeDuplicateDomains(unpinnedSites) - - // Merge the pinned and unpinned lists together - // Pinned items have priority because the position is important - let gridSites = pinnedTopSites(state).map((pinnedSite) => { - // Fetch latest siteDetail objects from appState.sites using location/partition - if (pinnedSite) { - const matches = sites.filter((site) => compareSites(site, pinnedSite)) - if (matches.size > 0) return matches.first() - } - // Default to unpinned items - const firstSite = unpinnedSites.first() - unpinnedSites = unpinnedSites.shift() - return firstSite - }) - - // Include up to [aboutNewTabMaxEntries] entries so that folks - // can ignore sites and have new items fill those empty spaces - if (unpinnedSites.size > 0) { - gridSites = gridSites.concat(unpinnedSites) - } - - return gridSites.filter((site) => site != null) -} const aboutNewTabState = { - maxSites: aboutNewTabMaxEntries, - getSites: (state) => { return state.getIn(['about', 'newtab', 'sites']) }, @@ -117,11 +24,12 @@ const aboutNewTabState = { return state.setIn(['about', 'newtab', 'updatedStamp'], new Date().getTime()) }, - setSites: (state) => { - state = makeImmutable(state) - - // return a filtered version of the sites array - state = state.setIn(['about', 'newtab', 'sites'], getTopSites(state)) + setSites: (state, topSites) => { + if (!topSites) { + return state + } + topSites = makeImmutable(topSites) + state = state.setIn(['about', 'newtab', 'sites'], topSites) return state.setIn(['about', 'newtab', 'updatedStamp'], new Date().getTime()) } } diff --git a/app/renderer/components/frame/frame.js b/app/renderer/components/frame/frame.js index 292c26f40be..5ad3ef64eff 100644 --- a/app/renderer/components/frame/frame.js +++ b/app/renderer/components/frame/frame.js @@ -496,7 +496,11 @@ class Frame extends React.Component { if (this.frame.isEmpty()) { return } - if (e.favicons && e.favicons.length > 0) { + if (e.favicons && + e.favicons.length > 0 && + // Favicon changes lead to recalculation of top site data so only fire + // this when needed. Some sites update favicons very frequently. + e.favicons[0] !== this.frame.get('icon')) { imageUtil.getWorkingImageUrl(e.favicons[0], (imageFound) => { windowActions.setFavicon(this.frame, imageFound ? e.favicons[0] : null) }) @@ -599,7 +603,7 @@ class Frame extends React.Component { } } - const loadEnd = (savePage, url) => { + const loadEnd = (savePage, url, inPageNav) => { if (this.frame.isEmpty()) { return } @@ -614,7 +618,7 @@ class Frame extends React.Component { const protocol = parsedUrl.protocol const isError = this.props.aboutDetailsErrorCode - if (!this.props.isPrivate && (protocol === 'http:' || protocol === 'https:') && !isError && savePage) { + if (!this.props.isPrivate && (protocol === 'http:' || protocol === 'https:') && !isError && savePage && !inPageNav) { // Register the site for recent history for navigation bar // calling with setTimeout is an ugly hack for a race condition // with setTitle. We either need to delay this call until the title is @@ -732,18 +736,18 @@ class Frame extends React.Component { }, { passive: true }) this.webview.addEventListener('did-fail-provisional-load', (e) => { if (e.isMainFrame) { - loadEnd(false, e.validatedURL) + loadEnd(false, e.validatedURL, false) loadFail(e, true, e.currentURL) } }) this.webview.addEventListener('did-fail-load', (e) => { if (e.isMainFrame) { - loadEnd(false, e.validatedURL) + loadEnd(false, e.validatedURL, false) loadFail(e, false, e.validatedURL) } }) this.webview.addEventListener('did-finish-load', (e) => { - loadEnd(true, e.validatedURL) + loadEnd(true, e.validatedURL, false) if (this.props.runInsecureContent) { appActions.removeSiteSetting(this.props.origin, 'runInsecureContent', this.props.isPrivate) } @@ -754,7 +758,7 @@ class Frame extends React.Component { } if (e.isMainFrame) { windowActions.setNavigated(e.url, this.props.frameKey, true, this.props.tabId) - loadEnd(true, e.url) + loadEnd(true, e.url, true) } }) this.webview.addEventListener('enter-html-full-screen', () => { diff --git a/js/actions/appActions.js b/js/actions/appActions.js index e4b0b571bb5..1856b1dca82 100644 --- a/js/actions/appActions.js +++ b/js/actions/appActions.js @@ -124,6 +124,13 @@ const appActions = { }) }, + topSiteDataAvailable: function (topSites) { + dispatch({ + actionType: appConstants.APP_TOP_SITE_DATA_AVAILABLE, + topSites + }) + }, + /** * A request for a URL load * @param {number} tabId - the tab ID to load the URL inside of diff --git a/js/constants/appConstants.js b/js/constants/appConstants.js index 809d8563596..f8e12793899 100644 --- a/js/constants/appConstants.js +++ b/js/constants/appConstants.js @@ -1,6 +1,5 @@ /* 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/. */ + * 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 mapValuesByKeys = require('../lib/functional').mapValuesByKeys @@ -107,6 +106,7 @@ const appConstants = { APP_TAB_MESSAGE_BOX_UPDATED: _, APP_NAVIGATOR_HANDLER_REGISTERED: _, APP_NAVIGATOR_HANDLER_UNREGISTERED: _, + APP_TOP_SITE_DATA_AVAILABLE: _, APP_URL_BAR_TEXT_CHANGED: _, APP_URL_BAR_SUGGESTIONS_CHANGED: _, APP_SEARCH_SUGGESTION_RESULTS_AVAILABLE: _, diff --git a/js/stores/appStore.js b/js/stores/appStore.js index 63fd8cc15aa..9d2096f74e4 100644 --- a/js/stores/appStore.js +++ b/js/stores/appStore.js @@ -32,6 +32,7 @@ const nativeImage = require('../../app/nativeImage') const filtering = require('../../app/filtering') const basicAuth = require('../../app/browser/basicAuth') const webtorrent = require('../../app/browser/webtorrent') +const {calculateTopSites} = require('../../app/browser/api/topSites') const assert = require('assert') const profiles = require('../../app/browser/profiles') const {zoomLevel} = require('../../app/common/constants/toolbarUserInterfaceScale') @@ -406,6 +407,7 @@ const handleAppAction = (action) => { require('../../app/browser/reducers/extensionsReducer'), require('../../app/browser/reducers/shareReducer'), require('../../app/browser/reducers/updatesReducer'), + require('../../app/browser/reducers/topSitesReducer'), require('../../app/ledger').doAction, require('../../app/browser/menu') ] @@ -443,7 +445,7 @@ const handleAppAction = (action) => { case appConstants.APP_CHANGE_NEW_TAB_DETAIL: appState = aboutNewTabState.mergeDetails(appState, action) if (action.refresh) { - appState = aboutNewTabState.setSites(appState, action) + calculateTopSites(true) } break case appConstants.APP_POPULATE_HISTORY: @@ -456,7 +458,7 @@ const handleAppAction = (action) => { case appConstants.APP_ADD_BOOKMARK: case appConstants.APP_EDIT_BOOKMARK: const oldSiteSize = appState.get('sites').size - appState = aboutNewTabState.setSites(appState, action) + calculateTopSites(false) appState = aboutHistoryState.setHistory(appState, action) // If there was an item added then clear out the old history entries if (oldSiteSize !== appState.get('sites').size) { @@ -465,7 +467,7 @@ const handleAppAction = (action) => { break case appConstants.APP_APPLY_SITE_RECORDS: case appConstants.APP_REMOVE_SITE: - appState = aboutNewTabState.setSites(appState, action) + calculateTopSites(true) appState = aboutHistoryState.setHistory(appState, action) break case appConstants.APP_SET_DATA_FILE_ETAG: @@ -660,7 +662,7 @@ const handleAppAction = (action) => { const clearData = defaults ? defaults.merge(temp) : temp if (clearData.get('browserHistory')) { - appState = aboutNewTabState.setSites(appState) + calculateTopSites(true) appState = aboutHistoryState.setHistory(appState) syncActions.clearHistory() BrowserWindow.getAllWindows().forEach((wnd) => wnd.webContents.send(messages.CLEAR_CLOSED_FRAMES)) @@ -796,7 +798,9 @@ const handleAppAction = (action) => { break case windowConstants.WINDOW_SET_FAVICON: appState = siteUtil.updateSiteFavicon(appState, action.frameProps.get('location'), action.favicon) - appState = aboutNewTabState.setSites(appState, action) + if (action.frameProps.get('favicon') !== action.favicon) { + calculateTopSites(false) + } break case appConstants.APP_RENDER_URL_TO_PDF: const pdf = require('../../app/pdf') diff --git a/test/unit/app/browser/api/topSitesTest.js b/test/unit/app/browser/api/topSitesTest.js new file mode 100644 index 00000000000..451f5dd879b --- /dev/null +++ b/test/unit/app/browser/api/topSitesTest.js @@ -0,0 +1,192 @@ +/* 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, after, afterEach, before */ +const Immutable = require('immutable') +const assert = require('assert') +const siteTags = require('../../../../../js/constants/siteTags') +const sinon = require('sinon') +const mockery = require('mockery') + +let getStateValue + +const defaultAppState = Immutable.fromJS({ + about: { + newtab: { + gridLayoutSize: 'large', + sites: [], + ignoredTopSites: [], + pinnedTopSites: [], + updatedStamp: undefined + } + } +}) + +const calculateTopSitesClockTime = 1000000 + +describe('topSites api', function () { + before(function () { + this.clock = sinon.useFakeTimers() + mockery.enable({ + warnOnReplace: false, + warnOnUnregistered: false, + useCleanCache: true + }) + mockery.registerMock('electron', require('../../../lib/fakeElectron')) + mockery.registerMock('ad-block', require('../../../lib/fakeAdBlock')) + this.appActions = require('../../../../../js/actions/appActions') + this.appStore = require('../../../../../js/stores/appStore') + sinon.stub(this.appActions, 'topSiteDataAvailable') + getStateValue = Immutable.Map() + sinon.stub(this.appStore, 'getState', () => { + return getStateValue + }) + this.topSites = require('../../../../../app/browser/api/topSites') + }) + after(function () { + this.appActions.topSiteDataAvailable.restore() + this.clock.restore() + mockery.disable() + }) + afterEach(function () { + this.appActions.topSiteDataAvailable.reset() + }) + describe('calculateTopSites', function () { + const site1 = Immutable.fromJS({ + location: 'https://example1.com/', title: 'sample 1', parentFolderId: 0, count: 10 + }) + const site2 = Immutable.fromJS({ + location: 'https://example2.com', title: 'sample 2', parentFolderId: 0, count: 5 + }) + const site3 = Immutable.fromJS({ + location: 'https://example3.com', title: 'sample 3', parentFolderId: 0, count: 23, lastAccessedTime: 123 + }) + const site4 = Immutable.fromJS({ + location: 'https://example4.com', title: 'sample 4', parentFolderId: 0, count: 0 + }) + const site5 = Immutable.fromJS({ + location: 'https://example4.com', title: 'sample 5', parentFolderId: 0, count: 23, lastAccessedTime: 456 + }) + const importedBookmark1 = Immutable.fromJS({ + location: 'https://example6.com', title: 'sample 6', parentFolderId: 0, count: 23, lastAccessedTime: 0 + }) + const folder1 = Immutable.fromJS({ + customTitle: 'folder1', parentFolderId: 0, tags: [siteTags.BOOKMARK_FOLDER] + }) + + describe('when fetching unpinned results', function () { + it('does not include bookmark folders', function () { + const stateWithSites = defaultAppState.set('sites', + Immutable.List().push(site1).push(folder1)) + const expectedSites = Immutable.List().push(site1) + getStateValue = stateWithSites + this.topSites.calculateTopSites(true) + this.clock.tick(calculateTopSitesClockTime) + assert.equal(this.appActions.topSiteDataAvailable.callCount, 1) + const newSitesData = this.appActions.topSiteDataAvailable.getCall(0).args[0] + assert.deepEqual(newSitesData.toJS(), expectedSites.toJS()) + }) + + it('does not include imported bookmarks (lastAccessedTime === 0)', function () { + const stateWithSites = defaultAppState.set('sites', + Immutable.List().push(site1).push(importedBookmark1)) + const expectedSites = Immutable.List().push(site1) + this.topSites.calculateTopSites(stateWithSites) + getStateValue = stateWithSites + this.clock.tick(calculateTopSitesClockTime) + assert.equal(this.appActions.topSiteDataAvailable.callCount, 1) + const newSitesData = this.appActions.topSiteDataAvailable.getCall(0).args[0] + assert.deepEqual(newSitesData.toJS(), expectedSites.toJS()) + }) + + it('sorts results by `count` DESC', function () { + const stateWithSites = defaultAppState.set('sites', + Immutable.List().push(site1).push(site2).push(site3).push(site4)) + const expectedSites = Immutable.List().push(site3).push(site1).push(site2).push(site4) + this.topSites.calculateTopSites(stateWithSites) + getStateValue = stateWithSites + this.clock.tick(calculateTopSitesClockTime) + assert.equal(this.appActions.topSiteDataAvailable.callCount, 1) + const newSitesData = this.appActions.topSiteDataAvailable.getCall(0).args[0] + assert.deepEqual(newSitesData.toJS(), expectedSites.toJS()) + }) + + it('sorts results by `lastAccessedTime` DESC if `count` is the same', function () { + const stateWithSites = defaultAppState.set('sites', + Immutable.List().push(site1).push(site3).push(site5)) + const expectedSites = Immutable.List().push(site5).push(site3).push(site1) + this.topSites.calculateTopSites(stateWithSites) + getStateValue = stateWithSites + this.clock.tick(calculateTopSitesClockTime) + assert.equal(this.appActions.topSiteDataAvailable.callCount, 1) + const newSitesData = this.appActions.topSiteDataAvailable.getCall(0).args[0] + assert.deepEqual(newSitesData.toJS(), expectedSites.toJS()) + }) + + it('only returns the last `maxSites` results', function () { + const maxSites = this.topSites.aboutNewTabMaxEntries + let tooManySites = Immutable.List() + for (let i = 0; i < maxSites + 1; i++) { + tooManySites = tooManySites.push( + site1.set('location', 'https://example' + i + '.com') + .set('title', 'sample ' + i) + .set('count', i)) + } + const stateWithTooManySites = defaultAppState.set('sites', tooManySites) + this.topSites.calculateTopSites(stateWithTooManySites) + + getStateValue = stateWithTooManySites + this.clock.tick(calculateTopSitesClockTime) + assert.equal(this.appActions.topSiteDataAvailable.callCount, 1) + const newSitesData = this.appActions.topSiteDataAvailable.getCall(0).args[0] + assert.equal(newSitesData.size, maxSites) + assert.equal(newSitesData.getIn([0, 'title']), 'sample ' + this.topSites.aboutNewTabMaxEntries) + }) + + it('does not include items marked as ignored', function () { + const ignoredSites = Immutable.List().push(site1).push(site3) + const stateWithIgnoredSites = defaultAppState + .set('sites', Immutable.List().push(site1).push(site2).push(site3).push(site4)) + .setIn(['about', 'newtab', 'ignoredTopSites'], ignoredSites) + const expectedSites = Immutable.List().push(site2).push(site4) + this.topSites.calculateTopSites(stateWithIgnoredSites) + getStateValue = stateWithIgnoredSites + this.clock.tick(calculateTopSitesClockTime) + assert.equal(this.appActions.topSiteDataAvailable.callCount, 1) + const newSitesData = this.appActions.topSiteDataAvailable.getCall(0).args[0] + assert.deepEqual(newSitesData.toJS(), expectedSites.toJS()) + }) + }) + + it('respects position of pinned items when populating results', function () { + const allPinned = Immutable.fromJS([null, null, site1, null, null, null, null, null, site4]) + const stateWithPinnedSites = defaultAppState + .set('sites', Immutable.List().push(site1).push(site2).push(site3).push(folder1).push(site4)) + .setIn(['about', 'newtab', 'pinnedTopSites'], allPinned) + const expectedSites = Immutable.List().push(site3).push(site2).push(site1).push(site4) + this.topSites.calculateTopSites(stateWithPinnedSites) + // checks: + // - pinned item are in their expected order + // - unpinned items fill the rest of the spots (starting w/ highest # visits first) + getStateValue = stateWithPinnedSites + this.clock.tick(calculateTopSitesClockTime) + assert.equal(this.appActions.topSiteDataAvailable.callCount, 1) + const newSitesData = this.appActions.topSiteDataAvailable.getCall(0).args[0] + assert.deepEqual(newSitesData.toJS(), expectedSites.toJS()) + }) + + it('only includes one result for a domain (the one with the highest count)', function () { + const stateWithDuplicateDomains = defaultAppState.set('sites', Immutable.List() + .push(site1.set('location', 'https://example1.com/test').set('count', 12)) + .push(site1.set('location', 'https://example1.com/about').set('count', 7))) + const expectedSites = Immutable.List().push(site1.set('location', 'https://example1.com/test').set('count', 12)) + this.topSites.calculateTopSites(stateWithDuplicateDomains) + getStateValue = stateWithDuplicateDomains + this.clock.tick(calculateTopSitesClockTime) + assert.equal(this.appActions.topSiteDataAvailable.callCount, 1) + const newSitesData = this.appActions.topSiteDataAvailable.getCall(0).args[0] + assert.deepEqual(newSitesData.toJS(), expectedSites.toJS()) + }) + }) +}) diff --git a/test/unit/app/browser/reducers/topSitesReducerTest.js b/test/unit/app/browser/reducers/topSitesReducerTest.js new file mode 100644 index 00000000000..89b783f837e --- /dev/null +++ b/test/unit/app/browser/reducers/topSitesReducerTest.js @@ -0,0 +1,46 @@ +/* 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, after, afterEach */ + +const sinon = require('sinon') +const Immutable = require('immutable') +const assert = require('assert') + +const appConstants = require('../../../../../js/constants/appConstants') +require('../../../braveUnit') + +describe('topSitesReducerTest', function () { + let topSitesReducer + let setSitesStub + before(function () { + const aboutNewTabState = require('../../../../../app/common/state/aboutNewTabState') + setSitesStub = sinon.stub(aboutNewTabState, 'setSites') + topSitesReducer = require('../../../../../app/browser/reducers/topSitesReducer') + }) + + after(function () { + setSitesStub.restore() + }) + + afterEach(function () { + setSitesStub.reset() + }) + + describe('APP_TOP_SITE_DATA_AVAILABLE', function () { + it('sets the data in new tab state', () => { + const site1 = Immutable.fromJS({ + location: 'https://example1.com', title: 'sample 1', parentFolderId: 0, count: 23, lastAccessedTime: 123 + }) + const site2 = Immutable.fromJS({ + location: 'https://example2.com', title: 'sample 2', parentFolderId: 0, count: 0 + }) + const topSites = Immutable.fromJS([site1, site2]) + this.newState = topSitesReducer(Immutable.Map(), {actionType: appConstants.APP_TOP_SITE_DATA_AVAILABLE, topSites}) + assert.equal(setSitesStub.calledOnce, true) + assert.deepEqual(setSitesStub.getCall(0).args[0].toJS(), {}) + assert.deepEqual(setSitesStub.getCall(0).args[1].toJS(), topSites.toJS()) + }) + }) +}) diff --git a/test/unit/app/common/state/aboutNewTabStateTest.js b/test/unit/app/common/state/aboutNewTabStateTest.js index c40351e9aa0..83a23f46ede 100644 --- a/test/unit/app/common/state/aboutNewTabStateTest.js +++ b/test/unit/app/common/state/aboutNewTabStateTest.js @@ -2,7 +2,6 @@ const aboutNewTabState = require('../../../../../app/common/state/aboutNewTabState') const Immutable = require('immutable') const assert = require('assert') -const siteTags = require('../../../../../js/constants/siteTags') const defaultAppState = Immutable.fromJS({ about: { @@ -50,99 +49,22 @@ describe('aboutNewTabState', function () { const site3 = Immutable.fromJS({ location: 'https://example3.com', title: 'sample 3', parentFolderId: 0, count: 23, lastAccessedTime: 123 }) - const site4 = Immutable.fromJS({ - location: 'https://example4.com', title: 'sample 4', parentFolderId: 0, count: 0 - }) - const site5 = Immutable.fromJS({ - location: 'https://example4.com', title: 'sample 5', parentFolderId: 0, count: 23, lastAccessedTime: 456 - }) - const importedBookmark1 = Immutable.fromJS({ - location: 'https://example6.com', title: 'sample 6', parentFolderId: 0, count: 23, lastAccessedTime: 0 - }) - const folder1 = Immutable.fromJS({ - customTitle: 'folder1', parentFolderId: 0, tags: [siteTags.BOOKMARK_FOLDER] - }) - - describe('when fetching unpinned results', function () { - it('does not include bookmark folders', function () { - const stateWithSites = defaultAppState.set('sites', - Immutable.List().push(site1).push(folder1)) - const expectedSites = Immutable.List().push(site1) - const actualState = aboutNewTabState.setSites(stateWithSites) - assert.deepEqual(actualState.getIn(['about', 'newtab', 'sites']).toJS(), expectedSites.toJS()) - }) - - it('does not include imported bookmarks (lastAccessedTime === 0)', function () { - const stateWithSites = defaultAppState.set('sites', - Immutable.List().push(site1).push(importedBookmark1)) - const expectedSites = Immutable.List().push(site1) - const actualState = aboutNewTabState.setSites(stateWithSites) - assert.deepEqual(actualState.getIn(['about', 'newtab', 'sites']).toJS(), expectedSites.toJS()) - }) - - it('sorts results by `count` DESC', function () { - const stateWithSites = defaultAppState.set('sites', - Immutable.List().push(site1).push(site2).push(site3).push(site4)) - const expectedSites = Immutable.List().push(site3).push(site1).push(site2).push(site4) - const actualState = aboutNewTabState.setSites(stateWithSites) - assert.deepEqual(actualState.getIn(['about', 'newtab', 'sites']).toJS(), expectedSites.toJS()) - }) - - it('sorts results by `lastAccessedTime` DESC if `count` is the same', function () { - const stateWithSites = defaultAppState.set('sites', - Immutable.List().push(site1).push(site3).push(site5)) - const expectedSites = Immutable.List().push(site5).push(site3).push(site1) - const actualState = aboutNewTabState.setSites(stateWithSites) - assert.deepEqual(actualState.getIn(['about', 'newtab', 'sites']).toJS(), expectedSites.toJS()) - }) - - it('only returns the last `maxSites` results', function () { - const maxSites = aboutNewTabState.maxSites - let tooManySites = Immutable.List() - for (let i = 0; i < maxSites + 1; i++) { - tooManySites = tooManySites.push( - site1.set('location', 'https://example' + i + '.com') - .set('title', 'sample ' + i) - .set('count', i)) - } - const stateWithTooManySites = defaultAppState.set('sites', tooManySites) - const actualState = aboutNewTabState.setSites(stateWithTooManySites) - const actualSites = actualState.getIn(['about', 'newtab', 'sites']) - assert.equal(actualSites.size, maxSites) - assert.equal(actualSites.getIn([0, 'title']), 'sample ' + aboutNewTabState.maxSites) - }) - - it('does not include items marked as ignored', function () { - const ignoredSites = Immutable.List().push(site1).push(site3) - const stateWithIgnoredSites = defaultAppState - .set('sites', Immutable.List().push(site1).push(site2).push(site3).push(site4)) - .setIn(['about', 'newtab', 'ignoredTopSites'], ignoredSites) - const expectedState = Immutable.List().push(site2).push(site4) - const actualState = aboutNewTabState.setSites(stateWithIgnoredSites) - assert.deepEqual(actualState.getIn(['about', 'newtab', 'sites']).toJS(), expectedState.toJS()) - }) + it('updates the `updatedStamp` value on success', function () { + const topSites = Immutable.fromJS([site1, site2, site3]) + const state = aboutNewTabState.setSites(defaultAppState, topSites) + assertTimeUpdated(state) }) - it('respects position of pinned items when populating results', function () { - const allPinned = Immutable.fromJS([null, null, site1, null, null, null, null, null, site4]) - const stateWithPinnedSites = defaultAppState - .set('sites', Immutable.List().push(site1).push(site2).push(site3).push(folder1).push(site4)) - .setIn(['about', 'newtab', 'pinnedTopSites'], allPinned) - const expectedSites = Immutable.List().push(site3).push(site2).push(site1).push(site4) - const actualState = aboutNewTabState.setSites(stateWithPinnedSites) - // checks: - // - pinned item are in their expected order - // - unpinned items fill the rest of the spots (starting w/ highest # visits first) - assert.deepEqual(actualState.getIn(['about', 'newtab', 'sites']).toJS(), expectedSites.toJS()) + it('does not update state or `updatedStamp` if input is falsey', function () { + const state = aboutNewTabState.setSites(defaultAppState, null) + assertNoChange(state) }) - it('only includes one result for a domain (the one with the highest count)', function () { - const stateWithDuplicateDomains = defaultAppState.set('sites', Immutable.List() - .push(site1.set('location', 'https://example1.com/test').set('count', 12)) - .push(site1.set('location', 'https://example1.com/about').set('count', 7))) - const expectedSites = Immutable.List().push(site1.set('location', 'https://example1.com/test').set('count', 12)) - const actualState = aboutNewTabState.setSites(stateWithDuplicateDomains) - assert.deepEqual(actualState.getIn(['about', 'newtab', 'sites']).toJS(), expectedSites.toJS()) + it('sets the provided data for top sites', function () { + const topSites = Immutable.fromJS([site1, site2, site3]) + const state = aboutNewTabState.setSites(defaultAppState, topSites) + const updatedValue = state.getIn(['about', 'newtab', 'sites']) + assert.deepEqual(updatedValue.toJS(), topSites.toJS()) }) })