Bug 1390158 - Notify user of extension controlling New Tab on first access r?aswan r?jaws draft
authorMark Striemer <mstriemer@mozilla.com>
Fri, 27 Oct 2017 12:16:21 -0500
changeset 695824 89f84422a3fbf8f5a948f9bfe8e292d0b641ee4d
parent 692149 58e3cc92a9e49a92ce07affb1c427df354bd913a
child 695865 3527e326e2ec7995cdefc9f7fd6ac1cab317de59
child 697230 e3826d7428511717af63de2595f96286d64c632a
push id88556
push userbmo:mstriemer@mozilla.com
push dateThu, 09 Nov 2017 22:13:46 +0000
reviewersaswan, jaws
bugs1390158
milestone58.0a1
Bug 1390158 - Notify user of extension controlling New Tab on first access r?aswan r?jaws MozReview-Commit-ID: 1g9d4UTuOgr
browser/components/customizableui/content/panelUI.inc.xul
browser/components/extensions/ext-url-overrides.js
browser/components/extensions/test/browser/browser_ext_url_overrides_newtab.js
browser/locales/en-US/chrome/browser/browser.dtd
browser/themes/shared/customizableui/panelUI.inc.css
--- a/browser/components/customizableui/content/panelUI.inc.xul
+++ b/browser/components/customizableui/content/panelUI.inc.xul
@@ -761,8 +761,33 @@
        onmouseover="clearTimeout(gCustomizeMode._downloadPanelAutoHideTimeout);"
        onmouseout="gCustomizeMode._downloadPanelAutoHideTimeout = setTimeout(() => event.target.hidePopup(), 2000);"
        onpopuphidden="clearTimeout(gCustomizeMode._downloadPanelAutoHideTimeout);"
        >
   <checkbox id="downloads-button-autohide-checkbox"
             label="&customizeMode.autoHideDownloadsButton.label;" checked="true"
             oncommand="gCustomizeMode.onDownloadsAutoHideChange(event)"/>
 </panel>
+
+<panel id="extension-notification-panel"
+       role="group"
+       type="arrow"
+       hidden="true"
+       flip="slide"
+       position="bottomcenter topright"
+       tabspecific="true">
+  <popupnotification id="extension-new-tab-notification"
+                     popupid="extension-new-tab"
+                     label="&newTabControlled.header.message;"
+                     buttonlabel="&newTabControlled.keepButton.label;"
+                     buttonaccesskey="&newTabControlled.keepButton.accesskey;"
+                     secondarybuttonlabel="&newTabControlled.restoreButton.label;"
+                     secondarybuttonaccesskey="&newTabControlled.restoreButton.accesskey;"
+                     closebuttonhidden="true"
+                     dropmarkerhidden="true"
+                     checkboxhidden="true">
+    <popupnotificationcontent id="extension-new-tab-notification-content" orient="vertical">
+      <description class="notification-panel-content">
+        &newTabControlled.message;
+      </description>
+    </popupnotificationcontent>
+  </popupnotification>
+</panel>
--- a/browser/components/extensions/ext-url-overrides.js
+++ b/browser/components/extensions/ext-url-overrides.js
@@ -1,31 +1,132 @@
 /* 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/. */
+/* import-globals-from ext-browser.js */
 
 "use strict";
 
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionSettingsStore",
                                   "resource://gre/modules/ExtensionSettingsStore.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
+                                  "resource://gre/modules/AddonManager.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI",
+                                  "resource:///modules/CustomizableUI.jsm");
 
 XPCOMUtils.defineLazyServiceGetter(this, "aboutNewTabService",
                                    "@mozilla.org/browser/aboutnewtab-service;1",
                                    "nsIAboutNewTabService");
 
 const STORE_TYPE = "url_overrides";
 const NEW_TAB_SETTING_NAME = "newTabURL";
+const NEW_TAB_SETTING_TYPE = "newTabNotification";
+
+let lastNewTabNotifiedId = null;
+let observerRegistered = false;
+
+function waitForFocus(target) {
+  return new Promise(resolve => {
+    if (target.focused) {
+      resolve();
+    }
+    target.addEventListener("focus", () => {
+      resolve();
+    }, {once: true});
+  });
+}
+
+function userWasNotified(extensionId) {
+  let setting = ExtensionSettingsStore.getSetting(NEW_TAB_SETTING_TYPE, extensionId);
+  return lastNewTabNotifiedId == extensionId
+    || (setting && setting.value == "confirmed");
+}
+
+async function handleNewTabNotificationCommand(action, extensionId) {
+  let extension = await AddonManager.getAddonByID(extensionId);
+  switch (action) {
+    case "keep":
+      await ExtensionSettingsStore.addSetting(
+        extension, NEW_TAB_SETTING_TYPE, extensionId, "confirmed", () => "none");
+      break;
+    case "restore":
+      ExtensionSettingsStore.removeSetting(NEW_TAB_SETTING_TYPE, extensionId);
+      extension.userDisabled = true;
+      break;
+  }
+}
+
+async function handleNewTabOpened() {
+  let item = ExtensionSettingsStore.getSetting(STORE_TYPE, NEW_TAB_SETTING_NAME);
+
+  if (!item || !item.id || userWasNotified(item.id)) {
+    return;
+  }
+
+  // Find the elements we need.
+  let win = windowTracker.getCurrentWindow({});
+  let doc = win.document;
+  let panel = doc.getElementById("extension-notification-panel");
+
+  // Setup the command handler.
+  let handleCommand = async (event) => {
+    await handleNewTabNotificationCommand(
+      event.originalTarget.getAttribute("anonid") == "button" ? "keep" : "restore",
+      item.id);
+    panel.hidePopup();
+  };
+  panel.addEventListener("command", handleCommand);
+  panel.addEventListener("popuphidden", () => {
+    panel.removeEventListener("command", handleCommand);
+  }, {once: true});
+
+  // Wait for the URL bar to get focused, otherwise the URL bar will steal our autofocus.
+  await waitForFocus(win.gURLBar);
+
+  // Look for a browserAction on the toolbar.
+  let action = CustomizableUI.getWidget(
+    `${global.makeWidgetId(item.id)}-browser-action`);
+  if (action) {
+    action = action.areaType == "toolbar" && action.forWindow(win).node;
+  }
+  let menuButton = doc.getElementById("PanelUI-menu-button");
+
+  // Anchor to a toolbar browserAction if found, otherwise use the menu button.
+  let anchor = doc.getAnonymousElementByAttribute(
+    action || menuButton, "class", "toolbarbutton-icon");
+  panel.hidden = false;
+  panel.openPopup(anchor);
+
+  // Record the id of the extension so we don't notify the user again until the
+  // controlling extension changes or Firefox restarts.
+  lastNewTabNotifiedId = item.id;
+}
+
+let newTabOpenedListener = {
+  observe(subject, topic, data) {
+    // Do this work in an idle callback to avoid interfering with new tab performance tracking.
+    windowTracker
+      .getCurrentWindow({})
+      .requestIdleCallback(handleNewTabOpened);
+  },
+};
 
 this.urlOverrides = class extends ExtensionAPI {
   processNewTabSetting(action) {
     let {extension} = this;
     let item = ExtensionSettingsStore[action](extension, STORE_TYPE, NEW_TAB_SETTING_NAME);
     if (item) {
+      if (!item.value && observerRegistered) {
+        Services.obs.removeObserver(newTabOpenedListener, "browser-open-newtab-start");
+        observerRegistered = false;
+      }
       aboutNewTabService.newTabURL = item.value || item.initialValue;
     }
+    lastNewTabNotifiedId = null;
+    ExtensionSettingsStore.removeSetting(extension, NEW_TAB_SETTING_TYPE, extension.id);
   }
 
   async onManifestEntry(entryName) {
     let {extension} = this;
     let {manifest} = extension;
 
     await ExtensionSettingsStore.initialize();
 
@@ -61,12 +162,16 @@ this.urlOverrides = class extends Extens
       if (["ADDON_ENABLE", "ADDON_UPGRADE", "ADDON_DOWNGRADE"]
           .includes(extension.startupReason)) {
         item = ExtensionSettingsStore.enable(extension, STORE_TYPE, NEW_TAB_SETTING_NAME);
       }
 
       // Set the newTabURL to the current value of the setting.
       if (item) {
         aboutNewTabService.newTabURL = item.value || item.initialValue;
+        if (!observerRegistered) {
+          Services.obs.addObserver(newTabOpenedListener, "browser-open-newtab-start");
+          observerRegistered = true;
+        }
       }
     }
   }
 };
--- a/browser/components/extensions/test/browser/browser_ext_url_overrides_newtab.js
+++ b/browser/components/extensions/test/browser/browser_ext_url_overrides_newtab.js
@@ -1,16 +1,56 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 
 "use strict";
 
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionSettingsStore",
+                                  "resource://gre/modules/ExtensionSettingsStore.jsm");
+
 const NEWTAB_URI_1 = "webext-newtab-1.html";
 
-add_task(async function test_sending_message_from_newtab_page() {
+function getNotificationSetting(extensionId) {
+  return ExtensionSettingsStore.getSetting("newTabNotification", extensionId);
+}
+
+function getNewTabDoorhanger() {
+  return document.getElementById("extension-new-tab-notification");
+}
+
+function settingChanged(extensionId) {
+  let initialValue = getNotificationSetting(extensionId);
+  return TestUtils.waitForCondition(
+    () => initialValue != getNotificationSetting(extensionId),
+    "settingChanged");
+}
+
+function clickKeepChanges(notification) {
+  let button = document.getAnonymousElementByAttribute(
+    notification, "anonid", "button");
+  button.doCommand();
+}
+
+function clickRestoreSettings(notification) {
+  let button = document.getAnonymousElementByAttribute(
+    notification, "anonid", "secondarybutton");
+  button.doCommand();
+}
+
+function waitForNewTab() {
+  return new Promise(resolve => {
+    function observer() {
+      Services.obs.removeObserver(observer, "browser-open-newtab-start");
+      resolve();
+    }
+    Services.obs.addObserver(observer, "browser-open-newtab-start");
+  });
+}
+
+add_task(async function test_new_tab_opens() {
   let extension = ExtensionTestUtils.loadExtension({
     manifest: {
       "chrome_url_overrides": {
         newtab: NEWTAB_URI_1,
       },
     },
     useAddonManager: "temporary",
     files: {
@@ -37,32 +77,240 @@ add_task(async function test_sending_mes
 
   // Simulate opening the newtab open as a user would.
   BrowserOpenTab();
 
   let url = await extension.awaitMessage("from-newtab-page");
   ok(url.endsWith(NEWTAB_URI_1),
      "Newtab url is overriden by the extension.");
 
+  // This will show a confirmation doorhanger, make sure we don't leave it open.
+  getNewTabDoorhanger().closest("panel").hidePopup();
   await BrowserTestUtils.removeTab(gBrowser.selectedTab);
   await extension.unload();
 });
 
+add_task(async function test_new_tab_ignore_settings() {
+  await ExtensionSettingsStore.initialize();
+  let notification = getNewTabDoorhanger();
+  let panel = notification.closest("panel");
+  let extensionId = "newtabignore@mochi.test";
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      applications: {gecko: {id: extensionId}},
+      browser_action: {default_popup: "ignore.html"},
+      chrome_url_overrides: {newtab: "ignore.html"},
+    },
+    files: {"ignore.html": '<h1 id="extension-new-tab">New Tab!</h1>'},
+    useAddonManager: "temporary",
+  });
+
+  ok(panel.getAttribute("panelopen") != "true",
+     "The notification panel is initially closed");
+
+  await extension.startup();
+
+  // Simulate opening the New Tab as a user would.
+  let popupShown = promisePopupShown(panel);
+  BrowserOpenTab();
+  await popupShown;
+
+  // Ensure the doorhanger is shown and the setting isn't set yet.
+  is(panel.getAttribute("panelopen"), "true",
+     "The notification panel is open after opening New Tab");
+  is(gURLBar.focused, false, "The URL bar is not focused with a doorhanger");
+  is(getNotificationSetting(extensionId), null,
+     "The New Tab notification is not set for this extension");
+  is(panel.anchorNode.closest("toolbarbutton").id,
+     "newtabignore_mochi_test-browser-action",
+     "The doorhanger is anchored to the browser action");
+
+  // Manually close the panel, as if the user ignored it.
+  let popupHidden = promisePopupHidden(panel);
+  panel.hidePopup();
+  await popupHidden;
+
+  // Ensure panel is closed and the setting still isn't set.
+  ok(panel.getAttribute("panelopen") != "true",
+     "The notification panel is closed");
+  is(getNotificationSetting(extensionId), null,
+     "The New Tab notification is not set after ignoring the doorhanger");
+
+  // Close the first tab and open another new tab.
+  await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+  let newTabOpened = waitForNewTab();
+  BrowserOpenTab();
+  await newTabOpened;
+
+  // Verify the doorhanger is not shown a second time.
+  ok(panel.getAttribute("panelopen") != "true",
+     "The notification panel doesn't open after ignoring the doorhanger");
+  is(gURLBar.focused, true, "The URL bar is focused with no doorhanger");
+
+  await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+  await extension.unload();
+});
+
+add_task(async function test_new_tab_keep_settings() {
+  await ExtensionSettingsStore.initialize();
+  let notification = getNewTabDoorhanger();
+  let panel = notification.closest("panel");
+  let extensionId = "newtabkeep@mochi.test";
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      applications: {gecko: {id: extensionId}},
+      chrome_url_overrides: {newtab: "keep.html"},
+    },
+    files: {"keep.html": '<h1 id="extension-new-tab">New Tab!</h1>'},
+    useAddonManager: "temporary",
+  });
+
+  ok(panel.getAttribute("panelopen") != "true",
+     "The notification panel is initially closed");
+
+  await extension.startup();
+
+  // Simulate opening the New Tab as a user would.
+  let popupShown = promisePopupShown(panel);
+  BrowserOpenTab();
+  await popupShown;
+
+  // Ensure the panel is open and the setting isn't saved yet.
+  is(panel.getAttribute("panelopen"), "true",
+     "The notification panel is open after opening New Tab");
+  is(getNotificationSetting(extensionId), null,
+     "The New Tab notification is not set for this extension");
+  is(panel.anchorNode.closest("toolbarbutton").id, "PanelUI-menu-button",
+     "The doorhanger is anchored to the menu icon");
+
+  // Click the Keep Changes button.
+  let popupHidden = promisePopupHidden(panel);
+  clickKeepChanges(notification);
+  await popupHidden;
+  await settingChanged(extensionId);
+
+  // Ensure panel is closed and setting is updated.
+  ok(panel.getAttribute("panelopen") != "true",
+     "The notification panel is closed after click");
+  is(getNotificationSetting(extensionId).value, "confirmed",
+     "The New Tab notification is set after keeping the changes");
+
+  // Close the first tab and open another new tab.
+  await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+  let newTabOpened = waitForNewTab();
+  BrowserOpenTab();
+  await newTabOpened;
+
+  // Verify the doorhanger is not shown a second time.
+  ok(panel.getAttribute("panelopen") != "true",
+     "The notification panel is not opened after keeping the changes");
+
+  await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+  await extension.unload();
+});
+
+add_task(async function test_new_tab_restore_settings() {
+  await ExtensionSettingsStore.initialize();
+  let notification = getNewTabDoorhanger();
+  let panel = notification.closest("panel");
+  let extensionId = "newtabrestore@mochi.test";
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      applications: {gecko: {id: extensionId}},
+      chrome_url_overrides: {newtab: "restore.html"},
+    },
+    files: {"restore.html": '<h1 id="extension-new-tab">New Tab!</h1>'},
+    useAddonManager: "temporary",
+  });
+
+  ok(panel.getAttribute("panelopen") != "true",
+     "The notification panel is initially closed");
+  is(getNotificationSetting(extensionId), null,
+     "The New Tab notification is not initially set for this extension");
+
+  await extension.startup();
+
+  // Simulate opening the newtab open as a user would.
+  let popupShown = promisePopupShown(panel);
+  BrowserOpenTab();
+  await popupShown;
+
+  // Verify that the panel is open and add-on is enabled.
+  let addon = await AddonManager.getAddonByID(extensionId);
+  is(addon.userDisabled, false, "The add-on is enabled at first");
+  is(panel.getAttribute("panelopen"), "true",
+     "The notification panel is open after opening New Tab");
+  is(getNotificationSetting(extensionId), null,
+     "The New Tab notification is not set for this extension");
+
+  // Click the Restore Changes button.
+  let addonDisabled = new Promise(resolve => {
+    let listener = {
+      onDisabled(disabledAddon) {
+        if (disabledAddon.id == addon.id) {
+          resolve();
+          AddonManager.removeAddonListener(listener);
+        }
+      },
+    };
+    AddonManager.addAddonListener(listener);
+  });
+  let popupHidden = promisePopupHidden(panel);
+  clickRestoreSettings(notification);
+  await popupHidden;
+  await addonDisabled;
+
+  // Ensure panel is closed, settings haven't changed and add-on is disabled.
+  ok(panel.getAttribute("panelopen") != "true",
+     "The notification panel is closed after click");
+  is(getNotificationSetting(extensionId), null,
+     "The New Tab notification is not set after resorting the settings");
+  is(addon.userDisabled, true, "The extension is now disabled");
+
+  // Reopen a browser tab and verify that there's no doorhanger.
+  await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+  let newTabOpened = waitForNewTab();
+  BrowserOpenTab();
+  await newTabOpened;
+
+  ok(panel.getAttribute("panelopen") != "true",
+     "The notification panel is not opened after keeping the changes");
+
+  // FIXME: We need to enable the add-on so it gets cleared from the
+  // ExtensionSettingsStore for now. See bug 1408226.
+  let addonEnabled = new Promise(resolve => {
+    let listener = {
+      onEnabled(enabledAddon) {
+        if (enabledAddon.id == addon.id) {
+          AddonManager.removeAddonListener(listener);
+          resolve();
+        }
+      },
+    };
+    AddonManager.addAddonListener(listener);
+  });
+  addon.userDisabled = false;
+  await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+  await addonEnabled;
+  await extension.unload();
+});
+
 /**
  * Ensure we don't show the extension URL in the URL bar temporarily in new tabs
  * while we're switching remoteness (when the URL we're loading and the
  * default content principal are different).
  */
 add_task(async function dontTemporarilyShowAboutExtensionPath() {
+  await ExtensionSettingsStore.initialize();
   let extension = ExtensionTestUtils.loadExtension({
     manifest: {
       name: "Test Extension",
       applications: {
         gecko: {
-          id: "newtab@mochi.test",
+          id: "newtaburl@mochi.test",
         },
       },
       chrome_url_overrides: {
         newtab: "newtab.html",
       },
     },
     background() {
       browser.test.sendMessage("url", browser.runtime.getURL("newtab.html"));
--- a/browser/locales/en-US/chrome/browser/browser.dtd
+++ b/browser/locales/en-US/chrome/browser/browser.dtd
@@ -960,16 +960,23 @@ you can use these alternative items. Oth
 <!ENTITY updateRestart.message2 "After a quick restart, &brandShorterName; will restore all your open tabs and windows that are not in Private Browsing mode.">
 <!ENTITY updateRestart.header.message2 "Restart to update &brandShorterName;.">
 <!ENTITY updateRestart.acceptButton.label "Restart and Restore">
 <!ENTITY updateRestart.acceptButton.accesskey "R">
 <!ENTITY updateRestart.cancelButton.label "Not Now">
 <!ENTITY updateRestart.cancelButton.accesskey "N">
 <!ENTITY updateRestart.panelUI.label2 "Restart to update &brandShorterName;">
 
+<!ENTITY newTabControlled.message "An extension has changed the page you see when you open a New Tab. You can restore your settings if you do not want this change.">
+<!ENTITY newTabControlled.header.message "Your New Tab has changed.">
+<!ENTITY newTabControlled.keepButton.label "Keep Changes">
+<!ENTITY newTabControlled.keepButton.accesskey "K">
+<!ENTITY newTabControlled.restoreButton.label "Restore Settings">
+<!ENTITY newTabControlled.restoreButton.accesskey "R">
+
 <!ENTITY pageActionButton.tooltip "Page actions">
 <!ENTITY pageAction.addToUrlbar.label "Add to Address Bar">
 <!ENTITY pageAction.removeFromUrlbar.label "Remove from Address Bar">
 
 <!ENTITY pageAction.sendTabToDevice.label "Send Tab to Device">
 <!ENTITY sendToDevice.syncNotReady.label "Syncing Devices…">
 
 <!ENTITY libraryButton.tooltip "View history, saved bookmarks, and more">
--- a/browser/themes/shared/customizableui/panelUI.inc.css
+++ b/browser/themes/shared/customizableui/panelUI.inc.css
@@ -106,16 +106,17 @@
 #PanelUI-menu-button[badge-status] > .toolbarbutton-badge-stack > .toolbarbutton-badge {
   display: -moz-box;
   height: 10px;
   width: 10px;
   background-size: contain;
   border: none;
 }
 
+#PanelUI-menu-button[badge-status="extension-new-tab"] > .toolbarbutton-badge-stack > .toolbarbutton-badge,
 #PanelUI-menu-button[badge-status="download-success"] > .toolbarbutton-badge-stack > .toolbarbutton-badge {
   display: none;
 }
 
 #PanelUI-menu-button[badge-status="update-available"] > .toolbarbutton-badge-stack > .toolbarbutton-badge,
 #PanelUI-menu-button[badge-status="update-manual"] > .toolbarbutton-badge-stack > .toolbarbutton-badge,
 #PanelUI-menu-button[badge-status="update-restart"] > .toolbarbutton-badge-stack > .toolbarbutton-badge {
   background: #74BF43 url(chrome://browser/skin/update-badge.svg) no-repeat center;
@@ -446,16 +447,40 @@ panelview:not([mainview]) .toolbarbutton
   text-align: start;
   display: -moz-box;
 }
 
 .cui-widget-panel > .panel-arrowcontainer > .panel-arrowcontent {
   padding: 4px 0;
 }
 
+/* START notification popups for extension controlled content */
+#extension-notification-panel > .panel-arrowcontainer > .panel-arrowcontent {
+  padding: 0;
+}
+
+#extension-new-tab-notification > .popup-notification-body-container > .popup-notification-body {
+  width: 30em;
+}
+
+#extension-new-tab-notification > .popup-notification-body-container > .popup-notification-body > hbox > vbox > .popup-notification-description {
+  font-size: 1.3em;
+  font-weight: lighter;
+}
+
+#extension-new-tab-notification > .popup-notification-body-container > .popup-notification-body > .notification-panel-content {
+  margin-bottom: 0;
+}
+
+#extension-new-tab-notification > .popup-notification-body-container > .popup-notification-body > .popup-notification-warning,
+#extension-new-tab-notification > .popup-notification-body-container > .popup-notification-icon {
+  display: none;
+}
+/* END notification popups for extension controlled content */
+
 /* START photonpanelview adjustments */
 
 #appMenu-popup > .panel-arrowcontainer > .panel-arrowcontent,
 panel[photon] > .panel-arrowcontainer > .panel-arrowcontent {
   padding: 0;
 }
 
 photonpanelmultiview panelview {