summaryrefslogtreecommitdiffstats
path: root/browser/components/asrouter/content-src
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /browser/components/asrouter/content-src
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/asrouter/content-src')
-rw-r--r--browser/components/asrouter/content-src/asrouter-utils.js79
-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
-rw-r--r--browser/components/asrouter/content-src/schemas/BackgroundTaskMessagingExperiment.schema.json305
-rw-r--r--browser/components/asrouter/content-src/schemas/FxMSCommon.schema.json128
-rw-r--r--browser/components/asrouter/content-src/schemas/MessagingExperiment.schema.json1366
-rw-r--r--browser/components/asrouter/content-src/schemas/corpus/ReachExperiments.messages.json15
-rw-r--r--browser/components/asrouter/content-src/schemas/extract-test-corpus.js65
-rwxr-xr-xbrowser/components/asrouter/content-src/schemas/make-schemas.py472
-rw-r--r--browser/components/asrouter/content-src/schemas/message-format.md111
-rw-r--r--browser/components/asrouter/content-src/schemas/message-group.schema.json64
-rw-r--r--browser/components/asrouter/content-src/schemas/provider-response.schema.json67
-rw-r--r--browser/components/asrouter/content-src/styles/_feature-callout-theme.scss92
-rw-r--r--browser/components/asrouter/content-src/styles/_feature-callout.scss775
-rw-r--r--browser/components/asrouter/content-src/styles/_shopping.scss209
-rw-r--r--browser/components/asrouter/content-src/templates/CFR/templates/CFRUrlbarChiclet.schema.json66
-rw-r--r--browser/components/asrouter/content-src/templates/CFR/templates/ExtensionDoorhanger.schema.json320
-rw-r--r--browser/components/asrouter/content-src/templates/CFR/templates/InfoBar.schema.json89
-rw-r--r--browser/components/asrouter/content-src/templates/OnboardingMessage/Spotlight.schema.json66
-rw-r--r--browser/components/asrouter/content-src/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json45
-rw-r--r--browser/components/asrouter/content-src/templates/OnboardingMessage/UpdateAction.schema.json47
-rw-r--r--browser/components/asrouter/content-src/templates/OnboardingMessage/WhatsNewMessage.schema.json73
-rw-r--r--browser/components/asrouter/content-src/templates/PBNewtab/NewtabPromoMessage.schema.json153
-rw-r--r--browser/components/asrouter/content-src/templates/ToastNotification/ToastNotification.schema.json113
31 files changed, 6953 insertions, 0 deletions
diff --git a/browser/components/asrouter/content-src/asrouter-utils.js b/browser/components/asrouter/content-src/asrouter-utils.js
new file mode 100644
index 0000000000..65d25cb907
--- /dev/null
+++ b/browser/components/asrouter/content-src/asrouter-utils.js
@@ -0,0 +1,79 @@
+/* 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 { MESSAGE_TYPE_HASH as msg } from "modules/ActorConstants.sys.mjs";
+import { actionCreators as ac } from "common/Actions.sys.mjs";
+
+export const ASRouterUtils = {
+ addListener(listener) {
+ if (global.ASRouterAddParentListener) {
+ global.ASRouterAddParentListener(listener);
+ }
+ },
+ removeListener(listener) {
+ if (global.ASRouterRemoveParentListener) {
+ global.ASRouterRemoveParentListener(listener);
+ }
+ },
+ sendMessage(action) {
+ if (global.ASRouterMessage) {
+ return global.ASRouterMessage(action);
+ }
+ throw new Error(`Unexpected call:\n${JSON.stringify(action, null, 3)}`);
+ },
+ blockById(id, options) {
+ return ASRouterUtils.sendMessage({
+ type: msg.BLOCK_MESSAGE_BY_ID,
+ data: { id, ...options },
+ });
+ },
+ modifyMessageJson(content) {
+ return ASRouterUtils.sendMessage({
+ type: msg.MODIFY_MESSAGE_JSON,
+ data: { content },
+ });
+ },
+ executeAction(button_action) {
+ return ASRouterUtils.sendMessage({
+ type: msg.USER_ACTION,
+ data: button_action,
+ });
+ },
+ unblockById(id) {
+ return ASRouterUtils.sendMessage({
+ type: msg.UNBLOCK_MESSAGE_BY_ID,
+ data: { id },
+ });
+ },
+ blockBundle(bundle) {
+ return ASRouterUtils.sendMessage({
+ type: msg.BLOCK_BUNDLE,
+ data: { bundle },
+ });
+ },
+ unblockBundle(bundle) {
+ return ASRouterUtils.sendMessage({
+ type: msg.UNBLOCK_BUNDLE,
+ data: { bundle },
+ });
+ },
+ overrideMessage(id) {
+ return ASRouterUtils.sendMessage({
+ type: msg.OVERRIDE_MESSAGE,
+ data: { id },
+ });
+ },
+ editState(key, value) {
+ return ASRouterUtils.sendMessage({
+ type: msg.EDIT_STATE,
+ data: { [key]: value },
+ });
+ },
+ sendTelemetry(ping) {
+ return ASRouterUtils.sendMessage(ac.ASRouterUserEvent(ping));
+ },
+ getPreviewEndpoint() {
+ return null;
+ },
+};
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,
+};
diff --git a/browser/components/asrouter/content-src/schemas/BackgroundTaskMessagingExperiment.schema.json b/browser/components/asrouter/content-src/schemas/BackgroundTaskMessagingExperiment.schema.json
new file mode 100644
index 0000000000..9de01052f7
--- /dev/null
+++ b/browser/components/asrouter/content-src/schemas/BackgroundTaskMessagingExperiment.schema.json
@@ -0,0 +1,305 @@
+{
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
+ "$id": "chrome://browser/content/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json",
+ "title": "Messaging Experiment",
+ "description": "A Firefox Messaging System message.",
+ "if": {
+ "type": "object",
+ "properties": {
+ "template": {
+ "const": "multi"
+ }
+ },
+ "required": ["template"]
+ },
+ "then": {
+ "$ref": "chrome://browser/content/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/MultiMessage"
+ },
+ "else": {
+ "$ref": "chrome://browser/content/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/TemplatedMessage"
+ },
+ "$defs": {
+ "ToastNotification": {
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
+ "$id": "file:///ToastNotification.schema.json",
+ "title": "ToastNotification",
+ "description": "A template for toast notifications displayed by the Alert service.",
+ "allOf": [
+ {
+ "$ref": "chrome://browser/content/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/Message"
+ }
+ ],
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "$ref": "chrome://browser/content/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/localizableText",
+ "description": "Id of localized string or message override of toast notification title"
+ },
+ "body": {
+ "$ref": "chrome://browser/content/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/localizableText",
+ "description": "Id of localized string or message override of toast notification body"
+ },
+ "icon_url": {
+ "description": "The URL of the image used as an icon of the toast notification.",
+ "type": "string",
+ "format": "moz-url-format"
+ },
+ "image_url": {
+ "description": "The URL of an image to be displayed as part of the notification.",
+ "type": "string",
+ "format": "moz-url-format"
+ },
+ "launch_url": {
+ "description": "The URL to launch when the notification or an action button is clicked.",
+ "type": "string",
+ "format": "moz-url-format"
+ },
+ "launch_action": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "The launch action to be performed when Firefox is launched."
+ },
+ "data": {
+ "type": "object"
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": true
+ },
+ "requireInteraction": {
+ "type": "boolean",
+ "description": "Whether the toast notification should remain active until the user clicks or dismisses it, rather than closing automatically."
+ },
+ "tag": {
+ "type": "string",
+ "description": "An identifying tag for the toast notification."
+ },
+ "data": {
+ "type": "object",
+ "description": "Arbitrary data associated with the toast notification."
+ },
+ "actions": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "$ref": "chrome://browser/content/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/localizableText",
+ "description": "The action text to be shown to the user."
+ },
+ "action": {
+ "type": "string",
+ "description": "Opaque identifer that identifies action."
+ },
+ "iconURL": {
+ "type": "string",
+ "format": "uri",
+ "description": "URL of an icon to display with the action."
+ },
+ "windowsSystemActivationType": {
+ "type": "boolean",
+ "description": "Whether to have Windows process the given `action`."
+ },
+ "launch_action": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "The launch action to be performed when Firefox is launched."
+ },
+ "data": {
+ "type": "object"
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": true
+ }
+ },
+ "required": ["action", "title"],
+ "additionalProperties": true
+ }
+ }
+ },
+ "additionalProperties": true,
+ "required": ["title", "body"]
+ },
+ "template": {
+ "type": "string",
+ "const": "toast_notification"
+ }
+ },
+ "required": ["content", "targeting", "template", "trigger"],
+ "additionalProperties": true
+ },
+ "Message": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The message identifier"
+ },
+ "groups": {
+ "description": "Array of preferences used to control `enabled` status of the group. If any is `false` the group is disabled.",
+ "type": "array",
+ "items": {
+ "type": "string",
+ "description": "Preference name"
+ }
+ },
+ "template": {
+ "type": "string",
+ "description": "Which messaging template this message is using.",
+ "enum": ["toast_notification"]
+ },
+ "frequency": {
+ "type": "object",
+ "description": "An object containing frequency cap information for a message.",
+ "properties": {
+ "lifetime": {
+ "type": "integer",
+ "description": "The maximum lifetime impressions for a message.",
+ "minimum": 1,
+ "maximum": 100
+ },
+ "custom": {
+ "type": "array",
+ "description": "An array of custom frequency cap definitions.",
+ "items": {
+ "description": "A frequency cap definition containing time and max impression information",
+ "type": "object",
+ "properties": {
+ "period": {
+ "type": "integer",
+ "description": "Period of time in milliseconds (e.g. 86400000 for one day)"
+ },
+ "cap": {
+ "type": "integer",
+ "description": "The maximum impressions for the message within the defined period.",
+ "minimum": 1,
+ "maximum": 100
+ }
+ },
+ "required": ["period", "cap"]
+ }
+ }
+ }
+ },
+ "priority": {
+ "description": "The priority of the message. If there are two competing messages to show, the one with the highest priority will be shown",
+ "type": "integer"
+ },
+ "order": {
+ "description": "The order in which messages should be shown. Messages will be shown in increasing order.",
+ "type": "integer"
+ },
+ "targeting": {
+ "description": "A JEXL expression representing targeting information",
+ "type": "string"
+ },
+ "trigger": {
+ "description": "An action to trigger potentially showing the message",
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "A string identifying the trigger action"
+ },
+ "params": {
+ "type": "array",
+ "description": "An optional array of string parameters for the trigger action",
+ "items": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "string"
+ }
+ ]
+ }
+ }
+ },
+ "required": ["id"]
+ },
+ "provider": {
+ "description": "An identifier for the provider of this message, such as \"cfr\" or \"preview\".",
+ "type": "string"
+ }
+ },
+ "additionalProperties": true,
+ "dependentRequired": {
+ "content": ["id", "template"],
+ "template": ["id", "content"]
+ }
+ },
+ "localizedText": {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "description": "Id of localized string to be rendered.",
+ "type": "string"
+ }
+ },
+ "required": ["string_id"]
+ },
+ "localizableText": {
+ "description": "Either a raw string or an object containing the string_id of the localized text",
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "The string to be rendered."
+ },
+ {
+ "$ref": "chrome://browser/content/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/localizedText"
+ }
+ ]
+ },
+ "TemplatedMessage": {
+ "description": "An FxMS message of one of a variety of types.",
+ "type": "object",
+ "allOf": [
+ {
+ "$ref": "chrome://browser/content/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/Message"
+ },
+ {
+ "if": {
+ "type": "object",
+ "properties": {
+ "template": {
+ "type": "string",
+ "enum": ["toast_notification"]
+ }
+ },
+ "required": ["template"]
+ },
+ "then": {
+ "$ref": "chrome://browser/content/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/ToastNotification"
+ }
+ }
+ ]
+ },
+ "MultiMessage": {
+ "description": "An object containing an array of messages.",
+ "type": "object",
+ "properties": {
+ "template": {
+ "type": "string",
+ "const": "multi"
+ },
+ "messages": {
+ "type": "array",
+ "description": "An array of messages.",
+ "items": {
+ "$ref": "chrome://browser/content/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json#/$defs/TemplatedMessage"
+ }
+ }
+ },
+ "required": ["template", "messages"]
+ }
+ }
+}
diff --git a/browser/components/asrouter/content-src/schemas/FxMSCommon.schema.json b/browser/components/asrouter/content-src/schemas/FxMSCommon.schema.json
new file mode 100644
index 0000000000..51dbd3efa6
--- /dev/null
+++ b/browser/components/asrouter/content-src/schemas/FxMSCommon.schema.json
@@ -0,0 +1,128 @@
+{
+ "description": "Common elements used across FxMS schemas",
+ "$id": "file:///FxMSCommon.schema.json",
+ "$defs": {
+ "Message": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The message identifier"
+ },
+ "groups": {
+ "description": "Array of preferences used to control `enabled` status of the group. If any is `false` the group is disabled.",
+ "type": "array",
+ "items": {
+ "type": "string",
+ "description": "Preference name"
+ }
+ },
+ "template": {
+ "type": "string",
+ "description": "Which messaging template this message is using."
+ },
+ "frequency": {
+ "type": "object",
+ "description": "An object containing frequency cap information for a message.",
+ "properties": {
+ "lifetime": {
+ "type": "integer",
+ "description": "The maximum lifetime impressions for a message.",
+ "minimum": 1,
+ "maximum": 100
+ },
+ "custom": {
+ "type": "array",
+ "description": "An array of custom frequency cap definitions.",
+ "items": {
+ "description": "A frequency cap definition containing time and max impression information",
+ "type": "object",
+ "properties": {
+ "period": {
+ "type": "integer",
+ "description": "Period of time in milliseconds (e.g. 86400000 for one day)"
+ },
+ "cap": {
+ "type": "integer",
+ "description": "The maximum impressions for the message within the defined period.",
+ "minimum": 1,
+ "maximum": 100
+ }
+ },
+ "required": ["period", "cap"]
+ }
+ }
+ }
+ },
+ "priority": {
+ "description": "The priority of the message. If there are two competing messages to show, the one with the highest priority will be shown",
+ "type": "integer"
+ },
+ "order": {
+ "description": "The order in which messages should be shown. Messages will be shown in increasing order.",
+ "type": "integer"
+ },
+ "targeting": {
+ "description": "A JEXL expression representing targeting information",
+ "type": "string"
+ },
+ "trigger": {
+ "description": "An action to trigger potentially showing the message",
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "A string identifying the trigger action"
+ },
+ "params": {
+ "type": "array",
+ "description": "An optional array of string parameters for the trigger action",
+ "items": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "string"
+ }
+ ]
+ }
+ }
+ },
+ "required": ["id"]
+ },
+ "provider": {
+ "description": "An identifier for the provider of this message, such as \"cfr\" or \"preview\".",
+ "type": "string"
+ }
+ },
+ "additionalProperties": true,
+ "dependentRequired": {
+ "content": ["id", "template"],
+ "template": ["id", "content"]
+ }
+ },
+ "localizedText": {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "description": "Id of localized string to be rendered.",
+ "type": "string"
+ }
+ },
+ "required": ["string_id"]
+ },
+ "localizableText": {
+ "description": "Either a raw string or an object containing the string_id of the localized text",
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "The string to be rendered."
+ },
+ {
+ "$ref": "#/$defs/localizedText"
+ }
+ ]
+ }
+ }
+}
diff --git a/browser/components/asrouter/content-src/schemas/MessagingExperiment.schema.json b/browser/components/asrouter/content-src/schemas/MessagingExperiment.schema.json
new file mode 100644
index 0000000000..a395f4f990
--- /dev/null
+++ b/browser/components/asrouter/content-src/schemas/MessagingExperiment.schema.json
@@ -0,0 +1,1366 @@
+{
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
+ "$id": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json",
+ "title": "Messaging Experiment",
+ "description": "A Firefox Messaging System message.",
+ "if": {
+ "type": "object",
+ "properties": {
+ "template": {
+ "const": "multi"
+ }
+ },
+ "required": ["template"]
+ },
+ "then": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/MultiMessage"
+ },
+ "else": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/TemplatedMessage"
+ },
+ "$defs": {
+ "CFRUrlbarChiclet": {
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
+ "$id": "file:///CFRUrlbarChiclet.schema.json",
+ "title": "CFRUrlbarChiclet",
+ "description": "A template with a chiclet button with text.",
+ "allOf": [
+ {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/Message"
+ }
+ ],
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "object",
+ "properties": {
+ "category": {
+ "type": "string",
+ "description": "Attribute used for different groups of messages from the same provider"
+ },
+ "layout": {
+ "type": "string",
+ "description": "Describes how content should be displayed.",
+ "enum": ["chiclet_open_url"]
+ },
+ "bucket_id": {
+ "type": "string",
+ "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting."
+ },
+ "notification_text": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText",
+ "description": "The text in the small blue chicklet that appears in the URL bar. This can be a reference to a localized string in Firefox or just a plain string."
+ },
+ "active_color": {
+ "type": "string",
+ "description": "Background color of the button"
+ },
+ "action": {
+ "type": "object",
+ "properties": {
+ "url": {
+ "description": "The page to open when the button is clicked.",
+ "type": "string",
+ "format": "moz-url-format"
+ },
+ "where": {
+ "description": "Should it open in a new tab or the current tab",
+ "type": "string",
+ "enum": ["current", "tabshifted"]
+ }
+ },
+ "additionalProperties": true,
+ "required": ["url", "where"]
+ }
+ },
+ "additionalProperties": true,
+ "required": [
+ "layout",
+ "category",
+ "bucket_id",
+ "notification_text",
+ "action"
+ ]
+ },
+ "template": {
+ "type": "string",
+ "const": "cfr_urlbar_chiclet"
+ }
+ },
+ "required": ["targeting", "trigger"]
+ },
+ "ExtensionDoorhanger": {
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
+ "$id": "file:///ExtensionDoorhanger.schema.json",
+ "title": "ExtensionDoorhanger",
+ "description": "A template with a heading, addon icon, title and description. No markup allowed.",
+ "allOf": [
+ {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/Message"
+ }
+ ],
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "object",
+ "properties": {
+ "category": {
+ "type": "string",
+ "description": "Attribute used for different groups of messages from the same provider"
+ },
+ "layout": {
+ "type": "string",
+ "description": "Attribute used for different groups of messages from the same provider",
+ "enum": [
+ "short_message",
+ "icon_and_message",
+ "addon_recommendation"
+ ]
+ },
+ "anchor_id": {
+ "type": "string",
+ "description": "A DOM element ID that the pop-over will be anchored."
+ },
+ "alt_anchor_id": {
+ "type": "string",
+ "description": "An alternate DOM element ID that the pop-over will be anchored."
+ },
+ "bucket_id": {
+ "type": "string",
+ "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting."
+ },
+ "skip_address_bar_notifier": {
+ "type": "boolean",
+ "description": "Skip the 'Recommend' notifier and show directly."
+ },
+ "persistent_doorhanger": {
+ "type": "boolean",
+ "description": "Prevent the doorhanger from being dismissed if user interacts with the page or switches between applications."
+ },
+ "show_in_private_browsing": {
+ "type": "boolean",
+ "description": "Whether to allow the message to be shown in private browsing mode. Defaults to false."
+ },
+ "notification_text": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText",
+ "description": "The text in the small blue chicklet that appears in the URL bar. This can be a reference to a localized string in Firefox or just a plain string."
+ },
+ "info_icon": {
+ "type": "object",
+ "description": "The small icon displayed in the top right corner of the pop-over. Should be 19x19px, svg or png. Defaults to a small question mark.",
+ "properties": {
+ "label": {
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "attributes": {
+ "type": "object",
+ "properties": {
+ "tooltiptext": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText",
+ "description": "Text for button tooltip used to provide information about the doorhanger."
+ }
+ },
+ "required": ["tooltiptext"]
+ }
+ },
+ "required": ["attributes"]
+ },
+ {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizedText"
+ }
+ ]
+ },
+ "sumo_path": {
+ "type": "string",
+ "description": "Last part of the path in the URL to the support page with the information about the doorhanger.",
+ "examples": [
+ "extensionpromotions",
+ "extensionrecommendations"
+ ]
+ }
+ }
+ },
+ "learn_more": {
+ "type": "string",
+ "description": "Last part of the path in the SUMO URL to the support page with the information about the doorhanger.",
+ "examples": ["extensionpromotions", "extensionrecommendations"]
+ },
+ "heading_text": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText",
+ "description": "The larger heading text displayed in the pop-over. This can be a reference to a localized string in Firefox or just a plain string."
+ },
+ "icon": {
+ "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/linkUrl",
+ "description": "The icon displayed in the pop-over. Should be 32x32px or 64x64px and png/svg."
+ },
+ "icon_dark_theme": {
+ "type": "string",
+ "description": "Pop-over icon, dark theme variant. Should be 32x32px or 64x64px and png/svg."
+ },
+ "icon_class": {
+ "type": "string",
+ "description": "CSS class of the pop-over icon."
+ },
+ "addon": {
+ "description": "Addon information including AMO URL.",
+ "type": "object",
+ "properties": {
+ "id": {
+ "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/plainText",
+ "description": "Unique addon ID"
+ },
+ "title": {
+ "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/plainText",
+ "description": "Addon name"
+ },
+ "author": {
+ "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/plainText",
+ "description": "Addon author"
+ },
+ "icon": {
+ "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/linkUrl",
+ "description": "The icon displayed in the pop-over. Should be 64x64px and png/svg."
+ },
+ "rating": {
+ "type": "string",
+ "description": "Star rating"
+ },
+ "users": {
+ "type": "string",
+ "description": "Installed users"
+ },
+ "amo_url": {
+ "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/linkUrl",
+ "description": "Link that offers more information related to the addon."
+ }
+ },
+ "required": ["title", "author", "icon", "amo_url"]
+ },
+ "text": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText",
+ "description": "The body text displayed in the pop-over. This can be a reference to a localized string in Firefox or just a plain string."
+ },
+ "descriptionDetails": {
+ "description": "Additional information and steps on how to use",
+ "type": "object",
+ "properties": {
+ "steps": {
+ "description": "Array of string_ids",
+ "type": "array",
+ "items": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizedText",
+ "description": "Id of string to localized addon description"
+ }
+ }
+ },
+ "required": ["steps"]
+ },
+ "buttons": {
+ "description": "The label and functionality for the buttons in the pop-over.",
+ "type": "object",
+ "properties": {
+ "primary": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "type": "object",
+ "oneOf": [
+ {
+ "properties": {
+ "value": {
+ "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/plainText",
+ "description": "Button label override used when a localized version is not available."
+ },
+ "attributes": {
+ "type": "object",
+ "properties": {
+ "accesskey": {
+ "type": "string",
+ "description": "A single character to be used as a shortcut key for the secondary button. This should be one of the characters that appears in the button label."
+ }
+ },
+ "required": ["accesskey"],
+ "description": "Button attributes."
+ }
+ },
+ "required": ["value", "attributes"]
+ },
+ {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizedText"
+ }
+ ],
+ "description": "Id of localized string or message override."
+ },
+ "action": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "Action dispatched by the button."
+ },
+ "data": {
+ "properties": {
+ "url": {
+ "type": "string",
+ "$comment": "This is dynamically generated from the addon.id. See CFRPageActions.sys.mjs",
+ "description": "URL used in combination with the primary action dispatched."
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "secondary": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "type": "object",
+ "oneOf": [
+ {
+ "properties": {
+ "value": {
+ "allOf": [
+ {
+ "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/plainText"
+ },
+ {
+ "description": "Button label override used when a localized version is not available."
+ }
+ ]
+ },
+ "attributes": {
+ "type": "object",
+ "properties": {
+ "accesskey": {
+ "type": "string",
+ "description": "A single character to be used as a shortcut key for the secondary button. This should be one of the characters that appears in the button label."
+ }
+ },
+ "required": ["accesskey"],
+ "description": "Button attributes."
+ }
+ },
+ "required": ["value", "attributes"]
+ },
+ {
+ "properties": {
+ "string_id": {
+ "allOf": [
+ {
+ "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/plainText"
+ },
+ {
+ "description": "Id of localized string for button"
+ }
+ ]
+ }
+ },
+ "required": ["string_id"]
+ }
+ ],
+ "description": "Id of localized string or message override."
+ },
+ "action": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "Action dispatched by the button."
+ },
+ "data": {
+ "properties": {
+ "url": {
+ "allOf": [
+ {
+ "$ref": "file:///ExtensionDoorhanger.schema.json#/$defs/linkUrl"
+ },
+ {
+ "description": "URL used in combination with the primary action dispatched."
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "additionalProperties": true,
+ "required": [
+ "layout",
+ "bucket_id",
+ "heading_text",
+ "text",
+ "buttons"
+ ],
+ "if": {
+ "properties": {
+ "skip_address_bar_notifier": {
+ "anyOf": [
+ {
+ "const": "false"
+ },
+ {
+ "const": null
+ }
+ ]
+ }
+ }
+ },
+ "then": {
+ "required": ["category", "notification_text"]
+ }
+ },
+ "template": {
+ "type": "string",
+ "enum": ["cfr_doorhanger", "milestone_message"]
+ }
+ },
+ "additionalProperties": true,
+ "required": ["targeting", "trigger"],
+ "$defs": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "linkUrl": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ }
+ },
+ "InfoBar": {
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
+ "$id": "file:///InfoBar.schema.json",
+ "title": "InfoBar",
+ "description": "A template with an image, test and buttons.",
+ "allOf": [
+ {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/Message"
+ }
+ ],
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "Should the message be global (persisted across tabs) or local (disappear when switching to a different tab).",
+ "enum": ["global", "tab"]
+ },
+ "text": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText",
+ "description": "The text show in the notification box."
+ },
+ "priority": {
+ "description": "Infobar priority level https://searchfox.org/mozilla-central/rev/3aef835f6cb12e607154d56d68726767172571e4/toolkit/content/widgets/notificationbox.js#387",
+ "type": "number",
+ "minumum": 0,
+ "exclusiveMaximum": 10
+ },
+ "buttons": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText",
+ "description": "The text label of the button."
+ },
+ "primary": {
+ "type": "boolean",
+ "description": "Is this the primary button?"
+ },
+ "accessKey": {
+ "type": "string",
+ "description": "Keyboard shortcut letter."
+ },
+ "action": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "Action dispatched by the button."
+ },
+ "data": {
+ "type": "object"
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": true
+ },
+ "supportPage": {
+ "type": "string",
+ "description": "A page title on SUMO to link to"
+ }
+ },
+ "required": ["label", "action"],
+ "additionalProperties": true
+ }
+ }
+ },
+ "additionalProperties": true,
+ "required": ["text", "buttons"]
+ },
+ "template": {
+ "type": "string",
+ "const": "infobar"
+ }
+ },
+ "additionalProperties": true,
+ "required": ["targeting", "trigger"],
+ "$defs": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "linkUrl": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ }
+ },
+ "NewtabPromoMessage": {
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
+ "$id": "file:///NewtabPromoMessage.schema.json",
+ "title": "PBNewtabPromoMessage",
+ "description": "Message shown on the private browsing newtab page.",
+ "allOf": [
+ {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/Message"
+ }
+ ],
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "object",
+ "properties": {
+ "hideDefault": {
+ "type": "boolean",
+ "description": "Should we hide the default promo after the experiment promo is dismissed."
+ },
+ "infoEnabled": {
+ "type": "boolean",
+ "description": "Should we show the info section."
+ },
+ "infoIcon": {
+ "type": "string",
+ "description": "Icon shown in the left side of the info section. Default is the private browsing icon."
+ },
+ "infoTitle": {
+ "type": "string",
+ "description": "Is the title in the info section enabled."
+ },
+ "infoTitleEnabled": {
+ "type": "boolean",
+ "description": "Is the title in the info section enabled."
+ },
+ "infoBody": {
+ "type": "string",
+ "description": "Text content in the info section."
+ },
+ "infoLinkText": {
+ "type": "string",
+ "description": "Text for the link in the info section."
+ },
+ "infoLinkUrl": {
+ "type": "string",
+ "description": "URL for the info section link.",
+ "format": "moz-url-format"
+ },
+ "promoEnabled": {
+ "type": "boolean",
+ "description": "Should we show the promo section."
+ },
+ "promoType": {
+ "type": "string",
+ "description": "Promo type used to determine if promo should show to a given user",
+ "enum": ["FOCUS", "VPN", "PIN", "COOKIE_BANNERS", "OTHER"]
+ },
+ "promoSectionStyle": {
+ "type": "string",
+ "description": "Sets the position of the promo section. Possible values are: top, below-search, bottom. Default bottom.",
+ "enum": ["top", "below-search", "bottom"]
+ },
+ "promoTitle": {
+ "type": "string",
+ "description": "The text content of the promo section."
+ },
+ "promoTitleEnabled": {
+ "type": "boolean",
+ "description": "Should we show text content in the promo section."
+ },
+ "promoLinkText": {
+ "type": "string",
+ "description": "The text of the link in the promo box."
+ },
+ "promoHeader": {
+ "type": "string",
+ "description": "The title of the promo section."
+ },
+ "promoButton": {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "Action dispatched by the button."
+ },
+ "data": {
+ "type": "object"
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": true
+ }
+ },
+ "required": ["action"]
+ },
+ "promoLinkType": {
+ "type": "string",
+ "description": "Type of promo link type. Possible values: link, button. Default is link.",
+ "enum": ["link", "button"]
+ },
+ "promoImageLarge": {
+ "type": "string",
+ "description": "URL for image used on the left side of the promo box, larger, showcases some feature. Default off.",
+ "format": "uri"
+ },
+ "promoImageSmall": {
+ "type": "string",
+ "description": "URL for image used on the right side of the promo box, smaller, usually a logo. Default off.",
+ "format": "uri"
+ }
+ },
+ "additionalProperties": true,
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "promoEnabled": {
+ "const": true
+ }
+ },
+ "required": ["promoEnabled"]
+ },
+ "then": {
+ "required": ["promoButton"]
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "infoEnabled": {
+ "const": true
+ }
+ },
+ "required": ["infoEnabled"]
+ },
+ "then": {
+ "required": ["infoLinkText"],
+ "if": {
+ "properties": {
+ "infoTitleEnabled": {
+ "const": true
+ }
+ },
+ "required": ["infoTitleEnabled"]
+ },
+ "then": {
+ "required": ["infoTitle"]
+ }
+ }
+ }
+ ]
+ },
+ "template": {
+ "type": "string",
+ "const": "pb_newtab"
+ }
+ },
+ "additionalProperties": true,
+ "required": ["targeting"]
+ },
+ "Spotlight": {
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
+ "$id": "file:///Spotlight.schema.json",
+ "title": "Spotlight",
+ "description": "A template with an image, title, content and two buttons.",
+ "allOf": [
+ {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/Message"
+ }
+ ],
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "object",
+ "properties": {
+ "template": {
+ "type": "string",
+ "description": "Specify the layout template for the Spotlight",
+ "const": "multistage"
+ },
+ "backdrop": {
+ "type": "string",
+ "description": "Background css behind modal content"
+ },
+ "logo": {
+ "type": "object",
+ "properties": {
+ "imageURL": {
+ "type": "string",
+ "description": "URL for image to use with the content"
+ },
+ "imageId": {
+ "type": "string",
+ "description": "The ID for a remotely hosted image"
+ },
+ "size": {
+ "type": "string",
+ "description": "The logo size."
+ }
+ },
+ "additionalProperties": true
+ },
+ "screens": {
+ "type": "array",
+ "description": "Collection of individual screen content"
+ },
+ "transitions": {
+ "type": "boolean",
+ "description": "Show transitions within and between screens"
+ },
+ "disableHistoryUpdates": {
+ "type": "boolean",
+ "description": "Don't alter the browser session's history stack - used with messaging surfaces like Feature Callouts"
+ },
+ "startScreen": {
+ "type": "integer",
+ "description": "Index of first screen to show from message, defaulting to 0"
+ }
+ },
+ "additionalProperties": true
+ },
+ "template": {
+ "type": "string",
+ "description": "Specify whether the surface is shown as a Spotlight modal or an in-surface Feature Callout dialog",
+ "enum": ["spotlight", "feature_callout"]
+ }
+ },
+ "additionalProperties": true,
+ "required": ["targeting"]
+ },
+ "ToastNotification": {
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
+ "$id": "file:///ToastNotification.schema.json",
+ "title": "ToastNotification",
+ "description": "A template for toast notifications displayed by the Alert service.",
+ "allOf": [
+ {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/Message"
+ }
+ ],
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText",
+ "description": "Id of localized string or message override of toast notification title"
+ },
+ "body": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText",
+ "description": "Id of localized string or message override of toast notification body"
+ },
+ "icon_url": {
+ "description": "The URL of the image used as an icon of the toast notification.",
+ "type": "string",
+ "format": "moz-url-format"
+ },
+ "image_url": {
+ "description": "The URL of an image to be displayed as part of the notification.",
+ "type": "string",
+ "format": "moz-url-format"
+ },
+ "launch_url": {
+ "description": "The URL to launch when the notification or an action button is clicked.",
+ "type": "string",
+ "format": "moz-url-format"
+ },
+ "launch_action": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "The launch action to be performed when Firefox is launched."
+ },
+ "data": {
+ "type": "object"
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": true
+ },
+ "requireInteraction": {
+ "type": "boolean",
+ "description": "Whether the toast notification should remain active until the user clicks or dismisses it, rather than closing automatically."
+ },
+ "tag": {
+ "type": "string",
+ "description": "An identifying tag for the toast notification."
+ },
+ "data": {
+ "type": "object",
+ "description": "Arbitrary data associated with the toast notification."
+ },
+ "actions": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText",
+ "description": "The action text to be shown to the user."
+ },
+ "action": {
+ "type": "string",
+ "description": "Opaque identifer that identifies action."
+ },
+ "iconURL": {
+ "type": "string",
+ "format": "uri",
+ "description": "URL of an icon to display with the action."
+ },
+ "windowsSystemActivationType": {
+ "type": "boolean",
+ "description": "Whether to have Windows process the given `action`."
+ },
+ "launch_action": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "The launch action to be performed when Firefox is launched."
+ },
+ "data": {
+ "type": "object"
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": true
+ }
+ },
+ "required": ["action", "title"],
+ "additionalProperties": true
+ }
+ }
+ },
+ "additionalProperties": true,
+ "required": ["title", "body"]
+ },
+ "template": {
+ "type": "string",
+ "const": "toast_notification"
+ }
+ },
+ "required": ["content", "targeting", "template", "trigger"],
+ "additionalProperties": true
+ },
+ "ToolbarBadgeMessage": {
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
+ "$id": "file:///ToolbarBadgeMessage.schema.json",
+ "title": "ToolbarBadgeMessage",
+ "description": "A template that specifies to which element in the browser toolbar to add a notification.",
+ "allOf": [
+ {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/Message"
+ }
+ ],
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "object",
+ "properties": {
+ "target": {
+ "type": "string"
+ },
+ "action": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": true,
+ "required": ["id"],
+ "description": "Optional action to take in addition to showing the notification"
+ },
+ "delay": {
+ "type": "number",
+ "description": "Optional delay in ms after which to show the notification"
+ },
+ "badgeDescription": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizedText",
+ "description": "This is used in combination with the badged button to offer a text based alternative to the visual badging. Example 'New Feature: What's New'"
+ }
+ },
+ "additionalProperties": true,
+ "required": ["target"]
+ },
+ "template": {
+ "type": "string",
+ "const": "toolbar_badge"
+ }
+ },
+ "additionalProperties": true,
+ "required": ["targeting"]
+ },
+ "UpdateAction": {
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
+ "$id": "file:///UpdateAction.schema.json",
+ "title": "UpdateActionMessage",
+ "description": "A template for messages that execute predetermined actions.",
+ "allOf": [
+ {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/Message"
+ }
+ ],
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "data": {
+ "type": "object",
+ "description": "Additional data provided as argument when executing the action",
+ "properties": {
+ "url": {
+ "type": "string",
+ "description": "URL data to be used as argument to the action"
+ },
+ "expireDelta": {
+ "type": "number",
+ "description": "Expiration timestamp to be used as argument to the action"
+ }
+ }
+ }
+ },
+ "additionalProperties": true,
+ "description": "Optional action to take in addition to showing the notification",
+ "required": ["id", "data"]
+ }
+ },
+ "additionalProperties": true,
+ "required": ["action"]
+ },
+ "template": {
+ "type": "string",
+ "const": "update_action"
+ }
+ },
+ "required": ["targeting"]
+ },
+ "WhatsNewMessage": {
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
+ "$id": "file:///WhatsNewMessage.schema.json",
+ "title": "WhatsNewMessage",
+ "description": "A template for the messages that appear in the What's New panel.",
+ "allOf": [
+ {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/Message"
+ }
+ ],
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "object",
+ "properties": {
+ "layout": {
+ "description": "Different message layouts",
+ "enum": ["tracking-protections"]
+ },
+ "bucket_id": {
+ "type": "string",
+ "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting."
+ },
+ "published_date": {
+ "type": "integer",
+ "description": "The date/time (number of milliseconds elapsed since January 1, 1970 00:00:00 UTC) the message was published."
+ },
+ "title": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText",
+ "description": "Id of localized string or message override of What's New message title"
+ },
+ "subtitle": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText",
+ "description": "Id of localized string or message override of What's New message subtitle"
+ },
+ "body": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText",
+ "description": "Id of localized string or message override of What's New message body"
+ },
+ "link_text": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText",
+ "description": "(optional) Id of localized string or message override of What's New message link text"
+ },
+ "cta_url": {
+ "description": "Target URL for the What's New message.",
+ "type": "string",
+ "format": "moz-url-format"
+ },
+ "cta_type": {
+ "description": "Type of url open action",
+ "enum": ["OPEN_URL", "OPEN_ABOUT_PAGE", "OPEN_PROTECTION_REPORT"]
+ },
+ "cta_where": {
+ "description": "How to open the cta: new window, tab, focused, unfocused.",
+ "enum": ["current", "tabshifted", "tab", "save", "window"]
+ },
+ "icon_url": {
+ "description": "(optional) URL for the What's New message icon.",
+ "type": "string",
+ "format": "uri"
+ },
+ "icon_alt": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizableText",
+ "description": "Alt text for image."
+ }
+ },
+ "additionalProperties": true,
+ "required": [
+ "published_date",
+ "title",
+ "body",
+ "cta_url",
+ "bucket_id"
+ ]
+ },
+ "template": {
+ "type": "string",
+ "const": "whatsnew_panel_message"
+ }
+ },
+ "required": ["order"],
+ "additionalProperties": true
+ },
+ "Message": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The message identifier"
+ },
+ "groups": {
+ "description": "Array of preferences used to control `enabled` status of the group. If any is `false` the group is disabled.",
+ "type": "array",
+ "items": {
+ "type": "string",
+ "description": "Preference name"
+ }
+ },
+ "template": {
+ "type": "string",
+ "description": "Which messaging template this message is using.",
+ "enum": [
+ "cfr_urlbar_chiclet",
+ "cfr_doorhanger",
+ "milestone_message",
+ "infobar",
+ "pb_newtab",
+ "spotlight",
+ "feature_callout",
+ "toast_notification",
+ "toolbar_badge",
+ "update_action",
+ "whatsnew_panel_message"
+ ]
+ },
+ "frequency": {
+ "type": "object",
+ "description": "An object containing frequency cap information for a message.",
+ "properties": {
+ "lifetime": {
+ "type": "integer",
+ "description": "The maximum lifetime impressions for a message.",
+ "minimum": 1,
+ "maximum": 100
+ },
+ "custom": {
+ "type": "array",
+ "description": "An array of custom frequency cap definitions.",
+ "items": {
+ "description": "A frequency cap definition containing time and max impression information",
+ "type": "object",
+ "properties": {
+ "period": {
+ "type": "integer",
+ "description": "Period of time in milliseconds (e.g. 86400000 for one day)"
+ },
+ "cap": {
+ "type": "integer",
+ "description": "The maximum impressions for the message within the defined period.",
+ "minimum": 1,
+ "maximum": 100
+ }
+ },
+ "required": ["period", "cap"]
+ }
+ }
+ }
+ },
+ "priority": {
+ "description": "The priority of the message. If there are two competing messages to show, the one with the highest priority will be shown",
+ "type": "integer"
+ },
+ "order": {
+ "description": "The order in which messages should be shown. Messages will be shown in increasing order.",
+ "type": "integer"
+ },
+ "targeting": {
+ "description": "A JEXL expression representing targeting information",
+ "type": "string"
+ },
+ "trigger": {
+ "description": "An action to trigger potentially showing the message",
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "A string identifying the trigger action"
+ },
+ "params": {
+ "type": "array",
+ "description": "An optional array of string parameters for the trigger action",
+ "items": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "string"
+ }
+ ]
+ }
+ }
+ },
+ "required": ["id"]
+ },
+ "provider": {
+ "description": "An identifier for the provider of this message, such as \"cfr\" or \"preview\".",
+ "type": "string"
+ }
+ },
+ "additionalProperties": true,
+ "dependentRequired": {
+ "content": ["id", "template"],
+ "template": ["id", "content"]
+ }
+ },
+ "localizedText": {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "description": "Id of localized string to be rendered.",
+ "type": "string"
+ }
+ },
+ "required": ["string_id"]
+ },
+ "localizableText": {
+ "description": "Either a raw string or an object containing the string_id of the localized text",
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "The string to be rendered."
+ },
+ {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/localizedText"
+ }
+ ]
+ },
+ "TemplatedMessage": {
+ "description": "An FxMS message of one of a variety of types.",
+ "type": "object",
+ "allOf": [
+ {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/Message"
+ },
+ {
+ "if": {
+ "type": "object",
+ "properties": {
+ "template": {
+ "type": "string",
+ "enum": ["cfr_urlbar_chiclet"]
+ }
+ },
+ "required": ["template"]
+ },
+ "then": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/CFRUrlbarChiclet"
+ }
+ },
+ {
+ "if": {
+ "type": "object",
+ "properties": {
+ "template": {
+ "type": "string",
+ "enum": ["cfr_doorhanger", "milestone_message"]
+ }
+ },
+ "required": ["template"]
+ },
+ "then": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/ExtensionDoorhanger"
+ }
+ },
+ {
+ "if": {
+ "type": "object",
+ "properties": {
+ "template": {
+ "type": "string",
+ "enum": ["infobar"]
+ }
+ },
+ "required": ["template"]
+ },
+ "then": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/InfoBar"
+ }
+ },
+ {
+ "if": {
+ "type": "object",
+ "properties": {
+ "template": {
+ "type": "string",
+ "enum": ["pb_newtab"]
+ }
+ },
+ "required": ["template"]
+ },
+ "then": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/NewtabPromoMessage"
+ }
+ },
+ {
+ "if": {
+ "type": "object",
+ "properties": {
+ "template": {
+ "type": "string",
+ "enum": ["spotlight", "feature_callout"]
+ }
+ },
+ "required": ["template"]
+ },
+ "then": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/Spotlight"
+ }
+ },
+ {
+ "if": {
+ "type": "object",
+ "properties": {
+ "template": {
+ "type": "string",
+ "enum": ["toast_notification"]
+ }
+ },
+ "required": ["template"]
+ },
+ "then": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/ToastNotification"
+ }
+ },
+ {
+ "if": {
+ "type": "object",
+ "properties": {
+ "template": {
+ "type": "string",
+ "enum": ["toolbar_badge"]
+ }
+ },
+ "required": ["template"]
+ },
+ "then": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/ToolbarBadgeMessage"
+ }
+ },
+ {
+ "if": {
+ "type": "object",
+ "properties": {
+ "template": {
+ "type": "string",
+ "enum": ["update_action"]
+ }
+ },
+ "required": ["template"]
+ },
+ "then": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/UpdateAction"
+ }
+ },
+ {
+ "if": {
+ "type": "object",
+ "properties": {
+ "template": {
+ "type": "string",
+ "enum": ["whatsnew_panel_message"]
+ }
+ },
+ "required": ["template"]
+ },
+ "then": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/WhatsNewMessage"
+ }
+ }
+ ]
+ },
+ "MultiMessage": {
+ "description": "An object containing an array of messages.",
+ "type": "object",
+ "properties": {
+ "template": {
+ "type": "string",
+ "const": "multi"
+ },
+ "messages": {
+ "type": "array",
+ "description": "An array of messages.",
+ "items": {
+ "$ref": "chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json#/$defs/TemplatedMessage"
+ }
+ }
+ },
+ "required": ["template", "messages"]
+ }
+ }
+}
diff --git a/browser/components/asrouter/content-src/schemas/corpus/ReachExperiments.messages.json b/browser/components/asrouter/content-src/schemas/corpus/ReachExperiments.messages.json
new file mode 100644
index 0000000000..1ccfefe478
--- /dev/null
+++ b/browser/components/asrouter/content-src/schemas/corpus/ReachExperiments.messages.json
@@ -0,0 +1,15 @@
+[
+ {
+ "trigger": {
+ "id": "defaultBrowserCheck"
+ },
+ "targeting": "source == 'startup' && !isMajorUpgrade && !activeNotifications && totalBookmarksCount == 5"
+ },
+ {
+ "groups": ["eco"],
+ "trigger": {
+ "id": "defaultBrowserCheck"
+ },
+ "targeting": "source == 'startup' && !isMajorUpgrade && !activeNotifications && totalBookmarksCount == 5"
+ }
+]
diff --git a/browser/components/asrouter/content-src/schemas/extract-test-corpus.js b/browser/components/asrouter/content-src/schemas/extract-test-corpus.js
new file mode 100644
index 0000000000..562a467561
--- /dev/null
+++ b/browser/components/asrouter/content-src/schemas/extract-test-corpus.js
@@ -0,0 +1,65 @@
+/* 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";
+
+const { CFRMessageProvider } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/CFRMessageProvider.sys.mjs"
+);
+const { OnboardingMessageProvider } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/OnboardingMessageProvider.sys.mjs"
+);
+const { PanelTestProvider } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/PanelTestProvider.sys.mjs"
+);
+
+const CWD = Services.dirsvc.get("CurWorkD", Ci.nsIFile).path;
+const CORPUS_DIR = PathUtils.join(CWD, "corpus");
+
+const CORPUS = [
+ {
+ name: "CFRMessageProvider.messages.json",
+ provider: CFRMessageProvider,
+ },
+ {
+ name: "OnboardingMessageProvider.messages.json",
+ provider: OnboardingMessageProvider,
+ },
+ {
+ name: "PanelTestProvider.messages.json",
+ provider: PanelTestProvider,
+ },
+ {
+ name: "PanelTestProvider_toast_notification.messages.json",
+ provider: PanelTestProvider,
+ filter: message => message.template === "toast_notification",
+ },
+];
+
+let exit = false;
+async function main() {
+ try {
+ await IOUtils.makeDirectory(CORPUS_DIR);
+
+ for (const entry of CORPUS) {
+ const { name, provider } = entry;
+ const filter = entry.filter ?? (() => true);
+ const messages = await provider.getMessages();
+ const json = `${JSON.stringify(messages.filter(filter), undefined, 2)}\n`;
+
+ const path = PathUtils.join(CORPUS_DIR, name);
+ await IOUtils.writeUTF8(path, json);
+ }
+ } finally {
+ exit = true;
+ }
+}
+
+main();
+
+// We need to spin the event loop here, otherwise everything goes out of scope.
+Services.tm.spinEventLoopUntil(
+ "extract-test-corpus.js: waiting for completion",
+ () => exit
+);
diff --git a/browser/components/asrouter/content-src/schemas/make-schemas.py b/browser/components/asrouter/content-src/schemas/make-schemas.py
new file mode 100755
index 0000000000..f66490f23a
--- /dev/null
+++ b/browser/components/asrouter/content-src/schemas/make-schemas.py
@@ -0,0 +1,472 @@
+#!/usr/bin/env python3
+# 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/.
+
+"""Firefox Messaging System Messaging Experiment schema generator
+
+The Firefox Messaging System handles several types of messages. This program
+patches and combines those schemas into a single schema
+(MessagingExperiment.schema.json) which is used to validate messaging
+experiments coming from Nimbus.
+
+Definitions from FxMsCommon.schema.json are bundled into this schema. This
+allows all of the FxMS schemas to reference common definitions, e.g.
+`localizableText` for translatable strings, via referencing the common schema.
+The bundled schema will be re-written so that the references now point at the
+top-level, generated schema.
+
+Additionally, all self-references in each messaging schema will be rewritten
+into absolute references, referencing each sub-schemas `$id`. This is requried
+due to the JSONSchema validation library used by Experimenter not fully
+supporting self-references and bundled schema.
+"""
+
+import json
+import sys
+from argparse import ArgumentParser
+from itertools import chain
+from pathlib import Path
+from typing import Any, Dict, List, NamedTuple, Union
+from urllib.parse import urlparse
+
+import jsonschema
+
+
+class SchemaDefinition(NamedTuple):
+ """A definition of a schema that is to be bundled."""
+
+ #: The $id of the generated schema.
+ schema_id: str
+
+ #: The path of the generated schema.
+ schema_path: Path
+
+ #: The message types that will be bundled into the schema.
+ message_types: Dict[str, Path]
+
+ #: What common definitions to bundle into the schema.
+ #:
+ #: If `True`, all definitions will be bundled.
+ #: If `False`, no definitons will be bundled.
+ #: If a list, only the named definitions will be bundled.
+ bundle_common: Union[bool, List[str]]
+
+ #: The testing corpus for the schema.
+ test_corpus: Dict[str, Path]
+
+
+SCHEMA_DIR = Path("..", "templates")
+
+SCHEMAS = [
+ SchemaDefinition(
+ schema_id="chrome://browser/content/asrouter/schemas/MessagingExperiment.schema.json",
+ schema_path=Path("MessagingExperiment.schema.json"),
+ message_types={
+ "CFRUrlbarChiclet": (
+ SCHEMA_DIR / "CFR" / "templates" / "CFRUrlbarChiclet.schema.json"
+ ),
+ "ExtensionDoorhanger": (
+ SCHEMA_DIR / "CFR" / "templates" / "ExtensionDoorhanger.schema.json"
+ ),
+ "InfoBar": SCHEMA_DIR / "CFR" / "templates" / "InfoBar.schema.json",
+ "NewtabPromoMessage": (
+ SCHEMA_DIR / "PBNewtab" / "NewtabPromoMessage.schema.json"
+ ),
+ "Spotlight": SCHEMA_DIR / "OnboardingMessage" / "Spotlight.schema.json",
+ "ToastNotification": (
+ SCHEMA_DIR / "ToastNotification" / "ToastNotification.schema.json"
+ ),
+ "ToolbarBadgeMessage": (
+ SCHEMA_DIR / "OnboardingMessage" / "ToolbarBadgeMessage.schema.json"
+ ),
+ "UpdateAction": (
+ SCHEMA_DIR / "OnboardingMessage" / "UpdateAction.schema.json"
+ ),
+ "WhatsNewMessage": (
+ SCHEMA_DIR / "OnboardingMessage" / "WhatsNewMessage.schema.json"
+ ),
+ },
+ bundle_common=True,
+ test_corpus={
+ "ReachExperiments": Path("corpus", "ReachExperiments.messages.json"),
+ # These are generated via extract-test-corpus.js
+ "CFRMessageProvider": Path("corpus", "CFRMessageProvider.messages.json"),
+ "OnboardingMessageProvider": Path(
+ "corpus", "OnboardingMessageProvider.messages.json"
+ ),
+ "PanelTestProvider": Path("corpus", "PanelTestProvider.messages.json"),
+ },
+ ),
+ SchemaDefinition(
+ schema_id=(
+ "chrome://browser/content/asrouter/schemas/"
+ "BackgroundTaskMessagingExperiment.schema.json"
+ ),
+ schema_path=Path("BackgroundTaskMessagingExperiment.schema.json"),
+ message_types={
+ "ToastNotification": (
+ SCHEMA_DIR / "ToastNotification" / "ToastNotification.schema.json"
+ ),
+ },
+ bundle_common=True,
+ # These are generated via extract-test-corpus.js
+ test_corpus={
+ # Just the "toast_notification" messages.
+ "PanelTestProvider": Path(
+ "corpus", "PanelTestProvider_toast_notification.messages.json"
+ ),
+ },
+ ),
+]
+
+COMMON_SCHEMA_NAME = "FxMSCommon.schema.json"
+COMMON_SCHEMA_PATH = Path(COMMON_SCHEMA_NAME)
+
+
+class NestedRefResolver(jsonschema.RefResolver):
+ """A custom ref resolver that handles bundled schema.
+
+ This is the resolver used by Experimenter.
+ """
+
+ def __init__(self, schema):
+ super().__init__(base_uri=None, referrer=None)
+
+ if "$id" in schema:
+ self.store[schema["$id"]] = schema
+
+ if "$defs" in schema:
+ for dfn in schema["$defs"].values():
+ if "$id" in dfn:
+ self.store[dfn["$id"]] = dfn
+
+
+def read_schema(path):
+ """Read a schema from disk and parse it as JSON."""
+ with path.open("r") as f:
+ return json.load(f)
+
+
+def extract_template_values(template):
+ """Extract the possible template values (either via JSON Schema enum or const)."""
+ enum = template.get("enum")
+ if enum:
+ return enum
+
+ const = template.get("const")
+ if const:
+ return [const]
+
+
+def patch_schema(schema, bundled_id, schema_id=None):
+ """Patch the given schema.
+
+ The JSON schema validator that Experimenter uses
+ (https://pypi.org/project/jsonschema/) does not support relative references,
+ nor does it support bundled schemas. We rewrite the schema so that all
+ relative refs are transformed into absolute refs via the schema's `$id`.
+
+ Additionally, we merge in the contents of FxMSCommon.schema.json, so all
+ refs relative to that schema will be transformed to become relative to this
+ schema.
+
+ See-also: https://github.com/python-jsonschema/jsonschema/issues/313
+ """
+ if schema_id is None:
+ schema_id = schema["$id"]
+
+ def patch_impl(schema):
+ ref = schema.get("$ref")
+
+ if ref:
+ uri = urlparse(ref)
+ if (
+ uri.scheme == ""
+ and uri.netloc == ""
+ and uri.path == ""
+ and uri.fragment != ""
+ ):
+ schema["$ref"] = f"{schema_id}#{uri.fragment}"
+ elif (uri.scheme, uri.path) == ("file", f"/{COMMON_SCHEMA_NAME}"):
+ schema["$ref"] = f"{bundled_id}#{uri.fragment}"
+
+ # If `schema` is object-like, inspect each of its indivual properties
+ # and patch them.
+ properties = schema.get("properties")
+ if properties:
+ for prop in properties.keys():
+ patch_impl(properties[prop])
+
+ # If `schema` is array-like, inspect each of its items and patch them.
+ items = schema.get("items")
+ if items:
+ patch_impl(items)
+
+ # Patch each `if`, `then`, `else`, and `not` sub-schema that is present.
+ for key in ("if", "then", "else", "not"):
+ if key in schema:
+ patch_impl(schema[key])
+
+ # Patch the items of each `oneOf`, `allOf`, and `anyOf` sub-schema that
+ # is present.
+ for key in ("oneOf", "allOf", "anyOf"):
+ subschema = schema.get(key)
+ if subschema:
+ for i, alternate in enumerate(subschema):
+ patch_impl(alternate)
+
+ # Patch the top-level type defined in the schema.
+ patch_impl(schema)
+
+ # Patch each named definition in the schema.
+ for key in ("$defs", "definitions"):
+ defns = schema.get(key)
+ if defns:
+ for defn_name, defn_value in defns.items():
+ patch_impl(defn_value)
+
+ return schema
+
+
+def bundle_schema(schema_def: SchemaDefinition):
+ """Create a bundled schema based on the schema definition."""
+ # Patch each message type schema to resolve all self-references to be
+ # absolute and rewrite # references to FxMSCommon.schema.json to be relative
+ # to the new schema (because we are about to bundle its definitions).
+ defs = {
+ name: patch_schema(read_schema(path), bundled_id=schema_def.schema_id)
+ for name, path in schema_def.message_types.items()
+ }
+
+ # Bundle the definitions from FxMSCommon.schema.json into this schema.
+ if schema_def.bundle_common:
+
+ def dfn_filter(name):
+ if schema_def.bundle_common is True:
+ return True
+
+ return name in schema_def.bundle_common
+
+ common_schema = patch_schema(
+ read_schema(COMMON_SCHEMA_PATH),
+ bundled_id=schema_def.schema_id,
+ schema_id=schema_def.schema_id,
+ )
+
+ # patch_schema mutates the given schema, so we read a new copy in for
+ # each bundle operation.
+ defs.update(
+ {
+ name: dfn
+ for name, dfn in common_schema["$defs"].items()
+ if dfn_filter(name)
+ }
+ )
+
+ # Ensure all bundled schemas have an $id so that $refs inside the
+ # bundled schema work correctly (i.e, they will reference the subschema
+ # and not the bundle).
+ for name in schema_def.message_types.keys():
+ subschema = defs[name]
+ if "$id" not in subschema:
+ raise ValueError(f"Schema {name} is missing an $id")
+
+ props = subschema["properties"]
+ if "template" not in props:
+ raise ValueError(f"Schema {name} is missing a template")
+
+ template = props["template"]
+ if "enum" not in template and "const" not in template:
+ raise ValueError(f"Schema {name} should have const or enum template")
+
+ templates = {
+ name: extract_template_values(defs[name]["properties"]["template"])
+ for name in schema_def.message_types.keys()
+ }
+
+ # Ensure that each schema has a unique set of template values.
+ for a in templates.keys():
+ a_keys = set(templates[a])
+
+ for b in templates.keys():
+ if a == b:
+ continue
+
+ b_keys = set(templates[b])
+ intersection = a_keys.intersection(b_keys)
+
+ if len(intersection):
+ raise ValueError(
+ f"Schema {a} and {b} have overlapping template values: "
+ f"{', '.join(intersection)}"
+ )
+
+ all_templates = list(chain.from_iterable(templates.values()))
+
+ # Enforce that one of the templates must match (so that one of the if
+ # branches will match).
+ defs["Message"]["properties"]["template"]["enum"] = all_templates
+ defs["TemplatedMessage"] = {
+ "description": "An FxMS message of one of a variety of types.",
+ "type": "object",
+ "allOf": [
+ # Ensure each message has all the fields defined in the base
+ # Message type.
+ #
+ # This is slightly redundant because each message should
+ # already inherit from this message type, but it is easier
+ # to add this requirement here than to verify that each
+ # message's schema is properly inheriting.
+ {"$ref": f"{schema_def.schema_id}#/$defs/Message"},
+ # For each message type, create a subschema that says if the
+ # template field matches a value for a message type defined
+ # in MESSAGE_TYPES, then the message must also match the
+ # schema for that message type.
+ #
+ # This is done using `allOf: [{ if, then }]` instead of `oneOf: []`
+ # because it provides better error messages. Using `if-then`
+ # will only show validation errors for the sub-schema that
+ # matches template, whereas using `oneOf` will show
+ # validation errors for *all* sub-schemas, which makes
+ # debugging messages much harder.
+ *(
+ {
+ "if": {
+ "type": "object",
+ "properties": {
+ "template": {
+ "type": "string",
+ "enum": templates[message_type],
+ },
+ },
+ "required": ["template"],
+ },
+ "then": {"$ref": f"{schema_def.schema_id}#/$defs/{message_type}"},
+ }
+ for message_type in schema_def.message_types
+ ),
+ ],
+ }
+ defs["MultiMessage"] = {
+ "description": "An object containing an array of messages.",
+ "type": "object",
+ "properties": {
+ "template": {"type": "string", "const": "multi"},
+ "messages": {
+ "type": "array",
+ "description": "An array of messages.",
+ "items": {"$ref": f"{schema_def.schema_id}#/$defs/TemplatedMessage"},
+ },
+ },
+ "required": ["template", "messages"],
+ }
+
+ # Generate the combined schema.
+ return {
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
+ "$id": schema_def.schema_id,
+ "title": "Messaging Experiment",
+ "description": "A Firefox Messaging System message.",
+ # A message must be one of:
+ # - An object that contains id, template, and content fields
+ # - An object that contains none of the above fields (empty message)
+ # - An array of messages like the above
+ "if": {
+ "type": "object",
+ "properties": {"template": {"const": "multi"}},
+ "required": ["template"],
+ },
+ "then": {
+ "$ref": f"{schema_def.schema_id}#/$defs/MultiMessage",
+ },
+ "else": {
+ "$ref": f"{schema_def.schema_id}#/$defs/TemplatedMessage",
+ },
+ "$defs": defs,
+ }
+
+
+def check_diff(schema_def: SchemaDefinition, schema: Dict[str, Any]):
+ """Check the generated schema matches the on-disk schema."""
+ print(f" Checking {schema_def.schema_path} for differences...")
+
+ with schema_def.schema_path.open("r") as f:
+ on_disk = json.load(f)
+
+ if on_disk != schema:
+ print(f"{schema_def.schema_path} does not match generated schema:")
+ print("Generated schema:")
+ json.dump(schema, sys.stdout, indent=2)
+ print("\n\nOn Disk schema:")
+ json.dump(on_disk, sys.stdout, indent=2)
+ print("\n\n")
+
+ raise ValueError("Schemas do not match!")
+
+
+def validate_corpus(schema_def: SchemaDefinition, schema: Dict[str, Any]):
+ """Check that the schema validates.
+
+ This uses the same validation configuration that is used in Experimenter.
+ """
+ print(" Validating messages with Experimenter JSON Schema validator...")
+
+ resolver = NestedRefResolver(schema)
+
+ for provider, provider_path in schema_def.test_corpus.items():
+ print(f" Validating messages from {provider}:")
+
+ try:
+ with provider_path.open("r") as f:
+ messages = json.load(f)
+ except FileNotFoundError as e:
+ if not provider_path.parent.exists():
+ new_exc = Exception(
+ f"Could not find {provider_path}: Did you run "
+ "`mach xpcshell extract-test-corpus.js` ?"
+ )
+ raise new_exc from e
+
+ raise e
+
+ for i, message in enumerate(messages):
+ template = message.get("template", "(no template)")
+ msg_id = message.get("id", f"index {i}")
+
+ print(
+ f" Validating {msg_id} {template} message with {schema_def.schema_path}..."
+ )
+ jsonschema.validate(instance=message, schema=schema, resolver=resolver)
+
+ print()
+
+
+def main(check=False):
+ """Generate Nimbus feature schemas for Firefox Messaging System."""
+ for schema_def in SCHEMAS:
+ print(f"Generating {schema_def.schema_path} ...")
+ schema = bundle_schema(schema_def)
+
+ if check:
+ print(f"Checking {schema_def.schema_path} ...")
+ check_diff(schema_def, schema)
+ validate_corpus(schema_def, schema)
+ else:
+ with schema_def.schema_path.open("wb") as f:
+ print(f"Writing {schema_def.schema_path} ...")
+ f.write(json.dumps(schema, indent=2).encode("utf-8"))
+ f.write(b"\n")
+
+
+if __name__ == "__main__":
+ parser = ArgumentParser(description=main.__doc__)
+ parser.add_argument(
+ "--check",
+ action="store_true",
+ help="Check that the generated schemas have not changed and run validation tests.",
+ default=False,
+ )
+ args = parser.parse_args()
+
+ main(args.check)
diff --git a/browser/components/asrouter/content-src/schemas/message-format.md b/browser/components/asrouter/content-src/schemas/message-format.md
new file mode 100644
index 0000000000..65f031e260
--- /dev/null
+++ b/browser/components/asrouter/content-src/schemas/message-format.md
@@ -0,0 +1,111 @@
+## Activity Stream Router message format
+
+Field name | Type | Required | Description | Example / Note
+--- | --- | --- | --- | ---
+`id` | `string` | Yes | A unique identifier for the message that should not conflict with any other previous message | `ONBOARDING_1`
+`template` | `string` | Yes | An id matching an existing Activity Stream Router template |
+`content` | `object` | Yes | An object containing all variables/props to be rendered in the template. Subset of allowed tags detailed below. | [See example below](#html-subset)
+`bundled` | `integer` | No | The number of messages of the same template this one should be shown with | [See example below](#a-bundled-message-example)
+`order` | `integer` | No | If bundled with other messages of the same template, which order should this one be placed in? Defaults to 0 if no order is desired | [See example below](#a-bundled-message-example)
+`campaign` | `string` | No | Campaign id that the message belongs to | `RustWebAssembly`
+`targeting` | `string` `JEXL` | No | A [JEXL expression](http://normandy.readthedocs.io/en/latest/user/filter_expressions.html#jexl-basics) with all targeting information needed in order to decide if the message is shown | Not yet implemented, [Examples](#targeting-attributes)
+`trigger` | `string` | No | An event or condition upon which the message will be immediately shown. This can be combined with `targeting`. Messages that define a trigger will not be shown during non-trigger-based passive message rotation.
+`trigger.params` | `[string]` | No | A set of hostnames passed down as parameters to the trigger condition. Used to restrict the number of domains where the trigger/message is valid. | [See example below](#trigger-params)
+`trigger.patterns` | `[string]` | No | A set of patterns that match multiple hostnames passed down as parameters to the trigger condition. Used to restrict the number of domains where the trigger/message is valid. | [See example below](#trigger-patterns)
+`frequency` | `object` | No | A definition for frequency cap information for the message
+`frequency.lifetime` | `integer` | No | The maximum number of lifetime impressions for the message.
+`frequency.custom` | `array` | No | An array of frequency cap definition objects including `period`, a time period in milliseconds, and `cap`, a max number of impressions for that period.
+
+### Message example
+```javascript
+{
+ weight: 100,
+ id: "PROTECTIONS_PANEL_1",
+ template: "protections_panel",
+ content: {
+ title: {
+ string_id: "cfr-protections-panel-header"
+ },
+ body: {
+ string_id: "cfr-protections-panel-body"
+ },
+ link_text: {
+ string_id: "cfr-protections-panel-link-text"
+ },
+ cta_url: "https://support.mozilla.org/1/firefox/121.0a1/Darwin/en-US/etp-promotions?as=u&utm_source=inproduct",
+ cta_type: "OPEN_URL"
+ },
+ trigger: {
+ id: "protectionsPanelOpen"
+ },
+ groups: [],
+ provider: "onboarding"
+}
+```
+
+### A Bundled Message example
+The following 2 messages have a `bundled` property, indicating that they should be shown together, since they have the same template. The number `2` indicates that this message should be shown in a bundle of 2 messages of the same template. The order property defines that ONBOARDING_2 should be shown after ONBOARDING_3 in the bundle.
+```javascript
+{
+ id: "ONBOARDING_2",
+ template: "onboarding",
+ bundled: 2,
+ order: 2,
+ content: {
+ title: "Private Browsing",
+ body: "Browse by yourself. Private Browsing with Tracking Protection blocks online trackers that follow you around the web."
+ },
+ targeting: "",
+ trigger: "firstRun"
+}
+{
+ id: "ONBOARDING_3",
+ template: "onboarding",
+ bundled: 2,
+ order: 1,
+ content: {
+ title: "Find it faster",
+ body: "Access all of your favorite search engines with a click. Search the whole Web or just one website from the search box."
+ },
+ targeting: "",
+ trigger: "firstRun"
+}
+```
+
+### HTML subset
+The following tags are allowed in the content of a message: `i, b, u, strong, em, br`.
+
+Links cannot be rendered using regular anchor tags because [Fluent does not allow for href attributes](https://github.com/projectfluent/fluent.js/blob/a03d3aa833660f8c620738b26c80e46b1a4edb05/fluent-dom/src/overlay.js#L13). They will be wrapped in custom tags, for example `<cta>link</cta>` and the url will be provided as part of the payload:
+```
+{
+ "id": "7899",
+ "content": {
+ "text": "Use the CMD (CTRL) + T keyboard shortcut to <cta>open a new tab quickly!</cta>",
+ "links": {
+ "cta": {
+ "url": "https://support.mozilla.org/en-US/kb/keyboard-shortcuts-perform-firefox-tasks-quickly"
+ }
+ }
+ }
+}
+```
+If a tag that is not on the allowed is used, the text content will be extracted and displayed.
+
+Grouping multiple allowed elements is not possible, only the first level will be used: `<u><b>text</b></u>` will be interpreted as `<u>text</u>`.
+
+### Trigger params
+A set of hostnames that need to exactly match the location of the selected tab in order for the trigger to execute.
+```
+["github.com", "wwww.github.com"]
+```
+More examples in the [CFRMessageProvider](https://github.com/mozilla/activity-stream/blob/e76ce12fbaaac1182aa492b84fc038f78c3acc33/lib/CFRMessageProvider.jsm#L40-L47).
+
+### Trigger patterns
+A set of patterns that can match multiple hostnames. When the location of the selected tab matches one of the patterns it can execute a trigger.
+```
+["*://*.github.com"] // can match `github.com` but also match `https://gist.github.com/`
+```
+More [MatchPattern examples](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns#Examples).
+
+### Targeting attributes
+(This section has moved to [targeting-attributes.md](../docs/targeting-attributes.md)).
diff --git a/browser/components/asrouter/content-src/schemas/message-group.schema.json b/browser/components/asrouter/content-src/schemas/message-group.schema.json
new file mode 100644
index 0000000000..421acf159a
--- /dev/null
+++ b/browser/components/asrouter/content-src/schemas/message-group.schema.json
@@ -0,0 +1,64 @@
+{
+ "title": "MessageGroup",
+ "description": "Configuration object for groups of Messaging System messages",
+ "type": "object",
+ "version": "1.0.0",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "A unique identifier for the message that should not conflict with any other previous message."
+ },
+ "enabled": {
+ "type": "boolean",
+ "description": "Enables or disables all messages associated with this group."
+ },
+ "userPreferences": {
+ "type": "array",
+ "description": "Collection of preferences that control if the group is enabled.",
+ "items": {
+ "type": "string",
+ "description": "Preference name"
+ }
+ },
+ "frequency": {
+ "type": "object",
+ "description": "An object containing frequency cap information for a message.",
+ "properties": {
+ "lifetime": {
+ "type": "integer",
+ "description": "The maximum lifetime impressions for a message.",
+ "minimum": 1,
+ "maximum": 100
+ },
+ "custom": {
+ "type": "array",
+ "description": "An array of custom frequency cap definitions.",
+ "items": {
+ "description": "A frequency cap definition containing time and max impression information",
+ "type": "object",
+ "properties": {
+ "period": {
+ "type": "integer",
+ "description": "Period of time in milliseconds (e.g. 86400000 for one day)"
+ },
+ "cap": {
+ "type": "integer",
+ "description": "The maximum impressions for the message within the defined period.",
+ "minimum": 1,
+ "maximum": 100
+ }
+ },
+ "required": ["period", "cap"]
+ }
+ }
+ }
+ },
+ "type": {
+ "type": "string",
+ "description": "Local auto-generated group or remote group configuration from RS.",
+ "enum": ["remote-settings", "local", "default"]
+ }
+ },
+ "required": ["id", "enabled", "type"],
+ "additionalProperties": true
+}
diff --git a/browser/components/asrouter/content-src/schemas/provider-response.schema.json b/browser/components/asrouter/content-src/schemas/provider-response.schema.json
new file mode 100644
index 0000000000..b6bdc04f36
--- /dev/null
+++ b/browser/components/asrouter/content-src/schemas/provider-response.schema.json
@@ -0,0 +1,67 @@
+{
+ "title": "ProviderResponse",
+ "description": "A response object for remote providers of AS Router",
+ "type": "object",
+ "version": "6.1.0",
+ "properties": {
+ "messages": {
+ "type": "array",
+ "description": "An array of router messages",
+ "items": {
+ "title": "RouterMessage",
+ "description": "A definition of an individual message",
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "A unique identifier for the message that should not conflict with any other previous message"
+ },
+ "template": {
+ "type": "string",
+ "description": "An id matching an existing Activity Stream Router template",
+ "enum": ["cfr_doorhanger"]
+ },
+ "bundled": {
+ "type": "integer",
+ "description": "The number of messages of the same template this one should be shown with (optional)"
+ },
+ "order": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "If bundled with other messages of the same template, which order should this one be placed in? (optional - defaults to 0)"
+ },
+ "content": {
+ "type": "object",
+ "description": "An object containing all variables/props to be rendered in the template. See individual template schemas for details."
+ },
+ "targeting": {
+ "type": "string",
+ "description": "A JEXL expression representing targeting information"
+ },
+ "trigger": {
+ "type": "object",
+ "description": "An action to trigger potentially showing the message",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "A string identifying the trigger action",
+ "enum": ["firstRun", "openURL"]
+ },
+ "params": {
+ "type": "array",
+ "description": "An optional array of string parameters for the trigger action",
+ "items": {
+ "type": "string",
+ "description": "A parameter for the trigger action"
+ }
+ }
+ },
+ "required": ["id"]
+ }
+ },
+ "required": ["id", "template", "content"]
+ }
+ }
+ },
+ "required": ["messages"]
+}
diff --git a/browser/components/asrouter/content-src/styles/_feature-callout-theme.scss b/browser/components/asrouter/content-src/styles/_feature-callout-theme.scss
new file mode 100644
index 0000000000..657d4fa6a3
--- /dev/null
+++ b/browser/components/asrouter/content-src/styles/_feature-callout-theme.scss
@@ -0,0 +1,92 @@
+// 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 color-hex-length, color-hex-case -- we want to preserve
+// these values exactly, since they're drawn from other parts of the browser.
+
+@mixin light-theme {
+ --fc-background: var(--fc-background-light, #fff);
+ --fc-color: var(--fc-color-light, rgb(21, 20, 26));
+ --fc-border: var(--fc-border-light, #CFCFD8);
+ --fc-accent-color: var(--fc-accent-color-light, rgb(0, 97, 224));
+ --fc-button-background: var(--fc-button-background-light, #F0F0F4);
+ --fc-button-color: var(--fc-button-color-light, rgb(21, 20, 26));
+ --fc-button-border: var(--fc-button-border-light, transparent);
+ --fc-button-background-hover: var(--fc-button-background-hover-light, #E0E0E6);
+ --fc-button-color-hover: var(--fc-button-color-hover-light, rgb(21, 20, 26));
+ --fc-button-border-hover: var(--fc-button-border-hover-light, transparent);
+ --fc-button-background-active: var(--fc-button-background-active-light, #CFCFD8);
+ --fc-button-color-active: var(--fc-button-color-active-light, rgb(21, 20, 26));
+ --fc-button-border-active: var(--fc-button-border-active-light, transparent);
+ --fc-primary-button-background: var(--fc-primary-button-background-light, #0061e0);
+ --fc-primary-button-color: var(--fc-primary-button-color-light, rgb(251,251,254));
+ --fc-primary-button-border: var(--fc-primary-button-border-light, transparent);
+ --fc-primary-button-background-hover: var(--fc-primary-button-background-hover-light, #0250bb);
+ --fc-primary-button-color-hover: var(--fc-primary-button-color-hover-light, rgb(251,251,254));
+ --fc-primary-button-border-hover: var(--fc-primary-button-border-hover-light, transparent);
+ --fc-primary-button-background-active: var(--fc-primary-button-background-active-light, #053e94);
+ --fc-primary-button-color-active: var(--fc-primary-button-color-active-light, rgb(251,251,254));
+ --fc-primary-button-border-active: var(--fc-primary-button-border-active-light, transparent);
+ --fc-step-color: color-mix(in srgb, currentColor 50%, transparent);
+ --fc-link-color: var(--fc-link-color-light, #0061E0);
+ --fc-link-color-hover: var(--fc-link-color-hover-light, #0250BB);
+ --fc-link-color-active: var(--fc-link-color-active-light, #053E94);
+}
+
+@mixin dark-theme {
+ --fc-background: var(--fc-background-dark, rgb(43, 42, 51));
+ --fc-color: var(--fc-color-dark, rgb(251, 251, 254));
+ --fc-border: var(--fc-border-dark, #3A3944);
+ --fc-accent-color: var(--fc-accent-color-dark, rgb(0, 221, 255));
+ --fc-button-background: var(--fc-button-background-dark, #2B2A33);
+ --fc-button-color: var(--fc-button-color-dark, rgb(251, 251, 254));
+ --fc-button-border: var(--fc-button-border-dark, transparent);
+ --fc-button-background-hover: var(--fc-button-background-hover-dark, #52525E);
+ --fc-button-color-hover: var(--fc-button-color-hover-dark, rgb(251, 251, 254));
+ --fc-button-border-hover: var(--fc-button-border-hover-dark, transparent);
+ --fc-button-background-active: var(--fc-button-background-active-dark, #5B5B66);
+ --fc-button-color-active: var(--fc-button-color-active-dark, rgb(251, 251, 254));
+ --fc-button-border-active: var(--fc-button-border-active-dark, transparent);
+ --fc-primary-button-background: var(--fc-primary-button-background-dark, rgb(0,221,255));
+ --fc-primary-button-color: var(--fc-primary-button-color-dark, rgb(43,42,51));
+ --fc-primary-button-border: var(--fc-primary-button-border-dark, transparent);
+ --fc-primary-button-background-hover: var(--fc-primary-button-background-hover-dark, rgb(128,235,255));
+ --fc-primary-button-color-hover: var(--fc-primary-button-color-hover-dark, rgb(43,42,51));
+ --fc-primary-button-border-hover: var(--fc-primary-button-border-hover-dark, transparent);
+ --fc-primary-button-background-active: var(--fc-primary-button-background-active-dark, rgb(170,242,255));
+ --fc-primary-button-color-active: var(--fc-primary-button-color-active-dark, rgb(43,42,51));
+ --fc-primary-button-border-active: var(--fc-primary-button-border-active-dark, transparent);
+ --fc-link-color: var(--fc-link-color-dark, #00DDFF);
+ --fc-link-color-hover: var(--fc-link-color-hover-dark, #80EBFF);
+ --fc-link-color-active: var(--fc-link-color-hover-active, #AAF2FF);
+}
+
+@mixin hcm-theme {
+ --fc-background: var(--fc-background-hcm, -moz-dialog);
+ --fc-color: var(--fc-color-hcm, -moz-dialogtext);
+ --fc-border: var(--fc-border-hcm, -moz-dialogtext);
+ --fc-accent-color: var(--fc-accent-color-hcm, LinkText);
+ --fc-button-background: var(--fc-button-background-hcm, ButtonFace);
+ --fc-button-color: var(--fc-button-color-hcm, ButtonText);
+ --fc-button-border: var(--fc-button-border-hcm, ButtonText);
+ --fc-button-background-hover: var(--fc-button-background-hover-hcm, ButtonText);
+ --fc-button-color-hover: var(--fc-button-color-hover-hcm, ButtonFace);
+ --fc-button-border-hover: var(--fc-button-border-hover-hcm, ButtonText);
+ --fc-button-background-active: var(--fc-button-background-active-hcm, ButtonText);
+ --fc-button-color-active: var(--fc-button-color-active-hcm, ButtonFace);
+ --fc-button-border-active: var(--fc-button-border-active-hcm, ButtonText);
+ --fc-primary-button-background: var(--fc-primary-button-background-hcm, ButtonText);
+ --fc-primary-button-color: var(--fc-primary-button-color-hcm, ButtonFace);
+ --fc-primary-button-border: var(--fc-primary-button-border-hcm, ButtonFace);
+ --fc-primary-button-background-hover: var(--fc-primary-button-background-hover-hcm, SelectedItem);
+ --fc-primary-button-color-hover: var(--fc-primary-button-color-hover-hcm, SelectedItemText);
+ --fc-primary-button-border-hover: var(--fc-primary-button-border-hover-hcm, SelectedItemText);
+ --fc-primary-button-background-active: var(--fc-primary-button-background-active-hcm, SelectedItemText);
+ --fc-primary-button-color-active: var(--fc-primary-button-color-active-hcm, SelectedItem);
+ --fc-primary-button-border-active: var(--fc-primary-button-border-active-hcm, SelectedItem);
+ --fc-step-color: var(--fc-accent-color-hcm, LinkText);
+ --fc-link-color: var(--fc-link-color-hcm, LinkText);
+ --fc-link-color-hover: var(--fc-link-color-hover-hcm, LinkText);
+ --fc-link-color-active: var(--fc-link-color-active-hcm, ActiveText);
+}
diff --git a/browser/components/asrouter/content-src/styles/_feature-callout.scss b/browser/components/asrouter/content-src/styles/_feature-callout.scss
new file mode 100644
index 0000000000..66770c2238
--- /dev/null
+++ b/browser/components/asrouter/content-src/styles/_feature-callout.scss
@@ -0,0 +1,775 @@
+// 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 'feature-callout-theme';
+
+/* stylelint-disable max-nesting-depth */
+
+#feature-callout {
+ // See _feature-callout-theme.scss for the theme mixins and
+ // FeatureCallout.sys.mjs for the color values
+ @include light-theme;
+
+ position: absolute;
+ z-index: 2147483647;
+ outline: none;
+ color: var(--fc-color);
+ accent-color: var(--fc-accent-color);
+ // Make sure HTML content uses non-native theming, even in chrome windows.
+ -moz-theme: non-native;
+
+ @media (prefers-color-scheme: dark) {
+ @include dark-theme;
+ }
+
+ @media (prefers-contrast) {
+ @include hcm-theme;
+ }
+
+ // Account for feature callouts that may be rendered in the chrome but
+ // displayed on top of content. Each context has its own color scheme, so they
+ // may not match. In that case, we use the special media query below.
+ &.simulateContent {
+ color-scheme: env(-moz-content-preferred-color-scheme);
+
+ // TODO - replace 2 mixins with a single mixin with light-dark() values.
+ @media (-moz-content-prefers-color-scheme: light) {
+ @include light-theme;
+ }
+
+ @media (-moz-content-prefers-color-scheme: dark) {
+ @include dark-theme;
+ }
+
+ @media (prefers-contrast) {
+ @include hcm-theme;
+ }
+ }
+
+ // The desired width of the arrow (the triangle base).
+ --arrow-width: 33.9411px;
+ // The width/height of the square that, rotated 90deg, will become the arrow.
+ --arrow-square-size: calc(var(--arrow-width) / sqrt(2));
+ // After rotating, the width is no longer the square width. It's now equal to
+ // the diagonal from corner to corner, i.e. √2 * the square width. We need to
+ // account for this extra width in some calculations.
+ --extra-width-from-rotation: calc(var(--arrow-width) - var(--arrow-square-size));
+ // The height of the arrow, once rotated and cut in half.
+ --arrow-visible-height: calc(var(--arrow-width) / 2);
+ // Half the width/height of the square. Calculations on the arrow itself need
+ // to treat the arrow as a square, since they are operating on the element
+ // _before_ it is rotated. Calculations on other elements (like the panel
+ // margin that needs to make space for the arrow) should use the visible
+ // height that treats it as a triangle.
+ --arrow-visible-size: calc(var(--arrow-square-size) / 2);
+ --arrow-center-inset: calc(50% - var(--arrow-visible-size));
+ // Move the arrow 1.5px closer to the callout to account for subpixel rounding
+ // differences, which might cause the corners of the arrow (which is actually
+ // a rotated square) to be visible.
+ --arrow-offset: calc(1.5px - var(--arrow-visible-size));
+ // For positions like top-end, the arrow is 12px away from the corner.
+ --arrow-corner-distance: 12px;
+ --arrow-corner-inset: calc(var(--arrow-corner-distance) + (var(--extra-width-from-rotation) / 2));
+ --arrow-overlap-magnitude: 5px;
+
+ @at-root panel#{&} {
+ --panel-color: var(--fc-color);
+ --panel-shadow: none;
+ // Extra space around the panel for the shadow to be drawn in. The panel
+ // content can't overflow the XUL popup frame, so the frame must be extended.
+ --panel-shadow-margin: 6px;
+ // The panel needs more extra space on the side that the arrow is on, since
+ // the arrow is absolute positioned. This adds the visible height of the
+ // arrow to the margin and subtracts 1 since the arrow is inset by 1.5px
+ // (see --arrow-offset).
+ --panel-arrow-space: calc(var(--panel-shadow-margin) + var(--arrow-visible-height) - 1.5px);
+ // The callout starts with its edge aligned with the edge of the anchor. But
+ // we want the arrow to align to the anchor, not the callout edge. So we need
+ // to offset the callout by the arrow size and margin, as well as the margin
+ // of the entire callout (it has margins on all sides to make room for the
+ // shadow when displayed in a panel, which would normally cut off the shadow).
+ --panel-margin-offset: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-corner-distance) + (var(--arrow-width) / 2)));
+ }
+
+ @at-root panel#{&}::part(content) {
+ width: initial;
+ border: 0;
+ border-radius: 0;
+ padding: 0;
+ margin: var(--panel-shadow-margin);
+ background: none;
+ color: inherit;
+ // stylelint-disable-next-line declaration-no-important
+ overflow: visible !important;
+ }
+
+ @at-root div#{&} {
+ transition: opacity 0.5s ease;
+
+ &.hidden {
+ opacity: 0;
+ pointer-events: none;
+ }
+ }
+
+ .onboardingContainer,
+ .onboardingContainer .outer-wrapper {
+ // Override the element transitions from aboutwelcome.scss
+ --transition: none;
+
+ // auto height to allow for arrow positioning based on height
+ height: auto;
+ }
+
+ // use a different approach to flipping to avoid the fuzzy aliasing that
+ // transform causes.
+ &:dir(rtl) {
+ transform: none;
+ direction: ltr;
+ }
+
+ & .outer-wrapper:dir(rtl) {
+ transform: none;
+ direction: rtl;
+ }
+
+ .screen {
+ // override transform in about:welcome
+ &:dir(rtl) {
+ transform: none;
+ }
+
+ &[pos='callout'] {
+ height: fit-content;
+ min-height: unset;
+ overflow: visible;
+
+ &[layout='inline'] {
+ .section-main {
+ .main-content,
+ .main-content.no-steps {
+ width: 18em;
+ padding-inline: 16px;
+ padding-block: 0;
+
+ .welcome-text {
+ // Same height as the dismiss button
+ height: 24px;
+ margin-block: 12px;
+ margin-inline: 0;
+ padding: 0;
+ white-space: nowrap;
+ }
+ }
+
+ .dismiss-button {
+ height: 24px;
+ width: 24px;
+ min-height: 24px;
+ min-width: 24px;
+ margin: 0;
+ top: calc(50% - 12px);
+ inset-inline-end: 12px;
+ }
+ }
+ }
+
+ .logo-container {
+ display: flex;
+ justify-content: center;
+
+ .brand-logo {
+ margin: 0;
+
+ // This may not work for all future messages, so we may want to make
+ // flipping the logo image in RTL mode configurable
+ &:dir(rtl) {
+ transform: rotateY(180deg);
+ }
+ }
+ }
+
+ .welcome-text {
+ align-items: baseline;
+ text-align: start;
+ margin: 0;
+ padding: 0;
+ gap: 8px;
+
+ h1,
+ h2 {
+ font-size: 0.813em;
+ margin: 0;
+ color: inherit;
+ }
+
+ h1 {
+ font-weight: 600;
+ }
+
+ .inline-icon-container {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+
+ .logo-container {
+ height: 16px;
+ width: 16px;
+ margin-inline-end: 6px;
+ box-sizing: border-box;
+ -moz-context-properties: fill;
+ fill: currentColor;
+
+ img {
+ height: 16px;
+ width: 16px;
+ margin: 0;
+ }
+ }
+
+ &[alignment='top'],
+ &[alignment='bottom'] {
+ flex-wrap: nowrap;
+
+ .logo-container {
+ height: 1.5em; // match the title's line-height
+ align-items: center;
+ padding-bottom: 0.15em;
+ box-sizing: border-box;
+ }
+ }
+
+ &[alignment='top'] {
+ align-items: start;
+ }
+
+ &[alignment='bottom'] {
+ align-items: end;
+ }
+ }
+
+ }
+
+ .multi-select-container {
+ margin: 0;
+ font-size: 0.813em;
+ row-gap: 12px;
+ color: inherit;
+ overflow: visible;
+
+ #multi-stage-multi-select-label {
+ font-size: inherit;
+ // There's a 12px gap that pushes the .multi-select-container down
+ // away from the .welcome-text. And there's an 8px gap between the h1
+ // and h2 in the .welcome-text container. So subtract 4px to get the
+ // desired 8px margin, so spacing is the same as for `subtitle`.
+ margin: -4px 0 0;
+ color: inherit;
+ }
+ }
+
+ .cta-link {
+ background: none;
+ text-decoration: underline;
+ cursor: pointer;
+ border: none;
+ padding: 0;
+ color: var(--fc-link-color);
+ order: -1;
+ margin-inline-end: auto;
+ margin-block: 8px;
+
+ &:hover {
+ color: var(--fc-link-color-hover);
+ }
+
+ &:active {
+ color: var(--fc-link-color-active);
+ }
+ }
+
+ // Secondary section is not included in callouts
+ .section-secondary {
+ display: none;
+ }
+
+ .section-main {
+ height: fit-content;
+ width: fit-content;
+
+ .main-content {
+ position: relative;
+ overflow: hidden;
+ border: 1px solid var(--fc-border);
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 15%);
+ border-radius: 4px;
+ padding: var(--callout-padding, 24px);
+ width: 25em;
+ gap: 16px;
+ background-color: var(--fc-background);
+
+ .main-content-inner {
+ gap: 12px;
+ }
+
+ .steps {
+ height: auto;
+ position: absolute;
+ // 24px is the callout's bottom padding. The CTAs are 32px tall, and
+ // the steps are 8px tall. So we need to offset the steps by half
+ // the difference in order to center them. 32/2 - 8/2 = 12.
+ bottom: calc(var(--callout-padding, 24px) + 12px);
+ padding-block: 0;
+
+ .indicator {
+ // using border will show up in Windows High Contrast Mode to improve accessibility.
+ border: 4px solid var(--fc-step-color);
+
+ &.current {
+ border-color: var(--fc-accent-color);
+ }
+ }
+
+ &:not(.progress-bar) {
+ flex-flow: row nowrap;
+ gap: 8px;
+
+ .indicator {
+ margin: 0;
+ }
+ }
+
+ & .indicator.current,
+ &.progress-bar .indicator.complete {
+ border-color: var(--fc-accent-color);
+ }
+ }
+ }
+
+ .dismiss-button {
+ font-size: 1em;
+ inset-block: 0 auto;
+ inset-inline: auto 0;
+ margin-block: 16px 0;
+ margin-inline: 0 16px;
+ background-color: var(--fc-background);
+
+ &[button-size='small'] {
+ height: 24px;
+ width: 24px;
+ min-height: 24px;
+ min-width: 24px;
+ }
+ }
+ }
+
+ .action-buttons {
+ display: flex;
+ flex-flow: row nowrap;
+ align-items: stretch;
+ justify-content: end;
+ gap: 10px;
+ // The Figma spec wants a 16px gap between major content blocks and the
+ // action buttons. But the action buttons are siblings with the minor
+ // content blocks, which want a 12px gap. So we use a 12px gap and just
+ // add 4px of margin to the action buttons.
+ margin-top: 4px;
+
+ &[alignment='start'] {
+ justify-content: start;
+ }
+
+ &[alignment='space-between'] {
+ justify-content: space-between;
+ }
+
+ .secondary-cta {
+ font-size: inherit;
+ }
+
+ .primary,
+ .secondary {
+ padding: 4px 16px;
+ margin: 0;
+ font-size: 0.813em;
+ font-weight: 600;
+ line-height: 16px;
+ min-height: 32px;
+ text-decoration: none;
+ cursor: default;
+ }
+
+ .secondary {
+ background-color: var(--fc-button-background);
+ }
+
+ .primary {
+ background-color: var(--fc-primary-button-background);
+ }
+
+ .split-button-container {
+ align-items: stretch;
+
+ &:not([hidden]) {
+ display: flex;
+ }
+
+ .primary,
+ .secondary,
+ .additional-cta {
+ &:not(.submenu-button) {
+ border-start-end-radius: 0;
+ border-end-end-radius: 0;
+ margin-inline-end: 0;
+ }
+
+ &:focus-visible {
+ z-index: 2;
+ }
+ }
+
+ .submenu-button {
+ border-start-start-radius: 0;
+ border-end-start-radius: 0;
+ margin-inline-start: 1px;
+ padding: 8px;
+ min-width: 30px;
+ box-sizing: border-box;
+ background-image: url('chrome://global/skin/icons/arrow-down.svg');
+ background-repeat: no-repeat;
+ background-size: 16px;
+ background-position: center;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ }
+ }
+ }
+
+ .action-buttons .primary,
+ .action-buttons .secondary,
+ .dismiss-button {
+ border-radius: 4px;
+
+ &:focus-visible {
+ box-shadow: none;
+ outline: 2px solid var(--fc-accent-color);
+ outline-offset: 2px;
+ }
+
+ &:disabled {
+ opacity: 0.4;
+ cursor: auto;
+ }
+ }
+
+ .action-buttons .secondary,
+ .dismiss-button {
+ border: 1px solid var(--fc-button-border);
+ color: var(--fc-button-color);
+
+ &:hover:not(:disabled),
+ &[open] {
+ background-color: var(--fc-button-background-hover);
+ color: var(--fc-button-color-hover);
+ border: 1px solid var(--fc-button-border-hover);
+
+ &:active {
+ background-color: var(--fc-button-background-active);
+ color: var(--fc-button-color-active);
+ border: 1px solid var(--fc-button-border-active);
+ }
+ }
+ }
+
+ .action-buttons .primary {
+ border: 1px solid var(--fc-primary-button-border);
+ color: var(--fc-primary-button-color);
+
+ &:hover:not(:disabled),
+ &[open] {
+ background-color: var(--fc-primary-button-background-hover);
+ color: var(--fc-primary-button-color-hover);
+ border: 1px solid var(--fc-primary-button-border-hover);
+
+ &:active {
+ background-color: var(--fc-primary-button-background-active);
+ color: var(--fc-primary-button-color-active);
+ border: 1px solid var(--fc-primary-button-border-active);
+ }
+ }
+ }
+ }
+ }
+
+ @at-root panel#{&}:is([side='top'], [side='bottom']):not([hide-arrow='permanent']) {
+ margin-inline: var(--panel-margin-offset);
+ }
+
+ @at-root panel#{&}:is([side='left'], [side='right']):not([hide-arrow='permanent']) {
+ margin-block: var(--panel-margin-offset);
+ }
+
+ @at-root panel#{&}::part(content) {
+ position: relative;
+ }
+
+ // all visible callout arrow boxes. boxes are for rotating 45 degrees, arrows
+ // are for the actual arrow shape and are children of the boxes.
+ .arrow-box {
+ position: absolute;
+ overflow: visible;
+ transform: rotate(45deg);
+ // keep the border crisp under transformation
+ transform-style: preserve-3d;
+ }
+
+ &:not([arrow-position]) .arrow-box,
+ &[hide-arrow] .arrow-box {
+ display: none;
+ }
+
+ // both shadow arrow and background arrow
+ .arrow {
+ width: var(--arrow-square-size);
+ height: var(--arrow-square-size);
+ }
+
+ // the arrow's shadow box
+ .shadow-arrow-box {
+ z-index: -1;
+ }
+
+ // the arrow's shadow
+ .shadow-arrow {
+ background: transparent;
+ outline: 1px solid var(--fc-border);
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 15%);
+ }
+
+ // the 'filled' arrow box
+ .background-arrow-box {
+ z-index: 1;
+ // the background arrow technically can overlap the dismiss button. it
+ // doesn't visibly overlap it because of the clip-path rule below, but it
+ // can still be clicked. so we need to make sure it doesn't block inputs on
+ // the button. the visible part of the arrow can still catch clicks because
+ // we don't add this rule to .shadow-arrow-box.
+ pointer-events: none;
+ }
+
+ // the 'filled' arrow
+ .background-arrow {
+ background-color: var(--fc-background);
+ clip-path: var(--fc-arrow-clip-path);
+ }
+
+ // top (center) arrow positioning
+ &[arrow-position='top'] .arrow-box {
+ top: var(--arrow-offset);
+ inset-inline-start: var(--arrow-center-inset);
+ // the callout arrow is actually a diamond (a rotated square), with the
+ // lower half invisible. the part that appears in front of the callout has
+ // only a background, so that where it overlaps the callout's border, the
+ // border is not visible. the part that appears behind the callout has only
+ // a border/shadow, so that it can't be seen overlapping the callout. but
+ // because the background is the same color as the callout, that half of the
+ // diamond would visibly overlap any callout content that happens to be in
+ // the same place. so we clip it to a triangle, with a 2% extension on the
+ // bottom to account for any subpixel rounding differences.
+ --fc-arrow-clip-path: polygon(100% 0, 100% 2%, 2% 100%, 0 100%, 0 0);
+ }
+
+ @at-root panel#{&}[arrow-position='top']::part(content) {
+ margin-top: var(--panel-arrow-space);
+ }
+
+ @at-root panel#{&}[arrow-position='top'] {
+ margin-top: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-overlap-magnitude)));
+ }
+
+ // bottom (center) arrow positioning
+ &[arrow-position='bottom'] .arrow-box {
+ bottom: var(--arrow-offset);
+ inset-inline-start: var(--arrow-center-inset);
+ --fc-arrow-clip-path: polygon(100% 0, 98% 0, 0 98%, 0 100%, 100% 100%);
+ }
+
+ @at-root panel#{&}[arrow-position='bottom']::part(content) {
+ margin-bottom: var(--panel-arrow-space);
+ }
+
+ @at-root panel#{&}[arrow-position='bottom'] {
+ margin-bottom: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-overlap-magnitude)));
+ }
+
+ // end (center) arrow positioning
+ &[arrow-position='inline-end'] .arrow-box {
+ top: var(--arrow-center-inset);
+ inset-inline-end: var(--arrow-offset);
+ --fc-arrow-clip-path: polygon(100% 0, 100% 100%, 98% 100%, 0 2%, 0 0);
+ }
+
+ @at-root panel#{&}[arrow-position='inline-end']::part(content) {
+ margin-inline-end: var(--panel-arrow-space);
+ }
+
+ @at-root panel#{&}[arrow-position='inline-end'] {
+ margin-inline-end: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-overlap-magnitude)));
+ }
+
+ // start (center) arrow positioning
+ &[arrow-position='inline-start'] .arrow-box {
+ top: var(--arrow-center-inset);
+ inset-inline-start: var(--arrow-offset);
+ --fc-arrow-clip-path: polygon(0 100%, 100% 100%, 100% 98%, 2% 0, 0 0);
+ }
+
+ @at-root panel#{&}[arrow-position='inline-start']::part(content) {
+ margin-inline-start: var(--panel-arrow-space);
+ }
+
+ @at-root panel#{&}[arrow-position='inline-start'] {
+ margin-inline-start: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-overlap-magnitude)));
+ }
+
+ // top-end arrow positioning
+ &[arrow-position='top-end'] .arrow-box {
+ top: var(--arrow-offset);
+ inset-inline-end: var(--arrow-corner-inset);
+ --fc-arrow-clip-path: polygon(100% 0, 100% 2%, 2% 100%, 0 100%, 0 0);
+ }
+
+ @at-root panel#{&}[arrow-position='top-end']::part(content) {
+ margin-top: var(--panel-arrow-space);
+ }
+
+ @at-root panel#{&}[arrow-position='top-end'] {
+ margin-top: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-overlap-magnitude)));
+ }
+
+ // top-start arrow positioning
+ &[arrow-position='top-start'] .arrow-box {
+ top: var(--arrow-offset);
+ inset-inline-start: var(--arrow-corner-inset);
+ --fc-arrow-clip-path: polygon(100% 0, 100% 2%, 2% 100%, 0 100%, 0 0);
+ }
+
+ @at-root panel#{&}[arrow-position='top-start']::part(content) {
+ margin-top: var(--panel-arrow-space);
+ }
+
+ @at-root panel#{&}[arrow-position='top-start'] {
+ margin-top: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-overlap-magnitude)));
+ }
+
+ // bottom-end arrow positioning
+ &[arrow-position='bottom-end'] .arrow-box {
+ bottom: var(--arrow-offset);
+ inset-inline-end: var(--arrow-corner-inset);
+ --fc-arrow-clip-path: polygon(100% 0, 98% 0, 0 98%, 0 100%, 100% 100%);
+ }
+
+ @at-root panel#{&}[arrow-position='bottom-end']::part(content) {
+ margin-bottom: var(--panel-arrow-space);
+ }
+
+ @at-root panel#{&}[arrow-position='bottom-end'] {
+ margin-bottom: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-overlap-magnitude)));
+ }
+
+ // bottom-start arrow positioning
+ &[arrow-position='bottom-start'] .arrow-box {
+ bottom: var(--arrow-offset);
+ inset-inline-start: var(--arrow-corner-inset);
+ --fc-arrow-clip-path: polygon(100% 0, 98% 0, 0 98%, 0 100%, 100% 100%);
+ }
+
+ @at-root panel#{&}[arrow-position='bottom-start']::part(content) {
+ margin-bottom: var(--panel-arrow-space);
+ }
+
+ @at-root panel#{&}[arrow-position='bottom-start'] {
+ margin-bottom: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-overlap-magnitude)));
+ }
+
+ // inline-end-top arrow positioning
+ &[arrow-position='inline-end-top'] .arrow-box {
+ top: var(--arrow-corner-inset);
+ inset-inline-end: var(--arrow-offset);
+ --fc-arrow-clip-path: polygon(100% 0, 100% 100%, 98% 100%, 0 2%, 0 0);
+ }
+
+ @at-root panel#{&}[arrow-position='inline-end-top']::part(content) {
+ margin-inline-end: var(--panel-arrow-space);
+ }
+
+ @at-root panel#{&}[arrow-position='inline-end-top'] {
+ margin-inline-end: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-overlap-magnitude)));
+ }
+
+ // inline-end-bottom arrow positioning
+ &[arrow-position='inline-end-bottom'] .arrow-box {
+ bottom: var(--arrow-corner-inset);
+ inset-inline-end: var(--arrow-offset);
+ --fc-arrow-clip-path: polygon(100% 0, 100% 100%, 98% 100%, 0 2%, 0 0);
+ }
+
+ @at-root panel#{&}[arrow-position='inline-end-bottom']::part(content) {
+ margin-inline-end: var(--panel-arrow-space);
+ }
+
+ @at-root panel#{&}[arrow-position='inline-end-bottom'] {
+ margin-inline-end: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-overlap-magnitude)));
+ }
+
+ // inline-start-top arrow positioning
+ &[arrow-position='inline-start-top'] .arrow-box {
+ top: var(--arrow-corner-inset);
+ inset-inline-start: var(--arrow-offset);
+ --fc-arrow-clip-path: polygon(0 100%, 100% 100%, 100% 98%, 2% 0, 0 0);
+ }
+
+ @at-root panel#{&}[arrow-position='inline-start-top']::part(content) {
+ margin-inline-start: var(--panel-arrow-space);
+ }
+
+ @at-root panel#{&}[arrow-position='inline-start-top'] {
+ margin-inline-start: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-overlap-magnitude)));
+ }
+
+ // inline-start-bottom arrow positioning
+ &[arrow-position='inline-start-bottom'] .arrow-box {
+ bottom: var(--arrow-corner-inset);
+ inset-inline-start: var(--arrow-offset);
+ --fc-arrow-clip-path: polygon(0 100%, 100% 100%, 100% 98%, 2% 0, 0 0);
+ }
+
+ @at-root panel#{&}[arrow-position='inline-start-bottom']::part(content) {
+ margin-inline-start: var(--panel-arrow-space);
+ }
+
+ @at-root panel#{&}[arrow-position='inline-start-bottom'] {
+ margin-inline-start: calc(-1 * (var(--panel-shadow-margin) + var(--arrow-overlap-magnitude)));
+ }
+
+ // focus outline for the callout itself
+ &:focus-visible {
+ .screen {
+ &[pos='callout'] {
+ .section-main .main-content {
+ outline: 2px solid var(--fc-accent-color);
+ border-color: transparent;
+
+ @media (prefers-contrast) {
+ border-color: var(--fc-background);
+ }
+ }
+ }
+ }
+
+ .shadow-arrow {
+ outline: 2px solid var(--fc-accent-color);
+ }
+ }
+}
diff --git a/browser/components/asrouter/content-src/styles/_shopping.scss b/browser/components/asrouter/content-src/styles/_shopping.scss
new file mode 100644
index 0000000000..218e996cb8
--- /dev/null
+++ b/browser/components/asrouter/content-src/styles/_shopping.scss
@@ -0,0 +1,209 @@
+// 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 */
+
+.onboardingContainer.shopping {
+ height: auto;
+
+ .outer-wrapper {
+ height: auto;
+ }
+}
+
+.onboardingContainer.shopping .screen[pos='split'] {
+ height: auto;
+ margin: 0 auto;
+ min-height: fit-content;
+ border-radius: 8px;
+ box-shadow: 0 2px 6px rgba(58, 57, 68, 20%);
+ overflow-x: auto;
+
+ @media (prefers-contrast: no-preference) and (prefers-color-scheme: dark) {
+ box-shadow: 0 2px 6px rgba(21, 20, 26, 100%);
+ }
+
+ &::before {
+ display: none;
+ }
+
+ .section-main {
+ width: auto;
+ height: auto;
+ margin: 0 auto;
+
+ .main-content {
+ border-radius: 4px;
+ color: inherit;
+ font: menu;
+
+ @media (prefers-contrast: no-preference) and (prefers-color-scheme: dark) {
+ background-color: #52525E;
+ }
+
+ &.no-steps {
+ padding: 16px 0 36px;
+ }
+
+ .welcome-text {
+ text-align: start;
+ margin-block: 10px 12px;
+
+ h1 {
+ width: auto;
+ font-weight: 400;
+ line-height: 1.5;
+ font-size: 1.7em;
+ }
+
+ h2 {
+ color: inherit;
+ font-size: 1em;
+ }
+ }
+
+ .action-buttons {
+ .primary,
+ .secondary {
+ min-width: auto;
+ }
+
+ .primary {
+ font-weight: 400;
+ padding: 4px 16px;
+ }
+
+ &.additional-cta-container {
+ align-items: center;
+ }
+ }
+
+ .legal-paragraph {
+ font-size: 0.85em;
+ line-height: 1.5;
+ margin-block: 0 20px;
+ padding-inline: 30px;
+ text-align: start;
+
+ a {
+ text-decoration: underline;
+ }
+ }
+
+ .brand-logo {
+ width: 100%;
+ max-width: 294px;
+ max-height: 290px;
+ height: auto;
+ }
+ }
+
+ .dismiss-button {
+ top: 0;
+ margin: 14px 10px;
+ }
+ }
+
+ .section-secondary {
+ display: none;
+ }
+
+ .info-text, .link-paragraph {
+ font-size: 1em;
+ margin: 10px auto;
+ line-height: 1.5;
+ }
+
+ .link-paragraph {
+ margin-block: 0 10px;
+ padding-inline: 30px;
+ text-align: start;
+
+ a {
+ text-decoration: underline;
+ }
+ }
+}
+
+.onboardingContainer.shopping .screen[pos='split'][layout='survey'] {
+ .main-content {
+ padding: 12px;
+
+ .main-content-inner {
+ min-height: auto;
+ align-items: initial;
+
+ .welcome-text {
+ align-items: initial;
+ padding: 0;
+ margin-top: 0;
+
+ h1,
+ h2 {
+ line-height: 20px;
+ }
+
+ h1 {
+ font-size: 1em;
+ font-weight: 590;
+ margin: 0;
+ margin-inline-end: 28px;
+ }
+
+ h2 {
+ color: inherit;
+ margin-block: 10px 0;
+ }
+ }
+
+ .action-buttons {
+ .cta-link {
+ padding: 4px;
+ margin-block: -4px;
+ outline-offset: 0;
+ min-height: revert;
+ }
+ }
+
+ .multi-select-container {
+ color: inherit;
+ padding: 0;
+ margin-block: 0 24px;
+ align-items: center;
+ overflow: visible;
+ font-size: 1em;
+ gap: 12px;
+ width: 100%;
+
+ #multi-stage-multi-select-label {
+ color: inherit;
+ line-height: 20px;
+ margin-block: -2px 0;
+ font-size: 1em;
+ }
+
+ .multi-select-item input {
+ margin-block: 0;
+ }
+ }
+
+ .steps {
+ height: auto;
+ margin-bottom: 12px;
+ }
+ }
+ }
+
+ .dismiss-button {
+ width: 24px;
+ height: 24px;
+ min-width: 24px;
+ min-height: 24px;
+ margin: 10px;
+ }
+}
+
+.onboardingContainer.shopping shopping-message-bar {
+ font: menu;
+}
diff --git a/browser/components/asrouter/content-src/templates/CFR/templates/CFRUrlbarChiclet.schema.json b/browser/components/asrouter/content-src/templates/CFR/templates/CFRUrlbarChiclet.schema.json
new file mode 100644
index 0000000000..ff5dff535a
--- /dev/null
+++ b/browser/components/asrouter/content-src/templates/CFR/templates/CFRUrlbarChiclet.schema.json
@@ -0,0 +1,66 @@
+{
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
+ "$id": "file:///CFRUrlbarChiclet.schema.json",
+ "title": "CFRUrlbarChiclet",
+ "description": "A template with a chiclet button with text.",
+ "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }],
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "object",
+ "properties": {
+ "category": {
+ "type": "string",
+ "description": "Attribute used for different groups of messages from the same provider"
+ },
+ "layout": {
+ "type": "string",
+ "description": "Describes how content should be displayed.",
+ "enum": ["chiclet_open_url"]
+ },
+ "bucket_id": {
+ "type": "string",
+ "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting."
+ },
+ "notification_text": {
+ "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText",
+ "description": "The text in the small blue chicklet that appears in the URL bar. This can be a reference to a localized string in Firefox or just a plain string."
+ },
+ "active_color": {
+ "type": "string",
+ "description": "Background color of the button"
+ },
+ "action": {
+ "type": "object",
+ "properties": {
+ "url": {
+ "description": "The page to open when the button is clicked.",
+ "type": "string",
+ "format": "moz-url-format"
+ },
+ "where": {
+ "description": "Should it open in a new tab or the current tab",
+ "type": "string",
+ "enum": ["current", "tabshifted"]
+ }
+ },
+ "additionalProperties": true,
+ "required": ["url", "where"]
+ }
+ },
+ "additionalProperties": true,
+ "required": [
+ "layout",
+ "category",
+ "bucket_id",
+ "notification_text",
+ "action"
+ ]
+ },
+ "template": {
+ "type": "string",
+ "const": "cfr_urlbar_chiclet"
+ }
+ },
+ "required": ["targeting", "trigger"]
+}
diff --git a/browser/components/asrouter/content-src/templates/CFR/templates/ExtensionDoorhanger.schema.json b/browser/components/asrouter/content-src/templates/CFR/templates/ExtensionDoorhanger.schema.json
new file mode 100644
index 0000000000..f25e6cc92c
--- /dev/null
+++ b/browser/components/asrouter/content-src/templates/CFR/templates/ExtensionDoorhanger.schema.json
@@ -0,0 +1,320 @@
+{
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
+ "$id": "file:///ExtensionDoorhanger.schema.json",
+ "title": "ExtensionDoorhanger",
+ "description": "A template with a heading, addon icon, title and description. No markup allowed.",
+ "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }],
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "object",
+ "properties": {
+ "category": {
+ "type": "string",
+ "description": "Attribute used for different groups of messages from the same provider"
+ },
+ "layout": {
+ "type": "string",
+ "description": "Attribute used for different groups of messages from the same provider",
+ "enum": ["short_message", "icon_and_message", "addon_recommendation"]
+ },
+ "anchor_id": {
+ "type": "string",
+ "description": "A DOM element ID that the pop-over will be anchored."
+ },
+ "alt_anchor_id": {
+ "type": "string",
+ "description": "An alternate DOM element ID that the pop-over will be anchored."
+ },
+ "bucket_id": {
+ "type": "string",
+ "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting."
+ },
+ "skip_address_bar_notifier": {
+ "type": "boolean",
+ "description": "Skip the 'Recommend' notifier and show directly."
+ },
+ "persistent_doorhanger": {
+ "type": "boolean",
+ "description": "Prevent the doorhanger from being dismissed if user interacts with the page or switches between applications."
+ },
+ "show_in_private_browsing": {
+ "type": "boolean",
+ "description": "Whether to allow the message to be shown in private browsing mode. Defaults to false."
+ },
+ "notification_text": {
+ "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText",
+ "description": "The text in the small blue chicklet that appears in the URL bar. This can be a reference to a localized string in Firefox or just a plain string."
+ },
+ "info_icon": {
+ "type": "object",
+ "description": "The small icon displayed in the top right corner of the pop-over. Should be 19x19px, svg or png. Defaults to a small question mark.",
+ "properties": {
+ "label": {
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "attributes": {
+ "type": "object",
+ "properties": {
+ "tooltiptext": {
+ "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText",
+ "description": "Text for button tooltip used to provide information about the doorhanger."
+ }
+ },
+ "required": ["tooltiptext"]
+ }
+ },
+ "required": ["attributes"]
+ },
+ {
+ "$ref": "file:///FxMSCommon.schema.json#/$defs/localizedText"
+ }
+ ]
+ },
+ "sumo_path": {
+ "type": "string",
+ "description": "Last part of the path in the URL to the support page with the information about the doorhanger.",
+ "examples": ["extensionpromotions", "extensionrecommendations"]
+ }
+ }
+ },
+ "learn_more": {
+ "type": "string",
+ "description": "Last part of the path in the SUMO URL to the support page with the information about the doorhanger.",
+ "examples": ["extensionpromotions", "extensionrecommendations"]
+ },
+ "heading_text": {
+ "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText",
+ "description": "The larger heading text displayed in the pop-over. This can be a reference to a localized string in Firefox or just a plain string."
+ },
+ "icon": {
+ "$ref": "#/$defs/linkUrl",
+ "description": "The icon displayed in the pop-over. Should be 32x32px or 64x64px and png/svg."
+ },
+ "icon_dark_theme": {
+ "type": "string",
+ "description": "Pop-over icon, dark theme variant. Should be 32x32px or 64x64px and png/svg."
+ },
+ "icon_class": {
+ "type": "string",
+ "description": "CSS class of the pop-over icon."
+ },
+ "addon": {
+ "description": "Addon information including AMO URL.",
+ "type": "object",
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/plainText",
+ "description": "Unique addon ID"
+ },
+ "title": {
+ "$ref": "#/$defs/plainText",
+ "description": "Addon name"
+ },
+ "author": {
+ "$ref": "#/$defs/plainText",
+ "description": "Addon author"
+ },
+ "icon": {
+ "$ref": "#/$defs/linkUrl",
+ "description": "The icon displayed in the pop-over. Should be 64x64px and png/svg."
+ },
+ "rating": {
+ "type": "string",
+ "description": "Star rating"
+ },
+ "users": {
+ "type": "string",
+ "description": "Installed users"
+ },
+ "amo_url": {
+ "$ref": "#/$defs/linkUrl",
+ "description": "Link that offers more information related to the addon."
+ }
+ },
+ "required": ["title", "author", "icon", "amo_url"]
+ },
+ "text": {
+ "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText",
+ "description": "The body text displayed in the pop-over. This can be a reference to a localized string in Firefox or just a plain string."
+ },
+ "descriptionDetails": {
+ "description": "Additional information and steps on how to use",
+ "type": "object",
+ "properties": {
+ "steps": {
+ "description": "Array of string_ids",
+ "type": "array",
+ "items": {
+ "$ref": "file:///FxMSCommon.schema.json#/$defs/localizedText",
+ "description": "Id of string to localized addon description"
+ }
+ }
+ },
+ "required": ["steps"]
+ },
+ "buttons": {
+ "description": "The label and functionality for the buttons in the pop-over.",
+ "type": "object",
+ "properties": {
+ "primary": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "type": "object",
+ "oneOf": [
+ {
+ "properties": {
+ "value": {
+ "$ref": "#/$defs/plainText",
+ "description": "Button label override used when a localized version is not available."
+ },
+ "attributes": {
+ "type": "object",
+ "properties": {
+ "accesskey": {
+ "type": "string",
+ "description": "A single character to be used as a shortcut key for the secondary button. This should be one of the characters that appears in the button label."
+ }
+ },
+ "required": ["accesskey"],
+ "description": "Button attributes."
+ }
+ },
+ "required": ["value", "attributes"]
+ },
+ {
+ "$ref": "file:///FxMSCommon.schema.json#/$defs/localizedText"
+ }
+ ],
+ "description": "Id of localized string or message override."
+ },
+ "action": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "Action dispatched by the button."
+ },
+ "data": {
+ "properties": {
+ "url": {
+ "type": "string",
+ "$comment": "This is dynamically generated from the addon.id. See CFRPageActions.sys.mjs",
+ "description": "URL used in combination with the primary action dispatched."
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "secondary": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "type": "object",
+ "oneOf": [
+ {
+ "properties": {
+ "value": {
+ "allOf": [
+ { "$ref": "#/$defs/plainText" },
+ {
+ "description": "Button label override used when a localized version is not available."
+ }
+ ]
+ },
+ "attributes": {
+ "type": "object",
+ "properties": {
+ "accesskey": {
+ "type": "string",
+ "description": "A single character to be used as a shortcut key for the secondary button. This should be one of the characters that appears in the button label."
+ }
+ },
+ "required": ["accesskey"],
+ "description": "Button attributes."
+ }
+ },
+ "required": ["value", "attributes"]
+ },
+ {
+ "properties": {
+ "string_id": {
+ "allOf": [
+ { "$ref": "#/$defs/plainText" },
+ {
+ "description": "Id of localized string for button"
+ }
+ ]
+ }
+ },
+ "required": ["string_id"]
+ }
+ ],
+ "description": "Id of localized string or message override."
+ },
+ "action": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "Action dispatched by the button."
+ },
+ "data": {
+ "properties": {
+ "url": {
+ "allOf": [
+ { "$ref": "#/$defs/linkUrl" },
+ {
+ "description": "URL used in combination with the primary action dispatched."
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "additionalProperties": true,
+ "required": ["layout", "bucket_id", "heading_text", "text", "buttons"],
+ "if": {
+ "properties": {
+ "skip_address_bar_notifier": {
+ "anyOf": [{ "const": "false" }, { "const": null }]
+ }
+ }
+ },
+ "then": {
+ "required": ["category", "notification_text"]
+ }
+ },
+ "template": {
+ "type": "string",
+ "enum": ["cfr_doorhanger", "milestone_message"]
+ }
+ },
+ "additionalProperties": true,
+ "required": ["targeting", "trigger"],
+ "$defs": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "linkUrl": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ }
+}
diff --git a/browser/components/asrouter/content-src/templates/CFR/templates/InfoBar.schema.json b/browser/components/asrouter/content-src/templates/CFR/templates/InfoBar.schema.json
new file mode 100644
index 0000000000..ca0c0745bb
--- /dev/null
+++ b/browser/components/asrouter/content-src/templates/CFR/templates/InfoBar.schema.json
@@ -0,0 +1,89 @@
+{
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
+ "$id": "file:///InfoBar.schema.json",
+ "title": "InfoBar",
+ "description": "A template with an image, test and buttons.",
+ "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }],
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "Should the message be global (persisted across tabs) or local (disappear when switching to a different tab).",
+ "enum": ["global", "tab"]
+ },
+ "text": {
+ "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText",
+ "description": "The text show in the notification box."
+ },
+ "priority": {
+ "description": "Infobar priority level https://searchfox.org/mozilla-central/rev/3aef835f6cb12e607154d56d68726767172571e4/toolkit/content/widgets/notificationbox.js#387",
+ "type": "number",
+ "minumum": 0,
+ "exclusiveMaximum": 10
+ },
+ "buttons": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText",
+ "description": "The text label of the button."
+ },
+ "primary": {
+ "type": "boolean",
+ "description": "Is this the primary button?"
+ },
+ "accessKey": {
+ "type": "string",
+ "description": "Keyboard shortcut letter."
+ },
+ "action": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "Action dispatched by the button."
+ },
+ "data": {
+ "type": "object"
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": true
+ },
+ "supportPage": {
+ "type": "string",
+ "description": "A page title on SUMO to link to"
+ }
+ },
+ "required": ["label", "action"],
+ "additionalProperties": true
+ }
+ }
+ },
+ "additionalProperties": true,
+ "required": ["text", "buttons"]
+ },
+ "template": {
+ "type": "string",
+ "const": "infobar"
+ }
+ },
+ "additionalProperties": true,
+ "required": ["targeting", "trigger"],
+ "$defs": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "linkUrl": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ }
+}
diff --git a/browser/components/asrouter/content-src/templates/OnboardingMessage/Spotlight.schema.json b/browser/components/asrouter/content-src/templates/OnboardingMessage/Spotlight.schema.json
new file mode 100644
index 0000000000..5d5b98f594
--- /dev/null
+++ b/browser/components/asrouter/content-src/templates/OnboardingMessage/Spotlight.schema.json
@@ -0,0 +1,66 @@
+{
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
+ "$id": "file:///Spotlight.schema.json",
+ "title": "Spotlight",
+ "description": "A template with an image, title, content and two buttons.",
+ "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }],
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "object",
+ "properties": {
+ "template": {
+ "type": "string",
+ "description": "Specify the layout template for the Spotlight",
+ "const": "multistage"
+ },
+ "backdrop": {
+ "type": "string",
+ "description": "Background css behind modal content"
+ },
+ "logo": {
+ "type": "object",
+ "properties": {
+ "imageURL": {
+ "type": "string",
+ "description": "URL for image to use with the content"
+ },
+ "imageId": {
+ "type": "string",
+ "description": "The ID for a remotely hosted image"
+ },
+ "size": {
+ "type": "string",
+ "description": "The logo size."
+ }
+ },
+ "additionalProperties": true
+ },
+ "screens": {
+ "type": "array",
+ "description": "Collection of individual screen content"
+ },
+ "transitions": {
+ "type": "boolean",
+ "description": "Show transitions within and between screens"
+ },
+ "disableHistoryUpdates": {
+ "type": "boolean",
+ "description": "Don't alter the browser session's history stack - used with messaging surfaces like Feature Callouts"
+ },
+ "startScreen": {
+ "type": "integer",
+ "description": "Index of first screen to show from message, defaulting to 0"
+ }
+ },
+ "additionalProperties": true
+ },
+ "template": {
+ "type": "string",
+ "description": "Specify whether the surface is shown as a Spotlight modal or an in-surface Feature Callout dialog",
+ "enum": ["spotlight", "feature_callout"]
+ }
+ },
+ "additionalProperties": true,
+ "required": ["targeting"]
+}
diff --git a/browser/components/asrouter/content-src/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json b/browser/components/asrouter/content-src/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json
new file mode 100644
index 0000000000..4ec7dc9522
--- /dev/null
+++ b/browser/components/asrouter/content-src/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json
@@ -0,0 +1,45 @@
+{
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
+ "$id": "file:///ToolbarBadgeMessage.schema.json",
+ "title": "ToolbarBadgeMessage",
+ "description": "A template that specifies to which element in the browser toolbar to add a notification.",
+ "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }],
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "object",
+ "properties": {
+ "target": {
+ "type": "string"
+ },
+ "action": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": true,
+ "required": ["id"],
+ "description": "Optional action to take in addition to showing the notification"
+ },
+ "delay": {
+ "type": "number",
+ "description": "Optional delay in ms after which to show the notification"
+ },
+ "badgeDescription": {
+ "$ref": "file:///FxMSCommon.schema.json#/$defs/localizedText",
+ "description": "This is used in combination with the badged button to offer a text based alternative to the visual badging. Example 'New Feature: What's New'"
+ }
+ },
+ "additionalProperties": true,
+ "required": ["target"]
+ },
+ "template": {
+ "type": "string",
+ "const": "toolbar_badge"
+ }
+ },
+ "additionalProperties": true,
+ "required": ["targeting"]
+}
diff --git a/browser/components/asrouter/content-src/templates/OnboardingMessage/UpdateAction.schema.json b/browser/components/asrouter/content-src/templates/OnboardingMessage/UpdateAction.schema.json
new file mode 100644
index 0000000000..c5a466a6e5
--- /dev/null
+++ b/browser/components/asrouter/content-src/templates/OnboardingMessage/UpdateAction.schema.json
@@ -0,0 +1,47 @@
+{
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
+ "$id": "file:///UpdateAction.schema.json",
+ "title": "UpdateActionMessage",
+ "description": "A template for messages that execute predetermined actions.",
+ "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }],
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "data": {
+ "type": "object",
+ "description": "Additional data provided as argument when executing the action",
+ "properties": {
+ "url": {
+ "type": "string",
+ "description": "URL data to be used as argument to the action"
+ },
+ "expireDelta": {
+ "type": "number",
+ "description": "Expiration timestamp to be used as argument to the action"
+ }
+ }
+ }
+ },
+ "additionalProperties": true,
+ "description": "Optional action to take in addition to showing the notification",
+ "required": ["id", "data"]
+ }
+ },
+ "additionalProperties": true,
+ "required": ["action"]
+ },
+ "template": {
+ "type": "string",
+ "const": "update_action"
+ }
+ },
+ "required": ["targeting"]
+}
diff --git a/browser/components/asrouter/content-src/templates/OnboardingMessage/WhatsNewMessage.schema.json b/browser/components/asrouter/content-src/templates/OnboardingMessage/WhatsNewMessage.schema.json
new file mode 100644
index 0000000000..26e795d068
--- /dev/null
+++ b/browser/components/asrouter/content-src/templates/OnboardingMessage/WhatsNewMessage.schema.json
@@ -0,0 +1,73 @@
+{
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
+ "$id": "file:///WhatsNewMessage.schema.json",
+ "title": "WhatsNewMessage",
+ "description": "A template for the messages that appear in the What's New panel.",
+ "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }],
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "object",
+ "properties": {
+ "layout": {
+ "description": "Different message layouts",
+ "enum": ["tracking-protections"]
+ },
+ "bucket_id": {
+ "type": "string",
+ "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting."
+ },
+ "published_date": {
+ "type": "integer",
+ "description": "The date/time (number of milliseconds elapsed since January 1, 1970 00:00:00 UTC) the message was published."
+ },
+ "title": {
+ "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText",
+ "description": "Id of localized string or message override of What's New message title"
+ },
+ "subtitle": {
+ "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText",
+ "description": "Id of localized string or message override of What's New message subtitle"
+ },
+ "body": {
+ "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText",
+ "description": "Id of localized string or message override of What's New message body"
+ },
+ "link_text": {
+ "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText",
+ "description": "(optional) Id of localized string or message override of What's New message link text"
+ },
+ "cta_url": {
+ "description": "Target URL for the What's New message.",
+ "type": "string",
+ "format": "moz-url-format"
+ },
+ "cta_type": {
+ "description": "Type of url open action",
+ "enum": ["OPEN_URL", "OPEN_ABOUT_PAGE", "OPEN_PROTECTION_REPORT"]
+ },
+ "cta_where": {
+ "description": "How to open the cta: new window, tab, focused, unfocused.",
+ "enum": ["current", "tabshifted", "tab", "save", "window"]
+ },
+ "icon_url": {
+ "description": "(optional) URL for the What's New message icon.",
+ "type": "string",
+ "format": "uri"
+ },
+ "icon_alt": {
+ "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText",
+ "description": "Alt text for image."
+ }
+ },
+ "additionalProperties": true,
+ "required": ["published_date", "title", "body", "cta_url", "bucket_id"]
+ },
+ "template": {
+ "type": "string",
+ "const": "whatsnew_panel_message"
+ }
+ },
+ "required": ["order"],
+ "additionalProperties": true
+}
diff --git a/browser/components/asrouter/content-src/templates/PBNewtab/NewtabPromoMessage.schema.json b/browser/components/asrouter/content-src/templates/PBNewtab/NewtabPromoMessage.schema.json
new file mode 100644
index 0000000000..3719419428
--- /dev/null
+++ b/browser/components/asrouter/content-src/templates/PBNewtab/NewtabPromoMessage.schema.json
@@ -0,0 +1,153 @@
+{
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
+ "$id": "file:///NewtabPromoMessage.schema.json",
+ "title": "PBNewtabPromoMessage",
+ "description": "Message shown on the private browsing newtab page.",
+ "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }],
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "object",
+ "properties": {
+ "hideDefault": {
+ "type": "boolean",
+ "description": "Should we hide the default promo after the experiment promo is dismissed."
+ },
+ "infoEnabled": {
+ "type": "boolean",
+ "description": "Should we show the info section."
+ },
+ "infoIcon": {
+ "type": "string",
+ "description": "Icon shown in the left side of the info section. Default is the private browsing icon."
+ },
+ "infoTitle": {
+ "type": "string",
+ "description": "Is the title in the info section enabled."
+ },
+ "infoTitleEnabled": {
+ "type": "boolean",
+ "description": "Is the title in the info section enabled."
+ },
+ "infoBody": {
+ "type": "string",
+ "description": "Text content in the info section."
+ },
+ "infoLinkText": {
+ "type": "string",
+ "description": "Text for the link in the info section."
+ },
+ "infoLinkUrl": {
+ "type": "string",
+ "description": "URL for the info section link.",
+ "format": "moz-url-format"
+ },
+ "promoEnabled": {
+ "type": "boolean",
+ "description": "Should we show the promo section."
+ },
+ "promoType": {
+ "type": "string",
+ "description": "Promo type used to determine if promo should show to a given user",
+ "enum": ["FOCUS", "VPN", "PIN", "COOKIE_BANNERS", "OTHER"]
+ },
+ "promoSectionStyle": {
+ "type": "string",
+ "description": "Sets the position of the promo section. Possible values are: top, below-search, bottom. Default bottom.",
+ "enum": ["top", "below-search", "bottom"]
+ },
+ "promoTitle": {
+ "type": "string",
+ "description": "The text content of the promo section."
+ },
+ "promoTitleEnabled": {
+ "type": "boolean",
+ "description": "Should we show text content in the promo section."
+ },
+ "promoLinkText": {
+ "type": "string",
+ "description": "The text of the link in the promo box."
+ },
+ "promoHeader": {
+ "type": "string",
+ "description": "The title of the promo section."
+ },
+ "promoButton": {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "Action dispatched by the button."
+ },
+ "data": {
+ "type": "object"
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": true
+ }
+ },
+ "required": ["action"]
+ },
+ "promoLinkType": {
+ "type": "string",
+ "description": "Type of promo link type. Possible values: link, button. Default is link.",
+ "enum": ["link", "button"]
+ },
+ "promoImageLarge": {
+ "type": "string",
+ "description": "URL for image used on the left side of the promo box, larger, showcases some feature. Default off.",
+ "format": "uri"
+ },
+ "promoImageSmall": {
+ "type": "string",
+ "description": "URL for image used on the right side of the promo box, smaller, usually a logo. Default off.",
+ "format": "uri"
+ }
+ },
+ "additionalProperties": true,
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "promoEnabled": { "const": true }
+ },
+ "required": ["promoEnabled"]
+ },
+ "then": {
+ "required": ["promoButton"]
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "infoEnabled": { "const": true }
+ },
+ "required": ["infoEnabled"]
+ },
+ "then": {
+ "required": ["infoLinkText"],
+ "if": {
+ "properties": {
+ "infoTitleEnabled": { "const": true }
+ },
+ "required": ["infoTitleEnabled"]
+ },
+ "then": {
+ "required": ["infoTitle"]
+ }
+ }
+ }
+ ]
+ },
+ "template": {
+ "type": "string",
+ "const": "pb_newtab"
+ }
+ },
+ "additionalProperties": true,
+ "required": ["targeting"]
+}
diff --git a/browser/components/asrouter/content-src/templates/ToastNotification/ToastNotification.schema.json b/browser/components/asrouter/content-src/templates/ToastNotification/ToastNotification.schema.json
new file mode 100644
index 0000000000..1fa3af5b69
--- /dev/null
+++ b/browser/components/asrouter/content-src/templates/ToastNotification/ToastNotification.schema.json
@@ -0,0 +1,113 @@
+{
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
+ "$id": "file:///ToastNotification.schema.json",
+ "title": "ToastNotification",
+ "description": "A template for toast notifications displayed by the Alert service.",
+ "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }],
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText",
+ "description": "Id of localized string or message override of toast notification title"
+ },
+ "body": {
+ "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText",
+ "description": "Id of localized string or message override of toast notification body"
+ },
+ "icon_url": {
+ "description": "The URL of the image used as an icon of the toast notification.",
+ "type": "string",
+ "format": "moz-url-format"
+ },
+ "image_url": {
+ "description": "The URL of an image to be displayed as part of the notification.",
+ "type": "string",
+ "format": "moz-url-format"
+ },
+ "launch_url": {
+ "description": "The URL to launch when the notification or an action button is clicked.",
+ "type": "string",
+ "format": "moz-url-format"
+ },
+ "launch_action": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "The launch action to be performed when Firefox is launched."
+ },
+ "data": {
+ "type": "object"
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": true
+ },
+ "requireInteraction": {
+ "type": "boolean",
+ "description": "Whether the toast notification should remain active until the user clicks or dismisses it, rather than closing automatically."
+ },
+ "tag": {
+ "type": "string",
+ "description": "An identifying tag for the toast notification."
+ },
+ "data": {
+ "type": "object",
+ "description": "Arbitrary data associated with the toast notification."
+ },
+ "actions": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "title": {
+ "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText",
+ "description": "The action text to be shown to the user."
+ },
+ "action": {
+ "type": "string",
+ "description": "Opaque identifer that identifies action."
+ },
+ "iconURL": {
+ "type": "string",
+ "format": "uri",
+ "description": "URL of an icon to display with the action."
+ },
+ "windowsSystemActivationType": {
+ "type": "boolean",
+ "description": "Whether to have Windows process the given `action`."
+ },
+ "launch_action": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "The launch action to be performed when Firefox is launched."
+ },
+ "data": {
+ "type": "object"
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": true
+ }
+ },
+ "required": ["action", "title"],
+ "additionalProperties": true
+ }
+ }
+ },
+ "additionalProperties": true,
+ "required": ["title", "body"]
+ },
+ "template": {
+ "type": "string",
+ "const": "toast_notification"
+ }
+ },
+ "required": ["content", "targeting", "template", "trigger"],
+ "additionalProperties": true
+}