diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /toolkit/components/reader/AboutReader.sys.mjs | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/reader/AboutReader.sys.mjs')
-rw-r--r-- | toolkit/components/reader/AboutReader.sys.mjs | 1548 |
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`) + ); + } + }, +}; |