summaryrefslogtreecommitdiffstats
path: root/browser/components/asrouter/content-src/components
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/asrouter/content-src/components')
-rw-r--r--browser/components/asrouter/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx1498
-rw-r--r--browser/components/asrouter/content-src/components/ASRouterAdmin/ASRouterAdmin.scss353
-rw-r--r--browser/components/asrouter/content-src/components/ASRouterAdmin/CopyButton.jsx33
-rw-r--r--browser/components/asrouter/content-src/components/ASRouterAdmin/ImpressionsSection.jsx146
-rw-r--r--browser/components/asrouter/content-src/components/ASRouterAdmin/SimpleHashRouter.jsx35
-rw-r--r--browser/components/asrouter/content-src/components/Button/Button.jsx32
-rw-r--r--browser/components/asrouter/content-src/components/Button/_Button.scss51
-rw-r--r--browser/components/asrouter/content-src/components/ConditionalWrapper/ConditionalWrapper.jsx9
-rw-r--r--browser/components/asrouter/content-src/components/ImpressionsWrapper/ImpressionsWrapper.jsx76
9 files changed, 2233 insertions, 0 deletions
diff --git a/browser/components/asrouter/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx b/browser/components/asrouter/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx
new file mode 100644
index 0000000000..f16dbacbd8
--- /dev/null
+++ b/browser/components/asrouter/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx
@@ -0,0 +1,1498 @@
+/* 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 { ASRouterUtils } from "../../asrouter-utils";
+import React from "react";
+import ReactDOM from "react-dom";
+import { SimpleHashRouter } from "./SimpleHashRouter";
+import { CopyButton } from "./CopyButton";
+import { ImpressionsSection } from "./ImpressionsSection";
+
+const Row = props => (
+ <tr className="message-item" {...props}>
+ {props.children}
+ </tr>
+);
+
+function relativeTime(timestamp) {
+ if (!timestamp) {
+ return "";
+ }
+ const seconds = Math.floor((Date.now() - timestamp) / 1000);
+ const minutes = Math.floor((Date.now() - timestamp) / 60000);
+ if (seconds < 2) {
+ return "just now";
+ } else if (seconds < 60) {
+ return `${seconds} seconds ago`;
+ } else if (minutes === 1) {
+ return "1 minute ago";
+ } else if (minutes < 600) {
+ return `${minutes} minutes ago`;
+ }
+ return new Date(timestamp).toLocaleString();
+}
+
+export class ToggleStoryButton extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.handleClick = this.handleClick.bind(this);
+ }
+
+ handleClick() {
+ this.props.onClick(this.props.story);
+ }
+
+ render() {
+ return <button onClick={this.handleClick}>collapse/open</button>;
+ }
+}
+
+export class ToggleMessageJSON extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.handleClick = this.handleClick.bind(this);
+ }
+
+ handleClick() {
+ this.props.toggleJSON(this.props.msgId);
+ }
+
+ render() {
+ let iconName = this.props.isCollapsed
+ ? "icon icon-arrowhead-forward-small"
+ : "icon icon-arrowhead-down-small";
+ return (
+ <button className="clearButton" onClick={this.handleClick}>
+ <span className={iconName} />
+ </button>
+ );
+ }
+}
+
+export class TogglePrefCheckbox extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onChange = this.onChange.bind(this);
+ }
+
+ onChange(event) {
+ this.props.onChange(this.props.pref, event.target.checked);
+ }
+
+ render() {
+ return (
+ <>
+ <input
+ type="checkbox"
+ checked={this.props.checked}
+ onChange={this.onChange}
+ disabled={this.props.disabled}
+ />{" "}
+ {this.props.pref}{" "}
+ </>
+ );
+ }
+}
+
+export class ASRouterAdminInner extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.handleEnabledToggle = this.handleEnabledToggle.bind(this);
+ this.handleUserPrefToggle = this.handleUserPrefToggle.bind(this);
+ this.onChangeMessageFilter = this.onChangeMessageFilter.bind(this);
+ this.onChangeMessageGroupsFilter =
+ this.onChangeMessageGroupsFilter.bind(this);
+ this.unblockAll = this.unblockAll.bind(this);
+ this.handleClearAllImpressionsByProvider =
+ this.handleClearAllImpressionsByProvider.bind(this);
+ this.handleExpressionEval = this.handleExpressionEval.bind(this);
+ this.onChangeTargetingParameters =
+ this.onChangeTargetingParameters.bind(this);
+ this.onChangeAttributionParameters =
+ this.onChangeAttributionParameters.bind(this);
+ this.setAttribution = this.setAttribution.bind(this);
+ this.onCopyTargetingParams = this.onCopyTargetingParams.bind(this);
+ this.onNewTargetingParams = this.onNewTargetingParams.bind(this);
+ this.handleOpenPB = this.handleOpenPB.bind(this);
+ this.selectPBMessage = this.selectPBMessage.bind(this);
+ this.resetPBJSON = this.resetPBJSON.bind(this);
+ this.resetPBMessageState = this.resetPBMessageState.bind(this);
+ this.toggleJSON = this.toggleJSON.bind(this);
+ this.toggleAllMessages = this.toggleAllMessages.bind(this);
+ this.resetGroups = this.resetGroups.bind(this);
+ this.onMessageFromParent = this.onMessageFromParent.bind(this);
+ this.setStateFromParent = this.setStateFromParent.bind(this);
+ this.setState = this.setState.bind(this);
+ this.state = {
+ messageFilter: "all",
+ messageGroupsFilter: "all",
+ collapsedMessages: [],
+ modifiedMessages: [],
+ selectedPBMessage: "",
+ evaluationStatus: {},
+ stringTargetingParameters: null,
+ newStringTargetingParameters: null,
+ copiedToClipboard: false,
+ attributionParameters: {
+ source: "addons.mozilla.org",
+ medium: "referral",
+ campaign: "non-fx-button",
+ content: `rta:${btoa("uBlock0@raymondhill.net")}`,
+ experiment: "ua-onboarding",
+ variation: "chrome",
+ ua: "Google Chrome 123",
+ dltoken: "00000000-0000-0000-0000-000000000000",
+ },
+ };
+ }
+
+ onMessageFromParent({ type, data }) {
+ // These only exists due to onPrefChange events in ASRouter
+ switch (type) {
+ case "UpdateAdminState": {
+ this.setStateFromParent(data);
+ break;
+ }
+ }
+ }
+
+ setStateFromParent(data) {
+ this.setState(data);
+ if (!this.state.stringTargetingParameters) {
+ const stringTargetingParameters = {};
+ for (const param of Object.keys(data.targetingParameters)) {
+ stringTargetingParameters[param] = JSON.stringify(
+ data.targetingParameters[param],
+ null,
+ 2
+ );
+ }
+ this.setState({ stringTargetingParameters });
+ }
+ }
+
+ componentWillMount() {
+ ASRouterUtils.addListener(this.onMessageFromParent);
+ const endpoint = ASRouterUtils.getPreviewEndpoint();
+ ASRouterUtils.sendMessage({
+ type: "ADMIN_CONNECT_STATE",
+ data: { endpoint },
+ }).then(this.setStateFromParent);
+ }
+
+ componentWillUnmount() {
+ ASRouterUtils.removeListener(this.onMessageFromParent);
+ }
+
+ handleBlock(msg) {
+ return () => ASRouterUtils.blockById(msg.id);
+ }
+
+ handleUnblock(msg) {
+ return () => ASRouterUtils.unblockById(msg.id);
+ }
+
+ resetJSON(msg) {
+ // reset the displayed JSON for the given message
+ document.getElementById(`${msg.id}-textarea`).value = JSON.stringify(
+ msg,
+ null,
+ 2
+ );
+ // remove the message from the list of modified IDs
+ let index = this.state.modifiedMessages.indexOf(msg.id);
+ this.setState(prevState => ({
+ modifiedMessages: [
+ ...prevState.modifiedMessages.slice(0, index),
+ ...prevState.modifiedMessages.slice(index + 1),
+ ],
+ }));
+ }
+
+ handleOverride(id) {
+ return () =>
+ ASRouterUtils.overrideMessage(id).then(state => {
+ this.setStateFromParent(state);
+ });
+ }
+
+ resetPBMessageState() {
+ // Iterate over Private Browsing messages and block/unblock each one to clear impressions
+ const PBMessages = this.state.messages.filter(
+ message => message.template === "pb_newtab"
+ ); // messages from state go here
+
+ PBMessages.forEach(message => {
+ if (message?.id) {
+ ASRouterUtils.blockById(message.id);
+ ASRouterUtils.unblockById(message.id);
+ }
+ });
+ // Clear the selected messages & radio buttons
+ document.getElementById("clear radio").checked = true;
+ this.selectPBMessage("clear");
+ }
+
+ resetPBJSON(msg) {
+ // reset the displayed JSON for the given message
+ document.getElementById(`${msg.id}-textarea`).value = JSON.stringify(
+ msg,
+ null,
+ 2
+ );
+ }
+
+ handleOpenPB() {
+ ASRouterUtils.sendMessage({
+ type: "FORCE_PRIVATE_BROWSING_WINDOW",
+ data: { message: { content: this.state.selectedPBMessage } },
+ });
+ }
+
+ expireCache() {
+ ASRouterUtils.sendMessage({ type: "EXPIRE_QUERY_CACHE" });
+ }
+
+ resetPref() {
+ ASRouterUtils.sendMessage({ type: "RESET_PROVIDER_PREF" });
+ }
+
+ resetGroups(id, value) {
+ ASRouterUtils.sendMessage({
+ type: "RESET_GROUPS_STATE",
+ }).then(this.setStateFromParent);
+ }
+
+ handleExpressionEval() {
+ const context = {};
+ for (const param of Object.keys(this.state.stringTargetingParameters)) {
+ const value = this.state.stringTargetingParameters[param];
+ context[param] = value ? JSON.parse(value) : null;
+ }
+ ASRouterUtils.sendMessage({
+ type: "EVALUATE_JEXL_EXPRESSION",
+ data: {
+ expression: this.refs.expressionInput.value,
+ context,
+ },
+ }).then(this.setStateFromParent);
+ }
+
+ onChangeTargetingParameters(event) {
+ const { name } = event.target;
+ const { value } = event.target;
+
+ this.setState(({ stringTargetingParameters }) => {
+ let targetingParametersError = null;
+ const updatedParameters = { ...stringTargetingParameters };
+ updatedParameters[name] = value;
+ try {
+ JSON.parse(value);
+ } catch (e) {
+ console.error(`Error parsing value of parameter ${name}`);
+ targetingParametersError = { id: name };
+ }
+
+ return {
+ copiedToClipboard: false,
+ evaluationStatus: {},
+ stringTargetingParameters: updatedParameters,
+ targetingParametersError,
+ };
+ });
+ }
+
+ unblockAll() {
+ return ASRouterUtils.sendMessage({
+ type: "UNBLOCK_ALL",
+ }).then(this.setStateFromParent);
+ }
+
+ handleClearAllImpressionsByProvider() {
+ const providerId = this.state.messageFilter;
+ if (!providerId) {
+ return;
+ }
+ const userPrefInfo = this.state.userPrefs;
+
+ const isUserEnabled =
+ providerId in userPrefInfo ? userPrefInfo[providerId] : true;
+
+ ASRouterUtils.sendMessage({
+ type: "DISABLE_PROVIDER",
+ data: providerId,
+ });
+ if (!isUserEnabled) {
+ ASRouterUtils.sendMessage({
+ type: "SET_PROVIDER_USER_PREF",
+ data: { id: providerId, value: true },
+ });
+ }
+ ASRouterUtils.sendMessage({
+ type: "ENABLE_PROVIDER",
+ data: providerId,
+ });
+ }
+
+ handleEnabledToggle(event) {
+ const provider = this.state.providerPrefs.find(
+ p => p.id === event.target.dataset.provider
+ );
+ const userPrefInfo = this.state.userPrefs;
+
+ const isUserEnabled =
+ provider.id in userPrefInfo ? userPrefInfo[provider.id] : true;
+ const isSystemEnabled = provider.enabled;
+ const isEnabling = event.target.checked;
+
+ if (isEnabling) {
+ if (!isUserEnabled) {
+ ASRouterUtils.sendMessage({
+ type: "SET_PROVIDER_USER_PREF",
+ data: { id: provider.id, value: true },
+ });
+ }
+ if (!isSystemEnabled) {
+ ASRouterUtils.sendMessage({
+ type: "ENABLE_PROVIDER",
+ data: provider.id,
+ });
+ }
+ } else {
+ ASRouterUtils.sendMessage({
+ type: "DISABLE_PROVIDER",
+ data: provider.id,
+ });
+ }
+
+ this.setState({ messageFilter: "all" });
+ }
+
+ handleUserPrefToggle(event) {
+ const action = {
+ type: "SET_PROVIDER_USER_PREF",
+ data: { id: event.target.dataset.provider, value: event.target.checked },
+ };
+ ASRouterUtils.sendMessage(action);
+ this.setState({ messageFilter: "all" });
+ }
+
+ onChangeMessageFilter(event) {
+ this.setState({ messageFilter: event.target.value });
+ }
+
+ onChangeMessageGroupsFilter(event) {
+ this.setState({ messageGroupsFilter: event.target.value });
+ }
+
+ // Simulate a copy event that sets to clipboard all targeting paramters and values
+ onCopyTargetingParams(event) {
+ const stringTargetingParameters = {
+ ...this.state.stringTargetingParameters,
+ };
+ for (const key of Object.keys(stringTargetingParameters)) {
+ // If the value is not set the parameter will be lost when we stringify
+ if (stringTargetingParameters[key] === undefined) {
+ stringTargetingParameters[key] = null;
+ }
+ }
+ const setClipboardData = e => {
+ e.preventDefault();
+ e.clipboardData.setData(
+ "text",
+ JSON.stringify(stringTargetingParameters, null, 2)
+ );
+ document.removeEventListener("copy", setClipboardData);
+ this.setState({ copiedToClipboard: true });
+ };
+
+ document.addEventListener("copy", setClipboardData);
+
+ document.execCommand("copy");
+ }
+
+ onNewTargetingParams(event) {
+ this.setState({ newStringTargetingParameters: event.target.value });
+ event.target.classList.remove("errorState");
+ this.refs.targetingParamsEval.innerText = "";
+
+ try {
+ const stringTargetingParameters = JSON.parse(event.target.value);
+ this.setState({ stringTargetingParameters });
+ } catch (e) {
+ event.target.classList.add("errorState");
+ this.refs.targetingParamsEval.innerText = e.message;
+ }
+ }
+
+ toggleJSON(msgId) {
+ if (this.state.collapsedMessages.includes(msgId)) {
+ let index = this.state.collapsedMessages.indexOf(msgId);
+ this.setState(prevState => ({
+ collapsedMessages: [
+ ...prevState.collapsedMessages.slice(0, index),
+ ...prevState.collapsedMessages.slice(index + 1),
+ ],
+ }));
+ } else {
+ this.setState(prevState => ({
+ collapsedMessages: prevState.collapsedMessages.concat(msgId),
+ }));
+ }
+ }
+
+ handleChange(msgId) {
+ if (!this.state.modifiedMessages.includes(msgId)) {
+ this.setState(prevState => ({
+ modifiedMessages: prevState.modifiedMessages.concat(msgId),
+ }));
+ }
+ }
+
+ renderMessageItem(msg) {
+ const isBlockedByGroup = this.state.groups
+ .filter(group => msg.groups.includes(group.id))
+ .some(group => !group.enabled);
+ const msgProvider =
+ this.state.providers.find(provider => provider.id === msg.provider) || {};
+ const isProviderExcluded =
+ msgProvider.exclude && msgProvider.exclude.includes(msg.id);
+ const isMessageBlocked =
+ this.state.messageBlockList.includes(msg.id) ||
+ this.state.messageBlockList.includes(msg.campaign);
+ const isBlocked =
+ isMessageBlocked || isBlockedByGroup || isProviderExcluded;
+ const impressions = this.state.messageImpressions[msg.id]
+ ? this.state.messageImpressions[msg.id].length
+ : 0;
+ const isCollapsed = this.state.collapsedMessages.includes(msg.id);
+ const isModified = this.state.modifiedMessages.includes(msg.id);
+ const aboutMessagePreviewSupported = [
+ "infobar",
+ "spotlight",
+ "cfr_doorhanger",
+ ].includes(msg.template);
+
+ let itemClassName = "message-item";
+ if (isBlocked) {
+ itemClassName += " blocked";
+ }
+
+ return (
+ <tr className={itemClassName} key={`${msg.id}-${msg.provider}`}>
+ <td className="message-id">
+ <span>
+ {msg.id} <br />
+ </span>
+ </td>
+ <td>
+ <ToggleMessageJSON
+ msgId={`${msg.id}`}
+ toggleJSON={this.toggleJSON}
+ isCollapsed={isCollapsed}
+ />
+ </td>
+ <td className="button-column">
+ <button
+ className={`button ${isBlocked ? "" : " primary"}`}
+ onClick={
+ isBlocked ? this.handleUnblock(msg) : this.handleBlock(msg)
+ }
+ >
+ {isBlocked ? "Unblock" : "Block"}
+ </button>
+ {
+ // eslint-disable-next-line no-nested-ternary
+ isBlocked ? null : isModified ? (
+ <button
+ className="button restore"
+ onClick={e => this.resetJSON(msg)}
+ >
+ Reset
+ </button>
+ ) : (
+ <button
+ className="button show"
+ onClick={this.handleOverride(msg.id)}
+ >
+ Show
+ </button>
+ )
+ }
+ {isBlocked ? null : (
+ <button
+ className="button modify"
+ onClick={e => this.modifyJson(msg)}
+ >
+ Modify
+ </button>
+ )}
+ {aboutMessagePreviewSupported ? (
+ <CopyButton
+ transformer={text =>
+ `about:messagepreview?json=${encodeURIComponent(btoa(text))}`
+ }
+ label="Share"
+ copiedLabel="Copied!"
+ inputSelector={`#${msg.id}-textarea`}
+ className={"button share"}
+ />
+ ) : null}
+ <br />({impressions} impressions)
+ </td>
+ <td className="message-summary">
+ {isBlocked && (
+ <tr>
+ Block reason:
+ {isBlockedByGroup && " Blocked by group"}
+ {isProviderExcluded && " Excluded by provider"}
+ {isMessageBlocked && " Message blocked"}
+ </tr>
+ )}
+ <tr>
+ <pre className={isCollapsed ? "collapsed" : "expanded"}>
+ <textarea
+ id={`${msg.id}-textarea`}
+ name={msg.id}
+ className="general-textarea"
+ disabled={isBlocked}
+ onChange={e => this.handleChange(msg.id)}
+ >
+ {JSON.stringify(msg, null, 2)}
+ </textarea>
+ </pre>
+ </tr>
+ </td>
+ </tr>
+ );
+ }
+
+ selectPBMessage(msgId) {
+ if (msgId === "clear") {
+ this.setState({
+ selectedPBMessage: "",
+ });
+ } else {
+ let selected = document.getElementById(`${msgId} radio`);
+ let msg = JSON.parse(document.getElementById(`${msgId}-textarea`).value);
+
+ if (selected.checked) {
+ this.setState({
+ selectedPBMessage: msg?.content,
+ });
+ } else {
+ this.setState({
+ selectedPBMessage: "",
+ });
+ }
+ }
+ }
+
+ modifyJson(content) {
+ const message = JSON.parse(
+ document.getElementById(`${content.id}-textarea`).value
+ );
+ return ASRouterUtils.modifyMessageJson(message).then(state => {
+ this.setStateFromParent(state);
+ });
+ }
+
+ renderPBMessageItem(msg) {
+ const isBlocked =
+ this.state.messageBlockList.includes(msg.id) ||
+ this.state.messageBlockList.includes(msg.campaign);
+ const impressions = this.state.messageImpressions[msg.id]
+ ? this.state.messageImpressions[msg.id].length
+ : 0;
+
+ const isCollapsed = this.state.collapsedMessages.includes(msg.id);
+
+ let itemClassName = "message-item";
+ if (isBlocked) {
+ itemClassName += " blocked";
+ }
+
+ return (
+ <tr className={itemClassName} key={`${msg.id}-${msg.provider}`}>
+ <td className="message-id">
+ <span>
+ {msg.id} <br />
+ <br />({impressions} impressions)
+ </span>
+ </td>
+ <td>
+ <ToggleMessageJSON
+ msgId={`${msg.id}`}
+ toggleJSON={this.toggleJSON}
+ isCollapsed={isCollapsed}
+ />
+ </td>
+ <td>
+ <input
+ type="radio"
+ id={`${msg.id} radio`}
+ name="PB_message_radio"
+ style={{ marginBottom: 20 }}
+ onClick={() => this.selectPBMessage(msg.id)}
+ disabled={isBlocked}
+ />
+ <button
+ className={`button ${isBlocked ? "" : " primary"}`}
+ onClick={
+ isBlocked ? this.handleUnblock(msg) : this.handleBlock(msg)
+ }
+ >
+ {isBlocked ? "Unblock" : "Block"}
+ </button>
+ <button
+ className="ASRouterButton slim button"
+ onClick={e => this.resetPBJSON(msg)}
+ >
+ Reset JSON
+ </button>
+ </td>
+ <td className={`message-summary`}>
+ <pre className={isCollapsed ? "collapsed" : "expanded"}>
+ <textarea
+ id={`${msg.id}-textarea`}
+ className="wnp-textarea"
+ name={msg.id}
+ >
+ {JSON.stringify(msg, null, 2)}
+ </textarea>
+ </pre>
+ </td>
+ </tr>
+ );
+ }
+
+ toggleAllMessages(messagesToShow) {
+ if (this.state.collapsedMessages.length) {
+ this.setState({
+ collapsedMessages: [],
+ });
+ } else {
+ Array.prototype.forEach.call(messagesToShow, msg => {
+ this.setState(prevState => ({
+ collapsedMessages: prevState.collapsedMessages.concat(msg.id),
+ }));
+ });
+ }
+ }
+
+ renderMessages() {
+ if (!this.state.messages) {
+ return null;
+ }
+ const messagesToShow =
+ this.state.messageFilter === "all"
+ ? this.state.messages
+ : this.state.messages.filter(
+ message =>
+ message.provider === this.state.messageFilter &&
+ message.template !== "pb_newtab"
+ );
+
+ return (
+ <div>
+ <button
+ className="ASRouterButton slim"
+ onClick={e => this.toggleAllMessages(messagesToShow)}
+ >
+ Collapse/Expand All
+ </button>
+ <p className="helpLink">
+ <span className="icon icon-small-spacer icon-info" />{" "}
+ <span>
+ To modify a message, change the JSON and click 'Modify' to see your
+ changes. Click 'Reset' to restore the JSON to the original. Click
+ 'Share' to copy a link to the clipboard that can be used to preview
+ the message by opening the link in Nightly/local builds.
+ </span>
+ </p>
+ <table>
+ <tbody>
+ {messagesToShow.map(msg => this.renderMessageItem(msg))}
+ </tbody>
+ </table>
+ </div>
+ );
+ }
+
+ renderMessagesByGroup() {
+ if (!this.state.messages) {
+ return null;
+ }
+ const messagesToShow =
+ this.state.messageGroupsFilter === "all"
+ ? this.state.messages.filter(m => m.groups.length)
+ : this.state.messages.filter(message =>
+ message.groups.includes(this.state.messageGroupsFilter)
+ );
+
+ return (
+ <table>
+ <tbody>{messagesToShow.map(msg => this.renderMessageItem(msg))}</tbody>
+ </table>
+ );
+ }
+
+ renderPBMessages() {
+ if (!this.state.messages) {
+ return null;
+ }
+ const messagesToShow = this.state.messages.filter(
+ message => message.template === "pb_newtab"
+ );
+ return (
+ <table>
+ <tbody>
+ {messagesToShow.map(msg => this.renderPBMessageItem(msg))}
+ </tbody>
+ </table>
+ );
+ }
+
+ renderMessageFilter() {
+ if (!this.state.providers) {
+ return null;
+ }
+
+ return (
+ <p>
+ <button
+ className="unblock-all ASRouterButton test-only"
+ onClick={this.unblockAll}
+ >
+ Unblock All Snippets
+ </button>
+ Show messages from{" "}
+ <select
+ value={this.state.messageFilter}
+ onChange={this.onChangeMessageFilter}
+ >
+ <option value="all">all providers</option>
+ {this.state.providers.map(provider => (
+ <option key={provider.id} value={provider.id}>
+ {provider.id}
+ </option>
+ ))}
+ </select>
+ {this.state.messageFilter !== "all" &&
+ !this.state.messageFilter.includes("_local_testing") ? (
+ <button
+ className="button messages-reset"
+ onClick={this.handleClearAllImpressionsByProvider}
+ >
+ Reset All
+ </button>
+ ) : null}
+ </p>
+ );
+ }
+
+ renderMessageGroupsFilter() {
+ if (!this.state.groups) {
+ return null;
+ }
+
+ return (
+ <p>
+ Show messages from {/* eslint-disable-next-line jsx-a11y/no-onchange */}
+ <select
+ value={this.state.messageGroupsFilter}
+ onChange={this.onChangeMessageGroupsFilter}
+ >
+ <option value="all">all groups</option>
+ {this.state.groups.map(group => (
+ <option key={group.id} value={group.id}>
+ {group.id}
+ </option>
+ ))}
+ </select>
+ </p>
+ );
+ }
+
+ renderTableHead() {
+ return (
+ <thead>
+ <tr className="message-item">
+ <td className="min" />
+ <td className="min">Provider ID</td>
+ <td>Source</td>
+ <td className="min">Cohort</td>
+ <td className="min">Last Updated</td>
+ </tr>
+ </thead>
+ );
+ }
+
+ renderProviders() {
+ const providersConfig = this.state.providerPrefs;
+ const providerInfo = this.state.providers;
+ const userPrefInfo = this.state.userPrefs;
+
+ return (
+ <table>
+ {this.renderTableHead()}
+ <tbody>
+ {providersConfig.map((provider, i) => {
+ const isTestProvider = provider.id.includes("_local_testing");
+ const info = providerInfo.find(p => p.id === provider.id) || {};
+ const isUserEnabled =
+ provider.id in userPrefInfo ? userPrefInfo[provider.id] : true;
+ const isSystemEnabled = isTestProvider || provider.enabled;
+
+ let label = "local";
+ if (provider.type === "remote") {
+ label = (
+ <span>
+ endpoint (
+ <a
+ className="providerUrl"
+ target="_blank"
+ href={info.url}
+ rel="noopener noreferrer"
+ >
+ {info.url}
+ </a>
+ )
+ </span>
+ );
+ } else if (provider.type === "remote-settings") {
+ label = `remote settings (${provider.collection})`;
+ } else if (provider.type === "remote-experiments") {
+ label = (
+ <span>
+ remote settings (
+ <a
+ className="providerUrl"
+ target="_blank"
+ href="https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/nimbus-desktop-experiments/records"
+ rel="noopener noreferrer"
+ >
+ nimbus-desktop-experiments
+ </a>
+ )
+ </span>
+ );
+ }
+
+ let reasonsDisabled = [];
+ if (!isSystemEnabled) {
+ reasonsDisabled.push("system pref");
+ }
+ if (!isUserEnabled) {
+ reasonsDisabled.push("user pref");
+ }
+ if (reasonsDisabled.length) {
+ label = `disabled via ${reasonsDisabled.join(", ")}`;
+ }
+
+ return (
+ <tr className="message-item" key={i}>
+ <td>
+ {isTestProvider ? (
+ <input
+ type="checkbox"
+ disabled={true}
+ readOnly={true}
+ checked={true}
+ />
+ ) : (
+ <input
+ type="checkbox"
+ data-provider={provider.id}
+ checked={isUserEnabled && isSystemEnabled}
+ onChange={this.handleEnabledToggle}
+ />
+ )}
+ </td>
+ <td>{provider.id}</td>
+ <td>
+ <span
+ className={`sourceLabel${
+ isUserEnabled && isSystemEnabled ? "" : " isDisabled"
+ }`}
+ >
+ {label}
+ </span>
+ </td>
+ <td>{provider.cohort}</td>
+ <td style={{ whiteSpace: "nowrap" }}>
+ {info.lastUpdated
+ ? new Date(info.lastUpdated).toLocaleString()
+ : ""}
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ );
+ }
+
+ renderTargetingParameters() {
+ // There was no error and the result is truthy
+ const success =
+ this.state.evaluationStatus.success &&
+ !!this.state.evaluationStatus.result;
+ const result =
+ JSON.stringify(this.state.evaluationStatus.result, null, 2) ||
+ "(Empty result)";
+
+ return (
+ <table>
+ <tbody>
+ <tr>
+ <td>
+ <h2>Evaluate JEXL expression</h2>
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <p>
+ <textarea
+ ref="expressionInput"
+ rows="10"
+ cols="60"
+ placeholder="Evaluate JEXL expressions and mock parameters by changing their values below"
+ />
+ </p>
+ <p>
+ Status:{" "}
+ <span ref="evaluationStatus">
+ {success ? "✅" : "❌"}, Result: {result}
+ </span>
+ </p>
+ </td>
+ <td>
+ <button
+ className="ASRouterButton secondary"
+ onClick={this.handleExpressionEval}
+ >
+ Evaluate
+ </button>
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <h2>Modify targeting parameters</h2>
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <button
+ className="ASRouterButton secondary"
+ onClick={this.onCopyTargetingParams}
+ disabled={this.state.copiedToClipboard}
+ >
+ {this.state.copiedToClipboard
+ ? "Parameters copied!"
+ : "Copy parameters"}
+ </button>
+ </td>
+ </tr>
+ {this.state.stringTargetingParameters &&
+ Object.keys(this.state.stringTargetingParameters).map(
+ (param, i) => {
+ const value = this.state.stringTargetingParameters[param];
+ const errorState =
+ this.state.targetingParametersError &&
+ this.state.targetingParametersError.id === param;
+ const className = errorState ? "errorState" : "";
+ const inputComp =
+ (value && value.length) > 30 ? (
+ <textarea
+ name={param}
+ className={className}
+ value={value}
+ rows="10"
+ cols="60"
+ onChange={this.onChangeTargetingParameters}
+ />
+ ) : (
+ <input
+ name={param}
+ className={className}
+ value={value}
+ onChange={this.onChangeTargetingParameters}
+ />
+ );
+
+ return (
+ <tr key={i}>
+ <td>{param}</td>
+ <td>{inputComp}</td>
+ </tr>
+ );
+ }
+ )}
+ </tbody>
+ </table>
+ );
+ }
+
+ onChangeAttributionParameters(event) {
+ const { name, value } = event.target;
+
+ this.setState(({ attributionParameters }) => {
+ const updatedParameters = { ...attributionParameters };
+ updatedParameters[name] = value;
+
+ return { attributionParameters: updatedParameters };
+ });
+ }
+
+ setAttribution(e) {
+ ASRouterUtils.sendMessage({
+ type: "FORCE_ATTRIBUTION",
+ data: this.state.attributionParameters,
+ }).then(this.setStateFromParent);
+ }
+
+ _getGroupImpressionsCount(id, frequency) {
+ if (frequency) {
+ return this.state.groupImpressions[id]
+ ? this.state.groupImpressions[id].length
+ : 0;
+ }
+
+ return "n/a";
+ }
+
+ renderAttributionParamers() {
+ return (
+ <div>
+ <h2> Attribution Parameters </h2>
+ <p>
+ {" "}
+ This forces the browser to set some attribution parameters, useful for
+ testing the Return To AMO feature. Clicking on 'Force Attribution',
+ with the default values in each field, will demo the Return To AMO
+ flow with the addon called 'uBlock Origin'. If you wish to try
+ different attribution parameters, enter them in the text boxes. If you
+ wish to try a different addon with the Return To AMO flow, make sure
+ the 'content' text box has a string that is 'rta:base64(addonID)', the
+ base64 string of the addonID prefixed with 'rta:'. The addon must
+ currently be a recommended addon on AMO. Then click 'Force
+ Attribution'. Clicking on 'Force Attribution' with blank text boxes
+ reset attribution data.
+ </p>
+ <table>
+ <tr>
+ <td>
+ <b> Source </b>
+ </td>
+ <td>
+ {" "}
+ <input
+ type="text"
+ name="source"
+ placeholder="addons.mozilla.org"
+ value={this.state.attributionParameters.source}
+ onChange={this.onChangeAttributionParameters}
+ />{" "}
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <b> Medium </b>
+ </td>
+ <td>
+ {" "}
+ <input
+ type="text"
+ name="medium"
+ placeholder="referral"
+ value={this.state.attributionParameters.medium}
+ onChange={this.onChangeAttributionParameters}
+ />{" "}
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <b> Campaign </b>
+ </td>
+ <td>
+ {" "}
+ <input
+ type="text"
+ name="campaign"
+ placeholder="non-fx-button"
+ value={this.state.attributionParameters.campaign}
+ onChange={this.onChangeAttributionParameters}
+ />{" "}
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <b> Content </b>
+ </td>
+ <td>
+ {" "}
+ <input
+ type="text"
+ name="content"
+ placeholder={`rta:${btoa("uBlock0@raymondhill.net")}`}
+ value={this.state.attributionParameters.content}
+ onChange={this.onChangeAttributionParameters}
+ />{" "}
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <b> Experiment </b>
+ </td>
+ <td>
+ {" "}
+ <input
+ type="text"
+ name="experiment"
+ placeholder="ua-onboarding"
+ value={this.state.attributionParameters.experiment}
+ onChange={this.onChangeAttributionParameters}
+ />{" "}
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <b> Variation </b>
+ </td>
+ <td>
+ {" "}
+ <input
+ type="text"
+ name="variation"
+ placeholder="chrome"
+ value={this.state.attributionParameters.variation}
+ onChange={this.onChangeAttributionParameters}
+ />{" "}
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <b> User Agent </b>
+ </td>
+ <td>
+ {" "}
+ <input
+ type="text"
+ name="ua"
+ placeholder="Google Chrome 123"
+ value={this.state.attributionParameters.ua}
+ onChange={this.onChangeAttributionParameters}
+ />{" "}
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <b> Download Token </b>
+ </td>
+ <td>
+ {" "}
+ <input
+ type="text"
+ name="dltoken"
+ placeholder="00000000-0000-0000-0000-000000000000"
+ value={this.state.attributionParameters.dltoken}
+ onChange={this.onChangeAttributionParameters}
+ />{" "}
+ </td>
+ </tr>
+ <tr>
+ <td>
+ {" "}
+ <button
+ className="ASRouterButton primary button"
+ onClick={this.setAttribution}
+ >
+ {" "}
+ Force Attribution{" "}
+ </button>{" "}
+ </td>
+ </tr>
+ </table>
+ </div>
+ );
+ }
+
+ renderErrorMessage({ id, errors }) {
+ const providerId = <td rowSpan={errors.length}>{id}</td>;
+ // .reverse() so that the last error (most recent) is first
+ return errors
+ .map(({ error, timestamp }, cellKey) => (
+ <tr key={cellKey}>
+ {cellKey === errors.length - 1 ? providerId : null}
+ <td>{error.message}</td>
+ <td>{relativeTime(timestamp)}</td>
+ </tr>
+ ))
+ .reverse();
+ }
+
+ renderErrors() {
+ const providersWithErrors =
+ this.state.providers &&
+ this.state.providers.filter(p => p.errors && p.errors.length);
+
+ if (providersWithErrors && providersWithErrors.length) {
+ return (
+ <table className="errorReporting">
+ <thead>
+ <tr>
+ <th>Provider ID</th>
+ <th>Message</th>
+ <th>Timestamp</th>
+ </tr>
+ </thead>
+ <tbody>{providersWithErrors.map(this.renderErrorMessage)}</tbody>
+ </table>
+ );
+ }
+
+ return <p>No errors</p>;
+ }
+
+ renderPBTab() {
+ if (!this.state.messages) {
+ return null;
+ }
+ let messagesToShow = this.state.messages.filter(
+ message => message.template === "pb_newtab"
+ );
+
+ return (
+ <div>
+ <p className="helpLink">
+ <span className="icon icon-small-spacer icon-info" />{" "}
+ <span>
+ To view an available message, select its radio button and click
+ "Open a Private Browsing Window".
+ <br />
+ To modify a message, make changes to the JSON first, then select the
+ radio button. (To make new changes, click "Reset Message State",
+ make your changes, and reselect the radio button.)
+ <br />
+ Click "Reset Message State" to clear all message impressions and
+ view messages in a clean state.
+ <br />
+ Note that ContentSearch functions do not work in debug mode.
+ </span>
+ </p>
+ <div>
+ <button
+ className="ASRouterButton primary button"
+ onClick={this.handleOpenPB}
+ >
+ Open a Private Browsing Window
+ </button>
+ <button
+ className="ASRouterButton primary button"
+ style={{ marginInlineStart: 12 }}
+ onClick={this.resetPBMessageState}
+ >
+ Reset Message State
+ </button>
+ <br />
+ <input
+ type="radio"
+ id={`clear radio`}
+ name="PB_message_radio"
+ value="clearPBMessage"
+ style={{ display: "none" }}
+ />
+ <h2>Messages</h2>
+ <button
+ className="ASRouterButton slim button"
+ onClick={e => this.toggleAllMessages(messagesToShow)}
+ >
+ Collapse/Expand All
+ </button>
+ {this.renderPBMessages()}
+ </div>
+ </div>
+ );
+ }
+
+ getSection() {
+ const [section] = this.props.location.routes;
+ switch (section) {
+ case "private":
+ return (
+ <React.Fragment>
+ <h2>Private Browsing Messages</h2>
+ {this.renderPBTab()}
+ </React.Fragment>
+ );
+ case "targeting":
+ return (
+ <React.Fragment>
+ <h2>Targeting Utilities</h2>
+ <button className="button" onClick={this.expireCache}>
+ Expire Cache
+ </button>{" "}
+ (This expires the cache in ASR Targeting for bookmarks and top
+ sites)
+ {this.renderTargetingParameters()}
+ {this.renderAttributionParamers()}
+ </React.Fragment>
+ );
+ case "groups":
+ return (
+ <React.Fragment>
+ <h2>Message Groups</h2>
+ <button className="button" onClick={this.resetGroups}>
+ Reset group impressions
+ </button>
+ <table>
+ <thead>
+ <tr className="message-item">
+ <td>Enabled</td>
+ <td>Impressions count</td>
+ <td>Custom frequency</td>
+ <td>User preferences</td>
+ </tr>
+ </thead>
+ <tbody>
+ {this.state.groups &&
+ this.state.groups.map(
+ (
+ { id, enabled, frequency, userPreferences = [] },
+ index
+ ) => (
+ <Row key={id}>
+ <td>
+ <TogglePrefCheckbox
+ checked={enabled}
+ pref={id}
+ disabled={true}
+ />
+ </td>
+ <td>{this._getGroupImpressionsCount(id, frequency)}</td>
+ <td>{JSON.stringify(frequency, null, 2)}</td>
+ <td>{userPreferences.join(", ")}</td>
+ </Row>
+ )
+ )}
+ </tbody>
+ </table>
+ {this.renderMessageGroupsFilter()}
+ {this.renderMessagesByGroup()}
+ </React.Fragment>
+ );
+ case "impressions":
+ return (
+ <React.Fragment>
+ <h2>Impressions</h2>
+ <ImpressionsSection
+ messageImpressions={this.state.messageImpressions}
+ groupImpressions={this.state.groupImpressions}
+ screenImpressions={this.state.screenImpressions}
+ />
+ </React.Fragment>
+ );
+ case "errors":
+ return (
+ <React.Fragment>
+ <h2>ASRouter Errors</h2>
+ {this.renderErrors()}
+ </React.Fragment>
+ );
+ default:
+ return (
+ <React.Fragment>
+ <h2>
+ Message Providers{" "}
+ <button
+ title="Restore all provider settings that ship with Firefox"
+ className="button"
+ onClick={this.resetPref}
+ >
+ Restore default prefs
+ </button>
+ </h2>
+ {this.state.providers ? this.renderProviders() : null}
+ <h2>Messages</h2>
+ {this.renderMessageFilter()}
+ {this.renderMessages()}
+ </React.Fragment>
+ );
+ }
+ }
+
+ render() {
+ if (!this.state.devtoolsEnabled) {
+ return (
+ <div className="asrouter-admin">
+ You must enable the ASRouter Admin page by setting{" "}
+ <code>
+ browser.newtabpage.activity-stream.asrouter.devtoolsEnabled
+ </code>{" "}
+ to <code>true</code> and then reloading this page.
+ </div>
+ );
+ }
+
+ return (
+ <div
+ className={`asrouter-admin ${
+ this.props.collapsed ? "collapsed" : "expanded"
+ }`}
+ >
+ <aside className="sidebar">
+ <ul>
+ <li>
+ <a href="#devtools">General</a>
+ </li>
+ <li>
+ <a href="#devtools-private">Private Browsing</a>
+ </li>
+ <li>
+ <a href="#devtools-targeting">Targeting</a>
+ </li>
+ <li>
+ <a href="#devtools-groups">Message Groups</a>
+ </li>
+ <li>
+ <a href="#devtools-impressions">Impressions</a>
+ </li>
+ <li>
+ <a href="#devtools-errors">Errors</a>
+ </li>
+ </ul>
+ </aside>
+ <main className="main-panel">
+ <h1>AS Router Admin</h1>
+
+ <p className="helpLink">
+ <span className="icon icon-small-spacer icon-info" />{" "}
+ <span>
+ Need help using these tools? Check out our{" "}
+ <a
+ target="blank"
+ href="https://firefox-source-docs.mozilla.org/browser/components/newtab/content-src/asrouter/docs/debugging-docs.html"
+ >
+ documentation
+ </a>
+ </span>
+ </p>
+
+ {this.getSection()}
+ </main>
+ </div>
+ );
+ }
+}
+
+export const ASRouterAdmin = props => (
+ <SimpleHashRouter>
+ <ASRouterAdminInner {...props} />
+ </SimpleHashRouter>
+);
+
+export function renderASRouterAdmin() {
+ ReactDOM.render(<ASRouterAdmin />, document.getElementById("root"));
+}
diff --git a/browser/components/asrouter/content-src/components/ASRouterAdmin/ASRouterAdmin.scss b/browser/components/asrouter/content-src/components/ASRouterAdmin/ASRouterAdmin.scss
new file mode 100644
index 0000000000..67f1abcbac
--- /dev/null
+++ b/browser/components/asrouter/content-src/components/ASRouterAdmin/ASRouterAdmin.scss
@@ -0,0 +1,353 @@
+/* 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/. */
+
+/* stylelint-disable max-nesting-depth */
+
+@import '../../../../newtab/content-src/styles/variables';
+@import '../../../../newtab/content-src/styles/theme';
+@import '../../../../newtab/content-src/styles/icons';
+@import '../Button/Button';
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Ubuntu, 'Helvetica Neue', sans-serif;
+}
+
+/**
+ * These styles are copied verbatim from _activity-stream.scss in order to maintain
+ * a continuity of styling while also decoupling from the newtab code. This should
+ * be removed when about:asrouter starts using the default in-content style sheets.
+ */
+.button,
+.actions button {
+ background-color: var(--newtab-button-secondary-color);
+ border: $border-primary;
+ border-radius: 4px;
+ color: inherit;
+ cursor: pointer;
+ margin-bottom: 15px;
+ padding: 10px 30px;
+ white-space: nowrap;
+
+ &:hover:not(.dismiss),
+ &:focus:not(.dismiss) {
+ box-shadow: $shadow-primary;
+ transition: box-shadow 150ms;
+ }
+
+ &.dismiss {
+ background-color: transparent;
+ border: 0;
+ padding: 0;
+ text-decoration: underline;
+ }
+
+ // Blue button
+ &.primary,
+ &.done {
+ background-color: var(--newtab-primary-action-background);
+ border: solid 1px var(--newtab-primary-action-background);
+ color: var(--newtab-primary-element-text-color);
+ margin-inline-start: auto;
+ }
+}
+
+.asrouter-admin {
+ max-width: 1300px;
+ $border-color: var(--newtab-border-color);
+ $monospace: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono',
+ 'Source Code Pro', monospace;
+ $sidebar-width: 240px;
+
+ font-size: 14px;
+ padding-inline-start: $sidebar-width;
+ color: var(--newtab-text-primary-color);
+
+ &.collapsed {
+ display: none;
+ }
+
+ .sidebar {
+ inset-inline-start: 0;
+ position: fixed;
+ width: $sidebar-width;
+
+ ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ }
+
+ li a {
+ padding: 10px 34px;
+ display: block;
+ color: var(--lwt-sidebar-text-color);
+
+ &:hover {
+ background: var(--newtab-background-color-secondary);
+ }
+ }
+ }
+
+ h1 {
+ font-weight: 200;
+ font-size: 32px;
+ }
+
+ h2 .button,
+ p .button {
+ font-size: 14px;
+ padding: 6px 12px;
+ margin-inline-start: 5px;
+ margin-bottom: 0;
+ }
+
+ .general-textarea {
+ direction: ltr;
+ width: 740px;
+ height: 500px;
+ overflow: auto;
+ resize: none;
+ border-radius: 4px;
+ display: flex;
+ }
+
+ .wnp-textarea {
+ direction: ltr;
+ width: 740px;
+ height: 500px;
+ overflow: auto;
+ resize: none;
+ border-radius: 4px;
+ display: flex;
+ }
+
+ .json-button {
+ display: inline-flex;
+ font-size: 10px;
+ padding: 4px 10px;
+ margin-bottom: 6px;
+ margin-inline-end: 4px;
+
+ &:hover {
+ background-color: var(--newtab-element-hover-color);
+ box-shadow: none;
+ }
+ }
+
+ table {
+ border-collapse: collapse;
+
+ &.minimal-table {
+ border-collapse: collapse;
+ border: 1px solid $border-color;
+
+ td {
+ padding: 8px;
+ }
+
+ td:first-child {
+ width: 1%;
+ white-space: nowrap;
+ }
+
+ td:not(:first-child) {
+ font-family: $monospace;
+ }
+ }
+
+ &.errorReporting {
+ tr {
+ border: 1px solid var(--newtab-background-color-secondary);
+ }
+
+ td {
+ padding: 4px;
+
+ &[rowspan] {
+ border: 1px solid var(--newtab-background-color-secondary);
+ }
+ }
+ }
+ }
+
+ .sourceLabel {
+ background: var(--newtab-background-color-secondary);
+ padding: 2px 5px;
+ border-radius: 3px;
+
+ &.isDisabled {
+ background: $email-input-invalid;
+ color: var(--newtab-status-error);
+ }
+ }
+
+ .message-item {
+ &:first-child td {
+ border-top: 1px solid $border-color;
+ }
+
+ td {
+ vertical-align: top;
+ padding: 8px;
+ border-bottom: 1px solid $border-color;
+
+ &.min {
+ width: 1%;
+ white-space: nowrap;
+ }
+
+ &.message-summary {
+ width: 60%;
+ }
+
+ &.button-column {
+ width: 15%;
+ }
+
+ &:first-child {
+ border-inline-start: 1px solid $border-color;
+ }
+
+ &:last-child {
+ border-inline-end: 1px solid $border-color;
+ }
+ }
+
+ &.blocked {
+ .message-id,
+ .message-summary {
+ opacity: 0.5;
+ }
+
+ .message-id {
+ opacity: 0.5;
+ }
+ }
+
+ .message-id {
+ font-family: $monospace;
+ font-size: 12px;
+ }
+ }
+
+ .providerUrl {
+ font-size: 12px;
+ }
+
+ pre {
+ background: var(--newtab-background-color-secondary);
+ margin: 0;
+ padding: 8px;
+ font-size: 12px;
+ max-width: 750px;
+ overflow: auto;
+ font-family: $monospace;
+ }
+
+ .errorState {
+ border: $input-error-border;
+ }
+
+ .helpLink {
+ padding: 10px;
+ display: flex;
+ background: $black-10;
+ border-radius: 3px;
+ align-items: center;
+
+ a {
+ text-decoration: underline;
+ }
+
+ .icon {
+ min-width: 18px;
+ min-height: 18px;
+ }
+ }
+
+ .ds-component {
+ margin-bottom: 20px;
+ }
+
+ .modalOverlayInner {
+ height: 80%;
+ }
+
+ .clearButton {
+ border: 0;
+ padding: 4px;
+ border-radius: 4px;
+ display: flex;
+
+ &:hover {
+ background: var(--newtab-element-hover-color);
+ }
+ }
+
+ .collapsed {
+ display: none;
+ }
+
+ .icon {
+ display: inline-table;
+ cursor: pointer;
+ width: 18px;
+ height: 18px;
+ }
+
+ .button {
+ &:disabled,
+ &:disabled:active {
+ opacity: 0.5;
+ cursor: unset;
+ box-shadow: none;
+ }
+ }
+
+ .impressions-section {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+
+ .impressions-item {
+ display: flex;
+ flex-flow: column nowrap;
+ padding: 8px;
+ border: 1px solid $border-color;
+ border-radius: 5px;
+
+ .impressions-inner-box {
+ display: flex;
+ flex-flow: row nowrap;
+ gap: 8px;
+ }
+
+ .impressions-category {
+ font-size: 1.15em;
+ white-space: nowrap;
+ flex-grow: 0.1;
+ }
+
+ .impressions-buttons {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ button {
+ margin: 0;
+ }
+ }
+
+ .impressions-editor {
+ display: flex;
+ flex-grow: 1.5;
+
+ .general-textarea {
+ width: auto;
+ flex-grow: 1;
+ }
+ }
+ }
+ }
+}
diff --git a/browser/components/asrouter/content-src/components/ASRouterAdmin/CopyButton.jsx b/browser/components/asrouter/content-src/components/ASRouterAdmin/CopyButton.jsx
new file mode 100644
index 0000000000..6739d38b97
--- /dev/null
+++ b/browser/components/asrouter/content-src/components/ASRouterAdmin/CopyButton.jsx
@@ -0,0 +1,33 @@
+/* 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 React, { useState, useRef, useCallback } from "react";
+
+export const CopyButton = ({
+ className,
+ label,
+ copiedLabel,
+ inputSelector,
+ transformer,
+ ...props
+}) => {
+ const [copied, setCopied] = useState(false);
+ const timeout = useRef(null);
+ const onClick = useCallback(() => {
+ let text = document.querySelector(inputSelector).value;
+ if (transformer) {
+ text = transformer(text);
+ }
+ navigator.clipboard.writeText(text);
+
+ clearTimeout(timeout.current);
+ setCopied(true);
+ timeout.current = setTimeout(() => setCopied(false), 1500);
+ }, [inputSelector, transformer]);
+ return (
+ <button className={className} onClick={e => onClick()} {...props}>
+ {(copied && copiedLabel) || label}
+ </button>
+ );
+};
diff --git a/browser/components/asrouter/content-src/components/ASRouterAdmin/ImpressionsSection.jsx b/browser/components/asrouter/content-src/components/ASRouterAdmin/ImpressionsSection.jsx
new file mode 100644
index 0000000000..87174cb6d9
--- /dev/null
+++ b/browser/components/asrouter/content-src/components/ASRouterAdmin/ImpressionsSection.jsx
@@ -0,0 +1,146 @@
+/* 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 { ASRouterUtils } from "../../asrouter-utils";
+import React, {
+ useState,
+ useMemo,
+ useCallback,
+ useEffect,
+ useRef,
+} from "react";
+
+const stringify = json => JSON.stringify(json, null, 2);
+
+export const ImpressionsSection = ({
+ messageImpressions,
+ groupImpressions,
+ screenImpressions,
+}) => {
+ const handleSaveMessageImpressions = useCallback(newImpressions => {
+ ASRouterUtils.editState("messageImpressions", newImpressions);
+ }, []);
+ const handleSaveGroupImpressions = useCallback(newImpressions => {
+ ASRouterUtils.editState("groupImpressions", newImpressions);
+ }, []);
+ const handleSaveScreenImpressions = useCallback(newImpressions => {
+ ASRouterUtils.editState("screenImpressions", newImpressions);
+ }, []);
+
+ const handleResetMessageImpressions = useCallback(() => {
+ ASRouterUtils.sendMessage({ type: "RESET_MESSAGE_STATE" });
+ }, []);
+ const handleResetGroupImpressions = useCallback(() => {
+ ASRouterUtils.sendMessage({ type: "RESET_GROUPS_STATE" });
+ }, []);
+ const handleResetScreenImpressions = useCallback(() => {
+ ASRouterUtils.sendMessage({ type: "RESET_SCREEN_IMPRESSIONS" });
+ }, []);
+
+ return (
+ <div className="impressions-section">
+ <ImpressionsItem
+ impressions={messageImpressions}
+ label="Message Impressions"
+ description="Message impressions are stored in an object, where each key is a message ID and each value is an array of timestamps. They are cleaned up when a message with that ID stops existing in ASRouter state (such as at the end of an experiment)."
+ onSave={handleSaveMessageImpressions}
+ onReset={handleResetMessageImpressions}
+ />
+ <ImpressionsItem
+ impressions={groupImpressions}
+ label="Group Impressions"
+ description="Group impressions are stored in an object, where each key is a group ID and each value is an array of timestamps. They are never cleaned up."
+ onSave={handleSaveGroupImpressions}
+ onReset={handleResetGroupImpressions}
+ />
+ <ImpressionsItem
+ impressions={screenImpressions}
+ label="Screen Impressions"
+ description="Screen impressions are stored in an object, where each key is a screen ID and each value is the most recent timestamp that screen was shown. They are never cleaned up."
+ onSave={handleSaveScreenImpressions}
+ onReset={handleResetScreenImpressions}
+ />
+ </div>
+ );
+};
+
+const ImpressionsItem = ({
+ impressions,
+ label,
+ description,
+ validator,
+ onSave,
+ onReset,
+}) => {
+ const [json, setJson] = useState(stringify(impressions));
+
+ const modified = useRef(false);
+
+ const isValidJson = useCallback(
+ text => {
+ try {
+ JSON.parse(text);
+ return validator ? validator(text) : true;
+ } catch (e) {
+ return false;
+ }
+ },
+ [validator]
+ );
+
+ const jsonIsInvalid = useMemo(() => !isValidJson(json), [json, isValidJson]);
+
+ const handleChange = useCallback(e => {
+ setJson(e.target.value);
+ modified.current = true;
+ }, []);
+ const handleSave = useCallback(() => {
+ if (jsonIsInvalid) {
+ return;
+ }
+ const newImpressions = JSON.parse(json);
+ modified.current = false;
+ onSave(newImpressions);
+ }, [json, jsonIsInvalid, onSave]);
+ const handleReset = useCallback(() => {
+ modified.current = false;
+ onReset();
+ }, [onReset]);
+
+ useEffect(() => {
+ if (!modified.current) {
+ setJson(stringify(impressions));
+ }
+ }, [impressions]);
+
+ return (
+ <div className="impressions-item">
+ <span className="impressions-category">{label}</span>
+ {description ? (
+ <p className="impressions-description">{description}</p>
+ ) : null}
+ <div className="impressions-inner-box">
+ <div className="impressions-buttons">
+ <button
+ className="button primary"
+ disabled={jsonIsInvalid}
+ onClick={handleSave}
+ >
+ Save
+ </button>
+ <button className="button reset" onClick={handleReset}>
+ Reset
+ </button>
+ </div>
+ <div className="impressions-editor">
+ <textarea
+ className="general-textarea"
+ value={json}
+ onChange={handleChange}
+ />
+ </div>
+ </div>
+ </div>
+ );
+};
diff --git a/browser/components/asrouter/content-src/components/ASRouterAdmin/SimpleHashRouter.jsx b/browser/components/asrouter/content-src/components/ASRouterAdmin/SimpleHashRouter.jsx
new file mode 100644
index 0000000000..9c3fd8579c
--- /dev/null
+++ b/browser/components/asrouter/content-src/components/ASRouterAdmin/SimpleHashRouter.jsx
@@ -0,0 +1,35 @@
+/* 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 React from "react";
+
+export class SimpleHashRouter extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onHashChange = this.onHashChange.bind(this);
+ this.state = { hash: global.location.hash };
+ }
+
+ onHashChange() {
+ this.setState({ hash: global.location.hash });
+ }
+
+ componentWillMount() {
+ global.addEventListener("hashchange", this.onHashChange);
+ }
+
+ componentWillUnmount() {
+ global.removeEventListener("hashchange", this.onHashChange);
+ }
+
+ render() {
+ const [, ...routes] = this.state.hash.split("-");
+ return React.cloneElement(this.props.children, {
+ location: {
+ hash: this.state.hash,
+ routes,
+ },
+ });
+ }
+}
diff --git a/browser/components/asrouter/content-src/components/Button/Button.jsx b/browser/components/asrouter/content-src/components/Button/Button.jsx
new file mode 100644
index 0000000000..b3ece86f16
--- /dev/null
+++ b/browser/components/asrouter/content-src/components/Button/Button.jsx
@@ -0,0 +1,32 @@
+/* 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 React from "react";
+
+const ALLOWED_STYLE_TAGS = ["color", "backgroundColor"];
+
+export const Button = props => {
+ const style = {};
+
+ // Add allowed style tags from props, e.g. props.color becomes style={color: props.color}
+ for (const tag of ALLOWED_STYLE_TAGS) {
+ if (typeof props[tag] !== "undefined") {
+ style[tag] = props[tag];
+ }
+ }
+ // remove border if bg is set to something custom
+ if (style.backgroundColor) {
+ style.border = "0";
+ }
+
+ return (
+ <button
+ onClick={props.onClick}
+ className={props.className || "ASRouterButton secondary"}
+ style={style}
+ >
+ {props.children}
+ </button>
+ );
+};
diff --git a/browser/components/asrouter/content-src/components/Button/_Button.scss b/browser/components/asrouter/content-src/components/Button/_Button.scss
new file mode 100644
index 0000000000..35234be4b0
--- /dev/null
+++ b/browser/components/asrouter/content-src/components/Button/_Button.scss
@@ -0,0 +1,51 @@
+.ASRouterButton {
+ font-weight: 600;
+ font-size: 14px;
+ white-space: nowrap;
+ border-radius: 2px;
+ border: 0;
+ font-family: inherit;
+ padding: 8px 15px;
+ margin-inline-start: 12px;
+ color: inherit;
+ cursor: pointer;
+
+ .tall & {
+ margin-inline-start: 20px;
+ }
+
+ &.test-only {
+ width: 0;
+ height: 0;
+ overflow: hidden;
+ display: block;
+ visibility: hidden;
+ }
+
+ &.primary {
+ border: 1px solid var(--newtab-primary-action-background);
+ background-color: var(--newtab-primary-action-background);
+ color: var(--newtab-primary-element-text-color);
+
+ &:hover {
+ background-color: var(--newtab-primary-element-hover-color);
+ }
+
+ &:active {
+ background-color: var(--newtab-primary-element-active-color);
+ }
+ }
+
+ &.slim {
+ border: $border-primary;
+ margin-inline-start: 0;
+ font-size: 12px;
+ padding: 6px 12px;
+
+ &:hover,
+ &:focus {
+ box-shadow: $shadow-primary;
+ transition: box-shadow 150ms;
+ }
+ }
+}
diff --git a/browser/components/asrouter/content-src/components/ConditionalWrapper/ConditionalWrapper.jsx b/browser/components/asrouter/content-src/components/ConditionalWrapper/ConditionalWrapper.jsx
new file mode 100644
index 0000000000..e4b0812f26
--- /dev/null
+++ b/browser/components/asrouter/content-src/components/ConditionalWrapper/ConditionalWrapper.jsx
@@ -0,0 +1,9 @@
+/* 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/. */
+
+// lifted from https://gist.github.com/kitze/23d82bb9eb0baabfd03a6a720b1d637f
+const ConditionalWrapper = ({ condition, wrap, children }) =>
+ condition && wrap ? wrap(children) : children;
+
+export default ConditionalWrapper;
diff --git a/browser/components/asrouter/content-src/components/ImpressionsWrapper/ImpressionsWrapper.jsx b/browser/components/asrouter/content-src/components/ImpressionsWrapper/ImpressionsWrapper.jsx
new file mode 100644
index 0000000000..8498bde03b
--- /dev/null
+++ b/browser/components/asrouter/content-src/components/ImpressionsWrapper/ImpressionsWrapper.jsx
@@ -0,0 +1,76 @@
+/* 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 React from "react";
+
+export const VISIBLE = "visible";
+export const VISIBILITY_CHANGE_EVENT = "visibilitychange";
+
+/**
+ * Component wrapper used to send telemetry pings on every impression.
+ */
+export class ImpressionsWrapper extends React.PureComponent {
+ // This sends an event when a user sees a set of new content. If content
+ // changes while the page is hidden (i.e. preloaded or on a hidden tab),
+ // only send the event if the page becomes visible again.
+ sendImpressionOrAddListener() {
+ if (this.props.document.visibilityState === VISIBLE) {
+ this.props.sendImpression({ id: this.props.id });
+ } else {
+ // We should only ever send the latest impression stats ping, so remove any
+ // older listeners.
+ if (this._onVisibilityChange) {
+ this.props.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+
+ // When the page becomes visible, send the impression stats ping if the section isn't collapsed.
+ this._onVisibilityChange = () => {
+ if (this.props.document.visibilityState === VISIBLE) {
+ this.props.sendImpression({ id: this.props.id });
+ this.props.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+ };
+ this.props.document.addEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+ }
+
+ componentWillUnmount() {
+ if (this._onVisibilityChange) {
+ this.props.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+ }
+
+ componentDidMount() {
+ if (this.props.sendOnMount) {
+ this.sendImpressionOrAddListener();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.shouldSendImpressionOnUpdate(this.props, prevProps)) {
+ this.sendImpressionOrAddListener();
+ }
+ }
+
+ render() {
+ return this.props.children;
+ }
+}
+
+ImpressionsWrapper.defaultProps = {
+ document: global.document,
+ sendOnMount: true,
+};