Bug 1565293 - Add What's New, sized welcome and bug fixes to New Tab Page r=r1cky a=RyanVM
☠☠ backed out by 03b028ae464b ☠ ☠
authorEd Lee <edilee@mozilla.com>
Thu, 11 Jul 2019 21:50:17 +0000
changeset 541706 a547b44c0ec3629502767bb2480c4b67a3b75cba
parent 541705 d00a9af203d160c2584500ad7c52a27658142342
child 541707 27c6f94fdb170b8713aca8227e9f0f572faee5b0
push id11687
push userapavel@mozilla.com
push dateFri, 02 Aug 2019 04:37:38 +0000
treeherdermozilla-beta@27c6f94fdb17 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersr1cky, RyanVM
bugs1565293
milestone69.0
Bug 1565293 - Add What's New, sized welcome and bug fixes to New Tab Page r=r1cky a=RyanVM Differential Revision: https://phabricator.services.mozilla.com/D37761
browser/components/newtab/bin/bootstrap
browser/components/newtab/bin/render-activity-stream-html.js
browser/components/newtab/content-src/asrouter/asrouter-content.jsx
browser/components/newtab/content-src/asrouter/docs/targeting-attributes.md
browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json
browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json
browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss
browser/components/newtab/content-src/asrouter/templates/Trailhead/_Trailhead.scss
browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx
browser/components/newtab/css/activity-stream-linux.css
browser/components/newtab/css/activity-stream-mac.css
browser/components/newtab/css/activity-stream-windows.css
browser/components/newtab/data/content/activity-stream.bundle.js
browser/components/newtab/data/content/assets/whatsnew-send-icon.png
browser/components/newtab/docs/v2-system-addon/1.GETTING_STARTED.md
browser/components/newtab/hooks/post-commit
browser/components/newtab/hooks/pre-commit
browser/components/newtab/hooks/pre-push
browser/components/newtab/lib/ASRouter.jsm
browser/components/newtab/lib/ASRouterTargeting.jsm
browser/components/newtab/lib/AboutPreferences.jsm
browser/components/newtab/lib/BookmarkPanelHub.jsm
browser/components/newtab/lib/LinksCache.jsm
browser/components/newtab/lib/PanelTestProvider.jsm
browser/components/newtab/lib/PersistentCache.jsm
browser/components/newtab/lib/Tokenize.jsm
browser/components/newtab/lib/ToolbarPanelHub.jsm
browser/components/newtab/test/browser/browser_asrouter_targeting.js
browser/components/newtab/test/unit/asrouter/ASRouter.test.js
browser/components/newtab/test/unit/asrouter/ASRouterFeed.test.js
browser/components/newtab/test/unit/asrouter/ASRouterTargeting.test.js
browser/components/newtab/test/unit/asrouter/PanelTestProvider.test.js
browser/components/newtab/test/unit/asrouter/templates/OnboardingMessage.test.jsx
browser/components/newtab/test/unit/lib/AboutPreferences.test.js
browser/components/newtab/test/unit/lib/BookmarkPanelHub.test.js
browser/components/newtab/test/unit/lib/LinksCache.test.js
browser/components/newtab/test/unit/lib/PersistentCache.test.js
browser/components/newtab/test/unit/lib/ToolbarBadgeHub.test.js
browser/components/newtab/test/unit/lib/ToolbarPanelHub.test.js
browser/components/newtab/test/unit/unit-entry.js
new file mode 100755
--- /dev/null
+++ b/browser/components/newtab/bin/bootstrap
@@ -0,0 +1,5 @@
+#!/bin/sh -x
+
+# bootstrap an activity-stream repo
+ln -s ../../hooks/pre-commit .git/hooks/pre-commit
+ln -s ../../hooks/post-commit .git/hooks/post-commit
--- a/browser/components/newtab/bin/render-activity-stream-html.js
+++ b/browser/components/newtab/bin/render-activity-stream-html.js
@@ -33,17 +33,22 @@ function templateHTML(options) {
     `${options.baseUrl}data/content/activity-stream.bundle.js`,
   ];
 
   // Add spacing and script tags
   const scriptRender = `\n${scripts
     .map(script => `    <script src="${script}"></script>`)
     .join("\n")}`;
 
-  return `<!doctype html>
+  return `
+<!-- 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/. -->
+
+<!doctype html>
 <html>
   <head>
     <meta charset="utf-8">
     <meta http-equiv="Content-Security-Policy" content="default-src 'none'; object-src 'none'; script-src resource: chrome:; connect-src https:; img-src https: data: blob:; style-src 'unsafe-inline';">
     <title data-l10n-id="newtab-page-title"></title>
     <link rel="icon" type="image/png" href="chrome://branding/content/icon32.png"/>
     <link rel="localization" href="browser/branding/brandings.ftl" />
     <link rel="localization" href="browser/newtab/newtab.ftl" />
@@ -53,17 +58,17 @@ function templateHTML(options) {
   <body class="activity-stream">
     <div id="header-asrouter-container" role="presentation"></div>
     <div id="root"></div>
     <div id="footer-asrouter-container" role="presentation"></div>${
       options.noscripts ? "" : scriptRender
     }
   </body>
 </html>
-`;
+`.trimStart();
 }
 
 /**
  * writeFiles - Writes to the desired files the result of a template given
  * various prerendered data and options.
  *
  * @param {string} destPath      Path to write the files to
  * @param {Map}    filesMap      Mapping of a string file name to templater
--- a/browser/components/newtab/content-src/asrouter/asrouter-content.jsx
+++ b/browser/components/newtab/content-src/asrouter/asrouter-content.jsx
@@ -264,19 +264,22 @@ export class ASRouterUISurface extends R
 
   componentWillUnmount() {
     ASRouterUtils.removeListener(this.onMessageFromParent);
   }
 
   renderSnippets() {
     if (
       this.state.bundle.template === "onboarding" ||
-      this.state.message.template === "fxa_overlay" ||
-      this.state.message.template === "return_to_amo_overlay" ||
-      this.state.message.template === "trailhead"
+      [
+        "fxa_overlay",
+        "return_to_amo_overlay",
+        "trailhead",
+        "whatsnew_panel_message",
+      ].includes(this.state.message.template)
     ) {
       return null;
     }
     const SnippetComponent = SnippetsTemplates[this.state.message.template];
     const { content } = this.state.message;
 
     return (
       <ImpressionsWrapper
--- a/browser/components/newtab/content-src/asrouter/docs/targeting-attributes.md
+++ b/browser/components/newtab/content-src/asrouter/docs/targeting-attributes.md
@@ -29,16 +29,19 @@ Please note that some targeting attribut
 * [topFrecentSites](#topfrecentsites)
 * [totalBookmarksCount](#totalbookmarkscount)
 * [trailheadInterrupt](#trailheadinterrupt)
 * [trailheadTriplet](#trailheadtriplet)
 * [usesFirefoxSync](#usesfirefoxsync)
 * [isFxAEnabled](#isFxAEnabled)
 * [xpinstallEnabled](#xpinstallEnabled)
 * [hasPinnedTabs](#haspinnedtabs)
+* [hasAccessedFxAPanel](#hasaccessedfxapanel)
+* [isWhatsNewPanelEnabled](#iswhatsnewpanelenabled)
+* [earliestFirefoxVersion](#earliestfirefoxversion)
 
 ## Detailed usage
 
 ### `addonsInfo`
 Provides information about the add-ons the user has installed.
 
 Note that the `name`, `userDisabled`, and `installDate` is only available if `isFullData` is `true` (this is usually not the case right at start-up).
 
@@ -469,8 +472,38 @@ declare const xpinstallEnabled: boolean;
 
 Does the user have any pinned tabs in any windows.
 
 #### Definition
 
 ```ts
 declare const hasPinnedTabs: boolean;
 ```
+
+### `hasAccessedFxAPanel`
+
+Boolean pref that gets set the first time the user opens the FxA toolbar panel
+
+#### Definition
+
+```ts
+declare const hasAccessedFxAPanel: boolean;
+```
+
+### `isWhatsNewPanelEnabled`
+
+Boolean pref that controls if the What's New panel feature is enabled
+
+#### Definition
+
+```ts
+declare const isWhatsNewPanelEnabled: boolean;
+```
+
+### `earliestFirefoxVersion`
+
+Integer value of the first Firefox version the profile ran on
+
+#### Definition
+
+```ts
+declare const earliestFirefoxVersion: boolean;
+```
new file mode 100644
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json
@@ -0,0 +1,28 @@
+{
+  "title": "ToolbarBadgeMessage",
+  "description": "A template that specifies to which element in the browser toolbar to add a notification.",
+  "version": "1.0.0",
+  "type": "object",
+  "properties": {
+    "target": {
+      "type": "string"
+    },
+    "action": {
+      "type": "object",
+      "properties": {
+        "id": {
+          "type": "string"
+        }
+      },
+      "additionalProperties": false,
+      "required": ["id"],
+      "description": "Optional action to take in addition to showing the notification"
+    },
+    "delay": {
+      "type": "number",
+      "description": "Optional delay in ms after which to show the notification"
+    }
+  },
+  "additionalProperties": false,
+  "required": ["target"]
+}
new file mode 100644
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json
@@ -0,0 +1,62 @@
+{
+  "title": "WhatsNewMessage",
+  "description": "A template for the messages that appear in the What's New panel.",
+  "version": "1.0.0",
+  "type": "object",
+  "definitions": {
+    "localizableText": {
+      "oneOf": [
+        {
+          "type": "string",
+          "description": "The string to be rendered."
+        },
+        {
+          "type": "object",
+          "properties": {
+            "string_id": {
+              "type": "string"
+            }
+          },
+          "required": ["string_id"],
+          "description": "Id of localized string to be rendered."
+        }
+      ]
+    }
+  },
+  "properties": {
+    "published_date": {
+      "type": "integer",
+      "description": "The date/time (number of milliseconds elapsed since January 1, 1970 00:00:00 UTC) the message was published."
+    },
+    "title": {
+      "allOf": [
+        {"$ref": "#/definitions/localizableText"},
+        {"description": "Id of localized string or message override of What's New message title"}
+      ]
+    },
+    "body": {
+      "allOf": [
+        {"$ref": "#/definitions/localizableText"},
+        {"description": "Id of localized string or message override of What's New message body"}
+      ]
+    },
+    "link_text": {
+      "allOf": [
+        {"$ref": "#/definitions/localizableText"},
+        {"description": "(optional) Id of localized string or message override of What's New message link text"}
+      ]
+    },
+    "cta_url": {
+      "description": "Target URL for the What's New message.",
+      "type": "string",
+      "format": "uri"
+    },
+    "icon_url": {
+      "description": "(optional) URL for the What's New message icon.",
+      "type": "string",
+      "format": "uri"
+    }
+  },
+  "additionalProperties": false,
+  "required": ["published_date", "title", "body", "cta_url"]
+}
--- a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss
+++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss
@@ -134,17 +134,18 @@
   height: 100px;
   width: 120px;
   background-size: 120px;
   background-position: center center;
   background-repeat: no-repeat;
   display: inline-block;
   vertical-align: middle;
 
-  @media(max-width: 850px) {
+  // Cards will wrap into the next line after this breakpoint
+  @media(max-width: 865px) {
     height: 75px;
     min-width: 80px;
     background-size: 80px;
   }
 
   &.addons {
     background-image: url('#{$image-path}illustration-addons@2x.png');
   }
--- a/browser/components/newtab/content-src/asrouter/templates/Trailhead/_Trailhead.scss
+++ b/browser/components/newtab/content-src/asrouter/templates/Trailhead/_Trailhead.scss
@@ -314,16 +314,17 @@
 
 .trailheadCardGrid {
   display: grid;
   grid-gap: $base-gutter;
   margin: 0;
   opacity: 0;
   transition: opacity 0.4s;
   transition-delay: 0.1s;
+  grid-auto-rows: 1fr;
 
   &.show {
     opacity: 1;
   }
 
   @media (min-width: $break-point-medium) {
     grid-template-columns: repeat(auto-fit, $card-width);
   }
--- a/browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx
+++ b/browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx
@@ -67,30 +67,23 @@ export class ContextMenu extends React.P
   }
 }
 
 export class ContextMenuItem extends React.PureComponent {
   constructor(props) {
     super(props);
     this.onClick = this.onClick.bind(this);
     this.onKeyDown = this.onKeyDown.bind(this);
-    this.focusFirst = this.focusFirst.bind(this);
   }
 
   onClick() {
     this.props.hideContext();
     this.props.option.onClick();
   }
 
-  focusFirst(button) {
-    if (button) {
-      button.focus();
-    }
-  }
-
   // This selects the correct node based on the key pressed
   focusSibling(target, key) {
     const parent = target.parentNode;
     const closestSiblingSelector =
       key === "ArrowUp" ? "previousSibling" : "nextSibling";
     if (!parent[closestSiblingSelector]) {
       return;
     }
@@ -136,17 +129,16 @@ export class ContextMenuItem extends Rea
     const { option } = this.props;
     return (
       <li role="menuitem" className="context-menu-item">
         <button
           className={option.disabled ? "disabled" : ""}
           tabIndex="0"
           onClick={this.onClick}
           onKeyDown={this.onKeyDown}
-          ref={option.first ? this.focusFirst : null}
         >
           {option.icon && (
             <span className={`icon icon-spacer icon-${option.icon}`} />
           )}
           <span data-l10n-id={option.string_id || option.id} />
         </button>
       </li>
     );
--- a/browser/components/newtab/css/activity-stream-linux.css
+++ b/browser/components/newtab/css/activity-stream-linux.css
@@ -3508,17 +3508,17 @@ body[lwt-newtab-brighttext] .scene2Icon 
 .onboardingMessageImage {
   height: 100px;
   width: 120px;
   background-size: 120px;
   background-position: center center;
   background-repeat: no-repeat;
   display: inline-block;
   vertical-align: middle; }
-  @media (max-width: 850px) {
+  @media (max-width: 865px) {
     .onboardingMessageImage {
       height: 75px;
       min-width: 80px;
       background-size: 80px; } }
   .onboardingMessageImage.addons {
     background-image: url("../data/content/assets/illustration-addons@2x.png"); }
   .onboardingMessageImage.privatebrowsing {
     background-image: url("../data/content/assets/illustration-privatebrowsing@2x.png"); }
@@ -4045,17 +4045,18 @@ a.firstrun-link {
       background-color: var(--newtab-element-hover-color); }
 
 .trailheadCardGrid {
   display: grid;
   grid-gap: 32px;
   margin: 0;
   opacity: 0;
   transition: opacity 0.4s;
-  transition-delay: 0.1s; }
+  transition-delay: 0.1s;
+  grid-auto-rows: 1fr; }
   .trailheadCardGrid.show {
     opacity: 1; }
   @media (min-width: 610px) {
     .trailheadCardGrid {
       grid-template-columns: repeat(auto-fit, 224px); } }
   @media (min-width: 1122px) {
     .trailheadCardGrid {
       grid-template-columns: repeat(auto-fit, 309px); } }
--- a/browser/components/newtab/css/activity-stream-mac.css
+++ b/browser/components/newtab/css/activity-stream-mac.css
@@ -3511,17 +3511,17 @@ body[lwt-newtab-brighttext] .scene2Icon 
 .onboardingMessageImage {
   height: 100px;
   width: 120px;
   background-size: 120px;
   background-position: center center;
   background-repeat: no-repeat;
   display: inline-block;
   vertical-align: middle; }
-  @media (max-width: 850px) {
+  @media (max-width: 865px) {
     .onboardingMessageImage {
       height: 75px;
       min-width: 80px;
       background-size: 80px; } }
   .onboardingMessageImage.addons {
     background-image: url("../data/content/assets/illustration-addons@2x.png"); }
   .onboardingMessageImage.privatebrowsing {
     background-image: url("../data/content/assets/illustration-privatebrowsing@2x.png"); }
@@ -4048,17 +4048,18 @@ a.firstrun-link {
       background-color: var(--newtab-element-hover-color); }
 
 .trailheadCardGrid {
   display: grid;
   grid-gap: 32px;
   margin: 0;
   opacity: 0;
   transition: opacity 0.4s;
-  transition-delay: 0.1s; }
+  transition-delay: 0.1s;
+  grid-auto-rows: 1fr; }
   .trailheadCardGrid.show {
     opacity: 1; }
   @media (min-width: 610px) {
     .trailheadCardGrid {
       grid-template-columns: repeat(auto-fit, 224px); } }
   @media (min-width: 1122px) {
     .trailheadCardGrid {
       grid-template-columns: repeat(auto-fit, 309px); } }
--- a/browser/components/newtab/css/activity-stream-windows.css
+++ b/browser/components/newtab/css/activity-stream-windows.css
@@ -3508,17 +3508,17 @@ body[lwt-newtab-brighttext] .scene2Icon 
 .onboardingMessageImage {
   height: 100px;
   width: 120px;
   background-size: 120px;
   background-position: center center;
   background-repeat: no-repeat;
   display: inline-block;
   vertical-align: middle; }
-  @media (max-width: 850px) {
+  @media (max-width: 865px) {
     .onboardingMessageImage {
       height: 75px;
       min-width: 80px;
       background-size: 80px; } }
   .onboardingMessageImage.addons {
     background-image: url("../data/content/assets/illustration-addons@2x.png"); }
   .onboardingMessageImage.privatebrowsing {
     background-image: url("../data/content/assets/illustration-privatebrowsing@2x.png"); }
@@ -4045,17 +4045,18 @@ a.firstrun-link {
       background-color: var(--newtab-element-hover-color); }
 
 .trailheadCardGrid {
   display: grid;
   grid-gap: 32px;
   margin: 0;
   opacity: 0;
   transition: opacity 0.4s;
-  transition-delay: 0.1s; }
+  transition-delay: 0.1s;
+  grid-auto-rows: 1fr; }
   .trailheadCardGrid.show {
     opacity: 1; }
   @media (min-width: 610px) {
     .trailheadCardGrid {
       grid-template-columns: repeat(auto-fit, 224px); } }
   @media (min-width: 1122px) {
     .trailheadCardGrid {
       grid-template-columns: repeat(auto-fit, 309px); } }
--- a/browser/components/newtab/data/content/activity-stream.bundle.js
+++ b/browser/components/newtab/data/content/activity-stream.bundle.js
@@ -96,16 +96,19 @@
 /* harmony import */ var content_src_lib_init_store__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(6);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(24);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_4__);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_5___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_5__);
 /* harmony import */ var react_dom__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(14);
 /* harmony import */ var react_dom__WEBPACK_IMPORTED_MODULE_6___default = /*#__PURE__*/__webpack_require__.n(react_dom__WEBPACK_IMPORTED_MODULE_6__);
 /* harmony import */ var common_Reducers_jsm__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(53);
+/* 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/. */
 
 
 
 
 
 
 
 
@@ -564,16 +567,19 @@ var actionUtils = {
 /* harmony import */ var content_src_components_DiscoveryStreamBase_DiscoveryStreamBase__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(49);
 /* harmony import */ var content_src_components_ErrorBoundary_ErrorBoundary__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(31);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_7___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_7__);
 /* harmony import */ var content_src_components_Search_Search__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(47);
 /* harmony import */ var content_src_components_Sections_Sections__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(36);
 function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
 
+/* 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/. */
 
 
 
 
 
 
 
 
@@ -754,16 +760,19 @@ const Base = Object(react_redux__WEBPACK
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(24);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_2__);
 /* harmony import */ var _asrouter_components_ModalOverlay_ModalOverlay__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(13);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_4__);
 /* harmony import */ var _SimpleHashRouter__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(26);
 function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
 
+/* 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/. */
 
 
 
 
 
 
 
 const Row = props => react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("tr", _extends({
@@ -1719,16 +1728,19 @@ const ASRouterAdmin = Object(react_redux
 /* harmony import */ var react_dom__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(14);
 /* harmony import */ var react_dom__WEBPACK_IMPORTED_MODULE_8___default = /*#__PURE__*/__webpack_require__.n(react_dom__WEBPACK_IMPORTED_MODULE_8__);
 /* harmony import */ var _templates_ReturnToAMO_ReturnToAMO__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(15);
 /* harmony import */ var _templates_template_manifest__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(51);
 /* harmony import */ var _templates_StartupOverlay_StartupOverlay__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(23);
 /* harmony import */ var _templates_Trailhead_Trailhead__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(25);
 function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
 
+/* 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/. */
 
 
 
 
 
 
 
 
@@ -2067,17 +2079,17 @@ class ASRouterUISurface extends react__W
     }
   }
 
   componentWillUnmount() {
     ASRouterUtils.removeListener(this.onMessageFromParent);
   }
 
   renderSnippets() {
-    if (this.state.bundle.template === "onboarding" || this.state.message.template === "fxa_overlay" || this.state.message.template === "return_to_amo_overlay" || this.state.message.template === "trailhead") {
+    if (this.state.bundle.template === "onboarding" || ["fxa_overlay", "return_to_amo_overlay", "trailhead", "whatsnew_panel_message"].includes(this.state.message.template)) {
       return null;
     }
 
     const SnippetComponent = _templates_template_manifest__WEBPACK_IMPORTED_MODULE_10__["SnippetsTemplates"][this.state.message.template];
     const {
       content
     } = this.state.message;
     return react__WEBPACK_IMPORTED_MODULE_7___default.a.createElement(_components_ImpressionsWrapper_ImpressionsWrapper__WEBPACK_IMPORTED_MODULE_3__["ImpressionsWrapper"], {
@@ -2211,16 +2223,20 @@ ASRouterUISurface.defaultProps = {
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "INCOMING_MESSAGE_NAME", function() { return INCOMING_MESSAGE_NAME; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "EARLY_QUEUED_ACTIONS", function() { return EARLY_QUEUED_ACTIONS; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "rehydrationMiddleware", function() { return rehydrationMiddleware; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "queueEarlyMessageMiddleware", function() { return queueEarlyMessageMiddleware; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "initStore", function() { return initStore; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var redux__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(7);
 /* harmony import */ var redux__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(redux__WEBPACK_IMPORTED_MODULE_1__);
+/* 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/. */
+
 /* eslint-env mozilla/frame-script */
 
 
 const MERGE_STORE_ACTION = "NEW_TAB_INITIAL_STATE";
 const OUTGOING_MESSAGE_NAME = "ActivityStream:ContentToMain";
 const INCOMING_MESSAGE_NAME = "ActivityStream:MainToContent";
 const EARLY_QUEUED_ACTIONS = [common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].SAVE_SESSION_PERF_DATA];
 /**
@@ -2372,16 +2388,19 @@ module.exports = Redux;
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "VISIBLE", function() { return VISIBLE; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "VISIBILITY_CHANGE_EVENT", function() { return VISIBILITY_CHANGE_EVENT; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ImpressionsWrapper", function() { return ImpressionsWrapper; });
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
+/* 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/. */
 
 const VISIBLE = "visible";
 const VISIBILITY_CHANGE_EVENT = "visibilitychange";
 /**
  * Component wrapper used to send telemetry pings on every impression.
  */
 
 class ImpressionsWrapper extends react__WEBPACK_IMPORTED_MODULE_0___default.a.PureComponent {
@@ -2458,16 +2477,19 @@ module.exports = PropTypes;
 /***/ }),
 /* 11 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "IS_NEWTAB", function() { return IS_NEWTAB; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "NEWTAB_DARK_THEME", function() { return NEWTAB_DARK_THEME; });
+/* 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/. */
 const IS_NEWTAB = global.document && global.document.documentURI === "about:newtab";
 const NEWTAB_DARK_THEME = {
   ntp_background: {
     r: 42,
     g: 42,
     b: 46,
     a: 1
   },
@@ -2500,16 +2522,19 @@ const NEWTAB_DARK_THEME = {
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "OnboardingCard", function() { return OnboardingCard; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "OnboardingMessage", function() { return OnboardingMessage; });
 /* harmony import */ var _components_ModalOverlay_ModalOverlay__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(13);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_1__);
 function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
 
+/* 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/. */
 
 
 const FLUENT_FILES = ["branding/brand.ftl", "browser/branding/sync-brand.ftl", "browser/newtab/onboarding.ftl"];
 class OnboardingCard extends react__WEBPACK_IMPORTED_MODULE_1___default.a.PureComponent {
   constructor(props) {
     super(props);
     this.onClick = this.onClick.bind(this);
   }
@@ -2591,16 +2616,19 @@ class OnboardingMessage extends react__W
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ModalOverlayWrapper", function() { return ModalOverlayWrapper; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ModalOverlay", function() { return ModalOverlay; });
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
+/* 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/. */
 
 class ModalOverlayWrapper extends react__WEBPACK_IMPORTED_MODULE_0___default.a.PureComponent {
   constructor(props) {
     super(props);
     this.onKeyDown = this.onKeyDown.bind(this);
   }
 
   onKeyDown(event) {
@@ -2676,16 +2704,19 @@ module.exports = ReactDOM;
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ReturnToAMO", function() { return ReturnToAMO; });
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
 /* harmony import */ var _components_RichText_RichText__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(16);
+/* 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/. */
 
  // Alt text if available; in the future this should come from the server. See bug 1551711
 
 const ICON_ALT_TEXT = "";
 class ReturnToAMO extends react__WEBPACK_IMPORTED_MODULE_0___default.a.PureComponent {
   constructor(props) {
     super(props);
     this.onClickAddExtension = this.onClickAddExtension.bind(this);
@@ -2770,16 +2801,19 @@ class ReturnToAMO extends react__WEBPACK
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "RichText", function() { return RichText; });
 /* harmony import */ var fluent_react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(50);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_1__);
 /* harmony import */ var _rich_text_strings__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(52);
 /* harmony import */ var _template_utils__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(17);
 function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
 
+/* 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/. */
 
 
 
  // Elements allowed in snippet content
 
 const ALLOWED_TAGS = {
   b: react__WEBPACK_IMPORTED_MODULE_1___default.a.createElement("b", null),
   i: react__WEBPACK_IMPORTED_MODULE_1___default.a.createElement("i", null),
@@ -2835,16 +2869,19 @@ function RichText(props) {
 
 /***/ }),
 /* 17 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "safeURI", function() { return safeURI; });
+/* 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/. */
 function safeURI(url) {
   if (!url) {
     return "";
   }
 
   const {
     protocol
   } = new URL(url);
@@ -2895,16 +2932,19 @@ module.exports = {"title":"SendToDeviceS
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_StartupOverlay", function() { return _StartupOverlay; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "StartupOverlay", function() { return StartupOverlay; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(24);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_1__);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_2__);
+/* 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/. */
 
 
 
 const FLUENT_FILES = ["branding/brand.ftl", "browser/branding/sync-brand.ftl", "browser/newtab/onboarding.ftl"];
 class _StartupOverlay extends react__WEBPACK_IMPORTED_MODULE_2___default.a.PureComponent {
   constructor(props) {
     super(props);
     this.onInputChange = this.onInputChange.bind(this);
@@ -3199,16 +3239,19 @@ module.exports = ReactRedux;
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Trailhead", function() { return Trailhead; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var _components_ModalOverlay_ModalOverlay__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(13);
 /* harmony import */ var _OnboardingMessage_OnboardingMessage__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(12);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_3__);
 function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
 
+/* 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/. */
 
 
 
 
 const FLUENT_FILES = ["branding/brand.ftl", "browser/branding/brandings.ftl", "browser/branding/sync-brand.ftl", "browser/newtab/onboarding.ftl"]; // From resource://devtools/client/shared/focus.js
 
 const FOCUSABLE_SELECTOR = ["a[href]:not([tabindex='-1'])", "button:not([disabled]):not([tabindex='-1'])", "iframe:not([tabindex='-1'])", "input:not([disabled]):not([tabindex='-1'])", "select:not([disabled]):not([tabindex='-1'])", "textarea:not([disabled]):not([tabindex='-1'])", "[tabindex]:not([tabindex='-1'])"].join(", ");
 class Trailhead extends react__WEBPACK_IMPORTED_MODULE_3___default.a.PureComponent {
@@ -3615,16 +3658,19 @@ class Trailhead extends react__WEBPACK_I
 /* 26 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SimpleHashRouter", function() { return SimpleHashRouter; });
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
+/* 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/. */
 
 class SimpleHashRouter extends react__WEBPACK_IMPORTED_MODULE_0___default.a.PureComponent {
   constructor(props) {
     super(props);
     this.onHashChange = this.onHashChange.bind(this);
     this.state = {
       hash: global.location.hash
     };
@@ -3665,16 +3711,19 @@ class SimpleHashRouter extends react__WE
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_ConfirmDialog", function() { return _ConfirmDialog; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ConfirmDialog", function() { return ConfirmDialog; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(24);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_1__);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_2__);
+/* 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/. */
 
 
 
 /**
  * ConfirmDialog component.
  * One primary action button, one cancel button.
  *
  * Content displayed is controlled by `data` prop the component receives.
@@ -3764,16 +3813,19 @@ const ConfirmDialog = Object(react_redux
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ContextMenu", function() { return ContextMenu; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ContextMenuItem", function() { return ContextMenuItem; });
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
+/* 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/. */
 
 class ContextMenu extends react__WEBPACK_IMPORTED_MODULE_0___default.a.PureComponent {
   constructor(props) {
     super(props);
     this.hideContext = this.hideContext.bind(this);
     this.onShow = this.onShow.bind(this);
     this.onClick = this.onClick.bind(this);
   }
@@ -3829,28 +3881,21 @@ class ContextMenu extends react__WEBPACK
   }
 
 }
 class ContextMenuItem extends react__WEBPACK_IMPORTED_MODULE_0___default.a.PureComponent {
   constructor(props) {
     super(props);
     this.onClick = this.onClick.bind(this);
     this.onKeyDown = this.onKeyDown.bind(this);
-    this.focusFirst = this.focusFirst.bind(this);
   }
 
   onClick() {
     this.props.hideContext();
     this.props.option.onClick();
-  }
-
-  focusFirst(button) {
-    if (button) {
-      button.focus();
-    }
   } // This selects the correct node based on the key pressed
 
 
   focusSibling(target, key) {
     const parent = target.parentNode;
     const closestSiblingSelector = key === "ArrowUp" ? "previousSibling" : "nextSibling";
 
     if (!parent[closestSiblingSelector]) {
@@ -3903,18 +3948,17 @@ class ContextMenuItem extends react__WEB
     } = this.props;
     return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("li", {
       role: "menuitem",
       className: "context-menu-item"
     }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("button", {
       className: option.disabled ? "disabled" : "",
       tabIndex: "0",
       onClick: this.onClick,
-      onKeyDown: this.onKeyDown,
-      ref: option.first ? this.focusFirst : null
+      onKeyDown: this.onKeyDown
     }, option.icon && react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("span", {
       className: `icon icon-spacer icon-${option.icon}`
     }), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("span", {
       "data-l10n-id": option.string_id || option.id
     })));
   }
 
 }
@@ -3926,16 +3970,19 @@ class ContextMenuItem extends react__WEB
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "INTERSECTION_RATIO", function() { return INTERSECTION_RATIO; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ImpressionStats", function() { return ImpressionStats; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_1__);
+/* 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/. */
 
 
 const VISIBLE = "visible";
 const VISIBILITY_CHANGE_EVENT = "visibilitychange"; // Per analytical requirement, we set the minimal intersection ratio to
 // 0.5, and an impression is identified when the wrapped item has at least
 // 50% visibility.
 //
 // This constant is exported for unit test
@@ -4153,16 +4200,19 @@ ImpressionStats.defaultProps = {
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "CollapsibleSection", function() { return CollapsibleSection; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var content_src_components_ErrorBoundary_ErrorBoundary__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(31);
 /* harmony import */ var content_src_components_FluentOrText_FluentOrText__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(33);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_3__);
 /* harmony import */ var content_src_components_SectionMenu_SectionMenu__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(34);
 /* harmony import */ var content_src_lib_section_menu_options__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(35);
+/* 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/. */
 
 
 
 
 
 
 const VISIBLE = "visible";
 const VISIBILITY_CHANGE_EVENT = "visibilitychange";
@@ -4449,16 +4499,19 @@ CollapsibleSection.defaultProps = {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ErrorBoundaryFallback", function() { return ErrorBoundaryFallback; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ErrorBoundary", function() { return ErrorBoundary; });
 /* harmony import */ var content_src_components_A11yLinkButton_A11yLinkButton__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(32);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_1__);
+/* 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/. */
 
 
 class ErrorBoundaryFallback extends react__WEBPACK_IMPORTED_MODULE_1___default.a.PureComponent {
   constructor(props) {
     super(props);
     this.windowObj = this.props.windowObj || window;
     this.onClick = this.onClick.bind(this);
   }
@@ -4533,16 +4586,19 @@ ErrorBoundary.defaultProps = {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "A11yLinkButton", function() { return A11yLinkButton; });
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
 function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
 
+/* 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/. */
 
 function A11yLinkButton(props) {
   // function for merging classes, if necessary
   let className = "a11y-link-button";
 
   if (props.className) {
     className += ` ${props.className}`;
   }
@@ -4558,16 +4614,19 @@ function A11yLinkButton(props) {
 /* 33 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "FluentOrText", function() { return FluentOrText; });
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
+/* 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/. */
 
 /**
  * Set text on a child element/component depending on if the message is already
  * translated plain text or a fluent id with optional args.
  */
 
 class FluentOrText extends react__WEBPACK_IMPORTED_MODULE_0___default.a.PureComponent {
   render() {
@@ -4605,16 +4664,19 @@ class FluentOrText extends react__WEBPAC
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_SectionMenu", function() { return _SectionMenu; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SectionMenu", function() { return SectionMenu; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var content_src_components_ContextMenu_ContextMenu__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(28);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_2__);
 /* harmony import */ var content_src_lib_section_menu_options__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(35);
+/* 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/. */
 
 
 
 
 const DEFAULT_SECTION_MENU_OPTIONS = ["MoveUp", "MoveDown", "Separator", "RemoveSection", "CheckCollapsed", "Separator", "ManageSection"];
 const WEBEXT_SECTION_MENU_OPTIONS = ["MoveUp", "MoveDown", "Separator", "CheckCollapsed", "Separator", "ManageWebExtension"];
 class _SectionMenu extends react__WEBPACK_IMPORTED_MODULE_2___default.a.PureComponent {
   getOptions() {
@@ -4681,16 +4743,19 @@ const SectionMenu = _SectionMenu;
 /***/ }),
 /* 35 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SectionMenuOptions", function() { return SectionMenuOptions; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
+/* 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/. */
 
 /**
  * List of functions that return items that can be included as menu options in a
  * SectionMenu. All functions take the section as the only parameter.
  */
 
 const SectionMenuOptions = {
   Separator: () => ({
@@ -4825,16 +4890,19 @@ const SectionMenuOptions = {
 /* harmony import */ var content_src_components_MoreRecommendations_MoreRecommendations__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(40);
 /* harmony import */ var content_src_components_PocketLoggedInCta_PocketLoggedInCta__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(41);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_8___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_8__);
 /* harmony import */ var content_src_components_Topics_Topics__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(42);
 /* harmony import */ var content_src_components_TopSites_TopSites__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(43);
 function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
 
+/* 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/. */
 
 
 
 
 
 
 
 
@@ -5175,16 +5243,20 @@ const Sections = Object(react_redux__WEB
 
 /***/ }),
 /* 37 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ScreenshotUtils", function() { return ScreenshotUtils; });
+/* 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/. */
+
 /**
  * List of helper functions for screenshot-based images.
  *
  * There are two kinds of images:
  * 1. Remote Image: This is the image from the main process and it refers to
  *    the image in the React props. This can either be an object with the `data`
  *    and `path` properties, if it is a blob, or a string, if it is a normal image.
  * 2. Local Image: This is the image object in the content process and it refers
@@ -5244,16 +5316,19 @@ const ScreenshotUtils = {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ComponentPerfTimer", function() { return ComponentPerfTimer; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var common_PerfService_jsm__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(39);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_2__);
+/* 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/. */
 
 
  // Currently record only a fixed set of sections. This will prevent data
 // from custom sections from showing up or from topstories.
 
 const RECORDED_SECTIONS = ["highlights", "topsites"];
 class ComponentPerfTimer extends react__WEBPACK_IMPORTED_MODULE_2___default.a.Component {
   constructor(props) {
@@ -5418,16 +5493,19 @@ class ComponentPerfTimer extends react__
 /***/ }),
 /* 39 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_PerfService", function() { return _PerfService; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "perfService", function() { return perfService; });
+/* 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/. */
 
 
 if (typeof ChromeUtils !== "undefined") {
   // Use a var here instead of let outside to avoid creating a locally scoped
   // variable that hides the global, which we modify for testing.
   // eslint-disable-next-line no-var, vars-on-top
   var {
     Services
@@ -5548,16 +5626,19 @@ var perfService = new _PerfService();
 /* 40 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "MoreRecommendations", function() { return MoreRecommendations; });
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
+/* 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/. */
 
 class MoreRecommendations extends react__WEBPACK_IMPORTED_MODULE_0___default.a.PureComponent {
   render() {
     const {
       read_more_endpoint
     } = this.props;
 
     if (read_more_endpoint) {
@@ -5580,16 +5661,19 @@ class MoreRecommendations extends react_
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_PocketLoggedInCta", function() { return _PocketLoggedInCta; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "PocketLoggedInCta", function() { return PocketLoggedInCta; });
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(24);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_0__);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_1__);
+/* 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/. */
 
 
 class _PocketLoggedInCta extends react__WEBPACK_IMPORTED_MODULE_1___default.a.PureComponent {
   render() {
     const {
       pocketCta
     } = this.props.Pocket;
     return react__WEBPACK_IMPORTED_MODULE_1___default.a.createElement("span", {
@@ -5618,16 +5702,19 @@ const PocketLoggedInCta = Object(react_r
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Topic", function() { return Topic; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Topics", function() { return Topics; });
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
+/* 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/. */
 
 class Topic extends react__WEBPACK_IMPORTED_MODULE_0___default.a.PureComponent {
   render() {
     const {
       url,
       name
     } = this.props;
     return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("li", null, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("a", {
@@ -5673,16 +5760,19 @@ class Topics extends react__WEBPACK_IMPO
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_6___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_6__);
 /* harmony import */ var _SearchShortcutsForm__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(45);
 /* harmony import */ var common_Reducers_jsm__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(53);
 /* harmony import */ var _TopSiteForm__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(55);
 /* harmony import */ var _TopSite__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(46);
 function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
 
+/* 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/. */
 
 
 
 
 
 
 
 
@@ -5884,16 +5974,19 @@ const TopSites = Object(react_redux__WEB
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TOP_SITES_SOURCE", function() { return TOP_SITES_SOURCE; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TOP_SITES_CONTEXT_MENU_OPTIONS", function() { return TOP_SITES_CONTEXT_MENU_OPTIONS; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS", function() { return TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "MIN_RICH_FAVICON_SIZE", function() { return MIN_RICH_FAVICON_SIZE; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "MIN_CORNER_FAVICON_SIZE", function() { return MIN_CORNER_FAVICON_SIZE; });
+/* 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/. */
 const TOP_SITES_SOURCE = "TOP_SITES";
 const TOP_SITES_CONTEXT_MENU_OPTIONS = ["CheckPinTopSite", "EditTopSite", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl", "DeleteUrl"]; // the special top site for search shortcut experiment can only have the option to unpin (which removes) the topsite
 
 const TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS = ["CheckPinTopSite", "Separator", "BlockUrl"]; // minimum size necessary to show a rich icon instead of a screenshot
 
 const MIN_RICH_FAVICON_SIZE = 96; // minimum size necessary to show any icon in the top left corner with a screenshot
 
 const MIN_CORNER_FAVICON_SIZE = 16;
@@ -5905,16 +5998,19 @@ const MIN_CORNER_FAVICON_SIZE = 16;
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SelectableSearchShortcut", function() { return SelectableSearchShortcut; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SearchShortcutsForm", function() { return SearchShortcutsForm; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_1__);
 /* harmony import */ var _TopSitesConstants__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(44);
+/* 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/. */
 
 
 
 class SelectableSearchShortcut extends react__WEBPACK_IMPORTED_MODULE_1___default.a.PureComponent {
   render() {
     const {
       shortcut,
       selected
@@ -6100,16 +6196,19 @@ class SearchShortcutsForm extends react_
 /* harmony import */ var _TopSitesConstants__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(44);
 /* harmony import */ var content_src_components_LinkMenu_LinkMenu__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(56);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_3__);
 /* harmony import */ var content_src_lib_screenshot_utils__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(37);
 /* harmony import */ var common_Reducers_jsm__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(53);
 function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
 
+/* 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/. */
 
 
 
 
 
 
 class TopSiteLink extends react__WEBPACK_IMPORTED_MODULE_3___default.a.PureComponent {
   constructor(props) {
@@ -6742,16 +6841,20 @@ class TopSiteList extends react__WEBPACK
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_Search", function() { return _Search; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Search", function() { return Search; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(24);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_1__);
 /* harmony import */ var content_src_lib_constants__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(11);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(9);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_3__);
+/* 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/. */
+
 /* globals ContentSearchUIController */
 
 
 
 
 
 
 class _Search extends react__WEBPACK_IMPORTED_MODULE_3___default.a.PureComponent {
@@ -6921,16 +7024,19 @@ const Search = Object(react_redux__WEBPA
 /* 48 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "DetectUserSessionStart", function() { return DetectUserSessionStart; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var common_PerfService_jsm__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(39);
+/* 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/. */
 
 
 const VISIBLE = "visible";
 const VISIBILITY_CHANGE_EVENT = "visibilitychange";
 class DetectUserSessionStart {
   constructor(store, options = {}) {
     this._store = store; // Overrides for testing
 
@@ -7010,16 +7116,19 @@ var Actions = __webpack_require__(2);
 var external_React_ = __webpack_require__(9);
 var external_React_default = /*#__PURE__*/__webpack_require__.n(external_React_);
 
 // EXTERNAL MODULE: external "ReactDOM"
 var external_ReactDOM_ = __webpack_require__(14);
 var external_ReactDOM_default = /*#__PURE__*/__webpack_require__.n(external_ReactDOM_);
 
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx
+/* 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/. */
 
 
 class DSImage_DSImage extends external_React_default.a.PureComponent {
   constructor(props) {
     super(props);
     this.onOptimizedImageError = this.onOptimizedImageError.bind(this);
     this.onNonOptimizedImageError = this.onNonOptimizedImageError.bind(this);
     this.state = {
@@ -7135,16 +7244,19 @@ DSImage_DSImage.defaultProps = {
   // Additional classnames to append to component
   optimize: true // Measure parent container to request exact sizes
 
 };
 // EXTERNAL MODULE: ./content-src/components/LinkMenu/LinkMenu.jsx + 1 modules
 var LinkMenu = __webpack_require__(56);
 
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx
+/* 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/. */
 
 
 class DSLinkMenu_DSLinkMenu extends external_React_default.a.PureComponent {
   constructor(props) {
     super(props);
     this.state = {
       activeCard: null,
       showContextMenu: false
@@ -7231,16 +7343,19 @@ class DSLinkMenu_DSLinkMenu extends exte
     }));
   }
 
 }
 // EXTERNAL MODULE: ./content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx
 var ImpressionStats = __webpack_require__(29);
 
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx
+/* 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/. */
 
 
 class SafeAnchor_SafeAnchor extends external_React_default.a.PureComponent {
   constructor(props) {
     super(props);
     this.onClick = this.onClick.bind(this);
   }
 
@@ -7307,16 +7422,19 @@ class SafeAnchor_SafeAnchor extends exte
       href: this.safeURI(url),
       className: className,
       onClick: this.onClick
     }, this.props.children);
   }
 
 }
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx
+/* 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/. */
 
 
 
 
 
 
 class DSCard_DSCard extends external_React_default.a.PureComponent {
   constructor(props) {
@@ -7396,16 +7514,19 @@ class DSCard_DSCard extends external_Rea
     }));
   }
 
 }
 const PlaceholderDSCard = props => external_React_default.a.createElement(DSCard_DSCard, {
   placeholder: true
 });
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx
+/* 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/. */
 
 
 class DSEmptyState_DSEmptyState extends external_React_default.a.PureComponent {
   constructor(props) {
     super(props);
     this.onReset = this.onReset.bind(this);
     this.state = {};
   }
@@ -7479,16 +7600,19 @@ class DSEmptyState_DSEmptyState extends 
       className: "section-empty-state"
     }, external_React_default.a.createElement("div", {
       className: "empty-state-message"
     }, this.renderState()));
   }
 
 }
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx
+/* 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/. */
 
 
 
 class CardGrid_CardGrid extends external_React_default.a.PureComponent {
   renderCards() {
     const recs = this.props.data.recommendations.slice(0, this.props.items);
     const cards = [];
 
@@ -7559,16 +7683,19 @@ CardGrid_CardGrid.defaultProps = {
 };
 // EXTERNAL MODULE: ./content-src/components/CollapsibleSection/CollapsibleSection.jsx
 var CollapsibleSection = __webpack_require__(30);
 
 // EXTERNAL MODULE: external "ReactRedux"
 var external_ReactRedux_ = __webpack_require__(24);
 
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage.jsx
+/* 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/. */
 
 
 class DSMessage_DSMessage extends external_React_default.a.PureComponent {
   render() {
     return external_React_default.a.createElement("div", {
       className: "ds-message"
     }, external_React_default.a.createElement("header", {
       className: "title"
@@ -7582,16 +7709,19 @@ class DSMessage_DSMessage extends extern
     }, this.props.title), this.props.link_text && this.props.link_url && external_React_default.a.createElement(SafeAnchor_SafeAnchor, {
       className: "link",
       url: this.props.link_url
     }, this.props.link_text)));
   }
 
 }
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/List/List.jsx
+/* 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/. */
 
 
 
 
 
 
 
 
@@ -7751,16 +7881,19 @@ function _List(props) {
   // Display numbers for each item
   items: 6 // Number of stories to display.  TODO: get from endpoint
 
 };
 const List = Object(external_ReactRedux_["connect"])(state => ({
   DiscoveryStream: state.DiscoveryStream
 }))(_List);
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/Hero/Hero.jsx
+/* 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/. */
 
 
 
 
 
 
 
 
@@ -7922,16 +8055,19 @@ Hero_Hero.defaultProps = {
 
 };
 // EXTERNAL MODULE: ./content-src/components/Sections/Sections.jsx
 var Sections = __webpack_require__(36);
 
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/Highlights/Highlights.jsx
 function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
 
+/* 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/. */
 
 
 
 class Highlights_Highlights extends external_React_default.a.PureComponent {
   render() {
     const section = this.props.Sections.find(s => s.id === "highlights");
 
     if (!section || !section.enabled) {
@@ -7945,26 +8081,32 @@ class Highlights_Highlights extends exte
     })));
   }
 
 }
 const Highlights = Object(external_ReactRedux_["connect"])(state => ({
   Sections: state.Sections
 }))(Highlights_Highlights);
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule.jsx
+/* 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/. */
 
 class HorizontalRule_HorizontalRule extends external_React_default.a.PureComponent {
   render() {
     return external_React_default.a.createElement("hr", {
       className: "ds-hr"
     });
   }
 
 }
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx
+/* 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/. */
 
 
 class Navigation_Topic extends external_React_default.a.PureComponent {
   render() {
     const {
       url,
       name
     } = this.props;
@@ -7992,16 +8134,19 @@ class Navigation_Navigation extends exte
       key: t.name,
       url: t.url,
       name: t.name
     })))));
   }
 
 }
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle.jsx
+/* 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/. */
 
 class SectionTitle_SectionTitle extends external_React_default.a.PureComponent {
   render() {
     const {
       header: {
         title,
         subtitle
       }
@@ -8012,16 +8157,19 @@ class SectionTitle_SectionTitle extends 
       className: "title"
     }, title), subtitle ? external_React_default.a.createElement("div", {
       className: "subtitle"
     }, subtitle) : null);
   }
 
 }
 // CONCATENATED MODULE: ./content-src/lib/selectLayoutRender.js
+/* 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/. */
 const selectLayoutRender = (state, prefs, rickRollCache) => {
   const {
     layout,
     feeds,
     spocs
   } = state;
   let spocIndex = 0;
   let bufferRollCache = []; // Records the chosen and unchosen spocs by the probability selection.
@@ -8216,16 +8364,19 @@ const selectLayoutRender = (state, prefs
     spocsFill,
     layoutRender
   };
 };
 // EXTERNAL MODULE: ./content-src/components/TopSites/TopSites.jsx
 var TopSites = __webpack_require__(43);
 
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/TopSites/TopSites.jsx
+/* 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/. */
 
 
 
 class TopSites_TopSites extends external_React_default.a.PureComponent {
   render() {
     const header = this.props.header || {};
     return external_React_default.a.createElement("div", {
       className: "ds-top-sites"
@@ -8238,16 +8389,19 @@ class TopSites_TopSites extends external
 }
 const TopSites_TopSites_TopSites = Object(external_ReactRedux_["connect"])(state => ({
   TopSites: state.TopSites
 }))(TopSites_TopSites);
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "isAllowedCSS", function() { return isAllowedCSS; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_DiscoveryStreamBase", function() { return DiscoveryStreamBase_DiscoveryStreamBase; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "DiscoveryStreamBase", function() { return DiscoveryStreamBase; });
+/* 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/. */
 
 
 
 
 
 
 
 
@@ -9347,16 +9501,19 @@ localized_Localized.propTypes = {
 // EXTERNAL MODULE: external "React"
 var external_React_ = __webpack_require__(9);
 var external_React_default = /*#__PURE__*/__webpack_require__.n(external_React_);
 
 // EXTERNAL MODULE: ./content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json
 var EOYSnippet_schema = __webpack_require__(18);
 
 // CONCATENATED MODULE: ./content-src/asrouter/components/Button/Button.jsx
+/* 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/. */
 
 const ALLOWED_STYLE_TAGS = ["color", "backgroundColor"];
 const Button = props => {
   const style = {}; // Add allowed style tags from props, e.g. props.color becomes style={color: props.color}
 
   for (const tag of ALLOWED_STYLE_TAGS) {
     if (typeof props[tag] !== "undefined") {
       style[tag] = props[tag];
@@ -9370,32 +9527,38 @@ const Button = props => {
 
   return external_React_default.a.createElement("button", {
     onClick: props.onClick,
     className: props.className || "ASRouterButton secondary",
     style: style
   }, props.children);
 };
 // CONCATENATED MODULE: ./content-src/asrouter/components/ConditionalWrapper/ConditionalWrapper.jsx
+/* 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/. */
 // lifted from https://gist.github.com/kitze/23d82bb9eb0baabfd03a6a720b1d637f
 const ConditionalWrapper = ({
   condition,
   wrap,
   children
 }) => condition ? wrap(children) : children;
 // EXTERNAL MODULE: ./content-src/asrouter/components/RichText/RichText.jsx
 var RichText = __webpack_require__(16);
 
 // EXTERNAL MODULE: ./content-src/asrouter/template-utils.js
 var template_utils = __webpack_require__(17);
 
 // EXTERNAL MODULE: ./content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json
 var SimpleSnippet_schema = __webpack_require__(19);
 
 // CONCATENATED MODULE: ./content-src/asrouter/components/SnippetBase/SnippetBase.jsx
+/* 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/. */
 
 
 class SnippetBase_SnippetBase extends external_React_default.a.PureComponent {
   constructor(props) {
     super(props);
     this.onBlockClicked = this.onBlockClicked.bind(this);
     this.onDismissClicked = this.onDismissClicked.bind(this);
     this.setBlockButtonRef = this.setBlockButtonRef.bind(this);
@@ -9495,16 +9658,19 @@ class SnippetBase_SnippetBase extends ex
       className: "innerWrapper"
     }, props.children), this.renderDismissButton());
   }
 
 }
 // CONCATENATED MODULE: ./content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx
 function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
 
+/* 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/. */
 
 
 
 
 
 
 const DEFAULT_ICON_PATH = "chrome://branding/content/icon64.png"; // Alt text placeholder in case the prop from the server isn't available
 
@@ -9688,16 +9854,19 @@ class SimpleSnippet_SimpleSnippet extend
       className: "body"
     }, this.renderText()), this.props.extraContent), external_React_default.a.createElement("div", null, this.renderButton())));
   }
 
 }
 // CONCATENATED MODULE: ./content-src/asrouter/templates/EOYSnippet/EOYSnippet.jsx
 function EOYSnippet_extends() { EOYSnippet_extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return EOYSnippet_extends.apply(this, arguments); }
 
+/* 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/. */
 
 
 
 
 class EOYSnippet_EOYSnippetBase extends external_React_default.a.PureComponent {
   constructor(props) {
     super(props);
     this.handleSubmit = this.handleSubmit.bind(this);
@@ -9836,16 +10005,19 @@ const EOYSnippet = props => {
   }));
 };
 // EXTERNAL MODULE: ./content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json
 var FXASignupSnippet_schema = __webpack_require__(20);
 
 // CONCATENATED MODULE: ./content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx
 function SubmitFormSnippet_extends() { SubmitFormSnippet_extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return SubmitFormSnippet_extends.apply(this, arguments); }
 
+/* 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/. */
 
 
 
 
 
  // Alt text placeholder in case the prop from the server isn't available
 
 const SubmitFormSnippet_ICON_ALT_TEXT = "";
@@ -10142,16 +10314,19 @@ class SubmitFormSnippet_SubmitFormSnippe
       onButtonClick: this.expandSnippet
     }));
   }
 
 }
 // CONCATENATED MODULE: ./content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.jsx
 function FXASignupSnippet_extends() { FXASignupSnippet_extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return FXASignupSnippet_extends.apply(this, arguments); }
 
+/* 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/. */
 
 
 
 const FXASignupSnippet = props => {
   const userAgent = window.navigator.userAgent.match(/Firefox\/([0-9]+)\./);
   const firefox_version = userAgent ? parseInt(userAgent[1], 10) : 0;
   const extendedContent = {
     scene1_button_label: FXASignupSnippet_schema.properties.scene1_button_label.default,
@@ -10178,16 +10353,19 @@ const FXASignupSnippet = props => {
   }));
 };
 // EXTERNAL MODULE: ./content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json
 var NewsletterSnippet_schema = __webpack_require__(21);
 
 // CONCATENATED MODULE: ./content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.jsx
 function NewsletterSnippet_extends() { NewsletterSnippet_extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return NewsletterSnippet_extends.apply(this, arguments); }
 
+/* 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/. */
 
 
 
 const NewsletterSnippet = props => {
   const extendedContent = {
     scene1_button_label: NewsletterSnippet_schema.properties.scene1_button_label.default,
     scene2_email_placeholder_text: NewsletterSnippet_schema.properties.scene2_email_placeholder_text.default,
     scene2_button_label: NewsletterSnippet_schema.properties.scene2_button_label.default,
@@ -10204,16 +10382,20 @@ const NewsletterSnippet = props => {
   };
   return external_React_default.a.createElement(SubmitFormSnippet_SubmitFormSnippet, NewsletterSnippet_extends({}, props, {
     content: extendedContent,
     form_action: "https://basket.mozilla.org/subscribe.json",
     form_method: "POST"
   }));
 };
 // CONCATENATED MODULE: ./content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber.js
+/* 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/. */
+
 /**
  * Checks if a given string is an email or phone number or neither
  * @param {string} val The user input
  * @param {ASRMessageContent} content .content property on ASR message
  * @returns {"email"|"phone"|""} The type of the input
  */
 function isEmailOrPhoneNumber(val, content) {
   const {
@@ -10251,16 +10433,19 @@ function isEmailOrPhoneNumber(val, conte
   return "";
 }
 // EXTERNAL MODULE: ./content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json
 var SendToDeviceSnippet_schema = __webpack_require__(22);
 
 // CONCATENATED MODULE: ./content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.jsx
 function SendToDeviceSnippet_extends() { SendToDeviceSnippet_extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return SendToDeviceSnippet_extends.apply(this, arguments); }
 
+/* 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/. */
 
 
 
 
 
 function validateInput(value, content) {
   const type = isEmailOrPhoneNumber(value, content);
   return type ? "" : "Must be an email or a phone number.";
@@ -10317,16 +10502,19 @@ const SendToDeviceSnippet = props => {
     inputType: propsWithDefaults.content.include_sms ? "text" : "email",
     validateInput: propsWithDefaults.content.include_sms ? validateInput : null,
     processFormData: processFormData
   }));
 };
 // CONCATENATED MODULE: ./content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx
 function SimpleBelowSearchSnippet_extends() { SimpleBelowSearchSnippet_extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return SimpleBelowSearchSnippet_extends.apply(this, arguments); }
 
+/* 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/. */
 
 
 
 
 const SimpleBelowSearchSnippet_DEFAULT_ICON_PATH = "chrome://branding/content/icon64.png"; // Alt text placeholder in case the prop from the server isn't available
 
 const SimpleBelowSearchSnippet_ICON_ALT_TEXT = "";
 class SimpleBelowSearchSnippet_SimpleBelowSearchSnippet extends external_React_default.a.PureComponent {
@@ -10367,16 +10555,19 @@ class SimpleBelowSearchSnippet_SimpleBel
     }), external_React_default.a.createElement("div", null, external_React_default.a.createElement("p", {
       className: "body"
     }, this.renderText()), this.props.extraContent));
   }
 
 }
 // CONCATENATED MODULE: ./content-src/asrouter/templates/template-manifest.jsx
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SnippetsTemplates", function() { return SnippetsTemplates; });
+/* 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/. */
 
 
 
 
 
  // Key names matching schema name of templates
 
 const SnippetsTemplates = {
@@ -11759,16 +11950,19 @@ function ftl(strings) {
 
 
 
 
 
 // CONCATENATED MODULE: ./content-src/asrouter/rich-text-strings.js
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "RICH_TEXT_KEYS", function() { return RICH_TEXT_KEYS; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "generateBundles", function() { return generateBundles; });
+/* 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/. */
 
 /**
  * Properties that allow rich text MUST be added to this list.
  *   key: the localization_id that should be used
  *   value: a property or array of properties on the message.content object
  */
 
 const RICH_TEXT_CONFIG = {
@@ -11811,16 +12005,19 @@ function generateBundles(content) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 
 // EXTERNAL MODULE: ./common/Actions.jsm
 var Actions = __webpack_require__(2);
 
 // CONCATENATED MODULE: ./common/Dedupe.jsm
+/* 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/. */
 class Dedupe {
   constructor(createKey) {
     this.createKey = createKey || this.defaultCreateKey;
   }
 
   defaultCreateKey(item) {
     return item;
   }
@@ -12667,16 +12864,19 @@ var reducers = {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 
 // EXTERNAL MODULE: ./common/Actions.jsm
 var Actions = __webpack_require__(2);
 
 // CONCATENATED MODULE: ./content-src/components/Card/types.js
+/* 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/. */
 const cardContextTypes = {
   history: {
     fluentID: "newtab-label-visited",
     icon: "history-item"
   },
   bookmark: {
     fluentID: "newtab-label-bookmarked",
     icon: "bookmark-added"
@@ -12706,16 +12906,19 @@ var external_React_default = /*#__PURE__
 
 // EXTERNAL MODULE: ./content-src/lib/screenshot-utils.js
 var screenshot_utils = __webpack_require__(37);
 
 // CONCATENATED MODULE: ./content-src/components/Card/Card.jsx
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_Card", function() { return Card_Card; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Card", function() { return Card; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "PlaceholderCard", function() { return PlaceholderCard; });
+/* 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/. */
 
 
 
 
 
  // Keep track of pending image loads to only request once
 
 const gImageLoading = new Map();
@@ -13057,16 +13260,19 @@ var A11yLinkButton = __webpack_require__
 // EXTERNAL MODULE: external "React"
 var external_React_ = __webpack_require__(9);
 var external_React_default = /*#__PURE__*/__webpack_require__.n(external_React_);
 
 // EXTERNAL MODULE: ./content-src/components/TopSites/TopSitesConstants.js
 var TopSitesConstants = __webpack_require__(44);
 
 // CONCATENATED MODULE: ./content-src/components/TopSites/TopSiteFormInput.jsx
+/* 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/. */
 
 class TopSiteFormInput_TopSiteFormInput extends external_React_default.a.PureComponent {
   constructor(props) {
     super(props);
     this.state = {
       validationError: this.props.validationError
     };
     this.onChange = this.onChange.bind(this);
@@ -13170,16 +13376,19 @@ TopSiteFormInput_TopSiteFormInput.defaul
   value: "",
   validationError: false
 };
 // EXTERNAL MODULE: ./content-src/components/TopSites/TopSite.jsx
 var TopSite = __webpack_require__(46);
 
 // CONCATENATED MODULE: ./content-src/components/TopSites/TopSiteForm.jsx
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TopSiteForm", function() { return TopSiteForm_TopSiteForm; });
+/* 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/. */
 
 
 
 
 
 
 class TopSiteForm_TopSiteForm extends external_React_default.a.PureComponent {
   constructor(props) {
@@ -13476,16 +13685,19 @@ var Actions = __webpack_require__(2);
 
 // EXTERNAL MODULE: external "ReactRedux"
 var external_ReactRedux_ = __webpack_require__(24);
 
 // EXTERNAL MODULE: ./content-src/components/ContextMenu/ContextMenu.jsx
 var ContextMenu = __webpack_require__(28);
 
 // CONCATENATED MODULE: ./content-src/lib/link-menu-options.js
+/* 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/. */
 
 
 const _OpenInPrivateWindow = site => ({
   id: "newtab-menu-open-new-private-window",
   icon: "new-window-private",
   action: Actions["actionCreators"].OnlyToMain({
     type: Actions["actionTypes"].OPEN_PRIVATE_WINDOW,
     data: {
@@ -13757,16 +13969,19 @@ const LinkMenuOptions = {
 };
 // EXTERNAL MODULE: external "React"
 var external_React_ = __webpack_require__(9);
 var external_React_default = /*#__PURE__*/__webpack_require__.n(external_React_);
 
 // CONCATENATED MODULE: ./content-src/components/LinkMenu/LinkMenu.jsx
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_LinkMenu", function() { return LinkMenu_LinkMenu; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "LinkMenu", function() { return LinkMenu; });
+/* 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/. */
 
 
 
 
 
 const DEFAULT_SITE_MENU_OPTIONS = ["CheckPinTopSite", "EditTopSite", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl"];
 class LinkMenu_LinkMenu extends external_React_default.a.PureComponent {
   getOptions() {
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..09e371bd817085ca3b0b3fff4ffc7f2d020fd2ee
GIT binary patch
literal 2038
zc$@+D2MPFzP)<h;3K|Lk000e1NJLTq001BW001Be1^@s6b9#F800001b5ch_0Itp)
z=>Px+ut`KgR9FeUmfdet_Zi1O=f$tFV>^y<Oh}VJ^FBZf(h|l>O4p6<<zn4#xabAa
zr0r@KyI>c+LE0oV{)Vwi-J3R~uF{wo)2gCD3tK5IAq)r!6cQ3Av17-M-;dAP^D_-1
zFfC{sJjyS&&#`~c_xJsNpXWIm60bl1vrQGdddzVc1t+1dJ-yK8G>tPJ-F^6-$sf;X
zuWdWBspix!J2)**FX`huYiLU@&9&XEIOStF&o;S&<3LM=`Y~N=(Ed2W|K*BEiDS!>
z*Z&kMw<*;BUo=<AYBiM^d`&tYuZ~@}DVCvXflGHVw6$<lZgcIW|L&7zIhdx#mtXR+
z?-f5ioi;%|k5wp=yqM+VkKDXB8E2+e#jWXUk~}d0TPRg0)N~z3Y^>=vZrQ6^b?EAL
z@xvd58Q$%MLJHEdY8ALyAW<r@W6<PvYl!1X_|r-f1!0wpCjwvzg`u#)f!DnBcNHoA
zGsU?N!kk;s>FqH&dMr%5!v%9Qkd@@ra3*c+OQ!fBEV5|$41@1S+HrY9yt{Z^<hT!E
zT>$kuB-(U-^3xza?elPDN#NUjWz!bka<}mE;RWJtBK+)}oO~8+NuIN@{t?D0DgXx5
zHA*!vmThq$+{qnb=Tc@0Q#V)_fI9vDQ5QX}a=MAT)c)K}ZJ~)=F2=!cYJ|55!fYNE
zr$rBOoKwZR6vJ8wHF$Pxk*w~bZ27TJU}}tP*vj?%J*4?zbZ^*uWQ{8x`CtgdiHSPr
zFFNFJd-31ch`$_RNo!}x6Ch<&@Kq-9R2L+98D~+Fo!yLG_6QcW++v3Sc{`3J1}tkP
z6@MFZr8JAB%=!!{rD_$M-|l~dw}Z{Rn!3nyv!5~GN#ncyMczM_qhR~l+n-?H&NMx<
zam=MKmYCF%(<A0BX%Wh%OXP5<h$L0RO{+i7jqI%_0ASTw=}eH_JA{$bxNtDWr{_*{
z`Sf{wg=y+_i;C#`t4~^a_d=A-hQkZK3M1Yi9pZPltx@oX8;KQwg5ecFZcJBGgX?uY
zpo<HWBinJZ23EGr+;ah%_w1tk{f{YES1@XI=0{)T*0;Y(;MNQ?AB{12bDncmACcl5
zJKX^i9)pljFU3pF6`*XmD2gFftO2mvIzO)vG(}mYnfX22q`qrnG+AyJfxGi4V78Q2
z-Rogy--}cO0hYG*u{6}j;M;#^sa#-uZkbETB*99SZcms2e*#ytG+tpKRZ2B_K?A^Q
zlkqnhxZF@IiGFnlkK8E5BGmkT86DDOg7S5_pE!T1fnFDRQvF+4X>XGX5OA`kH(RBp
z;O4If23bkR*?Iiel${#mrG>@{<ZlrTw6l=AgDz!gbhPX}5{~E``d)}19J2ZDaGE`2
zsH8S<Tj=#JuMod*1>a&CtHn>#bds)9XK|z>Y65WTTdz>)Y)2f|m;=^gom<U8GM#=(
z(FoCrDSX*w>6`|-P+O~4$rcyHn|4DIGaA)6dNf2|mo%7ZX(pfQwAJ18#yjvDHBM)z
z@Xx2|I{7y?y>|iIB^^(sQSuZwL`e_!K&C3pNWBPaWNPH%9vb~tcQd_vn9!Y>24m`<
zCLo-;l%l1H5gqiGUvkmcA?@?-HS7x-#ql;)k{iia{ftI?Y4t{^OEuL!E;N~LMp=5i
zY@+C2*fEIJ6oknlEQtgw(v)N{5t4?Z3?*YQ)z?co9BQzo04TY8`&=CSdX+DPY|K-C
zsd0O}%4n~TZBq$0<fBr<L9%)imKh)(*eHfc3w?rO)t$1rCFV=+6mzOV4~aJx1b{9q
z1SGjF$>+u85jVc246b6aF~C&vV6)Dfhca|stuY%L#;oRR{IPX}w>I^$duoh5%Zog3
z+Qfr8IZumpuZB+*_y?gXAl0YLw^2?hr;ds&X#pUA4Jjt1CA$Pn#7p9nG0ciQ`-pT3
zUol_jjngCSpQ|#MYr?d>Zj#^l5-a^3+#|;El^HsI_XncId193m$-=BmE;;?0(3l(E
zA&jWg3IL_QVj(T3R`25isL8}t$Pv3L(n`6iP^K+KB78XTHO_CT@WP2-U}pNdSc%0U
zSz^2`gwqu#GIj;`*maq#R|K^o%s&J`=&$*_WH&!|UlJL$4Pj%FG%O$#08s#Oee1Oe
ze3`5m_dbgXKtA4v<`Zf1X}t2RU?xZ8BpDGWQ^8UCRdot&t;8;?O&+IT`M|7J=|6Fj
zdn3EZbahg1l0Kolpa~mFe_5K0(ZuC3xhA(h58E_py*a_~^6`czboq|CxV^veTkgpQ
zrjD6jCN_2h+q_TP>W?3ORZm2*@oThRy-qRKN@}2wg`oj*;t5BFjuJcY*<HeSr)8hD
zhfw7koR#MW2}h*S{3q@?#dxcL5Srx~EB?Vnw*LJ04`_Qh@xOboXjLUymlmDhm|%Wr
zkZe~sooCMxA0K}Vz@vL;qdVSovRgWt-?f!Y&t?XG`3}+Rlk5KQ(T&t+RUOI?s#z&u
zs#~rX+Ww)#jyi#gTELG{k*@X(L)x=Gr(CL5qsu+R<T9S;bg7~<4|1+{x(64Y?i-a`
z_h{b3QYkWto^7a{Dz`B?e<s)8_UdoM#|fcC$^n@!PcIbh(o0Tybl-PQjUAc!7kLms
UKA~`ZeE<Le07*qoM6N<$f;tM@cK`qY
--- a/browser/components/newtab/docs/v2-system-addon/1.GETTING_STARTED.md
+++ b/browser/components/newtab/docs/v2-system-addon/1.GETTING_STARTED.md
@@ -42,16 +42,24 @@ You will also need to install:
 
 You will need to to clone Activity Stream to a local directory from the `master`
 branch of our Github repository: https://github.com/mozilla/activity-stream
 
 ```
 git clone https://github.com/mozilla/activity-stream.git
 ```
 
+Also be sure to install the hooks for this repository so that (at least)
+eslint and prettier fixing happens at commit time.
+
+```bash
+cd activity-stream
+./bin/bootstrap
+```
+
 ### Mozilla Central
 You will need a local copy of Mozilla Central in a directory named `mozilla-central`. Check the detail of how to get and build Mozilla Central in [Building Firefox](https://developer.mozilla.org/en-US/docs/Mozilla/Developer_guide/Build_Instructions/Simple_Firefox_build).
 That directory should be a sibling of your local `activity-stream` directory (like so):
 
 ```
 /
   mozilla-central/
   activity-stream/
new file mode 100755
--- /dev/null
+++ b/browser/components/newtab/hooks/post-commit
@@ -0,0 +1,11 @@
+#!/bin/sh
+#
+# Clean up any weirdness left around by prettier execution from pre-commit
+# hook.  Can happen for some workflows (eg `git commit .`).
+#
+# Install by executing
+#
+#   ln -s ../../hooks/post-commit .git/hooks/post-commit
+#
+# at the top-level of the activity-stream github repo.
+git update-index -g
new file mode 100755
--- /dev/null
+++ b/browser/components/newtab/hooks/pre-commit
@@ -0,0 +1,33 @@
+#!/bin/sh
+#
+# Recommended pre-commit git hook for activity-stream github repo
+#
+# Install by executing
+#
+#   ln -s ../../hooks/pre-commit .git/hooks/pre-commit
+#
+# at the top-level of the activity-stream github repo.
+#
+# Runs `eslint --fix` on all selected files, which, given our current
+# prettier configuration, means prettifying these files as well.  The
+# commit will be aborted if eslint exits with a failure code.
+#
+# Based on the example script in the prettier docs at
+# https://prettier.io/docs/en/precommit.html
+
+FILES=$(git diff --cached --name-only --diff-filter=ACM "*.js" "*.jsx" "*.jsm" | sed 's| |\\ |g')
+[ -z "$FILES" ] && exit 0
+
+echo "$FILES" | xargs ./node_modules/.bin/eslint --cache --fix
+if [ $? -ne 0 ]
+then
+  echo "eslint found errors but was unable to fix them all with --fix."
+  echo "Please check the output, resolve any issues, and retry."
+  echo "If you want to commit anyway, pass the --no-verify flag to git commit."
+  exit -1
+fi
+
+# Add back the modified/prettified files to staging
+echo "$FILES" | xargs git add
+
+exit 0
deleted file mode 100644
--- a/browser/components/newtab/hooks/pre-push
+++ /dev/null
@@ -1,11 +0,0 @@
-#!/bin/sh
-# Recommended pre-push hook for activity-stream
-
-hookName=`basename "$0"`
-
-if ! command -v node >/dev/null 2>&1; then
-  echo "Can't find node in PATH, trying to find a node binary on your system"
-fi
-
-echo "Running $hookName hook..."
-npm run lint
--- a/browser/components/newtab/lib/ASRouter.jsm
+++ b/browser/components/newtab/lib/ASRouter.jsm
@@ -13,16 +13,18 @@ XPCOMUtils.defineLazyModuleGetters(this,
   UITour: "resource:///modules/UITour.jsm",
   FxAccounts: "resource://gre/modules/FxAccounts.jsm",
   AppConstants: "resource://gre/modules/AppConstants.jsm",
   OS: "resource://gre/modules/osfile.jsm",
   BookmarkPanelHub: "resource://activity-stream/lib/BookmarkPanelHub.jsm",
   SnippetsTestMessageProvider:
     "resource://activity-stream/lib/SnippetsTestMessageProvider.jsm",
   PanelTestProvider: "resource://activity-stream/lib/PanelTestProvider.jsm",
+  ToolbarBadgeHub: "resource://activity-stream/lib/ToolbarBadgeHub.jsm",
+  ToolbarPanelHub: "resource://activity-stream/lib/ToolbarPanelHub.jsm",
 });
 const {
   ASRouterActions: ra,
   actionTypes: at,
   actionCreators: ac,
 } = ChromeUtils.import("resource://activity-stream/common/Actions.jsm");
 const { CFRMessageProvider } = ChromeUtils.import(
   "resource://activity-stream/lib/CFRMessageProvider.jsm"
@@ -486,21 +488,23 @@ class _ASRouter {
       trailheadInitialized: false,
       trailheadInterrupt: "",
       trailheadTriplet: "",
       messages: [],
       errors: [],
     };
     this._triggerHandler = this._triggerHandler.bind(this);
     this._localProviders = localProviders;
+    this.blockMessageById = this.blockMessageById.bind(this);
     this.onMessage = this.onMessage.bind(this);
     this.handleMessageRequest = this.handleMessageRequest.bind(this);
     this.addImpression = this.addImpression.bind(this);
     this._handleTargetingError = this._handleTargetingError.bind(this);
     this.onPrefChange = this.onPrefChange.bind(this);
+    this.dispatch = this.dispatch.bind(this);
   }
 
   async onPrefChange(prefName) {
     if (TARGETING_PREFERENCES.includes(prefName)) {
       // Notify all tabs of messages that have become invalid after pref change
       const invalidMessages = [];
       for (const msg of this._getUnblockedMessages()) {
         if (!msg.targeting) {
@@ -707,25 +711,32 @@ class _ASRouter {
     this.messageChannel = channel;
     this.messageChannel.addMessageListener(
       INCOMING_MESSAGE_NAME,
       this.onMessage
     );
     this._storage = storage;
     this.WHITELIST_HOSTS = this._loadSnippetsWhitelistHosts();
     this.dispatchToAS = dispatchToAS;
-    this.dispatch = this.dispatch.bind(this);
 
     ASRouterPreferences.init();
     ASRouterPreferences.addListener(this.onPrefChange);
     BookmarkPanelHub.init(
       this.handleMessageRequest,
       this.addImpression,
       this.dispatch
     );
+    ToolbarBadgeHub.init(this.waitForInitialized, {
+      handleMessageRequest: this.handleMessageRequest,
+      addImpression: this.addImpression,
+      blockMessageById: this.blockMessageById,
+    });
+    ToolbarPanelHub.init({
+      getMessages: this.handleMessageRequest,
+    });
 
     this._loadLocalProviders();
 
     // We need to check whether to set up telemetry for trailhead
     await this.setupTrailhead();
 
     const messageBlockList =
       (await this._storage.get("messageBlockList")) || [];
@@ -771,16 +782,18 @@ class _ASRouter {
       this.onMessage
     );
     this.messageChannel = null;
     this.dispatchToAS = null;
 
     ASRouterPreferences.removeListener(this.onPrefChange);
     ASRouterPreferences.uninit();
     BookmarkPanelHub.uninit();
+    ToolbarPanelHub.uninit();
+    ToolbarBadgeHub.uninit();
 
     // Uninitialise all trigger listeners
     for (const listener of ASRouterTriggerListeners.values()) {
       listener.uninit();
     }
     // If we added any CFR recommendations, they need to be removed
     CFRPageActions.clearRecommendations();
     this._resetInitialization();
@@ -1016,34 +1029,52 @@ class _ASRouter {
         });
       }
     }
   }
 
   // Return an object containing targeting parameters used to select messages
   _getMessagesContext() {
     const {
+      messageImpressions,
       previousSessionEnd,
       trailheadInterrupt,
       trailheadTriplet,
     } = this.state;
 
     return {
+      get messageImpressions() {
+        return messageImpressions;
+      },
       get previousSessionEnd() {
         return previousSessionEnd;
       },
       get trailheadInterrupt() {
         return trailheadInterrupt;
       },
       get trailheadTriplet() {
         return trailheadTriplet;
       },
     };
   }
 
+  _findAllMessages(candidateMessages, trigger) {
+    const messages = candidateMessages.filter(m =>
+      this.isBelowFrequencyCaps(m)
+    );
+    const context = this._getMessagesContext();
+
+    return ASRouterTargeting.findAllMatchingMessages({
+      messages,
+      trigger,
+      context,
+      onError: this._handleTargetingError,
+    });
+  }
+
   _findMessage(candidateMessages, trigger) {
     const messages = candidateMessages.filter(m =>
       this.isBelowFrequencyCaps(m)
     );
     const context = this._getMessagesContext();
 
     // Find a message that matches the targeting context as well as the trigger context (if one is provided)
     // If no trigger is provided, we should find a message WITHOUT a trigger property defined.
@@ -1248,16 +1279,19 @@ class _ASRouter {
           );
         }
         break;
       case "fxa_bookmark_panel":
         if (force) {
           BookmarkPanelHub._forceShowMessage(target, message);
         }
         break;
+      case "toolbar_badge":
+        ToolbarBadgeHub.registerBadgeNotificationListener(message, { force });
+        break;
       default:
         target.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {
           type: "SET_MESSAGE",
           data: message,
         });
         break;
     }
   }
@@ -1441,22 +1475,33 @@ class _ASRouter {
         messages: state.messages.filter(m => m.id !== message.id),
       }));
     } else {
       await this.setState({ lastMessageId: message ? message.id : null });
     }
     await this._sendMessageToTarget(message, target, trigger);
   }
 
-  handleMessageRequest(trigger) {
-    const msgs = this._getUnblockedMessages();
-    return this._findMessage(
-      msgs.filter(m => m.trigger && m.trigger.id === trigger.id),
-      trigger
-    );
+  handleMessageRequest({ triggerId, template, returnAll = false }) {
+    const msgs = this._getUnblockedMessages().filter(m => {
+      if (template && m.template !== template) {
+        return false;
+      }
+      if (m.trigger && m.trigger.id !== triggerId) {
+        return false;
+      }
+
+      return true;
+    });
+
+    if (returnAll) {
+      return this._findAllMessages(msgs, { id: triggerId });
+    }
+
+    return this._findMessage(msgs, { id: triggerId });
   }
 
   async setMessageById(id, target, force = true, action = {}) {
     await this.setState({ lastMessageId: id });
     const newMessage = this.getMessageById(id);
 
     await this._sendMessageToTarget(newMessage, target, action.data, force);
   }
--- a/browser/components/newtab/lib/ASRouterTargeting.jsm
+++ b/browser/components/newtab/lib/ASRouterTargeting.jsm
@@ -1,12 +1,15 @@
 const { FilterExpressions } = ChromeUtils.import(
   "resource://gre/modules/components-utils/FilterExpressions.jsm"
 );
 const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+  "resource://gre/modules/XPCOMUtils.jsm"
+);
 
 ChromeUtils.defineModuleGetter(
   this,
   "ASRouterPreferences",
   "resource://activity-stream/lib/ASRouterPreferences.jsm"
 );
 ChromeUtils.defineModuleGetter(
   this,
@@ -38,16 +41,22 @@ ChromeUtils.defineModuleGetter(
   "AppConstants",
   "resource://gre/modules/AppConstants.jsm"
 );
 ChromeUtils.defineModuleGetter(
   this,
   "AttributionCode",
   "resource:///modules/AttributionCode.jsm"
 );
+XPCOMUtils.defineLazyServiceGetter(
+  this,
+  "UpdateManager",
+  "@mozilla.org/updates/update-manager;1",
+  "nsIUpdateManager"
+);
 
 const FXA_USERNAME_PREF = "services.sync.username";
 const FXA_ENABLED_PREF = "identity.fxaccounts.enabled";
 const SEARCH_REGION_PREF = "browser.search.region";
 const MOZ_JEXL_FILEPATH = "mozjexl";
 
 const { activityStreamProvider: asProvider } = NewTabUtils;
 
@@ -70,30 +79,23 @@ function CachedTargetingGetter(
   return {
     _lastUpdated: 0,
     _value: null,
     // For testing
     expire() {
       this._lastUpdated = 0;
       this._value = null;
     },
-    get() {
-      return new Promise(async (resolve, reject) => {
-        const now = Date.now();
-        if (now - this._lastUpdated >= updateInterval) {
-          try {
-            this._value = await asProvider[property](options);
-            this._lastUpdated = now;
-          } catch (e) {
-            Cu.reportError(e);
-            reject(e);
-          }
-        }
-        resolve(this._value);
-      });
+    async get() {
+      const now = Date.now();
+      if (now - this._lastUpdated >= updateInterval) {
+        this._value = await asProvider[property](options);
+        this._lastUpdated = now;
+      }
+      return this._value;
     },
   };
 }
 
 function CheckBrowserNeedsUpdate(
   updateInterval = FRECENT_SITES_UPDATE_INTERVAL
 ) {
   const UpdateChecker = Cc["@mozilla.org/updates/update-checker;1"];
@@ -201,16 +203,45 @@ function sortMessagesByTargeting(message
     if (!a.targeting && b.targeting) {
       return 1;
     }
 
     return 0;
   });
 }
 
+/**
+ * Sort messages in descending order based on the value of `priority`
+ * Messages with no `priority` are ranked lowest (even after a message with
+ * priority 0).
+ */
+function sortMessagesByPriority(messages) {
+  return messages.sort((a, b) => {
+    if (isNaN(a.priority) && isNaN(b.priority)) {
+      return 0;
+    }
+    if (!isNaN(a.priority) && isNaN(b.priority)) {
+      return -1;
+    }
+    if (isNaN(a.priority) && !isNaN(b.priority)) {
+      return 1;
+    }
+
+    // Descending order; higher priority comes first
+    if (a.priority > b.priority) {
+      return -1;
+    }
+    if (a.priority < b.priority) {
+      return 1;
+    }
+
+    return 0;
+  });
+}
+
 const TargetingGetters = {
   get locale() {
     return Services.locale.appLocaleAsLangTag;
   },
   get localeLanguageCode() {
     return (
       Services.locale.appLocaleAsLangTag &&
       Services.locale.appLocaleAsLangTag.substr(0, 2)
@@ -354,16 +385,38 @@ const TargetingGetters = {
       }
       if (win.ownerGlobal.gBrowser.visibleTabs.filter(t => t.pinned).length) {
         return true;
       }
     }
 
     return false;
   },
+  get hasAccessedFxAPanel() {
+    return Services.prefs.getBoolPref(
+      "identity.fxaccounts.toolbar.accessed",
+      true
+    );
+  },
+  get isWhatsNewPanelEnabled() {
+    return Services.prefs.getBoolPref(
+      "browser.messaging-system.whatsNewPanel.enabled",
+      false
+    );
+  },
+  get earliestFirefoxVersion() {
+    if (UpdateManager.updateCount) {
+      const earliestFirefoxVersion = UpdateManager.getUpdateAt(
+        UpdateManager.updateCount - 1
+      ).previousAppVersion;
+      return parseInt(earliestFirefoxVersion.match(/\d+/), 10);
+    }
+
+    return null;
+  },
 };
 
 this.ASRouterTargeting = {
   Environment: TargetingGetters,
 
   ERROR_TYPES: {
     MALFORMED_EXPRESSION: "MALFORMED_EXPRESSION",
     OTHER_ERROR: "OTHER_ERROR",
@@ -448,47 +501,86 @@ this.ASRouterTargeting = {
           : this.ERROR_TYPES.OTHER_ERROR;
         onError(type, error, message);
       }
       result = false;
     }
     return result;
   },
 
+  _getSortedMessages(messages) {
+    const weightSortedMessages = sortMessagesByWeightedRank([...messages]);
+    const sortedMessages = sortMessagesByTargeting(weightSortedMessages);
+    return sortMessagesByPriority(sortedMessages);
+  },
+
+  _getCombinedContext(trigger, context) {
+    const triggerContext = trigger ? trigger.context : {};
+    return this.combineContexts(context, triggerContext);
+  },
+
+  _isMessageMatch(message, trigger, context, onError) {
+    return (
+      message &&
+      (trigger
+        ? this.isTriggerMatch(trigger, message.trigger)
+        : !message.trigger) &&
+      // If a trigger expression was passed to this function, the message should match it.
+      // Otherwise, we should choose a message with no trigger property (i.e. a message that can show up at any time)
+      this.checkMessageTargeting(message, context, onError)
+    );
+  },
+
   /**
    * findMatchingMessage - Given an array of messages, returns one message
    *                       whos targeting expression evaluates to true
    *
    * @param {Array} messages An array of AS router messages
-   * @param {obj} impressions An object containing impressions, where keys are message ids
    * @param {trigger} string A trigger expression if a message for that trigger is desired
    * @param {obj|null} context A FilterExpression context. Defaults to TargetingGetters above.
+   * @param {func} onError A function to handle errors (takes two params; error, message)
    * @returns {obj} an AS router message
    */
   async findMatchingMessage({ messages, trigger, context, onError }) {
-    const weightSortedMessages = sortMessagesByWeightedRank([...messages]);
-    const sortedMessages = sortMessagesByTargeting(weightSortedMessages);
-    const triggerContext = trigger ? trigger.context : {};
-    const combinedContext = this.combineContexts(context, triggerContext);
+    const sortedMessages = this._getSortedMessages(messages);
+    const combinedContext = this._getCombinedContext(trigger, context);
 
     for (const candidate of sortedMessages) {
       if (
-        candidate &&
-        (trigger
-          ? this.isTriggerMatch(trigger, candidate.trigger)
-          : !candidate.trigger) &&
-        // If a trigger expression was passed to this function, the message should match it.
-        // Otherwise, we should choose a message with no trigger property (i.e. a message that can show up at any time)
-        (await this.checkMessageTargeting(candidate, combinedContext, onError))
+        await this._isMessageMatch(candidate, trigger, combinedContext, onError)
       ) {
         return candidate;
       }
     }
+    return null;
+  },
 
-    return null;
+  /**
+   * findAllMatchingMessages - Given an array of messages, returns an array of
+   *                           messages that that match the targeting.
+   *
+   * @param {Array} messages An array of AS router messages.
+   * @param {trigger} string A trigger expression if a message for that trigger is desired.
+   * @param {obj|null} context A FilterExpression context. Defaults to TargetingGetters above.
+   * @param {func} onError A function to handle errors (takes two params; error, message)
+   * @returns {Array} An array of AS router messages that match.
+   */
+  async findAllMatchingMessages({ messages, trigger, context, onError }) {
+    const sortedMessages = this._getSortedMessages(messages);
+    const combinedContext = this._getCombinedContext(trigger, context);
+    const matchingMessages = [];
+
+    for (const candidate of sortedMessages) {
+      if (
+        await this._isMessageMatch(candidate, trigger, combinedContext, onError)
+      ) {
+        matchingMessages.push(candidate);
+      }
+    }
+    return matchingMessages;
   },
 };
 
 // Export for testing
 this.QueryCache = QueryCache;
 this.CachedTargetingGetter = CachedTargetingGetter;
 this.EXPORTED_SYMBOLS = [
   "ASRouterTargeting",
--- a/browser/components/newtab/lib/AboutPreferences.jsm
+++ b/browser/components/newtab/lib/AboutPreferences.jsm
@@ -1,22 +1,18 @@
 /* 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 { XPCOMUtils } = ChromeUtils.import(
-  "resource://gre/modules/XPCOMUtils.jsm"
-);
 const { actionTypes: at } = ChromeUtils.import(
   "resource://activity-stream/common/Actions.jsm"
 );
 
-XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
 const HTML_NS = "http://www.w3.org/1999/xhtml";
 const PREFERENCES_LOADED_EVENT = "home-pane-loaded";
 
 // These "section" objects are formatted in a way to be similar to the ones from
 // SectionsManager to construct the preferences view.
 const PREFS_BEFORE_SECTIONS = [
   {
     id: "search",
@@ -107,69 +103,36 @@ this.AboutPreferences = class AboutPrefe
     sectionsCopy.forEach(obj => {
       if (obj.id === "topstories") {
         obj.rowsPref = "";
       }
     });
     return sectionsCopy;
   }
 
-  async observe(window) {
+  observe(window) {
     const discoveryStreamConfig = this.store.getState().DiscoveryStream.config;
     let sections = this.store.getState().Sections;
 
     if (discoveryStreamConfig.enabled) {
       sections = this.handleDiscoverySettings(sections);
     }
 
-    this.renderPreferences(window, await this.strings, [
+    this.renderPreferences(window, [
       ...PREFS_BEFORE_SECTIONS,
       ...sections,
       ...PREFS_AFTER_SECTIONS,
     ]);
   }
 
   /**
-   * Get strings from a js file that the content page would have loaded. The
-   * file should be a single variable assignment of a JSON/JS object of strings.
+   * Render preferences to an about:preferences content window with the provided
+   * preferences structure.
    */
-  get strings() {
-    return (
-      this._strings ||
-      (this._strings = new Promise(async resolve => {
-        let data = {};
-        try {
-          const locale = Cc[
-            "@mozilla.org/browser/aboutnewtab-service;1"
-          ].getService(Ci.nsIAboutNewTabService).activityStreamLocale;
-          const request = await fetch(
-            `resource://activity-stream/prerendered/${locale}/activity-stream-strings.js`
-          );
-          const text = await request.text();
-          const [json] = text.match(/{[^]*}/);
-          data = JSON.parse(json);
-        } catch (ex) {
-          Cu.reportError(
-            "Failed to load strings for Activity Stream about:preferences"
-          );
-        }
-        resolve(data);
-      }))
-    );
-  }
-
-  /**
-   * Render preferences to an about:preferences content window with the provided
-   * strings and preferences structure.
-   */
-  renderPreferences(
-    { document, Preferences, gHomePane },
-    strings,
-    prefStructure
-  ) {
+  renderPreferences({ document, Preferences, gHomePane }, prefStructure) {
     // Helper to create a new element and append it
     const createAppend = (tag, parent, options) =>
       parent.appendChild(document.createXULElement(tag, options));
 
     // Helper to get fluentIDs sometimes encase in an object
     const getString = message =>
       typeof message !== "object" ? message : message.id;
 
--- a/browser/components/newtab/lib/BookmarkPanelHub.jsm
+++ b/browser/components/newtab/lib/BookmarkPanelHub.jsm
@@ -80,17 +80,19 @@ class _BookmarkPanelHub {
     ) {
       this.showMessage(this._response.content, target, win);
       return true;
     }
 
     // If we didn't match on a previously cached request then make sure
     // the container is empty
     this._removeContainer(target);
-    const response = await this._handleMessageRequest(this._trigger);
+    const response = await this._handleMessageRequest({
+      triggerId: this._trigger.id,
+    });
 
     return this.onResponse(response, target, win);
   }
 
   /**
    * If the response contains a message render it and send an impression.
    * Otherwise we remove the message from the container.
    */
--- a/browser/components/newtab/lib/LinksCache.jsm
+++ b/browser/components/newtab/lib/LinksCache.jsm
@@ -78,54 +78,59 @@ this.LinksCache = class LinksCache {
       // Allow custom rules around refreshing based on options
       this.shouldRefresh(this.lastOptions, options)
     ) {
       // Update request state early so concurrent requests can refer to it
       this.lastOptions = options;
       this.lastUpdate = now;
 
       // Save a promise before awaits, so other requests wait for correct data
-      this.cache = new Promise(async resolve => {
-        // Allow fast lookup of old links by url that might need to migrate
-        const toMigrate = new Map();
-        for (const oldLink of await this.cache) {
-          if (oldLink) {
-            toMigrate.set(oldLink.url, oldLink);
+      // eslint-disable-next-line no-async-promise-executor
+      this.cache = new Promise(async (resolve, reject) => {
+        try {
+          // Allow fast lookup of old links by url that might need to migrate
+          const toMigrate = new Map();
+          for (const oldLink of await this.cache) {
+            if (oldLink) {
+              toMigrate.set(oldLink.url, oldLink);
+            }
           }
-        }
 
-        // Update the cache with migrated links without modifying source objects
-        resolve(
-          (await this.linkGetter(options)).map(link => {
-            // Keep original array hole positions
-            if (!link) {
-              return link;
-            }
+          // Update the cache with migrated links without modifying source objects
+          resolve(
+            (await this.linkGetter(options)).map(link => {
+              // Keep original array hole positions
+              if (!link) {
+                return link;
+              }
 
-            // Migrate data to the new link copy if we have an old link
-            const newLink = Object.assign({}, link);
-            const oldLink = toMigrate.get(newLink.url);
-            if (oldLink) {
-              for (const property of this.migrateProperties) {
-                const oldValue = oldLink[property];
-                if (oldValue !== undefined) {
-                  newLink[property] = oldValue;
+              // Migrate data to the new link copy if we have an old link
+              const newLink = Object.assign({}, link);
+              const oldLink = toMigrate.get(newLink.url);
+              if (oldLink) {
+                for (const property of this.migrateProperties) {
+                  const oldValue = oldLink[property];
+                  if (oldValue !== undefined) {
+                    newLink[property] = oldValue;
+                  }
                 }
+              } else {
+                // Share data among link copies and new links from future requests
+                newLink.__sharedCache = {};
               }
-            } else {
-              // Share data among link copies and new links from future requests
-              newLink.__sharedCache = {};
-            }
-            // Provide a helper to update the cached link
-            newLink.__sharedCache.updateLink = (property, value) => {
-              newLink[property] = value;
-            };
+              // Provide a helper to update the cached link
+              newLink.__sharedCache.updateLink = (property, value) => {
+                newLink[property] = value;
+              };
 
-            return newLink;
-          })
-        );
+              return newLink;
+            })
+          );
+        } catch (error) {
+          reject(error);
+        }
       });
     }
 
     // Provide a shallow copy of the cached link objects for callers to modify
     return (await this.cache).map(link => link && Object.assign({}, link));
   }
 };
--- a/browser/components/newtab/lib/PanelTestProvider.jsm
+++ b/browser/components/newtab/lib/PanelTestProvider.jsm
@@ -1,13 +1,17 @@
 /* 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 FIREFOX_VERSION = parseInt(Services.appinfo.version.match(/\d+/), 10);
+
 const MESSAGES = () => [
   {
     id: "SIMPLE_FXA_BOOKMARK_TEST_FLUENT",
     template: "fxa_bookmark_panel",
     content: {
       title: { string_id: "cfr-doorhanger-bookmark-fxa-header" },
       text: { string_id: "cfr-doorhanger-bookmark-fxa-body" },
       cta: { string_id: "cfr-doorhanger-bookmark-fxa-link-text" },
@@ -41,16 +45,92 @@ const MESSAGES = () => [
         tooltiptext: "Toggle tooltip",
       },
       close_button: {
         tooltiptext: "Close tooltip",
       },
     },
     trigger: { id: "bookmark-panel" },
   },
+  {
+    id: "FXA_ACCOUNTS_BADGE",
+    template: "toolbar_badge",
+    content: {
+      target: "fxa-toolbar-menu-button",
+    },
+    // Never accessed the FxA panel && doesn't use Firefox sync & has FxA enabled
+    targeting: `!hasAccessedFxAPanel && !usesFirefoxSync && isFxAEnabled == true`,
+    trigger: { id: "toolbarBadgeUpdate" },
+  },
+  {
+    id: `WHATS_NEW_BADGE_${FIREFOX_VERSION}`,
+    template: "toolbar_badge",
+    content: {
+      // delay: 5 * 3600 * 1000,
+      delay: 5000,
+      target: "whats-new-menu-button",
+      action: { id: "show-whatsnew-button" },
+    },
+    priority: 1,
+    trigger: { id: "toolbarBadgeUpdate" },
+    frequency: {
+      // Makes it so that we track impressions for this message while at the
+      // same time it can have unlimited impressions
+      lifetime: Infinity,
+    },
+    // Never saw this message or saw it in the past 4 days or more recent
+    targeting: `isWhatsNewPanelEnabled &&
+      (earliestFirefoxVersion && firefoxVersion > earliestFirefoxVersion) &&
+        messageImpressions[.id == 'WHATS_NEW_BADGE_${FIREFOX_VERSION}']|length == 0 ||
+      (messageImpressions[.id == 'WHATS_NEW_BADGE_${FIREFOX_VERSION}']|length >= 1 &&
+        currentDate|date - messageImpressions[.id == 'WHATS_NEW_BADGE_${FIREFOX_VERSION}'][0] <= 4 * 24 * 3600 * 1000)`,
+  },
+  {
+    id: "WHATS_NEW_70_1",
+    template: "whatsnew_panel_message",
+    content: {
+      published_date: 1560969794394,
+      title: "Protection Is Our Focus",
+      icon_url:
+        "resource://activity-stream/data/content/assets/whatsnew-send-icon.png",
+      body:
+        "The New Enhanced Tracking Protection, gives you the best level of protection and performance. Discover how this version is the safest version of firefox ever made.",
+      cta_url: "https://blog.mozilla.org/",
+    },
+    targeting: `firefoxVersion > 69`,
+    trigger: { id: "whatsNewPanelOpened" },
+  },
+  {
+    id: "WHATS_NEW_70_2",
+    template: "whatsnew_panel_message",
+    content: {
+      published_date: 1560969794394,
+      title: "Another thing new in Firefox 70",
+      body:
+        "The New Enhanced Tracking Protection, gives you the best level of protection and performance. Discover how this version is the safest version of firefox ever made.",
+      link_text: "Learn more on our blog",
+      cta_url: "https://blog.mozilla.org/",
+    },
+    targeting: `firefoxVersion > 69`,
+    trigger: { id: "whatsNewPanelOpened" },
+  },
+  {
+    id: "WHATS_NEW_69_1",
+    template: "whatsnew_panel_message",
+    content: {
+      published_date: 1557346235089,
+      title: "Something new in Firefox 69",
+      body:
+        "The New Enhanced Tracking Protection, gives you the best level of protection and performance. Discover how this version is the safest version of firefox ever made.",
+      link_text: "Learn more on our blog",
+      cta_url: "https://blog.mozilla.org/",
+    },
+    targeting: `firefoxVersion > 68`,
+    trigger: { id: "whatsNewPanelOpened" },
+  },
 ];
 
 const PanelTestProvider = {
   getMessages() {
     return MESSAGES().map(message => ({
       ...message,
       targeting: `providerCohorts.panel_local_testing == "SHOW_TEST"`,
     }));
--- a/browser/components/newtab/lib/PersistentCache.jsm
+++ b/browser/components/newtab/lib/PersistentCache.jsm
@@ -52,28 +52,35 @@ this.PersistentCache = class PersistentC
   }
 
   /**
    * Load the cache into memory if it isn't already.
    */
   _load() {
     return (
       this._cache ||
-      (this._cache = new Promise(async resolve => {
+      // eslint-disable-next-line no-async-promise-executor
+      (this._cache = new Promise(async (resolve, reject) => {
+        let filepath;
+        try {
+          filepath = OS.Path.join(
+            OS.Constants.Path.localProfileDir,
+            this._filename
+          );
+        } catch (error) {
+          reject(error);
+          return;
+        }
+
         let file;
-        let data = {};
-        const filepath = OS.Path.join(
-          OS.Constants.Path.localProfileDir,
-          this._filename
-        );
-
         try {
           file = await fetch(`file://${filepath}`);
         } catch (error) {} // Cache file doesn't exist yet.
 
+        let data = {};
         if (file) {
           try {
             data = await file.json();
           } catch (error) {
             Cu.reportError(
               `Failed to parse ${this._filename}: ${error.message}`
             );
           }
--- a/browser/components/newtab/lib/Tokenize.jsm
+++ b/browser/components/newtab/lib/Tokenize.jsm
@@ -24,16 +24,18 @@ const UNICODE_NUMBER =
 const UNICODE_MARK =
   "\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u08D4-\u08E1\u08E3-\u0903\u093A-\u093C\u093E-\u094F\u0951-\u0957\u0962\u0963\u0981-\u0983\u09BC\u09BE-\u09C4\u09C7\u09C8\u09CB-\u09CD\u09D7\u09E2\u09E3\u0A01-\u0A03\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81-\u0A83\u0ABC\u0ABE-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AE2\u0AE3\u0B01-\u0B03\u0B3C\u0B3E-\u0B44\u0B47\u0B48\u0B4B-\u0B4D\u0B56\u0B57\u0B62\u0B63\u0B82\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD7\u0C00-\u0C03\u0C3E-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C81-\u0C83\u0CBC\u0CBE-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0CE2\u0CE3\u0D01-\u0D03\u0D3E-\u0D44\u0D46-\u0D48\u0D4A-\u0D4D\u0D57\u0D62\u0D63\u0D82\u0D83\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DF2\u0DF3\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EB9\u0EBB\u0EBC\u0EC8-\u0ECD\u0F18\u0F19\u0F35\u0F37\u0F39\u0F3E\u0F3F\u0F71-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102B-\u103E\u1056-\u1059\u105E-\u1060\u1062-\u1064\u1067-\u106D\u1071-\u1074\u1082-\u108D\u108F\u109A-\u109D\u135D-\u135F\u1712-\u1714\u1732-\u1734\u1752\u1753\u1772\u1773\u17B4-\u17D3\u17DD\u180B-\u180D\u1885\u1886\u18A9\u1920-\u192B\u1930-\u193B\u1A17-\u1A1B\u1A55-\u1A5E\u1A60-\u1A7C\u1A7F\u1AB0-\u1ABE\u1B00-\u1B04\u1B34-\u1B44\u1B6B-\u1B73\u1B80-\u1B82\u1BA1-\u1BAD\u1BE6-\u1BF3\u1C24-\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE8\u1CED\u1CF2-\u1CF4\u1CF8\u1CF9\u1DC0-\u1DF5\u1DFB-\u1DFF\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302F\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69E\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA823-\uA827\uA880\uA881\uA8B4-\uA8C5\uA8E0-\uA8F1\uA926-\uA92D\uA947-\uA953\uA980-\uA983\uA9B3-\uA9C0\uA9E5\uAA29-\uAA36\uAA43\uAA4C\uAA4D\uAA7B-\uAA7D\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEB-\uAAEF\uAAF5\uAAF6\uABE3-\uABEA\uABEC\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE2F";
 const UNICODE_LETTER =
   "A-Za-z\xAA\xB5\xBA\xC0-\xD6\xD8-\xF6\xF8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u052F\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0-\u08B4\u08B6-\u08BD\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0980\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0AF9\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C60\u0C61\u0C80\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D54-\u0D56\u0D5F-\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16F1-\u16F8\u1700-\u170C\u170E-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u1820-\u1877\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u1A00-\u1A16\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1C80-\u1C88\u1CE9-\u1CEC\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FD5\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B\uA640-\uA66E\uA67F-\uA69D\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788\uA78B-\uA7AE\uA7B0-\uA7B7\uA7F7-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB\uA8FD\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uA9E0-\uA9E4\uA9E6-\uA9EF\uA9FA-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB65\uAB70-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC";
 
 const REGEXP_SPLITS = new RegExp(
   `[${UNICODE_SPACE}${UNICODE_SYMBOL}${UNICODE_PUNCT}]+`
 );
+// Match all token characters, so okay for regex to split multiple code points
+// eslint-disable-next-line no-misleading-character-class
 const REGEXP_ALPHANUMS = new RegExp(
   `^[${UNICODE_NUMBER}${UNICODE_MARK}${UNICODE_LETTER}]+$`
 );
 
 /**
  * Downcases the text, and splits it into consecutive alphanumeric characters.
  * This is locale aware, and so will not strip accents. This uses "word
  * breaks", and os is not appropriate for languages without them
new file mode 100644
--- /dev/null
+++ b/browser/components/newtab/lib/ToolbarPanelHub.jsm
@@ -0,0 +1,229 @@
+/* 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";
+
+ChromeUtils.defineModuleGetter(
+  this,
+  "Services",
+  "resource://gre/modules/Services.jsm"
+);
+ChromeUtils.defineModuleGetter(
+  this,
+  "EveryWindow",
+  "resource:///modules/EveryWindow.jsm"
+);
+
+const WHATSNEW_ENABLED_PREF = "browser.messaging-system.whatsNewPanel.enabled";
+
+const TOOLBAR_BUTTON_ID = "whats-new-menu-button";
+const APPMENU_BUTTON_ID = "appMenu-whatsnew-button";
+const PANEL_HEADER_SELECTOR = "#PanelUI-whatsNew-title > label";
+
+const BUTTON_STRING_ID = "cfr-whatsnew-button";
+
+class _ToolbarPanelHub {
+  constructor() {
+    this._showAppmenuButton = this._showAppmenuButton.bind(this);
+    this._hideAppmenuButton = this._hideAppmenuButton.bind(this);
+    this._showToolbarButton = this._showToolbarButton.bind(this);
+    this._hideToolbarButton = this._hideToolbarButton.bind(this);
+  }
+
+  init({ getMessages }) {
+    this._getMessages = getMessages;
+    if (this.whatsNewPanelEnabled) {
+      this.enableAppmenuButton();
+    }
+  }
+
+  uninit() {
+    EveryWindow.unregisterCallback(TOOLBAR_BUTTON_ID);
+    EveryWindow.unregisterCallback(APPMENU_BUTTON_ID);
+  }
+
+  get whatsNewPanelEnabled() {
+    return Services.prefs.getBoolPref(WHATSNEW_ENABLED_PREF, false);
+  }
+
+  maybeInsertFTL(win) {
+    win.MozXULElement.insertFTLIfNeeded("browser/newtab/asrouter.ftl");
+  }
+
+  // Turns on the Appmenu (hamburger menu) button for all open windows and future windows.
+  enableAppmenuButton() {
+    EveryWindow.registerCallback(
+      APPMENU_BUTTON_ID,
+      this._showAppmenuButton,
+      this._hideAppmenuButton
+    );
+  }
+
+  // Turns on the Toolbar button for all open windows and future windows.
+  enableToolbarButton() {
+    EveryWindow.registerCallback(
+      TOOLBAR_BUTTON_ID,
+      this._showToolbarButton,
+      this._hideToolbarButton
+    );
+  }
+
+  // Render what's new messages into the panel.
+  async renderMessages(win, doc, containerId) {
+    const messages = (await this._getMessages({
+      template: "whatsnew_panel_message",
+      triggerId: "whatsNewPanelOpened",
+      returnAll: true,
+    })).sort((m1, m2) => {
+      // Sort by published_date in descending order.
+      if (m1.content.published_date === m2.content.published_date) {
+        return 0;
+      }
+      if (m1.content.published_date > m2.content.published_date) {
+        return -1;
+      }
+      return 1;
+    });
+    const container = doc.getElementById(containerId);
+
+    if (messages && !container.querySelector(".whatsNew-message")) {
+      let previousDate = 0;
+      for (let { content } of messages) {
+        container.appendChild(
+          this._createMessageElements(win, doc, content, previousDate)
+        );
+        previousDate = content.published_date;
+      }
+    }
+
+    // TODO: TELEMETRY
+  }
+
+  _createMessageElements(win, doc, content, previousDate) {
+    const messageEl = this._createElement(doc, "div");
+    messageEl.classList.add("whatsNew-message");
+
+    // Only render date if it is different from the one rendered before.
+    if (content.published_date !== previousDate) {
+      messageEl.appendChild(
+        this._createDateElement(doc, content.published_date)
+      );
+    }
+
+    const wrapperEl = this._createElement(doc, "div");
+    wrapperEl.classList.add("whatsNew-message-body");
+    messageEl.appendChild(wrapperEl);
+    wrapperEl.addEventListener("click", () => {
+      win.ownerGlobal.openLinkIn(content.cta_url, "tabshifted", {
+        private: false,
+        triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
+          {}
+        ),
+        csp: null,
+      });
+
+      // TODO: TELEMETRY
+    });
+
+    if (content.icon_url) {
+      wrapperEl.classList.add("has-icon");
+      const iconEl = this._createElement(doc, "img");
+      iconEl.src = content.icon_url;
+      iconEl.classList.add("whatsNew-message-icon");
+      wrapperEl.appendChild(iconEl);
+    }
+
+    const titleEl = this._createElement(doc, "h2");
+    titleEl.classList.add("whatsNew-message-title");
+    this._setString(doc, titleEl, content.title);
+    wrapperEl.appendChild(titleEl);
+
+    const bodyEl = this._createElement(doc, "p");
+    this._setString(doc, bodyEl, content.body);
+    wrapperEl.appendChild(bodyEl);
+
+    if (content.link_text) {
+      const linkEl = this._createElement(doc, "button");
+      linkEl.classList.add("text-link");
+      this._setString(doc, linkEl, content.link_text);
+      wrapperEl.appendChild(linkEl);
+    }
+
+    return messageEl;
+  }
+
+  _createElement(doc, elem) {
+    return doc.createElementNS("http://www.w3.org/1999/xhtml", elem);
+  }
+
+  _createDateElement(doc, date) {
+    const dateEl = this._createElement(doc, "p");
+    dateEl.classList.add("whatsNew-message-date");
+    dateEl.textContent = new Date(date).toLocaleDateString("default", {
+      month: "long",
+      day: "numeric",
+      year: "numeric",
+    });
+    return dateEl;
+  }
+
+  // If `string_id` is present it means we are relying on fluent for translations.
+  // Otherwise, we have a vanilla string.
+  _setString(doc, el, stringObj) {
+    if (stringObj.string_id) {
+      doc.l10n.setAttributes(el, stringObj.string_id);
+    } else {
+      el.textContent = stringObj;
+    }
+  }
+
+  _showAppmenuButton(win) {
+    this.maybeInsertFTL(win);
+    this._showElement(
+      win.browser.ownerDocument,
+      APPMENU_BUTTON_ID,
+      BUTTON_STRING_ID
+    );
+  }
+
+  _hideAppmenuButton(win) {
+    this._hideElement(win.browser.ownerDocument, APPMENU_BUTTON_ID);
+  }
+
+  _showToolbarButton(win) {
+    const document = win.browser.ownerDocument;
+    this.maybeInsertFTL(win);
+    this._showElement(document, TOOLBAR_BUTTON_ID, BUTTON_STRING_ID);
+    // The toolbar dropdown panel uses this extra header element that is hidden
+    // in the appmenu subview version of the panel. We only need to set it
+    // when showing the toolbar button.
+    document.l10n.setAttributes(
+      document.querySelector(PANEL_HEADER_SELECTOR),
+      "cfr-whatsnew-panel-header"
+    );
+  }
+
+  _hideToolbarButton(win) {
+    this._hideElement(win.browser.ownerDocument, TOOLBAR_BUTTON_ID);
+  }
+
+  _showElement(document, id, string_id) {
+    const el = document.getElementById(id);
+    document.l10n.setAttributes(el, string_id);
+    el.removeAttribute("hidden");
+  }
+
+  _hideElement(document, id) {
+    document.getElementById(id).setAttribute("hidden", true);
+  }
+}
+
+this._ToolbarPanelHub = _ToolbarPanelHub;
+
+/**
+ * ToolbarPanelHub - singleton instance of _ToolbarPanelHub that can initiate
+ * message requests and render messages.
+ */
+this.ToolbarPanelHub = new _ToolbarPanelHub();
+
+const EXPORTED_SYMBOLS = ["ToolbarPanelHub", "_ToolbarPanelHub"];
--- a/browser/components/newtab/test/browser/browser_asrouter_targeting.js
+++ b/browser/components/newtab/test/browser/browser_asrouter_targeting.js
@@ -802,16 +802,48 @@ add_task(async function check_pinned_tab
         "Should detect pinned tab"
       );
 
       gBrowser.unpinTab(tab);
     }
   );
 });
 
+add_task(async function check_hasAccessedFxAPanel() {
+  is(
+    await ASRouterTargeting.Environment.hasAccessedFxAPanel,
+    false,
+    "Not accessed yet"
+  );
+
+  await pushPrefs(["identity.fxaccounts.toolbar.accessed", true]);
+
+  is(
+    await ASRouterTargeting.Environment.hasAccessedFxAPanel,
+    true,
+    "Should detect panel access"
+  );
+});
+
+add_task(async function check_isWhatsNewPanelEnabled() {
+  is(
+    await ASRouterTargeting.Environment.isWhatsNewPanelEnabled,
+    false,
+    "Not enabled yet"
+  );
+
+  await pushPrefs(["browser.messaging-system.whatsNewPanel.enabled", true]);
+
+  is(
+    await ASRouterTargeting.Environment.isWhatsNewPanelEnabled,
+    true,
+    "Should update based on pref"
+  );
+});
+
 add_task(async function checkCFRPinnedTabsTargetting() {
   const now = Date.now();
   const timeMinutesAgo = numMinutes => now - numMinutes * 60 * 1000;
   const messages = CFRMessageProvider.getMessages();
   const trigger = {
     id: "frequentVisits",
     context: {
       recentVisits: [
--- a/browser/components/newtab/test/unit/asrouter/ASRouter.test.js
+++ b/browser/components/newtab/test/unit/asrouter/ASRouter.test.js
@@ -61,16 +61,18 @@ describe("ASRouter", () => {
   let providerImpressions;
   let previousSessionEnd;
   let fetchStub;
   let clock;
   let getStringPrefStub;
   let dispatchStub;
   let fakeAttributionCode;
   let FakeBookmarkPanelHub;
+  let FakeToolbarBadgeHub;
+  let FakeToolbarPanelHub;
 
   function createFakeStorage() {
     const getStub = sandbox.stub();
     getStub.returns(Promise.resolve());
     getStub
       .withArgs("messageBlockList")
       .returns(Promise.resolve(messageBlockList));
     getStub
@@ -134,23 +136,34 @@ describe("ASRouter", () => {
       _clearCache: () => sinon.stub(),
       getAttrDataAsync: () => Promise.resolve({ content: "addonID" }),
     };
     FakeBookmarkPanelHub = {
       init: sandbox.stub(),
       uninit: sandbox.stub(),
       _forceShowMessage: sandbox.stub(),
     };
+    FakeToolbarPanelHub = {
+      init: sandbox.stub(),
+      uninit: sandbox.stub(),
+    };
+    FakeToolbarBadgeHub = {
+      init: sandbox.stub(),
+      uninit: sandbox.stub(),
+      registerBadgeNotificationListener: sandbox.stub(),
+    };
     globals.set({
       AttributionCode: fakeAttributionCode,
       // Testing framework doesn't know how to `defineLazyModuleGetter` so we're
       // importing these modules into the global scope ourselves.
       SnippetsTestMessageProvider,
       PanelTestProvider,
       BookmarkPanelHub: FakeBookmarkPanelHub,
+      ToolbarBadgeHub: FakeToolbarBadgeHub,
+      ToolbarPanelHub: FakeToolbarPanelHub,
     });
     await createRouterAndInit();
   });
   afterEach(() => {
     ASRouterPreferences.uninit();
     sandbox.restore();
     globals.restore();
   });
@@ -174,16 +187,44 @@ describe("ASRouter", () => {
     });
     it("should set state.messageBlockList to the block list in persistent storage", async () => {
       messageBlockList = ["foo"];
       Router = new _ASRouter();
       await Router.init(channel, createFakeStorage(), dispatchStub);
 
       assert.deepEqual(Router.state.messageBlockList, ["foo"]);
     });
+    it("should initialize all the hub providers", async () => {
+      // ASRouter init called in `beforeEach` block above
+
+      assert.calledOnce(FakeToolbarBadgeHub.init);
+      assert.calledOnce(FakeToolbarPanelHub.init);
+      assert.calledOnce(FakeBookmarkPanelHub.init);
+
+      assert.calledWithExactly(
+        FakeToolbarBadgeHub.init,
+        Router.waitForInitialized,
+        {
+          handleMessageRequest: Router.handleMessageRequest,
+          addImpression: Router.addImpression,
+          blockMessageById: Router.blockMessageById,
+        }
+      );
+
+      assert.calledWithExactly(FakeToolbarPanelHub.init, {
+        getMessages: Router.handleMessageRequest,
+      });
+
+      assert.calledWithExactly(
+        FakeBookmarkPanelHub.init,
+        Router.handleMessageRequest,
+        Router.addImpression,
+        Router.dispatch
+      );
+    });
     it("should set state.messageImpressions to the messageImpressions object in persistent storage", async () => {
       // Note that messageImpressions are only kept if a message exists in router and has a .frequency property,
       // otherwise they will be cleaned up by .cleanupImpressions()
       const testMessage = { id: "foo", frequency: { lifetimeCap: 10 } };
       messageImpressions = { foo: [0, 1, 2] };
       setMessageProviderPref([
         { id: "onboarding", type: "local", messages: [testMessage] },
       ]);
@@ -407,16 +448,85 @@ describe("ASRouter", () => {
 
       assert.isFalse(
         targetStub.sendAsyncMessage.firstCall.args[1].data.evaluationStatus
           .success
       );
     });
   });
 
+  describe("#routeMessageToTarget", () => {
+    let target;
+    beforeEach(() => {
+      sandbox.stub(CFRPageActions, "forceRecommendation");
+      sandbox.stub(CFRPageActions, "addRecommendation");
+      target = { sendAsyncMessage: sandbox.stub() };
+    });
+    it("should route toolbar_badge message to the right hub", () => {
+      Router.routeMessageToTarget({ template: "toolbar_badge" }, target);
+
+      assert.calledOnce(FakeToolbarBadgeHub.registerBadgeNotificationListener);
+      assert.notCalled(FakeBookmarkPanelHub._forceShowMessage);
+      assert.notCalled(CFRPageActions.addRecommendation);
+      assert.notCalled(CFRPageActions.forceRecommendation);
+      assert.notCalled(target.sendAsyncMessage);
+    });
+    it("should route fxa_bookmark_panel message to the right hub force = true", () => {
+      Router.routeMessageToTarget(
+        { template: "fxa_bookmark_panel" },
+        target,
+        {},
+        true
+      );
+
+      assert.calledOnce(FakeBookmarkPanelHub._forceShowMessage);
+      assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
+      assert.notCalled(CFRPageActions.addRecommendation);
+      assert.notCalled(CFRPageActions.forceRecommendation);
+      assert.notCalled(target.sendAsyncMessage);
+    });
+    it("should route cfr_doorhanger message to the right hub force = false", () => {
+      Router.routeMessageToTarget(
+        { template: "cfr_doorhanger" },
+        target,
+        { param: {} },
+        false
+      );
+
+      assert.calledOnce(CFRPageActions.addRecommendation);
+      assert.notCalled(FakeBookmarkPanelHub._forceShowMessage);
+      assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
+      assert.notCalled(CFRPageActions.forceRecommendation);
+      assert.notCalled(target.sendAsyncMessage);
+    });
+    it("should route cfr_doorhanger message to the right hub force = true", () => {
+      Router.routeMessageToTarget(
+        { template: "cfr_doorhanger" },
+        target,
+        {},
+        true
+      );
+
+      assert.calledOnce(CFRPageActions.forceRecommendation);
+      assert.notCalled(CFRPageActions.addRecommendation);
+      assert.notCalled(FakeBookmarkPanelHub._forceShowMessage);
+      assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
+      assert.notCalled(target.sendAsyncMessage);
+    });
+    it("should route default to sending to content", () => {
+      Router.routeMessageToTarget({ template: "snippets" }, target, {}, true);
+
+      assert.calledOnce(target.sendAsyncMessage);
+      assert.notCalled(CFRPageActions.forceRecommendation);
+      assert.notCalled(CFRPageActions.addRecommendation);
+      assert.notCalled(FakeBookmarkPanelHub._forceShowMessage);
+      assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
+    });
+  });
+
   describe("#loadMessagesFromAllProviders", () => {
     function assertRouterContainsMessages(messages) {
       const messageIdsInRouter = Router.state.messages.map(m => m.id);
       for (const message of messages) {
         assert.include(messageIdsInRouter, message.id);
       }
     }
 
@@ -686,28 +796,115 @@ describe("ASRouter", () => {
         .returns(false);
       Router._updateMessageProviders();
       assert.equal(Router.state.providers.length, 0);
     });
   });
 
   describe("#handleMessageRequest", () => {
     it("should get unblocked messages that match the trigger", async () => {
-      const message = {
+      const message1 = {
         id: "1",
         campaign: "foocampaign",
         trigger: { id: "foo" },
       };
-      await Router.setState({ messages: [message] });
+      const message2 = {
+        id: "2",
+        campaign: "foocampaign",
+        trigger: { id: "bar" },
+      };
+      await Router.setState({ messages: [message2, message1] });
+      // Just return the first message provided as arg
+      sandbox.stub(Router, "_findMessage").callsFake(messages => messages[0]);
+
+      const result = Router.handleMessageRequest({ triggerId: "foo" });
+
+      assert.deepEqual(result, message1);
+    });
+    it("should get unblocked messages that match trigger and template", async () => {
+      const message1 = {
+        id: "1",
+        campaign: "foocampaign",
+        template: "badge",
+        trigger: { id: "foo" },
+      };
+      const message2 = {
+        id: "2",
+        campaign: "foocampaign",
+        template: "snippet",
+        trigger: { id: "foo" },
+      };
+      await Router.setState({ messages: [message2, message1] });
       // Just return the first message provided as arg
       sandbox.stub(Router, "_findMessage").callsFake(messages => messages[0]);
 
-      const result = Router.handleMessageRequest({ id: "foo" });
+      const result = Router.handleMessageRequest({
+        triggerId: "foo",
+        template: "badge",
+      });
+
+      assert.deepEqual(result, message1);
+    });
+    it("should get unblocked messages that match trigger and template", async () => {
+      const message1 = {
+        id: "1",
+        campaign: "foocampaign",
+        template: "badge",
+        trigger: { id: "foo" },
+      };
+      const message2 = {
+        id: "2",
+        campaign: "foocampaign",
+        template: "snippet",
+        trigger: { id: "foo" },
+      };
+      await Router.setState({ messages: [message2, message1] });
+      // Just return the first message provided as arg
+      sandbox.stub(Router, "_findMessage").callsFake(messages => messages[0]);
+
+      const result = Router.handleMessageRequest({
+        triggerId: "foo",
+        template: "badge",
+      });
 
-      assert.deepEqual(result, message);
+      assert.deepEqual(result, message1);
+    });
+    it("should have messageImpressions in the message context", () => {
+      assert.propertyVal(
+        Router._getMessagesContext(),
+        "messageImpressions",
+        Router.state.messageImpressions
+      );
+    });
+    it("should return all unblocked messages that match the template, trigger if returnAll=true", async () => {
+      const message1 = {
+        id: "1",
+        template: "whatsnew_panel_message",
+        trigger: { id: "whatsNewPanelOpened" },
+      };
+      const message2 = {
+        id: "2",
+        template: "whatsnew_panel_message",
+        trigger: { id: "whatsNewPanelOpened" },
+      };
+      const message3 = {
+        id: "3",
+        template: "badge",
+      };
+      sandbox
+        .stub(Router, "_findAllMessages")
+        .callsFake(messages => [message2, message1]);
+      await Router.setState({ messages: [message3, message2, message1] });
+      const result = await Router.handleMessageRequest({
+        template: "whatsnew-panel",
+        triggerId: "whatsNewPanelOpened",
+        returnAll: true,
+      });
+
+      assert.deepEqual(result, [message2, message1]);
     });
   });
 
   describe("blocking", () => {
     it("should not return a blocked message", async () => {
       // Block all messages except the first
       await Router.setState(() => ({
         messageBlockList: ALL_MESSAGE_IDS.slice(1),
@@ -1376,16 +1573,22 @@ describe("ASRouter", () => {
         ];
 
         const { data } = fakeAsyncMessage({
           type: "TRIGGER",
           data: { trigger: { id: "foo" } },
         });
         const message = await Router._findMessage(messages, data.data.trigger);
         assert.equal(message, messages[0]);
+
+        const matches = await Router._findAllMessages(
+          messages,
+          data.data.trigger
+        );
+        assert.deepEqual(matches, messages);
       });
       it("should pick a message with the right targeting and trigger", async () => {
         let messages = [
           {
             id: "foo1",
             template: "simple_template",
             bundled: 2,
             trigger: { id: "foo" },
--- a/browser/components/newtab/test/unit/asrouter/ASRouterFeed.test.js
+++ b/browser/components/newtab/test/unit/asrouter/ASRouterFeed.test.js
@@ -7,26 +7,42 @@ import { GlobalOverrider } from "test/un
 describe("ASRouterFeed", () => {
   let Router;
   let feed;
   let channel;
   let sandbox;
   let storage;
   let globals;
   let FakeBookmarkPanelHub;
+  let FakeToolbarBadgeHub;
+  let FakeToolbarPanelHub;
   beforeEach(() => {
     sandbox = sinon.createSandbox();
     globals = new GlobalOverrider();
     FakeBookmarkPanelHub = {
       init: sandbox.stub(),
       uninit: sandbox.stub(),
     };
+    FakeToolbarBadgeHub = {
+      init: sandbox.stub(),
+    };
+    FakeToolbarPanelHub = {
+      init: sandbox.stub(),
+      uninit: sandbox.stub(),
+    };
     globals.set("BookmarkPanelHub", FakeBookmarkPanelHub);
+    globals.set("ToolbarBadgeHub", FakeToolbarBadgeHub);
+    globals.set("ToolbarPanelHub", FakeToolbarPanelHub);
 
     Router = new _ASRouter({ providers: [FAKE_LOCAL_PROVIDER] });
+    FakeToolbarPanelHub = {
+      init: sandbox.stub(),
+      uninit: sandbox.stub(),
+    };
+
     storage = {
       get: sandbox.stub().returns(Promise.resolve([])),
       set: sandbox.stub().returns(Promise.resolve()),
     };
     feed = new ASRouterFeed({ router: Router }, storage);
     channel = new FakeRemotePageManager();
     feed.store = {
       _messageChannel: { channel },
--- a/browser/components/newtab/test/unit/asrouter/ASRouterTargeting.test.js
+++ b/browser/components/newtab/test/unit/asrouter/ASRouterTargeting.test.js
@@ -43,28 +43,30 @@ describe("#CachedTargetingGetter", () =>
     clock.tick(sixHours);
 
     await topsitesCache.get();
 
     assert.calledTwice(
       global.NewTabUtils.activityStreamProvider.getTopFrecentSites
     );
   });
-  it("should report errors", async () => {
+  it("throws when failing getter", async () => {
     frecentStub.rejects(new Error("fake error"));
     clock.tick(sixHours);
 
     // assert.throws expect a function as the first parameter, try/catch is a
     // workaround
+    let rejected = false;
     try {
       await topsitesCache.get();
-      assert.isTrue(false);
     } catch (e) {
-      assert.calledOnce(global.Cu.reportError);
+      rejected = true;
     }
+
+    assert(rejected);
   });
   it("should check targeted message before message without targeting", async () => {
     const messages = await OnboardingMessageProvider.getUntranslatedMessages();
     const stub = sandbox
       .stub(ASRouterTargeting, "checkMessageTargeting")
       .resolves();
     const context = {
       attributionData: {
@@ -97,16 +99,111 @@ describe("#CachedTargetingGetter", () =>
       messages,
       trigger: { id: "firstRun" },
       context,
     });
 
     assert.isDefined(result);
     assert.equal(result.id, "FXA_1");
   });
+  describe("sortMessagesByPriority", () => {
+    it("should sort messages in descending priority order", async () => {
+      const [
+        m1,
+        m2,
+        m3,
+      ] = await OnboardingMessageProvider.getUntranslatedMessages();
+      const checkMessageTargetingStub = sandbox
+        .stub(ASRouterTargeting, "checkMessageTargeting")
+        .resolves(false);
+      sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true);
+
+      await ASRouterTargeting.findMatchingMessage({
+        messages: [
+          { ...m1, priority: 0 },
+          { ...m2, priority: 1 },
+          { ...m3, priority: 2 },
+        ],
+        trigger: "testing",
+      });
+
+      assert.equal(checkMessageTargetingStub.callCount, 3);
+
+      const [arg_m1] = checkMessageTargetingStub.firstCall.args;
+      assert.equal(arg_m1.id, m3.id);
+
+      const [arg_m2] = checkMessageTargetingStub.secondCall.args;
+      assert.equal(arg_m2.id, m2.id);
+
+      const [arg_m3] = checkMessageTargetingStub.thirdCall.args;
+      assert.equal(arg_m3.id, m1.id);
+    });
+    it("should sort messages with no priority last", async () => {
+      const [
+        m1,
+        m2,
+        m3,
+      ] = await OnboardingMessageProvider.getUntranslatedMessages();
+      const checkMessageTargetingStub = sandbox
+        .stub(ASRouterTargeting, "checkMessageTargeting")
+        .resolves(false);
+      sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true);
+
+      await ASRouterTargeting.findMatchingMessage({
+        messages: [
+          { ...m1, priority: 0 },
+          { ...m2, priority: undefined },
+          { ...m3, priority: 2 },
+        ],
+        trigger: "testing",
+      });
+
+      assert.equal(checkMessageTargetingStub.callCount, 3);
+
+      const [arg_m1] = checkMessageTargetingStub.firstCall.args;
+      assert.equal(arg_m1.id, m3.id);
+
+      const [arg_m2] = checkMessageTargetingStub.secondCall.args;
+      assert.equal(arg_m2.id, m1.id);
+
+      const [arg_m3] = checkMessageTargetingStub.thirdCall.args;
+      assert.equal(arg_m3.id, m2.id);
+    });
+    it("should keep the order of messages with same priority unchanged", async () => {
+      const [
+        m1,
+        m2,
+        m3,
+      ] = await OnboardingMessageProvider.getUntranslatedMessages();
+      const checkMessageTargetingStub = sandbox
+        .stub(ASRouterTargeting, "checkMessageTargeting")
+        .resolves(false);
+      sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true);
+
+      await ASRouterTargeting.findMatchingMessage({
+        messages: [
+          { ...m1, priority: 2, targeting: undefined, rank: 1 },
+          { ...m2, priority: undefined, targeting: undefined, rank: 1 },
+          { ...m3, priority: 2, targeting: undefined, rank: 1 },
+        ],
+        trigger: "testing",
+      });
+
+      assert.equal(checkMessageTargetingStub.callCount, 3);
+
+      const [arg_m1] = checkMessageTargetingStub.firstCall.args;
+      assert.equal(arg_m1.id, m1.id);
+
+      const [arg_m2] = checkMessageTargetingStub.secondCall.args;
+      assert.equal(arg_m2.id, m3.id);
+
+      const [arg_m3] = checkMessageTargetingStub.thirdCall.args;
+      assert.equal(arg_m3.id, m2.id);
+    });
+  });
   describe("combineContexts", () => {
     it("should combine the properties of the two objects", () => {
       const joined = ASRouterTargeting.combineContexts(
         {
           get foo() {
             return "foo";
           },
         },
--- a/browser/components/newtab/test/unit/asrouter/PanelTestProvider.test.js
+++ b/browser/components/newtab/test/unit/asrouter/PanelTestProvider.test.js
@@ -1,12 +1,12 @@
 import { PanelTestProvider } from "lib/PanelTestProvider.jsm";
 import schema from "content-src/asrouter/schemas/panel/cfr-fxa-bookmark.schema.json";
 const messages = PanelTestProvider.getMessages();
 
 describe("PanelTestProvider", () => {
   it("should have a message", () => {
-    assert.lengthOf(messages, 2);
+    assert.lengthOf(messages, 7);
   });
   it("should be a valid message", () => {
     assert.jsonSchema(messages[0].content, schema);
   });
 });
--- a/browser/components/newtab/test/unit/asrouter/templates/OnboardingMessage.test.jsx
+++ b/browser/components/newtab/test/unit/asrouter/templates/OnboardingMessage.test.jsx
@@ -1,11 +1,13 @@
 import { GlobalOverrider } from "test/unit/utils";
 import { OnboardingMessageProvider } from "lib/OnboardingMessageProvider.jsm";
 import schema from "content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.schema.json";
+import badgeSchema from "content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json";
+import whatsNewSchema from "content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json";
 
 const DEFAULT_CONTENT = {
   title: "A title",
   text: "A description",
   icon: "icon",
   primary_button: {
     label: "some_button_label",
     action: {
@@ -56,16 +58,30 @@ describe("OnboardingMessage", () => {
   });
   it("should validate all messages from OnboardingMessageProvider", async () => {
     const messages = await OnboardingMessageProvider.getUntranslatedMessages();
     // FXA_1 doesn't have content - so filter it out
     messages
       .filter(msg => msg.template in ["onboarding", "return_to_amo_overlay"])
       .forEach(msg => assert.jsonSchema(msg.content, schema));
   });
+  it("should validate all badge template messages", async () => {
+    const messages = await OnboardingMessageProvider.getUntranslatedMessages();
+
+    messages
+      .filter(msg => msg.template === "toolbar_badge")
+      .forEach(msg => assert.jsonSchema(msg.content, badgeSchema));
+  });
+  it("should validate all What's New template messages", async () => {
+    const messages = await OnboardingMessageProvider.getUntranslatedMessages();
+
+    messages
+      .filter(msg => msg.template === "whatsnew_panel_message")
+      .forEach(msg => assert.jsonSchema(msg.content, whatsNewSchema));
+  });
   it("should decode the content field (double decoding)", async () => {
     const fakeContent = "foo%2540bar.org";
     globals.set("AttributionCode", {
       getAttrDataAsync: sandbox
         .stub()
         .resolves({ content: fakeContent, source: "addons.mozilla.org" }),
     });
 
--- a/browser/components/newtab/test/unit/lib/AboutPreferences.test.js
+++ b/browser/components/newtab/test/unit/lib/AboutPreferences.test.js
@@ -88,98 +88,48 @@ describe("AboutPreferences Feed", () => 
       assert.calledWith(
         Services.obs.removeObserver,
         instance,
         PREFERENCES_LOADED_EVENT
       );
     });
     it("should try to render on event", async () => {
       const stub = sandbox.stub(instance, "renderPreferences");
-      instance._strings = {};
       Sections.push({});
 
       await instance.observe(window, PREFERENCES_LOADED_EVENT);
 
       assert.calledOnce(stub);
       assert.equal(stub.firstCall.args[0], window);
-      assert.deepEqual(stub.firstCall.args[1], instance._strings);
-      assert.include(stub.firstCall.args[2], Sections[0]);
+      assert.include(stub.firstCall.args[1], Sections[0]);
     });
     it("Hide topstories rows select in sections if discovery stream is enabled", async () => {
       const stub = sandbox.stub(instance, "renderPreferences");
-      instance._strings = {};
 
       Sections.push({
         rowsPref: "row_pref",
         maxRows: 3,
         pref: { descString: "foo" },
         learnMore: { link: "https://foo.com" },
         id: "topstories",
       });
       DiscoveryStream = { config: { enabled: true } };
 
       await instance.observe(window, PREFERENCES_LOADED_EVENT);
 
       assert.calledOnce(stub);
-      assert.equal(stub.firstCall.args[2][0].id, "search");
-      assert.equal(stub.firstCall.args[2][1].id, "topsites");
-      assert.equal(stub.firstCall.args[2][2].id, "topstories");
-      assert.isEmpty(stub.firstCall.args[2][2].rowsPref);
-    });
-  });
-  describe("#strings", () => {
-    let activityStreamLocale;
-    let fetchStub;
-    let fetchText;
-    beforeEach(() => {
-      global.Cc["@mozilla.org/browser/aboutnewtab-service;1"] = {
-        getService() {
-          return { activityStreamLocale };
-        },
-      };
-      fetchStub = sandbox
-        .stub()
-        .resolves({ text: () => Promise.resolve(fetchText) });
-      globals.set("fetch", fetchStub);
-    });
-    it("should use existing strings if they exist", async () => {
-      instance._strings = {};
-
-      const strings = await instance.strings;
-
-      assert.equal(strings, instance._strings);
-    });
-    it("should report failure if missing", async () => {
-      sandbox.stub(Cu, "reportError");
-
-      const strings = await instance.strings;
-
-      assert.calledOnce(Cu.reportError);
-      assert.deepEqual(strings, {});
-    });
-    it("should fetch with the appropriate locale", async () => {
-      activityStreamLocale = "en-TEST";
-
-      await instance.strings;
-
-      assert.calledOnce(fetchStub);
-      assert.include(fetchStub.firstCall.args[0], activityStreamLocale);
-    });
-    it("should extract strings from js text", async () => {
-      const testStrings = { hello: "world" };
-      fetchText = `var strings = ${JSON.stringify(testStrings)};`;
-
-      const strings = await instance.strings;
-
-      assert.deepEqual(strings, testStrings);
+      const [, structure] = stub.firstCall.args;
+      assert.equal(structure[0].id, "search");
+      assert.equal(structure[1].id, "topsites");
+      assert.equal(structure[2].id, "topstories");
+      assert.isEmpty(structure[2].rowsPref);
     });
   });
   describe("#renderPreferences", () => {
     let node;
-    let strings;
     let prefStructure;
     let Preferences;
     let gHomePane;
     const testRender = () =>
       instance.renderPreferences(
         {
           document: {
             createXULElement: sandbox.stub().returns(node),
@@ -195,32 +145,30 @@ describe("AboutPreferences Feed", () => 
             insertBefore: sandbox.stub().returnsArg(0),
             querySelector: sandbox
               .stub()
               .returns({ appendChild: sandbox.stub() }),
           },
           Preferences,
           gHomePane,
         },
-        strings,
         prefStructure,
         DiscoveryStream.config
       );
     beforeEach(() => {
       node = {
         appendChild: sandbox.stub().returnsArg(0),
         addEventListener: sandbox.stub(),
         classList: { add: sandbox.stub(), remove: sandbox.stub() },
         cloneNode: sandbox.stub().returnsThis(),
         insertAdjacentElement: sandbox.stub().returnsArg(1),
         setAttribute: sandbox.stub(),
         remove: sandbox.stub(),
         style: {},
       };
-      strings = {};
       prefStructure = [];
       Preferences = {
         add: sandbox.stub(),
         get: sandbox.stub().returns({
           on: sandbox.stub(),
         }),
       };
       gHomePane = { toggleRestoreDefaultsBtn: sandbox.stub() };
--- a/browser/components/newtab/test/unit/lib/BookmarkPanelHub.test.js
+++ b/browser/components/newtab/test/unit/lib/BookmarkPanelHub.test.js
@@ -133,17 +133,19 @@ describe("BookmarkPanelHub", () => {
       assert.calledOnce(instance.showMessage);
     });
     it("should call handleMessageRequest", async () => {
       fakeHandleMessageRequest.resolves(fakeMessage);
 
       await instance.messageRequest(fakeTarget, {});
 
       assert.calledOnce(fakeHandleMessageRequest);
-      assert.calledWithExactly(fakeHandleMessageRequest, instance._trigger);
+      assert.calledWithExactly(fakeHandleMessageRequest, {
+        triggerId: instance._trigger.id,
+      });
     });
     it("should call onResponse", async () => {
       fakeHandleMessageRequest.resolves(fakeMessage);
 
       await instance.messageRequest(fakeTarget, {});
 
       assert.calledOnce(instance.onResponse);
       assert.calledWithExactly(
new file mode 100644
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/LinksCache.test.js
@@ -0,0 +1,16 @@
+import { LinksCache } from "lib/LinksCache.jsm";
+
+describe("LinksCache", () => {
+  it("throws when failing request", async () => {
+    const cache = new LinksCache();
+
+    let rejected = false;
+    try {
+      await cache.request();
+    } catch (error) {
+      rejected = true;
+    }
+
+    assert(rejected);
+  });
+});
--- a/browser/components/newtab/test/unit/lib/PersistentCache.test.js
+++ b/browser/components/newtab/test/unit/lib/PersistentCache.test.js
@@ -110,10 +110,22 @@ describe("PersistentCache", () => {
       assert.calledOnce(fakeOS.File.writeAtomic);
       assert.calledWith(
         fakeOS.File.writeAtomic,
         filename,
         `{"testkey":{"x":1,"y":2,"z":3}}`,
         { tmpPath: `${filename}.tmp` }
       );
     });
+    it("throws when failing the file", async () => {
+      sandbox.stub(OS.Path, "join").throws("bad file");
+
+      let rejected = false;
+      try {
+        await cache.set("key", "val");
+      } catch (error) {
+        rejected = true;
+      }
+
+      assert(rejected);
+    });
   });
 });
new file mode 100644
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/ToolbarBadgeHub.test.js
@@ -0,0 +1,364 @@
+import { _ToolbarBadgeHub } from "lib/ToolbarBadgeHub.jsm";
+import { GlobalOverrider } from "test/unit/utils";
+import { PanelTestProvider } from "lib/PanelTestProvider.jsm";
+import { _ToolbarPanelHub } from "lib/ToolbarPanelHub.jsm";
+
+describe("ToolbarBadgeHub", () => {
+  let sandbox;
+  let instance;
+  let fakeAddImpression;
+  let fxaMessage;
+  let whatsnewMessage;
+  let fakeElement;
+  let globals;
+  let everyWindowStub;
+  let clearTimeoutStub;
+  let setTimeoutStub;
+  beforeEach(async () => {
+    globals = new GlobalOverrider();
+    sandbox = sinon.createSandbox();
+    instance = new _ToolbarBadgeHub();
+    fakeAddImpression = sandbox.stub();
+    const msgs = await PanelTestProvider.getMessages();
+    fxaMessage = msgs.find(({ id }) => id === "FXA_ACCOUNTS_BADGE");
+    whatsnewMessage = msgs.find(({ id }) => id.includes("WHATS_NEW_BADGE_"));
+    fakeElement = {
+      setAttribute: sandbox.stub(),
+      removeAttribute: sandbox.stub(),
+      querySelector: sandbox.stub(),
+      addEventListener: sandbox.stub(),
+    };
+    // Share the same element when selecting child nodes
+    fakeElement.querySelector.returns(fakeElement);
+    everyWindowStub = {
+      registerCallback: sandbox.stub(),
+      unregisterCallback: sandbox.stub(),
+    };
+    clearTimeoutStub = sandbox.stub();
+    setTimeoutStub = sandbox.stub();
+    globals.set("EveryWindow", everyWindowStub);
+    globals.set("setTimeout", setTimeoutStub);
+    globals.set("clearTimeout", clearTimeoutStub);
+  });
+  afterEach(() => {
+    sandbox.restore();
+    globals.restore();
+  });
+  it("should create an instance", () => {
+    assert.ok(instance);
+  });
+  describe("#init", () => {
+    it("should make a messageRequest on init", async () => {
+      sandbox.stub(instance, "messageRequest");
+      const waitForInitialized = sandbox.stub().resolves();
+
+      await instance.init(waitForInitialized, {});
+      assert.calledOnce(instance.messageRequest);
+      assert.calledWithExactly(instance.messageRequest, "toolbarBadgeUpdate");
+    });
+  });
+  describe("#uninit", () => {
+    it("should clear any setTimeout cbs", () => {
+      instance.init(sandbox.stub().resolves(), {});
+
+      instance.state.showBadgeTimeoutId = 2;
+
+      instance.uninit();
+
+      assert.calledOnce(clearTimeoutStub);
+      assert.calledWithExactly(clearTimeoutStub, 2);
+    });
+  });
+  describe("messageRequest", () => {
+    let handleMessageRequestStub;
+    beforeEach(() => {
+      handleMessageRequestStub = sandbox.stub().returns(fxaMessage);
+      sandbox
+        .stub(instance, "_handleMessageRequest")
+        .value(handleMessageRequestStub);
+      sandbox.stub(instance, "registerBadgeNotificationListener");
+    });
+    it("should fetch a message with the provided trigger and template", async () => {
+      await instance.messageRequest("trigger");
+
+      assert.calledOnce(handleMessageRequestStub);
+      assert.calledWithExactly(handleMessageRequestStub, {
+        triggerId: "trigger",
+        template: instance.template,
+      });
+    });
+    it("should call addToolbarNotification with browser window and message", async () => {
+      await instance.messageRequest("trigger");
+
+      assert.calledOnce(instance.registerBadgeNotificationListener);
+      assert.calledWithExactly(
+        instance.registerBadgeNotificationListener,
+        fxaMessage
+      );
+    });
+    it("shouldn't do anything if no message is provided", () => {
+      handleMessageRequestStub.returns(null);
+      instance.messageRequest("trigger");
+
+      assert.notCalled(instance.registerBadgeNotificationListener);
+    });
+  });
+  describe("addToolbarNotification", () => {
+    let target;
+    let fakeDocument;
+    beforeEach(() => {
+      fakeDocument = { getElementById: sandbox.stub().returns(fakeElement) };
+      target = { browser: { ownerDocument: fakeDocument } };
+    });
+    it("shouldn't do anything if target element is not found", () => {
+      fakeDocument.getElementById.returns(null);
+      instance.addToolbarNotification(target, fxaMessage);
+
+      assert.notCalled(fakeElement.setAttribute);
+    });
+    it("should target the element specified in the message", () => {
+      instance.addToolbarNotification(target, fxaMessage);
+
+      assert.calledOnce(fakeDocument.getElementById);
+      assert.calledWithExactly(
+        fakeDocument.getElementById,
+        fxaMessage.content.target
+      );
+    });
+    it("should show a notification", () => {
+      instance.addToolbarNotification(target, fxaMessage);
+
+      assert.calledTwice(fakeElement.setAttribute);
+      assert.calledWithExactly(fakeElement.setAttribute, "badged", true);
+      assert.calledWithExactly(fakeElement.setAttribute, "value", "x");
+    });
+    it("should attach a cb on the notification", () => {
+      instance.addToolbarNotification(target, fxaMessage);
+
+      assert.calledTwice(fakeElement.addEventListener);
+      assert.calledWithExactly(
+        fakeElement.addEventListener,
+        "mousedown",
+        instance.removeAllNotifications
+      );
+      assert.calledWithExactly(
+        fakeElement.addEventListener,
+        "click",
+        instance.removeAllNotifications
+      );
+    });
+    it("should execute actions if they exist", () => {
+      sandbox.stub(instance, "executeAction");
+      instance.addToolbarNotification(target, whatsnewMessage);
+
+      assert.calledOnce(instance.executeAction);
+      assert.calledWithExactly(
+        instance.executeAction,
+        whatsnewMessage.content.action
+      );
+    });
+  });
+  describe("registerBadgeNotificationListener", () => {
+    beforeEach(() => {
+      instance.init(sandbox.stub().resolves(), {
+        addImpression: fakeAddImpression,
+      });
+      sandbox.stub(instance, "addToolbarNotification").returns(fakeElement);
+      sandbox.stub(instance, "removeToolbarNotification");
+    });
+    afterEach(() => {
+      instance.uninit();
+    });
+    it("should add an impression for the message", () => {
+      instance.registerBadgeNotificationListener(fxaMessage);
+
+      assert.calledOnce(instance._addImpression);
+      assert.calledWithExactly(instance._addImpression, fxaMessage);
+    });
+    it("should register a callback that adds/removes the notification", () => {
+      instance.registerBadgeNotificationListener(fxaMessage);
+
+      assert.calledOnce(everyWindowStub.registerCallback);
+      assert.calledWithExactly(
+        everyWindowStub.registerCallback,
+        instance.id,
+        sinon.match.func,
+        sinon.match.func
+      );
+
+      const [
+        ,
+        initFn,
+        uninitFn,
+      ] = everyWindowStub.registerCallback.firstCall.args;
+
+      initFn(window);
+      // Test that it doesn't try to add a second notification
+      initFn(window);
+
+      assert.calledOnce(instance.addToolbarNotification);
+      assert.calledWithExactly(
+        instance.addToolbarNotification,
+        window,
+        fxaMessage
+      );
+
+      uninitFn(window);
+
+      assert.calledOnce(instance.removeToolbarNotification);
+      assert.calledWithExactly(instance.removeToolbarNotification, fakeElement);
+    });
+    it("should unregister notifications when forcing a badge via devtools", () => {
+      instance.registerBadgeNotificationListener(fxaMessage, { force: true });
+
+      assert.calledOnce(everyWindowStub.unregisterCallback);
+      assert.calledWithExactly(everyWindowStub.unregisterCallback, instance.id);
+    });
+  });
+  describe("executeAction", () => {
+    it("should call ToolbarPanelHub.enableToolbarButton", () => {
+      const stub = sandbox.stub(
+        _ToolbarPanelHub.prototype,
+        "enableToolbarButton"
+      );
+
+      instance.executeAction({ id: "show-whatsnew-button" });
+
+      assert.calledOnce(stub);
+    });
+  });
+  describe("removeToolbarNotification", () => {
+    it("should remove the notification", () => {
+      instance.removeToolbarNotification(fakeElement);
+
+      assert.calledTwice(fakeElement.removeAttribute);
+      assert.calledWithExactly(fakeElement.removeAttribute, "badged");
+    });
+  });
+  describe("removeAllNotifications", () => {
+    let blockMessageByIdStub;
+    let fakeEvent;
+    beforeEach(() => {
+      blockMessageByIdStub = sandbox.stub();
+      sandbox.stub(instance, "_blockMessageById").value(blockMessageByIdStub);
+      instance.state = { notification: { id: fxaMessage.id } };
+      fakeEvent = { target: { removeEventListener: sandbox.stub() } };
+    });
+    it("should call to block the message", () => {
+      instance.removeAllNotifications();
+
+      assert.calledOnce(blockMessageByIdStub);
+      assert.calledWithExactly(blockMessageByIdStub, fxaMessage.id);
+    });
+    it("should remove the window listener", () => {
+      instance.removeAllNotifications();
+
+      assert.calledOnce(everyWindowStub.unregisterCallback);
+      assert.calledWithExactly(everyWindowStub.unregisterCallback, instance.id);
+    });
+    it("should ignore right mouse button (mousedown event)", () => {
+      fakeEvent.type = "mousedown";
+      fakeEvent.button = 1; // not left click
+
+      instance.removeAllNotifications(fakeEvent);
+
+      assert.notCalled(fakeEvent.target.removeEventListener);
+      assert.notCalled(everyWindowStub.unregisterCallback);
+    });
+    it("should ignore right mouse button (click event)", () => {
+      fakeEvent.type = "click";
+      fakeEvent.button = 1; // not left click
+
+      instance.removeAllNotifications(fakeEvent);
+
+      assert.notCalled(fakeEvent.target.removeEventListener);
+      assert.notCalled(everyWindowStub.unregisterCallback);
+    });
+    it("should ignore keypresses that are not meant to focus the target", () => {
+      fakeEvent.type = "keypress";
+      fakeEvent.key = "\t"; // not enter
+
+      instance.removeAllNotifications(fakeEvent);
+
+      assert.notCalled(fakeEvent.target.removeEventListener);
+      assert.notCalled(everyWindowStub.unregisterCallback);
+    });
+    it("should remove the event listeners after succesfully focusing the element", () => {
+      fakeEvent.type = "click";
+      fakeEvent.button = 0;
+
+      instance.removeAllNotifications(fakeEvent);
+
+      assert.calledTwice(fakeEvent.target.removeEventListener);
+      assert.calledWithExactly(
+        fakeEvent.target.removeEventListener,
+        "mousedown",
+        instance.removeAllNotifications
+      );
+      assert.calledWithExactly(
+        fakeEvent.target.removeEventListener,
+        "click",
+        instance.removeAllNotifications
+      );
+    });
+    it("should remove the event listeners after succesfully focusing the element", () => {
+      fakeEvent.type = "keypress";
+      fakeEvent.key = "Enter";
+
+      instance.removeAllNotifications(fakeEvent);
+
+      assert.calledTwice(fakeEvent.target.removeEventListener);
+      assert.calledWithExactly(
+        fakeEvent.target.removeEventListener,
+        "mousedown",
+        instance.removeAllNotifications
+      );
+      assert.calledWithExactly(
+        fakeEvent.target.removeEventListener,
+        "click",
+        instance.removeAllNotifications
+      );
+    });
+  });
+  describe("message with delay", () => {
+    let msg_with_delay;
+    beforeEach(() => {
+      instance.init(sandbox.stub().resolves(), {
+        addImpression: fakeAddImpression,
+      });
+      msg_with_delay = {
+        ...fxaMessage,
+        content: {
+          ...fxaMessage.content,
+          delay: 500,
+        },
+      };
+      sandbox.stub(instance, "registerBadgeToAllWindows");
+    });
+    afterEach(() => {
+      instance.uninit();
+    });
+    it("should register a cb to fire after msg.content.delay ms", () => {
+      instance.registerBadgeNotificationListener(msg_with_delay);
+
+      assert.calledOnce(setTimeoutStub);
+      assert.calledWithExactly(
+        setTimeoutStub,
+        sinon.match.func,
+        msg_with_delay.content.delay
+      );
+
+      const [cb] = setTimeoutStub.firstCall.args;
+
+      assert.notCalled(instance.registerBadgeToAllWindows);
+
+      cb();
+
+      assert.calledOnce(instance.registerBadgeToAllWindows);
+      assert.calledWithExactly(
+        instance.registerBadgeToAllWindows,
+        msg_with_delay
+      );
+    });
+  });
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/ToolbarPanelHub.test.js
@@ -0,0 +1,150 @@
+import { _ToolbarPanelHub } from "lib/ToolbarPanelHub.jsm";
+import { GlobalOverrider } from "test/unit/utils";
+import { PanelTestProvider } from "lib/PanelTestProvider.jsm";
+
+describe("ToolbarPanelHub", () => {
+  let globals;
+  let sandbox;
+  let instance;
+  let everyWindowStub;
+  let fakeDocument;
+  let fakeWindow;
+  let fakeElementById;
+  let createdElements = [];
+  let eventListeners = {};
+
+  beforeEach(async () => {
+    sandbox = sinon.createSandbox();
+    globals = new GlobalOverrider();
+    instance = new _ToolbarPanelHub();
+    fakeElementById = {
+      setAttribute: sandbox.stub(),
+      removeAttribute: sandbox.stub(),
+      querySelector: sandbox.stub().returns(null),
+      appendChild: sandbox.stub(),
+    };
+    fakeDocument = {
+      l10n: {
+        setAttributes: sandbox.stub(),
+      },
+      getElementById: sandbox.stub().returns(fakeElementById),
+      querySelector: sandbox.stub().returns({}),
+      createElementNS: (ns, tagName) => {
+        const element = {
+          tagName,
+          classList: {
+            add: sandbox.stub(),
+          },
+          addEventListener: (ev, fn) => {
+            eventListeners[ev] = fn;
+          },
+          appendChild: sandbox.stub(),
+        };
+        createdElements.push(element);
+        return element;
+      },
+    };
+    fakeWindow = {
+      browser: {
+        ownerDocument: fakeDocument,
+      },
+      MozXULElement: { insertFTLIfNeeded: sandbox.stub() },
+      ownerGlobal: {
+        openLinkIn: sandbox.stub(),
+      },
+    };
+    everyWindowStub = {
+      registerCallback: sandbox.stub(),
+      unregisterCallback: sandbox.stub(),
+    };
+    globals.set("EveryWindow", everyWindowStub);
+  });
+  afterEach(() => {
+    instance.uninit();
+    sandbox.restore();
+  });
+  it("should create an instance", () => {
+    assert.ok(instance);
+  });
+  it("should not enableAppmenuButton() on init() if pref is not enabled", () => {
+    sandbox.stub(global.Services.prefs, "getBoolPref").returns(false);
+    instance.enableAppmenuButton = sandbox.stub();
+    instance.init({ getMessages: () => {} });
+    assert.notCalled(instance.enableAppmenuButton);
+  });
+  it("should enableAppmenuButton() on init() if pref is enabled", () => {
+    sandbox.stub(global.Services.prefs, "getBoolPref").returns(true);
+    instance.enableAppmenuButton = sandbox.stub();
+    instance.init({ getMessages: () => {} });
+    assert.calledOnce(instance.enableAppmenuButton);
+  });
+  it("should unregisterCallback on uninit()", () => {
+    instance.uninit();
+    assert.calledTwice(everyWindowStub.unregisterCallback);
+  });
+  it("should registerCallback on enableAppmenuButton()", () => {
+    instance.enableAppmenuButton();
+    assert.calledOnce(everyWindowStub.registerCallback);
+  });
+  it("should registerCallback on enableToolbarButton()", () => {
+    instance.enableToolbarButton();
+    assert.calledOnce(everyWindowStub.registerCallback);
+  });
+  it("should unhide appmenu button on _showAppmenuButton()", () => {
+    instance._showAppmenuButton(fakeWindow);
+    assert.calledWith(fakeElementById.removeAttribute, "hidden");
+  });
+  it("should hide appmenu button on _hideAppmenuButton()", () => {
+    instance._hideAppmenuButton(fakeWindow);
+    assert.calledWith(fakeElementById.setAttribute, "hidden", true);
+  });
+  it("should unhide toolbar button on _showToolbarButton()", () => {
+    instance._showToolbarButton(fakeWindow);
+    assert.calledWith(fakeElementById.removeAttribute, "hidden");
+  });
+  it("should hide toolbar button on _hideToolbarButton()", () => {
+    instance._hideToolbarButton(fakeWindow);
+    assert.calledWith(fakeElementById.setAttribute, "hidden", true);
+  });
+  it("should render messages to the panel on renderMessages()", async () => {
+    const messages = (await PanelTestProvider.getMessages()).filter(
+      m => m.template === "whatsnew_panel_message"
+    );
+    messages[0].content.link_text = { string_id: "link_text_id" };
+    instance.init({
+      getMessages: sandbox
+        .stub()
+        .returns([messages[0], messages[2], messages[1]]),
+    });
+    await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+    for (let message of messages) {
+      assert.ok(
+        createdElements.find(
+          el => el.tagName === "h2" && el.textContent === message.content.title
+        )
+      );
+      assert.ok(
+        createdElements.find(
+          el => el.tagName === "p" && el.textContent === message.content.body
+        )
+      );
+    }
+    // Call the click handler to make coverage happy.
+    eventListeners.click();
+    assert.calledOnce(fakeWindow.ownerGlobal.openLinkIn);
+  });
+  it("should only render unique dates (no duplicates)", async () => {
+    instance._createDateElement = sandbox.stub();
+    const messages = (await PanelTestProvider.getMessages()).filter(
+      m => m.template === "whatsnew_panel_message"
+    );
+    const uniqueDates = [
+      ...new Set(messages.map(m => m.content.published_date)),
+    ];
+    instance.init({
+      getMessages: sandbox.stub().returns(messages),
+    });
+    await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+    assert.callCount(instance._createDateElement, uniqueDates.length);
+  });
+});
--- a/browser/components/newtab/test/unit/unit-entry.js
+++ b/browser/components/newtab/test/unit/unit-entry.js
@@ -37,17 +37,17 @@ chai.use(chaiJsonSchema);
 
 const overrider = new GlobalOverrider();
 const TEST_GLOBAL = {
   AddonManager: {
     getActiveAddons() {
       return Promise.resolve({ addons: [], fullData: false });
     },
   },
-  AppConstants: { MOZILLA_OFFICIAL: true },
+  AppConstants: { MOZILLA_OFFICIAL: true, MOZ_APP_VERSION: "69.0a1" },
   UpdateUtils: { getUpdateChannel() {} },
   BrowserWindowTracker: { getTopWindow() {} },
   ChromeUtils: {
     defineModuleGetter() {},
     generateQI() {
       return {};
     },
     import() {
@@ -277,19 +277,23 @@ const TEST_GLOBAL = {
         searchForm:
           "https://www.google.com/search?q=&ie=utf-8&oe=utf-8&client=firefox-b",
       },
     },
     scriptSecurityManager: {
       createNullPrincipal() {},
       getSystemPrincipal() {},
     },
-    wm: { getMostRecentWindow: () => window, getEnumerator: () => [] },
+    wm: {
+      getMostRecentWindow: () => window,
+      getMostRecentBrowserWindow: () => window,
+      getEnumerator: () => [],
+    },
     ww: { registerNotification() {}, unregisterNotification() {} },
-    appinfo: { appBuildID: "20180710100040" },
+    appinfo: { appBuildID: "20180710100040", version: "69.0a1" },
   },
   XPCOMUtils: {
     defineLazyGetter(object, name, f) {
       if (object && name) {
         object[name] = f();
       } else {
         f();
       }