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
--- 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)[<all_urls>] 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;
}