gecko-dev/browser/components/newtab/lib/AboutPreferences.jsm

350 строки
14 KiB
JavaScript

/* 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/. */
"use strict";
const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
ChromeUtils.defineModuleGetter(this, "PluralForm", "resource://gre/modules/PluralForm.jsm");
const {actionTypes: at} = ChromeUtils.import("resource://activity-stream/common/Actions.jsm");
XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
XPCOMUtils.defineLazyModuleGetters(this, {
PreferenceExperiments: "resource://normandy/lib/PreferenceExperiments.jsm",
});
const HTML_NS = "http://www.w3.org/1999/xhtml";
const PREFERENCES_LOADED_EVENT = "home-pane-loaded";
const DISCOVERY_STREAM_CONFIG_PREF_NAME = "browser.newtabpage.activity-stream.discoverystream.config";
const PREF_SHOW_SPONSORED = "showSponsored";
// These "section" objects are formatted in a way to be similar to the ones from
// SectionsManager to construct the preferences view.
const PREFS_BEFORE_SECTIONS = [
{
id: "search",
pref: {
feed: "showSearch",
titleString: "prefs_search_header",
},
icon: "chrome://browser/skin/search-glass.svg",
},
{
id: "topsites",
pref: {
feed: "feeds.topsites",
titleString: "settings_pane_topsites_header",
descString: "prefs_topsites_description",
},
icon: "topsites",
maxRows: 4,
rowsPref: "topSitesRows",
},
];
const PREFS_AFTER_SECTIONS = [
{
id: "snippets",
pref: {
feed: "feeds.snippets",
titleString: "settings_pane_snippets_header",
descString: "prefs_snippets_description",
},
icon: "info",
},
];
// This CSS is added to the whole about:preferences page
const CUSTOM_CSS = `
#homeContentsGroup checkbox[src] .checkbox-icon {
margin-inline-end: 8px;
margin-inline-start: 4px;
width: 16px;
}
#homeContentsGroup [data-subcategory] {
margin-top: 14px;
}
#homeContentsGroup [data-subcategory] .section-checkbox {
font-weight: 600;
}
#homeContentsGroup [data-subcategory] > vbox menulist {
margin-top: 0;
margin-bottom: 0;
}
#discoveryContentsGroup .contentDiscoveryButton {
margin-inline-start: 0;
}
`;
this.AboutPreferences = class AboutPreferences {
init() {
Services.obs.addObserver(this, PREFERENCES_LOADED_EVENT);
}
uninit() {
Services.obs.removeObserver(this, PREFERENCES_LOADED_EVENT);
}
onAction(action) {
switch (action.type) {
case at.INIT:
this.init();
break;
case at.UNINIT:
this.uninit();
break;
case at.SETTINGS_OPEN:
action._target.browser.ownerGlobal.openPreferences("paneHome", {origin: "aboutHome"});
break;
// This is used to open the web extension settings page for an extension
case at.OPEN_WEBEXT_SETTINGS:
action._target.browser.ownerGlobal.BrowserOpenAddonsMgr(`addons://detail/${encodeURIComponent(action.data)}`);
break;
}
}
async observe(window) {
this.renderPreferences(window, await this.strings, [...PREFS_BEFORE_SECTIONS,
...this.store.getState().Sections, ...PREFS_AFTER_SECTIONS], this.store.getState().DiscoveryStream.config);
}
/**
* Get strings from a js file that the content page would have loaded. The
* file should be a single variable assignment of a JSON/JS object of strings.
*/
get strings() {
return this._strings || (this._strings = new Promise(async resolve => {
let data = {};
try {
const locale = Cc["@mozilla.org/browser/aboutnewtab-service;1"]
.getService(Ci.nsIAboutNewTabService).activityStreamLocale;
const request = await fetch(`resource://activity-stream/prerendered/${locale}/activity-stream-strings.js`);
const text = await request.text();
const [json] = text.match(/{[^]*}/);
data = JSON.parse(json);
} catch (ex) {
Cu.reportError("Failed to load strings for Activity Stream about:preferences");
}
resolve(data);
}));
}
/**
* Render preferences to an about:preferences content window with the provided
* strings and preferences structure.
*/
renderPreferences({document, Preferences, gHomePane}, strings, prefStructure, discoveryStreamConfig) {
// Helper to create a new element and append it
const createAppend = (tag, parent, options) => parent.appendChild(
document.createXULElement(tag, options));
// Helper to get strings and format with values if necessary
const formatString = id => {
if (typeof id !== "object") {
return strings[id] || id;
}
let string = strings[id.id] || JSON.stringify(id);
if (id.values) {
Object.entries(id.values).forEach(([key, val]) => {
string = string.replace(new RegExp(`{${key}}`, "g"), val);
});
}
return string;
};
// Helper to link a UI element to a preference for updating
const linkPref = (element, name, type) => {
const fullPref = `browser.newtabpage.activity-stream.${name}`;
element.setAttribute("preference", fullPref);
Preferences.add({id: fullPref, type});
// Prevent changing the UI if the preference can't be changed
element.disabled = Preferences.get(fullPref).locked;
};
// Add in custom styling
document.insertBefore(document.createProcessingInstruction("xml-stylesheet",
`href="data:text/css,${encodeURIComponent(CUSTOM_CSS)}" type="text/css"`),
document.documentElement);
// Both Topstories and Discovery Stream need to toggle the same pref but
// we can't have two elements linked to the same pref so we reuse the same.
let sponsoredStoriesCheckbox = null;
// Insert a new group immediately after the homepage one
const homeGroup = document.getElementById("homepageGroup");
const contentsGroup = homeGroup.insertAdjacentElement("afterend", homeGroup.cloneNode());
contentsGroup.id = "homeContentsGroup";
contentsGroup.setAttribute("data-subcategory", "contents");
createAppend("label", contentsGroup)
.appendChild(document.createElementNS(HTML_NS, "h2"))
.textContent = formatString("prefs_home_header");
createAppend("description", contentsGroup)
.textContent = formatString("prefs_home_description");
// Add preferences for each section
prefStructure.forEach(sectionData => {
const {
id,
pref: prefData,
icon = "webextension",
maxRows,
rowsPref,
shouldHidePref,
} = sectionData;
const {
feed: name,
titleString,
descString,
nestedPrefs = [],
} = prefData || {};
// Don't show any sections that we don't want to expose in preferences UI
if (shouldHidePref) {
return;
}
// Use full icon spec for certain protocols or fall back to packaged icon
const iconUrl = !icon.search(/^(chrome|moz-extension|resource):/) ? icon :
`resource://activity-stream/data/content/assets/glyph-${icon}-16.svg`;
// Add the main preference for turning on/off a section
const sectionVbox = createAppend("vbox", contentsGroup);
sectionVbox.setAttribute("data-subcategory", id);
const checkbox = createAppend("checkbox", sectionVbox);
checkbox.classList.add("section-checkbox");
checkbox.setAttribute("label", formatString(titleString));
checkbox.setAttribute("src", iconUrl);
linkPref(checkbox, name, "bool");
// Specially add a link for stories
if (id === "topstories") {
const sponsoredHbox = createAppend("hbox", sectionVbox);
sponsoredHbox.setAttribute("align", "center");
sponsoredHbox.appendChild(checkbox);
checkbox.classList.add("tail-with-learn-more");
const link = createAppend("label", sponsoredHbox, {is: "text-link"});
link.classList.add("learn-sponsored");
link.setAttribute("href", sectionData.learnMore.link.href);
link.textContent = formatString(sectionData.learnMore.link.id);
}
// Add more details for the section (e.g., description, more prefs)
const detailVbox = createAppend("vbox", sectionVbox);
detailVbox.classList.add("indent");
if (descString) {
const label = createAppend("label", detailVbox);
label.classList.add("indent");
label.textContent = formatString(descString);
// Add a rows dropdown if we have a pref to control and a maximum
if (rowsPref && maxRows) {
const detailHbox = createAppend("hbox", detailVbox);
detailHbox.setAttribute("align", "center");
label.setAttribute("flex", 1);
detailHbox.appendChild(label);
// Add box so the search tooltip is positioned correctly
const tooltipBox = createAppend("hbox", detailHbox);
// Add appropriate number of localized entries to the dropdown
const menulist = createAppend("menulist", tooltipBox);
menulist.setAttribute("crop", "none");
const menupopup = createAppend("menupopup", menulist);
for (let num = 1; num <= maxRows; num++) {
const plurals = formatString({id: "prefs_section_rows_option", values: {num}});
const item = createAppend("menuitem", menupopup);
item.setAttribute("label", PluralForm.get(num, plurals));
item.setAttribute("value", num);
}
linkPref(menulist, rowsPref, "int");
}
}
// Add a checkbox pref for any nested preferences
nestedPrefs.forEach(nested => {
const subcheck = createAppend("checkbox", detailVbox);
subcheck.classList.add("indent");
subcheck.setAttribute("label", formatString(nested.titleString));
linkPref(subcheck, nested.name, "bool");
if (nested.name === PREF_SHOW_SPONSORED) {
sponsoredStoriesCheckbox = subcheck;
}
});
});
if (discoveryStreamConfig.enabled) {
// If Discovery Stream is enabled hide Home Content options
contentsGroup.style.visibility = "collapse";
const discoveryGroup = homeGroup.insertAdjacentElement("afterend", homeGroup.cloneNode());
discoveryGroup.id = "discoveryContentsGroup";
discoveryGroup.setAttribute("data-subcategory", "discovery");
createAppend("label", discoveryGroup)
.appendChild(document.createElementNS(HTML_NS, "h2"))
.textContent = formatString("prefs_content_discovery_header");
const descriptionHbox = createAppend("hbox", discoveryGroup);
const discoveryGroupDescription = createAppend("description", descriptionHbox);
discoveryGroupDescription.textContent = formatString("prefs_content_discovery_description");
discoveryGroupDescription.classList.add("tail-with-learn-more");
// Add the Learn more link in the description
const topstoriesSection = prefStructure.find(s => s.id === "topstories");
const learnMoreURL = topstoriesSection && topstoriesSection.learnMore.link.href;
const link = createAppend("label", descriptionHbox);
link.classList.add("learn-sponsored");
link.classList.add("text-link");
link.setAttribute("href", learnMoreURL);
link.textContent = formatString("prefs_topstories_sponsored_learn_more");
if (discoveryStreamConfig.show_spocs && sponsoredStoriesCheckbox) {
sponsoredStoriesCheckbox.remove();
sponsoredStoriesCheckbox.classList.remove("indent");
discoveryGroup.appendChild(sponsoredStoriesCheckbox);
} else if (discoveryStreamConfig.show_spocs) {
// If there is no element to reuse create one
const discoveryDetails = createAppend("vbox", discoveryGroup);
const subcheck = createAppend("checkbox", discoveryDetails);
subcheck.setAttribute("label", formatString("prefs_topstories_options_sponsored_label"));
linkPref(subcheck, PREF_SHOW_SPONSORED, "bool");
}
const contentDiscoveryButton = document.createElementNS(HTML_NS, "button");
contentDiscoveryButton.classList.add("contentDiscoveryButton");
contentDiscoveryButton.textContent = formatString("prefs_content_discovery_button");
createAppend("hbox", discoveryGroup)
.appendChild(contentDiscoveryButton)
.addEventListener("click", async () => {
this.store.dispatch({type: at.DISCOVERY_STREAM_OPT_OUT});
const activeExperiments = await PreferenceExperiments.getAllActive();
const experiment = activeExperiments.find(exp => exp.preferenceName === DISCOVERY_STREAM_CONFIG_PREF_NAME);
// Unconditionally update the UI for a fast user response and in
// order to help with testing
discoveryGroup.style.display = "none";
contentsGroup.style.visibility = "";
if (sponsoredStoriesCheckbox) {
// If we reused the checkbox element we need to restore it
sponsoredStoriesCheckbox.remove();
sponsoredStoriesCheckbox.classList.add("indent");
const topstoriesDetails = document.querySelector("[data-subcategory='topstories'] .indent");
topstoriesDetails.appendChild(sponsoredStoriesCheckbox);
}
if (experiment) {
await PreferenceExperiments.stop(experiment.name, {
resetValue: true,
reason: "individual-opt-out",
});
}
}, {once: true});
}
// Update the visibility of the Restore Defaults btn based on checked prefs
gHomePane.toggleRestoreDefaultsBtn();
}
};
this.PREFERENCES_LOADED_EVENT = PREFERENCES_LOADED_EVENT;
const EXPORTED_SYMBOLS = ["AboutPreferences", "PREFERENCES_LOADED_EVENT"];