diff options
Diffstat (limited to 'toolkit/modules/NewTabUtils.sys.mjs')
-rw-r--r-- | toolkit/modules/NewTabUtils.sys.mjs | 2363 |
1 files changed, 2363 insertions, 0 deletions
diff --git a/toolkit/modules/NewTabUtils.sys.mjs b/toolkit/modules/NewTabUtils.sys.mjs new file mode 100644 index 0000000000..55d7d75ee5 --- /dev/null +++ b/toolkit/modules/NewTabUtils.sys.mjs @@ -0,0 +1,2363 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +// Android tests don't import these properly, so guard against that +let shortURL = {}; +let searchShortcuts = {}; +let didSuccessfulImport = false; +try { + shortURL = ChromeUtils.import("resource://activity-stream/lib/ShortURL.jsm"); + searchShortcuts = ChromeUtils.importESModule( + "resource://activity-stream/lib/SearchShortcuts.sys.mjs" + ); + didSuccessfulImport = true; +} catch (e) { + // The test failed to import these files +} + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BinarySearch: "resource://gre/modules/BinarySearch.sys.mjs", + PageThumbs: "resource://gre/modules/PageThumbs.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + Pocket: "chrome://pocket/content/Pocket.sys.mjs", + pktApi: "chrome://pocket/content/pktApi.sys.mjs", +}); + +let BrowserWindowTracker; +try { + BrowserWindowTracker = ChromeUtils.import( + "resource:///modules/BrowserWindowTracker.jsm" + ).BrowserWindowTracker; +} catch (e) { + // BrowserWindowTracker is used to determine devicePixelRatio in + // _addFavicons. We fallback to the value 2 if we can't find a window, + // so it's safe to do nothing with this here. +} + +XPCOMUtils.defineLazyGetter(lazy, "gCryptoHash", function () { + return Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash); +}); + +// Boolean preferences that control newtab content +const PREF_NEWTAB_ENABLED = "browser.newtabpage.enabled"; + +// The maximum number of results PlacesProvider retrieves from history. +const HISTORY_RESULTS_LIMIT = 100; + +// The maximum number of links Links.getLinks will return. +const LINKS_GET_LINKS_LIMIT = 100; + +// The gather telemetry topic. +const TOPIC_GATHER_TELEMETRY = "gather-telemetry"; + +// Some default frecency threshold for Activity Stream requests +const ACTIVITY_STREAM_DEFAULT_FRECENCY = 150; + +// Some default query limit for Activity Stream requests +const ACTIVITY_STREAM_DEFAULT_LIMIT = 12; + +// Some default seconds ago for Activity Stream recent requests +const ACTIVITY_STREAM_DEFAULT_RECENT = 5 * 24 * 60 * 60; + +// The fallback value for the width of smallFavicon in pixels. +// This value will be multiplied by the current window's devicePixelRatio. +// If devicePixelRatio cannot be found, it will be multiplied by 2. +const DEFAULT_SMALL_FAVICON_WIDTH = 16; + +const POCKET_UPDATE_TIME = 24 * 60 * 60 * 1000; // 1 day +const POCKET_INACTIVE_TIME = 7 * 24 * 60 * 60 * 1000; // 1 week +const PREF_POCKET_LATEST_SINCE = "extensions.pocket.settings.latestSince"; + +/** + * Calculate the MD5 hash for a string. + * @param aValue + * The string to convert. + * @return The base64 representation of the MD5 hash. + */ +function toHash(aValue) { + let value = new TextEncoder().encode(aValue); + lazy.gCryptoHash.init(lazy.gCryptoHash.MD5); + lazy.gCryptoHash.update(value, value.length); + return lazy.gCryptoHash.finish(true); +} + +/** + * Singleton that provides storage functionality. + */ +XPCOMUtils.defineLazyGetter(lazy, "Storage", function () { + return new LinksStorage(); +}); + +function LinksStorage() { + // Handle migration of data across versions. + try { + if (this._storedVersion < this._version) { + // This is either an upgrade, or version information is missing. + if (this._storedVersion < 1) { + // Version 1 moved data from DOM Storage to prefs. Since migrating from + // version 0 is no more supported, we just reportError a dataloss later. + throw new Error("Unsupported newTab storage version"); + } + // Add further migration steps here. + } else { + // This is a downgrade. Since we cannot predict future, upgrades should + // be backwards compatible. We will set the version to the old value + // regardless, so, on next upgrade, the migration steps will run again. + // For this reason, they should also be able to run multiple times, even + // on top of an already up-to-date storage. + } + } catch (ex) { + // Something went wrong in the update process, we can't recover from here, + // so just clear the storage and start from scratch (dataloss!). + console.error( + "Unable to migrate the newTab storage to the current version. " + + "Restarting from scratch.\n", + ex + ); + this.clear(); + } + + // Set the version to the current one. + this._storedVersion = this._version; +} + +LinksStorage.prototype = { + get _version() { + return 1; + }, + + get _prefs() { + return Object.freeze({ + pinnedLinks: "browser.newtabpage.pinned", + blockedLinks: "browser.newtabpage.blocked", + }); + }, + + get _storedVersion() { + if (this.__storedVersion === undefined) { + // When the pref is not set, the storage version is unknown, so either: + // - it's a new profile + // - it's a profile where versioning information got lost + // In this case we still run through all of the valid migrations, + // starting from 1, as if it was a downgrade. As previously stated the + // migrations should already support running on an updated store. + this.__storedVersion = Services.prefs.getIntPref( + "browser.newtabpage.storageVersion", + 1 + ); + } + return this.__storedVersion; + }, + set _storedVersion(aValue) { + Services.prefs.setIntPref("browser.newtabpage.storageVersion", aValue); + this.__storedVersion = aValue; + }, + + /** + * Gets the value for a given key from the storage. + * @param aKey The storage key (a string). + * @param aDefault A default value if the key doesn't exist. + * @return The value for the given key. + */ + get: function Storage_get(aKey, aDefault) { + let value; + try { + let prefValue = Services.prefs.getStringPref(this._prefs[aKey]); + value = JSON.parse(prefValue); + } catch (e) {} + return value || aDefault; + }, + + /** + * Sets the storage value for a given key. + * @param aKey The storage key (a string). + * @param aValue The value to set. + */ + set: function Storage_set(aKey, aValue) { + // Page titles may contain unicode, thus use complex values. + Services.prefs.setStringPref(this._prefs[aKey], JSON.stringify(aValue)); + }, + + /** + * Removes the storage value for a given key. + * @param aKey The storage key (a string). + */ + remove: function Storage_remove(aKey) { + Services.prefs.clearUserPref(this._prefs[aKey]); + }, + + /** + * Clears the storage and removes all values. + */ + clear: function Storage_clear() { + for (let key in this._prefs) { + this.remove(key); + } + }, +}; + +/** + * Singleton that serves as a registry for all open 'New Tab Page's. + */ +var AllPages = { + /** + * The array containing all active pages. + */ + _pages: [], + + /** + * Cached value that tells whether the New Tab Page feature is enabled. + */ + _enabled: null, + + /** + * Adds a page to the internal list of pages. + * @param aPage The page to register. + */ + register: function AllPages_register(aPage) { + this._pages.push(aPage); + this._addObserver(); + }, + + /** + * Removes a page from the internal list of pages. + * @param aPage The page to unregister. + */ + unregister: function AllPages_unregister(aPage) { + let index = this._pages.indexOf(aPage); + if (index > -1) { + this._pages.splice(index, 1); + } + }, + + /** + * Returns whether the 'New Tab Page' is enabled. + */ + get enabled() { + if (this._enabled === null) { + this._enabled = Services.prefs.getBoolPref(PREF_NEWTAB_ENABLED); + } + + return this._enabled; + }, + + /** + * Enables or disables the 'New Tab Page' feature. + */ + set enabled(aEnabled) { + if (this.enabled != aEnabled) { + Services.prefs.setBoolPref(PREF_NEWTAB_ENABLED, !!aEnabled); + } + }, + + /** + * Returns the number of registered New Tab Pages (i.e. the number of open + * about:newtab instances). + */ + get length() { + return this._pages.length; + }, + + /** + * Updates all currently active pages but the given one. + * @param aExceptPage The page to exclude from updating. + * @param aReason The reason for updating all pages. + */ + update(aExceptPage, aReason = "") { + for (let page of this._pages.slice()) { + if (aExceptPage != page) { + page.update(aReason); + } + } + }, + + /** + * Implements the nsIObserver interface to get notified when the preference + * value changes or when a new copy of a page thumbnail is available. + */ + observe: function AllPages_observe(aSubject, aTopic, aData) { + if (aTopic == "nsPref:changed") { + // Clear the cached value. + switch (aData) { + case PREF_NEWTAB_ENABLED: + this._enabled = null; + break; + } + } + // and all notifications get forwarded to each page. + this._pages.forEach(function (aPage) { + aPage.observe(aSubject, aTopic, aData); + }, this); + }, + + /** + * Adds a preference and new thumbnail observer and turns itself into a + * no-op after the first invokation. + */ + _addObserver: function AllPages_addObserver() { + Services.prefs.addObserver(PREF_NEWTAB_ENABLED, this, true); + Services.obs.addObserver(this, "page-thumbnail:create", true); + this._addObserver = function () {}; + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), +}; + +/** + * Singleton that keeps track of all pinned links and their positions in the + * grid. + */ +var PinnedLinks = { + /** + * The cached list of pinned links. + */ + _links: null, + + /** + * The array of pinned links. + */ + get links() { + if (!this._links) { + this._links = lazy.Storage.get("pinnedLinks", []); + } + + return this._links; + }, + + /** + * Pins a link at the given position. + * @param aLink The link to pin. + * @param aIndex The grid index to pin the cell at. + * @return true if link changes, false otherwise + */ + pin: function PinnedLinks_pin(aLink, aIndex) { + // Clear the link's old position, if any. + this.unpin(aLink); + + // change pinned link into a history link + let changed = this._makeHistoryLink(aLink); + this.links[aIndex] = aLink; + this.save(); + return changed; + }, + + /** + * Unpins a given link. + * @param aLink The link to unpin. + */ + unpin: function PinnedLinks_unpin(aLink) { + let index = this._indexOfLink(aLink); + if (index == -1) { + return; + } + let links = this.links; + links[index] = null; + // trim trailing nulls + let i = links.length - 1; + while (i >= 0 && links[i] == null) { + i--; + } + links.splice(i + 1); + this.save(); + }, + + /** + * Saves the current list of pinned links. + */ + save: function PinnedLinks_save() { + lazy.Storage.set("pinnedLinks", this.links); + }, + + /** + * Checks whether a given link is pinned. + * @params aLink The link to check. + * @return whether The link is pinned. + */ + isPinned: function PinnedLinks_isPinned(aLink) { + return this._indexOfLink(aLink) != -1; + }, + + /** + * Resets the links cache. + */ + resetCache: function PinnedLinks_resetCache() { + this._links = null; + }, + + /** + * Finds the index of a given link in the list of pinned links. + * @param aLink The link to find an index for. + * @return The link's index. + */ + _indexOfLink: function PinnedLinks_indexOfLink(aLink) { + for (let i = 0; i < this.links.length; i++) { + let link = this.links[i]; + if (link && link.url == aLink.url) { + return i; + } + } + + // The given link is unpinned. + return -1; + }, + + /** + * Transforms link into a "history" link + * @param aLink The link to change + * @return true if link changes, false otherwise + */ + _makeHistoryLink: function PinnedLinks_makeHistoryLink(aLink) { + if (!aLink.type || aLink.type == "history") { + return false; + } + aLink.type = "history"; + return true; + }, + + /** + * Replaces existing link with another link. + * @param aUrl The url of existing link + * @param aLink The replacement link + */ + replace: function PinnedLinks_replace(aUrl, aLink) { + let index = this._indexOfLink({ url: aUrl }); + if (index == -1) { + return; + } + this.links[index] = aLink; + this.save(); + }, +}; + +/** + * Singleton that keeps track of all blocked links in the grid. + */ +var BlockedLinks = { + /** + * A list of objects that are observing blocked link changes. + */ + _observers: [], + + /** + * The cached list of blocked links. + */ + _links: null, + + /** + * Registers an object that will be notified when the blocked links change. + */ + addObserver(aObserver) { + this._observers.push(aObserver); + }, + + /** + * Remove the observers. + */ + removeObservers() { + this._observers = []; + }, + + /** + * The list of blocked links. + */ + get links() { + if (!this._links) { + this._links = lazy.Storage.get("blockedLinks", {}); + } + + return this._links; + }, + + /** + * Blocks a given link. Adjusts siteMap accordingly, and notifies listeners. + * @param aLink The link to block. + */ + block: function BlockedLinks_block(aLink) { + this._callObservers("onLinkBlocked", aLink); + this.links[toHash(aLink.url)] = 1; + this.save(); + + // Make sure we unpin blocked links. + PinnedLinks.unpin(aLink); + }, + + /** + * Unblocks a given link. Adjusts siteMap accordingly, and notifies listeners. + * @param aLink The link to unblock. + */ + unblock: function BlockedLinks_unblock(aLink) { + if (this.isBlocked(aLink)) { + delete this.links[toHash(aLink.url)]; + this.save(); + this._callObservers("onLinkUnblocked", aLink); + } + }, + + /** + * Saves the current list of blocked links. + */ + save: function BlockedLinks_save() { + lazy.Storage.set("blockedLinks", this.links); + }, + + /** + * Returns whether a given link is blocked. + * @param aLink The link to check. + */ + isBlocked: function BlockedLinks_isBlocked(aLink) { + return toHash(aLink.url) in this.links; + }, + + /** + * Checks whether the list of blocked links is empty. + * @return Whether the list is empty. + */ + isEmpty: function BlockedLinks_isEmpty() { + return !Object.keys(this.links).length; + }, + + /** + * Resets the links cache. + */ + resetCache: function BlockedLinks_resetCache() { + this._links = null; + }, + + _callObservers(methodName, ...args) { + for (let obs of this._observers) { + if (typeof obs[methodName] == "function") { + try { + obs[methodName](...args); + } catch (err) { + console.error(err); + } + } + } + }, +}; + +/** + * Singleton that serves as the default link provider for the grid. It queries + * the history to retrieve the most frequently visited sites. + */ +var PlacesProvider = { + /** + * Set this to change the maximum number of links the provider will provide. + */ + maxNumLinks: HISTORY_RESULTS_LIMIT, + + /** + * Must be called before the provider is used. + */ + init: function PlacesProvider_init() { + this._placesObserver = new PlacesWeakCallbackWrapper( + this.handlePlacesEvents.bind(this) + ); + PlacesObservers.addListener( + ["page-visited", "page-title-changed", "pages-rank-changed"], + this._placesObserver + ); + }, + + /** + * Gets the current set of links delivered by this provider. + * @param aCallback The function that the array of links is passed to. + */ + getLinks: function PlacesProvider_getLinks(aCallback) { + let options = lazy.PlacesUtils.history.getNewQueryOptions(); + options.maxResults = this.maxNumLinks; + + // Sort by frecency, descending. + options.sortingMode = + Ci.nsINavHistoryQueryOptions.SORT_BY_FRECENCY_DESCENDING; + + let links = []; + + let callback = { + handleResult(aResultSet) { + let row; + + while ((row = aResultSet.getNextRow())) { + let url = row.getResultByIndex(1); + if (LinkChecker.checkLoadURI(url)) { + let title = row.getResultByIndex(2); + let frecency = row.getResultByIndex(12); + let lastVisitDate = row.getResultByIndex(5); + links.push({ + url, + title, + frecency, + lastVisitDate, + type: "history", + }); + } + } + }, + + handleError(aError) { + // Should we somehow handle this error? + aCallback([]); + }, + + handleCompletion(aReason) { + // The Places query breaks ties in frecency by place ID descending, but + // that's different from how Links.compareLinks breaks ties, because + // compareLinks doesn't have access to place IDs. It's very important + // that the initial list of links is sorted in the same order imposed by + // compareLinks, because Links uses compareLinks to perform binary + // searches on the list. So, ensure the list is so ordered. + let i = 1; + let outOfOrder = []; + while (i < links.length) { + if (Links.compareLinks(links[i - 1], links[i]) > 0) { + outOfOrder.push(links.splice(i, 1)[0]); + } else { + i++; + } + } + for (let link of outOfOrder) { + i = lazy.BinarySearch.insertionIndexOf( + Links.compareLinks, + links, + link + ); + links.splice(i, 0, link); + } + + aCallback(links); + }, + }; + + // Execute the query. + let query = lazy.PlacesUtils.history.getNewQuery(); + lazy.PlacesUtils.history.asyncExecuteLegacyQuery(query, options, callback); + }, + + /** + * Registers an object that will be notified when the provider's links change. + * @param aObserver An object with the following optional properties: + * * onLinkChanged: A function that's called when a single link + * changes. It's passed the provider and the link object. Only the + * link's `url` property is guaranteed to be present. If its `title` + * property is present, then its title has changed, and the + * property's value is the new title. If any sort properties are + * present, then its position within the provider's list of links may + * have changed, and the properties' values are the new sort-related + * values. Note that this link may not necessarily have been present + * in the lists returned from any previous calls to getLinks. + * * onManyLinksChanged: A function that's called when many links + * change at once. It's passed the provider. You should call + * getLinks to get the provider's new list of links. + */ + addObserver: function PlacesProvider_addObserver(aObserver) { + this._observers.push(aObserver); + }, + + _observers: [], + + handlePlacesEvents(aEvents) { + for (let event of aEvents) { + switch (event.type) { + case "page-visited": { + if (event.visitCount == 1 && event.lastKnownTitle) { + this._callObservers("onLinkChanged", { + url: event.url, + title: event.lastKnownTitle, + }); + } + break; + } + case "page-title-changed": { + this._callObservers("onLinkChanged", { + url: event.url, + title: event.title, + }); + break; + } + case "pages-rank-changed": { + this._callObservers("onManyLinksChanged"); + break; + } + } + } + }, + + _callObservers: function PlacesProvider__callObservers(aMethodName, aArg) { + for (let obs of this._observers) { + if (obs[aMethodName]) { + try { + obs[aMethodName](this, aArg); + } catch (err) { + console.error(err); + } + } + } + }, +}; + +/** + * Queries history to retrieve the most frecent sites. Emits events when the + * history changes. + */ +var ActivityStreamProvider = { + THUMB_FAVICON_SIZE: 96, + + /** + * Shared adjustment for selecting potentially blocked links. + */ + _adjustLimitForBlocked({ ignoreBlocked, numItems }) { + // Just use the usual number if blocked links won't be filtered out + if (ignoreBlocked) { + return numItems; + } + // Additionally select the number of blocked links in case they're removed + return Object.keys(BlockedLinks.links).length + numItems; + }, + + /** + * Shared sub-SELECT to get the guid of a bookmark of the current url while + * avoiding LEFT JOINs on moz_bookmarks. This avoids gettings tags. The guid + * could be one of multiple possible guids. Assumes `moz_places h` is in FROM. + */ + _commonBookmarkGuidSelect: `( + SELECT guid + FROM moz_bookmarks b + WHERE fk = h.id + AND type = :bookmarkType + AND ( + SELECT id + FROM moz_bookmarks p + WHERE p.id = b.parent + AND p.parent <> :tagsFolderId + ) NOTNULL + ) AS bookmarkGuid`, + + /** + * Shared WHERE expression filtering out undesired pages, e.g., hidden, + * unvisited, and non-http/s urls. Assumes moz_places is in FROM / JOIN. + * + * NB: SUBSTR(url) is used even without an index instead of url_hash because + * most desired pages will match http/s, so it will only run on the ~10s of + * rows matched. If url_hash were to be used, it should probably *not* be used + * by the query optimizer as we primarily want it optimized for the other + * conditions, e.g., most frecent first. + */ + _commonPlacesWhere: ` + AND hidden = 0 + AND last_visit_date > 0 + AND (SUBSTR(url, 1, 6) == "https:" + OR SUBSTR(url, 1, 5) == "http:") + `, + + /** + * Shared parameters for getting correct bookmarks and LIMITed queries. + */ + _getCommonParams(aOptions, aParams = {}) { + return Object.assign( + { + bookmarkType: lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK, + limit: this._adjustLimitForBlocked(aOptions), + tagsFolderId: lazy.PlacesUtils.tagsFolderId, + }, + aParams + ); + }, + + /** + * Shared columns for Highlights related queries. + */ + _highlightsColumns: [ + "bookmarkGuid", + "description", + "guid", + "preview_image_url", + "title", + "url", + ], + + /** + * Shared post-processing of Highlights links. + */ + _processHighlights(aLinks, aOptions, aType) { + // Filter out blocked if necessary + if (!aOptions.ignoreBlocked) { + aLinks = aLinks.filter( + link => + !BlockedLinks.isBlocked( + link.pocket_id ? { url: link.open_url } : link + ) + ); + } + + // Limit the results to the requested number and set a type corresponding to + // which query selected it + return aLinks.slice(0, aOptions.numItems).map(item => + Object.assign(item, { + type: aType, + }) + ); + }, + + /** + * From an Array of links, if favicons are present, convert to data URIs + * + * @param {Array} aLinks + * an array containing objects with favicon data and mimeTypes + * + * @returns {Array} an array of links with favicons as data uri + */ + _faviconBytesToDataURI(aLinks) { + return aLinks.map(link => { + if (link.favicon) { + let encodedData = btoa(String.fromCharCode.apply(null, link.favicon)); + link.favicon = `data:${link.mimeType};base64,${encodedData}`; + delete link.mimeType; + } + + if (link.smallFavicon) { + let encodedData = btoa( + String.fromCharCode.apply(null, link.smallFavicon) + ); + link.smallFavicon = `data:${link.smallFaviconMimeType};base64,${encodedData}`; + delete link.smallFaviconMimeType; + } + + return link; + }); + }, + + /** + * Get favicon data (and metadata) for a uri. Fetches both the largest favicon + * available, for Activity Stream; and a normal-sized favicon, for the Urlbar. + * + * @param {nsIURI} aUri Page to check for favicon data + * @param {number} preferredFaviconWidth + * The preferred width of the of the normal-sized favicon in pixels. + * @returns A promise of an object (possibly empty) containing the data. + */ + async _loadIcons(aUri, preferredFaviconWidth) { + let iconData = {}; + // Fetch the largest icon available. + let faviconData; + try { + faviconData = await lazy.PlacesUtils.promiseFaviconData( + aUri, + this.THUMB_FAVICON_SIZE + ); + Object.assign(iconData, { + favicon: faviconData.data, + faviconLength: faviconData.dataLen, + faviconRef: faviconData.uri.ref, + faviconSize: faviconData.size, + mimeType: faviconData.mimeType, + }); + } catch (e) { + // Return early because fetching the largest favicon is the primary + // purpose of NewTabUtils. + return null; + } + + // Also fetch a smaller icon. + try { + faviconData = await lazy.PlacesUtils.promiseFaviconData( + aUri, + preferredFaviconWidth + ); + Object.assign(iconData, { + smallFavicon: faviconData.data, + smallFaviconLength: faviconData.dataLen, + smallFaviconRef: faviconData.uri.ref, + smallFaviconSize: faviconData.size, + smallFaviconMimeType: faviconData.mimeType, + }); + } catch (e) { + // Do nothing with the error since we still have the large favicon fields. + } + + return iconData; + }, + + /** + * Computes favicon data for each url in a set of links + * + * @param {Array} links + * an array containing objects without favicon data or mimeTypes yet + * + * @returns {Promise} Returns a promise with the array of links with the largest + * favicon available (as a byte array), mimeType, byte array + * length, and favicon size (width) + */ + _addFavicons(aLinks) { + let win; + if (BrowserWindowTracker) { + win = BrowserWindowTracker.getTopWindow(); + } + // We fetch two copies of a page's favicon: the largest available, for + // Activity Stream; and a smaller size appropriate for the Urlbar. + const preferredFaviconWidth = + DEFAULT_SMALL_FAVICON_WIDTH * (win ? win.devicePixelRatio : 2); + // Each link in the array needs a favicon for it's page - so we fire off a + // promise for each link to compute the favicon data and attach it back to + // the original link object. We must wait until all favicons for the array + // of links are computed before returning + return Promise.all( + aLinks.map( + link => + // eslint-disable-next-line no-async-promise-executor + new Promise(async resolve => { + // Never add favicon data for pocket items + if (link.type === "pocket") { + resolve(link); + return; + } + let iconData; + try { + let linkUri = Services.io.newURI(link.url); + iconData = await this._loadIcons(linkUri, preferredFaviconWidth); + + // Switch the scheme to try again with the other + if (!iconData) { + linkUri = linkUri + .mutate() + .setScheme(linkUri.scheme === "https" ? "http" : "https") + .finalize(); + iconData = await this._loadIcons( + linkUri, + preferredFaviconWidth + ); + } + } catch (e) { + // We just won't put icon data on the link + } + + // Add the icon data to the link if we have any + resolve(Object.assign(link, iconData)); + }) + ) + ); + }, + + /** + * Helper function which makes the call to the Pocket API to fetch the user's + * saved Pocket items. + */ + fetchSavedPocketItems(requestData) { + const latestSince = + Services.prefs.getStringPref(PREF_POCKET_LATEST_SINCE, 0) * 1000; + + // Do not fetch Pocket items for users that have been inactive for too long, or are not logged in + if ( + !lazy.pktApi.isUserLoggedIn() || + Date.now() - latestSince > POCKET_INACTIVE_TIME + ) { + return Promise.resolve(null); + } + + return new Promise((resolve, reject) => { + lazy.pktApi.retrieve(requestData, { + success(data) { + resolve(data); + }, + error(error) { + reject(error); + }, + }); + }); + }, + + /** + * Get the most recently Pocket-ed items from a user's Pocket list. See: + * https://getpocket.com/developer/docs/v3/retrieve for details + * + * @param {Object} aOptions + * {int} numItems: The max number of pocket items to fetch + */ + async getRecentlyPocketed(aOptions) { + const pocketSecondsAgo = + Math.floor(Date.now() / 1000) - ACTIVITY_STREAM_DEFAULT_RECENT; + const requestData = { + detailType: "complete", + count: aOptions.numItems, + since: pocketSecondsAgo, + }; + let data; + try { + data = await this.fetchSavedPocketItems(requestData); + if (!data) { + return []; + } + } catch (e) { + console.error(e); + return []; + } + /* Extract relevant parts needed to show this card as a highlight: + * url, preview image, title, description, and the unique item_id + * necessary for Pocket to identify the item + */ + let items = Object.values(data.list) + // status "0" means not archived or deleted + .filter(item => item.status === "0") + .map(item => ({ + date_added: item.time_added * 1000, + description: item.excerpt, + preview_image_url: item.image && item.image.src, + title: item.resolved_title, + url: item.resolved_url, + pocket_id: item.item_id, + open_url: item.open_url, + })); + + // Append the query param to let Pocket know this item came from highlights + for (let item of items) { + let url = new URL(item.open_url); + url.searchParams.append("src", "fx_new_tab"); + item.open_url = url.href; + } + + return this._processHighlights(items, aOptions, "pocket"); + }, + + /** + * Get most-recently-created visited bookmarks for Activity Stream. + * + * @param {Object} aOptions + * {num} bookmarkSecondsAgo: Maximum age of added bookmark. + * {bool} ignoreBlocked: Do not filter out blocked links. + * {int} numItems: Maximum number of items to return. + */ + async getRecentBookmarks(aOptions) { + const options = Object.assign( + { + bookmarkSecondsAgo: ACTIVITY_STREAM_DEFAULT_RECENT, + ignoreBlocked: false, + numItems: ACTIVITY_STREAM_DEFAULT_LIMIT, + }, + aOptions || {} + ); + + const sqlQuery = ` + SELECT + b.guid AS bookmarkGuid, + description, + h.guid, + preview_image_url, + b.title, + b.dateAdded / 1000 AS date_added, + url + FROM moz_bookmarks b + JOIN moz_bookmarks p + ON p.id = b.parent + JOIN moz_places h + ON h.id = b.fk + WHERE b.dateAdded >= :dateAddedThreshold + AND b.title NOTNULL + AND b.type = :bookmarkType + AND p.parent <> :tagsFolderId + ${this._commonPlacesWhere} + ORDER BY b.dateAdded DESC + LIMIT :limit + `; + + return this._processHighlights( + await this.executePlacesQuery(sqlQuery, { + columns: [...this._highlightsColumns, "date_added"], + params: this._getCommonParams(options, { + dateAddedThreshold: + (Date.now() - options.bookmarkSecondsAgo * 1000) * 1000, + }), + }), + options, + "bookmark" + ); + }, + + /** + * Get total count of all bookmarks. + * Note: this includes default bookmarks + * + * @return {int} The number bookmarks in the places DB. + */ + async getTotalBookmarksCount() { + let sqlQuery = ` + SELECT count(*) FROM moz_bookmarks b + JOIN moz_bookmarks t ON t.id = b.parent + AND t.parent <> :tags_folder + WHERE b.type = :type_bookmark + `; + + const result = await this.executePlacesQuery(sqlQuery, { + params: { + tags_folder: lazy.PlacesUtils.tagsFolderId, + type_bookmark: lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK, + }, + }); + + return result[0][0]; + }, + + /** + * Get most-recently-visited history with metadata for Activity Stream. + * + * @param {Object} aOptions + * {bool} ignoreBlocked: Do not filter out blocked links. + * {int} numItems: Maximum number of items to return. + */ + async getRecentHistory(aOptions) { + const options = Object.assign( + { + ignoreBlocked: false, + numItems: ACTIVITY_STREAM_DEFAULT_LIMIT, + }, + aOptions || {} + ); + + const sqlQuery = ` + SELECT + ${this._commonBookmarkGuidSelect}, + description, + guid, + preview_image_url, + title, + url + FROM moz_places h + WHERE description NOTNULL + AND preview_image_url NOTNULL + ${this._commonPlacesWhere} + ORDER BY last_visit_date DESC + LIMIT :limit + `; + + return this._processHighlights( + await this.executePlacesQuery(sqlQuery, { + columns: this._highlightsColumns, + params: this._getCommonParams(options), + }), + options, + "history" + ); + }, + + /* + * Gets the top frecent sites for Activity Stream. + * + * @param {Object} aOptions + * {bool} ignoreBlocked: Do not filter out blocked links. + * {int} numItems: Maximum number of items to return. + * {int} topsiteFrecency: Minimum amount of frecency for a site. + * {bool} onePerDomain: Dedupe the resulting list. + * {bool} includeFavicon: Include favicons if available. + * {string} hideWithSearchParam: URLs that contain this search param will be + * excluded from the returned links. This value should be either undefined + * or a string with one of the following forms: + * - undefined: Fall back to the value of pref + * `browser.newtabpage.activity-stream.hideTopSitesWithSearchParam` + * - "" (empty) - Disable this feature + * - "key" - Search param named "key" with any or no value + * - "key=" - Search param named "key" with no value + * - "key=value" - Search param named "key" with value "value" + * + * @returns {Promise} Returns a promise with the array of links as payload. + */ + async getTopFrecentSites(aOptions) { + const options = Object.assign( + { + ignoreBlocked: false, + numItems: ACTIVITY_STREAM_DEFAULT_LIMIT, + topsiteFrecency: ACTIVITY_STREAM_DEFAULT_FRECENCY, + onePerDomain: true, + includeFavicon: true, + hideWithSearchParam: Services.prefs.getCharPref( + "browser.newtabpage.activity-stream.hideTopSitesWithSearchParam", + "" + ), + }, + aOptions || {} + ); + + // Double the item count in case the host is deduped between with www or + // not-www (i.e., 2 hosts) and an extra buffer for multiple pages per host. + const origNumItems = options.numItems; + if (options.onePerDomain) { + options.numItems *= 2 * 10; + } + + // Keep this query fast with frecency-indexed lookups (even with excess + // rows) and shift the more complex logic to post-processing afterwards + const sqlQuery = ` + SELECT + ${this._commonBookmarkGuidSelect}, + frecency, + guid, + last_visit_date / 1000 AS lastVisitDate, + rev_host, + title, + url, + "history" as type + FROM moz_places h + WHERE frecency >= :frecencyThreshold + ${this._commonPlacesWhere} + ORDER BY frecency DESC + LIMIT :limit + `; + + let links = await this.executePlacesQuery(sqlQuery, { + columns: [ + "bookmarkGuid", + "frecency", + "guid", + "lastVisitDate", + "title", + "url", + "type", + ], + params: this._getCommonParams(options, { + frecencyThreshold: options.topsiteFrecency, + }), + }); + + // Determine if the other link is "better" (larger frecency, more recent, + // lexicographically earlier url) + function isOtherBetter(link, other) { + if (other.frecency === link.frecency) { + if (other.lastVisitDate === link.lastVisitDate) { + return other.url < link.url; + } + return other.lastVisitDate > link.lastVisitDate; + } + return other.frecency > link.frecency; + } + + // Update a host Map with the better link + function setBetterLink(map, link, hostMatcher, combiner = () => {}) { + const host = hostMatcher(link.url)[1]; + if (map.has(host)) { + const other = map.get(host); + if (isOtherBetter(link, other)) { + link = other; + } + combiner(link, other); + } + map.set(host, link); + } + + // Convert all links that are supposed to be a seach shortcut to its canonical URL + if ( + didSuccessfulImport && + Services.prefs.getBoolPref( + `browser.newtabpage.activity-stream.${searchShortcuts.SEARCH_SHORTCUTS_EXPERIMENT}` + ) + ) { + links.forEach(link => { + let searchProvider = searchShortcuts.getSearchProvider( + shortURL.shortURL(link) + ); + if (searchProvider) { + link.url = searchProvider.url; + } + }); + } + + // Remove links that contain the hide-with search param. + if (options.hideWithSearchParam) { + let [key, value] = options.hideWithSearchParam.split("="); + links = links.filter(link => { + try { + let { searchParams } = new URL(link.url); + return value === undefined + ? !searchParams.has(key) + : !searchParams.getAll(key).includes(value); + } catch (error) {} + return true; + }); + } + + // Remove any blocked links. + if (!options.ignoreBlocked) { + links = links.filter(link => !BlockedLinks.isBlocked(link)); + } + + if (options.onePerDomain) { + // De-dup the links. + const exactHosts = new Map(); + for (const link of links) { + // First we want to find the best link for an exact host + setBetterLink(exactHosts, link, url => url.match(/:\/\/([^\/]+)/)); + } + + // Clean up exact hosts to dedupe as non-www hosts + const hosts = new Map(); + for (const link of exactHosts.values()) { + setBetterLink( + hosts, + link, + url => url.match(/:\/\/(?:www\.)?([^\/]+)/), + // Combine frecencies when deduping these links + (targetLink, otherLink) => { + targetLink.frecency = link.frecency + otherLink.frecency; + } + ); + } + + links = [...hosts.values()]; + } + // Pick out the top links using the same comparer as before + links = links.sort(isOtherBetter).slice(0, origNumItems); + + if (!options.includeFavicon) { + return links; + } + // Get the favicons as data URI for now (until we use the favicon protocol) + return this._faviconBytesToDataURI(await this._addFavicons(links)); + }, + + /** + * Gets a specific bookmark given some info about it + * + * @param {Obj} aInfo + * An object with one and only one of the following properties: + * - url + * - guid + * - parentGuid and index + */ + async getBookmark(aInfo) { + let bookmark = await lazy.PlacesUtils.bookmarks.fetch(aInfo); + if (!bookmark) { + return null; + } + let result = {}; + result.bookmarkGuid = bookmark.guid; + result.bookmarkTitle = bookmark.title; + result.lastModified = bookmark.lastModified.getTime(); + result.url = bookmark.url.href; + return result; + }, + + /** + * Count the number of visited urls grouped by day + */ + getUserMonthlyActivity() { + let sqlQuery = ` + SELECT count(*), + strftime('%Y-%m-%d', visit_date/1000000.0, 'unixepoch', 'localtime') as date_format + FROM moz_historyvisits + WHERE visit_date > 0 + AND visit_date > strftime('%s','now','localtime','start of day','-27 days','utc') * 1000000 + GROUP BY date_format + ORDER BY date_format ASC + `; + + return this.executePlacesQuery(sqlQuery); + }, + + /** + * Executes arbitrary query against places database + * + * @param {String} aQuery + * SQL query to execute + * @param {Object} [optional] aOptions + * aOptions.columns - an array of column names. if supplied the return + * items will consists of objects keyed on column names. Otherwise + * array of raw values is returned in the select order + * aOptions.param - an object of SQL binding parameters + * + * @returns {Promise} Returns a promise with the array of retrieved items + */ + async executePlacesQuery(aQuery, aOptions = {}) { + let { columns, params } = aOptions; + let items = []; + let queryError = null; + let conn = await lazy.PlacesUtils.promiseDBConnection(); + await conn.executeCached(aQuery, params, (aRow, aCancel) => { + try { + let item = null; + // if columns array is given construct an object + if (columns && Array.isArray(columns)) { + item = {}; + columns.forEach(column => { + item[column] = aRow.getResultByName(column); + }); + } else { + // if no columns - make an array of raw values + item = []; + for (let i = 0; i < aRow.numEntries; i++) { + item.push(aRow.getResultByIndex(i)); + } + } + items.push(item); + } catch (e) { + queryError = e; + aCancel(); + } + }); + if (queryError) { + throw new Error(queryError); + } + return items; + }, +}; + +/** + * A set of actions which influence what sites shown on the Activity Stream page + */ +var ActivityStreamLinks = { + _savedPocketStories: null, + _pocketLastUpdated: 0, + _pocketLastLatest: 0, + + /** + * Block a url + * + * @param {Object} aLink + * The link which contains a URL to add to the block list + */ + blockURL(aLink) { + BlockedLinks.block(aLink); + // If we're blocking a pocket item, invalidate the cache too + if (aLink.pocket_id) { + this._savedPocketStories = null; + } + }, + + onLinkBlocked(aLink) { + Services.obs.notifyObservers(null, "newtab-linkBlocked", aLink.url); + }, + + /** + * Adds a bookmark and opens up the Bookmark Dialog to show feedback that + * the bookmarking action has been successful + * + * @param {Object} aData + * aData.url The url to bookmark + * aData.title The title of the page to bookmark + * @param {Window} aBrowserWindow + * The current browser chrome window + * + * @returns {Promise} Returns a promise set to an object representing the bookmark + */ + addBookmark(aData, aBrowserWindow) { + const { url, title } = aData; + return aBrowserWindow.PlacesCommandHook.bookmarkLink(url, title); + }, + + /** + * Removes a bookmark + * + * @param {String} aBookmarkGuid + * The bookmark guid associated with the bookmark to remove + * + * @returns {Promise} Returns a promise at completion. + */ + deleteBookmark(aBookmarkGuid) { + return lazy.PlacesUtils.bookmarks.remove(aBookmarkGuid); + }, + + /** + * Removes a history link and unpins the URL if previously pinned + * + * @param {String} aUrl + * The url to be removed from history + * + * @returns {Promise} Returns a promise set to true if link was removed + */ + deleteHistoryEntry(aUrl) { + const url = aUrl; + PinnedLinks.unpin({ url }); + return lazy.PlacesUtils.history.remove(url); + }, + + /** + * Helper function which makes the call to the Pocket API to delete an item from + * a user's saved to Pocket feed. Also, invalidate the Pocket stories cache + * + * @param {Integer} aItemID + * The unique pocket ID used to find the item to be deleted + * + *@returns {Promise} Returns a promise at completion + */ + deletePocketEntry(aItemID) { + this._savedPocketStories = null; + return new Promise((success, error) => + lazy.pktApi.deleteItem(aItemID, { success, error }) + ); + }, + + /** + * Helper function which makes the call to the Pocket API to archive an item from + * a user's saved to Pocket feed. Also, invalidate the Pocket stories cache + * + * @param {Integer} aItemID + * The unique pocket ID used to find the item to be archived + * + *@returns {Promise} Returns a promise at completion + */ + archivePocketEntry(aItemID) { + this._savedPocketStories = null; + return new Promise((success, error) => + lazy.pktApi.archiveItem(aItemID, { success, error }) + ); + }, + + /** + * Helper function which makes the call to the Pocket API to save an item to + * a user's saved to Pocket feed if they are logged in. Also, invalidate the + * Pocket stories cache + * + * @param {String} aUrl + * The URL belonging to the story being saved + * @param {String} aTitle + * The title belonging to the story being saved + * @param {Browser} aBrowser + * The target browser to show the doorhanger in + * + *@returns {Promise} Returns a promise at completion + */ + addPocketEntry(aUrl, aTitle, aBrowser) { + // If the user is not logged in, show the panel to prompt them to log in + if (!lazy.pktApi.isUserLoggedIn()) { + lazy.Pocket.savePage(aBrowser, aUrl, aTitle); + return Promise.resolve(null); + } + + // If the user is logged in, just save the link to Pocket and Activity Stream + // will update the page + this._savedPocketStories = null; + return new Promise((success, error) => { + lazy.pktApi.addLink(aUrl, { + title: aTitle, + success, + error, + }); + }); + }, + + /** + * Get the Highlights links to show on Activity Stream + * + * @param {Object} aOptions + * {bool} excludeBookmarks: Don't add bookmark items. + * {bool} excludeHistory: Don't add history items. + * {bool} excludePocket: Don't add Pocket items. + * {bool} withFavicons: Add favicon data: URIs, when possible. + * {int} numItems: Maximum number of (bookmark or history) items to return. + * + * @return {Promise} Returns a promise with the array of links as the payload + */ + async getHighlights(aOptions = {}) { + aOptions.numItems = aOptions.numItems || ACTIVITY_STREAM_DEFAULT_LIMIT; + const results = []; + + // First get bookmarks if we want them + if (!aOptions.excludeBookmarks) { + results.push( + ...(await ActivityStreamProvider.getRecentBookmarks(aOptions)) + ); + } + + // Add the Pocket items if we need more and want them + if (aOptions.numItems - results.length > 0 && !aOptions.excludePocket) { + const latestSince = ~~Services.prefs.getStringPref( + PREF_POCKET_LATEST_SINCE, + 0 + ); + // Invalidate the cache, get new stories, and update timestamps if: + // 1. we do not have saved to Pocket stories already cached OR + // 2. it has been too long since we last got Pocket stories OR + // 3. there has been a paged saved to pocket since we last got new stories + if ( + !this._savedPocketStories || + Date.now() - this._pocketLastUpdated > POCKET_UPDATE_TIME || + this._pocketLastLatest < latestSince + ) { + this._savedPocketStories = + await ActivityStreamProvider.getRecentlyPocketed(aOptions); + this._pocketLastUpdated = Date.now(); + this._pocketLastLatest = latestSince; + } + results.push(...this._savedPocketStories); + } + + // Add in history if we need more and want them + if (aOptions.numItems - results.length > 0 && !aOptions.excludeHistory) { + // Use the same numItems as bookmarks above in case we remove duplicates + const history = await ActivityStreamProvider.getRecentHistory(aOptions); + + // Only include a url once in the result preferring the bookmark + const bookmarkUrls = new Set(results.map(({ url }) => url)); + for (const page of history) { + if (!bookmarkUrls.has(page.url)) { + results.push(page); + + // Stop adding pages once we reach the desired maximum + if (results.length === aOptions.numItems) { + break; + } + } + } + } + + if (aOptions.withFavicons) { + return ActivityStreamProvider._faviconBytesToDataURI( + await ActivityStreamProvider._addFavicons(results) + ); + } + + return results; + }, + + /** + * Get the top sites to show on Activity Stream + * + * @return {Promise} Returns a promise with the array of links as the payload + */ + async getTopSites(aOptions = {}) { + return ActivityStreamProvider.getTopFrecentSites(aOptions); + }, +}; + +/** + * Singleton that provides access to all links contained in the grid (including + * the ones that don't fit on the grid). A link is a plain object that looks + * like this: + * + * { + * url: "http://www.mozilla.org/", + * title: "Mozilla", + * frecency: 1337, + * lastVisitDate: 1394678824766431, + * } + */ +var Links = { + /** + * The maximum number of links returned by getLinks. + */ + maxNumLinks: LINKS_GET_LINKS_LIMIT, + + /** + * A mapping from each provider to an object { sortedLinks, siteMap, linkMap }. + * sortedLinks is the cached, sorted array of links for the provider. + * siteMap is a mapping from base domains to URL count associated with the domain. + * The count does not include blocked URLs. siteMap is used to look up a + * user's top sites that can be targeted with a suggested tile. + * linkMap is a Map from link URLs to link objects. + */ + _providers: new Map(), + + /** + * The properties of link objects used to sort them. + */ + _sortProperties: ["frecency", "lastVisitDate", "url"], + + /** + * List of callbacks waiting for the cache to be populated. + */ + _populateCallbacks: [], + + /** + * A list of objects that are observing links updates. + */ + _observers: [], + + /** + * Registers an object that will be notified when links updates. + */ + addObserver(aObserver) { + this._observers.push(aObserver); + }, + + /** + * Adds a link provider. + * @param aProvider The link provider. + */ + addProvider: function Links_addProvider(aProvider) { + this._providers.set(aProvider, null); + aProvider.addObserver(this); + }, + + /** + * Removes a link provider. + * @param aProvider The link provider. + */ + removeProvider: function Links_removeProvider(aProvider) { + if (!this._providers.delete(aProvider)) { + throw new Error("Unknown provider"); + } + }, + + /** + * Populates the cache with fresh links from the providers. + * @param aCallback The callback to call when finished (optional). + * @param aForce When true, populates the cache even when it's already filled. + */ + populateCache: function Links_populateCache(aCallback, aForce) { + let callbacks = this._populateCallbacks; + + // Enqueue the current callback. + callbacks.push(aCallback); + + // There was a callback waiting already, thus the cache has not yet been + // populated. + if (callbacks.length > 1) { + return; + } + + function executeCallbacks() { + while (callbacks.length) { + let callback = callbacks.shift(); + if (callback) { + try { + callback(); + } catch (e) { + // We want to proceed even if a callback fails. + } + } + } + } + + let numProvidersRemaining = this._providers.size; + for (let [provider /* , links */] of this._providers) { + this._populateProviderCache( + provider, + () => { + if (--numProvidersRemaining == 0) { + executeCallbacks(); + } + }, + aForce + ); + } + + this._addObserver(); + }, + + /** + * Gets the current set of links contained in the grid. + * @return The links in the grid. + */ + getLinks: function Links_getLinks() { + let pinnedLinks = Array.from(PinnedLinks.links); + let links = this._getMergedProviderLinks(); + + let sites = new Set(); + for (let link of pinnedLinks) { + if (link) { + sites.add(NewTabUtils.extractSite(link.url)); + } + } + + // Filter blocked and pinned links and duplicate base domains. + links = links.filter(function (link) { + let site = NewTabUtils.extractSite(link.url); + if (site == null || sites.has(site)) { + return false; + } + sites.add(site); + + return !BlockedLinks.isBlocked(link) && !PinnedLinks.isPinned(link); + }); + + // Try to fill the gaps between pinned links. + for (let i = 0; i < pinnedLinks.length && links.length; i++) { + if (!pinnedLinks[i]) { + pinnedLinks[i] = links.shift(); + } + } + + // Append the remaining links if any. + if (links.length) { + pinnedLinks = pinnedLinks.concat(links); + } + + for (let link of pinnedLinks) { + if (link) { + link.baseDomain = NewTabUtils.extractSite(link.url); + } + } + return pinnedLinks; + }, + + /** + * Resets the links cache. + */ + resetCache: function Links_resetCache() { + for (let provider of this._providers.keys()) { + this._providers.set(provider, null); + } + }, + + /** + * Compares two links. + * @param aLink1 The first link. + * @param aLink2 The second link. + * @return A negative number if aLink1 is ordered before aLink2, zero if + * aLink1 and aLink2 have the same ordering, or a positive number if + * aLink1 is ordered after aLink2. + * + * @note compareLinks's this object is bound to Links below. + */ + compareLinks: function Links_compareLinks(aLink1, aLink2) { + for (let prop of this._sortProperties) { + if (!(prop in aLink1) || !(prop in aLink2)) { + throw new Error("Comparable link missing required property: " + prop); + } + } + return ( + aLink2.frecency - aLink1.frecency || + aLink2.lastVisitDate - aLink1.lastVisitDate || + aLink1.url.localeCompare(aLink2.url) + ); + }, + + _incrementSiteMap(map, link) { + if (NewTabUtils.blockedLinks.isBlocked(link)) { + // Don't count blocked URLs. + return; + } + let site = NewTabUtils.extractSite(link.url); + map.set(site, (map.get(site) || 0) + 1); + }, + + _decrementSiteMap(map, link) { + if (NewTabUtils.blockedLinks.isBlocked(link)) { + // Blocked URLs are not included in map. + return; + } + let site = NewTabUtils.extractSite(link.url); + let previousURLCount = map.get(site); + if (previousURLCount === 1) { + map.delete(site); + } else { + map.set(site, previousURLCount - 1); + } + }, + + /** + * Update the siteMap cache based on the link given and whether we need + * to increment or decrement it. We do this by iterating over all stored providers + * to find which provider this link already exists in. For providers that + * have this link, we will adjust siteMap for them accordingly. + * + * @param aLink The link that will affect siteMap + * @param increment A boolean for whether to increment or decrement siteMap + */ + _adjustSiteMapAndNotify(aLink, increment = true) { + for (let [, /* provider */ cache] of this._providers) { + // We only update siteMap if aLink is already stored in linkMap. + if (cache.linkMap.get(aLink.url)) { + if (increment) { + this._incrementSiteMap(cache.siteMap, aLink); + continue; + } + this._decrementSiteMap(cache.siteMap, aLink); + } + } + this._callObservers("onLinkChanged", aLink); + }, + + onLinkBlocked(aLink) { + this._adjustSiteMapAndNotify(aLink, false); + }, + + onLinkUnblocked(aLink) { + this._adjustSiteMapAndNotify(aLink); + }, + + populateProviderCache(provider, callback) { + if (!this._providers.has(provider)) { + throw new Error( + "Can only populate provider cache for existing provider." + ); + } + + return this._populateProviderCache(provider, callback, false); + }, + + /** + * Calls getLinks on the given provider and populates our cache for it. + * @param aProvider The provider whose cache will be populated. + * @param aCallback The callback to call when finished. + * @param aForce When true, populates the provider's cache even when it's + * already filled. + */ + _populateProviderCache(aProvider, aCallback, aForce) { + let cache = this._providers.get(aProvider); + let createCache = !cache; + if (createCache) { + cache = { + // Start with a resolved promise. + populatePromise: new Promise(resolve => resolve()), + }; + this._providers.set(aProvider, cache); + } + // Chain the populatePromise so that calls are effectively queued. + cache.populatePromise = cache.populatePromise.then(() => { + return new Promise(resolve => { + if (!createCache && !aForce) { + aCallback(); + resolve(); + return; + } + aProvider.getLinks(links => { + // Filter out null and undefined links so we don't have to deal with + // them in getLinks when merging links from providers. + links = links.filter(link => !!link); + cache.sortedLinks = links; + cache.siteMap = links.reduce((map, link) => { + this._incrementSiteMap(map, link); + return map; + }, new Map()); + cache.linkMap = links.reduce((map, link) => { + map.set(link.url, link); + return map; + }, new Map()); + aCallback(); + resolve(); + }); + }); + }); + }, + + /** + * Merges the cached lists of links from all providers whose lists are cached. + * @return The merged list. + */ + _getMergedProviderLinks: function Links__getMergedProviderLinks() { + // Build a list containing a copy of each provider's sortedLinks list. + let linkLists = []; + for (let provider of this._providers.keys()) { + let links = this._providers.get(provider); + if (links && links.sortedLinks) { + linkLists.push(links.sortedLinks.slice()); + } + } + + return this.mergeLinkLists(linkLists); + }, + + mergeLinkLists: function Links_mergeLinkLists(linkLists) { + if (linkLists.length == 1) { + return linkLists[0]; + } + + function getNextLink() { + let minLinks = null; + for (let links of linkLists) { + if ( + links.length && + (!minLinks || Links.compareLinks(links[0], minLinks[0]) < 0) + ) { + minLinks = links; + } + } + return minLinks ? minLinks.shift() : null; + } + + let finalLinks = []; + for ( + let nextLink = getNextLink(); + nextLink && finalLinks.length < this.maxNumLinks; + nextLink = getNextLink() + ) { + finalLinks.push(nextLink); + } + + return finalLinks; + }, + + /** + * Called by a provider to notify us when a single link changes. + * @param aProvider The provider whose link changed. + * @param aLink The link that changed. If the link is new, it must have all + * of the _sortProperties. Otherwise, it may have as few or as + * many as is convenient. + * @param aIndex The current index of the changed link in the sortedLinks + cache in _providers. Defaults to -1 if the provider doesn't know the index + * @param aDeleted Boolean indicating if the provider has deleted the link. + */ + onLinkChanged: function Links_onLinkChanged( + aProvider, + aLink, + aIndex = -1, + aDeleted = false + ) { + if (!("url" in aLink)) { + throw new Error("Changed links must have a url property"); + } + + let links = this._providers.get(aProvider); + if (!links) { + // This is not an error, it just means that between the time the provider + // was added and the future time we call getLinks on it, it notified us of + // a change. + return; + } + + let { sortedLinks, siteMap, linkMap } = links; + let existingLink = linkMap.get(aLink.url); + let insertionLink = null; + let updatePages = false; + + if (existingLink) { + // Update our copy's position in O(lg n) by first removing it from its + // list. It's important to do this before modifying its properties. + if (this._sortProperties.some(prop => prop in aLink)) { + let idx = aIndex; + if (idx < 0) { + idx = this._indexOf(sortedLinks, existingLink); + } else if (this.compareLinks(aLink, sortedLinks[idx]) != 0) { + throw new Error("aLink should be the same as sortedLinks[idx]"); + } + + if (idx < 0) { + throw new Error("Link should be in _sortedLinks if in _linkMap"); + } + sortedLinks.splice(idx, 1); + + if (aDeleted) { + updatePages = true; + linkMap.delete(existingLink.url); + this._decrementSiteMap(siteMap, existingLink); + } else { + // Update our copy's properties. + Object.assign(existingLink, aLink); + + // Finally, reinsert our copy below. + insertionLink = existingLink; + } + } + // Update our copy's title in O(1). + if ("title" in aLink && aLink.title != existingLink.title) { + existingLink.title = aLink.title; + updatePages = true; + } + } else if (this._sortProperties.every(prop => prop in aLink)) { + // Before doing the O(lg n) insertion below, do an O(1) check for the + // common case where the new link is too low-ranked to be in the list. + if (sortedLinks.length && sortedLinks.length == aProvider.maxNumLinks) { + let lastLink = sortedLinks[sortedLinks.length - 1]; + if (this.compareLinks(lastLink, aLink) < 0) { + return; + } + } + // Copy the link object so that changes later made to it by the caller + // don't affect our copy. + insertionLink = {}; + for (let prop in aLink) { + insertionLink[prop] = aLink[prop]; + } + linkMap.set(aLink.url, insertionLink); + this._incrementSiteMap(siteMap, aLink); + } + + if (insertionLink) { + let idx = this._insertionIndexOf(sortedLinks, insertionLink); + sortedLinks.splice(idx, 0, insertionLink); + if (sortedLinks.length > aProvider.maxNumLinks) { + let lastLink = sortedLinks.pop(); + linkMap.delete(lastLink.url); + this._decrementSiteMap(siteMap, lastLink); + } + updatePages = true; + } + + if (updatePages) { + AllPages.update(null, "links-changed"); + } + }, + + /** + * Called by a provider to notify us when many links change. + */ + onManyLinksChanged: function Links_onManyLinksChanged(aProvider) { + this._populateProviderCache( + aProvider, + () => { + AllPages.update(null, "links-changed"); + }, + true + ); + }, + + _indexOf: function Links__indexOf(aArray, aLink) { + return this._binsearch(aArray, aLink, "indexOf"); + }, + + _insertionIndexOf: function Links__insertionIndexOf(aArray, aLink) { + return this._binsearch(aArray, aLink, "insertionIndexOf"); + }, + + _binsearch: function Links__binsearch(aArray, aLink, aMethod) { + return lazy.BinarySearch[aMethod](this.compareLinks, aArray, aLink); + }, + + /** + * Implements the nsIObserver interface to get notified about browser history + * sanitization. + */ + observe: function Links_observe(aSubject, aTopic, aData) { + // Make sure to update open about:newtab instances. If there are no opened + // pages we can just wait for the next new tab to populate the cache again. + if (AllPages.length && AllPages.enabled) { + this.populateCache(function () { + AllPages.update(); + }, true); + } else { + this.resetCache(); + } + }, + + _callObservers(methodName, ...args) { + for (let obs of this._observers) { + if (typeof obs[methodName] == "function") { + try { + obs[methodName](this, ...args); + } catch (err) { + console.error(err); + } + } + } + }, + + /** + * Adds a sanitization observer and turns itself into a no-op after the first + * invokation. + */ + _addObserver: function Links_addObserver() { + Services.obs.addObserver(this, "browser:purge-session-history", true); + this._addObserver = function () {}; + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), +}; + +Links.compareLinks = Links.compareLinks.bind(Links); + +/** + * Singleton used to collect telemetry data. + * + */ +var Telemetry = { + /** + * Initializes object. + */ + init: function Telemetry_init() { + Services.obs.addObserver(this, TOPIC_GATHER_TELEMETRY); + }, + + uninit: function Telemetry_uninit() { + Services.obs.removeObserver(this, TOPIC_GATHER_TELEMETRY); + }, + + /** + * Collects data. + */ + _collect: function Telemetry_collect() { + let probes = [ + { histogram: "NEWTAB_PAGE_ENABLED", value: AllPages.enabled }, + { + histogram: "NEWTAB_PAGE_PINNED_SITES_COUNT", + value: PinnedLinks.links.length, + }, + { + histogram: "NEWTAB_PAGE_BLOCKED_SITES_COUNT", + value: Object.keys(BlockedLinks.links).length, + }, + ]; + + probes.forEach(function Telemetry_collect_forEach(aProbe) { + Services.telemetry.getHistogramById(aProbe.histogram).add(aProbe.value); + }); + }, + + /** + * Listens for gather telemetry topic. + */ + observe: function Telemetry_observe(aSubject, aTopic, aData) { + this._collect(); + }, +}; + +/** + * Singleton that checks if a given link should be displayed on about:newtab + * or if we should rather not do it for security reasons. URIs that inherit + * their caller's principal will be filtered. + */ +var LinkChecker = { + _cache: {}, + + get flags() { + return ( + Ci.nsIScriptSecurityManager.DISALLOW_INHERIT_PRINCIPAL | + Ci.nsIScriptSecurityManager.DONT_REPORT_ERRORS + ); + }, + + checkLoadURI: function LinkChecker_checkLoadURI(aURI) { + if (!(aURI in this._cache)) { + this._cache[aURI] = this._doCheckLoadURI(aURI); + } + + return this._cache[aURI]; + }, + + _doCheckLoadURI: function Links_doCheckLoadURI(aURI) { + try { + // about:newtab is currently privileged. In any case, it should be + // possible for tiles to point to pretty much everything - but not + // to stuff that inherits the system principal, so we check: + let systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal(); + Services.scriptSecurityManager.checkLoadURIStrWithPrincipal( + systemPrincipal, + aURI, + this.flags + ); + return true; + } catch (e) { + // We got a weird URI or one that would inherit the caller's principal. + return false; + } + }, +}; + +var ExpirationFilter = { + init: function ExpirationFilter_init() { + lazy.PageThumbs.addExpirationFilter(this); + }, + + filterForThumbnailExpiration: + function ExpirationFilter_filterForThumbnailExpiration(aCallback) { + if (!AllPages.enabled) { + aCallback([]); + return; + } + + Links.populateCache(function () { + let urls = []; + + // Add all URLs to the list that we want to keep thumbnails for. + for (let link of Links.getLinks().slice(0, 25)) { + if (link && link.url) { + urls.push(link.url); + } + } + + aCallback(urls); + }); + }, +}; + +/** + * Singleton that provides the public API of this JSM. + */ +export var NewTabUtils = { + _initialized: false, + + /** + * Extract a "site" from a url in a way that multiple urls of a "site" returns + * the same "site." + * @param aUrl Url spec string + * @return The "site" string or null + */ + extractSite: function Links_extractSite(url) { + let host; + try { + // Note that nsIURI.asciiHost throws NS_ERROR_FAILURE for some types of + // URIs, including jar and moz-icon URIs. + host = Services.io.newURI(url).asciiHost; + } catch (ex) { + return null; + } + + // Strip off common subdomains of the same site (e.g., www, load balancer) + return host.replace(/^(m|mobile|www\d*)\./, ""); + }, + + init: function NewTabUtils_init() { + if (this.initWithoutProviders()) { + PlacesProvider.init(); + Links.addProvider(PlacesProvider); + BlockedLinks.addObserver(Links); + BlockedLinks.addObserver(ActivityStreamLinks); + } + }, + + initWithoutProviders: function NewTabUtils_initWithoutProviders() { + if (!this._initialized) { + this._initialized = true; + ExpirationFilter.init(); + Telemetry.init(); + return true; + } + return false; + }, + + uninit: function NewTabUtils_uninit() { + if (this.initialized) { + Telemetry.uninit(); + BlockedLinks.removeObservers(); + } + }, + + getProviderLinks(aProvider) { + let cache = Links._providers.get(aProvider); + if (cache && cache.sortedLinks) { + return cache.sortedLinks; + } + return []; + }, + + isTopSiteGivenProvider(aSite, aProvider) { + let cache = Links._providers.get(aProvider); + if (cache && cache.siteMap) { + return cache.siteMap.has(aSite); + } + return false; + }, + + isTopPlacesSite(aSite) { + return this.isTopSiteGivenProvider(aSite, PlacesProvider); + }, + + /** + * Restores all sites that have been removed from the grid. + */ + restore: function NewTabUtils_restore() { + lazy.Storage.clear(); + Links.resetCache(); + PinnedLinks.resetCache(); + BlockedLinks.resetCache(); + + Links.populateCache(function () { + AllPages.update(); + }, true); + }, + + /** + * Undoes all sites that have been removed from the grid and keep the pinned + * tabs. + * @param aCallback the callback method. + */ + undoAll: function NewTabUtils_undoAll(aCallback) { + lazy.Storage.remove("blockedLinks"); + Links.resetCache(); + BlockedLinks.resetCache(); + Links.populateCache(aCallback, true); + }, + + links: Links, + allPages: AllPages, + pinnedLinks: PinnedLinks, + blockedLinks: BlockedLinks, + activityStreamLinks: ActivityStreamLinks, + activityStreamProvider: ActivityStreamProvider, +}; |