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