diff options
Diffstat (limited to 'browser/components/newtab/lib/PlacesFeed.jsm')
-rw-r--r-- | browser/components/newtab/lib/PlacesFeed.jsm | 568 |
1 files changed, 568 insertions, 0 deletions
diff --git a/browser/components/newtab/lib/PlacesFeed.jsm b/browser/components/newtab/lib/PlacesFeed.jsm new file mode 100644 index 0000000000..e76fc78000 --- /dev/null +++ b/browser/components/newtab/lib/PlacesFeed.jsm @@ -0,0 +1,568 @@ +/* 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"]; |