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 665621 64f72f5035e27249ff0a36c7ffacc7aa3091404c
parent 664736 dd6b788f149763c4014c27f2fe1a1d13228bda82
child 731837 2e61e1881a21c93fc3daf75c702fa5d2a080754a
push id80124
push userbmo:bob.silverberg@gmail.com
push dateFri, 15 Sep 2017 19:12:45 +0000
reviewersmixedpuppy
bugs1381992
milestone57.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
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.emitIsArticleChanged(message.target, message.data.isArticle);
+        }
+        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) {
@@ -508,16 +527,30 @@ class TabTracker extends TabTrackerBase 
     // `tabs.onRemoved.addListener`, then the tab would be closed before the
     // event listener is registered. To make sure that the event listener is
     // notified, we dispatch `tabs.onRemoved` asynchronously.
     Services.tm.dispatchToMainThread(() => {
       this.emit("tab-removed", {nativeTab, tabId, windowId, isWindowClosing});
     });
   }
 
+  /**
+   * Emits a "tab-isarticle" event for the given tab element.
+   *
+   * @param {NativeTab} nativeTab
+   *        The tab element in the window for which the reader mode is changing.
+   * @param {boolean} isArticle
+   *        Whether the document in the tab can be rendered in reader mode.
+   * @private
+   */
+  emitIsArticleChanged(nativeTab, isArticle) {
+    let tabId = this.getId(nativeTab);
+    this.emit("tab-isarticle", {nativeTab, tabId, isArticle});
+  }
+
   getBrowserData(browser) {
     if (browser.ownerDocument.documentURI === "about:addons") {
       // When we're loaded into a <browser> inside about:addons, we need to go up
       // one more level.
       browser = browser.ownerDocument.docShell.chromeEventHandler;
     }
 
     let result = {
@@ -641,16 +674,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.nativeTab.ownerGlobal;
+            let tab = tabManager.wrapTab(
+              gBrowser.getTabForBrowser(event.nativeTab));
+
+            fireForTab(tab, {isArticle: event.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
@@ -26,16 +26,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]
@@ -146,16 +148,17 @@ skip-if = debug || asan # Bug 1354681
 [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,96 @@
+/* -*- 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 = {};
+      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":
+            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();
+            } else {
+              await browser.test.assertRejects(
+                browser.tabs.toggleReaderMode(),
+                /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":
+            tab = await browser.tabs.get(tabId);
+            browser.test.assertTrue(tab.isInReaderMode, "The tab is in reader mode.");
+            browser.tabs.toggleReaderMode();
+            break;
+        }
+      });
+
+      browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
+        if (tab.url !== "about:blank") {
+          if (changeInfo.status === "complete") {
+            browser.test.sendMessage("tabUpdated", tab);
+            return;
+          }
+          if (changeInfo.isArticle == expected.isArticle) {
+            let freshTab = await browser.tabs.get(tab.id);
+            browser.test.sendMessage("isArticle", freshTab);
+          }
+        }
+      });
+    },
+  });
+
+  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 tabs = await Promise.all([
+    extension.awaitMessage("tabUpdated"),
+    extension.awaitMessage("isArticle"),
+  ]);
+
+  ok(!tabs[0].url.startsWith(READER_MODE_PREFIX), "Tab url does not indicate reader mode.");
+  ok(tabs[1].isArticle, "Tab is readerable.");
+
+  extension.sendMessage("enterReaderMode", true);
+  let tab = await extension.awaitMessage("tabUpdated");
+  ok(tab.url.startsWith(READER_MODE_PREFIX), "Tab url indicates reader mode.");
+
+  extension.sendMessage("leaveReaderMode");
+  tabs = await Promise.all([
+    extension.awaitMessage("tabUpdated"),
+    extension.awaitMessage("isArticle"),
+  ]);
+  ok(!tabs[0].url.startsWith(READER_MODE_PREFIX), "Tab url does not indicate reader mode.");
+
+  extension.sendMessage("updateUrl", false, `${TEST_PATH}readerModeNonArticle.html`);
+  tabs = await Promise.all([
+    extension.awaitMessage("tabUpdated"),
+    extension.awaitMessage("isArticle"),
+  ]);
+  ok(!tabs[0].url.startsWith(READER_MODE_PREFIX), "Tab url does not indicate reader mode.");
+  ok(!tabs[1].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;
     }