Bug 1567601 - New "Moments" Page. r=k88hudson, a=RyanVM
authorEd Lee <edilee@mozilla.com>
Tue, 13 Aug 2019 22:36:31 +0000
changeset 545135 feeaf170fca669a675eff2cb16bd56b0a329b508
parent 545134 8107e7f68e126fb10e697ee135c4589a8b72dd73
child 545136 194b831613666fa13fb024c934197ee3e776e368
push id2131
push userffxbld-merge
push dateMon, 26 Aug 2019 18:30:20 +0000
treeherdermozilla-release@b19ffb3ca153 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersk88hudson, RyanVM
bugs1567601, 1561536, 1561540, 1564898, 1564811, 1561537, 1561554, 1566372, 1571818, 1570935, 1533846, 1569020, 1571846, 1573953
milestone69.0
Bug 1567601 - New "Moments" Page. r=k88hudson, a=RyanVM Includes 15 activity-stream commits: 27b79564 Bug 1561536 - Add new message schema and template type for feature callouts (#5133) d8570e05 Bug 1561540 - CFR messages should have a priority field that allows sorting (#5156) 7c9f5603 Bug 1564898 - Use UpdateManager to get the earliest Firefox version used (#5165) 6ebba35b Bug 1564811 - Badge actions can define a timeout delay after which the action is executed (#5170) 90b5eea2 (nobug) Move the What's New and Badge test messages to PanelTestProvider.jsm (#5171) 23e918ca Port 1561537 - Add badge/feature-callout style that matches the design spec r=r1cky 28bf2f0d (nobug) - Restore the default icon style after removing the badge (#5180) ffcb9a2a Bug 1561554 - Add telemetry events for notification badges (#5176) 4fcf6451 Bug 1566372 - Listen for pref changes as a way to trigger notifications (#5191) 993d0446 Bug 1571818 - handleMessageRequest method does not consider trigger params (#5226) c42d699c Port 1570935 - Remove clip-path property for causing performance regression r=k88hudson e125087c Port 1533846 - Clear WeakMap entries for Messaging System notifications r=k88hudson 84abc9de Bug 1569020 - Add a new badge action for setting homepage_override pref (#5207) f4878d99 Bug 1571846 - Moments pref reset issue (#5223) 2febea0f Bug 1573953 - Don't add impressions for moments page pref check (#5256) Differential Revision: https://phabricator.services.mozilla.com/D41859
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/UpdateAction.schema.json
browser/components/newtab/docs/v2-system-addon/data_events.md
browser/components/newtab/lib/ASRouter.jsm
browser/components/newtab/lib/ASRouterTargeting.jsm
browser/components/newtab/lib/BookmarkPanelHub.jsm
browser/components/newtab/lib/PanelTestProvider.jsm
browser/components/newtab/lib/ToolbarBadgeHub.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/BookmarkPanelHub.test.js
browser/components/newtab/test/unit/lib/ToolbarBadgeHub.test.js
browser/components/newtab/test/unit/unit-entry.js
--- 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/UpdateAction.schema.json
@@ -0,0 +1,36 @@
+{
+  "title": "UpdateActionMessage",
+  "description": "A template for messages that execute predetermined actions.",
+  "version": "1.0.0",
+  "type": "object",
+  "properties": {
+    "action": {
+      "type": "object",
+      "properties": {
+        "id": {
+          "type": "string"
+        },
+        "data": {
+          "type": "object",
+          "properties": {
+            "url": {
+              "type": "string",
+              "description": "URL data to be used as argument to the action"
+            },
+            "expireDelta": {
+              "type": "number",
+              "description": "Expiration timestamp to be used as argument to the action"
+            }
+          }
+        },
+        "description": "Additional data provided as argument when executing the action"
+      },
+      "additionalProperties": false,
+      "description": "Optional action to take in addition to showing the notification"
+    },
+    "additionalProperties": false,
+    "required": ["id", "action"]
+  },
+  "additionalProperties": false,
+  "required": ["action"]
+}
--- a/browser/components/newtab/docs/v2-system-addon/data_events.md
+++ b/browser/components/newtab/docs/v2-system-addon/data_events.md
@@ -1063,8 +1063,26 @@ This reports an enrollment ping when a u
   "object": "preference_study"
   "value": "activity-stream-firstup-trailhead-interrupts",
   "extra_keys": {
     "experimentType": "as-firstrun",
     "branch": ["supercharge" | "join" | "sync" | "privacy" ...]
   }
 }
 ```
+
+## Feature Callouts interaction pings
+
+This reports when a user has seen or clicked a badge/notification in the browser toolbar in a non-PBM window
+
+```
+{
+  "locale": "en-US",
+  "client_id": "9da773d8-4356-f54f-b7cf-6134726bcf3d",
+  "version": "70.0a1",
+  "release_channel": "default",
+  "addon_version": "20190712095934",
+  "action": "cfr_user_event",
+  "source": "CFR",
+  "message_id": "FXA_ACCOUNTS_BADGE",
+  "event": ["CLICK" | "IMPRESSION"],
+}
+```
--- a/browser/components/newtab/lib/ASRouter.jsm
+++ b/browser/components/newtab/lib/ASRouter.jsm
@@ -13,16 +13,17 @@ 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",
 });
 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 +487,24 @@ class _ASRouter {
       trailheadInitialized: false,
       trailheadInterrupt: "",
       trailheadTriplet: "",
       messages: [],
       errors: [],
     };
     this._triggerHandler = this._triggerHandler.bind(this);
     this._localProviders = localProviders;
+    this.blockMessageById = this.blockMessageById.bind(this);
+    this.unblockMessageById = this.unblockMessageById.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,31 @@ 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,
+      unblockMessageById: this.unblockMessageById,
+      dispatch: this.dispatch,
+    });
 
     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 +781,17 @@ class _ASRouter {
       this.onMessage
     );
     this.messageChannel = null;
     this.dispatchToAS = null;
 
     ASRouterPreferences.removeListener(this.onPrefChange);
     ASRouterPreferences.uninit();
     BookmarkPanelHub.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,22 +1027,26 @@ 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;
@@ -1248,16 +1263,20 @@ class _ASRouter {
           );
         }
         break;
       case "fxa_bookmark_panel":
         if (force) {
           BookmarkPanelHub._forceShowMessage(target, message);
         }
         break;
+      case "toolbar_badge":
+      case "update_action":
+        ToolbarBadgeHub.registerBadgeNotificationListener(message, { force });
+        break;
       default:
         target.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {
           type: "SET_MESSAGE",
           data: message,
         });
         break;
     }
   }
@@ -1441,21 +1460,31 @@ 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();
+  handleMessageRequest({ triggerId, triggerParam, template }) {
+    const msgs = this._getUnblockedMessages().filter(m => {
+      if (template && m.template !== template) {
+        return false;
+      }
+      if (m.trigger && m.trigger.id !== triggerId) {
+        return false;
+      }
+
+      return true;
+    });
+
     return this._findMessage(
-      msgs.filter(m => m.trigger && m.trigger.id === trigger.id),
-      trigger
+      msgs,
+      triggerId && { id: triggerId, param: triggerParam }
     );
   }
 
   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);
@@ -1480,16 +1509,27 @@ class _ASRouter {
       });
 
       this._storage.set("messageBlockList", messageBlockList);
       this._storage.set("messageImpressions", messageImpressions);
       return { messageBlockList, messageImpressions };
     });
   }
 
+  unblockMessageById(id) {
+    return this.setState(state => {
+      const messageBlockList = [...state.messageBlockList];
+      const message = state.messages.find(m => m.id === id);
+      const idToUnblock = message && message.campaign ? message.campaign : id;
+      messageBlockList.splice(messageBlockList.indexOf(idToUnblock), 1);
+      this._storage.set("messageBlockList", messageBlockList);
+      return { messageBlockList };
+    });
+  }
+
   async blockProviderById(idOrIds) {
     const idsToBlock = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
 
     await this.setState(state => {
       const providerBlockList = [...state.providerBlockList, ...idsToBlock];
       // When a provider is blocked, its impressions should be cleared as well
       const providerImpressions = { ...state.providerImpressions };
       idsToBlock.forEach(id => delete providerImpressions[id]);
@@ -1793,25 +1833,17 @@ class _ASRouter {
         break;
       case "BLOCK_BUNDLE":
         await this.blockMessageById(action.data.bundle.map(b => b.id));
         this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {
           type: "CLEAR_BUNDLE",
         });
         break;
       case "UNBLOCK_MESSAGE_BY_ID":
-        await this.setState(state => {
-          const messageBlockList = [...state.messageBlockList];
-          const message = state.messages.find(m => m.id === action.data.id);
-          const idToUnblock =
-            message && message.campaign ? message.campaign : action.data.id;
-          messageBlockList.splice(messageBlockList.indexOf(idToUnblock), 1);
-          this._storage.set("messageBlockList", messageBlockList);
-          return { messageBlockList };
-        });
+        this.unblockMessageById(action.data.id);
         break;
       case "UNBLOCK_PROVIDER_BY_ID":
         await this.setState(state => {
           const providerBlockList = [...state.providerBlockList];
           providerBlockList.splice(
             providerBlockList.indexOf(action.data.id),
             1
           );
@@ -1839,16 +1871,17 @@ class _ASRouter {
         } else {
           await this._updateAdminState(target);
         }
         break;
       case "IMPRESSION":
         await this.addImpression(action.data);
         break;
       case "DOORHANGER_TELEMETRY":
+      case "TOOLBAR_BADGE_TELEMETRY":
         if (this.dispatchToAS) {
           this.dispatchToAS(ac.ASRouterUserEvent(action.data));
         }
         break;
       case "EXPIRE_QUERY_CACHE":
         QueryCache.expireAll();
         break;
       case "ENABLE_PROVIDER":
--- a/browser/components/newtab/lib/ASRouterTargeting.jsm
+++ b/browser/components/newtab/lib/ASRouterTargeting.jsm
@@ -1,12 +1,15 @@
 const { FilterExpressions } = ChromeUtils.import(
   "resource://gre/modules/components-utils/FilterExpressions.jsm"
 );
 const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+  "resource://gre/modules/XPCOMUtils.jsm"
+);
 
 ChromeUtils.defineModuleGetter(
   this,
   "ASRouterPreferences",
   "resource://activity-stream/lib/ASRouterPreferences.jsm"
 );
 ChromeUtils.defineModuleGetter(
   this,
@@ -38,16 +41,22 @@ ChromeUtils.defineModuleGetter(
   "AppConstants",
   "resource://gre/modules/AppConstants.jsm"
 );
 ChromeUtils.defineModuleGetter(
   this,
   "AttributionCode",
   "resource:///modules/AttributionCode.jsm"
 );
+XPCOMUtils.defineLazyServiceGetter(
+  this,
+  "UpdateManager",
+  "@mozilla.org/updates/update-manager;1",
+  "nsIUpdateManager"
+);
 
 const FXA_USERNAME_PREF = "services.sync.username";
 const FXA_ENABLED_PREF = "identity.fxaccounts.enabled";
 const SEARCH_REGION_PREF = "browser.search.region";
 const MOZ_JEXL_FILEPATH = "mozjexl";
 
 const { activityStreamProvider: asProvider } = NewTabUtils;
 
@@ -201,16 +210,45 @@ function sortMessagesByTargeting(message
     if (!a.targeting && b.targeting) {
       return 1;
     }
 
     return 0;
   });
 }
 
+/**
+ * Sort messages in descending order based on the value of `priority`
+ * Messages with no `priority` are ranked lowest (even after a message with
+ * priority 0).
+ */
+function sortMessagesByPriority(messages) {
+  return messages.sort((a, b) => {
+    if (isNaN(a.priority) && isNaN(b.priority)) {
+      return 0;
+    }
+    if (!isNaN(a.priority) && isNaN(b.priority)) {
+      return -1;
+    }
+    if (isNaN(a.priority) && !isNaN(b.priority)) {
+      return 1;
+    }
+
+    // Descending order; higher priority comes first
+    if (a.priority > b.priority) {
+      return -1;
+    }
+    if (a.priority < b.priority) {
+      return 1;
+    }
+
+    return 0;
+  });
+}
+
 const TargetingGetters = {
   get locale() {
     return Services.locale.appLocaleAsLangTag;
   },
   get localeLanguageCode() {
     return (
       Services.locale.appLocaleAsLangTag &&
       Services.locale.appLocaleAsLangTag.substr(0, 2)
@@ -354,16 +392,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",
@@ -460,17 +520,18 @@ this.ASRouterTargeting = {
    * @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.
    * @returns {obj} an AS router message
    */
   async findMatchingMessage({ messages, trigger, context, onError }) {
     const weightSortedMessages = sortMessagesByWeightedRank([...messages]);
-    const sortedMessages = sortMessagesByTargeting(weightSortedMessages);
+    let sortedMessages = sortMessagesByTargeting(weightSortedMessages);
+    sortedMessages = sortMessagesByPriority(sortedMessages);
     const triggerContext = trigger ? trigger.context : {};
     const combinedContext = this.combineContexts(context, triggerContext);
 
     for (const candidate of sortedMessages) {
       if (
         candidate &&
         (trigger
           ? this.isTriggerMatch(trigger, candidate.trigger)
--- 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/PanelTestProvider.jsm
+++ b/browser/components/newtab/lib/PanelTestProvider.jsm
@@ -1,13 +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 FIREFOX_VERSION = parseInt(Services.appinfo.version.match(/\d+/), 10);
+const TWO_DAYS = 2 * 24 * 3600 * 1000;
+
 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 +46,64 @@ 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: "WNP_THANK_YOU",
+    template: "update_action",
+    content: {
+      action: {
+        id: "moments-wnp",
+        data: {
+          url:
+            "https://www.mozilla.org/%LOCALE%/etc/firefox/retention/thank-you-a/",
+          expireDelta: TWO_DAYS,
+        },
+      },
+    },
+    trigger: { id: "momentsUpdate" },
+  },
+  {
+    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)`,
+  },
 ];
 
 const PanelTestProvider = {
   getMessages() {
     return MESSAGES().map(message => ({
       ...message,
       targeting: `providerCohorts.panel_local_testing == "SHOW_TEST"`,
     }));
new file mode 100644
--- /dev/null
+++ b/browser/components/newtab/lib/ToolbarBadgeHub.jsm
@@ -0,0 +1,344 @@
+/* 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,
+  "EveryWindow",
+  "resource:///modules/EveryWindow.jsm"
+);
+ChromeUtils.defineModuleGetter(
+  this,
+  "setTimeout",
+  "resource://gre/modules/Timer.jsm"
+);
+ChromeUtils.defineModuleGetter(
+  this,
+  "clearTimeout",
+  "resource://gre/modules/Timer.jsm"
+);
+ChromeUtils.defineModuleGetter(
+  this,
+  "Services",
+  "resource://gre/modules/Services.jsm"
+);
+ChromeUtils.defineModuleGetter(
+  this,
+  "PrivateBrowsingUtils",
+  "resource://gre/modules/PrivateBrowsingUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+  this,
+  "setInterval",
+  "resource://gre/modules/Timer.jsm"
+);
+ChromeUtils.defineModuleGetter(
+  this,
+  "clearInterval",
+  "resource://gre/modules/Timer.jsm"
+);
+
+// Frequency at which to check for new messages
+const SYSTEM_TICK_INTERVAL = 5 * 60 * 1000;
+let notificationsByWindow = new WeakMap();
+
+class _ToolbarBadgeHub {
+  constructor() {
+    this.id = "toolbar-badge-hub";
+    this.state = null;
+    this.prefs = {
+      WHATSNEW_TOOLBAR_PANEL: "browser.messaging-system.whatsNewPanel.enabled",
+      HOMEPAGE_OVERRIDE_PREF: "browser.startup.homepage_override.once",
+    };
+    this.removeAllNotifications = this.removeAllNotifications.bind(this);
+    this.removeToolbarNotification = this.removeToolbarNotification.bind(this);
+    this.addToolbarNotification = this.addToolbarNotification.bind(this);
+    this.registerBadgeToAllWindows = this.registerBadgeToAllWindows.bind(this);
+    this._sendTelemetry = this._sendTelemetry.bind(this);
+    this.sendUserEventTelemetry = this.sendUserEventTelemetry.bind(this);
+    this.checkHomepageOverridePref = this.checkHomepageOverridePref.bind(this);
+
+    this._handleMessageRequest = null;
+    this._addImpression = null;
+    this._blockMessageById = null;
+    this._dispatch = null;
+  }
+
+  async init(
+    waitForInitialized,
+    {
+      handleMessageRequest,
+      addImpression,
+      blockMessageById,
+      unblockMessageById,
+      dispatch,
+    }
+  ) {
+    this._handleMessageRequest = handleMessageRequest;
+    this._blockMessageById = blockMessageById;
+    this._unblockMessageById = unblockMessageById;
+    this._addImpression = addImpression;
+    this._dispatch = dispatch;
+    // Need to wait for ASRouter to initialize before trying to fetch messages
+    await waitForInitialized;
+    this.messageRequest({
+      triggerId: "toolbarBadgeUpdate",
+      template: "toolbar_badge",
+    });
+    // Listen for pref changes that could trigger new badges
+    Services.prefs.addObserver(this.prefs.WHATSNEW_TOOLBAR_PANEL, this);
+    const _intervalId = setInterval(
+      () => this.checkHomepageOverridePref(),
+      SYSTEM_TICK_INTERVAL
+    );
+    this.state = { _intervalId };
+  }
+
+  /**
+   * Pref is set via Remote Settings message. We want to continously
+   * monitor new messages that come in to ensure the one with the
+   * highest priority is set.
+   */
+  checkHomepageOverridePref() {
+    const prefValue = Services.prefs.getStringPref(
+      this.prefs.HOMEPAGE_OVERRIDE_PREF,
+      ""
+    );
+    if (prefValue) {
+      // If the pref is set it means the user has not yet seen this message.
+      // We clear the pref value and re-evaluate all possible messages to ensure
+      // we don't have a higher priority message to show.
+      Services.prefs.clearUserPref(this.prefs.HOMEPAGE_OVERRIDE_PREF);
+      let message_id;
+      try {
+        message_id = JSON.parse(prefValue).message_id;
+      } catch (e) {}
+      if (message_id) {
+        this._unblockMessageById(message_id);
+      }
+    }
+
+    this.messageRequest({
+      triggerId: "momentsUpdate",
+      template: "update_action",
+    });
+  }
+
+  observe(aSubject, aTopic, aPrefName) {
+    switch (aPrefName) {
+      case this.prefs.WHATSNEW_TOOLBAR_PANEL:
+        this.messageRequest({
+          triggerId: "toolbarBadgeUpdate",
+          template: "toolbar_badge",
+        });
+        break;
+    }
+  }
+
+  executeAction({ id, data, message_id }) {
+    switch (id) {
+      case "moments-wnp":
+        const { url, expireDelta } = data;
+        let { expire } = data;
+        if (!expire) {
+          expire = this.getExpirationDate(expireDelta);
+        }
+        Services.prefs.setStringPref(
+          this.prefs.HOMEPAGE_OVERRIDE_PREF,
+          JSON.stringify({ message_id, url, expire })
+        );
+        // Block immediately after taking the action
+        this._blockMessageById(message_id);
+        break;
+    }
+  }
+
+  /**
+   * If we don't have `expire` defined with the message it could be because
+   * it depends on user dependent parameters. Since the message matched
+   * targeting we calculate `expire` based on the current timestamp and the
+   * `expireDelta` which defines for how long it should be available.
+   * @param expireDelta {number} - Offset in milliseconds from the current date
+   */
+  getExpirationDate(expireDelta) {
+    return Date.now() + expireDelta;
+  }
+
+  _clearBadgeTimeout() {
+    if (this.state.showBadgeTimeoutId) {
+      clearTimeout(this.state.showBadgeTimeoutId);
+    }
+  }
+
+  removeAllNotifications(event) {
+    if (event) {
+      // ignore right clicks
+      if (
+        (event.type === "mousedown" || event.type === "click") &&
+        event.button !== 0
+      ) {
+        return;
+      }
+      // ignore keyboard access that is not one of the usual accessor keys
+      if (
+        event.type === "keypress" &&
+        event.key !== " " &&
+        event.key !== "Enter"
+      ) {
+        return;
+      }
+
+      event.target.removeEventListener(
+        "mousedown",
+        this.removeAllNotifications
+      );
+      event.target.removeEventListener("click", this.removeAllNotifications);
+      // If we have an event it means the user interacted with the badge
+      // we should send telemetry
+      if (this.state.notification) {
+        this.sendUserEventTelemetry("CLICK", this.state.notification);
+      }
+    }
+    // Will call uninit on every window
+    EveryWindow.unregisterCallback(this.id);
+    if (this.state.notification) {
+      this._blockMessageById(this.state.notification.id);
+    }
+    this._clearBadgeTimeout();
+    this.state = {};
+  }
+
+  removeToolbarNotification(toolbarButton) {
+    // Remove it from the element that displays the badge
+    toolbarButton
+      .querySelector(".toolbarbutton-badge")
+      .classList.remove("feature-callout");
+    toolbarButton.removeAttribute("badged");
+  }
+
+  addToolbarNotification(win, message) {
+    const document = win.browser.ownerDocument;
+    if (message.content.action) {
+      this.executeAction({ ...message.content.action, message_id: message.id });
+    }
+    let toolbarbutton = document.getElementById(message.content.target);
+    if (toolbarbutton) {
+      toolbarbutton.setAttribute("badged", true);
+      toolbarbutton
+        .querySelector(".toolbarbutton-badge")
+        .classList.add("feature-callout");
+
+      // `mousedown` event required because of the `onmousedown` defined on
+      // the button that prevents `click` events from firing
+      toolbarbutton.addEventListener("mousedown", this.removeAllNotifications);
+      // `click` event required for keyboard accessibility
+      toolbarbutton.addEventListener("click", this.removeAllNotifications);
+      this.state = { notification: { id: message.id } };
+
+      return toolbarbutton;
+    }
+
+    return null;
+  }
+
+  registerBadgeToAllWindows(message) {
+    if (message.template === "update_action") {
+      this.executeAction({ ...message.content.action, message_id: message.id });
+      // No badge to set only an action to execute
+      return;
+    }
+
+    // Impression should be added when the badge becomes visible
+    this._addImpression(message);
+    // Send a telemetry ping when adding the notification badge
+    this.sendUserEventTelemetry("IMPRESSION", message);
+
+    EveryWindow.registerCallback(
+      this.id,
+      win => {
+        if (notificationsByWindow.has(win)) {
+          // nothing to do
+          return;
+        }
+        const el = this.addToolbarNotification(win, message);
+        notificationsByWindow.set(win, el);
+      },
+      win => {
+        const el = notificationsByWindow.get(win);
+        if (el) {
+          this.removeToolbarNotification(el);
+        }
+        notificationsByWindow.delete(win);
+      }
+    );
+  }
+
+  registerBadgeNotificationListener(message, options = {}) {
+    // We need to clear any existing notifications and only show
+    // the one set by devtools
+    if (options.force) {
+      this.removeAllNotifications();
+    }
+
+    if (message.content.delay) {
+      this.state.showBadgeTimeoutId = setTimeout(() => {
+        this.registerBadgeToAllWindows(message);
+      }, message.content.delay);
+    } else {
+      this.registerBadgeToAllWindows(message);
+    }
+  }
+
+  async messageRequest({ triggerId, template }) {
+    const message = await this._handleMessageRequest({
+      triggerId,
+      template,
+    });
+    if (message) {
+      this.registerBadgeNotificationListener(message);
+    }
+  }
+
+  _sendTelemetry(ping) {
+    this._dispatch({
+      type: "TOOLBAR_BADGE_TELEMETRY",
+      data: { action: "cfr_user_event", source: "CFR", ...ping },
+    });
+  }
+
+  sendUserEventTelemetry(event, message) {
+    const win = Services.wm.getMostRecentWindow("navigator:browser");
+    // Only send pings for non private browsing windows
+    if (
+      win &&
+      !PrivateBrowsingUtils.isBrowserPrivate(
+        win.ownerGlobal.gBrowser.selectedBrowser
+      )
+    ) {
+      this._sendTelemetry({
+        message_id: message.id,
+        bucket_id: message.id,
+        event,
+      });
+    }
+  }
+
+  uninit() {
+    this._clearBadgeTimeout();
+    clearInterval(this.state._intervalId);
+    this.state = null;
+    notificationsByWindow = new WeakMap();
+    Services.prefs.removeObserver(this.prefs.WHATSNEW_TOOLBAR_PANEL, this);
+  }
+}
+
+this._ToolbarBadgeHub = _ToolbarBadgeHub;
+
+/**
+ * ToolbarBadgeHub - singleton instance of _ToolbarBadgeHub that can initiate
+ * message requests and render messages.
+ */
+this.ToolbarBadgeHub = new _ToolbarBadgeHub();
+
+const EXPORTED_SYMBOLS = ["ToolbarBadgeHub", "_ToolbarBadgeHub"];
--- 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,17 @@ describe("ASRouter", () => {
   let providerImpressions;
   let previousSessionEnd;
   let fetchStub;
   let clock;
   let getStringPrefStub;
   let dispatchStub;
   let fakeAttributionCode;
   let FakeBookmarkPanelHub;
+  let FakeToolbarBadgeHub;
 
   function createFakeStorage() {
     const getStub = sandbox.stub();
     getStub.returns(Promise.resolve());
     getStub
       .withArgs("messageBlockList")
       .returns(Promise.resolve(messageBlockList));
     getStub
@@ -134,23 +135,29 @@ describe("ASRouter", () => {
       _clearCache: () => sinon.stub(),
       getAttrDataAsync: () => Promise.resolve({ content: "addonID" }),
     };
     FakeBookmarkPanelHub = {
       init: sandbox.stub(),
       uninit: sandbox.stub(),
       _forceShowMessage: 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,
     });
     await createRouterAndInit();
   });
   afterEach(() => {
     ASRouterPreferences.uninit();
     sandbox.restore();
     globals.restore();
   });
@@ -174,16 +181,41 @@ 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(FakeBookmarkPanelHub.init);
+
+      assert.calledWithExactly(
+        FakeToolbarBadgeHub.init,
+        Router.waitForInitialized,
+        {
+          handleMessageRequest: Router.handleMessageRequest,
+          addImpression: Router.addImpression,
+          blockMessageById: Router.blockMessageById,
+          dispatch: Router.dispatch,
+          unblockMessageById: Router.unblockMessageById,
+        }
+      );
+
+      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 +439,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 +787,88 @@ 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, message);
+      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, message1);
+    });
+    it("should have messageImpressions in the message context", () => {
+      assert.propertyVal(
+        Router._getMessagesContext(),
+        "messageImpressions",
+        Router.state.messageImpressions
+      );
     });
   });
 
   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),
@@ -749,16 +910,40 @@ describe("ASRouter", () => {
       await Router.setState(() => ({ messageBlockList: ALL_MESSAGE_IDS }));
       const targetStub = { sendAsyncMessage: sandbox.stub() };
 
       await Router.sendNextMessage(targetStub);
 
       assert.calledOnce(targetStub.sendAsyncMessage);
       assert.equal(Router.state.lastMessageId, null);
     });
+    it("should forward trigger param info", async () => {
+      const trigger = { triggerId: "foo", triggerParam: "bar" };
+      const message1 = {
+        id: "1",
+        campaign: "foocampaign",
+        trigger: { id: "foo" },
+      };
+      const message2 = {
+        id: "2",
+        campaign: "foocampaign",
+        trigger: { id: "bar" },
+      };
+      await Router.setState({ messages: [message2, message1] });
+      // Just return the first message provided as arg
+      const stub = sandbox.stub(Router, "_findMessage");
+
+      Router.handleMessageRequest(trigger);
+
+      assert.calledOnce(stub);
+      assert.calledWithExactly(stub, sinon.match.array, {
+        id: trigger.triggerId,
+        param: trigger.triggerParam,
+      });
+    });
   });
 
   describe("#uninit", () => {
     it("should remove the message listener on the RemotePageManager", () => {
       const [, listenerAdded] = channel.addMessageListener.firstCall.args;
       assert.isFunction(listenerAdded);
 
       Router.uninit();
--- a/browser/components/newtab/test/unit/asrouter/ASRouterFeed.test.js
+++ b/browser/components/newtab/test/unit/asrouter/ASRouterFeed.test.js
@@ -7,26 +7,32 @@ import { GlobalOverrider } from "test/un
 describe("ASRouterFeed", () => {
   let Router;
   let feed;
   let channel;
   let sandbox;
   let storage;
   let globals;
   let FakeBookmarkPanelHub;
+  let FakeToolbarBadgeHub;
   beforeEach(() => {
     sandbox = sinon.createSandbox();
     globals = new GlobalOverrider();
     FakeBookmarkPanelHub = {
       init: sandbox.stub(),
       uninit: sandbox.stub(),
     };
+    FakeToolbarBadgeHub = {
+      init: sandbox.stub(),
+    };
     globals.set("BookmarkPanelHub", FakeBookmarkPanelHub);
+    globals.set("ToolbarBadgeHub", FakeToolbarBadgeHub);
 
     Router = new _ASRouter({ providers: [FAKE_LOCAL_PROVIDER] });
+
     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
@@ -97,16 +97,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,28 @@
 import { PanelTestProvider } from "lib/PanelTestProvider.jsm";
 import schema from "content-src/asrouter/schemas/panel/cfr-fxa-bookmark.schema.json";
+import update_schema from "content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json";
 const messages = PanelTestProvider.getMessages();
 
 describe("PanelTestProvider", () => {
   it("should have a message", () => {
-    assert.lengthOf(messages, 2);
+    // Careful: when changing this number make sure that new messages also go
+    // through schema verifications.
+    assert.lengthOf(messages, 5);
   });
   it("should be a valid message", () => {
-    assert.jsonSchema(messages[0].content, schema);
+    const fxaMessages = messages.filter(
+      ({ template }) => template === "fxa_bookmark_panel"
+    );
+    for (let message of fxaMessages) {
+      assert.jsonSchema(message.content, schema);
+    }
+  });
+  it("should be a valid message", () => {
+    const updateMessages = messages.filter(
+      ({ template }) => template === "update_action"
+    );
+    for (let message of updateMessages) {
+      assert.jsonSchema(message.content, update_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,12 @@
 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";
 
 const DEFAULT_CONTENT = {
   title: "A title",
   text: "A description",
   icon: "icon",
   primary_button: {
     label: "some_button_label",
     action: {
@@ -56,16 +57,23 @@ 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 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/BookmarkPanelHub.test.js
+++ b/browser/components/newtab/test/unit/lib/BookmarkPanelHub.test.js
@@ -133,17 +133,19 @@ describe("BookmarkPanelHub", () => {
       assert.calledOnce(instance.showMessage);
     });
     it("should call handleMessageRequest", async () => {
       fakeHandleMessageRequest.resolves(fakeMessage);
 
       await instance.messageRequest(fakeTarget, {});
 
       assert.calledOnce(fakeHandleMessageRequest);
-      assert.calledWithExactly(fakeHandleMessageRequest, instance._trigger);
+      assert.calledWithExactly(fakeHandleMessageRequest, {
+        triggerId: instance._trigger.id,
+      });
     });
     it("should call onResponse", async () => {
       fakeHandleMessageRequest.resolves(fakeMessage);
 
       await instance.messageRequest(fakeTarget, {});
 
       assert.calledOnce(instance.onResponse);
       assert.calledWithExactly(
new file mode 100644
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/ToolbarBadgeHub.test.js
@@ -0,0 +1,626 @@
+import { _ToolbarBadgeHub } from "lib/ToolbarBadgeHub.jsm";
+import { GlobalOverrider } from "test/unit/utils";
+import { PanelTestProvider } from "lib/PanelTestProvider.jsm";
+
+describe("ToolbarBadgeHub", () => {
+  let sandbox;
+  let instance;
+  let fakeAddImpression;
+  let fakeDispatch;
+  let isBrowserPrivateStub;
+  let fxaMessage;
+  let whatsnewMessage;
+  let fakeElement;
+  let globals;
+  let everyWindowStub;
+  let clearTimeoutStub;
+  let setTimeoutStub;
+  let setIntervalStub;
+  let addObserverStub;
+  let removeObserverStub;
+  let getStringPrefStub;
+  let clearUserPrefStub;
+  let setStringPrefStub;
+  beforeEach(async () => {
+    globals = new GlobalOverrider();
+    sandbox = sinon.createSandbox();
+    instance = new _ToolbarBadgeHub();
+    fakeAddImpression = sandbox.stub();
+    fakeDispatch = sandbox.stub();
+    isBrowserPrivateStub = 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 = {
+      classList: {
+        add: sandbox.stub(),
+        remove: sandbox.stub(),
+      },
+      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();
+    setIntervalStub = sandbox.stub();
+    const fakeWindow = {
+      ownerGlobal: {
+        gBrowser: {
+          selectedBrowser: "browser",
+        },
+      },
+    };
+    addObserverStub = sandbox.stub();
+    removeObserverStub = sandbox.stub();
+    getStringPrefStub = sandbox.stub();
+    clearUserPrefStub = sandbox.stub();
+    setStringPrefStub = sandbox.stub();
+    globals.set({
+      EveryWindow: everyWindowStub,
+      PrivateBrowsingUtils: { isBrowserPrivate: isBrowserPrivateStub },
+      setTimeout: setTimeoutStub,
+      clearTimeout: clearTimeoutStub,
+      setInterval: setIntervalStub,
+      Services: {
+        wm: {
+          getMostRecentWindow: () => fakeWindow,
+        },
+        prefs: {
+          addObserver: addObserverStub,
+          removeObserver: removeObserverStub,
+          getStringPref: getStringPrefStub,
+          clearUserPref: clearUserPrefStub,
+          setStringPref: setStringPrefStub,
+        },
+      },
+    });
+  });
+  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, {
+        template: "toolbar_badge",
+        triggerId: "toolbarBadgeUpdate",
+      });
+    });
+    it("should add a pref observer", async () => {
+      await instance.init(sandbox.stub().resolves(), {});
+
+      assert.calledOnce(addObserverStub);
+      assert.calledWithExactly(
+        addObserverStub,
+        instance.prefs.WHATSNEW_TOOLBAR_PANEL,
+        instance
+      );
+    });
+    it("should setInterval for `checkHomepageOverridePref`", async () => {
+      await instance.init(sandbox.stub().resolves(), {});
+      sandbox.stub(instance, "checkHomepageOverridePref");
+
+      assert.calledOnce(setIntervalStub);
+      assert.calledWithExactly(
+        setIntervalStub,
+        sinon.match.func,
+        5 * 60 * 1000
+      );
+
+      assert.notCalled(instance.checkHomepageOverridePref);
+      const [cb] = setIntervalStub.firstCall.args;
+
+      cb();
+
+      assert.calledOnce(instance.checkHomepageOverridePref);
+    });
+  });
+  describe("#uninit", () => {
+    beforeEach(async () => {
+      await instance.init(sandbox.stub().resolves(), {});
+    });
+    it("should clear any setTimeout cbs", async () => {
+      await instance.init(sandbox.stub().resolves(), {});
+
+      instance.state.showBadgeTimeoutId = 2;
+
+      instance.uninit();
+
+      assert.calledOnce(clearTimeoutStub);
+      assert.calledWithExactly(clearTimeoutStub, 2);
+    });
+    it("should remove the pref observer", () => {
+      instance.uninit();
+
+      assert.calledOnce(removeObserverStub);
+      assert.calledWithExactly(
+        removeObserverStub,
+        instance.prefs.WHATSNEW_TOOLBAR_PANEL,
+        instance
+      );
+    });
+  });
+  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({
+        triggerId: "trigger",
+        template: "template",
+      });
+
+      assert.calledOnce(handleMessageRequestStub);
+      assert.calledWithExactly(handleMessageRequestStub, {
+        triggerId: "trigger",
+        template: "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.calledOnce(fakeElement.setAttribute);
+      assert.calledWithExactly(fakeElement.setAttribute, "badged", true);
+      assert.calledWithExactly(fakeElement.classList.add, "feature-callout");
+    });
+    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,
+        message_id: whatsnewMessage.id,
+      });
+    });
+  });
+  describe("registerBadgeNotificationListener", () => {
+    beforeEach(async () => {
+      await instance.init(sandbox.stub().resolves(), {
+        addImpression: fakeAddImpression,
+        dispatch: fakeDispatch,
+      });
+      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 send an impression", async () => {
+      sandbox.stub(instance, "sendUserEventTelemetry");
+
+      instance.registerBadgeNotificationListener(fxaMessage);
+
+      assert.calledOnce(instance.sendUserEventTelemetry);
+      assert.calledWithExactly(
+        instance.sendUserEventTelemetry,
+        "IMPRESSION",
+        fxaMessage
+      );
+    });
+    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);
+    });
+    it("should only call executeAction for 'update_action' messages", () => {
+      const stub = sandbox.stub(instance, "executeAction");
+      const updateActionMsg = { ...fxaMessage, template: "update_action" };
+
+      instance.registerBadgeNotificationListener(updateActionMsg);
+
+      assert.notCalled(everyWindowStub.registerCallback);
+      assert.calledOnce(stub);
+    });
+  });
+  describe("executeAction", () => {
+    let blockMessageByIdStub;
+    beforeEach(async () => {
+      blockMessageByIdStub = sandbox.stub();
+      await instance.init(sandbox.stub().resolves(), {
+        blockMessageById: blockMessageByIdStub,
+      });
+    });
+    it("should set HOMEPAGE_OVERRIDE_PREF on `moments-wnp` action", () => {
+      instance.executeAction({
+        id: "moments-wnp",
+        data: {
+          url: "foo.com",
+          expire: 1,
+        },
+        message_id: "bar",
+      });
+
+      assert.calledOnce(setStringPrefStub);
+      assert.calledWithExactly(
+        setStringPrefStub,
+        instance.prefs.HOMEPAGE_OVERRIDE_PREF,
+        JSON.stringify({ message_id: "bar", url: "foo.com", expire: 1 })
+      );
+    });
+    it("should block after taking the action", () => {
+      instance.executeAction({
+        id: "moments-wnp",
+        data: {
+          url: "foo.com",
+          expire: 1,
+        },
+        message_id: "bar",
+      });
+
+      assert.calledOnce(blockMessageByIdStub);
+      assert.calledWithExactly(blockMessageByIdStub, "bar");
+    });
+    it("should compute expire based on expireDelta", () => {
+      sandbox.spy(instance, "getExpirationDate");
+
+      instance.executeAction({
+        id: "moments-wnp",
+        data: {
+          url: "foo.com",
+          expireDelta: 10,
+        },
+        message_id: "bar",
+      });
+
+      assert.calledOnce(instance.getExpirationDate);
+      assert.calledWithExactly(instance.getExpirationDate, 10);
+    });
+  });
+  describe("removeToolbarNotification", () => {
+    it("should remove the notification", () => {
+      instance.removeToolbarNotification(fakeElement);
+
+      assert.calledOnce(fakeElement.removeAttribute);
+      assert.calledWithExactly(fakeElement.removeAttribute, "badged");
+      assert.calledOnce(fakeElement.classList.remove);
+      assert.calledWithExactly(fakeElement.classList.remove, "feature-callout");
+    });
+  });
+  describe("removeAllNotifications", () => {
+    let blockMessageByIdStub;
+    let fakeEvent;
+    beforeEach(async () => {
+      await instance.init(sandbox.stub().resolves(), {
+        dispatch: fakeDispatch,
+      });
+      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 send telemetry", () => {
+      fakeEvent.type = "click";
+      fakeEvent.button = 0;
+      sandbox.stub(instance, "sendUserEventTelemetry");
+
+      instance.removeAllNotifications(fakeEvent);
+
+      assert.calledOnce(instance.sendUserEventTelemetry);
+      assert.calledWithExactly(instance.sendUserEventTelemetry, "CLICK", {
+        id: "FXA_ACCOUNTS_BADGE",
+      });
+    });
+    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(async () => {
+      await 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
+      );
+    });
+  });
+  describe("#sendUserEventTelemetry", () => {
+    beforeEach(async () => {
+      await instance.init(sandbox.stub().resolves(), {
+        dispatch: fakeDispatch,
+      });
+    });
+    it("should check for private window and not send", () => {
+      isBrowserPrivateStub.returns(true);
+
+      instance.sendUserEventTelemetry("CLICK", { id: fxaMessage });
+
+      assert.notCalled(instance._dispatch);
+    });
+    it("should check for private window and send", () => {
+      isBrowserPrivateStub.returns(false);
+
+      instance.sendUserEventTelemetry("CLICK", { id: fxaMessage });
+
+      assert.calledOnce(fakeDispatch);
+      const [ping] = instance._dispatch.firstCall.args;
+      assert.propertyVal(ping, "type", "TOOLBAR_BADGE_TELEMETRY");
+      assert.propertyVal(ping.data, "event", "CLICK");
+    });
+  });
+  describe("#observe", () => {
+    it("should make a message request when the whats new pref is changed", () => {
+      sandbox.stub(instance, "messageRequest");
+
+      instance.observe("", "", instance.prefs.WHATSNEW_TOOLBAR_PANEL);
+
+      assert.calledOnce(instance.messageRequest);
+      assert.calledWithExactly(instance.messageRequest, {
+        template: "toolbar_badge",
+        triggerId: "toolbarBadgeUpdate",
+      });
+    });
+    it("should not react to other pref changes", () => {
+      sandbox.stub(instance, "messageRequest");
+
+      instance.observe("", "", "foo");
+
+      assert.notCalled(instance.messageRequest);
+    });
+  });
+  describe("#checkHomepageOverridePref", () => {
+    let messageRequestStub;
+    let unblockMessageByIdStub;
+    beforeEach(async () => {
+      unblockMessageByIdStub = sandbox.stub();
+      await instance.init(sandbox.stub().resolves(), {
+        unblockMessageById: unblockMessageByIdStub,
+      });
+      messageRequestStub = sandbox.stub(instance, "messageRequest");
+    });
+    it("should reset HOMEPAGE_OVERRIDE_PREF if set", () => {
+      getStringPrefStub.returns(true);
+
+      instance.checkHomepageOverridePref();
+
+      assert.calledOnce(getStringPrefStub);
+      assert.calledWithExactly(
+        getStringPrefStub,
+        instance.prefs.HOMEPAGE_OVERRIDE_PREF,
+        ""
+      );
+      assert.calledOnce(clearUserPrefStub);
+      assert.calledWithExactly(
+        clearUserPrefStub,
+        instance.prefs.HOMEPAGE_OVERRIDE_PREF
+      );
+    });
+    it("should unblock the message set in the pref", () => {
+      getStringPrefStub.returns(JSON.stringify({ message_id: "foo" }));
+
+      instance.checkHomepageOverridePref();
+
+      assert.calledOnce(unblockMessageByIdStub);
+      assert.calledWithExactly(unblockMessageByIdStub, "foo");
+    });
+    it("should catch parse errors", () => {
+      getStringPrefStub.returns({});
+
+      instance.checkHomepageOverridePref();
+
+      assert.notCalled(unblockMessageByIdStub);
+      assert.calledOnce(messageRequestStub);
+      assert.calledWithExactly(messageRequestStub, {
+        template: "update_action",
+        triggerId: "momentsUpdate",
+      });
+    });
+  });
+});
--- 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();
       }