browser/components/newtab/lib/ActivityStream.jsm
author Ed Lee <edilee@mozilla.com>
Sat, 04 May 2019 05:36:06 +0000
changeset 472625 5c493140403539373c1b2b04b75f49255146e72c
parent 471308 d4f22be5eb413a9d2cb5eb610d6f9c0cee6039cf
child 472989 690025a0dcd52340c05432a84131d5239df2d61a
permissions -rw-r--r--
Bug 1548952 - Add offline fallback, request timeouts and bug fixes to Activity Stream r=k88hudson Differential Revision: https://phabricator.services.mozilla.com/D29909

/* 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");

ChromeUtils.defineModuleGetter(this, "AppConstants",
  "resource://gre/modules/AppConstants.jsm");
ChromeUtils.defineModuleGetter(this, "UpdateUtils",
  "resource://gre/modules/UpdateUtils.jsm");

// NB: Eagerly load modules that will be loaded/constructed/initialized in the
// common case to avoid the overhead of wrapping and detecting lazy loading.
const {actionCreators: ac, actionTypes: at} = ChromeUtils.import("resource://activity-stream/common/Actions.jsm");
const {AboutPreferences} = ChromeUtils.import("resource://activity-stream/lib/AboutPreferences.jsm");
const {DefaultPrefs} = ChromeUtils.import("resource://activity-stream/lib/ActivityStreamPrefs.jsm");
const {NewTabInit} = ChromeUtils.import("resource://activity-stream/lib/NewTabInit.jsm");
const {SectionsFeed} = ChromeUtils.import("resource://activity-stream/lib/SectionsManager.jsm");
const {PlacesFeed} = ChromeUtils.import("resource://activity-stream/lib/PlacesFeed.jsm");
const {PrefsFeed} = ChromeUtils.import("resource://activity-stream/lib/PrefsFeed.jsm");
const {Store} = ChromeUtils.import("resource://activity-stream/lib/Store.jsm");
const {SystemTickFeed} = ChromeUtils.import("resource://activity-stream/lib/SystemTickFeed.jsm");
const {TelemetryFeed} = ChromeUtils.import("resource://activity-stream/lib/TelemetryFeed.jsm");
const {FaviconFeed} = ChromeUtils.import("resource://activity-stream/lib/FaviconFeed.jsm");
const {TopSitesFeed} = ChromeUtils.import("resource://activity-stream/lib/TopSitesFeed.jsm");
const {TopStoriesFeed} = ChromeUtils.import("resource://activity-stream/lib/TopStoriesFeed.jsm");
const {HighlightsFeed} = ChromeUtils.import("resource://activity-stream/lib/HighlightsFeed.jsm");
const {ASRouterFeed} = ChromeUtils.import("resource://activity-stream/lib/ASRouterFeed.jsm");
const {DiscoveryStreamFeed} = ChromeUtils.import("resource://activity-stream/lib/DiscoveryStreamFeed.jsm");

const DEFAULT_SITES = new Map([
  // This first item is the global list fallback for any unexpected geos
  ["", "https://www.youtube.com/,https://www.facebook.com/,https://www.wikipedia.org/,https://www.reddit.com/,https://www.amazon.com/,https://twitter.com/"],
  ["US", "https://www.youtube.com/,https://www.facebook.com/,https://www.amazon.com/,https://www.reddit.com/,https://www.wikipedia.org/,https://twitter.com/"],
  ["CA", "https://www.youtube.com/,https://www.facebook.com/,https://www.reddit.com/,https://www.wikipedia.org/,https://www.amazon.ca/,https://twitter.com/"],
  ["DE", "https://www.youtube.com/,https://www.facebook.com/,https://www.amazon.de/,https://www.ebay.de/,https://www.wikipedia.org/,https://www.reddit.com/"],
  ["PL", "https://www.youtube.com/,https://www.facebook.com/,https://allegro.pl/,https://www.wikipedia.org/,https://www.olx.pl/,https://www.wykop.pl/"],
  ["RU", "https://vk.com/,https://www.youtube.com/,https://ok.ru/,https://www.avito.ru/,https://www.aliexpress.com/,https://www.wikipedia.org/"],
  ["GB", "https://www.youtube.com/,https://www.facebook.com/,https://www.reddit.com/,https://www.amazon.co.uk/,https://www.bbc.co.uk/,https://www.ebay.co.uk/"],
  ["FR", "https://www.youtube.com/,https://www.facebook.com/,https://www.wikipedia.org/,https://www.amazon.fr/,https://www.leboncoin.fr/,https://twitter.com/"],
]);
const GEO_PREF = "browser.search.region";
const SPOCS_GEOS = ["US"];
const IS_NIGHTLY_OR_UNBRANDED_BUILD = ["nightly", "default"].includes(UpdateUtils.getUpdateChannel(true));

// Determine if spocs should be shown for a geo/locale
function showSpocs({geo}) {
  return SPOCS_GEOS.includes(geo);
}

// Configure default Activity Stream prefs with a plain `value` or a `getValue`
// that computes a value. A `value_local_dev` is used for development defaults.
const PREFS_CONFIG = new Map([
  ["default.sites", {
    title: "Comma-separated list of default top sites to fill in behind visited sites",
    getValue: ({geo}) => DEFAULT_SITES.get(DEFAULT_SITES.has(geo) ? geo : ""),
  }],
  ["feeds.section.topstories.options", {
    title: "Configuration options for top stories feed",
    // This is a dynamic pref as it depends on the feed being shown or not
    getValue: args => JSON.stringify({
      api_key_pref: "extensions.pocket.oAuthConsumerKey",
      // Use the opposite value as what default value the feed would have used
      hidden: !PREFS_CONFIG.get("feeds.section.topstories").getValue(args),
      provider_icon: "pocket",
      provider_name: "Pocket",
      read_more_endpoint: "https://getpocket.com/explore/trending?src=fx_new_tab",
      stories_endpoint: `https://getpocket.cdn.mozilla.net/v3/firefox/global-recs?version=3&consumer_key=$apiKey&locale_lang=${args.locale}&feed_variant=${showSpocs(args) ? "default_spocs_on" : "default_spocs_off"}`,
      stories_referrer: "https://getpocket.com/recommendations",
      topics_endpoint: `https://getpocket.cdn.mozilla.net/v3/firefox/trending-topics?version=2&consumer_key=$apiKey&locale_lang=${args.locale}`,
      model_keys: ["nmf_model_animals", "nmf_model_business", "nmf_model_career", "nmf_model_datascience", "nmf_model_design", "nmf_model_education", "nmf_model_entertainment", "nmf_model_environment", "nmf_model_fashion", "nmf_model_finance", "nmf_model_food", "nmf_model_health", "nmf_model_home", "nmf_model_life", "nmf_model_marketing", "nmf_model_politics", "nmf_model_programming", "nmf_model_science", "nmf_model_shopping", "nmf_model_sports", "nmf_model_tech", "nmf_model_travel", "nb_model_animals", "nb_model_books", "nb_model_business", "nb_model_career", "nb_model_datascience", "nb_model_design", "nb_model_economics", "nb_model_education", "nb_model_entertainment", "nb_model_environment", "nb_model_fashion", "nb_model_finance", "nb_model_food", "nb_model_game", "nb_model_health", "nb_model_history", "nb_model_home", "nb_model_life", "nb_model_marketing", "nb_model_military", "nb_model_philosophy", "nb_model_photography", "nb_model_politics", "nb_model_productivity", "nb_model_programming", "nb_model_psychology", "nb_model_science", "nb_model_shopping", "nb_model_society", "nb_model_space", "nb_model_sports", "nb_model_tech", "nb_model_travel", "nb_model_writing"],
      show_spocs: showSpocs(args),
      personalized: true,
      version: 1,
    }),
  }],
  ["showSponsored", {
    title: "Show sponsored cards in spoc experiment (show_spocs in topstories.options has to be set to true as well)",
    value: true,
  }],
  ["pocketCta", {
    title: "Pocket cta and button for logged out users.",
    value: JSON.stringify({
      cta_button: "",
      cta_text: "",
      cta_url: "",
      use_cta: false,
    }),
  }],
  ["filterAdult", {
    title: "Remove adult pages from sites, highlights, etc.",
    value: true,
  }],
  ["prerender", {
    title: "Use the prerendered version of activity-stream.html. This is set automatically by PrefsFeed.jsm.",
    value: true,
  }],
  ["showSearch", {
    title: "Show the Search bar",
    value: true,
  }],
  ["feeds.snippets", {
    title: "Show snippets on activity stream",
    value: true,
  }],
  ["topSitesRows", {
    title: "Number of rows of Top Sites to display",
    value: 1,
  }],
  ["telemetry", {
    title: "Enable system error and usage data collection",
    value: true,
    value_local_dev: false,
  }],
  ["telemetry.ut.events", {
    title: "Enable Unified Telemetry event data collection",
    value: AppConstants.EARLY_BETA_OR_EARLIER,
    value_local_dev: false,
  }],
  ["telemetry.structuredIngestion", {
    title: "Enable Structured Ingestion Telemetry data collection",
    value: true,
    value_local_dev: false,
  }],
  ["telemetry.structuredIngestion.endpoint", {
    title: "Structured Ingestion telemetry server endpoint",
    value: "https://incoming.telemetry.mozilla.org/submit/activity-stream",
  }],
  ["telemetry.ping.endpoint", {
    title: "Telemetry server endpoint",
    value: "https://tiles.services.mozilla.com/v4/links/activity-stream",
  }],
  ["section.highlights.includeVisited", {
    title: "Boolean flag that decides whether or not to show visited pages in highlights.",
    value: true,
  }],
  ["section.highlights.includeBookmarks", {
    title: "Boolean flag that decides whether or not to show bookmarks in highlights.",
    value: true,
  }],
  ["section.highlights.includePocket", {
    title: "Boolean flag that decides whether or not to show saved Pocket stories in highlights.",
    value: true,
  }],
  ["section.highlights.includeDownloads", {
    title: "Boolean flag that decides whether or not to show saved recent Downloads in highlights.",
    value: true,
  }],
  ["section.highlights.rows", {
    title: "Number of rows of Highlights to display",
    value: 2,
  }],
  ["section.topstories.rows", {
    title: "Number of rows of Top Stories to display",
    value: 1,
  }],
  ["sectionOrder", {
    title: "The rendering order for the sections",
    value: "topsites,topstories,highlights",
  }],
  ["improvesearch.noDefaultSearchTile", {
    title: "Experiment to remove tiles that are the same as the default search",
    value: true,
  }],
  ["improvesearch.topSiteSearchShortcuts.searchEngines", {
    title: "An ordered, comma-delimited list of search shortcuts that we should try and pin",
    // This pref is dynamic as the shortcuts vary depending on the region
    getValue: ({geo}) => {
      if (!geo) {
        return "";
      }
      const searchShortcuts = [];
      if (geo === "CN") {
        searchShortcuts.push("baidu");
      } else if (["BY", "KZ", "RU", "TR"].includes(geo)) {
        searchShortcuts.push("yandex");
      } else {
        searchShortcuts.push("google");
      }
      if (["DE", "FR", "GB", "IT", "JP", "US"].includes(geo)) {
        searchShortcuts.push("amazon");
      }
      return searchShortcuts.join(",");
    },
  }],
  ["improvesearch.topSiteSearchShortcuts.havePinned", {
    title: "A comma-delimited list of search shortcuts that have previously been pinned",
    value: "",
  }],
  ["asrouter.devtoolsEnabled", {
    title: "Are the asrouter devtools enabled?",
    value: false,
  }],
  ["asrouter.userprefs.cfr.addons", {
    title: "Does the user allow CFR addon recommendations?",
    value: true,
  }],
  ["asrouter.userprefs.cfr.features", {
    title: "Does the user allow CFR feature recommendations?",
    value: true,
  }],
  ["asrouter.providers.onboarding", {
    title: "Configuration for onboarding provider",
    value: JSON.stringify({
      id: "onboarding",
      type: "local",
      localProvider: "OnboardingMessageProvider",
      enabled: true,
      // Block specific messages from this local provider
      exclude: [],
    }),
  }],
  ["asrouter.providers.cfr-fxa", {
    title: "Configuration for CFR FxA Messages provider",
    value: JSON.stringify({
      id: "cfr-fxa",
      enabled: false,
      type: "remote-settings",
      bucket: "cfr-fxa",
      frequency: {custom: [{period: "daily", cap: 1}]},
    }),
  }],
  // See browser/app/profile/firefox.js for other ASR preferences. They must be defined there to enable roll-outs.
  ["discoverystream.config", {
    title: "Configuration for the new pocket new tab",
    getValue: ({geo, locale}) => {
      const locales = ({
        "US": ["en-CA", "en-GB", "en-US", "en-ZA"],
        "CA": ["en-CA", "en-GB", "en-US", "en-ZA"],
      })[geo];
      const isEnabled = IS_NIGHTLY_OR_UNBRANDED_BUILD && locales && locales.includes(locale);
      return JSON.stringify({
        api_key_pref: "extensions.pocket.oAuthConsumerKey",
        collapsible: true,
        enabled: isEnabled,
        show_spocs: showSpocs({geo}),
        hardcoded_layout: true,
        personalized: false,
        // This is currently an exmple layout used for dev purposes.
        layout_endpoint: "https://getpocket.cdn.mozilla.net/v3/newtab/layout?version=1&consumer_key=$apiKey&layout_variant=basic",
      });
    },
  }],
  ["discoverystream.endpoints", {
    title: "Endpoint prefixes (comma-separated) that are allowed to be requested",
    value: "https://getpocket.cdn.mozilla.net/",
  }],
  ["discoverystream.optOut.0", {
    title: "Opt out of new layout v0",
    value: false,
  }],
  ["discoverystream.spoc.impressions", {
    title: "Track spoc impressions",
    skipBroadcast: true,
    value: "{}",
  }],
  ["discoverystream.rec.impressions", {
    title: "Track rec impressions",
    skipBroadcast: true,
    value: "{}",
  }],
]);

// Array of each feed's FEEDS_CONFIG factory and values to add to PREFS_CONFIG
const FEEDS_DATA = [
  {
    name: "aboutpreferences",
    factory: () => new AboutPreferences(),
    title: "about:preferences rendering",
    value: true,
  },
  {
    name: "newtabinit",
    factory: () => new NewTabInit(),
    title: "Sends a copy of the state to each new tab that is opened",
    value: true,
  },
  {
    name: "places",
    factory: () => new PlacesFeed(),
    title: "Listens for and relays various Places-related events",
    value: true,
  },
  {
    name: "prefs",
    factory: () => new PrefsFeed(PREFS_CONFIG),
    title: "Preferences",
    value: true,
  },
  {
    name: "sections",
    factory: () => new SectionsFeed(),
    title: "Manages sections",
    value: true,
  },
  {
    name: "section.highlights",
    factory: () => new HighlightsFeed(),
    title: "Fetches content recommendations from places db",
    value: true,
  },
  {
    name: "section.topstories",
    factory: () => new TopStoriesFeed(),
    title: "Fetches content recommendations from a configurable content provider",
    // Dynamically determine if Pocket should be shown for a geo / locale
    getValue: ({geo, locale}) => {
      const locales = ({
        "US": ["en-CA", "en-GB", "en-US", "en-ZA"],
        "CA": ["en-CA", "en-GB", "en-US", "en-ZA"],
        "DE": ["de", "de-DE", "de-AT", "de-CH"],
      })[geo];
      return !!locales && locales.includes(locale);
    },
  },
  {
    name: "systemtick",
    factory: () => new SystemTickFeed(),
    title: "Produces system tick events to periodically check for data expiry",
    value: true,
  },
  {
    name: "telemetry",
    factory: () => new TelemetryFeed(),
    title: "Relays telemetry-related actions to PingCentre",
    value: true,
  },
  {
    name: "favicon",
    factory: () => new FaviconFeed(),
    title: "Fetches tippy top manifests from remote service",
    value: true,
  },
  {
    name: "topsites",
    factory: () => new TopSitesFeed(),
    title: "Queries places and gets metadata for Top Sites section",
    value: true,
  },
  {
    name: "asrouterfeed",
    factory: () => new ASRouterFeed(),
    title: "Handles AS Router messages, such as snippets and onboaridng",
    value: true,
  },
  {
    name: "discoverystreamfeed",
    factory: () => new DiscoveryStreamFeed(),
    title: "Handles new pocket ui for the new tab page",
    value: true,
  },
];

const FEEDS_CONFIG = new Map();
for (const config of FEEDS_DATA) {
  const pref = `feeds.${config.name}`;
  FEEDS_CONFIG.set(pref, config.factory);
  PREFS_CONFIG.set(pref, config);
}

this.ActivityStream = class ActivityStream {
  /**
   * constructor - Initializes an instance of ActivityStream
   */
  constructor() {
    this.initialized = false;
    this.store = new Store();
    this.feeds = FEEDS_CONFIG;
    this._defaultPrefs = new DefaultPrefs(PREFS_CONFIG);
  }

  init() {
    try {
      this._migratePrefs();
      this._updateDynamicPrefs();
      this._defaultPrefs.init();

      // Hook up the store and let all feeds and pages initialize
      this.store.init(this.feeds, ac.BroadcastToContent({
        type: at.INIT,
        data: {},
      }), {type: at.UNINIT});

      this.initialized = true;
    } catch (e) {
      // TelemetryFeed could be unavailable if the telemetry is disabled, or
      // the telemetry feed is not yet initialized.
      const telemetryFeed = this.store.feeds.get("feeds.telemetry");
      if (telemetryFeed) {
        telemetryFeed.handleUndesiredEvent({data: {event: "ADDON_INIT_FAILED"}});
      }
      throw e;
    }
  }

  /**
   * Check if an old pref has a custom value to migrate. Clears the pref so that
   * it's the default after migrating (to avoid future need to migrate).
   *
   * @param oldPrefName {string} Pref to check and migrate
   * @param cbIfNotDefault {function} Callback that gets the current pref value
   */
  _migratePref(oldPrefName, cbIfNotDefault) {
    // Nothing to do if the user doesn't have a custom value
    if (!Services.prefs.prefHasUserValue(oldPrefName)) {
      return;
    }

    // Figure out what kind of pref getter to use
    let prefGetter;
    switch (Services.prefs.getPrefType(oldPrefName)) {
      case Services.prefs.PREF_BOOL:
        prefGetter = "getBoolPref";
        break;
      case Services.prefs.PREF_INT:
        prefGetter = "getIntPref";
        break;
      case Services.prefs.PREF_STRING:
        prefGetter = "getStringPref";
        break;
    }

    // Give the callback the current value then clear the pref
    cbIfNotDefault(Services.prefs[prefGetter](oldPrefName));
    Services.prefs.clearUserPref(oldPrefName);
  }

  _migratePrefs() {
    // Do a one time migration of Tiles about:newtab prefs that have been modified
    this._migratePref("browser.newtabpage.rows", rows => {
      // Just disable top sites if rows are not desired
      if (rows <= 0) {
        Services.prefs.setBoolPref("browser.newtabpage.activity-stream.feeds.topsites", false);
      } else {
        Services.prefs.setIntPref("browser.newtabpage.activity-stream.topSitesRows", rows);
      }
    });

    this._migratePref("browser.newtabpage.activity-stream.showTopSites", value => {
      if (value === false) {
        Services.prefs.setBoolPref("browser.newtabpage.activity-stream.feeds.topsites", false);
      }
    });

    // Old activity stream topSitesCount pref showed 6 per row
    this._migratePref("browser.newtabpage.activity-stream.topSitesCount", count => {
      Services.prefs.setIntPref("browser.newtabpage.activity-stream.topSitesRows", Math.ceil(count / 6));
    });
  }

  uninit() {
    if (this.geo === "") {
      Services.prefs.removeObserver(GEO_PREF, this);
    }

    this.store.uninit();
    this.initialized = false;
  }

  _updateDynamicPrefs() {
    // Save the geo pref if we have it
    if (Services.prefs.prefHasUserValue(GEO_PREF)) {
      this.geo = Services.prefs.getStringPref(GEO_PREF);
    } else if (this.geo !== "") {
      // Watch for geo changes and use a dummy value for now
      Services.prefs.addObserver(GEO_PREF, this);
      this.geo = "";
    }

    this.locale = Services.locale.appLocaleAsLangTag;

    // Update the pref config of those with dynamic values
    for (const pref of PREFS_CONFIG.keys()) {
      const prefConfig = PREFS_CONFIG.get(pref);
      if (!prefConfig.getValue) {
        continue;
      }

      const newValue = prefConfig.getValue({
        geo: this.geo,
        locale: this.locale,
      });

      // If there's an existing value and it has changed, that means we need to
      // overwrite the default with the new value.
      if (prefConfig.value !== undefined && prefConfig.value !== newValue) {
        this._defaultPrefs.set(pref, newValue);
      }

      prefConfig.value = newValue;
    }
  }

  observe(subject, topic, data) {
    switch (topic) {
      case "nsPref:changed":
        // We should only expect one geo change, so update and stop observing
        if (data === GEO_PREF) {
          this._updateDynamicPrefs();
          Services.prefs.removeObserver(GEO_PREF, this);
        }
        break;
    }
  }
};

const EXPORTED_SYMBOLS = ["ActivityStream", "PREFS_CONFIG"];