summaryrefslogtreecommitdiffstats
path: root/toolkit/components/normandy/content
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 01:47:29 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 01:47:29 +0000
commit0ebf5bdf043a27fd3dfb7f92e0cb63d88954c44d (patch)
treea31f07c9bcca9d56ce61e9a1ffd30ef350d513aa /toolkit/components/normandy/content
parentInitial commit. (diff)
downloadfirefox-esr-0ebf5bdf043a27fd3dfb7f92e0cb63d88954c44d.tar.xz
firefox-esr-0ebf5bdf043a27fd3dfb7f92e0cb63d88954c44d.zip
Adding upstream version 115.8.0esr.upstream/115.8.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/normandy/content')
-rw-r--r--toolkit/components/normandy/content/AboutPages.sys.mjs232
-rw-r--r--toolkit/components/normandy/content/ShieldFrameChild.sys.mjs172
-rw-r--r--toolkit/components/normandy/content/ShieldFrameParent.sys.mjs53
-rw-r--r--toolkit/components/normandy/content/about-studies/about-studies.css176
-rw-r--r--toolkit/components/normandy/content/about-studies/about-studies.html29
-rw-r--r--toolkit/components/normandy/content/about-studies/about-studies.js562
6 files changed, 1224 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..fedf85c2e8
--- /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}
+ */
+XPCOMUtils.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..8ca4110f6d
--- /dev/null
+++ b/toolkit/components/normandy/content/ShieldFrameChild.sys.mjs
@@ -0,0 +1,172 @@
+/* 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.
+ */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AboutPages: "resource://normandy-content/AboutPages.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "gBrandBundle", function () {
+ return Services.strings.createBundle(
+ "chrome://branding/locale/brand.properties"
+ );
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "gStringBundle", function () {
+ return Services.strings.createBundle(
+ "chrome://global/locale/aboutStudies.properties"
+ );
+});
+
+const NIMBUS_DEBUG_PREF = "nimbus.debug";
+
+/**
+ * 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 "GetRemoteValue:DebugModeOn":
+ this.triggerPageCallback(
+ "ReceiveRemoteValue:DebugModeOn",
+ Services.prefs.getBoolPref(NIMBUS_DEBUG_PREF)
+ );
+ 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..2c072a7a1e
--- /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(--in-content-error-text-color);
+}
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..c5a2319ac2
--- /dev/null
+++ b/toolkit/components/normandy/content/about-studies/about-studies.js
@@ -0,0 +1,562 @@
+/* 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"),
+ };
+}
+
+/**
+ * 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",
+ DebugModeOn: "debugMode",
+ };
+
+ 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,
+ debugMode,
+ } = 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,
+ debugMode,
+ })
+ );
+ }
+}
+
+/**
+ * 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, debugMode } =
+ 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,
+ debugMode,
+ });
+ }
+ 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, debugMode } = this.props;
+ const userFacingName = study.userFacingName || study.slug;
+ const userFacingDescription =
+ study.userFacingDescription || "Nimbus experiment.";
+ if (study.isRollout && !debugMode) {
+ return null;
+ }
+ 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"), // &bullet;
+ 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"), // &bullet;
+ 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"), // &bullet;
+ 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"));