Bug 1381992 - Add some reader mode support to the tabs API, r=mixedpuppy
☠☠ backed out by a97948d297e2 ☠ ☠
authorBob Silverberg <bsilverberg@mozilla.com>
Fri, 08 Sep 2017 17:00:27 -0400
changeset 433503 4e174306e0b012c57ac9c92bd29189541bc2a39f
parent 433502 b50170822d21c1ea1a55a60579f94b4fa6d16f1a
child 433504 a140ff3dc67ff6d83a231afce5193001b105dcb3
push id8114
push userjlorenzo@mozilla.com
push dateThu, 02 Nov 2017 16:33:21 +0000
treeherdermozilla-beta@73e0d89a540f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmixedpuppy
bugs1381992
milestone58.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1381992 - Add some reader mode support to the tabs API, r=mixedpuppy This adds two properties to the Tab object: - isArticle indicates whether the document in the tab is likely able to be rendered in reader mode. - isInReaderMode indicates if the document in the tab is being rendered in reader mode. It also adds a toggleReaderMode() which toggles a tab into and out of reader mode. There is also a new case in which tabs.onUpdated will fire. When the isArticle status of a tab changes, an onUpdated event will fire with data {isArticle: boolean}. MozReview-Commit-ID: AaAQ0V5qm2Z
browser/components/extensions/ext-browser.js
browser/components/extensions/ext-tabs.js
browser/components/extensions/schemas/tabs.json
browser/components/extensions/test/browser/browser-common.ini
browser/components/extensions/test/browser/browser_ext_tabs_readerMode.js
browser/components/extensions/test/mochitest/test_ext_all_apis.html
toolkit/components/extensions/ext-tabs-base.js
--- a/browser/components/extensions/ext-browser.js
+++ b/browser/components/extensions/ext-browser.js
@@ -15,16 +15,18 @@
 XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
                                   "resource://gre/modules/PrivateBrowsingUtils.jsm");
 
 var {
   ExtensionError,
   defineLazyGetter,
 } = ExtensionUtils;
 
+const READER_MODE_PREFIX = "about:reader";
+
 let tabTracker;
 let windowTracker;
 
 // This function is pretty tightly tied to Extension.jsm.
 // Its job is to fill in the |tab| property of the sender.
 const getSender = (extension, target, sender) => {
   let tabId;
   if ("tabId" in sender) {
@@ -223,16 +225,18 @@ class TabTracker extends TabTrackerBase 
     this._handleWindowClose = this._handleWindowClose.bind(this);
 
     windowTracker.addListener("TabClose", this);
     windowTracker.addListener("TabOpen", this);
     windowTracker.addListener("TabSelect", this);
     windowTracker.addOpenListener(this._handleWindowOpen);
     windowTracker.addCloseListener(this._handleWindowClose);
 
+    Services.mm.addMessageListener("Reader:UpdateReaderButton", this);
+
     /* eslint-disable mozilla/balanced-listeners */
     this.on("tab-detached", this._handleTabDestroyed);
     this.on("tab-removed", this._handleTabDestroyed);
     /* eslint-enable mozilla/balanced-listeners */
   }
 
   getId(nativeTab) {
     if (this._tabs.has(nativeTab)) {
@@ -358,16 +362,31 @@ class TabTracker extends TabTrackerBase 
         Promise.resolve().then(() => {
           this.emitActivated(nativeTab);
         });
         break;
     }
   }
 
   /**
+   * @param {Object} message
+   *        The message to handle.
+   * @private
+   */
+  receiveMessage(message) {
+    switch (message.name) {
+      case "Reader:UpdateReaderButton":
+        if (message.data && message.data.isArticle !== undefined) {
+          this.emit("tab-isarticle", message);
+        }
+        break;
+    }
+  }
+
+  /**
    * A private method which is called whenever a new browser window is opened,
    * and dispatches the necessary events for it.
    *
    * @param {DOMWindow} window
    *        The window being opened.
    * @private
    */
   _handleWindowOpen(window) {
@@ -630,16 +649,24 @@ class Tab extends TabBase {
   get window() {
     return this.nativeTab.ownerGlobal;
   }
 
   get windowId() {
     return windowTracker.getId(this.window);
   }
 
+  get isArticle() {
+    return this.nativeTab.linkedBrowser.isArticle;
+  }
+
+  get isInReaderMode() {
+    return this.url && this.url.startsWith(READER_MODE_PREFIX);
+  }
+
   /**
    * Converts session store data to an object compatible with the return value
    * of the convert() method, representing that data.
    *
    * @param {Extension} extension
    *        The extension for which to convert the data.
    * @param {Object} tabData
    *        Session store data for a closed tab, as returned by
--- a/browser/components/extensions/ext-tabs.js
+++ b/browser/components/extensions/ext-tabs.js
@@ -12,16 +12,20 @@ XPCOMUtils.defineLazyGetter(this, "strBu
 
 XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
                                   "resource://gre/modules/PrivateBrowsingUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PromiseUtils",
                                   "resource://gre/modules/PromiseUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Services",
                                   "resource://gre/modules/Services.jsm");
 
+var {
+  ExtensionError,
+} = ExtensionUtils;
+
 let tabListener = {
   tabReadyInitialized: false,
   tabReadyPromises: new WeakMap(),
   initializingTabs: new WeakSet(),
 
   initTabReady() {
     if (!this.tabReadyInitialized) {
       windowTracker.addListener("progress", this);
@@ -284,28 +288,39 @@ this.tabs = class extends ExtensionAPI {
               if (url) {
                 changed.url = url;
               }
 
               fireForTab(tabManager.wrapTab(tabElem), changed);
             }
           };
 
+          let isArticleChangeListener = (eventName, event) => {
+            let {gBrowser} = event.target.ownerGlobal;
+            let tab = tabManager.getWrapper(
+              gBrowser.getTabForBrowser(event.target));
+
+            fireForTab(tab, {isArticle: event.data.isArticle});
+          };
+
           windowTracker.addListener("status", statusListener);
           windowTracker.addListener("TabAttrModified", listener);
           windowTracker.addListener("TabPinned", listener);
           windowTracker.addListener("TabUnpinned", listener);
           windowTracker.addListener("TabBrowserInserted", listener);
 
+          tabTracker.on("tab-isarticle", isArticleChangeListener);
+
           return () => {
             windowTracker.removeListener("status", statusListener);
             windowTracker.removeListener("TabAttrModified", listener);
             windowTracker.removeListener("TabPinned", listener);
             windowTracker.removeListener("TabUnpinned", listener);
             windowTracker.removeListener("TabBrowserInserted", listener);
+            tabTracker.off("tab-isarticle", isArticleChangeListener);
           };
         }).api(),
 
         create(createProperties) {
           return new Promise((resolve, reject) => {
             let window = createProperties.windowId !== null ?
               windowTracker.getWindow(createProperties.windowId, context) :
               windowTracker.topWindow;
@@ -908,13 +923,23 @@ this.tabs = class extends ExtensionAPI {
                 resolve(retval == 0 ? "saved" : "replaced");
               } else {
                 // Cancel clicked (retval == 1)
                 resolve("canceled");
               }
             });
           });
         },
+
+        async toggleReaderMode(tabId) {
+          let tab = await promiseTabWhenReady(tabId);
+          if (!tab.isInReaderMode && !tab.isArticle) {
+            throw new ExtensionError("The specified tab cannot be placed into reader mode.");
+          }
+          tab = getTabOrActive(tabId);
+
+          tab.linkedBrowser.messageManager.sendAsyncMessage("Reader:ToggleReaderMode");
+        },
       },
     };
     return self;
   }
 };
--- a/browser/components/extensions/schemas/tabs.json
+++ b/browser/components/extensions/schemas/tabs.json
@@ -71,17 +71,19 @@
           "title": {"type": "string", "optional": true, "permissions": ["tabs"], "description": "The title of the tab. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission."},
           "favIconUrl": {"type": "string", "optional": true, "permissions": ["tabs"], "description": "The URL of the tab's favicon. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission. It may also be an empty string if the tab is loading."},
           "status": {"type": "string", "optional": true, "description": "Either <em>loading</em> or <em>complete</em>."},
           "discarded": {"type": "boolean", "optional": true, "description": "True while the tab is not loaded with content."},
           "incognito": {"type": "boolean", "description": "Whether the tab is in an incognito window."},
           "width": {"type": "integer", "optional": true, "description": "The width of the tab in pixels."},
           "height": {"type": "integer", "optional": true, "description": "The height of the tab in pixels."},
           "sessionId": {"type": "string", "optional": true, "description": "The session ID used to uniquely identify a Tab obtained from the $(ref:sessions) API."},
-          "cookieStoreId": {"type": "string", "optional": true, "description": "The CookieStoreId used for the tab."}
+          "cookieStoreId": {"type": "string", "optional": true, "description": "The CookieStoreId used for the tab."},
+          "isArticle": {"type": "boolean", "optional": true, "description": "Whether the document in the tab can be rendered in reader mode."},
+          "isInReaderMode": {"type": "boolean", "optional": true, "description": "Whether the document in the tab is being rendered in reader mode."}
         }
       },
       {
         "id": "ZoomSettingsMode",
         "type": "string",
         "description": "Defines how zoom changes are handled, i.e. which entity is responsible for the actual scaling of the page; defaults to <code>automatic</code>.",
         "enum": [
           {
@@ -896,16 +898,31 @@
                 "name": "language",
                 "description": "An ISO language code such as <code>en</code> or <code>fr</code>. For a complete list of languages supported by this method, see <a href='http://src.chromium.org/viewvc/chrome/trunk/src/third_party/cld/languages/internal/languages.cc'>kLanguageInfoTable</a>. The 2nd to 4th columns will be checked and the first non-NULL value will be returned except for Simplified Chinese for which zh-CN will be returned. For an unknown language, <code>und</code> will be returned."
               }
             ]
           }
         ]
       },
       {
+        "name": "toggleReaderMode",
+        "type": "function",
+        "description": "Toggles reader mode for the document in the tab.",
+        "async": true,
+        "parameters": [
+          {
+            "type": "integer",
+            "name": "tabId",
+            "minimum": 0,
+            "optional": true,
+            "description": "Defaults to the active tab of the $(topic:current-window)[current window]."
+          }
+        ]
+      },
+      {
         "name": "captureVisibleTab",
         "type": "function",
         "description": "Captures the visible area of the currently active tab in the specified window. You must have $(topic:declare_permissions)[&lt;all_urls&gt;] permission to use this method.",
         "permissions": ["<all_urls>"],
         "async": "callback",
         "parameters": [
           {
             "type": "integer",
--- a/browser/components/extensions/test/browser/browser-common.ini
+++ b/browser/components/extensions/test/browser/browser-common.ini
@@ -27,16 +27,18 @@ support-files =
   locale/chrome.manifest
   webNav_createdTarget.html
   webNav_createdTargetSource.html
   webNav_createdTargetSource_subframe.html
   serviceWorker.js
   searchSuggestionEngine.xml
   searchSuggestionEngine.sjs
   ../../../../../toolkit/components/extensions/test/mochitest/head_webrequest.js
+  ../../../../../toolkit/components/reader/test/readerModeNonArticle.html
+  ../../../../../toolkit/components/reader/test/readerModeArticle.html
 
 [browser_ext_browserAction_area.js]
 [browser_ext_browserAction_context.js]
 [browser_ext_browserAction_contextMenu.js]
 # bug 1369197
 skip-if = os == 'linux'
 [browser_ext_browserAction_disabled.js]
 [browser_ext_browserAction_pageAction_icon.js]
@@ -148,16 +150,17 @@ skip-if = os == "win" # Bug 1398514
 [browser_ext_tabs_move_window.js]
 [browser_ext_tabs_move_window_multiple.js]
 [browser_ext_tabs_move_window_pinned.js]
 [browser_ext_tabs_onHighlighted.js]
 [browser_ext_tabs_onUpdated.js]
 [browser_ext_tabs_opener.js]
 [browser_ext_tabs_printPreview.js]
 [browser_ext_tabs_query.js]
+[browser_ext_tabs_readerMode.js]
 [browser_ext_tabs_reload.js]
 [browser_ext_tabs_reload_bypass_cache.js]
 [browser_ext_tabs_sendMessage.js]
 [browser_ext_tabs_cookieStoreId.js]
 [browser_ext_tabs_update.js]
 [browser_ext_tabs_zoom.js]
 [browser_ext_tabs_update_url.js]
 [browser_ext_themes_icons.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_readerMode.js
@@ -0,0 +1,103 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function test_reader_mode() {
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "permissions": ["tabs"],
+    },
+
+    async background() {
+      let tab;
+      let tabId;
+      let expected = {isInReaderMode: false};
+      let testState = {};
+      browser.test.onMessage.addListener(async (msg, ...args) => {
+        switch (msg) {
+          case "updateUrl":
+            expected.isArticle = args[0];
+            expected.url = args[1];
+            tab = await browser.tabs.update({url: expected.url});
+            tabId = tab.id;
+            break;
+          case "enterReaderMode":
+            expected.isArticle = !args[0];
+            expected.isInReaderMode = true;
+            tab = await browser.tabs.get(tabId);
+            browser.test.assertEq(false, tab.isInReaderMode, "The tab is not in reader mode.");
+            if (args[0]) {
+              browser.tabs.toggleReaderMode(tabId);
+            } else {
+              await browser.test.assertRejects(
+                browser.tabs.toggleReaderMode(tabId),
+                /The specified tab cannot be placed into reader mode/,
+                "Toggle fails with an unreaderable document.");
+              browser.test.assertEq(false, tab.isInReaderMode, "The tab is still not in reader mode.");
+              browser.test.sendMessage("enterFailed");
+            }
+            break;
+          case "leaveReaderMode":
+            expected.isInReaderMode = false;
+            tab = await browser.tabs.get(tabId);
+            browser.test.assertTrue(tab.isInReaderMode, "The tab is in reader mode.");
+            browser.tabs.toggleReaderMode(tabId);
+            break;
+        }
+      });
+
+      browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
+        if (tab.url !== "about:blank") {
+          if (changeInfo.status === "complete") {
+            testState.url = tab.url;
+            let urlOk = expected.isInReaderMode
+              ? testState.url.startsWith("about:reader")
+              : expected.url == testState.url;
+            if (urlOk && expected.isArticle == testState.isArticle) {
+              browser.test.sendMessage("tabUpdated", tab);
+            }
+            return;
+          }
+          if (changeInfo.isArticle == expected.isArticle
+              && changeInfo.isArticle != testState.isArticle) {
+            testState.isArticle = changeInfo.isArticle;
+            let urlOk = expected.isInReaderMode
+              ? testState.url.startsWith("about:reader")
+              : expected.url == testState.url;
+            if (urlOk && expected.isArticle == testState.isArticle) {
+              browser.test.sendMessage("isArticle", tab);
+            }
+          }
+        }
+      });
+    },
+  });
+
+  const TEST_PATH = getRootDirectory(gTestPath).replace("chrome://mochitests/content", "http://example.com");
+  const READER_MODE_PREFIX = "about:reader";
+
+  await extension.startup();
+  extension.sendMessage("updateUrl", true, `${TEST_PATH}readerModeArticle.html`);
+  let tab = await extension.awaitMessage("isArticle");
+
+  ok(!tab.url.startsWith(READER_MODE_PREFIX), "Tab url does not indicate reader mode.");
+  ok(tab.isArticle, "Tab is readerable.");
+
+  extension.sendMessage("enterReaderMode", true);
+  tab = await extension.awaitMessage("tabUpdated");
+  ok(tab.url.startsWith(READER_MODE_PREFIX), "Tab url indicates reader mode.");
+
+  extension.sendMessage("leaveReaderMode");
+  tab = await extension.awaitMessage("tabUpdated");
+  ok(!tab.url.startsWith(READER_MODE_PREFIX), "Tab url does not indicate reader mode.");
+
+  extension.sendMessage("updateUrl", false, `${TEST_PATH}readerModeNonArticle.html`);
+  tab = await extension.awaitMessage("tabUpdated");
+  ok(!tab.url.startsWith(READER_MODE_PREFIX), "Tab url does not indicate reader mode.");
+  ok(!tab.isArticle, "Tab is not readerable.");
+
+  extension.sendMessage("enterReaderMode", false);
+  await extension.awaitMessage("enterFailed");
+
+  await extension.unload();
+});
--- a/browser/components/extensions/test/mochitest/test_ext_all_apis.html
+++ b/browser/components/extensions/test/mochitest/test_ext_all_apis.html
@@ -48,16 +48,17 @@ let expectedBackgroundApisTargetSpecific
   "tabs.query",
   "tabs.reload",
   "tabs.remove",
   "tabs.removeCSS",
   "tabs.saveAsPDF",
   "tabs.sendMessage",
   "tabs.setZoom",
   "tabs.setZoomSettings",
+  "tabs.toggleReaderMode",
   "tabs.update",
   "windows.CreateType",
   "windows.WINDOW_ID_CURRENT",
   "windows.WINDOW_ID_NONE",
   "windows.WindowState",
   "windows.WindowType",
   "windows.create",
   "windows.get",
--- a/toolkit/components/extensions/ext-tabs-base.js
+++ b/toolkit/components/extensions/ext-tabs-base.js
@@ -415,16 +415,38 @@ class TabBase {
    *        @readonly
    *        @abstract
    */
   get windowId() {
     throw new Error("Not implemented");
   }
 
   /**
+   * @property {boolean} isArticle
+   *        Returns true if the document in the tab can be rendered in reader
+   *        mode.
+   *        @readonly
+   *        @abstract
+   */
+  get isArticle() {
+    throw new Error("Not implemented");
+  }
+
+  /**
+   * @property {boolean} isInReaderMode
+   *        Returns true if the document in the tab is being rendered in reader
+   *        mode.
+   *        @readonly
+   *        @abstract
+   */
+  get isInReaderMode() {
+    throw new Error("Not implemented");
+  }
+
+  /**
    * Returns true if this tab matches the the given query info object. Omitted
    * or null have no effect on the match.
    *
    * @param {object} queryInfo
    *        The query info against which to match.
    * @param {boolean} [queryInfo.active]
    *        Matches against the exact value of the tab's `active` attribute.
    * @param {boolean} [queryInfo.audible]
@@ -493,16 +515,18 @@ class TabBase {
       status: this.status,
       discarded: this.discarded,
       incognito: this.incognito,
       width: this.width,
       height: this.height,
       lastAccessed: this.lastAccessed,
       audible: this.audible,
       mutedInfo: this.mutedInfo,
+      isArticle: this.isArticle,
+      isInReaderMode: this.isInReaderMode,
     };
 
     // If the tab has not been fully layed-out yet, fallback to the geometry
     // from a different tab (usually the currently active tab).
     if (fallbackTab && (!result.width || !result.height)) {
       result.width = fallbackTab.width;
       result.height = fallbackTab.height;
     }