summaryrefslogtreecommitdiffstats
path: root/toolkit/components/reader/AboutReader.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/reader/AboutReader.sys.mjs')
-rw-r--r--toolkit/components/reader/AboutReader.sys.mjs1548
1 files changed, 1548 insertions, 0 deletions
diff --git a/toolkit/components/reader/AboutReader.sys.mjs b/toolkit/components/reader/AboutReader.sys.mjs
new file mode 100644
index 0000000000..e9bc0bb729
--- /dev/null
+++ b/toolkit/components/reader/AboutReader.sys.mjs
@@ -0,0 +1,1548 @@
+/* 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/. */
+
+import { ReaderMode } from "resource://gre/modules/ReaderMode.sys.mjs";
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AsyncPrefs: "resource://gre/modules/AsyncPrefs.sys.mjs",
+ NarrateControls: "resource://gre/modules/narrate/NarrateControls.sys.mjs",
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "numberFormat",
+ () => new Services.intl.NumberFormat(undefined)
+);
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "pluralRules",
+ () => new Services.intl.PluralRules(undefined)
+);
+
+const COLORSCHEME_L10N_IDS = {
+ light: "about-reader-color-scheme-light",
+ dark: "about-reader-color-scheme-dark",
+ sepia: "about-reader-color-scheme-sepia",
+ auto: "about-reader-color-scheme-auto",
+};
+
+Services.telemetry.setEventRecordingEnabled("readermode", true);
+
+const zoomOnCtrl =
+ Services.prefs.getIntPref("mousewheel.with_control.action", 3) == 3;
+const zoomOnMeta =
+ Services.prefs.getIntPref("mousewheel.with_meta.action", 1) == 3;
+const isAppLocaleRTL = Services.locale.isAppLocaleRTL;
+
+export var AboutReader = function (
+ actor,
+ articlePromise,
+ docContentType = "document",
+ docTitle = ""
+) {
+ let win = actor.contentWindow;
+ let url = this._getOriginalUrl(win);
+ if (
+ !(
+ url.startsWith("http://") ||
+ url.startsWith("https://") ||
+ url.startsWith("file://")
+ )
+ ) {
+ let errorMsg =
+ "Only http://, https:// and file:// URLs can be loaded in about:reader.";
+ if (Services.prefs.getBoolPref("reader.errors.includeURLs")) {
+ errorMsg += " Tried to load: " + url + ".";
+ }
+ console.error(errorMsg);
+ win.location.href = "about:blank";
+ return;
+ }
+
+ let doc = win.document;
+ if (isAppLocaleRTL) {
+ doc.dir = "rtl";
+ }
+ doc.documentElement.setAttribute("platform", AppConstants.platform);
+
+ doc.title = docTitle;
+
+ this._actor = actor;
+ this._isLoggedInPocketUser = undefined;
+
+ this._docRef = Cu.getWeakReference(doc);
+ this._winRef = Cu.getWeakReference(win);
+ this._innerWindowId = win.windowGlobalChild.innerWindowId;
+
+ this._article = null;
+ this._languagePromise = new Promise(resolve => {
+ this._foundLanguage = resolve;
+ });
+
+ if (articlePromise) {
+ this._articlePromise = articlePromise;
+ }
+
+ this._headerElementRef = Cu.getWeakReference(
+ doc.querySelector(".reader-header")
+ );
+ this._domainElementRef = Cu.getWeakReference(
+ doc.querySelector(".reader-domain")
+ );
+ this._titleElementRef = Cu.getWeakReference(
+ doc.querySelector(".reader-title")
+ );
+ this._readTimeElementRef = Cu.getWeakReference(
+ doc.querySelector(".reader-estimated-time")
+ );
+ this._creditsElementRef = Cu.getWeakReference(
+ doc.querySelector(".reader-credits")
+ );
+ this._contentElementRef = Cu.getWeakReference(
+ doc.querySelector(".moz-reader-content")
+ );
+ this._toolbarContainerElementRef = Cu.getWeakReference(
+ doc.querySelector(".toolbar-container")
+ );
+ this._toolbarElementRef = Cu.getWeakReference(
+ doc.querySelector(".reader-controls")
+ );
+ this._messageElementRef = Cu.getWeakReference(
+ doc.querySelector(".reader-message")
+ );
+ this._containerElementRef = Cu.getWeakReference(
+ doc.querySelector(".container")
+ );
+
+ doc.addEventListener("mousedown", this);
+ doc.addEventListener("click", this);
+ doc.addEventListener("touchstart", this);
+
+ win.addEventListener("pagehide", this);
+ win.addEventListener("resize", this);
+ win.addEventListener("wheel", this, { passive: false });
+
+ this.colorSchemeMediaList = win.matchMedia("(prefers-color-scheme: dark)");
+ this.colorSchemeMediaList.addEventListener("change", this);
+
+ this.forcedColorsMediaList = win.matchMedia("(forced-colors)");
+ this.forcedColorsMediaList.addEventListener("change", this);
+
+ this._topScrollChange = this._topScrollChange.bind(this);
+ this._intersectionObs = new win.IntersectionObserver(this._topScrollChange, {
+ root: null,
+ threshold: [0, 1],
+ });
+ this._intersectionObs.observe(doc.querySelector(".top-anchor"));
+
+ this._ctaIntersectionObserver = new win.IntersectionObserver(
+ this._pocketCTAObserved.bind(this),
+ {
+ threshold: 0.5,
+ }
+ );
+
+ Services.obs.addObserver(this, "inner-window-destroyed");
+
+ this._setupButton("close-button", this._onReaderClose.bind(this));
+
+ // we're ready for any external setup, send a signal for that.
+ this._actor.sendAsyncMessage("Reader:OnSetup");
+
+ let colorSchemeValues = JSON.parse(
+ Services.prefs.getCharPref("reader.color_scheme.values")
+ );
+ let colorSchemeOptions = colorSchemeValues.map(value => ({
+ l10nId: COLORSCHEME_L10N_IDS[value],
+ groupName: "color-scheme",
+ value,
+ itemClass: value + "-button",
+ }));
+ let colorScheme = Services.prefs.getCharPref("reader.color_scheme");
+
+ this._setupSegmentedButton(
+ "color-scheme-buttons",
+ colorSchemeOptions,
+ colorScheme,
+ this._setColorSchemePref.bind(this)
+ );
+ this._setColorSchemePref(colorScheme);
+
+ let fontTypeOptions = [
+ {
+ l10nId: "about-reader-font-type-sans-serif",
+ groupName: "font-type",
+ value: "sans-serif",
+ itemClass: "sans-serif-button",
+ },
+ {
+ l10nId: "about-reader-font-type-serif",
+ groupName: "font-type",
+ value: "serif",
+ itemClass: "serif-button",
+ },
+ ];
+
+ let fontType = Services.prefs.getCharPref("reader.font_type");
+ this._setupSegmentedButton(
+ "font-type-buttons",
+ fontTypeOptions,
+ fontType,
+ this._setFontType.bind(this)
+ );
+ this._setFontType(fontType);
+
+ this._setupFontSizeButtons();
+
+ this._setupContentWidthButtons();
+
+ this._setupLineHeightButtons();
+
+ if (win.speechSynthesis && Services.prefs.getBoolPref("narrate.enabled")) {
+ new lazy.NarrateControls(win, this._languagePromise);
+ }
+
+ this._loadArticle(docContentType);
+};
+
+AboutReader.prototype = {
+ _BLOCK_IMAGES_SELECTOR:
+ ".content p > img:only-child, " +
+ ".content p > a:only-child > img:only-child, " +
+ ".content .wp-caption img, " +
+ ".content figure img",
+
+ _TABLES_SELECTOR: ".content table",
+
+ FONT_SIZE_MIN: 1,
+
+ FONT_SIZE_LEGACY_MAX: 9,
+
+ FONT_SIZE_MAX: 15,
+
+ FONT_SIZE_EXTENDED_VALUES: [32, 40, 56, 72, 96, 128],
+
+ get _doc() {
+ return this._docRef.get();
+ },
+
+ get _win() {
+ return this._winRef.get();
+ },
+
+ get _headerElement() {
+ return this._headerElementRef.get();
+ },
+
+ get _domainElement() {
+ return this._domainElementRef.get();
+ },
+
+ get _titleElement() {
+ return this._titleElementRef.get();
+ },
+
+ get _readTimeElement() {
+ return this._readTimeElementRef.get();
+ },
+
+ get _creditsElement() {
+ return this._creditsElementRef.get();
+ },
+
+ get _contentElement() {
+ return this._contentElementRef.get();
+ },
+
+ get _toolbarElement() {
+ return this._toolbarElementRef.get();
+ },
+
+ get _toolbarContainerElement() {
+ return this._toolbarContainerElementRef.get();
+ },
+
+ get _messageElement() {
+ return this._messageElementRef.get();
+ },
+
+ get _containerElement() {
+ return this._containerElementRef.get();
+ },
+
+ get _isToolbarVertical() {
+ if (this._toolbarVertical !== undefined) {
+ return this._toolbarVertical;
+ }
+ return (this._toolbarVertical = Services.prefs.getBoolPref(
+ "reader.toolbar.vertical"
+ ));
+ },
+
+ receiveMessage({ data, name }) {
+ const doc = this._doc;
+ switch (name) {
+ case "Reader:AddButton": {
+ if (data.id && data.image && !doc.getElementsByClassName(data.id)[0]) {
+ let btn = doc.createElement("button");
+ btn.dataset.buttonid = data.id;
+ btn.dataset.telemetryId = `reader-${data.telemetryId}`;
+ btn.className = "toolbar-button " + data.id;
+ btn.setAttribute("aria-labelledby", "label-" + data.id);
+ let tip = doc.createElement("span");
+ tip.className = "hover-label";
+ tip.id = "label-" + data.id;
+ doc.l10n.setAttributes(tip, data.l10nId);
+ btn.append(tip);
+ btn.style.backgroundImage = "url('" + data.image + "')";
+ if (data.width && data.height) {
+ btn.style.backgroundSize = `${data.width}px ${data.height}px`;
+ }
+ let tb = this._toolbarElement;
+ tb.appendChild(btn);
+ this._setupButton(data.id, button => {
+ this._actor.sendAsyncMessage(
+ "Reader:Clicked-" + button.dataset.buttonid,
+ { article: this._article }
+ );
+ });
+ }
+ break;
+ }
+ case "Reader:RemoveButton": {
+ if (data.id) {
+ let btn = doc.getElementsByClassName(data.id)[0];
+ if (btn) {
+ btn.remove();
+ }
+ }
+ break;
+ }
+ case "Reader:ZoomIn": {
+ this._changeFontSize(+1);
+ break;
+ }
+ case "Reader:ZoomOut": {
+ this._changeFontSize(-1);
+ break;
+ }
+ case "Reader:ResetZoom": {
+ this._resetFontSize();
+ break;
+ }
+ }
+ },
+
+ handleEvent(aEvent) {
+ if (!aEvent.isTrusted) {
+ return;
+ }
+
+ let target = aEvent.target;
+ switch (aEvent.type) {
+ case "touchstart":
+ /* fall through */
+ case "mousedown":
+ if (
+ !target.closest(".dropdown-popup") &&
+ // Skip handling the toggle button here becase
+ // the dropdown will get toggled with the 'click' event.
+ !target.classList.contains("dropdown-toggle")
+ ) {
+ this._closeDropdowns();
+ }
+ break;
+ case "click":
+ const buttonLabel =
+ target.attributes.getNamedItem(`data-telemetry-id`)?.value;
+
+ if (buttonLabel) {
+ Services.telemetry.recordEvent(
+ "readermode",
+ "button",
+ "click",
+ null,
+ {
+ label: buttonLabel,
+ }
+ );
+ }
+
+ if (target.classList.contains("dropdown-toggle")) {
+ this._toggleDropdownClicked(aEvent);
+ }
+ break;
+ case "scroll":
+ let lastHeight = this._lastHeight;
+ let { windowUtils } = this._win;
+ this._lastHeight = windowUtils.getBoundsWithoutFlushing(
+ this._doc.body
+ ).height;
+ // Only close dropdowns if the scroll events are not a result of line
+ // height / font-size changes that caused a page height change.
+ if (lastHeight == this._lastHeight) {
+ this._closeDropdowns(true);
+ }
+
+ break;
+ case "resize":
+ this._updateImageMargins();
+ this._scheduleToolbarOverlapHandler();
+ break;
+
+ case "wheel":
+ let doZoom =
+ (aEvent.ctrlKey && zoomOnCtrl) || (aEvent.metaKey && zoomOnMeta);
+ if (!doZoom) {
+ return;
+ }
+ aEvent.preventDefault();
+
+ // Throttle events to once per 150ms. This avoids excessively fast zooming.
+ if (aEvent.timeStamp <= this._zoomBackoffTime) {
+ return;
+ }
+ this._zoomBackoffTime = aEvent.timeStamp + 150;
+
+ // Determine the direction of the delta (we don't care about its size);
+ // This code is adapted from normalizeWheelEventDelta in
+ // toolkt/components/pdfjs/content/web/viewer.js
+ let delta = Math.abs(aEvent.deltaX) + Math.abs(aEvent.deltaY);
+ let angle = Math.atan2(aEvent.deltaY, aEvent.deltaX);
+ if (-0.25 * Math.PI < angle && angle < 0.75 * Math.PI) {
+ delta = -delta;
+ }
+
+ if (delta > 0) {
+ this._changeFontSize(+1);
+ } else if (delta < 0) {
+ this._changeFontSize(-1);
+ }
+ break;
+
+ case "pagehide":
+ this._closeDropdowns();
+
+ this._actor.readerModeHidden();
+ this.clearActor();
+
+ // Disconnect and delete IntersectionObservers to prevent memory leaks:
+
+ this._intersectionObs.unobserve(this._doc.querySelector(".top-anchor"));
+ this._ctaIntersectionObserver.disconnect();
+
+ delete this._intersectionObs;
+ delete this._ctaIntersectionObserver;
+
+ break;
+
+ case "change":
+ let colorScheme;
+ if (this.forcedColorsMediaList.matches) {
+ colorScheme = "hcm";
+ } else {
+ colorScheme = Services.prefs.getCharPref("reader.color_scheme");
+ // We should be changing the color scheme in relation to a preference change
+ // if the user has the color scheme preference set to "Auto".
+ if (colorScheme == "auto") {
+ colorScheme = this.colorSchemeMediaList.matches ? "dark" : "light";
+ }
+ }
+ this._setColorScheme(colorScheme);
+
+ break;
+ }
+ },
+
+ clearActor() {
+ this._actor = null;
+ },
+
+ _onReaderClose() {
+ if (this._actor) {
+ this._actor.closeReaderMode();
+ }
+ },
+
+ async _resetFontSize() {
+ await lazy.AsyncPrefs.reset("reader.font_size");
+ let currentSize = Services.prefs.getIntPref("reader.font_size");
+ this._setFontSize(currentSize);
+ },
+
+ _setFontSize(newFontSize) {
+ this._fontSize = Math.min(
+ this.FONT_SIZE_MAX,
+ Math.max(this.FONT_SIZE_MIN, newFontSize)
+ );
+ let size;
+ if (this._fontSize > this.FONT_SIZE_LEGACY_MAX) {
+ // -1 because we're indexing into a 0-indexed array, so the first value
+ // over the legacy max should be 0, the next 1, etc.
+ let index = this._fontSize - this.FONT_SIZE_LEGACY_MAX - 1;
+ size = this.FONT_SIZE_EXTENDED_VALUES[index];
+ } else {
+ size = 10 + 2 * this._fontSize;
+ }
+
+ let readerBody = this._doc.body;
+ readerBody.style.setProperty("--font-size", size + "px");
+ return lazy.AsyncPrefs.set("reader.font_size", this._fontSize);
+ },
+
+ _setupFontSizeButtons() {
+ let plusButton = this._doc.querySelector(".plus-button");
+ let minusButton = this._doc.querySelector(".minus-button");
+
+ let currentSize = Services.prefs.getIntPref("reader.font_size");
+ this._setFontSize(currentSize);
+ this._updateFontSizeButtonControls();
+
+ plusButton.addEventListener(
+ "click",
+ event => {
+ if (!event.isTrusted) {
+ return;
+ }
+ event.stopPropagation();
+ this._changeFontSize(+1);
+ },
+ true
+ );
+
+ minusButton.addEventListener(
+ "click",
+ event => {
+ if (!event.isTrusted) {
+ return;
+ }
+ event.stopPropagation();
+ this._changeFontSize(-1);
+ },
+ true
+ );
+ },
+
+ _updateFontSizeButtonControls() {
+ let plusButton = this._doc.querySelector(".plus-button");
+ let minusButton = this._doc.querySelector(".minus-button");
+
+ let currentSize = this._fontSize;
+ let fontValue = this._doc.querySelector(".font-size-value");
+ fontValue.textContent = currentSize;
+
+ if (currentSize === this.FONT_SIZE_MIN) {
+ minusButton.setAttribute("disabled", true);
+ } else {
+ minusButton.removeAttribute("disabled");
+ }
+ if (currentSize === this.FONT_SIZE_MAX) {
+ plusButton.setAttribute("disabled", true);
+ } else {
+ plusButton.removeAttribute("disabled");
+ }
+ },
+
+ _changeFontSize(changeAmount) {
+ let currentSize =
+ Services.prefs.getIntPref("reader.font_size") + changeAmount;
+ this._setFontSize(currentSize);
+ this._updateFontSizeButtonControls();
+ this._scheduleToolbarOverlapHandler();
+ },
+
+ _setContentWidth(newContentWidth) {
+ this._contentWidth = newContentWidth;
+ this._displayContentWidth(newContentWidth);
+ let width = 20 + 5 * (this._contentWidth - 1) + "em";
+ this._doc.body.style.setProperty("--content-width", width);
+ this._scheduleToolbarOverlapHandler();
+ return lazy.AsyncPrefs.set("reader.content_width", this._contentWidth);
+ },
+
+ _displayContentWidth(currentContentWidth) {
+ let contentWidthValue = this._doc.querySelector(".content-width-value");
+ contentWidthValue.textContent = currentContentWidth;
+ },
+
+ _setupContentWidthButtons() {
+ const CONTENT_WIDTH_MIN = 1;
+ const CONTENT_WIDTH_MAX = 9;
+
+ let currentContentWidth = Services.prefs.getIntPref("reader.content_width");
+ currentContentWidth = Math.max(
+ CONTENT_WIDTH_MIN,
+ Math.min(CONTENT_WIDTH_MAX, currentContentWidth)
+ );
+
+ this._displayContentWidth(currentContentWidth);
+
+ let plusButton = this._doc.querySelector(".content-width-plus-button");
+ let minusButton = this._doc.querySelector(".content-width-minus-button");
+
+ function updateControls() {
+ if (currentContentWidth === CONTENT_WIDTH_MIN) {
+ minusButton.setAttribute("disabled", true);
+ } else {
+ minusButton.removeAttribute("disabled");
+ }
+ if (currentContentWidth === CONTENT_WIDTH_MAX) {
+ plusButton.setAttribute("disabled", true);
+ } else {
+ plusButton.removeAttribute("disabled");
+ }
+ }
+
+ updateControls();
+ this._setContentWidth(currentContentWidth);
+
+ plusButton.addEventListener(
+ "click",
+ event => {
+ if (!event.isTrusted) {
+ return;
+ }
+ event.stopPropagation();
+
+ if (currentContentWidth >= CONTENT_WIDTH_MAX) {
+ return;
+ }
+
+ currentContentWidth++;
+ updateControls();
+ this._setContentWidth(currentContentWidth);
+ },
+ true
+ );
+
+ minusButton.addEventListener(
+ "click",
+ event => {
+ if (!event.isTrusted) {
+ return;
+ }
+ event.stopPropagation();
+
+ if (currentContentWidth <= CONTENT_WIDTH_MIN) {
+ return;
+ }
+
+ currentContentWidth--;
+ updateControls();
+ this._setContentWidth(currentContentWidth);
+ },
+ true
+ );
+ },
+
+ _setLineHeight(newLineHeight) {
+ this._displayLineHeight(newLineHeight);
+ let height = 1 + 0.2 * (newLineHeight - 1) + "em";
+ this._containerElement.style.setProperty("--line-height", height);
+ return lazy.AsyncPrefs.set("reader.line_height", newLineHeight);
+ },
+
+ _displayLineHeight(currentLineHeight) {
+ let lineHeightValue = this._doc.querySelector(".line-height-value");
+ lineHeightValue.textContent = currentLineHeight;
+ },
+
+ _setupLineHeightButtons() {
+ const LINE_HEIGHT_MIN = 1;
+ const LINE_HEIGHT_MAX = 9;
+
+ let currentLineHeight = Services.prefs.getIntPref("reader.line_height");
+ currentLineHeight = Math.max(
+ LINE_HEIGHT_MIN,
+ Math.min(LINE_HEIGHT_MAX, currentLineHeight)
+ );
+
+ this._displayLineHeight(currentLineHeight);
+
+ let plusButton = this._doc.querySelector(".line-height-plus-button");
+ let minusButton = this._doc.querySelector(".line-height-minus-button");
+
+ function updateControls() {
+ if (currentLineHeight === LINE_HEIGHT_MIN) {
+ minusButton.setAttribute("disabled", true);
+ } else {
+ minusButton.removeAttribute("disabled");
+ }
+ if (currentLineHeight === LINE_HEIGHT_MAX) {
+ plusButton.setAttribute("disabled", true);
+ } else {
+ plusButton.removeAttribute("disabled");
+ }
+ }
+
+ updateControls();
+ this._setLineHeight(currentLineHeight);
+
+ plusButton.addEventListener(
+ "click",
+ event => {
+ if (!event.isTrusted) {
+ return;
+ }
+ event.stopPropagation();
+
+ if (currentLineHeight >= LINE_HEIGHT_MAX) {
+ return;
+ }
+
+ currentLineHeight++;
+ updateControls();
+ this._setLineHeight(currentLineHeight);
+ },
+ true
+ );
+
+ minusButton.addEventListener(
+ "click",
+ event => {
+ if (!event.isTrusted) {
+ return;
+ }
+ event.stopPropagation();
+
+ if (currentLineHeight <= LINE_HEIGHT_MIN) {
+ return;
+ }
+
+ currentLineHeight--;
+ updateControls();
+ this._setLineHeight(currentLineHeight);
+ },
+ true
+ );
+ },
+
+ _setColorScheme(newColorScheme) {
+ // There's nothing to change if the new color scheme is the same as our current scheme.
+ if (this._colorScheme === newColorScheme) {
+ return;
+ }
+
+ let bodyClasses = this._doc.body.classList;
+
+ if (this._colorScheme) {
+ bodyClasses.remove(this._colorScheme);
+ }
+
+ if (!this._win.matchMedia("(forced-colors)").matches) {
+ if (newColorScheme === "auto") {
+ this._colorScheme = this.colorSchemeMediaList.matches
+ ? "dark"
+ : "light";
+ } else {
+ this._colorScheme = newColorScheme;
+ }
+ } else {
+ this._colorScheme = "hcm";
+ }
+
+ bodyClasses.add(this._colorScheme);
+ },
+
+ // Pref values include "dark", "light", "sepia", and "auto"
+ _setColorSchemePref(colorSchemePref) {
+ this._setColorScheme(colorSchemePref);
+
+ lazy.AsyncPrefs.set("reader.color_scheme", colorSchemePref);
+ },
+
+ _setFontType(newFontType) {
+ if (this._fontType === newFontType) {
+ return;
+ }
+
+ let bodyClasses = this._doc.body.classList;
+
+ if (this._fontType) {
+ bodyClasses.remove(this._fontType);
+ }
+
+ this._fontType = newFontType;
+ bodyClasses.add(this._fontType);
+
+ lazy.AsyncPrefs.set("reader.font_type", this._fontType);
+ },
+
+ async _loadArticle(docContentType = "document") {
+ let url = this._getOriginalUrl();
+ this._showProgressDelayed();
+
+ let article;
+ if (this._articlePromise) {
+ article = await this._articlePromise;
+ }
+
+ if (!article) {
+ try {
+ article = await ReaderMode.downloadAndParseDocument(
+ url,
+ docContentType
+ );
+ } catch (e) {
+ if (e?.newURL && this._actor) {
+ await this._actor.sendQuery("RedirectTo", {
+ newURL: e.newURL,
+ article: e.article,
+ });
+
+ let readerURL = "about:reader?url=" + encodeURIComponent(e.newURL);
+ this._win.location.replace(readerURL);
+ return;
+ }
+ }
+ }
+
+ if (!this._actor) {
+ return;
+ }
+
+ // Replace the loading message with an error message if there's a failure.
+ // Users are supposed to navigate away by themselves (because we cannot
+ // remove ourselves from session history.)
+ if (!article) {
+ this._showError();
+ return;
+ }
+
+ this._showContent(article);
+ },
+
+ async _requestPocketLoginStatus() {
+ let isLoggedIn = await this._actor.sendQuery(
+ "Reader:PocketLoginStatusRequest"
+ );
+
+ return isLoggedIn;
+ },
+
+ async _requestPocketArticleInfo(url) {
+ let articleInfo = await this._actor.sendQuery(
+ "Reader:PocketGetArticleInfo",
+ {
+ url,
+ }
+ );
+
+ return articleInfo?.item_preview?.item_id;
+ },
+
+ async _requestPocketArticleRecs(itemID) {
+ let recs = await this._actor.sendQuery("Reader:PocketGetArticleRecs", {
+ itemID,
+ });
+
+ return recs;
+ },
+
+ async _savePocketArticle(url) {
+ let result = await this._actor.sendQuery("Reader:PocketSaveArticle", {
+ url,
+ });
+
+ return result;
+ },
+
+ async _requestFavicon() {
+ let iconDetails = await this._actor.sendQuery("Reader:FaviconRequest", {
+ url: this._article.url,
+ preferredWidth: 16 * this._win.devicePixelRatio,
+ });
+
+ if (iconDetails) {
+ this._loadFavicon(iconDetails.url, iconDetails.faviconUrl);
+ }
+ },
+
+ _loadFavicon(url, faviconUrl) {
+ if (this._article.url !== url) {
+ return;
+ }
+
+ let doc = this._doc;
+
+ let link = doc.createElement("link");
+ link.rel = "shortcut icon";
+ link.href = faviconUrl;
+
+ doc.getElementsByTagName("head")[0].appendChild(link);
+ },
+
+ _updateImageMargins() {
+ let windowWidth = this._win.innerWidth;
+ let bodyWidth = this._doc.body.clientWidth;
+
+ let setImageMargins = function (img) {
+ img.classList.add("moz-reader-block-img");
+
+ // If the image is at least as wide as the window, make it fill edge-to-edge on mobile.
+ if (img.naturalWidth >= windowWidth) {
+ img.setAttribute("moz-reader-full-width", true);
+ } else {
+ img.removeAttribute("moz-reader-full-width");
+ }
+
+ // If the image is at least half as wide as the body, center it on desktop.
+ if (img.naturalWidth >= bodyWidth / 2) {
+ img.setAttribute("moz-reader-center", true);
+ } else {
+ img.removeAttribute("moz-reader-center");
+ }
+ };
+
+ let imgs = this._doc.querySelectorAll(this._BLOCK_IMAGES_SELECTOR);
+ for (let i = imgs.length; --i >= 0; ) {
+ let img = imgs[i];
+
+ if (img.naturalWidth > 0) {
+ setImageMargins(img);
+ } else {
+ img.onload = function () {
+ setImageMargins(img);
+ };
+ }
+ }
+ },
+
+ _updateWideTables() {
+ let windowWidth = this._win.innerWidth;
+
+ // Avoid horizontal overflow in the document by making tables that are wider than half browser window's size
+ // by making it scrollable.
+ let tables = this._doc.querySelectorAll(this._TABLES_SELECTOR);
+ for (let i = tables.length; --i >= 0; ) {
+ let table = tables[i];
+ let rect = table.getBoundingClientRect();
+ let tableWidth = rect.width;
+
+ if (windowWidth / 2 <= tableWidth) {
+ table.classList.add("moz-reader-wide-table");
+ }
+ }
+ },
+
+ _maybeSetTextDirection: function Read_maybeSetTextDirection(article) {
+ // Set the article's "dir" on the contents.
+ // If no direction is specified, the contents should automatically be LTR
+ // regardless of the UI direction to avoid inheriting the parent's direction
+ // if the UI is RTL.
+ this._containerElement.dir = article.dir || "ltr";
+
+ // The native locale could be set differently than the article's text direction.
+ this._readTimeElement.dir = isAppLocaleRTL ? "rtl" : "ltr";
+
+ // This is used to mirror the line height buttons in the toolbar, when relevant.
+ this._toolbarElement.setAttribute("articledir", article.dir || "ltr");
+ },
+
+ _showError() {
+ this._headerElement.classList.remove("reader-show-element");
+ this._contentElement.classList.remove("reader-show-element");
+
+ this._doc.l10n.setAttributes(
+ this._messageElement,
+ "about-reader-load-error"
+ );
+ this._doc.l10n.setAttributes(
+ this._doc.getElementById("reader-title"),
+ "about-reader-load-error"
+ );
+ this._messageElement.style.display = "block";
+
+ this._doc.documentElement.dataset.isError = true;
+
+ this._error = true;
+
+ this._doc.dispatchEvent(
+ new this._win.CustomEvent("AboutReaderContentError", {
+ bubbles: true,
+ cancelable: false,
+ })
+ );
+ },
+
+ // This function is the JS version of Java's StringUtils.stripCommonSubdomains.
+ _stripHost(host) {
+ if (!host) {
+ return host;
+ }
+
+ let start = 0;
+
+ if (host.startsWith("www.")) {
+ start = 4;
+ } else if (host.startsWith("m.")) {
+ start = 2;
+ } else if (host.startsWith("mobile.")) {
+ start = 7;
+ }
+
+ return host.substring(start);
+ },
+
+ _showContent(article) {
+ this._messageElement.classList.remove("reader-show-element");
+
+ this._article = article;
+
+ this._domainElement.href = article.url;
+ let articleUri = Services.io.newURI(article.url);
+
+ try {
+ this._domainElement.textContent = this._stripHost(articleUri.host);
+ } catch (ex) {
+ let url = this._actor.document.URL;
+ url = url.substring(url.indexOf("%2F") + 6);
+ url = url.substring(0, url.indexOf("%2F"));
+
+ this._domainElement.textContent = url;
+ }
+
+ this._creditsElement.textContent = article.byline;
+
+ this._titleElement.textContent = article.title;
+
+ // TODO: Once formatRange() and selectRange() are available outside Nightly,
+ // use them here. https://bugzilla.mozilla.org/show_bug.cgi?id=1795317
+ const slow = article.readingTimeMinsSlow;
+ const fast = article.readingTimeMinsFast;
+ const fastStr = lazy.numberFormat.format(fast);
+ const slowStr = lazy.numberFormat.format(slow);
+ this._doc.l10n.setAttributes(
+ this._readTimeElement,
+ "about-reader-estimated-read-time",
+ {
+ range: fastStr === slowStr ? `~${fastStr}` : `${fastStr}–${slowStr}`,
+ rangePlural: lazy.pluralRules.select(slow),
+ }
+ );
+
+ // If a document title was not provided in the constructor, we'll fall back
+ // to using the article title.
+ if (!this._doc.title) {
+ this._doc.title = article.title;
+ }
+
+ this._containerElement.setAttribute("lang", article.lang);
+
+ this._headerElement.classList.add("reader-show-element");
+
+ let parserUtils = Cc["@mozilla.org/parserutils;1"].getService(
+ Ci.nsIParserUtils
+ );
+ let contentFragment = parserUtils.parseFragment(
+ article.content,
+ Ci.nsIParserUtils.SanitizerDropForms |
+ Ci.nsIParserUtils.SanitizerAllowStyle,
+ false,
+ articleUri,
+ this._contentElement
+ );
+ this._contentElement.innerHTML = "";
+ this._contentElement.appendChild(contentFragment);
+ this._maybeSetTextDirection(article);
+ this._foundLanguage(article.language);
+
+ this._contentElement.classList.add("reader-show-element");
+ this._updateImageMargins();
+ this._updateWideTables();
+
+ this._requestFavicon();
+ this._doc.body.classList.add("loaded");
+
+ this._goToReference(articleUri.ref);
+
+ Services.obs.notifyObservers(this._win, "AboutReader:Ready");
+
+ this._doc.dispatchEvent(
+ new this._win.CustomEvent("AboutReaderContentReady", {
+ bubbles: true,
+ cancelable: false,
+ })
+ );
+
+ // Show Pocket CTA block after article has loaded to prevent it flashing in prematurely
+ this._setupPocketCTA();
+ },
+
+ _hideContent() {
+ this._headerElement.classList.remove("reader-show-element");
+ this._contentElement.classList.remove("reader-show-element");
+ },
+
+ _showProgressDelayed() {
+ this._win.setTimeout(() => {
+ // No need to show progress if the article has been loaded,
+ // if the window has been unloaded, or if there was an error
+ // trying to load the article.
+ if (this._article || !this._actor || this._error) {
+ return;
+ }
+
+ this._headerElement.classList.remove("reader-show-element");
+ this._contentElement.classList.remove("reader-show-element");
+
+ this._doc.l10n.setAttributes(
+ this._messageElement,
+ "about-reader-loading"
+ );
+ this._messageElement.classList.add("reader-show-element");
+ }, 300);
+ },
+
+ /**
+ * Returns the original article URL for this about:reader view.
+ */
+ _getOriginalUrl(win) {
+ let url = win ? win.location.href : this._win.location.href;
+ return ReaderMode.getOriginalUrl(url) || url;
+ },
+
+ _setupSegmentedButton(id, options, initialValue, callback) {
+ let doc = this._doc;
+ let segmentedButton = doc.getElementsByClassName(id)[0];
+
+ for (let option of options) {
+ let radioButton = doc.createElement("input");
+ radioButton.id = "radio-item" + option.itemClass;
+ radioButton.type = "radio";
+ radioButton.classList.add("radio-button");
+ radioButton.name = option.groupName;
+ segmentedButton.appendChild(radioButton);
+
+ let item = doc.createElement("label");
+ item.htmlFor = radioButton.id;
+ item.classList.add(option.itemClass);
+ doc.l10n.setAttributes(item, option.l10nId);
+
+ segmentedButton.appendChild(item);
+
+ radioButton.addEventListener(
+ "input",
+ function (aEvent) {
+ if (!aEvent.isTrusted) {
+ return;
+ }
+
+ let labels = segmentedButton.children;
+ for (let label of labels) {
+ label.removeAttribute("checked");
+ }
+
+ aEvent.target.nextElementSibling.setAttribute("checked", "true");
+ callback(option.value);
+ },
+ true
+ );
+
+ if (option.value === initialValue) {
+ radioButton.checked = true;
+ item.setAttribute("checked", "true");
+ }
+ }
+ },
+
+ _setupButton(id, callback) {
+ let button = this._doc.querySelector("." + id);
+ button.removeAttribute("hidden");
+ button.addEventListener(
+ "click",
+ function (aEvent) {
+ if (!aEvent.isTrusted) {
+ return;
+ }
+
+ let btn = aEvent.target;
+ callback(btn);
+ },
+ true
+ );
+ },
+
+ _toggleDropdownClicked(event) {
+ let dropdown = event.target.closest(".dropdown");
+
+ if (!dropdown) {
+ return;
+ }
+
+ event.stopPropagation();
+
+ if (dropdown.classList.contains("open")) {
+ this._closeDropdowns();
+ } else {
+ this._openDropdown(dropdown);
+ }
+ },
+
+ /*
+ * If the ReaderView banner font-dropdown is closed, open it.
+ */
+ _openDropdown(dropdown, window) {
+ if (dropdown.classList.contains("open")) {
+ return;
+ }
+
+ this._closeDropdowns();
+
+ // Get the height of the doc and start handling scrolling:
+ let { windowUtils } = this._win;
+ this._lastHeight = windowUtils.getBoundsWithoutFlushing(
+ this._doc.body
+ ).height;
+ this._doc.addEventListener("scroll", this);
+
+ dropdown.classList.add("open");
+ this._toolbarElement.classList.add("dropdown-open");
+
+ this._toolbarContainerElement.classList.add("dropdown-open");
+ this._toggleToolbarFixedPosition(true);
+ },
+
+ /*
+ * If the ReaderView has open dropdowns, close them. If we are closing the
+ * dropdowns because the page is scrolling, allow popups to stay open with
+ * the keep-open class.
+ */
+ _closeDropdowns(scrolling) {
+ let selector = ".dropdown.open";
+ if (scrolling) {
+ selector += ":not(.keep-open)";
+ }
+
+ let openDropdowns = this._doc.querySelectorAll(selector);
+ let haveOpenDropdowns = openDropdowns.length;
+ for (let dropdown of openDropdowns) {
+ dropdown.classList.remove("open");
+ }
+ this._toolbarElement.classList.remove("dropdown-open");
+
+ if (haveOpenDropdowns) {
+ this._toolbarContainerElement.classList.remove("dropdown-open");
+ this._toggleToolbarFixedPosition(false);
+ }
+
+ // Stop handling scrolling:
+ this._doc.removeEventListener("scroll", this);
+ },
+
+ _toggleToolbarFixedPosition(shouldBeFixed) {
+ let el = this._toolbarContainerElement;
+ let fontSize = this._doc.body.style.getPropertyValue("--font-size");
+ let contentWidth = this._doc.body.style.getPropertyValue("--content-width");
+ if (shouldBeFixed) {
+ el.style.setProperty("--font-size", fontSize);
+ el.style.setProperty("--content-width", contentWidth);
+ el.classList.add("transition-location");
+ } else {
+ let expectTransition =
+ el.style.getPropertyValue("--font-size") != fontSize ||
+ el.style.getPropertyValue("--content-width") != contentWidth;
+ if (expectTransition) {
+ el.addEventListener(
+ "transitionend",
+ () => el.classList.remove("transition-location"),
+ { once: true }
+ );
+ } else {
+ el.classList.remove("transition-location");
+ }
+ el.style.removeProperty("--font-size");
+ el.style.removeProperty("--content-width");
+ el.classList.remove("overlaps");
+ }
+ },
+
+ _scheduleToolbarOverlapHandler() {
+ if (this._enqueuedToolbarOverlapHandler) {
+ return;
+ }
+ this._enqueuedToolbarOverlapHandler = this._win.requestAnimationFrame(
+ () => {
+ this._win.setTimeout(() => this._toolbarOverlapHandler(), 0);
+ }
+ );
+ },
+
+ _toolbarOverlapHandler() {
+ delete this._enqueuedToolbarOverlapHandler;
+ // Ensure the dropdown is still open to avoid racing with that changing.
+ if (this._toolbarContainerElement.classList.contains("dropdown-open")) {
+ let { windowUtils } = this._win;
+ let toolbarBounds = windowUtils.getBoundsWithoutFlushing(
+ this._toolbarElement.parentNode
+ );
+ let textBounds = windowUtils.getBoundsWithoutFlushing(
+ this._containerElement
+ );
+ let overlaps = false;
+ if (isAppLocaleRTL) {
+ overlaps = textBounds.right > toolbarBounds.left;
+ } else {
+ overlaps = textBounds.left < toolbarBounds.right;
+ }
+ this._toolbarContainerElement.classList.toggle("overlaps", overlaps);
+ }
+ },
+
+ _topScrollChange(entries) {
+ if (!entries.length) {
+ return;
+ }
+ // If we don't intersect the item at the top of the document, we're
+ // scrolled down:
+ let scrolled = !entries[entries.length - 1].isIntersecting;
+ let tbc = this._toolbarContainerElement;
+ tbc.classList.toggle("scrolled", scrolled);
+ },
+
+ /*
+ * Scroll reader view to a reference
+ */
+ _goToReference(ref) {
+ if (ref) {
+ if (this._doc.readyState == "complete") {
+ this._win.location.hash = ref;
+ } else {
+ this._win.addEventListener(
+ "load",
+ () => {
+ this._win.location.hash = ref;
+ },
+ { once: true }
+ );
+ }
+ }
+ },
+
+ _enableDismissCTA() {
+ let elDismissCta = this._doc.querySelector(`.pocket-dismiss-cta`);
+
+ elDismissCta?.addEventListener(`click`, e => {
+ this._doc.querySelector("#pocket-cta-container").hidden = true;
+
+ Services.telemetry.recordEvent(
+ "readermode",
+ "pocket_cta",
+ "close_cta",
+ null,
+ {}
+ );
+ });
+ },
+
+ _enableRecShowHide() {
+ let elPocketRecs = this._doc.querySelector(`.pocket-recs`);
+ let elCollapseRecs = this._doc.querySelector(`.pocket-collapse-recs`);
+ let elSignUp = this._doc.querySelector(`div.pocket-sign-up-wrapper`);
+
+ let toggleRecsVisibility = () => {
+ let isClosed = elPocketRecs.classList.contains(`closed`);
+
+ isClosed = !isClosed; // Toggle
+
+ if (isClosed) {
+ elPocketRecs.classList.add(`closed`);
+ elCollapseRecs.classList.add(`closed`);
+ elSignUp.setAttribute(`hidden`, true);
+
+ Services.telemetry.recordEvent(
+ "readermode",
+ "pocket_cta",
+ "minimize_recs_click",
+ null,
+ {}
+ );
+ } else {
+ elPocketRecs.classList.remove(`closed`);
+ elCollapseRecs.classList.remove(`closed`);
+ elSignUp.removeAttribute(`hidden`);
+ }
+ };
+
+ elCollapseRecs?.addEventListener(`click`, e => {
+ toggleRecsVisibility();
+ });
+ },
+
+ _buildPocketRec(title, url, publisher, thumb, time) {
+ let fragment = this._doc.createDocumentFragment();
+
+ let elContainer = this._doc.createElement(`div`);
+ let elTitle = this._doc.createElement(`header`);
+ let elMetadata = this._doc.createElement(`p`);
+ let elThumb = this._doc.createElement(`img`);
+ let elSideWrap = this._doc.createElement(`div`);
+ let elTop = this._doc.createElement(`a`);
+ let elBottom = this._doc.createElement(`div`);
+ let elAdd = this._doc.createElement(`button`);
+
+ elAdd.classList.add(`pocket-btn-add`);
+ elBottom.classList.add(`pocket-rec-bottom`);
+ elTop.classList.add(`pocket-rec-top`);
+ elSideWrap.classList.add(`pocket-rec-side`);
+ elContainer.classList.add(`pocket-rec`);
+ elTitle.classList.add(`pocket-rec-title`);
+ elMetadata.classList.add(`pocket-rec-meta`);
+
+ elTop.setAttribute(`href`, url);
+
+ elTop.addEventListener(`click`, e => {
+ Services.telemetry.recordEvent(
+ "readermode",
+ "pocket_cta",
+ "rec_click",
+ null,
+ {}
+ );
+ });
+
+ elThumb.classList.add(`pocket-rec-thumb`);
+ elThumb.setAttribute(`loading`, `lazy`);
+ elThumb.addEventListener(`load`, () => {
+ elThumb.classList.add(`pocket-rec-thumb-loaded`);
+ });
+ elThumb.setAttribute(
+ `src`,
+ `https://img-getpocket.cdn.mozilla.net/132x132/filters:format(jpeg):quality(60):no_upscale():strip_exif()/${thumb}`
+ );
+
+ elAdd.textContent = `Save`;
+ elTitle.textContent = title;
+
+ if (publisher && time) {
+ elMetadata.textContent = `${publisher} · ${time} min`;
+ } else if (publisher) {
+ elMetadata.textContent = `${publisher}`;
+ } else if (time) {
+ elMetadata.textContent = `${time} min`;
+ }
+
+ elSideWrap.appendChild(elTitle);
+ elSideWrap.appendChild(elMetadata);
+ elTop.appendChild(elSideWrap);
+ elTop.appendChild(elThumb);
+ elBottom.appendChild(elAdd);
+ elContainer.appendChild(elTop);
+ elContainer.appendChild(elBottom);
+ fragment.appendChild(elContainer);
+
+ elAdd.addEventListener(`click`, e => {
+ this._savePocketArticle(url);
+ elAdd.textContent = `Saved`;
+ elAdd.classList.add(`saved`);
+
+ Services.telemetry.recordEvent(
+ "readermode",
+ "pocket_cta",
+ "rec_saved",
+ null,
+ {}
+ );
+ });
+
+ return fragment;
+ },
+
+ async _getAndBuildPocketRecs() {
+ let elTarget = this._doc.querySelector(`.pocket-recs`);
+ let url = this._getOriginalUrl();
+ let itemID = await this._requestPocketArticleInfo(url);
+ let articleRecs = await this._requestPocketArticleRecs(itemID);
+
+ articleRecs.recommendations.forEach(rec => {
+ // Parse a domain from the article URL in case the Publisher name isn't available
+ let parsedDomain = new URL(rec.item?.normal_url)?.hostname;
+
+ // Calculate read time from word count in case it's not available
+ let calculatedReadTime = Math.ceil(rec.item?.word_count / 220);
+
+ let elRec = this._buildPocketRec(
+ rec.item?.title,
+ rec.item?.normal_url,
+ rec.item?.domain_metadata?.name || parsedDomain,
+ rec.item?.top_image_url,
+ rec.item?.time_to_read || calculatedReadTime
+ );
+
+ elTarget.appendChild(elRec);
+ });
+ },
+
+ _pocketCTAObserved(entries) {
+ if (entries && entries[0]?.isIntersecting) {
+ this._ctaIntersectionObserver.disconnect();
+
+ Services.telemetry.recordEvent(
+ "readermode",
+ "pocket_cta",
+ "cta_seen",
+ null,
+ {
+ logged_in: `${this._isLoggedInPocketUser}`,
+ }
+ );
+ }
+ },
+
+ async _setupPocketCTA() {
+ let ctaVersion =
+ lazy.NimbusFeatures.readerMode.getAllVariables()?.pocketCTAVersion;
+ this._isLoggedInPocketUser = await this._requestPocketLoginStatus();
+ let elPocketCTAWrapper = this._doc.querySelector("#pocket-cta-container");
+
+ // Show the Pocket CTA container if the pref is set and valid
+ if (ctaVersion === `cta-and-recs` || ctaVersion === `cta-only`) {
+ if (ctaVersion === `cta-and-recs` && this._isLoggedInPocketUser) {
+ this._getAndBuildPocketRecs();
+ this._enableRecShowHide();
+ } else if (ctaVersion === `cta-and-recs` && !this._isLoggedInPocketUser) {
+ // Fall back to cta only for logged out users:
+ ctaVersion = `cta-only`;
+ }
+
+ if (ctaVersion == `cta-only`) {
+ this._enableDismissCTA();
+ }
+
+ elPocketCTAWrapper.hidden = false;
+ elPocketCTAWrapper.classList.add(`pocket-cta-container-${ctaVersion}`);
+ elPocketCTAWrapper.classList.add(
+ `pocket-cta-container-${
+ this._isLoggedInPocketUser ? `logged-in` : `logged-out`
+ }`
+ );
+
+ // Set up tracking for sign up buttons
+ this._doc
+ .querySelectorAll(`.pocket-sign-up, .pocket-discover-more`)
+ .forEach(el => {
+ el.addEventListener(`click`, e => {
+ Services.telemetry.recordEvent(
+ "readermode",
+ "pocket_cta",
+ "sign_up_click",
+ null,
+ {}
+ );
+ });
+ });
+
+ // Set up tracking for user seeing CTA
+ this._ctaIntersectionObserver.observe(
+ this._doc.querySelector(`#pocket-cta-container`)
+ );
+ }
+ },
+};