559 lines
16 KiB
JavaScript
559 lines
16 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";
|
|
/* 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 URL(location);
|
|
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.isRollout ? "rollout" : "nimbus",
|
|
sortDate: new Date(study.lastSeen),
|
|
});
|
|
if (!study.active) {
|
|
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" ||
|
|
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;
|
|
})
|
|
)
|
|
);
|
|
}
|
|
}
|
|
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,
|
|
});
|
|
}
|
|
|
|
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"));
|