summaryrefslogtreecommitdiffstats
path: root/browser/components/newtab/lib/PlacesFeed.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/newtab/lib/PlacesFeed.sys.mjs')
-rw-r--r--browser/components/newtab/lib/PlacesFeed.sys.mjs572
1 files changed, 572 insertions, 0 deletions
diff --git a/browser/components/newtab/lib/PlacesFeed.sys.mjs b/browser/components/newtab/lib/PlacesFeed.sys.mjs
new file mode 100644
index 0000000000..70011412f8
--- /dev/null
+++ b/browser/components/newtab/lib/PlacesFeed.sys.mjs
@@ -0,0 +1,572 @@
+/* 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 {
+ actionCreators as ac,
+ actionTypes as at,
+ actionUtils as au,
+} from "resource://activity-stream/common/Actions.sys.mjs";
+
+import { shortURL } from "resource://activity-stream/lib/ShortURL.sys.mjs";
+
+// We use importESModule here instead of static import so that
+// the Karma test environment won't choke on this module. This
+// is because the Karma test environment already stubs out
+// AboutNewTab, and overrides importESModule to be a no-op (which
+// can't be done for a static import statement).
+
+// eslint-disable-next-line mozilla/use-static-import
+const { AboutNewTab } = ChromeUtils.importESModule(
+ "resource:///modules/AboutNewTab.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
+ NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs",
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+ PartnerLinkAttribution: "resource:///modules/PartnerLinkAttribution.sys.mjs",
+ pktApi: "chrome://pocket/content/pktApi.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+const LINK_BLOCKED_EVENT = "newtab-linkBlocked";
+const PLACES_LINKS_CHANGED_DELAY_TIME = 1000; // time in ms to delay timer for places links changed events
+
+// The pref to store the blocked sponsors of the sponsored Top Sites.
+// The value of this pref is an array (JSON serialized) of hostnames of the
+// blocked sponsors.
+const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors";
+
+/**
+ * PlacesObserver - observes events from PlacesUtils.observers
+ */
+class PlacesObserver {
+ constructor(dispatch) {
+ this.dispatch = dispatch;
+ this.QueryInterface = ChromeUtils.generateQI(["nsISupportsWeakReference"]);
+ this.handlePlacesEvent = this.handlePlacesEvent.bind(this);
+ }
+
+ handlePlacesEvent(events) {
+ const removedPages = [];
+ const removedBookmarks = [];
+
+ for (const {
+ itemType,
+ source,
+ dateAdded,
+ guid,
+ title,
+ url,
+ isRemovedFromStore,
+ isTagging,
+ type,
+ } of events) {
+ switch (type) {
+ case "history-cleared":
+ this.dispatch({ type: at.PLACES_HISTORY_CLEARED });
+ break;
+ case "page-removed":
+ if (isRemovedFromStore) {
+ removedPages.push(url);
+ }
+ 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 !== lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK ||
+ source === lazy.PlacesUtils.bookmarks.SOURCES.IMPORT ||
+ source === lazy.PlacesUtils.bookmarks.SOURCES.RESTORE ||
+ source === lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP ||
+ source === lazy.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 === lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK &&
+ source !== lazy.PlacesUtils.bookmarks.SOURCES.IMPORT &&
+ source !== lazy.PlacesUtils.bookmarks.SOURCES.RESTORE &&
+ source !==
+ lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP &&
+ source !== lazy.PlacesUtils.bookmarks.SOURCES.SYNC)
+ ) {
+ removedBookmarks.push(url);
+ }
+ break;
+ }
+ }
+
+ if (removedPages.length || removedBookmarks.length) {
+ this.dispatch({ type: at.PLACES_LINKS_CHANGED });
+ }
+
+ if (removedPages.length) {
+ this.dispatch({
+ type: at.PLACES_LINKS_DELETED,
+ data: { urls: removedPages },
+ });
+ }
+
+ if (removedBookmarks.length) {
+ this.dispatch({
+ type: at.PLACES_BOOKMARKS_REMOVED,
+ data: { urls: removedBookmarks },
+ });
+ }
+ }
+}
+
+export class PlacesFeed {
+ constructor() {
+ this.placesChangedTimer = null;
+ this.customDispatch = this.customDispatch.bind(this);
+ this.placesObserver = new PlacesObserver(this.customDispatch);
+ }
+
+ addObservers() {
+ lazy.PlacesUtils.observers.addListener(
+ ["bookmark-added", "bookmark-removed", "history-cleared", "page-removed"],
+ 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 {
+ // To avoid blocking Places notifications on expensive work, run it at the
+ // next tick of the events loop.
+ Services.tm.dispatchToMainThread(() =>
+ this.store.dispatch(ac.BroadcastToContent(action))
+ );
+ }
+ }
+
+ removeObservers() {
+ if (this.placesChangedTimer) {
+ this.placesChangedTimer.cancel();
+ this.placesChangedTimer = null;
+ }
+ lazy.PlacesUtils.observers.removeListener(
+ ["bookmark-added", "bookmark-removed", "history-cleared", "page-removed"],
+ 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,
+ forceForeground: false, // This ensure we maintain user preference for how to open new tabs.
+ globalHistoryOptions: {
+ triggeringSponsoredURL: action.data.sponsored_tile_id
+ ? action.data.url
+ : undefined,
+ },
+ };
+
+ // 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) {
+ console.error(e);
+ return;
+ }
+
+ // Mark the page as typed for frecency bonus before opening the link
+ if (typedBonus) {
+ lazy.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) {
+ lazy.PlacesUtils.history.insert({
+ url: action.data.original_url,
+ visits: [{ transition: lazy.PlacesUtils.history.TRANSITION_TYPED }],
+ });
+ }
+ }
+
+ async saveToPocket(site, browser) {
+ const sendToPocket =
+ lazy.NimbusFeatures.pocketNewtab.getVariable("sendToPocket");
+ // An experiment to send the user directly to Pocket's signup page.
+ if (sendToPocket && !lazy.pktApi.isUserLoggedIn()) {
+ const pocketNewtabExperiment = lazy.ExperimentAPI.getExperiment({
+ featureId: "pocketNewtab",
+ });
+ const pocketSiteHost = Services.prefs.getStringPref(
+ "extensions.pocket.site"
+ ); // getpocket.com
+ let utmSource = "firefox_newtab_save_button";
+ // We want to know if the user is in a Pocket newtab related experiment.
+ let utmCampaign = pocketNewtabExperiment?.slug;
+ let utmContent = pocketNewtabExperiment?.branch?.slug;
+
+ const url = new URL(`https://${pocketSiteHost}/signup`);
+ url.searchParams.append("utm_source", utmSource);
+ if (utmCampaign && utmContent) {
+ url.searchParams.append("utm_campaign", utmCampaign);
+ url.searchParams.append("utm_content", utmContent);
+ }
+
+ const win = browser.ownerGlobal;
+ win.openTrustedLinkIn(url.href, "tab");
+ return;
+ }
+
+ const { url, title } = site;
+ try {
+ let data = await lazy.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) {
+ console.error(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 lazy.NewTabUtils.activityStreamLinks.deletePocketEntry(itemID);
+ this.store.dispatch({ type: at.POCKET_LINK_DELETED_OR_ARCHIVED });
+ } catch (err) {
+ console.error(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 lazy.NewTabUtils.activityStreamLinks.archivePocketEntry(itemID);
+ this.store.dispatch({ type: at.POCKET_LINK_DELETED_OR_ARCHIVED });
+ } catch (err) {
+ console.error(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
+ );
+ lazy.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"
+ ];
+ }
+
+ handoffSearchToAwesomebar(action) {
+ const { _target, data, meta } = action;
+ const searchEngine = this._getDefaultSearchEngine(
+ lazy.PrivateBrowsingUtils.isBrowserPrivate(_target.browser)
+ );
+ const urlBar = _target.browser.ownerGlobal.gURLBar;
+ let isFirstChange = true;
+
+ const newtabSession = AboutNewTab.activityStream.store.feeds
+ .get("feeds.telemetry")
+ ?.sessions.get(au.getPortIdOfSender(action));
+ if (!data || !data.text) {
+ urlBar.setHiddenFocus();
+ } else {
+ urlBar.handoff(data.text, searchEngine, newtabSession?.session_id);
+ 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(true);
+ urlBar.handoff("", searchEngine, newtabSession?.session_id);
+ this.store.dispatch(
+ ac.OnlyToOneContent({ type: at.DISABLE_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 = ev => {
+ // We are done. Show in-content search again and cleanup.
+ this.store.dispatch(
+ ac.OnlyToOneContent({ type: at.SHOW_SEARCH }, meta.fromTarget)
+ );
+
+ const forceSuppressFocusBorder = ev?.type === "mousedown";
+ urlBar.removeHiddenFocus(forceSuppressFocusBorder);
+
+ 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);
+ }
+
+ /**
+ * Add the hostnames of the given urls to the Top Sites sponsor blocklist.
+ *
+ * @param {array} urls
+ * An array of the objects structured as `{ url }`
+ */
+ addToBlockedTopSitesSponsors(urls) {
+ const blockedPref = JSON.parse(
+ Services.prefs.getStringPref(TOP_SITES_BLOCKED_SPONSORS_PREF, "[]")
+ );
+ const merged = new Set([...blockedPref, ...urls.map(url => shortURL(url))]);
+
+ Services.prefs.setStringPref(
+ TOP_SITES_BLOCKED_SPONSORS_PREF,
+ JSON.stringify([...merged])
+ );
+ }
+
+ 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) {
+ let sponsoredTopSites = [];
+ action.data.forEach(site => {
+ const { url, pocket_id, isSponsoredTopSite } = site;
+ lazy.NewTabUtils.activityStreamLinks.blockURL({ url, pocket_id });
+ if (isSponsoredTopSite) {
+ sponsoredTopSites.push({ url });
+ }
+ });
+ if (sponsoredTopSites.length) {
+ this.addToBlockedTopSitesSponsors(sponsoredTopSites);
+ }
+ }
+ break;
+ }
+ case at.BOOKMARK_URL:
+ lazy.NewTabUtils.activityStreamLinks.addBookmark(
+ action.data,
+ action._target.browser.ownerGlobal
+ );
+ break;
+ case at.DELETE_BOOKMARK_BY_ID:
+ lazy.NewTabUtils.activityStreamLinks.deleteBookmark(action.data);
+ break;
+ case at.DELETE_HISTORY_URL: {
+ const { url, forceBlock, pocket_id } = action.data;
+ lazy.NewTabUtils.activityStreamLinks.deleteHistoryEntry(url);
+ if (forceBlock) {
+ lazy.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;
+ }
+ }
+}
+
+// Exported for testing only
+PlacesFeed.PlacesObserver = PlacesObserver;