diff options
Diffstat (limited to 'toolkit/components/normandy/content')
6 files changed, 1211 insertions, 0 deletions
diff --git a/toolkit/components/normandy/content/AboutPages.sys.mjs b/toolkit/components/normandy/content/AboutPages.sys.mjs new file mode 100644 index 0000000000..5fc13c2724 --- /dev/null +++ b/toolkit/components/normandy/content/AboutPages.sys.mjs @@ -0,0 +1,232 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonStudies: "resource://normandy/lib/AddonStudies.sys.mjs", + BranchedAddonStudyAction: + "resource://normandy/actions/BranchedAddonStudyAction.sys.mjs", + ExperimentManager: "resource://nimbus/lib/ExperimentManager.sys.mjs", + PreferenceExperiments: + "resource://normandy/lib/PreferenceExperiments.sys.mjs", + RecipeRunner: "resource://normandy/lib/RecipeRunner.sys.mjs", + RemoteSettingsExperimentLoader: + "resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs", +}); + +const SHIELD_LEARN_MORE_URL_PREF = "app.normandy.shieldLearnMoreUrl"; +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "gOptOutStudiesEnabled", + "app.shield.optoutstudies.enabled" +); + +/** + * Class for managing an about: page that Normandy provides. Adapted from + * browser/components/pocket/content/AboutPocket.sys.mjs. + * + * @implements nsIFactory + * @implements nsIAboutModule + */ +class AboutPage { + constructor({ chromeUrl, aboutHost, classID, description, uriFlags }) { + this.chromeUrl = chromeUrl; + this.aboutHost = aboutHost; + this.classID = Components.ID(classID); + this.description = description; + this.uriFlags = uriFlags; + } + + getURIFlags() { + return this.uriFlags; + } + + newChannel(uri, loadInfo) { + const newURI = Services.io.newURI(this.chromeUrl); + const channel = Services.io.newChannelFromURIWithLoadInfo(newURI, loadInfo); + channel.originalURI = uri; + + if (this.uriFlags & Ci.nsIAboutModule.URI_SAFE_FOR_UNTRUSTED_CONTENT) { + const principal = Services.scriptSecurityManager.createContentPrincipal( + uri, + {} + ); + channel.owner = principal; + } + return channel; + } +} +AboutPage.prototype.QueryInterface = ChromeUtils.generateQI(["nsIAboutModule"]); + +/** + * The module exported by this file. + */ +export let AboutPages = {}; + +/** + * The weak set that keeps track of which browsing contexts + * have an about:studies page. + */ +let BrowsingContexts = new WeakSet(); +/** + * about:studies page for displaying in-progress and past Shield studies. + * @type {AboutPage} + * @implements {nsIMessageListener} + */ +ChromeUtils.defineLazyGetter(AboutPages, "aboutStudies", () => { + const aboutStudies = new AboutPage({ + chromeUrl: "resource://normandy-content/about-studies/about-studies.html", + aboutHost: "studies", + classID: "{6ab96943-a163-482c-9622-4faedc0e827f}", + description: "Shield Study Listing", + uriFlags: + Ci.nsIAboutModule.ALLOW_SCRIPT | + Ci.nsIAboutModule.URI_SAFE_FOR_UNTRUSTED_CONTENT | + Ci.nsIAboutModule.URI_MUST_LOAD_IN_CHILD | + Ci.nsIAboutModule.IS_SECURE_CHROME_UI, + }); + + // Extra methods for about:study-specific behavior. + Object.assign(aboutStudies, { + getAddonStudyList() { + return lazy.AddonStudies.getAll(); + }, + + getPreferenceStudyList() { + return lazy.PreferenceExperiments.getAll(); + }, + + getMessagingSystemList() { + return lazy.ExperimentManager.store.getAll(); + }, + + async optInToExperiment(data) { + try { + await lazy.RemoteSettingsExperimentLoader.optInToExperiment(data); + return { + error: false, + message: "Opt-in was successful.", + }; + } catch (error) { + return { + error: true, + message: error.message, + }; + } + }, + + /** Add a browsing context to the weak set; + * this weak set keeps track of all contexts + * that are housing an about:studies page. + */ + addToWeakSet(browsingContext) { + BrowsingContexts.add(browsingContext); + }, + /** Remove a browsing context to the weak set; + * this weak set keeps track of all contexts + * that are housing an about:studies page. + */ + removeFromWeakSet(browsingContext) { + BrowsingContexts.delete(browsingContext); + }, + + /** + * Sends a message to every about:studies page, + * by iterating over the BrowsingContexts weakset. + * @param {string} message The message string to send to. + * @param {object} data The data object to send. + */ + _sendToAll(message, data) { + ChromeUtils.nondeterministicGetWeakSetKeys(BrowsingContexts).forEach( + browser => + browser.currentWindowGlobal + .getActor("ShieldFrame") + .sendAsyncMessage(message, data) + ); + }, + + /** + * Get if studies are enabled. This has to be in the parent process, + * since RecipeRunner is stateful, and can't be interacted with from + * content processes safely. + */ + async getStudiesEnabled() { + await lazy.RecipeRunner.initializedPromise.promise; + return lazy.RecipeRunner.enabled && lazy.gOptOutStudiesEnabled; + }, + + /** + * Disable an active add-on study and remove its add-on. + * @param {String} recipeId the id of the addon to remove + * @param {String} reason the reason for removal + */ + async removeAddonStudy(recipeId, reason) { + try { + const action = new lazy.BranchedAddonStudyAction(); + await action.unenroll(recipeId, reason); + } catch (err) { + // If the exception was that the study was already removed, that's ok. + // If not, rethrow the error. + if (!err.toString().includes("already inactive")) { + throw err; + } + } finally { + // Update any open tabs with the new study list now that it has changed, + // even if the above failed. + this.getAddonStudyList().then(list => + this._sendToAll("Shield:UpdateAddonStudyList", list) + ); + } + }, + + /** + * Disable an active preference study. + * @param {String} experimentName the name of the experiment to remove + * @param {String} reason the reason for removal + */ + async removePreferenceStudy(experimentName, reason) { + try { + await lazy.PreferenceExperiments.stop(experimentName, { + reason, + caller: "AboutPages.removePreferenceStudy", + }); + } catch (err) { + // If the exception was that the study was already removed, that's ok. + // If not, rethrow the error. + if (!err.toString().includes("already expired")) { + throw err; + } + } finally { + // Update any open tabs with the new study list now that it has changed, + // even if the above failed. + this.getPreferenceStudyList().then(list => + this._sendToAll("Shield:UpdatePreferenceStudyList", list) + ); + } + }, + + async removeMessagingSystemExperiment(slug, reason) { + lazy.ExperimentManager.unenroll(slug, reason); + this._sendToAll( + "Shield:UpdateMessagingSystemExperimentList", + lazy.ExperimentManager.store.getAll() + ); + }, + + openDataPreferences() { + const browserWindow = + Services.wm.getMostRecentWindow("navigator:browser"); + browserWindow.openPreferences("privacy-reports"); + }, + + getShieldLearnMoreHref() { + return Services.urlFormatter.formatURLPref(SHIELD_LEARN_MORE_URL_PREF); + }, + }); + + return aboutStudies; +}); diff --git a/toolkit/components/normandy/content/ShieldFrameChild.sys.mjs b/toolkit/components/normandy/content/ShieldFrameChild.sys.mjs new file mode 100644 index 0000000000..96c57e1848 --- /dev/null +++ b/toolkit/components/normandy/content/ShieldFrameChild.sys.mjs @@ -0,0 +1,162 @@ +/* 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/. */ + +/** + * Listen for DOM events bubbling up from the about:studies page, and perform + * privileged actions in response to them. If we need to do anything that the + * content process can't handle (such as reading IndexedDB), we send a message + * to the parent process and handle it there. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AboutPages: "resource://normandy-content/AboutPages.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "gBrandBundle", function () { + return Services.strings.createBundle( + "chrome://branding/locale/brand.properties" + ); +}); + +ChromeUtils.defineLazyGetter(lazy, "gStringBundle", function () { + return Services.strings.createBundle( + "chrome://global/locale/aboutStudies.properties" + ); +}); + +/** + * Listen for DOM events bubbling up from the about:studies page, and perform + * privileged actions in response to them. If we need to do anything that the + * content process can't handle (such as reading IndexedDB), we send a message + * to the parent process and handle it there. + */ +export class ShieldFrameChild extends JSWindowActorChild { + async handleEvent(event) { + // On page show or page hide, + // add this child to the WeakSet in AboutStudies. + switch (event.type) { + case "pageshow": + this.sendAsyncMessage("Shield:AddToWeakSet"); + return; + + case "pagehide": + this.sendAsyncMessage("Shield:RemoveFromWeakSet"); + return; + } + switch (event.detail.action) { + // Actions that require the parent process + case "GetRemoteValue:AddonStudyList": + let addonStudies = await this.sendQuery("Shield:GetAddonStudyList"); + this.triggerPageCallback( + "ReceiveRemoteValue:AddonStudyList", + addonStudies + ); + break; + case "GetRemoteValue:PreferenceStudyList": + let prefStudies = await this.sendQuery("Shield:GetPreferenceStudyList"); + this.triggerPageCallback( + "ReceiveRemoteValue:PreferenceStudyList", + prefStudies + ); + break; + case "GetRemoteValue:MessagingSystemList": + let experiments = await this.sendQuery("Shield:GetMessagingSystemList"); + this.triggerPageCallback( + "ReceiveRemoteValue:MessagingSystemList", + experiments + ); + break; + case "RemoveAddonStudy": + this.sendAsyncMessage("Shield:RemoveAddonStudy", event.detail.data); + break; + case "RemovePreferenceStudy": + this.sendAsyncMessage( + "Shield:RemovePreferenceStudy", + event.detail.data + ); + break; + case "RemoveMessagingSystemExperiment": + this.sendAsyncMessage( + "Shield:RemoveMessagingSystemExperiment", + event.detail.data + ); + break; + case "GetRemoteValue:StudiesEnabled": + let studiesEnabled = await this.sendQuery("Shield:GetStudiesEnabled"); + this.triggerPageCallback( + "ReceiveRemoteValue:StudiesEnabled", + studiesEnabled + ); + break; + case "NavigateToDataPreferences": + this.sendAsyncMessage("Shield:OpenDataPreferences"); + break; + // Actions that can be performed in the content process + case "GetRemoteValue:ShieldLearnMoreHref": + this.triggerPageCallback( + "ReceiveRemoteValue:ShieldLearnMoreHref", + lazy.AboutPages.aboutStudies.getShieldLearnMoreHref() + ); + break; + case "GetRemoteValue:ShieldTranslations": + const strings = {}; + for (let str of lazy.gStringBundle.getSimpleEnumeration()) { + strings[str.key] = str.value; + } + const brandName = lazy.gBrandBundle.GetStringFromName("brandShortName"); + strings.enabledList = lazy.gStringBundle.formatStringFromName( + "enabledList", + [brandName] + ); + + this.triggerPageCallback( + "ReceiveRemoteValue:ShieldTranslations", + strings + ); + break; + case "ExperimentOptIn": + const message = await this.sendQuery( + "Shield:ExperimentOptIn", + event.detail.data + ); + this.triggerPageCallback("ReceiveRemoteValue:OptInMessage", message); + break; + } + } + + receiveMessage(msg) { + switch (msg.name) { + case "Shield:UpdateAddonStudyList": + this.triggerPageCallback("ReceiveRemoteValue:AddonStudyList", msg.data); + break; + case "Shield:UpdatePreferenceStudyList": + this.triggerPageCallback( + "ReceiveRemoteValue:PreferenceStudyList", + msg.data + ); + break; + case "Shield:UpdateMessagingSystemExperimentList": + this.triggerPageCallback( + "ReceiveRemoteValue:MessagingSystemList", + msg.data + ); + break; + } + } + /** + * Trigger an event to communicate with the unprivileged about:studies page. + * @param {String} type The type of event to trigger. + * @param {Object} detail The data to pass along to the event. + */ + triggerPageCallback(type, detail) { + // Clone details and use the event class from the unprivileged context. + const event = new this.document.defaultView.CustomEvent(type, { + bubbles: true, + detail: Cu.cloneInto(detail, this.document.defaultView), + }); + this.document.dispatchEvent(event); + } +} diff --git a/toolkit/components/normandy/content/ShieldFrameParent.sys.mjs b/toolkit/components/normandy/content/ShieldFrameParent.sys.mjs new file mode 100644 index 0000000000..73aa620474 --- /dev/null +++ b/toolkit/components/normandy/content/ShieldFrameParent.sys.mjs @@ -0,0 +1,53 @@ +/* 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 lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AboutPages: "resource://normandy-content/AboutPages.sys.mjs", +}); + +export class ShieldFrameParent extends JSWindowActorParent { + async receiveMessage(msg) { + let { aboutStudies } = lazy.AboutPages; + switch (msg.name) { + case "Shield:AddToWeakSet": + aboutStudies.addToWeakSet(this.browsingContext); + break; + case "Shield:RemoveFromWeakSet": + aboutStudies.removeFromWeakSet(this.browsingContext); + break; + case "Shield:GetAddonStudyList": + return aboutStudies.getAddonStudyList(); + case "Shield:GetPreferenceStudyList": + return aboutStudies.getPreferenceStudyList(); + case "Shield:GetMessagingSystemList": + return aboutStudies.getMessagingSystemList(); + case "Shield:RemoveAddonStudy": + aboutStudies.removeAddonStudy(msg.data.recipeId, msg.data.reason); + break; + case "Shield:RemovePreferenceStudy": + aboutStudies.removePreferenceStudy( + msg.data.experimentName, + msg.data.reason + ); + break; + case "Shield:RemoveMessagingSystemExperiment": + aboutStudies.removeMessagingSystemExperiment( + msg.data.slug, + msg.data.reason + ); + break; + case "Shield:OpenDataPreferences": + aboutStudies.openDataPreferences(); + break; + case "Shield:GetStudiesEnabled": + return aboutStudies.getStudiesEnabled(); + case "Shield:ExperimentOptIn": + return aboutStudies.optInToExperiment(msg.data); + } + + return null; + } +} diff --git a/toolkit/components/normandy/content/about-studies/about-studies.css b/toolkit/components/normandy/content/about-studies/about-studies.css new file mode 100644 index 0000000000..76819ba423 --- /dev/null +++ b/toolkit/components/normandy/content/about-studies/about-studies.css @@ -0,0 +1,176 @@ +/* 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/. */ + +:root { + --icon-background-color-1: #0A84FF; + --icon-background-color-2: #008EA4; + --icon-background-color-3: #ED00B5; + --icon-background-color-4: #058B00; + --icon-background-color-5: #A47F00; + --icon-background-color-6: #FF0039; + --icon-background-disabled-color: #737373; + --body-text-disabled-color: #737373; + --study-status-active-color: #058B00; + --study-status-disabled-color: #737373; +} + +html, +body, +#app { + height: 100%; + width: 100%; +} + +button > .button-box { + padding-inline: 10px; +} + +.about-studies-container { + max-width: 960px; + margin: 0 auto; +} + +.info-box { + margin-bottom: 10px; + text-align: center; +} + +.info-box-content { + align-items: center; + background: var(--in-content-box-info-background); + border: 1px solid var(--in-content-border-color); + display: inline-flex; + padding: 10px 15px; +} + +.info-box-content > * { + margin-right: 10px; +} + +.info-box-content > *:last-child { + margin-right: 0; +} + +.study-list { + list-style-type: none; + margin: 0; + padding: 0; +} + +.study { + align-items: center; + border-bottom: 1px solid var(--in-content-border-color); + display: flex; + flex-direction: row; + padding: 10px; +} + +.study.disabled { + color: var(--body-text-disabled-color); +} + +.study .study-status { + color: var(--study-status-active-color); + font-weight: bold; +} + +.study.disabled .study-status { + color: var(--study-status-disabled-color); +} + +.study:last-child { + border-bottom: none; +} + +.study > * { + margin-right: 15px; +} + +.study > *:last-child { + margin-right: 0; +} + +.study-icon { + color: #FFF; + flex: 0 0 40px; + font-size: 26px; + height: 40px; + line-height: 40px; + text-align: center; + text-transform: capitalize; +} + +.study:nth-child(6n+0) .study-icon { + background: var(--icon-background-color-1); +} + +.study:nth-child(6n+1) .study-icon { + background: var(--icon-background-color-2); +} + +.study:nth-child(6n+2) .study-icon { + background: var(--icon-background-color-3); +} + +.study:nth-child(6n+3) .study-icon { + background: var(--icon-background-color-4); +} + +.study:nth-child(6n+4) .study-icon { + background: var(--icon-background-color-5); +} + +.study:nth-child(6n+5) .study-icon { + background: var(--icon-background-color-6); +} + +.study.disabled .study-icon { + background: var(--icon-background-disabled-color); +} + +.study-details { + flex: 1; + overflow: hidden; +} + +.study-name { + font-weight: bold; +} + +.study-header { + margin-bottom: .3em; +} + +.study-header > * { + margin-right: 5px; +} + +.study-header > *:last-child { + margin-right: 0; +} + +.study-description code { + background-color: rgb(128, 128, 128, 0.1); + border-radius: 3px; + box-sizing: border-box; + color: var(--in-content-text-color); + font-size: 85%; + font-family: 'Fira Mono', 'mono', monospace; + padding: .05em .4em; +} + +.study-actions { + flex: 0 0; +} + +.opt-in-box { + border-radius: 3px; + padding: 10px; + color: var(--study-status-active-color); + border: 1px solid; +} + +.opt-in-box.opt-in-error { + color: var(--text-color-error); +} diff --git a/toolkit/components/normandy/content/about-studies/about-studies.html b/toolkit/components/normandy/content/about-studies/about-studies.html new file mode 100644 index 0000000000..e6f1347227 --- /dev/null +++ b/toolkit/components/normandy/content/about-studies/about-studies.html @@ -0,0 +1,29 @@ +<!-- 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/. --> +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <meta name="color-scheme" content="light dark" /> + <meta + http-equiv="Content-Security-Policy" + content="default-src chrome:; script-src resource:; style-src resource: chrome:; object-src 'none'" + /> + <title>about:studies</title> + <link rel="stylesheet" href="chrome://global/skin/global.css" /> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> + <link + rel="stylesheet" + href="resource://normandy-content/about-studies/about-studies.css" + /> + </head> + <body> + <div id="app"></div> + <script src="resource://normandy-vendor/React.js"></script> + <script src="resource://normandy-vendor/ReactDOM.js"></script> + <script src="resource://normandy-vendor/PropTypes.js"></script> + <script src="resource://normandy-vendor/classnames.js"></script> + <script src="resource://normandy-content/about-studies/about-studies.js"></script> + </body> +</html> diff --git a/toolkit/components/normandy/content/about-studies/about-studies.js b/toolkit/components/normandy/content/about-studies/about-studies.js new file mode 100644 index 0000000000..b43c1b32d8 --- /dev/null +++ b/toolkit/components/normandy/content/about-studies/about-studies.js @@ -0,0 +1,559 @@ +/* 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"; +/* global classnames PropTypes React ReactDOM */ + +/** + * Shorthand for creating elements (to avoid using a JSX preprocessor) + */ +const r = React.createElement; + +/** + * Dispatches a page event to the privileged frame script for this tab. + * @param {String} action + * @param {Object} data + */ +function sendPageEvent(action, data) { + const event = new CustomEvent("ShieldPageEvent", { + bubbles: true, + detail: { action, data }, + }); + document.dispatchEvent(event); +} + +function readOptinParams() { + let searchParams = new URLSearchParams(new URL(location).search); + return { + slug: searchParams.get("optin_slug"), + branch: searchParams.get("optin_branch"), + collection: searchParams.get("optin_collection"), + applyTargeting: !!searchParams.get("apply_targeting"), + }; +} + +/** + * Handle basic layout and routing within about:studies. + */ +class AboutStudies extends React.Component { + constructor(props) { + super(props); + + this.remoteValueNameMap = { + AddonStudyList: "addonStudies", + PreferenceStudyList: "prefStudies", + MessagingSystemList: "experiments", + ShieldLearnMoreHref: "learnMoreHref", + StudiesEnabled: "studiesEnabled", + ShieldTranslations: "translations", + }; + + this.state = {}; + for (const stateName of Object.values(this.remoteValueNameMap)) { + this.state[stateName] = null; + } + this.state.optInMessage = false; + } + + initializeData() { + for (const remoteName of Object.keys(this.remoteValueNameMap)) { + document.addEventListener(`ReceiveRemoteValue:${remoteName}`, this); + sendPageEvent(`GetRemoteValue:${remoteName}`); + } + } + + componentWillMount() { + let optinParams = readOptinParams(); + if (optinParams.branch && optinParams.slug) { + const onOptIn = ({ detail: value }) => { + this.setState({ optInMessage: value }); + this.initializeData(); + document.removeEventListener( + `ReceiveRemoteValue:OptInMessage`, + onOptIn + ); + }; + document.addEventListener(`ReceiveRemoteValue:OptInMessage`, onOptIn); + sendPageEvent(`ExperimentOptIn`, optinParams); + } else { + this.initializeData(); + } + } + + componentWillUnmount() { + for (const remoteName of Object.keys(this.remoteValueNameMap)) { + document.removeEventListener(`ReceiveRemoteValue:${remoteName}`, this); + } + } + + /** Event handle to receive remote values from documentAddEventListener */ + handleEvent({ type, detail: value }) { + const prefix = "ReceiveRemoteValue:"; + if (type.startsWith(prefix)) { + const name = type.substring(prefix.length); + this.setState({ [this.remoteValueNameMap[name]]: value }); + } + } + + render() { + const { + translations, + learnMoreHref, + studiesEnabled, + addonStudies, + prefStudies, + experiments, + optInMessage, + } = this.state; + // Wait for all values to be loaded before rendering. Some of the values may + // be falsey, so an explicit null check is needed. + if (Object.values(this.state).some(v => v === null)) { + return null; + } + + return r( + "div", + { className: "about-studies-container main-content" }, + r(WhatsThisBox, { translations, learnMoreHref, studiesEnabled }), + optInMessage && r(OptInBox, optInMessage), + r(StudyList, { + translations, + addonStudies, + prefStudies, + experiments, + }) + ); + } +} + +/** + * Explains the contents of the page, and offers a way to learn more and update preferences. + */ +class WhatsThisBox extends React.Component { + handleUpdateClick() { + sendPageEvent("NavigateToDataPreferences"); + } + + render() { + const { learnMoreHref, studiesEnabled, translations } = this.props; + + return r( + "div", + { className: "info-box" }, + r( + "div", + { className: "info-box-content" }, + r( + "span", + {}, + studiesEnabled ? translations.enabledList : translations.disabledList + ), + r( + "a", + { id: "shield-studies-learn-more", href: learnMoreHref }, + translations.learnMore + ), + + r( + "button", + { + id: "shield-studies-update-preferences", + onClick: this.handleUpdateClick, + }, + r( + "div", + { className: "button-box" }, + navigator.platform.includes("Win") + ? translations.updateButtonWin + : translations.updateButtonUnix + ) + ) + ) + ); + } +} +/**OptInMessage + * Explains the contents of the page, and offers a way to learn more and update preferences. + */ +function OptInBox({ error, message }) { + return r( + "div", + { className: "opt-in-box" + (error ? " opt-in-error" : "") }, + message + ); +} + +/** + * Shows a list of studies, with an option to end in-progress ones. + */ +class StudyList extends React.Component { + render() { + const { addonStudies, prefStudies, translations, experiments } = this.props; + + if (!addonStudies.length && !prefStudies.length && !experiments.length) { + return r("p", { className: "study-list-info" }, translations.noStudies); + } + + const activeStudies = []; + const inactiveStudies = []; + + // Since we are modifying the study objects, it is polite to make copies + for (const study of addonStudies) { + const clonedStudy = Object.assign({}, study, { + type: "addon", + sortDate: study.studyStartDate, + }); + if (study.active) { + activeStudies.push(clonedStudy); + } else { + inactiveStudies.push(clonedStudy); + } + } + + for (const study of prefStudies) { + const clonedStudy = Object.assign({}, study, { + type: "pref", + sortDate: new Date(study.lastSeen), + }); + if (study.expired) { + inactiveStudies.push(clonedStudy); + } else { + activeStudies.push(clonedStudy); + } + } + + for (const study of experiments) { + const clonedStudy = Object.assign({}, study, { + type: study.experimentType, + sortDate: new Date(study.lastSeen), + }); + if (!study.active && !study.isRollout) { + inactiveStudies.push(clonedStudy); + } else { + activeStudies.push(clonedStudy); + } + } + + activeStudies.sort((a, b) => b.sortDate - a.sortDate); + inactiveStudies.sort((a, b) => b.sortDate - a.sortDate); + return r( + "div", + {}, + r("h2", {}, translations.activeStudiesList), + r( + "ul", + { className: "study-list active-study-list" }, + activeStudies.map(study => { + if (study.type === "addon") { + return r(AddonStudyListItem, { + key: study.slug, + study, + translations, + }); + } + if (study.type === "nimbus" || study.type === "rollout") { + return r(MessagingSystemListItem, { + key: study.slug, + study, + translations, + }); + } + if (study.type === "pref") { + return r(PreferenceStudyListItem, { + key: study.slug, + study, + translations, + }); + } + return null; + }) + ), + r("h2", {}, translations.completedStudiesList), + r( + "ul", + { className: "study-list inactive-study-list" }, + inactiveStudies.map(study => { + if (study.type === "addon") { + return r(AddonStudyListItem, { + key: study.slug, + study, + translations, + }); + } + if ( + study.type === "nimbus" || + study.type === "messaging_experiment" + ) { + return r(MessagingSystemListItem, { + key: study.slug, + study, + translations, + }); + } + if (study.type === "pref") { + return r(PreferenceStudyListItem, { + key: study.slug, + study, + translations, + }); + } + return null; + }) + ) + ); + } +} +StudyList.propTypes = { + addonStudies: PropTypes.array.isRequired, + translations: PropTypes.object.isRequired, +}; + +class MessagingSystemListItem extends React.Component { + constructor(props) { + super(props); + this.handleClickRemove = this.handleClickRemove.bind(this); + } + + handleClickRemove() { + sendPageEvent("RemoveMessagingSystemExperiment", { + slug: this.props.study.slug, + reason: "individual-opt-out", + }); + } + + render() { + const { study, translations } = this.props; + const userFacingName = study.userFacingName || study.slug; + const userFacingDescription = + study.userFacingDescription || "Nimbus experiment."; + return r( + "li", + { + className: classnames("study nimbus", { + disabled: !study.active, + }), + "data-study-slug": study.slug, // used to identify this row in tests + }, + r("div", { className: "study-icon" }, userFacingName.slice(0, 1)), + r( + "div", + { className: "study-details" }, + r( + "div", + { className: "study-header" }, + r("span", { className: "study-name" }, userFacingName), + r("span", {}, "\u2022"), // • + !study.isRollout && [ + r("span", { className: "study-branch-slug" }, study.branch.slug), + r("span", {}, "\u2022"), // • + ], + r( + "span", + { className: "study-status" }, + study.active + ? translations.activeStatus + : translations.completeStatus + ) + ), + r("div", { className: "study-description" }, userFacingDescription) + ), + r( + "div", + { className: "study-actions" }, + study.active && + r( + "button", + { className: "remove-button", onClick: this.handleClickRemove }, + r("div", { className: "button-box" }, translations.removeButton) + ) + ) + ); + } +} + +/** + * Details about an individual add-on study, with an option to end it if it is active. + */ +class AddonStudyListItem extends React.Component { + constructor(props) { + super(props); + this.handleClickRemove = this.handleClickRemove.bind(this); + } + + handleClickRemove() { + sendPageEvent("RemoveAddonStudy", { + recipeId: this.props.study.recipeId, + reason: "individual-opt-out", + }); + } + + render() { + const { study, translations } = this.props; + return r( + "li", + { + className: classnames("study addon-study", { disabled: !study.active }), + "data-study-slug": study.slug, // used to identify this row in tests + }, + r( + "div", + { className: "study-icon" }, + study.userFacingName + .replace(/-?add-?on-?/i, "") + .replace(/-?study-?/i, "") + .slice(0, 1) + ), + r( + "div", + { className: "study-details" }, + r( + "div", + { className: "study-header" }, + r("span", { className: "study-name" }, study.userFacingName), + r("span", {}, "\u2022"), // • + r( + "span", + { className: "study-status" }, + study.active + ? translations.activeStatus + : translations.completeStatus + ) + ), + r( + "div", + { className: "study-description" }, + study.userFacingDescription + ) + ), + r( + "div", + { className: "study-actions" }, + study.active && + r( + "button", + { className: "remove-button", onClick: this.handleClickRemove }, + r("div", { className: "button-box" }, translations.removeButton) + ) + ) + ); + } +} +AddonStudyListItem.propTypes = { + study: PropTypes.shape({ + recipeId: PropTypes.number.isRequired, + slug: PropTypes.string.isRequired, + userFacingName: PropTypes.string.isRequired, + active: PropTypes.bool.isRequired, + userFacingDescription: PropTypes.string.isRequired, + }).isRequired, + translations: PropTypes.object.isRequired, +}; + +/** + * Details about an individual preference study, with an option to end it if it is active. + */ +class PreferenceStudyListItem extends React.Component { + constructor(props) { + super(props); + this.handleClickRemove = this.handleClickRemove.bind(this); + } + + handleClickRemove() { + sendPageEvent("RemovePreferenceStudy", { + experimentName: this.props.study.slug, + reason: "individual-opt-out", + }); + } + + render() { + const { study, translations } = this.props; + + let iconLetter = (study.userFacingName || study.slug) + .replace(/-?pref-?(flip|study)-?/, "") + .replace(/-?study-?/, "") + .slice(0, 1) + .toUpperCase(); + + let description = study.userFacingDescription; + if (!description) { + // Assume there is exactly one preference (old-style preference experiment). + const [preferenceName, { preferenceValue }] = Object.entries( + study.preferences + )[0]; + // Sanitize the values by setting them as the text content of an element, + // and then getting the HTML representation of that text. This will have the + // browser safely sanitize them. Use outerHTML to also include the <code> + // element in the string. + const sanitizer = document.createElement("code"); + sanitizer.textContent = preferenceName; + const sanitizedPreferenceName = sanitizer.outerHTML; + sanitizer.textContent = preferenceValue; + const sanitizedPreferenceValue = sanitizer.outerHTML; + description = translations.preferenceStudyDescription + .replace(/%(?:1\$)?S/, sanitizedPreferenceName) + .replace(/%(?:2\$)?S/, sanitizedPreferenceValue); + } + + return r( + "li", + { + className: classnames("study pref-study", { disabled: study.expired }), + "data-study-slug": study.slug, // used to identify this row in tests + }, + r("div", { className: "study-icon" }, iconLetter), + r( + "div", + { className: "study-details" }, + r( + "div", + { className: "study-header" }, + r( + "span", + { className: "study-name" }, + study.userFacingName || study.slug + ), + r("span", {}, "\u2022"), // • + r( + "span", + { className: "study-status" }, + study.expired + ? translations.completeStatus + : translations.activeStatus + ) + ), + r("div", { + className: "study-description", + dangerouslySetInnerHTML: { __html: description }, + }) + ), + r( + "div", + { className: "study-actions" }, + !study.expired && + r( + "button", + { className: "remove-button", onClick: this.handleClickRemove }, + r("div", { className: "button-box" }, translations.removeButton) + ) + ) + ); + } +} +PreferenceStudyListItem.propTypes = { + study: PropTypes.shape({ + slug: PropTypes.string.isRequired, + userFacingName: PropTypes.string, + userFacingDescription: PropTypes.string, + expired: PropTypes.bool.isRequired, + preferenceName: PropTypes.string.isRequired, + preferenceValue: PropTypes.oneOf( + PropTypes.string, + PropTypes.bool, + PropTypes.number + ).isRequired, + }).isRequired, + translations: PropTypes.object.isRequired, +}; + +ReactDOM.render(r(AboutStudies), document.getElementById("app")); |