Bug 1565293 - Add What's New, sized welcome and bug fixes to New Tab Page r=r1cky
authorEd Lee <edilee@mozilla.com>
Thu, 11 Jul 2019 21:50:17 +0000
changeset 482475 1f70de7f07a6c25a41794c67b189e204ee87c192
parent 482474 2126b3b1a0c24a9e2bf33e9a0a26ff01bdd277e7
child 482476 187dd776f6df31a4a1f8da28e5a84a2c985bfd92
push id36282
push userdvarga@mozilla.com
push dateFri, 12 Jul 2019 09:56:21 +0000
treeherdermozilla-central@cb2d564879e3 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersr1cky
bugs1565293
milestone70.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1565293 - Add What's New, sized welcome and bug fixes to New Tab Page r=r1cky Differential Revision: https://phabricator.services.mozilla.com/D37761
browser/components/newtab/.prettierignore
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/components/ModalOverlay/_ModalOverlay.scss
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
deleted file mode 100644
--- a/browser/components/newtab/.prettierignore
+++ /dev/null
@@ -1,1 +0,0 @@
-*
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
@@ -37,17 +37,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" />
@@ -57,17 +62,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
@@ -268,19 +268,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/components/ModalOverlay/_ModalOverlay.scss
+++ b/browser/components/newtab/content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss
@@ -17,19 +17,18 @@
   &.active {
     display: block;
   }
 }
 
 .modalOverlayInner {
   width: 960px;
   position: fixed;
-  top: 20%;
+  top: 15%;
   left: calc(50% - 480px); // halfway across minus half the width of the modal
-  max-height: calc(100% - 100px);
   background: $white;
   box-shadow: 0 1px 15px 0 $black-30;
   border-radius: 4px;
   display: none;
   z-index: 1101;
 
 
   // modal takes over entire screen
@@ -37,24 +36,19 @@
     width: 100%;
     height: 100%;
     top: 0;
     left: 0;
     box-shadow: none;
     border-radius: 0;
   }
 
-  // if modal is short enough, add a vertical scroll bar
-  @media(max-width: 850px) and (max-height: 730px) {
-    overflow-y: scroll;
-  }
-
-  // if modal is narrow enough, add a vertical scroll bar
-  @media(max-width: 650px) and (max-height: 600px) {
-    overflow-y: scroll;
+  // if modal is short enough, reduce the top margin
+  @media(max-height: 730px) {
+    top: 5%;
   }
 
   &.active {
     display: block;
   }
 
   h2 {
     color: $grey-60;
--- 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
@@ -3,17 +3,16 @@
   $benefit-icon-spacing: $benefit-icon-size + 12px;
   $benefit-icon-size-small: 40px;
   $benefit-icon-spacing-small: $benefit-icon-size-small + 12px;
   $responsive-breakpoint: 850px;
 
   background: url('#{$image-path}trailhead/accounts-form-bg.jpg') bottom / cover;
   color: $white;
   height: auto;
-  top: 100px;
 
   a {
     color: $white;
     text-decoration: underline;
   }
 
   input,
   button {
@@ -259,17 +258,16 @@
   .trailheadInner,
   .trailheadStart {
     animation: fadeIn 0.4s;
   }
 }
 
 .trailheadCards {
   background: var(--trailhead-cards-background-color);
-  max-height: 1000px;
   overflow: hidden;
   text-align: center;
   transition: max-height 0.5s $photon-easing;
 
 
   &.collapsed {
     max-height: 0;
   }
@@ -316,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
@@ -71,30 +71,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;
     }
@@ -140,17 +133,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
@@ -3001,38 +3001,34 @@ body[lwt-newtab-brighttext] .scene2Icon 
   display: none;
   z-index: 1100; }
   .modalOverlayOuter.active {
     display: block; }
 
 .modalOverlayInner {
   width: 960px;
   position: fixed;
-  top: 20%;
+  top: 15%;
   left: calc(50% - 480px);
-  max-height: calc(100% - 100px);
   background: #FFF;
   box-shadow: 0 1px 15px 0 rgba(0, 0, 0, 0.3);
   border-radius: 4px;
   display: none;
   z-index: 1101; }
   @media (max-width: 960px) {
     .modalOverlayInner {
       width: 100%;
       height: 100%;
       top: 0;
       left: 0;
       box-shadow: none;
       border-radius: 0; } }
-  @media (max-width: 850px) and (max-height: 730px) {
+  @media (max-height: 730px) {
     .modalOverlayInner {
-      overflow-y: scroll; } }
-  @media (max-width: 650px) and (max-height: 600px) {
-    .modalOverlayInner {
-      overflow-y: scroll; } }
+      top: 5%; } }
   .modalOverlayInner.active {
     display: block; }
   .modalOverlayInner h2 {
     color: #4A4A4F;
     text-align: center;
     font-weight: 200;
     margin-top: 30px;
     font-size: 28px;
@@ -3512,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"); }
@@ -3848,18 +3844,17 @@ a.firstrun-link {
     transform: translateY(-15px); }
   100% {
     opacity: 1;
     transform: translateY(0); } }
 
 .trailhead {
   background: url("../data/content/assets/trailhead/accounts-form-bg.jpg") bottom/cover;
   color: #FFF;
-  height: auto;
-  top: 100px; }
+  height: auto; }
   .trailhead a {
     color: #FFF;
     text-decoration: underline; }
   .trailhead input,
   .trailhead button {
     border-radius: 4px;
     padding: 10px; }
   .trailhead .trailheadInner {
@@ -4010,17 +4005,16 @@ a.firstrun-link {
     .trailhead .trailheadStart:active {
       background-color: #054096; }
   .trailhead .trailheadInner,
   .trailhead .trailheadStart {
     animation: fadeIn 0.4s; }
 
 .trailheadCards {
   background: var(--trailhead-cards-background-color);
-  max-height: 1000px;
   overflow: hidden;
   text-align: center;
   transition: max-height 0.5s cubic-bezier(0.07, 0.95, 0, 1); }
   .trailheadCards.collapsed {
     max-height: 0; }
   .trailheadCards h1 {
     font-size: 36px;
     font-weight: 200;
@@ -4051,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
@@ -3004,38 +3004,34 @@ body[lwt-newtab-brighttext] .scene2Icon 
   display: none;
   z-index: 1100; }
   .modalOverlayOuter.active {
     display: block; }
 
 .modalOverlayInner {
   width: 960px;
   position: fixed;
-  top: 20%;
+  top: 15%;
   left: calc(50% - 480px);
-  max-height: calc(100% - 100px);
   background: #FFF;
   box-shadow: 0 1px 15px 0 rgba(0, 0, 0, 0.3);
   border-radius: 4px;
   display: none;
   z-index: 1101; }
   @media (max-width: 960px) {
     .modalOverlayInner {
       width: 100%;
       height: 100%;
       top: 0;
       left: 0;
       box-shadow: none;
       border-radius: 0; } }
-  @media (max-width: 850px) and (max-height: 730px) {
+  @media (max-height: 730px) {
     .modalOverlayInner {
-      overflow-y: scroll; } }
-  @media (max-width: 650px) and (max-height: 600px) {
-    .modalOverlayInner {
-      overflow-y: scroll; } }
+      top: 5%; } }
   .modalOverlayInner.active {
     display: block; }
   .modalOverlayInner h2 {
     color: #4A4A4F;
     text-align: center;
     font-weight: 200;
     margin-top: 30px;
     font-size: 28px;
@@ -3515,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"); }
@@ -3851,18 +3847,17 @@ a.firstrun-link {
     transform: translateY(-15px); }
   100% {
     opacity: 1;
     transform: translateY(0); } }
 
 .trailhead {
   background: url("../data/content/assets/trailhead/accounts-form-bg.jpg") bottom/cover;
   color: #FFF;
-  height: auto;
-  top: 100px; }
+  height: auto; }
   .trailhead a {
     color: #FFF;
     text-decoration: underline; }
   .trailhead input,
   .trailhead button {
     border-radius: 4px;
     padding: 10px; }
   .trailhead .trailheadInner {
@@ -4013,17 +4008,16 @@ a.firstrun-link {
     .trailhead .trailheadStart:active {
       background-color: #054096; }
   .trailhead .trailheadInner,
   .trailhead .trailheadStart {
     animation: fadeIn 0.4s; }
 
 .trailheadCards {
   background: var(--trailhead-cards-background-color);
-  max-height: 1000px;
   overflow: hidden;
   text-align: center;
   transition: max-height 0.5s cubic-bezier(0.07, 0.95, 0, 1); }
   .trailheadCards.collapsed {
     max-height: 0; }
   .trailheadCards h1 {
     font-size: 36px;
     font-weight: 200;
@@ -4054,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
@@ -3001,38 +3001,34 @@ body[lwt-newtab-brighttext] .scene2Icon 
   display: none;
   z-index: 1100; }
   .modalOverlayOuter.active {
     display: block; }
 
 .modalOverlayInner {
   width: 960px;
   position: fixed;
-  top: 20%;
+  top: 15%;
   left: calc(50% - 480px);
-  max-height: calc(100% - 100px);
   background: #FFF;
   box-shadow: 0 1px 15px 0 rgba(0, 0, 0, 0.3);
   border-radius: 4px;
   display: none;
   z-index: 1101; }
   @media (max-width: 960px) {
     .modalOverlayInner {
       width: 100%;
       height: 100%;
       top: 0;
       left: 0;
       box-shadow: none;
       border-radius: 0; } }
-  @media (max-width: 850px) and (max-height: 730px) {
+  @media (max-height: 730px) {
     .modalOverlayInner {
-      overflow-y: scroll; } }
-  @media (max-width: 650px) and (max-height: 600px) {
-    .modalOverlayInner {
-      overflow-y: scroll; } }
+      top: 5%; } }
   .modalOverlayInner.active {
     display: block; }
   .modalOverlayInner h2 {
     color: #4A4A4F;
     text-align: center;
     font-weight: 200;
     margin-top: 30px;
     font-size: 28px;
@@ -3512,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"); }
@@ -3848,18 +3844,17 @@ a.firstrun-link {
     transform: translateY(-15px); }
   100% {
     opacity: 1;
     transform: translateY(0); } }
 
 .trailhead {
   background: url("../data/content/assets/trailhead/accounts-form-bg.jpg") bottom/cover;
   color: #FFF;
-  height: auto;
-  top: 100px; }
+  height: auto; }
   .trailhead a {
     color: #FFF;
     text-decoration: underline; }
   .trailhead input,
   .trailhead button {
     border-radius: 4px;
     padding: 10px; }
   .trailhead .trailheadInner {
@@ -4010,17 +4005,16 @@ a.firstrun-link {
     .trailhead .trailheadStart:active {
       background-color: #054096; }
   .trailhead .trailheadInner,
   .trailhead .trailheadStart {
     animation: fadeIn 0.4s; }
 
 .trailheadCards {
   background: var(--trailhead-cards-background-color);
-  max-height: 1000px;
   overflow: hidden;
   text-align: center;
   transition: max-height 0.5s cubic-bezier(0.07, 0.95, 0, 1); }
   .trailheadCards.collapsed {
     max-height: 0; }
   .trailheadCards h1 {
     font-size: 36px;
     font-weight: 200;
@@ -4051,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
@@ -1,12 +1,8 @@
-/* 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(modules) { // webpackBootstrap
 /******/ 	// The module cache
 /******/ 	var installedModules = {};
 /******/
 /******/ 	// The require function
 /******/ 	function __webpack_require__(moduleId) {
 /******/
 /******/ 		// Check if module is in cache
@@ -100,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/. */
 
 
 
 
 
 
 
 
@@ -460,17 +459,17 @@ function SetPref(name, value, importCont
       value
     }
   };
   return importContext === UI_CODE ? AlsoToMain(action) : action;
 }
 
 function WebExtEvent(type, data, importContext = globalImportContext) {
   if (!data || !data.source) {
-    throw new Error("WebExtEvent actions should include a property \"source\", the id of the webextension that should receive the event.");
+    throw new Error('WebExtEvent actions should include a property "source", the id of the webextension that should receive the event.');
   }
 
   const action = {
     type,
     data
   };
   return importContext === UI_CODE ? AlsoToMain(action) : action;
 }
@@ -568,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/. */
 
 
 
 
 
 
 
 
@@ -758,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({
@@ -791,17 +796,17 @@ function relativeTime(timestamp) {
   } else if (minutes < 600) {
     return `${minutes} minutes ago`;
   }
 
   return new Date(timestamp).toLocaleString();
 }
 
 const LAYOUT_VARIANTS = {
-  "basic": "Basic default layout (on by default in nightly)",
+  basic: "Basic default layout (on by default in nightly)",
   "dev-test-all": "A little bit of everything. Good layout for testing all components",
   "dev-test-feeds": "Stress testing for slow feeds"
 };
 class ToggleStoryButton extends react__WEBPACK_IMPORTED_MODULE_4___default.a.PureComponent {
   constructor(props) {
     super(props);
     this.handleClick = this.handleClick.bind(this);
   }
@@ -950,17 +955,17 @@ class DiscoveryStreamAdmin extends react
       layout
     } = this.props.state;
     return react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("div", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("div", {
       className: "dsEnabled"
     }, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("input", {
       type: "checkbox",
       checked: config.enabled,
       onChange: this.onEnableToggle
-    }), " enabled "), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("h3", null, "Endpoint variant"), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("p", null, "You can also change this manually by changing this pref: ", react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("code", null, "browser.newtabpage.activity-stream.discoverystream.config")), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("table", {
+    }), " ", "enabled", " "), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("h3", null, "Endpoint variant"), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("p", null, "You can also change this manually by changing this pref:", " ", react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("code", null, "browser.newtabpage.activity-stream.discoverystream.config")), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("table", {
       style: config.enabled ? null : {
         opacity: 0.5
       }
     }, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("tbody", null, Object.keys(LAYOUT_VARIANTS).map(id => react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement(Row, {
       key: id
     }, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("td", {
       className: "min"
     }, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("input", {
@@ -1405,17 +1410,17 @@ class ASRouterAdminInner extends react__
     // There was no error and the result is truthy
     const success = this.state.evaluationStatus.success && !!this.state.evaluationStatus.result;
     const result = JSON.stringify(this.state.evaluationStatus.result, null, 2) || "(Empty result)";
     return react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("table", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("tbody", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("tr", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("td", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("h2", null, "Evaluate JEXL expression"))), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("tr", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("td", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("p", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("textarea", {
       ref: "expressionInput",
       rows: "10",
       cols: "60",
       placeholder: "Evaluate JEXL expressions and mock parameters by changing their values below"
-    })), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("p", null, "Status: ", react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("span", {
+    })), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("p", null, "Status:", " ", react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("span", {
       ref: "evaluationStatus"
     }, success ? "✅" : "❌", ", Result: ", result))), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("td", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("button", {
       className: "ASRouterButton secondary",
       onClick: this.handleExpressionEval
     }, "Evaluate"))), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("tr", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("td", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("h2", null, "Modify targeting parameters"))), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("tr", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("td", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("button", {
       className: "ASRouterButton secondary",
       onClick: this.onCopyTargetingParams,
       disabled: this.state.copiedToClipboard
@@ -1499,17 +1504,17 @@ class ASRouterAdminInner extends react__
     }, "Enabled"), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("td", null, config.enabled ? "yes" : "no")), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("tr", {
       className: "message-item"
     }, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("td", {
       className: "min"
     }, "Endpoint"), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("td", null, config.endpoint || "(empty)")))));
   }
 
   renderAttributionParamers() {
-    return react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("div", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("h2", null, " Attribution Parameters "), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("p", null, " This forces the browser to set some attribution parameters, useful for testing the Return To AMO feature. Clicking on 'Force Attribution', with the default values in each field, will demo the Return To AMO flow with the addon called 'Iridium for Youtube'. If you wish to try different attribution parameters, enter them in the text boxes. If you wish to try a different addon with the Return To AMO flow, make sure the 'content' text box has the addon GUID, then click 'Force Attribution'."), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("table", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("tr", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("td", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("b", null, " Source ")), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("td", null, " ", react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("input", {
+    return react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("div", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("h2", null, " Attribution Parameters "), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("p", null, " ", "This forces the browser to set some attribution parameters, useful for testing the Return To AMO feature. Clicking on 'Force Attribution', with the default values in each field, will demo the Return To AMO flow with the addon called 'Iridium for Youtube'. If you wish to try different attribution parameters, enter them in the text boxes. If you wish to try a different addon with the Return To AMO flow, make sure the 'content' text box has the addon GUID, then click 'Force Attribution'."), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("table", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("tr", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("td", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("b", null, " Source ")), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("td", null, " ", react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("input", {
       type: "text",
       name: "source",
       placeholder: "addons.mozilla.org",
       value: this.state.attributionParameters.source,
       onChange: this.onChangeAttributionParameters
     }), " ")), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("tr", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("td", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("b", null, " Campaign ")), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("td", null, " ", react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("input", {
       type: "text",
       name: "campaign",
@@ -1520,17 +1525,17 @@ class ASRouterAdminInner extends react__
       type: "text",
       name: "content",
       placeholder: "iridium@particlecore.github.io",
       value: this.state.attributionParameters.content,
       onChange: this.onChangeAttributionParameters
     }), " ")), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("tr", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("td", null, " ", react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("button", {
       className: "ASRouterButton primary button",
       onClick: this.setAttribution
-    }, " Force Attribution "), " "))));
+    }, " ", "Force Attribution", " "), " "))));
   }
 
   renderErrorMessage({
     id,
     errors
   }) {
     const providerId = react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("td", {
       rowSpan: errors.length
@@ -1570,33 +1575,33 @@ class ASRouterAdminInner extends react__
   getSection() {
     const [section] = this.props.location.routes;
 
     switch (section) {
       case "targeting":
         return react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement(react__WEBPACK_IMPORTED_MODULE_4___default.a.Fragment, null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("h2", null, "Targeting Utilities"), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("button", {
           className: "button",
           onClick: this.expireCache
-        }, "Expire Cache"), " (This expires the cache in ASR Targeting for bookmarks and top sites)", this.renderTargetingParameters(), this.renderAttributionParamers());
+        }, "Expire Cache"), " ", "(This expires the cache in ASR Targeting for bookmarks and top sites)", this.renderTargetingParameters(), this.renderAttributionParamers());
 
       case "pocket":
         return react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement(react__WEBPACK_IMPORTED_MODULE_4___default.a.Fragment, null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("h2", null, "Pocket"), this.renderPocketStories());
 
       case "ds":
         return react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement(react__WEBPACK_IMPORTED_MODULE_4___default.a.Fragment, null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("h2", null, "Discovery Stream"), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement(DiscoveryStreamAdmin, {
           state: this.props.DiscoveryStream,
           otherPrefs: this.props.Prefs.values,
           dispatch: this.props.dispatch
         }));
 
       case "errors":
         return react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement(react__WEBPACK_IMPORTED_MODULE_4___default.a.Fragment, null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("h2", null, "ASRouter Errors"), this.renderErrors());
 
       default:
-        return react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement(react__WEBPACK_IMPORTED_MODULE_4___default.a.Fragment, null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("h2", null, "Message Providers ", react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("button", {
+        return react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement(react__WEBPACK_IMPORTED_MODULE_4___default.a.Fragment, null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("h2", null, "Message Providers", " ", react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("button", {
           title: "Restore all provider settings that ship with Firefox",
           className: "button",
           onClick: this.resetPref
         }, "Restore default prefs")), this.state.providers ? this.renderProviders() : null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("h2", null, "Trailhead"), this.renderTrailheadInfo(), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("h2", null, "Messages"), this.renderMessageFilter(), this.renderMessages(), this.renderPasteModal());
     }
   }
 
   render() {
@@ -1615,17 +1620,17 @@ class ASRouterAdminInner extends react__
     }, "Discovery Stream")), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("li", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("a", {
       href: "#devtools-errors"
     }, "Errors")))), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("main", {
       className: "main-panel"
     }, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("h1", null, "AS Router Admin"), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("p", {
       className: "helpLink"
     }, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("span", {
       className: "icon icon-small-spacer icon-info"
-    }), " ", react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("span", null, "Need help using these tools? Check out our ", react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("a", {
+    }), " ", react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("span", null, "Need help using these tools? Check out our", " ", react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("a", {
       target: "blank",
       href: "https://github.com/mozilla/activity-stream/blob/master/content-src/asrouter/docs/debugging-docs.md"
     }, "documentation"))), this.getSection()));
   }
 
 }
 class CollapseToggle extends react__WEBPACK_IMPORTED_MODULE_4___default.a.PureComponent {
   constructor(props) {
@@ -1723,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/. */
 
 
 
 
 
 
 
 
@@ -2071,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"], {
@@ -2127,17 +2135,17 @@ class ASRouterUISurface extends react__W
         onReady: this.triggerOnboarding,
         onBlock: this.onDismissById(message.id),
         dispatch: this.props.dispatch
       });
     } else if (message.template === "return_to_amo_overlay") {
       global.document.body.classList.add("amo");
       return react__WEBPACK_IMPORTED_MODULE_7___default.a.createElement(fluent_react__WEBPACK_IMPORTED_MODULE_4__["LocalizationProvider"], {
         bundles: Object(_rich_text_strings__WEBPACK_IMPORTED_MODULE_2__["generateBundles"])({
-          "amo_html": message.content.text
+          amo_html: message.content.text
         })
       }, react__WEBPACK_IMPORTED_MODULE_7___default.a.createElement(_templates_ReturnToAMO_ReturnToAMO__WEBPACK_IMPORTED_MODULE_9__["ReturnToAMO"], _extends({}, message, {
         UISurface: "NEWTAB_OVERLAY",
         onReady: this.triggerOnboarding,
         onBlock: this.onDismissById(message.id),
         onAction: ASRouterUtils.executeAction,
         sendUserActionTelemetry: this.sendUserActionTelemetry
       })));
@@ -2215,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];
 /**
@@ -2376,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 {
@@ -2462,41 +2477,44 @@ 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
+  ntp_background: {
+    r: 42,
+    g: 42,
+    b: 46,
+    a: 1
   },
-  "ntp_text": {
-    "r": 249,
-    "g": 249,
-    "b": 250,
-    "a": 1
+  ntp_text: {
+    r: 249,
+    g: 249,
+    b: 250,
+    a: 1
   },
-  "sidebar": {
-    "r": 56,
-    "g": 56,
-    "b": 61,
-    "a": 1
+  sidebar: {
+    r: 56,
+    g: 56,
+    b: 61,
+    a: 1
   },
-  "sidebar_text": {
-    "r": 249,
-    "g": 249,
-    "b": 250,
-    "a": 1
+  sidebar_text: {
+    r: 249,
+    g: 249,
+    b: 250,
+    a: 1
   }
 };
 /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(1)))
 
 /***/ }),
 /* 12 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
@@ -2504,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);
   }
@@ -2595,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) {
@@ -2680,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);
@@ -2774,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),
@@ -2839,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);
@@ -2899,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);
@@ -3203,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 {
@@ -3619,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
     };
@@ -3669,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.
@@ -3768,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);
   }
@@ -3833,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]) {
@@ -3907,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
     })));
   }
 
 }
@@ -3930,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
@@ -4157,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";
@@ -4453,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);
   }
@@ -4537,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}`;
   }
@@ -4562,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() {
@@ -4609,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() {
@@ -4685,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: () => ({
@@ -4829,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/. */
 
 
 
 
 
 
 
 
@@ -5179,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
@@ -5248,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) {
@@ -5422,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
@@ -5552,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) {
@@ -5584,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", {
@@ -5622,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", {
@@ -5677,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/. */
 
 
 
 
 
 
 
 
@@ -5726,22 +5812,22 @@ function topSiteIconType(link) {
 
 function countTopSitesIconsTypes(topSites) {
   const countTopSitesTypes = (acc, link) => {
     acc[topSiteIconType(link)]++;
     return acc;
   };
 
   return topSites.reduce(countTopSitesTypes, {
-    "custom_screenshot": 0,
-    "screenshot_with_icon": 0,
-    "screenshot": 0,
-    "tippytop": 0,
-    "rich_icon": 0,
-    "no_image": 0
+    custom_screenshot: 0,
+    screenshot_with_icon: 0,
+    screenshot: 0,
+    tippytop: 0,
+    rich_icon: 0,
+    no_image: 0
   });
 }
 
 class _TopSites extends react__WEBPACK_IMPORTED_MODULE_6___default.a.PureComponent {
   constructor(props) {
     super(props);
     this.onEditFormClose = this.onEditFormClose.bind(this);
     this.onSearchShortcutsFormClose = this.onSearchShortcutsFormClose.bind(this);
@@ -5888,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;
@@ -5909,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
@@ -6104,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) {
@@ -6746,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 {
@@ -6925,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
 
@@ -7014,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 = {
@@ -7139,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
@@ -7235,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);
   }
 
@@ -7311,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) {
@@ -7400,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 = {};
   }
@@ -7483,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 = [];
 
@@ -7563,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"
@@ -7586,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/. */
 
 
 
 
 
 
 
 
@@ -7755,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/. */
 
 
 
 
 
 
 
 
@@ -7925,16 +8054,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) {
@@ -7948,26 +8080,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;
@@ -7995,16 +8133,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
       }
@@ -8015,16 +8156,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.
@@ -8086,17 +8230,17 @@ const selectLayoutRender = (state, prefs
     let items = 0;
 
     if (component.properties && component.properties.items) {
       items = component.properties.items;
     }
 
     for (let i = 0; i < items; i++) {
       data.recommendations.push({
-        "placeholder": true
+        placeholder: true
       });
     }
 
     return { ...component,
       data
     };
   };
 
@@ -8216,16 +8360,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 +8385,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 +9497,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 +9523,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 +9654,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 +9850,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 +10001,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 +10310,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 +10349,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 +10378,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 +10429,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 +10498,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 +10551,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,31 +11946,34 @@ 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 = {
-  "text": ["text", "scene1_text"],
-  "success_text": "success_text",
-  "error_text": "error_text",
-  "scene2_text": "scene2_text",
-  "amo_html": "amo_html",
-  "privacy_html": "scene2_privacy_html",
-  "disclaimer_html": "scene2_disclaimer_html"
+  text: ["text", "scene1_text"],
+  success_text: "success_text",
+  error_text: "error_text",
+  scene2_text: "scene2_text",
+  amo_html: "amo_html",
+  privacy_html: "scene2_privacy_html",
+  disclaimer_html: "scene2_disclaimer_html"
 };
 const RICH_TEXT_KEYS = Object.keys(RICH_TEXT_CONFIG);
 /**
  * Generates an array of messages suitable for fluent's localization provider
  * including all needed strings for rich text.
  * @param {object} content A .content object from an ASR message (i.e. message.content)
  * @returns {FluentBundle[]} A array containing the fluent message context
  */
@@ -11811,16 +12001,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;
   }
@@ -12659,16 +12852,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"
@@ -12698,16 +12894,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();
@@ -13049,16 +13248,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);
@@ -13162,16 +13364,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) {
@@ -13195,17 +13400,17 @@ class TopSiteForm_TopSiteForm extends ex
     this.onCustomScreenshotUrlChange = this.onCustomScreenshotUrlChange.bind(this);
     this.onPreviewButtonClick = this.onPreviewButtonClick.bind(this);
     this.onEnableScreenshotUrlForm = this.onEnableScreenshotUrlForm.bind(this);
     this.validateUrl = this.validateUrl.bind(this);
   }
 
   onLabelChange(event) {
     this.setState({
-      "label": event.target.value
+      label: event.target.value
     });
   }
 
   onUrlChange(event) {
     this.setState({
       url: event.target.value,
       validationError: false
     });
@@ -13468,16 +13673,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: {
@@ -13749,16 +13957,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,16 +1,19 @@
 /* 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 { 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,
@@ -42,16 +45,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;
 
@@ -74,30 +83,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"];
@@ -205,16 +207,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)
@@ -358,16 +389,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",
@@ -452,47 +505,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
@@ -15,17 +15,20 @@ describe("BookmarkPanelHub", () => {
   let fakeContainer;
   let fakeDispatch;
   let fakeWindow;
   let isBrowserPrivateStub;
   beforeEach(() => {
     sandbox = sinon.createSandbox();
     globals = new GlobalOverrider();
 
-    fakeL10n = { setAttributes: sandbox.stub() };
+    fakeL10n = {
+      setAttributes: sandbox.stub(),
+      translateElements: sandbox.stub().resolves(),
+    };
     globals.set("DOMLocalization", function() {
       return fakeL10n;
     }); // eslint-disable-line prefer-arrow-callback
     globals.set("FxAccounts", {
       config: { promiseEmailFirstURI: sandbox.stub() },
     });
     isBrowserPrivateStub = sandbox.stub().returns(false);
     globals.set("PrivateBrowsingUtils", {
@@ -43,27 +46,31 @@ describe("BookmarkPanelHub", () => {
       addEventListener: sandbox.stub(),
       setAttribute: sandbox.stub(),
       removeAttribute: sandbox.stub(),
       classList: { add: sandbox.stub() },
       appendChild: sandbox.stub(),
       querySelector: sandbox.stub(),
       children: [],
       style: {},
+      getBoundingClientRect: sandbox.stub(),
+    };
+    const document = {
+      createElementNS: sandbox.stub().returns(fakeContainer),
+      getElementById: sandbox.stub().returns(fakeContainer),
+      l10n: fakeL10n,
     };
     fakeWindow = {
       ownerGlobal: {
         openLinkIn: sandbox.stub(),
         gBrowser: { selectedBrowser: "browser" },
       },
       MozXULElement: { insertFTLIfNeeded: sandbox.stub() },
-    };
-    const document = {
-      createElementNS: sandbox.stub().returns(fakeContainer),
-      getElementById: sandbox.stub().returns(fakeContainer),
+      document,
+      requestAnimationFrame: x => x(),
     };
     fakeTarget = {
       document,
       container: {
         querySelector: sandbox.stub(),
         appendChild: sandbox.stub(),
         setAttribute: sandbox.stub(),
         removeAttribute: sandbox.stub(),
@@ -126,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(
@@ -244,16 +253,29 @@ describe("BookmarkPanelHub", () => {
     });
     it("should set l10n attributes", () => {
       fakeTarget.container.querySelector.returns(false);
 
       instance.showMessage(fakeMessageFluent, fakeTarget);
 
       assert.equal(fakeL10n.setAttributes.callCount, 4);
     });
+    it("call adjust panel height when height is > 150px", async () => {
+      fakeTarget.container.querySelector.returns(false);
+      fakeContainer.getBoundingClientRect.returns({ height: 160 });
+
+      await instance._adjustPanelHeight(fakeWindow, fakeContainer);
+
+      assert.calledOnce(fakeWindow.document.l10n.translateElements);
+      assert.calledTwice(fakeContainer.getBoundingClientRect);
+      assert.calledWithExactly(
+        fakeContainer.classList.add,
+        "longMessagePadding"
+      );
+    });
     it("should reuse the container", () => {
       fakeTarget.container.querySelector.returns(true);
 
       instance.showMessage(fakeMessage, fakeTarget);
 
       assert.notCalled(fakeTarget.container.appendChild);
     });
     it("should open a tab with FxA signup", async () => {
@@ -353,16 +375,17 @@ describe("BookmarkPanelHub", () => {
     beforeEach(() => {
       sandbox.stub(instance, "toggleRecommendation");
       removeStub = sandbox.stub();
       fakeTarget = {
         container: {
           querySelector: sandbox.stub().returns({ remove: removeStub }),
         },
       };
+      instance._response = { win: fakeWindow };
     });
     it("should remove the message", () => {
       instance.hideMessage(fakeTarget);
 
       assert.calledOnce(removeStub);
     });
     it("should call toggleRecommendation `false`", () => {
       instance.hideMessage(fakeTarget);
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();
       }