fune/toolkit/components/reader/AboutReader.sys.mjs

1557 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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/. */
const { ReaderMode } = ChromeUtils.import(
"resource://gre/modules/ReaderMode.jsm"
);
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",
});
ChromeUtils.defineModuleGetter(
lazy,
"NarrateControls",
"resource://gre/modules/narrate/NarrateControls.jsm"
);
ChromeUtils.defineModuleGetter(
lazy,
"NimbusFeatures",
"resource://nimbus/ExperimentAPI.jsm"
);
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`)
);
}
},
};