/* 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 { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); const { actionCreators: ac, actionTypes: at } = ChromeUtils.import( "resource://activity-stream/common/Actions.jsm" ); ChromeUtils.defineModuleGetter( this, "NewTabUtils", "resource://gre/modules/NewTabUtils.jsm" ); ChromeUtils.defineModuleGetter( this, "PartnerLinkAttribution", "resource:///modules/PartnerLinkAttribution.jsm" ); ChromeUtils.defineModuleGetter( this, "PlacesUtils", "resource://gre/modules/PlacesUtils.jsm" ); ChromeUtils.defineModuleGetter( this, "PrivateBrowsingUtils", "resource://gre/modules/PrivateBrowsingUtils.jsm" ); const LINK_BLOCKED_EVENT = "newtab-linkBlocked"; const PLACES_LINKS_CHANGED_DELAY_TIME = 1000; // time in ms to delay timer for places links changed events /** * Observer - a wrapper around history/bookmark observers to add the QueryInterface. */ class Observer { constructor(dispatch, observerInterface) { this.dispatch = dispatch; this.QueryInterface = ChromeUtils.generateQI([ observerInterface, "nsISupportsWeakReference", ]); } } /** * HistoryObserver - observes events from PlacesUtils.history */ class HistoryObserver extends Observer { constructor(dispatch) { super(dispatch, Ci.nsINavHistoryObserver); } /** * onDeleteURI - Called when an link is deleted from history. * * @param {obj} uri A URI object representing the link's url * {str} uri.spec The URI as a string */ onDeleteURI(uri) { this.dispatch({ type: at.PLACES_LINKS_CHANGED }); this.dispatch({ type: at.PLACES_LINK_DELETED, data: { url: uri.spec }, }); } // Empty functions to make xpconnect happy onBeginUpdateBatch() {} onEndUpdateBatch() {} onDeleteVisits() {} } /** * BookmarksObserver - observes events from PlacesUtils.bookmarks */ class BookmarksObserver extends Observer { constructor(dispatch) { super(dispatch, Ci.nsINavBookmarkObserver); this.skipTags = true; } // Empty functions to make xpconnect happy onBeginUpdateBatch() {} onEndUpdateBatch() {} onItemMoved() {} // Disabled due to performance cost, see Issue 3203 / // https://bugzilla.mozilla.org/show_bug.cgi?id=1392267. onItemChanged() {} } /** * PlacesObserver - observes events from PlacesUtils.observers */ class PlacesObserver extends Observer { constructor(dispatch) { super(dispatch, Ci.nsINavBookmarkObserver); this.handlePlacesEvent = this.handlePlacesEvent.bind(this); } handlePlacesEvent(events) { for (const { itemType, source, dateAdded, guid, title, url, isTagging, type, } of events) { switch (type) { case "history-cleared": this.dispatch({ type: at.PLACES_HISTORY_CLEARED }); break; case "bookmark-added": // Skips items that are not bookmarks (like folders), about:* pages or // default bookmarks, added when the profile is created. if ( isTagging || itemType !== PlacesUtils.bookmarks.TYPE_BOOKMARK || source === PlacesUtils.bookmarks.SOURCES.IMPORT || source === PlacesUtils.bookmarks.SOURCES.RESTORE || source === PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP || source === PlacesUtils.bookmarks.SOURCES.SYNC || (!url.startsWith("http://") && !url.startsWith("https://")) ) { return; } this.dispatch({ type: at.PLACES_LINKS_CHANGED }); this.dispatch({ type: at.PLACES_BOOKMARK_ADDED, data: { bookmarkGuid: guid, bookmarkTitle: title, dateAdded: dateAdded * 1000, url, }, }); break; case "bookmark-removed": if ( isTagging || (itemType === PlacesUtils.bookmarks.TYPE_BOOKMARK && source !== PlacesUtils.bookmarks.SOURCES.IMPORT && source !== PlacesUtils.bookmarks.SOURCES.RESTORE && source !== PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP && source !== PlacesUtils.bookmarks.SOURCES.SYNC) ) { this.dispatch({ type: at.PLACES_LINKS_CHANGED }); this.dispatch({ type: at.PLACES_BOOKMARK_REMOVED, data: { url, bookmarkGuid: guid }, }); } break; } } } } class PlacesFeed { constructor() { this.placesChangedTimer = null; this.customDispatch = this.customDispatch.bind(this); this.historyObserver = new HistoryObserver(this.customDispatch); this.bookmarksObserver = new BookmarksObserver(this.customDispatch); this.placesObserver = new PlacesObserver(this.customDispatch); } addObservers() { // NB: Directly get services without importing the *BIG* PlacesUtils module Cc["@mozilla.org/browser/nav-history-service;1"] .getService(Ci.nsINavHistoryService) .addObserver(this.historyObserver, true); Cc["@mozilla.org/browser/nav-bookmarks-service;1"] .getService(Ci.nsINavBookmarksService) .addObserver(this.bookmarksObserver, true); PlacesUtils.observers.addListener( ["bookmark-added", "bookmark-removed", "history-cleared"], this.placesObserver.handlePlacesEvent ); Services.obs.addObserver(this, LINK_BLOCKED_EVENT); } /** * setTimeout - A custom function that creates an nsITimer that can be cancelled * * @param {func} callback A function to be executed after the timer expires * @param {int} delay The time (in ms) the timer should wait before the function is executed */ setTimeout(callback, delay) { let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); timer.initWithCallback(callback, delay, Ci.nsITimer.TYPE_ONE_SHOT); return timer; } customDispatch(action) { // If we are changing many links at once, delay this action and only dispatch // one action at the end if (action.type === at.PLACES_LINKS_CHANGED) { if (this.placesChangedTimer) { this.placesChangedTimer.delay = PLACES_LINKS_CHANGED_DELAY_TIME; } else { this.placesChangedTimer = this.setTimeout(() => { this.placesChangedTimer = null; this.store.dispatch(ac.OnlyToMain(action)); }, PLACES_LINKS_CHANGED_DELAY_TIME); } } else { this.store.dispatch(ac.BroadcastToContent(action)); } } removeObservers() { if (this.placesChangedTimer) { this.placesChangedTimer.cancel(); this.placesChangedTimer = null; } PlacesUtils.history.removeObserver(this.historyObserver); PlacesUtils.bookmarks.removeObserver(this.bookmarksObserver); PlacesUtils.observers.removeListener( ["bookmark-added", "bookmark-removed", "history-cleared"], this.placesObserver.handlePlacesEvent ); Services.obs.removeObserver(this, LINK_BLOCKED_EVENT); } /** * observe - An observer for the LINK_BLOCKED_EVENT. * Called when a link is blocked. * Links can be blocked outside of newtab, * which is why we need to listen to this * on such a generic level. * * @param {null} subject * @param {str} topic The name of the event * @param {str} value The data associated with the event */ observe(subject, topic, value) { if (topic === LINK_BLOCKED_EVENT) { this.store.dispatch( ac.BroadcastToContent({ type: at.PLACES_LINK_BLOCKED, data: { url: value }, }) ); } } /** * Open a link in a desired destination defaulting to action's event. */ openLink(action, where = "", isPrivate = false) { const params = { private: isPrivate, targetBrowser: action._target.browser, fromChrome: false, // This ensure we maintain user preference for how to open new tabs. }; // Always include the referrer (even for http links) if we have one const { event, referrer, typedBonus } = action.data; if (referrer) { const ReferrerInfo = Components.Constructor( "@mozilla.org/referrer-info;1", "nsIReferrerInfo", "init" ); params.referrerInfo = new ReferrerInfo( Ci.nsIReferrerInfo.UNSAFE_URL, true, Services.io.newURI(referrer) ); } // Pocket gives us a special reader URL to open their stories in const urlToOpen = action.data.type === "pocket" ? action.data.open_url : action.data.url; try { let uri = Services.io.newURI(urlToOpen); if (!["http", "https"].includes(uri.scheme)) { throw new Error( `Can't open link using ${uri.scheme} protocol from the new tab page.` ); } } catch (e) { Cu.reportError(e); return; } // Mark the page as typed for frecency bonus before opening the link if (typedBonus) { PlacesUtils.history.markPageAsTyped(Services.io.newURI(urlToOpen)); } const win = action._target.browser.ownerGlobal; win.openTrustedLinkIn( urlToOpen, where || win.whereToOpenLink(event), params ); // If there's an original URL e.g. using the unprocessed %YYYYMMDDHH% tag, // add a visit for that so it may become a frecent top site. if (action.data.original_url) { PlacesUtils.history.insert({ url: action.data.original_url, visits: [{ transition: PlacesUtils.history.TRANSITION_TYPED }], }); } } async saveToPocket(site, browser) { const { url, title } = site; try { let data = await NewTabUtils.activityStreamLinks.addPocketEntry( url, title, browser ); if (data) { this.store.dispatch( ac.BroadcastToContent({ type: at.PLACES_SAVED_TO_POCKET, data: { url, open_url: data.item.open_url, title, pocket_id: data.item.item_id, }, }) ); } } catch (err) { Cu.reportError(err); } } /** * Deletes an item from a user's saved to Pocket feed * @param {int} itemID * The unique ID given by Pocket for that item; used to look the item up when deleting */ async deleteFromPocket(itemID) { try { await NewTabUtils.activityStreamLinks.deletePocketEntry(itemID); this.store.dispatch({ type: at.POCKET_LINK_DELETED_OR_ARCHIVED }); } catch (err) { Cu.reportError(err); } } /** * Archives an item from a user's saved to Pocket feed * @param {int} itemID * The unique ID given by Pocket for that item; used to look the item up when archiving */ async archiveFromPocket(itemID) { try { await NewTabUtils.activityStreamLinks.archivePocketEntry(itemID); this.store.dispatch({ type: at.POCKET_LINK_DELETED_OR_ARCHIVED }); } catch (err) { Cu.reportError(err); } } /** * Sends an attribution request for Top Sites interactions. * @param {object} data * Attribution paramters from a Top Site. */ makeAttributionRequest(data) { let args = Object.assign( { campaignID: Services.prefs.getStringPref( "browser.partnerlink.campaign.topsites" ), }, data ); PartnerLinkAttribution.makeRequest(args); } async fillSearchTopSiteTerm({ _target, data }) { const searchEngine = await Services.search.getEngineByAlias(data.label); _target.browser.ownerGlobal.gURLBar.search(data.label, { searchEngine, searchModeEntry: "topsites_newtab", }); } _getDefaultSearchEngine(isPrivateWindow) { return Services.search[ isPrivateWindow ? "defaultPrivateEngine" : "defaultEngine" ]; } _getSearchPrefix(searchEngine) { const searchAliases = searchEngine.aliases; if (searchAliases && searchAliases.length) { return `${searchAliases[0]} `; } return ""; } handoffSearchToAwesomebar({ _target, data, meta }) { const searchEngine = this._getDefaultSearchEngine( PrivateBrowsingUtils.isBrowserPrivate(_target.browser) ); const searchAlias = this._getSearchPrefix(searchEngine); const urlBar = _target.browser.ownerGlobal.gURLBar; let isFirstChange = true; if (!data || !data.text) { urlBar.setHiddenFocus(); } else { urlBar.search(searchAlias + data.text, { searchEngine, searchModeEntry: "handoff", }); isFirstChange = false; } const checkFirstChange = () => { // Check if this is the first change since we hidden focused. If it is, // remove hidden focus styles, prepend the search alias and hide the // in-content search. if (isFirstChange) { isFirstChange = false; urlBar.removeHiddenFocus(); urlBar.search(searchAlias, { searchEngine, searchModeEntry: "handoff", }); this.store.dispatch( ac.OnlyToOneContent({ type: at.HIDE_SEARCH }, meta.fromTarget) ); urlBar.removeEventListener("compositionstart", checkFirstChange); urlBar.removeEventListener("paste", checkFirstChange); } }; const onKeydown = ev => { // Check if the keydown will cause a value change. if (ev.key.length === 1 && !ev.altKey && !ev.ctrlKey && !ev.metaKey) { checkFirstChange(); } // If the Esc button is pressed, we are done. Show in-content search and cleanup. if (ev.key === "Escape") { onDone(); // eslint-disable-line no-use-before-define } }; const onDone = () => { // We are done. Show in-content search again and cleanup. this.store.dispatch( ac.OnlyToOneContent({ type: at.SHOW_SEARCH }, meta.fromTarget) ); urlBar.removeHiddenFocus(); urlBar.removeEventListener("keydown", onKeydown); urlBar.removeEventListener("mousedown", onDone); urlBar.removeEventListener("blur", onDone); urlBar.removeEventListener("compositionstart", checkFirstChange); urlBar.removeEventListener("paste", checkFirstChange); }; urlBar.addEventListener("keydown", onKeydown); urlBar.addEventListener("mousedown", onDone); urlBar.addEventListener("blur", onDone); urlBar.addEventListener("compositionstart", checkFirstChange); urlBar.addEventListener("paste", checkFirstChange); } onAction(action) { switch (action.type) { case at.INIT: // Briefly avoid loading services for observing for better startup timing Services.tm.dispatchToMainThread(() => this.addObservers()); break; case at.UNINIT: this.removeObservers(); break; case at.ABOUT_SPONSORED_TOP_SITES: { const url = `${Services.urlFormatter.formatURLPref( "app.support.baseURL" )}sponsor-privacy`; const win = action._target.browser.ownerGlobal; win.openTrustedLinkIn(url, "tab"); break; } case at.BLOCK_URL: { if (action.data) { action.data.forEach(site => { const { url, pocket_id } = site; NewTabUtils.activityStreamLinks.blockURL({ url, pocket_id }); }); } break; } case at.BOOKMARK_URL: NewTabUtils.activityStreamLinks.addBookmark( action.data, action._target.browser.ownerGlobal ); break; case at.DELETE_BOOKMARK_BY_ID: NewTabUtils.activityStreamLinks.deleteBookmark(action.data); break; case at.DELETE_HISTORY_URL: { const { url, forceBlock, pocket_id } = action.data; NewTabUtils.activityStreamLinks.deleteHistoryEntry(url); if (forceBlock) { NewTabUtils.activityStreamLinks.blockURL({ url, pocket_id }); } break; } case at.OPEN_NEW_WINDOW: this.openLink(action, "window"); break; case at.OPEN_PRIVATE_WINDOW: this.openLink(action, "window", true); break; case at.SAVE_TO_POCKET: this.saveToPocket(action.data.site, action._target.browser); break; case at.DELETE_FROM_POCKET: this.deleteFromPocket(action.data.pocket_id); break; case at.ARCHIVE_FROM_POCKET: this.archiveFromPocket(action.data.pocket_id); break; case at.FILL_SEARCH_TERM: this.fillSearchTopSiteTerm(action); break; case at.HANDOFF_SEARCH_TO_AWESOMEBAR: this.handoffSearchToAwesomebar(action); break; case at.OPEN_LINK: { this.openLink(action); break; } case at.PARTNER_LINK_ATTRIBUTION: this.makeAttributionRequest(action.data); break; } } } this.PlacesFeed = PlacesFeed; // Exported for testing only PlacesFeed.HistoryObserver = HistoryObserver; PlacesFeed.BookmarksObserver = BookmarksObserver; PlacesFeed.PlacesObserver = PlacesObserver; const EXPORTED_SYMBOLS = ["PlacesFeed"];