Backed out 2 changesets (bug 1864896) for causing node failures. CLOSED TREE
[gecko.git] / browser / components / newtab / lib / ActivityStream.sys.mjs
blobf2287fe45edbf4f1e792e3fdcd6886a17c1bb9d2
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 // We use importESModule here instead of static import so that
6 // the Karma test environment won't choke on this module. This
7 // is because the Karma test environment already stubs out
8 // AppConstants, and overrides importESModule to be a no-op (which
9 // can't be done for a static import statement).
11 // eslint-disable-next-line mozilla/use-static-import
12 const { AppConstants } = ChromeUtils.importESModule(
13   "resource://gre/modules/AppConstants.sys.mjs"
16 const lazy = {};
18 ChromeUtils.defineESModuleGetters(lazy, {
19   AboutPreferences: "resource://activity-stream/lib/AboutPreferences.sys.mjs",
20   DEFAULT_SITES: "resource://activity-stream/lib/DefaultSites.sys.mjs",
21   DefaultPrefs: "resource://activity-stream/lib/ActivityStreamPrefs.sys.mjs",
22   DiscoveryStreamFeed:
23     "resource://activity-stream/lib/DiscoveryStreamFeed.sys.mjs",
24   FaviconFeed: "resource://activity-stream/lib/FaviconFeed.sys.mjs",
25   HighlightsFeed: "resource://activity-stream/lib/HighlightsFeed.sys.mjs",
26   NewTabInit: "resource://activity-stream/lib/NewTabInit.sys.mjs",
27   NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
28   PrefsFeed: "resource://activity-stream/lib/PrefsFeed.sys.mjs",
29   PlacesFeed: "resource://activity-stream/lib/PlacesFeed.sys.mjs",
30   RecommendationProvider:
31     "resource://activity-stream/lib/RecommendationProvider.sys.mjs",
32   Region: "resource://gre/modules/Region.sys.mjs",
33   SectionsFeed: "resource://activity-stream/lib/SectionsManager.sys.mjs",
34   Store: "resource://activity-stream/lib/Store.sys.mjs",
35   SystemTickFeed: "resource://activity-stream/lib/SystemTickFeed.sys.mjs",
36   TelemetryFeed: "resource://activity-stream/lib/TelemetryFeed.sys.mjs",
37   TopSitesFeed: "resource://activity-stream/lib/TopSitesFeed.sys.mjs",
38   TopStoriesFeed: "resource://activity-stream/lib/TopStoriesFeed.sys.mjs",
39 });
41 // NB: Eagerly load modules that will be loaded/constructed/initialized in the
42 // common case to avoid the overhead of wrapping and detecting lazy loading.
43 import {
44   actionCreators as ac,
45   actionTypes as at,
46 } from "resource://activity-stream/common/Actions.sys.mjs";
48 const REGION_BASIC_CONFIG =
49   "browser.newtabpage.activity-stream.discoverystream.region-basic-config";
51 // Determine if spocs should be shown for a geo/locale
52 function showSpocs({ geo }) {
53   const spocsGeoString =
54     lazy.NimbusFeatures.pocketNewtab.getVariable("regionSpocsConfig") || "";
55   const spocsGeo = spocsGeoString.split(",").map(s => s.trim());
56   return spocsGeo.includes(geo);
59 // Configure default Activity Stream prefs with a plain `value` or a `getValue`
60 // that computes a value. A `value_local_dev` is used for development defaults.
61 export const PREFS_CONFIG = new Map([
62   [
63     "default.sites",
64     {
65       title:
66         "Comma-separated list of default top sites to fill in behind visited sites",
67       getValue: ({ geo }) =>
68         lazy.DEFAULT_SITES.get(lazy.DEFAULT_SITES.has(geo) ? geo : ""),
69     },
70   ],
71   [
72     "feeds.section.topstories.options",
73     {
74       title: "Configuration options for top stories feed",
75       // This is a dynamic pref as it depends on the feed being shown or not
76       getValue: args =>
77         JSON.stringify({
78           api_key_pref: "extensions.pocket.oAuthConsumerKey",
79           // Use the opposite value as what default value the feed would have used
80           hidden: !PREFS_CONFIG.get("feeds.system.topstories").getValue(args),
81           provider_icon: "chrome://global/skin/icons/pocket.svg",
82           provider_name: "Pocket",
83           read_more_endpoint:
84             "https://getpocket.com/explore/trending?src=fx_new_tab",
85           stories_endpoint: `https://getpocket.cdn.mozilla.net/v3/firefox/global-recs?version=3&consumer_key=$apiKey&locale_lang=${
86             args.locale
87           }&feed_variant=${
88             showSpocs(args) ? "default_spocs_on" : "default_spocs_off"
89           }`,
90           stories_referrer: "https://getpocket.com/recommendations",
91           topics_endpoint: `https://getpocket.cdn.mozilla.net/v3/firefox/trending-topics?version=2&consumer_key=$apiKey&locale_lang=${args.locale}`,
92           show_spocs: showSpocs(args),
93         }),
94     },
95   ],
96   [
97     "feeds.topsites",
98     {
99       title: "Displays Top Sites on the New Tab Page",
100       value: true,
101     },
102   ],
103   [
104     "hideTopSitesTitle",
105     {
106       title:
107         "Hide the top sites section's title, including the section and collapse icons",
108       value: false,
109     },
110   ],
111   [
112     "showSponsored",
113     {
114       title: "User pref for sponsored Pocket content",
115       value: true,
116     },
117   ],
118   [
119     "system.showSponsored",
120     {
121       title: "System pref for sponsored Pocket content",
122       // This pref is dynamic as the sponsored content depends on the region
123       getValue: showSpocs,
124     },
125   ],
126   [
127     "showSponsoredTopSites",
128     {
129       title: "Show sponsored top sites",
130       value: true,
131     },
132   ],
133   [
134     "pocketCta",
135     {
136       title: "Pocket cta and button for logged out users.",
137       value: JSON.stringify({
138         cta_button: "",
139         cta_text: "",
140         cta_url: "",
141         use_cta: false,
142       }),
143     },
144   ],
145   [
146     "showSearch",
147     {
148       title: "Show the Search bar",
149       value: true,
150     },
151   ],
152   [
153     "topSitesRows",
154     {
155       title: "Number of rows of Top Sites to display",
156       value: 1,
157     },
158   ],
159   [
160     "telemetry",
161     {
162       title: "Enable system error and usage data collection",
163       value: true,
164       value_local_dev: false,
165     },
166   ],
167   [
168     "telemetry.ut.events",
169     {
170       title: "Enable Unified Telemetry event data collection",
171       value: AppConstants.EARLY_BETA_OR_EARLIER,
172       value_local_dev: false,
173     },
174   ],
175   [
176     "telemetry.structuredIngestion.endpoint",
177     {
178       title: "Structured Ingestion telemetry server endpoint",
179       value: "https://incoming.telemetry.mozilla.org/submit",
180     },
181   ],
182   [
183     "section.highlights.includeVisited",
184     {
185       title:
186         "Boolean flag that decides whether or not to show visited pages in highlights.",
187       value: true,
188     },
189   ],
190   [
191     "section.highlights.includeBookmarks",
192     {
193       title:
194         "Boolean flag that decides whether or not to show bookmarks in highlights.",
195       value: true,
196     },
197   ],
198   [
199     "section.highlights.includePocket",
200     {
201       title:
202         "Boolean flag that decides whether or not to show saved Pocket stories in highlights.",
203       value: true,
204     },
205   ],
206   [
207     "section.highlights.includeDownloads",
208     {
209       title:
210         "Boolean flag that decides whether or not to show saved recent Downloads in highlights.",
211       value: true,
212     },
213   ],
214   [
215     "section.highlights.rows",
216     {
217       title: "Number of rows of Highlights to display",
218       value: 1,
219     },
220   ],
221   [
222     "section.topstories.rows",
223     {
224       title: "Number of rows of Top Stories to display",
225       value: 1,
226     },
227   ],
228   [
229     "sectionOrder",
230     {
231       title: "The rendering order for the sections",
232       value: "topsites,topstories,highlights",
233     },
234   ],
235   [
236     "improvesearch.noDefaultSearchTile",
237     {
238       title: "Remove tiles that are the same as the default search",
239       value: true,
240     },
241   ],
242   [
243     "improvesearch.topSiteSearchShortcuts.searchEngines",
244     {
245       title:
246         "An ordered, comma-delimited list of search shortcuts that we should try and pin",
247       // This pref is dynamic as the shortcuts vary depending on the region
248       getValue: ({ geo }) => {
249         if (!geo) {
250           return "";
251         }
252         const searchShortcuts = [];
253         if (geo === "CN") {
254           searchShortcuts.push("baidu");
255         } else if (["BY", "KZ", "RU", "TR"].includes(geo)) {
256           searchShortcuts.push("yandex");
257         } else {
258           searchShortcuts.push("google");
259         }
260         if (["DE", "FR", "GB", "IT", "JP", "US"].includes(geo)) {
261           searchShortcuts.push("amazon");
262         }
263         return searchShortcuts.join(",");
264       },
265     },
266   ],
267   [
268     "improvesearch.topSiteSearchShortcuts.havePinned",
269     {
270       title:
271         "A comma-delimited list of search shortcuts that have previously been pinned",
272       value: "",
273     },
274   ],
275   [
276     "asrouter.devtoolsEnabled",
277     {
278       title: "Are the asrouter devtools enabled?",
279       value: false,
280     },
281   ],
282   [
283     "asrouter.providers.onboarding",
284     {
285       title: "Configuration for onboarding provider",
286       value: JSON.stringify({
287         id: "onboarding",
288         type: "local",
289         localProvider: "OnboardingMessageProvider",
290         enabled: true,
291         // Block specific messages from this local provider
292         exclude: [],
293       }),
294     },
295   ],
296   // See browser/app/profile/firefox.js for other ASR preferences. They must be defined there to enable roll-outs.
297   [
298     "discoverystream.flight.blocks",
299     {
300       title: "Track flight blocks",
301       skipBroadcast: true,
302       value: "{}",
303     },
304   ],
305   [
306     "discoverystream.config",
307     {
308       title: "Configuration for the new pocket new tab",
309       getValue: ({ geo, locale }) => {
310         return JSON.stringify({
311           api_key_pref: "extensions.pocket.oAuthConsumerKey",
312           collapsible: true,
313           enabled: true,
314         });
315       },
316     },
317   ],
318   [
319     "discoverystream.endpoints",
320     {
321       title:
322         "Endpoint prefixes (comma-separated) that are allowed to be requested",
323       value:
324         "https://getpocket.cdn.mozilla.net/,https://firefox-api-proxy.cdn.mozilla.net/,https://spocs.getpocket.com/",
325     },
326   ],
327   [
328     "discoverystream.isCollectionDismissible",
329     {
330       title: "Allows Pocket story collections to be dismissed",
331       value: false,
332     },
333   ],
334   [
335     "discoverystream.onboardingExperience.dismissed",
336     {
337       title: "Allows the user to dismiss the new Pocket onboarding experience",
338       skipBroadcast: true,
339       alsoToPreloaded: true,
340       value: false,
341     },
342   ],
343   [
344     "discoverystream.region-basic-layout",
345     {
346       title: "Decision to use basic layout based on region.",
347       getValue: ({ geo }) => {
348         const preffedRegionsString =
349           Services.prefs.getStringPref(REGION_BASIC_CONFIG) || "";
350         // If no regions are set to basic,
351         // we don't need to bother checking against the region.
352         // We are also not concerned if geo is not set,
353         // because stories are going to be empty until we have geo.
354         if (!preffedRegionsString) {
355           return false;
356         }
357         const preffedRegions = preffedRegionsString
358           .split(",")
359           .map(s => s.trim());
361         return preffedRegions.includes(geo);
362       },
363     },
364   ],
365   [
366     "discoverystream.spoc.impressions",
367     {
368       title: "Track spoc impressions",
369       skipBroadcast: true,
370       value: "{}",
371     },
372   ],
373   [
374     "discoverystream.endpointSpocsClear",
375     {
376       title:
377         "Endpoint for when a user opts-out of sponsored content to delete the user's data from the ad server.",
378       value: "https://spocs.getpocket.com/user",
379     },
380   ],
381   [
382     "discoverystream.rec.impressions",
383     {
384       title: "Track rec impressions",
385       skipBroadcast: true,
386       value: "{}",
387     },
388   ],
389   [
390     "showRecentSaves",
391     {
392       title: "Control whether a user wants recent saves visible on Newtab",
393       value: true,
394     },
395   ],
398 // Array of each feed's FEEDS_CONFIG factory and values to add to PREFS_CONFIG
399 const FEEDS_DATA = [
400   {
401     name: "aboutpreferences",
402     factory: () => new lazy.AboutPreferences(),
403     title: "about:preferences rendering",
404     value: true,
405   },
406   {
407     name: "newtabinit",
408     factory: () => new lazy.NewTabInit(),
409     title: "Sends a copy of the state to each new tab that is opened",
410     value: true,
411   },
412   {
413     name: "places",
414     factory: () => new lazy.PlacesFeed(),
415     title: "Listens for and relays various Places-related events",
416     value: true,
417   },
418   {
419     name: "prefs",
420     factory: () => new lazy.PrefsFeed(PREFS_CONFIG),
421     title: "Preferences",
422     value: true,
423   },
424   {
425     name: "sections",
426     factory: () => new lazy.SectionsFeed(),
427     title: "Manages sections",
428     value: true,
429   },
430   {
431     name: "section.highlights",
432     factory: () => new lazy.HighlightsFeed(),
433     title: "Fetches content recommendations from places db",
434     value: false,
435   },
436   {
437     name: "system.topstories",
438     factory: () =>
439       new lazy.TopStoriesFeed(PREFS_CONFIG.get("discoverystream.config")),
440     title:
441       "System pref that fetches content recommendations from a configurable content provider",
442     // Dynamically determine if Pocket should be shown for a geo / locale
443     getValue: ({ geo, locale }) => {
444       // If we don't have geo, we don't want to flash the screen with stories while geo loads.
445       // Best to display nothing until geo is ready.
446       if (!geo) {
447         return false;
448       }
449       const preffedRegionsBlockString =
450         lazy.NimbusFeatures.pocketNewtab.getVariable("regionStoriesBlock") ||
451         "";
452       const preffedRegionsString =
453         lazy.NimbusFeatures.pocketNewtab.getVariable("regionStoriesConfig") ||
454         "";
455       const preffedLocaleListString =
456         lazy.NimbusFeatures.pocketNewtab.getVariable("localeListConfig") || "";
457       const preffedBlockRegions = preffedRegionsBlockString
458         .split(",")
459         .map(s => s.trim());
460       const preffedRegions = preffedRegionsString.split(",").map(s => s.trim());
461       const preffedLocales = preffedLocaleListString
462         .split(",")
463         .map(s => s.trim());
464       const locales = {
465         US: ["en-CA", "en-GB", "en-US"],
466         CA: ["en-CA", "en-GB", "en-US"],
467         GB: ["en-CA", "en-GB", "en-US"],
468         AU: ["en-CA", "en-GB", "en-US"],
469         NZ: ["en-CA", "en-GB", "en-US"],
470         IN: ["en-CA", "en-GB", "en-US"],
471         IE: ["en-CA", "en-GB", "en-US"],
472         ZA: ["en-CA", "en-GB", "en-US"],
473         CH: ["de"],
474         BE: ["de"],
475         DE: ["de"],
476         AT: ["de"],
477         IT: ["it"],
478         FR: ["fr"],
479         ES: ["es-ES"],
480         PL: ["pl"],
481         JP: ["ja", "ja-JP-mac"],
482       }[geo];
484       const regionBlocked = preffedBlockRegions.includes(geo);
485       const localeEnabled = locale && preffedLocales.includes(locale);
486       const regionEnabled =
487         preffedRegions.includes(geo) && !!locales && locales.includes(locale);
488       return !regionBlocked && (localeEnabled || regionEnabled);
489     },
490   },
491   {
492     name: "systemtick",
493     factory: () => new lazy.SystemTickFeed(),
494     title: "Produces system tick events to periodically check for data expiry",
495     value: true,
496   },
497   {
498     name: "telemetry",
499     factory: () => new lazy.TelemetryFeed(),
500     title: "Relays telemetry-related actions to PingCentre",
501     value: true,
502   },
503   {
504     name: "favicon",
505     factory: () => new lazy.FaviconFeed(),
506     title: "Fetches tippy top manifests from remote service",
507     value: true,
508   },
509   {
510     name: "system.topsites",
511     factory: () => new lazy.TopSitesFeed(),
512     title: "Queries places and gets metadata for Top Sites section",
513     value: true,
514   },
515   {
516     name: "recommendationprovider",
517     factory: () => new lazy.RecommendationProvider(),
518     title: "Handles setup and interaction for the personality provider",
519     value: true,
520   },
521   {
522     name: "discoverystreamfeed",
523     factory: () => new lazy.DiscoveryStreamFeed(),
524     title: "Handles new pocket ui for the new tab page",
525     value: true,
526   },
529 const FEEDS_CONFIG = new Map();
530 for (const config of FEEDS_DATA) {
531   const pref = `feeds.${config.name}`;
532   FEEDS_CONFIG.set(pref, config.factory);
533   PREFS_CONFIG.set(pref, config);
536 export class ActivityStream {
537   /**
538    * constructor - Initializes an instance of ActivityStream
539    */
540   constructor() {
541     this.initialized = false;
542     this.store = new lazy.Store();
543     this.feeds = FEEDS_CONFIG;
544     this._defaultPrefs = new lazy.DefaultPrefs(PREFS_CONFIG);
545   }
547   init() {
548     try {
549       this._updateDynamicPrefs();
550       this._defaultPrefs.init();
551       Services.obs.addObserver(this, "intl:app-locales-changed");
553       // Look for outdated user pref values that might have been accidentally
554       // persisted when restoring the original pref value at the end of an
555       // experiment across versions with a different default value.
556       const DS_CONFIG =
557         "browser.newtabpage.activity-stream.discoverystream.config";
558       if (
559         Services.prefs.prefHasUserValue(DS_CONFIG) &&
560         [
561           // Firefox 66
562           `{"api_key_pref":"extensions.pocket.oAuthConsumerKey","enabled":false,"show_spocs":true,"layout_endpoint":"https://getpocket.com/v3/newtab/layout?version=1&consumer_key=$apiKey&layout_variant=basic"}`,
563           // Firefox 67
564           `{"api_key_pref":"extensions.pocket.oAuthConsumerKey","enabled":false,"show_spocs":true,"layout_endpoint":"https://getpocket.cdn.mozilla.net/v3/newtab/layout?version=1&consumer_key=$apiKey&layout_variant=basic"}`,
565           // Firefox 68
566           `{"api_key_pref":"extensions.pocket.oAuthConsumerKey","collapsible":true,"enabled":false,"show_spocs":true,"hardcoded_layout":true,"personalized":false,"layout_endpoint":"https://getpocket.cdn.mozilla.net/v3/newtab/layout?version=1&consumer_key=$apiKey&layout_variant=basic"}`,
567         ].includes(Services.prefs.getStringPref(DS_CONFIG))
568       ) {
569         Services.prefs.clearUserPref(DS_CONFIG);
570       }
572       // Hook up the store and let all feeds and pages initialize
573       this.store.init(
574         this.feeds,
575         ac.BroadcastToContent({
576           type: at.INIT,
577           data: {
578             locale: this.locale,
579           },
580           meta: {
581             isStartup: true,
582           },
583         }),
584         { type: at.UNINIT }
585       );
587       this.initialized = true;
588     } catch (e) {
589       // TelemetryFeed could be unavailable if the telemetry is disabled, or
590       // the telemetry feed is not yet initialized.
591       const telemetryFeed = this.store.feeds.get("feeds.telemetry");
592       if (telemetryFeed) {
593         telemetryFeed.handleUndesiredEvent({
594           data: { event: "ADDON_INIT_FAILED" },
595         });
596       }
597       throw e;
598     }
599   }
601   /**
602    * Check if an old pref has a custom value to migrate. Clears the pref so that
603    * it's the default after migrating (to avoid future need to migrate).
604    *
605    * @param oldPrefName {string} Pref to check and migrate
606    * @param cbIfNotDefault {function} Callback that gets the current pref value
607    */
608   _migratePref(oldPrefName, cbIfNotDefault) {
609     // Nothing to do if the user doesn't have a custom value
610     if (!Services.prefs.prefHasUserValue(oldPrefName)) {
611       return;
612     }
614     // Figure out what kind of pref getter to use
615     let prefGetter;
616     switch (Services.prefs.getPrefType(oldPrefName)) {
617       case Services.prefs.PREF_BOOL:
618         prefGetter = "getBoolPref";
619         break;
620       case Services.prefs.PREF_INT:
621         prefGetter = "getIntPref";
622         break;
623       case Services.prefs.PREF_STRING:
624         prefGetter = "getStringPref";
625         break;
626     }
628     // Give the callback the current value then clear the pref
629     cbIfNotDefault(Services.prefs[prefGetter](oldPrefName));
630     Services.prefs.clearUserPref(oldPrefName);
631   }
633   uninit() {
634     if (this.geo === "") {
635       Services.obs.removeObserver(this, lazy.Region.REGION_TOPIC);
636     }
638     Services.obs.removeObserver(this, "intl:app-locales-changed");
640     this.store.uninit();
641     this.initialized = false;
642   }
644   _updateDynamicPrefs() {
645     // Save the geo pref if we have it
646     if (lazy.Region.home) {
647       this.geo = lazy.Region.home;
648     } else if (this.geo !== "") {
649       // Watch for geo changes and use a dummy value for now
650       Services.obs.addObserver(this, lazy.Region.REGION_TOPIC);
651       this.geo = "";
652     }
654     this.locale = Services.locale.appLocaleAsBCP47;
656     // Update the pref config of those with dynamic values
657     for (const pref of PREFS_CONFIG.keys()) {
658       // Only need to process dynamic prefs
659       const prefConfig = PREFS_CONFIG.get(pref);
660       if (!prefConfig.getValue) {
661         continue;
662       }
664       // Have the dynamic pref just reuse using existing default, e.g., those
665       // set via Autoconfig or policy
666       try {
667         const existingDefault = this._defaultPrefs.get(pref);
668         if (existingDefault !== undefined && prefConfig.value === undefined) {
669           prefConfig.getValue = () => existingDefault;
670         }
671       } catch (ex) {
672         // We get NS_ERROR_UNEXPECTED for prefs that have a user value (causing
673         // default branch to believe there's a type) but no actual default value
674       }
676       // Compute the dynamic value (potentially generic based on dummy geo)
677       const newValue = prefConfig.getValue({
678         geo: this.geo,
679         locale: this.locale,
680       });
682       // If there's an existing value and it has changed, that means we need to
683       // overwrite the default with the new value.
684       if (prefConfig.value !== undefined && prefConfig.value !== newValue) {
685         this._defaultPrefs.set(pref, newValue);
686       }
688       prefConfig.value = newValue;
689     }
690   }
692   observe(subject, topic, data) {
693     switch (topic) {
694       case "intl:app-locales-changed":
695       case lazy.Region.REGION_TOPIC:
696         this._updateDynamicPrefs();
697         break;
698     }
699   }