Bug 1471391 - Create initial CFR doorhanger r=k88hudson
authorahillier <ahillier@mozilla.com>
Mon, 27 Aug 2018 15:53:18 +0000
changeset 491244 9837816f6c79bfe4940d825b0870ff32d3848e79
parent 491243 bd0dc4f87f8fa2514e5fa05225cc76f1b10d0359
child 491245 351b9c51222159656a05739ed463975451d04604
push id1815
push userffxbld-merge
push dateMon, 15 Oct 2018 10:40:45 +0000
treeherdermozilla-release@18d4c09e9378 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersk88hudson
bugs1471391
milestone63.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 1471391 - Create initial CFR doorhanger r=k88hudson Differential Revision: https://phabricator.services.mozilla.com/D4266
browser/base/content/browser.js
browser/components/newtab/data/content/assets/glyph-webextension-16.svg
browser/components/newtab/lib/CFRPageActions.jsm
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -14,16 +14,17 @@ ChromeUtils.import("resource://gre/modul
 const {WebExtensionPolicy} = Cu.getGlobalForObject(Services);
 
 // lazy module getters
 
 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",
   ContextualIdentityService: "resource://gre/modules/ContextualIdentityService.jsm",
   CustomizableUI: "resource:///modules/CustomizableUI.jsm",
   Deprecated: "resource://gre/modules/Deprecated.jsm",
   DownloadsCommon: "resource:///modules/DownloadsCommon.jsm",
   E10SUtils: "resource://gre/modules/E10SUtils.jsm",
@@ -4812,16 +4813,18 @@ var XULBrowserWindow = {
       // so don't use CustomizeMode.jsm to check for URI or customizing.
       if (location == "about:blank" &&
           gBrowser.selectedTab.hasAttribute("customizemode")) {
         gCustomizeMode.enter();
       } else if (CustomizationHandler.isEnteringCustomizeMode ||
                  CustomizationHandler.isCustomizing()) {
         gCustomizeMode.exit();
       }
+
+      CFRPageActions.updatePageActions(gBrowser.selectedBrowser);
     }
     UpdateBackForwardCommands(gBrowser.webNavigation);
     ReaderParent.updateReaderButton(gBrowser.selectedBrowser);
 
     if (!gMultiProcessBrowser) // Bug 1108553 - Cannot rotate images with e10s
       gGestureSupport.restoreRotationState();
 
     // See bug 358202, when tabs are switched during a drag operation,
--- a/browser/components/newtab/data/content/assets/glyph-webextension-16.svg
+++ b/browser/components/newtab/data/content/assets/glyph-webextension-16.svg
@@ -1,1 +1,1 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="context-fill" d="M14.5 8c-.971 0-1 1-1.75 1a.765.765 0 0 1-.75-.75V5a1 1 0 0 0-1-1H7.75A.765.765 0 0 1 7 3.25c0-.75 1-.779 1-1.75C8 .635 7.1 0 6 0S4 .635 4 1.5c0 .971 1 1 1 1.75a.765.765 0 0 1-.75.75H1a1 1 0 0 0-1 1v2.25A.765.765 0 0 0 .75 8c.75 0 .779-1 1.75-1C3.365 7 4 7.9 4 9s-.635 2-1.5 2c-.971 0-1-1-1.75-1a.765.765 0 0 0-.75.75V15a1 1 0 0 0 1 1h3.25a.765.765 0 0 0 .75-.75c0-.75-1-.779-1-1.75 0-.865.9-1.5 2-1.5s2 .635 2 1.5c0 .971-1 1-1 1.75a.765.765 0 0 0 .75.75H11a1 1 0 0 0 1-1v-3.25a.765.765 0 0 1 .75-.75c.75 0 .779 1 1.75 1 .865 0 1.5-.9 1.5-2s-.635-2-1.5-2z"/></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="context-fill" fill-opacity="context-fill-opacity" d="M14.5 8c-.971 0-1 1-1.75 1a.765.765 0 0 1-.75-.75V5a1 1 0 0 0-1-1H7.75A.765.765 0 0 1 7 3.25c0-.75 1-.779 1-1.75C8 .635 7.1 0 6 0S4 .635 4 1.5c0 .971 1 1 1 1.75a.765.765 0 0 1-.75.75H1a1 1 0 0 0-1 1v2.25A.765.765 0 0 0 .75 8c.75 0 .779-1 1.75-1C3.365 7 4 7.9 4 9s-.635 2-1.5 2c-.971 0-1-1-1.75-1a.765.765 0 0 0-.75.75V15a1 1 0 0 0 1 1h3.25a.765.765 0 0 0 .75-.75c0-.75-1-.779-1-1.75 0-.865.9-1.5 2-1.5s2 .635 2 1.5c0 .971-1 1-1 1.75a.765.765 0 0 0 .75.75H11a1 1 0 0 0 1-1v-3.25a.765.765 0 0 1 .75-.75c.75 0 .779 1 1.75 1 .865 0 1.5-.9 1.5-2s-.635-2-1.5-2z"/></svg>
\ No newline at end of file
new file mode 100644
--- /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 <esc>
+  _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"];