Bug 1616143 - Allow setting of properties per tab on action buttons. r=mkmelin
authorGeoff Lankow <geoff@darktrojan.net>
Mon, 17 Feb 2020 23:23:30 +1300
changeset 37391 9fb8ea5699c1c8c1d97084a109b395df11a44d71
parent 37390 75a7f1992cd1c95c44de3e157821e0dc15ebbd93
child 37392 231dc57f37045ab32c675e43a8058438d211129d
push id2566
push userclokep@gmail.com
push dateMon, 09 Mar 2020 19:20:31 +0000
treeherdercomm-beta@a352facfa0a4 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmkmelin
bugs1616143
Bug 1616143 - Allow setting of properties per tab on action buttons. r=mkmelin
mail/components/extensions/ExtensionToolbarButtons.jsm
mail/components/extensions/parent/ext-browserAction.js
mail/components/extensions/parent/ext-composeAction.js
mail/components/extensions/parent/ext-mail.js
mail/components/extensions/parent/ext-messageDisplayAction.js
mail/components/extensions/test/browser/browser.ini
mail/components/extensions/test/browser/browser_ext_browserAction_properties.js
mail/components/extensions/test/browser/browser_ext_composeAction_properties.js
mail/components/extensions/test/browser/browser_ext_messageDisplayAction_properties.js
--- a/mail/components/extensions/ExtensionToolbarButtons.jsm
+++ b/mail/components/extensions/ExtensionToolbarButtons.jsm
@@ -38,16 +38,24 @@ var { DefaultWeakMap, ExtensionError } =
 const { XPCOMUtils } = ChromeUtils.import(
   "resource://gre/modules/XPCOMUtils.jsm"
 );
 XPCOMUtils.defineLazyGlobalGetters(this, ["InspectorUtils"]);
 
 var DEFAULT_ICON = "chrome://messenger/content/extension.svg";
 
 this.ToolbarButtonAPI = class extends ExtensionAPI {
+  constructor(extension, global) {
+    super(extension);
+    this.global = global;
+    this.tabContext = new this.global.TabContext(target =>
+      this.getContextData(null)
+    );
+  }
+
   /**
    * Called when the extension is enabled.
    *
    * @param {String} entryName
    *        The name of the property in the extension manifest
    */
   async onManifestEntry(entryName) {
     let { extension } = this;
@@ -230,18 +238,19 @@ this.ToolbarButtonAPI = class extends Ex
    * This has no effect if the browser action is disabled for, or not
    * present in, the given window.
    *
    * @param {Window} window
    */
   async triggerAction(window) {
     let { document } = window;
     let button = document.getElementById(this.id);
-    let popupURL = this.getProperty(this.globals, "popup");
-    let enabled = this.getProperty(this.globals, "enabled");
+    let { popup: popupURL, enabled } = this.getContextData(
+      this.getTargetFromWindow(window)
+    );
 
     if (button && popupURL && enabled) {
       let popup =
         ViewPopup.for(this.extension, window) ||
         this.getPopup(window, popupURL);
       popup.viewNode.openPopup(button, "bottomcenter topleft", 0, 0);
     } else {
       this.emit("click", window);
@@ -257,16 +266,19 @@ this.ToolbarButtonAPI = class extends Ex
     let window = event.target.ownerGlobal;
 
     switch (event.type) {
       case "mousedown":
         if (event.button == 0) {
           this.triggerAction(window);
         }
         break;
+      case "TabSelect":
+        this.updateWindow(window);
+        break;
     }
   }
 
   /**
    * Returns a potentially pre-loaded popup for the given URL in the given
    * window. If a matching pre-load popup already exists, returns that.
    * Otherwise, initializes a new one.
    *
@@ -423,95 +435,116 @@ this.ToolbarButtonAPI = class extends Ex
    * Update the toolbar button for a given window.
    *
    * @param {ChromeWindow} window
    *        Browser chrome window.
    */
   async updateWindow(window) {
     let button = window.document.getElementById(this.id);
     if (button) {
-      this.updateButton(button, this.globals);
+      this.updateButton(
+        button,
+        this.getContextData(this.getTargetFromWindow(window))
+      );
     }
     await new Promise(window.requestAnimationFrame);
   }
 
   /**
    * Update the toolbar button when the extension changes the icon, title, url, etc.
    * If it only changes a parameter for a single tab, `target` will be that tab.
    * If it only changes a parameter for a single window, `target` will be that window.
    * Otherwise `target` will be null.
    *
    * @param {XULElement|ChromeWindow|null} target
    *        Browser tab or browser chrome window, may be null.
    */
   async updateOnChange(target) {
     if (target) {
-      let window = target.ownerGlobal;
+      let window = Cu.getGlobalForObject(target);
       if (target === window || target.selected) {
         await this.updateWindow(window);
       }
     } else {
       let promises = [];
       for (let window of ExtensionSupport.openWindows) {
         if (this.windowURLs.includes(window.location.href)) {
           promises.push(this.updateWindow(window));
         }
       }
       await Promise.all(promises);
     }
   }
 
   /**
-   * Gets the target object and its associated values corresponding to
-   * the `details` parameter of the various get* and set* API methods.
+   * Gets the active tab of the passed window if the window has tabs, or the
+   * window itself.
+   *
+   * @param {ChromeWindow} window
+   * @returns {XULElement|ChromeWindow}
+   */
+  getTargetFromWindow(window) {
+    let tabmail = window.document.getElementById("tabmail");
+    if (tabmail) {
+      return tabmail.currentTabInfo;
+    }
+    return window;
+  }
+
+  /**
+   * Gets the target object corresponding to the `details` parameter of the various
+   * get* and set* API methods.
    *
    * @param {Object} details
    *        An object with optional `tabId` or `windowId` properties.
-   * @throws if both `tabId` and `windowId` are specified, or if they are invalid.
-   * @returns {Object}
-   *        An object with two properties: `target` and `values`.
-   *        - If a `tabId` was specified, `target` will be the corresponding
-   *          XULElement tab. If a `windowId` was specified, `target` will be
-   *          the corresponding ChromeWindow. Otherwise it will be `null`.
-   *        - `values` will contain the icon, title, badge, etc. associated with
-   *          the target.
+   * @throws if `windowId` is specified, this is not valid in Thunderbird.
+   * @returns {XULElement|ChromeWindow|null}
+   *        If a `tabId` was specified, the corresponding XULElement tab.
+   *        If a `windowId` was specified, the corresponding ChromeWindow.
+   *        Otherwise, `null`.
    */
-  getContextData({ tabId, windowId }) {
-    if (tabId != null && windowId != null) {
-      throw new ExtensionError(
-        "Only one of tabId and windowId can be specified."
-      );
+  getTargetFromDetails({ tabId, windowId }) {
+    if (windowId != null) {
+      throw new ExtensionError("windowId is not allowed, use tabId instead.");
+    }
+    if (tabId != null) {
+      return this.global.tabTracker.getTab(tabId);
     }
-    let target, values;
-    // if (tabId != null) {
-    //   target = tabTracker.getTab(tabId);
-    //   values = this.tabContext.get(target);
-    // } else if (windowId != null) {
-    //   target = windowTracker.getWindow(windowId);
-    //   values = this.tabContext.get(target);
-    // } else {
-    target = null;
-    values = this.globals;
-    // }
-    return { target, values };
+    return null;
+  }
+
+  /**
+   * Gets the data associated with a tab, window, or the global one.
+   *
+   * @param {XULElement|ChromeWindow|null} target
+   *        A XULElement tab, a ChromeWindow, or null for the global data.
+   * @returns {Object}
+   *        The icon, title, badge, etc. associated with the target.
+   */
+  getContextData(target) {
+    if (target) {
+      return this.tabContext.get(target);
+    }
+    return this.globals;
   }
 
   /**
    * Set a global, window specific or tab specific property.
    *
    * @param {Object} details
    *        An object with optional `tabId` or `windowId` properties.
    * @param {string} prop
    *        String property to set. Should should be one of "icon", "title",
    *        "badgeText", "popup", "badgeBackgroundColor" or "enabled".
    * @param {string} value
    *        Value for prop.
    */
   async setProperty(details, prop, value) {
-    let { target, values } = this.getContextData(details);
+    let target = this.getTargetFromDetails(details);
+    let values = this.getContextData(target);
     if (value === null) {
       delete values[prop];
     } else {
       values[prop] = value;
     }
 
     await this.updateOnChange(target);
   }
@@ -523,17 +556,17 @@ this.ToolbarButtonAPI = class extends Ex
    *        An object with optional `tabId` or `windowId` properties.
    * @param {string} prop
    *        String property to retrieve. Should should be one of "icon", "title",
    *        "badgeText", "popup", "badgeBackgroundColor" or "enabled".
    * @returns {string} value
    *          Value of prop.
    */
   getProperty(details, prop) {
-    return this.getContextData(details).values[prop];
+    return this.getContextData(this.getTargetFromDetails(details))[prop];
   }
 
   /**
    * WebExtension API.
    *
    * @param {Object} context
    */
   getAPI(context) {
--- a/mail/components/extensions/parent/ext-browserAction.js
+++ b/mail/components/extensions/parent/ext-browserAction.js
@@ -18,16 +18,17 @@ this.browserAction = class extends Toolb
   async onManifestEntry(entryName) {
     await super.onManifestEntry(entryName);
     browserActionMap.set(this.extension, this);
   }
 
   close() {
     super.close();
     browserActionMap.delete(this.extension);
+    windowTracker.removeListener("TabSelect", this);
   }
 
   static onUninstall(extensionId) {
     let widgetId = makeWidgetId(extensionId);
     let id = `${widgetId}-browserAction-toolbarbutton`;
 
     let windowURL = "chrome://messenger/content/messenger.xhtml";
     let currentSet = Services.xulStore.getValue(
@@ -44,19 +45,20 @@ this.browserAction = class extends Toolb
         "mail-bar3",
         "currentset",
         currentSet.join(",")
       );
     }
   }
 
   constructor(extension) {
-    super(extension);
+    super(extension, global);
     this.manifest_name = "browser_action";
     this.manifestName = "browserAction";
     this.windowURLs = ["chrome://messenger/content/messenger.xhtml"];
     this.toolboxId = "mail-toolbox";
     this.toolbarId = "mail-bar3";
-    this.global = global;
+
+    windowTracker.addListener("TabSelect", this);
   }
 };
 
 global.browserActionFor = this.browserAction.for;
--- a/mail/components/extensions/parent/ext-composeAction.js
+++ b/mail/components/extensions/parent/ext-composeAction.js
@@ -5,33 +5,31 @@
 ChromeUtils.defineModuleGetter(
   this,
   "ToolbarButtonAPI",
   "resource:///modules/ExtensionToolbarButtons.jsm"
 );
 
 this.composeAction = class extends ToolbarButtonAPI {
   constructor(extension) {
-    super(extension);
+    super(extension, global);
     this.manifest_name = "compose_action";
     this.manifestName = "composeAction";
     this.windowURLs = [
       "chrome://messenger/content/messengercompose/messengercompose.xhtml",
     ];
 
     let format =
       extension.manifest.compose_action.default_area == "formattoolbar";
     this.toolboxId = format ? "FormatToolbox" : "compose-toolbox";
     this.toolbarId = format ? "FormatToolbar" : "composeToolbar2";
 
     if (format) {
       this.paint = this.paintFormatToolbar;
     }
-
-    this.global = global;
   }
 
   paintFormatToolbar(window) {
     let { document } = window;
     if (document.getElementById(this.id)) {
       return;
     }
 
--- a/mail/components/extensions/parent/ext-mail.js
+++ b/mail/components/extensions/parent/ext-mail.js
@@ -159,16 +159,60 @@ function getTabBrowser(nativeTabInfo) {
   if (nativeTabInfo.ownerGlobal && nativeTabInfo.ownerGlobal.getBrowser) {
     return nativeTabInfo.ownerGlobal.getBrowser();
   }
 
   return null;
 }
 global.getTabBrowser = getTabBrowser;
 
+/**
+ * Manages tab-specific and window-specific context data, and dispatches
+ * tab select events across all windows.
+ */
+global.TabContext = class extends EventEmitter {
+  /**
+   * @param {Function} getDefaultPrototype
+   *        Provides the prototype of the context value for a tab or window when there is none.
+   *        Called with a XULElement or ChromeWindow argument.
+   *        Should return an object or null.
+   */
+  constructor(getDefaultPrototype) {
+    super();
+    this.getDefaultPrototype = getDefaultPrototype;
+    this.tabData = new WeakMap();
+  }
+
+  /**
+   * Returns the context data associated with `keyObject`.
+   *
+   * @param {XULElement|ChromeWindow} keyObject
+   *        Browser tab or browser chrome window.
+   * @returns {Object}
+   */
+  get(keyObject) {
+    if (!this.tabData.has(keyObject)) {
+      let data = Object.create(this.getDefaultPrototype(keyObject));
+      this.tabData.set(keyObject, data);
+    }
+
+    return this.tabData.get(keyObject);
+  }
+
+  /**
+   * Clears the context data associated with `keyObject`.
+   *
+   * @param {XULElement|ChromeWindow} keyObject
+   *        Browser tab or browser chrome window.
+   */
+  clear(keyObject) {
+    this.tabData.delete(keyObject);
+  }
+};
+
 /* global searchInitialized */
 // This promise is used to wait for the search service to be initialized.
 // None of the code in the WebExtension modules requests that initialization.
 // It is assumed that it is started at some point. That might never happen,
 // e.g. if the application shuts down before the search service initializes.
 XPCOMUtils.defineLazyGetter(global, "searchInitialized", () => {
   if (Services.search.isInitialized) {
     return Promise.resolve();
--- a/mail/components/extensions/parent/ext-messageDisplayAction.js
+++ b/mail/components/extensions/parent/ext-messageDisplayAction.js
@@ -20,28 +20,31 @@ this.messageDisplayAction = class extend
   async onManifestEntry(entryName) {
     await super.onManifestEntry(entryName);
     messageDisplayActionMap.set(this.extension, this);
   }
 
   close() {
     super.close();
     messageDisplayActionMap.delete(this.extension);
+    windowTracker.removeListener("TabSelect", this);
   }
 
   constructor(extension) {
-    super(extension);
+    super(extension, global);
     this.manifest_name = "message_display_action";
     this.manifestName = "messageDisplayAction";
     this.windowURLs = [
       "chrome://messenger/content/messenger.xhtml",
       "chrome://messenger/content/messageWindow.xhtml",
     ];
     this.toolboxId = "header-view-toolbox";
     this.toolbarId = "header-view-toolbar";
+
+    windowTracker.addListener("TabSelect", this);
   }
 
   makeButton(window) {
     let button = super.makeButton(window);
     button.classList.add("msgHeaderView-button");
     button.style.listStyleImage = "var(--webextension-menupanel-image)";
     return button;
   }
--- a/mail/components/extensions/test/browser/browser.ini
+++ b/mail/components/extensions/test/browser/browser.ini
@@ -11,26 +11,29 @@ prefs =
   mailnews.database.global.indexer.enabled=false
   mailnews.start_page.override_url=about:blank
   mailnews.start_page.url=about:blank
 subsuite = thunderbird
 tags = webextensions
 
 [browser_ext_addressBooksUI.js]
 [browser_ext_browserAction.js]
+[browser_ext_browserAction_properties.js]
 [browser_ext_commands_execute_browser_action.js]
 [browser_ext_commands_getAll.js]
 [browser_ext_commands_onCommand.js]
 [browser_ext_commands_update.js]
 [browser_ext_compose_begin.js]
 [browser_ext_compose_details.js]
 [browser_ext_compose_onBeforeSend.js]
 [browser_ext_composeAction.js]
+[browser_ext_composeAction_properties.js]
 [browser_ext_mailTabs.js]
 [browser_ext_menus.js]
 support-files = data/content.html
 [browser_ext_menus_replace_menu.js]
 [browser_ext_menus_replace_menu_context.js]
 [browser_ext_messageDisplay.js]
 [browser_ext_messageDisplayAction.js]
+[browser_ext_messageDisplayAction_properties.js]
 [browser_ext_quickFilter.js]
 [browser_ext_windows.js]
 [browser_ext_windows_types.js]
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/test/browser/browser_ext_browserAction_properties.js
@@ -0,0 +1,136 @@
+/* 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/. */
+
+add_task(async () => {
+  let account = createAccount();
+  addIdentity(account);
+  let rootFolder = account.incomingServer.rootFolder;
+
+  window.gFolderTreeView.selectFolder(rootFolder);
+  await new Promise(resolve => executeSoon(resolve));
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background: async () => {
+      async function checkProperty(property, expectedDefault, ...expected) {
+        browser.test.log(
+          `${property}: ${expectedDefault}, ${expected.join(", ")}`
+        );
+
+        browser.test.assertEq(
+          expectedDefault,
+          await browser.browserAction[property]({})
+        );
+        for (let i = 0; i < 3; i++) {
+          browser.test.assertEq(
+            expected[i],
+            await browser.browserAction[property]({ tabId: tabIDs[i] })
+          );
+        }
+
+        await new Promise(resolve => {
+          browser.test.onMessage.addListener(function listener() {
+            browser.test.onMessage.removeListener(listener);
+            resolve();
+          });
+          browser.test.sendMessage("checkProperty", property, expected);
+        });
+      }
+
+      let tabs = await browser.mailTabs.query({});
+      let tabIDs = tabs.map(t => t.id);
+
+      await checkProperty("isEnabled", true, true, true, true);
+      await browser.browserAction.disable();
+      await checkProperty("isEnabled", false, false, false, false);
+      await browser.browserAction.enable(tabIDs[0]);
+      await checkProperty("isEnabled", false, true, false, false);
+      await browser.browserAction.enable();
+      await checkProperty("isEnabled", true, true, true, true);
+      await browser.browserAction.disable();
+      await checkProperty("isEnabled", false, true, false, false);
+      await browser.browserAction.disable(tabIDs[0]);
+      await checkProperty("isEnabled", false, false, false, false);
+      await browser.browserAction.enable();
+      await checkProperty("isEnabled", true, false, true, true);
+
+      await checkProperty(
+        "getTitle",
+        "default",
+        "default",
+        "default",
+        "default"
+      );
+      await browser.browserAction.setTitle({ tabId: tabIDs[2], title: "tab2" });
+      await checkProperty("getTitle", "default", "default", "default", "tab2");
+      await browser.browserAction.setTitle({ title: "new" });
+      await checkProperty("getTitle", "new", "new", "new", "tab2");
+      await browser.browserAction.setTitle({ tabId: tabIDs[1], title: "tab1" });
+      await checkProperty("getTitle", "new", "new", "tab1", "tab2");
+      await browser.browserAction.setTitle({ tabId: tabIDs[2], title: null });
+      await checkProperty("getTitle", "new", "new", "tab1", "new");
+      await browser.browserAction.setTitle({ title: null });
+      await checkProperty("getTitle", "default", "default", "tab1", "default");
+      await browser.browserAction.setTitle({ tabId: tabIDs[1], title: null });
+      await checkProperty(
+        "getTitle",
+        "default",
+        "default",
+        "default",
+        "default"
+      );
+
+      await browser.tabs.remove(tabIDs[0]);
+      await browser.tabs.remove(tabIDs[1]);
+      await browser.tabs.remove(tabIDs[2]);
+      browser.test.notifyPass("finished");
+    },
+    manifest: {
+      applications: {
+        gecko: {
+          id: "test1@mochi.test",
+        },
+      },
+      browser_action: {
+        default_title: "default",
+      },
+    },
+  });
+
+  await extension.startup();
+
+  let tabmail = document.getElementById("tabmail");
+  tabmail.openTab("folder", { folder: rootFolder, background: false });
+  tabmail.openTab("folder", { folder: rootFolder, background: false });
+
+  let mailTabs = tabmail.tabInfo;
+  is(mailTabs.length, 3);
+
+  let button = document.getElementById(
+    "test1_mochi_test-browserAction-toolbarbutton"
+  );
+
+  extension.onMessage("checkProperty", async (property, expected) => {
+    for (let i = 0; i < 3; i++) {
+      tabmail.switchToTab(mailTabs[i]);
+      await new Promise(resolve => requestAnimationFrame(resolve));
+      switch (property) {
+        case "isEnabled":
+          is(button.disabled, !expected[i], `button ${i} enabled state`);
+          break;
+        case "getTitle":
+          is(button.getAttribute("label"), expected[i], `button ${i} label`);
+          break;
+      }
+    }
+
+    extension.sendMessage();
+  });
+
+  await extension.awaitFinish("finished");
+  await extension.unload();
+
+  tabmail.closeTab(mailTabs[2]);
+  tabmail.closeTab(mailTabs[1]);
+  is(tabmail.tabInfo.length, 1);
+});
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/test/browser/browser_ext_composeAction_properties.js
@@ -0,0 +1,130 @@
+/* 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/. */
+
+add_task(async () => {
+  let account = createAccount();
+  addIdentity(account);
+  let rootFolder = account.incomingServer.rootFolder;
+
+  window.gFolderTreeView.selectFolder(rootFolder);
+  await new Promise(resolve => executeSoon(resolve));
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background: async () => {
+      async function checkProperty(property, expectedDefault, ...expected) {
+        browser.test.log(
+          `${property}: ${expectedDefault}, ${expected.join(", ")}`
+        );
+
+        browser.test.assertEq(
+          expectedDefault,
+          await browser.composeAction[property]({})
+        );
+        for (let i = 0; i < 3; i++) {
+          browser.test.assertEq(
+            expected[i],
+            await browser.composeAction[property]({ tabId: tabIDs[i] })
+          );
+        }
+
+        await new Promise(resolve => {
+          browser.test.onMessage.addListener(function listener() {
+            browser.test.onMessage.removeListener(listener);
+            resolve();
+          });
+          browser.test.sendMessage("checkProperty", property, expected);
+        });
+      }
+
+      await browser.compose.beginNew();
+      await browser.compose.beginNew();
+      await browser.compose.beginNew();
+      let windows = await browser.windows.getAll({
+        populate: true,
+        windowTypes: ["messageCompose"],
+      });
+      let tabIDs = windows.map(w => w.tabs[0].id);
+
+      await checkProperty("isEnabled", true, true, true, true);
+      await browser.composeAction.disable();
+      await checkProperty("isEnabled", false, false, false, false);
+      await browser.composeAction.enable(tabIDs[0]);
+      await checkProperty("isEnabled", false, true, false, false);
+      await browser.composeAction.enable();
+      await checkProperty("isEnabled", true, true, true, true);
+      await browser.composeAction.disable();
+      await checkProperty("isEnabled", false, true, false, false);
+      await browser.composeAction.disable(tabIDs[0]);
+      await checkProperty("isEnabled", false, false, false, false);
+      await browser.composeAction.enable();
+      await checkProperty("isEnabled", true, false, true, true);
+
+      await checkProperty(
+        "getTitle",
+        "default",
+        "default",
+        "default",
+        "default"
+      );
+      await browser.composeAction.setTitle({ tabId: tabIDs[2], title: "tab2" });
+      await checkProperty("getTitle", "default", "default", "default", "tab2");
+      await browser.composeAction.setTitle({ title: "new" });
+      await checkProperty("getTitle", "new", "new", "new", "tab2");
+      await browser.composeAction.setTitle({ tabId: tabIDs[1], title: "tab1" });
+      await checkProperty("getTitle", "new", "new", "tab1", "tab2");
+      await browser.composeAction.setTitle({ tabId: tabIDs[2], title: null });
+      await checkProperty("getTitle", "new", "new", "tab1", "new");
+      await browser.composeAction.setTitle({ title: null });
+      await checkProperty("getTitle", "default", "default", "tab1", "default");
+      await browser.composeAction.setTitle({ tabId: tabIDs[1], title: null });
+      await checkProperty(
+        "getTitle",
+        "default",
+        "default",
+        "default",
+        "default"
+      );
+
+      await browser.tabs.remove(tabIDs[0]);
+      await browser.tabs.remove(tabIDs[1]);
+      await browser.tabs.remove(tabIDs[2]);
+      browser.test.notifyPass("finished");
+    },
+    manifest: {
+      applications: {
+        gecko: {
+          id: "test1@mochi.test",
+        },
+      },
+      compose_action: {
+        default_title: "default",
+      },
+    },
+  });
+
+  extension.onMessage("checkProperty", async (property, expected) => {
+    let composeWindows = [...Services.wm.getEnumerator("msgcompose")];
+    is(composeWindows.length, 3);
+
+    for (let i = 0; i < 3; i++) {
+      let button = composeWindows[i].document.getElementById(
+        "test1_mochi_test-composeAction-toolbarbutton"
+      );
+      switch (property) {
+        case "isEnabled":
+          is(button.disabled, !expected[i], `button ${i} enabled state`);
+          break;
+        case "getTitle":
+          is(button.getAttribute("label"), expected[i], `button ${i} label`);
+          break;
+      }
+    }
+
+    extension.sendMessage();
+  });
+
+  await extension.startup();
+  await extension.awaitFinish("finished");
+  await extension.unload();
+});
new file mode 100644
--- /dev/null
+++ b/mail/components/extensions/test/browser/browser_ext_messageDisplayAction_properties.js
@@ -0,0 +1,174 @@
+/* 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/. */
+
+add_task(async () => {
+  let account = createAccount();
+  addIdentity(account);
+  let rootFolder = account.incomingServer.rootFolder;
+  rootFolder.createSubfolder("test", null);
+  let folder = rootFolder.getChildNamed("test");
+  createMessages(folder, 1);
+
+  window.gFolderTreeView.selectFolder(folder);
+  window.gFolderDisplay.selectViewIndex(0);
+
+  window.MsgOpenSelectedMessages();
+  window.MsgOpenNewWindowForMessage();
+  await new Promise(resolve => executeSoon(resolve));
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background: async () => {
+      async function checkProperty(property, expectedDefault, ...expected) {
+        browser.test.log(
+          `${property}: ${expectedDefault}, ${expected.join(", ")}`
+        );
+
+        browser.test.assertEq(
+          expectedDefault,
+          await browser.messageDisplayAction[property]({})
+        );
+        for (let i = 0; i < 3; i++) {
+          browser.test.assertEq(
+            expected[i],
+            await browser.messageDisplayAction[property]({ tabId: tabIDs[i] })
+          );
+        }
+
+        await new Promise(resolve => {
+          browser.test.onMessage.addListener(function listener() {
+            browser.test.onMessage.removeListener(listener);
+            resolve();
+          });
+          browser.test.sendMessage("checkProperty", property, expected);
+        });
+      }
+
+      let tabs = await browser.tabs.query({});
+      browser.test.assertEq(3, tabs.length);
+      let tabIDs = tabs.map(t => t.id);
+
+      await checkProperty("isEnabled", true, true, true, true);
+      await browser.messageDisplayAction.disable();
+      await checkProperty("isEnabled", false, false, false, false);
+      await browser.messageDisplayAction.enable(tabIDs[0]);
+      await checkProperty("isEnabled", false, true, false, false);
+      await browser.messageDisplayAction.enable();
+      await checkProperty("isEnabled", true, true, true, true);
+      await browser.messageDisplayAction.disable();
+      await checkProperty("isEnabled", false, true, false, false);
+      await browser.messageDisplayAction.disable(tabIDs[0]);
+      await checkProperty("isEnabled", false, false, false, false);
+      await browser.messageDisplayAction.enable();
+      await checkProperty("isEnabled", true, false, true, true);
+
+      await checkProperty(
+        "getTitle",
+        "default",
+        "default",
+        "default",
+        "default"
+      );
+      await browser.messageDisplayAction.setTitle({
+        tabId: tabIDs[2],
+        title: "tab2",
+      });
+      await checkProperty("getTitle", "default", "default", "default", "tab2");
+      await browser.messageDisplayAction.setTitle({ title: "new" });
+      await checkProperty("getTitle", "new", "new", "new", "tab2");
+      await browser.messageDisplayAction.setTitle({
+        tabId: tabIDs[1],
+        title: "tab1",
+      });
+      await checkProperty("getTitle", "new", "new", "tab1", "tab2");
+      await browser.messageDisplayAction.setTitle({
+        tabId: tabIDs[2],
+        title: null,
+      });
+      await checkProperty("getTitle", "new", "new", "tab1", "new");
+      await browser.messageDisplayAction.setTitle({ title: null });
+      await checkProperty("getTitle", "default", "default", "tab1", "default");
+      await browser.messageDisplayAction.setTitle({
+        tabId: tabIDs[1],
+        title: null,
+      });
+      await checkProperty(
+        "getTitle",
+        "default",
+        "default",
+        "default",
+        "default"
+      );
+
+      await browser.tabs.remove(tabIDs[0]);
+      await browser.tabs.remove(tabIDs[1]);
+      await browser.tabs.remove(tabIDs[2]);
+      browser.test.notifyPass("finished");
+    },
+    manifest: {
+      applications: {
+        gecko: {
+          id: "test1@mochi.test",
+        },
+      },
+      message_display_action: {
+        default_title: "default",
+      },
+    },
+  });
+
+  await extension.startup();
+
+  let tabmail = document.getElementById("tabmail");
+  let mainWindowTabs = tabmail.tabInfo;
+  is(mainWindowTabs.length, 2);
+
+  let mainWindowButton = document.getElementById(
+    "test1_mochi_test-messageDisplayAction-toolbarbutton"
+  );
+
+  let messageWindow = Services.wm.getMostRecentWindow("mail:messageWindow");
+  let messageWindowButton = messageWindow.document.getElementById(
+    "test1_mochi_test-messageDisplayAction-toolbarbutton"
+  );
+
+  extension.onMessage("checkProperty", async (property, expected) => {
+    function checkButton(button, expectedIndex) {
+      switch (property) {
+        case "isEnabled":
+          is(
+            button.disabled,
+            !expected[expectedIndex],
+            `button ${expectedIndex} enabled state`
+          );
+          break;
+        case "getTitle":
+          is(
+            button.getAttribute("label"),
+            expected[expectedIndex],
+            `button ${expectedIndex} label`
+          );
+          break;
+      }
+    }
+
+    for (let i = 0; i < 2; i++) {
+      tabmail.switchToTab(mainWindowTabs[i]);
+      await new Promise(resolve => requestAnimationFrame(resolve));
+      checkButton(mainWindowButton, i);
+    }
+    checkButton(messageWindowButton, 2);
+
+    extension.sendMessage();
+  });
+
+  await extension.awaitFinish("finished");
+  await extension.unload();
+
+  messageWindow.close();
+  tabmail.closeTab(mainWindowTabs[1]);
+  is(tabmail.tabInfo.length, 1);
+
+  document.getElementById("folderTree").focus();
+  await new Promise(resolve => executeSoon(resolve));
+});