1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 /* global classnames PropTypes React ReactDOM */
9 * Shorthand for creating elements (to avoid using a JSX preprocessor)
11 const r
= React
.createElement
;
14 * Dispatches a page event to the privileged frame script for this tab.
15 * @param {String} action
16 * @param {Object} data
18 function sendPageEvent(action
, data
) {
19 const event
= new CustomEvent("ShieldPageEvent", {
21 detail
: { action
, data
},
23 document
.dispatchEvent(event
);
26 function readOptinParams() {
27 let searchParams
= new URLSearchParams(new URL(location
).search
);
29 slug
: searchParams
.get("optin_slug"),
30 branch
: searchParams
.get("optin_branch"),
31 collection
: searchParams
.get("optin_collection"),
32 applyTargeting
: !!searchParams
.get("apply_targeting"),
37 * Handle basic layout and routing within about:studies.
39 class AboutStudies
extends React
.Component
{
43 this.remoteValueNameMap
= {
44 AddonStudyList
: "addonStudies",
45 PreferenceStudyList
: "prefStudies",
46 MessagingSystemList
: "experiments",
47 ShieldLearnMoreHref
: "learnMoreHref",
48 StudiesEnabled
: "studiesEnabled",
49 ShieldTranslations
: "translations",
53 for (const stateName
of Object
.values(this.remoteValueNameMap
)) {
54 this.state
[stateName
] = null;
56 this.state
.optInMessage
= false;
60 for (const remoteName
of Object
.keys(this.remoteValueNameMap
)) {
61 document
.addEventListener(`ReceiveRemoteValue:${remoteName}`, this);
62 sendPageEvent(`GetRemoteValue:${remoteName}`);
66 componentWillMount() {
67 let optinParams
= readOptinParams();
68 if (optinParams
.branch
&& optinParams
.slug
) {
69 const onOptIn
= ({ detail
: value
}) => {
70 this.setState({ optInMessage
: value
});
71 this.initializeData();
72 document
.removeEventListener(
73 `ReceiveRemoteValue:OptInMessage`,
77 document
.addEventListener(`ReceiveRemoteValue:OptInMessage`, onOptIn
);
78 sendPageEvent(`ExperimentOptIn`, optinParams
);
80 this.initializeData();
84 componentWillUnmount() {
85 for (const remoteName
of Object
.keys(this.remoteValueNameMap
)) {
86 document
.removeEventListener(`ReceiveRemoteValue:${remoteName}`, this);
90 /** Event handle to receive remote values from documentAddEventListener */
91 handleEvent({ type
, detail
: value
}) {
92 const prefix
= "ReceiveRemoteValue:";
93 if (type
.startsWith(prefix
)) {
94 const name
= type
.substring(prefix
.length
);
95 this.setState({ [this.remoteValueNameMap
[name
]]: value
});
109 // Wait for all values to be loaded before rendering. Some of the values may
110 // be falsey, so an explicit null check is needed.
111 if (Object
.values(this.state
).some(v
=> v
=== null)) {
117 { className
: "about-studies-container main-content" },
118 r(WhatsThisBox
, { translations
, learnMoreHref
, studiesEnabled
}),
119 optInMessage
&& r(OptInBox
, optInMessage
),
131 * Explains the contents of the page, and offers a way to learn more and update preferences.
133 class WhatsThisBox
extends React
.Component
{
134 handleUpdateClick() {
135 sendPageEvent("NavigateToDataPreferences");
139 const { learnMoreHref
, studiesEnabled
, translations
} = this.props
;
143 { className
: "info-box" },
146 { className
: "info-box-content" },
150 studiesEnabled
? translations
.enabledList
: translations
.disabledList
154 { id
: "shield-studies-learn-more", href
: learnMoreHref
},
155 translations
.learnMore
161 id
: "shield-studies-update-preferences",
162 onClick
: this.handleUpdateClick
,
166 { className
: "button-box" },
167 navigator
.platform
.includes("Win")
168 ? translations
.updateButtonWin
169 : translations
.updateButtonUnix
177 * Explains the contents of the page, and offers a way to learn more and update preferences.
179 function OptInBox({ error
, message
}) {
182 { className
: "opt-in-box" + (error
? " opt-in-error" : "") },
188 * Shows a list of studies, with an option to end in-progress ones.
190 class StudyList
extends React
.Component
{
192 const { addonStudies
, prefStudies
, translations
, experiments
} = this.props
;
194 if (!addonStudies
.length
&& !prefStudies
.length
&& !experiments
.length
) {
195 return r("p", { className
: "study-list-info" }, translations
.noStudies
);
198 const activeStudies
= [];
199 const inactiveStudies
= [];
201 // Since we are modifying the study objects, it is polite to make copies
202 for (const study
of addonStudies
) {
203 const clonedStudy
= Object
.assign({}, study
, {
205 sortDate
: study
.studyStartDate
,
208 activeStudies
.push(clonedStudy
);
210 inactiveStudies
.push(clonedStudy
);
214 for (const study
of prefStudies
) {
215 const clonedStudy
= Object
.assign({}, study
, {
217 sortDate
: new Date(study
.lastSeen
),
220 inactiveStudies
.push(clonedStudy
);
222 activeStudies
.push(clonedStudy
);
226 for (const study
of experiments
) {
227 const clonedStudy
= Object
.assign({}, study
, {
228 type
: study
.experimentType
,
229 sortDate
: new Date(study
.lastSeen
),
232 inactiveStudies
.push(clonedStudy
);
234 activeStudies
.push(clonedStudy
);
238 activeStudies
.sort((a
, b
) => b
.sortDate
- a
.sortDate
);
239 inactiveStudies
.sort((a
, b
) => b
.sortDate
- a
.sortDate
);
243 r("h2", {}, translations
.activeStudiesList
),
246 { className
: "study-list active-study-list" },
247 activeStudies
.map(study
=> {
248 if (study
.type
=== "addon") {
249 return r(AddonStudyListItem
, {
255 if (study
.type
=== "nimbus" || study
.type
=== "rollout") {
256 return r(MessagingSystemListItem
, {
262 if (study
.type
=== "pref") {
263 return r(PreferenceStudyListItem
, {
272 r("h2", {}, translations
.completedStudiesList
),
275 { className
: "study-list inactive-study-list" },
276 inactiveStudies
.map(study
=> {
277 if (study
.type
=== "addon") {
278 return r(AddonStudyListItem
, {
285 study
.type
=== "nimbus" ||
286 study
.type
=== "messaging_experiment" ||
287 study
.type
=== "rollout"
289 return r(MessagingSystemListItem
, {
295 if (study
.type
=== "pref") {
296 return r(PreferenceStudyListItem
, {
308 StudyList
.propTypes
= {
309 addonStudies
: PropTypes
.array
.isRequired
,
310 translations
: PropTypes
.object
.isRequired
,
313 class MessagingSystemListItem
extends React
.Component
{
316 this.handleClickRemove
= this.handleClickRemove
.bind(this);
319 handleClickRemove() {
320 sendPageEvent("RemoveMessagingSystemExperiment", {
321 slug
: this.props
.study
.slug
,
322 reason
: "individual-opt-out",
327 const { study
, translations
} = this.props
;
328 const userFacingName
= study
.userFacingName
|| study
.slug
;
329 const userFacingDescription
=
330 study
.userFacingDescription
|| "Nimbus experiment.";
334 className
: classnames("study nimbus", {
335 disabled
: !study
.active
,
337 "data-study-slug": study
.slug
, // used to identify this row in tests
339 r("div", { className
: "study-icon" }, userFacingName
.slice(0, 1)),
342 { className
: "study-details" },
345 { className
: "study-header" },
346 r("span", { className
: "study-name" }, userFacingName
),
347 r("span", {}, "\u2022"), // •
348 !study
.isRollout
&& [
349 r("span", { className
: "study-branch-slug" }, study
.branch
.slug
),
350 r("span", {}, "\u2022"), // •
354 { className
: "study-status" },
356 ? translations
.activeStatus
357 : translations
.completeStatus
360 r("div", { className
: "study-description" }, userFacingDescription
)
364 { className
: "study-actions" },
368 { className
: "remove-button", onClick
: this.handleClickRemove
},
369 r("div", { className
: "button-box" }, translations
.removeButton
)
377 * Details about an individual add-on study, with an option to end it if it is active.
379 class AddonStudyListItem
extends React
.Component
{
382 this.handleClickRemove
= this.handleClickRemove
.bind(this);
385 handleClickRemove() {
386 sendPageEvent("RemoveAddonStudy", {
387 recipeId
: this.props
.study
.recipeId
,
388 reason
: "individual-opt-out",
393 const { study
, translations
} = this.props
;
397 className
: classnames("study addon-study", { disabled
: !study
.active
}),
398 "data-study-slug": study
.slug
, // used to identify this row in tests
402 { className
: "study-icon" },
404 .replace(/-?add-?on-?/i, "")
405 .replace(/-?study-?/i, "")
410 { className
: "study-details" },
413 { className
: "study-header" },
414 r("span", { className
: "study-name" }, study
.userFacingName
),
415 r("span", {}, "\u2022"), // •
418 { className
: "study-status" },
420 ? translations
.activeStatus
421 : translations
.completeStatus
426 { className
: "study-description" },
427 study
.userFacingDescription
432 { className
: "study-actions" },
436 { className
: "remove-button", onClick
: this.handleClickRemove
},
437 r("div", { className
: "button-box" }, translations
.removeButton
)
443 AddonStudyListItem
.propTypes
= {
444 study
: PropTypes
.shape({
445 recipeId
: PropTypes
.number
.isRequired
,
446 slug
: PropTypes
.string
.isRequired
,
447 userFacingName
: PropTypes
.string
.isRequired
,
448 active
: PropTypes
.bool
.isRequired
,
449 userFacingDescription
: PropTypes
.string
.isRequired
,
451 translations
: PropTypes
.object
.isRequired
,
455 * Details about an individual preference study, with an option to end it if it is active.
457 class PreferenceStudyListItem
extends React
.Component
{
460 this.handleClickRemove
= this.handleClickRemove
.bind(this);
463 handleClickRemove() {
464 sendPageEvent("RemovePreferenceStudy", {
465 experimentName
: this.props
.study
.slug
,
466 reason
: "individual-opt-out",
471 const { study
, translations
} = this.props
;
473 let iconLetter
= (study
.userFacingName
|| study
.slug
)
474 .replace(/-?pref-?(flip|study)-?/, "")
475 .replace(/-?study-?/, "")
479 let description
= study
.userFacingDescription
;
481 // Assume there is exactly one preference (old-style preference experiment).
482 const [preferenceName
, { preferenceValue
}] = Object
.entries(
485 // Sanitize the values by setting them as the text content of an element,
486 // and then getting the HTML representation of that text. This will have the
487 // browser safely sanitize them. Use outerHTML to also include the <code>
488 // element in the string.
489 const sanitizer
= document
.createElement("code");
490 sanitizer
.textContent
= preferenceName
;
491 const sanitizedPreferenceName
= sanitizer
.outerHTML
;
492 sanitizer
.textContent
= preferenceValue
;
493 const sanitizedPreferenceValue
= sanitizer
.outerHTML
;
494 description
= translations
.preferenceStudyDescription
495 .replace(/%(?:1\$)?S/, sanitizedPreferenceName
)
496 .replace(/%(?:2\$)?S/, sanitizedPreferenceValue
);
502 className
: classnames("study pref-study", { disabled
: study
.expired
}),
503 "data-study-slug": study
.slug
, // used to identify this row in tests
505 r("div", { className
: "study-icon" }, iconLetter
),
508 { className
: "study-details" },
511 { className
: "study-header" },
514 { className
: "study-name" },
515 study
.userFacingName
|| study
.slug
517 r("span", {}, "\u2022"), // •
520 { className
: "study-status" },
522 ? translations
.completeStatus
523 : translations
.activeStatus
527 className
: "study-description",
528 dangerouslySetInnerHTML
: { __html
: description
},
533 { className
: "study-actions" },
537 { className
: "remove-button", onClick
: this.handleClickRemove
},
538 r("div", { className
: "button-box" }, translations
.removeButton
)
544 PreferenceStudyListItem
.propTypes
= {
545 study
: PropTypes
.shape({
546 slug
: PropTypes
.string
.isRequired
,
547 userFacingName
: PropTypes
.string
,
548 userFacingDescription
: PropTypes
.string
,
549 expired
: PropTypes
.bool
.isRequired
,
550 preferenceName
: PropTypes
.string
.isRequired
,
551 preferenceValue
: PropTypes
.oneOf(
557 translations
: PropTypes
.object
.isRequired
,
560 ReactDOM
.render(r(AboutStudies
), document
.getElementById("app"));