diff --git a/browser/base/content/browser.js b/browser/base/content/browser.js
index 3d11006b10bc..5db666184d07 100644
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -19,6 +19,7 @@ XPCOMUtils.defineLazyModuleGetters(this, {
BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.jsm",
BrowserUtils: "resource://gre/modules/BrowserUtils.jsm",
BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm",
+ CFRPageActions: "resource://activity-stream/lib/CFRPageActions.jsm",
CharsetMenu: "resource://gre/modules/CharsetMenu.jsm",
Color: "resource://gre/modules/Color.jsm",
ContentSearch: "resource:///modules/ContentSearch.jsm",
@@ -4817,6 +4818,8 @@ var XULBrowserWindow = {
CustomizationHandler.isCustomizing()) {
gCustomizeMode.exit();
}
+
+ CFRPageActions.updatePageActions(gBrowser.selectedBrowser);
}
UpdateBackForwardCommands(gBrowser.webNavigation);
ReaderParent.updateReaderButton(gBrowser.selectedBrowser);
diff --git a/browser/components/newtab/data/content/assets/glyph-webextension-16.svg b/browser/components/newtab/data/content/assets/glyph-webextension-16.svg
index c057f148e7e7..1b65dd449041 100644
--- a/browser/components/newtab/data/content/assets/glyph-webextension-16.svg
+++ b/browser/components/newtab/data/content/assets/glyph-webextension-16.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/browser/components/newtab/lib/CFRPageActions.jsm b/browser/components/newtab/lib/CFRPageActions.jsm
new file mode 100644
index 000000000000..5be4c035b82e
--- /dev/null
+++ b/browser/components/newtab/lib/CFRPageActions.jsm
@@ -0,0 +1,247 @@
+/* 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 POPUP_NOTIFICATION_ID = "contextual-feature-recommendation";
+
+const DELAY_BEFORE_EXPAND_MS = 1000;
+const DURATION_OF_EXPAND_MS = 5000;
+
+/**
+ * A WeakMap from browsers to {host, recommendation} pairs. Recommendations are
+ * defined in the ExtensionDoorhanger.schema.json.
+ *
+ * A recommendation is specific to a browser and host and is active until the
+ * given browser is closed or the user navigates (within that browser) away from
+ * the host.
+ */
+const RecommendationMap = new WeakMap();
+
+/**
+ * A WeakMap from windows to their CFR PageAction.
+ */
+const PageActionMap = new WeakMap();
+
+/**
+ * We need one PageAction for each window
+ */
+class PageAction {
+ constructor(win, dispatchToASRouter) {
+ this.window = win;
+ this.urlbar = win.document.getElementById("urlbar");
+ this.container = win.document.getElementById("contextual-feature-recommendation");
+ this.button = win.document.getElementById("cfr-button");
+ this.label = win.document.getElementById("cfr-label");
+
+ this._dispatchToASRouter = dispatchToASRouter;
+ this._popupStateChange = this._popupStateChange.bind(this);
+ this._collapse = this._collapse.bind(this);
+ this._handleClick = this._handleClick.bind(this);
+
+ // Saved timeout IDs for scheduled state changes, so they can be cancelled
+ this.stateTransitionTimeoutIDs = [];
+
+ this.container.onclick = this._handleClick;
+ }
+
+ async show(notificationText, shouldExpand = false) {
+ this.container.hidden = false;
+
+ this.label.value = notificationText;
+
+ // Wait for layout to flush to avoid a synchronous reflow then calculate the
+ // label width. We can safely get the width even though the recommendation is
+ // collapsed; the label itself remains full width (with its overflow hidden)
+ await this.window.promiseDocumentFlushed;
+ const [{width}] = this.label.getClientRects();
+ this.urlbar.style.setProperty("--cfr-label-width", `${width}px`);
+
+ if (shouldExpand) {
+ this._clearScheduledStateChanges();
+
+ // After one second, expand
+ this._expand(DELAY_BEFORE_EXPAND_MS);
+
+ // Five seconds later, collapse again
+ this._collapse(DELAY_BEFORE_EXPAND_MS + DURATION_OF_EXPAND_MS);
+ }
+ }
+
+ hide() {
+ this.container.hidden = true;
+ this._clearScheduledStateChanges();
+ this.urlbar.removeAttribute("cfr-recommendation-state");
+ }
+
+ _expand(delay = 0) {
+ if (!delay) {
+ // Non-delayed state change overrides any scheduled state changes
+ this._clearScheduledStateChanges();
+ this.urlbar.setAttribute("cfr-recommendation-state", "expanded");
+ } else {
+ this.stateTransitionTimeoutIDs.push(this.window.setTimeout(() => {
+ this.urlbar.setAttribute("cfr-recommendation-state", "expanded");
+ }, delay));
+ }
+ }
+
+ _collapse(delay = 0) {
+ if (!delay) {
+ // Non-delayed state change overrides any scheduled state changes
+ this._clearScheduledStateChanges();
+ if (this.urlbar.getAttribute("cfr-recommendation-state") === "expanded") {
+ this.urlbar.setAttribute("cfr-recommendation-state", "collapsed");
+ }
+ } else {
+ this.stateTransitionTimeoutIDs.push(this.window.setTimeout(() => {
+ if (this.urlbar.getAttribute("cfr-recommendation-state") === "expanded") {
+ this.urlbar.setAttribute("cfr-recommendation-state", "collapsed");
+ }
+ }, delay));
+ }
+ }
+
+ _clearScheduledStateChanges() {
+ while (this.stateTransitionTimeoutIDs.length > 0) {
+ // clearTimeout is safe even with invalid/expired IDs
+ this.window.clearTimeout(this.stateTransitionTimeoutIDs.pop());
+ }
+ }
+
+ // This is called when the popup closes as a result of interaction _outside_
+ // the popup, e.g. by hitting
+ _popupStateChange(state) {
+ if (["dismissed", "removed"].includes(state)) {
+ this._collapse();
+ }
+ }
+
+ /**
+ * Respond to a user click on the recommendation by showing a doorhanger/
+ * popup notification
+ */
+ _handleClick(event) {
+ const browser = this.window.gBrowser.selectedBrowser;
+ if (!RecommendationMap.has(browser)) {
+ // There's no recommendation for this browser, so the user shouldn't have
+ // been able to click
+ this.hide();
+ return;
+ }
+ const {content} = RecommendationMap.get(browser);
+
+ // The recommendation should remain either collapsed or expanded while the
+ // doorhanger is showing
+ this._clearScheduledStateChanges();
+
+ // A hacky way of setting the popup anchor outside the usual url bar icon box
+ // See https://searchfox.org/mozilla-central/rev/847b64cc28b74b44c379f9bff4f415b97da1c6d7/toolkit/modules/PopupNotifications.jsm#42
+ browser.cfrpopupnotificationanchor = this.container;
+
+ const {primary, secondary} = content.buttons;
+
+ const mainAction = {
+ label: primary.label,
+ accessKey: primary.accessKey,
+ callback: () => this._dispatchToASRouter(primary.action)
+ };
+
+ const secondaryActions = [{
+ label: secondary.label,
+ accessKey: secondary.accessKey,
+ callback: this._collapse
+ }];
+
+ const options = {
+ popupIconURL: content.addon.icon,
+ hideClose: true,
+ eventCallback: this._popupStateChange
+ };
+
+ this.window.PopupNotifications.show(
+ browser,
+ POPUP_NOTIFICATION_ID,
+ content.text,
+ "cfr",
+ mainAction,
+ secondaryActions,
+ options
+ );
+ }
+}
+
+function isHostMatch(browser, host) {
+ return (browser.documentURI.scheme.startsWith("http") &&
+ browser.documentURI.host === host);
+}
+
+const CFRPageActions = {
+ // For testing purposes
+ RecommendationMap,
+ PageActionMap,
+
+ /**
+ * To be called from browser.js on a location change, passing in the browser
+ * that's been updated
+ */
+ updatePageActions(browser) {
+ const win = browser.ownerGlobal;
+ const pageAction = PageActionMap.get(win);
+ if (!pageAction || browser !== win.gBrowser.selectedBrowser) {
+ return;
+ }
+ if (RecommendationMap.has(browser)) {
+ const {host, content} = RecommendationMap.get(browser);
+ if (isHostMatch(browser, host)) {
+ // The browser has a recommendation specified with this host, so show
+ // the page action
+ pageAction.show(content.notification_text);
+ } else {
+ // The user has navigated away from the specified host in the given
+ // browser, so the recommendation is no longer valid and should be removed
+ RecommendationMap.delete(browser);
+ pageAction.hide();
+ }
+ } else {
+ // There's no recommendation specified for this browser, so hide the page action
+ pageAction.hide();
+ }
+ },
+
+ /**
+ * Add a recommendation specific to the given browser and host.
+ * @param browser The browser for the recommendation
+ * @param host The host for the recommendation
+ * @param recommendation The recommendation to show
+ * @param dispatchToASRouter A function to dispatch resulting actions to
+ * @param force Force the recommendation to appear if the host doesn't match
+ * @return Did adding the recommendation succeed?
+ */
+ async addRecommendation(browser, host, recommendation, dispatchToASRouter, force = false) {
+ const win = browser.ownerGlobal;
+ if (browser !== win.gBrowser.selectedBrowser || !(force || isHostMatch(browser, host))) {
+ return false;
+ }
+ const {id, content} = recommendation;
+ RecommendationMap.set(browser, {id, host, content});
+ if (!PageActionMap.has(win)) {
+ PageActionMap.set(win, new PageAction(win, dispatchToASRouter));
+ }
+ await PageActionMap.get(win).show(recommendation.content.notification_text, true);
+ return true;
+ },
+
+ /**
+ * Clear all recommendations and hide all PageActions
+ */
+ clearRecommendations() {
+ for (const [win, pageAction] of PageActionMap) {
+ pageAction.hide();
+ PageActionMap.delete(win);
+ }
+ RecommendationMap.clear();
+ }
+};
+
+const EXPORTED_SYMBOLS = ["CFRPageActions"];