Bug 1381992 - Add some reader mode support to the tabs API, r?mixedpuppy draft
authorBob Silverberg <bsilverberg@mozilla.com>
Fri, 08 Sep 2017 17:00:27 -0400
changeset 670021 da51bc378fb205972bd3e6f286b92d8ad0714ea6
parent 669742 5f3f19824efa14cc6db546baf59c54a0fc15ddc9
child 733101 beafd85393fd5b8b31a31f254658e5313dad5f4e
push id81484
push userbmo:bob.silverberg@gmail.com
push dateMon, 25 Sep 2017 17:58:37 +0000
reviewersmixedpuppy
bugs1381992
milestone58.0a1
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
mobile/android/components/extensions/ext-utils.js
mobile/android/components/extensions/schemas/tabs.json
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/mobile/android/components/extensions/ext-utils.js
+++ b/mobile/android/components/extensions/ext-utils.js
@@ -552,16 +552,26 @@ class Tab extends TabBase {
 
   get window() {
     return this.browser.ownerGlobal;
   }
 
   get windowId() {
     return windowTracker.getId(this.window);
   }
+
+  // TODO: Just return false for these until properly implemented on Android.
+  // https://bugzilla.mozilla.org/show_bug.cgi?id=1402924
+  get isArticle() {
+    return false;
+  }
+
+  get isInReaderMode() {
+    return false;
+  }
 }
 
 // Manages tab-specific context data and dispatches tab select and close events.
 class TabContext extends EventEmitter {
   constructor(getDefaults, extension) {
     super();
 
     this.extension = extension;
--- a/mobile/android/components/extensions/schemas/tabs.json
+++ b/mobile/android/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": [
           {
--- 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;
     }