Bug 1496632 - Move existing add-on installation UI to notification panels; r=mkmelin
authorGeoff Lankow <geoff@darktrojan.net>
Fri, 19 Oct 2018 21:59:41 +1300
changeset 33417 ba7329940979e5879434eda94842f528a1b06f93
parent 33416 3a91ffa3617c611a043c9df09546a06d71831cd7
child 33418 e16ec20c7395cf961894b9d67936208ad906ab31
push id387
push userclokep@gmail.com
push dateMon, 10 Dec 2018 21:30:47 +0000
reviewersmkmelin
bugs1496632
Bug 1496632 - Move existing add-on installation UI to notification panels; r=mkmelin
mail/base/content/messenger.xul
mail/base/content/msgMail3PaneWindow.js
mail/base/content/specialTabs.js
mail/base/content/tabmail.xml
mail/base/modules/ExtensionsUI.jsm
mail/base/modules/moz.build
mail/locales/en-US/chrome/messenger/addons.properties
mail/locales/en-US/chrome/messenger/messenger.properties
mail/locales/jar.mn
mail/themes/shared/mail/messenger.css
--- a/mail/base/content/messenger.xul
+++ b/mail/base/content/messenger.xul
@@ -58,16 +58,17 @@
 %msgViewPickerDTD;
 ]>
 
 <!--
   - The 'what you think of when you think of thunderbird' window;
   -  3-pane view inside of tabs.
   -->
 <window id="messengerWindow"
+        xmlns:html="http://www.w3.org/1999/xhtml"
         xmlns:svg="http://www.w3.org/2000/svg"
         xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
         title="&titledefault.label;@PRE_RELEASE_SUFFIX@"
         titlemodifier="&titledefault.label;@PRE_RELEASE_SUFFIX@"
         titlemenuseparator="&titleSeparator.label;"
         defaultTabTitle="&defaultTabTitle.label;"
         onload="OnLoadMessenger()"
         onunload="OnUnloadMessenger()"
@@ -300,16 +301,46 @@
   </menupopup>
 
   <tooltip id="tabmail-tabs-tooltip" onpopupshowing="document.getElementById('tabmail').createTooltip(event);"/>
   <tooltip id="folderpopup" class="folderSummaryPopup"/>
 
   <tooltip id="aHTMLTooltip" page="true"/>
   <tooltip id="tabmail-tabs-tooltip" onpopupshowing="document.getElementById('tabmail').createTooltip(event);"/>
 
+  <panel id="notification-popup"
+         type="arrow"
+         position="after_start"
+         orient="vertical"
+         noautofocus="true"
+         role="alert"/>
+
+  <popupnotification id="addon-progress-notification" hidden="true">
+    <popupnotificationcontent orient="vertical">
+      <progressmeter id="addon-progress-notification-progressmeter"/>
+      <label id="addon-progress-notification-progresstext" crop="end"/>
+    </popupnotificationcontent>
+  </popupnotification>
+
+  <popupnotification id="addon-install-confirmation-notification" hidden="true">
+    <popupnotificationcontent id="addon-install-confirmation-content" orient="vertical"/>
+  </popupnotification>
+
+  <popupnotification id="addon-webext-permissions-notification" hidden="true">
+    <popupnotificationcontent class="addon-webext-perm-notification-content" orient="vertical">
+      <description id="addon-webext-perm-text" class="addon-webext-perm-text"/>
+      <label id="addon-webext-perm-intro" class="addon-webext-perm-text"/>
+      <html:ul id="addon-webext-perm-list" class="addon-webext-perm-list"/>
+    </popupnotificationcontent>
+  </popupnotification>
+
+  <popupnotification id="addon-installed-notification" hidden="true">
+    <popupnotificationcontent class="addon-installed-notification-content" orient="vertical"/>
+  </popupnotification>
+
 #include editContactPanel.inc
 #include ../../components/im/content/chat-menu.inc
 </popupset>
 #ifdef XP_MACOSX
 <popupset>
   <menupopup id="menu_mac_dockmenu">
     <menuitem label="&writeNewMessageDock.label;" id="tasksWriteNewMessage"
               oncommand="writeNewMessageDock();"/>
@@ -357,16 +388,25 @@
                  class="tabmail-tab" crop="end" linkedpanel="mailContent"/>
       </tabs>
 
       <!-- Use of this element for extensions is deprecated! Current
            extensions should add to #mail-toolbox and add a toolbar item to
            #tabbar-toolbar below. -->
       <hbox id="tabmail-buttons"/>
 
+      <box id="notification-popup-box"
+           align="center"
+           hidden="true">
+        <image id="addons-notification-icon"
+               src="chrome://mozapps/skin/extensions/extensionGeneric-16.svg"
+               class="notification-anchor-icon install-icon"
+               role="button"/>
+      </box>
+
       <toolbar id="tabbar-toolbar" toolboxid="mail-toolbox"
                context="toolbar-context-menu"
                customizable="true"
                mode="icons" defaultmode="icons" lockmode="true"
                iconsize="small" defaulticonsize="small" lockiconsize="true"
                defaultset=""/>
 
       <toolbarbutton class="toolbarbutton-1 tabs-alltabs-button"
--- a/mail/base/content/msgMail3PaneWindow.js
+++ b/mail/base/content/msgMail3PaneWindow.js
@@ -19,16 +19,36 @@ ChromeUtils.import("resource:///modules/
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://gre/modules/AppConstants.jsm");
 ChromeUtils.import("resource://gre/modules/Color.jsm");
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   LightweightThemeManager: "resource://gre/modules/LightweightThemeManager.jsm",
 });
 
+XPCOMUtils.defineLazyGetter(this, "PopupNotifications", function() {
+  let tmp = {};
+  ChromeUtils.import("resource://gre/modules/PopupNotifications.jsm", tmp);
+  try {
+    // Hide all notifications while the URL is being edited and the address bar
+    // has focus, including the virtual focus in the results popup.
+    // We also have to hide notifications explicitly when the window is
+    // minimized because of the effects of the "noautohide" attribute on Linux.
+    // This can be removed once bug 545265 and bug 1320361 are fixed.
+    let shouldSuppress = () => window.windowState == window.STATE_MINIMIZED;
+    return new tmp.PopupNotifications(document.getElementById("tabmail"),
+                                      document.getElementById("notification-popup"),
+                                      document.getElementById("notification-popup-box"),
+                                      { shouldSuppress });
+  } catch (ex) {
+    Cu.reportError(ex);
+    return null;
+  }
+});
+
 // Copied from M-C's TelemetryEnvironment.jsm
 ChromeUtils.defineModuleGetter(this, "ctypes",
                                "resource://gre/modules/ctypes.jsm");
 /**
  * Gets the service pack and build information on Windows platforms. The initial version
  * was copied from nsUpdateService.js.
  *
  * @return An object containing the service pack major and minor versions, along with the
--- a/mail/base/content/specialTabs.js
+++ b/mail/base/content/specialTabs.js
@@ -627,18 +627,16 @@ var specialTabs = {
     request.onerror = onDownloadError;
     request.timeout = this.REQUEST_TIMEOUT;
     request.ontimeout = onDownloadError;
     request.send(null);
   },
 
   // This will open any special tabs if necessary on startup.
   openSpecialTabsOnStartup: function() {
-    window.addEventListener("unload", specialTabs.onunload);
-
     let browser = document.getElementById("dummycontentbrowser");
 
     // Manually hook up session and global history for the first browser
     // so that we don't have to load global history before bringing up a
     // window.
     // Wire up session and global history before any possible
     // progress notifications for back/forward button updating
     browser.docShell.initSessionHistory();
@@ -650,18 +648,16 @@ var specialTabs = {
 
     // enable global history
     try {
       browser.docShell.useGlobalHistory = true;
     } catch(ex) {
       Cu.reportError("Places database may be locked: " + ex);
     }
 
-    Services.obs.addObserver(specialTabs, "mail-startup-done");
-
     let tabmail = document.getElementById('tabmail');
 
     tabmail.registerTabType(this.contentTabType);
     tabmail.registerTabType(this.chromeTabType);
 
     // If we've upgraded (note: always get these values so that we set
     // the mstone preference for the new version):
     let [fromVer, toVer] = this.getApplicationUpgradeVersions();
@@ -1380,240 +1376,16 @@ var specialTabs = {
       // Save the function we'll use as listener so we can remove it later.
       aTab.closeListener = onDOMWindowClose;
       // Add the listener.
       aTab.browser.addEventListener("DOMWindowClose",
                                     aTab.closeListener, true);
     }
   },
 
-  observe: function (aSubject, aTopic, aData) {
-    if (aTopic != "mail-startup-done")
-      return;
-
-    Services.obs.removeObserver(specialTabs, "mail-startup-done");
-    Services.obs.addObserver(this.xpInstallObserver, "addon-install-disabled");
-    Services.obs.addObserver(this.xpInstallObserver, "addon-install-blocked");
-    Services.obs.addObserver(this.xpInstallObserver, "addon-install-failed");
-    Services.obs.addObserver(this.xpInstallObserver, "addon-install-confirmation");
-    Services.obs.addObserver(this.xpInstallObserver, "addon-install-complete");
-  },
-
-  onunload: function () {
-    window.removeEventListener("unload", specialTabs.onunload);
-
-    Services.obs.removeObserver(specialTabs.xpInstallObserver, "addon-install-disabled");
-    Services.obs.removeObserver(specialTabs.xpInstallObserver, "addon-install-blocked");
-    Services.obs.removeObserver(specialTabs.xpInstallObserver, "addon-install-failed");
-    Services.obs.removeObserver(specialTabs.xpInstallObserver, "addon-install-confirmation");
-    Services.obs.removeObserver(specialTabs.xpInstallObserver, "addon-install-complete");
-  },
-
-  xpInstallObserver: {
-    observe: function (aSubject, aTopic, aData) {
-      let brandBundle = document.getElementById("bundle_brand");
-      let messengerBundle = document.getElementById("bundle_messenger");
-
-      let installInfo = aSubject.wrappedJSObject;
-      let browser = installInfo.browser;
-      let notificationBox = getNotificationBox(browser.contentWindow);
-      let notificationID = aTopic;
-      let brandShortName = brandBundle.getString("brandShortName");
-      let notificationName, messageString, buttons;
-      const iconURL = "chrome://mozapps/skin/extensions/extensionGeneric-16.svg";
-
-      switch (aTopic) {
-      case "addon-install-disabled":
-        notificationID = "xpinstall-disabled";
-
-        if (Services.prefs.prefIsLocked("xpinstall.enabled")) {
-          messageString = messengerBundle.getString("xpinstallDisabledMessageLocked");
-          buttons = [];
-        }
-        else {
-          messageString = messengerBundle.getString("xpinstallDisabledMessage");
-
-          buttons = [{
-            label: messengerBundle.getString("xpinstallDisabledButton"),
-            accessKey: messengerBundle.getString("xpinstallDisabledButton.accesskey"),
-            popup: null,
-            callback: function editPrefs() {
-              Services.prefs.setBoolPref("xpinstall.enabled", true);
-              return false;
-            }
-          }];
-        }
-        if (notificationBox && !notificationBox.getNotificationWithValue(notificationID)) {
-          notificationBox.appendNotification(messageString, notificationID,
-                                             iconURL,
-                                             notificationBox.PRIORITY_CRITICAL_HIGH,
-                                             buttons);
-        }
-        break;
-      case "addon-install-blocked":
-        messageString =
-          messengerBundle.getFormattedString("xpinstallPromptWarning",
-                                             [brandShortName, installInfo.originatingURI.host]);
-
-        buttons = [{
-          label: messengerBundle.getString("xpinstallPromptAllowButton"),
-          accessKey: messengerBundle.getString("xpinstallPromptAllowButton.accesskey"),
-          popup: null,
-          callback: function() {
-            installInfo.install();
-          }
-        }];
-
-        if (notificationBox && !notificationBox.getNotificationWithValue(notificationName)) {
-            notificationBox.appendNotification(messageString, notificationName,
-                                               iconURL,
-                                               notificationBox.PRIORITY_WARNING_MEDIUM,
-                                               buttons);
-          }
-        break;
-      case "addon-install-failed":
-        // XXX TODO This isn't terribly ideal for the multiple failure case
-        for (let install of installInfo.installs) {
-          let host = (installInfo.originatingURI instanceof Ci.nsIStandardURL) &&
-                      installInfo.originatingURI.host;
-          if (!host)
-            host = (install.sourceURI instanceof Ci.nsIStandardURL) &&
-                    install.sourceURI.host;
-
-          let error = (host || install.error == 0) ?
-                       "addonError" : "addonLocalError";
-
-          // Temporarily replace the usual warning message with this more-likely one.
-          if (install.error == AddonManager.ERROR_CORRUPT_FILE)
-            error += "Legacy";
-          else if (install.error != 0)
-            error += install.error;
-          else if (install.addon.blocklistState == Ci.nsIBlocklistService.STATE_BLOCKED)
-            error += "Blocklisted";
-          else
-            error += "Incompatible";
-
-          messageString = messengerBundle.getString(error);
-          messageString = messageString.replace("#1", install.name);
-          if (host)
-            messageString = messageString.replace("#2", host);
-          messageString = messageString.replace("#3", brandShortName);
-          messageString = messageString.replace("#4", Services.appinfo.version);
-
-          if (notificationBox && !notificationBox.getNotificationWithValue(notificationID)) {
-            notificationBox.appendNotification(messageString,
-                                               notificationID,
-                                               iconURL,
-                                               notificationBox.PRIORITY_CRITICAL_HIGH,
-                                               []);
-          }
-        }
-        break;
-      case "addon-install-confirmation":
-        let acceptInstallation = () => {
-          for (let install of installInfo.installs)
-            install.install();
-          installInfo = null;
-        };
-
-        let cancelInstallation = () => {
-          if (installInfo) {
-            for (let install of installInfo.installs) {
-              // The notification may have been closed because the add-ons got
-              // cancelled elsewhere, only try to cancel those that are still
-              // pending install.
-              if (install.state != AddonManager.STATE_CANCELLED)
-                install.cancel();
-            }
-          }
-        };
-
-        messageString = messengerBundle.getString("addonConfirmInstall.message");
-        messageString = PluralForm.get(installInfo.installs.length, messageString);
-        messageString = messageString.replace("#1", brandShortName);
-        messageString = messageString.replace("#2", installInfo.installs.length);
-        messageString += " " + installInfo.installs.map(ii => ii.name).join(", ");
-
-        buttons = [{
-          label: messengerBundle.getString("addonConfirmInstall.installButton.label"),
-          accessKey: messengerBundle.getString("addonConfirmInstall.installButton.accesskey"),
-          callback: acceptInstallation,
-        }, {
-          label: messengerBundle.getString("addonConfirmInstall.cancelButton.label"),
-          accessKey: messengerBundle.getString("addonConfirmInstall.cancelButton.accesskey"),
-          callback: cancelInstallation,
-        }];
-
-        if (notificationBox)
-          notificationBox.appendNotification(messageString,
-                                             notificationID,
-                                             iconURL,
-                                             notificationBox.PRIORITY_WARNING_MEDIUM,
-                                             buttons);
-        break;
-      case "addon-install-complete":
-        let needsRestart = installInfo.installs.some(function(i) {
-            return i.addon.pendingOperations != AddonManager.PENDING_NONE;
-        });
-
-        if (needsRestart) {
-          messageString = messengerBundle.getString("addonsInstalledNeedsRestart");
-          buttons = [{
-            label: messengerBundle.getString("addonInstallRestartButton"),
-            accessKey: messengerBundle.getString("addonInstallRestartButton.accesskey"),
-            popup: null,
-            callback: function() {
-              BrowserUtils.restartApplication();
-            }
-          }];
-        } else if (browser.currentURI.spec == "about:addons") {
-          messageString = messengerBundle.getString("addonsInstalled");
-          buttons = [];
-        } else {
-          messageString = messengerBundle.getString("addonsInstalled");
-          buttons = [{
-            label: messengerBundle.getString("addonInstallManage"),
-            accessKey: messengerBundle.getString("addonInstallManage.accesskey"),
-            popup: null,
-            callback: function() {
-              // Calculate the add-on type that is most popular in the list of
-              // installs.
-              let types = {};
-              let bestType = null;
-              for (let install of installInfo.installs) {
-                if (install.type in types)
-                  types[install.type]++;
-                else
-                  types[install.type] = 1;
-
-                if (!bestType || types[install.type] > types[bestType])
-                  bestType = install.type;
-
-                openAddonsMgr("addons://list/" + bestType);
-              }
-            }
-          }];
-        }
-
-        messageString = PluralForm.get(installInfo.installs.length, messageString);
-        messageString = messageString.replace("#1", installInfo.installs[0].name);
-        messageString = messageString.replace("#2", installInfo.installs.length);
-        messageString = messageString.replace("#3", brandShortName);
-
-        if (notificationBox)
-          notificationBox.appendNotification(messageString,
-                                             notificationID,
-                                             iconURL,
-                                             notificationBox.PRIORITY_INFO_MEDIUM,
-                                             buttons);
-        break;
-      }
-    }
-  },
-
   /**
    * Determine if we should load fav icons or not.
    *
    * @param aURI  An nsIURI containing the current url.
    */
   _shouldLoadFavIcon: function shouldLoadFavIcon(aURI) {
     return (aURI &&
             Services.prefs.getBoolPref("browser.chrome.site_icons") &&
--- a/mail/base/content/tabmail.xml
+++ b/mail/base/content/tabmail.xml
@@ -1135,16 +1135,18 @@
 
           return this.currentTabInfo;
         ]]></getter>
         <setter><![CDATA[
           this.switchToTab(val);
         ]]></setter>
       </property>
 
+      <property name="selectedBrowser" onget="return this.getBrowserForSelectedTab();" />
+
       <!-- getBrowserForSelectedTab is required as some toolkit functions
            require a getBrowser() function. -->
       <method name="getBrowserForSelectedTab">
         <body><![CDATA[
           if (!this.currentTabInfo)
             this.currentTabInfo = this.tabInfo[0];
 
           let tab = this.currentTabInfo;
new file mode 100644
--- /dev/null
+++ b/mail/base/modules/ExtensionsUI.jsm
@@ -0,0 +1,428 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+this.EXPORTED_SYMBOLS = [];
+
+ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+XPCOMUtils.defineLazyModuleGetters(this, {
+  AddonManager: "resource://gre/modules/AddonManager.jsm",
+  PluralForm: "resource://gre/modules/PluralForm.jsm",
+  Services: "resource://gre/modules/Services.jsm",
+  setTimeout: "resource://gre/modules/Timer.jsm",
+  StringBundle: "resource:///modules/StringBundle.js",
+});
+
+XPCOMUtils.defineLazyGetter(this, "addonsBundle", function() {
+  return new StringBundle("chrome://messenger/locale/addons.properties");
+});
+XPCOMUtils.defineLazyGetter(this, "brandBundle", function() {
+  return new StringBundle("chrome://branding/locale/brand.properties");
+});
+
+function getNotification(id, browser) {
+  return browser.ownerGlobal.PopupNotifications.getNotification(id, browser);
+}
+
+function showNotification(browser, ...args) {
+  let notifications = browser.ownerGlobal.PopupNotifications;
+  return notifications.show(browser, ...args);
+}
+
+// Removes a doorhanger notification if all of the installs it was notifying
+// about have ended in some way.
+function removeNotificationOnEnd(notification, installs) {
+  let count = installs.length;
+
+  function maybeRemove(install) {
+    install.removeListener(this);
+
+    if (--count == 0) {
+      // Check that the notification is still showing
+      let current = getNotification(notification.id, notification.browser);
+      if (current === notification) {
+        notification.remove();
+      }
+    }
+  }
+
+  for (let install of installs) {
+    install.addListener({
+      onDownloadCancelled: maybeRemove,
+      onDownloadFailed: maybeRemove,
+      onInstallFailed: maybeRemove,
+      onInstallEnded: maybeRemove,
+    });
+  }
+}
+
+var gXPInstallObserver = {
+  pendingInstalls: new WeakMap(),
+
+  showInstallConfirmation(browser, installInfo, height = undefined) {
+    let document = browser.ownerDocument;
+    // If the confirmation notification is already open cache the installInfo
+    // and the new confirmation will be shown later
+    if (getNotification("addon-install-confirmation", browser)) {
+      let pending = this.pendingInstalls.get(browser);
+      if (pending) {
+        pending.push(installInfo);
+      } else {
+        this.pendingInstalls.set(browser, [installInfo]);
+      }
+      return;
+    }
+
+    let showNextConfirmation = () => {
+      let pending = this.pendingInstalls.get(browser);
+      if (pending && pending.length) {
+        this.showInstallConfirmation(browser, pending.shift());
+      }
+    };
+
+    // If all installs have already been cancelled in some way then just show
+    // the next confirmation.
+    if (installInfo.installs.every(i => i.state != AddonManager.STATE_DOWNLOADED)) {
+      showNextConfirmation();
+      return;
+    }
+
+    const anchorID = "addons-notification-icon";
+
+    // Make notifications persistent
+    var options = {
+      displayURI: installInfo.originatingURI,
+      persistent: true,
+      hideClose: true,
+    };
+
+    let acceptInstallation = () => {
+      for (let install of installInfo.installs) {
+        install.install();
+      }
+      installInfo = null;
+    };
+
+    let cancelInstallation = () => {
+      if (installInfo) {
+        for (let install of installInfo.installs) {
+          // The notification may have been closed because the add-ons got
+          // cancelled elsewhere, only try to cancel those that are still
+          // pending install.
+          if (install.state != AddonManager.STATE_CANCELLED) {
+            install.cancel();
+          }
+        }
+      }
+
+      showNextConfirmation();
+    };
+
+    options.eventCallback = event => {
+      switch (event) {
+        case "removed":
+          cancelInstallation();
+          break;
+        case "shown":
+          let addonList = document.getElementById("addon-install-confirmation-content");
+          while (addonList.firstChild) {
+            addonList.firstChild.remove();
+          }
+
+          for (let install of installInfo.installs) {
+            let container = document.createXULElement("hbox");
+
+            let name = document.createXULElement("label");
+            name.setAttribute("value", install.addon.name);
+            name.setAttribute("class", "addon-install-confirmation-name");
+            container.appendChild(name);
+
+            addonList.appendChild(container);
+          }
+          break;
+      }
+    };
+
+    options.learnMoreURL = Services.urlFormatter.formatURLPref("app.support.baseURL");
+
+    let messageString;
+    let notification = document.getElementById("addon-install-confirmation-notification");
+    messageString = addonsBundle.getString("addonConfirmInstall.message");
+    notification.removeAttribute("warning");
+    options.learnMoreURL += "find-and-install-add-ons";
+
+    let brandShortName = brandBundle.getString("brandShortName");
+
+    messageString = PluralForm.get(installInfo.installs.length, messageString);
+    messageString = messageString.replace("#1", brandShortName);
+    messageString = messageString.replace("#2", installInfo.installs.length);
+
+    let action = {
+      label: addonsBundle.getString("addonInstall.acceptButton2.label"),
+      accessKey: addonsBundle.getString("addonInstall.acceptButton2.accesskey"),
+      callback: acceptInstallation,
+    };
+
+    let secondaryAction = {
+      label: addonsBundle.getString("addonInstall.cancelButton.label"),
+      accessKey: addonsBundle.getString("addonInstall.cancelButton.accesskey"),
+      callback: () => {},
+    };
+
+    if (height) {
+      notification.style.minHeight = height + "px";
+    }
+
+    let popup = showNotification(browser, "addon-install-confirmation", messageString, anchorID,
+                                 action, [secondaryAction], options);
+    removeNotificationOnEnd(popup, installInfo.installs);
+  },
+
+  observe(subject, topic, data) {
+    let installInfo = subject.wrappedJSObject;
+    let browser = installInfo.browser;
+    let window = browser.ownerGlobal;
+
+    const anchorID = "addons-notification-icon";
+    var messageString, action;
+    var brandShortName = brandBundle.getString("brandShortName");
+
+    var notificationID = topic;
+    // Make notifications persistent
+    var options = {
+      displayURI: installInfo.originatingURI,
+      persistent: true,
+      hideClose: true,
+      timeout: Date.now() + 30000,
+    };
+
+    switch (topic) {
+      case "addon-install-disabled": {
+        notificationID = "xpinstall-disabled";
+        let secondaryActions = null;
+
+        if (Services.prefs.prefIsLocked("xpinstall.enabled")) {
+          messageString = addonsBundle.getString("xpinstallDisabledMessageLocked");
+        } else {
+          messageString = addonsBundle.getString("xpinstallDisabledMessage");
+
+          action = {
+            label: addonsBundle.getString("xpinstallDisabledButton"),
+            accessKey: addonsBundle.getString("xpinstallDisabledButton.accesskey"),
+            callback: () => {
+              Services.prefs.setBoolPref("xpinstall.enabled", true);
+            },
+          };
+
+          secondaryActions = [{
+            label: addonsBundle.getString("addonInstall.cancelButton.label"),
+            accessKey: addonsBundle.getString("addonInstall.cancelButton.accesskey"),
+            callback: () => {},
+          }];
+        }
+
+        showNotification(browser, notificationID, messageString, anchorID,
+                         action, secondaryActions, options);
+        break;
+      }
+      case "addon-install-origin-blocked": {
+        messageString = addonsBundle.getFormattedString("xpinstallPromptMessage", [brandShortName]);
+
+        options.removeOnDismissal = true;
+        options.persistent = false;
+
+        let popup = showNotification(browser, notificationID, messageString, anchorID,
+                                     null, null, options);
+        removeNotificationOnEnd(popup, installInfo.installs);
+        break;
+      }
+      case "addon-install-blocked": {
+        messageString = addonsBundle.getFormattedString("xpinstallPromptMessage", [brandShortName]);
+
+        action = {
+          label: addonsBundle.getString("xpinstallPromptAllowButton"),
+          accessKey: addonsBundle.getString("xpinstallPromptAllowButton.accesskey"),
+          callback() {
+            installInfo.install();
+          },
+        };
+        let secondaryAction = {
+          label: addonsBundle.getString("xpinstallPromptMessage.dontAllow"),
+          accessKey: addonsBundle.getString("xpinstallPromptMessage.dontAllow.accesskey"),
+          callback: () => {
+            for (let install of installInfo.installs) {
+              if (install.state != AddonManager.STATE_CANCELLED) {
+                install.cancel();
+              }
+            }
+          },
+        };
+
+        let popup = showNotification(browser, notificationID, messageString, anchorID,
+                                     action, [secondaryAction], options);
+        removeNotificationOnEnd(popup, installInfo.installs);
+        break;
+      }
+      case "addon-install-started": {
+        let needsDownload = function(install) {
+          return install.state != AddonManager.STATE_DOWNLOADED;
+        };
+        // If all installs have already been downloaded then there is no need to
+        // show the download progress.
+        if (!installInfo.installs.some(needsDownload)) {
+          return;
+        }
+        notificationID = "addon-progress";
+        messageString = addonsBundle.getString("addonDownloadingAndVerifying");
+        messageString = PluralForm.get(installInfo.installs.length, messageString);
+        messageString = messageString.replace("#1", installInfo.installs.length);
+        options.installs = installInfo.installs;
+        options.contentWindow = browser.contentWindow;
+        options.sourceURI = browser.currentURI;
+        options.eventCallback = function(event) {
+          switch (event) {
+            case "shown":
+              let notificationElement = [...this.owner.panel.children]
+                                        .find(n => n.notification == this);
+              if (notificationElement) {
+                notificationElement.setAttribute("mainactiondisabled", "true");
+              }
+              break;
+            case "removed":
+              options.contentWindow = null;
+              options.sourceURI = null;
+              break;
+          }
+        };
+        action = {
+          label: addonsBundle.getString("addonInstall.acceptButton2.label"),
+          accessKey: addonsBundle.getString("addonInstall.acceptButton2.accesskey"),
+          callback: () => {},
+        };
+        let secondaryAction = {
+          label: addonsBundle.getString("addonInstall.cancelButton.label"),
+          accessKey: addonsBundle.getString("addonInstall.cancelButton.accesskey"),
+          callback: () => {
+            for (let install of installInfo.installs) {
+              if (install.state != AddonManager.STATE_CANCELLED) {
+                install.cancel();
+              }
+            }
+          },
+        };
+        let notification = showNotification(browser, notificationID, messageString, anchorID,
+                                            action, [secondaryAction], options);
+        notification._startTime = Date.now();
+        break;
+      }
+      case "addon-install-failed": {
+        options.removeOnDismissal = true;
+        options.persistent = false;
+
+        // TODO This isn't terribly ideal for the multiple failure case
+        for (let install of installInfo.installs) {
+          let host;
+          try {
+            host  = options.displayURI.host;
+          } catch (e) {
+            // displayURI might be missing or 'host' might throw for non-nsStandardURL nsIURIs.
+          }
+
+          if (!host) {
+            host = (install.sourceURI instanceof Ci.nsIStandardURL) &&
+                   install.sourceURI.host;
+          }
+
+          let error = (host || install.error == 0) ? "addonInstallError" : "addonLocalInstallError";
+          let args;
+          if (install.error < 0) {
+            error += install.error;
+            args = [brandShortName, install.name];
+          } else if (install.addon.blocklistState == Ci.nsIBlocklistService.STATE_BLOCKED) {
+            error += "Blocklisted";
+            args = [install.name];
+          } else {
+            error += "Incompatible";
+            args = [brandShortName, Services.appinfo.version, install.name];
+          }
+
+          messageString = addonsBundle.getFormattedString(error, args);
+
+          showNotification(browser, notificationID, messageString, anchorID,
+                           action, null, options);
+
+          // Can't have multiple notifications with the same ID, so stop here.
+          break;
+        }
+        this._removeProgressNotification(browser);
+        break;
+      }
+      case "addon-install-confirmation": {
+        let showNotification = () => {
+          let height;
+          if (window.PopupNotifications.isPanelOpen) {
+            let rect = browser.ownerDocument.getElementById("addon-progress-notification")
+                                            .getBoundingClientRect();
+            height = rect.height;
+          }
+
+          this._removeProgressNotification(browser);
+          this.showInstallConfirmation(browser, installInfo, height);
+        };
+
+        let progressNotification = getNotification("addon-progress", browser);
+        if (progressNotification) {
+          let downloadDuration = Date.now() - progressNotification._startTime;
+          let securityDelay = Services.prefs.getIntPref("security.dialog_enable_delay");
+          if (securityDelay > downloadDuration) {
+            setTimeout(() => {
+              // The download may have been cancelled during the security delay
+              if (getNotification("addon-progress", browser)) {
+                showNotification();
+              }
+            }, securityDelay - downloadDuration);
+            break;
+          }
+        }
+        showNotification();
+        break;
+      }
+      case "addon-install-complete": {
+        let secondaryActions = null;
+        let numAddons = installInfo.installs.length;
+
+        if (numAddons == 1) {
+          messageString = addonsBundle.getFormattedString("addonInstalled",
+                                                          [installInfo.installs[0].name]);
+        } else {
+          messageString = addonsBundle.getString("addonsGenericInstalled");
+          messageString = PluralForm.get(numAddons, messageString);
+          messageString = messageString.replace("#1", numAddons);
+        }
+        action = null;
+
+        options.removeOnDismissal = true;
+        options.persistent = false;
+
+        showNotification(browser, notificationID, messageString, anchorID,
+                         action, secondaryActions, options);
+        break;
+      }
+    }
+  },
+  _removeProgressNotification(browser) {
+    let notification = getNotification("addon-progress", browser);
+    if (notification) {
+      notification.remove();
+    }
+  },
+};
+
+Services.obs.addObserver(gXPInstallObserver, "addon-install-disabled");
+Services.obs.addObserver(gXPInstallObserver, "addon-install-origin-blocked");
+Services.obs.addObserver(gXPInstallObserver, "addon-install-blocked");
+Services.obs.addObserver(gXPInstallObserver, "addon-install-started");
+Services.obs.addObserver(gXPInstallObserver, "addon-install-failed");
+Services.obs.addObserver(gXPInstallObserver, "addon-install-confirmation");
+Services.obs.addObserver(gXPInstallObserver, "addon-install-complete");
--- a/mail/base/modules/moz.build
+++ b/mail/base/modules/moz.build
@@ -2,16 +2,17 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 EXTRA_JS_MODULES += [
     'AttachmentChecker.jsm',
     'DBViewWrapper.jsm',
     'DisplayNameUtils.jsm',
+    'ExtensionsUI.jsm',
     'MailConsts.jsm',
     'MailInstrumentation.jsm',
     'MailMigrator.jsm',
     'MailUtils.js',
     'MailUtils.jsm',
     'MailViewManager.jsm',
     'MsgHdrSyntheticView.jsm',
     'QuickFilterManager.jsm',
new file mode 100644
--- /dev/null
+++ b/mail/locales/en-US/chrome/messenger/addons.properties
@@ -0,0 +1,77 @@
+xpinstallPromptMessage=%S prevented this site from asking you to install software on your computer.
+xpinstallPromptMessage.dontAllow=Don’t Allow
+xpinstallPromptMessage.dontAllow.accesskey=D
+xpinstallPromptAllowButton=Allow
+# Accessibility Note:
+# Be sure you do not choose an accesskey that is used elsewhere in the active context (e.g. main menu bar, submenu of the warning popup button)
+# See http://www.mozilla.org/access/keyboard/accesskey for details
+xpinstallPromptAllowButton.accesskey=A
+xpinstallDisabledMessageLocked=Software installation has been disabled by your system administrator.
+xpinstallDisabledMessage=Software installation is currently disabled. Click Enable and try again.
+xpinstallDisabledButton=Enable
+xpinstallDisabledButton.accesskey=n
+
+# LOCALIZATION NOTE (addonPostInstall.message1)
+# %1$S is replaced with the localized named of the extension that was
+# just installed.
+# %2$S is replaced with the localized name of the application.
+addonPostInstall.message1=%1$S has been added to %2$S.
+addonPostInstall.okay.label=OK
+addonPostInstall.okay.key=O
+
+# LOCALIZATION NOTE (addonDownloadingAndVerifying):
+# Semicolon-separated list of plural forms. See:
+# http://developer.mozilla.org/en/docs/Localization_and_Plurals
+# Also see https://bugzilla.mozilla.org/show_bug.cgi?id=570012 for mockups
+addonDownloadingAndVerifying=Downloading and verifying add-on…;Downloading and verifying #1 add-ons…
+addonDownloadVerifying=Verifying
+
+addonInstall.unsigned=(Unverified)
+addonInstall.cancelButton.label=Cancel
+addonInstall.cancelButton.accesskey=C
+addonInstall.acceptButton2.label=Add
+addonInstall.acceptButton2.accesskey=A
+
+# LOCALIZATION NOTE (addonConfirmInstallMessage,addonConfirmInstallUnsigned):
+# Semicolon-separated list of plural forms. See:
+# http://developer.mozilla.org/en/docs/Localization_and_Plurals
+# #1 is brandShortName
+# #2 is the number of add-ons being installed
+addonConfirmInstall.message=This site would like to install an add-on in #1:;This site would like to install #2 add-ons in #1:
+addonConfirmInstallUnsigned.message=Caution: This site would like to install an unverified add-on in #1. Proceed at your own risk.;Caution: This site would like to install #2 unverified add-ons in #1. Proceed at your own risk.
+
+# LOCALIZATION NOTE (addonConfirmInstallSomeUnsigned.message):
+# Semicolon-separated list of plural forms. See:
+# http://developer.mozilla.org/en/docs/Localization_and_Plurals
+# #1 is brandShortName
+# #2 is the total number of add-ons being installed (at least 2)
+addonConfirmInstallSomeUnsigned.message=;Caution: This site would like to install #2 add-ons in #1, some of which are unverified. Proceed at your own risk.
+
+# LOCALIZATION NOTE (addonInstalled):
+# %S is the name of the add-on
+addonInstalled=%S has been installed successfully.
+# LOCALIZATION NOTE (addonsGenericInstalled):
+# Semicolon-separated list of plural forms. See:
+# http://developer.mozilla.org/en/docs/Localization_and_Plurals
+# #1 number of add-ons
+addonsGenericInstalled=#1 add-on has been installed successfully.;#1 add-ons have been installed successfully.
+
+# LOCALIZATION NOTE (addonInstallError-1, addonInstallError-2, addonInstallError-3, addonInstallError-4, addonInstallError-5, addonLocalInstallError-1, addonLocalInstallError-2, addonLocalInstallError-3, addonLocalInstallError-4, addonLocalInstallError-5):
+# %1$S is the application name, %2$S is the add-on name
+addonInstallError-1=The add-on could not be downloaded because of a connection failure.
+addonInstallError-2=The add-on could not be installed because it does not match the add-on %1$S expected.
+addonInstallError-3=The add-on downloaded from this site could not be installed because it appears to be corrupt.
+addonInstallError-4=%2$S could not be installed because %1$S cannot modify the needed file.
+addonInstallError-5=%1$S has prevented this site from installing an unverified add-on.
+addonLocalInstallError-1=This add-on could not be installed because of a filesystem error.
+addonLocalInstallError-2=This add-on could not be installed because it does not match the add-on %1$S expected.
+addonLocalInstallError-3=This add-on could not be installed because it appears to be corrupt.
+addonLocalInstallError-4=%2$S could not be installed because %1$S cannot modify the needed file.
+addonLocalInstallError-5=This add-on could not be installed because it has not been verified.
+
+# LOCALIZATION NOTE (addonInstallErrorIncompatible):
+# %1$S is the application name, %2$S is the application version, %3$S is the add-on name
+addonInstallErrorIncompatible=%3$S could not be installed because it is not compatible with %1$S %2$S.
+
+# LOCALIZATION NOTE (addonInstallErrorBlocklisted): %S is add-on name
+addonInstallErrorBlocklisted=%S could not be installed because it has a high risk of causing stability or security problems.
--- a/mail/locales/en-US/chrome/messenger/messenger.properties
+++ b/mail/locales/en-US/chrome/messenger/messenger.properties
@@ -646,77 +646,16 @@ headerccFieldMe=Me
 headerbccFieldMe=Me
 
 expandAttachmentPaneTooltip=Show the attachment pane
 collapseAttachmentPaneTooltip=Hide the attachment pane
 
 # Shown when content tabs are being loaded.
 loadingTab=Loading…
 
-# LOCALIZATION NOTE (xpinstallPromptWarning):
-# %1$S is replaced by brandShortName, %2$S is replaced by the host name of the
-# site.
-xpinstallPromptWarning=%1$S prevented the site (%2$S) from asking you to install software on your computer.
-xpinstallPromptAllowButton=Allow
-# LOCALIZATION NOTE (xpinstallPromptAllowButton.accesskey):
-# Accessibility Note:
-# Be sure you do not choose an accesskey that is used elsewhere in the active
-# context (e.g. main menu bar, submenu of the warning popup button).
-# See https://www.mozilla.org/access/keyboard/accesskey for details
-xpinstallPromptAllowButton.accesskey=A
-
-xpinstallDisabledMessageLocked=Software installation has been disabled by your system administrator.
-xpinstallDisabledMessage=Software installation is currently disabled. Click Enable and try again.
-xpinstallDisabledButton=Enable
-xpinstallDisabledButton.accesskey=n
-
-# LOCALIZATION NOTE (addonsInstalled, addonsInstalledNeedsRestart):
-# Semi-colon list of plural forms. See:
-# https://developer.mozilla.org/en/docs/Localization_and_Plurals
-# #1 first add-on's name, #2 number of add-ons, #3 application name
-addonsInstalled=#1 has been installed successfully.;#2 add-ons have been installed successfully.
-addonsInstalledNeedsRestart=#1 will be installed after you restart #3.;#2 add-ons will be installed after you restart #3.
-addonInstallRestartButton=Restart Now
-addonInstallRestartButton.accesskey=R
-addonInstallManage=Open Add-ons Manager
-addonInstallManage.accesskey=O
-
-# LOCALIZATION NOTE (addonConfirmInstallMessage):
-# Semicolon-separated list of plural forms. See:
-# http://developer.mozilla.org/en/docs/Localization_and_Plurals
-# #1 is brandShortName
-# #2 is the number of add-ons being installed
-addonConfirmInstall.message=Allow the installation of the following add-on in #1:;Allow the installation of the following #2 add-ons in #1:
-addonConfirmInstall.cancelButton.label=Cancel
-addonConfirmInstall.cancelButton.accesskey=C
-addonConfirmInstall.installButton.label=Install
-addonConfirmInstall.installButton.accesskey=I
-
-# LOCALIZATION NOTE (addonError-1, addonError-2, addonError-3, addonError-4):
-# #1 is the add-on name, #2 is the host name, #3 is the application name
-# #4 is the application version
-addonError-1=The add-on could not be downloaded because of a connection failure on #2.
-addonError-2=The add-on from #2 could not be installed because it does not match the add-on #3 expected.
-addonError-3=The add-on downloaded from #2 could not be installed because it appears to be corrupt.
-addonError-4=#1 could not be installed because #3 cannot modify the needed file.
-
-# LOCALIZATION NOTE (addonLocalError-1, addonLocalError-2, addonLocalError-3, addonLocalError-4, addonErrorIncompatible, addonErrorBlocklisted):
-# #1 is the add-on name, #3 is the application name, #4 is the application version
-addonLocalError-1=This add-on could not be installed because of a filesystem error.
-addonLocalError-2=This add-on could not be installed because it does not match the add-on #3 expected.
-addonLocalError-3=This add-on could not be installed because it appears to be corrupt.
-addonLocalError-4=#1 could not be installed because #3 cannot modify the needed file.
-addonErrorIncompatible=#1 could not be installed because it is not compatible with #3 #4.
-addonErrorBlocklisted=#1 could not be installed because it has a high risk of causing stability or security problems.
-
-# LOCALIZATION NOTE (addonErrorLegacy, addonLocalErrorLegacy):
-# #3 is the application name, #4 is the application version
-addonErrorLegacy=The add-on could not be installed because it is not compatible with #3 #4.
-addonLocalErrorLegacy=This add-on could not be installed because it is not compatible with #3 #4.
-
 confirmMsgDelete.title=Confirm Deletion
 confirmMsgDelete.collapsed.desc=This will delete messages in collapsed threads. Are you sure you want to continue?
 confirmMsgDelete.deleteNoTrash.desc=This will delete messages immediately, without saving a copy to Trash. Are you sure you want to continue?
 confirmMsgDelete.deleteFromTrash.desc=This will permanently delete messages from Trash. Are you sure you want to continue?
 confirmMsgDelete.dontAsk.label=Don't ask me again.
 confirmMsgDelete.delete.label=Delete
 
 mailServerLoginFailedTitle=Login Failed
--- a/mail/locales/jar.mn
+++ b/mail/locales/jar.mn
@@ -22,16 +22,17 @@
   locale/@AB_CD@/messenger/aboutRights.properties                       (%chrome/messenger/aboutRights.properties)
   locale/@AB_CD@/messenger/aboutSupportMail.dtd                         (%chrome/messenger/aboutSupportMail.dtd)
   locale/@AB_CD@/messenger/aboutSupportMail.properties                  (%chrome/messenger/aboutSupportMail.properties)
   locale/@AB_CD@/messenger/telemetry.properties                         (%chrome/messenger/telemetry.properties)
   locale/@AB_CD@/messenger/accountCreation.dtd                          (%chrome/messenger/accountCreation.dtd)
   locale/@AB_CD@/messenger/accountCreation.properties                   (%chrome/messenger/accountCreation.properties)
   locale/@AB_CD@/messenger/accountCreationModel.properties              (%chrome/messenger/accountCreationModel.properties)
   locale/@AB_CD@/messenger/accountCreationUtil.properties               (%chrome/messenger/accountCreationUtil.properties)
+  locale/@AB_CD@/messenger/addons.properties                            (%chrome/messenger/addons.properties)
   locale/@AB_CD@/messenger/charsetTitles.properties                     (%chrome/messenger/charsetTitles.properties)
   locale/@AB_CD@/messenger/customizeToolbar.dtd                         (%chrome/messenger/customizeToolbar.dtd)
   locale/@AB_CD@/messenger/customizeToolbar.properties                  (%chrome/messenger/customizeToolbar.properties)
   locale/@AB_CD@/messenger/viewSource.dtd                               (%chrome/messenger/viewSource.dtd)
   locale/@AB_CD@/messenger/viewSource.properties                        (%chrome/messenger/viewSource.properties)
   locale/@AB_CD@/messenger/datetimepicker.dtd                           (%chrome/messenger/datetimepicker.dtd)
   locale/@AB_CD@/messenger/systemIntegrationDialog.dtd                  (%chrome/messenger/systemIntegrationDialog.dtd)
   locale/@AB_CD@/messenger/virtualFolderProperties.dtd                  (%chrome/messenger/virtualFolderProperties.dtd)
--- a/mail/themes/shared/mail/messenger.css
+++ b/mail/themes/shared/mail/messenger.css
@@ -29,16 +29,24 @@
 description.error {
   color: #f00;
 }
 
 toolbar[printpreview="true"] {
   -moz-binding: url("chrome://global/content/printPreviewBindings.xml#printpreviewtoolbar");
 }
 
+#notification-popup-box > image.notification-anchor-icon {
+  width: 16px;
+  height: 16px;
+  -moz-context-properties: fill, fill-opacity;
+  fill: var(--lwt-toolbarbutton-icon-fill, currentColor);
+  fill-opacity: var(--toolbarbutton-icon-fill-opacity);
+}
+
 #tabbar-toolbar {
   -moz-appearance: none;
   padding: 0;
 }
 
 #tabbar-toolbar[customizing="true"] {
   min-width: 16px;
   min-height: 10px;